From efa9ecfb266c75d9ca83333d5e15623f62f0b70b Mon Sep 17 00:00:00 2001 From: Pedro Nauck Date: Sat, 11 Jun 2022 03:41:32 -0300 Subject: [PATCH] refac: add first version of refactoring on swap --- .../src/systems/Core/components/CoinInput.tsx | 4 +- packages/app/src/systems/Core/utils/math.ts | 8 + .../systems/Pool/hooks/useUserPositions.ts | 10 +- .../systems/Swap/components/PricePerToken.tsx | 103 ---------- .../systems/Swap/components/SwapComponent.tsx | 162 --------------- .../systems/Swap/components/SwapPreview.tsx | 84 -------- .../systems/Swap/components/SwapWidget.tsx | 35 ++++ .../app/src/systems/Swap/components/index.tsx | 4 +- .../app/src/systems/Swap/hooks/useSwap.tsx | 98 +++++++++ .../src/systems/Swap/hooks/useSwapButton.ts | 51 +++++ .../systems/Swap/hooks/useSwapCoinInput.ts | 64 ++++++ .../systems/Swap/hooks/useSwapCoinSelector.ts | 40 ++++ .../src/systems/Swap/machines/swapMachine.ts | 186 +++++++++++++++++ .../app/src/systems/Swap/pages/SwapPage.tsx | 148 +------------- packages/app/src/systems/Swap/routes.tsx | 12 +- packages/app/src/systems/Swap/state.ts | 32 --- packages/app/src/systems/Swap/types.ts | 57 +++--- .../app/src/systems/Swap/utils/helpers.ts | 192 ++++-------------- .../app/src/systems/Swap/utils/queries.ts | 51 ++--- packages/app/src/types/index.ts | 2 + packages/config/eslint.js | 1 + 21 files changed, 612 insertions(+), 732 deletions(-) delete mode 100644 packages/app/src/systems/Swap/components/PricePerToken.tsx delete mode 100644 packages/app/src/systems/Swap/components/SwapComponent.tsx delete mode 100644 packages/app/src/systems/Swap/components/SwapPreview.tsx create mode 100644 packages/app/src/systems/Swap/components/SwapWidget.tsx create mode 100644 packages/app/src/systems/Swap/hooks/useSwap.tsx create mode 100644 packages/app/src/systems/Swap/hooks/useSwapButton.ts create mode 100644 packages/app/src/systems/Swap/hooks/useSwapCoinInput.ts create mode 100644 packages/app/src/systems/Swap/hooks/useSwapCoinSelector.ts create mode 100644 packages/app/src/systems/Swap/machines/swapMachine.ts delete mode 100644 packages/app/src/systems/Swap/state.ts diff --git a/packages/app/src/systems/Core/components/CoinInput.tsx b/packages/app/src/systems/Core/components/CoinInput.tsx index 140e8ff28..0247f40da 100644 --- a/packages/app/src/systems/Core/components/CoinInput.tsx +++ b/packages/app/src/systems/Core/components/CoinInput.tsx @@ -167,7 +167,7 @@ function getRightValue(value: string, displayType: string) { return value === "0.0" ? "0" : value; } -type CoinInputProps = Omit & +export type CoinInputProps = Omit & NumberInputProps & { value: string; displayType: DisplayType; @@ -223,7 +223,7 @@ export const CoinInput = forwardRef( setValue(e.target.value); }} decimalScale={DECIMAL_UNITS} - placeholder="0" + placeholder={props.placeholder || "0"} className="coinInput--input" thousandSeparator={false} onInput={onInput} diff --git a/packages/app/src/systems/Core/utils/math.ts b/packages/app/src/systems/Core/utils/math.ts index 177980091..9c8dedde8 100644 --- a/packages/app/src/systems/Core/utils/math.ts +++ b/packages/app/src/systems/Core/utils/math.ts @@ -67,6 +67,14 @@ export function parseToFormattedNumber( return ethers.commify(toFixed(formatUnits(val, precision), FIXED_UNITS)); } +export const parseValueBigInt = (value: string) => { + if (value !== '') { + const nextValue = value === '.' ? '0.' : value; + return toBigInt(parseUnits(nextValue)); + } + return ZERO; +}; + export function multiplyFn(value?: BigNumberish | null, by?: BigNumberish | null) { return new Decimal(value?.toString() || 0).mul(by?.toString() || 0).toNumber(); } diff --git a/packages/app/src/systems/Pool/hooks/useUserPositions.ts b/packages/app/src/systems/Pool/hooks/useUserPositions.ts index 1c06f921d..669d6820c 100644 --- a/packages/app/src/systems/Pool/hooks/useUserPositions.ts +++ b/packages/app/src/systems/Pool/hooks/useUserPositions.ts @@ -10,6 +10,13 @@ import { multiplyFn, toFixed, } from '~/systems/Core'; +import type { PoolInfo } from '~/types/contracts/ExchangeContractAbi'; + +export async function getPoolRatio(info?: PoolInfo | null) { + const tokenReserve = toBigInt(info?.token_reserve || ZERO); + const ethReserve = toBigInt(info?.eth_reserve || ZERO); + return divideFnValidOnly(ethReserve, tokenReserve); +} export function useUserPositions() { const { data: info } = usePoolInfo(); @@ -24,6 +31,7 @@ export function useUserPositions() { const totalLiquidity = toBigInt(info?.lp_token_supply || ZERO); const tokenReserve = toBigInt(info?.token_reserve || ZERO); const ethReserve = toBigInt(info?.eth_reserve || ZERO); + const poolRatio = getPoolRatio(info); const formattedTokenReserve = parseToFormattedNumber(tokenReserve); const formattedEthReserve = parseToFormattedNumber(ethReserve); @@ -38,8 +46,6 @@ export function useUserPositions() { const formattedPoolShare = toFixed(poolShare * 100); const hasPositions = poolTokensNum > ZERO; - const poolRatio = divideFnValidOnly(ethReserve, tokenReserve); - return { pooledDAI, pooledETH, diff --git a/packages/app/src/systems/Swap/components/PricePerToken.tsx b/packages/app/src/systems/Swap/components/PricePerToken.tsx deleted file mode 100644 index 9150061e5..000000000 --- a/packages/app/src/systems/Swap/components/PricePerToken.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import { useEffect, useState } from "react"; -import { AiOutlineSwap } from "react-icons/ai"; - -import { useValueIsTyping } from "../state"; -import type { SwapState } from "../types"; -import { ActiveInput } from "../types"; - -import { - toNumber, - divideFnValidOnly, - ZERO, - toFixed, - ONE_ASSET, -} from "~/systems/Core"; -import { Button } from "~/systems/UI"; - -const style = { - wrapper: `flex items-center gap-3 my-4 px-2 text-sm text-gray-400`, - priceContainer: `min-w-[150px] cursor-pointer`, -}; - -function getPricePerToken( - fromAmount?: bigint | null, - toAmount?: bigint | null -) { - if (!toAmount || !fromAmount) return ""; - const ratio = divideFnValidOnly(toAmount, fromAmount); - const price = ratio * toNumber(ONE_ASSET); - return toFixed(price / toNumber(ONE_ASSET)); -} - -type PricePerTokenProps = { - swapState?: SwapState | null; - previewAmount?: bigint | null; - isLoading?: boolean; -}; - -type Asset = { - symbol: string; - amount: bigint; -}; - -const createAsset = ( - symbol?: string | null, - amount?: bigint | null -): Asset => ({ - symbol: symbol || "", - amount: amount || ZERO, -}); - -export function PricePerToken({ - swapState, - previewAmount, - isLoading, -}: PricePerTokenProps) { - const [[assetFrom, assetTo], setAssets] = useState< - [Asset | null, Asset | null] - >([null, null]); - const isTyping = useValueIsTyping(); - - useEffect(() => { - if (swapState?.direction === ActiveInput.from) { - setAssets([ - createAsset(swapState.coinFrom.symbol, swapState.amount), - createAsset(swapState.coinTo.symbol, previewAmount), - ]); - } else if (swapState) { - setAssets([ - createAsset(swapState.coinFrom.symbol, previewAmount), - createAsset(swapState.coinTo.symbol, swapState.amount), - ]); - } - }, [swapState, previewAmount]); - - function toggle() { - setAssets([assetTo, assetFrom]); - } - - if (isTyping || isLoading) return null; - if (!assetFrom?.amount || !assetTo?.amount) return null; - const pricePerToken = getPricePerToken(assetFrom.amount, assetTo.amount); - - return ( -
-
- 1 {assetFrom.symbol} ={" "} - {pricePerToken} {assetTo.symbol} -
- -
- ); -} diff --git a/packages/app/src/systems/Swap/components/SwapComponent.tsx b/packages/app/src/systems/Swap/components/SwapComponent.tsx deleted file mode 100644 index aa48ab35f..000000000 --- a/packages/app/src/systems/Swap/components/SwapComponent.tsx +++ /dev/null @@ -1,162 +0,0 @@ -import { useAtom, useAtomValue } from "jotai"; -import { startTransition, useEffect } from "react"; - -import { - swapAmountAtom, - swapActiveInputAtom, - swapCoinsAtom, - swapHasSwappedAtom, - useSetIsTyping, -} from "../state"; -import type { SwapState } from "../types"; -import { ActiveInput } from "../types"; - -import { CoinInput, useCoinInput, CoinSelector } from "~/systems/Core"; -import { InvertButton } from "~/systems/UI"; -import type { Coin } from "~/types"; - -const style = { - switchDirection: `flex items-center sm:justify-center -my-5`, -}; - -type SwapComponentProps = { - previewAmount?: bigint | null; - networkFee?: bigint | null; - onChange?: (swapState: SwapState) => void; - isLoading?: boolean; -}; - -export function SwapComponent({ - onChange, - isLoading, - previewAmount, - networkFee, -}: SwapComponentProps) { - const [initialAmount, setInitialAmount] = useAtom(swapAmountAtom); - const [activeInput, setActiveInput] = useAtom(swapActiveInputAtom); - const [[coinFrom, coinTo], setCoins] = useAtom(swapCoinsAtom); - const hasSwapped = useAtomValue(swapHasSwappedAtom); - const setTyping = useSetIsTyping(); - - const handleInvertCoins = () => { - setTyping(true); - if (activeInput === ActiveInput.to) { - const from = fromInput.amount; - startTransition(() => { - setActiveInput(ActiveInput.from); - fromInput.setAmount(toInput.amount); - toInput.setAmount(from); - }); - } else { - const to = toInput.amount; - startTransition(() => { - setActiveInput(ActiveInput.to); - toInput.setAmount(fromInput.amount); - fromInput.setAmount(to); - }); - } - setCoins([coinTo, coinFrom]); - }; - - const fromInput = useCoinInput({ - coin: coinFrom, - disableWhenEth: true, - gasFee: networkFee, - onChangeCoin: (coin: Coin) => { - setCoins([coin, coinTo]); - }, - onInput: () => { - setTyping(true); - setActiveInput(ActiveInput.from); - }, - }); - - const toInput = useCoinInput({ - coin: coinTo, - disableWhenEth: true, - onChangeCoin: (coin: Coin) => { - setCoins([coinFrom, coin]); - }, - onInput: () => { - setTyping(true); - setActiveInput(ActiveInput.to); - }, - }); - - useEffect(() => { - if (activeInput === ActiveInput.to) { - toInput.setAmount(initialAmount); - } else { - fromInput.setAmount(initialAmount); - } - }, []); - - useEffect(() => { - const currentInput = activeInput === ActiveInput.from ? fromInput : toInput; - const amount = currentInput.amount; - - // This is used to reset preview amount when set first input value for null - if (activeInput === ActiveInput.from && amount === null) { - toInput.setAmount(null); - } - if (activeInput === ActiveInput.to && amount === null) { - fromInput.setAmount(null); - } - - // Set value to hydrate - setInitialAmount(amount); - - if (coinFrom && coinTo) { - // Call on onChange - onChange?.({ - amount, - amountFrom: fromInput.amount, - coinFrom, - coinTo, - direction: activeInput, - hasBalance: fromInput.hasEnoughBalance, - }); - } - }, [fromInput.amount, toInput.amount, coinFrom, coinTo]); - - useEffect(() => { - if (activeInput === ActiveInput.from) { - toInput.setAmount(previewAmount || null); - } else { - fromInput.setAmount(previewAmount || null); - } - }, [previewAmount]); - - useEffect(() => { - if (hasSwapped) { - toInput.setAmount(null); - fromInput.setAmount(null); - } - }, [hasSwapped]); - - return ( - <> -
- } - /> -
-
- -
-
- } - /> -
- - ); -} diff --git a/packages/app/src/systems/Swap/components/SwapPreview.tsx b/packages/app/src/systems/Swap/components/SwapPreview.tsx deleted file mode 100644 index fcfdac635..000000000 --- a/packages/app/src/systems/Swap/components/SwapPreview.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import { BsArrowDown } from "react-icons/bs"; - -import { useValueIsTyping } from "../state"; -import type { SwapInfo } from "../types"; -import { ActiveInput } from "../types"; -import { calculatePriceWithSlippage, calculatePriceImpact } from "../utils"; - -import { - PreviewItem, - PreviewTable, - useSlippage, - ZERO, - parseToFormattedNumber, - NetworkFeePreviewItem, -} from "~/systems/Core"; - -type SwapPreviewProps = { - swapInfo: SwapInfo; - isLoading: boolean; - networkFee?: bigint | null; -}; - -export function SwapPreview({ - swapInfo, - networkFee, - isLoading, -}: SwapPreviewProps) { - const { amount, previewAmount, direction, coinFrom, coinTo } = swapInfo; - const isTyping = useValueIsTyping(); - const slippage = useSlippage(); - - if ( - !coinFrom || - !coinTo || - !previewAmount || - !direction || - !amount || - isLoading || - isTyping - ) { - return null; - } - - // Expected amount of tokens to be received - const nextAmount = - direction === ActiveInput.from ? previewAmount : amount || ZERO; - - const outputAmount = parseToFormattedNumber(nextAmount); - const priceWithSlippage = calculatePriceWithSlippage( - previewAmount, - slippage.value, - direction - ); - const inputAmountWithSlippage = parseToFormattedNumber(priceWithSlippage); - - return ( -
-
- -
- - - - - - -
- ); -} diff --git a/packages/app/src/systems/Swap/components/SwapWidget.tsx b/packages/app/src/systems/Swap/components/SwapWidget.tsx new file mode 100644 index 000000000..1886cedc7 --- /dev/null +++ b/packages/app/src/systems/Swap/components/SwapWidget.tsx @@ -0,0 +1,35 @@ +import { useSwap } from "../hooks/useSwap"; +import { useSwapCoinInput } from "../hooks/useSwapCoinInput"; +import { useSwapCoinSelector } from "../hooks/useSwapCoinSelector"; +import { FROM_TO, TO_FROM } from "../machines/swapMachine"; + +import { CoinInput, CoinSelector } from "~/systems/Core"; +import { InvertButton } from "~/systems/UI"; + +export function SwapWidget() { + const { onInvertCoins } = useSwap(); + const coinSelectorFromProps = useSwapCoinSelector(FROM_TO); + const coinSelectorToProps = useSwapCoinSelector(TO_FROM); + const coinInputFromProps = useSwapCoinInput(FROM_TO); + const coinInputToProps = useSwapCoinInput(TO_FROM); + + return ( + <> +
+ } + /> +
+
+ +
+
+ } + /> +
+ + ); +} diff --git a/packages/app/src/systems/Swap/components/index.tsx b/packages/app/src/systems/Swap/components/index.tsx index a9e1ece3a..8337712ea 100644 --- a/packages/app/src/systems/Swap/components/index.tsx +++ b/packages/app/src/systems/Swap/components/index.tsx @@ -1,3 +1 @@ -export * from "./PricePerToken"; -export * from "./SwapComponent"; -export * from "./SwapPreview"; +// diff --git a/packages/app/src/systems/Swap/hooks/useSwap.tsx b/packages/app/src/systems/Swap/hooks/useSwap.tsx new file mode 100644 index 000000000..8481afde7 --- /dev/null +++ b/packages/app/src/systems/Swap/hooks/useSwap.tsx @@ -0,0 +1,98 @@ +import { useMachine, useSelector } from "@xstate/react"; +import type { ReactNode } from "react"; +import { createContext, useContext } from "react"; + +import type { + SwapMachineService, + SwapMachineState, +} from "../machines/swapMachine"; +import { FROM_TO, swapMachine } from "../machines/swapMachine"; + +import { useWallet, useContract, TOKENS, useSlippage } from "~/systems/Core"; + +export function isLoadingState(state: SwapMachineState) { + return ( + state.matches("fetchingBalances") || + state.matches("fetchingTxCost") || + state.matches("checkPoolCreated") || + state.matches("fetchingPreview") + ); +} + +const selectors = { + isLoading: isLoadingState, + direction: (state: SwapMachineState) => state.context.direction, + canSwap: (state: SwapMachineState) => { + const isLoading = isLoadingState(state); + const hasPreview = state.context.previewInfo; + return !isLoading && hasPreview; + }, +}; + +// ---------------------------------------------------------------------------- +// SwapContext +// ---------------------------------------------------------------------------- + +type Context = { + state: SwapMachineState; + send: SwapMachineService["send"]; + service: SwapMachineService; +}; + +const swapContext = createContext({} as Context); + +export function useSwapContext() { + return useContext(swapContext); +} + +export function useSwap() { + const { send, service } = useContext(swapContext); + const isLoading = useSelector(service, selectors.isLoading); + const direction = useSelector(service, selectors.direction); + const canSwap = useSelector(service, selectors.canSwap); + + function onInvertCoins() { + send("INVERT_COINS"); + } + + // TODO: implement swap function using old algorithm + function onSwap() { + // + } + + return { + onSwap, + onInvertCoins, + state: { + isLoading, + direction, + canSwap, + }, + }; +} + +type SwapProviderProps = { + children: ReactNode; +}; + +export function SwapProvider({ children }: SwapProviderProps) { + const wallet = useWallet(); + const contract = useContract(); + const slippage = useSlippage(); + + const [state, send, service] = useMachine(() => + swapMachine.withContext({ + wallet, + contract, + direction: FROM_TO, + coinFrom: TOKENS[0], + slippage: slippage.value, + }) + ); + + return ( + + {children} + + ); +} diff --git a/packages/app/src/systems/Swap/hooks/useSwapButton.ts b/packages/app/src/systems/Swap/hooks/useSwapButton.ts new file mode 100644 index 000000000..fcbaa1fc6 --- /dev/null +++ b/packages/app/src/systems/Swap/hooks/useSwapButton.ts @@ -0,0 +1,51 @@ +import { useSelector } from '@xstate/react'; + +import type { SwapMachineState } from '../machines/swapMachine'; +import { FROM_TO, TO_FROM } from '../machines/swapMachine'; +import { notHasEnoughBalance, notHasBalancePlusSlippage, notHasLiquidityForSwap } from '../utils'; + +import { useSwap, useSwapContext, isLoadingState } from './useSwap'; + +// TODO: add tests for all this cases +const selectors = { + buttonText: (state: SwapMachineState) => { + const ctx = state.context; + const { coinFrom, coinTo, toAmount, coinToBalance } = ctx; + + if (isLoadingState(state)) { + return 'Loading...'; + } + if (notHasLiquidityForSwap(ctx)) { + return 'Insufficient Liquidity'; + } + if (notHasBalancePlusSlippage(ctx)) { + return `Insufficient ${coinFrom?.name || ''} balance`; + } + if (notHasEnoughBalance(toAmount?.raw, coinToBalance)) { + return `Insufficient ${coinTo?.name || ''} balance`; + } + if (!ctx.coinFrom || !ctx.coinTo) { + const opposite = ctx.direction === FROM_TO ? TO_FROM : FROM_TO; + return `Select ${opposite} token`; + } + if (!ctx.poolRatio) { + return 'No pool found'; + } + return 'Enter amount'; + }, +}; + +export function useSwapButton() { + const { service } = useSwapContext(); + const { state, onSwap } = useSwap(); + const text = useSelector(service, selectors.buttonText); + + return { + text, + props: { + isLoading: state.isLoading, + isDisabled: !state.canSwap, + onPress: onSwap, + }, + }; +} diff --git a/packages/app/src/systems/Swap/hooks/useSwapCoinInput.ts b/packages/app/src/systems/Swap/hooks/useSwapCoinInput.ts new file mode 100644 index 000000000..d7027f63a --- /dev/null +++ b/packages/app/src/systems/Swap/hooks/useSwapCoinInput.ts @@ -0,0 +1,64 @@ +import { useSelector } from '@xstate/react'; +import type React from 'react'; +import { useEffect, useRef } from 'react'; +import type { NumberFormatValues } from 'react-number-format'; + +import type { SwapMachineState } from '../machines/swapMachine'; +import { FROM_TO, TO_FROM } from '../machines/swapMachine'; +import type { SwapDirection } from '../types'; + +import { useSwapContext } from './useSwap'; + +import type { CoinInputProps } from '~/systems/Core'; +import { MAX_U64_VALUE, parseValueBigInt } from '~/systems/Core'; + +const selectors = { + activeDir: (state: SwapMachineState) => state.context.direction, + fromAmount: (state: SwapMachineState) => state.context.fromAmount?.value || '', + toAmount: (state: SwapMachineState) => state.context.toAmount?.value || '', + isDisabled: (dir: SwapDirection) => (state: SwapMachineState) => { + const { coinFrom, coinTo } = state.context; + return (dir === FROM_TO && !coinFrom) || (dir === TO_FROM && !coinTo); + }, +}; + +type UseCoinInputReturn = CoinInputProps & { ref: React.ForwardedRef }; + +// TODO: check useCoinInput on CoinInput to find missing pieces +export function useSwapCoinInput(direction: SwapDirection): UseCoinInputReturn { + const { service, send } = useSwapContext(); + const ref = useRef(null); + const isFrom = direction === FROM_TO; + const coinAmount = useSelector(service, isFrom ? selectors.fromAmount : selectors.toAmount); + const activeDir = useSelector(service, selectors.activeDir); + const isDisabled = useSelector(service, selectors.isDisabled(direction)); + + const isAllowed = ({ value: val }: NumberFormatValues) => { + return parseValueBigInt(val) <= MAX_U64_VALUE; + }; + + function onFocus() { + send('SET_DIRECTION', { data: direction }); + } + function onChange(val: string) { + send('INPUT_CHANGE', { data: val }); + } + + useEffect(() => { + if (!isDisabled && activeDir === direction) { + ref.current?.focus(); + } + }, [activeDir, isDisabled]); + + return { + ref, + isAllowed, + onChange, + onFocus, + value: coinAmount, + displayType: 'input', + disabled: isDisabled, + 'aria-label': `Coin ${isFrom ? 'from' : 'to'} amount`, + ...(isDisabled && { placeholder: 'Select...' }), + }; +} diff --git a/packages/app/src/systems/Swap/hooks/useSwapCoinSelector.ts b/packages/app/src/systems/Swap/hooks/useSwapCoinSelector.ts new file mode 100644 index 000000000..5907d593d --- /dev/null +++ b/packages/app/src/systems/Swap/hooks/useSwapCoinSelector.ts @@ -0,0 +1,40 @@ +import { useSelector } from '@xstate/react'; +import { useMemo } from 'react'; + +import type { SwapMachineState } from '../machines/swapMachine'; +import { FROM_TO } from '../machines/swapMachine'; +import type { SwapDirection } from '../types'; + +import { useSwapContext } from './useSwap'; + +import type { CoinSelectorProps } from '~/systems/Core'; +import { COIN_ETH } from '~/systems/Core'; + +const selectors = { + coinFrom: (state: SwapMachineState) => state.context.coinFrom, + fromBalance: (state: SwapMachineState) => state.context.coinFromBalance, + coinTo: (state: SwapMachineState) => state.context.coinTo, + toBalance: (state: SwapMachineState) => state.context.coinToBalance, +}; + +// TODO: check useCoinInput on CoinInput to find missing pieces +export function useSwapCoinSelector(direction: SwapDirection): CoinSelectorProps { + const { service, send } = useSwapContext(); + const isFrom = direction === FROM_TO; + const coin = useSelector(service, isFrom ? selectors.coinFrom : selectors.coinTo); + const balance = useSelector(service, isFrom ? selectors.fromBalance : selectors.toBalance); + const isETH = useMemo(() => coin?.assetId === COIN_ETH, [coin?.assetId]); + + return { + coin, + showBalance: Boolean(balance), + showMaxButton: Boolean(coin) && Boolean(balance), + onChange: (item) => { + send('SELECT_COIN', { data: { direction, coin: item } }); + }, + ...(isETH && { + isReadOnly: true, + tooltip: 'Currently, we only support ETH to TOKEN.', + }), + }; +} diff --git a/packages/app/src/systems/Swap/machines/swapMachine.ts b/packages/app/src/systems/Swap/machines/swapMachine.ts new file mode 100644 index 000000000..4ea889900 --- /dev/null +++ b/packages/app/src/systems/Swap/machines/swapMachine.ts @@ -0,0 +1,186 @@ +import type { CoinQuantity } from 'fuels'; +import type { InterpreterFrom, StateFrom } from 'xstate'; +import { assign, createMachine } from 'xstate'; + +import type { SwapMachineContext } from '../types'; +import { SwapDirection } from '../types'; +import { queryNetworkFeeOnSwap } from '../utils'; + +import { parseValueBigInt } from '~/systems/Core'; +import type { TransactionCost } from '~/systems/Core/utils/gas'; +import { emptyTransactionCost, getTransactionCost } from '~/systems/Core/utils/gas'; +import { getPoolRatio } from '~/systems/Pool'; +import type { Coin } from '~/types'; + +export const FROM_TO = SwapDirection.fromTo; +export const TO_FROM = SwapDirection.toFrom; + +// ---------------------------------------------------------------------------- +// Machine +// ---------------------------------------------------------------------------- + +type MachineEvents = + | { type: ''; data: CoinQuantity[] } + | { type: ''; data: TransactionCost } + | { type: 'SET_DIRECTION'; data: string } + | { type: 'INPUT_CHANGE'; data: string } + | { type: 'SELECT_COIN'; data: { direction: SwapDirection; coin: Coin } }; + +const machine = createMachine({ + schema: { + context: {} as SwapMachineContext, + events: {} as MachineEvents, + }, + id: 'swap', + initial: 'fetchingBalances', + states: { + fetchingBalances: { + invoke: { + src: 'fetchBalances', + onDone: { + actions: ['setBalances'], + target: 'fetchingTxCost', + }, + }, + }, + fetchingTxCost: { + invoke: { + src: 'fetchTxCost', + onDone: { + actions: ['setTxCost'], + target: 'checkPoolCreated', + }, + }, + }, + checkPoolCreated: { + invoke: { + src: 'fetchPoolRatio', + onDone: { + actions: ['setPoolInfo', 'setPoolRatio'], + target: 'waitingAmount', + }, + }, + }, + // debouncing: { + // after: { + // 450: 'fetchingPreview', + // }, + // }, + waitingAmount: { + on: { + INPUT_CHANGE: { + actions: ['setActiveValue'], + }, + }, + }, + fetchingPreview: {}, + }, + on: { + SET_DIRECTION: { + actions: ['setDirection'], + }, + INVERT_COINS: { + actions: ['invertDirection'], + target: 'fetchingBalances', + }, + SELECT_COIN: { + actions: ['selectCoin'], + target: 'fetchingBalances', + }, + }, +}); + +export const swapMachine = machine.withConfig({ + services: { + fetchBalances: async (ctx) => { + const { coinFrom, coinTo, wallet } = ctx; + const fromID = coinFrom?.assetId; + const toID = coinTo?.assetId; + const fromBalance = coinFrom && (await wallet?.getBalance(fromID)); + const toBalance = coinTo && (await wallet?.getBalance(toID)); + + return { + fromBalance, + toBalance, + }; + }, + fetchTxCost: async (ctx) => { + if (!ctx.contract) { + throw new Error('Contract not found'); + } + const networkFee = queryNetworkFeeOnSwap(ctx.contract, ctx.direction); + const txCost = getTransactionCost(networkFee); + return txCost || emptyTransactionCost(); + }, + fetchPoolRatio: async (ctx) => { + if (!ctx.contract) { + throw new Error('Contract not found'); + } + const info = await ctx.contract?.dryRun.get_pool_info(); + const ratio = await getPoolRatio(info); + return { info, ratio }; + }, + }, + actions: { + setBalances: assign((ctx, ev) => ({ + ...ctx, + coinFromBalance: ev.data.fromBalance, + coinToBalance: ev.data.toBalance, + })), + setTxCost: assign({ + txCost: (_, ev) => ev.data as TransactionCost, + }), + setPoolInfo: assign({ + poolInfo: (_, ev) => ev.data.info, + }), + setPoolRatio: assign({ + poolRatio: (_, ev) => ev.data.ratio, + }), + selectCoin: assign((ctx, ev) => ({ + ...ctx, + ...(ev.data.direction === FROM_TO && { + coinFrom: ev.data.coin, + }), + ...(ev.data.direction === TO_FROM && { + coinTo: ev.data.coin, + }), + })), + invertDirection: assign((ctx) => { + const isFrom = ctx.direction === FROM_TO; + return { + direction: isFrom ? TO_FROM : FROM_TO, + coinFrom: ctx.coinTo, + coinTo: ctx.coinFrom, + fromAmount: ctx.toAmount, + toAmount: ctx.fromAmount, + }; + }), + setDirection: assign({ + direction: (_, ev) => ev.data, + }), + setActiveValue: assign((ctx, ev) => { + const isFrom = ctx.direction === FROM_TO; + const next = { + value: ev.data, + raw: parseValueBigInt(ev.data), + }; + return { + ...ctx, + ...(isFrom ? { fromAmount: next } : { toAmount: next }), + }; + }), + }, +}); + +export type SwapMachine = typeof swapMachine; +export type SwapMachineService = InterpreterFrom; +export type SwapMachineState = StateFrom; + +export function isLoadingState(state: SwapMachineState) { + return ( + state.matches('fetchingBalances') || + state.matches('fetchingTxCost') || + state.matches('checkPoolCreated') || + state.matches('fetchingPreview') + ); +} diff --git a/packages/app/src/systems/Swap/pages/SwapPage.tsx b/packages/app/src/systems/Swap/pages/SwapPage.tsx index 6a1d6a3b7..d3f895810 100644 --- a/packages/app/src/systems/Swap/pages/SwapPage.tsx +++ b/packages/app/src/systems/Swap/pages/SwapPage.tsx @@ -1,131 +1,12 @@ -import { useSetAtom } from "jotai"; -import { useMemo, useState } from "react"; -import toast from "react-hot-toast"; import { MdSwapCalls } from "react-icons/md"; -import { useMutation, useQuery } from "react-query"; -import { SwapComponent, SwapPreview, PricePerToken } from "../components"; -import { swapHasSwappedAtom } from "../state"; -import type { SwapState, SwapInfo } from "../types"; -import { ValidationStateEnum } from "../types"; -import { - hasReserveAmount, - queryPreviewAmount, - swapTokens, - getValidationState, - getValidationText, - queryNetworkFee, - SwapQueries, -} from "../utils"; +import { SwapWidget } from "../components/SwapWidget"; +import { useSwapButton } from "../hooks/useSwapButton"; -import { - useDebounce, - useBalances, - useContract, - useSlippage, - ZERO, - isSwayInfinity, -} from "~/systems/Core"; -import { useTransactionCost } from "~/systems/Core/hooks/useTransactionCost"; -import { usePoolInfo, useUserPositions } from "~/systems/Pool"; import { Button, Card } from "~/systems/UI"; -import type { PreviewInfo } from "~/types/contracts/ExchangeContractAbi"; export function SwapPage() { - const contract = useContract()!; - const [previewInfo, setPreviewInfo] = useState(null); - const [swapState, setSwapState] = useState(null); - const [hasLiquidity, setHasLiquidity] = useState(true); - const debouncedState = useDebounce(swapState); - const { data: poolInfo } = usePoolInfo(); - - const previewAmount = previewInfo?.amount || ZERO; - const swapInfo = useMemo( - () => ({ - ...poolInfo, - ...previewInfo, - ...swapState, - previewAmount, - }), - [poolInfo, previewInfo, swapState] - ); - - const slippage = useSlippage(); - const balances = useBalances(); - const setHasSwapped = useSetAtom(swapHasSwappedAtom); - const { poolRatio } = useUserPositions(); - - const { isLoading } = useQuery( - [ - SwapQueries.SwapPreview, - debouncedState?.amount?.toString(), - debouncedState?.direction, - debouncedState?.coinFrom.assetId, - debouncedState?.coinTo.assetId, - ], - async () => { - // This is a hard coded solution, need to be dynamic in future - if (!poolRatio) { - setHasLiquidity(false); - return; - } - if (!debouncedState?.amount) return null; - if (!hasReserveAmount(debouncedState, poolInfo)) return null; - return queryPreviewAmount(contract, debouncedState); - }, - { - onSuccess: (preview) => { - if (preview == null) { - setPreviewInfo(null); - } else if (isSwayInfinity(preview.amount)) { - setPreviewInfo(null); - setHasLiquidity(false); - } else { - setHasLiquidity(preview.has_liquidity); - setPreviewInfo(preview); - } - }, - } - ); - - const txCost = useTransactionCost( - [SwapQueries.NetworkFee, swapState?.direction], - () => queryNetworkFee(contract, swapState?.direction) - ); - - const { mutate: swap, isLoading: isSwapping } = useMutation( - async () => { - if (!swapState) return; - if (!txCost?.gas || txCost.error) return; - setHasSwapped(false); - await swapTokens(contract, swapState, txCost); - }, - { - onSuccess: async () => { - setHasSwapped(true); - toast.success("Swap made successfully!"); - await balances.refetch(); - }, - } - ); - - function handleSwap(state: SwapState) { - setSwapState(state); - } - - const validationState = getValidationState({ - swapState, - previewAmount, - hasLiquidity, - balances: balances.data, - slippage: slippage.value, - txCost, - }); - - const shouldDisableSwap = - isLoading || validationState !== ValidationStateEnum.Swap; - - const btnText = getValidationText(validationState, swapState); + const button = useSwapButton(); return ( @@ -133,32 +14,15 @@ export function SwapPage() { Swap - - - + ); diff --git a/packages/app/src/systems/Swap/routes.tsx b/packages/app/src/systems/Swap/routes.tsx index 232467d47..b6a323a41 100644 --- a/packages/app/src/systems/Swap/routes.tsx +++ b/packages/app/src/systems/Swap/routes.tsx @@ -1,7 +1,17 @@ import { Route } from "react-router-dom"; +import { SwapProvider } from "./hooks/useSwap"; import { SwapPage } from "./pages"; import { Pages } from "~/types"; -export const swapRoutes = } />; +export const swapRoutes = ( + + + + } + /> +); diff --git a/packages/app/src/systems/Swap/state.ts b/packages/app/src/systems/Swap/state.ts deleted file mode 100644 index 6c9006fd6..000000000 --- a/packages/app/src/systems/Swap/state.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { atom, useAtomValue, useSetAtom } from 'jotai'; -import { useRef } from 'react'; - -import { ActiveInput } from './types'; - -import { TOKENS } from '~/systems/Core'; -import type { Coin } from '~/types'; - -export const swapActiveInputAtom = atom(ActiveInput.from); -export const swapCoinsAtom = atom<[Coin, Coin]>([TOKENS[0], TOKENS[1]]); -export const swapIsTypingAtom = atom(false); - -export const useValueIsTyping = () => useAtomValue(swapIsTypingAtom); -export const useSetIsTyping = () => { - const setTyping = useSetAtom(swapIsTypingAtom); - const timeout = useRef(0); - - return (typing: boolean) => { - setTyping(typing); - if (typing) { - clearTimeout(timeout.current); - timeout.current = Number( - setTimeout(() => { - setTyping(false); - }, 600) - ); - } - }; -}; - -export const swapHasSwappedAtom = atom(false); -export const swapAmountAtom = atom(null); diff --git a/packages/app/src/systems/Swap/types.ts b/packages/app/src/systems/Swap/types.ts index ba5978597..2881f11c1 100644 --- a/packages/app/src/systems/Swap/types.ts +++ b/packages/app/src/systems/Swap/types.ts @@ -1,33 +1,34 @@ -import type { Coin } from '~/types'; -import type { PoolInfo } from '~/types/contracts/ExchangeContractAbi'; +import type { Wallet } from 'fuels'; -export enum ActiveInput { - 'from' = 'from', - 'to' = 'to', -} +import type { TransactionCost } from '../Core/utils/gas'; -export type SwapState = { - direction: ActiveInput; - coinFrom: Coin; - coinTo: Coin; - amount: bigint | null; - amountFrom: bigint | null; - hasBalance: boolean; -}; +import type { Coin, Maybe } from '~/types'; +import type { ExchangeContractAbi } from '~/types/contracts'; +import type { PoolInfo, PreviewInfo } from '~/types/contracts/ExchangeContractAbi'; -export type SwapInfo = Partial< - SwapState & - PoolInfo & { - previewAmount: bigint | null; - } ->; +export type CoinAmount = { + raw: bigint; + value: string; +}; -export enum ValidationStateEnum { - SelectToken = 0, - EnterAmount = 1, - InsufficientBalance = 2, - InsufficientAmount = 3, - InsufficientLiquidity = 4, - InsufficientFeeBalance = 5, - Swap = 6, +export enum SwapDirection { + 'fromTo' = 'from', + 'toFrom' = 'to', } + +export type SwapMachineContext = { + wallet?: Maybe; + contract: Maybe; + direction: SwapDirection; + coinFrom?: Coin; + coinTo?: Coin; + coinFromBalance?: Maybe; + coinToBalance?: Maybe; + fromAmount?: Maybe; + toAmount?: Maybe; + poolInfo?: Maybe; + poolRatio?: Maybe; + previewInfo?: Maybe; + txCost?: TransactionCost; + slippage?: number; +}; diff --git a/packages/app/src/systems/Swap/utils/helpers.ts b/packages/app/src/systems/Swap/utils/helpers.ts index ceb0c58a4..1bdf96cb8 100644 --- a/packages/app/src/systems/Swap/utils/helpers.ts +++ b/packages/app/src/systems/Swap/utils/helpers.ts @@ -1,160 +1,56 @@ -import type { CoinQuantity } from 'fuels'; +import type { SwapMachineContext } from '../types'; -import type { SwapInfo, SwapState } from '../types'; -import { ActiveInput, ValidationStateEnum } from '../types'; +import { COIN_ETH, multiplyFn, ZERO } from '~/systems/Core'; +import type { Maybe } from '~/types'; -import { COIN_ETH } from '~/systems/Core/utils/constants'; -import type { TransactionCost } from '~/systems/Core/utils/gas'; -import { - ZERO, - toNumber, - isSwayInfinity, - divideFnValidOnly, - multiplyFn, -} from '~/systems/Core/utils/math'; -import type { PoolInfo } from '~/types/contracts/ExchangeContractAbi'; - -export function getPriceImpact( - outputAmount: bigint, - inputAmount: bigint, - reserveInput: bigint, - reserveOutput: bigint -) { - const exchangeRateAfter = divideFnValidOnly(inputAmount, outputAmount); - const exchangeRateBefore = divideFnValidOnly(reserveInput, reserveOutput); - const result = (exchangeRateAfter / exchangeRateBefore - 1) * 100; - return result > 100 ? 100 : result.toFixed(2); -} - -export const calculatePriceImpact = ({ - direction, - amount, - coinFrom, - previewAmount, - token_reserve, - eth_reserve, -}: SwapInfo) => { - // If any value is 0 return 0 - if (!previewAmount || !amount || !token_reserve || !eth_reserve) return '0'; - - if (direction === ActiveInput.from) { - if (coinFrom?.assetId !== COIN_ETH) { - return getPriceImpact(previewAmount, amount, token_reserve, eth_reserve); - } - return getPriceImpact(previewAmount, amount, eth_reserve, token_reserve); - } - if (coinFrom?.assetId !== COIN_ETH) { - return getPriceImpact(amount, previewAmount, token_reserve, eth_reserve); - } - return getPriceImpact(amount, previewAmount, eth_reserve, token_reserve); -}; - -export const calculatePriceWithSlippage = ( - amount: bigint, - slippage: number, - direction: ActiveInput -) => { - let total = 0; - if (direction === ActiveInput.from) { - total = multiplyFn(amount, 1 - slippage); - } else { - total = multiplyFn(amount, 1 + slippage); - } +// TODO: add unit tests on this +// TODO: talk to Luiz about old version of this algorithm +export const calculatePriceWithSlippage = (amount: bigint, slippage: number) => { + const total = multiplyFn(amount, 1 + slippage); return BigInt(Math.trunc(total)); }; -type StateParams = { - swapState: SwapState | null; - previewAmount: bigint | null; - hasLiquidity: boolean; - slippage: number; - balances?: CoinQuantity[]; - txCost?: TransactionCost; -}; - -export const getValidationText = (state: ValidationStateEnum, swapState: SwapState | null) => { - switch (state) { - case ValidationStateEnum.SelectToken: - return 'Select token'; - case ValidationStateEnum.EnterAmount: - return 'Enter amount'; - case ValidationStateEnum.InsufficientBalance: - return `Insufficient ${swapState?.coinFrom.symbol || ''} balance`; - case ValidationStateEnum.InsufficientAmount: - return `Insufficient amount to swap`; - case ValidationStateEnum.InsufficientLiquidity: - return 'Insufficient liquidity'; - case ValidationStateEnum.InsufficientFeeBalance: - return 'Insufficient ETH for gas'; - default: - return 'Swap'; - } -}; +// TODO: add unit tests on this +export function notHasEnoughBalance(amount: Maybe, balance: Maybe) { + return amount && balance && amount > balance; +} -export const notHasBalanceWithSlippage = ({ - swapState, - previewAmount, +// TODO: add unit tests on this +export function notHasBalancePlusSlippage({ + coinFrom: coin, + fromAmount: amount, + coinFromBalance: balance, slippage, - balances, txCost, -}: StateParams) => { - if (swapState!.direction === ActiveInput.to) { - let amountWithSlippage = calculatePriceWithSlippage( - previewAmount || ZERO, - slippage, - swapState!.direction - ); - const currentBalance = toNumber( - balances?.find((coin) => coin.assetId === swapState!.coinFrom.assetId)?.amount || ZERO - ); - - if (swapState!.coinFrom.assetId === COIN_ETH) { - amountWithSlippage += txCost?.total || ZERO; - } +}: SwapMachineContext) { + if (!coin || !amount || !balance || !slippage) return false; - return amountWithSlippage > currentBalance; + let amountPlusSlippage = calculatePriceWithSlippage(amount.raw, slippage); + if (coin.assetId === COIN_ETH) { + amountPlusSlippage += txCost?.total || ZERO; } - return false; -}; + return amountPlusSlippage > balance; +} -const hasEthForNetworkFee = ({ balances, txCost }: StateParams) => { - const currentBalance = toNumber( - balances?.find((coin) => coin.assetId === COIN_ETH)?.amount || ZERO +// TODO: Add unit tests on this +export function notHasLiquidityForSwap({ + poolInfo, + coinFrom, + fromAmount, + coinTo, + toAmount, +}: SwapMachineContext) { + if (!coinFrom || !coinTo) return false; + + const ethReserve = poolInfo?.eth_reserve || ZERO; + const tokenReserve = poolInfo?.token_reserve || ZERO; + const fromIsETH = coinFrom.assetId === COIN_ETH; + const toIsETH = coinTo.assetId === COIN_ETH; + const fromBN = fromAmount?.raw || ZERO; + const toBN = toAmount?.raw || ZERO; + + return ( + (fromIsETH && (fromBN > ethReserve || toBN > tokenReserve)) || + (toIsETH && (toBN > ethReserve || fromBN > tokenReserve)) ); - return currentBalance > (txCost?.total || ZERO); -}; - -export const getValidationState = (stateParams: StateParams): ValidationStateEnum => { - const { swapState, previewAmount, hasLiquidity } = stateParams; - if (!swapState?.coinFrom || !swapState?.coinTo) { - return ValidationStateEnum.SelectToken; - } - if (!swapState?.amount) { - return ValidationStateEnum.EnterAmount; - } - if (!swapState.hasBalance || notHasBalanceWithSlippage(stateParams)) { - return ValidationStateEnum.InsufficientBalance; - } - if (!previewAmount) { - return ValidationStateEnum.InsufficientLiquidity; - } - if (!hasLiquidity || isSwayInfinity(previewAmount)) { - return ValidationStateEnum.InsufficientLiquidity; - } - if (!hasEthForNetworkFee(stateParams)) { - return ValidationStateEnum.InsufficientFeeBalance; - } - return ValidationStateEnum.Swap; -}; - -// If amount desired is bigger then -// the reserves return -export const hasReserveAmount = (swapState?: SwapState | null, poolInfo?: PoolInfo) => { - if (swapState?.direction === ActiveInput.to) { - if (swapState.coinTo.assetId === COIN_ETH) { - return (swapState.amount || 0) < (poolInfo?.eth_reserve || 0); - } - return (swapState.amount || 0) < (poolInfo?.token_reserve || 0); - } - return true; -}; +} diff --git a/packages/app/src/systems/Swap/utils/queries.ts b/packages/app/src/systems/Swap/utils/queries.ts index 803cc7416..fcfc86449 100644 --- a/packages/app/src/systems/Swap/utils/queries.ts +++ b/packages/app/src/systems/Swap/utils/queries.ts @@ -1,18 +1,13 @@ import type { ScriptTransactionRequest } from 'fuels'; -import type { SwapState } from '../types'; -import { ActiveInput } from '../types'; +import { SwapDirection } from '../types'; import { COIN_ETH } from '~/systems/Core'; import type { TransactionCost } from '~/systems/Core/utils/gas'; import { getOverrides } from '~/systems/Core/utils/gas'; +import type { Coin, Maybe } from '~/types'; import type { ExchangeContractAbi } from '~/types/contracts'; -export enum SwapQueries { - SwapPreview = 'SwapPage-SwapPreview', - NetworkFee = 'SwapPage-NetworkFee', -} - const getSwapWithMaximumRequiredAmount = async ( contract: ExchangeContractAbi, assetId: string, @@ -35,11 +30,18 @@ const getSwapWithMinimumMinAmount = async ( return minAmount; }; -export const queryPreviewAmount = async ( - contract: ExchangeContractAbi, - { amount, direction, coinFrom }: SwapState -) => { - if (direction === ActiveInput.to && amount) { +type SwapTokensParams = { + contract: ExchangeContractAbi; + coinFrom: Coin; + direction: SwapDirection; + amount: Maybe; + txCost: TransactionCost; +}; + +export const queryPreviewAmount = async (params: SwapTokensParams) => { + const { contract, coinFrom, direction, amount } = params; + + if (direction === SwapDirection.toFrom && amount) { const previewAmount = await getSwapWithMaximumRequiredAmount( contract, coinFrom.assetId, @@ -54,13 +56,11 @@ export const queryPreviewAmount = async ( return null; }; -export const swapTokens = async ( - contract: ExchangeContractAbi, - { coinFrom, direction, amount }: SwapState, - txCost: TransactionCost -) => { - const DEADLINE = 1000; - if (direction === ActiveInput.to && amount) { +const DEADLINE = 1000; +export const swapTokens = async (params: SwapTokensParams) => { + const { contract, coinFrom, direction, amount, txCost } = params; + + if (direction === SwapDirection.toFrom && amount) { const forwardAmount = await getSwapWithMaximumRequiredAmount( contract, coinFrom.assetId, @@ -78,7 +78,9 @@ export const swapTokens = async ( variableOutputs: 1, }) ); - } else if (direction === ActiveInput.from && amount) { + } + + if (direction === SwapDirection.fromTo && amount) { const minValue = await getSwapWithMinimumMinAmount(contract, coinFrom.assetId, amount); if (!minValue.has_liquidity) { throw new Error('Not enough liquidity on pool'); @@ -95,13 +97,12 @@ export const swapTokens = async ( } }; -export const queryNetworkFee = async ( +export const queryNetworkFeeOnSwap = async ( contract: ExchangeContractAbi, - direction?: ActiveInput + direction?: SwapDirection ): Promise => { - const DEADLINE = 1000; - const directionValue = direction || ActiveInput.from; - if (directionValue === ActiveInput.to) { + const directionValue = direction || SwapDirection.fromTo; + if (directionValue === SwapDirection.toFrom) { return contract.prepareCall.swap_with_maximum(1, DEADLINE, { forward: [1, COIN_ETH], variableOutputs: 1, diff --git a/packages/app/src/types/index.ts b/packages/app/src/types/index.ts index 86f6320d4..d415636c2 100644 --- a/packages/app/src/types/index.ts +++ b/packages/app/src/types/index.ts @@ -23,3 +23,5 @@ export enum Queries { UserQueryBalances = 'UserQueryBalances', FaucetQuery = 'FaucetQuery', } + +export type Maybe = T | undefined | null; diff --git a/packages/config/eslint.js b/packages/config/eslint.js index 199823da5..973e6f6df 100644 --- a/packages/config/eslint.js +++ b/packages/config/eslint.js @@ -55,6 +55,7 @@ module.exports = { alphabetize: { order: 'asc' }, }, ], + 'arrow-body-style': 'off', 'import/prefer-default-export': 'off', 'no-await-in-loop': 0, 'no-bitwise': 0,