@coin-voyage/swapkit 0.1.0-beta.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/dist/components/connected-wallets.d.ts +1 -1
  2. package/dist/components/connected-wallets.js +37 -14
  3. package/dist/components/connected-wallets.test.d.ts +1 -0
  4. package/dist/components/connected-wallets.test.js +104 -0
  5. package/dist/components/execution-modal.js +8 -2
  6. package/dist/components/styles.d.ts +4 -1
  7. package/dist/components/styles.js +86 -16
  8. package/dist/components/swap.d.ts +27 -1
  9. package/dist/components/swap.js +41 -18
  10. package/dist/components/token-picker.d.ts +3 -1
  11. package/dist/components/token-picker.js +19 -9
  12. package/dist/components/token-picker.test.d.ts +1 -0
  13. package/dist/components/token-picker.test.js +43 -0
  14. package/dist/components/wallet-connector.d.ts +3 -1
  15. package/dist/components/wallet-connector.js +69 -20
  16. package/dist/components/wallet-connector.test.d.ts +1 -0
  17. package/dist/components/wallet-connector.test.js +89 -0
  18. package/dist/hooks/use-swap-execution.d.ts +1 -1
  19. package/dist/hooks/use-swap-execution.js +69 -35
  20. package/dist/hooks/use-swap-state.d.ts +4 -1
  21. package/dist/hooks/use-swap-state.js +3 -3
  22. package/dist/hooks/use-token-data.d.ts +1 -0
  23. package/dist/hooks/use-token-data.js +4 -3
  24. package/dist/lib/currency-selection.js +3 -4
  25. package/dist/lib/execution.d.ts +12 -5
  26. package/dist/lib/quote.d.ts +1 -1
  27. package/dist/lib/quote.js +7 -7
  28. package/dist/providers/swapkit-provider.d.ts +1 -1
  29. package/dist/providers/swapkit-provider.js +3 -1
  30. package/dist/providers/swapkit-provider.test.d.ts +1 -0
  31. package/dist/providers/swapkit-provider.test.js +20 -0
  32. package/dist/types.d.ts +2 -1
  33. package/package.json +4 -4
@@ -1 +1 @@
1
- export declare function ConnectedWallets(): import("react/jsx-runtime").JSX.Element | null;
1
+ export declare function ConnectedWallets(): import("react/jsx-runtime").JSX.Element;
@@ -1,10 +1,12 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { useAccount, useAccountDisconnect, useInstalledWallets } from "@coin-voyage/crypto/hooks";
3
3
  import { getChainTypeName } from "@coin-voyage/shared/chain";
4
+ import { ChainType } from "@coin-voyage/shared/types";
4
5
  import { useEffect, useRef, useState } from "react";
5
6
  import { CloseIcon, UnplugIcon } from "./icons";
6
- import { ConnectedWalletPanel, ConnectedWalletRow, DisconnectAllButton, IconButton, TriggerWalletIcons, ToolbarGroup, WalletDetails, WalletDisconnectButton, WalletIdentity, WalletLabel, WalletPanelHeader, } from "./styles";
7
+ import { ConnectedWalletPanel, ConnectedWalletRow, DisconnectAllButton, IconButton, TriggerWalletIcons, ToolbarGroup, WalletActions, WalletDetails, WalletDisconnectButton, WalletIdentity, WalletLabel, WalletPanelHeader, WalletSwitchButton, } from "./styles";
7
8
  import { WalletIconDisplay } from "./wallet-icon";
9
+ import { WalletConnector } from "./wallet-connector";
8
10
  function truncateAddress(address) {
9
11
  return address.length <= 14 ? address : `${address.slice(0, 6)}…${address.slice(-5)}`;
10
12
  }
@@ -17,6 +19,15 @@ function connectorId(connector) {
17
19
  return connector.name;
18
20
  return undefined;
19
21
  }
22
+ function connectorName(connector) {
23
+ if (!connector || typeof connector !== "object")
24
+ return undefined;
25
+ if ("displayName" in connector && typeof connector.displayName === "string")
26
+ return connector.displayName;
27
+ if ("name" in connector && typeof connector.name === "string")
28
+ return connector.name;
29
+ return undefined;
30
+ }
20
31
  function accountAddress(account) {
21
32
  return account.address ?? account.addresses?.[0];
22
33
  }
@@ -25,11 +36,20 @@ export function ConnectedWallets() {
25
36
  const wallets = useInstalledWallets();
26
37
  const disconnectAccount = useAccountDisconnect();
27
38
  const [open, setOpen] = useState(false);
39
+ const [switchAccount, setSwitchAccount] = useState(null);
28
40
  const groupRef = useRef(null);
29
41
  const connectedAccounts = accounts.filter((account) => account.isConnected);
30
42
  const walletForAccount = (account) => {
31
43
  const accountConnectorId = connectorId(account.connector);
32
- return wallets.find((wallet) => wallet.connectors.some((candidate) => candidate.chainType === account.chainType && connectorId(candidate.connector) === accountConnectorId));
44
+ const accountConnectorName = connectorName(account.connector)?.toLowerCase();
45
+ return wallets.find((wallet) => wallet.connectors.some((candidate) => {
46
+ if (candidate.chainType !== account.chainType)
47
+ return false;
48
+ const candidateConnectorId = connectorId(candidate.connector);
49
+ const candidateConnectorName = connectorName(candidate.connector)?.toLowerCase();
50
+ return ((accountConnectorId !== undefined && candidateConnectorId === accountConnectorId) ||
51
+ (accountConnectorName !== undefined && candidateConnectorName === accountConnectorName));
52
+ }));
33
53
  };
34
54
  useEffect(() => {
35
55
  if (!open)
@@ -49,18 +69,21 @@ export function ConnectedWallets() {
49
69
  document.removeEventListener("keydown", dismissFromKeyboard);
50
70
  };
51
71
  }, [open]);
52
- if (connectedAccounts.length === 0)
53
- return null;
54
72
  const triggerLabel = connectedAccounts.length === 1
55
73
  ? truncateAddress(accountAddress(connectedAccounts[0]) ?? "Wallet")
56
74
  : `${connectedAccounts.length} connections`;
57
- return (_jsxs(ToolbarGroup, { ref: groupRef, children: [_jsxs(IconButton, { type: "button", "aria-label": "Connected wallets", "aria-expanded": open, onClick: () => setOpen((current) => !current), children: [_jsx(TriggerWalletIcons, { children: connectedAccounts.slice(0, 3).map((account, index) => {
58
- const wallet = walletForAccount(account);
59
- return (_jsx(WalletIconDisplay, { icon: wallet?.icon, name: wallet?.name ?? getChainTypeName(account.chainType), size: 20 }, `${account.chainType}:${accountAddress(account) ?? index}`));
60
- }) }), _jsx(WalletLabel, { children: triggerLabel })] }), open && (_jsxs(ConnectedWalletPanel, { role: "menu", "aria-label": "Connected wallets menu", children: [_jsx(WalletPanelHeader, { children: "Connected wallets" }), connectedAccounts.map((account, index) => {
61
- const wallet = walletForAccount(account);
62
- const chainName = getChainTypeName(account.chainType);
63
- const address = accountAddress(account);
64
- return (_jsxs(ConnectedWalletRow, { children: [_jsxs(WalletIdentity, { children: [_jsx(WalletIconDisplay, { icon: wallet?.icon, name: wallet?.name ?? chainName, size: 20 }), _jsxs(WalletDetails, { children: [_jsx("strong", { children: chainName }), _jsx("small", { children: address ? truncateAddress(address) : "Connected" })] })] }), _jsx(WalletDisconnectButton, { type: "button", "aria-label": `Disconnect ${chainName} wallet`, onClick: () => void disconnectAccount(account), children: _jsx(CloseIcon, { size: 16 }) })] }, `${account.chainType}:${address ?? index}`));
65
- }), _jsxs(DisconnectAllButton, { type: "button", onClick: () => void disconnectAccount(), children: [_jsx(UnplugIcon, {}), "Disconnect all"] })] }))] }));
75
+ return (_jsxs(_Fragment, { children: [connectedAccounts.length > 0 && (_jsxs(ToolbarGroup, { ref: groupRef, children: [_jsxs(IconButton, { type: "button", "aria-label": "Connected wallets", "aria-expanded": open, onClick: () => setOpen((current) => !current), children: [_jsx(TriggerWalletIcons, { children: connectedAccounts.slice(0, 3).map((account, index) => {
76
+ const wallet = walletForAccount(account);
77
+ return (_jsx(WalletIconDisplay, { icon: wallet?.icon, name: wallet?.name ?? getChainTypeName(account.chainType), size: 20 }, `${account.chainType}:${accountAddress(account) ?? index}`));
78
+ }) }), _jsx(WalletLabel, { children: triggerLabel })] }), open && (_jsxs(ConnectedWalletPanel, { role: "group", "aria-label": "Connected wallets", children: [_jsx(WalletPanelHeader, { children: "Connected wallets" }), connectedAccounts.map((account, index) => {
79
+ const wallet = walletForAccount(account);
80
+ const chainName = getChainTypeName(account.chainType);
81
+ const address = accountAddress(account);
82
+ return (_jsxs(ConnectedWalletRow, { children: [_jsxs(WalletIdentity, { children: [_jsx(WalletIconDisplay, { icon: wallet?.icon, name: wallet?.name ?? chainName, size: 20 }), _jsxs(WalletDetails, { children: [_jsx("strong", { children: chainName }), _jsx("small", { children: address ? truncateAddress(address) : "Connected" })] })] }), _jsxs(WalletActions, { children: [_jsx(WalletSwitchButton, { type: "button", "aria-label": `Switch ${chainName} wallet`, onClick: () => {
83
+ setOpen(false);
84
+ setSwitchAccount(account);
85
+ }, children: "Switch" }), _jsx(WalletDisconnectButton, { type: "button", "aria-label": `Disconnect ${chainName} wallet`, onClick: () => void disconnectAccount(account), children: _jsx(CloseIcon, { size: 16 }) })] })] }, `${account.chainType}:${address ?? index}`));
86
+ }), _jsxs(DisconnectAllButton, { type: "button", onClick: () => void disconnectAccount(), children: [_jsx(UnplugIcon, {}), "Disconnect all"] })] }))] })), _jsx(WalletConnector, { open: switchAccount !== null, chainType: switchAccount?.chainType, intent: "switch", beforeConnect: switchAccount && switchAccount.chainType !== ChainType.SOL
87
+ ? () => disconnectAccount(switchAccount)
88
+ : undefined, onClose: () => setSwitchAccount(null) })] }));
66
89
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,104 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ // @vitest-environment jsdom
3
+ import { ChainId, ChainType } from "@coin-voyage/shared/types";
4
+ import { cleanup, render, screen } from "@testing-library/react";
5
+ import userEvent from "@testing-library/user-event";
6
+ import { afterEach, describe, expect, it, vi } from "vitest";
7
+ import { ConnectedWallets } from "./connected-wallets";
8
+ const disconnectAccount = vi.fn();
9
+ let accountsConnected = true;
10
+ let accountChainType = ChainType.SOL;
11
+ let accountAddress = "current-address";
12
+ let accountConnector = { id: "Test wallet", name: "Test wallet" };
13
+ let installedWalletOverride;
14
+ vi.mock("@coin-voyage/crypto/hooks", () => ({
15
+ useAccount: () => ({
16
+ accounts: accountsConnected
17
+ ? [
18
+ {
19
+ address: accountAddress,
20
+ chainId: ChainId.SOL,
21
+ chainType: accountChainType,
22
+ connector: accountConnector,
23
+ isConnected: true,
24
+ isConnecting: false,
25
+ isDisconnected: false,
26
+ isReconnecting: false,
27
+ status: "connected",
28
+ },
29
+ ]
30
+ : [],
31
+ }),
32
+ useAccountDisconnect: () => disconnectAccount,
33
+ useInstalledWallets: () => [
34
+ installedWalletOverride ?? {
35
+ id: "test-wallet",
36
+ name: "Test wallet",
37
+ connectors: [{ chainType: accountChainType, connector: { id: "Test wallet", name: "Test wallet" } }],
38
+ },
39
+ ],
40
+ }));
41
+ vi.mock("./wallet-connector", () => ({
42
+ WalletConnector: ({ open, chainType, intent }) => open ? _jsx("div", { "data-testid": "wallet-connector", children: `${intent}:${chainType}` }) : null,
43
+ }));
44
+ afterEach(() => {
45
+ cleanup();
46
+ disconnectAccount.mockClear();
47
+ accountsConnected = true;
48
+ accountChainType = ChainType.SOL;
49
+ accountAddress = "current-address";
50
+ accountConnector = { id: "Test wallet", name: "Test wallet" };
51
+ installedWalletOverride = undefined;
52
+ });
53
+ describe("ConnectedWallets", () => {
54
+ it("opens the wallet switcher for the selected account chain", async () => {
55
+ const user = userEvent.setup();
56
+ render(_jsx(ConnectedWallets, {}));
57
+ await user.click(screen.getByRole("button", { name: "Connected wallets" }));
58
+ await user.click(screen.getByRole("button", { name: "Switch Solana wallet" }));
59
+ expect(screen.queryByRole("group", { name: "Connected wallets" })).toBeNull();
60
+ expect(screen.getByTestId("wallet-connector").textContent).toBe(`switch:${ChainType.SOL}`);
61
+ expect(disconnectAccount).not.toHaveBeenCalled();
62
+ });
63
+ it("keeps the switcher open while the previous account disconnects", async () => {
64
+ const user = userEvent.setup();
65
+ const { rerender } = render(_jsx(ConnectedWallets, {}));
66
+ await user.click(screen.getByRole("button", { name: "Connected wallets" }));
67
+ await user.click(screen.getByRole("button", { name: "Switch Solana wallet" }));
68
+ accountsConnected = false;
69
+ rerender(_jsx(ConnectedWallets, {}));
70
+ expect(screen.getByTestId("wallet-connector").textContent).toBe(`switch:${ChainType.SOL}`);
71
+ });
72
+ it("updates the connected-wallet label when Phantom changes address", () => {
73
+ accountAddress = "old-address-123456789";
74
+ const { rerender } = render(_jsx(ConnectedWallets, {}));
75
+ expect(screen.getByRole("button", { name: "Connected wallets" }).textContent).toContain("old-ad…56789");
76
+ accountAddress = "new-address-987654321";
77
+ rerender(_jsx(ConnectedWallets, {}));
78
+ expect(screen.getByRole("button", { name: "Connected wallets" }).textContent).toContain("new-ad…54321");
79
+ });
80
+ it("uses the Phantom icon for its Ethereum connection in a multichain wallet", () => {
81
+ accountChainType = ChainType.EVM;
82
+ accountConnector = { id: "injected", name: "Phantom" };
83
+ installedWalletOverride = {
84
+ id: "app.phantom",
85
+ name: "Phantom",
86
+ icon: "phantom.svg",
87
+ connectors: [
88
+ { chainType: ChainType.EVM, connector: { id: "app.phantom", name: "Phantom" } },
89
+ { chainType: ChainType.SOL, connector: { id: "Phantom", name: "Phantom" } },
90
+ ],
91
+ };
92
+ render(_jsx(ConnectedWallets, {}));
93
+ expect(screen.getByAltText("Phantom wallet")).not.toBeNull();
94
+ });
95
+ it("only disconnects an account from the explicit disconnect action", async () => {
96
+ accountChainType = ChainType.EVM;
97
+ const user = userEvent.setup();
98
+ render(_jsx(ConnectedWallets, {}));
99
+ await user.click(screen.getByRole("button", { name: "Connected wallets" }));
100
+ await user.click(screen.getByRole("button", { name: "Disconnect Ethereum wallet" }));
101
+ expect(disconnectAccount).toHaveBeenCalledTimes(1);
102
+ expect(disconnectAccount.mock.calls[0]?.[0]?.chainType).toBe(ChainType.EVM);
103
+ });
104
+ });
@@ -36,16 +36,22 @@ function outcomeState(stage) {
36
36
  if (!("swapData" in stage) || !stage.swapData)
37
37
  return { tone: "muted", label: "Awaiting" };
38
38
  if (stage.kind === "done") {
39
+ const transactionHash = stage.destinationTxHash ?? stage.sourceTxHash;
40
+ const chainId = stage.destinationTxHash
41
+ ? stage.swapData.data.output.currency.chain_id
42
+ : stage.swapData.data.input.currency.chain_id;
39
43
  return {
40
44
  tone: "complete",
41
- explorerUrl: getChainExplorerTxUrl(stage.swapData.data.output.currency.chain_id, stage.txHash),
45
+ explorerUrl: transactionHash ? getChainExplorerTxUrl(chainId, transactionHash) : undefined,
42
46
  };
43
47
  }
44
48
  if (stage.kind === "refunded") {
45
49
  return {
46
50
  tone: "warning",
47
51
  label: "Refunded",
48
- explorerUrl: getChainExplorerTxUrl(stage.swapData.data.input.currency.chain_id, stage.refundTxHash),
52
+ explorerUrl: stage.refundTxHash
53
+ ? getChainExplorerTxUrl(stage.swapData.data.input.currency.chain_id, stage.refundTxHash)
54
+ : undefined,
49
55
  };
50
56
  }
51
57
  return { tone: "muted", label: "Awaiting" };
@@ -28,6 +28,8 @@ export declare const Hint: import("styled-components").StyledComponent<"p", any,
28
28
  export declare const CardWrap: import("styled-components").StyledComponent<"div", any, {}, never>;
29
29
  export declare const CurrencyCard: import("styled-components").StyledComponent<"section", any, {}, never>;
30
30
  export declare const CardTitle: import("styled-components").StyledComponent<"div", any, {}, never>;
31
+ export declare const QuickAmountActions: import("styled-components").StyledComponent<"div", any, {}, never>;
32
+ export declare const QuickAmountButton: import("styled-components").StyledComponent<"button", any, {}, never>;
31
33
  export declare const AmountRow: import("styled-components").StyledComponent<"div", any, {}, never>;
32
34
  export declare const AmountInput: import("styled-components").StyledComponent<"input", any, {}, never>;
33
35
  export declare const AmountSkeleton: import("styled-components").StyledComponent<"span", any, {}, never>;
@@ -44,7 +46,6 @@ export declare const ChainBadge: import("styled-components").StyledComponent<"sp
44
46
  }, never>;
45
47
  export declare const CardFooter: import("styled-components").StyledComponent<"div", any, {}, never>;
46
48
  export declare const InlineActions: import("styled-components").StyledComponent<"div", any, {}, never>;
47
- export declare const TextButton: import("styled-components").StyledComponent<"button", any, {}, never>;
48
49
  export declare const OutputWalletControl: import("styled-components").StyledComponent<"span", any, {}, never>;
49
50
  export declare const OutputWalletTrigger: import("styled-components").StyledComponent<"button", any, {}, never>;
50
51
  export declare const OutputWalletMenu: import("styled-components").StyledComponent<"span", any, {}, never>;
@@ -94,6 +95,8 @@ export declare const WalletPanelHeader: import("styled-components").StyledCompon
94
95
  export declare const ConnectedWalletRow: import("styled-components").StyledComponent<"div", any, {}, never>;
95
96
  export declare const WalletIdentity: import("styled-components").StyledComponent<"div", any, {}, never>;
96
97
  export declare const WalletDetails: import("styled-components").StyledComponent<"div", any, {}, never>;
98
+ export declare const WalletActions: import("styled-components").StyledComponent<"div", any, {}, never>;
99
+ export declare const WalletSwitchButton: import("styled-components").StyledComponent<"button", any, {}, never>;
97
100
  export declare const WalletDisconnectButton: import("styled-components").StyledComponent<"button", any, {}, never>;
98
101
  export declare const DisconnectAllButton: import("styled-components").StyledComponent<"button", any, {}, never>;
99
102
  export declare const TriggerWalletIcons: import("styled-components").StyledComponent<"span", any, {}, never>;
@@ -199,14 +199,30 @@ export const CardWrap = styled.div `
199
199
  position: relative;
200
200
  `;
201
201
  export const CurrencyCard = styled.section `
202
+ position: relative;
202
203
  width: 100%;
203
- min-height: 143px;
204
- padding: 14px;
204
+ min-height: 128px;
205
+ padding: 14px 14px 8px;
205
206
  border: 0;
206
207
  border-style: none;
207
208
  border-radius: var(--sk-radius);
208
209
  color: var(--sk-text);
209
210
  background: var(--sk-surface);
211
+
212
+ &:hover [data-quick-amounts],
213
+ &:focus-within [data-quick-amounts] {
214
+ opacity: 1;
215
+ pointer-events: auto;
216
+ transform: translateY(0);
217
+ }
218
+
219
+ @media (hover: none) {
220
+ [data-quick-amounts] {
221
+ opacity: 1;
222
+ pointer-events: auto;
223
+ transform: translateY(0);
224
+ }
225
+ }
210
226
  `;
211
227
  export const CardTitle = styled.div `
212
228
  margin-bottom: 12px;
@@ -215,6 +231,47 @@ export const CardTitle = styled.div `
215
231
  font-weight: 500;
216
232
  line-height: 16px;
217
233
  `;
234
+ export const QuickAmountActions = styled.div `
235
+ position: absolute;
236
+ top: 10px;
237
+ right: 12px;
238
+ display: flex;
239
+ align-items: center;
240
+ gap: 4px;
241
+ opacity: 0;
242
+ pointer-events: none;
243
+ transform: translateY(-2px);
244
+ transition:
245
+ opacity 120ms ease,
246
+ transform 120ms ease;
247
+
248
+ @media (prefers-reduced-motion: reduce) {
249
+ transition: none;
250
+ }
251
+ `;
252
+ export const QuickAmountButton = styled.button `
253
+ min-width: 34px;
254
+ height: 24px;
255
+ padding: 0 7px;
256
+ border: 1px solid var(--sk-border);
257
+ border-radius: 999px;
258
+ color: var(--sk-text);
259
+ background: var(--sk-surface-elevated);
260
+ cursor: pointer;
261
+ font-size: 11px;
262
+ font-weight: 600;
263
+ line-height: 22px;
264
+
265
+ &:hover:not(:disabled) {
266
+ border-color: var(--sk-accent);
267
+ color: var(--sk-accent);
268
+ }
269
+
270
+ &:disabled {
271
+ cursor: not-allowed;
272
+ opacity: 0.5;
273
+ }
274
+ `;
218
275
  export const AmountRow = styled.div `
219
276
  display: grid;
220
277
  grid-template-columns: minmax(0, 1fr) auto;
@@ -371,20 +428,6 @@ export const InlineActions = styled.div `
371
428
  align-items: center;
372
429
  gap: 8px;
373
430
  `;
374
- export const TextButton = styled.button `
375
- padding: 0;
376
- border: 0;
377
- color: var(--sk-accent);
378
- background: transparent;
379
- cursor: pointer;
380
- font-size: 12px;
381
- font-weight: 600;
382
-
383
- &:disabled {
384
- cursor: not-allowed;
385
- opacity: 0.5;
386
- }
387
- `;
388
431
  export const OutputWalletControl = styled.span `
389
432
  position: relative;
390
433
  display: inline-flex;
@@ -912,6 +955,11 @@ export const WalletOption = styled.button `
912
955
  &:hover {
913
956
  background: var(--sk-surface);
914
957
  }
958
+
959
+ &:disabled {
960
+ cursor: wait;
961
+ opacity: 0.6;
962
+ }
915
963
  `;
916
964
  export const WalletIconFrame = styled.span `
917
965
  display: inline-flex;
@@ -1025,6 +1073,28 @@ export const WalletDetails = styled.div `
1025
1073
  font-size: 12px;
1026
1074
  }
1027
1075
  `;
1076
+ export const WalletActions = styled.div `
1077
+ display: flex;
1078
+ flex: 0 0 auto;
1079
+ align-items: center;
1080
+ gap: 4px;
1081
+ `;
1082
+ export const WalletSwitchButton = styled.button `
1083
+ height: 28px;
1084
+ padding: 0 8px;
1085
+ border: 0;
1086
+ border-radius: 6px;
1087
+ color: var(--sk-text-muted);
1088
+ background: transparent;
1089
+ cursor: pointer;
1090
+ font-size: 12px;
1091
+ font-weight: 500;
1092
+
1093
+ &:hover {
1094
+ color: var(--sk-accent);
1095
+ background: color-mix(in srgb, var(--sk-accent) 12%, transparent);
1096
+ }
1097
+ `;
1028
1098
  export const WalletDisconnectButton = styled.button `
1029
1099
  display: inline-flex;
1030
1100
  width: 28px;
@@ -1,2 +1,28 @@
1
- import type { SwapProps } from "../types";
1
+ import { type ReactNode } from "react";
2
+ import { useSwapExecution } from "../hooks/use-swap-execution";
3
+ import { type SwapToken } from "../hooks/use-token-data";
4
+ import { type SwapSide } from "../lib/quote";
5
+ import type { SwapCompleteResult, SwapError, SwapProps } from "../types";
6
+ export declare function useSwapNotifications({ stage, quoteError, tokenError, onBalancesChanged, onComplete, onError, }: {
7
+ stage: ReturnType<typeof useSwapExecution>["stage"];
8
+ quoteError?: string | null;
9
+ tokenError?: string | null;
10
+ onBalancesChanged: () => void;
11
+ onComplete?: (result: SwapCompleteResult) => void;
12
+ onError?: (error: SwapError) => void;
13
+ }): void;
14
+ interface CurrencyPanelProps {
15
+ side: SwapSide;
16
+ token?: SwapToken;
17
+ amount: string;
18
+ balance?: number;
19
+ quoteUsd?: number | null;
20
+ quoteLoading?: boolean;
21
+ disabled?: boolean;
22
+ onAmountChange: (value: string) => void;
23
+ onSelectToken: () => void;
24
+ footerAction?: ReactNode;
25
+ }
26
+ export declare function CurrencyPanel({ side, token, amount, balance, quoteUsd, quoteLoading, disabled, onAmountChange, onSelectToken, footerAction, }: CurrencyPanelProps): import("react/jsx-runtime").JSX.Element;
2
27
  export declare function Swap({ defaultSourceCurrency, defaultDestinationCurrency, defaultSlippageBps, onComplete, onError, }?: SwapProps): import("react/jsx-runtime").JSX.Element;
28
+ export {};
@@ -16,7 +16,7 @@ import { ExecutionModal } from "./execution-modal";
16
16
  import { ConnectedWallets } from "./connected-wallets";
17
17
  import { ArrowDownIcon, ChevronDownIcon, FuelIcon, InfoIcon, PasteIcon, SettingsIcon, WalletIcon } from "./icons";
18
18
  import { Modal } from "./modal";
19
- 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, SettingsLabel, SettingsPanel, SettingsRow, SettingsTooltip, SlippageControl, SlippageInput, SlippageInputWrap, SlippageModeButton, Spinner, SwitchButton, TextButton, TokenButton, Toolbar, ToolbarGroup, WalletRequirementButton, } from "./styles";
19
+ 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
20
  import { TokenIcon } from "./token-icon";
21
21
  import { TokenPicker } from "./token-picker";
22
22
  import { WalletConnector } from "./wallet-connector";
@@ -54,24 +54,30 @@ function SlippageInfoTooltip({ id }) {
54
54
  const open = hovered || focused;
55
55
  return (_jsxs(InfoWrap, { onMouseEnter: () => setHovered(true), onMouseLeave: () => setHovered(false), children: [_jsx(InfoButton, { type: "button", "aria-label": "About max slippage", "aria-describedby": open ? id : undefined, onFocus: () => setFocused(true), onBlur: () => setFocused(false), children: _jsx(InfoIcon, {}) }), open && (_jsx(SettingsTooltip, { id: id, role: "tooltip", children: SLIPPAGE_TOOLTIP }))] }));
56
56
  }
57
- function useSwapNotifications({ stage, quoteError, tokenError, onComplete, onError, }) {
57
+ export function useSwapNotifications({ stage, quoteError, tokenError, onBalancesChanged, onComplete, onError, }) {
58
58
  const onCompleteRef = useRef(onComplete);
59
59
  const onErrorRef = useRef(onError);
60
+ const onBalancesChangedRef = useRef(onBalancesChanged);
60
61
  const lastNotification = useRef(undefined);
61
62
  useEffect(() => {
62
63
  onCompleteRef.current = onComplete;
63
64
  onErrorRef.current = onError;
64
- }, [onComplete, onError]);
65
+ onBalancesChangedRef.current = onBalancesChanged;
66
+ }, [onBalancesChanged, onComplete, onError]);
65
67
  useEffect(() => {
66
68
  if (stage.kind === "idle") {
67
69
  lastNotification.current = undefined;
68
70
  return;
69
71
  }
70
72
  if (stage.kind === "done") {
71
- const key = `complete:${stage.orderId}:${stage.txHash}`;
73
+ const transactionHash = stage.destinationTxHash ?? stage.sourceTxHash;
74
+ if (!transactionHash)
75
+ return;
76
+ const key = `complete:${stage.orderId}:${transactionHash}`;
72
77
  if (lastNotification.current !== key) {
73
78
  lastNotification.current = key;
74
- onCompleteRef.current?.({ orderId: stage.orderId, transactionHash: stage.txHash });
79
+ onBalancesChangedRef.current();
80
+ onCompleteRef.current?.({ orderId: stage.orderId, transactionHash });
75
81
  }
76
82
  }
77
83
  else if (stage.kind === "error") {
@@ -131,12 +137,26 @@ function tokenUsdValue(token, amount) {
131
137
  return undefined;
132
138
  return token.currency.price_usd * numericAmount;
133
139
  }
134
- function CurrencyPanel({ side, token, amount, balance, quoteUsd, quoteLoading, disabled, onAmountChange, onSelectToken, onMax, footerAction, }) {
140
+ const QUICK_AMOUNTS = [
141
+ { label: "25%", fraction: 0.25 },
142
+ { label: "50%", fraction: 0.5 },
143
+ { label: "75%", fraction: 0.75 },
144
+ { label: "Max", fraction: 1 },
145
+ ];
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
+ export function CurrencyPanel({ side, token, amount, balance, quoteUsd, quoteLoading, disabled, onAmountChange, onSelectToken, footerAction, }) {
135
152
  const label = side === "input" ? "SELL" : "BUY";
136
- return (_jsxs(CurrencyCard, { children: [_jsx(CardTitle, { children: label }), _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
+ const showQuickAmounts = side === "input" && balance !== undefined && token !== undefined;
154
+ 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
155
+ ? `Use maximum ${token.currency.symbol} balance`
156
+ : `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) => {
137
157
  if (isValidSwapAmountInput(event.target.value))
138
158
  onAmountChange(event.target.value);
139
- } })), _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()] }), onMax && token && (_jsx(TextButton, { type: "button", "aria-label": `Use maximum ${token.currency.symbol} balance`, onClick: onMax, children: "Max" })), footerAction] })] })] }));
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] })] })] }));
140
160
  }
141
161
  function ManualWalletModal({ open, chainId, initialValue, onClose, onSave, }) {
142
162
  const [value, setValue] = useState(initialValue ?? "");
@@ -156,7 +176,7 @@ export function Swap({ defaultSourceCurrency, defaultDestinationCurrency, defaul
156
176
  const { mode, theme } = useSwapKit();
157
177
  const slippageInputId = useId();
158
178
  const slippageTooltipId = useId();
159
- const { tokens, accounts, isLoading, error: tokenError } = useTokenData();
179
+ const { tokens, accounts, isLoading, error: tokenError, refreshBalances } = useTokenData();
160
180
  const { accounts: connectedAccounts } = useAccount();
161
181
  const [sourcePickerOpen, setSourcePickerOpen] = useState(false);
162
182
  const [destinationPickerOpen, setDestinationPickerOpen] = useState(false);
@@ -223,9 +243,16 @@ export function Swap({ defaultSourceCurrency, defaultDestinationCurrency, defaul
223
243
  slippageBps,
224
244
  sourceToken?.currency,
225
245
  ]);
226
- const execution = useSwapExecution(sourceChainType);
246
+ const execution = useSwapExecution(sourceChainType, inputAccount?.address);
227
247
  const quoteState = useSwapQuote(quoteRequest, execution.stage.kind === "idle");
228
- useSwapNotifications({ stage: execution.stage, quoteError: quoteState.error, tokenError, onComplete, onError });
248
+ useSwapNotifications({
249
+ stage: execution.stage,
250
+ quoteError: quoteState.error,
251
+ tokenError,
252
+ onBalancesChanged: refreshBalances,
253
+ onComplete,
254
+ onError,
255
+ });
229
256
  const displayedInputAmount = activeSide === "output" && quoteState.quote ? quoteState.quote.input.amount.ui : inputAmount;
230
257
  const displayedOutputAmount = activeSide === "input" && quoteState.quote ? quoteState.quote.output.amount.ui : outputAmount;
231
258
  const sourceBalance = sourceToken?.balance;
@@ -271,11 +298,7 @@ export function Swap({ defaultSourceCurrency, defaultDestinationCurrency, defaul
271
298
  } }), _jsx("span", { children: "%" })] })] })] }), slippageMode === "custom" &&
272
299
  (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) => {
273
300
  swapState.setAmount("input", value);
274
- }, onSelectToken: () => setSourcePickerOpen(true), onMax: sourceBalance === undefined
275
- ? undefined
276
- : () => {
277
- swapState.setAmount("input", sourceBalance.toString());
278
- } }), _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) => {
301
+ }, 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) => {
279
302
  swapState.setAmount("output", value);
280
303
  }, onSelectToken: () => setDestinationPickerOpen(true), footerAction: showOutputWalletSelector ? (_jsx(OutputWalletSelector, { label: outputAddress ? truncateAddress(outputAddress) : "Connect Wallet", open: outputWalletMenuOpen, onOpenChange: setOutputWalletMenuOpen, onConnect: () => {
281
304
  setOutputWalletMenuOpen(false);
@@ -283,13 +306,13 @@ export function Swap({ defaultSourceCurrency, defaultDestinationCurrency, defaul
283
306
  }, onPaste: () => {
284
307
  setOutputWalletMenuOpen(false);
285
308
  setManualWalletOpen(true);
286
- } })) : undefined }), quoteState.error && _jsx(ErrorBox, { children: quoteState.error }), tokenError && _jsx(ErrorBox, { children: tokenError }), insufficientBalance && _jsxs(ErrorBox, { children: ["Insufficient ", sourceToken?.currency.symbol, " balance"] }), sameSwap && _jsx(ErrorBox, { children: "Select a different token or destination wallet" }), (quoteState.loading || quoteState.quote) && (_jsxs(BreakdownCard, { children: [_jsxs(BreakdownToggle, { type: "button", "aria-expanded": breakdownOpen, onClick: () => setBreakdownOpen((open) => !open), children: [_jsx("span", { children: quoteState.loading ? "Finding the best route…" : quoteSummary }), _jsxs(BreakdownMeta, { children: [quoteState.quote && (_jsxs(NetworkCost, { children: [_jsx(FuelIcon, {}), _jsx("span", { children: formatUsd(quoteState.quote.input.breakdown?.gas?.value_usd) })] })), _jsx(ChevronDownIcon, { size: 16, up: breakdownOpen })] })] }), breakdownOpen && (_jsxs(BreakdownBody, { children: [quoteState.quote?.output.amount_out_minimum && (_jsxs(DetailRow, { children: [_jsx("span", { children: "Receive at least" }), _jsxs("strong", { children: [quoteState.quote.output.amount_out_minimum.ui, " ", quoteState.quote.output.currency.symbol] })] })), _jsxs(DetailRow, { children: [_jsx("span", { children: "Price impact" }), _jsx("strong", { children: quoteState.quote ? formatPriceImpact(quoteState.quote.price_impact) : "—" })] }), _jsxs(DetailRow, { children: [_jsx("span", { children: "Network cost" }), _jsx("strong", { children: formatUsd(quoteState.quote?.input.breakdown?.gas?.value_usd) })] }), _jsxs(DetailRow, { children: [_jsx("span", { children: "Total fees" }), quoteState.quote?.input.breakdown?.fees?.total_fee.value_usd === 0 ? (_jsx(FreeFeeBadge, { children: "Free" })) : (_jsx("strong", { children: formatUsd(quoteState.quote?.input.breakdown?.fees?.total_fee.value_usd) }))] })] }))] })), walletRequirement.target !== "none" ? (_jsx(WalletRequirementButton, { type: "button", onClick: () => setConnectTarget({ open: true, chainType: walletRequirement.chainType }), children: walletRequirement.label })) : (_jsxs(PrimaryButton, { type: "button", disabled: !canExecute || quoteState.loading, onClick: () => void executeSwap(), children: [quoteState.loading ? _jsx(Spinner, {}) : null, isLoading ? "Loading tokens…" : quoteState.loading ? "Fetching quote…" : "Swap"] })), _jsx(TokenPicker, { open: sourcePickerOpen, title: "Select Token", tokens: tokens, selectedId: resolvedSourceId, onClose: () => setSourcePickerOpen(false), onSelect: (token) => selectToken("input", token) }), _jsx(TokenPicker, { open: destinationPickerOpen, title: "Select Token", tokens: tokens, selectedId: resolvedDestinationId, onClose: () => setDestinationPickerOpen(false), onSelect: (token) => selectToken("output", token) }), _jsx(WalletConnector, { open: connectTarget.open, chainType: connectTarget.chainType, onClose: () => setConnectTarget({ open: false }) }), manualWalletOpen && (_jsx(ManualWalletModal, { open: true, chainId: destinationToken?.currency.chain_id, initialValue: manualAddress, onClose: () => setManualWalletOpen(false), onSave: (address) => {
309
+ } })) : undefined }), quoteState.error && _jsx(ErrorBox, { children: quoteState.error }), tokenError && _jsx(ErrorBox, { children: tokenError }), insufficientBalance && _jsxs(ErrorBox, { children: ["Insufficient ", sourceToken?.currency.symbol, " balance"] }), sameSwap && _jsx(ErrorBox, { children: "Select a different token or destination wallet" }), (quoteState.loading || quoteState.quote) && (_jsxs(BreakdownCard, { children: [_jsxs(BreakdownToggle, { type: "button", "aria-expanded": breakdownOpen, onClick: () => setBreakdownOpen((open) => !open), children: [_jsx("span", { children: quoteState.loading ? "Finding the best route…" : quoteSummary }), _jsxs(BreakdownMeta, { children: [quoteState.quote && (_jsxs(NetworkCost, { children: [_jsx(FuelIcon, {}), _jsx("span", { children: formatUsd(quoteState.quote.input.breakdown?.gas?.value_usd) })] })), _jsx(ChevronDownIcon, { size: 16, up: breakdownOpen })] })] }), breakdownOpen && (_jsxs(BreakdownBody, { children: [quoteState.quote?.output.amount_out_minimum && (_jsxs(DetailRow, { children: [_jsx("span", { children: "Receive at least" }), _jsxs("strong", { children: [quoteState.quote.output.amount_out_minimum.ui, " ", quoteState.quote.output.currency.symbol] })] })), _jsxs(DetailRow, { children: [_jsx("span", { children: "Price impact" }), _jsx("strong", { children: quoteState.quote ? formatPriceImpact(quoteState.quote.price_impact) : "—" })] }), _jsxs(DetailRow, { children: [_jsx("span", { children: "Network cost" }), _jsx("strong", { children: formatUsd(quoteState.quote?.input.breakdown?.gas?.value_usd) })] }), _jsxs(DetailRow, { children: [_jsx("span", { children: "Total fees" }), quoteState.quote?.input.breakdown?.fees?.total_fee.value_usd === 0 ? (_jsx(FreeFeeBadge, { children: "Free" })) : (_jsx("strong", { children: formatUsd(quoteState.quote?.input.breakdown?.fees?.total_fee.value_usd) }))] })] }))] })), walletRequirement.target !== "none" ? (_jsx(WalletRequirementButton, { type: "button", onClick: () => setConnectTarget({ open: true, chainType: walletRequirement.chainType }), children: walletRequirement.label })) : (_jsxs(PrimaryButton, { type: "button", disabled: !canExecute || quoteState.loading, onClick: () => void executeSwap(), children: [quoteState.loading ? _jsx(Spinner, {}) : null, isLoading ? "Loading tokens…" : quoteState.loading ? "Fetching quote…" : "Swap"] })), _jsx(TokenPicker, { open: sourcePickerOpen, title: "Select Token", tokens: tokens, selectedId: resolvedSourceId, onClose: () => setSourcePickerOpen(false), onSelect: (token) => selectToken("input", token) }), _jsx(TokenPicker, { open: destinationPickerOpen, title: "Select Token", tokens: tokens, selectedId: resolvedDestinationId, preferredChainId: sourceToken?.currency.chain_id, onClose: () => setDestinationPickerOpen(false), onSelect: (token) => selectToken("output", token) }), _jsx(WalletConnector, { open: connectTarget.open, chainType: connectTarget.chainType, onClose: () => setConnectTarget({ open: false }) }), manualWalletOpen && (_jsx(ManualWalletModal, { open: true, chainId: destinationToken?.currency.chain_id, initialValue: manualAddress, onClose: () => setManualWalletOpen(false), onSave: (address) => {
287
310
  if (destinationToken)
288
311
  swapState.setManualOutputWallet({ chainId: destinationToken.currency.chain_id, address });
289
312
  } })), _jsx(ExecutionModal, { stage: execution.stage, onClose: () => {
290
313
  const terminal = ["done", "refunded"].includes(execution.stage.kind);
291
314
  execution.reset();
292
315
  if (terminal)
293
- swapState.reset();
316
+ swapState.reset({ preserveCurrencies: execution.stage.kind === "done" });
294
317
  } })] }) }));
295
318
  }
@@ -1,11 +1,13 @@
1
+ import { ChainId } from "@coin-voyage/shared/types";
1
2
  import type { SwapToken } from "../hooks/use-token-data";
2
3
  interface TokenPickerProps {
3
4
  open: boolean;
4
5
  title: string;
5
6
  tokens: SwapToken[];
6
7
  selectedId?: string;
8
+ preferredChainId?: ChainId;
7
9
  onClose: () => void;
8
10
  onSelect: (token: SwapToken) => void;
9
11
  }
10
- export declare function TokenPicker({ open, title, tokens, selectedId, onClose, onSelect }: TokenPickerProps): import("react/jsx-runtime").JSX.Element;
12
+ export declare function TokenPicker({ open, title, tokens, selectedId, preferredChainId, onClose, onSelect, }: TokenPickerProps): import("react/jsx-runtime").JSX.Element;
11
13
  export {};