@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.
@@ -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: [{ currency: source, balance: "2" }, { currency: destination }, { currency: solana }],
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
- footerAction?: ReactNode;
24
+ headerAction?: ReactNode;
25
25
  }
26
- export declare function CurrencyPanel({ side, token, amount, balance, quoteUsd, quoteLoading, disabled, onAmountChange, onSelectToken, footerAction, }: CurrencyPanelProps): import("react/jsx-runtime").JSX.Element;
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 {};
@@ -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, InlineActions, 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";
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, footerAction, }) {
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)) })), _jsxs(InlineActions, { children: [balance !== undefined && _jsxs("span", { children: ["Balance: ", Number(balance).toLocaleString()] }), footerAction] })] })] }));
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), footerAction: showOutputWalletSelector ? (_jsx(OutputWalletSelector, { label: outputAddress ? truncateAddress(outputAddress) : "Connect Wallet", open: outputWalletMenuOpen, onOpenChange: setOutputWalletMenuOpen, onConnect: separateOutputWallet
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
- refreshBalances: import("react").ActionDispatch<[]>;
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 { currencyID } from "@coin-voyage/shared/types";
4
- import { useEffect, useReducer, useState } from "react";
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 portfolioById = new Map(portfolio.map((token) => [currencyID(token.currency), token]));
8
- const tokenListIds = new Set(tokenList.map(currencyID));
9
- const listed = tokenList.map((currency) => portfolioById.get(currencyID(currency)) ?? { currency });
10
- const unlisted = portfolio.filter((token) => !tokenListIds.has(currencyID(token.currency)));
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, refreshBalances] = useReducer((revision) => revision + 1, 0);
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 activePortfolioTokens = portfolioState.walletKey === walletKey ? portfolioState.tokens : [];
79
- const tokens = mergeTokens(listedCurrencies, activePortfolioTokens);
80
- return { tokens, accounts, isLoading, error: tokenError ?? portfolioError, refreshBalances };
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
  });
@@ -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 {};
@@ -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
+ });
@@ -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-beta.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-beta.0",
26
- "@coin-voyage/shared": "3.6.0-beta.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.1",
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",