@coin-voyage/swapkit 0.1.0-beta.1 → 0.2.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.
@@ -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>;
@@ -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;
@@ -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);
@@ -290,6 +313,6 @@ export function Swap({ defaultSourceCurrency, defaultDestinationCurrency, defaul
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,6 +1,6 @@
1
1
  import type { ChainType, SwapExecuteRequest } from "@coin-voyage/shared/types";
2
2
  import { type SwapStage } from "../lib/execution";
3
- export declare function useSwapExecution(chainType: ChainType | undefined): {
3
+ export declare function useSwapExecution(chainType: ChainType | undefined, currentFromAddress: string | undefined): {
4
4
  stage: SwapStage;
5
5
  execute: (request: SwapExecuteRequest, fromAddress: string) => Promise<void>;
6
6
  reset: () => void;
@@ -1,34 +1,51 @@
1
1
  import { usePrepareTransaction } from "@coin-voyage/crypto/hooks";
2
+ import { assertActiveWallet, getErrorMessage, isUserRejectedError, prepareForActiveWallet, } from "@coin-voyage/crypto/utils";
2
3
  import { WebhookEventType } from "@coin-voyage/shared/types";
3
- import { useCallback, useEffect, useRef, useState } from "react";
4
+ import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
4
5
  import { buildSwapExecuteTransactions } from "../lib/execution";
5
6
  import { useSwapKit } from "../providers/swapkit-context";
6
7
  import { useOrderStatus } from "./use-order-status";
7
8
  function errorMessage(error, fallback) {
8
- const message = error instanceof Error ? error.message.trim() : "";
9
+ const message = getErrorMessage(error).trim();
9
10
  return message || fallback;
10
11
  }
11
- function isRejected(error) {
12
- const message = error instanceof Error ? error.message.toLowerCase() : "";
13
- return message.includes("user rejected") || message.includes("rejected the request");
12
+ function watchedOrderId(stage) {
13
+ if (stage.kind === "awaiting_payment" || stage.kind === "source_confirming" || stage.kind === "executing") {
14
+ return stage.orderId;
15
+ }
16
+ return undefined;
14
17
  }
15
- export function useSwapExecution(chainType) {
18
+ export function useSwapExecution(chainType, currentFromAddress) {
16
19
  const { api } = useSwapKit();
17
20
  const transactionActions = usePrepareTransaction(chainType);
18
- const [stage, setStage] = useState({ kind: "idle" });
19
- const [orderId, setOrderId] = useState();
20
- const stageRef = useRef(stage);
21
+ const [stage, setStageState] = useState({ kind: "idle" });
22
+ const stageRef = useRef({ kind: "idle" });
23
+ const currentWalletRef = useRef({ address: currentFromAddress, chainType });
24
+ const transactionActionsRef = useRef(transactionActions);
21
25
  const runId = useRef(0);
22
- useEffect(() => {
23
- stageRef.current = stage;
24
- }, [stage]);
26
+ const setStage = useCallback((next) => {
27
+ stageRef.current = next;
28
+ setStageState(next);
29
+ }, []);
30
+ useLayoutEffect(() => {
31
+ const chainChanged = currentWalletRef.current?.chainType !== chainType;
32
+ currentWalletRef.current = { address: currentFromAddress, chainType };
33
+ transactionActionsRef.current = transactionActions;
34
+ if (chainChanged) {
35
+ runId.current += 1;
36
+ if (stageRef.current.kind !== "idle")
37
+ setStage({ kind: "idle" });
38
+ }
39
+ }, [chainType, currentFromAddress, setStage, transactionActions]);
25
40
  const reset = useCallback(() => {
26
41
  runId.current += 1;
27
- setOrderId(undefined);
28
- setStage({ kind: "idle" });
29
- }, []);
42
+ if (stageRef.current.kind !== "idle")
43
+ setStage({ kind: "idle" });
44
+ }, [setStage]);
30
45
  const onOrderEvent = useCallback((event) => {
31
46
  const current = stageRef.current;
47
+ if (current.kind === "done" || current.kind === "refunded" || current.kind === "error")
48
+ return;
32
49
  if (!("swapData" in current) || !current.swapData || event.order.id !== current.swapData.order_id)
33
50
  return;
34
51
  const swapData = event.order.payment
@@ -36,55 +53,71 @@ export function useSwapExecution(chainType) {
36
53
  : { ...current.swapData, status: event.order.status };
37
54
  const sourceTxHash = event.order.payment?.source_tx_hash ?? ("sourceTxHash" in current ? current.sourceTxHash : undefined);
38
55
  if (event.event === WebhookEventType.ORDER_COMPLETED) {
39
- const txHash = event.order.payment?.destination_tx_hash ?? sourceTxHash;
40
- if (txHash)
41
- setStage({ kind: "done", swapData, orderId: event.order.id, sourceTxHash, txHash });
42
- setOrderId(undefined);
56
+ const destinationTxHash = event.order.payment?.destination_tx_hash ?? undefined;
57
+ runId.current += 1;
58
+ if (destinationTxHash) {
59
+ setStage({ kind: "done", swapData, orderId: event.order.id, sourceTxHash, destinationTxHash });
60
+ }
61
+ else if (sourceTxHash) {
62
+ setStage({ kind: "done", swapData, orderId: event.order.id, sourceTxHash });
63
+ }
64
+ else {
65
+ setStage({ kind: "error", swapData, message: "Swap completed without a transaction hash" });
66
+ }
43
67
  }
44
- else if (event.event === WebhookEventType.ORDER_REFUNDED && event.order.payment?.refund_tx_hash) {
68
+ else if (event.event === WebhookEventType.ORDER_REFUNDED) {
69
+ const refundTxHash = event.order.payment?.refund_tx_hash ?? undefined;
70
+ runId.current += 1;
45
71
  setStage({
46
72
  kind: "refunded",
47
73
  swapData,
48
74
  orderId: event.order.id,
49
75
  sourceTxHash,
50
- refundTxHash: event.order.payment.refund_tx_hash,
76
+ ...(refundTxHash ? { refundTxHash } : {}),
51
77
  });
52
- setOrderId(undefined);
53
78
  }
54
79
  else if (event.event === WebhookEventType.ORDER_ERROR) {
80
+ runId.current += 1;
55
81
  setStage({ kind: "error", swapData, message: "Swap failed" });
56
- setOrderId(undefined);
57
82
  }
58
83
  else if (event.event === WebhookEventType.ORDER_EXECUTING) {
59
84
  setStage({ kind: "executing", swapData, orderId: event.order.id, sourceTxHash });
60
85
  }
61
86
  else if (event.event === WebhookEventType.ORDER_CONFIRMING ||
62
87
  event.event === WebhookEventType.ORDER_PARTIAL_PAYMENT) {
88
+ if (current.kind === "executing")
89
+ return;
63
90
  setStage({ kind: "source_confirming", swapData, orderId: event.order.id, sourceTxHash });
64
91
  }
65
- }, []);
66
- useOrderStatus({ api, orderId, onEvent: onOrderEvent });
92
+ }, [setStage]);
93
+ useOrderStatus({ api, orderId: watchedOrderId(stage), onEvent: onOrderEvent });
67
94
  const execute = useCallback(async (request, fromAddress) => {
68
- if (!transactionActions)
95
+ if (!transactionActionsRef.current || !chainType)
69
96
  return;
70
97
  const currentRun = ++runId.current;
71
98
  let swapData = null;
72
99
  setStage({ kind: "confirming" });
73
100
  try {
74
- const response = await api.swapExecute(request);
101
+ const { wallet, value: response } = await prepareForActiveWallet({ address: fromAddress, chainType }, () => currentWalletRef.current, ({ address }) => api.swapExecute({
102
+ ...request,
103
+ source: { ...request.source, sender_address: address },
104
+ }));
75
105
  if (currentRun !== runId.current)
76
106
  return;
77
107
  if (response.error || !response.data) {
78
108
  setStage({ kind: "error", swapData: null, message: response.error?.message || "Failed to prepare swap" });
79
109
  return;
80
110
  }
111
+ const paymentTransactionActions = transactionActionsRef.current;
112
+ if (!paymentTransactionActions)
113
+ throw new Error("Transaction actions must be defined");
81
114
  swapData = response.data;
82
- setOrderId(swapData.order_id);
83
- const transactions = buildSwapExecuteTransactions(swapData, fromAddress);
115
+ const transactions = buildSwapExecuteTransactions(swapData, wallet.address);
84
116
  let sourceTxHash;
85
117
  for (const [paymentStepIndex, transaction] of transactions.entries()) {
86
- setStage({ kind: "awaiting_payment", swapData, paymentStepIndex });
87
- sourceTxHash = await transactionActions.execute(transaction);
118
+ setStage({ kind: "awaiting_payment", swapData, orderId: swapData.order_id, paymentStepIndex });
119
+ assertActiveWallet(wallet, currentWalletRef.current);
120
+ sourceTxHash = await paymentTransactionActions.execute(transaction);
88
121
  if (currentRun !== runId.current)
89
122
  return;
90
123
  if (!sourceTxHash)
@@ -93,13 +126,14 @@ export function useSwapExecution(chainType) {
93
126
  setStage({ kind: "source_confirming", swapData, orderId: swapData.order_id, sourceTxHash });
94
127
  }
95
128
  catch (error) {
96
- if (isRejected(error)) {
129
+ if (currentRun !== runId.current)
130
+ return;
131
+ if (isUserRejectedError(error)) {
97
132
  reset();
98
133
  return;
99
134
  }
100
- setOrderId(undefined);
101
135
  setStage({ kind: "error", swapData, message: errorMessage(error, "Transaction failed") });
102
136
  }
103
- }, [api, reset, transactionActions]);
104
- return { stage, execute, reset };
137
+ }, [api, chainType, reset, setStage]);
138
+ return useMemo(() => ({ stage, execute, reset }), [execute, reset, stage]);
105
139
  }
@@ -47,6 +47,7 @@ export type SwapStateAction = {
47
47
  wallet?: ManualOutputWallet;
48
48
  } | {
49
49
  type: "reset";
50
+ preserveCurrencies?: boolean;
50
51
  };
51
52
  export declare function createSwapState(defaultSlippageBps?: number): SwapState;
52
53
  export declare function swapStateReducer(state: SwapState, action: SwapStateAction): SwapState;
@@ -65,6 +66,8 @@ export declare function useSwapState({ currencies, connectedChainTypes, defaultS
65
66
  setSlippageMode: (mode: SlippageMode) => void;
66
67
  setSlippagePercent: (value: string) => void;
67
68
  setManualOutputWallet: (wallet?: ManualOutputWallet) => void;
68
- reset: () => void;
69
+ reset: (options?: {
70
+ preserveCurrencies?: boolean;
71
+ }) => void;
69
72
  };
70
73
  export {};
@@ -72,8 +72,8 @@ export function swapStateReducer(state, action) {
72
72
  case "reset":
73
73
  return {
74
74
  ...state,
75
- sourceId: state.initialSourceId,
76
- destinationId: state.initialDestinationId,
75
+ sourceId: action.preserveCurrencies ? state.sourceId : state.initialSourceId,
76
+ destinationId: action.preserveCurrencies ? state.destinationId : state.initialDestinationId,
77
77
  inputAmount: "",
78
78
  outputAmount: "",
79
79
  activeSide: "input",
@@ -122,7 +122,7 @@ export function useSwapState({ currencies, connectedChainTypes, defaultSourceCur
122
122
  const setSlippagePercent = useCallback((value) => dispatch({ type: "set_slippage", value }), []);
123
123
  const setSlippageMode = useCallback((mode) => dispatch({ type: "set_slippage_mode", mode }), []);
124
124
  const setManualOutputWallet = useCallback((wallet) => dispatch({ type: "set_manual_output_wallet", wallet }), []);
125
- const reset = useCallback(() => dispatch({ type: "reset" }), []);
125
+ const reset = useCallback((options) => dispatch({ type: "reset", ...options }), []);
126
126
  const resolvedState = !state.currenciesInitialized && currencies.length > 0
127
127
  ? swapStateReducer(state, {
128
128
  type: "initialize_currencies",
@@ -9,4 +9,5 @@ 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
13
  };
@@ -1,7 +1,7 @@
1
1
  import { useAccount } from "@coin-voyage/crypto/hooks";
2
2
  import { fetchTokenList, tokenToCurrency } from "@coin-voyage/shared/currency";
3
3
  import { currencyID } from "@coin-voyage/shared/types";
4
- import { useEffect, useState } from "react";
4
+ import { useEffect, useReducer, useState } from "react";
5
5
  import { useSwapKit } from "../providers/swapkit-context";
6
6
  function mergeTokens(tokenList, portfolio) {
7
7
  const portfolioById = new Map(portfolio.map((token) => [currencyID(token.currency), token]));
@@ -21,6 +21,7 @@ export function useTokenData() {
21
21
  const [isLoading, setIsLoading] = useState(true);
22
22
  const [tokenError, setTokenError] = useState(null);
23
23
  const [portfolioError, setPortfolioError] = useState(null);
24
+ const [portfolioRevision, refreshBalances] = useReducer((revision) => revision + 1, 0);
24
25
  useEffect(() => {
25
26
  let active = true;
26
27
  fetchTokenList()
@@ -73,8 +74,8 @@ export function useTokenData() {
73
74
  }
74
75
  });
75
76
  return () => controller.abort();
76
- }, [api, walletKey]);
77
+ }, [api, portfolioRevision, walletKey]);
77
78
  const activePortfolioTokens = portfolioState.walletKey === walletKey ? portfolioState.tokens : [];
78
79
  const tokens = mergeTokens(listedCurrencies, activePortfolioTokens);
79
- return { tokens, accounts, isLoading, error: tokenError ?? portfolioError };
80
+ return { tokens, accounts, isLoading, error: tokenError ?? portfolioError, refreshBalances };
80
81
  }
@@ -1,10 +1,9 @@
1
+ import { addressesMatch } from "@coin-voyage/crypto/utils";
1
2
  import { getChainTypeByChainId } from "@coin-voyage/shared/chain";
2
3
  import { currencyID } from "@coin-voyage/shared/types";
3
- function normalizedAddress(currency) {
4
- return currency.address?.toLowerCase() ?? null;
5
- }
6
4
  export function currenciesMatch(left, right) {
7
- return left.chain_id === right.chain_id && normalizedAddress(left) === normalizedAddress(right);
5
+ return (left.chain_id === right.chain_id &&
6
+ addressesMatch(left.address, right.address, getChainTypeByChainId(left.chain_id)));
8
7
  }
9
8
  export function resolveCurrencySelection({ currencies, connectedChainTypes, defaultSourceCurrency, defaultDestinationCurrency, }) {
10
9
  const preferredSource = defaultSourceCurrency
@@ -1,5 +1,12 @@
1
1
  import type { ExecuteTransaction } from "@coin-voyage/crypto/types";
2
2
  import type { SwapExecuteResponse } from "@coin-voyage/shared/types";
3
+ type CompletedTransactionHashes = {
4
+ sourceTxHash: string;
5
+ destinationTxHash?: string;
6
+ } | {
7
+ sourceTxHash?: string;
8
+ destinationTxHash: string;
9
+ };
3
10
  export type SwapStage = {
4
11
  kind: "idle";
5
12
  } | {
@@ -7,6 +14,7 @@ export type SwapStage = {
7
14
  } | {
8
15
  kind: "awaiting_payment";
9
16
  swapData: SwapExecuteResponse;
17
+ orderId: string;
10
18
  paymentStepIndex: number;
11
19
  } | {
12
20
  kind: "source_confirming";
@@ -18,21 +26,20 @@ export type SwapStage = {
18
26
  swapData: SwapExecuteResponse;
19
27
  orderId: string;
20
28
  sourceTxHash?: string;
21
- } | {
29
+ } | ({
22
30
  kind: "done";
23
31
  swapData: SwapExecuteResponse;
24
32
  orderId: string;
25
- sourceTxHash?: string;
26
- txHash: string;
27
- } | {
33
+ } & CompletedTransactionHashes) | {
28
34
  kind: "refunded";
29
35
  swapData: SwapExecuteResponse;
30
36
  orderId: string;
31
37
  sourceTxHash?: string;
32
- refundTxHash: string;
38
+ refundTxHash?: string;
33
39
  } | {
34
40
  kind: "error";
35
41
  swapData: SwapExecuteResponse | null;
36
42
  message: string;
37
43
  };
38
44
  export declare function buildSwapExecuteTransactions(swapData: SwapExecuteResponse, fromAddress: string): ExecuteTransaction[];
45
+ export {};
@@ -1,4 +1,4 @@
1
- import { type CurrencyBase, type SwapQuoteRequest } from "@coin-voyage/shared/types";
1
+ import type { CurrencyBase, SwapQuoteRequest } from "@coin-voyage/shared/types";
2
2
  export type SwapSide = "input" | "output";
3
3
  export declare function toCurrencyBase(currency: CurrencyBase): CurrencyBase;
4
4
  interface BuildSwapQuoteRequestProps {
package/dist/lib/quote.js CHANGED
@@ -1,4 +1,6 @@
1
- import { currencyID } from "@coin-voyage/shared/types";
1
+ import { addressesMatch } from "@coin-voyage/crypto/utils";
2
+ import { getChainTypeByChainId } from "@coin-voyage/shared/chain";
3
+ import { currenciesMatch } from "./currency-selection";
2
4
  export function toCurrencyBase(currency) {
3
5
  return currency.address ? { chain_id: currency.chain_id, address: currency.address } : { chain_id: currency.chain_id };
4
6
  }
@@ -15,10 +17,8 @@ export function buildSwapQuoteRequest({ amount, activeSide, source, destination,
15
17
  };
16
18
  }
17
19
  export function isSameSwap(source, destination, inputWalletAddress, outputWalletAddress) {
18
- return Boolean(source &&
19
- destination &&
20
- currencyID(source) === currencyID(destination) &&
21
- inputWalletAddress &&
22
- outputWalletAddress &&
23
- inputWalletAddress.toLowerCase() === outputWalletAddress.toLowerCase());
20
+ if (!source || !destination || !currenciesMatch(source, destination))
21
+ return false;
22
+ const chainType = getChainTypeByChainId(source.chain_id);
23
+ return Boolean(inputWalletAddress && outputWalletAddress && addressesMatch(inputWalletAddress, outputWalletAddress, chainType));
24
24
  }
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.1.0-beta.1",
4
+ "version": "0.2.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/shared": "3.4.1-beta.0",
26
- "@coin-voyage/crypto": "3.3.0"
25
+ "@coin-voyage/crypto": "3.4.0",
26
+ "@coin-voyage/shared": "3.4.1-beta.1"
27
27
  },
28
28
  "devDependencies": {
29
29
  "@testing-library/react": "^16.3.0",