@coin-voyage/swapkit 0.4.0-beta.0 → 0.4.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/LICENSE +8 -8
- package/README.md +68 -68
- package/dist/components/execution-modal.d.ts +1 -1
- package/dist/components/execution-modal.js +6 -2
- package/dist/components/styles.d.ts +1 -1
- package/dist/components/styles.js +1282 -1281
- package/dist/components/swap-recipient.test.js +32 -1
- package/dist/components/swap.d.ts +2 -2
- package/dist/components/swap.js +18 -7
- package/dist/hooks/use-token-data.d.ts +2 -1
- package/dist/hooks/use-token-data.js +39 -10
- package/dist/hooks/use-token-data.test.js +46 -1
- package/dist/lib/execution.d.ts +2 -0
- package/dist/lib/execution.js +6 -0
- package/dist/lib/execution.test.d.ts +1 -0
- package/dist/lib/execution.test.js +24 -0
- package/dist/styles/root.js +59 -59
- package/package.json +4 -4
|
@@ -12,9 +12,15 @@ vi.mock("../hooks/use-swap-execution", () => ({
|
|
|
12
12
|
}));
|
|
13
13
|
vi.mock("../hooks/use-token-data", () => ({
|
|
14
14
|
useTokenData: () => ({
|
|
15
|
-
tokens: [
|
|
15
|
+
tokens: [
|
|
16
|
+
{ currency: source, balance: "2" },
|
|
17
|
+
{ currency: destination, balance: "7.5" },
|
|
18
|
+
{ currency: unheld },
|
|
19
|
+
{ currency: solana },
|
|
20
|
+
],
|
|
16
21
|
accounts,
|
|
17
22
|
isLoading: false,
|
|
23
|
+
balancesLoaded: true,
|
|
18
24
|
refreshBalances: vi.fn(),
|
|
19
25
|
}),
|
|
20
26
|
}));
|
|
@@ -50,6 +56,14 @@ const destination = {
|
|
|
50
56
|
symbol: "USDC",
|
|
51
57
|
decimals: 6,
|
|
52
58
|
};
|
|
59
|
+
const unheld = {
|
|
60
|
+
id: "dai",
|
|
61
|
+
chain_id: ChainId.ETH,
|
|
62
|
+
address: "0x6B175474E89094C44Da98b954EedeAC495271d0F",
|
|
63
|
+
name: "Dai",
|
|
64
|
+
symbol: "DAI",
|
|
65
|
+
decimals: 18,
|
|
66
|
+
};
|
|
53
67
|
const solana = {
|
|
54
68
|
id: "sol",
|
|
55
69
|
chain_id: ChainId.SOL,
|
|
@@ -87,6 +101,23 @@ describe("Swap destination recipient", () => {
|
|
|
87
101
|
destination: expect.objectContaining({ recipient }),
|
|
88
102
|
}), sender);
|
|
89
103
|
});
|
|
104
|
+
it("shows the buy token balance until a recipient is pasted", () => {
|
|
105
|
+
renderSwap();
|
|
106
|
+
expect(screen.getByText("Balance: 7.5")).toBeTruthy();
|
|
107
|
+
openRecipientModal();
|
|
108
|
+
fireEvent.change(screen.getByRole("textbox", { name: "Wallet address" }), { target: { value: recipient } });
|
|
109
|
+
fireEvent.click(screen.getByRole("button", { name: "Use address" }));
|
|
110
|
+
expect(screen.queryByText("Balance: 7.5")).toBeNull();
|
|
111
|
+
expect(screen.getByText("Balance: 2")).toBeTruthy();
|
|
112
|
+
});
|
|
113
|
+
it("shows a zero buy balance for a token the connected wallet does not hold", () => {
|
|
114
|
+
renderSwap(unheld);
|
|
115
|
+
expect(screen.getByText("Balance: 0")).toBeTruthy();
|
|
116
|
+
});
|
|
117
|
+
it("shows no buy balance when no wallet is connected for the destination chain", () => {
|
|
118
|
+
renderSwap(solana);
|
|
119
|
+
expect(screen.queryByText("Balance: 0")).toBeNull();
|
|
120
|
+
});
|
|
90
121
|
it("rejects invalid same-chain destination addresses", () => {
|
|
91
122
|
renderSwap();
|
|
92
123
|
openRecipientModal();
|
|
@@ -21,8 +21,8 @@ interface CurrencyPanelProps {
|
|
|
21
21
|
disabled?: boolean;
|
|
22
22
|
onAmountChange: (value: string) => void;
|
|
23
23
|
onSelectToken: () => void;
|
|
24
|
-
|
|
24
|
+
headerAction?: ReactNode;
|
|
25
25
|
}
|
|
26
|
-
export declare function CurrencyPanel({ side, token, amount, balance, quoteUsd, quoteLoading, disabled, onAmountChange, onSelectToken,
|
|
26
|
+
export declare function CurrencyPanel({ side, token, amount, balance, quoteUsd, quoteLoading, disabled, onAmountChange, onSelectToken, headerAction, }: CurrencyPanelProps): import("react/jsx-runtime").JSX.Element;
|
|
27
27
|
export declare function Swap({ defaultSourceCurrency, defaultDestinationCurrency, defaultSlippageBps, onComplete, onError, }?: SwapProps): import("react/jsx-runtime").JSX.Element;
|
|
28
28
|
export {};
|
package/dist/components/swap.js
CHANGED
|
@@ -17,7 +17,7 @@ import { ExecutionModal } from "./execution-modal";
|
|
|
17
17
|
import { ConnectedWallets } from "./connected-wallets";
|
|
18
18
|
import { ArrowDownIcon, ChevronDownIcon, FuelIcon, InfoIcon, PasteIcon, SettingsIcon, WalletIcon } from "./icons";
|
|
19
19
|
import { Modal } from "./modal";
|
|
20
|
-
import { AmountInput, AmountRow, AmountSkeleton, BreakdownBody, BreakdownCard, BreakdownMeta, BreakdownToggle, CardFooter, CardTitle, CardWrap, CompactInput, CurrencyCard, DetailRow, ErrorBox, FieldLabel, FormStack, FreeFeeBadge, Hint, IconButton,
|
|
20
|
+
import { AmountInput, AmountRow, AmountSkeleton, BreakdownBody, BreakdownCard, BreakdownMeta, BreakdownToggle, CardFooter, CardHeaderAction, CardTitle, CardWrap, CompactInput, CurrencyCard, DetailRow, ErrorBox, FieldLabel, FormStack, FreeFeeBadge, Hint, IconButton, InfoButton, InfoWrap, NetworkCost, OutputWalletControl, OutputWalletMenu, OutputWalletOption, OutputWalletTrigger, PrimaryButton, QuoteValueSkeleton, QuickAmountActions, QuickAmountButton, SettingsLabel, SettingsPanel, SettingsRow, SettingsTooltip, SlippageControl, SlippageInput, SlippageInputWrap, SlippageModeButton, Spinner, SwitchButton, TokenButton, Toolbar, ToolbarGroup, WalletRequirementButton, } from "./styles";
|
|
21
21
|
import { TokenIcon } from "./token-icon";
|
|
22
22
|
import { TokenPicker } from "./token-picker";
|
|
23
23
|
import { WalletConnector } from "./wallet-connector";
|
|
@@ -81,6 +81,13 @@ export function useSwapNotifications({ stage, quoteError, tokenError, onBalances
|
|
|
81
81
|
onCompleteRef.current?.({ orderId: stage.orderId, transactionHash });
|
|
82
82
|
}
|
|
83
83
|
}
|
|
84
|
+
else if (stage.kind === "refunded") {
|
|
85
|
+
const key = `refunded:${stage.orderId}`;
|
|
86
|
+
if (lastNotification.current !== key) {
|
|
87
|
+
lastNotification.current = key;
|
|
88
|
+
onBalancesChangedRef.current();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
84
91
|
else if (stage.kind === "error") {
|
|
85
92
|
const key = `error:${stage.message}`;
|
|
86
93
|
if (lastNotification.current !== key) {
|
|
@@ -144,15 +151,15 @@ const QUICK_AMOUNTS = [
|
|
|
144
151
|
{ label: "75%", fraction: 0.75 },
|
|
145
152
|
{ label: "Max", fraction: 1 },
|
|
146
153
|
];
|
|
147
|
-
export function CurrencyPanel({ side, token, amount, balance, quoteUsd, quoteLoading, disabled, onAmountChange, onSelectToken,
|
|
154
|
+
export function CurrencyPanel({ side, token, amount, balance, quoteUsd, quoteLoading, disabled, onAmountChange, onSelectToken, headerAction, }) {
|
|
148
155
|
const label = side === "input" ? "SELL" : "BUY";
|
|
149
156
|
const showQuickAmounts = side === "input" && balance !== undefined && token !== undefined;
|
|
150
|
-
return (_jsxs(CurrencyCard, { children: [_jsx(CardTitle, { children: label }), showQuickAmounts && (_jsx(QuickAmountActions, { "data-quick-amounts": true, children: QUICK_AMOUNTS.map(({ label: amountLabel, fraction }) => (_jsx(QuickAmountButton, { type: "button", disabled: disabled, "aria-label": fraction === 1
|
|
157
|
+
return (_jsxs(CurrencyCard, { children: [_jsx(CardTitle, { children: label }), headerAction && _jsx(CardHeaderAction, { children: headerAction }), showQuickAmounts && (_jsx(QuickAmountActions, { "data-quick-amounts": true, children: QUICK_AMOUNTS.map(({ label: amountLabel, fraction }) => (_jsx(QuickAmountButton, { type: "button", disabled: disabled, "aria-label": fraction === 1
|
|
151
158
|
? `Use maximum ${token.currency.symbol} balance`
|
|
152
159
|
: `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) => {
|
|
153
160
|
if (isValidSwapAmountInput(event.target.value))
|
|
154
161
|
onAmountChange(event.target.value);
|
|
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)) })),
|
|
162
|
+
} })), _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)) })), balance !== undefined && _jsxs("span", { children: ["Balance: ", Number(balance).toLocaleString()] })] })] }));
|
|
156
163
|
}
|
|
157
164
|
function ManualWalletModal({ open, chainId, initialValue, onClose, onSave, }) {
|
|
158
165
|
const [value, setValue] = useState(initialValue ?? "");
|
|
@@ -172,7 +179,7 @@ export function Swap({ defaultSourceCurrency, defaultDestinationCurrency, defaul
|
|
|
172
179
|
const { mode, theme } = useSwapKit();
|
|
173
180
|
const slippageInputId = useId();
|
|
174
181
|
const slippageTooltipId = useId();
|
|
175
|
-
const { tokens, accounts, isLoading, error: tokenError, refreshBalances } = useTokenData();
|
|
182
|
+
const { tokens, accounts, isLoading, error: tokenError, balancesLoaded, refreshBalances } = useTokenData();
|
|
176
183
|
const { accounts: connectedAccounts } = useAccount();
|
|
177
184
|
const [sourcePickerOpen, setSourcePickerOpen] = useState(false);
|
|
178
185
|
const [destinationPickerOpen, setDestinationPickerOpen] = useState(false);
|
|
@@ -255,6 +262,10 @@ export function Swap({ defaultSourceCurrency, defaultDestinationCurrency, defaul
|
|
|
255
262
|
? formatCurrencyAmount(quoteState.quote.output.amount.raw, quoteState.quote.output.currency.decimals)
|
|
256
263
|
: outputAmount;
|
|
257
264
|
const sourceBalance = sourceToken?.balance;
|
|
265
|
+
// A pasted recipient is not one of the connected wallets, so its balance is unknown.
|
|
266
|
+
const destinationBalance = manualAddress
|
|
267
|
+
? undefined
|
|
268
|
+
: (destinationToken?.balance ?? (balancesLoaded && outputAccount?.address ? "0" : undefined));
|
|
258
269
|
const insufficientBalance = sourceBalance !== undefined && Number(displayedInputAmount) > Number(sourceBalance);
|
|
259
270
|
const sameSwap = isSameSwap(sourceToken?.currency, destinationToken?.currency, inputAccount?.address, outputAddress);
|
|
260
271
|
const canExecute = Boolean(quoteState.quote &&
|
|
@@ -297,9 +308,9 @@ export function Swap({ defaultSourceCurrency, defaultDestinationCurrency, defaul
|
|
|
297
308
|
} }), _jsx("span", { children: "%" })] })] })] }), slippageMode === "custom" &&
|
|
298
309
|
(slippage >= 40 ? (_jsx(Hint, { "$tone": "danger", children: "This slippage tolerance may cause a severely unfavorable trade." })) : slippage >= 6 ? (_jsx(Hint, { "$tone": "warning", children: "High slippage can result in a worse execution price." })) : null)] }))] })] }), _jsxs(CardWrap, { children: [_jsx(CurrencyPanel, { side: "input", token: sourceToken, amount: displayedInputAmount, balance: sourceBalance, quoteUsd: quoteState.quote?.input.amount.value_usd, quoteLoading: quoteState.loading && activeSide === "output", disabled: execution.stage.kind !== "idle", onAmountChange: (value) => {
|
|
299
310
|
swapState.setAmount("input", value);
|
|
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) => {
|
|
311
|
+
}, 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, balance: destinationBalance, quoteUsd: quoteState.quote?.output.amount.value_usd, quoteLoading: quoteState.loading && activeSide === "input", disabled: execution.stage.kind !== "idle", onAmountChange: (value) => {
|
|
301
312
|
swapState.setAmount("output", value);
|
|
302
|
-
}, onSelectToken: () => setDestinationPickerOpen(true),
|
|
313
|
+
}, onSelectToken: () => setDestinationPickerOpen(true), headerAction: showOutputWalletSelector ? (_jsx(OutputWalletSelector, { label: outputAddress ? truncateAddress(outputAddress) : "Connect Wallet", open: outputWalletMenuOpen, onOpenChange: setOutputWalletMenuOpen, onConnect: separateOutputWallet
|
|
303
314
|
? () => {
|
|
304
315
|
setOutputWalletMenuOpen(false);
|
|
305
316
|
setConnectTarget({ open: true, chainType: destinationChainType });
|
|
@@ -9,5 +9,6 @@ export declare function useTokenData(): {
|
|
|
9
9
|
accounts: import("@coin-voyage/crypto/hooks").Account[];
|
|
10
10
|
isLoading: boolean;
|
|
11
11
|
error: string | null;
|
|
12
|
-
|
|
12
|
+
balancesLoaded: boolean;
|
|
13
|
+
refreshBalances: () => void;
|
|
13
14
|
};
|
|
@@ -1,13 +1,25 @@
|
|
|
1
1
|
import { useAccount } from "@coin-voyage/crypto/hooks";
|
|
2
2
|
import { fetchTokenList, formatCurrencyAmount, tokenToCurrency } from "@coin-voyage/shared/currency";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { getChainTypeByChainId } from "@coin-voyage/shared/chain";
|
|
4
|
+
import { ChainType, currencyID } from "@coin-voyage/shared/types";
|
|
5
|
+
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
|
|
5
6
|
import { useSwapKit } from "../providers/swapkit-context";
|
|
7
|
+
const BALANCE_REFRESH_DELAYS_MS = [4000, 12000];
|
|
8
|
+
// The token list and the portfolio can report the same address in different casing.
|
|
9
|
+
function currencyKey(currency) {
|
|
10
|
+
const chainType = getChainTypeByChainId(currency.chain_id);
|
|
11
|
+
const caseInsensitive = chainType === ChainType.EVM || chainType === ChainType.SUI;
|
|
12
|
+
return currencyID(caseInsensitive ? { ...currency, address: currency.address?.toLowerCase() } : currency);
|
|
13
|
+
}
|
|
6
14
|
function mergeTokens(tokenList, portfolio) {
|
|
7
|
-
const
|
|
8
|
-
const
|
|
9
|
-
const listed = tokenList.map((currency) =>
|
|
10
|
-
|
|
15
|
+
const portfolioByKey = new Map(portfolio.map((token) => [currencyKey(token.currency), token]));
|
|
16
|
+
const tokenListKeys = new Set(tokenList.map(currencyKey));
|
|
17
|
+
const listed = tokenList.map((currency) => {
|
|
18
|
+
const held = portfolioByKey.get(currencyKey(currency));
|
|
19
|
+
// Keep the listed address so the token's id stays stable once balances load.
|
|
20
|
+
return held ? { ...held, currency: { ...held.currency, address: currency.address } } : { currency };
|
|
21
|
+
});
|
|
22
|
+
const unlisted = portfolio.filter((token) => !tokenListKeys.has(currencyKey(token.currency)));
|
|
11
23
|
return [...listed, ...unlisted].sort((left, right) => (right.balanceUsd ?? 0) - (left.balanceUsd ?? 0));
|
|
12
24
|
}
|
|
13
25
|
export function useTokenData() {
|
|
@@ -17,11 +29,24 @@ export function useTokenData() {
|
|
|
17
29
|
const [portfolioState, setPortfolioState] = useState({
|
|
18
30
|
walletKey: "[]",
|
|
19
31
|
tokens: [],
|
|
32
|
+
complete: false,
|
|
20
33
|
});
|
|
21
34
|
const [isLoading, setIsLoading] = useState(true);
|
|
22
35
|
const [tokenError, setTokenError] = useState(null);
|
|
23
36
|
const [portfolioError, setPortfolioError] = useState(null);
|
|
24
|
-
const [portfolioRevision,
|
|
37
|
+
const [portfolioRevision, bumpPortfolioRevision] = useReducer((revision) => revision + 1, 0);
|
|
38
|
+
const refreshTimers = useRef([]);
|
|
39
|
+
const clearRefreshTimers = useCallback(() => {
|
|
40
|
+
refreshTimers.current.forEach(clearTimeout);
|
|
41
|
+
refreshTimers.current = [];
|
|
42
|
+
}, []);
|
|
43
|
+
// Balances can lag behind a settled swap, so refetch now and again once the chain data has caught up.
|
|
44
|
+
const refreshBalances = useCallback(() => {
|
|
45
|
+
clearRefreshTimers();
|
|
46
|
+
bumpPortfolioRevision();
|
|
47
|
+
refreshTimers.current = BALANCE_REFRESH_DELAYS_MS.map((delay) => setTimeout(bumpPortfolioRevision, delay));
|
|
48
|
+
}, [clearRefreshTimers]);
|
|
49
|
+
useEffect(() => clearRefreshTimers, [clearRefreshTimers]);
|
|
25
50
|
useEffect(() => {
|
|
26
51
|
let active = true;
|
|
27
52
|
fetchTokenList()
|
|
@@ -61,6 +86,7 @@ export function useTokenData() {
|
|
|
61
86
|
setPortfolioError(null);
|
|
62
87
|
setPortfolioState({
|
|
63
88
|
walletKey,
|
|
89
|
+
complete: !data.errors?.length,
|
|
64
90
|
tokens: data.portfolio.flatMap((result) => result.currencies.map(({ balance, currency }) => ({
|
|
65
91
|
currency,
|
|
66
92
|
balance: formatCurrencyAmount(balance.raw, currency.decimals),
|
|
@@ -75,7 +101,10 @@ export function useTokenData() {
|
|
|
75
101
|
});
|
|
76
102
|
return () => controller.abort();
|
|
77
103
|
}, [api, portfolioRevision, walletKey]);
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
104
|
+
const portfolioActive = portfolioState.walletKey === walletKey;
|
|
105
|
+
// True once every connected wallet's balances are known, so a token without a balance is a zero balance.
|
|
106
|
+
const balancesLoaded = portfolioActive && portfolioState.complete;
|
|
107
|
+
// Token identities must stay stable across renders: the quote request is memoized on them.
|
|
108
|
+
const tokens = useMemo(() => mergeTokens(listedCurrencies, portfolioActive ? portfolioState.tokens : []), [listedCurrencies, portfolioActive, portfolioState.tokens]);
|
|
109
|
+
return { tokens, accounts, isLoading, error: tokenError ?? portfolioError, balancesLoaded, refreshBalances };
|
|
81
110
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
|
+
import { fetchTokenList } from "@coin-voyage/shared/currency";
|
|
2
3
|
import { ChainId, ChainType } from "@coin-voyage/shared/types";
|
|
3
|
-
import { cleanup, renderHook, waitFor } from "@testing-library/react";
|
|
4
|
+
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
|
|
4
5
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
6
|
import { useTokenData } from "./use-token-data";
|
|
6
7
|
const { api } = vi.hoisted(() => ({ api: { portfolio: vi.fn() } }));
|
|
@@ -33,4 +34,48 @@ describe("useTokenData", () => {
|
|
|
33
34
|
await waitFor(() => expect(result.current.tokens).toHaveLength(1));
|
|
34
35
|
expect(result.current.tokens[0]).toEqual({ currency, balance: expected, balanceUsd: 12.34 });
|
|
35
36
|
});
|
|
37
|
+
it("attaches balances to listed tokens when the portfolio address casing differs", async () => {
|
|
38
|
+
const address = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
|
|
39
|
+
const listed = { name: "USD Coin", ticker: "USDC", decimals: 6, chainId: ChainId.ETH, address };
|
|
40
|
+
vi.mocked(fetchTokenList).mockResolvedValueOnce({ chains: [{ tokens: [listed] }] });
|
|
41
|
+
const held = {
|
|
42
|
+
id: "usdc",
|
|
43
|
+
chain_id: ChainId.ETH,
|
|
44
|
+
address: address.toLowerCase(),
|
|
45
|
+
name: "USD Coin",
|
|
46
|
+
symbol: "USDC",
|
|
47
|
+
decimals: 6,
|
|
48
|
+
};
|
|
49
|
+
api.portfolio.mockResolvedValue({
|
|
50
|
+
data: { portfolio: [{ currencies: [{ currency: held, balance: { raw: "1500000", ui: "1.5", value_usd: 1.5 } }] }] },
|
|
51
|
+
});
|
|
52
|
+
const { result, rerender } = renderHook(() => useTokenData());
|
|
53
|
+
await waitFor(() => expect(result.current.tokens[0]?.balance).toBe("1.5"));
|
|
54
|
+
expect(result.current.tokens).toHaveLength(1);
|
|
55
|
+
expect(result.current.tokens[0].currency.address).toBe(address);
|
|
56
|
+
const { currency } = result.current.tokens[0];
|
|
57
|
+
rerender();
|
|
58
|
+
expect(result.current.tokens[0].currency).toBe(currency);
|
|
59
|
+
});
|
|
60
|
+
it("refetches the portfolio immediately and again after a refresh", async () => {
|
|
61
|
+
vi.useFakeTimers();
|
|
62
|
+
try {
|
|
63
|
+
api.portfolio.mockResolvedValue({ data: { portfolio: [] } });
|
|
64
|
+
const { result, unmount } = renderHook(() => useTokenData());
|
|
65
|
+
expect(api.portfolio).toHaveBeenCalledTimes(1);
|
|
66
|
+
act(() => result.current.refreshBalances());
|
|
67
|
+
expect(api.portfolio).toHaveBeenCalledTimes(2);
|
|
68
|
+
await act(() => vi.advanceTimersByTimeAsync(4000));
|
|
69
|
+
expect(api.portfolio).toHaveBeenCalledTimes(3);
|
|
70
|
+
await act(() => vi.advanceTimersByTimeAsync(8000));
|
|
71
|
+
expect(api.portfolio).toHaveBeenCalledTimes(4);
|
|
72
|
+
act(() => result.current.refreshBalances());
|
|
73
|
+
unmount();
|
|
74
|
+
await vi.advanceTimersByTimeAsync(20000);
|
|
75
|
+
expect(api.portfolio).toHaveBeenCalledTimes(5);
|
|
76
|
+
}
|
|
77
|
+
finally {
|
|
78
|
+
vi.useRealTimers();
|
|
79
|
+
}
|
|
80
|
+
});
|
|
36
81
|
});
|
package/dist/lib/execution.d.ts
CHANGED
|
@@ -41,5 +41,7 @@ export type SwapStage = {
|
|
|
41
41
|
swapData: SwapExecuteResponse | null;
|
|
42
42
|
message: string;
|
|
43
43
|
};
|
|
44
|
+
/** Whether the user's own swap transaction delivers the output, leaving no separate receive leg to track. */
|
|
45
|
+
export declare function deliversInSwapTransaction(swapData: SwapExecuteResponse): boolean;
|
|
44
46
|
export declare function buildSwapExecuteTransactions(swapData: SwapExecuteResponse, fromAddress: string): ExecuteTransaction[];
|
|
45
47
|
export {};
|
package/dist/lib/execution.js
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
import { isChainPaymentData, isDepositStepData } from "@coin-voyage/shared/payment";
|
|
2
|
+
/** Whether the user's own swap transaction delivers the output, leaving no separate receive leg to track. */
|
|
3
|
+
export function deliversInSwapTransaction(swapData) {
|
|
4
|
+
const { input, output, steps } = swapData.data;
|
|
5
|
+
const lastStep = steps[steps.length - 1];
|
|
6
|
+
return (input.currency.chain_id === output.currency.chain_id && lastStep !== undefined && !isDepositStepData(lastStep.data));
|
|
7
|
+
}
|
|
2
8
|
export function buildSwapExecuteTransactions(swapData, fromAddress) {
|
|
3
9
|
const steps = swapData.data.steps;
|
|
4
10
|
if (steps.length === 0)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { ChainId } from "@coin-voyage/shared/types";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { deliversInSwapTransaction } from "./execution";
|
|
4
|
+
const swapStep = { data: { type: "EVM" } };
|
|
5
|
+
const depositStep = { data: { amount: "1", currency: {}, deposit_address: "0x1" } };
|
|
6
|
+
function swapData(inputChain, outputChain, steps) {
|
|
7
|
+
return {
|
|
8
|
+
data: { input: { currency: { chain_id: inputChain } }, output: { currency: { chain_id: outputChain } }, steps },
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
describe("deliversInSwapTransaction", () => {
|
|
12
|
+
it("is true for a same-chain swap executed by the user's transaction", () => {
|
|
13
|
+
expect(deliversInSwapTransaction(swapData(ChainId.ETH, ChainId.ETH, [swapStep]))).toBe(true);
|
|
14
|
+
});
|
|
15
|
+
it("is false for a cross-chain swap", () => {
|
|
16
|
+
expect(deliversInSwapTransaction(swapData(ChainId.ETH, ChainId.BASE, [swapStep]))).toBe(false);
|
|
17
|
+
});
|
|
18
|
+
it("is false when the user deposits and the output is delivered later", () => {
|
|
19
|
+
expect(deliversInSwapTransaction(swapData(ChainId.ETH, ChainId.ETH, [swapStep, depositStep]))).toBe(false);
|
|
20
|
+
});
|
|
21
|
+
it("is false without steps", () => {
|
|
22
|
+
expect(deliversInSwapTransaction(swapData(ChainId.ETH, ChainId.ETH, []))).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
});
|
package/dist/styles/root.js
CHANGED
|
@@ -2,23 +2,23 @@ import { css } from "styled-components";
|
|
|
2
2
|
import styled from "./styled";
|
|
3
3
|
import { darkTheme, lightTheme } from "./theme";
|
|
4
4
|
function variables(theme) {
|
|
5
|
-
return css `
|
|
6
|
-
--sk-font-family: ${theme.fontFamily};
|
|
7
|
-
--sk-background: ${theme.background};
|
|
8
|
-
--sk-surface: ${theme.surface};
|
|
9
|
-
--sk-surface-elevated: ${theme.surfaceElevated};
|
|
10
|
-
--sk-border: ${theme.border};
|
|
11
|
-
--sk-text: ${theme.text};
|
|
12
|
-
--sk-text-muted: ${theme.textMuted};
|
|
13
|
-
--sk-accent: ${theme.accent};
|
|
14
|
-
--sk-accent-hover: ${theme.accentHover};
|
|
15
|
-
--sk-accent-text: ${theme.accentText};
|
|
16
|
-
--sk-danger: ${theme.danger};
|
|
17
|
-
--sk-warning: ${theme.warning};
|
|
18
|
-
--sk-success: ${theme.success};
|
|
19
|
-
--sk-overlay: ${theme.overlay};
|
|
20
|
-
--sk-shadow: ${theme.shadow};
|
|
21
|
-
--sk-radius: ${theme.radius};
|
|
5
|
+
return css `
|
|
6
|
+
--sk-font-family: ${theme.fontFamily};
|
|
7
|
+
--sk-background: ${theme.background};
|
|
8
|
+
--sk-surface: ${theme.surface};
|
|
9
|
+
--sk-surface-elevated: ${theme.surfaceElevated};
|
|
10
|
+
--sk-border: ${theme.border};
|
|
11
|
+
--sk-text: ${theme.text};
|
|
12
|
+
--sk-text-muted: ${theme.textMuted};
|
|
13
|
+
--sk-accent: ${theme.accent};
|
|
14
|
+
--sk-accent-hover: ${theme.accentHover};
|
|
15
|
+
--sk-accent-text: ${theme.accentText};
|
|
16
|
+
--sk-danger: ${theme.danger};
|
|
17
|
+
--sk-warning: ${theme.warning};
|
|
18
|
+
--sk-success: ${theme.success};
|
|
19
|
+
--sk-overlay: ${theme.overlay};
|
|
20
|
+
--sk-shadow: ${theme.shadow};
|
|
21
|
+
--sk-radius: ${theme.radius};
|
|
22
22
|
`;
|
|
23
23
|
}
|
|
24
24
|
function modeVariables(mode, customTheme) {
|
|
@@ -28,48 +28,48 @@ function modeVariables(mode, customTheme) {
|
|
|
28
28
|
return variables(light);
|
|
29
29
|
if (mode === "dark")
|
|
30
30
|
return variables(dark);
|
|
31
|
-
return css `
|
|
32
|
-
${variables(light)}
|
|
33
|
-
@media (prefers-color-scheme: dark) {
|
|
34
|
-
${variables(dark)}
|
|
35
|
-
}
|
|
31
|
+
return css `
|
|
32
|
+
${variables(light)}
|
|
33
|
+
@media (prefers-color-scheme: dark) {
|
|
34
|
+
${variables(dark)}
|
|
35
|
+
}
|
|
36
36
|
`;
|
|
37
37
|
}
|
|
38
|
-
export const SwapKitRoot = styled.div `
|
|
39
|
-
${({ $mode, $customTheme }) => modeVariables($mode, $customTheme)}
|
|
40
|
-
|
|
41
|
-
all: initial;
|
|
42
|
-
display: block;
|
|
43
|
-
width: 100%;
|
|
44
|
-
color: var(--sk-text);
|
|
45
|
-
background: ${({ $customTheme }) => $customTheme.background ?? "transparent"};
|
|
46
|
-
font-family: var(--sk-font-family);
|
|
47
|
-
font-size: 16px;
|
|
48
|
-
text-align: left;
|
|
49
|
-
text-rendering: optimizeLegibility;
|
|
50
|
-
-webkit-font-smoothing: antialiased;
|
|
51
|
-
|
|
52
|
-
&,
|
|
53
|
-
* {
|
|
54
|
-
box-sizing: border-box;
|
|
55
|
-
font-family: var(--sk-font-family);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
button,
|
|
59
|
-
input {
|
|
60
|
-
font-family: var(--sk-font-family);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
button:focus-visible,
|
|
64
|
-
input:focus-visible,
|
|
65
|
-
a:focus-visible {
|
|
66
|
-
outline: 2px solid var(--sk-accent);
|
|
67
|
-
outline-offset: 2px;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
img,
|
|
71
|
-
svg {
|
|
72
|
-
display: block;
|
|
73
|
-
max-width: 100%;
|
|
74
|
-
}
|
|
38
|
+
export const SwapKitRoot = styled.div `
|
|
39
|
+
${({ $mode, $customTheme }) => modeVariables($mode, $customTheme)}
|
|
40
|
+
|
|
41
|
+
all: initial;
|
|
42
|
+
display: block;
|
|
43
|
+
width: 100%;
|
|
44
|
+
color: var(--sk-text);
|
|
45
|
+
background: ${({ $customTheme }) => $customTheme.background ?? "transparent"};
|
|
46
|
+
font-family: var(--sk-font-family);
|
|
47
|
+
font-size: 16px;
|
|
48
|
+
text-align: left;
|
|
49
|
+
text-rendering: optimizeLegibility;
|
|
50
|
+
-webkit-font-smoothing: antialiased;
|
|
51
|
+
|
|
52
|
+
&,
|
|
53
|
+
* {
|
|
54
|
+
box-sizing: border-box;
|
|
55
|
+
font-family: var(--sk-font-family);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
button,
|
|
59
|
+
input {
|
|
60
|
+
font-family: var(--sk-font-family);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
button:focus-visible,
|
|
64
|
+
input:focus-visible,
|
|
65
|
+
a:focus-visible {
|
|
66
|
+
outline: 2px solid var(--sk-accent);
|
|
67
|
+
outline-offset: 2px;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
img,
|
|
71
|
+
svg {
|
|
72
|
+
display: block;
|
|
73
|
+
max-width: 100%;
|
|
74
|
+
}
|
|
75
75
|
`;
|
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.0
|
|
4
|
+
"version": "0.4.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.6.0
|
|
26
|
-
"@coin-voyage/shared": "3.6.0
|
|
25
|
+
"@coin-voyage/crypto": "3.6.0",
|
|
26
|
+
"@coin-voyage/shared": "3.6.0"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
29
|
"@testing-library/react": "^16.3.0",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"typescript-plugin-styled-components": "^3.0.0"
|
|
37
37
|
},
|
|
38
38
|
"peerDependencies": {
|
|
39
|
-
"@bigmi/react": "^0.9.
|
|
39
|
+
"@bigmi/react": "^0.9.4",
|
|
40
40
|
"@mysten/dapp-kit-react": "^2.1.4",
|
|
41
41
|
"@solana/wallet-adapter-react": "^0.15.39",
|
|
42
42
|
"@tanstack/react-query": "^5.90.6",
|