@coin-voyage/swapkit 0.4.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/dist/components/currency-panel.d.ts +17 -0
- package/dist/components/currency-panel.js +22 -0
- package/dist/components/execution-modal.js +28 -5
- 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 +1 -0
- package/dist/components/styles.js +14 -2
- package/dist/components/swap-recipient.test.js +29 -1
- 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 -312
- 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.js +3 -3
- package/dist/hooks/use-token-data.test.js +33 -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/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/package.json +3 -3
|
@@ -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
|
+
}
|
|
@@ -23,7 +23,7 @@ function mergeTokens(tokenList, portfolio) {
|
|
|
23
23
|
return [...listed, ...unlisted].sort((left, right) => (right.balanceUsd ?? 0) - (left.balanceUsd ?? 0));
|
|
24
24
|
}
|
|
25
25
|
export function useTokenData() {
|
|
26
|
-
const { api } = useSwapKit();
|
|
26
|
+
const { api, environment } = useSwapKit();
|
|
27
27
|
const { accounts } = useAccount();
|
|
28
28
|
const [listedCurrencies, setListedCurrencies] = useState([]);
|
|
29
29
|
const [portfolioState, setPortfolioState] = useState({
|
|
@@ -49,7 +49,7 @@ export function useTokenData() {
|
|
|
49
49
|
useEffect(() => clearRefreshTimers, [clearRefreshTimers]);
|
|
50
50
|
useEffect(() => {
|
|
51
51
|
let active = true;
|
|
52
|
-
fetchTokenList()
|
|
52
|
+
fetchTokenList({ preview: environment !== "production" })
|
|
53
53
|
.then((data) => {
|
|
54
54
|
if (!active)
|
|
55
55
|
return;
|
|
@@ -67,7 +67,7 @@ export function useTokenData() {
|
|
|
67
67
|
return () => {
|
|
68
68
|
active = false;
|
|
69
69
|
};
|
|
70
|
-
}, []);
|
|
70
|
+
}, [environment]);
|
|
71
71
|
const walletKey = JSON.stringify(accounts
|
|
72
72
|
.flatMap((account) => (account.address ? [{ address: account.address, chain_type: account.chainType }] : []))
|
|
73
73
|
.sort((left, right) => `${left.chain_type}:${left.address}`.localeCompare(`${right.chain_type}:${right.address}`)));
|
|
@@ -4,7 +4,10 @@ import { ChainId, ChainType } from "@coin-voyage/shared/types";
|
|
|
4
4
|
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
|
|
5
5
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
6
|
import { useTokenData } from "./use-token-data";
|
|
7
|
-
const { api } = vi.hoisted(() => ({
|
|
7
|
+
const { api, context } = vi.hoisted(() => ({
|
|
8
|
+
api: { portfolio: vi.fn() },
|
|
9
|
+
context: { environment: "production" },
|
|
10
|
+
}));
|
|
8
11
|
vi.mock("@coin-voyage/crypto/hooks", () => ({
|
|
9
12
|
useAccount: () => ({ accounts: [{ address: "0x123", chainType: ChainType.EVM }] }),
|
|
10
13
|
}));
|
|
@@ -13,13 +16,38 @@ vi.mock("@coin-voyage/shared/currency", async (importOriginal) => ({
|
|
|
13
16
|
fetchTokenList: vi.fn().mockResolvedValue({ chains: [] }),
|
|
14
17
|
}));
|
|
15
18
|
vi.mock("../providers/swapkit-context", () => ({
|
|
16
|
-
useSwapKit: () => ({ api }),
|
|
19
|
+
useSwapKit: () => ({ api, ...context }),
|
|
17
20
|
}));
|
|
18
21
|
afterEach(() => {
|
|
19
22
|
cleanup();
|
|
20
23
|
vi.clearAllMocks();
|
|
24
|
+
context.environment = "production";
|
|
21
25
|
});
|
|
22
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
|
+
});
|
|
23
51
|
it.each([
|
|
24
52
|
{ decimals: 6, raw: "1234567", ui: "1.23", expected: "1.234567" },
|
|
25
53
|
{ decimals: 9, raw: "1239999999", ui: "1.24", expected: "1.239999999" },
|
|
@@ -47,7 +75,9 @@ describe("useTokenData", () => {
|
|
|
47
75
|
decimals: 6,
|
|
48
76
|
};
|
|
49
77
|
api.portfolio.mockResolvedValue({
|
|
50
|
-
data: {
|
|
78
|
+
data: {
|
|
79
|
+
portfolio: [{ currencies: [{ currency: held, balance: { raw: "1500000", ui: "1.5", value_usd: 1.5 } }] }],
|
|
80
|
+
},
|
|
51
81
|
});
|
|
52
82
|
const { result, rerender } = renderHook(() => useTokenData());
|
|
53
83
|
await waitFor(() => expect(result.current.tokens[0]?.balance).toBe("1.5"));
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { SwapQuote } from "@coin-voyage/shared/types";
|
|
2
|
+
export declare function formatUsd(value?: number | null): string;
|
|
3
|
+
export declare function formatPriceImpact(value: number): string;
|
|
4
|
+
export declare function usdValue(priceUsd: number | null | undefined, amount: string): number | undefined;
|
|
5
|
+
export declare function formatQuoteRate(quote: SwapQuote): string;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export function formatUsd(value) {
|
|
2
|
+
if (value === undefined || value === null || !Number.isFinite(value))
|
|
3
|
+
return "$0.00";
|
|
4
|
+
return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 2 }).format(value);
|
|
5
|
+
}
|
|
6
|
+
export function formatPriceImpact(value) {
|
|
7
|
+
if (value > 0 && value < 0.01)
|
|
8
|
+
return "<0.01%";
|
|
9
|
+
return `${value.toFixed(2)}%`;
|
|
10
|
+
}
|
|
11
|
+
export function usdValue(priceUsd, amount) {
|
|
12
|
+
const numericAmount = Number(amount);
|
|
13
|
+
if (!priceUsd || !Number.isFinite(numericAmount))
|
|
14
|
+
return undefined;
|
|
15
|
+
return priceUsd * numericAmount;
|
|
16
|
+
}
|
|
17
|
+
export function formatQuoteRate(quote) {
|
|
18
|
+
const rate = Number(quote.output.amount.ui) / Math.max(Number(quote.input.amount.ui), Number.EPSILON);
|
|
19
|
+
return `1 ${quote.input.currency.symbol} ≈ ${rate.toLocaleString(undefined, { maximumSignificantDigits: 6 })} ${quote.output.currency.symbol}`;
|
|
20
|
+
}
|
package/dist/lib/quote.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { CurrencyBase, SwapQuoteRequest } from "@coin-voyage/shared/types";
|
|
1
|
+
import type { CurrencyBase, SwapExecuteRequest, 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 {
|
|
@@ -9,5 +9,14 @@ interface BuildSwapQuoteRequestProps {
|
|
|
9
9
|
slippageBps?: number;
|
|
10
10
|
}
|
|
11
11
|
export declare function buildSwapQuoteRequest({ amount, activeSide, source, destination, slippageBps, }: BuildSwapQuoteRequestProps): SwapQuoteRequest | null;
|
|
12
|
+
interface BuildSwapExecuteRequestProps {
|
|
13
|
+
quoteRequest: SwapQuoteRequest;
|
|
14
|
+
source: CurrencyBase;
|
|
15
|
+
destination: CurrencyBase;
|
|
16
|
+
senderAddress: string;
|
|
17
|
+
recipient: string;
|
|
18
|
+
slippageBps?: number;
|
|
19
|
+
}
|
|
20
|
+
export declare function buildSwapExecuteRequest({ quoteRequest, source, destination, senderAddress, recipient, slippageBps, }: BuildSwapExecuteRequestProps): SwapExecuteRequest;
|
|
12
21
|
export declare function isSameSwap(source: CurrencyBase | undefined, destination: CurrencyBase | undefined, inputWalletAddress?: string, outputWalletAddress?: string): boolean;
|
|
13
22
|
export {};
|
package/dist/lib/quote.js
CHANGED
|
@@ -16,6 +16,18 @@ export function buildSwapQuoteRequest({ amount, activeSide, source, destination,
|
|
|
16
16
|
...(slippageBps === undefined ? {} : { slippage_bps: slippageBps }),
|
|
17
17
|
};
|
|
18
18
|
}
|
|
19
|
+
export function buildSwapExecuteRequest({ quoteRequest, source, destination, senderAddress, recipient, slippageBps, }) {
|
|
20
|
+
return {
|
|
21
|
+
trade_type: quoteRequest.trade_type,
|
|
22
|
+
amount: quoteRequest.amount,
|
|
23
|
+
source: {
|
|
24
|
+
currency: toCurrencyBase(source),
|
|
25
|
+
sender_address: senderAddress,
|
|
26
|
+
...(slippageBps === undefined ? {} : { slippage_bps: slippageBps }),
|
|
27
|
+
},
|
|
28
|
+
destination: { currency: toCurrencyBase(destination), recipient },
|
|
29
|
+
};
|
|
30
|
+
}
|
|
19
31
|
export function isSameSwap(source, destination, inputWalletAddress, outputWalletAddress) {
|
|
20
32
|
if (!source || !destination || !currenciesMatch(source, destination))
|
|
21
33
|
return false;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { SlippageMode } from "../hooks/use-swap-state";
|
|
2
|
+
export interface ResolvedSlippage {
|
|
3
|
+
invalid: boolean;
|
|
4
|
+
/** Undefined in auto mode, or while the custom value is invalid. */
|
|
5
|
+
bps?: number;
|
|
6
|
+
warning?: {
|
|
7
|
+
tone: "warning" | "danger";
|
|
8
|
+
message: string;
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
export declare function resolveSlippage(mode: SlippageMode, percent: string): ResolvedSlippage;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
const HIGH_SLIPPAGE_PERCENT = 6;
|
|
2
|
+
const SEVERE_SLIPPAGE_PERCENT = 40;
|
|
3
|
+
export function resolveSlippage(mode, percent) {
|
|
4
|
+
if (mode !== "custom")
|
|
5
|
+
return { invalid: false };
|
|
6
|
+
const value = Number(percent);
|
|
7
|
+
const invalid = percent.length === 0 || !Number.isFinite(value) || value < 0 || value > 100;
|
|
8
|
+
return {
|
|
9
|
+
invalid,
|
|
10
|
+
bps: invalid ? undefined : Math.round(value * 100),
|
|
11
|
+
warning: value >= SEVERE_SLIPPAGE_PERCENT
|
|
12
|
+
? { tone: "danger", message: "This slippage tolerance may cause a severely unfavorable trade." }
|
|
13
|
+
: value >= HIGH_SLIPPAGE_PERCENT
|
|
14
|
+
? { tone: "warning", message: "High slippage can result in a worse execution price." }
|
|
15
|
+
: undefined,
|
|
16
|
+
};
|
|
17
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coin-voyage/swapkit",
|
|
3
3
|
"description": "A reusable cross-chain swap component for CoinVoyage.",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.5.0",
|
|
5
5
|
"private": false,
|
|
6
6
|
"sideEffects": false,
|
|
7
7
|
"author": "Lars <lars@coinvoyage.io>",
|
|
@@ -22,8 +22,8 @@
|
|
|
22
22
|
"LICENSE"
|
|
23
23
|
],
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@coin-voyage/crypto": "3.
|
|
26
|
-
"@coin-voyage/shared": "3.
|
|
25
|
+
"@coin-voyage/crypto": "3.7.0",
|
|
26
|
+
"@coin-voyage/shared": "3.7.0"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
29
|
"@testing-library/react": "^16.3.0",
|