diff --git a/.gitignore b/.gitignore index f9752eb5..6ddd0081 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ .idea .vscode .eslintcache +.env # dependencies /node_modules diff --git a/public/static/exchange-icons/svg/component.svg b/public/static/exchange-icons/svg/component.svg new file mode 100644 index 00000000..3bbf8717 --- /dev/null +++ b/public/static/exchange-icons/svg/component.svg @@ -0,0 +1,33 @@ + + + + diff --git a/public/static/exchange-icons/svg/maker.svg b/public/static/exchange-icons/svg/maker.svg new file mode 100644 index 00000000..38b10316 --- /dev/null +++ b/public/static/exchange-icons/svg/maker.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/public/static/exchange-icons/svg/saddle.svg b/public/static/exchange-icons/svg/saddle.svg new file mode 100644 index 00000000..9bb30edf --- /dev/null +++ b/public/static/exchange-icons/svg/saddle.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/public/static/exchange-icons/svg/smoothy.svg b/public/static/exchange-icons/svg/smoothy.svg new file mode 100644 index 00000000..2a054d80 --- /dev/null +++ b/public/static/exchange-icons/svg/smoothy.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/static/exchange-icons/svg/xsigma.svg b/public/static/exchange-icons/svg/xsigma.svg new file mode 100644 index 00000000..864bf535 --- /dev/null +++ b/public/static/exchange-icons/svg/xsigma.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/public/static/okcoin_icon.png b/public/static/okcoin_icon.png deleted file mode 100644 index 55929135..00000000 Binary files a/public/static/okcoin_icon.png and /dev/null differ diff --git a/src/components/AccountModal.js b/src/components/AccountModal/index.js similarity index 88% rename from src/components/AccountModal.js rename to src/components/AccountModal/index.js index b8a52025..9a55202c 100644 --- a/src/components/AccountModal.js +++ b/src/components/AccountModal/index.js @@ -23,7 +23,7 @@ export default function AccountModal({ address, onboard }) { return ( <> @@ -36,10 +36,10 @@ export default function AccountModal({ address, onboard }) { - Connected Wallet: {`${walletState.wallet.name}`} + Connected Wallet: {walletState.wallet.name} - {`${address?.substr(0, 8)}...${address?.substr(address.length - 6)}`} + {address?.substr(0, 8)}...{address?.substr(address.length - 6)} + ); +} diff --git a/src/components/SwapForm/SwapInfo.js b/src/components/SwapForm/SwapInfo.js index 0c694940..6ab84b46 100644 --- a/src/components/SwapForm/SwapInfo.js +++ b/src/components/SwapForm/SwapInfo.js @@ -1,3 +1,4 @@ +/* eslint-disable no-unused-vars */ import { Spacer, Divider, Flex, Text } from '@chakra-ui/react'; import React from 'react'; @@ -15,21 +16,14 @@ export default function SwapInfo({ }) { const checkSource = () => { if (exchanges.length === 1) { - return Exchanges.data[exchanges[0].name].name; + return Exchanges.data[exchanges[0]?.name]?.name; } if (exchanges.length === 0) { - return 'Weth <> Eth'; + return 'ETH <> WETH'; } return 'Split Routing'; }; - const checkLoading = () => { - if (typeof defaults.exchanges === 'string') { - return {defaults.exchanges}; - } - return defaults.exchanges; - }; - return ( <> @@ -41,13 +35,14 @@ export default function SwapInfo({ {price === defaults.price ? ( <> {`1 ${watchTokenIn.value} = `} - {price} + {price} {` ${watchTokenOut.value}`} ) : ( {`1 ${watchTokenIn.value} = ${parseFloat(price) .toFixed(6) - .replace(/\.0+/, '')} ${watchTokenOut.value}`} + .replace(/(0+)$/, '') + .replace(/\.$/, '')} ${watchTokenOut.value}`} )} @@ -65,7 +60,7 @@ export default function SwapInfo({ ) : null} ) : ( - <>{checkLoading()} + defaults.exchanges )} @@ -73,14 +68,21 @@ export default function SwapInfo({ Gas Price - {gasPrice} Gwei + {gasPrice === defaults.gasPrice ? gasPrice : {gasPrice}} + + Gwei + Gas Estimate - {estimatedGas} + {estimatedGas === defaults.estimatedGas ? ( + estimatedGas + ) : ( + {estimatedGas} + )} diff --git a/src/components/SwapForm/TokenDropdown.js b/src/components/SwapForm/TokenDropdown.js new file mode 100644 index 00000000..853084c9 --- /dev/null +++ b/src/components/SwapForm/TokenDropdown.js @@ -0,0 +1,71 @@ +/* eslint-disable react/jsx-props-no-spreading */ +import React from 'react'; +import { HStack, Text } from '@chakra-ui/react'; +import { components } from 'react-select'; + +const { Option, SingleValue } = components; + +export const IconOption = (props) => { + const { data } = props; + return ( + + ); +}; + +export const ValueOption = (props) => { + const { data } = props; + return ( + + + {data.label} + {data.label} + + + ); +}; + +export const DropdownStyle = { + menu: (provided) => ({ + ...provided, + width: 150, + margin: 0, + fontFamily: 'Poppins', + fontWeight: '600', + }), + + dropdownIndicator: (provided) => ({ + ...provided, + color: '#A0AEBF', + }), + + control: () => ({ + width: 160, + height: 52, + display: 'flex', + flexDirection: 'row', + marginLeft: 4, + color: '#A0AEBF', + fontFamily: 'Poppins', + fontWeight: '600', + }), + + placeholder: (provided) => ({ + ...provided, + color: '#A0AEBF', + fontSize: 19, + marginTop: 1, + fontWeight: '400', + }), + + singleValue: (provided, state) => { + const opacity = state.isDisabled ? 0.5 : 1; + const transition = 'opacity 300ms'; + + return { ...provided, opacity, transition }; + }, +}; diff --git a/src/components/SwapForm/index.js b/src/components/SwapForm/index.js index e4b5c58c..5fcc79c8 100644 --- a/src/components/SwapForm/index.js +++ b/src/components/SwapForm/index.js @@ -1,70 +1,75 @@ /* eslint-disable react/jsx-props-no-spreading */ +/* eslint-disable */ import { Box, Button, Center, Flex, Heading, - HStack, Input, Text, useToast, Spinner, + useDisclosure, } from '@chakra-ui/react'; import debounce from 'debounce'; import { Controller, useForm } from 'react-hook-form'; import React, { useEffect, useState } from 'react'; +import { IconOption, ValueOption, DropdownStyle } from './TokenDropdown'; import Select, { components } from 'react-select'; import FullPageSpinner from '../FullPageSpinner'; +import SwapModal from '../SwapModal'; import zeroXSwap from '../../hooks/use0xSwap'; import use0xPrice from '../../hooks/use0xPrice'; - -import Tokens from '../../constants/tokens'; -import Toasts from '../../constants/toasts'; - import { getTokenIconPNG32 } from '../../utils/getTokenIcon'; import SwapInfo from './SwapInfo'; +import SwapButton from './SwapButton'; +import Toasts from '../../constants/toasts'; +import Tokens from '../../constants/tokens'; export default function SwapForm({ onboardState, web3, onboard }) { const { register, handleSubmit, watch, setValue, errors, control } = useForm(); - const [isLoading, setIsLoading] = useState(); const [sellAmount, setSellAmount] = useState(); + const { isOpen, onOpen, onClose } = useDisclosure(); + const [swapConfirmed, setSwapConfirmed] = useState(false); + const [formData, setFormData] = useState({}); const toast = useToast(); const watchTokenIn = watch('tokenIn', ''); const watchTokenOut = watch('tokenOut', ''); const watchAmountIn = watch('amountIn', 0); - const defaults = { - price: , - gasPrice: , - exchanges: , - estimatedGas: , - sources: [], - }; + // Token dropdown values + const tokenArray = Tokens.tokens.map((symbol) => ({ + value: symbol, + label: symbol, + icon: getTokenIconPNG32(symbol), + })); + // 0x API quote const { data: zeroExQuote } = use0xPrice( Tokens.data[watchTokenIn.value], Tokens.data[watchTokenOut.value], sellAmount ); - const { price, gasPrice, estimatedGas, exchanges } = - zeroExQuote === undefined ? defaults : zeroExQuote; + // Placeholder values + const defaults = { + price: , + gasPrice: , + exchanges: , + estimatedGas: , + sources: [], + }; - useEffect(() => { - if (watchAmountIn && watchTokenIn && watchTokenOut && price !== defaults.price) { - const n = watchAmountIn * price; - setValue('amountOut', n.toFixed(6).replace(/\.0+/, '')); - } - if (!watchAmountIn) { - setValue('amountOut', ''); - } - }, [price, watchAmountIn, watchTokenIn, watchTokenOut]); + // Set quote values + const { price, gasPrice, estimatedGas, exchanges, apiError } = + zeroExQuote === undefined ? defaults : zeroExQuote; + // Connect wallet function async function readyToTransact() { if (!onboardState.address) { const walletSelected = await onboard.walletSelect(); @@ -75,14 +80,37 @@ export default function SwapForm({ onboardState, web3, onboard }) { return ready; } - // Execute the swap - const onSubmit = async (data) => { - const ready = await readyToTransact(); - if (!ready) return; + // SwapButton text function + const getButtonText = () => { + if (!watchAmountIn || watchAmountIn <= 0) return 'Enter amount in'; + if (!watchTokenIn || !watchTokenOut) return 'Select tokens'; + if (apiError) { + return ( + <> + + {apiError} + + ); + } + return 'Swap Tokens'; + }; - const { amountIn, tokenIn, tokenOut } = data; - setIsLoading(true); + // Set amount out + useEffect(() => { + if (watchAmountIn > 0 && watchTokenIn && watchTokenOut && price !== defaults.price) { + const n = watchAmountIn * price; + setValue('amountOut', n.toFixed(6).replace(/(0+)$/, '').replace(/\.$/, '')); + } + if (!watchAmountIn || watchAmountIn <= 0 || apiError) { + setValue('amountOut', ''); + } + }, [price, watchAmountIn, watchTokenIn, watchTokenOut]); + useEffect(() => { + if (!swapConfirmed) { + return; + } + const { amountIn, tokenIn, tokenOut } = formData; zeroXSwap(Tokens.data[tokenIn.value], Tokens.data[tokenOut.value], amountIn, web3) .then(() => { setIsLoading(false); @@ -93,42 +121,23 @@ export default function SwapForm({ onboardState, web3, onboard }) { console.log(err); toast(Toasts.error); }); - }; + setSwapConfirmed(false); + setIsLoading(false); + }); - if (!onboard) { - return ; - } - - // display tokens - const tokenArray = Tokens.tokens.map((symbol) => ({ - value: symbol, - label: symbol, - icon: getTokenIconPNG32(symbol), - })); + // Loading screen + if (!onboard) return ; - const { Option, SingleValue } = components; - const IconOption = (props) => { - const { data } = props; - return ( - - ); - }; + // Execute the swap + const onSubmit = async (data) => { + onOpen(); + const closed = await isOpen; + console.log(closed); + const ready = await readyToTransact(); + if (!ready) return; - const ValueOption = (props) => { - const { data } = props; - return ( - - - {data.label} - {data.label} - - - ); + setFormData(data); + setIsLoading(true); }; return ( @@ -147,70 +156,49 @@ export default function SwapForm({ onboardState, web3, onboard }) { control={control} render={({ onChange, name, value, ref }) => ( setSellAmount(event.target.value), 1500)} + onKeyDown={(e) => { + const char = e.key; + if (char === 'e' || char === '-' || char === '+' || new RegExp('^(0+)$').test(e)) { + e.preventDefault(); + } + }} + onWheel={(e) => { + e.currentTarget.blur(); + }} + onChange={debounce((event) => setSellAmount(event.target.value), 1000)} /> @@ -224,62 +212,31 @@ export default function SwapForm({ onboardState, web3, onboard }) { control={control} render={({ onChange, name, value, ref }) => ( - {watchTokenIn && watchTokenOut && watchAmountIn && price ? ( + {watchTokenIn && watchTokenOut && watchAmountIn > 0 && price ? ( {onboardState.address ? ( - + <> + + + ) : ( - + )} - {/* {errors.amountIn ? 'Input amount is required' : null} */} ); diff --git a/src/components/SwapModal.js b/src/components/SwapModal.js new file mode 100644 index 00000000..8aeff8fa --- /dev/null +++ b/src/components/SwapModal.js @@ -0,0 +1,116 @@ +/* eslint-disable */ +import { + Box, + Button, + Modal, + ModalOverlay, + ModalContent, + ModalHeader, + ModalFooter, + ModalBody, + ModalCloseButton, + useClipboard, + Text, + Flex, +} from '@chakra-ui/react'; +import SwapInfo from './SwapForm/SwapInfo'; +import React from 'react'; + +export default function SwapModal({ + address, + onboard, + errors, + isOpen, + onClose, + setSwapConfirmed, + watchAmountIn, + watchTokenIn, + watchTokenOut, + price, + defaults, + exchanges, + gasPrice, + estimatedGas, + getPicture, +}) { + const walletState = onboard.getState(); + const { onCopy } = useClipboard(address); + + return ( + <> + + + + + Confirm Swap + + + + + + + {watchTokenIn.value} + + {watchAmountIn} + + + + {watchTokenIn.value} + + + + + {watchTokenOut.value} + + {price} + + + + {watchTokenOut.value} + + + + + + + + + + + + ); +} diff --git a/src/constants/exchanges.js b/src/constants/exchanges.js index 2d8d5bcf..f8ecf7c6 100644 --- a/src/constants/exchanges.js +++ b/src/constants/exchanges.js @@ -8,6 +8,10 @@ export default { name: 'Balancer', iconSVG: '/static/exchange-icons/svg/balancer.svg', }, + Balancer_V2: { + name: 'Balancer V2', + iconSVG: '/static/exchange-icons/svg/balancer.svg', + }, Bancor: { name: 'Bancor', iconSVG: '/static/exchange-icons/svg/bancor.svg', @@ -16,6 +20,10 @@ export default { name: 'Cream', iconSVG: '/static/exchange-icons/svg/cream.svg', }, + Component: { + name: 'Component', + iconSVG: '/static/exchange-icons/svg/component.svg', + }, CryptoCom: { name: 'DeFi Swap', iconSVG: '/static/exchange-icons/svg/defiswap.svg', @@ -40,6 +48,10 @@ export default { name: 'Kyber', iconSVG: '/static/exchange-icons/svg/kyber.svg', }, + KyberDMM: { + name: 'Kyber DMM', + iconSVG: '/static/exchange-icons/svg/kyber.svg', + }, Linkswap: { name: 'Linkswap', iconSVG: '/static/exchange-icons/svg/linkswap.svg', @@ -48,6 +60,10 @@ export default { name: '0x Private Market Maker', iconSVG: '/static/exchange-icons/svg/0x.svg', }, + MakerPsm: { + name: 'Maker PSM', + iconSVG: '/static/exchange-icons/svg/maker.svg', + }, Mooniswap: { name: 'Mooniswap', iconSVG: '/static/exchange-icons/svg/mooniswap.svg', @@ -60,10 +76,18 @@ export default { name: '0x MultiHop', iconSVG: '/static/exchange-icons/svg/0x.svg', }, + Saddle: { + name: 'Saddle', + iconSVG: '/static/exchange-icons/svg/saddle.svg', + }, Shell: { name: 'Shell', iconSVG: '/static/exchange-icons/svg/shell.svg', }, + Smoothy: { + name: 'Smoothy', + iconSVG: '/static/exchange-icons/svg/smoothy.svg', + }, SnowSwap: { name: 'Snowswap', iconSVG: '/static/exchange-icons/svg/snowswap.svg', @@ -84,33 +108,48 @@ export default { name: 'Uniswap V2', iconSVG: '/static/exchange-icons/svg/uniswap.svg', }, + Uniswap_V3: { + name: 'Uniswap V3', + iconSVG: '/static/exchange-icons/svg/uniswap.svg', + }, mStable: { name: 'mStable', iconSVG: '/static/exchange-icons/svg/mstable.svg', }, + xSigma: { + name: 'xSigma', + iconSVG: '/static/exchange-icons/svg/xsigma', + }, }, exchanges: [ '0x', 'Balancer', + 'Balancer_V2', 'Bancor', 'CREAM', + 'Component', 'CryptoCom', 'Curve', 'DODO', 'DODO_V2', 'Eth2Dai', 'Kyber', + 'KyberDMM', 'Linkswap', 'LiquidityProvider', + 'MakerPsm', 'Mooniswap', - 'MultiBridge', 'MultiHop', + 'Saddle', 'Shell', + 'Smoothy', 'SnowSwap', 'SushiSwap', 'Swerve', 'Uniswap', 'Uniswap_V2', + 'Uniswap_V3', 'mStable', + 'xSigma', ], }; diff --git a/src/constants/toasts.js b/src/constants/toasts.js index 51acf7ce..974a353d 100644 --- a/src/constants/toasts.js +++ b/src/constants/toasts.js @@ -1,8 +1,9 @@ export default { success: { title: 'Swap Success', - description: 'Your swap was successfully executed', + description: 'Your swap was successfully executed.', status: 'success', + position: 'bottom-left', duration: 9000, isClosable: true, }, @@ -10,6 +11,23 @@ export default { title: 'Swap Error', description: 'There was an error while executing your swap, check the console', status: 'error', + position: 'bottom-left', + duration: 9000, + isClosable: true, + }, + transactionReject: { + title: 'Swap Error', + description: 'Transaction rejected.', + status: 'error', + position: 'bottom-left', + duration: 9000, + isClosable: true, + }, + apiError: { + title: 'Swap Error', + description: 'API timed out while requesting data.', + status: 'error', + position: 'bottom-left', duration: 9000, isClosable: true, }, diff --git a/src/hooks/use0xPrice.js b/src/hooks/use0xPrice.js index 76653177..a46eeef1 100644 --- a/src/hooks/use0xPrice.js +++ b/src/hooks/use0xPrice.js @@ -3,43 +3,67 @@ import axios from 'axios'; import BD from 'js-big-decimal'; import Web3 from 'web3'; -const getPrice = async (tokenIn, tokenOut, sellAmount) => { - if (!tokenIn || !tokenOut || !sellAmount) { +function handleError(err) { + // Default message + let reason = 'Server error'; + + // Input out of range + if (err.message && err.message === 'Cannot divide by 0') { + reason = 'Input too large'; + } + + // Validation Error + if (err.response && err.response.data.code === 100) { + reason = err.response.data.validationErrors[0].reason; + reason = reason.charAt(0) + reason.slice(1).toLowerCase().replaceAll('_', ' '); + } + + return reason; +} + +async function getPrice(tokenIn, tokenOut, sellAmount) { + if (!tokenIn || !tokenOut || !sellAmount || sellAmount <= 0) { return []; } - const conversionRate = new BD(`1.0e${tokenIn.decimals}`); - const converted = new BD(sellAmount).multiply(conversionRate); - - const params = new URLSearchParams({ - sellToken: tokenIn.symbol, - buyToken: tokenOut.symbol, - sellAmount: converted.getValue(), - }); - - const { data } = await axios.get( - `${ - process.env.REACT_APP_ENV === 'production' - ? process.env.REACT_APP_ZEROEX_PROD - : process.env.REACT_APP_ZEROEX_DEV - }/swap/v1/price?${params.toString()}` - ); - const { price, gasPrice, estimatedGas, sources } = data; - const getDex = data.sources.filter((item) => item.proportion !== '0'); - const inverse = new BD(1).divide(new BD(data.price)); - - return { - exchanges: getDex, - sources: sources.filter((source) => source.proportion !== '0'), - price, - inverse: inverse.getValue(), - gasPrice: Web3.utils.fromWei(gasPrice, 'Gwei'), - estimatedGas: new BD(estimatedGas).getPrettyValue(), - }; -}; - -export default function use0xPrice(tokenIn, tokenOut, sellAmount) { - return useQuery(['price', '0x', tokenIn, tokenOut, sellAmount], () => - getPrice(tokenIn, tokenOut, sellAmount) + try { + const conversionRate = new BD(`1.0e${tokenIn.decimals}`); + const converted = new BD(sellAmount).multiply(conversionRate); + + const params = new URLSearchParams({ + sellToken: tokenIn.symbol, + buyToken: tokenOut.symbol, + sellAmount: converted.getValue(), + }); + + const { data } = await axios.get( + `${ + process.env.REACT_APP_ENV === 'production' + ? process.env.REACT_APP_ZEROEX_PROD + : process.env.REACT_APP_ZEROEX_DEV + }/swap/v1/price?${params.toString()}` + ); + const { price, gasPrice, estimatedGas, sources } = data; + const getDex = data.sources.filter((item) => item.proportion !== '0'); + const inverse = new BD(1).divide(new BD(data.price)); + + return { + exchanges: getDex, + sources: sources.filter((source) => source.proportion !== '0'), + price, + inverse: inverse.getValue(), + gasPrice: Web3.utils.fromWei(gasPrice, 'Gwei'), + estimatedGas: new BD(estimatedGas).getPrettyValue(), + }; + } catch (err) { + return { apiError: handleError(err) }; + } +} + +export default function use0xPrice(tokenIn, tokenOut, sellAmount, onError) { + return useQuery( + ['price', '0x', tokenIn, tokenOut, sellAmount], + () => getPrice(tokenIn, tokenOut, sellAmount), + { onError, retry: 3, retryDelay: (attempt) => attempt * 100 } ); } diff --git a/src/utils/getTokenIcon.js b/src/utils/getTokenIcon.js index 30d096d1..5e8ae05e 100644 --- a/src/utils/getTokenIcon.js +++ b/src/utils/getTokenIcon.js @@ -5,7 +5,7 @@ const iconDirectory = '/static/token-icons'; * @param tokenSymbol The symbol for the desired token */ export function getTokenIconPNG32(tokenSymbol) { - const symbol = tokenSymbol.toLowerCase(); + const symbol = tokenSymbol?.toLowerCase(); return `${iconDirectory}/32/${symbol}.png`; }