Skip to content

Commit

Permalink
feat: add multihop routing (#29)
Browse files Browse the repository at this point in the history
  • Loading branch information
svvald authored Oct 1, 2024
1 parent 64e5e4f commit 4f5b13e
Show file tree
Hide file tree
Showing 4 changed files with 146 additions and 84 deletions.
102 changes: 33 additions & 69 deletions src/components/common/Swap/Swap.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {useCallback, useRef, useState} from "react";
import {useCallback, useEffect, useRef, useState} from "react";
import {useAccount, useConnectUI, useIsConnected} from "@fuels/react";
import {useDebounceCallback, useLocalStorage} from "usehooks-ts";
import {clsx} from "clsx";
Expand All @@ -9,8 +9,6 @@ import ActionButton from "@/src/components/common/ActionButton/ActionButton";
import ConvertIcon from "@/src/components/icons/Convert/ConvertIcon";
import IconButton from "@/src/components/common/IconButton/IconButton";
import useModal from "@/src/hooks/useModal/useModal";
import useExactInputPreview from "@/src/hooks/useExactInputPreview/useExactInputPreview";
import useExactOutputPreview from "@/src/hooks/useExactOutputPreview/useExactOutputPreview";
import {CoinName, coinsConfig} from "@/src/utils/coinsConfig";
import useSwap from "@/src/hooks/useSwap/useSwap";

Expand All @@ -28,7 +26,7 @@ import useInitialSwapState from "@/src/hooks/useInitialSwapState/useInitialSwapS
import useFaucetLink from "@/src/hooks/useFaucetLink";
import {InsufficientReservesError} from "mira-dex-ts/dist/sdk/errors";
import useCheckActiveNetwork from "@/src/hooks/useCheckActiveNetwork";
import {ValidNetwork} from "@/src/utils/constants";
import useSwapPreview from "@/src/hooks/useSwapPreview";

export type CurrencyBoxMode = "buy" | "sell";
export type CurrencyBoxState = {
Expand Down Expand Up @@ -66,8 +64,7 @@ const Swap = () => {

const [swapCoins, setSwapCoins] = useLocalStorage('swapCoins', {sell: initialSwapState.sell.coin, buy: initialSwapState.buy.coin});

const previousInputPreviewValue = useRef('');
const previousOutputPreviewValue = useRef('');
const previousPreviewValue = useRef('');
const swapStateForPreview = useRef(swapState);
const modeForCoinSelector = useRef<CurrencyBoxMode>('sell');

Expand All @@ -83,67 +80,27 @@ const Swap = () => {
const buyBalance = balances?.find(b => b.assetId === coinsConfig.get(swapState.buy.coin)?.assetId)?.amount.toNumber();
const buyBalanceValue = buyBalance ? buyBalance / 10 ** coinsConfig.get(swapState.buy.coin)?.decimals! : 0;

const {data: inputPreviewData, isFetching: inputPreviewIsFetching, error: inputPreviewError } = useExactInputPreview({
swapState,
sellAmount: swapState.sell.amount ? parseFloat(swapState.sell.amount) : null,
lastFocusedMode,
});
const buyDecimals = coinsConfig.get(swapState.buy.coin)?.decimals!;
const inputPreviewValue = inputPreviewData && inputPreviewData[1].toNumber() / 10 ** buyDecimals;
const inputPreviewValueString = inputPreviewValue !== undefined ? inputPreviewValue === 0 ? '' : inputPreviewValue.toFixed(buyDecimals) : previousInputPreviewValue.current;
previousInputPreviewValue.current = inputPreviewValueString;
const buyValue = lastFocusedMode === 'sell' ? inputPreviewValueString : inputsState.buy.amount;
if (buyValue !== swapState.buy.amount) {
setSwapState(prevState => ({
...prevState,
buy: {
...prevState.buy,
amount: buyValue,
},
}));
}
const { previewData, previewFetching, previewError } = useSwapPreview({ swapState, mode: lastFocusedMode });
const anotherMode = lastFocusedMode === 'sell' ? 'buy' : 'sell';
const decimals = anotherMode === 'sell' ? coinsConfig.get(swapState.sell.coin)?.decimals! : coinsConfig.get(swapState.buy.coin)?.decimals!;
const normalizedPreviewValue = previewData && previewData.previewAmount / 10 ** decimals;
const previewValueString = normalizedPreviewValue !== null ? normalizedPreviewValue === 0 ? '' : normalizedPreviewValue.toFixed(decimals) : previousPreviewValue.current;
previousPreviewValue.current = previewValueString;
useEffect(() => {
if (previewValueString !== swapState[anotherMode].amount) {
setSwapState(prevState => ({
...prevState,
[anotherMode]: {
...prevState[anotherMode],
amount: previewValueString,
},
}));
}
}, [previewValueString]);

const {data: outputPreviewData, isFetching: outputPreviewIsFetching, error: outputPreviewError} = useExactOutputPreview({
swapState,
buyAmount: swapState.buy.amount ? parseFloat(swapState.buy.amount) : null,
lastFocusedMode,
});
const sellDecimals = coinsConfig.get(swapState.sell.coin)?.decimals!;
const outputPreviewValue = outputPreviewData && outputPreviewData[1].toNumber() / 10 ** sellDecimals;
const outputPreviewValueString = outputPreviewValue !== undefined ? outputPreviewValue === 0 ? '' : outputPreviewValue.toFixed(sellDecimals) : previousOutputPreviewValue.current;
previousOutputPreviewValue.current = outputPreviewValueString;
const sellValue = lastFocusedMode === 'buy' ? outputPreviewValueString : inputsState.sell.amount;
if (sellValue !== swapState.sell.amount) {
setSwapState(prevState => ({
...prevState,
sell: {
...prevState.sell,
amount: sellValue,
},
}));
}

// const changeCoins = () => {
// setSwapState(prevState => ({
// buy: {
// ...prevState.buy,
// coin: prevState.sell.coin,
// },
// sell: {
// ...prevState.sell,
// coin: prevState.buy.coin,
// },
// }));
//
// const params = new URLSearchParams(searchParams.toString());
// if (swapState.sell.coin) {
// params.set('buy', swapState.sell.coin ?? '');
// }
// if (swapState.buy.coin) {
// params.set('sell', swapState.buy.coin ?? '');
// }
// router.replace(`${pathname}?${params.toString()}`, { scroll: false });
// };
const sellValue = lastFocusedMode === 'buy' ? previewValueString : inputsState.sell.amount;
const buyValue = lastFocusedMode === 'sell' ? previewValueString : inputsState.buy.amount;

const swapAssets = useCallback(() => {
setSwapState(prevState => ({
Expand Down Expand Up @@ -253,7 +210,12 @@ const Swap = () => {
closeCoinsModal();
};

const { fetchTxCost, triggerSwap, swapPending, swapResult } = useSwap({swapState, mode: lastFocusedMode, slippage});
const { fetchTxCost, triggerSwap, swapPending, swapResult } = useSwap({
swapState,
mode: lastFocusedMode,
slippage,
pools: previewData?.pools,
});

const coinMissing = swapState.buy.coin === null || swapState.sell.coin === null;
const amountMissing = swapState.buy.amount === '' || swapState.sell.amount === '';
Expand Down Expand Up @@ -298,7 +260,6 @@ const Swap = () => {
const insufficientSellBalance = parseFloat(sellValue) > sellBalanceValue;
const ethWithZeroBalanceSelected = swapState.sell.coin === 'ETH' && balances?.find(b => b.assetId === coinsConfig.get('ETH')?.assetId)?.amount.toNumber() === 0;
const showInsufficientBalance = insufficientSellBalance && !ethWithZeroBalanceSelected;
const previewError = inputPreviewError || outputPreviewError;
const insufficientReserves = previewError instanceof InsufficientReservesError;

let swapButtonTitle = 'Swap';
Expand All @@ -321,6 +282,9 @@ const Swap = () => {
const exchangeRate = useExchangeRate(swapState);
const feeValue = ((feePercentage / 100) * parseFloat(sellValue)).toFixed(sellDecimals);

const inputPreviewPending = previewFetching && lastFocusedMode === 'buy';
const outputPreviewPending = previewFetching && lastFocusedMode === 'sell';

return (
<>
<div className={styles.swapAndRate}>
Expand All @@ -337,7 +301,7 @@ const Swap = () => {
mode="sell"
balance={sellBalanceValue}
setAmount={setAmount('sell')}
loading={outputPreviewIsFetching || swapPending}
loading={inputPreviewPending || swapPending}
onCoinSelectorClick={handleCoinSelectorClick}
/>
<div className={styles.splitter}>
Expand All @@ -351,7 +315,7 @@ const Swap = () => {
mode="buy"
balance={buyBalanceValue}
setAmount={setAmount('buy')}
loading={inputPreviewIsFetching || swapPending}
loading={outputPreviewPending || swapPending}
onCoinSelectorClick={handleCoinSelectorClick}
/>
{swapPending && (
Expand Down
4 changes: 0 additions & 4 deletions src/hooks/useAssetPair/useSwapData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ type SwapData = {
buyDecimals: number;
sellAssetIdInput: AssetIdInput;
buyAssetIdInput: AssetIdInput;
assets: [AssetIdInput, AssetIdInput];
};

const useSwapData = (swapState: SwapState): SwapData => {
Expand All @@ -32,16 +31,13 @@ const useSwapData = (swapState: SwapState): SwapData => {
bits: buyAssetId,
};

const assets: [AssetIdInput, AssetIdInput] = [sellAssetIdInput, buyAssetIdInput];

return {
sellAssetId,
buyAssetId,
sellDecimals,
buyDecimals,
sellAssetIdInput,
buyAssetIdInput,
assets,
};
}, [swapState.buy.coin, swapState.sell.coin]);
};
Expand Down
18 changes: 7 additions & 11 deletions src/hooks/useSwap/useSwap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,25 +7,23 @@ import type {CurrencyBoxMode, SwapState} from "@/src/components/common/Swap/Swap
import useMiraDex from "@/src/hooks/useMiraDex/useMiraDex";
import useSwapData from "@/src/hooks/useAssetPair/useSwapData";
import {DefaultTxParams, MaxDeadline} from "@/src/utils/constants";
import usePoolsIds from "@/src/hooks/usePoolsIds";
import {createPoolId} from "@/src/utils/common";
import {buildPoolId} from "mira-dex-ts";
import {PoolId} from "mira-dex-ts";

type Props = {
swapState: SwapState;
mode: CurrencyBoxMode;
slippage: number;
pools: PoolId[] | undefined;
}

const useSwap = ({ swapState, mode, slippage }: Props) => {
const useSwap = ({ swapState, mode, slippage, pools }: Props) => {
const { wallet } = useWallet();
const miraDex = useMiraDex();
const swapData = useSwapData(swapState);
const pools = usePoolsIds();
const { sellAssetIdInput, buyAssetIdInput, sellDecimals, buyDecimals } = swapData;

const getTxCost = useCallback(async () => {
if (!wallet || !miraDex) {
if (!wallet || !miraDex || !pools) {
return;
}

Expand All @@ -35,11 +33,9 @@ const useSwap = ({ swapState, mode, slippage }: Props) => {
const buyAmountWithSlippage = buyAmount * (1 - slippage / 100);
const sellAmountWithSlippage = sellAmount * (1 + slippage / 100);

const pool = buildPoolId(sellAssetIdInput.bits, buyAssetIdInput.bits, false);

const tx = mode === 'sell' ?
await miraDex.swapExactInput(sellAmount, sellAssetIdInput, buyAmountWithSlippage, [pool], MaxDeadline, DefaultTxParams) :
await miraDex.swapExactOutput(buyAmount, buyAssetIdInput, sellAmountWithSlippage, [pool], MaxDeadline, DefaultTxParams);
await miraDex.swapExactInput(sellAmount, sellAssetIdInput, buyAmountWithSlippage, pools, MaxDeadline, DefaultTxParams) :
await miraDex.swapExactOutput(buyAmount, buyAssetIdInput, sellAmountWithSlippage, pools, MaxDeadline, DefaultTxParams);

const txCost = await wallet.getTransactionCost(tx);

Expand All @@ -55,7 +51,7 @@ const useSwap = ({ swapState, mode, slippage }: Props) => {
mode,
sellAssetIdInput,
buyAssetIdInput,
pools
pools,
]);

const sendTx = useCallback(async (inputTx: ScriptTransactionRequest) => {
Expand Down
106 changes: 106 additions & 0 deletions src/hooks/useSwapPreview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import {useQuery} from "@tanstack/react-query";
import {CurrencyBoxMode, SwapState} from "@/src/components/common/Swap/Swap";
import useSwapData from "@/src/hooks/useAssetPair/useSwapData";
import useReadonlyMira from "@/src/hooks/useReadonlyMira";
import {buildPoolId, PoolId} from "mira-dex-ts";

type Props = {
swapState: SwapState;
mode: CurrencyBoxMode;
}

type TradeType = 'ExactInput' | 'ExactOutput';

type MultihopPreviewData = {
path: [string, string, boolean][];
input_amount: number;
output_amount: number;
};

type SwapPreviewData = {
pools: PoolId[];
previewAmount: number;
};

const useSwapPreview = ({ swapState, mode }: Props) => {
const {
sellAssetIdInput,
buyAssetIdInput,
sellDecimals,
buyDecimals,
} = useSwapData(swapState);
const inputAssetId = sellAssetIdInput.bits;
const outputAssetId = buyAssetIdInput.bits;

const amountString = mode === 'sell' ? swapState.sell.amount : swapState.buy.amount;
const amount = parseFloat(amountString);
const amountValid = !isNaN(amount);
const decimals = mode === 'sell' ? sellDecimals : buyDecimals;
const normalizedAmount = amountValid ? amount * 10 ** decimals : 0;
const amountNonZero = normalizedAmount > 0;

const tradeType: TradeType = mode === 'sell' ? 'ExactInput' : 'ExactOutput';

const { data: multihopPreviewData, error: multihopPreviewError, isFetching: multihopPreviewFetching } = useQuery({
queryKey: ['multihopPreview', inputAssetId, outputAssetId, normalizedAmount, tradeType],
queryFn: async () => {
const res = await fetch('https://dev.api.mira.ly/find_route', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
input: inputAssetId,
output: outputAssetId,
amount: normalizedAmount,
trade_type: tradeType,
}),
});

return await res.json();
},
retry: 2,
enabled: amountNonZero,
});

const miraAmm = useReadonlyMira();
const miraExists = Boolean(miraAmm);
const pool = buildPoolId(inputAssetId, outputAssetId, false);
const shouldFetchFallback = Boolean(multihopPreviewError) && miraExists && amountNonZero;

const { data: fallbackPreviewData, error: fallbackPreviewError, isFetching: fallbackPreviewFetching } = useQuery({
queryKey: ['fallbackPreview', inputAssetId, outputAssetId, normalizedAmount],
queryFn: async () => {
return mode === 'sell' ?
await miraAmm?.previewSwapExactInput(
sellAssetIdInput,
normalizedAmount,
[pool],
) :
await miraAmm?.previewSwapExactOutput(
buyAssetIdInput,
normalizedAmount,
[pool],
);
},
enabled: shouldFetchFallback,
});

let previewData: SwapPreviewData | null = null;
if (multihopPreviewData) {
const { path, input_amount, output_amount } = multihopPreviewData as MultihopPreviewData;
previewData = {
pools: path.map(([input, output, stable]) => buildPoolId(`0x${input}`, `0x${output}`, stable)),
previewAmount: mode === 'sell' ? output_amount : input_amount,
};
} else if (fallbackPreviewData) {
previewData = {
pools: [pool],
previewAmount: fallbackPreviewData[1].toNumber(),
};
}

return { previewData, previewFetching: multihopPreviewFetching || fallbackPreviewFetching, previewError: multihopPreviewError || fallbackPreviewError };
};

export default useSwapPreview;

0 comments on commit 4f5b13e

Please sign in to comment.