From 652c83b3c09dde48f7f4f4c6aebce7ba88126d1e Mon Sep 17 00:00:00 2001 From: Pedro Nauck Date: Mon, 27 Jun 2022 19:45:05 -0300 Subject: [PATCH] refactor: add first version of refactoring on swap (#283) * chore: add Maybe type * refac: move useCoinInput to a separate file * refac: rename ActiveInput enum to SwapDirection * refac: code improvements * fix: dependency cycle * refac: use state machine on swap * refac: improve state machine * refactor: remove refetch balances on swap machine * fix: add insufficient eth for gas fee as first validation * chore: rename fetch pool info state * refac: add safeBigInt method * chore: udpate deps * fix: adapt swap to new CoinBalance component * fix: keep swap service between refreshs on dev * fix: tests * chore: some few small script changes * fix: swap url params for coin selected * fix: price impact calculation * fix: tests * chore: missing requested changes from review * chore: add tx feedback on swa --- .eslintignore | 3 +- .gitignore | 5 +- .prettierignore | 3 +- package.json | 8 +- packages/app/cypress/e2e/App.cy.ts | 21 +- packages/app/package.json | 13 +- .../scripts/contracts-init/initializePool.ts | 1 + packages/app/scripts/postinstall.sh | 2 + .../src/systems/Core/components/AssetItem.tsx | 4 +- .../systems/Core/components/CoinBalance.tsx | 34 +- .../src/systems/Core/components/CoinInput.tsx | 187 +------ .../systems/Core/components/CoinSelector.tsx | 17 +- .../Core/components/NetworkFeePreviewItem.tsx | 4 +- .../src/systems/Core/components/TokenIcon.tsx | 6 +- .../systems/Core/components/WalletInfo.tsx | 2 +- .../app/src/systems/Core/components/index.tsx | 1 + packages/app/src/systems/Core/context.tsx | 7 +- packages/app/src/systems/Core/hooks/index.ts | 3 + .../app/src/systems/Core/hooks/useBalances.ts | 15 +- .../src/systems/Core/hooks/useCoinInput.ts | 179 +++++++ .../app/src/systems/Core/hooks/usePubSub.ts | 18 + packages/app/src/systems/Core/utils/asset.ts | 12 - .../app/src/systems/Core/utils/constants.ts | 2 +- .../app/src/systems/Core/utils/feedback.tsx | 7 +- .../app/src/systems/Core/utils/helpers.ts | 17 +- packages/app/src/systems/Core/utils/index.ts | 6 +- packages/app/src/systems/Core/utils/math.ts | 34 +- .../src/systems/Core/utils/queryClient.tsx | 6 +- .../app/src/systems/Core/utils/relativeUrl.ts | 5 + .../app/src/systems/Core/utils/tokenList.ts | 2 +- .../src/systems/Faucet/hooks/useCaptcha.ts | 5 +- .../app/src/systems/Faucet/hooks/useFaucet.ts | 5 +- .../app/src/systems/Mint/hooks/useMint.ts | 4 +- .../Pool/components/AddLiquidityPreview.tsx | 3 +- .../components/RemoveLiquidityPreview.tsx | 5 +- .../Pool/hooks/__mocks__/useAddLiquidity.ts | 34 +- .../Pool/hooks/usePreviewAddLiquidity.ts | 3 +- .../systems/Pool/hooks/usePreviewLiquidity.ts | 21 +- .../Pool/hooks/usePreviewRemoveLiquidity.ts | 10 +- .../systems/Pool/hooks/useUserPositions.ts | 16 +- packages/app/src/systems/Pool/index.tsx | 3 +- .../systems/Pool/pages/AddLiquidity.test.tsx | 26 +- .../src/systems/Pool/pages/AddLiquidity.tsx | 18 +- .../app/src/systems/Pool/pages/PoolPage.tsx | 10 +- .../systems/Pool/pages/RemoveLiquidity.tsx | 5 +- packages/app/src/systems/Pool/routes.tsx | 11 +- packages/app/src/systems/Pool/state.ts | 6 +- .../app/src/systems/Pool/utils/helpers.ts | 8 + packages/app/src/systems/Pool/utils/index.ts | 1 + .../systems/Swap/components/PricePerToken.tsx | 83 +-- .../systems/Swap/components/SwapComponent.tsx | 165 ------ .../systems/Swap/components/SwapPreview.tsx | 74 +-- .../systems/Swap/components/SwapWidget.tsx | 55 ++ .../app/src/systems/Swap/components/index.tsx | 2 +- .../systems/Swap/hooks/usePricePerToken.tsx | 57 ++ .../app/src/systems/Swap/hooks/useSwap.tsx | 161 ++++++ .../src/systems/Swap/hooks/useSwapButton.tsx | 58 +++ .../systems/Swap/hooks/useSwapCoinInput.tsx | 77 +++ .../Swap/hooks/useSwapCoinSelector.tsx | 43 ++ .../systems/Swap/hooks/useSwapGlobalState.tsx | 9 + .../systems/Swap/hooks/useSwapMaxButton.tsx | 59 +++ .../src/systems/Swap/hooks/useSwapPreview.tsx | 56 ++ .../systems/Swap/hooks/useSwapURLParams.tsx | 49 ++ .../src/systems/Swap/machines/swapMachine.ts | 486 ++++++++++++++++++ .../src/systems/Swap/pages/SwapPage.test.tsx | 153 ++++-- .../app/src/systems/Swap/pages/SwapPage.tsx | 170 +----- packages/app/src/systems/Swap/routes.tsx | 5 +- packages/app/src/systems/Swap/state.ts | 8 +- packages/app/src/systems/Swap/types.ts | 65 ++- .../app/src/systems/Swap/utils/helpers.ts | 250 +++++---- .../app/src/systems/Swap/utils/queries.ts | 134 +++-- .../systems/UI/components/InvertButton.tsx | 11 +- .../src/systems/UI/components/NumberInput.tsx | 4 +- .../Welcome/components/WelcomePage.test.tsx | 7 +- .../systems/Welcome/hooks/useWelcomeSteps.tsx | 6 +- packages/app/src/types/index.ts | 2 + packages/config/eslint.js | 1 + packages/config/package.json | 4 +- packages/scripts/src/bin/bin.ts | 2 +- pnpm-lock.yaml | 108 +++- swayswap.config.ts | 5 +- 81 files changed, 2052 insertions(+), 1135 deletions(-) create mode 100644 packages/app/src/systems/Core/hooks/useCoinInput.ts create mode 100644 packages/app/src/systems/Core/hooks/usePubSub.ts delete mode 100644 packages/app/src/systems/Core/utils/asset.ts create mode 100644 packages/app/src/systems/Core/utils/relativeUrl.ts create mode 100644 packages/app/src/systems/Pool/utils/helpers.ts delete mode 100644 packages/app/src/systems/Swap/components/SwapComponent.tsx create mode 100644 packages/app/src/systems/Swap/components/SwapWidget.tsx create mode 100644 packages/app/src/systems/Swap/hooks/usePricePerToken.tsx create mode 100644 packages/app/src/systems/Swap/hooks/useSwap.tsx create mode 100644 packages/app/src/systems/Swap/hooks/useSwapButton.tsx create mode 100644 packages/app/src/systems/Swap/hooks/useSwapCoinInput.tsx create mode 100644 packages/app/src/systems/Swap/hooks/useSwapCoinSelector.tsx create mode 100644 packages/app/src/systems/Swap/hooks/useSwapGlobalState.tsx create mode 100644 packages/app/src/systems/Swap/hooks/useSwapMaxButton.tsx create mode 100644 packages/app/src/systems/Swap/hooks/useSwapPreview.tsx create mode 100644 packages/app/src/systems/Swap/hooks/useSwapURLParams.tsx create mode 100644 packages/app/src/systems/Swap/machines/swapMachine.ts diff --git a/.eslintignore b/.eslintignore index 1bd8e131..1e237e9d 100644 --- a/.eslintignore +++ b/.eslintignore @@ -4,4 +4,5 @@ dist CHANGELOG.md packages/app/src/types -contracts \ No newline at end of file +contracts +**/*.typegen.ts diff --git a/.gitignore b/.gitignore index 6e742899..8214947e 100644 --- a/.gitignore +++ b/.gitignore @@ -77,4 +77,7 @@ contracts/token_contract/.rustc_info.json .vercel # Local actions to test CI using ACT -act-actions \ No newline at end of file +act-actions + +# XState typegen +*.typegen.ts diff --git a/.prettierignore b/.prettierignore index 88f8c3a3..f4a7290a 100644 --- a/.prettierignore +++ b/.prettierignore @@ -11,4 +11,5 @@ yarn-lock.yaml .github packages/contracts .pnpm-store -.env \ No newline at end of file +.env +**/*.typegen.ts diff --git a/package.json b/package.json index 9dcbd315..869defc8 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "lint": "run-s lint:check prettier:check", "prettier:check": "prettier --check .", "prettier:format": "prettier --write .", - "scripts:setup": "turbo run build --filter=swayswap-scripts && pnpm install", + "scripts:setup": "pnpm run --filter=swayswap-scripts build && pnpm install", "services:clean": "make -C docker services-clean", "services:setup": "run-s scripts:setup services:run contracts contracts:init-pool", "services:run": "make -C docker services-run", @@ -48,9 +48,9 @@ "services:run-test": "make -C docker services-run-test", "services:reset-test": "NODE_ENV=test run-s services:clean-test services:setup-test", "deps:update": "./scripts/update-deps.sh", - "test": "turbo run test -- --passWithNoTests --runInBand", - "test:coverage": "turbo run test -- --collectCoverage --passWithNoTests --runInBand", - "test:clear": "jest --clearCache", + "test": "pnpm run --filter=swayswap-app test", + "test:coverage": "pnpm run --filter=swayswap-app test --coverage", + "test:clear": "pnpm -r exec jest --clearCache", "test:e2e": "run-p --race dev cy:run" }, "devDependencies": { diff --git a/packages/app/cypress/e2e/App.cy.ts b/packages/app/cypress/e2e/App.cy.ts index aa22347d..b44ef17c 100644 --- a/packages/app/cypress/e2e/App.cy.ts +++ b/packages/app/cypress/e2e/App.cy.ts @@ -9,14 +9,14 @@ describe('End-to-end Test: 😁 Happy Path', () => { cy.contains('button', 'Give me ETH').click(); cy.getByAriaLabel('Accept the use agreement').check(); cy.contains('button', 'Get Swapping!').click(); - cy.contains('Enter amount'); + cy.contains('Select to token'); // mint tokens cy.visit('/mint'); cy.contains('button', 'Mint tokens').click(); cy.contains('Token received successfully!'); // wait to be redirected to swap page after minting - cy.contains('Enter amount'); + cy.contains('Select to token'); // go to pool page -> add liquidity page cy.contains('button', 'Pool').click(); @@ -44,7 +44,7 @@ describe('End-to-end Test: 😁 Happy Path', () => { if (hasPoolCreated) { // validate add liquidity cy.contains('Enter Ether amount'); - cy.getByAriaLabel('Coin From Input').type('0.2'); + cy.getByAriaLabel('Coin from input').type('0.2'); // make sure preview output box shows up cy.getByAriaLabel('Preview Add Liquidity Output'); @@ -55,8 +55,8 @@ describe('End-to-end Test: 😁 Happy Path', () => { } else { // validate create pool cy.contains('Enter Ether amount'); - cy.getByAriaLabel('Coin From Input').type('0.2'); - cy.getByAriaLabel('Coin To Input').type('190'); + cy.getByAriaLabel('Coin from input').type('0.2'); + cy.getByAriaLabel('Coin to input').type('190'); // make sure preview output box shows up cy.getByAriaLabel('Preview Add Liquidity Output'); @@ -71,9 +71,13 @@ describe('End-to-end Test: 😁 Happy Path', () => { // validate swap cy.contains('button', 'Swap').click(); - cy.contains('Enter amount'); - cy.getByAriaLabel('Coin From Input').type('0.1'); + cy.contains('Select to token'); + cy.getByAriaLabel('Coin selector to').click(); + cy.get('[role=menu').type('{enter}'); + cy.getByAriaLabel('Coin from input').type('0.1'); + // make sure preview output box shows up + cy.contains('Swap'); cy.getByAriaLabel('Preview Swap Output'); // execute swap operation @@ -84,9 +88,10 @@ describe('End-to-end Test: 😁 Happy Path', () => { cy.contains('button', 'Pool').click(); cy.contains('button', 'Remove liquidity').click(); cy.getByAriaLabel('Set Maximun Balance').click(); - // + // make sure preview output box shows up cy.getByAriaLabel('Preview Remove Liquidity Output'); + // make sure current positions box shows up cy.getByAriaLabel('Pool Current Position'); cy.contains('button', 'Remove liquidity').click(); diff --git a/packages/app/package.json b/packages/app/package.json index 84fca031..79041849 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -6,13 +6,16 @@ "scripts": { "build": "tsc && vite build && pnpm create404", "create404": "cp ./dist/index.html ./dist/404.html", - "dev": "vite", + "dev": "run-p vite xstate:typegen:watch", "gh-preview": "sh ./scripts/gh-pages-preview.sh", "contracts:init": "pnpm exec ts-node ./scripts/contracts-init", "postinstall": "sh ./scripts/postinstall.sh", "preview": "vite preview", - "test": "jest --verbose", - "test:watch": "jest --watch --detectOpenHandles" + "test": "jest --verbose --runInBand", + "test:watch": "jest --watch --runInBand --detectOpenHandles", + "vite": "vite", + "xstate:typegen": "xstate typegen 'src/**/*.ts?(x)'", + "xstate:typegen:watch": "xstate typegen 'src/**/*.ts?(x)' --watch" }, "dependencies": { "@ethersproject/bignumber": "^5.6.2", @@ -47,10 +50,11 @@ "cross-fetch": "^3.1.5", "decimal.js": "^10.3.1", "ethers": "^5.6.9", - "framer-motion": "^6.3.12", + "framer-motion": "^6.3.15", "fuels": "0.0.0-master-b32f29b9", "graphql-request": "^4.3.0", "jotai": "^1.7.2", + "mitt": "^3.0.0", "react": "^18.2.0", "react-content-loader": "^6.2.0", "react-dom": "^18.2.0", @@ -80,6 +84,7 @@ "@types/react-google-recaptcha": "^2.1.5", "@types/react-helmet": "^6.1.5", "@vitejs/plugin-react": "^1.3.2", + "@xstate/cli": "^0.2.1", "autoprefixer": "^10.4.7", "dotenv": "^16.0.1", "eslint": "^8.18.0", diff --git a/packages/app/scripts/contracts-init/initializePool.ts b/packages/app/scripts/contracts-init/initializePool.ts index 99e19f38..6d3469e0 100644 --- a/packages/app/scripts/contracts-init/initializePool.ts +++ b/packages/app/scripts/contracts-init/initializePool.ts @@ -25,6 +25,7 @@ export async function initializePool( ...overrides, variableOutputs: 1, }); + const deadline = await wallet.provider.getBlockNumber(); process.stdout.write('Depositing ETH\n'); diff --git a/packages/app/scripts/postinstall.sh b/packages/app/scripts/postinstall.sh index 95ca5934..fafc9db6 100644 --- a/packages/app/scripts/postinstall.sh +++ b/packages/app/scripts/postinstall.sh @@ -4,3 +4,5 @@ ENV_FILE=.env if [ ! -f "$FILE" ]; then cp .env.example $ENV_FILE fi + +pnpm xstate:typegen diff --git a/packages/app/src/systems/Core/components/AssetItem.tsx b/packages/app/src/systems/Core/components/AssetItem.tsx index e06200ce..4111b372 100644 --- a/packages/app/src/systems/Core/components/AssetItem.tsx +++ b/packages/app/src/systems/Core/components/AssetItem.tsx @@ -1,4 +1,6 @@ -import { CoinInput, useCoinInput } from "./CoinInput"; +import { useCoinInput } from "../hooks/useCoinInput"; + +import { CoinInput } from "./CoinInput"; import { CoinSelector } from "./CoinSelector"; import type { Coin } from "~/types"; diff --git a/packages/app/src/systems/Core/components/CoinBalance.tsx b/packages/app/src/systems/Core/components/CoinBalance.tsx index 57ac154a..529a821d 100644 --- a/packages/app/src/systems/Core/components/CoinBalance.tsx +++ b/packages/app/src/systems/Core/components/CoinBalance.tsx @@ -1,45 +1,39 @@ import { useMemo } from "react"; +import type { CoinBalanceProps } from "../hooks"; import { useBalances } from "../hooks"; import { parseToFormattedNumber } from "../utils"; import { Button, Tooltip } from "~/systems/UI"; -import type { Coin } from "~/types"; - -export type CoinBalanceProps = { - coin?: Coin | null; - showMaxButton?: boolean; - onSetMaxBalance?: () => void; - gasFee?: bigint | null; -}; export const CoinBalance = ({ coin, + gasFee, + showBalance = true, showMaxButton = true, onSetMaxBalance, - gasFee, }: CoinBalanceProps) => { const { data: balances } = useBalances({ enabled: true }); const balance = useMemo(() => { - const coinBalance = balances?.find( - (item) => item.assetId === coin?.assetId - ); + const coinBalance = balances?.find((i) => i.assetId === coin?.assetId); return parseToFormattedNumber(coinBalance?.amount || BigInt(0)); }, [balances, coin?.assetId]); return (
-
- Balance: {balance} -
+ {showBalance && ( +
+ Balance: {balance} +
+ )} {showMaxButton && ( diff --git a/packages/app/src/systems/Swap/routes.tsx b/packages/app/src/systems/Swap/routes.tsx index 3e60bf48..e2809e17 100644 --- a/packages/app/src/systems/Swap/routes.tsx +++ b/packages/app/src/systems/Swap/routes.tsx @@ -2,6 +2,7 @@ import { Route } from "react-router-dom"; import { PrivateRoute } from "../Core"; +import { SwapProvider } from "./hooks/useSwap"; import { SwapPage } from "./pages"; import { Pages } from "~/types"; @@ -11,7 +12,9 @@ export const swapRoutes = ( path={Pages.swap} element={ - + + + } /> diff --git a/packages/app/src/systems/Swap/state.ts b/packages/app/src/systems/Swap/state.ts index 6c9006fd..1bec3563 100644 --- a/packages/app/src/systems/Swap/state.ts +++ b/packages/app/src/systems/Swap/state.ts @@ -1,12 +1,12 @@ import { atom, useAtomValue, useSetAtom } from 'jotai'; import { useRef } from 'react'; -import { ActiveInput } from './types'; +import { SwapDirection } from './types'; import { TOKENS } from '~/systems/Core'; -import type { Coin } from '~/types'; +import type { Coin, Maybe } from '~/types'; -export const swapActiveInputAtom = atom(ActiveInput.from); +export const swapActiveInputAtom = atom(SwapDirection.fromTo); export const swapCoinsAtom = atom<[Coin, Coin]>([TOKENS[0], TOKENS[1]]); export const swapIsTypingAtom = atom(false); @@ -29,4 +29,4 @@ export const useSetIsTyping = () => { }; export const swapHasSwappedAtom = atom(false); -export const swapAmountAtom = atom(null); +export const swapAmountAtom = atom>(null); diff --git a/packages/app/src/systems/Swap/types.ts b/packages/app/src/systems/Swap/types.ts index ba597859..cced1ab0 100644 --- a/packages/app/src/systems/Swap/types.ts +++ b/packages/app/src/systems/Swap/types.ts @@ -1,33 +1,42 @@ -import type { Coin } from '~/types'; -import type { PoolInfo } from '~/types/contracts/ExchangeContractAbi'; +import type { CoinQuantity, Wallet } from 'fuels'; +import type { QueryClient } from 'react-query'; -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, + 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 = { + client?: QueryClient; + wallet?: Maybe; + contract: Maybe; + balances?: Maybe; + direction: SwapDirection; + coinFrom?: Coin; + coinTo?: Coin; + coinFromBalance?: Maybe; + coinToBalance?: Maybe; + ethBalance?: Maybe; + amountPlusSlippage?: Maybe; + amountLessSlippage?: Maybe; + fromAmount?: Maybe; + toAmount?: Maybe; + poolInfo?: Maybe; + txCost?: TransactionCost; + slippage?: number; + previewInfo?: Maybe; +}; diff --git a/packages/app/src/systems/Swap/utils/helpers.ts b/packages/app/src/systems/Swap/utils/helpers.ts index 84079e17..4d6a3911 100644 --- a/packages/app/src/systems/Swap/utils/helpers.ts +++ b/packages/app/src/systems/Swap/utils/helpers.ts @@ -1,160 +1,154 @@ -import type { CoinQuantity } from 'fuels'; +import type { CoinAmount, SwapMachineContext } from '../types'; +import { SwapDirection } from '../types'; -import type { SwapInfo, SwapState } from '../types'; -import { ActiveInput, ValidationStateEnum } from '../types'; - -import { COIN_ETH } from '~/systems/Core/utils/constants'; -import type { TransactionCost } from '~/systems/Core/utils/gas'; +import { DECIMAL_UNITS } from '~/config'; +import { isCoinEth } from '~/systems/Core'; import { ZERO, toNumber, - isSwayInfinity, divideFnValidOnly, multiplyFn, + ONE_ASSET, + toFixed, + parseInputValueBigInt, + formatUnits, + parseToFormattedNumber, + toBigInt, + safeBigInt, } from '~/systems/Core/utils/math'; -import type { PoolInfo } from '~/types/contracts/ExchangeContractAbi'; +import type { Maybe } from '~/types'; + +export const ZERO_AMOUNT = { value: '', raw: ZERO }; + +/** + * This function returns amounts that are used inside SwapMachine + * + * Should return ZERO_AMOUNT if value doesn't exist or is less than zero + * Should do rightly formatter according to type + * + * @param value Maybe + * @returns CoinAmount + */ +export function createAmount(value: Maybe): CoinAmount { + if (!value) return ZERO_AMOUNT; + if (typeof value === 'bigint' && value < ZERO) return ZERO_AMOUNT; + if (typeof value === 'number' && value < 0) return ZERO_AMOUNT; + if (typeof value === 'bigint') { + return { + value: formatUnits(value, DECIMAL_UNITS), + raw: value, + }; + } + if (typeof value === 'string') { + return { + value, + raw: parseInputValueBigInt(value), + }; + } + if (typeof value === 'number') { + return { + value: parseToFormattedNumber(value), + raw: toBigInt(Math.ceil(value)), + }; + } + return ZERO_AMOUNT; +} + +export function getPricePerToken(fromAmount?: Maybe, toAmount?: Maybe) { + if (!toAmount || !fromAmount) return ''; + const ratio = divideFnValidOnly(toAmount, fromAmount); + const price = ratio * toNumber(ONE_ASSET); + return toFixed(price / toNumber(ONE_ASSET)); +} -export function getPriceImpact( - outputAmount: bigint, - inputAmount: bigint, - reserveInput: bigint, - reserveOutput: bigint -) { - const exchangeRateAfter = divideFnValidOnly(inputAmount, outputAmount); - const exchangeRateBefore = divideFnValidOnly(reserveInput, reserveOutput); +function getPriceImpact(amounts: bigint[], reserves: bigint[]) { + const exchangeRateAfter = divideFnValidOnly(amounts[1], amounts[0]); + const exchangeRateBefore = divideFnValidOnly(reserves[1], reserves[0]); 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) => { +export const calculatePriceImpact = (ctx: SwapMachineContext) => { // 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); + const { coinFrom, poolInfo, fromAmount, toAmount } = ctx; + const ethReserve = poolInfo?.eth_reserve; + const tokenReserve = poolInfo?.token_reserve; + if (!fromAmount?.raw || !toAmount?.raw || !ethReserve || !tokenReserve) { + return '0'; } - return getPriceImpact(amount, previewAmount, eth_reserve, token_reserve); + + const isEth = isCoinEth(coinFrom); + const amounts = [toAmount.raw, fromAmount.raw]; + const reserves = isEth ? [tokenReserve, ethReserve] : [ethReserve, tokenReserve]; + return getPriceImpact(amounts, reserves); }; export const calculatePriceWithSlippage = ( amount: bigint, - slippage: number, - direction: ActiveInput + direction: SwapDirection, + slippage: number ) => { - let total = 0; - if (direction === ActiveInput.from) { - total = multiplyFn(amount, 1 - slippage); - } else { - total = multiplyFn(amount, 1 + slippage); - } + const isFrom = direction === SwapDirection.fromTo; + const total = multiplyFn(amount, isFrom ? 1 - slippage : 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'; - } -}; +export function hasEnoughBalance(amount: Maybe, balance: Maybe) { + return Boolean(amount && balance && amount < balance); +} -export const notHasBalanceWithSlippage = ({ - swapState, - previewAmount, - slippage, - balances, +// TODO: Add unit tests on this +export function hasLiquidityForSwap({ + direction, + poolInfo, + coinFrom, + fromAmount, + coinTo, + toAmount, 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 - ); + amountPlusSlippage, +}: SwapMachineContext) { + if (!coinFrom || !coinTo || !txCost?.fee) return false; - if (swapState!.coinFrom.assetId === COIN_ETH) { - amountWithSlippage += txCost?.fee || ZERO; - } + const isFrom = direction === SwapDirection.fromTo; + const ethReserve = safeBigInt(poolInfo?.eth_reserve); + const tokenReserve = safeBigInt(poolInfo?.token_reserve); + const fromAmountRaw = safeBigInt(fromAmount?.raw); + const toAmountRaw = safeBigInt(toAmount?.raw); + const networkFee = safeBigInt(txCost?.fee); + const plusSlippage = safeBigInt(amountPlusSlippage?.raw); - return amountWithSlippage > currentBalance; + if (isCoinEth(coinFrom) && isFrom) { + return fromAmountRaw + networkFee < ethReserve && toAmountRaw < tokenReserve; } - return false; -}; + if (isCoinEth(coinFrom) && !isFrom) { + return plusSlippage + networkFee < ethReserve && toAmountRaw < tokenReserve; + } + return fromAmountRaw < tokenReserve && toAmountRaw + networkFee < ethReserve; +} -const hasEthForNetworkFee = ({ balances, txCost }: StateParams) => { - const currentBalance = toNumber( - balances?.find((coin) => coin.assetId === COIN_ETH)?.amount || ZERO - ); - return currentBalance > (txCost?.fee || ZERO); -}; +export const hasEthForNetworkFee = (params: SwapMachineContext) => { + const { ethBalance, direction, coinFrom, fromAmount, txCost, amountPlusSlippage } = params; + const balance = safeBigInt(ethBalance); + const txCostTotal = safeBigInt(txCost?.fee); + const plusSlippage = safeBigInt(amountPlusSlippage?.raw); + const fromAmountRaw = safeBigInt(fromAmount?.raw); + const isFrom = direction === SwapDirection.fromTo; -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; + /** + * When coinFrom is ETH and we wan't to buy tokens if exact amount of ETH + */ + if (isCoinEth(coinFrom) && isFrom) { + return fromAmountRaw + txCostTotal <= balance; } - 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); + /** + * When coinFrom is ETH and we wan't to buy exact amount of token + */ + if (isCoinEth(coinFrom) && !isFrom) { + return plusSlippage + txCostTotal <= balance; } - return true; + /** + * When coinFrom isn't ETH but you need to pay gas fee + */ + return balance > txCostTotal; }; diff --git a/packages/app/src/systems/Swap/utils/queries.ts b/packages/app/src/systems/Swap/utils/queries.ts index 725864c7..81a7efbd 100644 --- a/packages/app/src/systems/Swap/utils/queries.ts +++ b/packages/app/src/systems/Swap/utils/queries.ts @@ -1,15 +1,32 @@ -import type { SwapState } from '../types'; -import { ActiveInput } from '../types'; +import { SwapDirection } from '../types'; +import type { SwapMachineContext } from '../types'; -import { COIN_ETH, getDeadline } from '~/systems/Core'; -import type { TransactionCost } from '~/systems/Core/utils/gas'; +import { safeBigInt, getDeadline } from '~/systems/Core'; import { getOverrides } from '~/systems/Core/utils/gas'; import type { ExchangeContractAbi } from '~/types/contracts'; -export enum SwapQueries { - SwapPreview = 'SwapPage-SwapPreview', - NetworkFee = 'SwapPage-NetworkFee', -} +export const queryNetworkFeeOnSwap = async (params: SwapMachineContext) => { + const { direction, contract, coinFrom } = params; + if (!contract || !coinFrom) { + throw new Error('Contract not found'); + } + + const deadline = await getDeadline(contract); + const directionValue = direction || SwapDirection.fromTo; + + if (directionValue === SwapDirection.toFrom) { + return contract.prepareCall.swap_with_maximum(1, deadline, { + forward: [1, coinFrom.assetId], + variableOutputs: 2, + gasLimit: 1000000, + }); + } + return contract.prepareCall.swap_with_minimum(1, deadline, { + forward: [1, coinFrom.assetId], + variableOutputs: 2, + gasLimit: 1000000, + }); +}; const getSwapWithMaximumRequiredAmount = async ( contract: ExchangeContractAbi, @@ -33,83 +50,60 @@ const getSwapWithMinimumMinAmount = async ( return minAmount; }; -export const queryPreviewAmount = async ( - contract: ExchangeContractAbi, - { amount, direction, coinFrom }: SwapState -) => { - if (direction === ActiveInput.to && amount) { - const previewAmount = await getSwapWithMaximumRequiredAmount( - contract, - coinFrom.assetId, - amount - ); - return previewAmount; +export const queryPreviewAmount = async (params: SwapMachineContext) => { + const { coinFrom: coin, fromAmount, toAmount, contract, direction } = params; + + if (!coin || !contract) return; + const coinId = coin.assetId; + const isFrom = direction === SwapDirection.fromTo; + const amount = isFrom ? fromAmount : toAmount; + + if (direction === SwapDirection.toFrom) { + return getSwapWithMaximumRequiredAmount(contract, coinId, safeBigInt(amount?.raw)); } - if (amount) { - const previewAmount = await getSwapWithMinimumMinAmount(contract, coinFrom.assetId, amount); - return previewAmount; + if (direction === SwapDirection.fromTo) { + return getSwapWithMinimumMinAmount(contract, coinId, safeBigInt(amount?.raw)); } - return null; }; -export const swapTokens = async ( - contract: ExchangeContractAbi, - { coinFrom, direction, amount }: SwapState, - txCost: TransactionCost -) => { - const deadline = await getDeadline(contract); +export const swapTokens = async (params: SwapMachineContext) => { + const { + contract, + coinFrom, + coinTo, + fromAmount, + toAmount, + direction, + txCost, + amountLessSlippage, + amountPlusSlippage, + } = params; - if (direction === ActiveInput.to && amount) { - const forwardAmount = await getSwapWithMaximumRequiredAmount( - contract, - coinFrom.assetId, - amount - ); - if (!forwardAmount.has_liquidity) { - throw new Error('Not enough liquidity on pool'); - } - return contract.submitResult.swap_with_maximum( - amount, - deadline, - getOverrides({ - forward: [forwardAmount.amount, coinFrom.assetId], - gasLimit: txCost.total, - variableOutputs: 2, - }) - ); + if (!contract || !coinFrom || !coinTo || !toAmount || !fromAmount || !txCost?.total) { + throw new Error('Missing some parameters'); } - if (direction === ActiveInput.from && amount) { - const minValue = await getSwapWithMinimumMinAmount(contract, coinFrom.assetId, amount); - if (!minValue.has_liquidity) { - throw new Error('Not enough liquidity on pool'); - } + const deadline = await getDeadline(contract); + + if (direction === SwapDirection.fromTo) { return contract.submitResult.swap_with_minimum( - minValue.amount, + safeBigInt(amountLessSlippage?.raw), deadline, getOverrides({ - forward: [amount, coinFrom.assetId], + forward: [fromAmount.raw, coinFrom.assetId], gasLimit: txCost.total, variableOutputs: 2, }) ); } -}; - -export const queryNetworkFee = async (contract: ExchangeContractAbi, direction?: ActiveInput) => { - const directionValue = direction || ActiveInput.from; - const deadline = await getDeadline(contract); - if (directionValue === ActiveInput.to) { - return contract.prepareCall.swap_with_maximum(1, deadline, { - forward: [1, COIN_ETH], + return contract.submitResult.swap_with_maximum( + toAmount.raw, + deadline, + getOverrides({ + forward: [safeBigInt(amountPlusSlippage?.raw), coinFrom.assetId], + gasLimit: txCost.total, variableOutputs: 2, - gasLimit: 1000000, - }); - } - return contract.prepareCall.swap_with_minimum(1, deadline, { - forward: [1, COIN_ETH], - variableOutputs: 1, - gasLimit: 1000000, - }); + }) + ); }; diff --git a/packages/app/src/systems/UI/components/InvertButton.tsx b/packages/app/src/systems/UI/components/InvertButton.tsx index fdfd2706..00fc23f6 100644 --- a/packages/app/src/systems/UI/components/InvertButton.tsx +++ b/packages/app/src/systems/UI/components/InvertButton.tsx @@ -8,14 +8,20 @@ const style = { confirmButton: ` p-0 relative w-10 h-10 rounded-xl mb-3 mt-0 translate-x-[60px] border-2 border-gray-700 bg-gray-800 cursor-pointer text-gray-400 - sm:translate-x-0 sm:my-1 sm:w-12 sm:h-12 sm:rounded-xl hover:text-gray-50`, + sm:translate-x-0 sm:my-1 sm:w-12 sm:h-12 sm:rounded-xl hover:text-gray-50 + disabled:hover:text-gray-400 disabled:opacity-90`, icon: `transition-all w-[14px] sm:w-[18px]`, iconLeft: `translate-x-[6px]`, iconRight: `translate-x-[-6px]`, rotate: `rotate-180`, }; -export function InvertButton({ onClick }: { onClick: () => void }) { +type InvertButtonProps = { + isDisabled?: boolean; + onClick: () => void; +}; + +export function InvertButton({ onClick, isDisabled }: InvertButtonProps) { const [isInverted, setInverted] = useState(false); function handleClick() { @@ -25,6 +31,7 @@ export function InvertButton({ onClick }: { onClick: () => void }) { return (