@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.
- package/dist/components/connected-wallets.d.ts +1 -1
- package/dist/components/connected-wallets.js +37 -14
- package/dist/components/connected-wallets.test.d.ts +1 -0
- package/dist/components/connected-wallets.test.js +104 -0
- package/dist/components/styles.d.ts +2 -0
- package/dist/components/styles.js +27 -0
- package/dist/components/swap.js +1 -1
- 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/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
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare function ConnectedWallets(): import("react/jsx-runtime").JSX.Element
|
|
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
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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;
|
package/dist/components/swap.js
CHANGED
|
@@ -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 =
|
|
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,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.
|
|
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.
|
|
26
|
-
"@coin-voyage/shared": "3.
|
|
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.
|
|
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",
|