@coin-voyage/swapkit 0.4.0-beta.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +8 -8
- package/README.md +68 -68
- package/dist/components/currency-panel.d.ts +17 -0
- package/dist/components/currency-panel.js +22 -0
- package/dist/components/execution-modal.d.ts +1 -1
- package/dist/components/execution-modal.js +33 -6
- package/dist/components/execution-modal.test.d.ts +1 -0
- package/dist/components/execution-modal.test.js +92 -0
- package/dist/components/manual-wallet-modal.d.ts +8 -0
- package/dist/components/manual-wallet-modal.js +19 -0
- package/dist/components/output-wallet-selector.d.ts +7 -0
- package/dist/components/output-wallet-selector.js +10 -0
- package/dist/components/quote-breakdown.d.ts +5 -0
- package/dist/components/quote-breakdown.js +13 -0
- package/dist/components/styles.d.ts +2 -1
- package/dist/components/styles.js +1294 -1281
- package/dist/components/swap-recipient.test.js +61 -2
- package/dist/components/swap-settings.d.ts +9 -0
- package/dist/components/swap-settings.js +24 -0
- package/dist/components/swap.d.ts +2 -28
- package/dist/components/swap.js +27 -301
- package/dist/components/token-picker.test.js +11 -0
- package/dist/hooks/use-dismiss.d.ts +3 -0
- package/dist/hooks/use-dismiss.js +26 -0
- package/dist/hooks/use-swap-notifications.d.ts +10 -0
- package/dist/hooks/use-swap-notifications.js +53 -0
- package/dist/hooks/use-swap-quote.js +49 -51
- package/dist/hooks/use-swap.d.ts +47 -0
- package/dist/hooks/use-swap.js +189 -0
- package/dist/hooks/use-token-data.d.ts +2 -1
- package/dist/hooks/use-token-data.js +42 -13
- package/dist/hooks/use-token-data.test.js +78 -3
- package/dist/lib/currency-selection.js +6 -4
- package/dist/lib/currency-selection.test.d.ts +1 -0
- package/dist/lib/currency-selection.test.js +16 -0
- package/dist/lib/execution.d.ts +2 -0
- package/dist/lib/execution.js +6 -0
- package/dist/lib/execution.test.d.ts +1 -0
- package/dist/lib/execution.test.js +24 -0
- package/dist/lib/format.d.ts +5 -0
- package/dist/lib/format.js +20 -0
- package/dist/lib/quote.d.ts +10 -1
- package/dist/lib/quote.js +12 -0
- package/dist/lib/slippage.d.ts +11 -0
- package/dist/lib/slippage.js +17 -0
- package/dist/styles/root.js +59 -59
- package/package.json +4 -4
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
export function useSwapNotifications({ stage, quoteError, tokenError, onBalancesChanged, onComplete, onError, }) {
|
|
3
|
+
const onCompleteRef = useRef(onComplete);
|
|
4
|
+
const onErrorRef = useRef(onError);
|
|
5
|
+
const onBalancesChangedRef = useRef(onBalancesChanged);
|
|
6
|
+
const lastNotification = useRef(undefined);
|
|
7
|
+
useEffect(() => {
|
|
8
|
+
onCompleteRef.current = onComplete;
|
|
9
|
+
onErrorRef.current = onError;
|
|
10
|
+
onBalancesChangedRef.current = onBalancesChanged;
|
|
11
|
+
}, [onBalancesChanged, onComplete, onError]);
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
if (stage.kind === "idle") {
|
|
14
|
+
lastNotification.current = undefined;
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
if (stage.kind === "done") {
|
|
18
|
+
const transactionHash = stage.destinationTxHash ?? stage.sourceTxHash;
|
|
19
|
+
if (!transactionHash)
|
|
20
|
+
return;
|
|
21
|
+
const key = `complete:${stage.orderId}:${transactionHash}`;
|
|
22
|
+
if (lastNotification.current !== key) {
|
|
23
|
+
lastNotification.current = key;
|
|
24
|
+
onBalancesChangedRef.current();
|
|
25
|
+
onCompleteRef.current?.({ orderId: stage.orderId, transactionHash });
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
else if (stage.kind === "refunded") {
|
|
29
|
+
const key = `refunded:${stage.orderId}`;
|
|
30
|
+
if (lastNotification.current !== key) {
|
|
31
|
+
lastNotification.current = key;
|
|
32
|
+
onBalancesChangedRef.current();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
else if (stage.kind === "error") {
|
|
36
|
+
const key = `error:${stage.message}`;
|
|
37
|
+
if (lastNotification.current !== key) {
|
|
38
|
+
lastNotification.current = key;
|
|
39
|
+
onErrorRef.current?.({ message: stage.message });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}, [stage]);
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
const message = quoteError ?? tokenError;
|
|
45
|
+
if (!message)
|
|
46
|
+
return;
|
|
47
|
+
const key = `error:${message}`;
|
|
48
|
+
if (lastNotification.current !== key) {
|
|
49
|
+
lastNotification.current = key;
|
|
50
|
+
onErrorRef.current?.({ message });
|
|
51
|
+
}
|
|
52
|
+
}, [quoteError, tokenError]);
|
|
53
|
+
}
|
|
@@ -1,63 +1,61 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { hashKey, useQuery } from "@tanstack/react-query";
|
|
2
2
|
import { useSwapKit } from "../providers/swapkit-context";
|
|
3
3
|
const QUOTE_DEBOUNCE_MS = 400;
|
|
4
4
|
const QUOTE_REFRESH_MS = 10000;
|
|
5
|
+
// Scope cache entries by client identity without exposing API keys or session details.
|
|
6
|
+
const clientScopes = new WeakMap();
|
|
7
|
+
let nextClientScope = 0;
|
|
8
|
+
function getClientScope(api) {
|
|
9
|
+
let scope = clientScopes.get(api);
|
|
10
|
+
if (scope === undefined) {
|
|
11
|
+
scope = nextClientScope++;
|
|
12
|
+
clientScopes.set(api, scope);
|
|
13
|
+
}
|
|
14
|
+
return scope;
|
|
15
|
+
}
|
|
16
|
+
const quoteKey = (scope, request, live) => ["swap-quote", scope, request, live];
|
|
17
|
+
function debounce(signal) {
|
|
18
|
+
return new Promise((resolve, reject) => {
|
|
19
|
+
const timeout = setTimeout(resolve, QUOTE_DEBOUNCE_MS);
|
|
20
|
+
signal.addEventListener("abort", () => {
|
|
21
|
+
clearTimeout(timeout);
|
|
22
|
+
reject(signal.reason);
|
|
23
|
+
}, { once: true });
|
|
24
|
+
});
|
|
25
|
+
}
|
|
5
26
|
export function useSwapQuote(request, enabled = true) {
|
|
6
27
|
const { api } = useSwapKit();
|
|
7
|
-
const
|
|
8
|
-
const
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
setLoading(true);
|
|
24
|
-
const controller = new AbortController();
|
|
25
|
-
let interval;
|
|
26
|
-
const load = async (initial) => {
|
|
28
|
+
const live = Boolean(request && enabled);
|
|
29
|
+
const scope = getClientScope(api);
|
|
30
|
+
const { data, isFetchedAfterMount } = useQuery({
|
|
31
|
+
queryKey: quoteKey(scope, request, live),
|
|
32
|
+
enabled: live,
|
|
33
|
+
gcTime: 0,
|
|
34
|
+
retry: false,
|
|
35
|
+
networkMode: "always",
|
|
36
|
+
refetchInterval: QUOTE_REFRESH_MS,
|
|
37
|
+
refetchIntervalInBackground: true,
|
|
38
|
+
refetchOnWindowFocus: false,
|
|
39
|
+
placeholderData: (previous, previousQuery) => !live && previousQuery?.queryHash === hashKey(quoteKey(scope, request, true)) ? previous : undefined,
|
|
40
|
+
queryFn: async ({ signal }) => {
|
|
41
|
+
if (!request)
|
|
42
|
+
return { quote: null, error: null };
|
|
43
|
+
await debounce(signal);
|
|
27
44
|
try {
|
|
28
|
-
const response = await api.swapQuote(request, { signal
|
|
29
|
-
if (requestId.current !== id)
|
|
30
|
-
return;
|
|
45
|
+
const response = await api.swapQuote(request, { signal });
|
|
31
46
|
if (response.error || !response.data?.quote) {
|
|
32
|
-
|
|
33
|
-
setError(response.error?.message || "No swap route is available");
|
|
34
|
-
}
|
|
35
|
-
else {
|
|
36
|
-
setQuote(response.data.quote);
|
|
37
|
-
setError(null);
|
|
47
|
+
return { quote: null, error: response.error?.message || "No swap route is available" };
|
|
38
48
|
}
|
|
49
|
+
return { quote: response.data.quote, error: null };
|
|
39
50
|
}
|
|
40
51
|
catch (requestError) {
|
|
41
|
-
if (
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
setError("Unable to fetch a swap quote");
|
|
45
|
-
}
|
|
46
|
-
finally {
|
|
47
|
-
if (initial && requestId.current === id)
|
|
48
|
-
setLoading(false);
|
|
52
|
+
if (signal.aborted)
|
|
53
|
+
throw requestError;
|
|
54
|
+
return { quote: null, error: "Unable to fetch a swap quote" };
|
|
49
55
|
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
return () => {
|
|
56
|
-
clearTimeout(timeout);
|
|
57
|
-
if (interval)
|
|
58
|
-
clearInterval(interval);
|
|
59
|
-
controller.abort();
|
|
60
|
-
};
|
|
61
|
-
}, [api, request, enabled]);
|
|
62
|
-
return { quote, loading, error };
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
// A re-enabled query may still be cached. Only expose it after this activation fetches.
|
|
59
|
+
const visibleData = live && !isFetchedAfterMount ? undefined : data;
|
|
60
|
+
return { quote: visibleData?.quote ?? null, loading: live && !visibleData, error: visibleData?.error ?? null };
|
|
63
61
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { type SwapSide } from "../lib/quote";
|
|
2
|
+
import type { SwapProps } from "../types";
|
|
3
|
+
import { type SwapToken } from "./use-token-data";
|
|
4
|
+
/** Wires token data, wallets, quoting and execution together into the view model rendered by `<Swap />`. */
|
|
5
|
+
export declare function useSwap({ defaultSourceCurrency, defaultDestinationCurrency, defaultSlippageBps, onComplete, onError, }: SwapProps): {
|
|
6
|
+
tokens: SwapToken[];
|
|
7
|
+
sourceTokens: SwapToken[];
|
|
8
|
+
tokensLoading: boolean;
|
|
9
|
+
tokenError: string | null;
|
|
10
|
+
sourceId: string | undefined;
|
|
11
|
+
destinationId: string | undefined;
|
|
12
|
+
sourceToken: SwapToken | undefined;
|
|
13
|
+
destinationToken: SwapToken | undefined;
|
|
14
|
+
activeSide: SwapSide;
|
|
15
|
+
displayedInputAmount: string;
|
|
16
|
+
displayedOutputAmount: string;
|
|
17
|
+
sourceBalance: string | undefined;
|
|
18
|
+
destinationBalance: string | undefined;
|
|
19
|
+
slippageMode: import("./use-swap-state").SlippageMode;
|
|
20
|
+
slippagePercent: string;
|
|
21
|
+
slippage: import("../lib/slippage").ResolvedSlippage;
|
|
22
|
+
destinationChainType: import("@coin-voyage/shared/types").ChainType | undefined;
|
|
23
|
+
destinationChainName: string | undefined;
|
|
24
|
+
destinationHasWallet: boolean;
|
|
25
|
+
separateOutputWallet: boolean;
|
|
26
|
+
manualAddress: string | undefined;
|
|
27
|
+
outputWalletLabel: string;
|
|
28
|
+
showOutputWalletSelector: boolean;
|
|
29
|
+
walletRequirement: import("../lib/output-wallet").SwapWalletRequirement;
|
|
30
|
+
requiresPastedAddress: boolean;
|
|
31
|
+
quote: import("@coin-voyage/shared/types").SwapQuote | null;
|
|
32
|
+
quoteLoading: boolean;
|
|
33
|
+
quoteError: string | null;
|
|
34
|
+
stage: import("../lib/execution").SwapStage;
|
|
35
|
+
idle: boolean;
|
|
36
|
+
insufficientBalance: boolean;
|
|
37
|
+
sameSwap: boolean;
|
|
38
|
+
canExecute: boolean;
|
|
39
|
+
setAmount: (side: SwapSide, value: string) => void;
|
|
40
|
+
setSlippageMode: (mode: import("./use-swap-state").SlippageMode) => void;
|
|
41
|
+
setSlippagePercent: (value: string) => void;
|
|
42
|
+
selectToken: (side: SwapSide, token: SwapToken) => void;
|
|
43
|
+
reverse: () => void;
|
|
44
|
+
saveManualAddress: (address: string) => void;
|
|
45
|
+
execute: () => Promise<void>;
|
|
46
|
+
closeExecution: () => void;
|
|
47
|
+
};
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { useAccount } from "@coin-voyage/crypto/hooks";
|
|
2
|
+
import { getChainName, getChainTypeByChainId, supportsWalletPayments } from "@coin-voyage/shared/chain";
|
|
3
|
+
import { formatCurrencyAmount } from "@coin-voyage/shared/currency";
|
|
4
|
+
import { currencyID } from "@coin-voyage/shared/types";
|
|
5
|
+
import { truncateAddress } from "@coin-voyage/shared/utils";
|
|
6
|
+
import { useCallback, useMemo } from "react";
|
|
7
|
+
import { getSwapWalletRequirement, shouldShowOutputWalletSelector, shouldUseSeparateOutputWallet, } from "../lib/output-wallet";
|
|
8
|
+
import { buildSwapExecuteRequest, buildSwapQuoteRequest, isSameSwap } from "../lib/quote";
|
|
9
|
+
import { resolveSlippage } from "../lib/slippage";
|
|
10
|
+
import { useSwapExecution } from "./use-swap-execution";
|
|
11
|
+
import { useSwapNotifications } from "./use-swap-notifications";
|
|
12
|
+
import { useSwapQuote } from "./use-swap-quote";
|
|
13
|
+
import { useSwapState } from "./use-swap-state";
|
|
14
|
+
import { useTokenData } from "./use-token-data";
|
|
15
|
+
/** Wires token data, wallets, quoting and execution together into the view model rendered by `<Swap />`. */
|
|
16
|
+
export function useSwap({ defaultSourceCurrency, defaultDestinationCurrency, defaultSlippageBps, onComplete, onError, }) {
|
|
17
|
+
const { tokens, accounts, isLoading, error: tokenError, balancesLoaded, refreshBalances } = useTokenData();
|
|
18
|
+
const { accounts: connectedAccounts } = useAccount();
|
|
19
|
+
const connectedChainTypes = new Set(accounts.map((account) => account.chainType));
|
|
20
|
+
const swapState = useSwapState({
|
|
21
|
+
currencies: tokens.map((token) => token.currency),
|
|
22
|
+
connectedChainTypes,
|
|
23
|
+
defaultSourceCurrency,
|
|
24
|
+
defaultDestinationCurrency,
|
|
25
|
+
defaultSlippageBps,
|
|
26
|
+
});
|
|
27
|
+
const { sourceId, destinationId, inputAmount, outputAmount, activeSide, slippageMode, slippagePercent } = swapState.state;
|
|
28
|
+
const { manualOutputWallet } = swapState.state;
|
|
29
|
+
const sourceToken = tokens.find((token) => currencyID(token.currency) === sourceId);
|
|
30
|
+
const destinationToken = tokens.find((token) => currencyID(token.currency) === destinationId);
|
|
31
|
+
const sourceChainType = sourceToken ? getChainTypeByChainId(sourceToken.currency.chain_id) : undefined;
|
|
32
|
+
const destinationChainType = destinationToken ? getChainTypeByChainId(destinationToken.currency.chain_id) : undefined;
|
|
33
|
+
// Wallet-less chains (Zcash) share a chain type with a connectable one, so never match a wallet to them.
|
|
34
|
+
const sourceTokens = useMemo(() => tokens.filter((token) => supportsWalletPayments(token.currency.chain_id)), [tokens]);
|
|
35
|
+
const destinationHasWallet = supportsWalletPayments(destinationToken?.currency.chain_id);
|
|
36
|
+
const inputAccount = supportsWalletPayments(sourceToken?.currency.chain_id)
|
|
37
|
+
? connectedAccounts.find((account) => account.chainType === sourceChainType)
|
|
38
|
+
: undefined;
|
|
39
|
+
const outputAccount = destinationHasWallet
|
|
40
|
+
? connectedAccounts.find((account) => account.chainType === destinationChainType)
|
|
41
|
+
: undefined;
|
|
42
|
+
const separateOutputWallet = shouldUseSeparateOutputWallet(sourceChainType, destinationChainType);
|
|
43
|
+
const manualAddress = manualOutputWallet && manualOutputWallet.chainId === destinationToken?.currency.chain_id
|
|
44
|
+
? manualOutputWallet.address
|
|
45
|
+
: undefined;
|
|
46
|
+
const connectedOutputAddress = separateOutputWallet ? outputAccount?.address : inputAccount?.address;
|
|
47
|
+
const outputAddress = manualAddress ?? (destinationHasWallet ? connectedOutputAddress : undefined);
|
|
48
|
+
const walletRequirement = getSwapWalletRequirement({
|
|
49
|
+
inputWalletAddress: inputAccount?.address,
|
|
50
|
+
inputChainType: sourceChainType,
|
|
51
|
+
shouldRestrictInputChainType: Boolean(sourceToken),
|
|
52
|
+
outputWalletAddress: outputAddress,
|
|
53
|
+
outputChainType: destinationChainType,
|
|
54
|
+
});
|
|
55
|
+
const showOutputWalletSelector = shouldShowOutputWalletSelector({
|
|
56
|
+
outputChainType: destinationChainType,
|
|
57
|
+
connectionRequirement: walletRequirement,
|
|
58
|
+
});
|
|
59
|
+
// Wallet-less destinations can only be satisfied by pasting an address.
|
|
60
|
+
const requiresPastedAddress = walletRequirement.target === "output" && !destinationHasWallet;
|
|
61
|
+
const outputWalletLabel = outputAddress
|
|
62
|
+
? truncateAddress(outputAddress)
|
|
63
|
+
: destinationHasWallet
|
|
64
|
+
? "Connect Wallet"
|
|
65
|
+
: "Paste Address";
|
|
66
|
+
const slippage = resolveSlippage(slippageMode, slippagePercent);
|
|
67
|
+
const sameSwap = isSameSwap(sourceToken?.currency, destinationToken?.currency, inputAccount?.address, outputAddress);
|
|
68
|
+
const activeAmount = activeSide === "input" ? inputAmount : outputAmount;
|
|
69
|
+
const quoteRequest = useMemo(() => slippage.invalid || sameSwap
|
|
70
|
+
? null
|
|
71
|
+
: buildSwapQuoteRequest({
|
|
72
|
+
amount: activeAmount,
|
|
73
|
+
activeSide,
|
|
74
|
+
source: sourceToken?.currency,
|
|
75
|
+
destination: destinationToken?.currency,
|
|
76
|
+
slippageBps: slippage.bps,
|
|
77
|
+
}), [
|
|
78
|
+
activeAmount,
|
|
79
|
+
activeSide,
|
|
80
|
+
destinationToken?.currency,
|
|
81
|
+
sameSwap,
|
|
82
|
+
slippage.bps,
|
|
83
|
+
slippage.invalid,
|
|
84
|
+
sourceToken?.currency,
|
|
85
|
+
]);
|
|
86
|
+
const execution = useSwapExecution(sourceChainType, inputAccount?.address);
|
|
87
|
+
const idle = execution.stage.kind === "idle";
|
|
88
|
+
const quoteState = useSwapQuote(quoteRequest, idle);
|
|
89
|
+
useSwapNotifications({
|
|
90
|
+
stage: execution.stage,
|
|
91
|
+
quoteError: quoteState.error,
|
|
92
|
+
tokenError,
|
|
93
|
+
onBalancesChanged: refreshBalances,
|
|
94
|
+
onComplete,
|
|
95
|
+
onError,
|
|
96
|
+
});
|
|
97
|
+
const { quote } = quoteState;
|
|
98
|
+
const displayedInputAmount = activeSide === "output" && quote
|
|
99
|
+
? formatCurrencyAmount(quote.input.amount.raw, quote.input.currency.decimals)
|
|
100
|
+
: inputAmount;
|
|
101
|
+
const displayedOutputAmount = activeSide === "input" && quote
|
|
102
|
+
? formatCurrencyAmount(quote.output.amount.raw, quote.output.currency.decimals)
|
|
103
|
+
: outputAmount;
|
|
104
|
+
const sourceBalance = sourceToken?.balance;
|
|
105
|
+
// A pasted recipient is not one of the connected wallets, so its balance is unknown.
|
|
106
|
+
const destinationBalance = manualAddress
|
|
107
|
+
? undefined
|
|
108
|
+
: (destinationToken?.balance ?? (balancesLoaded && outputAccount?.address ? "0" : undefined));
|
|
109
|
+
const insufficientBalance = sourceBalance !== undefined && Number(displayedInputAmount) > Number(sourceBalance);
|
|
110
|
+
const canExecute = Boolean(quote &&
|
|
111
|
+
quoteRequest &&
|
|
112
|
+
inputAccount?.address &&
|
|
113
|
+
outputAddress &&
|
|
114
|
+
walletRequirement.target === "none" &&
|
|
115
|
+
!insufficientBalance &&
|
|
116
|
+
!sameSwap &&
|
|
117
|
+
!slippage.invalid);
|
|
118
|
+
const { reverse: reverseState, selectCurrency, setManualOutputWallet, reset: resetState } = swapState;
|
|
119
|
+
const reverse = useCallback(() => {
|
|
120
|
+
reverseState(displayedInputAmount, displayedOutputAmount);
|
|
121
|
+
}, [displayedInputAmount, displayedOutputAmount, reverseState]);
|
|
122
|
+
const selectToken = useCallback((side, token) => selectCurrency(side, currencyID(token.currency)), [selectCurrency]);
|
|
123
|
+
const saveManualAddress = useCallback((address) => {
|
|
124
|
+
if (destinationToken)
|
|
125
|
+
setManualOutputWallet({ chainId: destinationToken.currency.chain_id, address });
|
|
126
|
+
}, [destinationToken, setManualOutputWallet]);
|
|
127
|
+
const execute = useCallback(async () => {
|
|
128
|
+
if (!quoteRequest || !sourceToken || !destinationToken || !inputAccount?.address || !outputAddress)
|
|
129
|
+
return;
|
|
130
|
+
const request = buildSwapExecuteRequest({
|
|
131
|
+
quoteRequest,
|
|
132
|
+
source: sourceToken.currency,
|
|
133
|
+
destination: destinationToken.currency,
|
|
134
|
+
senderAddress: inputAccount.address,
|
|
135
|
+
recipient: outputAddress,
|
|
136
|
+
slippageBps: slippage.bps,
|
|
137
|
+
});
|
|
138
|
+
await execution.execute(request, inputAccount.address);
|
|
139
|
+
}, [destinationToken, execution, inputAccount, outputAddress, quoteRequest, slippage.bps, sourceToken]);
|
|
140
|
+
const closeExecution = useCallback(() => {
|
|
141
|
+
const { kind } = execution.stage;
|
|
142
|
+
execution.reset();
|
|
143
|
+
if (kind === "done" || kind === "refunded")
|
|
144
|
+
resetState({ preserveCurrencies: kind === "done" });
|
|
145
|
+
}, [execution, resetState]);
|
|
146
|
+
return {
|
|
147
|
+
tokens,
|
|
148
|
+
sourceTokens,
|
|
149
|
+
tokensLoading: isLoading,
|
|
150
|
+
tokenError,
|
|
151
|
+
sourceId,
|
|
152
|
+
destinationId,
|
|
153
|
+
sourceToken,
|
|
154
|
+
destinationToken,
|
|
155
|
+
activeSide,
|
|
156
|
+
displayedInputAmount,
|
|
157
|
+
displayedOutputAmount,
|
|
158
|
+
sourceBalance,
|
|
159
|
+
destinationBalance,
|
|
160
|
+
slippageMode,
|
|
161
|
+
slippagePercent,
|
|
162
|
+
slippage,
|
|
163
|
+
destinationChainType,
|
|
164
|
+
destinationChainName: getChainName(destinationToken?.currency.chain_id),
|
|
165
|
+
destinationHasWallet,
|
|
166
|
+
separateOutputWallet,
|
|
167
|
+
manualAddress,
|
|
168
|
+
outputWalletLabel,
|
|
169
|
+
showOutputWalletSelector,
|
|
170
|
+
walletRequirement,
|
|
171
|
+
requiresPastedAddress,
|
|
172
|
+
quote,
|
|
173
|
+
quoteLoading: quoteState.loading,
|
|
174
|
+
quoteError: quoteState.error,
|
|
175
|
+
stage: execution.stage,
|
|
176
|
+
idle,
|
|
177
|
+
insufficientBalance,
|
|
178
|
+
sameSwap,
|
|
179
|
+
canExecute,
|
|
180
|
+
setAmount: swapState.setAmount,
|
|
181
|
+
setSlippageMode: swapState.setSlippageMode,
|
|
182
|
+
setSlippagePercent: swapState.setSlippagePercent,
|
|
183
|
+
selectToken,
|
|
184
|
+
reverse,
|
|
185
|
+
saveManualAddress,
|
|
186
|
+
execute,
|
|
187
|
+
closeExecution,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
@@ -9,5 +9,6 @@ export declare function useTokenData(): {
|
|
|
9
9
|
accounts: import("@coin-voyage/crypto/hooks").Account[];
|
|
10
10
|
isLoading: boolean;
|
|
11
11
|
error: string | null;
|
|
12
|
-
|
|
12
|
+
balancesLoaded: boolean;
|
|
13
|
+
refreshBalances: () => void;
|
|
13
14
|
};
|
|
@@ -1,30 +1,55 @@
|
|
|
1
1
|
import { useAccount } from "@coin-voyage/crypto/hooks";
|
|
2
2
|
import { fetchTokenList, formatCurrencyAmount, tokenToCurrency } from "@coin-voyage/shared/currency";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { getChainTypeByChainId } from "@coin-voyage/shared/chain";
|
|
4
|
+
import { ChainType, currencyID } from "@coin-voyage/shared/types";
|
|
5
|
+
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
|
|
5
6
|
import { useSwapKit } from "../providers/swapkit-context";
|
|
7
|
+
const BALANCE_REFRESH_DELAYS_MS = [4000, 12000];
|
|
8
|
+
// The token list and the portfolio can report the same address in different casing.
|
|
9
|
+
function currencyKey(currency) {
|
|
10
|
+
const chainType = getChainTypeByChainId(currency.chain_id);
|
|
11
|
+
const caseInsensitive = chainType === ChainType.EVM || chainType === ChainType.SUI;
|
|
12
|
+
return currencyID(caseInsensitive ? { ...currency, address: currency.address?.toLowerCase() } : currency);
|
|
13
|
+
}
|
|
6
14
|
function mergeTokens(tokenList, portfolio) {
|
|
7
|
-
const
|
|
8
|
-
const
|
|
9
|
-
const listed = tokenList.map((currency) =>
|
|
10
|
-
|
|
15
|
+
const portfolioByKey = new Map(portfolio.map((token) => [currencyKey(token.currency), token]));
|
|
16
|
+
const tokenListKeys = new Set(tokenList.map(currencyKey));
|
|
17
|
+
const listed = tokenList.map((currency) => {
|
|
18
|
+
const held = portfolioByKey.get(currencyKey(currency));
|
|
19
|
+
// Keep the listed address so the token's id stays stable once balances load.
|
|
20
|
+
return held ? { ...held, currency: { ...held.currency, address: currency.address } } : { currency };
|
|
21
|
+
});
|
|
22
|
+
const unlisted = portfolio.filter((token) => !tokenListKeys.has(currencyKey(token.currency)));
|
|
11
23
|
return [...listed, ...unlisted].sort((left, right) => (right.balanceUsd ?? 0) - (left.balanceUsd ?? 0));
|
|
12
24
|
}
|
|
13
25
|
export function useTokenData() {
|
|
14
|
-
const { api } = useSwapKit();
|
|
26
|
+
const { api, environment } = useSwapKit();
|
|
15
27
|
const { accounts } = useAccount();
|
|
16
28
|
const [listedCurrencies, setListedCurrencies] = useState([]);
|
|
17
29
|
const [portfolioState, setPortfolioState] = useState({
|
|
18
30
|
walletKey: "[]",
|
|
19
31
|
tokens: [],
|
|
32
|
+
complete: false,
|
|
20
33
|
});
|
|
21
34
|
const [isLoading, setIsLoading] = useState(true);
|
|
22
35
|
const [tokenError, setTokenError] = useState(null);
|
|
23
36
|
const [portfolioError, setPortfolioError] = useState(null);
|
|
24
|
-
const [portfolioRevision,
|
|
37
|
+
const [portfolioRevision, bumpPortfolioRevision] = useReducer((revision) => revision + 1, 0);
|
|
38
|
+
const refreshTimers = useRef([]);
|
|
39
|
+
const clearRefreshTimers = useCallback(() => {
|
|
40
|
+
refreshTimers.current.forEach(clearTimeout);
|
|
41
|
+
refreshTimers.current = [];
|
|
42
|
+
}, []);
|
|
43
|
+
// Balances can lag behind a settled swap, so refetch now and again once the chain data has caught up.
|
|
44
|
+
const refreshBalances = useCallback(() => {
|
|
45
|
+
clearRefreshTimers();
|
|
46
|
+
bumpPortfolioRevision();
|
|
47
|
+
refreshTimers.current = BALANCE_REFRESH_DELAYS_MS.map((delay) => setTimeout(bumpPortfolioRevision, delay));
|
|
48
|
+
}, [clearRefreshTimers]);
|
|
49
|
+
useEffect(() => clearRefreshTimers, [clearRefreshTimers]);
|
|
25
50
|
useEffect(() => {
|
|
26
51
|
let active = true;
|
|
27
|
-
fetchTokenList()
|
|
52
|
+
fetchTokenList({ preview: environment !== "production" })
|
|
28
53
|
.then((data) => {
|
|
29
54
|
if (!active)
|
|
30
55
|
return;
|
|
@@ -42,7 +67,7 @@ export function useTokenData() {
|
|
|
42
67
|
return () => {
|
|
43
68
|
active = false;
|
|
44
69
|
};
|
|
45
|
-
}, []);
|
|
70
|
+
}, [environment]);
|
|
46
71
|
const walletKey = JSON.stringify(accounts
|
|
47
72
|
.flatMap((account) => (account.address ? [{ address: account.address, chain_type: account.chainType }] : []))
|
|
48
73
|
.sort((left, right) => `${left.chain_type}:${left.address}`.localeCompare(`${right.chain_type}:${right.address}`)));
|
|
@@ -61,6 +86,7 @@ export function useTokenData() {
|
|
|
61
86
|
setPortfolioError(null);
|
|
62
87
|
setPortfolioState({
|
|
63
88
|
walletKey,
|
|
89
|
+
complete: !data.errors?.length,
|
|
64
90
|
tokens: data.portfolio.flatMap((result) => result.currencies.map(({ balance, currency }) => ({
|
|
65
91
|
currency,
|
|
66
92
|
balance: formatCurrencyAmount(balance.raw, currency.decimals),
|
|
@@ -75,7 +101,10 @@ export function useTokenData() {
|
|
|
75
101
|
});
|
|
76
102
|
return () => controller.abort();
|
|
77
103
|
}, [api, portfolioRevision, walletKey]);
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
104
|
+
const portfolioActive = portfolioState.walletKey === walletKey;
|
|
105
|
+
// True once every connected wallet's balances are known, so a token without a balance is a zero balance.
|
|
106
|
+
const balancesLoaded = portfolioActive && portfolioState.complete;
|
|
107
|
+
// Token identities must stay stable across renders: the quote request is memoized on them.
|
|
108
|
+
const tokens = useMemo(() => mergeTokens(listedCurrencies, portfolioActive ? portfolioState.tokens : []), [listedCurrencies, portfolioActive, portfolioState.tokens]);
|
|
109
|
+
return { tokens, accounts, isLoading, error: tokenError ?? portfolioError, balancesLoaded, refreshBalances };
|
|
81
110
|
}
|
|
@@ -1,9 +1,13 @@
|
|
|
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
|
-
const { api } = vi.hoisted(() => ({
|
|
7
|
+
const { api, context } = vi.hoisted(() => ({
|
|
8
|
+
api: { portfolio: vi.fn() },
|
|
9
|
+
context: { environment: "production" },
|
|
10
|
+
}));
|
|
7
11
|
vi.mock("@coin-voyage/crypto/hooks", () => ({
|
|
8
12
|
useAccount: () => ({ accounts: [{ address: "0x123", chainType: ChainType.EVM }] }),
|
|
9
13
|
}));
|
|
@@ -12,13 +16,38 @@ vi.mock("@coin-voyage/shared/currency", async (importOriginal) => ({
|
|
|
12
16
|
fetchTokenList: vi.fn().mockResolvedValue({ chains: [] }),
|
|
13
17
|
}));
|
|
14
18
|
vi.mock("../providers/swapkit-context", () => ({
|
|
15
|
-
useSwapKit: () => ({ api }),
|
|
19
|
+
useSwapKit: () => ({ api, ...context }),
|
|
16
20
|
}));
|
|
17
21
|
afterEach(() => {
|
|
18
22
|
cleanup();
|
|
19
23
|
vi.clearAllMocks();
|
|
24
|
+
context.environment = "production";
|
|
20
25
|
});
|
|
21
26
|
describe("useTokenData", () => {
|
|
27
|
+
it.each(["production", "development", "local"])("loads the token list for %s", async (environment) => {
|
|
28
|
+
context.environment = environment;
|
|
29
|
+
const preview = environment !== "production";
|
|
30
|
+
const zec = { name: "Zcash", ticker: "ZEC", decimals: 8, chainId: ChainId.ZEC, logoURI: "zec.png" };
|
|
31
|
+
vi.mocked(fetchTokenList).mockImplementationOnce(async (options) => ({
|
|
32
|
+
chains: typeof options === "object" && options.preview
|
|
33
|
+
? [{ chainId: ChainId.ZEC, name: "Zcash", logoURI: "zec.png", tokens: [zec] }]
|
|
34
|
+
: [],
|
|
35
|
+
}));
|
|
36
|
+
api.portfolio.mockResolvedValue({ data: { portfolio: [] } });
|
|
37
|
+
const { result } = renderHook(() => useTokenData());
|
|
38
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
39
|
+
expect(fetchTokenList).toHaveBeenCalledWith({ preview });
|
|
40
|
+
expect(result.current.tokens.map(({ currency }) => currency.symbol)).toEqual(preview ? ["ZEC"] : []);
|
|
41
|
+
});
|
|
42
|
+
it("reloads the list when the provider environment changes", async () => {
|
|
43
|
+
api.portfolio.mockResolvedValue({ data: { portfolio: [] } });
|
|
44
|
+
const { result, rerender } = renderHook(() => useTokenData());
|
|
45
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
46
|
+
context.environment = "development";
|
|
47
|
+
rerender();
|
|
48
|
+
await waitFor(() => expect(fetchTokenList).toHaveBeenLastCalledWith({ preview: true }));
|
|
49
|
+
expect(fetchTokenList).toHaveBeenCalledTimes(2);
|
|
50
|
+
});
|
|
22
51
|
it.each([
|
|
23
52
|
{ decimals: 6, raw: "1234567", ui: "1.23", expected: "1.234567" },
|
|
24
53
|
{ decimals: 9, raw: "1239999999", ui: "1.24", expected: "1.239999999" },
|
|
@@ -33,4 +62,50 @@ describe("useTokenData", () => {
|
|
|
33
62
|
await waitFor(() => expect(result.current.tokens).toHaveLength(1));
|
|
34
63
|
expect(result.current.tokens[0]).toEqual({ currency, balance: expected, balanceUsd: 12.34 });
|
|
35
64
|
});
|
|
65
|
+
it("attaches balances to listed tokens when the portfolio address casing differs", async () => {
|
|
66
|
+
const address = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
|
|
67
|
+
const listed = { name: "USD Coin", ticker: "USDC", decimals: 6, chainId: ChainId.ETH, address };
|
|
68
|
+
vi.mocked(fetchTokenList).mockResolvedValueOnce({ chains: [{ tokens: [listed] }] });
|
|
69
|
+
const held = {
|
|
70
|
+
id: "usdc",
|
|
71
|
+
chain_id: ChainId.ETH,
|
|
72
|
+
address: address.toLowerCase(),
|
|
73
|
+
name: "USD Coin",
|
|
74
|
+
symbol: "USDC",
|
|
75
|
+
decimals: 6,
|
|
76
|
+
};
|
|
77
|
+
api.portfolio.mockResolvedValue({
|
|
78
|
+
data: {
|
|
79
|
+
portfolio: [{ currencies: [{ currency: held, balance: { raw: "1500000", ui: "1.5", value_usd: 1.5 } }] }],
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
const { result, rerender } = renderHook(() => useTokenData());
|
|
83
|
+
await waitFor(() => expect(result.current.tokens[0]?.balance).toBe("1.5"));
|
|
84
|
+
expect(result.current.tokens).toHaveLength(1);
|
|
85
|
+
expect(result.current.tokens[0].currency.address).toBe(address);
|
|
86
|
+
const { currency } = result.current.tokens[0];
|
|
87
|
+
rerender();
|
|
88
|
+
expect(result.current.tokens[0].currency).toBe(currency);
|
|
89
|
+
});
|
|
90
|
+
it("refetches the portfolio immediately and again after a refresh", async () => {
|
|
91
|
+
vi.useFakeTimers();
|
|
92
|
+
try {
|
|
93
|
+
api.portfolio.mockResolvedValue({ data: { portfolio: [] } });
|
|
94
|
+
const { result, unmount } = renderHook(() => useTokenData());
|
|
95
|
+
expect(api.portfolio).toHaveBeenCalledTimes(1);
|
|
96
|
+
act(() => result.current.refreshBalances());
|
|
97
|
+
expect(api.portfolio).toHaveBeenCalledTimes(2);
|
|
98
|
+
await act(() => vi.advanceTimersByTimeAsync(4000));
|
|
99
|
+
expect(api.portfolio).toHaveBeenCalledTimes(3);
|
|
100
|
+
await act(() => vi.advanceTimersByTimeAsync(8000));
|
|
101
|
+
expect(api.portfolio).toHaveBeenCalledTimes(4);
|
|
102
|
+
act(() => result.current.refreshBalances());
|
|
103
|
+
unmount();
|
|
104
|
+
await vi.advanceTimersByTimeAsync(20000);
|
|
105
|
+
expect(api.portfolio).toHaveBeenCalledTimes(5);
|
|
106
|
+
}
|
|
107
|
+
finally {
|
|
108
|
+
vi.useRealTimers();
|
|
109
|
+
}
|
|
110
|
+
});
|
|
36
111
|
});
|
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
import { addressesMatch } from "@coin-voyage/crypto/utils";
|
|
2
|
-
import { getChainTypeByChainId } from "@coin-voyage/shared/chain";
|
|
2
|
+
import { getChainTypeByChainId, supportsWalletPayments } from "@coin-voyage/shared/chain";
|
|
3
3
|
import { currencyID } from "@coin-voyage/shared/types";
|
|
4
4
|
export function currenciesMatch(left, right) {
|
|
5
5
|
return (left.chain_id === right.chain_id &&
|
|
6
6
|
addressesMatch(left.address, right.address, getChainTypeByChainId(left.chain_id)));
|
|
7
7
|
}
|
|
8
8
|
export function resolveCurrencySelection({ currencies, connectedChainTypes, defaultSourceCurrency, defaultDestinationCurrency, }) {
|
|
9
|
+
// The source is paid from a connected wallet, so wallet-less chains can only be a destination.
|
|
10
|
+
const sourceCurrencies = currencies.filter((currency) => supportsWalletPayments(currency.chain_id));
|
|
9
11
|
const preferredSource = defaultSourceCurrency
|
|
10
|
-
?
|
|
12
|
+
? sourceCurrencies.find((currency) => currenciesMatch(currency, defaultSourceCurrency))
|
|
11
13
|
: undefined;
|
|
12
14
|
const source = preferredSource ??
|
|
13
|
-
|
|
14
|
-
|
|
15
|
+
sourceCurrencies.find((currency) => connectedChainTypes.has(getChainTypeByChainId(currency.chain_id))) ??
|
|
16
|
+
sourceCurrencies[0];
|
|
15
17
|
const preferredDestination = defaultDestinationCurrency
|
|
16
18
|
? currencies.find((currency) => currenciesMatch(currency, defaultDestinationCurrency))
|
|
17
19
|
: undefined;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { ChainId, ChainType, currencyID } from "@coin-voyage/shared/types";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { resolveCurrencySelection } from "./currency-selection";
|
|
4
|
+
const zec = { id: "zec", chain_id: ChainId.ZEC, name: "Zcash", symbol: "ZEC", decimals: 8 };
|
|
5
|
+
const btc = { id: "btc", chain_id: ChainId.BTC, name: "Bitcoin", symbol: "BTC", decimals: 8 };
|
|
6
|
+
describe("resolveCurrencySelection", () => {
|
|
7
|
+
it("never selects a wallet-less chain as the source", () => {
|
|
8
|
+
const selection = resolveCurrencySelection({
|
|
9
|
+
currencies: [zec, btc],
|
|
10
|
+
connectedChainTypes: new Set([ChainType.UTXO]),
|
|
11
|
+
defaultSourceCurrency: { chain_id: ChainId.ZEC },
|
|
12
|
+
});
|
|
13
|
+
expect(selection.sourceId).toBe(currencyID(btc));
|
|
14
|
+
expect(selection.destinationId).toBe(currencyID(zec));
|
|
15
|
+
});
|
|
16
|
+
});
|