From ec6c1cdc64d8cbff072c037229bb497ae65525dd Mon Sep 17 00:00:00 2001 From: Liam Date: Thu, 11 May 2023 18:04:48 +0700 Subject: [PATCH 01/10] feat: swap --- src/enums/path.ts | 1 + src/icons/index.ts | 2 + src/icons/refresh.tsx | 22 ++++++++ src/icons/setting.tsx | 34 +++++++++++++ src/icons/swap.tsx | 34 +++++++++++++ .../Home/components/AccountBalances/index.tsx | 4 +- .../SwapForm/RadioPercentSelect.tsx | 51 +++++++++++++++++++ .../Swap/components/SwapForm/SelectToken.tsx | 20 ++++++++ .../Swap/components/SwapForm/index.tsx | 50 ++++++++++++++++++ src/modules/Swap/index.tsx | 22 ++++++++ src/pages/Swap/index.tsx | 10 ++++ src/public/locales/en/common.json | 3 +- src/router/browserRouter.tsx | 5 ++ 13 files changed, 255 insertions(+), 3 deletions(-) create mode 100644 src/icons/refresh.tsx create mode 100644 src/icons/setting.tsx create mode 100644 src/icons/swap.tsx create mode 100644 src/modules/Swap/components/SwapForm/RadioPercentSelect.tsx create mode 100644 src/modules/Swap/components/SwapForm/SelectToken.tsx create mode 100644 src/modules/Swap/components/SwapForm/index.tsx create mode 100644 src/modules/Swap/index.tsx create mode 100644 src/pages/Swap/index.tsx diff --git a/src/enums/path.ts b/src/enums/path.ts index 3b390fd..f4f5204 100644 --- a/src/enums/path.ts +++ b/src/enums/path.ts @@ -4,6 +4,7 @@ export enum PathEnum { NFT = '/nfts', STAKING = '/staking', SEND = '/send', + SWAP = '/swap', // New wallet. NEW_WALLET = '/create', NEW_PASSWORD = '/create/new-password', diff --git a/src/icons/index.ts b/src/icons/index.ts index 15afa2e..3b1fd5d 100644 --- a/src/icons/index.ts +++ b/src/icons/index.ts @@ -8,3 +8,5 @@ export * from './import'; export * from './home'; export * from './send'; export * from './key'; +export * from './setting'; +export * from './refresh'; diff --git a/src/icons/refresh.tsx b/src/icons/refresh.tsx new file mode 100644 index 0000000..43d3adc --- /dev/null +++ b/src/icons/refresh.tsx @@ -0,0 +1,22 @@ +import { SVGProps } from 'react'; + +type Props = SVGProps; + +export const RefreshIcon = ({ color = '#353945', ...props }: Props) => { + return ( + + + + ); +}; diff --git a/src/icons/setting.tsx b/src/icons/setting.tsx new file mode 100644 index 0000000..209e878 --- /dev/null +++ b/src/icons/setting.tsx @@ -0,0 +1,34 @@ +import { SVGProps } from 'react'; + +type Props = SVGProps; + +export const SettingIcon = ({ color = '#353945', ...props }: Props) => { + return ( + + + + + + + + + + + ); +}; diff --git a/src/icons/swap.tsx b/src/icons/swap.tsx new file mode 100644 index 0000000..9f5d030 --- /dev/null +++ b/src/icons/swap.tsx @@ -0,0 +1,34 @@ +import { SVGProps } from 'react'; + +type Props = SVGProps; + +export const SwapIcon = ({ color = '#353945', ...props }: Props) => { + return ( + + + + + + + + + + + ); +}; diff --git a/src/modules/Home/components/AccountBalances/index.tsx b/src/modules/Home/components/AccountBalances/index.tsx index 93b63db..666787e 100644 --- a/src/modules/Home/components/AccountBalances/index.tsx +++ b/src/modules/Home/components/AccountBalances/index.tsx @@ -34,9 +34,9 @@ const AccountBalances = (props: AccountBalancesProps) => { - + { + return ( + { + // const masterKey = KeyFactory.getInstance().generate( + // parseInt(value, 10) + // ); + // setValue('masterKey', masterKey); + + // onChange(parseInt(value)); + // }} + > + {PERCENTS.map((item) => { + return ( + + {item.label} + + ); + })} + + ); +}; + +export default RadioPercentSelect; diff --git a/src/modules/Swap/components/SwapForm/SelectToken.tsx b/src/modules/Swap/components/SwapForm/SelectToken.tsx new file mode 100644 index 0000000..0f92a5d --- /dev/null +++ b/src/modules/Swap/components/SwapForm/SelectToken.tsx @@ -0,0 +1,20 @@ +import { Box, Flex, Input, Text } from '@chakra-ui/react'; + +const SelectToken = () => { + return ( + + + + + CSPR + + + + + + + + ); +}; + +export default SelectToken; diff --git a/src/modules/Swap/components/SwapForm/index.tsx b/src/modules/Swap/components/SwapForm/index.tsx new file mode 100644 index 0000000..36f9dd7 --- /dev/null +++ b/src/modules/Swap/components/SwapForm/index.tsx @@ -0,0 +1,50 @@ +import { Box, Button, Flex } from '@chakra-ui/react'; +import { FormProvider, useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; + +import RadioPercentSelect from './RadioPercentSelect'; +import SelectToken from './SelectToken'; +import Paper from '@/components/Paper'; +import { RefreshIcon, SettingIcon } from '@/icons'; + +const SwapForm = () => { + const { t } = useTranslation(); + const methods = useForm(); + + const handleOnSubmit = () => { + console.log('submit'); + }; + + return ( + + + + + + + + + +
+ + + + + + + +
+
+
+ ); +}; + +export default SwapForm; diff --git a/src/modules/Swap/index.tsx b/src/modules/Swap/index.tsx new file mode 100644 index 0000000..ee4ee18 --- /dev/null +++ b/src/modules/Swap/index.tsx @@ -0,0 +1,22 @@ +import { Box, Flex, Text } from '@chakra-ui/react'; +import { useTranslation } from 'react-i18next'; + +import SwapForm from './components/SwapForm'; + +const Swap = () => { + const { t } = useTranslation(); + + return ( + + + + {t('swap')} + {t('swap_description')} + + + + + ); +}; + +export default Swap; diff --git a/src/pages/Swap/index.tsx b/src/pages/Swap/index.tsx new file mode 100644 index 0000000..4a5a869 --- /dev/null +++ b/src/pages/Swap/index.tsx @@ -0,0 +1,10 @@ +import LightLayout from '@/layouts/Light'; +import Swap from '@/modules/Swap'; + +const SwapPage = () => ( + + + +); + +export default SwapPage; diff --git a/src/public/locales/en/common.json b/src/public/locales/en/common.json index fd99550..cfd109d 100644 --- a/src/public/locales/en/common.json +++ b/src/public/locales/en/common.json @@ -116,5 +116,6 @@ "lock_wallet": "Lock Wallet", "recieve": "Recieve", "your_wallet_address": "Your wallet address", - "copy_wallet_address": "Copy Wallet Address" + "copy_wallet_address": "Copy Wallet Address", + "swap_description": "Send your token the fastest and easy way." } diff --git a/src/router/browserRouter.tsx b/src/router/browserRouter.tsx index a3775c4..936de2b 100644 --- a/src/router/browserRouter.tsx +++ b/src/router/browserRouter.tsx @@ -9,6 +9,7 @@ import NewWalletDoubleCheckPage from '@/pages/NewWallet/DoubleCheck'; import NewWalletPasswordPage from '@/pages/NewWallet/NewPassword'; import NFTsPage from '@/pages/NFTs'; import SendPage from '@/pages/Send'; +import SwapPage from '@/pages/Swap'; export const browserRouter = createBrowserRouter([ { @@ -43,4 +44,8 @@ export const browserRouter = createBrowserRouter([ path: PathEnum.NFT, element: , }, + { + path: PathEnum.SWAP, + element: , + }, ]); From 052054e06bc1ff0a2c68bdd5a22773f0ff1ea38f Mon Sep 17 00:00:00 2001 From: Liam Date: Mon, 15 May 2023 00:10:45 +0700 Subject: [PATCH 02/10] feat: swap --- .../Inputs/RadioButton/RadioButtonGroup.tsx | 10 +-- src/components/Modal/index.tsx | 49 ++++++++++ .../Surface/CircleWrapper/index.tsx | 25 ++++++ src/icons/arrow-down.tsx | 23 +++++ src/icons/index.ts | 3 + src/icons/refresh.tsx | 3 +- src/icons/reverse.tsx | 42 +++++++++ src/icons/search.tsx | 24 +++++ src/icons/setting.tsx | 3 +- .../components/ModalSelectToken/index.tsx | 90 +++++++++++++++++++ .../RadioPercentSlippage.tsx | 48 ++++++++++ .../ModalTransactionSetting/index.tsx | 45 ++++++++++ .../SwapForm/RadioPercentSelect.tsx | 3 +- .../components/SwapForm/SelectSwapFrom.tsx | 21 +++++ .../Swap/components/SwapForm/SelectSwapTo.tsx | 21 +++++ .../Swap/components/SwapForm/SelectToken.tsx | 47 ++++++++-- .../Swap/components/SwapForm/Setting.tsx | 29 ++++++ .../Swap/components/SwapForm/index.tsx | 42 ++++++--- src/modules/Swap/index.tsx | 12 +-- src/public/locales/en/common.json | 8 +- src/theme/components/radioButtons.ts | 54 ++++++++--- src/theme/foundations/fontSizes.ts | 5 +- src/theme/foundations/space.ts | 15 ++-- 23 files changed, 561 insertions(+), 61 deletions(-) create mode 100644 src/components/Modal/index.tsx create mode 100644 src/components/Surface/CircleWrapper/index.tsx create mode 100644 src/icons/arrow-down.tsx create mode 100644 src/icons/reverse.tsx create mode 100644 src/icons/search.tsx create mode 100644 src/modules/Swap/components/ModalSelectToken/index.tsx create mode 100644 src/modules/Swap/components/ModalTransactionSetting/RadioPercentSlippage.tsx create mode 100644 src/modules/Swap/components/ModalTransactionSetting/index.tsx create mode 100644 src/modules/Swap/components/SwapForm/SelectSwapFrom.tsx create mode 100644 src/modules/Swap/components/SwapForm/SelectSwapTo.tsx create mode 100644 src/modules/Swap/components/SwapForm/Setting.tsx diff --git a/src/components/Inputs/RadioButton/RadioButtonGroup.tsx b/src/components/Inputs/RadioButton/RadioButtonGroup.tsx index 2af38a2..5944212 100644 --- a/src/components/Inputs/RadioButton/RadioButtonGroup.tsx +++ b/src/components/Inputs/RadioButton/RadioButtonGroup.tsx @@ -48,15 +48,7 @@ export const RadioButtonGroup = ({ const group = getRootProps(); return ( - + {radios.map((child: ReactElement, index: number) => { const radioProps = getRadioProps({ value: child.props.value }); return cloneElement(child, { diff --git a/src/components/Modal/index.tsx b/src/components/Modal/index.tsx new file mode 100644 index 0000000..b5e8c2a --- /dev/null +++ b/src/components/Modal/index.tsx @@ -0,0 +1,49 @@ +import { ReactNode } from 'react'; + +import { + Modal as ModalChakra, + ModalOverlay, + ModalHeader, + ModalBody, + ModalCloseButton, + Heading, + ModalFooter, + Divider, + ModalContent, +} from '@chakra-ui/react'; + +type ModalProps = { + isOpen: boolean; + onClose: () => void; + title?: string | null; + children: ReactNode; +}; + +const Modal = ({ isOpen, onClose, title, children }: ModalProps) => { + return ( + <> + + + + + + {title} + + + + + {children} + + + + + ); +}; + +export default Modal; diff --git a/src/components/Surface/CircleWrapper/index.tsx b/src/components/Surface/CircleWrapper/index.tsx new file mode 100644 index 0000000..82a58e2 --- /dev/null +++ b/src/components/Surface/CircleWrapper/index.tsx @@ -0,0 +1,25 @@ +import { Box, BoxProps } from '@chakra-ui/react'; + +type CircleWrapperProps = { + size?: number | string; +} & BoxProps; + +const CircleWrapper = ({ + size = 8, + children, + ...restProps +}: CircleWrapperProps) => { + return ( + + {children} + + ); +}; + +export default CircleWrapper; diff --git a/src/icons/arrow-down.tsx b/src/icons/arrow-down.tsx new file mode 100644 index 0000000..93c1734 --- /dev/null +++ b/src/icons/arrow-down.tsx @@ -0,0 +1,23 @@ +import { SVGProps } from 'react'; + +type Props = SVGProps; + +export const ArrowDownIcon = ({ ...props }: Props) => { + return ( + + + + ); +}; diff --git a/src/icons/index.ts b/src/icons/index.ts index 3b1fd5d..4cf62e9 100644 --- a/src/icons/index.ts +++ b/src/icons/index.ts @@ -10,3 +10,6 @@ export * from './send'; export * from './key'; export * from './setting'; export * from './refresh'; +export * from './arrow-down'; +export * from './reverse'; +export * from './search'; diff --git a/src/icons/refresh.tsx b/src/icons/refresh.tsx index 43d3adc..d834d7c 100644 --- a/src/icons/refresh.tsx +++ b/src/icons/refresh.tsx @@ -2,7 +2,7 @@ import { SVGProps } from 'react'; type Props = SVGProps; -export const RefreshIcon = ({ color = '#353945', ...props }: Props) => { +export const RefreshIcon = ({ ...props }: Props) => { return ( { viewBox="0 0 22 24" fill="none" xmlns="http://www.w3.org/2000/svg" - color={color} {...props} > ; + +export const ReverseIcon = ({ ...props }: Props) => { + return ( + + + + + + + + + + + + ); +}; diff --git a/src/icons/search.tsx b/src/icons/search.tsx new file mode 100644 index 0000000..2994808 --- /dev/null +++ b/src/icons/search.tsx @@ -0,0 +1,24 @@ +import { SVGProps } from 'react'; + +type Props = SVGProps; + +export const SearchIcon = ({ ...props }: Props) => { + return ( + + + + ); +}; diff --git a/src/icons/setting.tsx b/src/icons/setting.tsx index 209e878..bd4a9d6 100644 --- a/src/icons/setting.tsx +++ b/src/icons/setting.tsx @@ -2,7 +2,7 @@ import { SVGProps } from 'react'; type Props = SVGProps; -export const SettingIcon = ({ color = '#353945', ...props }: Props) => { +export const SettingIcon = ({ ...props }: Props) => { return ( { viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" - color={color} {...props} > diff --git a/src/modules/Swap/components/ModalSelectToken/index.tsx b/src/modules/Swap/components/ModalSelectToken/index.tsx new file mode 100644 index 0000000..55b6a42 --- /dev/null +++ b/src/modules/Swap/components/ModalSelectToken/index.tsx @@ -0,0 +1,90 @@ +import { + Modal, + ModalOverlay, + ModalHeader, + ModalBody, + ModalCloseButton, + Heading, + ModalFooter, + Divider, + ModalContent, + Input, + Flex, + Text, +} from '@chakra-ui/react'; +import { useTranslation } from 'react-i18next'; + +import CircleWrapper from '@/components/Surface/CircleWrapper'; +import { SearchIcon } from '@/icons'; + +type ModalReceivingAddressProps = { + isOpen: boolean; + onClose: () => void; +}; + +const ModalSelectToken = ({ isOpen, onClose }: ModalReceivingAddressProps) => { + const { t } = useTranslation(); + + return ( + <> + + + + + + {t('search_token')} + + + + + + + + + + + + + {t('token_list')} + + + + + + CSPR + + 0 + + + + + + + + ); +}; + +export default ModalSelectToken; diff --git a/src/modules/Swap/components/ModalTransactionSetting/RadioPercentSlippage.tsx b/src/modules/Swap/components/ModalTransactionSetting/RadioPercentSlippage.tsx new file mode 100644 index 0000000..80593c2 --- /dev/null +++ b/src/modules/Swap/components/ModalTransactionSetting/RadioPercentSlippage.tsx @@ -0,0 +1,48 @@ +import RadioButton from '@/components/Inputs/RadioButton'; +import RadioButtonGroup from '@/components/Inputs/RadioButton/RadioButtonGroup'; + +const PERCENTS = [ + { + value: '0.1', + label: '0.1%', + }, + { + value: '0.5', + label: '0.5%', + }, + { + value: '1', + label: '1%', + }, +]; + +const RadioPercentSlippage = () => { + return ( + { + // const masterKey = KeyFactory.getInstance().generate( + // parseInt(value, 10) + // ); + // setValue('masterKey', masterKey); + + // onChange(parseInt(value)); + // }} + > + {PERCENTS.map((item) => { + return ( + + {item.label} + + ); + })} + + ); +}; + +export default RadioPercentSlippage; diff --git a/src/modules/Swap/components/ModalTransactionSetting/index.tsx b/src/modules/Swap/components/ModalTransactionSetting/index.tsx new file mode 100644 index 0000000..653c9a6 --- /dev/null +++ b/src/modules/Swap/components/ModalTransactionSetting/index.tsx @@ -0,0 +1,45 @@ +import { Box, FormControl, FormLabel, Input } from '@chakra-ui/react'; +import { useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; + +import RadioPercentSlippage from './RadioPercentSlippage'; +import Modal from '@/components/Modal'; + +type ModalTransactionSettingProps = { + isOpen: boolean; + onClose: () => void; +}; + +const ModalTransactionSetting = ({ + isOpen, + onClose, +}: ModalTransactionSettingProps) => { + const { t } = useTranslation(); + const { handleSubmit, register } = useForm(); + + const handleOnSubmit = () => { + console.log('submit'); + }; + + return ( + +
+ + + {t('slippage_tolerance')} + + + + + + + + {t('transaction_deadline')} + + +
+
+ ); +}; + +export default ModalTransactionSetting; diff --git a/src/modules/Swap/components/SwapForm/RadioPercentSelect.tsx b/src/modules/Swap/components/SwapForm/RadioPercentSelect.tsx index b4a0839..aa01246 100644 --- a/src/modules/Swap/components/SwapForm/RadioPercentSelect.tsx +++ b/src/modules/Swap/components/SwapForm/RadioPercentSelect.tsx @@ -25,8 +25,7 @@ const RadioPercentSelect = () => { { // const masterKey = KeyFactory.getInstance().generate( diff --git a/src/modules/Swap/components/SwapForm/SelectSwapFrom.tsx b/src/modules/Swap/components/SwapForm/SelectSwapFrom.tsx new file mode 100644 index 0000000..9a9baea --- /dev/null +++ b/src/modules/Swap/components/SwapForm/SelectSwapFrom.tsx @@ -0,0 +1,21 @@ +import { useDisclosure } from '@chakra-ui/react'; + +import SelectToken from './SelectToken'; +import ModalSelectToken from '../ModalSelectToken'; + +const SelectSwapFrom = () => { + const { isOpen, onClose, onOpen } = useDisclosure(); + + const handleOnClick = () => { + onOpen(); + }; + + return ( + <> + + + + ); +}; + +export default SelectSwapFrom; diff --git a/src/modules/Swap/components/SwapForm/SelectSwapTo.tsx b/src/modules/Swap/components/SwapForm/SelectSwapTo.tsx new file mode 100644 index 0000000..8e2454e --- /dev/null +++ b/src/modules/Swap/components/SwapForm/SelectSwapTo.tsx @@ -0,0 +1,21 @@ +import { useDisclosure } from '@chakra-ui/react'; + +import SelectToken from './SelectToken'; +import ModalSelectToken from '../ModalSelectToken'; + +const SelectSwapTo = () => { + const { isOpen, onClose, onOpen } = useDisclosure(); + + const handleOnClick = () => { + onOpen(); + }; + + return ( + <> + + + + ); +}; + +export default SelectSwapTo; diff --git a/src/modules/Swap/components/SwapForm/SelectToken.tsx b/src/modules/Swap/components/SwapForm/SelectToken.tsx index 0f92a5d..ac41637 100644 --- a/src/modules/Swap/components/SwapForm/SelectToken.tsx +++ b/src/modules/Swap/components/SwapForm/SelectToken.tsx @@ -1,15 +1,46 @@ import { Box, Flex, Input, Text } from '@chakra-ui/react'; -const SelectToken = () => { +import CircleWrapper from '@/components/Surface/CircleWrapper'; +import { ArrowDownIcon } from '@/icons'; + +type SelectTokenProps = { + onClick?: () => void; +}; + +const SelectToken = ({ onClick }: SelectTokenProps) => { return ( - - - - - CSPR + + + + + CSPR + + + - - + + diff --git a/src/modules/Swap/components/SwapForm/Setting.tsx b/src/modules/Swap/components/SwapForm/Setting.tsx new file mode 100644 index 0000000..c8490d4 --- /dev/null +++ b/src/modules/Swap/components/SwapForm/Setting.tsx @@ -0,0 +1,29 @@ +import { Box, useDisclosure } from '@chakra-ui/react'; + +import ModalTransactionSetting from '../ModalTransactionSetting'; +import CircleWrapper from '@/components/Surface/CircleWrapper'; +import { SettingIcon } from '@/icons'; + +const Setting = () => { + const { isOpen, onOpen, onClose } = useDisclosure(); + + return ( + <> + + + + + + + + ); +}; + +export default Setting; diff --git a/src/modules/Swap/components/SwapForm/index.tsx b/src/modules/Swap/components/SwapForm/index.tsx index 36f9dd7..b4c4ad1 100644 --- a/src/modules/Swap/components/SwapForm/index.tsx +++ b/src/modules/Swap/components/SwapForm/index.tsx @@ -3,9 +3,12 @@ import { FormProvider, useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import RadioPercentSelect from './RadioPercentSelect'; -import SelectToken from './SelectToken'; +import SelectSwapFrom from './SelectSwapFrom'; +import SelectSwapTo from './SelectSwapTo'; +import Setting from './Setting'; import Paper from '@/components/Paper'; -import { RefreshIcon, SettingIcon } from '@/icons'; +import CircleWrapper from '@/components/Surface/CircleWrapper'; +import { RefreshIcon, ReverseIcon } from '@/icons'; const SwapForm = () => { const { t } = useTranslation(); @@ -17,18 +20,35 @@ const SwapForm = () => { return ( - - - - + + + + + + + + - +
- - - + + + + + + + + + +
diff --git a/src/modules/Swap/index.tsx b/src/modules/Swap/index.tsx index ee4ee18..af6e7ac 100644 --- a/src/modules/Swap/index.tsx +++ b/src/modules/Swap/index.tsx @@ -1,4 +1,4 @@ -import { Box, Flex, Text } from '@chakra-ui/react'; +import { Box, Flex, Heading, Text } from '@chakra-ui/react'; import { useTranslation } from 'react-i18next'; import SwapForm from './components/SwapForm'; @@ -7,12 +7,12 @@ const Swap = () => { const { t } = useTranslation(); return ( - + - - {t('swap')} - {t('swap_description')} - + + {t('swap')} + {t('swap_description')} + diff --git a/src/public/locales/en/common.json b/src/public/locales/en/common.json index cfd109d..7e6b5c6 100644 --- a/src/public/locales/en/common.json +++ b/src/public/locales/en/common.json @@ -117,5 +117,11 @@ "recieve": "Recieve", "your_wallet_address": "Your wallet address", "copy_wallet_address": "Copy Wallet Address", - "swap_description": "Send your token the fastest and easy way." + "swap_description": "Send your token the fastest and easy way.", + "search_token": "Search Token", + "token_list": "Token List", + "transaction_settings": "Transaction Settings", + "slippage_tolerance": "Slippage Tolerance", + "transaction_deadline": "Transaction Deadline", + "minutes": "Minutes" } diff --git a/src/theme/components/radioButtons.ts b/src/theme/components/radioButtons.ts index da92d85..a4ca8af 100644 --- a/src/theme/components/radioButtons.ts +++ b/src/theme/components/radioButtons.ts @@ -8,26 +8,22 @@ const RadioButtons = helpers.defineMultiStyleConfig({ baseStyle: { radioButtons: { display: 'flex', + gap: 4, }, item: { cursor: 'pointer', - borderWidth: '1px', - borderRadius: '3xl', - boxShadow: 'md', + fontWeight: 'medium', + lineHeight: 'normal', textAlign: 'center', + borderWidth: '1px', + _focus: { + boxShadow: 'outline', + }, _checked: { bg: 'blue.300', color: 'white', borderColor: 'blue.300', }, - _focus: { - boxShadow: 'outline', - }, - px: 5, - py: 3, - fontWeight: 'medium', - lineHeight: 'normal', - color: 'gray.600', }, }, sizes: { @@ -45,7 +41,17 @@ const RadioButtons = helpers.defineMultiStyleConfig({ }, variants: { primary: { + radioButtons: { + flexWrap: 'wrap', + maxW: { base: 'xs', md: 'lg' }, + justifyContent: 'center', + }, item: { + boxShadow: 'md', + px: 5, + py: 3, + color: 'gray.600', + borderRadius: '3xl', _checked: { bg: 'primary', borderColor: 'primary', @@ -66,6 +72,32 @@ const RadioButtons = helpers.defineMultiStyleConfig({ bg: 'orange.100', }, }, + setting: { + radioButtons: { + gap: 2, + }, + item: { + height: 9, + minW: 16, + px: 2, + py: 2, + borderRadius: '30px', + bg: 'blue.50', + }, + }, + 'full-width': { + radioButtons: { + w: '100%', + gap: '2px', + }, + item: { + flex: '0 0 100%', + bg: 'blue.50', + py: 2, + h: 8, + fontSize: 'xs', + }, + }, }, defaultProps: { size: 'sm', diff --git a/src/theme/foundations/fontSizes.ts b/src/theme/foundations/fontSizes.ts index 335d77f..07bf4a3 100644 --- a/src/theme/foundations/fontSizes.ts +++ b/src/theme/foundations/fontSizes.ts @@ -1,6 +1,7 @@ const fontSizes = { - xs: '0.75rem', - sm: '0.875rem', + tiny: '0.625rem', // 10px + xs: '0.75rem', // 12px + sm: '0.875rem', // 14px md: '1rem', lg: '1.125rem', xl: '1.25rem', diff --git a/src/theme/foundations/space.ts b/src/theme/foundations/space.ts index 8edf42b..264cec2 100644 --- a/src/theme/foundations/space.ts +++ b/src/theme/foundations/space.ts @@ -4,22 +4,23 @@ const space = { 1: '0.25rem', 1.5: '0.375rem', 2: '0.5rem', - 2.5: '0.625rem', - 3: '0.75rem', - 3.5: '0.875rem', + 2.5: '0.625rem', // 10px + 3: '0.75rem', // 12px + 3.5: '0.875rem', // 14px 4: '1rem', // 16px 5: '1.25rem', - 6: '1.5rem', + 6: '1.5rem', // 24px 7: '1.75rem', 8: '2rem', 9: '2.25rem', 10: '2.5rem', + 11: '2.75rem', 12: '3rem', 14: '3.5rem', - 16: '4rem', + 16: '4rem', // 64px 17: '4.25rem', - 18: '4.5rem', - 20: '5rem', + 18: '4.5rem', // 72px + 20: '5rem', // 80px 24: '6rem', 28: '7rem', 30: '7.5rem', From 1d0a39c4aeb83f59f8b5fe9b8a71538f891361b5 Mon Sep 17 00:00:00 2001 From: Liam Date: Mon, 15 May 2023 00:11:14 +0700 Subject: [PATCH 03/10] nit --- src/theme/foundations/radii.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/theme/foundations/radii.ts b/src/theme/foundations/radii.ts index 7b5d6b8..c63ebc3 100644 --- a/src/theme/foundations/radii.ts +++ b/src/theme/foundations/radii.ts @@ -4,10 +4,10 @@ const radii = { base: '0.25rem', md: '0.375rem', lg: '0.5rem', - xl: '0.75rem', - '2xl': '1rem', - '3xl': '1.5rem', - '4xl': '1.75rem', + xl: '0.75rem', // 12px + '2xl': '1rem', // 16px + '3xl': '1.5rem', // 24px + '4xl': '1.75rem', // 28px '5xl': '2rem', full: '9999px', }; From 1cea98e398ac9a947f857ee6e023a4ac7b340830 Mon Sep 17 00:00:00 2001 From: Liam Date: Mon, 15 May 2023 01:47:19 +0700 Subject: [PATCH 04/10] feat: swap --- src/components/Modal/index.tsx | 26 ++-- src/config/configuration.ts | 3 + src/enums/queryKeys.enum.ts | 2 + src/hooks/queries/useGetListSwapTokens.ts | 39 ++++++ src/hooks/useFuse.ts | 45 ++++++ src/icons/search.tsx | 4 +- .../components/ModalSelectToken/TokenItem.tsx | 32 +++++ .../components/ModalSelectToken/index.tsx | 128 ++++++++---------- .../ModalTransactionSetting/index.tsx | 14 +- .../SelectToken.tsx => SelectToken/index.tsx} | 24 +++- .../components/SwapForm/ButtonReverse.tsx | 32 +++++ .../components/SwapForm/SelectSwapFrom.tsx | 22 ++- .../Swap/components/SwapForm/SelectSwapTo.tsx | 22 ++- .../Swap/components/SwapForm/Setting.tsx | 2 +- .../Swap/components/SwapForm/index.tsx | 16 ++- src/public/locales/en/common.json | 4 +- src/services/friendlyMarket/amm/index.ts | 13 ++ src/services/friendlyMarket/amm/type.ts | 4 + src/services/friendlyMarket/balance/index.ts | 21 +++ src/services/friendlyMarket/balance/type.ts | 10 ++ src/services/friendlyMarket/request.ts | 28 ++++ src/services/friendlyMarket/tokens/index.ts | 14 ++ src/services/friendlyMarket/tokens/type.ts | 15 ++ src/theme/components/radioButtons.ts | 2 + 24 files changed, 414 insertions(+), 108 deletions(-) create mode 100644 src/hooks/queries/useGetListSwapTokens.ts create mode 100644 src/hooks/useFuse.ts create mode 100644 src/modules/Swap/components/ModalSelectToken/TokenItem.tsx rename src/modules/Swap/components/{SwapForm/SelectToken.tsx => SelectToken/index.tsx} (58%) create mode 100644 src/modules/Swap/components/SwapForm/ButtonReverse.tsx create mode 100644 src/services/friendlyMarket/amm/index.ts create mode 100644 src/services/friendlyMarket/amm/type.ts create mode 100644 src/services/friendlyMarket/balance/index.ts create mode 100644 src/services/friendlyMarket/balance/type.ts create mode 100644 src/services/friendlyMarket/request.ts create mode 100644 src/services/friendlyMarket/tokens/index.ts create mode 100644 src/services/friendlyMarket/tokens/type.ts diff --git a/src/components/Modal/index.tsx b/src/components/Modal/index.tsx index b5e8c2a..ce7dacf 100644 --- a/src/components/Modal/index.tsx +++ b/src/components/Modal/index.tsx @@ -8,7 +8,6 @@ import { ModalCloseButton, Heading, ModalFooter, - Divider, ModalContent, } from '@chakra-ui/react'; @@ -17,26 +16,29 @@ type ModalProps = { onClose: () => void; title?: string | null; children: ReactNode; + header?: ReactNode; }; -const Modal = ({ isOpen, onClose, title, children }: ModalProps) => { +const Modal = ({ isOpen, onClose, title, children, header }: ModalProps) => { return ( <> - - {title} - + {title && ( + + {title} + + )} + {header} - {children} diff --git a/src/config/configuration.ts b/src/config/configuration.ts index 888a722..9781d89 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -6,4 +6,7 @@ export const Config = { VITE_CASPERDASH_BASE_API_URL || 'https://api.casperdash.io', networkName: VITE_NETWORK_NAME || 'casper', csprLiveUrl: 'https://testnet.cspr.live', + friendlyMarketUrl: 'https://api.friendly.market/api/v1', + tokenListUrl: + 'https://raw.githubusercontent.com/FriendlyMarket/token-list/main/tokenlist.json', }; diff --git a/src/enums/queryKeys.enum.ts b/src/enums/queryKeys.enum.ts index 4641faf..c17088f 100644 --- a/src/enums/queryKeys.enum.ts +++ b/src/enums/queryKeys.enum.ts @@ -20,4 +20,6 @@ export enum QueryKeysEnum { PRICE_HISTORIES = 'price_histories', NFTS = 'nfts', PRIVATE_KEY_WITH_UID = 'private_key_with_uid', + // Swap + SWAP_TOKENS = 'swap_tokens', } diff --git a/src/hooks/queries/useGetListSwapTokens.ts b/src/hooks/queries/useGetListSwapTokens.ts new file mode 100644 index 0000000..992a6d3 --- /dev/null +++ b/src/hooks/queries/useGetListSwapTokens.ts @@ -0,0 +1,39 @@ +import { useQuery } from '@tanstack/react-query'; +import * as _ from 'lodash-es'; + +import { QueryKeysEnum } from '@/enums/queryKeys.enum'; +import { getListTokens, Token } from '@/services/friendlyMarket/tokens'; + +export const MAP_COINGECKO_IDS = { + wcspr: 'casper-network', + cspr: 'casper-network', + deth: 'ethereum', + dusdc: 'usd-coin', + dusdt: 'tether', + dai: 'dai', + frax: 'frax', +}; + +export const useGetListSwapTokens = (options = {}) => { + return useQuery( + [QueryKeysEnum.SWAP_TOKENS], + async () => { + const tokens = await getListTokens(); + + return tokens.map((token: Token) => { + const { symbol = '' } = token; + const coingeckoId = _.get( + MAP_COINGECKO_IDS, + symbol.toLowerCase(), + undefined + ); + + return { + ...token, + coingeckoId, + }; + }); + }, + options + ); +}; diff --git a/src/hooks/useFuse.ts b/src/hooks/useFuse.ts new file mode 100644 index 0000000..40c7e2a --- /dev/null +++ b/src/hooks/useFuse.ts @@ -0,0 +1,45 @@ +import { ChangeEvent, useCallback, useMemo, useState } from 'react'; + +import Fuse from 'fuse.js'; +import { debounce } from 'lodash-es'; + +interface Options extends Fuse.IFuseOptions { + limit?: number; +} + +export const useFuse = (list: T[], options: Options) => { + // defining our query state in there directly + const [query, updateQuery] = useState(''); + + // removing custom options from Fuse options object + // NOTE: `limit` is actually a `fuse.search` option, but we merge all options for convenience + const { limit = 1000, ...fuseOptions } = options; + + // let's memoize the fuse instance for performances + const fuse = useMemo(() => new Fuse(list, fuseOptions), [list, fuseOptions]); + + // memoize results whenever the query or options change + const hits = useMemo( + // if query is empty and `matchAllOnEmptyQuery` is `true` then return all list + // NOTE: we remap the results to match the return structure of `fuse.search()` + () => fuse.search(query, { limit }), + [fuse, limit, query] + ); + + // debounce updateQuery and rename it `setQuery` so it's transparent + const setQuery = useMemo(() => debounce(updateQuery, 100), []); + + // pass a handling helper to speed up implementation + const onSearch = useCallback( + (e: ChangeEvent) => setQuery(e.target.value.trim()), + [setQuery] + ); + + // still returning `setQuery` for custom handler implementations + return { + hits, + onSearch, + query, + setQuery, + }; +}; diff --git a/src/icons/search.tsx b/src/icons/search.tsx index 2994808..004e74f 100644 --- a/src/icons/search.tsx +++ b/src/icons/search.tsx @@ -2,7 +2,7 @@ import { SVGProps } from 'react'; type Props = SVGProps; -export const SearchIcon = ({ ...props }: Props) => { +export const SearchIcon = ({ color = '#777E90', ...props }: Props) => { return ( { > void; +}; + +const TokenItem = ({ name, imageUrl, onClick }: TokenItemProps) => { + return ( + + + + + + {name} + + 0 + + ); +}; + +export default TokenItem; diff --git a/src/modules/Swap/components/ModalSelectToken/index.tsx b/src/modules/Swap/components/ModalSelectToken/index.tsx index 55b6a42..b2e4827 100644 --- a/src/modules/Swap/components/ModalSelectToken/index.tsx +++ b/src/modules/Swap/components/ModalSelectToken/index.tsx @@ -1,89 +1,69 @@ -import { - Modal, - ModalOverlay, - ModalHeader, - ModalBody, - ModalCloseButton, - Heading, - ModalFooter, - Divider, - ModalContent, - Input, - Flex, - Text, -} from '@chakra-ui/react'; +import { Input, Flex, Text } from '@chakra-ui/react'; +import * as _ from 'lodash-es'; import { useTranslation } from 'react-i18next'; -import CircleWrapper from '@/components/Surface/CircleWrapper'; +import TokenItem from './TokenItem'; +import Modal from '@/components/Modal'; +import { useGetListSwapTokens } from '@/hooks/queries/useGetListSwapTokens'; +import { useFuse } from '@/hooks/useFuse'; import { SearchIcon } from '@/icons'; +import { Token } from '@/services/friendlyMarket/tokens'; type ModalReceivingAddressProps = { isOpen: boolean; onClose: () => void; + onSelect?: (token: Token) => void; }; -const ModalSelectToken = ({ isOpen, onClose }: ModalReceivingAddressProps) => { +const ModalSelectToken = ({ + isOpen, + onClose, + onSelect, +}: ModalReceivingAddressProps) => { const { t } = useTranslation(); + const { data: listTokens = [] } = useGetListSwapTokens(); + + const { hits, query, onSearch } = useFuse(listTokens, { + keys: ['name'], + limit: 1000, + }); + + const tokens = query ? _.map(hits, 'item') : listTokens; return ( - <> - - - - - - {t('search_token')} - - - - - - - - - - - - - {t('token_list')} - - - - - - CSPR - - 0 - - - - - - - + + + + + + + + + {t('token_list')} + + + {tokens?.map((token) => ( + onSelect?.(token)} + /> + ))} + + ); }; diff --git a/src/modules/Swap/components/ModalTransactionSetting/index.tsx b/src/modules/Swap/components/ModalTransactionSetting/index.tsx index 653c9a6..553fec4 100644 --- a/src/modules/Swap/components/ModalTransactionSetting/index.tsx +++ b/src/modules/Swap/components/ModalTransactionSetting/index.tsx @@ -1,4 +1,11 @@ -import { Box, FormControl, FormLabel, Input } from '@chakra-ui/react'; +import { + Box, + Button, + Flex, + FormControl, + FormLabel, + Input, +} from '@chakra-ui/react'; import { useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; @@ -37,6 +44,11 @@ const ModalTransactionSetting = ({ {t('transaction_deadline')} + + + ); diff --git a/src/modules/Swap/components/SwapForm/SelectToken.tsx b/src/modules/Swap/components/SelectToken/index.tsx similarity index 58% rename from src/modules/Swap/components/SwapForm/SelectToken.tsx rename to src/modules/Swap/components/SelectToken/index.tsx index ac41637..196c5d8 100644 --- a/src/modules/Swap/components/SwapForm/SelectToken.tsx +++ b/src/modules/Swap/components/SelectToken/index.tsx @@ -1,13 +1,19 @@ -import { Box, Flex, Input, Text } from '@chakra-ui/react'; +import { Box, Flex, Image, Input, Text } from '@chakra-ui/react'; +import * as _ from 'lodash-es'; +import { useTranslation } from 'react-i18next'; import CircleWrapper from '@/components/Surface/CircleWrapper'; import { ArrowDownIcon } from '@/icons'; +import { Token } from '@/services/friendlyMarket/tokens'; type SelectTokenProps = { onClick?: () => void; + value?: Token; }; -const SelectToken = ({ onClick }: SelectTokenProps) => { +const SelectToken = ({ value, onClick }: SelectTokenProps) => { + const { t } = useTranslation(); + return ( { _hover={{ color: 'light', cursor: 'pointer' }} onClick={onClick} > - - CSPR + {!_.isEmpty(value) ? ( + <> + + + + {value.symbol} + + ) : ( + + {t('select_token')} + + )} diff --git a/src/modules/Swap/components/SwapForm/ButtonReverse.tsx b/src/modules/Swap/components/SwapForm/ButtonReverse.tsx new file mode 100644 index 0000000..f93801b --- /dev/null +++ b/src/modules/Swap/components/SwapForm/ButtonReverse.tsx @@ -0,0 +1,32 @@ +import { Flex } from '@chakra-ui/react'; +import { useFormContext, useWatch } from 'react-hook-form'; + +import CircleWrapper from '@/components/Surface/CircleWrapper'; +import { ReverseIcon } from '@/icons'; + +const ButtonReverse = () => { + const { control, setValue } = useFormContext(); + const swapFrom = useWatch({ + control, + name: 'swapFrom', + }); + + const swapTo = useWatch({ + control, + name: 'swapTo', + }); + const handleReverse = () => { + setValue('swapFrom', swapTo); + setValue('swapTo', swapFrom); + }; + + return ( + + + + + + ); +}; + +export default ButtonReverse; diff --git a/src/modules/Swap/components/SwapForm/SelectSwapFrom.tsx b/src/modules/Swap/components/SwapForm/SelectSwapFrom.tsx index 9a9baea..8b1fbeb 100644 --- a/src/modules/Swap/components/SwapForm/SelectSwapFrom.tsx +++ b/src/modules/Swap/components/SwapForm/SelectSwapFrom.tsx @@ -1,19 +1,35 @@ import { useDisclosure } from '@chakra-ui/react'; +import { useFormContext, useWatch } from 'react-hook-form'; -import SelectToken from './SelectToken'; import ModalSelectToken from '../ModalSelectToken'; +import SelectToken from '../SelectToken'; +import { Token } from '@/services/friendlyMarket/tokens'; const SelectSwapFrom = () => { const { isOpen, onClose, onOpen } = useDisclosure(); + const { control, setValue } = useFormContext(); + const valueWatched = useWatch({ + control, + name: 'swapFrom', + }); const handleOnClick = () => { onOpen(); }; + const handleOnSelect = (token: Token) => { + setValue('swapFrom', token); + onClose(); + }; + return ( <> - - + + ); }; diff --git a/src/modules/Swap/components/SwapForm/SelectSwapTo.tsx b/src/modules/Swap/components/SwapForm/SelectSwapTo.tsx index 8e2454e..dae5ac0 100644 --- a/src/modules/Swap/components/SwapForm/SelectSwapTo.tsx +++ b/src/modules/Swap/components/SwapForm/SelectSwapTo.tsx @@ -1,19 +1,35 @@ import { useDisclosure } from '@chakra-ui/react'; +import { useFormContext, useWatch } from 'react-hook-form'; -import SelectToken from './SelectToken'; import ModalSelectToken from '../ModalSelectToken'; +import SelectToken from '../SelectToken'; +import { Token } from '@/services/friendlyMarket/tokens'; const SelectSwapTo = () => { const { isOpen, onClose, onOpen } = useDisclosure(); + const { control, setValue } = useFormContext(); + const valueWatched = useWatch({ + control, + name: 'swapTo', + }); const handleOnClick = () => { onOpen(); }; + const handleOnSelect = (token: Token) => { + setValue('swapTo', token); + onClose(); + }; + return ( <> - - + + ); }; diff --git a/src/modules/Swap/components/SwapForm/Setting.tsx b/src/modules/Swap/components/SwapForm/Setting.tsx index c8490d4..767c437 100644 --- a/src/modules/Swap/components/SwapForm/Setting.tsx +++ b/src/modules/Swap/components/SwapForm/Setting.tsx @@ -17,7 +17,7 @@ const Setting = () => { _hover={{ color: 'light' }} onClick={onOpen} > - + diff --git a/src/modules/Swap/components/SwapForm/index.tsx b/src/modules/Swap/components/SwapForm/index.tsx index b4c4ad1..f225f7d 100644 --- a/src/modules/Swap/components/SwapForm/index.tsx +++ b/src/modules/Swap/components/SwapForm/index.tsx @@ -2,17 +2,23 @@ import { Box, Button, Flex } from '@chakra-ui/react'; import { FormProvider, useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; +import ButtonReverse from './ButtonReverse'; import RadioPercentSelect from './RadioPercentSelect'; import SelectSwapFrom from './SelectSwapFrom'; import SelectSwapTo from './SelectSwapTo'; import Setting from './Setting'; import Paper from '@/components/Paper'; import CircleWrapper from '@/components/Surface/CircleWrapper'; -import { RefreshIcon, ReverseIcon } from '@/icons'; +import { RefreshIcon } from '@/icons'; const SwapForm = () => { const { t } = useTranslation(); - const methods = useForm(); + const methods = useForm({ + defaultValues: { + swapFrom: {}, + swapTo: {}, + }, + }); const handleOnSubmit = () => { console.log('submit'); @@ -43,11 +49,7 @@ const SwapForm = () => { - - - - - + {t('transaction_deadline')} - + - diff --git a/src/modules/Swap/components/SelectToken/Balance.tsx b/src/modules/Swap/components/SelectToken/Balance.tsx new file mode 100644 index 0000000..1ad33a8 --- /dev/null +++ b/src/modules/Swap/components/SelectToken/Balance.tsx @@ -0,0 +1,28 @@ +import { Text } from '@chakra-ui/react'; +import { useTranslation } from 'react-i18next'; + +import { useAccount } from '@/hooks/useAccount'; +import { useGetSwapTokenBalance } from '@/modules/Swap/hooks/useGetSwapTokenBalance'; +import { Token } from '@/services/friendlyMarket/tokens'; + +type BalanceProps = { + value?: Token; +}; + +const Balance = ({ value }: BalanceProps) => { + const { publicKey } = useAccount(); + + const { data } = useGetSwapTokenBalance({ + ...value, + publicKey, + }); + const { t } = useTranslation(); + + return ( + + {t('balance')}: {data?.balance || 0} + + ); +}; + +export default Balance; diff --git a/src/modules/Swap/components/SelectToken/Price.tsx b/src/modules/Swap/components/SelectToken/Price.tsx new file mode 100644 index 0000000..e016628 --- /dev/null +++ b/src/modules/Swap/components/SelectToken/Price.tsx @@ -0,0 +1,29 @@ +import { Text } from '@chakra-ui/react'; +import Big from 'big.js'; +import { useTranslation } from 'react-i18next'; + +import { useGetCoinMarketData } from '@/hooks/queries/useGetCoinMarketData'; +import { Token } from '@/services/friendlyMarket/tokens'; + +type PriceProps = { + value?: Token; +}; + +const Price = ({ value }: PriceProps) => { + const { t } = useTranslation(); + const { data = { price: 0, amount: 0 } } = useGetCoinMarketData( + value?.coingeckoId + ); + const amountInUsd = Big(data.price || 0) + .times(value?.amount || 0) + .round(8) + .toNumber(); + + return ( + + {t('price')}: {amountInUsd || 0} + + ); +}; + +export default Price; diff --git a/src/modules/Swap/components/SelectToken/index.tsx b/src/modules/Swap/components/SelectToken/index.tsx index 196c5d8..13f0fc3 100644 --- a/src/modules/Swap/components/SelectToken/index.tsx +++ b/src/modules/Swap/components/SelectToken/index.tsx @@ -2,25 +2,30 @@ import { Box, Flex, Image, Input, Text } from '@chakra-ui/react'; import * as _ from 'lodash-es'; import { useTranslation } from 'react-i18next'; +import Balance from './Balance'; +import Price from './Price'; import CircleWrapper from '@/components/Surface/CircleWrapper'; import { ArrowDownIcon } from '@/icons'; import { Token } from '@/services/friendlyMarket/tokens'; type SelectTokenProps = { onClick?: () => void; + onChangeAmount?: (amount: string) => void; value?: Token; }; -const SelectToken = ({ value, onClick }: SelectTokenProps) => { +const SelectToken = ({ value, onClick, onChangeAmount }: SelectTokenProps) => { const { t } = useTranslation(); return ( { _hover={{ color: 'light', cursor: 'pointer' }} onClick={onClick} > - {!_.isEmpty(value) ? ( + {_.isEmpty(value) || !value.contractHash ? ( + {t('select_token')} + ) : ( <> {value.symbol} - ) : ( - - {t('select_token')} - )} @@ -56,10 +59,17 @@ const SelectToken = ({ value, onClick }: SelectTokenProps) => { variant="unstyled" border={'none'} placeholder="0.0" + onChange={(e) => { + onChangeAmount?.(e.target.value); + }} + value={value?.amount} /> - + + + + ); }; diff --git a/src/modules/Swap/components/SwapForm/ButtonReverse.tsx b/src/modules/Swap/components/SwapForm/ButtonReverse.tsx index f93801b..7c742f1 100644 --- a/src/modules/Swap/components/SwapForm/ButtonReverse.tsx +++ b/src/modules/Swap/components/SwapForm/ButtonReverse.tsx @@ -21,8 +21,13 @@ const ButtonReverse = () => { }; return ( - - + + diff --git a/src/modules/Swap/components/SwapForm/RadioPercentSelect.tsx b/src/modules/Swap/components/SwapForm/RadioPercentSelect.tsx index aa01246..a3e0ca7 100644 --- a/src/modules/Swap/components/SwapForm/RadioPercentSelect.tsx +++ b/src/modules/Swap/components/SwapForm/RadioPercentSelect.tsx @@ -1,5 +1,9 @@ +import Big from 'big.js'; + import RadioButton from '@/components/Inputs/RadioButton'; import RadioButtonGroup from '@/components/Inputs/RadioButton/RadioButtonGroup'; +import { useGetBalanceSelectedToken } from '@/modules/Swap/hooks/useGetBalanceSelectedToken'; +import { useSetValueSwapFrom } from '@/modules/Swap/hooks/useSetValueSwapFrom'; const PERCENTS = [ { @@ -21,20 +25,24 @@ const PERCENTS = [ ]; const RadioPercentSelect = () => { + const { data } = useGetBalanceSelectedToken('swapFrom'); + const setValueSwapFrom = useSetValueSwapFrom(); + + const handleSetAmount = (value: string) => { + const amount = Big(value) + .mul(data?.balance || 0) + .div(100) + .toNumber(); + setValueSwapFrom(amount); + }; + return ( { - // const masterKey = KeyFactory.getInstance().generate( - // parseInt(value, 10) - // ); - // setValue('masterKey', masterKey); - - // onChange(parseInt(value)); - // }} + defaultValue={'0'} + onChange={handleSetAmount} > {PERCENTS.map((item) => { return ( diff --git a/src/modules/Swap/components/SwapForm/Receipt.tsx b/src/modules/Swap/components/SwapForm/Receipt.tsx new file mode 100644 index 0000000..74856ee --- /dev/null +++ b/src/modules/Swap/components/SwapForm/Receipt.tsx @@ -0,0 +1,115 @@ +import { Flex, Text, VStack } from '@chakra-ui/react'; +import Big from 'big.js'; +import { useFormContext } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; + +import { useCalculateAmountOutMin } from '@/modules/Swap/hooks/useCalculateAmountOutMin'; +import { useGetCurrentAMMPair } from '@/modules/Swap/hooks/useGetCurrentAMMPair'; +import { useGetSwapSettings } from '@/modules/Swap/hooks/useGetSwapSettings'; +import { useSelectToken } from '@/modules/Swap/hooks/useSelectToken'; +import { Token } from '@/services/friendlyMarket/tokens'; + +const Row = ({ label, value }: { label: string; value: string }) => { + return ( + + {label} + {value} + + ); +}; + +const Receipt = () => { + const { setValue } = useFormContext(); + const { t } = useTranslation(); + const swapFrom: Token = useSelectToken('swapFrom'); + const swapTo: Token = useSelectToken('swapTo'); + const { data: swapSettings = { slippage: 0 } } = useGetSwapSettings({ + onSuccess: (data) => { + setValue('swapSettings', data); + }, + }); + const { data: pair } = useGetCurrentAMMPair({ + onSuccess: (data) => { + setValue('pair', data); + }, + }); + const amountOutMin = useCalculateAmountOutMin(); + + console.log('swapSettings: ', swapSettings); + + const fee = Big(swapFrom.amount || 0) + .times(0.3) + .div(100) + .round(swapFrom.decimals, 0) + .toNumber(); + const priceImpact = swapTo.amountInUSD + ? Big(100) + .minus( + Big(swapFrom.amountInUSD || 0) + .div(swapTo.amountInUSD || 1) + .times(100) + ) + .round(4, 0) + .toNumber() + : 0; + const rate = + swapTo.amount && swapTo.amount > 0 + ? Big(swapTo.amount || 0) + .div(Big(swapFrom.amount || 1)) + .toFixed(8, 0) + : 0; + + if (!pair) { + return null; + } + + const items = [ + { + label: t('rate'), + value: `1 ${swapFrom.symbol} ~ ${ + swapTo.amount && swapTo.amount > 0 ? rate : 0 + } ${swapTo.symbol}`, + }, + { + label: t('fee'), + value: `${fee} ${swapFrom.symbol}`, + }, + { + label: t('price_impact'), + value: `${Math.min(Math.abs(priceImpact), 99.7)}%`, + }, + { + label: t('minimum'), + value: `At least ${Big(amountOutMin).toFixed(8, 0)}`, + }, + { + label: t('slippage'), + value: `${swapSettings.slippage}%`, + }, + ]; + + return ( + + {items.map((item) => { + return ( + + ); + })} + + ); +}; + +export default Receipt; diff --git a/src/modules/Swap/components/SwapForm/SelectSwapFrom.tsx b/src/modules/Swap/components/SwapForm/SelectSwapFrom.tsx index 8b1fbeb..40a1f4f 100644 --- a/src/modules/Swap/components/SwapForm/SelectSwapFrom.tsx +++ b/src/modules/Swap/components/SwapForm/SelectSwapFrom.tsx @@ -1,8 +1,10 @@ import { useDisclosure } from '@chakra-ui/react'; +import Big from 'big.js'; import { useFormContext, useWatch } from 'react-hook-form'; import ModalSelectToken from '../ModalSelectToken'; import SelectToken from '../SelectToken'; +import { useSetValueSwapFrom } from '@/modules/Swap/hooks/useSetValueSwapFrom'; import { Token } from '@/services/friendlyMarket/tokens'; const SelectSwapFrom = () => { @@ -12,19 +14,36 @@ const SelectSwapFrom = () => { control, name: 'swapFrom', }); + const setValueSwapFrom = useSetValueSwapFrom(); const handleOnClick = () => { onOpen(); }; const handleOnSelect = (token: Token) => { - setValue('swapFrom', token); + setValue('swapFrom', { + ...token, + amount: 0, + }); + setValueSwapFrom(0); + onClose(); }; + const handleOnChangeAmount = (amount: string) => { + const value = Big(amount || 0) + .round(valueWatched.decimals) + .toNumber(); + setValueSwapFrom(value); + }; + return ( <> - + { @@ -12,19 +14,37 @@ const SelectSwapTo = () => { control, name: 'swapTo', }); + const setValueAmountIn = useCalculateAmountIn(); const handleOnClick = () => { onOpen(); }; const handleOnSelect = (token: Token) => { - setValue('swapTo', token); + setValue('swapTo', { + ...token, + amount: 0, + }); + setValueAmountIn(0); + onClose(); }; + const handleOnChangeAmount = (amount: string) => { + const value = Big(amount || 0) + .round(valueWatched.decimals || 0) + .toNumber(); + setValue('swapTo.amount', value); + setValueAmountIn(value); + }; + return ( <> - + { + const { toastSuccess } = useI18nToast(); const { t } = useTranslation(); - const methods = useForm({ + const { mutate, isLoading } = useMutateSwapTokens({ + onSuccess: (result: string | undefined) => { + toastSuccess('deploy_hash', { deployHash: result || '' }); + }, + }); + const methods = useForm({ defaultValues: { swapFrom: {}, swapTo: {}, + swapSettings: { + slippage: 0, + deadline: 0, + }, + pair: {}, }, }); - const handleOnSubmit = () => { - console.log('submit'); + const handleOnSubmit = (values: FieldValues) => { + const { swapFrom, swapTo, pair, swapSettings } = values; + const amountInValue = Big(swapFrom.amount || 0) + .round(swapFrom.decimals, 0) + .toNumber(); + + let path = [swapFrom.contractHash, swapTo.contractHash].map( + (hash) => `hash-${hash}` + ); + const routingPair = pair as PairRouteData; + if (routingPair.isUsingRouting) { + path = routingPair.path; + } + const amountOutMin = calculateAmountOutMin( + swapTo.amount || 0, + swapSettings.slippage, + swapTo.decimals + ); + + const amountIn = Big(amountInValue * 10 ** swapFrom.decimals).toNumber(); + + const amountOut = Big(amountOutMin * 10 ** swapTo.decimals).toNumber(); + + if ( + swapFrom.type === TokenTypesEnum.NATIVE && + swapTo.type === TokenTypesEnum.ERC20 + ) { + return mutate({ + functionType: FUNCTIONS.SWAP_EXACT_CSPR_FOR_TOKENS, + amountIn, + amountOut, + deadlineInMinutes: swapSettings.deadline, + path, + }); + } + + if ( + swapFrom.type === TokenTypesEnum.ERC20 && + swapTo.type === TokenTypesEnum.NATIVE + ) { + return mutate({ + functionType: FUNCTIONS.SWAP_EXACT_TOKENS_FOR_CSPR, + amountIn, + amountOut, + deadlineInMinutes: swapSettings.deadline, + path, + }); + } + + if ( + swapFrom.type === TokenTypesEnum.ERC20 && + swapTo.type === TokenTypesEnum.ERC20 + ) { + if (routingPair.isUsingRouting) { + return mutate({ + functionType: FUNCTIONS.SWAP_EXACT_TOKENS_FOR_TOKENS, + amountIn, + amountOut, + deadlineInMinutes: swapSettings.deadline, + path, + }); + } + + return mutate({ + functionType: FUNCTIONS.SWAP_TOKENS_FOR_EXACT_TOKENS, + amountIn, + amountOut, + deadlineInMinutes: swapSettings.deadline, + path, + }); + } }; return ( @@ -51,17 +150,17 @@ const SwapForm = () => {
- + + diff --git a/src/modules/Swap/hooks/useCalculateAmountIn.ts b/src/modules/Swap/hooks/useCalculateAmountIn.ts new file mode 100644 index 0000000..4e56ffa --- /dev/null +++ b/src/modules/Swap/hooks/useCalculateAmountIn.ts @@ -0,0 +1,66 @@ +import { useCallback } from 'react'; + +import Big from 'big.js'; +import { useFormContext } from 'react-hook-form'; + +import { useGetCurrentAMMPair } from './useGetCurrentAMMPair'; +import { useSelectToken } from './useSelectToken'; +import { getAmountIn } from '../utils'; +import { PairData, PairRouteData } from '@/services/friendlyMarket/amm/type'; + +type CalculatePriceParams = { + value: number; + reverseIn: number | string; + reverseOut: number | string; + decimals: number; +}; + +const useCalculateAmountIn = () => { + const { setValue } = useFormContext(); + const { data: pair = {} } = useGetCurrentAMMPair(); + const swapFrom = useSelectToken('swapFrom'); + const calculatePrice = useCallback( + ({ value, reverseIn, reverseOut, decimals }: CalculatePriceParams) => { + const amount = Big(getAmountIn(reverseIn, reverseOut, value)) + .round(decimals, 0) + .toNumber(); + + setValue('swapFrom.amount', amount); + }, + [setValue] + ); + + const handleChangeAmount = useCallback( + (value: number) => { + if (!value) { + return; + } + const pairRouting = pair; + if (pairRouting.isUsingRouting) { + const bridgeAmount = getAmountIn( + pairRouting.intToken1Pair.reserve0, + pairRouting.intToken1Pair.reserve1, + value + ); + calculatePrice({ + value: bridgeAmount, + reverseIn: pairRouting.token0IntPair.reserve0, + reverseOut: pairRouting.token0IntPair.reserve1, + decimals: swapFrom.decimals, + }); + } else if (pair && swapFrom.contractHash) { + calculatePrice({ + value, + reverseIn: (pair).reserve0, + reverseOut: (pair).reserve1, + decimals: swapFrom.decimals, + }); + } + }, + [calculatePrice, pair, swapFrom.contractHash, swapFrom.decimals] + ); + + return handleChangeAmount; +}; + +export default useCalculateAmountIn; diff --git a/src/modules/Swap/hooks/useCalculateAmountOut.ts b/src/modules/Swap/hooks/useCalculateAmountOut.ts new file mode 100644 index 0000000..159cfbe --- /dev/null +++ b/src/modules/Swap/hooks/useCalculateAmountOut.ts @@ -0,0 +1,66 @@ +import { useCallback } from 'react'; + +import Big from 'big.js'; +import { useFormContext } from 'react-hook-form'; + +import { useGetCurrentAMMPair } from './useGetCurrentAMMPair'; +import { useSelectToken } from './useSelectToken'; +import { getAmountOut } from '../utils'; +import { PairData, PairRouteData } from '@/services/friendlyMarket/amm/type'; + +type CalculatePriceParams = { + value: number; + reverseIn: number | string; + reverseOut: number | string; + decimals: number; +}; + +const useCalculateAmountOut = () => { + const { setValue } = useFormContext(); + const { data: pair = {} } = useGetCurrentAMMPair(); + const swapTo = useSelectToken('swapTo'); + const calculatePrice = useCallback( + ({ value, reverseIn, reverseOut, decimals }: CalculatePriceParams) => { + const amount = Big(getAmountOut(reverseIn, reverseOut, value)) + .round(decimals, 0) + .toNumber(); + + setValue('swapTo.amount', amount); + }, + [setValue] + ); + + const handleChangeAmount = useCallback( + (value: number) => { + if (!value) { + return; + } + const pairRouting = pair; + if (pairRouting.isUsingRouting) { + const bridgeAmount = getAmountOut( + pairRouting.token0IntPair.reserve0, + pairRouting.token0IntPair.reserve1, + value + ); + calculatePrice({ + value: bridgeAmount, + reverseIn: pairRouting.intToken1Pair.reserve0, + reverseOut: pairRouting.intToken1Pair.reserve1, + decimals: swapTo.decimals, + }); + } else if (pair && swapTo.contractHash) { + calculatePrice({ + value, + reverseIn: (pair).reserve0, + reverseOut: (pair).reserve1, + decimals: swapTo.decimals, + }); + } + }, + [calculatePrice, pair, swapTo.contractHash, swapTo.decimals] + ); + + return handleChangeAmount; +}; + +export default useCalculateAmountOut; diff --git a/src/modules/Swap/hooks/useCalculateAmountOutMin.ts b/src/modules/Swap/hooks/useCalculateAmountOutMin.ts new file mode 100644 index 0000000..420a397 --- /dev/null +++ b/src/modules/Swap/hooks/useCalculateAmountOutMin.ts @@ -0,0 +1,21 @@ +import { useGetSwapSettings } from './useGetSwapSettings'; +import { useSelectToken } from './useSelectToken'; +import { calculateAmountOutMin } from '../utils'; + +export const useCalculateAmountOutMin = () => { + const swapTo = useSelectToken('swapTo'); + const { data: swapSettings = { slippage: 0 }, isLoading } = + useGetSwapSettings(); + + if (isLoading) { + return 0; + } + + const amountOutMin = calculateAmountOutMin( + swapTo.amount, + swapSettings.slippage, + swapTo.decimals + ); + + return amountOutMin; +}; diff --git a/src/modules/Swap/hooks/useGetBalanceSelectedToken.ts b/src/modules/Swap/hooks/useGetBalanceSelectedToken.ts new file mode 100644 index 0000000..38ea80a --- /dev/null +++ b/src/modules/Swap/hooks/useGetBalanceSelectedToken.ts @@ -0,0 +1,20 @@ +import { useFormContext, useWatch } from 'react-hook-form'; + +import { useGetSwapTokenBalance } from './useGetSwapTokenBalance'; +import { useAccount } from '@/hooks/useAccount'; + +type Name = 'swapFrom' | 'swapTo'; + +export const useGetBalanceSelectedToken = (name: Name) => { + const { publicKey } = useAccount(); + const { control } = useFormContext(); + const value = useWatch({ + control, + name: name, + }); + + return useGetSwapTokenBalance({ + ...value, + publicKey, + }); +}; diff --git a/src/modules/Swap/hooks/useGetCurrentAMMPair.ts b/src/modules/Swap/hooks/useGetCurrentAMMPair.ts new file mode 100644 index 0000000..45c211f --- /dev/null +++ b/src/modules/Swap/hooks/useGetCurrentAMMPair.ts @@ -0,0 +1,26 @@ +import { useFormContext, useWatch } from 'react-hook-form'; + +import { + useGetSwapAMMPair, + UseGetSwapAMMPairData, + UseGetSwapAMMPairOption, +} from './useGetSwapAMMPair'; +import { Token } from '@/services/friendlyMarket/tokens'; + +export const useGetCurrentAMMPair = ( + options: UseGetSwapAMMPairOption = {} +): UseGetSwapAMMPairData => { + const { control } = useFormContext(); + + const swapFrom: Token = useWatch({ + control, + name: 'swapFrom', + }); + + const swapTo: Token = useWatch({ + control, + name: 'swapTo', + }); + + return useGetSwapAMMPair(swapFrom.contractHash, swapTo.contractHash, options); +}; diff --git a/src/modules/Swap/hooks/useGetSwapAMMPair.ts b/src/modules/Swap/hooks/useGetSwapAMMPair.ts new file mode 100644 index 0000000..eada4d7 --- /dev/null +++ b/src/modules/Swap/hooks/useGetSwapAMMPair.ts @@ -0,0 +1,55 @@ +import { + useQuery, + UseQueryOptions, + UseQueryResult, +} from '@tanstack/react-query'; +import _isArray from 'lodash-es/isArray'; + +import { QueryKeysEnum } from '@/enums/queryKeys.enum'; +import { getPair } from '@/services/friendlyMarket/amm'; +import { PairData, PairRouteData } from '@/services/friendlyMarket/amm/type'; + +type Data = PairData | PairRouteData | undefined; + +export type UseGetSwapAMMPairData = UseQueryResult; + +export type UseGetSwapAMMPairOption = Omit< + UseQueryOptions< + Data, + unknown, + Data, + [QueryKeysEnum.SWAP_AMM_PAIR, string | undefined, string | undefined] + >, + 'queryKey' | 'queryFn' +>; + +export const useGetSwapAMMPair = ( + fromContractHash: string, + toContractHash: string, + options: UseGetSwapAMMPairOption = {} +): UseGetSwapAMMPairData => { + return useQuery( + [QueryKeysEnum.SWAP_AMM_PAIR, fromContractHash, toContractHash], + async () => { + const data = await getPair({ fromContractHash, toContractHash }); + + const pairRouteData = data; + if ( + pairRouteData.path && + _isArray(pairRouteData.path) && + pairRouteData.path.length > 0 + ) { + return { + ...pairRouteData, + isUsingRouting: true, + }; + } + + return data; + }, + { + ...options, + enabled: !!fromContractHash && !!toContractHash, + } + ); +}; diff --git a/src/hooks/queries/useGetListSwapTokens.ts b/src/modules/Swap/hooks/useGetSwapListTokens.ts similarity index 79% rename from src/hooks/queries/useGetListSwapTokens.ts rename to src/modules/Swap/hooks/useGetSwapListTokens.ts index 992a6d3..f40342d 100644 --- a/src/hooks/queries/useGetListSwapTokens.ts +++ b/src/modules/Swap/hooks/useGetSwapListTokens.ts @@ -2,6 +2,7 @@ import { useQuery } from '@tanstack/react-query'; import * as _ from 'lodash-es'; import { QueryKeysEnum } from '@/enums/queryKeys.enum'; +import { useAccount } from '@/hooks/useAccount'; import { getListTokens, Token } from '@/services/friendlyMarket/tokens'; export const MAP_COINGECKO_IDS = { @@ -14,7 +15,8 @@ export const MAP_COINGECKO_IDS = { frax: 'frax', }; -export const useGetListSwapTokens = (options = {}) => { +export const useGetSwapListTokens = (options = {}) => { + const { publicKey = '' } = useAccount(); return useQuery( [QueryKeysEnum.SWAP_TOKENS], async () => { @@ -34,6 +36,9 @@ export const useGetListSwapTokens = (options = {}) => { }; }); }, - options + { + ...options, + enabled: !!publicKey, + } ); }; diff --git a/src/modules/Swap/hooks/useGetSwapSettings.ts b/src/modules/Swap/hooks/useGetSwapSettings.ts new file mode 100644 index 0000000..ca0cbea --- /dev/null +++ b/src/modules/Swap/hooks/useGetSwapSettings.ts @@ -0,0 +1,44 @@ +import { + useQuery, + useQueryClient, + UseQueryOptions, +} from '@tanstack/react-query'; + +import { QueryKeysEnum } from '@/enums/queryKeys.enum'; + +type Data = { + slippage: number; + deadline: number; +}; + +const DEFAULT_SETTINGS = { + slippage: 0.5, + deadline: 20, +}; + +export const useGetSwapSettings = ( + options: Omit< + UseQueryOptions, + 'queryKey' | 'queryFn' + > = {} +) => { + const queryClient = useQueryClient(); + return useQuery({ + ...options, + queryFn: async () => { + const swapSettings = queryClient.getQueryData([ + QueryKeysEnum.SWAP_SETTINGS, + ]); + if (!swapSettings) { + return DEFAULT_SETTINGS; + } + + return { + ...DEFAULT_SETTINGS, + ...swapSettings, + }; + }, + queryKey: [QueryKeysEnum.SWAP_SETTINGS], + networkMode: 'offlineFirst', + }); +}; diff --git a/src/modules/Swap/hooks/useGetSwapTokenBalance.ts b/src/modules/Swap/hooks/useGetSwapTokenBalance.ts new file mode 100644 index 0000000..50478db --- /dev/null +++ b/src/modules/Swap/hooks/useGetSwapTokenBalance.ts @@ -0,0 +1,65 @@ +import { useQuery } from '@tanstack/react-query'; +import Big from 'big.js'; + +import { QueryKeysEnum } from '@/enums/queryKeys.enum'; +import { TokenTypesEnum } from '@/enums/tokenTypes'; +import { + getBalance, + getErc20Balance, + GetBalanceResponse, +} from '@/services/friendlyMarket/balance'; + +type GetErc20BalanceParams = { + publicKey?: string; + contractHash?: string; + type?: TokenTypesEnum; + decimals?: number; +}; + +export const useGetSwapTokenBalance = ( + { publicKey, contractHash, type, decimals = 0 }: GetErc20BalanceParams, + options = {} +) => { + return useQuery( + [QueryKeysEnum.SWAP_TOKEN_BALANCE, publicKey, type, contractHash], + async () => { + let result: GetBalanceResponse = { + balance: 0, + }; + + if (type === TokenTypesEnum.ERC20) { + if (!contractHash) { + return result; + } + + result = await getErc20Balance({ + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + publicKey: publicKey!, + contractHash, + }); + } + + if (type === TokenTypesEnum.NATIVE) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + result = await getBalance({ publicKey: publicKey! }); + } + + if (result.error) { + return result; + } + + return { + ...result, + balance: + Big(result.balance || 0) + .div(Big(10).pow(decimals)) + .round(decimals) + .toNumber() || 0, + }; + }, + { + ...options, + enabled: !!publicKey && !!type && !!decimals, + } + ); +}; diff --git a/src/modules/Swap/hooks/useMutateSwapSettings.ts b/src/modules/Swap/hooks/useMutateSwapSettings.ts new file mode 100644 index 0000000..5376ece --- /dev/null +++ b/src/modules/Swap/hooks/useMutateSwapSettings.ts @@ -0,0 +1,37 @@ +import { + useQueryClient, + useMutation, + UseMutationOptions, +} from '@tanstack/react-query'; + +import { QueryKeysEnum } from '@/enums/queryKeys.enum'; + +type Params = { + slippage?: number; + deadline?: number; +}; + +type MutateVariables = Params; +type Data = Params; + +export const useMutateSwapSettings = ( + options: UseMutationOptions +) => { + const queryClient = useQueryClient(); + + return useMutation({ + ...options, + mutationFn: async (newSettings: Params) => { + queryClient.setQueryData( + [QueryKeysEnum.SWAP_SETTINGS], + (oldSettings: Params = {}) => ({ + ...oldSettings, + ...newSettings, + }) + ); + + return newSettings; + }, + networkMode: 'offlineFirst', + }); +}; diff --git a/src/modules/Swap/hooks/useMutateSwapTokens.ts b/src/modules/Swap/hooks/useMutateSwapTokens.ts new file mode 100644 index 0000000..53b0623 --- /dev/null +++ b/src/modules/Swap/hooks/useMutateSwapTokens.ts @@ -0,0 +1,124 @@ +import { useMutation, UseMutationOptions } from '@tanstack/react-query'; +import { DeployUtil } from 'casper-js-sdk'; +import dayjs from 'dayjs'; + +import { Config } from '@/config'; +import { useAccount } from '@/hooks/useAccount'; +import { deploy } from '@/services/casperdash/deploy/deploy.service'; +import casperUserUtil from '@/utils/casper/casperUser'; +import { + buildExactSwapCSPRForTokensDeploy, + buildSwapExactTokensForCSPRDeploy, + buildSwapExactTokensForTokensDeploy, + buildSwapTokensForExactTokensDeploy, + FUNCTIONS, +} from '@/utils/casper/tokenServices'; + +type SwapTokensParams = { + functionType: string; + amountIn: number; + amountOut: number; + deadlineInMinutes: number; + path: string[]; +}; + +export const useMutateSwapTokens = ( + options: UseMutationOptions = {} +) => { + const { publicKey = '' } = useAccount(); + + const putSignedDeploy = async (buildedDeploy: DeployUtil.Deploy) => { + const signedDeploy = await casperUserUtil.signWithPrivateKey(buildedDeploy); + + const result = await deploy(signedDeploy); + + return result.deployHash; + }; + + return useMutation({ + mutationFn: async ({ + functionType, + amountIn, + amountOut, + deadlineInMinutes, + path, + }: SwapTokensParams) => { + let builtDeploy = null; + + switch (functionType) { + case FUNCTIONS.SWAP_EXACT_CSPR_FOR_TOKENS: + builtDeploy = await buildExactSwapCSPRForTokensDeploy( + Config.swapContractHash, + { + toPublicKey: publicKey, + fromPublicKey: publicKey, + amountIn, + amountOutMin: amountOut, + deadline: dayjs().add(deadlineInMinutes, 'minutes').valueOf(), + path, + } + ); + + break; + case FUNCTIONS.SWAP_TOKENS_FOR_EXACT_TOKENS: + builtDeploy = await buildSwapTokensForExactTokensDeploy( + Config.swapContractHash, + { + toPublicKey: publicKey, + fromPublicKey: publicKey, + amountInMax: amountIn, + amountOut: amountOut, + deadline: dayjs().add(deadlineInMinutes, 'minutes').valueOf(), + path, + } + ); + + break; + + case FUNCTIONS.SWAP_EXACT_TOKENS_FOR_CSPR: + builtDeploy = await buildSwapExactTokensForCSPRDeploy( + Config.swapContractHash, + { + toPublicKey: publicKey, + fromPublicKey: publicKey, + amountIn, + amountOutMin: amountOut, + deadline: dayjs().add(deadlineInMinutes, 'minutes').valueOf(), + path, + } + ); + + break; + case FUNCTIONS.SWAP_EXACT_TOKENS_FOR_TOKENS: + builtDeploy = await buildSwapExactTokensForTokensDeploy( + Config.swapContractHash, + { + toPublicKey: publicKey, + fromPublicKey: publicKey, + amountIn, + amountOutMin: amountOut, + deadline: dayjs().add(deadlineInMinutes, 'minutes').valueOf(), + path, + } + ); + + break; + default: + throw new Error('Invalid function type'); + } + + if (!builtDeploy) { + throw new Error('Cannot build deploy'); + } + + return putSignedDeploy(builtDeploy); + }, + ...options, + onSuccess: (deployHash, variables, context) => { + options.onSuccess && options.onSuccess(deployHash, variables, context); + }, + onError: (error, variables, context) => { + options.onError && options.onError(error, variables, context); + }, + }); +}; diff --git a/src/modules/Swap/hooks/useSelectToken.ts b/src/modules/Swap/hooks/useSelectToken.ts new file mode 100644 index 0000000..dd14085 --- /dev/null +++ b/src/modules/Swap/hooks/useSelectToken.ts @@ -0,0 +1,20 @@ +import { useFormContext, useWatch } from 'react-hook-form'; + +import { SwapName } from '../type'; +import { useGetCoinMarketData } from '@/hooks/queries/useGetCoinMarketData'; + +export const useSelectToken = (name: SwapName) => { + const { control } = useFormContext(); + const valueWatched = useWatch({ + control, + name: name, + }); + const { data = { price: 0, amount: 0 } } = useGetCoinMarketData( + valueWatched?.coingeckoId + ); + + return { + ...valueWatched, + price: data.price, + }; +}; diff --git a/src/modules/Swap/hooks/useSetValueSwapFrom.ts b/src/modules/Swap/hooks/useSetValueSwapFrom.ts new file mode 100644 index 0000000..841324b --- /dev/null +++ b/src/modules/Swap/hooks/useSetValueSwapFrom.ts @@ -0,0 +1,20 @@ +import { useCallback } from 'react'; + +import { useFormContext } from 'react-hook-form'; + +import useCalculateAmountOut from './useCalculateAmountOut'; + +export const useSetValueSwapFrom = () => { + const { setValue } = useFormContext(); + const handleChangeAmountOut = useCalculateAmountOut(); + + const setValueSwapFrom = useCallback( + (value: number) => { + setValue('swapFrom.amount', value); + handleChangeAmountOut(value); + }, + [handleChangeAmountOut, setValue] + ); + + return setValueSwapFrom; +}; diff --git a/src/modules/Swap/type.ts b/src/modules/Swap/type.ts new file mode 100644 index 0000000..9880351 --- /dev/null +++ b/src/modules/Swap/type.ts @@ -0,0 +1 @@ +export type SwapName = 'swapFrom' | 'swapTo'; diff --git a/src/modules/Swap/utils.ts b/src/modules/Swap/utils.ts new file mode 100644 index 0000000..4af0b6c --- /dev/null +++ b/src/modules/Swap/utils.ts @@ -0,0 +1,41 @@ +import Big from 'big.js'; + +export const getAmountIn = ( + reserveIn: number | string, + reserveOut: number | string, + amountOut: number +) => { + if (!amountOut) { + return 0; + } + const numerator = Big(reserveIn).times(amountOut).times(1000); + const denominator = Big(reserveOut).minus(amountOut).times(997); + + return numerator.div(Big(denominator).add(1)).toNumber(); +}; + +export const getAmountOut = ( + reserveIn: number | string, + reserveOut: number | string, + amountIn: number +) => { + if (!amountIn) { + return 0; + } + const amountInWithFee = Big(amountIn).times(997); + const numerator = Big(amountInWithFee).times(reserveOut); + const denominator = Big(reserveIn).times(1000).add(amountInWithFee); + + return numerator.div(denominator).toNumber(); +}; + +export const calculateAmountOutMin = ( + amountOut: number, + slippage: number, + decimals: number +) => { + return Big(amountOut || 0) + .times(1 - slippage / 100) + .round(decimals, 0) + .toNumber(); +}; diff --git a/src/modules/core/TableToken/Header.tsx b/src/modules/core/TableToken/Header.tsx index 84ce3b3..38f24bd 100644 --- a/src/modules/core/TableToken/Header.tsx +++ b/src/modules/core/TableToken/Header.tsx @@ -26,6 +26,7 @@ import { useForm, useWatch } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import { z } from 'zod'; +import useDebounce from '@/hooks/helpers/useDebounce'; import { useI18nToast } from '@/hooks/helpers/useI18nToast'; import { useMutateAddMyToken } from '@/hooks/mutates/useMutateAddMyToken'; import { useGetToken } from '@/hooks/queries/useGetToken'; @@ -67,9 +68,11 @@ const TokenFormModal = ({ isOpen, onClose }: TokenFormProps) => { control, name: 'tokenAddress', }); + + const tokenAddressDebounced = useDebounce(tokenAddressTracked, 300); const { isFetching: isFetchingToken } = useGetToken( { - tokenAddress: tokenAddressTracked, + tokenAddress: tokenAddressDebounced, }, { onSuccess: (tokenResponse: GetTokenResponse) => { diff --git a/src/public/locales/en/common.json b/src/public/locales/en/common.json index 0df6531..c347f3f 100644 --- a/src/public/locales/en/common.json +++ b/src/public/locales/en/common.json @@ -125,5 +125,12 @@ "transaction_deadline": "Transaction Deadline", "minutes": "Minutes", "save": "Save", - "select_token": "Select token" + "select_token": "Select token", + "transaction_settings_saved": "Transaction settings saved successfully", + "price": "Price", + "rate": "Rate", + "fee": "Fee", + "price_impact": "Price Impact", + "minimum": "Minimum", + "slippage": "Slippage" } diff --git a/src/public/locales/en/message.json b/src/public/locales/en/message.json index 163f25e..36b5280 100644 --- a/src/public/locales/en/message.json +++ b/src/public/locales/en/message.json @@ -10,7 +10,8 @@ "account_selected": "You have successfully selected {{ name }}", "account_imported": "You have successfully imported", "token_added": "You have successfully added {{ name }} token", - "your_wallet_locked": "Your wallet locked successfully" + "your_wallet_locked": "Your wallet locked successfully", + "deploy_hash": "Your deploy hash is {{ deployHash }}" }, "error": { "please_try_other_password": "We're sorry, the password you entered is incorrect. Please try again with a different password", diff --git a/src/services/coingecko/coin/prices.ts b/src/services/coingecko/coin/prices.ts new file mode 100644 index 0000000..3442702 --- /dev/null +++ b/src/services/coingecko/coin/prices.ts @@ -0,0 +1,9 @@ +import request from '../request'; + +type GetCoinPriceParams = { + id: string; +}; + +export const getCoinPrice = async ({ id }: GetCoinPriceParams) => { + return request.get(`coins/${id}`); +}; diff --git a/src/services/coingecko/request.ts b/src/services/coingecko/request.ts new file mode 100644 index 0000000..78ae739 --- /dev/null +++ b/src/services/coingecko/request.ts @@ -0,0 +1,27 @@ +import axios from 'axios'; + +import { Config } from '@/config'; + +const request = axios.create({ + baseURL: Config.coingeckoBaseUrl, + timeout: 30 * 1000, +}); + +request.interceptors.response.use( + (response) => response.data, + (error) => { + const { status } = error.response; + + if (status === 400) { + const { + data: { message }, + } = error.response; + + alert(message); + } + + return Promise.reject(error); + } +); + +export default request; diff --git a/src/services/friendlyMarket/amm/index.ts b/src/services/friendlyMarket/amm/index.ts index c038932..154b172 100644 --- a/src/services/friendlyMarket/amm/index.ts +++ b/src/services/friendlyMarket/amm/index.ts @@ -1,11 +1,11 @@ -import { GetPairParams } from './type'; +import { GetPairParams, PairData, PairRouteData } from './type'; import request from '../request'; export const getPair = async ({ fromContractHash, toContractHash, -}: GetPairParams) => { - const { data = {} } = await request.get( +}: GetPairParams): Promise => { + const { data } = await request.get( `/amm/pair/${fromContractHash}/${toContractHash}` ); diff --git a/src/services/friendlyMarket/amm/type.ts b/src/services/friendlyMarket/amm/type.ts index 1122030..ea3ff31 100644 --- a/src/services/friendlyMarket/amm/type.ts +++ b/src/services/friendlyMarket/amm/type.ts @@ -2,3 +2,61 @@ export type GetPairParams = { fromContractHash: string; toContractHash: string; }; + +export type GetPairResponse = { + data: PairData | PairRouteData; + error?: string; +}; + +export type PairRouteData = { + path: string[]; + token0IntPair: PairData; + intToken1Pair: PairData; + isUsingRouting?: boolean; +}; + +export type PairData = { + reserve0: string; + reserve1: string; + pairContractHash: string; + pairContractPackageHash: string; + token0: string; + token1: string; + decimals0: string; + decimals1: string; + symbol0: string; + symbol1: string; + token0Price: string; + token1Price: string; + volumeUSD: string; + reserveUSD: string; + untrackedVolumeUSD: string; + token0Model: TokenModel; + token1Model: TokenModel; + totalSupply: string; +}; + +export type TokenModel = { + _id: string; + totalSupply: string; + tradeVolume: string; + tradeVolumeUSD: string; + untrackedVolumeUSD: string; + txCount: string; + totalLiquidity: string; + derivedCSPR: string; + tokenDayData: string; + pairDayDataBase: string; + pairDayDataQuote: string; + pairBase: string; + pairQuote: string; + deploys: unknown[]; + contractHash: string; + contractPackageHash: string; + symbol: string; + name: string; + decimals: string; + createdAt: string; + updatedAt: string; + __v: number; +}; diff --git a/src/services/friendlyMarket/balance/index.ts b/src/services/friendlyMarket/balance/index.ts index 8e47ba2..5fe76fb 100644 --- a/src/services/friendlyMarket/balance/index.ts +++ b/src/services/friendlyMarket/balance/index.ts @@ -1,4 +1,8 @@ -import { GetBalanceParams, getErc20BalanceParams } from './type'; +import { + GetBalanceParams, + GetBalanceResponse, + getErc20BalanceParams, +} from './type'; import request from '../request'; const DEFAULT_NETWORK = 'casper'; @@ -6,7 +10,7 @@ const DEFAULT_NETWORK = 'casper'; export const getBalance = async ({ publicKey, network = DEFAULT_NETWORK, -}: GetBalanceParams) => { +}: GetBalanceParams): Promise => { return request.get(`/misc/balance/${publicKey}?network=${network}`); }; @@ -14,8 +18,10 @@ export const getErc20Balance = async ({ publicKey, contractHash, network = DEFAULT_NETWORK, -}: getErc20BalanceParams) => { +}: getErc20BalanceParams): Promise => { return request.get( `/erc20/balance/hash-${contractHash}/${publicKey}?network=${network}` ); }; + +export * from './type'; diff --git a/src/services/friendlyMarket/balance/type.ts b/src/services/friendlyMarket/balance/type.ts index 8e7ba7f..08d24b5 100644 --- a/src/services/friendlyMarket/balance/type.ts +++ b/src/services/friendlyMarket/balance/type.ts @@ -8,3 +8,8 @@ export type getErc20BalanceParams = { contractHash: string; network?: string; }; + +export type GetBalanceResponse = { + balance?: number; + error?: string; +}; diff --git a/src/services/friendlyMarket/moduleBytes/index.ts b/src/services/friendlyMarket/moduleBytes/index.ts new file mode 100644 index 0000000..abfef11 --- /dev/null +++ b/src/services/friendlyMarket/moduleBytes/index.ts @@ -0,0 +1,9 @@ +import axios from 'axios'; + +import { Config } from '@/config'; + +export const getSwapModuleBytes = async (): Promise => { + const { data } = await axios.get(Config.friendlyMarketModuleBytesUrl); + + return data; +}; diff --git a/src/services/friendlyMarket/request.ts b/src/services/friendlyMarket/request.ts index 6fb1778..7ab47ac 100644 --- a/src/services/friendlyMarket/request.ts +++ b/src/services/friendlyMarket/request.ts @@ -5,7 +5,6 @@ import { Config } from '@/config'; const request = axios.create({ baseURL: Config.friendlyMarketUrl, timeout: 30 * 1000, - withCredentials: true, }); request.interceptors.response.use( diff --git a/src/services/friendlyMarket/tokens/type.ts b/src/services/friendlyMarket/tokens/type.ts index 5666be8..a721a3f 100644 --- a/src/services/friendlyMarket/tokens/type.ts +++ b/src/services/friendlyMarket/tokens/type.ts @@ -1,10 +1,12 @@ +import { TokenTypesEnum } from '@/enums/tokenTypes'; + export type ListTokenResponse = { tokens: Token[]; }; export type Token = { chainId: number; - type: string; + type: TokenTypesEnum; contractPackageHash: string; contractHash: string; name: string; @@ -12,4 +14,7 @@ export type Token = { decimals: number; logoURI: string; coingeckoId?: string; + amount?: number; + amountInUSD?: number; + balance?: number; }; diff --git a/src/theme/foundations/space.ts b/src/theme/foundations/space.ts index 264cec2..48c5ffd 100644 --- a/src/theme/foundations/space.ts +++ b/src/theme/foundations/space.ts @@ -21,6 +21,8 @@ const space = { 17: '4.25rem', 18: '4.5rem', // 72px 20: '5rem', // 80px + 21: '5.25rem', + 22: '5.5rem', 24: '6rem', 28: '7rem', 30: '7.5rem', diff --git a/src/utils/casper/casperServices.ts b/src/utils/casper/casperServices.ts new file mode 100644 index 0000000..34c0c0c --- /dev/null +++ b/src/utils/casper/casperServices.ts @@ -0,0 +1,180 @@ +import { Buffer } from 'buffer/'; +import { + DeployUtil, + Signer, + RuntimeArgs, + CLValueBuilder, + CLAccountHash, + CLKey, + CLPublicKey, +} from 'casper-js-sdk'; + +import { Config } from '@/config'; + +export const NETWORK_NAME = Config.networkName; +export const PAYMENT_AMOUNT = 100000000000; +export const MOTE_RATE = 1000000000; +export const DEPLOY_TTL_MS = 1800000; + +type BuildTransferDeployParams = { + fromAccount: CLPublicKey; + toAccount: CLPublicKey; + amount: number; + transferId: number; + fee: number; + network: string; +}; + +type BuildTransferTokenDeployParams = { + fromAccount: CLPublicKey; + toAccount: CLPublicKey; + amount: number; + fee: number; + contractHash: string; + network: string; +}; + +type BuildContractInstallDeployParams = { + baseAccount: CLPublicKey; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + session: any; + network: string; +}; + +type SignDeployByCasperSignerParams = { + deploy: DeployUtil.Deploy; + mainAccountHex: string; + setAccountHex: string; +}; + +/** + * Get Transfer deploy + * @param {CLPublicKey} fromAccount main account public key + * @param {CLPublicKey} toAccount public key of target account + * @param {Number} amount transfer amount + * @param {Number} transferId transfer id. This parameter is optional + * @param {Number} fee transfer fee + * @param {String} network network name + * @returns {Deploy} transfer deploy + */ +export const buildTransferDeploy = ({ + fromAccount, + toAccount, + amount, + transferId, + fee, + network, +}: BuildTransferDeployParams) => { + const deployParams = new DeployUtil.DeployParams(fromAccount, network); + const transferParams = DeployUtil.ExecutableDeployItem.newTransfer( + amount, + toAccount, + null, + transferId + ); + const payment = DeployUtil.standardPayment(fee * MOTE_RATE); + const deploy = DeployUtil.makeDeploy(deployParams, transferParams, payment); + return deploy; +}; + +/** + * Build deploy for contract + * @param {CLPublicKey} baseAccount main account public key + * @param {Object} session hash contract content + * @returns {Deploy} deploy of the contract + */ +export const buildContractInstallDeploy = ({ + baseAccount, + session, + network, +}: BuildContractInstallDeployParams) => { + const deployParams = new DeployUtil.DeployParams(baseAccount, network); + const payment = DeployUtil.standardPayment(PAYMENT_AMOUNT); + return DeployUtil.makeDeploy(deployParams, session, payment); +}; + +/** + * Sign a deploy by singer + * @param {Deploy} deploy main account public key + * @param {String} mainAccountHex hash contract content + * @param {String} setAccountHex contract's arguments + * @param {Object} ledgerOptions ledger's options + * @returns {Deploy} Signed deploy + */ +export const signDeployByCasperSigner = async ({ + deploy, + mainAccountHex, + setAccountHex, +}: SignDeployByCasperSignerParams) => { + const deployObj = DeployUtil.deployToJson(deploy); + const signedDeploy = await Signer.sign( + deployObj, + mainAccountHex, + setAccountHex + ); + return signedDeploy; +}; + +/** + * Get Recipient address + * @param {CLPublicKey} recipient + */ +export const createRecipientAddress = (recipient: CLPublicKey) => { + return new CLKey(new CLAccountHash(recipient.toAccountHash())); +}; + +/** + * Get Transfer Token deploy + * @param {CLPublicKey} fromAccount from account public key + * @param {CLPublicKey} toAccount to account public key + * @param {Number} amount transfer amount + * @param {String} contractHash token contract hash + * @returns {Deploy} transfer deploy + */ +export const buildTransferTokenDeploy = ({ + fromAccount, + toAccount, + amount, + contractHash, + fee, + network = NETWORK_NAME, +}: BuildTransferTokenDeployParams) => { + const contractHashAsByteArray = Uint8Array.from( + Buffer.from(contractHash, 'hex') + ); + const deployParams = new DeployUtil.DeployParams( + fromAccount, + network, + 1, + DEPLOY_TTL_MS + ); + const transferParams = + DeployUtil.ExecutableDeployItem.newStoredContractByHash( + contractHashAsByteArray, + 'transfer', + RuntimeArgs.fromMap({ + amount: CLValueBuilder.u256(amount), + recipient: createRecipientAddress(toAccount), + }) + ); + const payment = DeployUtil.standardPayment(fee * MOTE_RATE); + return DeployUtil.makeDeploy(deployParams, transferParams, payment); +}; + +/** + * Request to connect with signer + * @returns {string} error message + */ +export const connectCasperSigner = () => { + try { + Signer.sendConnectionRequest(); + } catch (error) { + return (error).message; + } +}; +/** + * Convert a contract hash to a byte array + * @param contractHash - The contract hash of the contract you want to get the bytecode of. + */ +export const contractHashToByteArray = (contractHash: string) => + Uint8Array.from(Buffer.from(contractHash, 'hex')); diff --git a/src/utils/casper/tokenServices.ts b/src/utils/casper/tokenServices.ts new file mode 100644 index 0000000..b48bd0d --- /dev/null +++ b/src/utils/casper/tokenServices.ts @@ -0,0 +1,292 @@ +import { BigNumberish } from '@ethersproject/bignumber'; +import { Buffer } from 'buffer/'; +import { + CLPublicKey, + DeployUtil, + CLValueBuilder, + RuntimeArgs, + CLKey, +} from 'casper-js-sdk'; + +import { + buildTransferTokenDeploy, + contractHashToByteArray, + createRecipientAddress, + DEPLOY_TTL_MS, + NETWORK_NAME, +} from './casperServices'; +import { toBigNumMotes } from '../currency'; +import { getSwapModuleBytes } from '@/services/friendlyMarket/moduleBytes'; + +export const FUNCTIONS = { + // CSPR -> TOKENS + SWAP_EXACT_CSPR_FOR_TOKENS: 'swap_exact_cspr_for_tokens', + // TOKENS -> TOKENS + SWAP_TOKENS_FOR_EXACT_TOKENS: 'swap_tokens_for_exact_tokens', + // TOKENS -> CSPR + SWAP_EXACT_TOKENS_FOR_CSPR: 'swap_exact_tokens_for_cspr', + // TOKENS -> TOKENS (Routing) + SWAP_EXACT_TOKENS_FOR_TOKENS: 'swap_exact_tokens_for_tokens', +}; + +type GetTransferDeployParams = { + fromAddress: string; + toAddress: string; + amount: number; + transferId: number; + fee: number; + network: string; + contractInfo: { + address: string; + decimals: { + hex: number; + }; + }; +}; + +type BuldExactSwapCSPRForTokensDeployParams = { + fromPublicKey: string; + toPublicKey: string; + amountIn: number; + amountOutMin: number; + path: string[]; + deadline: number; +}; + +type BuildSwapTokensForExactTokensDeployParams = { + fromPublicKey: string; + toPublicKey: string; + amountOut: number; + amountInMax: number; + path: string[]; + deadline: number; +}; + +type BuildSwapExactTokensForTokensDeployParams = { + fromPublicKey: string; + toPublicKey: string; + amountIn: number; + amountOutMin: number; + path: string[]; + deadline: number; +}; + +type BuildSwapExactTokensForTokensDeploy = { + fromPublicKey: string; + toPublicKey: string; + amountIn: number; + amountOutMin: number; + path: string[]; + deadline: number; +}; + +/** + * It builds a transfer token deploy. + * @param [transactionDetail] - { + * @returns The transaction object. + */ +export const getTransferTokenDeploy = ( + transactionDetail: GetTransferDeployParams +) => { + try { + const { fromAddress, toAddress, amount, fee, network } = transactionDetail; + const fromPbKey = CLPublicKey.fromHex(fromAddress); + const toPbKey = CLPublicKey.fromHex(toAddress); + return buildTransferTokenDeploy({ + fromAccount: fromPbKey, + toAccount: toPbKey, + amount: amount * 10 ** transactionDetail.contractInfo.decimals.hex, + contractHash: transactionDetail.contractInfo.address, + fee, + network, + }); + } catch (error) { + console.error(error); + throw new Error(`Failed to get token transfer deploy.`); + } +}; + +export const buildExactSwapCSPRForTokensDeploy = async ( + contractHash: string, + transactionDetail: BuldExactSwapCSPRForTokensDeployParams +) => { + console.log('contractHash: ', contractHash); + const contractHashByteArray = contractHashToByteArray(contractHash); + const fromPbKey = CLPublicKey.fromHex(transactionDetail.fromPublicKey); + const toPbKey = CLPublicKey.fromHex(transactionDetail.toPublicKey); + + const mapping = { + amount_in: CLValueBuilder.u256(transactionDetail.amountIn), + amount_out_min: CLValueBuilder.u256(transactionDetail.amountOutMin), + path: CLValueBuilder.list( + transactionDetail.path.map((token) => CLValueBuilder.string(token)) + ), + to: createRecipientAddress(toPbKey), + deadline: CLValueBuilder.u64(transactionDetail.deadline), + contract_hash_key: new CLKey( + CLValueBuilder.byteArray(contractHashByteArray) + ), + // target_account: CLValueBuilder.byteArray(fromPbKey.toAccountHash()), + deposit_entry_point_name: CLValueBuilder.string( + FUNCTIONS.SWAP_EXACT_CSPR_FOR_TOKENS + ), + amount: CLValueBuilder.u512(transactionDetail.amountIn), + }; + + const runtimeArgs = RuntimeArgs.fromMap(mapping); + + return buildEntryPointModulBytesDeploy( + fromPbKey, + runtimeArgs, + toBigNumMotes(10) + ); +}; + +export const buildSwapTokensForExactTokensDeploy = async ( + contractHash: string, + transactionDetail: BuildSwapTokensForExactTokensDeployParams +) => { + const fromPbKey = CLPublicKey.fromHex(transactionDetail.fromPublicKey); + const toPbKey = CLPublicKey.fromHex(transactionDetail.toPublicKey); + + const mapping = { + amount_out: CLValueBuilder.u256(transactionDetail.amountOut), + amount_in_max: CLValueBuilder.u256(transactionDetail.amountInMax), + path: CLValueBuilder.list( + transactionDetail.path.map((token) => CLValueBuilder.string(token)) + ), + to: createRecipientAddress(toPbKey), + deadline: CLValueBuilder.u64(transactionDetail.deadline), + // contract_hash_key: CLValueBuilder.key(CLValueBuilder.byteArray(contractHashByteArray)), + // amount: CLValueBuilder.u256(0), + // deposit_entry_point_name: CLValueBuilder.string(FUNCTIONS.SWAP_TOKENS_FOR_EXACT_TOKENS), + // with_approve: CLValueBuilder.bool(true), + // token0: CLValueBuilder.key(CLValueBuilder.byteArray(contractHashToByteArray(transactionDetail.path[0]))), + // spender: CLValueBuilder.key(CLValueBuilder.byteArray(spenderHashByteArray)), + // to: createRecipientAddress(toPbKey), + }; + + const runtimeArgs = RuntimeArgs.fromMap(mapping); + + return buildEntryPointDeploy( + fromPbKey, + contractHash, + FUNCTIONS.SWAP_TOKENS_FOR_EXACT_TOKENS, + runtimeArgs, + toBigNumMotes(5) + ); +}; + +export const buildSwapExactTokensForTokensDeploy = async ( + contractHash: string, + transactionDetail: BuildSwapExactTokensForTokensDeploy +) => { + const contractHashByteArray = contractHashToByteArray(contractHash); + const fromPbKey = CLPublicKey.fromHex(transactionDetail.fromPublicKey); + const toPbKey = CLPublicKey.fromHex(transactionDetail.toPublicKey); + + const mapping = { + amount_in: CLValueBuilder.u256(transactionDetail.amountIn), + amount_out_min: CLValueBuilder.u256(transactionDetail.amountOutMin), + path: CLValueBuilder.list( + transactionDetail.path.map((token) => CLValueBuilder.string(token)) + ), + to: createRecipientAddress(toPbKey), + deadline: CLValueBuilder.u64(transactionDetail.deadline), + contract_hash_key: CLValueBuilder.key( + CLValueBuilder.byteArray(contractHashByteArray) + ), + }; + + const runtimeArgs = RuntimeArgs.fromMap(mapping); + + return buildEntryPointDeploy( + fromPbKey, + contractHash, + FUNCTIONS.SWAP_EXACT_TOKENS_FOR_TOKENS, + runtimeArgs, + toBigNumMotes(5) + ); +}; + +export const buildSwapExactTokensForCSPRDeploy = async ( + contractHash: string, + transactionDetail: BuildSwapExactTokensForTokensDeployParams +) => { + const fromPbKey = CLPublicKey.fromHex(transactionDetail.fromPublicKey); + const toPbKey = CLPublicKey.fromHex(transactionDetail.toPublicKey); + + const mapping = { + amount_in: CLValueBuilder.u256(transactionDetail.amountIn), + amount_out_min: CLValueBuilder.u256(transactionDetail.amountOutMin), + path: CLValueBuilder.list( + transactionDetail.path.map((token) => CLValueBuilder.string(token)) + ), + target_account: CLValueBuilder.byteArray(toPbKey.toAccountHash()), + deadline: CLValueBuilder.u64(transactionDetail.deadline), + }; + + const runtimeArgs = RuntimeArgs.fromMap(mapping); + + return buildEntryPointDeploy( + fromPbKey, + contractHash, + FUNCTIONS.SWAP_EXACT_TOKENS_FOR_CSPR, + runtimeArgs, + toBigNumMotes(15) + ); +}; + +/** + * @param publicKey - The public key of the account that will be used to deploy the contract. + * @param runtimeArgs - The arguments to pass to the contract. + * @param paymentAmount - The amount of tokens to pay for the deploy. + * @returns The deploy is being returned. + */ +export const buildEntryPointModulBytesDeploy = async ( + publicKey: CLPublicKey, + runtimeArgs: RuntimeArgs, + paymentAmount: BigNumberish +) => { + const hex = await getSwapModuleBytes(); + + return DeployUtil.makeDeploy( + new DeployUtil.DeployParams(publicKey, NETWORK_NAME, 1, DEPLOY_TTL_MS), + DeployUtil.ExecutableDeployItem.newModuleBytes( + Uint8Array.from(Buffer.from(hex, 'hex')), + runtimeArgs + ), + + DeployUtil.standardPayment(paymentAmount) + ); +}; + +/** + * @param publicKey - The public key of the account that will be used to deploy the contract. + * @param contractHash - The hash of the contract to be deployed. + * @param entryPoint - The entry point. + * @param runtimeArgs - The arguments to pass to the contract. + * @param paymentAmount - The amount of tokens to pay for the deploy. + * @returns The deploy is being returned. + */ +export const buildEntryPointDeploy = ( + publicKey: CLPublicKey, + contractHash: string, + entryPoint: string, + runtimeArgs: RuntimeArgs, + paymentAmount: BigNumberish +) => { + const contractHashAsByteArray = Uint8Array.from( + Buffer.from(contractHash, 'hex') + ); + + return DeployUtil.makeDeploy( + new DeployUtil.DeployParams(publicKey, NETWORK_NAME, 1, DEPLOY_TTL_MS), + DeployUtil.ExecutableDeployItem.newStoredContractByHash( + contractHashAsByteArray, + entryPoint, + runtimeArgs + ), + DeployUtil.standardPayment(paymentAmount) + ); +}; diff --git a/src/utils/currency.ts b/src/utils/currency.ts index ade1ff7..b333467 100644 --- a/src/utils/currency.ts +++ b/src/utils/currency.ts @@ -1,4 +1,4 @@ -import { BigNumber } from '@ethersproject/bignumber'; +import { BigNumber, BigNumberish } from '@ethersproject/bignumber'; import Big from 'big.js'; const MOTE_RATE = 1000000000; @@ -26,6 +26,24 @@ export const toMotes = (amount: number): BigNumber | number => { } }; +/** + * /** + * Conver CSPR to Motes. + * + * Inpsired from toWie implementation (https://github.com/ethjs/ethjs-unit/blob/master/src/index.js#L119) + * It will convert to String number | number to Big Number (We use big.js to cover the float numbers). + * After that multiple with mote rate 10⁸ + * + * @param {Number|String} amount + */ +export const toBigNumMotes = (amount: number): BigNumberish => { + const bigAmount = Big(amount) + .times(MOTE_RATE) + .round(0, Big.roundDown) + .toString(); + return BigNumber.from(bigAmount); +}; + /** * Convert motes to CSPR * @@ -57,3 +75,13 @@ export const formatBalanceFromHex = ( .toNumber() || 0 ); }; + +// convert hex number to number +export const hexToNumber = ( + balanceHex: string, + decimalsHex: string +): number => { + return new Big(parseInt(balanceHex, 16)) + .div(new Big(10).pow(parseInt(decimalsHex, 16))) + .toNumber(); +}; From 8243b8df96e5389b459a44015e361820fc21a379 Mon Sep 17 00:00:00 2001 From: Liam Date: Tue, 23 May 2023 12:03:28 +0700 Subject: [PATCH 06/10] feat: routing paths --- .../Inputs/InputNumberField/index.tsx | 35 ++++++++++++ .../components/ModalSelectToken/TokenItem.tsx | 10 +++- .../components/ModalSelectToken/index.tsx | 7 ++- .../RadioPercentSlippage.tsx | 17 +++--- .../ModalTransactionSetting/index.tsx | 55 ++++++++++--------- .../Swap/components/SelectToken/index.tsx | 1 + .../Swap/components/SwapForm/Receipt.tsx | 28 +++++++--- .../Swap/components/SwapForm/RoutePaths.tsx | 50 +++++++++++++++++ .../Swap/components/SwapForm/index.tsx | 5 +- .../Swap/hooks/useCalculateAmountIn.ts | 1 + .../Swap/hooks/useCalculateAmountOut.ts | 1 + .../Swap/hooks/useMutateSwapSettings.ts | 1 + src/public/locales/en/common.json | 3 +- 13 files changed, 163 insertions(+), 51 deletions(-) create mode 100644 src/components/Inputs/InputNumberField/index.tsx create mode 100644 src/modules/Swap/components/SwapForm/RoutePaths.tsx diff --git a/src/components/Inputs/InputNumberField/index.tsx b/src/components/Inputs/InputNumberField/index.tsx new file mode 100644 index 0000000..1aadce9 --- /dev/null +++ b/src/components/Inputs/InputNumberField/index.tsx @@ -0,0 +1,35 @@ +import { NumberInput, NumberInputField } from '@chakra-ui/react'; +import { Control, Controller, ControllerProps } from 'react-hook-form'; + +type Props = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + control: Control; + max?: number; + min?: number; +} & Omit; + +const InputNumberField = ({ min, max, name, ...restProps }: Props) => { + return ( + { + return ( + { + onChange(parseFloat(val) || 0); + }} + onBlur={onBlur} + > + + + ); + }} + > + ); +}; + +export default InputNumberField; diff --git a/src/modules/Swap/components/ModalSelectToken/TokenItem.tsx b/src/modules/Swap/components/ModalSelectToken/TokenItem.tsx index 77d26e4..ca80401 100644 --- a/src/modules/Swap/components/ModalSelectToken/TokenItem.tsx +++ b/src/modules/Swap/components/ModalSelectToken/TokenItem.tsx @@ -1,14 +1,20 @@ import { Flex, Image, Text } from '@chakra-ui/react'; import CircleWrapper from '@/components/Surface/CircleWrapper'; +import { useGetSwapTokenBalance } from '@/modules/Swap/hooks/useGetSwapTokenBalance'; import { Token } from '@/services/friendlyMarket/tokens'; type TokenItemProps = { token: Token; + publicKey?: string; onClick?: () => void; }; -const TokenItem = ({ token, onClick }: TokenItemProps) => { +const TokenItem = ({ publicKey, token, onClick }: TokenItemProps) => { + const { data: { balance } = { balance: 0 } } = useGetSwapTokenBalance({ + ...token, + publicKey, + }); return ( { {token.name} - {token.balance || 0} + {balance || 0} ); }; diff --git a/src/modules/Swap/components/ModalSelectToken/index.tsx b/src/modules/Swap/components/ModalSelectToken/index.tsx index 9272e7e..d8bf8aa 100644 --- a/src/modules/Swap/components/ModalSelectToken/index.tsx +++ b/src/modules/Swap/components/ModalSelectToken/index.tsx @@ -2,11 +2,12 @@ import { Input, Flex, Text } from '@chakra-ui/react'; import * as _ from 'lodash-es'; import { useTranslation } from 'react-i18next'; -import { useGetBalanceTokens } from './hooks'; import TokenItem from './TokenItem'; import Modal from '@/components/Modal'; +import { useAccount } from '@/hooks/useAccount'; import { useFuse } from '@/hooks/useFuse'; import { SearchIcon } from '@/icons'; +import { useGetSwapListTokens } from '@/modules/Swap/hooks/useGetSwapListTokens'; import { Token } from '@/services/friendlyMarket/tokens'; type ModalReceivingAddressProps = { @@ -21,7 +22,8 @@ const ModalSelectToken = ({ onSelect, }: ModalReceivingAddressProps) => { const { t } = useTranslation(); - const listTokens = useGetBalanceTokens(); + const { publicKey } = useAccount(); + const { data: listTokens = [] } = useGetSwapListTokens(); const { hits, query, onSearch } = useFuse(listTokens, { keys: ['name'], @@ -56,6 +58,7 @@ const ModalSelectToken = ({ {tokens?.map((token) => ( onSelect?.(token)} diff --git a/src/modules/Swap/components/ModalTransactionSetting/RadioPercentSlippage.tsx b/src/modules/Swap/components/ModalTransactionSetting/RadioPercentSlippage.tsx index 80593c2..13e41f0 100644 --- a/src/modules/Swap/components/ModalTransactionSetting/RadioPercentSlippage.tsx +++ b/src/modules/Swap/components/ModalTransactionSetting/RadioPercentSlippage.tsx @@ -16,7 +16,11 @@ const PERCENTS = [ }, ]; -const RadioPercentSlippage = () => { +type Props = { + onChange?: (value: number) => void; +}; + +const RadioPercentSlippage = ({ onChange }: Props) => { return ( { size="md" defaultValue={'12'} justifyContent={'start'} - // onChange={(value: string) => { - // const masterKey = KeyFactory.getInstance().generate( - // parseInt(value, 10) - // ); - // setValue('masterKey', masterKey); - - // onChange(parseInt(value)); - // }} + onChange={(value: string) => { + onChange?.(parseFloat(value)); + }} > {PERCENTS.map((item) => { return ( diff --git a/src/modules/Swap/components/ModalTransactionSetting/index.tsx b/src/modules/Swap/components/ModalTransactionSetting/index.tsx index e0c100a..22e0176 100644 --- a/src/modules/Swap/components/ModalTransactionSetting/index.tsx +++ b/src/modules/Swap/components/ModalTransactionSetting/index.tsx @@ -1,33 +1,19 @@ -import { - Box, - Button, - Flex, - FormControl, - FormLabel, - Input, -} from '@chakra-ui/react'; +import { Box, Button, Flex, FormControl, FormLabel } from '@chakra-ui/react'; import { zodResolver } from '@hookform/resolvers/zod'; import { useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import { z } from 'zod'; import RadioPercentSlippage from './RadioPercentSlippage'; +import InputNumberField from '@/components/Inputs/InputNumberField'; import Modal from '@/components/Modal'; import { useI18nToast } from '@/hooks/helpers/useI18nToast'; import { useGetSwapSettings } from '@/modules/Swap/hooks/useGetSwapSettings'; import { useMutateSwapSettings } from '@/modules/Swap/hooks/useMutateSwapSettings'; const validationSchema = z.object({ - slippage: z - .string() - .transform((val) => parseFloat(val)) - .refine((val) => val >= 0, 'slippage_required') - .default('0'), - deadline: z - .string() - .transform((val) => parseFloat(val)) - .refine((val) => val >= 0, 'deadline_required') - .default('0'), + slippage: z.number().refine((val) => val >= 0, 'slippage_required'), + deadline: z.number().refine((val) => val >= 0, 'deadline_required'), }); export type SubmitValues = z.infer; @@ -49,7 +35,12 @@ const ModalTransactionSetting = ({ onClose(); }, }); - const { handleSubmit, register, setValue } = useForm({ + const { + handleSubmit, + setValue, + control, + formState: { errors }, + } = useForm({ resolver: zodResolver(validationSchema), }); useGetSwapSettings({ @@ -60,25 +51,37 @@ const ModalTransactionSetting = ({ }); const handleOnSubmit = (data: SubmitValues) => { - console.log('data: ', data); mutate(data); }; + console.log('errors: ', errors); + return (
- {t('slippage_tolerance')} - + + {t('slippage_tolerance')} (%) + + - - + + setValue('slippage', value)} + /> - {t('transaction_deadline')} - + + {t('transaction_deadline')} ({t('minutes')}) + + - +
@@ -160,6 +160,7 @@ const SwapForm = () => { {t('confirm')}
+ diff --git a/src/modules/Swap/hooks/useCalculateAmountIn.ts b/src/modules/Swap/hooks/useCalculateAmountIn.ts index 4e56ffa..182111d 100644 --- a/src/modules/Swap/hooks/useCalculateAmountIn.ts +++ b/src/modules/Swap/hooks/useCalculateAmountIn.ts @@ -33,6 +33,7 @@ const useCalculateAmountIn = () => { const handleChangeAmount = useCallback( (value: number) => { if (!value) { + setValue('swapFrom.amount', 0); return; } const pairRouting = pair; diff --git a/src/modules/Swap/hooks/useCalculateAmountOut.ts b/src/modules/Swap/hooks/useCalculateAmountOut.ts index 159cfbe..66c201b 100644 --- a/src/modules/Swap/hooks/useCalculateAmountOut.ts +++ b/src/modules/Swap/hooks/useCalculateAmountOut.ts @@ -33,6 +33,7 @@ const useCalculateAmountOut = () => { const handleChangeAmount = useCallback( (value: number) => { if (!value) { + setValue('swapTo.amount', 0); return; } const pairRouting = pair; diff --git a/src/modules/Swap/hooks/useMutateSwapSettings.ts b/src/modules/Swap/hooks/useMutateSwapSettings.ts index 5376ece..9c9526e 100644 --- a/src/modules/Swap/hooks/useMutateSwapSettings.ts +++ b/src/modules/Swap/hooks/useMutateSwapSettings.ts @@ -29,6 +29,7 @@ export const useMutateSwapSettings = ( ...newSettings, }) ); + await queryClient.prefetchQuery([QueryKeysEnum.SWAP_SETTINGS]); return newSettings; }, diff --git a/src/public/locales/en/common.json b/src/public/locales/en/common.json index c347f3f..5cf1a18 100644 --- a/src/public/locales/en/common.json +++ b/src/public/locales/en/common.json @@ -132,5 +132,6 @@ "fee": "Fee", "price_impact": "Price Impact", "minimum": "Minimum", - "slippage": "Slippage" + "slippage": "Slippage", + "route": "Route" } From 6d0252cbb7e0f2d435134e81bfc9cd571b5ca2d8 Mon Sep 17 00:00:00 2001 From: Liam Date: Tue, 23 May 2023 18:22:35 +0700 Subject: [PATCH 07/10] feat: confirm popup swap --- package.json | 2 +- src/components/Modal/index.tsx | 14 +- .../components/ModalSelectToken/TokenItem.tsx | 14 +- .../Swap/components/SwapForm/ModalConfirm.tsx | 168 ++++++++++++++++++ .../Swap/components/SwapForm/Receipt.tsx | 14 +- .../Swap/components/SwapForm/RoutePaths.tsx | 20 ++- .../Swap/components/SwapForm/Setting.tsx | 2 +- .../Swap/components/SwapForm/index.tsx | 117 ++---------- .../Swap/hooks/useCalculateAmountIn.ts | 24 +-- .../Swap/hooks/useCalculateAmountOut.ts | 23 +-- src/modules/Swap/hooks/useMutateSwapTokens.ts | 11 ++ src/modules/Swap/type.ts | 13 ++ src/public/locales/en/common.json | 5 +- src/utils/casper/tokenServices.ts | 13 +- yarn.lock | 8 +- 15 files changed, 289 insertions(+), 159 deletions(-) create mode 100644 src/modules/Swap/components/SwapForm/ModalConfirm.tsx diff --git a/package.json b/package.json index e6155e7..38e9185 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "chakra-react-select": "^4.6.0", "dayjs": "^1.11.7", "dotenv": "^16.0.3", - "framer-motion": "^10.2.4", + "framer-motion": "^10.12.12", "fuse.js": "^6.6.2", "i18next": "^22.4.11", "identicon.js": "^2.3.3", diff --git a/src/components/Modal/index.tsx b/src/components/Modal/index.tsx index ce7dacf..d7274a2 100644 --- a/src/components/Modal/index.tsx +++ b/src/components/Modal/index.tsx @@ -17,14 +17,22 @@ type ModalProps = { title?: string | null; children: ReactNode; header?: ReactNode; + footer?: ReactNode; }; -const Modal = ({ isOpen, onClose, title, children, header }: ModalProps) => { +const Modal = ({ + isOpen, + onClose, + title, + children, + header, + footer, +}: ModalProps) => { return ( <> - + {title && ( { {children} - + {footer} diff --git a/src/modules/Swap/components/ModalSelectToken/TokenItem.tsx b/src/modules/Swap/components/ModalSelectToken/TokenItem.tsx index ca80401..0c5c669 100644 --- a/src/modules/Swap/components/ModalSelectToken/TokenItem.tsx +++ b/src/modules/Swap/components/ModalSelectToken/TokenItem.tsx @@ -1,4 +1,4 @@ -import { Flex, Image, Text } from '@chakra-ui/react'; +import { Flex, Image, Spinner, Text } from '@chakra-ui/react'; import CircleWrapper from '@/components/Surface/CircleWrapper'; import { useGetSwapTokenBalance } from '@/modules/Swap/hooks/useGetSwapTokenBalance'; @@ -11,10 +11,12 @@ type TokenItemProps = { }; const TokenItem = ({ publicKey, token, onClick }: TokenItemProps) => { - const { data: { balance } = { balance: 0 } } = useGetSwapTokenBalance({ - ...token, - publicKey, - }); + const { data: { balance } = { balance: 0 }, isLoading } = + useGetSwapTokenBalance({ + ...token, + publicKey, + }); + return ( { {token.name} - {balance || 0} + {isLoading ? : {balance || 0}}
); }; diff --git a/src/modules/Swap/components/SwapForm/ModalConfirm.tsx b/src/modules/Swap/components/SwapForm/ModalConfirm.tsx new file mode 100644 index 0000000..c12d3d0 --- /dev/null +++ b/src/modules/Swap/components/SwapForm/ModalConfirm.tsx @@ -0,0 +1,168 @@ +import { Box, Button, Divider, Flex, Text } from '@chakra-ui/react'; +import Big from 'big.js'; +import { useFormContext } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; + +import Receipt from './Receipt'; +import RoutePaths from './RoutePaths'; +import MiddleTruncatedText from '@/components/Common/MiddleTruncatedText'; +import Modal from '@/components/Modal'; +import { Config } from '@/config'; +import { TokenTypesEnum } from '@/enums/tokenTypes'; +import { useI18nToast } from '@/hooks/helpers/useI18nToast'; +import { useAccount } from '@/hooks/useAccount'; +import { useMutateSwapTokens } from '@/modules/Swap/hooks/useMutateSwapTokens'; +import { FieldValues } from '@/modules/Swap/type'; +import { calculateAmountOutMin } from '@/modules/Swap/utils'; +import { PairRouteData } from '@/services/friendlyMarket/amm/type'; +import { Token } from '@/services/friendlyMarket/tokens'; +import { FUNCTIONS } from '@/utils/casper/tokenServices'; + +type ModalConfirmProps = { + isOpen: boolean; + onClose: () => void; + onSelect?: (token: Token) => void; +}; + +const ModalConfirm = ({ isOpen, onClose }: ModalConfirmProps) => { + const { toastSuccess } = useI18nToast(); + const { t } = useTranslation(); + const { publicKey } = useAccount(); + + const { mutate, isLoading } = useMutateSwapTokens({ + onSuccess: (result: string | undefined) => { + toastSuccess('deploy_hash', { deployHash: result || '' }); + }, + }); + const { getValues } = useFormContext(); + + const handleOnConfirm = () => { + const values = getValues(); + const { swapFrom, swapTo, pair, swapSettings } = values; + const amountInValue = Big(swapFrom.amount || 0) + .round(swapFrom.decimals, 0) + .toNumber(); + + let path = [swapFrom.contractHash, swapTo.contractHash].map( + (hash) => `hash-${hash}` + ); + const routingPair = pair as PairRouteData; + if (routingPair.isUsingRouting) { + path = routingPair.path; + } + const amountOutMin = calculateAmountOutMin( + swapTo.amount || 0, + swapSettings.slippage, + swapTo.decimals + ); + + const amountIn = Big(amountInValue * 10 ** swapFrom.decimals).toNumber(); + + const amountOut = Big(amountOutMin * 10 ** swapTo.decimals).toNumber(); + + if ( + swapFrom.type === TokenTypesEnum.NATIVE && + swapTo.type === TokenTypesEnum.ERC20 + ) { + return mutate({ + functionType: FUNCTIONS.SWAP_EXACT_CSPR_FOR_TOKENS, + amountIn, + amountOut, + deadlineInMinutes: swapSettings.deadline, + path, + }); + } + + if ( + swapFrom.type === TokenTypesEnum.ERC20 && + swapTo.type === TokenTypesEnum.NATIVE + ) { + return mutate({ + functionType: FUNCTIONS.SWAP_EXACT_TOKENS_FOR_CSPR, + amountIn, + amountOut, + deadlineInMinutes: swapSettings.deadline, + path, + }); + } + + if ( + swapFrom.type === TokenTypesEnum.ERC20 && + swapTo.type === TokenTypesEnum.ERC20 + ) { + if (routingPair.isUsingRouting) { + return mutate({ + functionType: FUNCTIONS.SWAP_EXACT_TOKENS_FOR_TOKENS, + amountIn, + amountOut, + deadlineInMinutes: swapSettings.deadline, + path, + }); + } + + return mutate({ + functionType: FUNCTIONS.SWAP_TOKENS_FOR_EXACT_TOKENS, + amountIn, + amountOut, + deadlineInMinutes: swapSettings.deadline, + path, + }); + } + }; + + return ( + + + + + + + + + + {t('contract_hash')} + + + + {t('receiving_address')} + + + + {t('payment_amount')} + + {} + + + + + + + + + + + + ); +}; + +export default ModalConfirm; diff --git a/src/modules/Swap/components/SwapForm/Receipt.tsx b/src/modules/Swap/components/SwapForm/Receipt.tsx index a3dba7e..aba1a08 100644 --- a/src/modules/Swap/components/SwapForm/Receipt.tsx +++ b/src/modules/Swap/components/SwapForm/Receipt.tsx @@ -1,6 +1,6 @@ import { ReactNode } from 'react'; -import { Flex, Text, VStack } from '@chakra-ui/react'; +import { Flex, StackProps, Text, VStack } from '@chakra-ui/react'; import Big from 'big.js'; import { useFormContext } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; @@ -13,6 +13,10 @@ import { useSelectToken } from '@/modules/Swap/hooks/useSelectToken'; import { PairRouteData } from '@/services/friendlyMarket/amm/type'; import { Token } from '@/services/friendlyMarket/tokens'; +type ReceiptProps = { + isShowRoute?: boolean; +} & StackProps; + const Row = ({ label, value }: { label: string; value: ReactNode }) => { return ( @@ -22,7 +26,7 @@ const Row = ({ label, value }: { label: string; value: ReactNode }) => { ); }; -const Receipt = () => { +const Receipt = ({ isShowRoute, ...props }: ReceiptProps) => { const { setValue } = useFormContext(); const { t } = useTranslation(); const swapFrom: Token = useSelectToken('swapFrom'); @@ -91,7 +95,7 @@ const Receipt = () => { ]; const pairRoute = pair as PairRouteData; - if (pairRoute.isUsingRouting) { + if (isShowRoute && pairRoute.isUsingRouting) { items.push({ label: t('route'), value: , @@ -102,9 +106,7 @@ const Receipt = () => { { - const { data: pair = { isUsingRouting: false }, isLoading } = - useGetCurrentAMMPair(); + const { data: pair = { isUsingRouting: false } } = useGetCurrentAMMPair(); const { data: tokens = [] } = useGetSwapListTokens(); const pairRoute = pair as PairRouteData; - if (!pairRoute.isUsingRouting || isLoading) { - return null; + let paths: string[] = []; + if (pairRoute.isUsingRouting) { + paths = pairRoute.path; + } else if (pair) { + const simplePair = pair as PairData; + paths = [ + simplePair.token0Model.contractHash, + simplePair.token1Model.contractHash, + ]; } return ( - {pairRoute.path.map((path, index) => { + {paths.map((path, index) => { const foundToken = tokens.find( (token) => token.contractHash === path.replace('hash-', '') ); @@ -35,7 +41,7 @@ const RoutePaths = () => { {foundToken?.symbol || ''} - {index < pairRoute.path.length - 1 && ( + {index < paths.length - 1 && ( diff --git a/src/modules/Swap/components/SwapForm/Setting.tsx b/src/modules/Swap/components/SwapForm/Setting.tsx index 767c437..9f12d8e 100644 --- a/src/modules/Swap/components/SwapForm/Setting.tsx +++ b/src/modules/Swap/components/SwapForm/Setting.tsx @@ -17,7 +17,7 @@ const Setting = () => { _hover={{ color: 'light' }} onClick={onOpen} > - + diff --git a/src/modules/Swap/components/SwapForm/index.tsx b/src/modules/Swap/components/SwapForm/index.tsx index 702632a..bd6066e 100644 --- a/src/modules/Swap/components/SwapForm/index.tsx +++ b/src/modules/Swap/components/SwapForm/index.tsx @@ -1,43 +1,23 @@ -import { Box, Button, Divider, Flex } from '@chakra-ui/react'; -import Big from 'big.js'; +import { Box, Button, Divider, Flex, useDisclosure } from '@chakra-ui/react'; import { FormProvider, useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import ButtonReverse from './ButtonReverse'; +import ModalConfirm from './ModalConfirm'; import RadioPercentSelect from './RadioPercentSelect'; import Receipt from './Receipt'; import SelectSwapFrom from './SelectSwapFrom'; import SelectSwapTo from './SelectSwapTo'; import Setting from './Setting'; -import { calculateAmountOutMin } from '../../utils'; import Paper from '@/components/Paper'; import CircleWrapper from '@/components/Surface/CircleWrapper'; -import { TokenTypesEnum } from '@/enums/tokenTypes'; -import { useI18nToast } from '@/hooks/helpers/useI18nToast'; import { RefreshIcon } from '@/icons'; -import { useMutateSwapTokens } from '@/modules/Swap/hooks/useMutateSwapTokens'; -import { PairData, PairRouteData } from '@/services/friendlyMarket/amm/type'; -import { Token } from '@/services/friendlyMarket/tokens'; -import { FUNCTIONS } from '@/utils/casper/tokenServices'; - -type FieldValues = { - swapFrom: Token; - swapTo: Token; - swapSettings: { - slippage: number; - deadline: number; - }; - pair: PairData | PairRouteData; -}; +import UnlockWalletPopupRequired from '@/modules/core/UnlockWalletPopupRequired'; +import { FieldValues } from '@/modules/Swap/type'; const SwapForm = () => { - const { toastSuccess } = useI18nToast(); const { t } = useTranslation(); - const { mutate, isLoading } = useMutateSwapTokens({ - onSuccess: (result: string | undefined) => { - toastSuccess('deploy_hash', { deployHash: result || '' }); - }, - }); + const { isOpen, onOpen, onClose } = useDisclosure(); const methods = useForm({ defaultValues: { swapFrom: {}, @@ -50,77 +30,8 @@ const SwapForm = () => { }, }); - const handleOnSubmit = (values: FieldValues) => { - const { swapFrom, swapTo, pair, swapSettings } = values; - const amountInValue = Big(swapFrom.amount || 0) - .round(swapFrom.decimals, 0) - .toNumber(); - - let path = [swapFrom.contractHash, swapTo.contractHash].map( - (hash) => `hash-${hash}` - ); - const routingPair = pair as PairRouteData; - if (routingPair.isUsingRouting) { - path = routingPair.path; - } - const amountOutMin = calculateAmountOutMin( - swapTo.amount || 0, - swapSettings.slippage, - swapTo.decimals - ); - - const amountIn = Big(amountInValue * 10 ** swapFrom.decimals).toNumber(); - - const amountOut = Big(amountOutMin * 10 ** swapTo.decimals).toNumber(); - - if ( - swapFrom.type === TokenTypesEnum.NATIVE && - swapTo.type === TokenTypesEnum.ERC20 - ) { - return mutate({ - functionType: FUNCTIONS.SWAP_EXACT_CSPR_FOR_TOKENS, - amountIn, - amountOut, - deadlineInMinutes: swapSettings.deadline, - path, - }); - } - - if ( - swapFrom.type === TokenTypesEnum.ERC20 && - swapTo.type === TokenTypesEnum.NATIVE - ) { - return mutate({ - functionType: FUNCTIONS.SWAP_EXACT_TOKENS_FOR_CSPR, - amountIn, - amountOut, - deadlineInMinutes: swapSettings.deadline, - path, - }); - } - - if ( - swapFrom.type === TokenTypesEnum.ERC20 && - swapTo.type === TokenTypesEnum.ERC20 - ) { - if (routingPair.isUsingRouting) { - return mutate({ - functionType: FUNCTIONS.SWAP_EXACT_TOKENS_FOR_TOKENS, - amountIn, - amountOut, - deadlineInMinutes: swapSettings.deadline, - path, - }); - } - - return mutate({ - functionType: FUNCTIONS.SWAP_TOKENS_FOR_EXACT_TOKENS, - amountIn, - amountOut, - deadlineInMinutes: swapSettings.deadline, - path, - }); - } + const handleOnSubmit = () => { + onOpen(); }; return ( @@ -151,18 +62,16 @@ const SwapForm = () => { - - + + + + diff --git a/src/modules/Swap/hooks/useCalculateAmountIn.ts b/src/modules/Swap/hooks/useCalculateAmountIn.ts index 182111d..e6539be 100644 --- a/src/modules/Swap/hooks/useCalculateAmountIn.ts +++ b/src/modules/Swap/hooks/useCalculateAmountIn.ts @@ -19,17 +19,18 @@ const useCalculateAmountIn = () => { const { setValue } = useFormContext(); const { data: pair = {} } = useGetCurrentAMMPair(); const swapFrom = useSelectToken('swapFrom'); - const calculatePrice = useCallback( - ({ value, reverseIn, reverseOut, decimals }: CalculatePriceParams) => { - const amount = Big(getAmountIn(reverseIn, reverseOut, value)) - .round(decimals, 0) - .toNumber(); - - setValue('swapFrom.amount', amount); - }, - [setValue] - ); + const calculatePrice = ({ + value, + reverseIn, + reverseOut, + decimals, + }: CalculatePriceParams) => { + const amount = Big(getAmountIn(reverseIn, reverseOut, value)) + .round(decimals, 0) + .toNumber(); + setValue('swapFrom.amount', amount); + }; const handleChangeAmount = useCallback( (value: number) => { if (!value) { @@ -58,7 +59,8 @@ const useCalculateAmountIn = () => { }); } }, - [calculatePrice, pair, swapFrom.contractHash, swapFrom.decimals] + // eslint-disable-next-line react-hooks/exhaustive-deps + [pair, swapFrom.contractHash, swapFrom.decimals] ); return handleChangeAmount; diff --git a/src/modules/Swap/hooks/useCalculateAmountOut.ts b/src/modules/Swap/hooks/useCalculateAmountOut.ts index 66c201b..d8ffc2a 100644 --- a/src/modules/Swap/hooks/useCalculateAmountOut.ts +++ b/src/modules/Swap/hooks/useCalculateAmountOut.ts @@ -19,16 +19,18 @@ const useCalculateAmountOut = () => { const { setValue } = useFormContext(); const { data: pair = {} } = useGetCurrentAMMPair(); const swapTo = useSelectToken('swapTo'); - const calculatePrice = useCallback( - ({ value, reverseIn, reverseOut, decimals }: CalculatePriceParams) => { - const amount = Big(getAmountOut(reverseIn, reverseOut, value)) - .round(decimals, 0) - .toNumber(); + const calculatePrice = ({ + value, + reverseIn, + reverseOut, + decimals, + }: CalculatePriceParams) => { + const amount = Big(getAmountOut(reverseIn, reverseOut, value)) + .round(decimals, 0) + .toNumber(); - setValue('swapTo.amount', amount); - }, - [setValue] - ); + setValue('swapTo.amount', amount); + }; const handleChangeAmount = useCallback( (value: number) => { @@ -58,7 +60,8 @@ const useCalculateAmountOut = () => { }); } }, - [calculatePrice, pair, swapTo.contractHash, swapTo.decimals] + // eslint-disable-next-line react-hooks/exhaustive-deps + [pair, swapTo.contractHash, swapTo.decimals] ); return handleChangeAmount; diff --git a/src/modules/Swap/hooks/useMutateSwapTokens.ts b/src/modules/Swap/hooks/useMutateSwapTokens.ts index 53b0623..2023d8e 100644 --- a/src/modules/Swap/hooks/useMutateSwapTokens.ts +++ b/src/modules/Swap/hooks/useMutateSwapTokens.ts @@ -22,6 +22,13 @@ type SwapTokensParams = { path: string[]; }; +export const MAP_PAYMENT_AMOUNT = { + [FUNCTIONS.SWAP_EXACT_CSPR_FOR_TOKENS]: 10, + [FUNCTIONS.SWAP_TOKENS_FOR_EXACT_TOKENS]: 5, + [FUNCTIONS.SWAP_EXACT_TOKENS_FOR_CSPR]: 5, + [FUNCTIONS.SWAP_EXACT_TOKENS_FOR_TOKENS]: 15, +}; + export const useMutateSwapTokens = ( options: UseMutationOptions = {} ) => { @@ -56,6 +63,7 @@ export const useMutateSwapTokens = ( amountOutMin: amountOut, deadline: dayjs().add(deadlineInMinutes, 'minutes').valueOf(), path, + paymentAmount: MAP_PAYMENT_AMOUNT[functionType], } ); @@ -70,6 +78,7 @@ export const useMutateSwapTokens = ( amountOut: amountOut, deadline: dayjs().add(deadlineInMinutes, 'minutes').valueOf(), path, + paymentAmount: MAP_PAYMENT_AMOUNT[functionType], } ); @@ -85,6 +94,7 @@ export const useMutateSwapTokens = ( amountOutMin: amountOut, deadline: dayjs().add(deadlineInMinutes, 'minutes').valueOf(), path, + paymentAmount: MAP_PAYMENT_AMOUNT[functionType], } ); @@ -99,6 +109,7 @@ export const useMutateSwapTokens = ( amountOutMin: amountOut, deadline: dayjs().add(deadlineInMinutes, 'minutes').valueOf(), path, + paymentAmount: MAP_PAYMENT_AMOUNT[functionType], } ); diff --git a/src/modules/Swap/type.ts b/src/modules/Swap/type.ts index 9880351..b6851ee 100644 --- a/src/modules/Swap/type.ts +++ b/src/modules/Swap/type.ts @@ -1 +1,14 @@ +import { PairData, PairRouteData } from '@/services/friendlyMarket/amm/type'; +import { Token } from '@/services/friendlyMarket/tokens'; + export type SwapName = 'swapFrom' | 'swapTo'; + +export type FieldValues = { + swapFrom: Token; + swapTo: Token; + swapSettings: { + slippage: number; + deadline: number; + }; + pair: PairData | PairRouteData; +}; diff --git a/src/public/locales/en/common.json b/src/public/locales/en/common.json index 5cf1a18..5301a79 100644 --- a/src/public/locales/en/common.json +++ b/src/public/locales/en/common.json @@ -133,5 +133,8 @@ "price_impact": "Price Impact", "minimum": "Minimum", "slippage": "Slippage", - "route": "Route" + "route": "Route", + "swap_confirmation": "Swap Confirmation", + "contract_hash": "Contract Hash", + "payment_amount": "Payment Amount" } diff --git a/src/utils/casper/tokenServices.ts b/src/utils/casper/tokenServices.ts index b48bd0d..afcbc2b 100644 --- a/src/utils/casper/tokenServices.ts +++ b/src/utils/casper/tokenServices.ts @@ -51,6 +51,7 @@ type BuldExactSwapCSPRForTokensDeployParams = { amountOutMin: number; path: string[]; deadline: number; + paymentAmount: number; }; type BuildSwapTokensForExactTokensDeployParams = { @@ -60,6 +61,7 @@ type BuildSwapTokensForExactTokensDeployParams = { amountInMax: number; path: string[]; deadline: number; + paymentAmount: number; }; type BuildSwapExactTokensForTokensDeployParams = { @@ -69,6 +71,7 @@ type BuildSwapExactTokensForTokensDeployParams = { amountOutMin: number; path: string[]; deadline: number; + paymentAmount: number; }; type BuildSwapExactTokensForTokensDeploy = { @@ -78,6 +81,7 @@ type BuildSwapExactTokensForTokensDeploy = { amountOutMin: number; path: string[]; deadline: number; + paymentAmount: number; }; /** @@ -110,7 +114,6 @@ export const buildExactSwapCSPRForTokensDeploy = async ( contractHash: string, transactionDetail: BuldExactSwapCSPRForTokensDeployParams ) => { - console.log('contractHash: ', contractHash); const contractHashByteArray = contractHashToByteArray(contractHash); const fromPbKey = CLPublicKey.fromHex(transactionDetail.fromPublicKey); const toPbKey = CLPublicKey.fromHex(transactionDetail.toPublicKey); @@ -138,7 +141,7 @@ export const buildExactSwapCSPRForTokensDeploy = async ( return buildEntryPointModulBytesDeploy( fromPbKey, runtimeArgs, - toBigNumMotes(10) + toBigNumMotes(transactionDetail.paymentAmount) ); }; @@ -173,7 +176,7 @@ export const buildSwapTokensForExactTokensDeploy = async ( contractHash, FUNCTIONS.SWAP_TOKENS_FOR_EXACT_TOKENS, runtimeArgs, - toBigNumMotes(5) + toBigNumMotes(transactionDetail.paymentAmount) ); }; @@ -205,7 +208,7 @@ export const buildSwapExactTokensForTokensDeploy = async ( contractHash, FUNCTIONS.SWAP_EXACT_TOKENS_FOR_TOKENS, runtimeArgs, - toBigNumMotes(5) + toBigNumMotes(transactionDetail.paymentAmount) ); }; @@ -233,7 +236,7 @@ export const buildSwapExactTokensForCSPRDeploy = async ( contractHash, FUNCTIONS.SWAP_EXACT_TOKENS_FOR_CSPR, runtimeArgs, - toBigNumMotes(15) + toBigNumMotes(transactionDetail.paymentAmount) ); }; diff --git a/yarn.lock b/yarn.lock index cdff9da..edfefd5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8904,10 +8904,10 @@ fragment-cache@^0.2.1: dependencies: map-cache "^0.2.2" -framer-motion@^10.2.4: - version "10.6.0" - resolved "https://registry.yarnpkg.com/framer-motion/-/framer-motion-10.6.0.tgz#348a71ca894456ec1a7ce695a19a377c217d4768" - integrity sha512-RkZe8iBhdWNs1y2HfYh4WlM9Co11/Vu+JCSQzmLaP5alM3QbBiue6OkeXygfxsRDp4yCar95ExoHE0CZQNyuVQ== +framer-motion@^10.12.12: + version "10.12.12" + resolved "https://registry.yarnpkg.com/framer-motion/-/framer-motion-10.12.12.tgz#24c3620520f225e446972c42cad1c94d6ce70bbc" + integrity sha512-DDCqp60U6hR7aUrXj/BXc/t0Sd/U4ep6w/NZQkw898K+u7s+Vv/P8yxq4WTNA86kU9QCsqOgn1Qhz2DpYK0Oag== dependencies: tslib "^2.4.0" optionalDependencies: From d0d6c89aaf1630f480a0efe238f2f0b9b94f78bb Mon Sep 17 00:00:00 2001 From: Liam Date: Wed, 24 May 2023 01:33:49 +0700 Subject: [PATCH 08/10] feat: swap validation --- src/enums/queryKeys.enum.ts | 3 + .../ModalTransactionSetting/index.tsx | 9 +-- .../Swap/components/SwapForm/ButtonSwap.tsx | 22 +++++++ .../Swap/components/SwapForm/index.tsx | 9 +-- src/modules/Swap/hooks/useSelectToken.ts | 13 ++++- src/modules/Swap/hooks/useValidateSwap.ts | 58 +++++++++++++++++++ src/public/locales/en/common.json | 7 ++- 7 files changed, 104 insertions(+), 17 deletions(-) create mode 100644 src/modules/Swap/components/SwapForm/ButtonSwap.tsx create mode 100644 src/modules/Swap/hooks/useValidateSwap.ts diff --git a/src/enums/queryKeys.enum.ts b/src/enums/queryKeys.enum.ts index f9d5603..67bd511 100644 --- a/src/enums/queryKeys.enum.ts +++ b/src/enums/queryKeys.enum.ts @@ -28,4 +28,7 @@ export enum QueryKeysEnum { SWAP_AMM_PAIR = 'swap_amm_pair', // Coingecko COINGECKO_COIN_MARKET_DATA = 'coingecko_coin_market_data', + + // Utils + UTILS_VALIDATE_SWAP = 'utils_validate_swap', } diff --git a/src/modules/Swap/components/ModalTransactionSetting/index.tsx b/src/modules/Swap/components/ModalTransactionSetting/index.tsx index 22e0176..3c39302 100644 --- a/src/modules/Swap/components/ModalTransactionSetting/index.tsx +++ b/src/modules/Swap/components/ModalTransactionSetting/index.tsx @@ -35,12 +35,7 @@ const ModalTransactionSetting = ({ onClose(); }, }); - const { - handleSubmit, - setValue, - control, - formState: { errors }, - } = useForm({ + const { handleSubmit, setValue, control } = useForm({ resolver: zodResolver(validationSchema), }); useGetSwapSettings({ @@ -54,8 +49,6 @@ const ModalTransactionSetting = ({ mutate(data); }; - console.log('errors: ', errors); - return (
diff --git a/src/modules/Swap/components/SwapForm/ButtonSwap.tsx b/src/modules/Swap/components/SwapForm/ButtonSwap.tsx new file mode 100644 index 0000000..6510044 --- /dev/null +++ b/src/modules/Swap/components/SwapForm/ButtonSwap.tsx @@ -0,0 +1,22 @@ +import { Button } from '@chakra-ui/react'; +import { useTranslation } from 'react-i18next'; + +import { useValidateSwap } from '@/modules/Swap/hooks/useValidateSwap'; + +export const ButtonSwap = () => { + const { t } = useTranslation(); + + const validateSwap = useValidateSwap(); + + const validated = validateSwap(); + return ( + + ); +}; diff --git a/src/modules/Swap/components/SwapForm/index.tsx b/src/modules/Swap/components/SwapForm/index.tsx index bd6066e..2f55f4a 100644 --- a/src/modules/Swap/components/SwapForm/index.tsx +++ b/src/modules/Swap/components/SwapForm/index.tsx @@ -1,8 +1,8 @@ -import { Box, Button, Divider, Flex, useDisclosure } from '@chakra-ui/react'; +import { Box, Divider, Flex, useDisclosure } from '@chakra-ui/react'; import { FormProvider, useForm } from 'react-hook-form'; -import { useTranslation } from 'react-i18next'; import ButtonReverse from './ButtonReverse'; +import { ButtonSwap } from './ButtonSwap'; import ModalConfirm from './ModalConfirm'; import RadioPercentSelect from './RadioPercentSelect'; import Receipt from './Receipt'; @@ -16,7 +16,6 @@ import UnlockWalletPopupRequired from '@/modules/core/UnlockWalletPopupRequired' import { FieldValues } from '@/modules/Swap/type'; const SwapForm = () => { - const { t } = useTranslation(); const { isOpen, onOpen, onClose } = useDisclosure(); const methods = useForm({ defaultValues: { @@ -62,9 +61,7 @@ const SwapForm = () => { - + diff --git a/src/modules/Swap/hooks/useSelectToken.ts b/src/modules/Swap/hooks/useSelectToken.ts index dd14085..bf84e23 100644 --- a/src/modules/Swap/hooks/useSelectToken.ts +++ b/src/modules/Swap/hooks/useSelectToken.ts @@ -1,20 +1,29 @@ import { useFormContext, useWatch } from 'react-hook-form'; +import { useGetSwapTokenBalance } from './useGetSwapTokenBalance'; import { SwapName } from '../type'; import { useGetCoinMarketData } from '@/hooks/queries/useGetCoinMarketData'; +import { useAccount } from '@/hooks/useAccount'; export const useSelectToken = (name: SwapName) => { + const { publicKey } = useAccount(); const { control } = useFormContext(); const valueWatched = useWatch({ control, name: name, }); - const { data = { price: 0, amount: 0 } } = useGetCoinMarketData( + const { data: value = { price: 0, amount: 0 } } = useGetCoinMarketData( valueWatched?.coingeckoId ); + const { data: { balance = 0 } = { balance: 0 } } = useGetSwapTokenBalance({ + ...valueWatched, + publicKey, + }); + return { ...valueWatched, - price: data.price, + price: value.price, + balance, }; }; diff --git a/src/modules/Swap/hooks/useValidateSwap.ts b/src/modules/Swap/hooks/useValidateSwap.ts new file mode 100644 index 0000000..5f2d560 --- /dev/null +++ b/src/modules/Swap/hooks/useValidateSwap.ts @@ -0,0 +1,58 @@ +import { useCallback } from 'react'; + +import { useTranslation } from 'react-i18next'; + +import { useSelectToken } from './useSelectToken'; +import { Token } from '@/services/friendlyMarket/tokens'; + +export const useValidateSwap = () => { + const swapFrom: Token = useSelectToken('swapFrom'); + const swapTo: Token = useSelectToken('swapTo'); + const { t } = useTranslation(); + + const validateSwap = useCallback(() => { + console.log('swapFrom: ', swapFrom); + console.log('swapTo: ', swapTo); + if (!swapFrom.contractHash || !swapTo.contractHash) { + return { + isValid: false, + error: t('please_select_token'), + }; + } + + if (swapFrom.contractHash === swapTo.contractHash) { + return { + isValid: false, + error: t('token_must_be_different'), + }; + } + + if (!swapFrom.amount || swapFrom.amount <= 0) { + return { + isValid: false, + error: t('please_enter_amount'), + }; + } + + if (swapFrom.amount > swapFrom.balance) { + return { + isValid: false, + error: t('insufficient_balance'), + }; + } + + if (swapFrom.amount <= 0 && swapTo.amount > 0) { + return { + isValid: false, + error: t('insufficient_for_this_trade'), + }; + } + + return { + isValid: true, + error: '', + }; + }, [swapFrom, swapTo, t]); + + return validateSwap; +}; diff --git a/src/public/locales/en/common.json b/src/public/locales/en/common.json index 5301a79..be9bded 100644 --- a/src/public/locales/en/common.json +++ b/src/public/locales/en/common.json @@ -136,5 +136,10 @@ "route": "Route", "swap_confirmation": "Swap Confirmation", "contract_hash": "Contract Hash", - "payment_amount": "Payment Amount" + "payment_amount": "Payment Amount", + "please_select_token": "Please select token", + "token_must_be_different": "Tokens must be different", + "please_enter_amount": "Please enter amount", + "insufficient_balance": "Insufficient balance", + "insufficient_for_this_trade": "Insufficient liquidity for this trade." } From 805284b7035e04404ef198472c9c6062e2955bbe Mon Sep 17 00:00:00 2001 From: Liam Date: Wed, 24 May 2023 18:11:54 +0700 Subject: [PATCH 09/10] feat: price impact --- .../Inputs/InputNumberField/index.tsx | 26 ++++++++++++++----- .../Swap/components/SelectToken/Price.tsx | 12 ++------- .../Swap/components/SwapForm/Receipt.tsx | 9 ++++--- .../components/SwapForm/SelectSwapFrom.tsx | 10 +++---- src/modules/Swap/hooks/useGetAmountInUsd.ts | 20 ++++++++++++++ src/modules/Swap/utils.ts | 7 +++++ 6 files changed, 58 insertions(+), 26 deletions(-) create mode 100644 src/modules/Swap/hooks/useGetAmountInUsd.ts diff --git a/src/components/Inputs/InputNumberField/index.tsx b/src/components/Inputs/InputNumberField/index.tsx index 1aadce9..d2a022b 100644 --- a/src/components/Inputs/InputNumberField/index.tsx +++ b/src/components/Inputs/InputNumberField/index.tsx @@ -1,26 +1,38 @@ import { NumberInput, NumberInputField } from '@chakra-ui/react'; -import { Control, Controller, ControllerProps } from 'react-hook-form'; +import { Control, Controller } from 'react-hook-form'; type Props = { // eslint-disable-next-line @typescript-eslint/no-explicit-any control: Control; - max?: number; + name: string; + onChange?: (value: number) => void; min?: number; -} & Omit; + max?: number; +}; -const InputNumberField = ({ min, max, name, ...restProps }: Props) => { +const InputNumberField = ({ + min, + max, + name, + control, + onChange, + ...restProps +}: Props) => { return ( { + control={control} + render={({ field: { onChange: onChangeForm, value, onBlur } }) => { return ( { - onChange(parseFloat(val) || 0); + const valNumber = parseFloat(val) || 0; + onChangeForm(valNumber); + onChange?.(valNumber); }} onBlur={onBlur} > diff --git a/src/modules/Swap/components/SelectToken/Price.tsx b/src/modules/Swap/components/SelectToken/Price.tsx index e016628..37eb286 100644 --- a/src/modules/Swap/components/SelectToken/Price.tsx +++ b/src/modules/Swap/components/SelectToken/Price.tsx @@ -1,8 +1,7 @@ import { Text } from '@chakra-ui/react'; -import Big from 'big.js'; import { useTranslation } from 'react-i18next'; -import { useGetCoinMarketData } from '@/hooks/queries/useGetCoinMarketData'; +import { useGetAmountInUsd } from '@/modules/Swap/hooks/useGetAmountInUsd'; import { Token } from '@/services/friendlyMarket/tokens'; type PriceProps = { @@ -11,14 +10,7 @@ type PriceProps = { const Price = ({ value }: PriceProps) => { const { t } = useTranslation(); - const { data = { price: 0, amount: 0 } } = useGetCoinMarketData( - value?.coingeckoId - ); - const amountInUsd = Big(data.price || 0) - .times(value?.amount || 0) - .round(8) - .toNumber(); - + const amountInUsd = useGetAmountInUsd({ token: value }); return ( {t('price')}: {amountInUsd || 0} diff --git a/src/modules/Swap/components/SwapForm/Receipt.tsx b/src/modules/Swap/components/SwapForm/Receipt.tsx index aba1a08..6142408 100644 --- a/src/modules/Swap/components/SwapForm/Receipt.tsx +++ b/src/modules/Swap/components/SwapForm/Receipt.tsx @@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next'; import RoutePaths from './RoutePaths'; import { useCalculateAmountOutMin } from '@/modules/Swap/hooks/useCalculateAmountOutMin'; +import { useGetAmountInUsd } from '@/modules/Swap/hooks/useGetAmountInUsd'; import { useGetCurrentAMMPair } from '@/modules/Swap/hooks/useGetCurrentAMMPair'; import { useGetSwapSettings } from '@/modules/Swap/hooks/useGetSwapSettings'; import { useSelectToken } from '@/modules/Swap/hooks/useSelectToken'; @@ -31,6 +32,8 @@ const Receipt = ({ isShowRoute, ...props }: ReceiptProps) => { const { t } = useTranslation(); const swapFrom: Token = useSelectToken('swapFrom'); const swapTo: Token = useSelectToken('swapTo'); + const fromAmountInUsd = useGetAmountInUsd({ token: swapFrom }); + const toAmountInUsd = useGetAmountInUsd({ token: swapTo }); const { data: swapSettings = { slippage: 0 } } = useGetSwapSettings({ onSuccess: (data) => { setValue('swapSettings', data); @@ -48,11 +51,11 @@ const Receipt = ({ isShowRoute, ...props }: ReceiptProps) => { .div(100) .round(swapFrom.decimals, 0) .toNumber(); - const priceImpact = swapTo.amountInUSD + const priceImpact = fromAmountInUsd ? Big(100) .minus( - Big(swapFrom.amountInUSD || 0) - .div(swapTo.amountInUSD || 1) + Big(fromAmountInUsd || 0) + .div(toAmountInUsd || 1) .times(100) ) .round(4, 0) diff --git a/src/modules/Swap/components/SwapForm/SelectSwapFrom.tsx b/src/modules/Swap/components/SwapForm/SelectSwapFrom.tsx index 40a1f4f..eaa979a 100644 --- a/src/modules/Swap/components/SwapForm/SelectSwapFrom.tsx +++ b/src/modules/Swap/components/SwapForm/SelectSwapFrom.tsx @@ -1,19 +1,17 @@ import { useDisclosure } from '@chakra-ui/react'; import Big from 'big.js'; -import { useFormContext, useWatch } from 'react-hook-form'; +import { useFormContext } from 'react-hook-form'; import ModalSelectToken from '../ModalSelectToken'; import SelectToken from '../SelectToken'; +import { useSelectToken } from '@/modules/Swap/hooks/useSelectToken'; import { useSetValueSwapFrom } from '@/modules/Swap/hooks/useSetValueSwapFrom'; import { Token } from '@/services/friendlyMarket/tokens'; const SelectSwapFrom = () => { const { isOpen, onClose, onOpen } = useDisclosure(); - const { control, setValue } = useFormContext(); - const valueWatched = useWatch({ - control, - name: 'swapFrom', - }); + const { setValue } = useFormContext(); + const valueWatched = useSelectToken('swapFrom'); const setValueSwapFrom = useSetValueSwapFrom(); const handleOnClick = () => { diff --git a/src/modules/Swap/hooks/useGetAmountInUsd.ts b/src/modules/Swap/hooks/useGetAmountInUsd.ts new file mode 100644 index 0000000..c5f2628 --- /dev/null +++ b/src/modules/Swap/hooks/useGetAmountInUsd.ts @@ -0,0 +1,20 @@ +import Big from 'big.js'; + +import { useGetCoinMarketData } from '@/hooks/queries/useGetCoinMarketData'; +import { Token } from '@/services/friendlyMarket/tokens'; + +type GetAmountInUsdParams = { + token?: Token; +}; + +export const useGetAmountInUsd = ({ token }: GetAmountInUsdParams) => { + const { data = { price: 0, amount: 0 } } = useGetCoinMarketData( + token?.coingeckoId + ); + const amountInUsd = Big(data.price || 0) + .times(token?.amount || 0) + .round(8) + .toNumber(); + + return amountInUsd; +}; diff --git a/src/modules/Swap/utils.ts b/src/modules/Swap/utils.ts index 4af0b6c..6cc6b0a 100644 --- a/src/modules/Swap/utils.ts +++ b/src/modules/Swap/utils.ts @@ -8,6 +8,9 @@ export const getAmountIn = ( if (!amountOut) { return 0; } + if (!reserveIn || !reserveOut) { + return 0; + } const numerator = Big(reserveIn).times(amountOut).times(1000); const denominator = Big(reserveOut).minus(amountOut).times(997); @@ -22,6 +25,10 @@ export const getAmountOut = ( if (!amountIn) { return 0; } + if (!reserveIn || !reserveOut) { + return 0; + } + const amountInWithFee = Big(amountIn).times(997); const numerator = Big(amountInWithFee).times(reserveOut); const denominator = Big(reserveIn).times(1000).add(amountInWithFee); From 782a17f7d81b5e42134c506c8aac539c2409916b Mon Sep 17 00:00:00 2001 From: Liam Date: Wed, 24 May 2023 20:58:10 +0700 Subject: [PATCH 10/10] feat: route swap logic --- .../components/RecoveryKeysForm/index.tsx | 1 - .../Swap/components/SelectToken/Price.tsx | 2 +- .../Swap/hooks/useCalculateAmountIn.ts | 30 +++++++++++----- .../Swap/hooks/useCalculateAmountOut.ts | 29 +++++++++++----- src/modules/Swap/hooks/useValidateSwap.ts | 13 ++++--- src/modules/Swap/utils.ts | 34 +++++++++++++++++++ 6 files changed, 87 insertions(+), 22 deletions(-) diff --git a/src/modules/NewWallet/components/RecoveryKeysForm/index.tsx b/src/modules/NewWallet/components/RecoveryKeysForm/index.tsx index 3663c76..217349e 100644 --- a/src/modules/NewWallet/components/RecoveryKeysForm/index.tsx +++ b/src/modules/NewWallet/components/RecoveryKeysForm/index.tsx @@ -51,7 +51,6 @@ const RecoveryKeysForm = ({ ...restProps }: Props) => { }); const handleOnSubmit = ({ masterKey, encryptionType }: SubmitValues) => { - console.log(masterKey); dispatch( updateEncryptionTypeAndMasterKey({ encryptionType, diff --git a/src/modules/Swap/components/SelectToken/Price.tsx b/src/modules/Swap/components/SelectToken/Price.tsx index 37eb286..2a7b6ec 100644 --- a/src/modules/Swap/components/SelectToken/Price.tsx +++ b/src/modules/Swap/components/SelectToken/Price.tsx @@ -13,7 +13,7 @@ const Price = ({ value }: PriceProps) => { const amountInUsd = useGetAmountInUsd({ token: value }); return ( - {t('price')}: {amountInUsd || 0} + {t('price')}: ${amountInUsd || 0} ); }; diff --git a/src/modules/Swap/hooks/useCalculateAmountIn.ts b/src/modules/Swap/hooks/useCalculateAmountIn.ts index e6539be..1a4da2a 100644 --- a/src/modules/Swap/hooks/useCalculateAmountIn.ts +++ b/src/modules/Swap/hooks/useCalculateAmountIn.ts @@ -5,7 +5,11 @@ import { useFormContext } from 'react-hook-form'; import { useGetCurrentAMMPair } from './useGetCurrentAMMPair'; import { useSelectToken } from './useSelectToken'; -import { getAmountIn } from '../utils'; +import { + findReverseRouteIntToken1PairByContractHash, + findReverseRouteToken0IntPairByContractHash, + getAmountIn, +} from '../utils'; import { PairData, PairRouteData } from '@/services/friendlyMarket/amm/type'; type CalculatePriceParams = { @@ -19,6 +23,7 @@ const useCalculateAmountIn = () => { const { setValue } = useFormContext(); const { data: pair = {} } = useGetCurrentAMMPair(); const swapFrom = useSelectToken('swapFrom'); + const swapTo = useSelectToken('swapTo'); const calculatePrice = ({ value, reverseIn, @@ -31,6 +36,7 @@ const useCalculateAmountIn = () => { setValue('swapFrom.amount', amount); }; + const handleChangeAmount = useCallback( (value: number) => { if (!value) { @@ -39,15 +45,23 @@ const useCalculateAmountIn = () => { } const pairRouting = pair; if (pairRouting.isUsingRouting) { - const bridgeAmount = getAmountIn( - pairRouting.intToken1Pair.reserve0, - pairRouting.intToken1Pair.reserve1, - value - ); + const [reserve0, reserve1] = + findReverseRouteIntToken1PairByContractHash( + swapTo.contractHash, + pairRouting + ); + const bridgeAmount = getAmountIn(reserve0, reserve1, value); + + const [reserve0IntPair, reserve1IntPair] = + findReverseRouteToken0IntPairByContractHash( + swapFrom.contractHash, + pairRouting + ); + calculatePrice({ value: bridgeAmount, - reverseIn: pairRouting.token0IntPair.reserve0, - reverseOut: pairRouting.token0IntPair.reserve1, + reverseIn: reserve0IntPair, + reverseOut: reserve1IntPair, decimals: swapFrom.decimals, }); } else if (pair && swapFrom.contractHash) { diff --git a/src/modules/Swap/hooks/useCalculateAmountOut.ts b/src/modules/Swap/hooks/useCalculateAmountOut.ts index d8ffc2a..2443bc6 100644 --- a/src/modules/Swap/hooks/useCalculateAmountOut.ts +++ b/src/modules/Swap/hooks/useCalculateAmountOut.ts @@ -5,7 +5,11 @@ import { useFormContext } from 'react-hook-form'; import { useGetCurrentAMMPair } from './useGetCurrentAMMPair'; import { useSelectToken } from './useSelectToken'; -import { getAmountOut } from '../utils'; +import { + findReverseRouteIntToken1PairByContractHash, + findReverseRouteToken0IntPairByContractHash, + getAmountOut, +} from '../utils'; import { PairData, PairRouteData } from '@/services/friendlyMarket/amm/type'; type CalculatePriceParams = { @@ -18,6 +22,7 @@ type CalculatePriceParams = { const useCalculateAmountOut = () => { const { setValue } = useFormContext(); const { data: pair = {} } = useGetCurrentAMMPair(); + const swapFrom = useSelectToken('swapFrom'); const swapTo = useSelectToken('swapTo'); const calculatePrice = ({ value, @@ -39,16 +44,24 @@ const useCalculateAmountOut = () => { return; } const pairRouting = pair; + if (pairRouting.isUsingRouting) { - const bridgeAmount = getAmountOut( - pairRouting.token0IntPair.reserve0, - pairRouting.token0IntPair.reserve1, - value - ); + const [reserve0, reserve1] = + findReverseRouteToken0IntPairByContractHash( + swapFrom.contractHash, + pairRouting + ); + const bridgeAmount = getAmountOut(reserve0, reserve1, value); + const [reserve0IntPair, reserve1IntPair] = + findReverseRouteIntToken1PairByContractHash( + swapTo.contractHash, + pairRouting + ); + calculatePrice({ value: bridgeAmount, - reverseIn: pairRouting.intToken1Pair.reserve0, - reverseOut: pairRouting.intToken1Pair.reserve1, + reverseIn: reserve0IntPair, + reverseOut: reserve1IntPair, decimals: swapTo.decimals, }); } else if (pair && swapTo.contractHash) { diff --git a/src/modules/Swap/hooks/useValidateSwap.ts b/src/modules/Swap/hooks/useValidateSwap.ts index 5f2d560..f481d54 100644 --- a/src/modules/Swap/hooks/useValidateSwap.ts +++ b/src/modules/Swap/hooks/useValidateSwap.ts @@ -6,13 +6,11 @@ import { useSelectToken } from './useSelectToken'; import { Token } from '@/services/friendlyMarket/tokens'; export const useValidateSwap = () => { - const swapFrom: Token = useSelectToken('swapFrom'); - const swapTo: Token = useSelectToken('swapTo'); + const swapFrom: Token & { balance: number } = useSelectToken('swapFrom'); + const swapTo: Token & { balance: number } = useSelectToken('swapTo'); const { t } = useTranslation(); const validateSwap = useCallback(() => { - console.log('swapFrom: ', swapFrom); - console.log('swapTo: ', swapTo); if (!swapFrom.contractHash || !swapTo.contractHash) { return { isValid: false, @@ -34,6 +32,13 @@ export const useValidateSwap = () => { }; } + if (!swapTo.amount || swapTo.amount < 0) { + return { + isValid: false, + error: t('please_enter_amount'), + }; + } + if (swapFrom.amount > swapFrom.balance) { return { isValid: false, diff --git a/src/modules/Swap/utils.ts b/src/modules/Swap/utils.ts index 6cc6b0a..de91d93 100644 --- a/src/modules/Swap/utils.ts +++ b/src/modules/Swap/utils.ts @@ -1,5 +1,7 @@ import Big from 'big.js'; +import { PairRouteData } from '@/services/friendlyMarket/amm/type'; + export const getAmountIn = ( reserveIn: number | string, reserveOut: number | string, @@ -46,3 +48,35 @@ export const calculateAmountOutMin = ( .round(decimals, 0) .toNumber(); }; + +export const findReverseRouteIntToken1PairByContractHash = ( + contractHash: string, + pairRouteData: PairRouteData +) => { + const { intToken1Pair } = pairRouteData; + if (intToken1Pair.token0 === contractHash) { + return [intToken1Pair.reserve1, intToken1Pair.reserve0]; + } + + if (intToken1Pair.token1 === contractHash) { + return [intToken1Pair.reserve0, intToken1Pair.reserve1]; + } + + return [0, 0]; +}; + +export const findReverseRouteToken0IntPairByContractHash = ( + contractHash: string, + pairRouteData: PairRouteData +) => { + const { token0IntPair } = pairRouteData; + if (token0IntPair.token0 === contractHash) { + return [token0IntPair.reserve0, token0IntPair.reserve1]; + } + + if (token0IntPair.token1 === contractHash) { + return [token0IntPair.reserve1, token0IntPair.reserve0]; + } + + return [0, 0]; +};