@coin-voyage/swapkit 0.2.0 → 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.
@@ -1 +1 @@
1
- export declare function ConnectedWallets(): import("react/jsx-runtime").JSX.Element | null;
1
+ export declare function ConnectedWallets(): import("react/jsx-runtime").JSX.Element;
@@ -1,10 +1,12 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { useAccount, useAccountDisconnect, useInstalledWallets } from "@coin-voyage/crypto/hooks";
3
3
  import { getChainTypeName } from "@coin-voyage/shared/chain";
4
+ import { ChainType } from "@coin-voyage/shared/types";
4
5
  import { useEffect, useRef, useState } from "react";
5
6
  import { CloseIcon, UnplugIcon } from "./icons";
6
- import { ConnectedWalletPanel, ConnectedWalletRow, DisconnectAllButton, IconButton, TriggerWalletIcons, ToolbarGroup, WalletDetails, WalletDisconnectButton, WalletIdentity, WalletLabel, WalletPanelHeader, } from "./styles";
7
+ import { ConnectedWalletPanel, ConnectedWalletRow, DisconnectAllButton, IconButton, TriggerWalletIcons, ToolbarGroup, WalletActions, WalletDetails, WalletDisconnectButton, WalletIdentity, WalletLabel, WalletPanelHeader, WalletSwitchButton, } from "./styles";
7
8
  import { WalletIconDisplay } from "./wallet-icon";
9
+ import { WalletConnector } from "./wallet-connector";
8
10
  function truncateAddress(address) {
9
11
  return address.length <= 14 ? address : `${address.slice(0, 6)}…${address.slice(-5)}`;
10
12
  }
@@ -17,6 +19,15 @@ function connectorId(connector) {
17
19
  return connector.name;
18
20
  return undefined;
19
21
  }
22
+ function connectorName(connector) {
23
+ if (!connector || typeof connector !== "object")
24
+ return undefined;
25
+ if ("displayName" in connector && typeof connector.displayName === "string")
26
+ return connector.displayName;
27
+ if ("name" in connector && typeof connector.name === "string")
28
+ return connector.name;
29
+ return undefined;
30
+ }
20
31
  function accountAddress(account) {
21
32
  return account.address ?? account.addresses?.[0];
22
33
  }
@@ -25,11 +36,20 @@ export function ConnectedWallets() {
25
36
  const wallets = useInstalledWallets();
26
37
  const disconnectAccount = useAccountDisconnect();
27
38
  const [open, setOpen] = useState(false);
39
+ const [switchAccount, setSwitchAccount] = useState(null);
28
40
  const groupRef = useRef(null);
29
41
  const connectedAccounts = accounts.filter((account) => account.isConnected);
30
42
  const walletForAccount = (account) => {
31
43
  const accountConnectorId = connectorId(account.connector);
32
- return wallets.find((wallet) => wallet.connectors.some((candidate) => candidate.chainType === account.chainType && connectorId(candidate.connector) === accountConnectorId));
44
+ const accountConnectorName = connectorName(account.connector)?.toLowerCase();
45
+ return wallets.find((wallet) => wallet.connectors.some((candidate) => {
46
+ if (candidate.chainType !== account.chainType)
47
+ return false;
48
+ const candidateConnectorId = connectorId(candidate.connector);
49
+ const candidateConnectorName = connectorName(candidate.connector)?.toLowerCase();
50
+ return ((accountConnectorId !== undefined && candidateConnectorId === accountConnectorId) ||
51
+ (accountConnectorName !== undefined && candidateConnectorName === accountConnectorName));
52
+ }));
33
53
  };
34
54
  useEffect(() => {
35
55
  if (!open)
@@ -49,18 +69,21 @@ export function ConnectedWallets() {
49
69
  document.removeEventListener("keydown", dismissFromKeyboard);
50
70
  };
51
71
  }, [open]);
52
- if (connectedAccounts.length === 0)
53
- return null;
54
72
  const triggerLabel = connectedAccounts.length === 1
55
73
  ? truncateAddress(accountAddress(connectedAccounts[0]) ?? "Wallet")
56
74
  : `${connectedAccounts.length} connections`;
57
- return (_jsxs(ToolbarGroup, { ref: groupRef, children: [_jsxs(IconButton, { type: "button", "aria-label": "Connected wallets", "aria-expanded": open, onClick: () => setOpen((current) => !current), children: [_jsx(TriggerWalletIcons, { children: connectedAccounts.slice(0, 3).map((account, index) => {
58
- const wallet = walletForAccount(account);
59
- return (_jsx(WalletIconDisplay, { icon: wallet?.icon, name: wallet?.name ?? getChainTypeName(account.chainType), size: 20 }, `${account.chainType}:${accountAddress(account) ?? index}`));
60
- }) }), _jsx(WalletLabel, { children: triggerLabel })] }), open && (_jsxs(ConnectedWalletPanel, { role: "menu", "aria-label": "Connected wallets menu", children: [_jsx(WalletPanelHeader, { children: "Connected wallets" }), connectedAccounts.map((account, index) => {
61
- const wallet = walletForAccount(account);
62
- const chainName = getChainTypeName(account.chainType);
63
- const address = accountAddress(account);
64
- return (_jsxs(ConnectedWalletRow, { children: [_jsxs(WalletIdentity, { children: [_jsx(WalletIconDisplay, { icon: wallet?.icon, name: wallet?.name ?? chainName, size: 20 }), _jsxs(WalletDetails, { children: [_jsx("strong", { children: chainName }), _jsx("small", { children: address ? truncateAddress(address) : "Connected" })] })] }), _jsx(WalletDisconnectButton, { type: "button", "aria-label": `Disconnect ${chainName} wallet`, onClick: () => void disconnectAccount(account), children: _jsx(CloseIcon, { size: 16 }) })] }, `${account.chainType}:${address ?? index}`));
65
- }), _jsxs(DisconnectAllButton, { type: "button", onClick: () => void disconnectAccount(), children: [_jsx(UnplugIcon, {}), "Disconnect all"] })] }))] }));
75
+ return (_jsxs(_Fragment, { children: [connectedAccounts.length > 0 && (_jsxs(ToolbarGroup, { ref: groupRef, children: [_jsxs(IconButton, { type: "button", "aria-label": "Connected wallets", "aria-expanded": open, onClick: () => setOpen((current) => !current), children: [_jsx(TriggerWalletIcons, { children: connectedAccounts.slice(0, 3).map((account, index) => {
76
+ const wallet = walletForAccount(account);
77
+ return (_jsx(WalletIconDisplay, { icon: wallet?.icon, name: wallet?.name ?? getChainTypeName(account.chainType), size: 20 }, `${account.chainType}:${accountAddress(account) ?? index}`));
78
+ }) }), _jsx(WalletLabel, { children: triggerLabel })] }), open && (_jsxs(ConnectedWalletPanel, { role: "group", "aria-label": "Connected wallets", children: [_jsx(WalletPanelHeader, { children: "Connected wallets" }), connectedAccounts.map((account, index) => {
79
+ const wallet = walletForAccount(account);
80
+ const chainName = getChainTypeName(account.chainType);
81
+ const address = accountAddress(account);
82
+ return (_jsxs(ConnectedWalletRow, { children: [_jsxs(WalletIdentity, { children: [_jsx(WalletIconDisplay, { icon: wallet?.icon, name: wallet?.name ?? chainName, size: 20 }), _jsxs(WalletDetails, { children: [_jsx("strong", { children: chainName }), _jsx("small", { children: address ? truncateAddress(address) : "Connected" })] })] }), _jsxs(WalletActions, { children: [_jsx(WalletSwitchButton, { type: "button", "aria-label": `Switch ${chainName} wallet`, onClick: () => {
83
+ setOpen(false);
84
+ setSwitchAccount(account);
85
+ }, children: "Switch" }), _jsx(WalletDisconnectButton, { type: "button", "aria-label": `Disconnect ${chainName} wallet`, onClick: () => void disconnectAccount(account), children: _jsx(CloseIcon, { size: 16 }) })] })] }, `${account.chainType}:${address ?? index}`));
86
+ }), _jsxs(DisconnectAllButton, { type: "button", onClick: () => void disconnectAccount(), children: [_jsx(UnplugIcon, {}), "Disconnect all"] })] }))] })), _jsx(WalletConnector, { open: switchAccount !== null, chainType: switchAccount?.chainType, intent: "switch", beforeConnect: switchAccount && switchAccount.chainType !== ChainType.SOL
87
+ ? () => disconnectAccount(switchAccount)
88
+ : undefined, onClose: () => setSwitchAccount(null) })] }));
66
89
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,104 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ // @vitest-environment jsdom
3
+ import { ChainId, ChainType } from "@coin-voyage/shared/types";
4
+ import { cleanup, render, screen } from "@testing-library/react";
5
+ import userEvent from "@testing-library/user-event";
6
+ import { afterEach, describe, expect, it, vi } from "vitest";
7
+ import { ConnectedWallets } from "./connected-wallets";
8
+ const disconnectAccount = vi.fn();
9
+ let accountsConnected = true;
10
+ let accountChainType = ChainType.SOL;
11
+ let accountAddress = "current-address";
12
+ let accountConnector = { id: "Test wallet", name: "Test wallet" };
13
+ let installedWalletOverride;
14
+ vi.mock("@coin-voyage/crypto/hooks", () => ({
15
+ useAccount: () => ({
16
+ accounts: accountsConnected
17
+ ? [
18
+ {
19
+ address: accountAddress,
20
+ chainId: ChainId.SOL,
21
+ chainType: accountChainType,
22
+ connector: accountConnector,
23
+ isConnected: true,
24
+ isConnecting: false,
25
+ isDisconnected: false,
26
+ isReconnecting: false,
27
+ status: "connected",
28
+ },
29
+ ]
30
+ : [],
31
+ }),
32
+ useAccountDisconnect: () => disconnectAccount,
33
+ useInstalledWallets: () => [
34
+ installedWalletOverride ?? {
35
+ id: "test-wallet",
36
+ name: "Test wallet",
37
+ connectors: [{ chainType: accountChainType, connector: { id: "Test wallet", name: "Test wallet" } }],
38
+ },
39
+ ],
40
+ }));
41
+ vi.mock("./wallet-connector", () => ({
42
+ WalletConnector: ({ open, chainType, intent }) => open ? _jsx("div", { "data-testid": "wallet-connector", children: `${intent}:${chainType}` }) : null,
43
+ }));
44
+ afterEach(() => {
45
+ cleanup();
46
+ disconnectAccount.mockClear();
47
+ accountsConnected = true;
48
+ accountChainType = ChainType.SOL;
49
+ accountAddress = "current-address";
50
+ accountConnector = { id: "Test wallet", name: "Test wallet" };
51
+ installedWalletOverride = undefined;
52
+ });
53
+ describe("ConnectedWallets", () => {
54
+ it("opens the wallet switcher for the selected account chain", async () => {
55
+ const user = userEvent.setup();
56
+ render(_jsx(ConnectedWallets, {}));
57
+ await user.click(screen.getByRole("button", { name: "Connected wallets" }));
58
+ await user.click(screen.getByRole("button", { name: "Switch Solana wallet" }));
59
+ expect(screen.queryByRole("group", { name: "Connected wallets" })).toBeNull();
60
+ expect(screen.getByTestId("wallet-connector").textContent).toBe(`switch:${ChainType.SOL}`);
61
+ expect(disconnectAccount).not.toHaveBeenCalled();
62
+ });
63
+ it("keeps the switcher open while the previous account disconnects", async () => {
64
+ const user = userEvent.setup();
65
+ const { rerender } = render(_jsx(ConnectedWallets, {}));
66
+ await user.click(screen.getByRole("button", { name: "Connected wallets" }));
67
+ await user.click(screen.getByRole("button", { name: "Switch Solana wallet" }));
68
+ accountsConnected = false;
69
+ rerender(_jsx(ConnectedWallets, {}));
70
+ expect(screen.getByTestId("wallet-connector").textContent).toBe(`switch:${ChainType.SOL}`);
71
+ });
72
+ it("updates the connected-wallet label when Phantom changes address", () => {
73
+ accountAddress = "old-address-123456789";
74
+ const { rerender } = render(_jsx(ConnectedWallets, {}));
75
+ expect(screen.getByRole("button", { name: "Connected wallets" }).textContent).toContain("old-ad…56789");
76
+ accountAddress = "new-address-987654321";
77
+ rerender(_jsx(ConnectedWallets, {}));
78
+ expect(screen.getByRole("button", { name: "Connected wallets" }).textContent).toContain("new-ad…54321");
79
+ });
80
+ it("uses the Phantom icon for its Ethereum connection in a multichain wallet", () => {
81
+ accountChainType = ChainType.EVM;
82
+ accountConnector = { id: "injected", name: "Phantom" };
83
+ installedWalletOverride = {
84
+ id: "app.phantom",
85
+ name: "Phantom",
86
+ icon: "phantom.svg",
87
+ connectors: [
88
+ { chainType: ChainType.EVM, connector: { id: "app.phantom", name: "Phantom" } },
89
+ { chainType: ChainType.SOL, connector: { id: "Phantom", name: "Phantom" } },
90
+ ],
91
+ };
92
+ render(_jsx(ConnectedWallets, {}));
93
+ expect(screen.getByAltText("Phantom wallet")).not.toBeNull();
94
+ });
95
+ it("only disconnects an account from the explicit disconnect action", async () => {
96
+ accountChainType = ChainType.EVM;
97
+ const user = userEvent.setup();
98
+ render(_jsx(ConnectedWallets, {}));
99
+ await user.click(screen.getByRole("button", { name: "Connected wallets" }));
100
+ await user.click(screen.getByRole("button", { name: "Disconnect Ethereum wallet" }));
101
+ expect(disconnectAccount).toHaveBeenCalledTimes(1);
102
+ expect(disconnectAccount.mock.calls[0]?.[0]?.chainType).toBe(ChainType.EVM);
103
+ });
104
+ });
@@ -95,6 +95,8 @@ export declare const WalletPanelHeader: import("styled-components").StyledCompon
95
95
  export declare const ConnectedWalletRow: import("styled-components").StyledComponent<"div", any, {}, never>;
96
96
  export declare const WalletIdentity: import("styled-components").StyledComponent<"div", any, {}, never>;
97
97
  export declare const WalletDetails: import("styled-components").StyledComponent<"div", any, {}, never>;
98
+ export declare const WalletActions: import("styled-components").StyledComponent<"div", any, {}, never>;
99
+ export declare const WalletSwitchButton: import("styled-components").StyledComponent<"button", any, {}, never>;
98
100
  export declare const WalletDisconnectButton: import("styled-components").StyledComponent<"button", any, {}, never>;
99
101
  export declare const DisconnectAllButton: import("styled-components").StyledComponent<"button", any, {}, never>;
100
102
  export declare const TriggerWalletIcons: import("styled-components").StyledComponent<"span", any, {}, never>;
@@ -955,6 +955,11 @@ export const WalletOption = styled.button `
955
955
  &:hover {
956
956
  background: var(--sk-surface);
957
957
  }
958
+
959
+ &:disabled {
960
+ cursor: wait;
961
+ opacity: 0.6;
962
+ }
958
963
  `;
959
964
  export const WalletIconFrame = styled.span `
960
965
  display: inline-flex;
@@ -1068,6 +1073,28 @@ export const WalletDetails = styled.div `
1068
1073
  font-size: 12px;
1069
1074
  }
1070
1075
  `;
1076
+ export const WalletActions = styled.div `
1077
+ display: flex;
1078
+ flex: 0 0 auto;
1079
+ align-items: center;
1080
+ gap: 4px;
1081
+ `;
1082
+ export const WalletSwitchButton = styled.button `
1083
+ height: 28px;
1084
+ padding: 0 8px;
1085
+ border: 0;
1086
+ border-radius: 6px;
1087
+ color: var(--sk-text-muted);
1088
+ background: transparent;
1089
+ cursor: pointer;
1090
+ font-size: 12px;
1091
+ font-weight: 500;
1092
+
1093
+ &:hover {
1094
+ color: var(--sk-accent);
1095
+ background: color-mix(in srgb, var(--sk-accent) 12%, transparent);
1096
+ }
1097
+ `;
1071
1098
  export const WalletDisconnectButton = styled.button `
1072
1099
  display: inline-flex;
1073
1100
  width: 28px;
@@ -306,7 +306,7 @@ export function Swap({ defaultSourceCurrency, defaultDestinationCurrency, defaul
306
306
  }, onPaste: () => {
307
307
  setOutputWalletMenuOpen(false);
308
308
  setManualWalletOpen(true);
309
- } })) : undefined }), quoteState.error && _jsx(ErrorBox, { children: quoteState.error }), tokenError && _jsx(ErrorBox, { children: tokenError }), insufficientBalance && _jsxs(ErrorBox, { children: ["Insufficient ", sourceToken?.currency.symbol, " balance"] }), sameSwap && _jsx(ErrorBox, { children: "Select a different token or destination wallet" }), (quoteState.loading || quoteState.quote) && (_jsxs(BreakdownCard, { children: [_jsxs(BreakdownToggle, { type: "button", "aria-expanded": breakdownOpen, onClick: () => setBreakdownOpen((open) => !open), children: [_jsx("span", { children: quoteState.loading ? "Finding the best route…" : quoteSummary }), _jsxs(BreakdownMeta, { children: [quoteState.quote && (_jsxs(NetworkCost, { children: [_jsx(FuelIcon, {}), _jsx("span", { children: formatUsd(quoteState.quote.input.breakdown?.gas?.value_usd) })] })), _jsx(ChevronDownIcon, { size: 16, up: breakdownOpen })] })] }), breakdownOpen && (_jsxs(BreakdownBody, { children: [quoteState.quote?.output.amount_out_minimum && (_jsxs(DetailRow, { children: [_jsx("span", { children: "Receive at least" }), _jsxs("strong", { children: [quoteState.quote.output.amount_out_minimum.ui, " ", quoteState.quote.output.currency.symbol] })] })), _jsxs(DetailRow, { children: [_jsx("span", { children: "Price impact" }), _jsx("strong", { children: quoteState.quote ? formatPriceImpact(quoteState.quote.price_impact) : "—" })] }), _jsxs(DetailRow, { children: [_jsx("span", { children: "Network cost" }), _jsx("strong", { children: formatUsd(quoteState.quote?.input.breakdown?.gas?.value_usd) })] }), _jsxs(DetailRow, { children: [_jsx("span", { children: "Total fees" }), quoteState.quote?.input.breakdown?.fees?.total_fee.value_usd === 0 ? (_jsx(FreeFeeBadge, { children: "Free" })) : (_jsx("strong", { children: formatUsd(quoteState.quote?.input.breakdown?.fees?.total_fee.value_usd) }))] })] }))] })), walletRequirement.target !== "none" ? (_jsx(WalletRequirementButton, { type: "button", onClick: () => setConnectTarget({ open: true, chainType: walletRequirement.chainType }), children: walletRequirement.label })) : (_jsxs(PrimaryButton, { type: "button", disabled: !canExecute || quoteState.loading, onClick: () => void executeSwap(), children: [quoteState.loading ? _jsx(Spinner, {}) : null, isLoading ? "Loading tokens…" : quoteState.loading ? "Fetching quote…" : "Swap"] })), _jsx(TokenPicker, { open: sourcePickerOpen, title: "Select Token", tokens: tokens, selectedId: resolvedSourceId, onClose: () => setSourcePickerOpen(false), onSelect: (token) => selectToken("input", token) }), _jsx(TokenPicker, { open: destinationPickerOpen, title: "Select Token", tokens: tokens, selectedId: resolvedDestinationId, onClose: () => setDestinationPickerOpen(false), onSelect: (token) => selectToken("output", token) }), _jsx(WalletConnector, { open: connectTarget.open, chainType: connectTarget.chainType, onClose: () => setConnectTarget({ open: false }) }), manualWalletOpen && (_jsx(ManualWalletModal, { open: true, chainId: destinationToken?.currency.chain_id, initialValue: manualAddress, onClose: () => setManualWalletOpen(false), onSave: (address) => {
309
+ } })) : undefined }), quoteState.error && _jsx(ErrorBox, { children: quoteState.error }), tokenError && _jsx(ErrorBox, { children: tokenError }), insufficientBalance && _jsxs(ErrorBox, { children: ["Insufficient ", sourceToken?.currency.symbol, " balance"] }), sameSwap && _jsx(ErrorBox, { children: "Select a different token or destination wallet" }), (quoteState.loading || quoteState.quote) && (_jsxs(BreakdownCard, { children: [_jsxs(BreakdownToggle, { type: "button", "aria-expanded": breakdownOpen, onClick: () => setBreakdownOpen((open) => !open), children: [_jsx("span", { children: quoteState.loading ? "Finding the best route…" : quoteSummary }), _jsxs(BreakdownMeta, { children: [quoteState.quote && (_jsxs(NetworkCost, { children: [_jsx(FuelIcon, {}), _jsx("span", { children: formatUsd(quoteState.quote.input.breakdown?.gas?.value_usd) })] })), _jsx(ChevronDownIcon, { size: 16, up: breakdownOpen })] })] }), breakdownOpen && (_jsxs(BreakdownBody, { children: [quoteState.quote?.output.amount_out_minimum && (_jsxs(DetailRow, { children: [_jsx("span", { children: "Receive at least" }), _jsxs("strong", { children: [quoteState.quote.output.amount_out_minimum.ui, " ", quoteState.quote.output.currency.symbol] })] })), _jsxs(DetailRow, { children: [_jsx("span", { children: "Price impact" }), _jsx("strong", { children: quoteState.quote ? formatPriceImpact(quoteState.quote.price_impact) : "—" })] }), _jsxs(DetailRow, { children: [_jsx("span", { children: "Network cost" }), _jsx("strong", { children: formatUsd(quoteState.quote?.input.breakdown?.gas?.value_usd) })] }), _jsxs(DetailRow, { children: [_jsx("span", { children: "Total fees" }), quoteState.quote?.input.breakdown?.fees?.total_fee.value_usd === 0 ? (_jsx(FreeFeeBadge, { children: "Free" })) : (_jsx("strong", { children: formatUsd(quoteState.quote?.input.breakdown?.fees?.total_fee.value_usd) }))] })] }))] })), walletRequirement.target !== "none" ? (_jsx(WalletRequirementButton, { type: "button", onClick: () => setConnectTarget({ open: true, chainType: walletRequirement.chainType }), children: walletRequirement.label })) : (_jsxs(PrimaryButton, { type: "button", disabled: !canExecute || quoteState.loading, onClick: () => void executeSwap(), children: [quoteState.loading ? _jsx(Spinner, {}) : null, isLoading ? "Loading tokens…" : quoteState.loading ? "Fetching quote…" : "Swap"] })), _jsx(TokenPicker, { open: sourcePickerOpen, title: "Select Token", tokens: tokens, selectedId: resolvedSourceId, onClose: () => setSourcePickerOpen(false), onSelect: (token) => selectToken("input", token) }), _jsx(TokenPicker, { open: destinationPickerOpen, title: "Select Token", tokens: tokens, selectedId: resolvedDestinationId, preferredChainId: sourceToken?.currency.chain_id, onClose: () => setDestinationPickerOpen(false), onSelect: (token) => selectToken("output", token) }), _jsx(WalletConnector, { open: connectTarget.open, chainType: connectTarget.chainType, onClose: () => setConnectTarget({ open: false }) }), manualWalletOpen && (_jsx(ManualWalletModal, { open: true, chainId: destinationToken?.currency.chain_id, initialValue: manualAddress, onClose: () => setManualWalletOpen(false), onSave: (address) => {
310
310
  if (destinationToken)
311
311
  swapState.setManualOutputWallet({ chainId: destinationToken.currency.chain_id, address });
312
312
  } })), _jsx(ExecutionModal, { stage: execution.stage, onClose: () => {
@@ -1,11 +1,13 @@
1
+ import { ChainId } from "@coin-voyage/shared/types";
1
2
  import type { SwapToken } from "../hooks/use-token-data";
2
3
  interface TokenPickerProps {
3
4
  open: boolean;
4
5
  title: string;
5
6
  tokens: SwapToken[];
6
7
  selectedId?: string;
8
+ preferredChainId?: ChainId;
7
9
  onClose: () => void;
8
10
  onSelect: (token: SwapToken) => void;
9
11
  }
10
- export declare function TokenPicker({ open, title, tokens, selectedId, onClose, onSelect }: TokenPickerProps): import("react/jsx-runtime").JSX.Element;
12
+ export declare function TokenPicker({ open, title, tokens, selectedId, preferredChainId, onClose, onSelect, }: TokenPickerProps): import("react/jsx-runtime").JSX.Element;
11
13
  export {};
@@ -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,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 { useConnectCallback } from "@coin-voyage/crypto/hooks";
3
4
  import { ApiClient } from "@coin-voyage/shared/api";
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 {};
@@ -0,0 +1,20 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ // @vitest-environment jsdom
3
+ import { render } from "@testing-library/react";
4
+ import { describe, expect, it, vi } from "vitest";
5
+ import { SwapKitProvider } from "./swapkit-provider";
6
+ const useConnectCallback = vi.fn();
7
+ vi.mock("@coin-voyage/crypto/evm", () => ({
8
+ useInWagmiContext: () => true,
9
+ }));
10
+ vi.mock("@coin-voyage/crypto/hooks", () => ({
11
+ useConnectCallback: (props) => useConnectCallback(props),
12
+ }));
13
+ describe("SwapKitProvider", () => {
14
+ it("listens for connected-provider address changes", () => {
15
+ const onConnect = vi.fn();
16
+ const onDisconnect = vi.fn();
17
+ render(_jsx(SwapKitProvider, { apiKey: "public-api-key", onConnect: onConnect, onDisconnect: onDisconnect, children: _jsx("div", {}) }));
18
+ expect(useConnectCallback).toHaveBeenCalledWith({ onConnect, onDisconnect });
19
+ });
20
+ });
package/dist/types.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { APIEnvironment } from "@coin-voyage/shared/api";
2
+ import type { useConnectCallbackProps } from "@coin-voyage/crypto/hooks";
2
3
  import type { CurrencyBase } from "@coin-voyage/shared/types";
3
4
  import type { ReactNode } from "react";
4
5
  export type SwapKitMode = "light" | "dark" | "auto";
@@ -20,7 +21,7 @@ export interface SwapKitTheme {
20
21
  shadow: string;
21
22
  radius: string;
22
23
  }
23
- export interface SwapKitProviderProps {
24
+ export interface SwapKitProviderProps extends Pick<useConnectCallbackProps, "onConnect" | "onDisconnect"> {
24
25
  apiKey: string;
25
26
  environment?: APIEnvironment;
26
27
  mode?: SwapKitMode;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@coin-voyage/swapkit",
3
3
  "description": "A reusable cross-chain swap component for CoinVoyage.",
4
- "version": "0.2.0",
4
+ "version": "0.3.0",
5
5
  "private": false,
6
6
  "sideEffects": false,
7
7
  "author": "Lars <lars@coinvoyage.io>",
@@ -22,8 +22,8 @@
22
22
  "LICENSE"
23
23
  ],
24
24
  "dependencies": {
25
- "@coin-voyage/crypto": "3.4.0",
26
- "@coin-voyage/shared": "3.4.1-beta.1"
25
+ "@coin-voyage/crypto": "3.5.0",
26
+ "@coin-voyage/shared": "3.5.0"
27
27
  },
28
28
  "devDependencies": {
29
29
  "@testing-library/react": "^16.3.0",
@@ -36,7 +36,7 @@
36
36
  "typescript-plugin-styled-components": "^3.0.0"
37
37
  },
38
38
  "peerDependencies": {
39
- "@bigmi/react": "^0.8.0",
39
+ "@bigmi/react": "^0.9.1",
40
40
  "@mysten/dapp-kit-react": "^2.1.4",
41
41
  "@solana/wallet-adapter-react": "^0.15.39",
42
42
  "@tanstack/react-query": "^5.90.6",