@coin-voyage/swapkit 0.3.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/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 +19 -18
- package/dist/components/swap.test.d.ts +1 -0
- package/dist/components/swap.test.js +76 -0
- package/dist/components/token-picker.js +1 -1
- package/dist/components/wallet-connector.d.ts +1 -1
- package/dist/components/wallet-connector.js +24 -4
- package/dist/components/wallet-connector.test.js +55 -1
- 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.js +1 -1
- package/dist/styles/root.js +1 -1
- package/dist/styles/theme.js +1 -1
- package/package.json +7 -7
|
@@ -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,10 +299,12 @@ 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
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) => {
|
|
@@ -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
|
+
});
|
|
@@ -74,6 +74,6 @@ export function TokenPicker({ open, title, tokens, selectedId, preferredChainId,
|
|
|
74
74
|
return (_jsxs(TokenOption, { type: "button", role: "option", "aria-label": `${token.currency.symbol} ${token.currency.name}`, "aria-selected": id === selectedId, onClick: () => {
|
|
75
75
|
onSelect(token);
|
|
76
76
|
closePicker();
|
|
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));
|
|
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 ? "" : Number(token.balance).toLocaleString() })] })] }, id));
|
|
78
78
|
}), filtered.length === 0 && _jsx(EmptyState, { children: "No matching tokens" })] })] }) }));
|
|
79
79
|
}
|
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { createWalletConnectModalConnector } from "@coin-voyage/crypto/connectors";
|
|
2
3
|
import { useInstalledWallets, useUniversalConnect } from "@coin-voyage/crypto/hooks";
|
|
3
|
-
import { getErrorMessage, isWalletConnectionCancelledError } from "@coin-voyage/crypto/utils";
|
|
4
|
+
import { getConnectorId, getErrorMessage, isWalletConnectionCancelledError } from "@coin-voyage/crypto/utils";
|
|
4
5
|
import { getChainTypeName } from "@coin-voyage/shared/chain";
|
|
6
|
+
import { ChainType } from "@coin-voyage/shared/types";
|
|
5
7
|
import { useMemo, useRef, useState } from "react";
|
|
6
8
|
import { EmptyState, Hint, MultichainBadge, WalletList, WalletOption, WalletOptionMeta, WalletSwitchButton, } from "./styles";
|
|
7
9
|
import { Modal } from "./modal";
|
|
8
10
|
import { WalletIconDisplay } from "./wallet-icon";
|
|
11
|
+
const getWalletDisplayName = (wallet) => wallet.id.toLowerCase() === "walletconnect" ? "Mobile Wallets" : wallet.name;
|
|
12
|
+
const addWalletConnectModalStyle = () => {
|
|
13
|
+
const style = document.createElement("style");
|
|
14
|
+
style.textContent = "w3m-modal, wcm-modal { --wcm-z-index: 2147483647; --w3m-z-index: 2147483647; }";
|
|
15
|
+
document.head.appendChild(style);
|
|
16
|
+
return style;
|
|
17
|
+
};
|
|
9
18
|
export function WalletConnector(props) {
|
|
10
19
|
if (!props.open)
|
|
11
20
|
return null;
|
|
@@ -42,14 +51,23 @@ function OpenWalletConnector({ chainType, intent = "connect", beforeConnect, onC
|
|
|
42
51
|
pendingRef.current = true;
|
|
43
52
|
setPending(true);
|
|
44
53
|
setError(null);
|
|
54
|
+
let walletConnectModalStyle;
|
|
45
55
|
try {
|
|
46
56
|
await beforeConnect?.();
|
|
47
|
-
|
|
57
|
+
const connectorId = getConnectorId(connector.connector);
|
|
58
|
+
const isWalletConnect = connectorId?.toLowerCase().includes("walletconnect");
|
|
59
|
+
if (isWalletConnect)
|
|
60
|
+
walletConnectModalStyle = addWalletConnectModalStyle();
|
|
61
|
+
const walletConnector = connector.chainType === ChainType.EVM && isWalletConnect
|
|
62
|
+
? { ...connector, connector: await createWalletConnectModalConnector(connector.connector) }
|
|
63
|
+
: connector;
|
|
64
|
+
await connect({ walletConnector });
|
|
48
65
|
}
|
|
49
66
|
catch (error) {
|
|
50
67
|
showError(error);
|
|
51
68
|
}
|
|
52
69
|
finally {
|
|
70
|
+
walletConnectModalStyle?.remove();
|
|
53
71
|
const shouldUpdatePending = pendingRef.current;
|
|
54
72
|
pendingRef.current = false;
|
|
55
73
|
if (shouldUpdatePending)
|
|
@@ -69,14 +87,16 @@ function OpenWalletConnector({ chainType, intent = "connect", beforeConnect, onC
|
|
|
69
87
|
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
88
|
? selectedConnectors.map((connector) => {
|
|
71
89
|
const chainName = getChainTypeName(connector.chainType);
|
|
72
|
-
|
|
90
|
+
const walletName = getWalletDisplayName(selectedWallet);
|
|
91
|
+
return (_jsxs(WalletOption, { type: "button", disabled: pending, "aria-label": `Connect ${walletName} 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
92
|
})
|
|
74
93
|
: wallets.map((wallet) => {
|
|
75
94
|
const connector = wallet.connectors.find((candidate) => !chainType || candidate.chainType === chainType);
|
|
76
95
|
if (!connector)
|
|
77
96
|
return null;
|
|
97
|
+
const walletName = getWalletDisplayName(wallet);
|
|
78
98
|
const chainTypes = [...new Set(wallet.connectors.map(({ chainType }) => chainType))];
|
|
79
99
|
const capabilityLabel = chainTypes.map(getChainTypeName).join(" · ");
|
|
80
|
-
return (_jsxs(WalletOption, { type: "button", disabled: pending, "aria-label": `${intent === "switch" ? "Switch to" : "Connect"} ${
|
|
100
|
+
return (_jsxs(WalletOption, { type: "button", disabled: pending, "aria-label": `${intent === "switch" ? "Switch to" : "Connect"} ${walletName} wallet`, onClick: () => chooseWallet(wallet), children: [_jsx(WalletIconDisplay, { icon: wallet.icon, name: walletName }), _jsxs(WalletOptionMeta, { children: [_jsxs("span", { children: [_jsx("strong", { children: walletName }), chainTypes.length > 1 && _jsx(MultichainBadge, { children: "Multichain" })] }), _jsx("small", { children: chainTypes.length > 1 ? capabilityLabel : `${capabilityLabel} wallet` })] })] }, wallet.id));
|
|
81
101
|
}) }), !selectedWallet && wallets.length === 0 && _jsx(EmptyState, { children: "No compatible installed wallets found" }), error && (_jsx(Hint, { "$tone": "danger", role: "alert", children: error }))] }));
|
|
82
102
|
}
|
|
@@ -5,7 +5,9 @@ import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
|
|
|
5
5
|
import userEvent from "@testing-library/user-event";
|
|
6
6
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
7
7
|
import { WalletConnector } from "./wallet-connector";
|
|
8
|
+
import { Overlay } from "./styles";
|
|
8
9
|
const connectWallet = vi.fn();
|
|
10
|
+
const createWalletConnectModalConnector = vi.fn();
|
|
9
11
|
let connectErrorHandler;
|
|
10
12
|
let installedWallets = [
|
|
11
13
|
{
|
|
@@ -21,12 +23,16 @@ vi.mock("@coin-voyage/crypto/hooks", () => ({
|
|
|
21
23
|
return { connect: connectWallet };
|
|
22
24
|
},
|
|
23
25
|
}));
|
|
26
|
+
vi.mock("@coin-voyage/crypto/connectors", () => ({
|
|
27
|
+
createWalletConnectModalConnector: (...args) => createWalletConnectModalConnector(...args),
|
|
28
|
+
}));
|
|
24
29
|
vi.mock("./modal", () => ({
|
|
25
30
|
Modal: ({ open, title, closeDisabled, children, }) => open ? (_jsx("div", { role: "dialog", "aria-label": title, "data-close-disabled": closeDisabled || undefined, children: children })) : null,
|
|
26
31
|
}));
|
|
27
32
|
afterEach(() => {
|
|
28
33
|
cleanup();
|
|
29
|
-
connectWallet.
|
|
34
|
+
connectWallet.mockReset();
|
|
35
|
+
createWalletConnectModalConnector.mockReset();
|
|
30
36
|
connectErrorHandler = undefined;
|
|
31
37
|
installedWallets = [
|
|
32
38
|
{
|
|
@@ -37,6 +43,10 @@ afterEach(() => {
|
|
|
37
43
|
];
|
|
38
44
|
});
|
|
39
45
|
describe("WalletConnector", () => {
|
|
46
|
+
it("keeps the wallet selector below the WalletConnect modal layer", () => {
|
|
47
|
+
render(_jsx(Overlay, { "data-testid": "wallet-selector-overlay" }));
|
|
48
|
+
expect(Number(getComputedStyle(screen.getByTestId("wallet-selector-overlay")).zIndex)).toBeLessThan(2147483647);
|
|
49
|
+
});
|
|
40
50
|
it("uses switch language when replacing a connected wallet", () => {
|
|
41
51
|
render(_jsx(WalletConnector, { open: true, chainType: ChainType.SOL, intent: "switch", onClose: vi.fn() }));
|
|
42
52
|
expect(screen.getByRole("dialog", { name: "Switch wallet" })).not.toBeNull();
|
|
@@ -74,6 +84,50 @@ describe("WalletConnector", () => {
|
|
|
74
84
|
await user.click(screen.getByRole("button", { name: "Connect Phantom on Solana" }));
|
|
75
85
|
expect(connectWallet).toHaveBeenCalledWith({ walletConnector: solanaConnector });
|
|
76
86
|
});
|
|
87
|
+
it("opens the WalletConnect modal before connecting an EVM wallet", async () => {
|
|
88
|
+
const user = userEvent.setup();
|
|
89
|
+
const headlessConnector = { id: "walletConnect", name: "WalletConnect" };
|
|
90
|
+
const modalConnector = { id: "walletConnect", name: "WalletConnect Modal" };
|
|
91
|
+
createWalletConnectModalConnector.mockResolvedValue(modalConnector);
|
|
92
|
+
installedWallets = [
|
|
93
|
+
{
|
|
94
|
+
id: "walletconnect",
|
|
95
|
+
name: "WalletConnect",
|
|
96
|
+
connectors: [{ chainType: ChainType.EVM, connector: headlessConnector }],
|
|
97
|
+
},
|
|
98
|
+
];
|
|
99
|
+
render(_jsx(WalletConnector, { open: true, onClose: vi.fn() }));
|
|
100
|
+
expect(screen.getByText("Mobile Wallets")).not.toBeNull();
|
|
101
|
+
await user.click(screen.getByRole("button", { name: "Connect Mobile Wallets wallet" }));
|
|
102
|
+
expect(createWalletConnectModalConnector).toHaveBeenCalledWith(headlessConnector);
|
|
103
|
+
expect(connectWallet).toHaveBeenCalledWith({
|
|
104
|
+
walletConnector: { chainType: ChainType.EVM, connector: modalConnector },
|
|
105
|
+
});
|
|
106
|
+
expect([...document.head.querySelectorAll("style")].some((style) => style.textContent?.includes("--w3m-z-index: 2147483647"))).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
it("keeps the Solana WalletConnect modal above the wallet selector and cleans up after failure", async () => {
|
|
109
|
+
const user = userEvent.setup();
|
|
110
|
+
const solanaConnector = { id: "WalletConnect", name: "WalletConnect" };
|
|
111
|
+
let failConnect;
|
|
112
|
+
connectWallet.mockImplementation(() => new Promise((_resolve, reject) => {
|
|
113
|
+
failConnect = () => reject(new Error("WalletConnect failed"));
|
|
114
|
+
}));
|
|
115
|
+
installedWallets = [
|
|
116
|
+
{
|
|
117
|
+
id: "walletconnect",
|
|
118
|
+
name: "WalletConnect",
|
|
119
|
+
connectors: [{ chainType: ChainType.SOL, connector: solanaConnector }],
|
|
120
|
+
},
|
|
121
|
+
];
|
|
122
|
+
render(_jsx(WalletConnector, { open: true, chainType: ChainType.SOL, onClose: vi.fn() }));
|
|
123
|
+
await user.click(screen.getByRole("button", { name: "Connect Mobile Wallets wallet" }));
|
|
124
|
+
expect(connectWallet).toHaveBeenCalledWith({
|
|
125
|
+
walletConnector: { chainType: ChainType.SOL, connector: solanaConnector },
|
|
126
|
+
});
|
|
127
|
+
expect([...document.head.querySelectorAll("style")].some((style) => style.textContent?.includes("--w3m-z-index: 2147483647"))).toBe(true);
|
|
128
|
+
failConnect?.();
|
|
129
|
+
await waitFor(() => expect([...document.head.querySelectorAll("style")].some((style) => style.textContent?.includes("--w3m-z-index: 2147483647"))).toBe(false));
|
|
130
|
+
});
|
|
77
131
|
it("does not show viem's resource-unavailable error when the wallet prompt is closed", () => {
|
|
78
132
|
render(_jsx(WalletConnector, { open: true, onClose: vi.fn() }));
|
|
79
133
|
act(() => {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useAccount } from "@coin-voyage/crypto/hooks";
|
|
2
|
-
import { fetchTokenList, tokenToCurrency } from "@coin-voyage/shared/currency";
|
|
2
|
+
import { fetchTokenList, formatCurrencyAmount, tokenToCurrency } from "@coin-voyage/shared/currency";
|
|
3
3
|
import { currencyID } from "@coin-voyage/shared/types";
|
|
4
4
|
import { useEffect, useReducer, useState } from "react";
|
|
5
5
|
import { useSwapKit } from "../providers/swapkit-context";
|
|
@@ -63,7 +63,7 @@ export function useTokenData() {
|
|
|
63
63
|
walletKey,
|
|
64
64
|
tokens: data.portfolio.flatMap((result) => result.currencies.map(({ balance, currency }) => ({
|
|
65
65
|
currency,
|
|
66
|
-
balance:
|
|
66
|
+
balance: formatCurrencyAmount(balance.raw, currency.decimals),
|
|
67
67
|
balanceUsd: balance.value_usd ?? undefined,
|
|
68
68
|
}))),
|
|
69
69
|
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { ChainId, ChainType } from "@coin-voyage/shared/types";
|
|
3
|
+
import { cleanup, renderHook, waitFor } from "@testing-library/react";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { useTokenData } from "./use-token-data";
|
|
6
|
+
const { api } = vi.hoisted(() => ({ api: { portfolio: vi.fn() } }));
|
|
7
|
+
vi.mock("@coin-voyage/crypto/hooks", () => ({
|
|
8
|
+
useAccount: () => ({ accounts: [{ address: "0x123", chainType: ChainType.EVM }] }),
|
|
9
|
+
}));
|
|
10
|
+
vi.mock("@coin-voyage/shared/currency", async (importOriginal) => ({
|
|
11
|
+
...(await importOriginal()),
|
|
12
|
+
fetchTokenList: vi.fn().mockResolvedValue({ chains: [] }),
|
|
13
|
+
}));
|
|
14
|
+
vi.mock("../providers/swapkit-context", () => ({
|
|
15
|
+
useSwapKit: () => ({ api }),
|
|
16
|
+
}));
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
cleanup();
|
|
19
|
+
vi.clearAllMocks();
|
|
20
|
+
});
|
|
21
|
+
describe("useTokenData", () => {
|
|
22
|
+
it.each([
|
|
23
|
+
{ decimals: 6, raw: "1234567", ui: "1.23", expected: "1.234567" },
|
|
24
|
+
{ decimals: 9, raw: "1239999999", ui: "1.24", expected: "1.239999999" },
|
|
25
|
+
{ decimals: 18, raw: "1123456789012345678", ui: "1.12", expected: "1.123456789012345678" },
|
|
26
|
+
{ decimals: 18, raw: "1", ui: "0.00", expected: "0.000000000000000001" },
|
|
27
|
+
])("preserves the raw balance at $decimals decimals ($raw)", async ({ decimals, raw, ui, expected }) => {
|
|
28
|
+
const currency = { id: "eth", chain_id: ChainId.ETH, name: "Ether", symbol: "ETH", decimals };
|
|
29
|
+
api.portfolio.mockResolvedValue({
|
|
30
|
+
data: { portfolio: [{ currencies: [{ currency, balance: { raw, ui, value_usd: 12.34 } }] }] },
|
|
31
|
+
});
|
|
32
|
+
const { result } = renderHook(() => useTokenData());
|
|
33
|
+
await waitFor(() => expect(result.current.tokens).toHaveLength(1));
|
|
34
|
+
expect(result.current.tokens[0]).toEqual({ currency, balance: expected, balanceUsd: 12.34 });
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export declare function balanceFraction(balance: string, fraction: number, decimals: number): string;
|
|
1
2
|
export declare function isValidSwapAmountInput(value: string): boolean;
|
|
2
3
|
export declare function toSwapAmountInputValue(amount?: string | null): string;
|
|
3
4
|
export declare function shouldValidateSwapBalance(amount: string): boolean;
|
package/dist/lib/amount-input.js
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
|
+
import { formatCurrencyAmount, parseCurrencyAmount } from "@coin-voyage/shared/currency";
|
|
1
2
|
const DECIMAL_INPUT = /^(?:\d+(?:\.\d*)?|\.\d+)?$/;
|
|
3
|
+
export function balanceFraction(balance, fraction, decimals) {
|
|
4
|
+
if (fraction === 1)
|
|
5
|
+
return balance;
|
|
6
|
+
const raw = BigInt(parseCurrencyAmount(balance, decimals));
|
|
7
|
+
return formatCurrencyAmount((raw * BigInt(fraction * 100)) / 100n, decimals);
|
|
8
|
+
}
|
|
2
9
|
export function isValidSwapAmountInput(value) {
|
|
3
10
|
return DECIMAL_INPUT.test(value);
|
|
4
11
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { balanceFraction } from "./amount-input";
|
|
3
|
+
describe("balanceFraction", () => {
|
|
4
|
+
it.each(["1.239999999999999999", "0.000000000000000001", "9007199254740993.123456789012345678", "0"])("preserves the full MAX balance %s", (balance) => {
|
|
5
|
+
expect(balanceFraction(balance, 1, 18)).toBe(balance);
|
|
6
|
+
});
|
|
7
|
+
it.each([
|
|
8
|
+
[0.25, "0.309999999999999999"],
|
|
9
|
+
[0.5, "0.619999999999999999"],
|
|
10
|
+
[0.75, "0.929999999999999999"],
|
|
11
|
+
])("floors a %s fraction to spendable base units", (fraction, expected) => {
|
|
12
|
+
expect(balanceFraction("1.239999999999999999", fraction, 18)).toBe(expected);
|
|
13
|
+
});
|
|
14
|
+
it("does not round a fraction up for indivisible tokens", () => {
|
|
15
|
+
expect(balanceFraction("3", 0.5, 0)).toBe("1");
|
|
16
|
+
});
|
|
17
|
+
});
|
|
@@ -15,10 +15,9 @@ interface GetSwapWalletRequirementProps {
|
|
|
15
15
|
}
|
|
16
16
|
export declare function shouldUseSeparateOutputWallet(sourceChainType: ChainType | undefined, destinationChainType: ChainType | undefined): boolean;
|
|
17
17
|
interface ShouldShowOutputWalletSelectorProps {
|
|
18
|
-
requiresSeparateOutputWallet: boolean;
|
|
19
18
|
outputChainType?: ChainType;
|
|
20
19
|
connectionRequirement: SwapWalletRequirement;
|
|
21
20
|
}
|
|
22
|
-
export declare function shouldShowOutputWalletSelector({
|
|
21
|
+
export declare function shouldShowOutputWalletSelector({ outputChainType, connectionRequirement, }: ShouldShowOutputWalletSelectorProps): boolean;
|
|
23
22
|
export declare function getSwapWalletRequirement({ inputWalletAddress, inputChainType, shouldRestrictInputChainType, outputWalletAddress, outputChainType, }: GetSwapWalletRequirementProps): SwapWalletRequirement;
|
|
24
23
|
export {};
|
|
@@ -2,8 +2,8 @@ import { getChainTypeName } from "@coin-voyage/shared/chain";
|
|
|
2
2
|
export function shouldUseSeparateOutputWallet(sourceChainType, destinationChainType) {
|
|
3
3
|
return Boolean(sourceChainType && destinationChainType && sourceChainType !== destinationChainType);
|
|
4
4
|
}
|
|
5
|
-
export function shouldShowOutputWalletSelector({
|
|
6
|
-
return Boolean(outputChainType &&
|
|
5
|
+
export function shouldShowOutputWalletSelector({ outputChainType, connectionRequirement, }) {
|
|
6
|
+
return Boolean(outputChainType && connectionRequirement.target !== "input");
|
|
7
7
|
}
|
|
8
8
|
export function getSwapWalletRequirement({ inputWalletAddress, inputChainType, shouldRestrictInputChainType = true, outputWalletAddress, outputChainType, }) {
|
|
9
9
|
if (!inputWalletAddress) {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { ChainType } from "@coin-voyage/shared/types";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { shouldShowOutputWalletSelector } from "./output-wallet";
|
|
4
|
+
describe("shouldShowOutputWalletSelector", () => {
|
|
5
|
+
it("allows changing the recipient when the input wallet is connected", () => {
|
|
6
|
+
expect(shouldShowOutputWalletSelector({
|
|
7
|
+
outputChainType: ChainType.EVM,
|
|
8
|
+
connectionRequirement: { target: "none" },
|
|
9
|
+
})).toBe(true);
|
|
10
|
+
});
|
|
11
|
+
it("allows choosing an address when an output wallet is required", () => {
|
|
12
|
+
expect(shouldShowOutputWalletSelector({
|
|
13
|
+
outputChainType: ChainType.SOL,
|
|
14
|
+
connectionRequirement: { target: "output", label: "Connect Wallet" },
|
|
15
|
+
})).toBe(true);
|
|
16
|
+
});
|
|
17
|
+
it("hides the selector until the input wallet is connected", () => {
|
|
18
|
+
expect(shouldShowOutputWalletSelector({
|
|
19
|
+
outputChainType: ChainType.EVM,
|
|
20
|
+
connectionRequirement: { target: "input", label: "Connect Wallet" },
|
|
21
|
+
})).toBe(false);
|
|
22
|
+
});
|
|
23
|
+
it("hides the selector without a destination chain", () => {
|
|
24
|
+
expect(shouldShowOutputWalletSelector({ connectionRequirement: { target: "none" } })).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ApiClient, APIEnvironment } from "@coin-voyage/shared/api";
|
|
1
|
+
import type { ApiClient, APIEnvironment } from "@coin-voyage/shared/api/browser";
|
|
2
2
|
import type { SwapKitMode, SwapKitTheme } from "../types";
|
|
3
3
|
export interface SwapKitContextValue {
|
|
4
4
|
api: ApiClient;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { useInWagmiContext } from "@coin-voyage/crypto/evm";
|
|
3
3
|
import { useConnectCallback } from "@coin-voyage/crypto/hooks";
|
|
4
|
-
import { ApiClient } from "@coin-voyage/shared/api";
|
|
4
|
+
import { ApiClient } from "@coin-voyage/shared/api/browser";
|
|
5
5
|
import { generateSessionId } from "@coin-voyage/shared/utils";
|
|
6
6
|
import { useMemo, useState } from "react";
|
|
7
7
|
import { SwapKitContext } from "./swapkit-context";
|
package/dist/styles/root.js
CHANGED
|
@@ -42,7 +42,7 @@ export const SwapKitRoot = styled.div `
|
|
|
42
42
|
display: block;
|
|
43
43
|
width: 100%;
|
|
44
44
|
color: var(--sk-text);
|
|
45
|
-
background:
|
|
45
|
+
background: ${({ $customTheme }) => $customTheme.background ?? "transparent"};
|
|
46
46
|
font-family: var(--sk-font-family);
|
|
47
47
|
font-size: 16px;
|
|
48
48
|
text-align: left;
|
package/dist/styles/theme.js
CHANGED
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.4.0-beta.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.6.0-beta.0",
|
|
26
|
+
"@coin-voyage/shared": "3.6.0-beta.0"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
29
|
"@testing-library/react": "^16.3.0",
|
|
@@ -56,14 +56,14 @@
|
|
|
56
56
|
"scripts": {
|
|
57
57
|
"watch": "tsc -w -p ./tsconfig.json",
|
|
58
58
|
"build": "tsc --build --force",
|
|
59
|
-
"test": "vitest run --config ./vitest.config.ts",
|
|
60
|
-
"test:coverage": "vitest run --coverage --maxWorkers=1 --no-file-parallelism --config ./vitest.config.ts",
|
|
61
59
|
"clean": "rm -rf dist tsconfig.tsbuildinfo coverage",
|
|
60
|
+
"release:build": "pnpm clean && pnpm build",
|
|
62
61
|
"pre:release": "pnpm version prerelease --preid=beta",
|
|
63
62
|
"pre:publish": "pnpm publish --access public --tag beta --no-git-checks",
|
|
64
63
|
"pre:steps": "pnpm pre:release && pnpm pre:publish",
|
|
65
|
-
"release:build": "pnpm clean && pnpm build",
|
|
66
64
|
"release": "pnpm publish --access public --no-git-checks",
|
|
67
|
-
"type-check": "tsc --noEmit"
|
|
65
|
+
"type-check": "tsc --noEmit",
|
|
66
|
+
"test": "vitest run --config ./vitest.config.ts",
|
|
67
|
+
"test:coverage": "vitest run --coverage --maxWorkers=1 --no-file-parallelism --config ./vitest.config.ts"
|
|
68
68
|
}
|
|
69
69
|
}
|