diff --git a/.env.development b/.env.development index 13aae14..2d35860 100644 --- a/.env.development +++ b/.env.development @@ -1,3 +1,3 @@ -VITE_CASPERDASH_BASE_API_URL=https://testnet-api.casperdash.io -VITE_NETWORK_NAME=casper-test +VITE_CASPERDASH_BASE_API_URL=https://api.casperdash.io +VITE_NETWORK_NAME=casper VITE_CSPR_LIVE_URL=https://testnet.cspr.live 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/App.tsx b/src/App.tsx index 90aa544..2fa8343 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -46,6 +46,7 @@ const App = () => { QueryKeysEnum.MY_TOKENS, QueryKeysEnum.ACCOUNT, QueryKeysEnum.ACCOUNT_BALANCES, + QueryKeysEnum.SWAP_SETTINGS, ].includes(_.first(queryKey) as unknown as QueryKeysEnum); }, }, diff --git a/src/components/Inputs/InputNumberField/index.tsx b/src/components/Inputs/InputNumberField/index.tsx new file mode 100644 index 0000000..d2a022b --- /dev/null +++ b/src/components/Inputs/InputNumberField/index.tsx @@ -0,0 +1,47 @@ +import { NumberInput, NumberInputField } from '@chakra-ui/react'; +import { Control, Controller } from 'react-hook-form'; + +type Props = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + control: Control; + name: string; + onChange?: (value: number) => void; + min?: number; + max?: number; +}; + +const InputNumberField = ({ + min, + max, + name, + control, + onChange, + ...restProps +}: Props) => { + return ( + { + return ( + { + const valNumber = parseFloat(val) || 0; + onChangeForm(valNumber); + onChange?.(valNumber); + }} + onBlur={onBlur} + > + + + ); + }} + > + ); +}; + +export default InputNumberField; 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..d7274a2 --- /dev/null +++ b/src/components/Modal/index.tsx @@ -0,0 +1,59 @@ +import { ReactNode } from 'react'; + +import { + Modal as ModalChakra, + ModalOverlay, + ModalHeader, + ModalBody, + ModalCloseButton, + Heading, + ModalFooter, + ModalContent, +} from '@chakra-ui/react'; + +type ModalProps = { + isOpen: boolean; + onClose: () => void; + title?: string | null; + children: ReactNode; + header?: ReactNode; + footer?: ReactNode; +}; + +const Modal = ({ + isOpen, + onClose, + title, + children, + header, + footer, +}: ModalProps) => { + return ( + <> + + + + + {title && ( + + {title} + + )} + {header} + + + {children} + {footer} + + + + ); +}; + +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/config/configuration.ts b/src/config/configuration.ts index 888a722..09fa347 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -6,4 +6,11 @@ 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', + friendlyMarketModuleBytesUrl: + 'https://s3.ap-southeast-1.amazonaws.com/assets.casperdash.io/sc-resources/gistfile1.txt', + swapContractHash: + 'fa64806972777d6263dea1f0e5a908620ffd19113df57ebd9ea4aa4e23de6090', }; 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/enums/queryKeys.enum.ts b/src/enums/queryKeys.enum.ts index 4641faf..67bd511 100644 --- a/src/enums/queryKeys.enum.ts +++ b/src/enums/queryKeys.enum.ts @@ -7,7 +7,8 @@ export enum QueryKeysEnum { LOCKED = 'locked', ACCOUNT_BALANCES = 'account_balances', RECOVERY_PHRASE = 'recovery_phrase', - MY_TOKENS = 'tokens', + TOKENS = 'tokens', + MY_TOKENS = 'my_tokens', TOKEN = 'token', CURRENT_ACCOUNT = 'current_account', ASSETS = 'assets', @@ -20,4 +21,14 @@ export enum QueryKeysEnum { PRICE_HISTORIES = 'price_histories', NFTS = 'nfts', PRIVATE_KEY_WITH_UID = 'private_key_with_uid', + // Swap + SWAP_TOKENS = 'swap_tokens', + SWAP_TOKEN_BALANCE = 'swap_token_balance', + SWAP_SETTINGS = 'swap_settings', + 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/enums/tokenTypes.ts b/src/enums/tokenTypes.ts new file mode 100644 index 0000000..e5a9033 --- /dev/null +++ b/src/enums/tokenTypes.ts @@ -0,0 +1,4 @@ +export enum TokenTypesEnum { + NATIVE = 'Native', + ERC20 = 'ERC20', +} diff --git a/src/hooks/queries/useGetCoinMarketData.ts b/src/hooks/queries/useGetCoinMarketData.ts new file mode 100644 index 0000000..77959a1 --- /dev/null +++ b/src/hooks/queries/useGetCoinMarketData.ts @@ -0,0 +1,44 @@ +import { useQuery, UseQueryOptions } from '@tanstack/react-query'; +import _get from 'lodash-es/get'; + +import { QueryKeysEnum } from '@/enums/queryKeys.enum'; +import { getCoinPrice } from '@/services/coingecko/coin/prices'; + +type Data = { + marketCap: number; + price: number; + priceChange: number; + volume: number; +}; + +export const useGetCoinMarketData = ( + id?: string, + options: Omit< + UseQueryOptions< + Data, + unknown, + Data, + [QueryKeysEnum.COINGECKO_COIN_MARKET_DATA, string | undefined] + >, + 'queryKey' | 'queryFn' + > = {} +) => { + return useQuery( + [QueryKeysEnum.COINGECKO_COIN_MARKET_DATA, id], + async (): Promise => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const result = await getCoinPrice({ id: id! }); + + return { + marketCap: _get(result, 'market_data.market_cap.usd', 0), + price: _get(result, 'market_data.current_price.usd', 0), + priceChange: _get(result, 'market_data.price_change_percentage_24h', 0), + volume: _get(result, 'market_data.total_volume.usd', 0), + }; + }, + { + ...options, + enabled: !!id, + } + ); +}; diff --git a/src/hooks/queries/useGetToken.ts b/src/hooks/queries/useGetToken.ts index dfa4cef..d68e571 100644 --- a/src/hooks/queries/useGetToken.ts +++ b/src/hooks/queries/useGetToken.ts @@ -1,6 +1,5 @@ import { useQuery, UseQueryOptions } from '@tanstack/react-query'; -import useDebounce from '../helpers/useDebounce'; import { QueryKeysEnum } from '@/enums/queryKeys.enum'; import { getToken, GetTokenResponse } from '@/services/casperdash/token'; @@ -20,13 +19,12 @@ export const useGetToken = ( 'queryKey' | 'queryFn' > ) => { - const tokenAddressDebounced = useDebounce(tokenAddress, 300); return useQuery( - [QueryKeysEnum.TOKEN, tokenAddressDebounced], - () => getToken({ tokenAddress: tokenAddressDebounced }), + [QueryKeysEnum.TOKEN, tokenAddress], + () => getToken({ tokenAddress }), { ...options, - enabled: !!tokenAddressDebounced, + enabled: !!tokenAddress, } ); }; diff --git a/src/hooks/queries/useGetTokens.ts b/src/hooks/queries/useGetTokens.ts new file mode 100644 index 0000000..c2411e7 --- /dev/null +++ b/src/hooks/queries/useGetTokens.ts @@ -0,0 +1,54 @@ +import { useQuery, UseQueryOptions } from '@tanstack/react-query'; +import * as _ from 'lodash-es'; + +import { QueryKeysEnum } from '@/enums/queryKeys.enum'; +import { getTokensInfo, TokenInfo } from '@/services/casperdash/token'; +import { Token } from '@/typings/token'; +import { hexToNumber } from '@/utils/currency'; + +type UseGetTokensParams = { + tokenAddresses: string[]; + publicKey?: string; +}; + +export const useGetTokens = ( + { tokenAddresses, publicKey }: UseGetTokensParams, + options?: Omit< + UseQueryOptions< + Token[], + unknown, + Token[], + [QueryKeysEnum.TOKENS, string | undefined, string[]] + >, + 'queryKey' | 'queryFn' + > +) => { + return useQuery( + [QueryKeysEnum.TOKENS, publicKey, tokenAddresses], + async () => { + if (!publicKey) { + return []; + } + const tokensInfo = await getTokensInfo({ + publicKey, + tokenAddress: tokenAddresses, + }); + + return tokensInfo.map((tokenInfo: TokenInfo) => { + const balanceHex = _.get(tokenInfo, 'balance.hex', '0'); + const decimalsHex = _.get(tokenInfo, 'decimals.hex', '0'); + return { + name: tokenInfo.name, + tokenAddress: tokenInfo.address, + symbol: tokenInfo.symbol, + balance: hexToNumber(balanceHex, decimalsHex), + decimals: parseInt(decimalsHex, 16), + }; + }); + }, + { + ...options, + enabled: !!publicKey, + } + ); +}; 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/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 15afa2e..4cf62e9 100644 --- a/src/icons/index.ts +++ b/src/icons/index.ts @@ -8,3 +8,8 @@ export * from './import'; export * from './home'; 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 new file mode 100644 index 0000000..d834d7c --- /dev/null +++ b/src/icons/refresh.tsx @@ -0,0 +1,21 @@ +import { SVGProps } from 'react'; + +type Props = SVGProps; + +export const RefreshIcon = ({ ...props }: Props) => { + return ( + + + + ); +}; diff --git a/src/icons/reverse.tsx b/src/icons/reverse.tsx new file mode 100644 index 0000000..69c03ac --- /dev/null +++ b/src/icons/reverse.tsx @@ -0,0 +1,42 @@ +import { SVGProps } from 'react'; + +type Props = SVGProps; + +export const ReverseIcon = ({ ...props }: Props) => { + return ( + + + + + + + + + + + + ); +}; diff --git a/src/icons/search.tsx b/src/icons/search.tsx new file mode 100644 index 0000000..004e74f --- /dev/null +++ b/src/icons/search.tsx @@ -0,0 +1,24 @@ +import { SVGProps } from 'react'; + +type Props = SVGProps; + +export const SearchIcon = ({ color = '#777E90', ...props }: Props) => { + return ( + + + + ); +}; diff --git a/src/icons/setting.tsx b/src/icons/setting.tsx new file mode 100644 index 0000000..bd4a9d6 --- /dev/null +++ b/src/icons/setting.tsx @@ -0,0 +1,33 @@ +import { SVGProps } from 'react'; + +type Props = SVGProps; + +export const SettingIcon = ({ ...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) => { - + { }); const handleOnSubmit = ({ masterKey, encryptionType }: SubmitValues) => { - console.log(masterKey); dispatch( updateEncryptionTypeAndMasterKey({ encryptionType, diff --git a/src/modules/Swap/components/ModalSelectToken/TokenItem.tsx b/src/modules/Swap/components/ModalSelectToken/TokenItem.tsx new file mode 100644 index 0000000..0c5c669 --- /dev/null +++ b/src/modules/Swap/components/ModalSelectToken/TokenItem.tsx @@ -0,0 +1,40 @@ +import { Flex, Image, Spinner, 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 = ({ publicKey, token, onClick }: TokenItemProps) => { + const { data: { balance } = { balance: 0 }, isLoading } = + useGetSwapTokenBalance({ + ...token, + publicKey, + }); + + return ( + + + + + + {token.name} + + {isLoading ? : {balance || 0}} + + ); +}; + +export default TokenItem; diff --git a/src/modules/Swap/components/ModalSelectToken/hooks.ts b/src/modules/Swap/components/ModalSelectToken/hooks.ts new file mode 100644 index 0000000..ba27a1c --- /dev/null +++ b/src/modules/Swap/components/ModalSelectToken/hooks.ts @@ -0,0 +1,32 @@ +import * as _ from 'lodash-es'; + +import { useGetTokens } from '@/hooks/queries/useGetTokens'; +import { useAccount } from '@/hooks/useAccount'; +import { useGetSwapListTokens } from '@/modules/Swap/hooks/useGetSwapListTokens'; +import { Token as Token } from '@/typings/token'; + +export const useGetBalanceTokens = () => { + const { publicKey } = useAccount(); + const { data: listTokens = [] } = useGetSwapListTokens(); + + const { data: tokenInfos } = useGetTokens({ + tokenAddresses: _.map(listTokens, 'contractHash'), + publicKey, + }); + + const fetchedTokens = _.map(listTokens, (token) => { + const tokenInfo = tokenInfos?.find( + (fToken: Token) => fToken.tokenAddress === token.contractHash + ); + if (!tokenInfo) { + return token; + } + + return { + ...token, + balance: tokenInfo.balance, + }; + }); + + return fetchedTokens; +}; diff --git a/src/modules/Swap/components/ModalSelectToken/index.tsx b/src/modules/Swap/components/ModalSelectToken/index.tsx new file mode 100644 index 0000000..d8bf8aa --- /dev/null +++ b/src/modules/Swap/components/ModalSelectToken/index.tsx @@ -0,0 +1,72 @@ +import { Input, Flex, Text } from '@chakra-ui/react'; +import * as _ from 'lodash-es'; +import { useTranslation } from 'react-i18next'; + +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 = { + isOpen: boolean; + onClose: () => void; + onSelect?: (token: Token) => void; +}; + +const ModalSelectToken = ({ + isOpen, + onClose, + onSelect, +}: ModalReceivingAddressProps) => { + const { t } = useTranslation(); + const { publicKey } = useAccount(); + const { data: listTokens = [] } = useGetSwapListTokens(); + + const { hits, query, onSearch } = useFuse(listTokens, { + keys: ['name'], + limit: 1000, + }); + + const tokens = query ? _.map(hits, 'item') : listTokens; + + return ( + + + + + + + + + {t('token_list')} + + + {tokens?.map((token) => ( + onSelect?.(token)} + /> + ))} + + + ); +}; + +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..13e41f0 --- /dev/null +++ b/src/modules/Swap/components/ModalTransactionSetting/RadioPercentSlippage.tsx @@ -0,0 +1,47 @@ +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%', + }, +]; + +type Props = { + onChange?: (value: number) => void; +}; + +const RadioPercentSlippage = ({ onChange }: Props) => { + return ( + { + onChange?.(parseFloat(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..3c39302 --- /dev/null +++ b/src/modules/Swap/components/ModalTransactionSetting/index.tsx @@ -0,0 +1,96 @@ +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.number().refine((val) => val >= 0, 'slippage_required'), + deadline: z.number().refine((val) => val >= 0, 'deadline_required'), +}); + +export type SubmitValues = z.infer; + +type ModalTransactionSettingProps = { + isOpen: boolean; + onClose: () => void; +}; + +const ModalTransactionSetting = ({ + isOpen, + onClose, +}: ModalTransactionSettingProps) => { + const { t } = useTranslation(); + const { toastSuccess } = useI18nToast(); + const { mutate, isLoading: isSubmitting } = useMutateSwapSettings({ + onSuccess: () => { + toastSuccess('transaction_settings_saved'); + onClose(); + }, + }); + const { handleSubmit, setValue, control } = useForm({ + resolver: zodResolver(validationSchema), + }); + useGetSwapSettings({ + onSuccess: (data) => { + setValue('slippage', data.slippage); + setValue('deadline', data.deadline); + }, + }); + + const handleOnSubmit = (data: SubmitValues) => { + mutate(data); + }; + + return ( + +
+ + + + {t('slippage_tolerance')} (%) + + + + + setValue('slippage', value)} + /> + + + + + {t('transaction_deadline')} ({t('minutes')}) + + + + + + +
+
+ ); +}; + +export default ModalTransactionSetting; 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..2a7b6ec --- /dev/null +++ b/src/modules/Swap/components/SelectToken/Price.tsx @@ -0,0 +1,21 @@ +import { Text } from '@chakra-ui/react'; +import { useTranslation } from 'react-i18next'; + +import { useGetAmountInUsd } from '@/modules/Swap/hooks/useGetAmountInUsd'; +import { Token } from '@/services/friendlyMarket/tokens'; + +type PriceProps = { + value?: Token; +}; + +const Price = ({ value }: PriceProps) => { + const { t } = useTranslation(); + const amountInUsd = useGetAmountInUsd({ token: value }); + 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 new file mode 100644 index 0000000..2630899 --- /dev/null +++ b/src/modules/Swap/components/SelectToken/index.tsx @@ -0,0 +1,78 @@ +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, onChangeAmount }: SelectTokenProps) => { + const { t } = useTranslation(); + + return ( + + + + {_.isEmpty(value) || !value.contractHash ? ( + {t('select_token')} + ) : ( + <> + + + + {value.symbol} + + )} + + + + + + { + onChangeAmount?.(e.target.value); + }} + value={value?.amount} + /> + + + + + + + + ); +}; + +export default SelectToken; diff --git a/src/modules/Swap/components/SwapForm/ButtonReverse.tsx b/src/modules/Swap/components/SwapForm/ButtonReverse.tsx new file mode 100644 index 0000000..7c742f1 --- /dev/null +++ b/src/modules/Swap/components/SwapForm/ButtonReverse.tsx @@ -0,0 +1,37 @@ +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/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/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/RadioPercentSelect.tsx b/src/modules/Swap/components/SwapForm/RadioPercentSelect.tsx new file mode 100644 index 0000000..a3e0ca7 --- /dev/null +++ b/src/modules/Swap/components/SwapForm/RadioPercentSelect.tsx @@ -0,0 +1,58 @@ +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 = [ + { + value: '25', + label: '25%', + }, + { + value: '50', + label: '50%', + }, + { + value: '75', + label: '75%', + }, + { + value: '100', + label: '100%', + }, +]; + +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 ( + + {PERCENTS.map((item) => { + return ( + + {item.label} + + ); + })} + + ); +}; + +export default RadioPercentSelect; diff --git a/src/modules/Swap/components/SwapForm/Receipt.tsx b/src/modules/Swap/components/SwapForm/Receipt.tsx new file mode 100644 index 0000000..6142408 --- /dev/null +++ b/src/modules/Swap/components/SwapForm/Receipt.tsx @@ -0,0 +1,130 @@ +import { ReactNode } from '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'; + +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'; +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 ( + + {label} + {value} + + ); +}; + +const Receipt = ({ isShowRoute, ...props }: ReceiptProps) => { + const { setValue } = useFormContext(); + 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); + }, + }); + const { data: pair } = useGetCurrentAMMPair({ + onSuccess: (data) => { + setValue('pair', data); + }, + }); + const amountOutMin = useCalculateAmountOutMin(); + + const fee = Big(swapFrom.amount || 0) + .times(0.3) + .div(100) + .round(swapFrom.decimals, 0) + .toNumber(); + const priceImpact = fromAmountInUsd + ? Big(100) + .minus( + Big(fromAmountInUsd || 0) + .div(toAmountInUsd || 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: string; value: ReactNode }[] = [ + { + 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}%`, + }, + ]; + const pairRoute = pair as PairRouteData; + + if (isShowRoute && pairRoute.isUsingRouting) { + items.push({ + label: t('route'), + value: , + }); + } + + return ( + + {items.map((item) => { + return ( + + ); + })} + + ); +}; + +export default Receipt; diff --git a/src/modules/Swap/components/SwapForm/RoutePaths.tsx b/src/modules/Swap/components/SwapForm/RoutePaths.tsx new file mode 100644 index 0000000..fb14171 --- /dev/null +++ b/src/modules/Swap/components/SwapForm/RoutePaths.tsx @@ -0,0 +1,56 @@ +import { ArrowForwardIcon } from '@chakra-ui/icons'; +import { Box, Flex, Image, Text } from '@chakra-ui/react'; + +import { useGetCurrentAMMPair } from '@/modules/Swap/hooks/useGetCurrentAMMPair'; +import { useGetSwapListTokens } from '@/modules/Swap/hooks/useGetSwapListTokens'; +import { PairData, PairRouteData } from '@/services/friendlyMarket/amm/type'; + +const RoutePaths = () => { + const { data: pair = { isUsingRouting: false } } = useGetCurrentAMMPair(); + const { data: tokens = [] } = useGetSwapListTokens(); + const pairRoute = pair as PairRouteData; + + 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 ( + + {paths.map((path, index) => { + const foundToken = tokens.find( + (token) => token.contractHash === path.replace('hash-', '') + ); + + return ( + <> + + + {foundToken?.name + + {foundToken?.symbol || ''} + + {index < paths.length - 1 && ( + + + + )} + + ); + })} + + ); +}; + +export default RoutePaths; diff --git a/src/modules/Swap/components/SwapForm/SelectSwapFrom.tsx b/src/modules/Swap/components/SwapForm/SelectSwapFrom.tsx new file mode 100644 index 0000000..eaa979a --- /dev/null +++ b/src/modules/Swap/components/SwapForm/SelectSwapFrom.tsx @@ -0,0 +1,54 @@ +import { useDisclosure } from '@chakra-ui/react'; +import Big from 'big.js'; +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 { setValue } = useFormContext(); + const valueWatched = useSelectToken('swapFrom'); + const setValueSwapFrom = useSetValueSwapFrom(); + + const handleOnClick = () => { + onOpen(); + }; + + const handleOnSelect = (token: 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 ( + <> + + + + ); +}; + +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..6c6bc08 --- /dev/null +++ b/src/modules/Swap/components/SwapForm/SelectSwapTo.tsx @@ -0,0 +1,57 @@ +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 useCalculateAmountIn from '@/modules/Swap/hooks/useCalculateAmountIn'; +import { Token } from '@/services/friendlyMarket/tokens'; + +const SelectSwapTo = () => { + const { isOpen, onClose, onOpen } = useDisclosure(); + const { control, setValue } = useFormContext(); + const valueWatched = useWatch({ + control, + name: 'swapTo', + }); + const setValueAmountIn = useCalculateAmountIn(); + + const handleOnClick = () => { + onOpen(); + }; + + const handleOnSelect = (token: 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 ( + <> + + + + ); +}; + +export default SelectSwapTo; diff --git a/src/modules/Swap/components/SwapForm/Setting.tsx b/src/modules/Swap/components/SwapForm/Setting.tsx new file mode 100644 index 0000000..9f12d8e --- /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 new file mode 100644 index 0000000..2f55f4a --- /dev/null +++ b/src/modules/Swap/components/SwapForm/index.tsx @@ -0,0 +1,78 @@ +import { Box, Divider, Flex, useDisclosure } from '@chakra-ui/react'; +import { FormProvider, useForm } from 'react-hook-form'; + +import ButtonReverse from './ButtonReverse'; +import { ButtonSwap } from './ButtonSwap'; +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 Paper from '@/components/Paper'; +import CircleWrapper from '@/components/Surface/CircleWrapper'; +import { RefreshIcon } from '@/icons'; +import UnlockWalletPopupRequired from '@/modules/core/UnlockWalletPopupRequired'; +import { FieldValues } from '@/modules/Swap/type'; + +const SwapForm = () => { + const { isOpen, onOpen, onClose } = useDisclosure(); + const methods = useForm({ + defaultValues: { + swapFrom: {}, + swapTo: {}, + swapSettings: { + slippage: 0, + deadline: 0, + }, + pair: {}, + }, + }); + + const handleOnSubmit = () => { + onOpen(); + }; + + return ( + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + +
+
+
+ ); +}; + +export default SwapForm; diff --git a/src/modules/Swap/hooks/useCalculateAmountIn.ts b/src/modules/Swap/hooks/useCalculateAmountIn.ts new file mode 100644 index 0000000..1a4da2a --- /dev/null +++ b/src/modules/Swap/hooks/useCalculateAmountIn.ts @@ -0,0 +1,83 @@ +import { useCallback } from 'react'; + +import Big from 'big.js'; +import { useFormContext } from 'react-hook-form'; + +import { useGetCurrentAMMPair } from './useGetCurrentAMMPair'; +import { useSelectToken } from './useSelectToken'; +import { + findReverseRouteIntToken1PairByContractHash, + findReverseRouteToken0IntPairByContractHash, + 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 swapTo = useSelectToken('swapTo'); + 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) { + setValue('swapFrom.amount', 0); + return; + } + const pairRouting = pair; + if (pairRouting.isUsingRouting) { + const [reserve0, reserve1] = + findReverseRouteIntToken1PairByContractHash( + swapTo.contractHash, + pairRouting + ); + const bridgeAmount = getAmountIn(reserve0, reserve1, value); + + const [reserve0IntPair, reserve1IntPair] = + findReverseRouteToken0IntPairByContractHash( + swapFrom.contractHash, + pairRouting + ); + + calculatePrice({ + value: bridgeAmount, + reverseIn: reserve0IntPair, + reverseOut: reserve1IntPair, + decimals: swapFrom.decimals, + }); + } else if (pair && swapFrom.contractHash) { + calculatePrice({ + value, + reverseIn: (pair).reserve0, + reverseOut: (pair).reserve1, + decimals: swapFrom.decimals, + }); + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [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..2443bc6 --- /dev/null +++ b/src/modules/Swap/hooks/useCalculateAmountOut.ts @@ -0,0 +1,83 @@ +import { useCallback } from 'react'; + +import Big from 'big.js'; +import { useFormContext } from 'react-hook-form'; + +import { useGetCurrentAMMPair } from './useGetCurrentAMMPair'; +import { useSelectToken } from './useSelectToken'; +import { + findReverseRouteIntToken1PairByContractHash, + findReverseRouteToken0IntPairByContractHash, + 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 swapFrom = useSelectToken('swapFrom'); + const swapTo = useSelectToken('swapTo'); + const calculatePrice = ({ + value, + reverseIn, + reverseOut, + decimals, + }: CalculatePriceParams) => { + const amount = Big(getAmountOut(reverseIn, reverseOut, value)) + .round(decimals, 0) + .toNumber(); + + setValue('swapTo.amount', amount); + }; + + const handleChangeAmount = useCallback( + (value: number) => { + if (!value) { + setValue('swapTo.amount', 0); + return; + } + const pairRouting = pair; + + if (pairRouting.isUsingRouting) { + const [reserve0, reserve1] = + findReverseRouteToken0IntPairByContractHash( + swapFrom.contractHash, + pairRouting + ); + const bridgeAmount = getAmountOut(reserve0, reserve1, value); + const [reserve0IntPair, reserve1IntPair] = + findReverseRouteIntToken1PairByContractHash( + swapTo.contractHash, + pairRouting + ); + + calculatePrice({ + value: bridgeAmount, + reverseIn: reserve0IntPair, + reverseOut: reserve1IntPair, + decimals: swapTo.decimals, + }); + } else if (pair && swapTo.contractHash) { + calculatePrice({ + value, + reverseIn: (pair).reserve0, + reverseOut: (pair).reserve1, + decimals: swapTo.decimals, + }); + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [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/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/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/modules/Swap/hooks/useGetSwapListTokens.ts b/src/modules/Swap/hooks/useGetSwapListTokens.ts new file mode 100644 index 0000000..f40342d --- /dev/null +++ b/src/modules/Swap/hooks/useGetSwapListTokens.ts @@ -0,0 +1,44 @@ +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 = { + wcspr: 'casper-network', + cspr: 'casper-network', + deth: 'ethereum', + dusdc: 'usd-coin', + dusdt: 'tether', + dai: 'dai', + frax: 'frax', +}; + +export const useGetSwapListTokens = (options = {}) => { + const { publicKey = '' } = useAccount(); + 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, + 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..9c9526e --- /dev/null +++ b/src/modules/Swap/hooks/useMutateSwapSettings.ts @@ -0,0 +1,38 @@ +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, + }) + ); + await queryClient.prefetchQuery([QueryKeysEnum.SWAP_SETTINGS]); + + 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..2023d8e --- /dev/null +++ b/src/modules/Swap/hooks/useMutateSwapTokens.ts @@ -0,0 +1,135 @@ +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 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 = {} +) => { + 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, + paymentAmount: MAP_PAYMENT_AMOUNT[functionType], + } + ); + + 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, + paymentAmount: MAP_PAYMENT_AMOUNT[functionType], + } + ); + + 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, + paymentAmount: MAP_PAYMENT_AMOUNT[functionType], + } + ); + + 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, + paymentAmount: MAP_PAYMENT_AMOUNT[functionType], + } + ); + + 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..bf84e23 --- /dev/null +++ b/src/modules/Swap/hooks/useSelectToken.ts @@ -0,0 +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: value = { price: 0, amount: 0 } } = useGetCoinMarketData( + valueWatched?.coingeckoId + ); + + const { data: { balance = 0 } = { balance: 0 } } = useGetSwapTokenBalance({ + ...valueWatched, + publicKey, + }); + + return { + ...valueWatched, + price: value.price, + balance, + }; +}; 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/hooks/useValidateSwap.ts b/src/modules/Swap/hooks/useValidateSwap.ts new file mode 100644 index 0000000..f481d54 --- /dev/null +++ b/src/modules/Swap/hooks/useValidateSwap.ts @@ -0,0 +1,63 @@ +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 & { balance: number } = useSelectToken('swapFrom'); + const swapTo: Token & { balance: number } = useSelectToken('swapTo'); + const { t } = useTranslation(); + + const validateSwap = useCallback(() => { + 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 (!swapTo.amount || swapTo.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/modules/Swap/index.tsx b/src/modules/Swap/index.tsx new file mode 100644 index 0000000..af6e7ac --- /dev/null +++ b/src/modules/Swap/index.tsx @@ -0,0 +1,22 @@ +import { Box, Flex, Heading, 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/modules/Swap/type.ts b/src/modules/Swap/type.ts new file mode 100644 index 0000000..b6851ee --- /dev/null +++ b/src/modules/Swap/type.ts @@ -0,0 +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/modules/Swap/utils.ts b/src/modules/Swap/utils.ts new file mode 100644 index 0000000..de91d93 --- /dev/null +++ b/src/modules/Swap/utils.ts @@ -0,0 +1,82 @@ +import Big from 'big.js'; + +import { PairRouteData } from '@/services/friendlyMarket/amm/type'; + +export const getAmountIn = ( + reserveIn: number | string, + reserveOut: number | string, + amountOut: number +) => { + 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); + + return numerator.div(Big(denominator).add(1)).toNumber(); +}; + +export const getAmountOut = ( + reserveIn: number | string, + reserveOut: number | string, + amountIn: number +) => { + 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); + + 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(); +}; + +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]; +}; 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/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..be9bded 100644 --- a/src/public/locales/en/common.json +++ b/src/public/locales/en/common.json @@ -116,5 +116,30 @@ "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.", + "search_token": "Search Token", + "token_list": "Token List", + "transaction_settings": "Transaction Settings", + "slippage_tolerance": "Slippage Tolerance", + "transaction_deadline": "Transaction Deadline", + "minutes": "Minutes", + "save": "Save", + "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", + "route": "Route", + "swap_confirmation": "Swap Confirmation", + "contract_hash": "Contract Hash", + "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." } 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/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: , + }, ]); 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 new file mode 100644 index 0000000..154b172 --- /dev/null +++ b/src/services/friendlyMarket/amm/index.ts @@ -0,0 +1,13 @@ +import { GetPairParams, PairData, PairRouteData } from './type'; +import request from '../request'; + +export const getPair = async ({ + fromContractHash, + toContractHash, +}: GetPairParams): Promise => { + const { data } = await request.get( + `/amm/pair/${fromContractHash}/${toContractHash}` + ); + + return data; +}; diff --git a/src/services/friendlyMarket/amm/type.ts b/src/services/friendlyMarket/amm/type.ts new file mode 100644 index 0000000..ea3ff31 --- /dev/null +++ b/src/services/friendlyMarket/amm/type.ts @@ -0,0 +1,62 @@ +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 new file mode 100644 index 0000000..5fe76fb --- /dev/null +++ b/src/services/friendlyMarket/balance/index.ts @@ -0,0 +1,27 @@ +import { + GetBalanceParams, + GetBalanceResponse, + getErc20BalanceParams, +} from './type'; +import request from '../request'; + +const DEFAULT_NETWORK = 'casper'; + +export const getBalance = async ({ + publicKey, + network = DEFAULT_NETWORK, +}: GetBalanceParams): Promise => { + return request.get(`/misc/balance/${publicKey}?network=${network}`); +}; + +export const getErc20Balance = async ({ + publicKey, + contractHash, + network = DEFAULT_NETWORK, +}: 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 new file mode 100644 index 0000000..08d24b5 --- /dev/null +++ b/src/services/friendlyMarket/balance/type.ts @@ -0,0 +1,15 @@ +export type GetBalanceParams = { + publicKey: string; + network?: string; +}; + +export type getErc20BalanceParams = { + publicKey: string; + 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 new file mode 100644 index 0000000..7ab47ac --- /dev/null +++ b/src/services/friendlyMarket/request.ts @@ -0,0 +1,27 @@ +import axios from 'axios'; + +import { Config } from '@/config'; + +const request = axios.create({ + baseURL: Config.friendlyMarketUrl, + 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/tokens/index.ts b/src/services/friendlyMarket/tokens/index.ts new file mode 100644 index 0000000..29941e6 --- /dev/null +++ b/src/services/friendlyMarket/tokens/index.ts @@ -0,0 +1,14 @@ +import axios from 'axios'; + +import { ListTokenResponse } from './type'; +import { Config } from '@/config'; + +export const getListTokens = async () => { + const { data = { tokens: [] } } = await axios.get( + Config.tokenListUrl + ); + + return data.tokens; +}; + +export * from './type'; diff --git a/src/services/friendlyMarket/tokens/type.ts b/src/services/friendlyMarket/tokens/type.ts new file mode 100644 index 0000000..a721a3f --- /dev/null +++ b/src/services/friendlyMarket/tokens/type.ts @@ -0,0 +1,20 @@ +import { TokenTypesEnum } from '@/enums/tokenTypes'; + +export type ListTokenResponse = { + tokens: Token[]; +}; + +export type Token = { + chainId: number; + type: TokenTypesEnum; + contractPackageHash: string; + contractHash: string; + name: string; + symbol: string; + decimals: number; + logoURI: string; + coingeckoId?: string; + amount?: number; + amountInUSD?: number; + balance?: number; +}; diff --git a/src/theme/components/radioButtons.ts b/src/theme/components/radioButtons.ts index da92d85..fc2b9c3 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,34 @@ 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: { + border: 'none', + color: 'gray.500', + 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/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', }; diff --git a/src/theme/foundations/space.ts b/src/theme/foundations/space.ts index 8edf42b..48c5ffd 100644 --- a/src/theme/foundations/space.ts +++ b/src/theme/foundations/space.ts @@ -4,22 +4,25 @@ 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 + 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..afcbc2b --- /dev/null +++ b/src/utils/casper/tokenServices.ts @@ -0,0 +1,295 @@ +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; + paymentAmount: number; +}; + +type BuildSwapTokensForExactTokensDeployParams = { + fromPublicKey: string; + toPublicKey: string; + amountOut: number; + amountInMax: number; + path: string[]; + deadline: number; + paymentAmount: number; +}; + +type BuildSwapExactTokensForTokensDeployParams = { + fromPublicKey: string; + toPublicKey: string; + amountIn: number; + amountOutMin: number; + path: string[]; + deadline: number; + paymentAmount: number; +}; + +type BuildSwapExactTokensForTokensDeploy = { + fromPublicKey: string; + toPublicKey: string; + amountIn: number; + amountOutMin: number; + path: string[]; + deadline: number; + paymentAmount: 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 +) => { + 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(transactionDetail.paymentAmount) + ); +}; + +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(transactionDetail.paymentAmount) + ); +}; + +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(transactionDetail.paymentAmount) + ); +}; + +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(transactionDetail.paymentAmount) + ); +}; + +/** + * @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(); +}; 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: