@coin-voyage/swapkit 0.2.0 → 0.4.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/connected-wallets.d.ts +1 -1
- package/dist/components/connected-wallets.js +37 -14
- package/dist/components/connected-wallets.test.d.ts +1 -0
- package/dist/components/connected-wallets.test.js +104 -0
- package/dist/components/styles.d.ts +2 -0
- package/dist/components/styles.js +27 -0
- package/dist/components/swap-recipient.test.d.ts +1 -0
- package/dist/components/swap-recipient.test.js +105 -0
- package/dist/components/swap.d.ts +1 -1
- package/dist/components/swap.js +20 -19
- package/dist/components/swap.test.d.ts +1 -0
- package/dist/components/swap.test.js +76 -0
- package/dist/components/token-picker.d.ts +3 -1
- package/dist/components/token-picker.js +20 -10
- package/dist/components/token-picker.test.d.ts +1 -0
- package/dist/components/token-picker.test.js +43 -0
- package/dist/components/wallet-connector.d.ts +4 -2
- package/dist/components/wallet-connector.js +89 -20
- package/dist/components/wallet-connector.test.d.ts +1 -0
- package/dist/components/wallet-connector.test.js +143 -0
- package/dist/hooks/use-order-status.d.ts +1 -1
- package/dist/hooks/use-token-data.d.ts +1 -1
- package/dist/hooks/use-token-data.js +2 -2
- package/dist/hooks/use-token-data.test.d.ts +1 -0
- package/dist/hooks/use-token-data.test.js +36 -0
- package/dist/lib/amount-input.d.ts +1 -0
- package/dist/lib/amount-input.js +7 -0
- package/dist/lib/amount-input.test.d.ts +1 -0
- package/dist/lib/amount-input.test.js +17 -0
- package/dist/lib/output-wallet.d.ts +1 -2
- package/dist/lib/output-wallet.js +2 -2
- package/dist/lib/output-wallet.test.d.ts +1 -0
- package/dist/lib/output-wallet.test.js +26 -0
- package/dist/providers/swapkit-context.d.ts +1 -1
- package/dist/providers/swapkit-provider.d.ts +1 -1
- package/dist/providers/swapkit-provider.js +4 -2
- package/dist/providers/swapkit-provider.test.d.ts +1 -0
- package/dist/providers/swapkit-provider.test.js +20 -0
- package/dist/styles/root.js +1 -1
- package/dist/styles/theme.js +1 -1
- package/dist/types.d.ts +2 -1
- package/package.json +8 -8
|
@@ -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;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,105 @@
|
|
|
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, fireEvent, render, screen } from "@testing-library/react";
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { Swap } from "./swap";
|
|
7
|
+
const { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
|
|
8
|
+
vi.mock("@coin-voyage/crypto/hooks", () => ({ useAccount: () => ({ accounts }) }));
|
|
9
|
+
vi.mock("../providers/swapkit-context", () => ({ useSwapKit: () => ({ mode: "light", theme: {} }) }));
|
|
10
|
+
vi.mock("../hooks/use-swap-execution", () => ({
|
|
11
|
+
useSwapExecution: () => ({ stage: { kind: "idle" }, execute, reset: vi.fn() }),
|
|
12
|
+
}));
|
|
13
|
+
vi.mock("../hooks/use-token-data", () => ({
|
|
14
|
+
useTokenData: () => ({
|
|
15
|
+
tokens: [{ currency: source, balance: "2" }, { currency: destination }, { currency: solana }],
|
|
16
|
+
accounts,
|
|
17
|
+
isLoading: false,
|
|
18
|
+
refreshBalances: vi.fn(),
|
|
19
|
+
}),
|
|
20
|
+
}));
|
|
21
|
+
vi.mock("../hooks/use-swap-quote", () => ({
|
|
22
|
+
useSwapQuote: () => ({
|
|
23
|
+
quote: {
|
|
24
|
+
input: { currency: source, amount: { raw: "1000000000000000000", ui: "1" } },
|
|
25
|
+
output: { currency: destination, amount: { raw: "1000000", ui: "1" } },
|
|
26
|
+
price_impact: 0,
|
|
27
|
+
},
|
|
28
|
+
loading: false,
|
|
29
|
+
}),
|
|
30
|
+
}));
|
|
31
|
+
vi.mock("./connected-wallets", () => ({ ConnectedWallets: () => null }));
|
|
32
|
+
vi.mock("./token-picker", () => ({ TokenPicker: () => null }));
|
|
33
|
+
vi.mock("./wallet-connector", () => ({ WalletConnector: () => null }));
|
|
34
|
+
vi.mock("./execution-modal", () => ({ ExecutionModal: () => null }));
|
|
35
|
+
const sender = "0x1111111111111111111111111111111111111111";
|
|
36
|
+
const recipient = "0x2222222222222222222222222222222222222222";
|
|
37
|
+
const accounts = [{ address: sender, chainType: ChainType.EVM }];
|
|
38
|
+
const source = {
|
|
39
|
+
id: "eth",
|
|
40
|
+
chain_id: ChainId.ETH,
|
|
41
|
+
name: "Ether",
|
|
42
|
+
symbol: "ETH",
|
|
43
|
+
decimals: 18,
|
|
44
|
+
};
|
|
45
|
+
const destination = {
|
|
46
|
+
id: "usdc",
|
|
47
|
+
chain_id: ChainId.ETH,
|
|
48
|
+
address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
|
|
49
|
+
name: "USD Coin",
|
|
50
|
+
symbol: "USDC",
|
|
51
|
+
decimals: 6,
|
|
52
|
+
};
|
|
53
|
+
const solana = {
|
|
54
|
+
id: "sol",
|
|
55
|
+
chain_id: ChainId.SOL,
|
|
56
|
+
name: "Solana",
|
|
57
|
+
symbol: "SOL",
|
|
58
|
+
decimals: 9,
|
|
59
|
+
};
|
|
60
|
+
beforeEach(() => execute.mockClear());
|
|
61
|
+
afterEach(cleanup);
|
|
62
|
+
function renderSwap(output = destination) {
|
|
63
|
+
render(_jsx(Swap, { defaultSourceCurrency: source, defaultDestinationCurrency: output }));
|
|
64
|
+
fireEvent.change(screen.getByRole("textbox", { name: "Sell amount" }), { target: { value: "1" } });
|
|
65
|
+
}
|
|
66
|
+
function openRecipientModal() {
|
|
67
|
+
fireEvent.click(screen.getByRole("button", { name: /^Output wallet:/ }));
|
|
68
|
+
fireEvent.click(screen.getByRole("menuitem", { name: "Paste Address" }));
|
|
69
|
+
}
|
|
70
|
+
describe("Swap destination recipient", () => {
|
|
71
|
+
it("offers only a pasted address for swaps on the same chain", () => {
|
|
72
|
+
renderSwap();
|
|
73
|
+
fireEvent.click(screen.getByRole("button", { name: /^Output wallet:/ }));
|
|
74
|
+
expect(screen.getByRole("menuitem", { name: "Paste Address" })).toBeTruthy();
|
|
75
|
+
expect(screen.queryByRole("menuitem", { name: "Connect Wallet" })).toBeNull();
|
|
76
|
+
expect(screen.getAllByRole("menuitem")).toHaveLength(1);
|
|
77
|
+
});
|
|
78
|
+
it("executes a same-chain swap to the pasted recipient", () => {
|
|
79
|
+
renderSwap();
|
|
80
|
+
openRecipientModal();
|
|
81
|
+
fireEvent.change(screen.getByRole("textbox", { name: "Wallet address" }), { target: { value: recipient } });
|
|
82
|
+
fireEvent.click(screen.getByRole("button", { name: "Use address" }));
|
|
83
|
+
expect(screen.queryByRole("dialog", { name: "Paste destination address" })).toBeNull();
|
|
84
|
+
fireEvent.click(screen.getByRole("button", { name: "Swap" }));
|
|
85
|
+
expect(execute).toHaveBeenCalledWith(expect.objectContaining({
|
|
86
|
+
source: expect.objectContaining({ sender_address: sender }),
|
|
87
|
+
destination: expect.objectContaining({ recipient }),
|
|
88
|
+
}), sender);
|
|
89
|
+
});
|
|
90
|
+
it("rejects invalid same-chain destination addresses", () => {
|
|
91
|
+
renderSwap();
|
|
92
|
+
openRecipientModal();
|
|
93
|
+
fireEvent.change(screen.getByRole("textbox", { name: "Wallet address" }), { target: { value: "invalid" } });
|
|
94
|
+
fireEvent.click(screen.getByRole("button", { name: "Use address" }));
|
|
95
|
+
expect(screen.getByText("Enter a valid address for the destination network")).toBeTruthy();
|
|
96
|
+
expect(screen.getByRole("dialog", { name: "Paste destination address" })).toBeTruthy();
|
|
97
|
+
expect(execute).not.toHaveBeenCalled();
|
|
98
|
+
});
|
|
99
|
+
it("keeps both connect and paste options for cross-family swaps", () => {
|
|
100
|
+
renderSwap(solana);
|
|
101
|
+
fireEvent.click(screen.getByRole("button", { name: /^Output wallet:/ }));
|
|
102
|
+
expect(screen.getByRole("menuitem", { name: "Connect Wallet" })).toBeTruthy();
|
|
103
|
+
expect(screen.getByRole("menuitem", { name: "Paste Address" })).toBeTruthy();
|
|
104
|
+
});
|
|
105
|
+
});
|
package/dist/components/swap.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useAccount } from "@coin-voyage/crypto/hooks";
|
|
3
3
|
import { getChainTypeByChainId, isValidAddress } from "@coin-voyage/shared/chain";
|
|
4
|
+
import { formatCurrencyAmount } from "@coin-voyage/shared/currency";
|
|
4
5
|
import { currencyID } from "@coin-voyage/shared/types";
|
|
5
6
|
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
|
|
6
7
|
import { useSwapExecution } from "../hooks/use-swap-execution";
|
|
7
8
|
import { useSwapQuote } from "../hooks/use-swap-quote";
|
|
8
9
|
import { useSwapState } from "../hooks/use-swap-state";
|
|
9
10
|
import { useTokenData } from "../hooks/use-token-data";
|
|
10
|
-
import { isValidSwapAmountInput } from "../lib/amount-input";
|
|
11
|
+
import { balanceFraction, isValidSwapAmountInput } from "../lib/amount-input";
|
|
11
12
|
import { getSwapWalletRequirement, shouldShowOutputWalletSelector, shouldUseSeparateOutputWallet, } from "../lib/output-wallet";
|
|
12
13
|
import { buildSwapQuoteRequest, isSameSwap, toCurrencyBase } from "../lib/quote";
|
|
13
14
|
import { useSwapKit } from "../providers/swapkit-context";
|
|
@@ -119,7 +120,7 @@ function OutputWalletSelector({ label, open, onOpenChange, onConnect, onPaste, }
|
|
|
119
120
|
document.removeEventListener("keydown", dismissOnEscape);
|
|
120
121
|
};
|
|
121
122
|
}, [onOpenChange, open]);
|
|
122
|
-
return (_jsxs(OutputWalletControl, { ref: controlRef, children: [_jsxs(OutputWalletTrigger, { type: "button", "aria-label": `Output wallet: ${label}`, "aria-expanded": open, onClick: () => onOpenChange(!open), children: [_jsx("span", { children: label }), _jsx(ChevronDownIcon, { size: 16, up: open })] }), open && (_jsxs(OutputWalletMenu, { role: "menu", "aria-label": "Destination wallet options", children: [_jsxs(OutputWalletOption, { type: "button", role: "menuitem", onClick: onConnect, children: [_jsx("span", { children: "Connect Wallet" }), _jsx(WalletIcon, {})] }), _jsxs(OutputWalletOption, { type: "button", role: "menuitem", onClick: onPaste, children: [_jsx("span", { children: "Paste Address" }), _jsx(PasteIcon, {})] })] }))] }));
|
|
123
|
+
return (_jsxs(OutputWalletControl, { ref: controlRef, children: [_jsxs(OutputWalletTrigger, { type: "button", "aria-label": `Output wallet: ${label}`, "aria-expanded": open, onClick: () => onOpenChange(!open), children: [_jsx("span", { children: label }), _jsx(ChevronDownIcon, { size: 16, up: open })] }), open && (_jsxs(OutputWalletMenu, { role: "menu", "aria-label": "Destination wallet options", children: [onConnect && (_jsxs(OutputWalletOption, { type: "button", role: "menuitem", onClick: onConnect, children: [_jsx("span", { children: "Connect Wallet" }), _jsx(WalletIcon, {})] })), _jsxs(OutputWalletOption, { type: "button", role: "menuitem", onClick: onPaste, children: [_jsx("span", { children: "Paste Address" }), _jsx(PasteIcon, {})] })] }))] }));
|
|
123
124
|
}
|
|
124
125
|
function formatUsd(value) {
|
|
125
126
|
if (value === undefined || value === null || !Number.isFinite(value))
|
|
@@ -143,11 +144,6 @@ const QUICK_AMOUNTS = [
|
|
|
143
144
|
{ label: "75%", fraction: 0.75 },
|
|
144
145
|
{ label: "Max", fraction: 1 },
|
|
145
146
|
];
|
|
146
|
-
function balanceFraction(balance, fraction, decimals) {
|
|
147
|
-
if (fraction === 1)
|
|
148
|
-
return balance.toString();
|
|
149
|
-
return Number((balance * fraction).toFixed(Math.min(Math.max(decimals, 0), 18))).toString();
|
|
150
|
-
}
|
|
151
147
|
export function CurrencyPanel({ side, token, amount, balance, quoteUsd, quoteLoading, disabled, onAmountChange, onSelectToken, footerAction, }) {
|
|
152
148
|
const label = side === "input" ? "SELL" : "BUY";
|
|
153
149
|
const showQuickAmounts = side === "input" && balance !== undefined && token !== undefined;
|
|
@@ -156,13 +152,13 @@ export function CurrencyPanel({ side, token, amount, balance, quoteUsd, quoteLoa
|
|
|
156
152
|
: `Use ${amountLabel} of ${token.currency.symbol} balance`, onClick: () => onAmountChange(balanceFraction(balance, fraction, token.currency.decimals)), children: amountLabel }, amountLabel))) })), _jsxs(AmountRow, { children: [quoteLoading ? (_jsx(AmountSkeleton, { role: "status", "aria-label": "Loading quoted amount" })) : (_jsx(AmountInput, { "aria-label": side === "input" ? "Sell amount" : "Buy amount", type: "text", inputMode: "decimal", autoComplete: "off", autoCorrect: "off", spellCheck: false, placeholder: "0", value: amount, disabled: disabled, onChange: (event) => {
|
|
157
153
|
if (isValidSwapAmountInput(event.target.value))
|
|
158
154
|
onAmountChange(event.target.value);
|
|
159
|
-
} })), _jsxs(TokenButton, { type: "button", onClick: onSelectToken, "aria-label": `Select ${side} token`, children: [token ? _jsx(TokenIcon, { currency: token.currency }) : null, _jsx("span", { children: token?.currency.symbol ?? "Select" }), _jsx(ChevronDownIcon, {})] })] }), _jsxs(CardFooter, { children: [quoteLoading ? (_jsx(QuoteValueSkeleton, { role: "status", "aria-label": "Loading quoted value" })) : (_jsx("span", { children: formatUsd(quoteUsd ?? tokenUsdValue(token, amount)) })), _jsxs(InlineActions, { children: [balance !== undefined && _jsxs("span", { children: ["Balance: ", balance.toLocaleString()] }), footerAction] })] })] }));
|
|
155
|
+
} })), _jsxs(TokenButton, { type: "button", onClick: onSelectToken, "aria-label": `Select ${side} token`, children: [token ? _jsx(TokenIcon, { currency: token.currency }) : null, _jsx("span", { children: token?.currency.symbol ?? "Select" }), _jsx(ChevronDownIcon, {})] })] }), _jsxs(CardFooter, { children: [quoteLoading ? (_jsx(QuoteValueSkeleton, { role: "status", "aria-label": "Loading quoted value" })) : (_jsx("span", { children: formatUsd(quoteUsd ?? tokenUsdValue(token, amount)) })), _jsxs(InlineActions, { children: [balance !== undefined && _jsxs("span", { children: ["Balance: ", Number(balance).toLocaleString()] }), footerAction] })] })] }));
|
|
160
156
|
}
|
|
161
157
|
function ManualWalletModal({ open, chainId, initialValue, onClose, onSave, }) {
|
|
162
158
|
const [value, setValue] = useState(initialValue ?? "");
|
|
163
159
|
const [error, setError] = useState(null);
|
|
164
160
|
const outputAddressId = useId();
|
|
165
|
-
return (_jsxs(Modal, { open: open, title: "Paste destination address", onClose: onClose, children: [_jsx(FieldLabel, { htmlFor: outputAddressId, children: "Wallet address" }), _jsx(CompactInput, { id: outputAddressId, value: value, autoComplete: "off", onChange: (event) => setValue(event.target.value.trim()) }), error && _jsx(Hint, { "$tone": "danger", children: error }), _jsx(PrimaryButton, { type: "button", onClick: () => {
|
|
161
|
+
return (_jsxs(Modal, { open: open, title: "Paste destination address", onClose: onClose, children: [_jsx(FieldLabel, { htmlFor: outputAddressId, children: "Wallet address" }), _jsx(CompactInput, { id: outputAddressId, value: value, autoComplete: "off", onChange: (event) => setValue(event.target.value.trim()) }), error && _jsx(Hint, { "$tone": "danger", children: error }), _jsx(PrimaryButton, { type: "button", style: { marginTop: 12 }, onClick: () => {
|
|
166
162
|
if (!chainId || !isValidAddress(value, chainId)) {
|
|
167
163
|
setError("Enter a valid address for the destination network");
|
|
168
164
|
return;
|
|
@@ -206,7 +202,7 @@ export function Swap({ defaultSourceCurrency, defaultDestinationCurrency, defaul
|
|
|
206
202
|
const manualAddress = manualOutputWallet && manualOutputWallet.chainId === destinationToken?.currency.chain_id
|
|
207
203
|
? manualOutputWallet.address
|
|
208
204
|
: undefined;
|
|
209
|
-
const outputAddress =
|
|
205
|
+
const outputAddress = manualAddress ?? (separateOutputWallet ? outputAccount?.address : inputAccount?.address);
|
|
210
206
|
const walletRequirement = getSwapWalletRequirement({
|
|
211
207
|
inputWalletAddress: inputAccount?.address,
|
|
212
208
|
inputChainType: sourceChainType,
|
|
@@ -215,7 +211,6 @@ export function Swap({ defaultSourceCurrency, defaultDestinationCurrency, defaul
|
|
|
215
211
|
outputChainType: destinationChainType,
|
|
216
212
|
});
|
|
217
213
|
const showOutputWalletSelector = shouldShowOutputWalletSelector({
|
|
218
|
-
requiresSeparateOutputWallet: separateOutputWallet,
|
|
219
214
|
outputChainType: destinationChainType,
|
|
220
215
|
connectionRequirement: walletRequirement,
|
|
221
216
|
});
|
|
@@ -253,10 +248,14 @@ export function Swap({ defaultSourceCurrency, defaultDestinationCurrency, defaul
|
|
|
253
248
|
onComplete,
|
|
254
249
|
onError,
|
|
255
250
|
});
|
|
256
|
-
const displayedInputAmount = activeSide === "output" && quoteState.quote
|
|
257
|
-
|
|
251
|
+
const displayedInputAmount = activeSide === "output" && quoteState.quote
|
|
252
|
+
? formatCurrencyAmount(quoteState.quote.input.amount.raw, quoteState.quote.input.currency.decimals)
|
|
253
|
+
: inputAmount;
|
|
254
|
+
const displayedOutputAmount = activeSide === "input" && quoteState.quote
|
|
255
|
+
? formatCurrencyAmount(quoteState.quote.output.amount.raw, quoteState.quote.output.currency.decimals)
|
|
256
|
+
: outputAmount;
|
|
258
257
|
const sourceBalance = sourceToken?.balance;
|
|
259
|
-
const insufficientBalance = sourceBalance !== undefined && Number(displayedInputAmount) > sourceBalance;
|
|
258
|
+
const insufficientBalance = sourceBalance !== undefined && Number(displayedInputAmount) > Number(sourceBalance);
|
|
260
259
|
const sameSwap = isSameSwap(sourceToken?.currency, destinationToken?.currency, inputAccount?.address, outputAddress);
|
|
261
260
|
const canExecute = Boolean(quoteState.quote &&
|
|
262
261
|
quoteRequest &&
|
|
@@ -300,13 +299,15 @@ export function Swap({ defaultSourceCurrency, defaultDestinationCurrency, defaul
|
|
|
300
299
|
swapState.setAmount("input", value);
|
|
301
300
|
}, onSelectToken: () => setSourcePickerOpen(true) }), _jsx(SwitchButton, { type: "button", "aria-label": "Swap tokens", onClick: reverseTokens, disabled: !sourceToken || !destinationToken, children: _jsx(ArrowDownIcon, { size: 24 }) })] }), _jsx(CurrencyPanel, { side: "output", token: destinationToken, amount: displayedOutputAmount, quoteUsd: quoteState.quote?.output.amount.value_usd, quoteLoading: quoteState.loading && activeSide === "input", disabled: execution.stage.kind !== "idle", onAmountChange: (value) => {
|
|
302
301
|
swapState.setAmount("output", value);
|
|
303
|
-
}, onSelectToken: () => setDestinationPickerOpen(true), footerAction: showOutputWalletSelector ? (_jsx(OutputWalletSelector, { label: outputAddress ? truncateAddress(outputAddress) : "Connect Wallet", open: outputWalletMenuOpen, onOpenChange: setOutputWalletMenuOpen, onConnect:
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
302
|
+
}, onSelectToken: () => setDestinationPickerOpen(true), footerAction: showOutputWalletSelector ? (_jsx(OutputWalletSelector, { label: outputAddress ? truncateAddress(outputAddress) : "Connect Wallet", open: outputWalletMenuOpen, onOpenChange: setOutputWalletMenuOpen, onConnect: separateOutputWallet
|
|
303
|
+
? () => {
|
|
304
|
+
setOutputWalletMenuOpen(false);
|
|
305
|
+
setConnectTarget({ open: true, chainType: destinationChainType });
|
|
306
|
+
}
|
|
307
|
+
: undefined, onPaste: () => {
|
|
307
308
|
setOutputWalletMenuOpen(false);
|
|
308
309
|
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) => {
|
|
310
|
+
} })) : 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
311
|
if (destinationToken)
|
|
311
312
|
swapState.setManualOutputWallet({ chainId: destinationToken.currency.chain_id, address });
|
|
312
313
|
} })), _jsx(ExecutionModal, { stage: execution.stage, onClose: () => {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,76 @@
|
|
|
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 { Swap } from "./swap";
|
|
7
|
+
vi.mock("@coin-voyage/crypto/hooks", () => ({ useAccount: () => ({ accounts: [] }) }));
|
|
8
|
+
vi.mock("../providers/swapkit-context", () => ({ useSwapKit: () => ({ mode: "light", theme: {} }) }));
|
|
9
|
+
vi.mock("../hooks/use-swap-execution", () => ({
|
|
10
|
+
useSwapExecution: () => ({ stage: { kind: "idle" }, execute: vi.fn(), reset: vi.fn() }),
|
|
11
|
+
}));
|
|
12
|
+
vi.mock("../hooks/use-token-data", () => ({
|
|
13
|
+
useTokenData: () => ({
|
|
14
|
+
tokens: [{ currency: source, balance: "2.345678901234567891" }, { currency: destination }],
|
|
15
|
+
accounts: [],
|
|
16
|
+
isLoading: false,
|
|
17
|
+
refreshBalances: vi.fn(),
|
|
18
|
+
}),
|
|
19
|
+
}));
|
|
20
|
+
vi.mock("../hooks/use-swap-quote", () => ({
|
|
21
|
+
useSwapQuote: () => ({
|
|
22
|
+
quote: {
|
|
23
|
+
input: { currency: source, amount: { raw: "1234567890123456789", ui: "1.23" } },
|
|
24
|
+
output: { currency: destination, amount: { raw: "9876543", ui: "9.88" } },
|
|
25
|
+
price_impact: 0,
|
|
26
|
+
},
|
|
27
|
+
loading: false,
|
|
28
|
+
}),
|
|
29
|
+
}));
|
|
30
|
+
vi.mock("./connected-wallets", () => ({ ConnectedWallets: () => null }));
|
|
31
|
+
vi.mock("./token-picker", () => ({ TokenPicker: () => null }));
|
|
32
|
+
vi.mock("./wallet-connector", () => ({ WalletConnector: () => null }));
|
|
33
|
+
vi.mock("./execution-modal", () => ({ ExecutionModal: () => null }));
|
|
34
|
+
const source = {
|
|
35
|
+
id: "eth",
|
|
36
|
+
chain_id: ChainId.ETH,
|
|
37
|
+
name: "Ether",
|
|
38
|
+
symbol: "ETH",
|
|
39
|
+
decimals: 18,
|
|
40
|
+
};
|
|
41
|
+
const destination = {
|
|
42
|
+
id: "usdc",
|
|
43
|
+
chain_id: ChainId.ETH,
|
|
44
|
+
address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
|
|
45
|
+
name: "USD Coin",
|
|
46
|
+
symbol: "USDC",
|
|
47
|
+
decimals: 6,
|
|
48
|
+
};
|
|
49
|
+
afterEach(cleanup);
|
|
50
|
+
function renderSwap() {
|
|
51
|
+
render(_jsx(Swap, { defaultSourceCurrency: source, defaultDestinationCurrency: destination }));
|
|
52
|
+
return {
|
|
53
|
+
sell: screen.getByRole("textbox", { name: "Sell amount" }),
|
|
54
|
+
buy: screen.getByRole("textbox", { name: "Buy amount" }),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
describe("Swap amount precision", () => {
|
|
58
|
+
it("shows the exact quoted buy amount while preserving the typed sell amount", () => {
|
|
59
|
+
const { sell, buy } = renderSwap();
|
|
60
|
+
fireEvent.change(sell, { target: { value: "1.234567890123456780" } });
|
|
61
|
+
expect(sell.value).toBe("1.234567890123456780");
|
|
62
|
+
expect(buy.value).toBe("9.876543");
|
|
63
|
+
});
|
|
64
|
+
it("shows the exact quoted sell amount while preserving the typed buy amount", () => {
|
|
65
|
+
const { sell, buy } = renderSwap();
|
|
66
|
+
fireEvent.change(buy, { target: { value: "9.876540" } });
|
|
67
|
+
expect(buy.value).toBe("9.876540");
|
|
68
|
+
expect(sell.value).toBe("1.234567890123456789");
|
|
69
|
+
});
|
|
70
|
+
it("fills the full balance with MAX after editing the buy amount", () => {
|
|
71
|
+
const { sell, buy } = renderSwap();
|
|
72
|
+
fireEvent.change(buy, { target: { value: "9.876543" } });
|
|
73
|
+
fireEvent.click(screen.getByRole("button", { name: "Use maximum ETH balance" }));
|
|
74
|
+
expect(sell.value).toBe("2.345678901234567891");
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -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 {};
|