From 525e668a3fa8947b6e63681ec1423c8ecbd688bf Mon Sep 17 00:00:00 2001 From: iower Date: Tue, 26 Sep 2023 18:52:21 +0500 Subject: [PATCH 01/44] 'Swap' button --- src/ui/views/Dashboard/index.tsx | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/ui/views/Dashboard/index.tsx b/src/ui/views/Dashboard/index.tsx index db9ba9e6449..791b1b0ca84 100644 --- a/src/ui/views/Dashboard/index.tsx +++ b/src/ui/views/Dashboard/index.tsx @@ -371,6 +371,10 @@ const Dashboard = () => { history.push('/via-quests'); }; + const onClickSwap = () => { + history.push('/dex-swap'); + }; + const brandIcon = useWalletConnectIcon(currentAccount); const { t } = useTranslation(); @@ -665,18 +669,23 @@ const Dashboard = () => { )} -
+
+ {/* Date: Tue, 26 Sep 2023 22:35:54 +0500 Subject: [PATCH 02/44] Swap -> SwapNew (duplicate components) --- src/ui/views/Dashboard/index.tsx | 2 +- src/ui/views/MainRoute.tsx | 5 + src/ui/views/SwapNew/Component/Header.tsx | 54 ++ src/ui/views/SwapNew/Component/History.tsx | 285 +++++++ .../views/SwapNew/Component/IconRefresh.tsx | 95 +++ .../SwapNew/Component/InsufficientTip.tsx | 20 + src/ui/views/SwapNew/Component/Main.tsx | 546 ++++++++++++++ src/ui/views/SwapNew/Component/QuoteItem.tsx | 707 ++++++++++++++++++ src/ui/views/SwapNew/Component/QuoteLogo.tsx | 40 + src/ui/views/SwapNew/Component/Quotes.tsx | 282 +++++++ .../views/SwapNew/Component/ReceiveDetail.tsx | 383 ++++++++++ src/ui/views/SwapNew/Component/Slippage.tsx | 226 ++++++ .../views/SwapNew/Component/TokenRender.tsx | 85 +++ .../SwapNew/Component/TradingSettings.tsx | 165 ++++ src/ui/views/SwapNew/Component/loading.tsx | 80 ++ src/ui/views/SwapNew/hooks/context.tsx | 23 + src/ui/views/SwapNew/hooks/history.tsx | 104 +++ src/ui/views/SwapNew/hooks/index.tsx | 7 + src/ui/views/SwapNew/hooks/quote.tsx | 642 ++++++++++++++++ src/ui/views/SwapNew/hooks/settings.tsx | 21 + src/ui/views/SwapNew/hooks/swapReport.tsx | 15 + src/ui/views/SwapNew/hooks/token.tsx | 478 ++++++++++++ src/ui/views/SwapNew/hooks/verify.tsx | 140 ++++ src/ui/views/SwapNew/index.tsx | 24 + 24 files changed, 4428 insertions(+), 1 deletion(-) create mode 100644 src/ui/views/SwapNew/Component/Header.tsx create mode 100644 src/ui/views/SwapNew/Component/History.tsx create mode 100644 src/ui/views/SwapNew/Component/IconRefresh.tsx create mode 100644 src/ui/views/SwapNew/Component/InsufficientTip.tsx create mode 100644 src/ui/views/SwapNew/Component/Main.tsx create mode 100644 src/ui/views/SwapNew/Component/QuoteItem.tsx create mode 100644 src/ui/views/SwapNew/Component/QuoteLogo.tsx create mode 100644 src/ui/views/SwapNew/Component/Quotes.tsx create mode 100644 src/ui/views/SwapNew/Component/ReceiveDetail.tsx create mode 100644 src/ui/views/SwapNew/Component/Slippage.tsx create mode 100644 src/ui/views/SwapNew/Component/TokenRender.tsx create mode 100644 src/ui/views/SwapNew/Component/TradingSettings.tsx create mode 100644 src/ui/views/SwapNew/Component/loading.tsx create mode 100644 src/ui/views/SwapNew/hooks/context.tsx create mode 100644 src/ui/views/SwapNew/hooks/history.tsx create mode 100644 src/ui/views/SwapNew/hooks/index.tsx create mode 100644 src/ui/views/SwapNew/hooks/quote.tsx create mode 100644 src/ui/views/SwapNew/hooks/settings.tsx create mode 100644 src/ui/views/SwapNew/hooks/swapReport.tsx create mode 100644 src/ui/views/SwapNew/hooks/token.tsx create mode 100644 src/ui/views/SwapNew/hooks/verify.tsx create mode 100644 src/ui/views/SwapNew/index.tsx diff --git a/src/ui/views/Dashboard/index.tsx b/src/ui/views/Dashboard/index.tsx index 791b1b0ca84..c4bce1b0bea 100644 --- a/src/ui/views/Dashboard/index.tsx +++ b/src/ui/views/Dashboard/index.tsx @@ -372,7 +372,7 @@ const Dashboard = () => { }; const onClickSwap = () => { - history.push('/dex-swap'); + history.push('/dex-swap-new'); }; const brandIcon = useWalletConnectIcon(currentAccount); diff --git a/src/ui/views/MainRoute.tsx b/src/ui/views/MainRoute.tsx index ef12dedb709..c42072a3413 100644 --- a/src/ui/views/MainRoute.tsx +++ b/src/ui/views/MainRoute.tsx @@ -47,6 +47,7 @@ import AddressDetail from './AddressDetail'; import AddressBackupMnemonics from './AddressBackup/Mnemonics'; import AddressBackupPrivateKey from './AddressBackup/PrivateKey'; import Swap from './Swap'; +import SwapNew from './SwapNew'; import { getUiType, useWallet } from '../utils'; import GasTopUp from './GasTopUp'; import ApprovalManage from './ApprovalManage'; @@ -280,6 +281,10 @@ const Main = () => { + + + + diff --git a/src/ui/views/SwapNew/Component/Header.tsx b/src/ui/views/SwapNew/Component/Header.tsx new file mode 100644 index 00000000000..483549b0819 --- /dev/null +++ b/src/ui/views/SwapNew/Component/Header.tsx @@ -0,0 +1,54 @@ +import { ReactComponent as IconSwapSettings } from '@/ui/assets/swap/settings.svg'; +import { ReactComponent as IconSwapHistory } from '@/ui/assets/swap/history.svg'; + +import { PageHeader } from '@/ui/component'; +import React, { useCallback, useState } from 'react'; +import { TradingSettings } from './TradingSettings'; +import { useSetSettingVisible, useSettingVisible } from '../hooks'; +import { SwapTxHistory } from './History'; + +export const Header = () => { + const visible = useSettingVisible(); + const setVisible = useSetSettingVisible(); + + const [historyVisible, setHistoryVisible] = useState(false); + + return ( + <> + + { + setHistoryVisible(true); + }, [])} + /> + { + setVisible(true); + }, [])} + /> + + } + > + From Chain + + { + setVisible(false); + }, [])} + /> + { + setHistoryVisible(false); + }, [])} + /> + + ); +}; diff --git a/src/ui/views/SwapNew/Component/History.tsx b/src/ui/views/SwapNew/Component/History.tsx new file mode 100644 index 00000000000..76022d34370 --- /dev/null +++ b/src/ui/views/SwapNew/Component/History.tsx @@ -0,0 +1,285 @@ +import { Popup, TokenWithChain } from '@/ui/component'; +import React, { forwardRef, useMemo } from 'react'; +import { useSwapHistory } from '../hooks'; +import { SwapItem, TokenItem } from '@rabby-wallet/rabby-api/dist/types'; +import { CHAINS_LIST } from '@debank/common'; +import { formatAmount, formatUsdValue, openInTab, sinceTime } from '@/ui/utils'; +import { getTokenSymbol } from '@/ui/utils/token'; +import { TooltipWithMagnetArrow } from '@/ui/component/Tooltip/TooltipWithMagnetArrow'; +import ImgPending from 'ui/assets/swap/pending.svg'; +import ImgCompleted from 'ui/assets/swap/completed.svg'; +import ImgEmpty from 'ui/assets/swap/empty.svg'; + +import { ReactComponent as RcIconSwapArrow } from 'ui/assets/swap/arrow-right.svg'; + +import clsx from 'clsx'; +import SkeletonInput from 'antd/lib/skeleton/Input'; +import { ellipsis } from '@/ui/utils/address'; +import BigNumber from 'bignumber.js'; +import { useTranslation } from 'react-i18next'; + +const TokenCost = ({ + payToken, + receiveToken, + payTokenAmount, + receiveTokenAmount, + loading = false, + actual = false, +}: { + payToken: TokenItem; + receiveToken: TokenItem; + payTokenAmount?: number; + receiveTokenAmount?: number; + loading?: boolean; + actual?: boolean; +}) => { + if (loading) { + return ( + + ); + } + return ( +
+ +
+ {formatAmount(payTokenAmount || '0')} {getTokenSymbol(payToken)} +
+ + +
+ {formatAmount(receiveTokenAmount || '0')} {getTokenSymbol(receiveToken)} +
+
+ ); +}; + +interface TransactionProps { + data: SwapItem; +} +const Transaction = forwardRef( + ({ data }, ref) => { + const isPending = data.status === 'Pending'; + const isCompleted = data?.status === 'Completed'; + const time = data?.finished_at || data?.create_at; + const targetDex = data?.dex_id; + const txId = data?.tx_id; + const chainItem = useMemo( + () => CHAINS_LIST.find((e) => e.serverId === data?.chain), + [data?.chain] + ); + const chainName = chainItem?.name || ''; + const scanLink = useMemo(() => chainItem?.scanLink.replace('_s_', ''), [ + chainItem?.scanLink, + ]); + const loading = data?.status !== 'Finished'; + + const gasUsed = useMemo(() => { + if (data?.gas) { + return `${formatAmount(data.gas.native_gas_fee)} ${getTokenSymbol( + data?.gas.native_token + )} (${formatUsdValue(data.gas.usd_gas_fee)})`; + } + return ''; + }, [data?.gas]); + + const gotoScan = React.useCallback(() => { + if (scanLink && txId) { + openInTab(scanLink + txId); + } + }, []); + + const slippagePercent = useMemo( + () => new BigNumber(data.quote.slippage).times(100).toString(10) + '%', + [data?.quote?.slippage] + ); + const actualSlippagePercent = useMemo( + () => new BigNumber(data?.actual?.slippage).times(100).toString(10) + '%', + [data?.quote?.slippage] + ); + + const { t } = useTranslation(); + + return ( +
+
+
+ {isPending && ( + +
+ loading + {t('page.swap.Pending')} +
+
+ )} + {isCompleted && ( + +
+ + {t('page.swap.Completed')} +
+
+ )} + {!isPending && sinceTime(time)} +
+ {!!targetDex && ( + + {targetDex} + + )} +
+ +
+ {t('page.swap.estimate')} +
+ +
+
+ +
+ {t('page.swap.actual')} +
+ +
+
+ +
+
+ {t('page.swap.slippage_tolerance')} {slippagePercent} +
+
+ {t('page.swap.actual-slippage')} + {loading ? ( + + ) : ( + {actualSlippagePercent} + )} +
+
+ +
+ + {chainName}:{' '} + + {ellipsis(txId)} + + + + {!loading ? ( + + {t('page.swap.gas-fee', { gasUsed })} + + ) : ( + + {t('page.swap.gas-x-price', { + price: data?.gas?.gas_price || '', + })} + + )} +
+
+ ); + } +); + +const HistoryList = () => { + const { txList, loading, loadingMore, ref } = useSwapHistory(); + const { t } = useTranslation(); + if (!loading && (!txList || !txList?.list?.length)) { + return ( +
+ +

+ {t('page.swap.no-transaction-records')} +

+
+ ); + } + + console.log('txList?.list', txList?.list); + + return ( +
+ {txList?.list?.map((swap, idx) => ( + + ))} + {((loading && !txList) || loadingMore) && ( + <> + + + + )} +
+ ); +}; + +export const SwapTxHistory = ({ + visible, + onClose, +}: { + visible: boolean; + onClose: () => void; +}) => { + const { t } = useTranslation(); + return ( + + + + ); +}; diff --git a/src/ui/views/SwapNew/Component/IconRefresh.tsx b/src/ui/views/SwapNew/Component/IconRefresh.tsx new file mode 100644 index 00000000000..e87a5de8acd --- /dev/null +++ b/src/ui/views/SwapNew/Component/IconRefresh.tsx @@ -0,0 +1,95 @@ +import clsx from 'clsx'; +import React, { memo } from 'react'; + +export const IconRefresh = memo((props: React.SVGProps) => { + const { className, ...other } = props; + + return ( + + + + + + + + + + + + + + + + + + + ); +}); diff --git a/src/ui/views/SwapNew/Component/InsufficientTip.tsx b/src/ui/views/SwapNew/Component/InsufficientTip.tsx new file mode 100644 index 00000000000..d73122f2e05 --- /dev/null +++ b/src/ui/views/SwapNew/Component/InsufficientTip.tsx @@ -0,0 +1,20 @@ +import ImgWarning from 'ui/assets/swap/warn.svg'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +export const InSufficientTip = ({ + inSufficient, +}: { + inSufficient: boolean; +}) => { + const { t } = useTranslation(); + if (!inSufficient) return null; + return ( +
+ + + {t('page.swap.InSufficientTip')} + +
+ ); +}; diff --git a/src/ui/views/SwapNew/Component/Main.tsx b/src/ui/views/SwapNew/Component/Main.tsx new file mode 100644 index 00000000000..b494d9037b3 --- /dev/null +++ b/src/ui/views/SwapNew/Component/Main.tsx @@ -0,0 +1,546 @@ +import React, { useCallback, useLayoutEffect, useMemo, useRef } from 'react'; +import { useRabbySelector } from '@/ui/store'; +import { CHAINS, CHAINS_ENUM } from '@debank/common'; +import TokenSelect from '@/ui/component/TokenSelect'; +import { ReactComponent as IconSwapArrow } from '@/ui/assets/swap/swap-arrow.svg'; +import { TokenRender } from './TokenRender'; +import { useTokenPair } from '../hooks/token'; +import { Alert, Button, Input, Modal, Switch } from 'antd'; +import BigNumber from 'bignumber.js'; +import { formatAmount, formatUsdValue, useWallet } from '@/ui/utils'; +import styled from 'styled-components'; +import clsx from 'clsx'; +import { QuoteList } from './Quotes'; +import { useQuoteVisible, useSetQuoteVisible } from '../hooks'; +import { InfoCircleFilled } from '@ant-design/icons'; +import { ReceiveDetails } from './ReceiveDetail'; +import { Slippage } from './Slippage'; +import { DEX_ENUM, DEX_SPENDER_WHITELIST } from '@rabby-wallet/rabby-swap'; +import { useDispatch } from 'react-redux'; +import { useRbiSource } from '@/ui/utils/ga-event'; +import { useCss } from 'react-use'; +import { DEX, SWAP_SUPPORT_CHAINS } from '@/constant'; +import { getTokenSymbol } from '@/ui/utils/token'; +import ChainSelectorInForm from '@/ui/component/ChainSelector/InForm'; +import { findChainByServerID } from '@/utils/chain'; +import type { SelectChainItemProps } from '@/ui/component/ChainSelector/components/SelectChainItem'; +import i18n from '@/i18n'; +import { Trans, useTranslation } from 'react-i18next'; + +const tipsClassName = clsx('text-gray-subTitle text-12 mb-4 pt-10'); + +const StyledInput = styled(Input)` + background: #f5f6fa; + border-radius: 6px; + height: 46px; + font-weight: 500; + font-size: 18px; + color: #ffffff; + box-shadow: none; + & > .ant-input { + background: #f5f6fa; + font-weight: 500; + font-size: 18px; + } + + &.ant-input-affix-wrapper, + &:focus, + &:active { + border: 1px solid transparent; + } + &:hover { + border: 1px solid rgba(255, 255, 255, 0.8); + box-shadow: none; + } + + &:placeholder-shown { + color: #707280; + } + &::-webkit-inner-spin-button, + &::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; + } +`; + +const getDisabledTips: SelectChainItemProps['disabledTips'] = (ctx) => { + const chainItem = findChainByServerID(ctx.chain.serverId); + + if (chainItem?.isTestnet) return i18n.t('page.swap.testnet-is-not-supported'); + + return i18n.t('page.swap.not-supported'); +}; + +export const Main = () => { + const { userAddress, unlimitedAllowance } = useRabbySelector((state) => ({ + userAddress: state.account.currentAccount?.address || '', + unlimitedAllowance: state.swap.unlimitedAllowance || false, + })); + + const dispatch = useDispatch(); + + const setUnlimited = useCallback( + (bool: boolean) => { + dispatch.swap.setUnlimitedAllowance(bool); + }, + [dispatch.swap.setUnlimitedAllowance] + ); + + const { + chain, + switchChain, + + payToken, + setPayToken, + receiveToken, + setReceiveToken, + exchangeToken, + + handleAmountChange, + handleBalance, + payAmount, + payTokenIsNativeToken, + isWrapToken, + inSufficient, + slippageChanged, + setSlippageChanged, + slippageState, + slippage, + setSlippage, + + feeRate, + + quoteLoading, + quoteList, + + currentProvider: activeProvider, + setActiveProvider, + slippageValidInfo, + expired, + } = useTokenPair(userAddress); + + const inputRef = useRef(); + + useLayoutEffect(() => { + if ((payToken?.id, receiveToken?.id)) { + inputRef.current?.focus(); + } + }, [payToken?.id, receiveToken?.id]); + + const miniReceivedAmount = useMemo(() => { + if (activeProvider?.quote?.toTokenAmount) { + const receivedTokeAmountBn = new BigNumber( + activeProvider?.quote?.toTokenAmount + ).div( + 10 ** + (activeProvider?.quote?.toTokenDecimals || + receiveToken?.decimals || + 1) + ); + return formatAmount( + receivedTokeAmountBn + .minus(receivedTokeAmountBn.times(slippage).div(100)) + .toString(10) + ); + } + return ''; + }, [ + activeProvider?.quote?.toTokenAmount, + activeProvider?.quote?.toTokenDecimals, + receiveToken?.decimals, + slippage, + ]); + + const DexDisplayName = useMemo( + () => DEX?.[activeProvider?.name as keyof typeof DEX]?.name || '', + [activeProvider?.name] + ); + + const visible = useQuoteVisible(); + const setVisible = useSetQuoteVisible(); + const { t } = useTranslation(); + + const btnText = useMemo(() => { + if (slippageChanged) { + return t('page.swap.slippage-adjusted-refresh-quote'); + } + if (activeProvider && expired) { + return t('page.swap.price-expired-refresh-quote'); + } + if (activeProvider?.shouldApproveToken) { + return t('page.swap.approve-x-symbol', { + symbol: getTokenSymbol(payToken), + }); + } + if (activeProvider?.name) { + return t('page.swap.swap-via-x', { + name: isWrapToken ? 'Wrap Contract' : DexDisplayName, + }); + } + + return t('page.swap.get-quotes'); + }, [ + slippageChanged, + activeProvider, + expired, + payToken, + isWrapToken, + DexDisplayName, + ]); + + const wallet = useWallet(); + const rbiSource = useRbiSource(); + + const gotoSwap = useCallback(async () => { + if (!inSufficient && payToken && receiveToken && activeProvider?.quote) { + try { + wallet.dexSwap( + { + chain, + quote: activeProvider?.quote, + needApprove: activeProvider.shouldApproveToken, + spender: + activeProvider?.name === DEX_ENUM.WRAPTOKEN + ? '' + : DEX_SPENDER_WHITELIST[activeProvider.name][chain], + pay_token_id: payToken.id, + unlimited: unlimitedAllowance, + shouldTwoStepApprove: activeProvider.shouldTwoStepApprove, + postSwapParams: { + quote: { + pay_token_id: payToken.id, + pay_token_amount: Number(payAmount), + receive_token_id: receiveToken!.id, + receive_token_amount: new BigNumber( + activeProvider?.quote.toTokenAmount + ) + .div( + 10 ** + (activeProvider?.quote.toTokenDecimals || + receiveToken.decimals) + ) + .toNumber(), + slippage: new BigNumber(slippage).div(100).toNumber(), + }, + dex_id: activeProvider?.name.replace('API', ''), + }, + }, + { + ga: { + category: 'Swap', + source: 'swap', + trigger: rbiSource, + }, + } + ); + window.close(); + } catch (error) { + console.error(error); + } + } + }, [ + inSufficient, + payToken, + unlimitedAllowance, + activeProvider?.quote, + wallet?.dexSwap, + activeProvider?.shouldApproveToken, + activeProvider?.name, + activeProvider?.shouldTwoStepApprove, + ]); + + const twoStepApproveCn = useCss({ + '& .ant-modal-content': { + background: '#fff', + }, + '& .ant-modal-body': { + padding: '12px 8px 32px 16px', + }, + '& .ant-modal-confirm-content': { + padding: '4px 0 0 0', + }, + '& .ant-modal-confirm-btns': { + justifyContent: 'center', + '.ant-btn-primary': { + width: '260px', + height: '40px', + }, + 'button:first-child': { + display: 'none', + }, + }, + }); + + return ( +
+
+
{t('page.swap.chain')}
+ + +
+ {t('page.swap.swap-from')} + {t('page.swap.to')} +
+ +
+ { + const chainItem = findChainByServerID(token.chain); + if (chainItem?.enum !== chain) { + switchChain(chainItem?.enum || CHAINS_ENUM.ETH); + setReceiveToken(undefined); + } + setPayToken(token); + }} + chainId={CHAINS[chain].serverId} + type={'swapFrom'} + placeholder={t('page.swap.search-by-name-address')} + excludeTokens={receiveToken?.id ? [receiveToken?.id] : undefined} + tokenRender={(p) => } + /> + + { + const chainItem = findChainByServerID(token.chain); + if (chainItem?.enum !== chain) { + switchChain(chainItem?.enum || CHAINS_ENUM.ETH); + setPayToken(undefined); + } + setReceiveToken(token); + }} + chainId={CHAINS[chain].serverId} + type={'swapTo'} + placeholder={t('page.swap.search-by-name-address')} + excludeTokens={payToken?.id ? [payToken?.id] : undefined} + tokenRender={(p) => } + useSwapTokenList + /> +
+ +
+
+ {t('page.swap.amount-in', { + symbol: payToken ? getTokenSymbol(payToken) : '', + })}{' '} +
+
{ + if (!payTokenIsNativeToken) { + handleBalance(); + } + }} + > + {t('global.Balance')}: {formatAmount(payToken?.amount || 0)} +
+
+ + {payAmount + ? `≈ ${formatUsdValue( + new BigNumber(payAmount) + .times(payToken?.price || 0) + .toString(10) + )}` + : ''} + + } + /> + + {payAmount && + activeProvider && + activeProvider?.quote?.toTokenAmount && + payToken && + receiveToken && ( + <> + + {isWrapToken ? ( +
+ {t('page.swap.there-is-no-fee-and-slippage-for-this-trade')} +
+ ) : ( +
+
+ { + setSlippageChanged(true); + setSlippage(e); + }} + recommendValue={ + slippageValidInfo?.is_valid + ? undefined + : slippageValidInfo?.suggest_slippage + } + /> +
+ {t('page.swap.minimum-received')} + + {miniReceivedAmount}{' '} + {receiveToken ? getTokenSymbol(receiveToken) : ''} + +
+
+ {t('page.swap.rabby-fee')} + 0% +
+
+
+ )} + + )} +
+ + {inSufficient ? ( + + } + banner + message={ + + {t('page.swap.insufficient-balance')} + + } + /> + ) : null} + +
+ {!expired && activeProvider && activeProvider.shouldApproveToken && ( +
+
{t('page.swap.approve-tips')}
+
+ {t('page.swap.unlimited-allowance')}{' '} + +
+
+ )} + +
+ {payToken && receiveToken && chain ? ( + { + setVisible(false); + }} + userAddress={userAddress} + chain={chain} + slippage={slippage} + payToken={payToken} + payAmount={payAmount} + receiveToken={receiveToken} + fee={feeRate} + inSufficient={inSufficient} + setActiveProvider={setActiveProvider} + /> + ) : null} +
+ ); +}; diff --git a/src/ui/views/SwapNew/Component/QuoteItem.tsx b/src/ui/views/SwapNew/Component/QuoteItem.tsx new file mode 100644 index 00000000000..6294be204aa --- /dev/null +++ b/src/ui/views/SwapNew/Component/QuoteItem.tsx @@ -0,0 +1,707 @@ +import { CEX } from '@/constant'; +import { formatAmount, formatUsdValue } from '@/ui/utils'; +import { CHAINS_ENUM } from '@debank/common'; +import { TokenItem, CEXQuote } from '@rabby-wallet/rabby-api/dist/types'; +import { DEX_ENUM } from '@rabby-wallet/rabby-swap'; +import { QuoteResult } from '@rabby-wallet/rabby-swap/dist/quote'; +import { Tooltip } from 'antd'; +import clsx from 'clsx'; +import React, { useMemo, useCallback, useState } from 'react'; +import { useCss, useDebounce } from 'react-use'; +import styled from 'styled-components'; +import { QuoteLogo } from './QuoteLogo'; +import BigNumber from 'bignumber.js'; +import ImgLock from '@/ui/assets/swap/lock.svg'; +import ImgGas from '@/ui/assets/swap/gas.svg'; +import ImgWarning from '@/ui/assets/swap/warn.svg'; +import ImgVerified from '@/ui/assets/swap/verified.svg'; +import ImgWhiteWarning from '@/ui/assets/swap/warning-white.svg'; + +import { + QuotePreExecResultInfo, + QuoteProvider, + isSwapWrapToken, +} from '../hooks/quote'; +import { + useSetQuoteVisible, + useSetSettingVisible, + useVerifySdk, +} from '../hooks'; +import { useRabbySelector } from '@/ui/store'; +import { getTokenSymbol } from '@/ui/utils/token'; +import { TooltipWithMagnetArrow } from '@/ui/component/Tooltip/TooltipWithMagnetArrow'; +import { useTranslation } from 'react-i18next'; +import i18n from '@/i18n'; + +const ItemWrapper = styled.div` + position: relative; + height: 60px; + font-size: 12px; + padding: 0 12px; + display: flex; + align-items: center; + color: #13141a; + + border-radius: 6px; + box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.08); + border-radius: 6px; + border: 1px solid transparent; + background: white; + cursor: pointer; + + .disabled-trade { + position: absolute; + left: 0; + top: 0; + transform: translateY(-20px); + opacity: 0; + width: 100%; + height: 0; + padding-left: 16px; + background: #000000; + border-radius: 6px; + display: flex; + align-items: center; + font-size: 12px; + gap: 8px; + font-weight: 400; + font-size: 12px; + color: #ffffff; + pointer-events: none; + &.active { + pointer-events: auto; + height: 100%; + transform: translateY(0); + opacity: 1; + /* transition: opacity 0.35s, transform 0.35s; */ + } + } + + &:hover:not(.disabled, .inSufficient) { + background: linear-gradient( + 0deg, + rgba(134, 151, 255, 0.1), + rgba(134, 151, 255, 0.1) + ), + #ffffff; + border: 1px solid #8697ff; + } + &.active { + outline: 2px solid #8697ff; + } + &.disabled { + height: 56px; + border-color: transparent; + box-shadow: none; + background-color: transparent; + border-radius: 6px; + cursor: not-allowed; + } + &.error { + } + &:not(.cex).inSufficient, + &:not(.cex).disabled { + height: 60px; + border: 1px solid #e5e9ef; + border-radius: 6px; + box-shadow: none; + } + + &.cex { + font-weight: 500; + font-size: 13px; + line-height: 15px; + color: #13141a; + height: 48px; + background-color: transparent; + border: none; + outline: none; + } + + .price { + flex: 1; + display: flex; + align-items: center; + gap: 6px; + color: #707280; + .receiveNum { + font-size: 15px; + max-width: 130px; + display: inline-block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 500; + color: #707280; + .toToken { + color: #13141a; + } + } + } + .no-price { + color: #13141a; + } + + .percent { + font-weight: 500; + font-size: 13px; + font-weight: 500; + color: #27c193; + &.red { + color: #ec5151; + } + } + + .diff { + margin-left: auto; + } +`; + +export interface QuoteItemProps { + quote: QuoteResult | null; + name: string; + loading?: boolean; + payToken: TokenItem; + receiveToken: TokenItem; + payAmount: string; + chain: CHAINS_ENUM; + bestAmount: string; + isBestQuote: boolean; + active: boolean; + userAddress: string; + slippage: string; + fee: string; + isLoading?: boolean; + quoteProviderInfo: { name: string; logo: string }; + inSufficient: boolean; + setActiveProvider: React.Dispatch< + React.SetStateAction + >; +} + +export const DexQuoteItem = ( + props: QuoteItemProps & { + preExecResult: QuotePreExecResultInfo; + } +) => { + const { + isLoading, + quote, + name: dexId, + loading, + bestAmount, + payToken, + receiveToken, + payAmount, + chain, + active, + userAddress, + isBestQuote, + slippage, + fee, + inSufficient, + preExecResult, + quoteProviderInfo, + setActiveProvider: updateActiveQuoteProvider, + } = props; + + const { t } = useTranslation(); + + const openSwapSettings = useSetSettingVisible(); + const openSwapQuote = useSetQuoteVisible(); + + const tradeList = useRabbySelector((s) => s.swap.tradeList); + const disabledTrade = useMemo( + () => + !tradeList?.[dexId] && + !isSwapWrapToken(payToken.id, receiveToken.id, chain), + [tradeList, dexId, payToken.id, receiveToken.id, chain] + ); + + const { isSdkDataPass } = useVerifySdk({ + chain, + dexId: dexId as DEX_ENUM, + slippage, + data: { + ...quote, + fromToken: payToken.id, + fromTokenAmount: new BigNumber(payAmount) + .times(10 ** payToken.decimals) + .toFixed(0, 1), + toToken: receiveToken?.id, + } as typeof quote, + payToken, + receiveToken, + }); + + const halfBetterRateString = ''; + + const [ + middleContent, + rightContent, + disabled, + receivedTokenUsd, + diffReceivedTokenUsd, + ] = useMemo(() => { + let center: React.ReactNode = ( +
-
+ ); + let right: React.ReactNode = ''; + let disable = false; + let receivedUsd = '0'; + let diffUsd = '0'; + + const actualReceiveAmount = inSufficient + ? new BigNumber(quote?.toTokenAmount || 0) + .div(10 ** (quote?.toTokenDecimals || receiveToken.decimals)) + .toString() + : preExecResult?.swapPreExecTx.balance_change.receive_token_list[0] + ?.amount; + if (actualReceiveAmount || dexId === 'WrapToken') { + const receiveAmount = + actualReceiveAmount || (dexId === 'WrapToken' ? payAmount : 0); + const bestQuoteAmount = new BigNumber(bestAmount); + const receivedTokeAmountBn = new BigNumber(receiveAmount); + const percent = new BigNumber(receiveAmount) + .minus(bestAmount || 0) + .div(bestAmount) + .times(100); + + receivedUsd = formatUsdValue( + receivedTokeAmountBn.times(receiveToken.price || 0).toString(10) + ); + + diffUsd = formatUsdValue( + new BigNumber(receiveAmount) + .minus(bestQuoteAmount || 0) + .times(receiveToken.price || 0) + .toString(10) + ); + + const s = formatAmount(receivedTokeAmountBn.toString(10)); + const receiveTokenSymbol = getTokenSymbol(receiveToken); + center = ( + + + {s} + {' '} + {receiveTokenSymbol} + + ); + + right = ( + + {isBestQuote + ? t('page.swap.best') + : `${percent.toFixed(2, BigNumber.ROUND_DOWN)}%`} + + ); + } + + if (!quote?.toTokenAmount) { + right = ( +
+ {t('page.swap.unable-to-fetch-the-price')} +
+ ); + center =
-
; + disable = true; + } + + if (quote?.toTokenAmount) { + if (!preExecResult && !inSufficient) { + center =
-
; + right = ( +
+ {t('page.swap.fail-to-simulate-transaction')} +
+ ); + disable = true; + } + } + + if (!isSdkDataPass) { + disable = true; + center =
-
; + right = ( +
+ {t('page.swap.security-verification-failed')} +
+ ); + } + return [center, right, disable, receivedUsd, diffUsd]; + }, [ + quote?.toTokenAmount, + quote?.toTokenDecimals, + inSufficient, + receiveToken.decimals, + receiveToken.price, + receiveToken.symbol, + preExecResult, + isSdkDataPass, + bestAmount, + isBestQuote, + ]); + + const quoteWarning = useMemo(() => { + if (!quote?.toTokenAmount || !preExecResult) { + return; + } + + if (isSwapWrapToken(payToken.id, receiveToken.id, chain)) { + return; + } + const receivedTokeAmountBn = new BigNumber(quote?.toTokenAmount || 0).div( + 10 ** (quote?.toTokenDecimals || receiveToken.decimals) + ); + + const diff = receivedTokeAmountBn + .minus( + preExecResult?.swapPreExecTx?.balance_change.receive_token_list[0] + ?.amount || 0 + ) + .div(receivedTokeAmountBn); + + const diffPercent = diff.times(100); + + return diffPercent.gt(0.01) + ? ([ + formatAmount(receivedTokeAmountBn.toString(10)) + + getTokenSymbol(receiveToken), + `${diffPercent.toPrecision(2)}% (${formatAmount( + receivedTokeAmountBn + .minus( + preExecResult?.swapPreExecTx?.balance_change + .receive_token_list[0]?.amount || 0 + ) + .toString(10) + )} ${getTokenSymbol(receiveToken)})`, + ] as [string, string]) + : undefined; + }, [ + chain, + payToken.id, + preExecResult, + quote?.toTokenAmount, + quote?.toTokenDecimals, + receiveToken.decimals, + receiveToken.id, + receiveToken.symbol, + ]); + + const CheckIcon = useCallback(() => { + if (disabled || loading || !quote?.tx || !preExecResult?.swapPreExecTx) { + return null; + } + return ; + }, [ + disabled, + loading, + quote?.tx, + preExecResult?.swapPreExecTx, + quoteWarning, + ]); + + const [disabledTradeTipsOpen, setDisabledTradeTipsOpen] = useState(false); + + const handleClick = useCallback(() => { + if (disabledTrade) { + // setDisabledTradeTipsOpen(true); + return; + } + if (inSufficient) { + // message.error('Insufficient balance to select the rate'); + return; + } + if (active || disabled || disabledTrade) return; + updateActiveQuoteProvider({ + name: dexId, + quote, + gasPrice: preExecResult?.gasPrice, + shouldApproveToken: !!preExecResult?.shouldApproveToken, + shouldTwoStepApprove: !!preExecResult?.shouldTwoStepApprove, + error: !preExecResult, + halfBetterRate: halfBetterRateString, + quoteWarning, + actualReceiveAmount: + preExecResult?.swapPreExecTx.balance_change.receive_token_list[0] + ?.amount || '', + gasUsd: preExecResult?.gasUsd, + }); + + openSwapQuote(false); + }, [ + active, + disabled, + inSufficient, + updateActiveQuoteProvider, + dexId, + quote, + preExecResult, + quoteWarning, + ]); + + useDebounce( + () => { + if (active) { + updateActiveQuoteProvider((e) => ({ + ...e, + name: dexId, + quote, + gasPrice: preExecResult?.gasPrice, + shouldApproveToken: !!preExecResult?.shouldApproveToken, + shouldTwoStepApprove: !!preExecResult?.shouldTwoStepApprove, + error: !preExecResult, + halfBetterRate: halfBetterRateString, + quoteWarning, + actualReceiveAmount: + preExecResult?.swapPreExecTx.balance_change.receive_token_list[0] + ?.amount || '', + gasUsed: preExecResult?.gasUsd, + })); + } + }, + 300, + [ + quoteWarning, + halfBetterRateString, + active, + dexId, + updateActiveQuoteProvider, + quote, + preExecResult, + ] + ); + + const isWrapTokensWap = useMemo( + () => isSwapWrapToken(payToken.id, receiveToken.id, chain), + [payToken, receiveToken, chain] + ); + + return ( + { + if (disabledTrade && !inSufficient && quote && preExecResult) { + setDisabledTradeTipsOpen(true); + } + }} + onMouseLeave={() => { + setDisabledTradeTipsOpen(false); + }} + onClick={handleClick} + className={clsx( + active && 'active', + (disabledTrade || disabled) && 'disabled error', + inSufficient && !disabled && 'disabled inSufficient' + )} + > + + +
+
+
+ + {quoteProviderInfo.name} + + {!!preExecResult?.shouldApproveToken && ( + + + + )} +
+ +
+
+ {middleContent} + +
+
+ {!isBestQuote &&
{rightContent}
} +
+ + {!disabled && ( +
+
+ {!inSufficient && ( + <> + + {preExecResult?.gasUsd} + + )} +
+ + ≈{receivedTokenUsd} + + {!isBestQuote && ( + {diffReceivedTokenUsd} + )} +
+ )} +
+ + {isBestQuote &&
{rightContent}
} + +
+ + + {t('page.swap.this-exchange-is-not-enabled-to-trade-by-you')} + { + e.stopPropagation(); + openSwapSettings(true); + setDisabledTradeTipsOpen(false); + }} + > + {t('page.swap.enable-it')} + + +
+
+ ); +}; + +export const CexQuoteItem = (props: { + name: string; + data: CEXQuote | null; + bestAmount: string; + isBestQuote: boolean; + isLoading?: boolean; + inSufficient: boolean; +}) => { + const { + name, + data, + bestAmount, + isBestQuote, + isLoading, + inSufficient, + } = props; + const { t } = useTranslation(); + const dexInfo = useMemo(() => CEX[name as keyof typeof CEX], [name]); + + const [middleContent, rightContent] = useMemo(() => { + let center: React.ReactNode = ( +
-
+ ); + let right: React.ReactNode = ''; + let disable = false; + + if (!data?.receive_token?.amount) { + right = ( +
+ {t('page.swap.this-token-pair-is-not-supported')} +
+ ); + disable = true; + } + + if (data?.receive_token?.amount) { + const bestQuoteAmount = new BigNumber(bestAmount); + const receiveToken = data.receive_token; + const percent = new BigNumber(receiveToken.amount) + .minus(bestQuoteAmount || 0) + .div(bestQuoteAmount) + .times(100); + const s = formatAmount(receiveToken.amount.toString(10)); + const receiveTokenSymbol = getTokenSymbol(receiveToken); + + center = ( + + + {s} + {' '} + {receiveTokenSymbol} + + ); + + right = ( + + {isBestQuote + ? t('page.swap.best') + : `${percent.toFixed(2, BigNumber.ROUND_DOWN)}%`} + + ); + } + + return [center, right, disable]; + }, [data?.receive_token, bestAmount, isBestQuote]); + + return ( + + + +
+
+
+ {dexInfo.name} +
+ +
+
{middleContent}
+
+
{rightContent}
+
+
+
+ ); +}; + +export const CexListWrapper = styled.div` + border: 0.5px solid rgba(255, 255, 255, 0.2); + border-radius: 6px; + & > div:not(:last-child) { + position: relative; + &:not(:last-child):before { + content: ''; + position: absolute; + width: 440px; + height: 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + left: 20px; + bottom: 0; + } + } +`; + +const getQuoteLessWarning = ([receive, diff]: [string, string]) => + i18n.t('page.swap.QuoteLessWarning', { receive, diff }); + +export function WarningOrChecked({ + quoteWarning, +}: { + quoteWarning?: [string, string]; +}) { + const { t } = useTranslation(); + return ( + + + + ); +} diff --git a/src/ui/views/SwapNew/Component/QuoteLogo.tsx b/src/ui/views/SwapNew/Component/QuoteLogo.tsx new file mode 100644 index 00000000000..fc7493888d6 --- /dev/null +++ b/src/ui/views/SwapNew/Component/QuoteLogo.tsx @@ -0,0 +1,40 @@ +import { ReactComponent as IconQuoteLoading } from '@/ui/assets/swap/quote-loading.svg'; +import clsx from 'clsx'; +import React from 'react'; + +export const QuoteLogo = ({ + isLoading, + logo, + isCex = false, + loaded = false, +}: { + isLoading?: boolean; + logo: string; + isCex?: boolean; + loaded?: boolean; +}) => { + return ( +
+
+ + {isLoading && ( +
+ +
+ )} +
+
+ ); +}; diff --git a/src/ui/views/SwapNew/Component/Quotes.tsx b/src/ui/views/SwapNew/Component/Quotes.tsx new file mode 100644 index 00000000000..756af4a4b80 --- /dev/null +++ b/src/ui/views/SwapNew/Component/Quotes.tsx @@ -0,0 +1,282 @@ +import { Popup } from '@/ui/component'; +import React, { useMemo } from 'react'; +import { QuoteListLoading, QuoteLoading } from './loading'; +import styled from 'styled-components'; +import { IconRefresh } from './IconRefresh'; +import { CexQuoteItem, DexQuoteItem, QuoteItemProps } from './QuoteItem'; +import { + TCexQuoteData, + TDexQuoteData, + isSwapWrapToken, + useSetRefreshId, + useSetSettingVisible, + useSwapSettings, +} from '../hooks'; +import BigNumber from 'bignumber.js'; +import { CEX, DEX, DEX_WITH_WRAP } from '@/constant'; +import { SvgIconCross } from 'ui/assets'; +import { useTranslation } from 'react-i18next'; +import { getTokenSymbol } from '@/ui/utils/token'; + +const CexListWrapper = styled.div` + border: 1px solid #e5e9ef; + border-radius: 6px; + &:empty { + display: none; + } + + & > div:not(:last-child) { + position: relative; + &:not(:last-child):before { + content: ''; + position: absolute; + width: 328px; + height: 0; + border-bottom: 1px solid #e5e9ef; + left: 16px; + bottom: 0; + } + } +`; + +const exchangeCount = Object.keys(DEX).length + Object.keys(CEX).length; + +interface QuotesProps + extends Omit< + QuoteItemProps, + | 'bestAmount' + | 'name' + | 'quote' + | 'active' + | 'isBestQuote' + | 'quoteProviderInfo' + > { + list?: (TCexQuoteData | TDexQuoteData)[]; + activeName?: string; + visible: boolean; + onClose: () => void; +} + +export const Quotes = ({ + list, + activeName, + inSufficient, + ...other +}: QuotesProps) => { + const { t } = useTranslation(); + const { swapViewList, swapTradeList } = useSwapSettings(); + + const viewCount = useMemo(() => { + if (swapViewList) { + return ( + exchangeCount - + Object.values(swapViewList).filter((e) => e === false).length + ); + } + return exchangeCount; + }, [swapViewList]); + + const tradeCount = useMemo(() => { + if (swapTradeList) { + return Object.values(swapTradeList).filter((e) => e === true).length; + } + return 0; + }, [swapTradeList]); + + const setSettings = useSetSettingVisible(); + const openSettings = React.useCallback(() => { + setSettings(true); + }, []); + const sortedList = useMemo( + () => + list?.sort((a, b) => { + const getNumber = (quote: typeof a) => { + if (quote.isDex) { + if (inSufficient) { + return new BigNumber(quote.data?.toTokenAmount || 0); + } + if (!quote.preExecResult) { + return new BigNumber(0); + } + return new BigNumber( + quote?.preExecResult.swapPreExecTx.balance_change + .receive_token_list?.[0]?.amount || 0 + ); + } + + return new BigNumber(quote?.data?.receive_token?.amount || 0); + }; + return getNumber(b).minus(getNumber(a)).toNumber(); + }) || [], + [inSufficient, list] + ); + + const bestAmount = useMemo(() => { + const bestQuote = sortedList?.[0]; + + return ( + (bestQuote?.isDex + ? inSufficient + ? new BigNumber(bestQuote.data?.toTokenAmount || 0) + .div( + 10 ** + (bestQuote?.data?.toTokenDecimals || + other.receiveToken.decimals || + 1) + ) + .toString(10) + : bestQuote?.preExecResult?.swapPreExecTx.balance_change + .receive_token_list[0]?.amount + : new BigNumber(bestQuote?.data?.receive_token.amount || '0').toString( + 10 + )) || '0' + ); + }, [inSufficient, other?.receiveToken?.decimals, sortedList]); + + const fetchedList = useMemo(() => list?.map((e) => e.name) || [], [list]); + + const noCex = useMemo(() => { + return Object.keys(CEX).every((e) => swapViewList?.[e] === false); + }, [swapViewList]); + if (isSwapWrapToken(other.payToken.id, other.receiveToken.id, other.chain)) { + const dex = sortedList.find((e) => e.isDex) as TDexQuoteData | undefined; + + return ( +
+ {dex ? ( + + ) : ( + + )} + +
+ {t('page.swap.directlySwap', { + symbol: getTokenSymbol(other.payToken), + })} +
+
+ ); + } + return ( +
+
+ {sortedList.map((params, idx) => { + const { name, data, isDex } = params; + if (!isDex) return null; + return ( + + ); + })} + +
+ {!noCex && ( +
+ {t('page.swap.rates-from-cex')} +
+ )} + + {sortedList.map((params, idx) => { + const { name, data, isDex } = params; + if (isDex) return null; + return ( + + ); + })} + + +
+
+ {t('page.swap.tradingSettingTips', { viewCount, tradeCount })} + + {t('page.swap.edit')} + +
+
+ ); +}; + +const bodyStyle = { + paddingTop: 0, + paddingBottom: 0, +}; + +export const QuoteList = (props: QuotesProps) => { + const { visible, onClose } = props; + const refresh = useSetRefreshId(); + + const refreshQuote = React.useCallback(() => { + refresh((e) => e + 1); + }, [refresh]); + + const { t } = useTranslation(); + + return ( + + } + visible={visible} + title={ +
+
{t('page.swap.the-following-swap-rates-are-found')}
+
+
+ +
+
+
+ } + height={544} + onClose={onClose} + closable + destroyOnClose + className="isConnectView z-[999]" + bodyStyle={bodyStyle} + > + +
+ ); +}; diff --git a/src/ui/views/SwapNew/Component/ReceiveDetail.tsx b/src/ui/views/SwapNew/Component/ReceiveDetail.tsx new file mode 100644 index 00000000000..6fd3b1375f1 --- /dev/null +++ b/src/ui/views/SwapNew/Component/ReceiveDetail.tsx @@ -0,0 +1,383 @@ +import { TokenItem } from '@rabby-wallet/rabby-api/dist/types'; +import { Skeleton, Tooltip } from 'antd'; +import BigNumber from 'bignumber.js'; +import { + InsHTMLAttributes, + PropsWithChildren, + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; +import styled from 'styled-components'; +import ImgVerified from '@/ui/assets/swap/verified.svg'; +import ImgWarning from '@/ui/assets/swap/warn.svg'; +import ImgInfo from '@/ui/assets/swap/info-outline.svg'; +import ImgSwitch from '@/ui/assets/swap/switch.svg'; +import ImgGas from '@/ui/assets/swap/gas.svg'; +import ImgLock from '@/ui/assets/swap/lock.svg'; + +import clsx from 'clsx'; +import { SkeletonInputProps } from 'antd/lib/skeleton/Input'; +import React from 'react'; +import { formatAmount } from '@/ui/utils'; +import { QuoteProvider, useSetQuoteVisible } from '../hooks'; +import { DEX } from '@/constant'; +import { getTokenSymbol } from '@/ui/utils/token'; +import { TooltipWithMagnetArrow } from '@/ui/component/Tooltip/TooltipWithMagnetArrow'; +import i18n from '@/i18n'; +import { useTranslation } from 'react-i18next'; + +const getQuoteLessWarning = ([receive, diff]: [string, string]) => + i18n.t('page.swap.QuoteLessWarning', { receive, diff }); + +export const WarningOrChecked = ({ + quoteWarning, +}: { + quoteWarning?: [string, string]; +}) => { + const { t } = useTranslation(); + return ( + + + + ); +}; + +const ReceiveWrapper = styled.div` + position: relative; + margin-top: 24px; + border: 1px solid #e5e9ef; + border-radius: 4px; + padding: 12px; + + color: #4b4d59; + font-size: 13px; + .receive-token { + font-size: 15px; + color: #13141a; + } + + .diffPercent { + &.negative { + color: #ff7878; + } + &.positive { + color: #27c193; + } + } + .column { + display: flex; + justify-content: space-between; + + .right { + font-weight: medium; + display: inline-flex; + align-items: center; + gap: 4px; + .ellipsis { + max-width: 170px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + img { + width: 14px; + height: 14px; + } + } + } + + .warning { + margin-bottom: 8px; + padding: 8px; + font-weight: 400; + font-size: 12px; + color: #ffb020; + position: relative; + background: rgba(255, 176, 32, 0.1); + border-radius: 4px; + } + + .footer { + position: relative; + border-top: 0.5px solid #e5e9ef; + padding-top: 8px; + } + .quote-provider { + position: absolute; + top: -12px; + left: 12px; + height: 20px; + padding: 4px 6px; + display: flex; + align-items: center; + justify-content: center; + font-size: 13px; + cursor: pointer; + + color: #13141a; + + background: #e4e8ff; + border-radius: 4px; + border: 1px solid transparent; + &:hover { + background: #d4daff; + border: 1px solid rgba(134, 151, 255, 0.5); + } + } +`; + +const SkeletonChildren = ( + props: PropsWithChildren +) => { + const { loading = true, children, ...other } = props; + if (loading) { + return ; + } + return <>{children}; +}; + +interface ReceiveDetailsProps { + payAmount: string | number; + receiveRawAmount: string | number; + payToken: TokenItem; + receiveToken: TokenItem; + receiveTokenDecimals?: number; + quoteWarning?: [string, string]; + loading?: boolean; + activeProvider: QuoteProvider; + isWrapToken?: boolean; +} +export const ReceiveDetails = ( + props: ReceiveDetailsProps & InsHTMLAttributes +) => { + const { t } = useTranslation(); + const { + receiveRawAmount: receiveAmount, + payAmount, + payToken, + receiveToken, + quoteWarning, + loading = false, + activeProvider, + isWrapToken, + ...other + } = props; + + const [reverse, setReverse] = useState(false); + + const reverseRate = useCallback(() => { + setReverse((e) => !e); + }, []); + + useEffect(() => { + if (payToken && receiveToken) { + setReverse(false); + } + }, [receiveToken, payToken]); + + const { + receiveNum, + payUsd, + receiveUsd, + rate, + diff, + sign, + showLoss, + } = useMemo(() => { + const pay = new BigNumber(payAmount).times(payToken.price || 0); + const receiveAll = new BigNumber(receiveAmount); + const receive = receiveAll.times(receiveToken.price || 0); + const cut = receive.minus(pay).div(pay).times(100); + const rateBn = new BigNumber(reverse ? payAmount : receiveAll).div( + reverse ? receiveAll : payAmount + ); + + return { + receiveNum: formatAmount(receiveAll.toString(10)), + payUsd: formatAmount(pay.toString(10)), + receiveUsd: formatAmount(receive.toString(10)), + rate: rateBn.lt(0.0001) + ? new BigNumber(rateBn.toPrecision(1, 0)).toString(10) + : formatAmount(rateBn.toString(10)), + sign: cut.eq(0) ? '' : cut.lt(0) ? '-' : '+', + diff: cut.abs().toFixed(2), + showLoss: cut.lte(-5), + }; + }, [payAmount, payToken.price, receiveAmount, receiveToken.price, reverse]); + + const openQuote = useSetQuoteVisible(); + const payTokenSymbol = getTokenSymbol(payToken); + const receiveTokenSymbol = getTokenSymbol(receiveToken); + + return ( + +
+
+
+ +
+ +
+
+ + {isWrapToken + ? t('page.swap.wrap-contract') + : DEX?.[activeProvider?.name]?.name} + + {!!activeProvider.shouldApproveToken && ( + + + + )} +
+ {!!activeProvider?.gasUsd && ( +
+ + {activeProvider?.gasUsd} +
+ )} +
+
+
+
+ + + {receiveNum}{' '} + {receiveTokenSymbol} + + + +
+ +
+ + ≈ ${receiveUsd} ( + + {sign} + {diff}% + + ) + + +
+ {t('page.swap.est-payment')} {payAmount} + {payTokenSymbol} ≈ ${payUsd} +
+
+ {t('page.swap.est-receiving')} {receiveNum} + {receiveTokenSymbol} ≈ ${receiveUsd} +
+
+ {t('page.swap.est-difference')} {sign} + {diff}% +
+
+ } + > + + +
+
+
+ {!loading && quoteWarning && ( +
{getQuoteLessWarning(quoteWarning)}
+ )} + + {!loading && showLoss && ( +
+ {t( + 'page.swap.selected-offer-differs-greatly-from-current-rate-may-cause-big-losses' + )} +
+ )} +
+ {t('page.swap.rate')} +
+ + + + 1 {reverse ? receiveTokenSymbol : payTokenSymbol}{' '} + + ={' '} + + {rate} {reverse ? payTokenSymbol : receiveTokenSymbol} + + + +
+
+ {activeProvider.name && receiveToken ? ( +
{ + openQuote(true); + }} + > + +
+ ) : null} + + ); +}; diff --git a/src/ui/views/SwapNew/Component/Slippage.tsx b/src/ui/views/SwapNew/Component/Slippage.tsx new file mode 100644 index 00000000000..75aa6e12ba8 --- /dev/null +++ b/src/ui/views/SwapNew/Component/Slippage.tsx @@ -0,0 +1,226 @@ +import clsx from 'clsx'; +import { + memo, + useMemo, + useCallback, + ChangeEventHandler, + useState, +} from 'react'; +import { useToggle } from 'react-use'; +import styled from 'styled-components'; +import BigNumber from 'bignumber.js'; +import React from 'react'; +import { Input } from 'antd'; +import ImgArrowUp from 'ui/assets/swap/arrow-up.svg'; +import i18n from '@/i18n'; +import { Trans, useTranslation } from 'react-i18next'; + +export const SlippageItem = styled.div<{ + active?: boolean; + error?: boolean; + hasAmount?: boolean; +}>` + position: relative; + display: flex; + justify-content: center; + align-items: center; + border: 1px solid transparent; + cursor: pointer; + border-radius: 6px; + width: 52px; + height: 28px; + font-weight: 500; + font-size: 12px; + background: #f5f6fa; + border-radius: 4px; + &:hover { + background: rgba(134, 151, 255, 0.2); + } +`; + +const SLIPPAGE = ['0.1', '0.3', '0.5']; + +const Wrapper = styled.section` + .slippage { + display: flex; + align-items: center; + gap: 8px; + } + + .input { + font-weight: 500; + font-size: 12px; + background: #f5f6fa; + border: 1px solid #e5e9ef; + border-radius: 4px; + + &:placeholder-shown { + color: #707280; + } + .ant-input { + border-radius: 0; + } + } + + .warning { + padding: 10px; + color: #ffb020; + font-weight: 400; + font-size: 12px; + line-height: 14px; + position: relative; + border-radius: 4px; + background: rgba(255, 176, 32, 0.1); + margin-top: 8px; + } +`; +interface SlippageProps { + value: string; + displaySlippage: string; + onChange: (n: string) => void; + recommendValue?: number; +} +export const Slippage = memo((props: SlippageProps) => { + const { t } = useTranslation(); + + const { value, displaySlippage, onChange, recommendValue } = props; + const [isCustom, setIsCustom] = useToggle(false); + + const [slippageOpen, setSlippageOpen] = useState(false); + + const [isLow, isHigh] = useMemo(() => { + return [ + value?.trim() !== '' && Number(value || 0) < 0.1, + value?.trim() !== '' && Number(value || 0) > 10, + ]; + }, [value]); + + const setRecommendValue = useCallback(() => { + onChange(new BigNumber(recommendValue || 0).times(100).toString()); + }, [onChange, recommendValue]); + + const tips = useMemo(() => { + if (isLow) { + return i18n.t( + 'page.swap.low-slippage-may-cause-failed-transactions-due-to-high-volatility' + ); + } + if (isHigh) { + return i18n.t( + 'page.swap.transaction-might-be-frontrun-because-of-high-slippage-tolerance' + ); + } + if (recommendValue) { + return ( + + + To prevent front-running, we recommend a slippage of{' '} + + {{ + slippage: new BigNumber(recommendValue || 0) + .times(100) + .toString(), + }} + + %{' '} + + + ); + } + return null; + }, [isHigh, isLow, recommendValue, setRecommendValue]); + + const onInputFocus: ChangeEventHandler = useCallback( + (e) => { + e.target?.select?.(); + }, + [] + ); + + const onInputChange: ChangeEventHandler = useCallback( + (e) => { + const v = e.target.value; + if (/^\d*(\.\d*)?$/.test(v)) { + onChange(Number(v) > 50 ? '50' : v); + } + }, + [onChange] + ); + + return ( +
+
{ + setSlippageOpen((e) => !e); + }} + > + {t('page.swap.slippage-tolerance')} + + + {displaySlippage}%{' '} + + + +
+ +
+ {SLIPPAGE.map((e) => ( + { + event.stopPropagation(); + setIsCustom(false); + onChange(e); + }} + active={!isCustom && e === value} + > + {e}% + + ))} +
{ + event.stopPropagation(); + setIsCustom(true); + }} + className="flex-1" + > + %
} + /> +
+
+ + {!!tips &&
{tips}
} + + + ); +}); diff --git a/src/ui/views/SwapNew/Component/TokenRender.tsx b/src/ui/views/SwapNew/Component/TokenRender.tsx new file mode 100644 index 00000000000..8775545cc2c --- /dev/null +++ b/src/ui/views/SwapNew/Component/TokenRender.tsx @@ -0,0 +1,85 @@ +import { TokenWithChain } from '@/ui/component'; +import React from 'react'; +import styled from 'styled-components'; +import { ReactComponent as IconRcArrowDownTriangle } from '@/ui/assets/swap/arrow-caret-down.svg'; +import { TokenItem } from '@rabby-wallet/rabby-api/dist/types'; +import { getTokenSymbol } from '@/ui/utils/token'; +import { useTranslation } from 'react-i18next'; +const TokenRenderWrapper = styled.div` + width: 150px; + height: 46px; + background: #f5f6fa; + border-radius: 4px; + display: flex; + align-items: center; + padding: 12px; + font-weight: 500; + font-size: 18px; + color: #13141a; + border: 1px solid transparent; + cursor: pointer; + &:hover { + background: rgba(134, 151, 255, 0.2); + } + .token { + display: flex; + flex: 1; + gap: 8px; + align-items: center; + + .text { + max-width: 68px; + display: inline-block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } + .select { + flex: 1; + display: flex; + justify-content: space-between; + align-items: center; + white-space: nowrap; + } + .arrow { + margin-left: auto; + font-size: 12px; + opacity: 0.8; + width: 18px; + height: 18px; + } +`; +export const TokenRender = ({ + openTokenModal, + token, +}: { + token?: TokenItem | undefined; + openTokenModal: () => void; +}) => { + const { t } = useTranslation(); + return ( + + {token ? ( +
+ + + {getTokenSymbol(token)} + + +
+ ) : ( +
+ {t('page.swap.select-token')} + +
+ )} +
+ ); +}; diff --git a/src/ui/views/SwapNew/Component/TradingSettings.tsx b/src/ui/views/SwapNew/Component/TradingSettings.tsx new file mode 100644 index 00000000000..a2bc9a4c88f --- /dev/null +++ b/src/ui/views/SwapNew/Component/TradingSettings.tsx @@ -0,0 +1,165 @@ +import { Checkbox, Modal, Popup } from '@/ui/component'; +import React, { useState } from 'react'; +import { useSwapSettings } from '../hooks'; +import { CEX, CHAINS_ENUM, DEX } from '@/constant'; +import clsx from 'clsx'; +import { Button, Switch } from 'antd'; +import { useTranslation } from 'react-i18next'; + +const list = [...Object.values(DEX), ...Object.values(CEX)] as { + id: keyof typeof DEX | keyof typeof CEX; + logo: string; + name: string; + chains: CHAINS_ENUM[]; +}[]; + +export const TradingSettings = ({ + visible, + onClose, +}: { + visible: boolean; + onClose: () => void; +}) => { + const { t } = useTranslation(); + const { + swapViewList, + swapTradeList, + setSwapView, + setSwapTrade, + } = useSwapSettings(); + + const [open, setOpen] = useState(false); + + const [id, setId] = useState[0][0]>(); + + const onConfirm = () => { + if (id) { + setSwapTrade([id, true]); + setOpen(false); + } + }; + + return ( + +
+
+
{t('page.swap.exchanges')}
+
{t('page.swap.view-quotes')}
+
{t('page.swap.trade')}
+
+ +
+ {list.map((item) => { + return ( +
+
+ + + {item.name} + + + {item?.chains ? t('page.swap.dex') : t('page.swap.cex')} + +
+
+ { + setSwapView([item.id, checked]); + if (!checked && DEX[item.id]) { + setSwapTrade([item.id, checked]); + } + }} + /> +
+
+ { + if (checked) { + setId(item.id); + setOpen(true); + } else { + setSwapTrade([item.id, checked]); + } + }} + /> +
+
+ ); + })} +
+
+ { + setOpen(false); + }} + > + + +
+ ); +}; + +function EnableTrading({ onConfirm }: { onConfirm: () => void }) { + const [checked, setChecked] = useState(false); + const { t } = useTranslation(); + return ( +
+
+ {t('page.swap.enable-trading')} +
+
+

{t('page.swap.tradingSettingTip1')}

+

{t('page.swap.tradingSettingTip2')}

+
+
+ + {t('page.swap.i-understand-and-accept-it')} + + + +
+
+ ); +} diff --git a/src/ui/views/SwapNew/Component/loading.tsx b/src/ui/views/SwapNew/Component/loading.tsx new file mode 100644 index 00000000000..c8f19b3d306 --- /dev/null +++ b/src/ui/views/SwapNew/Component/loading.tsx @@ -0,0 +1,80 @@ +import { CEX, DEX } from '@/constant'; +import { Skeleton } from 'antd'; +import clsx from 'clsx'; +import React from 'react'; +import { QuoteLogo } from './QuoteLogo'; +import { useSwapSettings } from '../hooks'; + +type QuoteListLoadingProps = { + fetchedList?: string[]; + isCex?: boolean; +}; + +export const QuoteLoading = ({ + logo, + name, + isCex = false, +}: { + logo: string; + name: string; + isCex?: boolean; +}) => { + return ( +
+ + + {name} + +
+ + + +
+
+ ); +}; + +export const QuoteListLoading = ({ + fetchedList: dataList, + isCex, +}: QuoteListLoadingProps) => { + const { swapViewList } = useSwapSettings(); + return ( + <> + {Object.entries(isCex ? CEX : DEX).map(([key, value]) => { + if ( + (dataList && dataList.includes(key)) || + swapViewList?.[key] === false + ) + return null; + return ( + + ); + })} + + ); +}; diff --git a/src/ui/views/SwapNew/hooks/context.tsx b/src/ui/views/SwapNew/hooks/context.tsx new file mode 100644 index 00000000000..01893c33ce4 --- /dev/null +++ b/src/ui/views/SwapNew/hooks/context.tsx @@ -0,0 +1,23 @@ +import { createContextState } from '@/ui/hooks/contextState'; + +const [ + SettingVisibleProvider, + useSettingVisible, + useSetSettingVisible, +] = createContextState(false); + +const [ + QuoteVisibleProvider, + useQuoteVisible, + useSetQuoteVisible, +] = createContextState(false); + +const [RefreshIdProvider, useRefreshId, useSetRefreshId] = createContextState( + 0 +); + +export { SettingVisibleProvider, useSettingVisible, useSetSettingVisible }; + +export { RefreshIdProvider, useRefreshId, useSetRefreshId }; + +export { QuoteVisibleProvider, useQuoteVisible, useSetQuoteVisible }; diff --git a/src/ui/views/SwapNew/hooks/history.tsx b/src/ui/views/SwapNew/hooks/history.tsx new file mode 100644 index 00000000000..4560188fec1 --- /dev/null +++ b/src/ui/views/SwapNew/hooks/history.tsx @@ -0,0 +1,104 @@ +import { useInViewport, useInfiniteScroll } from 'ahooks'; +import React, { useEffect, useRef, useState } from 'react'; +import { useQuoteMethods } from './quote'; +import { useRabbySelector } from '@/ui/store'; +import { useAsync } from 'react-use'; +import { uniqBy } from 'lodash'; +import { SwapItem } from '@rabby-wallet/rabby-api/dist/types'; + +export const useSwapHistory = () => { + const { getSwapList } = useQuoteMethods(); + const addr = useRabbySelector( + (state) => state.account.currentAccount?.address || '' + ); + + const [refreshSwapTxListCount, setRefreshSwapListTx] = useState(0); + const refreshSwapListTx = React.useCallback(() => { + setRefreshSwapListTx((e) => e + 1); + }, []); + const isInSwap = true; + + const { + data: txList, + loading, + loadMore, + loadingMore, + noMore, + mutate, + } = useInfiniteScroll( + (d) => + getSwapList( + addr, + d?.list?.length && d?.list?.length > 1 ? d?.list?.length - 1 : 0, + 5 + ), + { + reloadDeps: [isInSwap], + isNoMore(data) { + if (data) { + return data?.list.length >= data?.totalCount; + } + return true; + }, + manual: !isInSwap || !addr, + } + ); + + const { value } = useAsync(async () => { + if (addr) { + return getSwapList(addr, 0, 5); + } + }, [addr, refreshSwapTxListCount]); + + useEffect(() => { + if (value?.list) { + mutate((d) => { + if (!d) { + return; + } + return { + last: d?.last, + totalCount: d?.totalCount, + list: uniqBy( + [...(value.list || []), ...(d?.list || [])], + (e) => `${e.chain}-${e.tx_id}` + ) as SwapItem[], + }; + }); + } + }, [mutate, value]); + + const ref = useRef(null); + + const [inViewport] = useInViewport(ref); + + useEffect(() => { + if (!noMore && inViewport && !loadingMore && loadMore && isInSwap) { + loadMore(); + } + }, [inViewport, loadMore, loading, loadingMore, noMore, isInSwap]); + + useEffect(() => { + let timer: NodeJS.Timeout; + if ( + !loading && + !loadingMore && + txList?.list?.some((e) => e.status !== 'Finished') && + isInSwap + ) { + timer = setTimeout(refreshSwapListTx, 2000); + } + return () => { + if (timer) { + clearTimeout(timer); + } + }; + }, [loading, loadingMore, refreshSwapListTx, txList?.list, isInSwap]); + + return { + loading, + txList, + loadingMore, + ref, + }; +}; diff --git a/src/ui/views/SwapNew/hooks/index.tsx b/src/ui/views/SwapNew/hooks/index.tsx new file mode 100644 index 00000000000..5e7e70be84a --- /dev/null +++ b/src/ui/views/SwapNew/hooks/index.tsx @@ -0,0 +1,7 @@ +export * from './swapReport'; +export * from './token'; +export * from './settings'; +export * from './context'; +export * from './verify'; +export * from './quote'; +export * from './history'; diff --git a/src/ui/views/SwapNew/hooks/quote.tsx b/src/ui/views/SwapNew/hooks/quote.tsx new file mode 100644 index 00000000000..9997ce75e99 --- /dev/null +++ b/src/ui/views/SwapNew/hooks/quote.tsx @@ -0,0 +1,642 @@ +import { CEX, DEX, ETH_USDT_CONTRACT, SWAP_FEE_ADDRESS } from '@/constant'; +import { formatUsdValue, isSameAddress, useWallet } from '@/ui/utils'; +import { CHAINS, CHAINS_ENUM } from '@debank/common'; +import { + CEXQuote, + ExplainTxResponse, + TokenItem, + Tx, +} from '@rabby-wallet/rabby-api/dist/types'; +import { + DEX_ENUM, + DEX_ROUTER_WHITELIST, + DEX_SPENDER_WHITELIST, + WrapTokenAddressMap, +} from '@rabby-wallet/rabby-swap'; +import { QuoteResult, getQuote } from '@rabby-wallet/rabby-swap/dist/quote'; +import BigNumber from 'bignumber.js'; +import React from 'react'; +import pRetry from 'p-retry'; +import { useRabbySelector } from '@/ui/store'; +import stats from '@/stats'; + +export interface validSlippageParams { + chain: CHAINS_ENUM; + slippage: string; + payTokenId: string; + receiveTokenId: string; +} + +export const useQuoteMethods = () => { + const walletController = useWallet(); + const walletOpenapi = walletController.openapi; + const validSlippage = React.useCallback( + async ({ + chain, + slippage, + payTokenId, + receiveTokenId, + }: validSlippageParams) => { + const p = { + slippage: new BigNumber(slippage).div(100).toString(), + chain_id: CHAINS[chain].serverId, + from_token_id: payTokenId, + to_token_id: receiveTokenId, + }; + + return walletOpenapi.checkSlippage(p); + }, + [walletOpenapi] + ); + + const getSwapList = React.useCallback( + async (addr: string, start = 0, limit = 5) => { + const data = await walletOpenapi.getSwapTradeList({ + user_addr: addr, + start: `${start}`, + limit: `${limit}`, + }); + return { + list: data?.history_list, + last: data, + totalCount: data?.total_cnt, + }; + }, + [walletOpenapi] + ); + const postSwap = React.useCallback( + async ({ + payToken, + receiveToken, + payAmount, + // receiveRawAmount, + slippage, + dexId, + txId, + quote, + tx, + }: postSwapParams) => + walletOpenapi.postSwap({ + quote: { + pay_token_id: payToken.id, + pay_token_amount: Number(payAmount), + receive_token_id: receiveToken.id, + receive_token_amount: new BigNumber(quote.toTokenAmount) + .div(10 ** (quote.toTokenDecimals || receiveToken.decimals)) + .toNumber(), + slippage: new BigNumber(slippage).div(100).toNumber(), + }, + // 0xAPI => 0x + dex_id: dexId.replace('API', ''), + tx_id: txId, + tx, + }), + [walletOpenapi] + ); + + const getToken = React.useCallback( + async ({ addr, chain, tokenId }: getTokenParams) => { + return walletOpenapi.getToken( + addr, + CHAINS[chain].serverId, + tokenId // CHAINS[chain].nativeTokenAddress + ); + }, + [walletOpenapi] + ); + + const getTokenApproveStatus = React.useCallback( + async ({ + payToken, + receiveToken, + payAmount, + chain, + dexId, + }: Pick< + getDexQuoteParams, + 'payToken' | 'receiveToken' | 'payAmount' | 'chain' | 'dexId' + >) => { + if ( + payToken?.id === CHAINS[chain].nativeTokenAddress || + isSwapWrapToken(payToken.id, receiveToken.id, chain) + ) { + return [true, false]; + } + + const allowance = await walletController.getERC20Allowance( + CHAINS[chain].serverId, + payToken.id, + getSpender(dexId, chain) + ); + + const tokenApproved = new BigNumber(allowance).gte( + new BigNumber(payAmount).times(10 ** payToken.decimals) + ); + + if ( + chain === CHAINS_ENUM.ETH && + isSameAddress(payToken.id, ETH_USDT_CONTRACT) && + Number(allowance) !== 0 && + !tokenApproved + ) { + return [tokenApproved, true]; + } + return [tokenApproved, false]; + }, + [walletController.getERC20Allowance] + ); + + const getPreExecResult = React.useCallback( + async ({ + userAddress, + chain, + payToken, + receiveToken, + payAmount, + dexId, + quote, + }: getPreExecResultParams) => { + const nonce = await walletController.getRecommendNonce({ + from: userAddress, + chainId: CHAINS[chain].id, + }); + + const gasMarket = await walletOpenapi.gasMarket(CHAINS[chain].serverId); + const gasPrice = gasMarket?.[1]?.price; + + let nextNonce = nonce; + const pendingTx: Tx[] = []; + let gasUsed = 0; + + const approveToken = async (amount: string) => { + const tokenApproveParams = await walletController.generateApproveTokenTx( + { + from: userAddress, + to: payToken.id, + chainId: CHAINS[chain].id, + spender: getSpender(dexId, chain), + amount, + } + ); + const tokenApproveTx = { + ...tokenApproveParams, + nonce: nextNonce, + value: '0x', + gasPrice: `0x${new BigNumber(gasPrice).toString(16)}`, + gas: '0x0', + }; + + const tokenApprovePreExecTx = await walletOpenapi.preExecTx({ + tx: tokenApproveTx, + origin: INTERNAL_REQUEST_ORIGIN, + address: userAddress, + updateNonce: true, + pending_tx_list: pendingTx, + }); + + if (!tokenApprovePreExecTx?.pre_exec?.success) { + throw new Error('pre_exec_tx error'); + } + gasUsed += tokenApprovePreExecTx.gas.gas_used; + + pendingTx.push({ + ...tokenApproveTx, + gas: `0x${new BigNumber(tokenApprovePreExecTx.gas.gas_used) + .times(4) + .toString(16)}`, + }); + nextNonce = `0x${new BigNumber(nextNonce).plus(1).toString(16)}`; + }; + + const [tokenApproved, shouldTwoStepApprove] = await getTokenApproveStatus( + { + payToken, + receiveToken, + payAmount, + chain, + dexId, + } + ); + + if (shouldTwoStepApprove) { + await approveToken('0'); + } + + if (!tokenApproved) { + await approveToken( + new BigNumber(payAmount).times(10 ** payToken.decimals).toFixed(0, 1) + ); + } + + const swapPreExecTx = await walletOpenapi.preExecTx({ + tx: { + ...quote.tx, + nonce: nextNonce, + chainId: CHAINS[chain].id, + value: `0x${new BigNumber(quote.tx.value).toString(16)}`, + gasPrice: `0x${new BigNumber(gasPrice).toString(16)}`, + gas: '0x0', + } as Tx, + origin: INTERNAL_REQUEST_ORIGIN, + address: userAddress, + updateNonce: true, + pending_tx_list: pendingTx, + }); + + if (!swapPreExecTx?.pre_exec?.success) { + throw new Error('pre_exec_tx error'); + } + + gasUsed += swapPreExecTx.gas.gas_used; + + return { + shouldApproveToken: !tokenApproved, + shouldTwoStepApprove, + swapPreExecTx, + gasPrice, + gasUsd: formatUsdValue( + new BigNumber(gasUsed) + .times(gasPrice) + .div(10 ** swapPreExecTx.native_token.decimals) + .times(swapPreExecTx.native_token.price) + .toString(10) + ), + }; + }, + [ + walletOpenapi, + getTokenApproveStatus, + walletController.getRecommendNonce, + walletController.generateApproveTokenTx, + ] + ); + + const getDexQuote = React.useCallback( + async ({ + payToken, + receiveToken, + userAddress, + slippage, + fee: feeAfterDiscount, + payAmount, + chain, + dexId, + setQuote, + }: getDexQuoteParams & { + setQuote?: (quote: TDexQuoteData) => void; + }): Promise => { + const isOpenOcean = dexId === DEX_ENUM.OPENOCEAN; + try { + let gasPrice: number; + if (isOpenOcean) { + const gasMarket = await walletOpenapi.gasMarket( + CHAINS[chain].serverId + ); + gasPrice = gasMarket?.[1]?.price; + } + stats.report('swapRequestQuote', { + dex: dexId, + chain, + fromToken: payToken.id, + toToken: receiveToken.id, + }); + + const data = await pRetry( + () => + getQuote( + isSwapWrapToken(payToken.id, receiveToken.id, chain) + ? DEX_ENUM.WRAPTOKEN + : dexId, + { + fromToken: payToken.id, + toToken: receiveToken.id, + feeAddress: SWAP_FEE_ADDRESS, + fromTokenDecimals: payToken.decimals, + amount: new BigNumber(payAmount) + .times(10 ** payToken.decimals) + .toFixed(0, 1), + userAddress, + slippage: Number(slippage), + feeRate: + feeAfterDiscount === '0' && isOpenOcean + ? undefined + : Number(feeAfterDiscount) || 0, + chain, + gasPrice, + }, + walletOpenapi + ), + { + retries: 1, + } + ); + + stats.report('swapQuoteResult', { + dex: dexId, + chain, + fromToken: payToken.id, + toToken: receiveToken.id, + status: data ? 'success' : 'fail', + }); + + let preExecResult; + if (data) { + try { + preExecResult = await pRetry( + () => + getPreExecResult({ + userAddress, + chain, + payToken, + receiveToken, + payAmount, + quote: data, + dexId: dexId as DEX_ENUM, + }), + { + retries: 1, + } + ); + } catch (error) { + const quote: TDexQuoteData = { + data, + name: dexId, + isDex: true, + preExecResult: null, + }; + setQuote?.(quote); + return quote; + } + } + const quote: TDexQuoteData = { + data, + name: dexId, + isDex: true, + preExecResult, + }; + setQuote?.(quote); + return quote; + } catch (error) { + console.error('getQuote error ', error); + + stats.report('swapQuoteResult', { + dex: dexId, + chain, + fromToken: payToken.id, + toToken: receiveToken.id, + status: 'fail', + }); + + const quote: TDexQuoteData = { + data: null, + name: dexId, + isDex: true, + preExecResult: null, + }; + setQuote?.(quote); + return quote; + } + }, + [walletOpenapi, pRetry, getPreExecResult] + ); + + const getCexQuote = React.useCallback( + async ( + params: getAllCexQuotesParams & { + cexId: string; + setQuote?: (quote: TCexQuoteData) => void; + } + ): Promise => { + const { + payToken, + payAmount, + receiveTokenId: receive_token_id, + chain, + cexId: cex_id, + setQuote, + } = params; + + const p = { + cex_id, + pay_token_amount: payAmount, + chain_id: CHAINS[chain].serverId, + pay_token_id: payToken.id, + receive_token_id, + }; + + let quote: TCexQuoteData; + + try { + const data = await walletOpenapi.getCEXSwapQuote(p); + quote = { + data, + name: cex_id, + isDex: false, + }; + } catch (error) { + quote = { + data: null, + name: cex_id, + isDex: false, + }; + } + + setQuote?.(quote); + + return quote; + }, + [walletOpenapi] + ); + + const swapViewList = useRabbySelector((s) => s.swap.viewList); + + const getAllQuotes = React.useCallback( + async ( + params: Omit & { + setQuote: (quote: TCexQuoteData | TDexQuoteData) => void; + } + ) => { + if ( + isSwapWrapToken( + params.payToken.id, + params.receiveToken.id, + params.chain + ) + ) { + return getDexQuote({ + ...params, + dexId: DEX_ENUM.WRAPTOKEN, + }); + } + + return Promise.all([ + ...(Object.keys(DEX).filter( + (e) => swapViewList?.[e] !== false + ) as DEX_ENUM[]).map((dexId) => getDexQuote({ ...params, dexId })), + ...Object.keys(CEX) + .filter((e) => swapViewList?.[e] !== false) + .map((cexId) => + getCexQuote({ + cexId, + payToken: params.payToken, + payAmount: params.payAmount, + receiveTokenId: params.receiveToken.id, + chain: params.chain, + setQuote: params.setQuote, + }) + ), + ]); + }, + [getDexQuote, getCexQuote] + ); + + return { + validSlippage, + getSwapList, + postSwap, + getToken, + getTokenApproveStatus, + getPreExecResult, + getDexQuote, + getAllQuotes, + swapViewList, + }; +}; + +export interface postSwapParams { + payToken: TokenItem; + receiveToken: TokenItem; + payAmount: string; + // receiveRawAmount: string; + slippage: string; + dexId: string; + txId: string; + quote: QuoteResult; + tx: Tx; +} + +interface getTokenParams { + addr: string; + chain: CHAINS_ENUM; + tokenId: string; +} + +export const getRouter = (dexId: DEX_ENUM, chain: CHAINS_ENUM) => { + const list = DEX_ROUTER_WHITELIST[dexId as keyof typeof DEX_ROUTER_WHITELIST]; + return list[chain as keyof typeof list]; +}; + +export const getSpender = (dexId: DEX_ENUM, chain: CHAINS_ENUM) => { + if (dexId === DEX_ENUM.WRAPTOKEN) { + return ''; + } + const list = + DEX_SPENDER_WHITELIST[dexId as keyof typeof DEX_SPENDER_WHITELIST]; + return list[chain as keyof typeof list]; +}; + +const INTERNAL_REQUEST_ORIGIN = window.location.origin; + +interface getPreExecResultParams + extends Omit { + quote: QuoteResult; +} + +export const halfBetterRate = ( + full: ExplainTxResponse, + half: ExplainTxResponse +) => { + if ( + full.balance_change.success && + half.balance_change.success && + half.balance_change.receive_token_list[0]?.amount && + full.balance_change.receive_token_list[0]?.amount + ) { + const halfReceive = new BigNumber( + half.balance_change.receive_token_list[0].amount + ); + + const fullREceive = new BigNumber( + full.balance_change.receive_token_list[0]?.amount + ); + const diff = new BigNumber(halfReceive).times(2).minus(fullREceive); + + return diff.gt(0) + ? new BigNumber(diff.div(fullREceive).toPrecision(1)) + .times(100) + .toString(10) + : null; + } + return null; +}; + +export type QuotePreExecResultInfo = { + shouldApproveToken: boolean; + shouldTwoStepApprove: boolean; + swapPreExecTx: ExplainTxResponse; + gasPrice: number; + gasUsd: string; +} | null; + +interface getDexQuoteParams { + payToken: TokenItem; + receiveToken: TokenItem; + userAddress: string; + slippage: string; + fee: string; + payAmount: string; + chain: CHAINS_ENUM; + dexId: DEX_ENUM; +} + +export type TDexQuoteData = { + data: null | QuoteResult; + name: string; + isDex: true; + preExecResult: QuotePreExecResultInfo; + loading?: boolean; +}; + +interface getAllCexQuotesParams { + payToken: TokenItem; + payAmount: string; + receiveTokenId: string; + chain: CHAINS_ENUM; +} + +export type TCexQuoteData = { + data: null | CEXQuote; + name: string; + isDex: false; + loading?: boolean; +}; + +export function isSwapWrapToken( + payTokenId: string, + receiveId: string, + chain: CHAINS_ENUM +) { + const wrapTokens = [ + WrapTokenAddressMap[chain as keyof typeof WrapTokenAddressMap], + CHAINS[chain].nativeTokenAddress, + ]; + return ( + !!wrapTokens.find((token) => isSameAddress(payTokenId, token)) && + !!wrapTokens.find((token) => isSameAddress(receiveId, token)) + ); +} + +export type QuoteProvider = { + name: string; + error?: boolean; + quote: QuoteResult | null; + shouldApproveToken: boolean; + shouldTwoStepApprove: boolean; + halfBetterRate?: string; + quoteWarning?: [string, string]; + gasPrice?: number; + activeLoading?: boolean; + activeTx?: string; + actualReceiveAmount: string | number; + gasUsd?: string; +}; diff --git a/src/ui/views/SwapNew/hooks/settings.tsx b/src/ui/views/SwapNew/hooks/settings.tsx new file mode 100644 index 00000000000..ffc6304d417 --- /dev/null +++ b/src/ui/views/SwapNew/hooks/settings.tsx @@ -0,0 +1,21 @@ +import { useRabbyDispatch, useRabbySelector } from '@/ui/store'; +import { useMemo } from 'react'; + +export const useSwapSettings = () => { + const swapViewList = useRabbySelector((s) => s.swap.viewList); + const swapTradeList = useRabbySelector((s) => s.swap.tradeList); + const prevChain = useRabbySelector((s) => s.swap.selectedChain); + const dispatch = useRabbyDispatch(); + + const methods = useMemo(() => { + const { setSelectedChain, setSwapTrade, setSwapView } = dispatch.swap; + return { setSelectedChain, setSwapTrade, setSwapView }; + }, [dispatch]); + + return { + swapViewList, + swapTradeList, + prevChain, + ...methods, + }; +}; diff --git a/src/ui/views/SwapNew/hooks/swapReport.tsx b/src/ui/views/SwapNew/hooks/swapReport.tsx new file mode 100644 index 00000000000..7e7d43791d7 --- /dev/null +++ b/src/ui/views/SwapNew/hooks/swapReport.tsx @@ -0,0 +1,15 @@ +import stats from '@/stats'; +import { useRbiSource } from '@/ui/utils/ga-event'; +import { useEffect } from 'react'; + +export const useSwapStatsReport = () => { + const rbiSource = useRbiSource(); + + useEffect(() => { + if (rbiSource) { + stats.report('enterSwapDescPage', { + refer: rbiSource, + }); + } + }, [rbiSource]); +}; diff --git a/src/ui/views/SwapNew/hooks/token.tsx b/src/ui/views/SwapNew/hooks/token.tsx new file mode 100644 index 00000000000..ebd8a8ffd8f --- /dev/null +++ b/src/ui/views/SwapNew/hooks/token.tsx @@ -0,0 +1,478 @@ +import { useRabbyDispatch, useRabbySelector } from '@/ui/store'; +import { isSameAddress, useWallet } from '@/ui/utils'; +import { CHAINS, CHAINS_ENUM } from '@debank/common'; +import { TokenItem } from '@rabby-wallet/rabby-api/dist/types'; +import { WrapTokenAddressMap } from '@rabby-wallet/rabby-swap'; +import BigNumber from 'bignumber.js'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useAsync, useDebounce } from 'react-use'; +import { + QuoteProvider, + TCexQuoteData, + TDexQuoteData, + useQuoteMethods, +} from './quote'; +import { + useQuoteVisible, + useRefreshId, + useSetRefreshId, + useSettingVisible, +} from './context'; +import { useLocation } from 'react-router-dom'; +import { query2obj } from '@/ui/utils/url'; +import { useRbiSource } from '@/ui/utils/ga-event'; +import stats from '@/stats'; +import { useSwapSettings } from './settings'; +import { useAsyncInitializeChainList } from '@/ui/hooks/useChain'; +import { SWAP_SUPPORT_CHAINS } from '@/constant'; + +const useTokenInfo = ({ + userAddress, + chain, + defaultToken, +}: { + userAddress?: string; + chain?: CHAINS_ENUM; + defaultToken?: TokenItem; +}) => { + const refreshId = useRefreshId(); + const wallet = useWallet(); + const [token, setToken] = useState(defaultToken); + + const { value, loading, error } = useAsync(async () => { + if (userAddress && token?.id && chain) { + const data = await wallet.openapi.getToken( + userAddress, + CHAINS[chain].serverId, + token.id + ); + return data; + } + }, [refreshId, userAddress, token?.id, token?.raw_amount_hex_str, chain]); + + useDebounce( + () => { + if (value && !error && !loading) { + setToken(value); + } + }, + 300, + [value, error, loading] + ); + + if (error) { + console.error('token info error', chain, token?.symbol, token?.id, error); + } + return [token, setToken] as const; +}; + +export const useSlippage = () => { + const [slippageState, setSlippage] = useState('0.1'); + const slippage = useMemo(() => slippageState || '0.1', [slippageState]); + const [slippageChanged, setSlippageChanged] = useState(false); + + return { + slippageChanged, + setSlippageChanged, + slippageState, + slippage, + setSlippage, + }; +}; + +export interface FeeProps { + fee: '0.3' | '0.1' | '0'; + symbol?: string; +} + +export const useTokenPair = (userAddress: string) => { + const dispatch = useRabbyDispatch(); + const refreshId = useRefreshId(); + + const { initialSelectedChain, oChain } = useRabbySelector((state) => { + return { + initialSelectedChain: state.swap.$$initialSelectedChain, + oChain: state.swap.selectedChain || CHAINS_ENUM.ETH, + }; + }); + const [chain, setChain] = useState(oChain); + const handleChain = (c: CHAINS_ENUM) => { + setChain(c); + dispatch.swap.setSelectedChain(c); + // resetSwapTokens(c); + }; + useAsyncInitializeChainList({ + // NOTICE: now `useTokenPair` is only used for swap page, so we can use `SWAP_SUPPORT_CHAINS` here + supportChains: SWAP_SUPPORT_CHAINS, + onChainInitializedAsync: (firstEnum) => { + // only init chain if it's not cached before + if (!initialSelectedChain) { + handleChain(firstEnum); + } + }, + }); + + const [payToken, setPayToken] = useTokenInfo({ + userAddress, + chain, + defaultToken: getChainDefaultToken(chain), + }); + const [receiveToken, setReceiveToken] = useTokenInfo({ + userAddress, + chain, + }); + + const [payAmount, setPayAmount] = useState(''); + + const [feeRate] = useState('0'); + + const { + slippageChanged, + setSlippageChanged, + slippageState, + slippage, + setSlippage, + } = useSlippage(); + + const [currentProvider, setOriActiveProvider] = useState< + QuoteProvider | undefined + >(); + + const expiredTimer = useRef(); + const [expired, setExpired] = useState(false); + + const setActiveProvider: React.Dispatch< + React.SetStateAction + > = useCallback((p) => { + if (expiredTimer.current) { + clearTimeout(expiredTimer.current); + } + setSlippageChanged(false); + setExpired(false); + expiredTimer.current = setTimeout(() => { + setExpired(true); + }, 1000 * 30); + setOriActiveProvider(p); + }, []); + + const exchangeToken = useCallback(() => { + setPayToken(receiveToken); + setReceiveToken(payToken); + }, [setPayToken, receiveToken, setReceiveToken, payToken]); + + const payTokenIsNativeToken = useMemo(() => { + if (payToken) { + return isSameAddress(payToken.id, CHAINS[chain].nativeTokenAddress); + } + return false; + }, [chain, payToken]); + + const handleAmountChange: React.ChangeEventHandler = useCallback( + (e) => { + const v = e.target.value; + if (!/^\d*(\.\d*)?$/.test(v)) { + return; + } + setPayAmount(v); + }, + [] + ); + + const handleBalance = useCallback(() => { + if (!payTokenIsNativeToken && payToken) { + setPayAmount(tokenAmountBn(payToken).toString(10)); + } + }, [payToken, payTokenIsNativeToken]); + + const isStableCoin = useMemo(() => { + if (payToken?.price && receiveToken?.price) { + return new BigNumber(payToken?.price) + .minus(receiveToken?.price) + .div(payToken?.price) + .abs() + .lte(0.01); + } + return false; + }, [payToken, receiveToken]); + + const [isWrapToken, wrapTokenSymbol] = useMemo(() => { + if (payToken?.id && receiveToken?.id) { + const wrapTokens = [ + WrapTokenAddressMap[chain], + CHAINS[chain].nativeTokenAddress, + ]; + const res = + !!wrapTokens.find((token) => isSameAddress(payToken?.id, token)) && + !!wrapTokens.find((token) => isSameAddress(receiveToken?.id, token)); + return [ + res, + isSameAddress(payToken?.id, WrapTokenAddressMap[chain]) + ? payToken.symbol + : receiveToken.symbol, + ]; + } + return [false, '']; + }, [payToken?.id, receiveToken?.id, chain]); + + const inSufficient = useMemo( + () => + payToken + ? tokenAmountBn(payToken).lt(payAmount) + : new BigNumber(0).lt(payAmount), + [payToken, payAmount] + ); + + const switchChain = useCallback( + (c: CHAINS_ENUM, opts?: { payTokenId?: string; changeTo?: boolean }) => { + handleChain(c); + if (!opts?.changeTo) { + setPayToken({ + ...getChainDefaultToken(c), + ...(opts?.payTokenId ? { id: opts?.payTokenId } : {}), + }); + setReceiveToken(undefined); + } else { + setReceiveToken({ + ...getChainDefaultToken(c), + ...(opts?.payTokenId ? { id: opts?.payTokenId } : {}), + }); + // setPayToken(undefined); + } + setPayAmount(''); + setActiveProvider(undefined); + }, + [setPayToken, setReceiveToken] + ); + + useEffect(() => { + // if (isWrapToken) { + // setFeeRate('0'); + // } else if (isStableCoin) { + // setFeeRate('0.1'); + // } else { + // setFeeRate('0.3'); + // } + + if (isStableCoin) { + setSlippage('0.05'); + } + }, [isWrapToken, isStableCoin]); + + const [quoteList, setQuotesList] = useState< + (TCexQuoteData | TDexQuoteData)[] + >([]); + + useEffect(() => { + setQuotesList([]); + }, [payToken?.id, receiveToken?.id, chain, payAmount]); + + const setQuote = useCallback( + (id: number) => (quote: TCexQuoteData | TDexQuoteData) => { + if (id === fetchIdRef.current) { + setQuotesList((e) => { + const index = e.findIndex((q) => q.name === quote.name); + // setActiveProvider((activeQuote) => { + // if (activeQuote?.name === quote.name) { + // return undefined; + // } + // return activeQuote; + // }); + + const v = { ...quote, loading: false }; + if (index === -1) { + return [...e, v]; + } + e[index] = v; + return [...e]; + }); + } + }, + [] + ); + const visible = useQuoteVisible(); + const settingVisible = useSettingVisible(); + + useEffect(() => { + if (!visible) { + setQuotesList([]); + } + }, [visible]); + + const setRefreshId = useSetRefreshId(); + const { swapTradeList, swapViewList } = useSwapSettings(); + + useDebounce( + () => { + if (!settingVisible) { + setQuotesList([]); + setRefreshId((e) => e + 1); + } + }, + 300, + [swapTradeList, swapViewList, settingVisible] + ); + + const fetchIdRef = useRef(0); + const { getAllQuotes, validSlippage } = useQuoteMethods(); + const { loading: quoteLoading, error: quotesError } = useAsync(async () => { + fetchIdRef.current += 1; + const currentFetchId = fetchIdRef.current; + if ( + visible && + userAddress && + payToken?.id && + receiveToken?.id && + receiveToken && + chain && + payAmount && + feeRate + ) { + // setActiveProvider((e) => (e ? { ...e, halfBetterRate: '' } : e)); + setQuotesList((e) => e.map((q) => ({ ...q, loading: true }))); + return getAllQuotes({ + userAddress, + payToken, + receiveToken, + slippage: slippage || '0.1', + chain, + payAmount: payAmount, + fee: feeRate, + setQuote: setQuote(currentFetchId), + }).finally(() => { + // enableSwapBySlippageChanged(currentFetchId); + }); + } + }, [ + // setActiveProvider, + setQuotesList, + setQuote, + refreshId, + userAddress, + payToken?.id, + receiveToken?.id, + chain, + payAmount, + feeRate, + slippage, + visible, + ]); + + if (quotesError) { + console.error('quotesError', quotesError); + } + + const { + value: slippageValidInfo, + error: slippageValidError, + loading: slippageValidLoading, + } = useAsync(async () => { + if (chain && Number(slippage) && payToken?.id && receiveToken?.id) { + return validSlippage({ + chain, + slippage, + payTokenId: payToken?.id, + receiveTokenId: receiveToken?.id, + }); + } + }, [slippage, chain, payToken?.id, receiveToken?.id, refreshId]); + + useEffect(() => { + setExpired(false); + setActiveProvider(undefined); + setSlippageChanged(false); + }, [payToken?.id, receiveToken?.id, chain, payAmount, inSufficient]); + + const { search } = useLocation(); + const [searchObj] = useState<{ + payTokenId?: string; + chain?: string; + }>(query2obj(search)); + + useEffect(() => { + if (searchObj.chain && searchObj.payTokenId) { + const target = Object.values(CHAINS).find( + (item) => item.serverId === searchObj.chain + ); + if (target) { + setChain(target?.enum); + setPayToken({ + ...getChainDefaultToken(target?.enum), + id: searchObj.payTokenId, + }); + } + } + }, [searchObj?.chain, searchObj?.payTokenId]); + + const rbiSource = useRbiSource(); + + useEffect(() => { + if (rbiSource) { + stats.report('enterSwapDescPage', { + refer: rbiSource, + }); + } + }, [rbiSource]); + + return { + chain, + switchChain, + + payToken, + setPayToken, + receiveToken, + setReceiveToken, + exchangeToken, + payTokenIsNativeToken, + + handleAmountChange, + handleBalance, + payAmount, + + isWrapToken, + wrapTokenSymbol, + inSufficient, + slippageChanged, + setSlippageChanged, + slippageState, + slippage, + setSlippage, + feeRate, + + //quote + quoteLoading, + quoteList, + currentProvider, + setActiveProvider, + + slippageValidInfo, + slippageValidLoading, + + expired, + }; +}; + +function getChainDefaultToken(chain: CHAINS_ENUM) { + const chainInfo = CHAINS[chain]; + return { + id: chainInfo.nativeTokenAddress, + decimals: chainInfo.nativeTokenDecimals, + logo_url: chainInfo.nativeTokenLogo, + symbol: chainInfo.nativeTokenSymbol, + display_symbol: chainInfo.nativeTokenSymbol, + optimized_symbol: chainInfo.nativeTokenSymbol, + is_core: true, + is_verified: true, + is_wallet: true, + amount: 0, + price: 0, + name: chainInfo.nativeTokenSymbol, + chain: chainInfo.serverId, + time_at: 0, + } as TokenItem; +} + +function tokenAmountBn(token: TokenItem) { + return new BigNumber(token?.raw_amount_hex_str || 0, 16).div( + 10 ** (token?.decimals || 1) + ); +} diff --git a/src/ui/views/SwapNew/hooks/verify.tsx b/src/ui/views/SwapNew/hooks/verify.tsx new file mode 100644 index 00000000000..2c3b3197043 --- /dev/null +++ b/src/ui/views/SwapNew/hooks/verify.tsx @@ -0,0 +1,140 @@ +import { isSameAddress } from '@/background/utils'; +import { CHAINS_ENUM, CHAINS } from '@debank/common'; +import { DEX_ENUM } from '@rabby-wallet/rabby-swap'; +import { + decodeCalldata, + QuoteResult, + DecodeCalldataResult, +} from '@rabby-wallet/rabby-swap/dist/quote'; +import { useMemo } from 'react'; +import { getRouter, getSpender, isSwapWrapToken } from './quote'; +import BigNumber from 'bignumber.js'; + +type ValidateTokenParam = { + id: string; + symbol: string; + decimals: number; +}; + +export const useVerifyRouterAndSpender = ( + chain: CHAINS_ENUM, + dexId: DEX_ENUM, + router?: string, + spender?: string, + payTokenId?: string, + receiveTokenId?: string +) => { + const data = useMemo(() => { + if (dexId === DEX_ENUM.WRAPTOKEN) { + return [true, true]; + } + if (!dexId || !router || !spender || !payTokenId || !receiveTokenId) { + return [true, true]; + } + const routerWhitelist = getRouter(dexId, chain); + const spenderWhitelist = getSpender(dexId, chain); + const isNativeToken = isSameAddress( + payTokenId, + CHAINS[chain].nativeTokenAddress + ); + const isWrapTokens = isSwapWrapToken(payTokenId, receiveTokenId, chain); + + return [ + isSameAddress(routerWhitelist, router), + isNativeToken || isWrapTokens + ? true + : isSameAddress(spenderWhitelist, spender), + ]; + }, [chain, dexId, payTokenId, receiveTokenId, router, spender]); + return data; +}; + +const isNativeToken = (chain: CHAINS_ENUM, tokenId: string) => + isSameAddress(tokenId, CHAINS[chain].nativeTokenAddress); + +export const useVerifyCalldata = < + T extends Parameters[1] +>( + data: QuoteResult | null, + dexId: DEX_ENUM | null, + slippage: string | number, + tx?: T +) => { + const callDataResult = useMemo(() => { + if (dexId && dexId !== DEX_ENUM.WRAPTOKEN && tx) { + try { + return decodeCalldata(dexId, tx) as DecodeCalldataResult; + } catch (error) { + return null; + } + } + return null; + }, [dexId, tx]); + + const result = useMemo(() => { + if (slippage && callDataResult && data && tx) { + const estimateMinReceive = new BigNumber(data.toTokenAmount).times( + new BigNumber(1).minus(slippage) + ); + const chain = Object.values(CHAINS).find( + (item) => item.id === tx.chainId + ); + + if (!chain) return true; + + return ( + ((dexId === DEX_ENUM['UNISWAP'] && + isNativeToken(chain.enum, data.fromToken)) || + isSameAddress(callDataResult.fromToken, data.fromToken)) && + callDataResult.fromTokenAmount === data.fromTokenAmount && + isSameAddress(callDataResult.toToken, data.toToken) && + new BigNumber(callDataResult.minReceiveToTokenAmount) + .minus(estimateMinReceive) + .div(estimateMinReceive) + .abs() + .lte(0.05) + ); + } + return true; + }, [callDataResult, data, slippage]); + + return result; +}; + +type VerifySdkParams = { + chain: CHAINS_ENUM; + dexId: DEX_ENUM; + slippage: string | number; + data: QuoteResult | null; + payToken: T; + receiveToken: T; +}; + +export const useVerifySdk = ( + p: VerifySdkParams +) => { + const { chain, dexId, slippage, data, payToken, receiveToken } = p; + + const isWrapTokens = isSwapWrapToken(payToken.id, receiveToken.id, chain); + const actualDexId = isWrapTokens ? DEX_ENUM.WRAPTOKEN : dexId; + + const [routerPass, spenderPass] = useVerifyRouterAndSpender( + chain, + actualDexId, + data?.tx?.to, + data?.spender, + payToken?.id, + receiveToken?.id + ); + + const callDataPass = useVerifyCalldata( + data, + actualDexId, + new BigNumber(slippage).div(100).toFixed(), + data?.tx ? { ...data?.tx, chainId: CHAINS[chain].id } : undefined + ); + + return { + isSdkDataPass: routerPass && spenderPass && callDataPass, + }; +}; diff --git a/src/ui/views/SwapNew/index.tsx b/src/ui/views/SwapNew/index.tsx new file mode 100644 index 00000000000..4d9a27c2624 --- /dev/null +++ b/src/ui/views/SwapNew/index.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { Header } from './Component/Header'; +import { Main } from './Component/Main'; +import { + QuoteVisibleProvider, + RefreshIdProvider, + SettingVisibleProvider, +} from './hooks'; + +const Swap = () => { + return ( + + + +
+
+
+
+
+
+
+ ); +}; +export default Swap; From c1ee5b48e3c8b08a62bfb19ee3252325aa4af5dd Mon Sep 17 00:00:00 2001 From: iower Date: Wed, 27 Sep 2023 14:47:23 +0500 Subject: [PATCH 03/44] 'Select Chain': place chains list --- src/ui/component/ChainSelectorNew/InForm.tsx | 138 ++++++++++ src/ui/component/ChainSelectorNew/Modal.tsx | 222 ++++++++++++++++ .../components/SelectChainItem.tsx | 133 ++++++++++ .../components/SelectChainList.tsx | 140 ++++++++++ .../components/SortableSelectChainItem.tsx | 37 +++ src/ui/component/ChainSelectorNew/index.tsx | 97 +++++++ src/ui/component/ChainSelectorNew/style.less | 239 ++++++++++++++++++ src/ui/component/ChainSelectorNew/tag.tsx | 141 +++++++++++ src/ui/component/index.tsx | 1 + src/ui/views/ApprovalManage/index.tsx | 2 +- src/ui/views/SwapNew/Component/Header.tsx | 2 +- src/ui/views/SwapNew/Component/Main.tsx | 5 +- 12 files changed, 1152 insertions(+), 5 deletions(-) create mode 100644 src/ui/component/ChainSelectorNew/InForm.tsx create mode 100644 src/ui/component/ChainSelectorNew/Modal.tsx create mode 100644 src/ui/component/ChainSelectorNew/components/SelectChainItem.tsx create mode 100644 src/ui/component/ChainSelectorNew/components/SelectChainList.tsx create mode 100644 src/ui/component/ChainSelectorNew/components/SortableSelectChainItem.tsx create mode 100644 src/ui/component/ChainSelectorNew/index.tsx create mode 100644 src/ui/component/ChainSelectorNew/style.less create mode 100644 src/ui/component/ChainSelectorNew/tag.tsx diff --git a/src/ui/component/ChainSelectorNew/InForm.tsx b/src/ui/component/ChainSelectorNew/InForm.tsx new file mode 100644 index 00000000000..dcb441b1899 --- /dev/null +++ b/src/ui/component/ChainSelectorNew/InForm.tsx @@ -0,0 +1,138 @@ +import React, { InsHTMLAttributes, useEffect } from 'react'; +import clsx from 'clsx'; +import { CHAINS, CHAINS_ENUM } from '@debank/common'; + +import { useState } from 'react'; +import { SelectChainListProps } from './components/SelectChainList'; +import ChainSelectorModal from './Modal'; +import styled from 'styled-components'; +import ChainIcon from '@/ui/component/ChainIcon'; +import ImgArrowDown from '@/ui/assets/swap/arrow-down.svg'; +import { useWallet } from '@/ui/utils'; + +const ChainWrapper = styled.div` + height: 40px; + background: #f5f6fa; + border-radius: 6px; + padding: 12px 10px; + width: 100%; + display: flex; + align-items: center; + gap: 8px; + border: 1px solid transparent; + cursor: pointer; + &:hover { + background: rgba(134, 151, 255, 0.2); + } + & > { + .down { + margin-left: auto; + width: 20px; + height: 20px; + } + .name { + color: #13141a; + } + } +`; + +export const ChainRender = ({ + chain, + readonly, + className, + ...other +}: { + chain: CHAINS_ENUM; + readonly: boolean; +} & InsHTMLAttributes) => { + const wallet = useWallet(); + + const [customRPC, setCustomRPC] = useState(''); + const getCustomRPC = async () => { + const rpc = await wallet.getCustomRpcByChain(chain); + setCustomRPC(rpc?.enable ? rpc.url : ''); + }; + useEffect(() => { + getCustomRPC(); + }, [chain]); + return ( + + {/* {CHAINS[chain].name} */} + + {CHAINS[chain].name} + {!readonly && } + + ); +}; + +interface ChainSelectorProps { + value: CHAINS_ENUM; + onChange?(value: CHAINS_ENUM): void; + readonly?: boolean; + showModal?: boolean; + direction?: 'top' | 'bottom'; + supportChains?: SelectChainListProps['supportChains']; + disabledTips?: SelectChainListProps['disabledTips']; + title?: React.ReactNode; +} +export default function ChainSelectorInForm({ + value, + onChange, + readonly = false, + showModal = false, + disabledTips, + title, + supportChains, +}: ChainSelectorProps) { + const [showSelectorModal, setShowSelectorModal] = useState(showModal); + + const handleClickSelector = () => { + if (readonly) return; + setShowSelectorModal(true); + }; + + const handleCancel = () => { + if (readonly) return; + setShowSelectorModal(false); + }; + + const handleChange = (value: CHAINS_ENUM) => { + if (readonly) return; + onChange && onChange(value); + setShowSelectorModal(false); + }; + + return ( + <> + + {!readonly && ( + + )} + + ); +} diff --git a/src/ui/component/ChainSelectorNew/Modal.tsx b/src/ui/component/ChainSelectorNew/Modal.tsx new file mode 100644 index 00000000000..f6d704099a8 --- /dev/null +++ b/src/ui/component/ChainSelectorNew/Modal.tsx @@ -0,0 +1,222 @@ +import { Input } from 'antd'; +import React, { ReactNode, useEffect, useMemo, useState } from 'react'; + +import { useRabbyDispatch, useRabbySelector } from '@/ui/store'; +import { Chain } from 'background/service/openapi'; + +import { CHAINS_ENUM } from 'consts'; +import IconSearch from 'ui/assets/search.svg'; + +import Empty from '../Empty'; +import { + SelectChainList, + SelectChainListProps, +} from './components/SelectChainList'; +import { findChainByEnum, varyAndSortChainItems } from '@/utils/chain'; +import NetSwitchTabs, { + NetSwitchTabsKey, + useSwitchNetTab, +} from '../PillsSwitch/NetSwitchTabs'; +import { useTranslation } from 'react-i18next'; + +interface ChainSelectorModalProps { + visible: boolean; + value?: CHAINS_ENUM; + onCancel(): void; + onChange(val: CHAINS_ENUM): void; + connection?: boolean; + title?: ReactNode; + className?: string; + supportChains?: SelectChainListProps['supportChains']; + disabledTips?: SelectChainListProps['disabledTips']; + hideTestnetTab?: boolean; + showRPCStatus?: boolean; + height?: number; +} + +const useChainSeletorList = ({ + supportChains, + netTabKey, +}: { + supportChains?: Chain['enum'][]; + netTabKey?: NetSwitchTabsKey; +}) => { + const [search, setSearch] = useState(''); + const { pinned, chainBalances } = useRabbySelector((state) => { + return { + pinned: (state.preference.pinnedChain?.filter((item) => + findChainByEnum(item) + ) || []) as CHAINS_ENUM[], + chainBalances: + netTabKey === 'testnet' + ? state.account.testnetMatteredChainBalances + : state.account.matteredChainBalances, + isShowTestnet: state.preference.isShowTestnet, + }; + }); + + const dispatch = useRabbyDispatch(); + + const handleStarChange = (chain: CHAINS_ENUM, value) => { + if (value) { + dispatch.preference.addPinnedChain(chain); + } else { + dispatch.preference.removePinnedChain(chain); + } + }; + const handleSort = (chains: Chain[]) => { + dispatch.preference.updatePinnedChainList(chains.map((item) => item.enum)); + }; + const { allSearched, matteredList, unmatteredList } = useMemo(() => { + const searchKw = search?.trim().toLowerCase(); + const result = varyAndSortChainItems({ + supportChains, + searchKeyword: searchKw, + matteredChainBalances: chainBalances, + pinned, + netTabKey, + }); + + return { + allSearched: result.allSearched, + matteredList: searchKw ? [] : result.matteredList, + unmatteredList: searchKw ? [] : result.unmatteredList, + }; + }, [search, pinned, supportChains, chainBalances, netTabKey]); + + useEffect(() => { + dispatch.preference.getPreference('pinnedChain'); + }, [dispatch]); + + return { + matteredList, + unmatteredList: search?.trim() ? allSearched : unmatteredList, + allSearched, + handleStarChange, + handleSort, + search, + setSearch, + pinned, + }; +}; + +const ChainSelectorModal = ({ + title, + visible, + onCancel, + onChange, + value, + connection = false, + className, + supportChains, + disabledTips, + hideTestnetTab = false, + showRPCStatus = false, + height = 494, +}: ChainSelectorModalProps) => { + const handleCancel = () => { + onCancel(); + }; + + const handleChange = (val: CHAINS_ENUM) => { + onChange(val); + }; + + const { isShowTestnet, selectedTab, onTabChange } = useSwitchNetTab({ + hideTestnetTab, + }); + + const { t } = useTranslation(); + + const { + matteredList, + unmatteredList, + handleStarChange, + handleSort, + search, + setSearch, + pinned, + } = useChainSeletorList({ + supportChains, + netTabKey: selectedTab, + }); + + useEffect(() => { + if (!value || !visible) return; + + const chainItem = findChainByEnum(value); + onTabChange(chainItem?.isTestnet ? 'testnet' : 'mainnet'); + }, [value, visible, onTabChange]); + + const rDispatch = useRabbyDispatch(); + + useEffect(() => { + if (!visible) { + setSearch(''); + } else { + // (async () => { + // // await rDispatch.account.triggerFetchBalanceOnBackground(); + // rDispatch.account.getMatteredChainBalance(); + // })(); + rDispatch.account.getMatteredChainBalance(); + } + }, [visible, rDispatch]); + + return ( + <> +
+ {isShowTestnet && ( + + )} + } + // Search chain + placeholder={t('component.ChainSelectorModal.searchPlaceholder')} + onChange={(e) => setSearch(e.target.value)} + value={search} + allowClear + /> +
+
+ + + {matteredList.length === 0 && unmatteredList.length === 0 ? ( +
+ + {/* No chains */} + {t('component.ChainSelectorModal.noChains')} + +
+ ) : null} +
+ + ); +}; + +export default ChainSelectorModal; diff --git a/src/ui/component/ChainSelectorNew/components/SelectChainItem.tsx b/src/ui/component/ChainSelectorNew/components/SelectChainItem.tsx new file mode 100644 index 00000000000..4d2e030f154 --- /dev/null +++ b/src/ui/component/ChainSelectorNew/components/SelectChainItem.tsx @@ -0,0 +1,133 @@ +import React, { useMemo, forwardRef, HTMLAttributes, useEffect } from 'react'; +import { Chain } from '@/background/service/openapi'; +import { CHAINS_ENUM } from '@debank/common'; +import { Tooltip } from 'antd'; +import clsx from 'clsx'; +import { useRabbyDispatch, useRabbySelector } from '@/ui/store'; +import ChainIcon from '../../ChainIcon'; +import IconCheck from 'ui/assets/check-2.svg'; +import IconPinned from 'ui/assets/icon-pinned.svg'; +import IconPinnedFill from 'ui/assets/icon-pinned-fill.svg'; +import IconChainBalance from 'ui/assets/chain-select/chain-balance.svg'; +import { formatUsdValue } from '@/ui/utils'; + +export type SelectChainItemProps = { + stared?: boolean; + data: Chain; + value?: CHAINS_ENUM; + onStarChange?: (value: boolean) => void; + onChange?: (value: CHAINS_ENUM) => void; + disabled?: boolean; + disabledTips?: string | ((ctx: { chain: Chain }) => string); + showRPCStatus?: boolean; +} & Omit, 'onChange'>; + +export const SelectChainItem = forwardRef( + ( + { + data, + className, + stared, + value, + onStarChange, + onChange, + disabled = false, + disabledTips = 'Coming soon', + showRPCStatus = false, + ...rest + }: SelectChainItemProps, + ref: React.ForwardedRef + ) => { + const { customRPC, cachedChainBalances } = useRabbySelector((s) => ({ + customRPC: s.customRPC.customRPC, + cachedChainBalances: { + mainnet: s.account.matteredChainBalances, + testnet: s.account.testnetMatteredChainBalances, + }, + })); + const dispatch = useRabbyDispatch(); + + useEffect(() => { + dispatch.customRPC.getAllRPC(); + }, []); + + const finalDisabledTips = useMemo(() => { + if (typeof disabledTips === 'function') { + return disabledTips({ chain: data }); + } + + return disabledTips; + }, [disabledTips]); + + const chainBalanceItem = useMemo(() => { + return ( + cachedChainBalances.mainnet?.[data.serverId] || + cachedChainBalances.testnet?.[data.serverId] + ); + }, [cachedChainBalances]); + + return ( + +
!disabled && onChange?.(data.enum)} + > +
+ {showRPCStatus ? ( + + ) : ( + + )} +
+
{data.name}
+ {!!chainBalanceItem?.usd_value && ( +
+ {formatUsdValue(chainBalanceItem?.usd_value +
+ {formatUsdValue(chainBalanceItem?.usd_value || 0)} +
+
+ )} +
+
+ { + e.stopPropagation(); + onStarChange?.(!stared); + }} + /> + {value === data.enum ? ( + + ) : null} +
+
+ ); + } +); diff --git a/src/ui/component/ChainSelectorNew/components/SelectChainList.tsx b/src/ui/component/ChainSelectorNew/components/SelectChainList.tsx new file mode 100644 index 00000000000..c732811bfb1 --- /dev/null +++ b/src/ui/component/ChainSelectorNew/components/SelectChainList.tsx @@ -0,0 +1,140 @@ +import { CHAINS_ENUM } from '@debank/common'; +import { + DndContext, + MeasuringStrategy, + MouseSensor, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import { DragEndEvent } from '@dnd-kit/core/dist/types'; +import { SortableContext } from '@dnd-kit/sortable'; +import { Chain } from 'background/service/openapi'; +import clsx from 'clsx'; +import React, { useMemo } from 'react'; +import { SelectChainItemProps } from './SelectChainItem'; +import { SortableSelectChainItem } from './SortableSelectChainItem'; + +export type SelectChainListProps = { + className?: string; + data: Chain[]; + sortable?: boolean; + stared?: boolean; + onSort?: (data: Chain[]) => void; + value?: CHAINS_ENUM; + onChange?: (value: CHAINS_ENUM) => void; + onStarChange?: (v: CHAINS_ENUM, value: boolean) => void; + pinned: CHAINS_ENUM[]; + supportChains?: CHAINS_ENUM[]; + disabledTips?: SelectChainItemProps['disabledTips']; + showRPCStatus?: boolean; +}; + +export const SelectChainList = (props: SelectChainListProps) => { + const { + data, + className, + onSort, + sortable = false, + value, + onChange, + onStarChange, + pinned, + supportChains, + disabledTips, + showRPCStatus = false, + } = props; + + const items = useMemo(() => { + return data.map((item, index) => ({ + ...item, + index, + })); + }, [data]); + + const handleDragEnd = (event: DragEndEvent) => { + const source = event.active.id; + const destination = event.over?.id; + if (!destination || destination === source) return; + const sourceIndex = items.findIndex( + (item) => item.id.toString() === source + ); + const destinationIndex = items.findIndex( + (item) => item.id.toString() === destination + ); + const newItems = Array.from(items); + const [removed] = newItems.splice(sourceIndex, 1); + newItems.splice(destinationIndex, 0, removed); + onSort?.(newItems); + }; + const sensors = useSensors( + useSensor(MouseSensor, { + activationConstraint: { + distance: 3, + }, + }) + ); + + if (data?.length <= 0) { + return null; + } + if (sortable) { + return ( +
+ + ({ ...item, id: String(item.id) }))} + > + {items.map((item) => { + return ( + chain === item.enum)} + key={item.id} + data={item} + value={value} + onStarChange={(v) => { + onStarChange?.(item.enum, v); + }} + onChange={onChange} + disabled={ + supportChains ? !supportChains.includes(item.enum) : false + } + disabledTips={disabledTips} + showRPCStatus={showRPCStatus} + > + ); + })} + + +
+ ); + } + return ( +
+ {items.map((item) => { + return ( + { + onStarChange?.(item.enum, v); + }} + stared={!!pinned.find((chain) => chain === item.enum)} + onChange={onChange} + disabled={ + supportChains ? !supportChains.includes(item.enum) : false + } + disabledTips={disabledTips} + showRPCStatus={showRPCStatus} + > + ); + })} +
+ ); +}; diff --git a/src/ui/component/ChainSelectorNew/components/SortableSelectChainItem.tsx b/src/ui/component/ChainSelectorNew/components/SortableSelectChainItem.tsx new file mode 100644 index 00000000000..36cc284c92c --- /dev/null +++ b/src/ui/component/ChainSelectorNew/components/SortableSelectChainItem.tsx @@ -0,0 +1,37 @@ +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import React from 'react'; +import { SelectChainItem, SelectChainItemProps } from './SelectChainItem'; + +export const SortableSelectChainItem = ({ + data, + className, + ...rest +}: SelectChainItemProps) => { + const { + attributes, + setNodeRef, + transform, + transition, + listeners, + } = useSortable({ + id: data?.id + '', + }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + + return ( + + ); +}; diff --git a/src/ui/component/ChainSelectorNew/index.tsx b/src/ui/component/ChainSelectorNew/index.tsx new file mode 100644 index 00000000000..d7fc0a5bfc4 --- /dev/null +++ b/src/ui/component/ChainSelectorNew/index.tsx @@ -0,0 +1,97 @@ +import React, { ReactNode, useEffect, useState } from 'react'; +import { CHAINS_ENUM } from 'consts'; +import { useHover, useWallet } from 'ui/utils'; +import { ReactComponent as ArrowDownSVG } from '@/ui/assets/dashboard/arrow-down.svg'; +import ChainSelectorModal from './Modal'; +import ChainIcon from '../ChainIcon'; + +import './style.less'; +import clsx from 'clsx'; +import { findChainByEnum } from '@/utils/chain'; +import { ChainSelectorPurpose } from '@/ui/hooks/useChain'; + +interface ChainSelectorProps { + value: CHAINS_ENUM; + onChange(value: CHAINS_ENUM): void; + direction?: 'top' | 'bottom'; + connection?: boolean; + showModal?: boolean; + className?: string; + title?: ReactNode; + onAfterOpen?: () => void; + showRPCStatus?: boolean; + modalHeight?: number; +} + +const ChainSelector = ({ + title, + value, + onChange, + connection = false, + showModal = false, + className = '', + onAfterOpen, + showRPCStatus = false, + modalHeight, +}: ChainSelectorProps) => { + const [showSelectorModal, setShowSelectorModal] = useState(showModal); + const [isHovering, hoverProps] = useHover(); + const [customRPC, setCustomRPC] = useState(''); + const wallet = useWallet(); + + const handleClickSelector = () => { + setShowSelectorModal(true); + onAfterOpen?.(); + }; + + const handleCancel = () => { + setShowSelectorModal(false); + }; + + const handleChange = (value: CHAINS_ENUM) => { + onChange(value); + setShowSelectorModal(false); + }; + + const getCustomRPC = async () => { + const rpc = await wallet.getCustomRpcByChain(value); + setCustomRPC(rpc?.enable ? rpc.url : ''); + }; + + useEffect(() => { + getCustomRPC(); + }, [value]); + + return ( + <> +
+
+ +
+ {findChainByEnum(value)?.name} + +
+ + + ); +}; + +export default ChainSelector; diff --git a/src/ui/component/ChainSelectorNew/style.less b/src/ui/component/ChainSelectorNew/style.less new file mode 100644 index 00000000000..1bf3ad49f0b --- /dev/null +++ b/src/ui/component/ChainSelectorNew/style.less @@ -0,0 +1,239 @@ +@import '../../style/var.less'; + +.chain-selector { + height: 32px; + cursor: pointer; + display: flex; + align-items: center; + flex-shrink: 0; + -webkit-user-select: none; + background: rgba(134, 151, 255, 0.12); + border: 1px solid #e5e9ef; + border-radius: 6px; + padding: 8px 4px 8px 8px; + font-weight: 500; + .chain-logo { + width: 16px; + height: 16px; + margin-right: 6px; + } + .icon { + width: 16px; + height: 16px; + margin-left: 2px; + } +} + +.hover { + background: rgba(134, 151, 255, 0.1); + border: 1px solid rgba(134, 151, 255, 0.5); +} +.chain-selector-options { + width: 100%; + max-height: 376px; + margin-bottom: 0; + display: flex; + flex-wrap: wrap; + overflow-y: auto; + &::-webkit-scrollbar { + display: none !important; + } +} + +.chain-selector__modal { + .ant-drawer-content-wrapper { + max-height: calc(100vh - 40px); + height: 494px !important; + } + header { + padding-top: 8px; + padding-bottom: 24px; + position: sticky; + top: 0; + z-index: 1; + background-color: #fff; + .ant-input-affix-wrapper { + border: 1px solid #e5e9ef; + border-radius: 6px; + height: 36px; + } + + .ant-input-affix-wrapper:focus, + .ant-input-affix-wrapper-focused { + border-color: #b0bdff; + } + } + .ant-drawer-header { + padding: 12px 20px; + background: linear-gradient( + 180deg, + #eff1ff 0%, + rgba(238, 234, 255, 0) 111.46% + ); + border: none; + .chain-selector-tips { + font-weight: 400; + font-size: 12px; + line-height: 14px; + color: @color-comment-1; + margin-bottom: 2px; + } + .chain-selector-site { + font-weight: 500; + font-size: 13px; + line-height: 15px; + color: @color-title; + } + } + .ant-drawer-body { + padding-top: 0 !important; + &::-webkit-scrollbar { + display: none !important; + } + } + .ant-drawer-content { + border-radius: 16px 16px 0 0; + } + .no-pinned-container { + width: 360px; + height: 56px; + background: @color-bg; + border-radius: 6px; + font-weight: normal; + font-size: 12px; + line-height: 14px; + text-align: center; + color: @color-comment-1; + display: flex; + justify-content: center; + align-items: center; + margin-bottom: 20px; + } + .cardg { + background: @color-bg; + } + .all-chais { + width: 100%; + background: #ffffff; + display: flex; + justify-content: center; + margin-top: 16px; + + span { + font-style: normal; + font-weight: normal; + font-size: 12px; + line-height: 14px; + text-align: center; + color: @color-comment-1; + cursor: pointer; + } + } + .ant-modal-body { + max-height: 450px; + } + .tip { + margin-top: 16px; + } +} +.chain-tag-selector { + min-width: 116px; + display: inline-flex; + height: 40px; + background: #9ba9fd; + border-radius: 6px 6px 0px 0px; + padding: 8px 6px 8px 12px; + font-size: 12px; + line-height: 14px; + color: rgba(255, 255, 255, 0.7); + cursor: pointer; + .chain-tag-selector__name { + color: #fff; + font-weight: 500; + margin-left: 5px; + } + .icon-arrow-down { + path { + width: 14px; + height: 14px; + fill: #fff; + } + } +} + +.select-chain { + &-list { + border-radius: 6px; + & + & { + margin-top: 24px; + } + } + &-item { + display: flex; + align-items: center; + padding: 14px 16px; + cursor: pointer; + border-radius: 4px; + position: relative; + border: 1px solid transparent; + height: 56px; + + &::after { + content: ''; + position: absolute; + bottom: -1px; + left: 16px; + right: 16px; + border-bottom: 1px solid @color-border; + } + + .chain-icon-comp { + img { + width: 28px; + height: 28px; + } + } + + &-icon { + width: 24px; + height: 24px; + } + &-info { + margin-left: 12px; + } + &-name { + font-weight: 500; + font-size: 15px; + line-height: 18px; + color: @color-title; + } + &-balance { + color: #707280; + font-size: 12px; + display: flex; + align-items: center; + justify-content: flex-start; + } + &-star { + margin-left: 8px; + display: none; + } + &-checked { + margin-left: 16px; + } + &:hover { + // background: rgba(134, 151, 255, 0.1); + background-color: #dce0f9; + border: 1px solid #8697ff; + &::after { + display: none; + } + .select-chain-item-star { + display: block; + } + } + &:last-child::after { + display: none; + } + } +} diff --git a/src/ui/component/ChainSelectorNew/tag.tsx b/src/ui/component/ChainSelectorNew/tag.tsx new file mode 100644 index 00000000000..da8b5ecbe67 --- /dev/null +++ b/src/ui/component/ChainSelectorNew/tag.tsx @@ -0,0 +1,141 @@ +import React, { useState } from 'react'; +import { CHAINS_ENUM, CHAINS } from 'consts'; +import { SvgIconArrowDownTriangle } from 'ui/assets'; +import Modal from './Modal'; + +import './style.less'; +import { SelectChainListProps } from './components/SelectChainList'; +import { useRabbySelector } from '@/ui/store'; +import { DEX_SUPPORT_CHAINS } from '@/constant/dex-swap'; +import { ReactComponent as SvgIconSwapArrowDownTriangle } from '@/ui/assets/swap/arrow-caret-down2.svg'; +import { findChainByEnum } from '@/utils/chain'; + +interface ChainSelectorProps { + value: CHAINS_ENUM; + onChange?(value: CHAINS_ENUM): void; + readonly?: boolean; + showModal?: boolean; + direction?: 'top' | 'bottom'; + supportChains?: SelectChainListProps['supportChains']; + disabledTips?: SelectChainListProps['disabledTips']; + title?: React.ReactNode; +} + +const ChainSelector = ({ + value, + onChange, + readonly = false, + showModal = false, + supportChains, +}: ChainSelectorProps) => { + const [showSelectorModal, setShowSelectorModal] = useState(showModal); + + const handleClickSelector = () => { + if (readonly) return; + setShowSelectorModal(true); + }; + + const handleCancel = () => { + if (readonly) return; + setShowSelectorModal(false); + }; + + const handleChange = (value: CHAINS_ENUM) => { + if (readonly) return; + onChange && onChange(value); + setShowSelectorModal(false); + }; + + return ( + <> +
+ On{' '} + + {findChainByEnum(value)?.name || ''} + + {!readonly && ( + + )} +
+ {!readonly && ( + + )} + + ); +}; + +export const SwapChainSelector = ({ + value, + onChange, + readonly = false, + showModal = false, + disabledTips, + title, +}: // supportChains, +ChainSelectorProps) => { + const [showSelectorModal, setShowSelectorModal] = useState(showModal); + + const handleClickSelector = () => { + if (readonly) return; + setShowSelectorModal(true); + }; + + const handleCancel = () => { + if (readonly) return; + setShowSelectorModal(false); + }; + + const handleChange = (value: CHAINS_ENUM) => { + if (readonly) return; + onChange && onChange(value); + setShowSelectorModal(false); + }; + + const dexId = useRabbySelector((s) => s.swap.selectedDex); + + if (!dexId) { + return null; + } + const supportChains = DEX_SUPPORT_CHAINS[dexId]; + + const chainItem = React.useMemo(() => { + return findChainByEnum(value); + }, [value]); + + return ( + <> +
+ + + {chainItem?.name || ''} + + {!readonly && } +
+ {!readonly && ( + + )} + + ); +}; + +export default ChainSelector; diff --git a/src/ui/component/index.tsx b/src/ui/component/index.tsx index 0541140ff6b..cb4904fdfdd 100644 --- a/src/ui/component/index.tsx +++ b/src/ui/component/index.tsx @@ -9,6 +9,7 @@ export { default as PageHeader } from './PageHeader'; export { default as Field } from './Field'; export { default as AddAddressOptions } from './AddAddressOptions'; export { default as ChainSelector } from './ChainSelector'; +export { default as ChainSelectorNew } from './ChainSelectorNew'; export { default as AuthenticationModal } from './AuthenticationModal'; export { default as Uploader } from './Uploader'; export { default as FieldCheckbox } from './FieldCheckbox'; diff --git a/src/ui/views/ApprovalManage/index.tsx b/src/ui/views/ApprovalManage/index.tsx index 21b51818784..b2030216de5 100644 --- a/src/ui/views/ApprovalManage/index.tsx +++ b/src/ui/views/ApprovalManage/index.tsx @@ -409,7 +409,7 @@ const ApprovalManage = () => { {subTitle} -
+
{loading && } diff --git a/src/ui/views/SwapNew/Component/Header.tsx b/src/ui/views/SwapNew/Component/Header.tsx index 483549b0819..1af49b4dd0c 100644 --- a/src/ui/views/SwapNew/Component/Header.tsx +++ b/src/ui/views/SwapNew/Component/Header.tsx @@ -35,7 +35,7 @@ export const Header = () => {
} > - From Chain +
Select Chain
{
From 15013bdcb910988dfc9aa7e6d80afdbeeafa69f1 Mon Sep 17 00:00:00 2001 From: iower Date: Wed, 27 Sep 2023 16:21:50 +0500 Subject: [PATCH 04/44] Improve styles --- src/ui/component/ChainSelectorNew/InForm.tsx | 10 +++++----- src/ui/component/ChainSelectorNew/Modal.tsx | 1 + src/ui/component/ChainSelectorNew/style.less | 8 ++++++++ src/ui/views/SwapNew/Component/Main.tsx | 3 +-- 4 files changed, 15 insertions(+), 7 deletions(-) diff --git a/src/ui/component/ChainSelectorNew/InForm.tsx b/src/ui/component/ChainSelectorNew/InForm.tsx index dcb441b1899..f53fb1bdeea 100644 --- a/src/ui/component/ChainSelectorNew/InForm.tsx +++ b/src/ui/component/ChainSelectorNew/InForm.tsx @@ -117,11 +117,6 @@ export default function ChainSelectorInForm({ return ( <> - {!readonly && ( )} + ); } diff --git a/src/ui/component/ChainSelectorNew/Modal.tsx b/src/ui/component/ChainSelectorNew/Modal.tsx index f6d704099a8..14b71a47341 100644 --- a/src/ui/component/ChainSelectorNew/Modal.tsx +++ b/src/ui/component/ChainSelectorNew/Modal.tsx @@ -173,6 +173,7 @@ const ChainSelectorModal = ({ /> )} } // Search chain placeholder={t('component.ChainSelectorModal.searchPlaceholder')} diff --git a/src/ui/component/ChainSelectorNew/style.less b/src/ui/component/ChainSelectorNew/style.less index 1bf3ad49f0b..2990a29d6df 100644 --- a/src/ui/component/ChainSelectorNew/style.less +++ b/src/ui/component/ChainSelectorNew/style.less @@ -237,3 +237,11 @@ } } } + +.select-chain-input { + .ant-input { + height: 48px; + background: transparent; + color: white; + } +} diff --git a/src/ui/views/SwapNew/Component/Main.tsx b/src/ui/views/SwapNew/Component/Main.tsx index d80dd75ee6b..9c22fcb7c82 100644 --- a/src/ui/views/SwapNew/Component/Main.tsx +++ b/src/ui/views/SwapNew/Component/Main.tsx @@ -282,8 +282,7 @@ export const Main = () => { : 'pb-[110px]' )} > -
-
{t('page.swap.chain')}
+
Date: Wed, 27 Sep 2023 16:24:13 +0500 Subject: [PATCH 05/44] Remove pin and check --- .../component/ChainSelectorNew/components/SelectChainItem.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/ui/component/ChainSelectorNew/components/SelectChainItem.tsx b/src/ui/component/ChainSelectorNew/components/SelectChainItem.tsx index 4d2e030f154..7660c073f16 100644 --- a/src/ui/component/ChainSelectorNew/components/SelectChainItem.tsx +++ b/src/ui/component/ChainSelectorNew/components/SelectChainItem.tsx @@ -112,6 +112,7 @@ export const SelectChainItem = forwardRef( )}
+ {/* ) : null} + */}
); From 8b4d9a2875cb507b9b5be07c159febc726f8d5d9 Mon Sep 17 00:00:00 2001 From: iower Date: Wed, 27 Sep 2023 19:56:23 +0500 Subject: [PATCH 06/44] Styles --- src/ui/component/ChainSelectorNew/style.less | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/ui/component/ChainSelectorNew/style.less b/src/ui/component/ChainSelectorNew/style.less index 2990a29d6df..95c717e4ea3 100644 --- a/src/ui/component/ChainSelectorNew/style.less +++ b/src/ui/component/ChainSelectorNew/style.less @@ -171,9 +171,9 @@ &-item { display: flex; align-items: center; - padding: 14px 16px; + padding: 12px; cursor: pointer; - border-radius: 4px; + border-radius: 6px; position: relative; border: 1px solid transparent; height: 56px; @@ -202,10 +202,10 @@ margin-left: 12px; } &-name { - font-weight: 500; - font-size: 15px; - line-height: 18px; - color: @color-title; + font-weight: normal; + font-size: 14px; + line-height: 20px; + color: #ccc; } &-balance { color: #707280; @@ -223,8 +223,8 @@ } &:hover { // background: rgba(134, 151, 255, 0.1); - background-color: #dce0f9; - border: 1px solid #8697ff; + background-color: #1F1F1F; + border: 1px solid #333; &::after { display: none; } From 8a3071d151f4cd08d13310847f08c231a6d428ae Mon Sep 17 00:00:00 2001 From: iower Date: Wed, 27 Sep 2023 20:12:27 +0500 Subject: [PATCH 07/44] Hide unsupported chains --- .../ChainSelectorNew/components/SelectChainList.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/ui/component/ChainSelectorNew/components/SelectChainList.tsx b/src/ui/component/ChainSelectorNew/components/SelectChainList.tsx index c732811bfb1..47bd4f1240e 100644 --- a/src/ui/component/ChainSelectorNew/components/SelectChainList.tsx +++ b/src/ui/component/ChainSelectorNew/components/SelectChainList.tsx @@ -45,10 +45,12 @@ export const SelectChainList = (props: SelectChainListProps) => { } = props; const items = useMemo(() => { - return data.map((item, index) => ({ - ...item, - index, - })); + return data + .map((item, index) => ({ + ...item, + index, + })) + .filter((item) => (supportChains || []).includes(item.enum)); }, [data]); const handleDragEnd = (event: DragEndEvent) => { From 786a54e76ac74f751db13af16c37f058e6b77c2e Mon Sep 17 00:00:00 2001 From: iower Date: Wed, 27 Sep 2023 20:30:58 +0500 Subject: [PATCH 08/44] Chains: nothing found case --- src/ui/component/ChainSelectorNew/Modal.tsx | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/ui/component/ChainSelectorNew/Modal.tsx b/src/ui/component/ChainSelectorNew/Modal.tsx index 14b71a47341..e815bde288a 100644 --- a/src/ui/component/ChainSelectorNew/Modal.tsx +++ b/src/ui/component/ChainSelectorNew/Modal.tsx @@ -7,7 +7,6 @@ import { Chain } from 'background/service/openapi'; import { CHAINS_ENUM } from 'consts'; import IconSearch from 'ui/assets/search.svg'; -import Empty from '../Empty'; import { SelectChainList, SelectChainListProps, @@ -208,12 +207,7 @@ const ChainSelectorModal = ({ showRPCStatus={showRPCStatus} > {matteredList.length === 0 && unmatteredList.length === 0 ? ( -
- - {/* No chains */} - {t('component.ChainSelectorModal.noChains')} - -
+
Nothing found
) : null}
From 251bee82731bb514c1de9ace3ae3db7377eb3c04 Mon Sep 17 00:00:00 2001 From: iower Date: Wed, 27 Sep 2023 20:38:40 +0500 Subject: [PATCH 09/44] Unfix 'Get quotes' --- src/ui/views/SwapNew/Component/Main.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ui/views/SwapNew/Component/Main.tsx b/src/ui/views/SwapNew/Component/Main.tsx index 9c22fcb7c82..a22a0f05d81 100644 --- a/src/ui/views/SwapNew/Component/Main.tsx +++ b/src/ui/views/SwapNew/Component/Main.tsx @@ -279,7 +279,7 @@ export const Main = () => { ? '' : activeProvider?.shouldApproveToken ? 'pb-[130px]' - : 'pb-[110px]' + : 'pb-24' )} >
@@ -460,7 +460,7 @@ export const Main = () => {
From 05eda635b9371910ecd5e2e0a10e615fd23e8d31 Mon Sep 17 00:00:00 2001 From: iower Date: Thu, 28 Sep 2023 11:55:56 +0500 Subject: [PATCH 10/44] viaSwap model (steps) --- src/ui/models/index.ts | 4 +++- src/ui/models/via.ts | 27 +++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/ui/models/index.ts b/src/ui/models/index.ts index 0262bcc43e6..97615ad66aa 100644 --- a/src/ui/models/index.ts +++ b/src/ui/models/index.ts @@ -18,7 +18,7 @@ import { swap } from './swap'; import { customRPC } from './customRPC'; import { securityEngine } from './securityEngine'; import { sign } from './sign'; -import { viaScore } from './via'; +import { viaScore, viaSwap } from './via'; export interface RootModel extends Models { app: typeof app; @@ -40,6 +40,7 @@ export interface RootModel extends Models { securityEngine: typeof securityEngine; sign: typeof sign; viaScore: typeof viaScore; + viaSwap: typeof viaSwap; } export const models: RootModel = { @@ -62,4 +63,5 @@ export const models: RootModel = { securityEngine, sign, viaScore, + viaSwap, }; diff --git a/src/ui/models/via.ts b/src/ui/models/via.ts index 594069e8436..1dd564023c6 100644 --- a/src/ui/models/via.ts +++ b/src/ui/models/via.ts @@ -128,3 +128,30 @@ export const viaScore = createModel()({ }, }), }); + +type ViaSwapStep = 'CHAIN' | 'FROM_TOKEN' | 'TO_TOKEN' | 'AMOUNT'; + +interface ViaSwap { + step: ViaSwapStep; +} + +export const viaSwap = createModel()({ + name: 'viaSwap', + state: { + step: 'CHAIN', + } as ViaSwap, + reducers: { + setStep(state, step: ViaSwapStep) { + return { + ...state, + step, + }; + }, + }, + selectors: (slice) => ({ + getStep() { + return slice((state) => state.step); + }, + }), + effects: (dispatch) => ({}), +}); From 5562aa022fc2523c970e9fb4043beda7ebc449b8 Mon Sep 17 00:00:00 2001 From: iower Date: Thu, 28 Sep 2023 13:14:02 +0500 Subject: [PATCH 11/44] SwapRenew --- src/ui/views/MainRoute.tsx | 4 + src/ui/views/SwapRenew/Component/Header.tsx | 56 ++ src/ui/views/SwapRenew/Component/History.tsx | 285 +++++++ .../views/SwapRenew/Component/IconRefresh.tsx | 95 +++ .../SwapRenew/Component/InsufficientTip.tsx | 20 + src/ui/views/SwapRenew/Component/Main.tsx | 546 ++++++++++++++ .../views/SwapRenew/Component/QuoteItem.tsx | 707 ++++++++++++++++++ .../views/SwapRenew/Component/QuoteLogo.tsx | 40 + src/ui/views/SwapRenew/Component/Quotes.tsx | 282 +++++++ .../SwapRenew/Component/ReceiveDetail.tsx | 383 ++++++++++ src/ui/views/SwapRenew/Component/Slippage.tsx | 226 ++++++ .../views/SwapRenew/Component/TokenRender.tsx | 85 +++ .../SwapRenew/Component/TradingSettings.tsx | 165 ++++ src/ui/views/SwapRenew/Component/loading.tsx | 80 ++ src/ui/views/SwapRenew/hooks/context.tsx | 23 + src/ui/views/SwapRenew/hooks/history.tsx | 104 +++ src/ui/views/SwapRenew/hooks/index.tsx | 7 + src/ui/views/SwapRenew/hooks/quote.tsx | 642 ++++++++++++++++ src/ui/views/SwapRenew/hooks/settings.tsx | 21 + src/ui/views/SwapRenew/hooks/swapReport.tsx | 15 + src/ui/views/SwapRenew/hooks/token.tsx | 478 ++++++++++++ src/ui/views/SwapRenew/hooks/verify.tsx | 140 ++++ src/ui/views/SwapRenew/index.tsx | 24 + 23 files changed, 4428 insertions(+) create mode 100644 src/ui/views/SwapRenew/Component/Header.tsx create mode 100644 src/ui/views/SwapRenew/Component/History.tsx create mode 100644 src/ui/views/SwapRenew/Component/IconRefresh.tsx create mode 100644 src/ui/views/SwapRenew/Component/InsufficientTip.tsx create mode 100644 src/ui/views/SwapRenew/Component/Main.tsx create mode 100644 src/ui/views/SwapRenew/Component/QuoteItem.tsx create mode 100644 src/ui/views/SwapRenew/Component/QuoteLogo.tsx create mode 100644 src/ui/views/SwapRenew/Component/Quotes.tsx create mode 100644 src/ui/views/SwapRenew/Component/ReceiveDetail.tsx create mode 100644 src/ui/views/SwapRenew/Component/Slippage.tsx create mode 100644 src/ui/views/SwapRenew/Component/TokenRender.tsx create mode 100644 src/ui/views/SwapRenew/Component/TradingSettings.tsx create mode 100644 src/ui/views/SwapRenew/Component/loading.tsx create mode 100644 src/ui/views/SwapRenew/hooks/context.tsx create mode 100644 src/ui/views/SwapRenew/hooks/history.tsx create mode 100644 src/ui/views/SwapRenew/hooks/index.tsx create mode 100644 src/ui/views/SwapRenew/hooks/quote.tsx create mode 100644 src/ui/views/SwapRenew/hooks/settings.tsx create mode 100644 src/ui/views/SwapRenew/hooks/swapReport.tsx create mode 100644 src/ui/views/SwapRenew/hooks/token.tsx create mode 100644 src/ui/views/SwapRenew/hooks/verify.tsx create mode 100644 src/ui/views/SwapRenew/index.tsx diff --git a/src/ui/views/MainRoute.tsx b/src/ui/views/MainRoute.tsx index c42072a3413..539997eb276 100644 --- a/src/ui/views/MainRoute.tsx +++ b/src/ui/views/MainRoute.tsx @@ -48,6 +48,7 @@ import AddressBackupMnemonics from './AddressBackup/Mnemonics'; import AddressBackupPrivateKey from './AddressBackup/PrivateKey'; import Swap from './Swap'; import SwapNew from './SwapNew'; +import SwapRenew from './SwapRenew'; import { getUiType, useWallet } from '../utils'; import GasTopUp from './GasTopUp'; import ApprovalManage from './ApprovalManage'; @@ -284,6 +285,9 @@ const Main = () => { + + + diff --git a/src/ui/views/SwapRenew/Component/Header.tsx b/src/ui/views/SwapRenew/Component/Header.tsx new file mode 100644 index 00000000000..5c13bb27a46 --- /dev/null +++ b/src/ui/views/SwapRenew/Component/Header.tsx @@ -0,0 +1,56 @@ +import { ReactComponent as IconSwapSettings } from '@/ui/assets/swap/settings.svg'; +import { ReactComponent as IconSwapHistory } from '@/ui/assets/swap/history.svg'; + +import { PageHeader } from '@/ui/component'; +import React, { useCallback, useState } from 'react'; +import { TradingSettings } from './TradingSettings'; +import { useSetSettingVisible, useSettingVisible } from '../hooks'; +import { SwapTxHistory } from './History'; +import { useTranslation } from 'react-i18next'; + +export const Header = () => { + const visible = useSettingVisible(); + const setVisible = useSetSettingVisible(); + + const [historyVisible, setHistoryVisible] = useState(false); + const { t } = useTranslation(); + + return ( + <> + + { + setHistoryVisible(true); + }, [])} + /> + { + setVisible(true); + }, [])} + /> +
+ } + > + {t('page.swap.title')} + + { + setVisible(false); + }, [])} + /> + { + setHistoryVisible(false); + }, [])} + /> + + ); +}; diff --git a/src/ui/views/SwapRenew/Component/History.tsx b/src/ui/views/SwapRenew/Component/History.tsx new file mode 100644 index 00000000000..76022d34370 --- /dev/null +++ b/src/ui/views/SwapRenew/Component/History.tsx @@ -0,0 +1,285 @@ +import { Popup, TokenWithChain } from '@/ui/component'; +import React, { forwardRef, useMemo } from 'react'; +import { useSwapHistory } from '../hooks'; +import { SwapItem, TokenItem } from '@rabby-wallet/rabby-api/dist/types'; +import { CHAINS_LIST } from '@debank/common'; +import { formatAmount, formatUsdValue, openInTab, sinceTime } from '@/ui/utils'; +import { getTokenSymbol } from '@/ui/utils/token'; +import { TooltipWithMagnetArrow } from '@/ui/component/Tooltip/TooltipWithMagnetArrow'; +import ImgPending from 'ui/assets/swap/pending.svg'; +import ImgCompleted from 'ui/assets/swap/completed.svg'; +import ImgEmpty from 'ui/assets/swap/empty.svg'; + +import { ReactComponent as RcIconSwapArrow } from 'ui/assets/swap/arrow-right.svg'; + +import clsx from 'clsx'; +import SkeletonInput from 'antd/lib/skeleton/Input'; +import { ellipsis } from '@/ui/utils/address'; +import BigNumber from 'bignumber.js'; +import { useTranslation } from 'react-i18next'; + +const TokenCost = ({ + payToken, + receiveToken, + payTokenAmount, + receiveTokenAmount, + loading = false, + actual = false, +}: { + payToken: TokenItem; + receiveToken: TokenItem; + payTokenAmount?: number; + receiveTokenAmount?: number; + loading?: boolean; + actual?: boolean; +}) => { + if (loading) { + return ( + + ); + } + return ( +
+ +
+ {formatAmount(payTokenAmount || '0')} {getTokenSymbol(payToken)} +
+ + +
+ {formatAmount(receiveTokenAmount || '0')} {getTokenSymbol(receiveToken)} +
+
+ ); +}; + +interface TransactionProps { + data: SwapItem; +} +const Transaction = forwardRef( + ({ data }, ref) => { + const isPending = data.status === 'Pending'; + const isCompleted = data?.status === 'Completed'; + const time = data?.finished_at || data?.create_at; + const targetDex = data?.dex_id; + const txId = data?.tx_id; + const chainItem = useMemo( + () => CHAINS_LIST.find((e) => e.serverId === data?.chain), + [data?.chain] + ); + const chainName = chainItem?.name || ''; + const scanLink = useMemo(() => chainItem?.scanLink.replace('_s_', ''), [ + chainItem?.scanLink, + ]); + const loading = data?.status !== 'Finished'; + + const gasUsed = useMemo(() => { + if (data?.gas) { + return `${formatAmount(data.gas.native_gas_fee)} ${getTokenSymbol( + data?.gas.native_token + )} (${formatUsdValue(data.gas.usd_gas_fee)})`; + } + return ''; + }, [data?.gas]); + + const gotoScan = React.useCallback(() => { + if (scanLink && txId) { + openInTab(scanLink + txId); + } + }, []); + + const slippagePercent = useMemo( + () => new BigNumber(data.quote.slippage).times(100).toString(10) + '%', + [data?.quote?.slippage] + ); + const actualSlippagePercent = useMemo( + () => new BigNumber(data?.actual?.slippage).times(100).toString(10) + '%', + [data?.quote?.slippage] + ); + + const { t } = useTranslation(); + + return ( +
+
+
+ {isPending && ( + +
+ loading + {t('page.swap.Pending')} +
+
+ )} + {isCompleted && ( + +
+ + {t('page.swap.Completed')} +
+
+ )} + {!isPending && sinceTime(time)} +
+ {!!targetDex && ( + + {targetDex} + + )} +
+ +
+ {t('page.swap.estimate')} +
+ +
+
+ +
+ {t('page.swap.actual')} +
+ +
+
+ +
+
+ {t('page.swap.slippage_tolerance')} {slippagePercent} +
+
+ {t('page.swap.actual-slippage')} + {loading ? ( + + ) : ( + {actualSlippagePercent} + )} +
+
+ +
+ + {chainName}:{' '} + + {ellipsis(txId)} + + + + {!loading ? ( + + {t('page.swap.gas-fee', { gasUsed })} + + ) : ( + + {t('page.swap.gas-x-price', { + price: data?.gas?.gas_price || '', + })} + + )} +
+
+ ); + } +); + +const HistoryList = () => { + const { txList, loading, loadingMore, ref } = useSwapHistory(); + const { t } = useTranslation(); + if (!loading && (!txList || !txList?.list?.length)) { + return ( +
+ +

+ {t('page.swap.no-transaction-records')} +

+
+ ); + } + + console.log('txList?.list', txList?.list); + + return ( +
+ {txList?.list?.map((swap, idx) => ( + + ))} + {((loading && !txList) || loadingMore) && ( + <> + + + + )} +
+ ); +}; + +export const SwapTxHistory = ({ + visible, + onClose, +}: { + visible: boolean; + onClose: () => void; +}) => { + const { t } = useTranslation(); + return ( + + + + ); +}; diff --git a/src/ui/views/SwapRenew/Component/IconRefresh.tsx b/src/ui/views/SwapRenew/Component/IconRefresh.tsx new file mode 100644 index 00000000000..e87a5de8acd --- /dev/null +++ b/src/ui/views/SwapRenew/Component/IconRefresh.tsx @@ -0,0 +1,95 @@ +import clsx from 'clsx'; +import React, { memo } from 'react'; + +export const IconRefresh = memo((props: React.SVGProps) => { + const { className, ...other } = props; + + return ( + + + + + + + + + + + + + + + + + + + ); +}); diff --git a/src/ui/views/SwapRenew/Component/InsufficientTip.tsx b/src/ui/views/SwapRenew/Component/InsufficientTip.tsx new file mode 100644 index 00000000000..d73122f2e05 --- /dev/null +++ b/src/ui/views/SwapRenew/Component/InsufficientTip.tsx @@ -0,0 +1,20 @@ +import ImgWarning from 'ui/assets/swap/warn.svg'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +export const InSufficientTip = ({ + inSufficient, +}: { + inSufficient: boolean; +}) => { + const { t } = useTranslation(); + if (!inSufficient) return null; + return ( +
+ + + {t('page.swap.InSufficientTip')} + +
+ ); +}; diff --git a/src/ui/views/SwapRenew/Component/Main.tsx b/src/ui/views/SwapRenew/Component/Main.tsx new file mode 100644 index 00000000000..38782856251 --- /dev/null +++ b/src/ui/views/SwapRenew/Component/Main.tsx @@ -0,0 +1,546 @@ +import React, { useCallback, useLayoutEffect, useMemo, useRef } from 'react'; +import { useRabbySelector } from '@/ui/store'; +import { CHAINS, CHAINS_ENUM } from '@debank/common'; +import TokenSelect from '@/ui/component/TokenSelect'; +import { ReactComponent as IconSwapArrow } from '@/ui/assets/swap/swap-arrow.svg'; +import { TokenRender } from './TokenRender'; +import { useTokenPair } from '../hooks/token'; +import { Alert, Button, Input, Modal, Switch } from 'antd'; +import BigNumber from 'bignumber.js'; +import { formatAmount, formatUsdValue, useWallet } from '@/ui/utils'; +import styled from 'styled-components'; +import clsx from 'clsx'; +import { QuoteList } from './Quotes'; +import { useQuoteVisible, useSetQuoteVisible } from '../hooks'; +import { InfoCircleFilled } from '@ant-design/icons'; +import { ReceiveDetails } from './ReceiveDetail'; +import { Slippage } from './Slippage'; +import { DEX_ENUM, DEX_SPENDER_WHITELIST } from '@rabby-wallet/rabby-swap'; +import { useDispatch } from 'react-redux'; +import { useRbiSource } from '@/ui/utils/ga-event'; +import { useCss } from 'react-use'; +import { DEX, SWAP_SUPPORT_CHAINS } from '@/constant'; +import { getTokenSymbol } from '@/ui/utils/token'; +import ChainSelectorInForm from '@/ui/component/ChainSelector/InForm'; +import { findChainByServerID } from '@/utils/chain'; +import type { SelectChainItemProps } from '@/ui/component/ChainSelector/components/SelectChainItem'; +import i18n from '@/i18n'; +import { Trans, useTranslation } from 'react-i18next'; + +const tipsClassName = clsx('text-gray-subTitle text-12 mb-4 pt-10'); + +const StyledInput = styled(Input)` + background: #f5f6fa; + border-radius: 6px; + height: 46px; + font-weight: 500; + font-size: 18px; + color: #ffffff; + box-shadow: none; + & > .ant-input { + background: #f5f6fa; + font-weight: 500; + font-size: 18px; + } + + &.ant-input-affix-wrapper, + &:focus, + &:active { + border: 1px solid transparent; + } + &:hover { + border: 1px solid rgba(255, 255, 255, 0.8); + box-shadow: none; + } + + &:placeholder-shown { + color: #707280; + } + &::-webkit-inner-spin-button, + &::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; + } +`; + +const getDisabledTips: SelectChainItemProps['disabledTips'] = (ctx) => { + const chainItem = findChainByServerID(ctx.chain.serverId); + + if (chainItem?.isTestnet) return i18n.t('page.swap.testnet-is-not-supported'); + + return i18n.t('page.swap.not-supported'); +}; + +export const Main = () => { + const { userAddress, unlimitedAllowance } = useRabbySelector((state) => ({ + userAddress: state.account.currentAccount?.address || '', + unlimitedAllowance: state.swap.unlimitedAllowance || false, + })); + + const dispatch = useDispatch(); + + const setUnlimited = useCallback( + (bool: boolean) => { + dispatch.swap.setUnlimitedAllowance(bool); + }, + [dispatch.swap.setUnlimitedAllowance] + ); + + const { + chain, + switchChain, + + payToken, + setPayToken, + receiveToken, + setReceiveToken, + exchangeToken, + + handleAmountChange, + handleBalance, + payAmount, + payTokenIsNativeToken, + isWrapToken, + inSufficient, + slippageChanged, + setSlippageChanged, + slippageState, + slippage, + setSlippage, + + feeRate, + + quoteLoading, + quoteList, + + currentProvider: activeProvider, + setActiveProvider, + slippageValidInfo, + expired, + } = useTokenPair(userAddress); + + const inputRef = useRef(); + + useLayoutEffect(() => { + if ((payToken?.id, receiveToken?.id)) { + inputRef.current?.focus(); + } + }, [payToken?.id, receiveToken?.id]); + + const miniReceivedAmount = useMemo(() => { + if (activeProvider?.quote?.toTokenAmount) { + const receivedTokeAmountBn = new BigNumber( + activeProvider?.quote?.toTokenAmount + ).div( + 10 ** + (activeProvider?.quote?.toTokenDecimals || + receiveToken?.decimals || + 1) + ); + return formatAmount( + receivedTokeAmountBn + .minus(receivedTokeAmountBn.times(slippage).div(100)) + .toString(10) + ); + } + return ''; + }, [ + activeProvider?.quote?.toTokenAmount, + activeProvider?.quote?.toTokenDecimals, + receiveToken?.decimals, + slippage, + ]); + + const DexDisplayName = useMemo( + () => DEX?.[activeProvider?.name as keyof typeof DEX]?.name || '', + [activeProvider?.name] + ); + + const visible = useQuoteVisible(); + const setVisible = useSetQuoteVisible(); + const { t } = useTranslation(); + + const btnText = useMemo(() => { + if (slippageChanged) { + return t('page.swap.slippage-adjusted-refresh-quote'); + } + if (activeProvider && expired) { + return t('page.swap.price-expired-refresh-quote'); + } + if (activeProvider?.shouldApproveToken) { + return t('page.swap.approve-x-symbol', { + symbol: getTokenSymbol(payToken), + }); + } + if (activeProvider?.name) { + return t('page.swap.swap-via-x', { + name: isWrapToken ? 'Wrap Contract' : DexDisplayName, + }); + } + + return t('page.swap.get-quotes'); + }, [ + slippageChanged, + activeProvider, + expired, + payToken, + isWrapToken, + DexDisplayName, + ]); + + const wallet = useWallet(); + const rbiSource = useRbiSource(); + + const gotoSwap = useCallback(async () => { + if (!inSufficient && payToken && receiveToken && activeProvider?.quote) { + try { + wallet.dexSwap( + { + chain, + quote: activeProvider?.quote, + needApprove: activeProvider.shouldApproveToken, + spender: + activeProvider?.name === DEX_ENUM.WRAPTOKEN + ? '' + : DEX_SPENDER_WHITELIST[activeProvider.name][chain], + pay_token_id: payToken.id, + unlimited: unlimitedAllowance, + shouldTwoStepApprove: activeProvider.shouldTwoStepApprove, + postSwapParams: { + quote: { + pay_token_id: payToken.id, + pay_token_amount: Number(payAmount), + receive_token_id: receiveToken!.id, + receive_token_amount: new BigNumber( + activeProvider?.quote.toTokenAmount + ) + .div( + 10 ** + (activeProvider?.quote.toTokenDecimals || + receiveToken.decimals) + ) + .toNumber(), + slippage: new BigNumber(slippage).div(100).toNumber(), + }, + dex_id: activeProvider?.name.replace('API', ''), + }, + }, + { + ga: { + category: 'Swap', + source: 'swap', + trigger: rbiSource, + }, + } + ); + window.close(); + } catch (error) { + console.error(error); + } + } + }, [ + inSufficient, + payToken, + unlimitedAllowance, + activeProvider?.quote, + wallet?.dexSwap, + activeProvider?.shouldApproveToken, + activeProvider?.name, + activeProvider?.shouldTwoStepApprove, + ]); + + const twoStepApproveCn = useCss({ + '& .ant-modal-content': { + background: '#fff', + }, + '& .ant-modal-body': { + padding: '12px 8px 32px 16px', + }, + '& .ant-modal-confirm-content': { + padding: '4px 0 0 0', + }, + '& .ant-modal-confirm-btns': { + justifyContent: 'center', + '.ant-btn-primary': { + width: '260px', + height: '40px', + }, + 'button:first-child': { + display: 'none', + }, + }, + }); + + return ( +
+
+
{t('page.swap.chain')}
+ + +
+ {t('page.swap.swap-from')} + {t('page.swap.to')} +
+ +
+ { + const chainItem = findChainByServerID(token.chain); + if (chainItem?.enum !== chain) { + switchChain(chainItem?.enum || CHAINS_ENUM.ETH); + setReceiveToken(undefined); + } + setPayToken(token); + }} + chainId={CHAINS[chain].serverId} + type={'swapFrom'} + placeholder={t('page.swap.search-by-name-address')} + excludeTokens={receiveToken?.id ? [receiveToken?.id] : undefined} + tokenRender={(p) => } + /> + + { + const chainItem = findChainByServerID(token.chain); + if (chainItem?.enum !== chain) { + switchChain(chainItem?.enum || CHAINS_ENUM.ETH); + setPayToken(undefined); + } + setReceiveToken(token); + }} + chainId={CHAINS[chain].serverId} + type={'swapTo'} + placeholder={t('page.swap.search-by-name-address')} + excludeTokens={payToken?.id ? [payToken?.id] : undefined} + tokenRender={(p) => } + useSwapTokenList + /> +
+ +
+
+ {t('page.swap.amount-in', { + symbol: payToken ? getTokenSymbol(payToken) : '', + })}{' '} +
+
{ + if (!payTokenIsNativeToken) { + handleBalance(); + } + }} + > + {t('global.Balance')}: {formatAmount(payToken?.amount || 0)} +
+
+ + {payAmount + ? `≈ ${formatUsdValue( + new BigNumber(payAmount) + .times(payToken?.price || 0) + .toString(10) + )}` + : ''} + + } + /> + + {payAmount && + activeProvider && + activeProvider?.quote?.toTokenAmount && + payToken && + receiveToken && ( + <> + + {isWrapToken ? ( +
+ {t('page.swap.there-is-no-fee-and-slippage-for-this-trade')} +
+ ) : ( +
+
+ { + setSlippageChanged(true); + setSlippage(e); + }} + recommendValue={ + slippageValidInfo?.is_valid + ? undefined + : slippageValidInfo?.suggest_slippage + } + /> +
+ {t('page.swap.minimum-received')} + + {miniReceivedAmount}{' '} + {receiveToken ? getTokenSymbol(receiveToken) : ''} + +
+
+ {t('page.swap.rabby-fee')} + 0% +
+
+
+ )} + + )} +
+ + {inSufficient ? ( + + } + banner + message={ + + {t('page.swap.insufficient-balance')} + + } + /> + ) : null} + +
+ {!expired && activeProvider && activeProvider.shouldApproveToken && ( +
+
{t('page.swap.approve-tips')}
+
+ {t('page.swap.unlimited-allowance')}{' '} + +
+
+ )} + +
+ {payToken && receiveToken && chain ? ( + { + setVisible(false); + }} + userAddress={userAddress} + chain={chain} + slippage={slippage} + payToken={payToken} + payAmount={payAmount} + receiveToken={receiveToken} + fee={feeRate} + inSufficient={inSufficient} + setActiveProvider={setActiveProvider} + /> + ) : null} +
+ ); +}; diff --git a/src/ui/views/SwapRenew/Component/QuoteItem.tsx b/src/ui/views/SwapRenew/Component/QuoteItem.tsx new file mode 100644 index 00000000000..6294be204aa --- /dev/null +++ b/src/ui/views/SwapRenew/Component/QuoteItem.tsx @@ -0,0 +1,707 @@ +import { CEX } from '@/constant'; +import { formatAmount, formatUsdValue } from '@/ui/utils'; +import { CHAINS_ENUM } from '@debank/common'; +import { TokenItem, CEXQuote } from '@rabby-wallet/rabby-api/dist/types'; +import { DEX_ENUM } from '@rabby-wallet/rabby-swap'; +import { QuoteResult } from '@rabby-wallet/rabby-swap/dist/quote'; +import { Tooltip } from 'antd'; +import clsx from 'clsx'; +import React, { useMemo, useCallback, useState } from 'react'; +import { useCss, useDebounce } from 'react-use'; +import styled from 'styled-components'; +import { QuoteLogo } from './QuoteLogo'; +import BigNumber from 'bignumber.js'; +import ImgLock from '@/ui/assets/swap/lock.svg'; +import ImgGas from '@/ui/assets/swap/gas.svg'; +import ImgWarning from '@/ui/assets/swap/warn.svg'; +import ImgVerified from '@/ui/assets/swap/verified.svg'; +import ImgWhiteWarning from '@/ui/assets/swap/warning-white.svg'; + +import { + QuotePreExecResultInfo, + QuoteProvider, + isSwapWrapToken, +} from '../hooks/quote'; +import { + useSetQuoteVisible, + useSetSettingVisible, + useVerifySdk, +} from '../hooks'; +import { useRabbySelector } from '@/ui/store'; +import { getTokenSymbol } from '@/ui/utils/token'; +import { TooltipWithMagnetArrow } from '@/ui/component/Tooltip/TooltipWithMagnetArrow'; +import { useTranslation } from 'react-i18next'; +import i18n from '@/i18n'; + +const ItemWrapper = styled.div` + position: relative; + height: 60px; + font-size: 12px; + padding: 0 12px; + display: flex; + align-items: center; + color: #13141a; + + border-radius: 6px; + box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.08); + border-radius: 6px; + border: 1px solid transparent; + background: white; + cursor: pointer; + + .disabled-trade { + position: absolute; + left: 0; + top: 0; + transform: translateY(-20px); + opacity: 0; + width: 100%; + height: 0; + padding-left: 16px; + background: #000000; + border-radius: 6px; + display: flex; + align-items: center; + font-size: 12px; + gap: 8px; + font-weight: 400; + font-size: 12px; + color: #ffffff; + pointer-events: none; + &.active { + pointer-events: auto; + height: 100%; + transform: translateY(0); + opacity: 1; + /* transition: opacity 0.35s, transform 0.35s; */ + } + } + + &:hover:not(.disabled, .inSufficient) { + background: linear-gradient( + 0deg, + rgba(134, 151, 255, 0.1), + rgba(134, 151, 255, 0.1) + ), + #ffffff; + border: 1px solid #8697ff; + } + &.active { + outline: 2px solid #8697ff; + } + &.disabled { + height: 56px; + border-color: transparent; + box-shadow: none; + background-color: transparent; + border-radius: 6px; + cursor: not-allowed; + } + &.error { + } + &:not(.cex).inSufficient, + &:not(.cex).disabled { + height: 60px; + border: 1px solid #e5e9ef; + border-radius: 6px; + box-shadow: none; + } + + &.cex { + font-weight: 500; + font-size: 13px; + line-height: 15px; + color: #13141a; + height: 48px; + background-color: transparent; + border: none; + outline: none; + } + + .price { + flex: 1; + display: flex; + align-items: center; + gap: 6px; + color: #707280; + .receiveNum { + font-size: 15px; + max-width: 130px; + display: inline-block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 500; + color: #707280; + .toToken { + color: #13141a; + } + } + } + .no-price { + color: #13141a; + } + + .percent { + font-weight: 500; + font-size: 13px; + font-weight: 500; + color: #27c193; + &.red { + color: #ec5151; + } + } + + .diff { + margin-left: auto; + } +`; + +export interface QuoteItemProps { + quote: QuoteResult | null; + name: string; + loading?: boolean; + payToken: TokenItem; + receiveToken: TokenItem; + payAmount: string; + chain: CHAINS_ENUM; + bestAmount: string; + isBestQuote: boolean; + active: boolean; + userAddress: string; + slippage: string; + fee: string; + isLoading?: boolean; + quoteProviderInfo: { name: string; logo: string }; + inSufficient: boolean; + setActiveProvider: React.Dispatch< + React.SetStateAction + >; +} + +export const DexQuoteItem = ( + props: QuoteItemProps & { + preExecResult: QuotePreExecResultInfo; + } +) => { + const { + isLoading, + quote, + name: dexId, + loading, + bestAmount, + payToken, + receiveToken, + payAmount, + chain, + active, + userAddress, + isBestQuote, + slippage, + fee, + inSufficient, + preExecResult, + quoteProviderInfo, + setActiveProvider: updateActiveQuoteProvider, + } = props; + + const { t } = useTranslation(); + + const openSwapSettings = useSetSettingVisible(); + const openSwapQuote = useSetQuoteVisible(); + + const tradeList = useRabbySelector((s) => s.swap.tradeList); + const disabledTrade = useMemo( + () => + !tradeList?.[dexId] && + !isSwapWrapToken(payToken.id, receiveToken.id, chain), + [tradeList, dexId, payToken.id, receiveToken.id, chain] + ); + + const { isSdkDataPass } = useVerifySdk({ + chain, + dexId: dexId as DEX_ENUM, + slippage, + data: { + ...quote, + fromToken: payToken.id, + fromTokenAmount: new BigNumber(payAmount) + .times(10 ** payToken.decimals) + .toFixed(0, 1), + toToken: receiveToken?.id, + } as typeof quote, + payToken, + receiveToken, + }); + + const halfBetterRateString = ''; + + const [ + middleContent, + rightContent, + disabled, + receivedTokenUsd, + diffReceivedTokenUsd, + ] = useMemo(() => { + let center: React.ReactNode = ( +
-
+ ); + let right: React.ReactNode = ''; + let disable = false; + let receivedUsd = '0'; + let diffUsd = '0'; + + const actualReceiveAmount = inSufficient + ? new BigNumber(quote?.toTokenAmount || 0) + .div(10 ** (quote?.toTokenDecimals || receiveToken.decimals)) + .toString() + : preExecResult?.swapPreExecTx.balance_change.receive_token_list[0] + ?.amount; + if (actualReceiveAmount || dexId === 'WrapToken') { + const receiveAmount = + actualReceiveAmount || (dexId === 'WrapToken' ? payAmount : 0); + const bestQuoteAmount = new BigNumber(bestAmount); + const receivedTokeAmountBn = new BigNumber(receiveAmount); + const percent = new BigNumber(receiveAmount) + .minus(bestAmount || 0) + .div(bestAmount) + .times(100); + + receivedUsd = formatUsdValue( + receivedTokeAmountBn.times(receiveToken.price || 0).toString(10) + ); + + diffUsd = formatUsdValue( + new BigNumber(receiveAmount) + .minus(bestQuoteAmount || 0) + .times(receiveToken.price || 0) + .toString(10) + ); + + const s = formatAmount(receivedTokeAmountBn.toString(10)); + const receiveTokenSymbol = getTokenSymbol(receiveToken); + center = ( + + + {s} + {' '} + {receiveTokenSymbol} + + ); + + right = ( + + {isBestQuote + ? t('page.swap.best') + : `${percent.toFixed(2, BigNumber.ROUND_DOWN)}%`} + + ); + } + + if (!quote?.toTokenAmount) { + right = ( +
+ {t('page.swap.unable-to-fetch-the-price')} +
+ ); + center =
-
; + disable = true; + } + + if (quote?.toTokenAmount) { + if (!preExecResult && !inSufficient) { + center =
-
; + right = ( +
+ {t('page.swap.fail-to-simulate-transaction')} +
+ ); + disable = true; + } + } + + if (!isSdkDataPass) { + disable = true; + center =
-
; + right = ( +
+ {t('page.swap.security-verification-failed')} +
+ ); + } + return [center, right, disable, receivedUsd, diffUsd]; + }, [ + quote?.toTokenAmount, + quote?.toTokenDecimals, + inSufficient, + receiveToken.decimals, + receiveToken.price, + receiveToken.symbol, + preExecResult, + isSdkDataPass, + bestAmount, + isBestQuote, + ]); + + const quoteWarning = useMemo(() => { + if (!quote?.toTokenAmount || !preExecResult) { + return; + } + + if (isSwapWrapToken(payToken.id, receiveToken.id, chain)) { + return; + } + const receivedTokeAmountBn = new BigNumber(quote?.toTokenAmount || 0).div( + 10 ** (quote?.toTokenDecimals || receiveToken.decimals) + ); + + const diff = receivedTokeAmountBn + .minus( + preExecResult?.swapPreExecTx?.balance_change.receive_token_list[0] + ?.amount || 0 + ) + .div(receivedTokeAmountBn); + + const diffPercent = diff.times(100); + + return diffPercent.gt(0.01) + ? ([ + formatAmount(receivedTokeAmountBn.toString(10)) + + getTokenSymbol(receiveToken), + `${diffPercent.toPrecision(2)}% (${formatAmount( + receivedTokeAmountBn + .minus( + preExecResult?.swapPreExecTx?.balance_change + .receive_token_list[0]?.amount || 0 + ) + .toString(10) + )} ${getTokenSymbol(receiveToken)})`, + ] as [string, string]) + : undefined; + }, [ + chain, + payToken.id, + preExecResult, + quote?.toTokenAmount, + quote?.toTokenDecimals, + receiveToken.decimals, + receiveToken.id, + receiveToken.symbol, + ]); + + const CheckIcon = useCallback(() => { + if (disabled || loading || !quote?.tx || !preExecResult?.swapPreExecTx) { + return null; + } + return ; + }, [ + disabled, + loading, + quote?.tx, + preExecResult?.swapPreExecTx, + quoteWarning, + ]); + + const [disabledTradeTipsOpen, setDisabledTradeTipsOpen] = useState(false); + + const handleClick = useCallback(() => { + if (disabledTrade) { + // setDisabledTradeTipsOpen(true); + return; + } + if (inSufficient) { + // message.error('Insufficient balance to select the rate'); + return; + } + if (active || disabled || disabledTrade) return; + updateActiveQuoteProvider({ + name: dexId, + quote, + gasPrice: preExecResult?.gasPrice, + shouldApproveToken: !!preExecResult?.shouldApproveToken, + shouldTwoStepApprove: !!preExecResult?.shouldTwoStepApprove, + error: !preExecResult, + halfBetterRate: halfBetterRateString, + quoteWarning, + actualReceiveAmount: + preExecResult?.swapPreExecTx.balance_change.receive_token_list[0] + ?.amount || '', + gasUsd: preExecResult?.gasUsd, + }); + + openSwapQuote(false); + }, [ + active, + disabled, + inSufficient, + updateActiveQuoteProvider, + dexId, + quote, + preExecResult, + quoteWarning, + ]); + + useDebounce( + () => { + if (active) { + updateActiveQuoteProvider((e) => ({ + ...e, + name: dexId, + quote, + gasPrice: preExecResult?.gasPrice, + shouldApproveToken: !!preExecResult?.shouldApproveToken, + shouldTwoStepApprove: !!preExecResult?.shouldTwoStepApprove, + error: !preExecResult, + halfBetterRate: halfBetterRateString, + quoteWarning, + actualReceiveAmount: + preExecResult?.swapPreExecTx.balance_change.receive_token_list[0] + ?.amount || '', + gasUsed: preExecResult?.gasUsd, + })); + } + }, + 300, + [ + quoteWarning, + halfBetterRateString, + active, + dexId, + updateActiveQuoteProvider, + quote, + preExecResult, + ] + ); + + const isWrapTokensWap = useMemo( + () => isSwapWrapToken(payToken.id, receiveToken.id, chain), + [payToken, receiveToken, chain] + ); + + return ( + { + if (disabledTrade && !inSufficient && quote && preExecResult) { + setDisabledTradeTipsOpen(true); + } + }} + onMouseLeave={() => { + setDisabledTradeTipsOpen(false); + }} + onClick={handleClick} + className={clsx( + active && 'active', + (disabledTrade || disabled) && 'disabled error', + inSufficient && !disabled && 'disabled inSufficient' + )} + > + + +
+
+
+ + {quoteProviderInfo.name} + + {!!preExecResult?.shouldApproveToken && ( + + + + )} +
+ +
+
+ {middleContent} + +
+
+ {!isBestQuote &&
{rightContent}
} +
+ + {!disabled && ( +
+
+ {!inSufficient && ( + <> + + {preExecResult?.gasUsd} + + )} +
+ + ≈{receivedTokenUsd} + + {!isBestQuote && ( + {diffReceivedTokenUsd} + )} +
+ )} +
+ + {isBestQuote &&
{rightContent}
} + +
+ + + {t('page.swap.this-exchange-is-not-enabled-to-trade-by-you')} + { + e.stopPropagation(); + openSwapSettings(true); + setDisabledTradeTipsOpen(false); + }} + > + {t('page.swap.enable-it')} + + +
+
+ ); +}; + +export const CexQuoteItem = (props: { + name: string; + data: CEXQuote | null; + bestAmount: string; + isBestQuote: boolean; + isLoading?: boolean; + inSufficient: boolean; +}) => { + const { + name, + data, + bestAmount, + isBestQuote, + isLoading, + inSufficient, + } = props; + const { t } = useTranslation(); + const dexInfo = useMemo(() => CEX[name as keyof typeof CEX], [name]); + + const [middleContent, rightContent] = useMemo(() => { + let center: React.ReactNode = ( +
-
+ ); + let right: React.ReactNode = ''; + let disable = false; + + if (!data?.receive_token?.amount) { + right = ( +
+ {t('page.swap.this-token-pair-is-not-supported')} +
+ ); + disable = true; + } + + if (data?.receive_token?.amount) { + const bestQuoteAmount = new BigNumber(bestAmount); + const receiveToken = data.receive_token; + const percent = new BigNumber(receiveToken.amount) + .minus(bestQuoteAmount || 0) + .div(bestQuoteAmount) + .times(100); + const s = formatAmount(receiveToken.amount.toString(10)); + const receiveTokenSymbol = getTokenSymbol(receiveToken); + + center = ( + + + {s} + {' '} + {receiveTokenSymbol} + + ); + + right = ( + + {isBestQuote + ? t('page.swap.best') + : `${percent.toFixed(2, BigNumber.ROUND_DOWN)}%`} + + ); + } + + return [center, right, disable]; + }, [data?.receive_token, bestAmount, isBestQuote]); + + return ( + + + +
+
+
+ {dexInfo.name} +
+ +
+
{middleContent}
+
+
{rightContent}
+
+
+
+ ); +}; + +export const CexListWrapper = styled.div` + border: 0.5px solid rgba(255, 255, 255, 0.2); + border-radius: 6px; + & > div:not(:last-child) { + position: relative; + &:not(:last-child):before { + content: ''; + position: absolute; + width: 440px; + height: 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + left: 20px; + bottom: 0; + } + } +`; + +const getQuoteLessWarning = ([receive, diff]: [string, string]) => + i18n.t('page.swap.QuoteLessWarning', { receive, diff }); + +export function WarningOrChecked({ + quoteWarning, +}: { + quoteWarning?: [string, string]; +}) { + const { t } = useTranslation(); + return ( + + + + ); +} diff --git a/src/ui/views/SwapRenew/Component/QuoteLogo.tsx b/src/ui/views/SwapRenew/Component/QuoteLogo.tsx new file mode 100644 index 00000000000..fc7493888d6 --- /dev/null +++ b/src/ui/views/SwapRenew/Component/QuoteLogo.tsx @@ -0,0 +1,40 @@ +import { ReactComponent as IconQuoteLoading } from '@/ui/assets/swap/quote-loading.svg'; +import clsx from 'clsx'; +import React from 'react'; + +export const QuoteLogo = ({ + isLoading, + logo, + isCex = false, + loaded = false, +}: { + isLoading?: boolean; + logo: string; + isCex?: boolean; + loaded?: boolean; +}) => { + return ( +
+
+ + {isLoading && ( +
+ +
+ )} +
+
+ ); +}; diff --git a/src/ui/views/SwapRenew/Component/Quotes.tsx b/src/ui/views/SwapRenew/Component/Quotes.tsx new file mode 100644 index 00000000000..756af4a4b80 --- /dev/null +++ b/src/ui/views/SwapRenew/Component/Quotes.tsx @@ -0,0 +1,282 @@ +import { Popup } from '@/ui/component'; +import React, { useMemo } from 'react'; +import { QuoteListLoading, QuoteLoading } from './loading'; +import styled from 'styled-components'; +import { IconRefresh } from './IconRefresh'; +import { CexQuoteItem, DexQuoteItem, QuoteItemProps } from './QuoteItem'; +import { + TCexQuoteData, + TDexQuoteData, + isSwapWrapToken, + useSetRefreshId, + useSetSettingVisible, + useSwapSettings, +} from '../hooks'; +import BigNumber from 'bignumber.js'; +import { CEX, DEX, DEX_WITH_WRAP } from '@/constant'; +import { SvgIconCross } from 'ui/assets'; +import { useTranslation } from 'react-i18next'; +import { getTokenSymbol } from '@/ui/utils/token'; + +const CexListWrapper = styled.div` + border: 1px solid #e5e9ef; + border-radius: 6px; + &:empty { + display: none; + } + + & > div:not(:last-child) { + position: relative; + &:not(:last-child):before { + content: ''; + position: absolute; + width: 328px; + height: 0; + border-bottom: 1px solid #e5e9ef; + left: 16px; + bottom: 0; + } + } +`; + +const exchangeCount = Object.keys(DEX).length + Object.keys(CEX).length; + +interface QuotesProps + extends Omit< + QuoteItemProps, + | 'bestAmount' + | 'name' + | 'quote' + | 'active' + | 'isBestQuote' + | 'quoteProviderInfo' + > { + list?: (TCexQuoteData | TDexQuoteData)[]; + activeName?: string; + visible: boolean; + onClose: () => void; +} + +export const Quotes = ({ + list, + activeName, + inSufficient, + ...other +}: QuotesProps) => { + const { t } = useTranslation(); + const { swapViewList, swapTradeList } = useSwapSettings(); + + const viewCount = useMemo(() => { + if (swapViewList) { + return ( + exchangeCount - + Object.values(swapViewList).filter((e) => e === false).length + ); + } + return exchangeCount; + }, [swapViewList]); + + const tradeCount = useMemo(() => { + if (swapTradeList) { + return Object.values(swapTradeList).filter((e) => e === true).length; + } + return 0; + }, [swapTradeList]); + + const setSettings = useSetSettingVisible(); + const openSettings = React.useCallback(() => { + setSettings(true); + }, []); + const sortedList = useMemo( + () => + list?.sort((a, b) => { + const getNumber = (quote: typeof a) => { + if (quote.isDex) { + if (inSufficient) { + return new BigNumber(quote.data?.toTokenAmount || 0); + } + if (!quote.preExecResult) { + return new BigNumber(0); + } + return new BigNumber( + quote?.preExecResult.swapPreExecTx.balance_change + .receive_token_list?.[0]?.amount || 0 + ); + } + + return new BigNumber(quote?.data?.receive_token?.amount || 0); + }; + return getNumber(b).minus(getNumber(a)).toNumber(); + }) || [], + [inSufficient, list] + ); + + const bestAmount = useMemo(() => { + const bestQuote = sortedList?.[0]; + + return ( + (bestQuote?.isDex + ? inSufficient + ? new BigNumber(bestQuote.data?.toTokenAmount || 0) + .div( + 10 ** + (bestQuote?.data?.toTokenDecimals || + other.receiveToken.decimals || + 1) + ) + .toString(10) + : bestQuote?.preExecResult?.swapPreExecTx.balance_change + .receive_token_list[0]?.amount + : new BigNumber(bestQuote?.data?.receive_token.amount || '0').toString( + 10 + )) || '0' + ); + }, [inSufficient, other?.receiveToken?.decimals, sortedList]); + + const fetchedList = useMemo(() => list?.map((e) => e.name) || [], [list]); + + const noCex = useMemo(() => { + return Object.keys(CEX).every((e) => swapViewList?.[e] === false); + }, [swapViewList]); + if (isSwapWrapToken(other.payToken.id, other.receiveToken.id, other.chain)) { + const dex = sortedList.find((e) => e.isDex) as TDexQuoteData | undefined; + + return ( +
+ {dex ? ( + + ) : ( + + )} + +
+ {t('page.swap.directlySwap', { + symbol: getTokenSymbol(other.payToken), + })} +
+
+ ); + } + return ( +
+
+ {sortedList.map((params, idx) => { + const { name, data, isDex } = params; + if (!isDex) return null; + return ( + + ); + })} + +
+ {!noCex && ( +
+ {t('page.swap.rates-from-cex')} +
+ )} + + {sortedList.map((params, idx) => { + const { name, data, isDex } = params; + if (isDex) return null; + return ( + + ); + })} + + +
+
+ {t('page.swap.tradingSettingTips', { viewCount, tradeCount })} + + {t('page.swap.edit')} + +
+
+ ); +}; + +const bodyStyle = { + paddingTop: 0, + paddingBottom: 0, +}; + +export const QuoteList = (props: QuotesProps) => { + const { visible, onClose } = props; + const refresh = useSetRefreshId(); + + const refreshQuote = React.useCallback(() => { + refresh((e) => e + 1); + }, [refresh]); + + const { t } = useTranslation(); + + return ( + + } + visible={visible} + title={ +
+
{t('page.swap.the-following-swap-rates-are-found')}
+
+
+ +
+
+
+ } + height={544} + onClose={onClose} + closable + destroyOnClose + className="isConnectView z-[999]" + bodyStyle={bodyStyle} + > + +
+ ); +}; diff --git a/src/ui/views/SwapRenew/Component/ReceiveDetail.tsx b/src/ui/views/SwapRenew/Component/ReceiveDetail.tsx new file mode 100644 index 00000000000..6fd3b1375f1 --- /dev/null +++ b/src/ui/views/SwapRenew/Component/ReceiveDetail.tsx @@ -0,0 +1,383 @@ +import { TokenItem } from '@rabby-wallet/rabby-api/dist/types'; +import { Skeleton, Tooltip } from 'antd'; +import BigNumber from 'bignumber.js'; +import { + InsHTMLAttributes, + PropsWithChildren, + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; +import styled from 'styled-components'; +import ImgVerified from '@/ui/assets/swap/verified.svg'; +import ImgWarning from '@/ui/assets/swap/warn.svg'; +import ImgInfo from '@/ui/assets/swap/info-outline.svg'; +import ImgSwitch from '@/ui/assets/swap/switch.svg'; +import ImgGas from '@/ui/assets/swap/gas.svg'; +import ImgLock from '@/ui/assets/swap/lock.svg'; + +import clsx from 'clsx'; +import { SkeletonInputProps } from 'antd/lib/skeleton/Input'; +import React from 'react'; +import { formatAmount } from '@/ui/utils'; +import { QuoteProvider, useSetQuoteVisible } from '../hooks'; +import { DEX } from '@/constant'; +import { getTokenSymbol } from '@/ui/utils/token'; +import { TooltipWithMagnetArrow } from '@/ui/component/Tooltip/TooltipWithMagnetArrow'; +import i18n from '@/i18n'; +import { useTranslation } from 'react-i18next'; + +const getQuoteLessWarning = ([receive, diff]: [string, string]) => + i18n.t('page.swap.QuoteLessWarning', { receive, diff }); + +export const WarningOrChecked = ({ + quoteWarning, +}: { + quoteWarning?: [string, string]; +}) => { + const { t } = useTranslation(); + return ( + + + + ); +}; + +const ReceiveWrapper = styled.div` + position: relative; + margin-top: 24px; + border: 1px solid #e5e9ef; + border-radius: 4px; + padding: 12px; + + color: #4b4d59; + font-size: 13px; + .receive-token { + font-size: 15px; + color: #13141a; + } + + .diffPercent { + &.negative { + color: #ff7878; + } + &.positive { + color: #27c193; + } + } + .column { + display: flex; + justify-content: space-between; + + .right { + font-weight: medium; + display: inline-flex; + align-items: center; + gap: 4px; + .ellipsis { + max-width: 170px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + img { + width: 14px; + height: 14px; + } + } + } + + .warning { + margin-bottom: 8px; + padding: 8px; + font-weight: 400; + font-size: 12px; + color: #ffb020; + position: relative; + background: rgba(255, 176, 32, 0.1); + border-radius: 4px; + } + + .footer { + position: relative; + border-top: 0.5px solid #e5e9ef; + padding-top: 8px; + } + .quote-provider { + position: absolute; + top: -12px; + left: 12px; + height: 20px; + padding: 4px 6px; + display: flex; + align-items: center; + justify-content: center; + font-size: 13px; + cursor: pointer; + + color: #13141a; + + background: #e4e8ff; + border-radius: 4px; + border: 1px solid transparent; + &:hover { + background: #d4daff; + border: 1px solid rgba(134, 151, 255, 0.5); + } + } +`; + +const SkeletonChildren = ( + props: PropsWithChildren +) => { + const { loading = true, children, ...other } = props; + if (loading) { + return ; + } + return <>{children}; +}; + +interface ReceiveDetailsProps { + payAmount: string | number; + receiveRawAmount: string | number; + payToken: TokenItem; + receiveToken: TokenItem; + receiveTokenDecimals?: number; + quoteWarning?: [string, string]; + loading?: boolean; + activeProvider: QuoteProvider; + isWrapToken?: boolean; +} +export const ReceiveDetails = ( + props: ReceiveDetailsProps & InsHTMLAttributes +) => { + const { t } = useTranslation(); + const { + receiveRawAmount: receiveAmount, + payAmount, + payToken, + receiveToken, + quoteWarning, + loading = false, + activeProvider, + isWrapToken, + ...other + } = props; + + const [reverse, setReverse] = useState(false); + + const reverseRate = useCallback(() => { + setReverse((e) => !e); + }, []); + + useEffect(() => { + if (payToken && receiveToken) { + setReverse(false); + } + }, [receiveToken, payToken]); + + const { + receiveNum, + payUsd, + receiveUsd, + rate, + diff, + sign, + showLoss, + } = useMemo(() => { + const pay = new BigNumber(payAmount).times(payToken.price || 0); + const receiveAll = new BigNumber(receiveAmount); + const receive = receiveAll.times(receiveToken.price || 0); + const cut = receive.minus(pay).div(pay).times(100); + const rateBn = new BigNumber(reverse ? payAmount : receiveAll).div( + reverse ? receiveAll : payAmount + ); + + return { + receiveNum: formatAmount(receiveAll.toString(10)), + payUsd: formatAmount(pay.toString(10)), + receiveUsd: formatAmount(receive.toString(10)), + rate: rateBn.lt(0.0001) + ? new BigNumber(rateBn.toPrecision(1, 0)).toString(10) + : formatAmount(rateBn.toString(10)), + sign: cut.eq(0) ? '' : cut.lt(0) ? '-' : '+', + diff: cut.abs().toFixed(2), + showLoss: cut.lte(-5), + }; + }, [payAmount, payToken.price, receiveAmount, receiveToken.price, reverse]); + + const openQuote = useSetQuoteVisible(); + const payTokenSymbol = getTokenSymbol(payToken); + const receiveTokenSymbol = getTokenSymbol(receiveToken); + + return ( + +
+
+
+ +
+ +
+
+ + {isWrapToken + ? t('page.swap.wrap-contract') + : DEX?.[activeProvider?.name]?.name} + + {!!activeProvider.shouldApproveToken && ( + + + + )} +
+ {!!activeProvider?.gasUsd && ( +
+ + {activeProvider?.gasUsd} +
+ )} +
+
+
+
+ + + {receiveNum}{' '} + {receiveTokenSymbol} + + + +
+ +
+ + ≈ ${receiveUsd} ( + + {sign} + {diff}% + + ) + + +
+ {t('page.swap.est-payment')} {payAmount} + {payTokenSymbol} ≈ ${payUsd} +
+
+ {t('page.swap.est-receiving')} {receiveNum} + {receiveTokenSymbol} ≈ ${receiveUsd} +
+
+ {t('page.swap.est-difference')} {sign} + {diff}% +
+
+ } + > + + +
+
+
+ {!loading && quoteWarning && ( +
{getQuoteLessWarning(quoteWarning)}
+ )} + + {!loading && showLoss && ( +
+ {t( + 'page.swap.selected-offer-differs-greatly-from-current-rate-may-cause-big-losses' + )} +
+ )} +
+ {t('page.swap.rate')} +
+ + + + 1 {reverse ? receiveTokenSymbol : payTokenSymbol}{' '} + + ={' '} + + {rate} {reverse ? payTokenSymbol : receiveTokenSymbol} + + + +
+
+ {activeProvider.name && receiveToken ? ( +
{ + openQuote(true); + }} + > + +
+ ) : null} + + ); +}; diff --git a/src/ui/views/SwapRenew/Component/Slippage.tsx b/src/ui/views/SwapRenew/Component/Slippage.tsx new file mode 100644 index 00000000000..75aa6e12ba8 --- /dev/null +++ b/src/ui/views/SwapRenew/Component/Slippage.tsx @@ -0,0 +1,226 @@ +import clsx from 'clsx'; +import { + memo, + useMemo, + useCallback, + ChangeEventHandler, + useState, +} from 'react'; +import { useToggle } from 'react-use'; +import styled from 'styled-components'; +import BigNumber from 'bignumber.js'; +import React from 'react'; +import { Input } from 'antd'; +import ImgArrowUp from 'ui/assets/swap/arrow-up.svg'; +import i18n from '@/i18n'; +import { Trans, useTranslation } from 'react-i18next'; + +export const SlippageItem = styled.div<{ + active?: boolean; + error?: boolean; + hasAmount?: boolean; +}>` + position: relative; + display: flex; + justify-content: center; + align-items: center; + border: 1px solid transparent; + cursor: pointer; + border-radius: 6px; + width: 52px; + height: 28px; + font-weight: 500; + font-size: 12px; + background: #f5f6fa; + border-radius: 4px; + &:hover { + background: rgba(134, 151, 255, 0.2); + } +`; + +const SLIPPAGE = ['0.1', '0.3', '0.5']; + +const Wrapper = styled.section` + .slippage { + display: flex; + align-items: center; + gap: 8px; + } + + .input { + font-weight: 500; + font-size: 12px; + background: #f5f6fa; + border: 1px solid #e5e9ef; + border-radius: 4px; + + &:placeholder-shown { + color: #707280; + } + .ant-input { + border-radius: 0; + } + } + + .warning { + padding: 10px; + color: #ffb020; + font-weight: 400; + font-size: 12px; + line-height: 14px; + position: relative; + border-radius: 4px; + background: rgba(255, 176, 32, 0.1); + margin-top: 8px; + } +`; +interface SlippageProps { + value: string; + displaySlippage: string; + onChange: (n: string) => void; + recommendValue?: number; +} +export const Slippage = memo((props: SlippageProps) => { + const { t } = useTranslation(); + + const { value, displaySlippage, onChange, recommendValue } = props; + const [isCustom, setIsCustom] = useToggle(false); + + const [slippageOpen, setSlippageOpen] = useState(false); + + const [isLow, isHigh] = useMemo(() => { + return [ + value?.trim() !== '' && Number(value || 0) < 0.1, + value?.trim() !== '' && Number(value || 0) > 10, + ]; + }, [value]); + + const setRecommendValue = useCallback(() => { + onChange(new BigNumber(recommendValue || 0).times(100).toString()); + }, [onChange, recommendValue]); + + const tips = useMemo(() => { + if (isLow) { + return i18n.t( + 'page.swap.low-slippage-may-cause-failed-transactions-due-to-high-volatility' + ); + } + if (isHigh) { + return i18n.t( + 'page.swap.transaction-might-be-frontrun-because-of-high-slippage-tolerance' + ); + } + if (recommendValue) { + return ( + + + To prevent front-running, we recommend a slippage of{' '} + + {{ + slippage: new BigNumber(recommendValue || 0) + .times(100) + .toString(), + }} + + %{' '} + + + ); + } + return null; + }, [isHigh, isLow, recommendValue, setRecommendValue]); + + const onInputFocus: ChangeEventHandler = useCallback( + (e) => { + e.target?.select?.(); + }, + [] + ); + + const onInputChange: ChangeEventHandler = useCallback( + (e) => { + const v = e.target.value; + if (/^\d*(\.\d*)?$/.test(v)) { + onChange(Number(v) > 50 ? '50' : v); + } + }, + [onChange] + ); + + return ( +
+
{ + setSlippageOpen((e) => !e); + }} + > + {t('page.swap.slippage-tolerance')} + + + {displaySlippage}%{' '} + + + +
+ +
+ {SLIPPAGE.map((e) => ( + { + event.stopPropagation(); + setIsCustom(false); + onChange(e); + }} + active={!isCustom && e === value} + > + {e}% + + ))} +
{ + event.stopPropagation(); + setIsCustom(true); + }} + className="flex-1" + > + %
} + /> +
+
+ + {!!tips &&
{tips}
} + +
+ ); +}); diff --git a/src/ui/views/SwapRenew/Component/TokenRender.tsx b/src/ui/views/SwapRenew/Component/TokenRender.tsx new file mode 100644 index 00000000000..8775545cc2c --- /dev/null +++ b/src/ui/views/SwapRenew/Component/TokenRender.tsx @@ -0,0 +1,85 @@ +import { TokenWithChain } from '@/ui/component'; +import React from 'react'; +import styled from 'styled-components'; +import { ReactComponent as IconRcArrowDownTriangle } from '@/ui/assets/swap/arrow-caret-down.svg'; +import { TokenItem } from '@rabby-wallet/rabby-api/dist/types'; +import { getTokenSymbol } from '@/ui/utils/token'; +import { useTranslation } from 'react-i18next'; +const TokenRenderWrapper = styled.div` + width: 150px; + height: 46px; + background: #f5f6fa; + border-radius: 4px; + display: flex; + align-items: center; + padding: 12px; + font-weight: 500; + font-size: 18px; + color: #13141a; + border: 1px solid transparent; + cursor: pointer; + &:hover { + background: rgba(134, 151, 255, 0.2); + } + .token { + display: flex; + flex: 1; + gap: 8px; + align-items: center; + + .text { + max-width: 68px; + display: inline-block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } + .select { + flex: 1; + display: flex; + justify-content: space-between; + align-items: center; + white-space: nowrap; + } + .arrow { + margin-left: auto; + font-size: 12px; + opacity: 0.8; + width: 18px; + height: 18px; + } +`; +export const TokenRender = ({ + openTokenModal, + token, +}: { + token?: TokenItem | undefined; + openTokenModal: () => void; +}) => { + const { t } = useTranslation(); + return ( + + {token ? ( +
+ + + {getTokenSymbol(token)} + + +
+ ) : ( +
+ {t('page.swap.select-token')} + +
+ )} +
+ ); +}; diff --git a/src/ui/views/SwapRenew/Component/TradingSettings.tsx b/src/ui/views/SwapRenew/Component/TradingSettings.tsx new file mode 100644 index 00000000000..a2bc9a4c88f --- /dev/null +++ b/src/ui/views/SwapRenew/Component/TradingSettings.tsx @@ -0,0 +1,165 @@ +import { Checkbox, Modal, Popup } from '@/ui/component'; +import React, { useState } from 'react'; +import { useSwapSettings } from '../hooks'; +import { CEX, CHAINS_ENUM, DEX } from '@/constant'; +import clsx from 'clsx'; +import { Button, Switch } from 'antd'; +import { useTranslation } from 'react-i18next'; + +const list = [...Object.values(DEX), ...Object.values(CEX)] as { + id: keyof typeof DEX | keyof typeof CEX; + logo: string; + name: string; + chains: CHAINS_ENUM[]; +}[]; + +export const TradingSettings = ({ + visible, + onClose, +}: { + visible: boolean; + onClose: () => void; +}) => { + const { t } = useTranslation(); + const { + swapViewList, + swapTradeList, + setSwapView, + setSwapTrade, + } = useSwapSettings(); + + const [open, setOpen] = useState(false); + + const [id, setId] = useState[0][0]>(); + + const onConfirm = () => { + if (id) { + setSwapTrade([id, true]); + setOpen(false); + } + }; + + return ( + +
+
+
{t('page.swap.exchanges')}
+
{t('page.swap.view-quotes')}
+
{t('page.swap.trade')}
+
+ +
+ {list.map((item) => { + return ( +
+
+ + + {item.name} + + + {item?.chains ? t('page.swap.dex') : t('page.swap.cex')} + +
+
+ { + setSwapView([item.id, checked]); + if (!checked && DEX[item.id]) { + setSwapTrade([item.id, checked]); + } + }} + /> +
+
+ { + if (checked) { + setId(item.id); + setOpen(true); + } else { + setSwapTrade([item.id, checked]); + } + }} + /> +
+
+ ); + })} +
+
+ { + setOpen(false); + }} + > + + +
+ ); +}; + +function EnableTrading({ onConfirm }: { onConfirm: () => void }) { + const [checked, setChecked] = useState(false); + const { t } = useTranslation(); + return ( +
+
+ {t('page.swap.enable-trading')} +
+
+

{t('page.swap.tradingSettingTip1')}

+

{t('page.swap.tradingSettingTip2')}

+
+
+ + {t('page.swap.i-understand-and-accept-it')} + + + +
+
+ ); +} diff --git a/src/ui/views/SwapRenew/Component/loading.tsx b/src/ui/views/SwapRenew/Component/loading.tsx new file mode 100644 index 00000000000..c8f19b3d306 --- /dev/null +++ b/src/ui/views/SwapRenew/Component/loading.tsx @@ -0,0 +1,80 @@ +import { CEX, DEX } from '@/constant'; +import { Skeleton } from 'antd'; +import clsx from 'clsx'; +import React from 'react'; +import { QuoteLogo } from './QuoteLogo'; +import { useSwapSettings } from '../hooks'; + +type QuoteListLoadingProps = { + fetchedList?: string[]; + isCex?: boolean; +}; + +export const QuoteLoading = ({ + logo, + name, + isCex = false, +}: { + logo: string; + name: string; + isCex?: boolean; +}) => { + return ( +
+ + + {name} + +
+ + + +
+
+ ); +}; + +export const QuoteListLoading = ({ + fetchedList: dataList, + isCex, +}: QuoteListLoadingProps) => { + const { swapViewList } = useSwapSettings(); + return ( + <> + {Object.entries(isCex ? CEX : DEX).map(([key, value]) => { + if ( + (dataList && dataList.includes(key)) || + swapViewList?.[key] === false + ) + return null; + return ( + + ); + })} + + ); +}; diff --git a/src/ui/views/SwapRenew/hooks/context.tsx b/src/ui/views/SwapRenew/hooks/context.tsx new file mode 100644 index 00000000000..01893c33ce4 --- /dev/null +++ b/src/ui/views/SwapRenew/hooks/context.tsx @@ -0,0 +1,23 @@ +import { createContextState } from '@/ui/hooks/contextState'; + +const [ + SettingVisibleProvider, + useSettingVisible, + useSetSettingVisible, +] = createContextState(false); + +const [ + QuoteVisibleProvider, + useQuoteVisible, + useSetQuoteVisible, +] = createContextState(false); + +const [RefreshIdProvider, useRefreshId, useSetRefreshId] = createContextState( + 0 +); + +export { SettingVisibleProvider, useSettingVisible, useSetSettingVisible }; + +export { RefreshIdProvider, useRefreshId, useSetRefreshId }; + +export { QuoteVisibleProvider, useQuoteVisible, useSetQuoteVisible }; diff --git a/src/ui/views/SwapRenew/hooks/history.tsx b/src/ui/views/SwapRenew/hooks/history.tsx new file mode 100644 index 00000000000..4560188fec1 --- /dev/null +++ b/src/ui/views/SwapRenew/hooks/history.tsx @@ -0,0 +1,104 @@ +import { useInViewport, useInfiniteScroll } from 'ahooks'; +import React, { useEffect, useRef, useState } from 'react'; +import { useQuoteMethods } from './quote'; +import { useRabbySelector } from '@/ui/store'; +import { useAsync } from 'react-use'; +import { uniqBy } from 'lodash'; +import { SwapItem } from '@rabby-wallet/rabby-api/dist/types'; + +export const useSwapHistory = () => { + const { getSwapList } = useQuoteMethods(); + const addr = useRabbySelector( + (state) => state.account.currentAccount?.address || '' + ); + + const [refreshSwapTxListCount, setRefreshSwapListTx] = useState(0); + const refreshSwapListTx = React.useCallback(() => { + setRefreshSwapListTx((e) => e + 1); + }, []); + const isInSwap = true; + + const { + data: txList, + loading, + loadMore, + loadingMore, + noMore, + mutate, + } = useInfiniteScroll( + (d) => + getSwapList( + addr, + d?.list?.length && d?.list?.length > 1 ? d?.list?.length - 1 : 0, + 5 + ), + { + reloadDeps: [isInSwap], + isNoMore(data) { + if (data) { + return data?.list.length >= data?.totalCount; + } + return true; + }, + manual: !isInSwap || !addr, + } + ); + + const { value } = useAsync(async () => { + if (addr) { + return getSwapList(addr, 0, 5); + } + }, [addr, refreshSwapTxListCount]); + + useEffect(() => { + if (value?.list) { + mutate((d) => { + if (!d) { + return; + } + return { + last: d?.last, + totalCount: d?.totalCount, + list: uniqBy( + [...(value.list || []), ...(d?.list || [])], + (e) => `${e.chain}-${e.tx_id}` + ) as SwapItem[], + }; + }); + } + }, [mutate, value]); + + const ref = useRef(null); + + const [inViewport] = useInViewport(ref); + + useEffect(() => { + if (!noMore && inViewport && !loadingMore && loadMore && isInSwap) { + loadMore(); + } + }, [inViewport, loadMore, loading, loadingMore, noMore, isInSwap]); + + useEffect(() => { + let timer: NodeJS.Timeout; + if ( + !loading && + !loadingMore && + txList?.list?.some((e) => e.status !== 'Finished') && + isInSwap + ) { + timer = setTimeout(refreshSwapListTx, 2000); + } + return () => { + if (timer) { + clearTimeout(timer); + } + }; + }, [loading, loadingMore, refreshSwapListTx, txList?.list, isInSwap]); + + return { + loading, + txList, + loadingMore, + ref, + }; +}; diff --git a/src/ui/views/SwapRenew/hooks/index.tsx b/src/ui/views/SwapRenew/hooks/index.tsx new file mode 100644 index 00000000000..5e7e70be84a --- /dev/null +++ b/src/ui/views/SwapRenew/hooks/index.tsx @@ -0,0 +1,7 @@ +export * from './swapReport'; +export * from './token'; +export * from './settings'; +export * from './context'; +export * from './verify'; +export * from './quote'; +export * from './history'; diff --git a/src/ui/views/SwapRenew/hooks/quote.tsx b/src/ui/views/SwapRenew/hooks/quote.tsx new file mode 100644 index 00000000000..9997ce75e99 --- /dev/null +++ b/src/ui/views/SwapRenew/hooks/quote.tsx @@ -0,0 +1,642 @@ +import { CEX, DEX, ETH_USDT_CONTRACT, SWAP_FEE_ADDRESS } from '@/constant'; +import { formatUsdValue, isSameAddress, useWallet } from '@/ui/utils'; +import { CHAINS, CHAINS_ENUM } from '@debank/common'; +import { + CEXQuote, + ExplainTxResponse, + TokenItem, + Tx, +} from '@rabby-wallet/rabby-api/dist/types'; +import { + DEX_ENUM, + DEX_ROUTER_WHITELIST, + DEX_SPENDER_WHITELIST, + WrapTokenAddressMap, +} from '@rabby-wallet/rabby-swap'; +import { QuoteResult, getQuote } from '@rabby-wallet/rabby-swap/dist/quote'; +import BigNumber from 'bignumber.js'; +import React from 'react'; +import pRetry from 'p-retry'; +import { useRabbySelector } from '@/ui/store'; +import stats from '@/stats'; + +export interface validSlippageParams { + chain: CHAINS_ENUM; + slippage: string; + payTokenId: string; + receiveTokenId: string; +} + +export const useQuoteMethods = () => { + const walletController = useWallet(); + const walletOpenapi = walletController.openapi; + const validSlippage = React.useCallback( + async ({ + chain, + slippage, + payTokenId, + receiveTokenId, + }: validSlippageParams) => { + const p = { + slippage: new BigNumber(slippage).div(100).toString(), + chain_id: CHAINS[chain].serverId, + from_token_id: payTokenId, + to_token_id: receiveTokenId, + }; + + return walletOpenapi.checkSlippage(p); + }, + [walletOpenapi] + ); + + const getSwapList = React.useCallback( + async (addr: string, start = 0, limit = 5) => { + const data = await walletOpenapi.getSwapTradeList({ + user_addr: addr, + start: `${start}`, + limit: `${limit}`, + }); + return { + list: data?.history_list, + last: data, + totalCount: data?.total_cnt, + }; + }, + [walletOpenapi] + ); + const postSwap = React.useCallback( + async ({ + payToken, + receiveToken, + payAmount, + // receiveRawAmount, + slippage, + dexId, + txId, + quote, + tx, + }: postSwapParams) => + walletOpenapi.postSwap({ + quote: { + pay_token_id: payToken.id, + pay_token_amount: Number(payAmount), + receive_token_id: receiveToken.id, + receive_token_amount: new BigNumber(quote.toTokenAmount) + .div(10 ** (quote.toTokenDecimals || receiveToken.decimals)) + .toNumber(), + slippage: new BigNumber(slippage).div(100).toNumber(), + }, + // 0xAPI => 0x + dex_id: dexId.replace('API', ''), + tx_id: txId, + tx, + }), + [walletOpenapi] + ); + + const getToken = React.useCallback( + async ({ addr, chain, tokenId }: getTokenParams) => { + return walletOpenapi.getToken( + addr, + CHAINS[chain].serverId, + tokenId // CHAINS[chain].nativeTokenAddress + ); + }, + [walletOpenapi] + ); + + const getTokenApproveStatus = React.useCallback( + async ({ + payToken, + receiveToken, + payAmount, + chain, + dexId, + }: Pick< + getDexQuoteParams, + 'payToken' | 'receiveToken' | 'payAmount' | 'chain' | 'dexId' + >) => { + if ( + payToken?.id === CHAINS[chain].nativeTokenAddress || + isSwapWrapToken(payToken.id, receiveToken.id, chain) + ) { + return [true, false]; + } + + const allowance = await walletController.getERC20Allowance( + CHAINS[chain].serverId, + payToken.id, + getSpender(dexId, chain) + ); + + const tokenApproved = new BigNumber(allowance).gte( + new BigNumber(payAmount).times(10 ** payToken.decimals) + ); + + if ( + chain === CHAINS_ENUM.ETH && + isSameAddress(payToken.id, ETH_USDT_CONTRACT) && + Number(allowance) !== 0 && + !tokenApproved + ) { + return [tokenApproved, true]; + } + return [tokenApproved, false]; + }, + [walletController.getERC20Allowance] + ); + + const getPreExecResult = React.useCallback( + async ({ + userAddress, + chain, + payToken, + receiveToken, + payAmount, + dexId, + quote, + }: getPreExecResultParams) => { + const nonce = await walletController.getRecommendNonce({ + from: userAddress, + chainId: CHAINS[chain].id, + }); + + const gasMarket = await walletOpenapi.gasMarket(CHAINS[chain].serverId); + const gasPrice = gasMarket?.[1]?.price; + + let nextNonce = nonce; + const pendingTx: Tx[] = []; + let gasUsed = 0; + + const approveToken = async (amount: string) => { + const tokenApproveParams = await walletController.generateApproveTokenTx( + { + from: userAddress, + to: payToken.id, + chainId: CHAINS[chain].id, + spender: getSpender(dexId, chain), + amount, + } + ); + const tokenApproveTx = { + ...tokenApproveParams, + nonce: nextNonce, + value: '0x', + gasPrice: `0x${new BigNumber(gasPrice).toString(16)}`, + gas: '0x0', + }; + + const tokenApprovePreExecTx = await walletOpenapi.preExecTx({ + tx: tokenApproveTx, + origin: INTERNAL_REQUEST_ORIGIN, + address: userAddress, + updateNonce: true, + pending_tx_list: pendingTx, + }); + + if (!tokenApprovePreExecTx?.pre_exec?.success) { + throw new Error('pre_exec_tx error'); + } + gasUsed += tokenApprovePreExecTx.gas.gas_used; + + pendingTx.push({ + ...tokenApproveTx, + gas: `0x${new BigNumber(tokenApprovePreExecTx.gas.gas_used) + .times(4) + .toString(16)}`, + }); + nextNonce = `0x${new BigNumber(nextNonce).plus(1).toString(16)}`; + }; + + const [tokenApproved, shouldTwoStepApprove] = await getTokenApproveStatus( + { + payToken, + receiveToken, + payAmount, + chain, + dexId, + } + ); + + if (shouldTwoStepApprove) { + await approveToken('0'); + } + + if (!tokenApproved) { + await approveToken( + new BigNumber(payAmount).times(10 ** payToken.decimals).toFixed(0, 1) + ); + } + + const swapPreExecTx = await walletOpenapi.preExecTx({ + tx: { + ...quote.tx, + nonce: nextNonce, + chainId: CHAINS[chain].id, + value: `0x${new BigNumber(quote.tx.value).toString(16)}`, + gasPrice: `0x${new BigNumber(gasPrice).toString(16)}`, + gas: '0x0', + } as Tx, + origin: INTERNAL_REQUEST_ORIGIN, + address: userAddress, + updateNonce: true, + pending_tx_list: pendingTx, + }); + + if (!swapPreExecTx?.pre_exec?.success) { + throw new Error('pre_exec_tx error'); + } + + gasUsed += swapPreExecTx.gas.gas_used; + + return { + shouldApproveToken: !tokenApproved, + shouldTwoStepApprove, + swapPreExecTx, + gasPrice, + gasUsd: formatUsdValue( + new BigNumber(gasUsed) + .times(gasPrice) + .div(10 ** swapPreExecTx.native_token.decimals) + .times(swapPreExecTx.native_token.price) + .toString(10) + ), + }; + }, + [ + walletOpenapi, + getTokenApproveStatus, + walletController.getRecommendNonce, + walletController.generateApproveTokenTx, + ] + ); + + const getDexQuote = React.useCallback( + async ({ + payToken, + receiveToken, + userAddress, + slippage, + fee: feeAfterDiscount, + payAmount, + chain, + dexId, + setQuote, + }: getDexQuoteParams & { + setQuote?: (quote: TDexQuoteData) => void; + }): Promise => { + const isOpenOcean = dexId === DEX_ENUM.OPENOCEAN; + try { + let gasPrice: number; + if (isOpenOcean) { + const gasMarket = await walletOpenapi.gasMarket( + CHAINS[chain].serverId + ); + gasPrice = gasMarket?.[1]?.price; + } + stats.report('swapRequestQuote', { + dex: dexId, + chain, + fromToken: payToken.id, + toToken: receiveToken.id, + }); + + const data = await pRetry( + () => + getQuote( + isSwapWrapToken(payToken.id, receiveToken.id, chain) + ? DEX_ENUM.WRAPTOKEN + : dexId, + { + fromToken: payToken.id, + toToken: receiveToken.id, + feeAddress: SWAP_FEE_ADDRESS, + fromTokenDecimals: payToken.decimals, + amount: new BigNumber(payAmount) + .times(10 ** payToken.decimals) + .toFixed(0, 1), + userAddress, + slippage: Number(slippage), + feeRate: + feeAfterDiscount === '0' && isOpenOcean + ? undefined + : Number(feeAfterDiscount) || 0, + chain, + gasPrice, + }, + walletOpenapi + ), + { + retries: 1, + } + ); + + stats.report('swapQuoteResult', { + dex: dexId, + chain, + fromToken: payToken.id, + toToken: receiveToken.id, + status: data ? 'success' : 'fail', + }); + + let preExecResult; + if (data) { + try { + preExecResult = await pRetry( + () => + getPreExecResult({ + userAddress, + chain, + payToken, + receiveToken, + payAmount, + quote: data, + dexId: dexId as DEX_ENUM, + }), + { + retries: 1, + } + ); + } catch (error) { + const quote: TDexQuoteData = { + data, + name: dexId, + isDex: true, + preExecResult: null, + }; + setQuote?.(quote); + return quote; + } + } + const quote: TDexQuoteData = { + data, + name: dexId, + isDex: true, + preExecResult, + }; + setQuote?.(quote); + return quote; + } catch (error) { + console.error('getQuote error ', error); + + stats.report('swapQuoteResult', { + dex: dexId, + chain, + fromToken: payToken.id, + toToken: receiveToken.id, + status: 'fail', + }); + + const quote: TDexQuoteData = { + data: null, + name: dexId, + isDex: true, + preExecResult: null, + }; + setQuote?.(quote); + return quote; + } + }, + [walletOpenapi, pRetry, getPreExecResult] + ); + + const getCexQuote = React.useCallback( + async ( + params: getAllCexQuotesParams & { + cexId: string; + setQuote?: (quote: TCexQuoteData) => void; + } + ): Promise => { + const { + payToken, + payAmount, + receiveTokenId: receive_token_id, + chain, + cexId: cex_id, + setQuote, + } = params; + + const p = { + cex_id, + pay_token_amount: payAmount, + chain_id: CHAINS[chain].serverId, + pay_token_id: payToken.id, + receive_token_id, + }; + + let quote: TCexQuoteData; + + try { + const data = await walletOpenapi.getCEXSwapQuote(p); + quote = { + data, + name: cex_id, + isDex: false, + }; + } catch (error) { + quote = { + data: null, + name: cex_id, + isDex: false, + }; + } + + setQuote?.(quote); + + return quote; + }, + [walletOpenapi] + ); + + const swapViewList = useRabbySelector((s) => s.swap.viewList); + + const getAllQuotes = React.useCallback( + async ( + params: Omit & { + setQuote: (quote: TCexQuoteData | TDexQuoteData) => void; + } + ) => { + if ( + isSwapWrapToken( + params.payToken.id, + params.receiveToken.id, + params.chain + ) + ) { + return getDexQuote({ + ...params, + dexId: DEX_ENUM.WRAPTOKEN, + }); + } + + return Promise.all([ + ...(Object.keys(DEX).filter( + (e) => swapViewList?.[e] !== false + ) as DEX_ENUM[]).map((dexId) => getDexQuote({ ...params, dexId })), + ...Object.keys(CEX) + .filter((e) => swapViewList?.[e] !== false) + .map((cexId) => + getCexQuote({ + cexId, + payToken: params.payToken, + payAmount: params.payAmount, + receiveTokenId: params.receiveToken.id, + chain: params.chain, + setQuote: params.setQuote, + }) + ), + ]); + }, + [getDexQuote, getCexQuote] + ); + + return { + validSlippage, + getSwapList, + postSwap, + getToken, + getTokenApproveStatus, + getPreExecResult, + getDexQuote, + getAllQuotes, + swapViewList, + }; +}; + +export interface postSwapParams { + payToken: TokenItem; + receiveToken: TokenItem; + payAmount: string; + // receiveRawAmount: string; + slippage: string; + dexId: string; + txId: string; + quote: QuoteResult; + tx: Tx; +} + +interface getTokenParams { + addr: string; + chain: CHAINS_ENUM; + tokenId: string; +} + +export const getRouter = (dexId: DEX_ENUM, chain: CHAINS_ENUM) => { + const list = DEX_ROUTER_WHITELIST[dexId as keyof typeof DEX_ROUTER_WHITELIST]; + return list[chain as keyof typeof list]; +}; + +export const getSpender = (dexId: DEX_ENUM, chain: CHAINS_ENUM) => { + if (dexId === DEX_ENUM.WRAPTOKEN) { + return ''; + } + const list = + DEX_SPENDER_WHITELIST[dexId as keyof typeof DEX_SPENDER_WHITELIST]; + return list[chain as keyof typeof list]; +}; + +const INTERNAL_REQUEST_ORIGIN = window.location.origin; + +interface getPreExecResultParams + extends Omit { + quote: QuoteResult; +} + +export const halfBetterRate = ( + full: ExplainTxResponse, + half: ExplainTxResponse +) => { + if ( + full.balance_change.success && + half.balance_change.success && + half.balance_change.receive_token_list[0]?.amount && + full.balance_change.receive_token_list[0]?.amount + ) { + const halfReceive = new BigNumber( + half.balance_change.receive_token_list[0].amount + ); + + const fullREceive = new BigNumber( + full.balance_change.receive_token_list[0]?.amount + ); + const diff = new BigNumber(halfReceive).times(2).minus(fullREceive); + + return diff.gt(0) + ? new BigNumber(diff.div(fullREceive).toPrecision(1)) + .times(100) + .toString(10) + : null; + } + return null; +}; + +export type QuotePreExecResultInfo = { + shouldApproveToken: boolean; + shouldTwoStepApprove: boolean; + swapPreExecTx: ExplainTxResponse; + gasPrice: number; + gasUsd: string; +} | null; + +interface getDexQuoteParams { + payToken: TokenItem; + receiveToken: TokenItem; + userAddress: string; + slippage: string; + fee: string; + payAmount: string; + chain: CHAINS_ENUM; + dexId: DEX_ENUM; +} + +export type TDexQuoteData = { + data: null | QuoteResult; + name: string; + isDex: true; + preExecResult: QuotePreExecResultInfo; + loading?: boolean; +}; + +interface getAllCexQuotesParams { + payToken: TokenItem; + payAmount: string; + receiveTokenId: string; + chain: CHAINS_ENUM; +} + +export type TCexQuoteData = { + data: null | CEXQuote; + name: string; + isDex: false; + loading?: boolean; +}; + +export function isSwapWrapToken( + payTokenId: string, + receiveId: string, + chain: CHAINS_ENUM +) { + const wrapTokens = [ + WrapTokenAddressMap[chain as keyof typeof WrapTokenAddressMap], + CHAINS[chain].nativeTokenAddress, + ]; + return ( + !!wrapTokens.find((token) => isSameAddress(payTokenId, token)) && + !!wrapTokens.find((token) => isSameAddress(receiveId, token)) + ); +} + +export type QuoteProvider = { + name: string; + error?: boolean; + quote: QuoteResult | null; + shouldApproveToken: boolean; + shouldTwoStepApprove: boolean; + halfBetterRate?: string; + quoteWarning?: [string, string]; + gasPrice?: number; + activeLoading?: boolean; + activeTx?: string; + actualReceiveAmount: string | number; + gasUsd?: string; +}; diff --git a/src/ui/views/SwapRenew/hooks/settings.tsx b/src/ui/views/SwapRenew/hooks/settings.tsx new file mode 100644 index 00000000000..ffc6304d417 --- /dev/null +++ b/src/ui/views/SwapRenew/hooks/settings.tsx @@ -0,0 +1,21 @@ +import { useRabbyDispatch, useRabbySelector } from '@/ui/store'; +import { useMemo } from 'react'; + +export const useSwapSettings = () => { + const swapViewList = useRabbySelector((s) => s.swap.viewList); + const swapTradeList = useRabbySelector((s) => s.swap.tradeList); + const prevChain = useRabbySelector((s) => s.swap.selectedChain); + const dispatch = useRabbyDispatch(); + + const methods = useMemo(() => { + const { setSelectedChain, setSwapTrade, setSwapView } = dispatch.swap; + return { setSelectedChain, setSwapTrade, setSwapView }; + }, [dispatch]); + + return { + swapViewList, + swapTradeList, + prevChain, + ...methods, + }; +}; diff --git a/src/ui/views/SwapRenew/hooks/swapReport.tsx b/src/ui/views/SwapRenew/hooks/swapReport.tsx new file mode 100644 index 00000000000..7e7d43791d7 --- /dev/null +++ b/src/ui/views/SwapRenew/hooks/swapReport.tsx @@ -0,0 +1,15 @@ +import stats from '@/stats'; +import { useRbiSource } from '@/ui/utils/ga-event'; +import { useEffect } from 'react'; + +export const useSwapStatsReport = () => { + const rbiSource = useRbiSource(); + + useEffect(() => { + if (rbiSource) { + stats.report('enterSwapDescPage', { + refer: rbiSource, + }); + } + }, [rbiSource]); +}; diff --git a/src/ui/views/SwapRenew/hooks/token.tsx b/src/ui/views/SwapRenew/hooks/token.tsx new file mode 100644 index 00000000000..ebd8a8ffd8f --- /dev/null +++ b/src/ui/views/SwapRenew/hooks/token.tsx @@ -0,0 +1,478 @@ +import { useRabbyDispatch, useRabbySelector } from '@/ui/store'; +import { isSameAddress, useWallet } from '@/ui/utils'; +import { CHAINS, CHAINS_ENUM } from '@debank/common'; +import { TokenItem } from '@rabby-wallet/rabby-api/dist/types'; +import { WrapTokenAddressMap } from '@rabby-wallet/rabby-swap'; +import BigNumber from 'bignumber.js'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useAsync, useDebounce } from 'react-use'; +import { + QuoteProvider, + TCexQuoteData, + TDexQuoteData, + useQuoteMethods, +} from './quote'; +import { + useQuoteVisible, + useRefreshId, + useSetRefreshId, + useSettingVisible, +} from './context'; +import { useLocation } from 'react-router-dom'; +import { query2obj } from '@/ui/utils/url'; +import { useRbiSource } from '@/ui/utils/ga-event'; +import stats from '@/stats'; +import { useSwapSettings } from './settings'; +import { useAsyncInitializeChainList } from '@/ui/hooks/useChain'; +import { SWAP_SUPPORT_CHAINS } from '@/constant'; + +const useTokenInfo = ({ + userAddress, + chain, + defaultToken, +}: { + userAddress?: string; + chain?: CHAINS_ENUM; + defaultToken?: TokenItem; +}) => { + const refreshId = useRefreshId(); + const wallet = useWallet(); + const [token, setToken] = useState(defaultToken); + + const { value, loading, error } = useAsync(async () => { + if (userAddress && token?.id && chain) { + const data = await wallet.openapi.getToken( + userAddress, + CHAINS[chain].serverId, + token.id + ); + return data; + } + }, [refreshId, userAddress, token?.id, token?.raw_amount_hex_str, chain]); + + useDebounce( + () => { + if (value && !error && !loading) { + setToken(value); + } + }, + 300, + [value, error, loading] + ); + + if (error) { + console.error('token info error', chain, token?.symbol, token?.id, error); + } + return [token, setToken] as const; +}; + +export const useSlippage = () => { + const [slippageState, setSlippage] = useState('0.1'); + const slippage = useMemo(() => slippageState || '0.1', [slippageState]); + const [slippageChanged, setSlippageChanged] = useState(false); + + return { + slippageChanged, + setSlippageChanged, + slippageState, + slippage, + setSlippage, + }; +}; + +export interface FeeProps { + fee: '0.3' | '0.1' | '0'; + symbol?: string; +} + +export const useTokenPair = (userAddress: string) => { + const dispatch = useRabbyDispatch(); + const refreshId = useRefreshId(); + + const { initialSelectedChain, oChain } = useRabbySelector((state) => { + return { + initialSelectedChain: state.swap.$$initialSelectedChain, + oChain: state.swap.selectedChain || CHAINS_ENUM.ETH, + }; + }); + const [chain, setChain] = useState(oChain); + const handleChain = (c: CHAINS_ENUM) => { + setChain(c); + dispatch.swap.setSelectedChain(c); + // resetSwapTokens(c); + }; + useAsyncInitializeChainList({ + // NOTICE: now `useTokenPair` is only used for swap page, so we can use `SWAP_SUPPORT_CHAINS` here + supportChains: SWAP_SUPPORT_CHAINS, + onChainInitializedAsync: (firstEnum) => { + // only init chain if it's not cached before + if (!initialSelectedChain) { + handleChain(firstEnum); + } + }, + }); + + const [payToken, setPayToken] = useTokenInfo({ + userAddress, + chain, + defaultToken: getChainDefaultToken(chain), + }); + const [receiveToken, setReceiveToken] = useTokenInfo({ + userAddress, + chain, + }); + + const [payAmount, setPayAmount] = useState(''); + + const [feeRate] = useState('0'); + + const { + slippageChanged, + setSlippageChanged, + slippageState, + slippage, + setSlippage, + } = useSlippage(); + + const [currentProvider, setOriActiveProvider] = useState< + QuoteProvider | undefined + >(); + + const expiredTimer = useRef(); + const [expired, setExpired] = useState(false); + + const setActiveProvider: React.Dispatch< + React.SetStateAction + > = useCallback((p) => { + if (expiredTimer.current) { + clearTimeout(expiredTimer.current); + } + setSlippageChanged(false); + setExpired(false); + expiredTimer.current = setTimeout(() => { + setExpired(true); + }, 1000 * 30); + setOriActiveProvider(p); + }, []); + + const exchangeToken = useCallback(() => { + setPayToken(receiveToken); + setReceiveToken(payToken); + }, [setPayToken, receiveToken, setReceiveToken, payToken]); + + const payTokenIsNativeToken = useMemo(() => { + if (payToken) { + return isSameAddress(payToken.id, CHAINS[chain].nativeTokenAddress); + } + return false; + }, [chain, payToken]); + + const handleAmountChange: React.ChangeEventHandler = useCallback( + (e) => { + const v = e.target.value; + if (!/^\d*(\.\d*)?$/.test(v)) { + return; + } + setPayAmount(v); + }, + [] + ); + + const handleBalance = useCallback(() => { + if (!payTokenIsNativeToken && payToken) { + setPayAmount(tokenAmountBn(payToken).toString(10)); + } + }, [payToken, payTokenIsNativeToken]); + + const isStableCoin = useMemo(() => { + if (payToken?.price && receiveToken?.price) { + return new BigNumber(payToken?.price) + .minus(receiveToken?.price) + .div(payToken?.price) + .abs() + .lte(0.01); + } + return false; + }, [payToken, receiveToken]); + + const [isWrapToken, wrapTokenSymbol] = useMemo(() => { + if (payToken?.id && receiveToken?.id) { + const wrapTokens = [ + WrapTokenAddressMap[chain], + CHAINS[chain].nativeTokenAddress, + ]; + const res = + !!wrapTokens.find((token) => isSameAddress(payToken?.id, token)) && + !!wrapTokens.find((token) => isSameAddress(receiveToken?.id, token)); + return [ + res, + isSameAddress(payToken?.id, WrapTokenAddressMap[chain]) + ? payToken.symbol + : receiveToken.symbol, + ]; + } + return [false, '']; + }, [payToken?.id, receiveToken?.id, chain]); + + const inSufficient = useMemo( + () => + payToken + ? tokenAmountBn(payToken).lt(payAmount) + : new BigNumber(0).lt(payAmount), + [payToken, payAmount] + ); + + const switchChain = useCallback( + (c: CHAINS_ENUM, opts?: { payTokenId?: string; changeTo?: boolean }) => { + handleChain(c); + if (!opts?.changeTo) { + setPayToken({ + ...getChainDefaultToken(c), + ...(opts?.payTokenId ? { id: opts?.payTokenId } : {}), + }); + setReceiveToken(undefined); + } else { + setReceiveToken({ + ...getChainDefaultToken(c), + ...(opts?.payTokenId ? { id: opts?.payTokenId } : {}), + }); + // setPayToken(undefined); + } + setPayAmount(''); + setActiveProvider(undefined); + }, + [setPayToken, setReceiveToken] + ); + + useEffect(() => { + // if (isWrapToken) { + // setFeeRate('0'); + // } else if (isStableCoin) { + // setFeeRate('0.1'); + // } else { + // setFeeRate('0.3'); + // } + + if (isStableCoin) { + setSlippage('0.05'); + } + }, [isWrapToken, isStableCoin]); + + const [quoteList, setQuotesList] = useState< + (TCexQuoteData | TDexQuoteData)[] + >([]); + + useEffect(() => { + setQuotesList([]); + }, [payToken?.id, receiveToken?.id, chain, payAmount]); + + const setQuote = useCallback( + (id: number) => (quote: TCexQuoteData | TDexQuoteData) => { + if (id === fetchIdRef.current) { + setQuotesList((e) => { + const index = e.findIndex((q) => q.name === quote.name); + // setActiveProvider((activeQuote) => { + // if (activeQuote?.name === quote.name) { + // return undefined; + // } + // return activeQuote; + // }); + + const v = { ...quote, loading: false }; + if (index === -1) { + return [...e, v]; + } + e[index] = v; + return [...e]; + }); + } + }, + [] + ); + const visible = useQuoteVisible(); + const settingVisible = useSettingVisible(); + + useEffect(() => { + if (!visible) { + setQuotesList([]); + } + }, [visible]); + + const setRefreshId = useSetRefreshId(); + const { swapTradeList, swapViewList } = useSwapSettings(); + + useDebounce( + () => { + if (!settingVisible) { + setQuotesList([]); + setRefreshId((e) => e + 1); + } + }, + 300, + [swapTradeList, swapViewList, settingVisible] + ); + + const fetchIdRef = useRef(0); + const { getAllQuotes, validSlippage } = useQuoteMethods(); + const { loading: quoteLoading, error: quotesError } = useAsync(async () => { + fetchIdRef.current += 1; + const currentFetchId = fetchIdRef.current; + if ( + visible && + userAddress && + payToken?.id && + receiveToken?.id && + receiveToken && + chain && + payAmount && + feeRate + ) { + // setActiveProvider((e) => (e ? { ...e, halfBetterRate: '' } : e)); + setQuotesList((e) => e.map((q) => ({ ...q, loading: true }))); + return getAllQuotes({ + userAddress, + payToken, + receiveToken, + slippage: slippage || '0.1', + chain, + payAmount: payAmount, + fee: feeRate, + setQuote: setQuote(currentFetchId), + }).finally(() => { + // enableSwapBySlippageChanged(currentFetchId); + }); + } + }, [ + // setActiveProvider, + setQuotesList, + setQuote, + refreshId, + userAddress, + payToken?.id, + receiveToken?.id, + chain, + payAmount, + feeRate, + slippage, + visible, + ]); + + if (quotesError) { + console.error('quotesError', quotesError); + } + + const { + value: slippageValidInfo, + error: slippageValidError, + loading: slippageValidLoading, + } = useAsync(async () => { + if (chain && Number(slippage) && payToken?.id && receiveToken?.id) { + return validSlippage({ + chain, + slippage, + payTokenId: payToken?.id, + receiveTokenId: receiveToken?.id, + }); + } + }, [slippage, chain, payToken?.id, receiveToken?.id, refreshId]); + + useEffect(() => { + setExpired(false); + setActiveProvider(undefined); + setSlippageChanged(false); + }, [payToken?.id, receiveToken?.id, chain, payAmount, inSufficient]); + + const { search } = useLocation(); + const [searchObj] = useState<{ + payTokenId?: string; + chain?: string; + }>(query2obj(search)); + + useEffect(() => { + if (searchObj.chain && searchObj.payTokenId) { + const target = Object.values(CHAINS).find( + (item) => item.serverId === searchObj.chain + ); + if (target) { + setChain(target?.enum); + setPayToken({ + ...getChainDefaultToken(target?.enum), + id: searchObj.payTokenId, + }); + } + } + }, [searchObj?.chain, searchObj?.payTokenId]); + + const rbiSource = useRbiSource(); + + useEffect(() => { + if (rbiSource) { + stats.report('enterSwapDescPage', { + refer: rbiSource, + }); + } + }, [rbiSource]); + + return { + chain, + switchChain, + + payToken, + setPayToken, + receiveToken, + setReceiveToken, + exchangeToken, + payTokenIsNativeToken, + + handleAmountChange, + handleBalance, + payAmount, + + isWrapToken, + wrapTokenSymbol, + inSufficient, + slippageChanged, + setSlippageChanged, + slippageState, + slippage, + setSlippage, + feeRate, + + //quote + quoteLoading, + quoteList, + currentProvider, + setActiveProvider, + + slippageValidInfo, + slippageValidLoading, + + expired, + }; +}; + +function getChainDefaultToken(chain: CHAINS_ENUM) { + const chainInfo = CHAINS[chain]; + return { + id: chainInfo.nativeTokenAddress, + decimals: chainInfo.nativeTokenDecimals, + logo_url: chainInfo.nativeTokenLogo, + symbol: chainInfo.nativeTokenSymbol, + display_symbol: chainInfo.nativeTokenSymbol, + optimized_symbol: chainInfo.nativeTokenSymbol, + is_core: true, + is_verified: true, + is_wallet: true, + amount: 0, + price: 0, + name: chainInfo.nativeTokenSymbol, + chain: chainInfo.serverId, + time_at: 0, + } as TokenItem; +} + +function tokenAmountBn(token: TokenItem) { + return new BigNumber(token?.raw_amount_hex_str || 0, 16).div( + 10 ** (token?.decimals || 1) + ); +} diff --git a/src/ui/views/SwapRenew/hooks/verify.tsx b/src/ui/views/SwapRenew/hooks/verify.tsx new file mode 100644 index 00000000000..2c3b3197043 --- /dev/null +++ b/src/ui/views/SwapRenew/hooks/verify.tsx @@ -0,0 +1,140 @@ +import { isSameAddress } from '@/background/utils'; +import { CHAINS_ENUM, CHAINS } from '@debank/common'; +import { DEX_ENUM } from '@rabby-wallet/rabby-swap'; +import { + decodeCalldata, + QuoteResult, + DecodeCalldataResult, +} from '@rabby-wallet/rabby-swap/dist/quote'; +import { useMemo } from 'react'; +import { getRouter, getSpender, isSwapWrapToken } from './quote'; +import BigNumber from 'bignumber.js'; + +type ValidateTokenParam = { + id: string; + symbol: string; + decimals: number; +}; + +export const useVerifyRouterAndSpender = ( + chain: CHAINS_ENUM, + dexId: DEX_ENUM, + router?: string, + spender?: string, + payTokenId?: string, + receiveTokenId?: string +) => { + const data = useMemo(() => { + if (dexId === DEX_ENUM.WRAPTOKEN) { + return [true, true]; + } + if (!dexId || !router || !spender || !payTokenId || !receiveTokenId) { + return [true, true]; + } + const routerWhitelist = getRouter(dexId, chain); + const spenderWhitelist = getSpender(dexId, chain); + const isNativeToken = isSameAddress( + payTokenId, + CHAINS[chain].nativeTokenAddress + ); + const isWrapTokens = isSwapWrapToken(payTokenId, receiveTokenId, chain); + + return [ + isSameAddress(routerWhitelist, router), + isNativeToken || isWrapTokens + ? true + : isSameAddress(spenderWhitelist, spender), + ]; + }, [chain, dexId, payTokenId, receiveTokenId, router, spender]); + return data; +}; + +const isNativeToken = (chain: CHAINS_ENUM, tokenId: string) => + isSameAddress(tokenId, CHAINS[chain].nativeTokenAddress); + +export const useVerifyCalldata = < + T extends Parameters[1] +>( + data: QuoteResult | null, + dexId: DEX_ENUM | null, + slippage: string | number, + tx?: T +) => { + const callDataResult = useMemo(() => { + if (dexId && dexId !== DEX_ENUM.WRAPTOKEN && tx) { + try { + return decodeCalldata(dexId, tx) as DecodeCalldataResult; + } catch (error) { + return null; + } + } + return null; + }, [dexId, tx]); + + const result = useMemo(() => { + if (slippage && callDataResult && data && tx) { + const estimateMinReceive = new BigNumber(data.toTokenAmount).times( + new BigNumber(1).minus(slippage) + ); + const chain = Object.values(CHAINS).find( + (item) => item.id === tx.chainId + ); + + if (!chain) return true; + + return ( + ((dexId === DEX_ENUM['UNISWAP'] && + isNativeToken(chain.enum, data.fromToken)) || + isSameAddress(callDataResult.fromToken, data.fromToken)) && + callDataResult.fromTokenAmount === data.fromTokenAmount && + isSameAddress(callDataResult.toToken, data.toToken) && + new BigNumber(callDataResult.minReceiveToTokenAmount) + .minus(estimateMinReceive) + .div(estimateMinReceive) + .abs() + .lte(0.05) + ); + } + return true; + }, [callDataResult, data, slippage]); + + return result; +}; + +type VerifySdkParams = { + chain: CHAINS_ENUM; + dexId: DEX_ENUM; + slippage: string | number; + data: QuoteResult | null; + payToken: T; + receiveToken: T; +}; + +export const useVerifySdk = ( + p: VerifySdkParams +) => { + const { chain, dexId, slippage, data, payToken, receiveToken } = p; + + const isWrapTokens = isSwapWrapToken(payToken.id, receiveToken.id, chain); + const actualDexId = isWrapTokens ? DEX_ENUM.WRAPTOKEN : dexId; + + const [routerPass, spenderPass] = useVerifyRouterAndSpender( + chain, + actualDexId, + data?.tx?.to, + data?.spender, + payToken?.id, + receiveToken?.id + ); + + const callDataPass = useVerifyCalldata( + data, + actualDexId, + new BigNumber(slippage).div(100).toFixed(), + data?.tx ? { ...data?.tx, chainId: CHAINS[chain].id } : undefined + ); + + return { + isSdkDataPass: routerPass && spenderPass && callDataPass, + }; +}; diff --git a/src/ui/views/SwapRenew/index.tsx b/src/ui/views/SwapRenew/index.tsx new file mode 100644 index 00000000000..96cfc8f59a1 --- /dev/null +++ b/src/ui/views/SwapRenew/index.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { Header } from './Component/Header'; +import { Main } from './Component/Main'; +import { + QuoteVisibleProvider, + RefreshIdProvider, + SettingVisibleProvider, +} from './hooks'; + +const Swap = () => { + return ( + + + +
+
+
+
+
+
+
+ ); +}; +export default Swap; From cdba793c1bdb3eccd5f52487ddd3040af9564726 Mon Sep 17 00:00:00 2001 From: iower Date: Thu, 28 Sep 2023 14:29:22 +0500 Subject: [PATCH 12/44] Styles --- src/ui/views/SwapRenew/Component/Main.tsx | 3 +-- src/ui/views/SwapRenew/index.tsx | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/ui/views/SwapRenew/Component/Main.tsx b/src/ui/views/SwapRenew/Component/Main.tsx index 38782856251..33dd33515ce 100644 --- a/src/ui/views/SwapRenew/Component/Main.tsx +++ b/src/ui/views/SwapRenew/Component/Main.tsx @@ -282,7 +282,7 @@ export const Main = () => { : 'pb-[110px]' )} > -
+
{t('page.swap.chain')}
{
diff --git a/src/ui/views/SwapRenew/index.tsx b/src/ui/views/SwapRenew/index.tsx index 96cfc8f59a1..a48fa309fbf 100644 --- a/src/ui/views/SwapRenew/index.tsx +++ b/src/ui/views/SwapRenew/index.tsx @@ -12,7 +12,7 @@ const Swap = () => { -
+
From 58b406c951dc736b49d7a11a077568efb59c3a77 Mon Sep 17 00:00:00 2001 From: iower Date: Thu, 28 Sep 2023 15:14:10 +0500 Subject: [PATCH 13/44] Styles --- src/ui/component/ChainSelector/InForm.tsx | 8 ++++---- src/ui/views/SwapRenew/Component/Main.tsx | 2 +- src/ui/views/SwapRenew/Component/TokenRender.tsx | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/ui/component/ChainSelector/InForm.tsx b/src/ui/component/ChainSelector/InForm.tsx index c94770f27da..b97a8dd4434 100644 --- a/src/ui/component/ChainSelector/InForm.tsx +++ b/src/ui/component/ChainSelector/InForm.tsx @@ -14,7 +14,7 @@ import { SWAP_SUPPORT_CHAINS } from '@/constant'; const ChainWrapper = styled.div` height: 40px; - background: #f5f6fa; + background: #1d1d1d; border-radius: 6px; padding: 12px 10px; width: 100%; @@ -24,7 +24,7 @@ const ChainWrapper = styled.div` border: 1px solid transparent; cursor: pointer; &:hover { - background: rgba(134, 151, 255, 0.2); + background: #292929; } & > { .down { @@ -33,7 +33,7 @@ const ChainWrapper = styled.div` height: 20px; } .name { - color: #13141a; + color: #ccc; } } `; @@ -61,7 +61,7 @@ export const ChainRender = ({ { : 'pb-[110px]' )} > -
+
{t('page.swap.chain')}
Date: Thu, 28 Sep 2023 15:36:24 +0500 Subject: [PATCH 14/44] Styles --- src/ui/views/SwapRenew/Component/Main.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/ui/views/SwapRenew/Component/Main.tsx b/src/ui/views/SwapRenew/Component/Main.tsx index 78b697fa83d..ab9c6d5e5f6 100644 --- a/src/ui/views/SwapRenew/Component/Main.tsx +++ b/src/ui/views/SwapRenew/Component/Main.tsx @@ -30,17 +30,18 @@ import { Trans, useTranslation } from 'react-i18next'; const tipsClassName = clsx('text-gray-subTitle text-12 mb-4 pt-10'); const StyledInput = styled(Input)` - background: #f5f6fa; + background: #1c1c1c !important; border-radius: 6px; height: 46px; font-weight: 500; font-size: 18px; - color: #ffffff; + color: #ccc !important; box-shadow: none; & > .ant-input { - background: #f5f6fa; + background: transparent; font-weight: 500; font-size: 18px; + color: #ccc; } &.ant-input-affix-wrapper, @@ -49,7 +50,7 @@ const StyledInput = styled(Input)` border: 1px solid transparent; } &:hover { - border: 1px solid rgba(255, 255, 255, 0.8); + background: #292929 !important; box-shadow: none; } @@ -282,7 +283,7 @@ export const Main = () => { : 'pb-[110px]' )} > -
+
{t('page.swap.chain')}
Date: Thu, 28 Sep 2023 16:40:44 +0500 Subject: [PATCH 15/44] Styles --- .../ChainSelector/components/SelectChainList.tsx | 4 ++-- src/ui/component/ChainSelector/style.less | 3 ++- src/ui/component/ChainSelectorNew/style.less | 11 +++++++---- src/ui/views/Dashboard/index.tsx | 2 +- 4 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/ui/component/ChainSelector/components/SelectChainList.tsx b/src/ui/component/ChainSelector/components/SelectChainList.tsx index f382fe0245f..282c24333d5 100644 --- a/src/ui/component/ChainSelector/components/SelectChainList.tsx +++ b/src/ui/component/ChainSelector/components/SelectChainList.tsx @@ -79,7 +79,7 @@ export const SelectChainList = (props: SelectChainListProps) => { } if (sortable) { return ( -
+
{ ); } return ( -
+
{items.map((item) => { return ( { }; const onClickSwap = () => { - history.push('/dex-swap-new'); + history.push('/dex-swap-renew'); }; const brandIcon = useWalletConnectIcon(currentAccount); From 044d725e1fabb8df51cbe09f54881de24afceecf Mon Sep 17 00:00:00 2001 From: iower Date: Thu, 28 Sep 2023 17:30:55 +0500 Subject: [PATCH 16/44] Styles --- src/ui/component/ChainSelector/style.less | 1 - src/ui/component/TokenSelector/style.less | 19 ++++++++++++------- src/ui/style/mixin.less | 2 +- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/ui/component/ChainSelector/style.less b/src/ui/component/ChainSelector/style.less index 3530fa88479..3e2ca45fc26 100644 --- a/src/ui/component/ChainSelector/style.less +++ b/src/ui/component/ChainSelector/style.less @@ -164,7 +164,6 @@ .select-chain { &-list { - background: #f5f6fa; border-radius: 6px; & + & { margin-top: 24px; diff --git a/src/ui/component/TokenSelector/style.less b/src/ui/component/TokenSelector/style.less index d7427ad8f06..5cc8cbc5bf2 100644 --- a/src/ui/component/TokenSelector/style.less +++ b/src/ui/component/TokenSelector/style.less @@ -18,7 +18,7 @@ font-size: 20px; line-height: 23px; text-align: center; - color: #161819; + color: #fff; margin-bottom: 20px; } .input-wrapper { @@ -40,7 +40,7 @@ } &:hover, &.active { - border-color: #8697ff; + border-color: #666; } } } @@ -63,13 +63,12 @@ margin-left: 4px; } &.active { - color: #8697ff; + color: #666; } } } .token-list { position: relative; - background-color: #fff; flex: 1; overflow-y: auto; margin: 0; @@ -115,7 +114,6 @@ position: sticky; top: 0; z-index: 1; - background-color: #fff; cursor: initial; font-size: 12px; line-height: 14px; @@ -146,7 +144,7 @@ border: 1px solid transparent; &:hover { border-radius: 6px; - border-color: #8697ff; + border-color: #666; background: rgba(134, 151, 255, 0.1); } } @@ -184,7 +182,8 @@ align-items: center; border-radius: 4px; - background: #f5f6fa; + border: 1px solid #333; + background: #1f1f1f; } img.filter-item__chain-logo { @@ -196,4 +195,10 @@ cursor: pointer; } } + .ant-drawer-content { + background: #151515; + } + .ant-skeleton { + filter: invert(); + } } diff --git a/src/ui/style/mixin.less b/src/ui/style/mixin.less index ed75f120da6..55bcff9bc27 100644 --- a/src/ui/style/mixin.less +++ b/src/ui/style/mixin.less @@ -10,7 +10,7 @@ position: absolute; content: ''; position: absolute; - background-color: #d8dfeb; + background-color: #333; height: 0.5px; width: @width; } From 690691a79cd2ade2719de12ea4ee39076a2046d1 Mon Sep 17 00:00:00 2001 From: iower Date: Thu, 28 Sep 2023 18:18:58 +0500 Subject: [PATCH 17/44] Styles --- src/ui/assets/back.svg | 2 +- src/ui/assets/swap/history.svg | 10 +++++----- src/ui/assets/swap/setting.svg | 4 ++-- src/ui/assets/swap/settings.svg | 6 +++--- src/ui/style/antd-overwrite.less | 1 + src/ui/views/SwapRenew/Component/Header.tsx | 4 ++-- 6 files changed, 14 insertions(+), 13 deletions(-) diff --git a/src/ui/assets/back.svg b/src/ui/assets/back.svg index a321d1fd68f..7285eac5a0a 100644 --- a/src/ui/assets/back.svg +++ b/src/ui/assets/back.svg @@ -1,3 +1,3 @@ - + diff --git a/src/ui/assets/swap/history.svg b/src/ui/assets/swap/history.svg index 5bcd5ff45d7..24912cefade 100644 --- a/src/ui/assets/swap/history.svg +++ b/src/ui/assets/swap/history.svg @@ -1,12 +1,12 @@ - - + stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" /> + + - + \ No newline at end of file diff --git a/src/ui/assets/swap/setting.svg b/src/ui/assets/swap/setting.svg index e180f88216b..6d66ded95fd 100644 --- a/src/ui/assets/swap/setting.svg +++ b/src/ui/assets/swap/setting.svg @@ -1,4 +1,4 @@ - - + + diff --git a/src/ui/assets/swap/settings.svg b/src/ui/assets/swap/settings.svg index 387765021c1..162ff96649c 100644 --- a/src/ui/assets/swap/settings.svg +++ b/src/ui/assets/swap/settings.svg @@ -1,10 +1,10 @@ - + stroke="currentColor" stroke-width="2" stroke-linecap="round" /> + stroke="currentColor" stroke-width="2" stroke-linecap="round" /> \ No newline at end of file diff --git a/src/ui/style/antd-overwrite.less b/src/ui/style/antd-overwrite.less index 52db012ec4d..2700809cddd 100644 --- a/src/ui/style/antd-overwrite.less +++ b/src/ui/style/antd-overwrite.less @@ -368,6 +368,7 @@ textarea { .ant-drawer-close { font-size: 19px; line-height: 0; + color: #747474; } .ant-drawer-body { padding: 20px 20px 24px; diff --git a/src/ui/views/SwapRenew/Component/Header.tsx b/src/ui/views/SwapRenew/Component/Header.tsx index 5c13bb27a46..e030ab3d43f 100644 --- a/src/ui/views/SwapRenew/Component/Header.tsx +++ b/src/ui/views/SwapRenew/Component/Header.tsx @@ -23,13 +23,13 @@ export const Header = () => { rightSlot={
{ setHistoryVisible(true); }, [])} /> { setVisible(true); }, [])} From e608063f4fc769e4de2ff7d3193273466a7997b6 Mon Sep 17 00:00:00 2001 From: iower Date: Fri, 29 Sep 2023 11:18:14 +0500 Subject: [PATCH 18/44] Styles --- src/ui/style/antd-overwrite.less | 5 ++++- src/ui/views/SwapRenew/Component/TradingSettings.tsx | 4 ++-- tailwind.config.js | 4 ++-- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/ui/style/antd-overwrite.less b/src/ui/style/antd-overwrite.less index 2700809cddd..21ae85fea15 100644 --- a/src/ui/style/antd-overwrite.less +++ b/src/ui/style/antd-overwrite.less @@ -189,6 +189,8 @@ max-height: 500px; overflow: auto; overflow: -moz-scrollbars-none; + background: #131313!important; + border: 1px solid #292929; &::-webkit-scrollbar { display: none; } @@ -256,7 +258,8 @@ Switch ---------------------*/ .ant-switch { - background: #b4bdcc; + background: #A8A29E + ; } .ant-switch-disabled { diff --git a/src/ui/views/SwapRenew/Component/TradingSettings.tsx b/src/ui/views/SwapRenew/Component/TradingSettings.tsx index a2bc9a4c88f..844dbedc6cc 100644 --- a/src/ui/views/SwapRenew/Component/TradingSettings.tsx +++ b/src/ui/views/SwapRenew/Component/TradingSettings.tsx @@ -61,7 +61,7 @@ export const TradingSettings = ({ {list.map((item) => { return (
@@ -69,7 +69,7 @@ export const TradingSettings = ({ src={item.logo} className="w-[24px] h-[24px] rounded-full" /> - + {item.name} Date: Fri, 29 Sep 2023 12:13:21 +0500 Subject: [PATCH 19/44] Fix popup style --- src/ui/component/Popup/index.less | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/component/Popup/index.less b/src/ui/component/Popup/index.less index 57b187a75ec..90d26962484 100644 --- a/src/ui/component/Popup/index.less +++ b/src/ui/component/Popup/index.less @@ -12,7 +12,7 @@ color: @color-title; } .ant-drawer-mask { - background-color: rgba(45, 48, 51, 0.2); + background-color: rgba(0, 0, 0, 0.4); } .ant-drawer-header { background: transparent; From cea6f5c7a515e5d670330831fa5757d1ede37236 Mon Sep 17 00:00:00 2001 From: Max Korolev Date: Tue, 3 Oct 2023 12:11:18 +0100 Subject: [PATCH 20/44] adapter fix --- package.json | 2 +- src/background/service/openapi.ts | 2 ++ yarn.lock | 8 ++++---- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index db565b1b739..8b3a99973da 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "@rabby-wallet/eth-watch-keyring": "^1.0.0", "@rabby-wallet/gnosis-sdk": "^1.3.5", "@rabby-wallet/page-provider": "^0.1.20", - "@rabby-wallet/rabby-api": "^0.6.20", + "@rabby-wallet/rabby-api": "^0.6.22", "@vespaiach/axios-fetch-adapter": "^0.3.0", "@rabby-wallet/rabby-security-engine": "^1.1.16", "@rabby-wallet/rabby-swap": "^0.0.28", diff --git a/src/background/service/openapi.ts b/src/background/service/openapi.ts index 79e37be2aaf..493fdf39bf9 100644 --- a/src/background/service/openapi.ts +++ b/src/background/service/openapi.ts @@ -1,4 +1,5 @@ import { INITIAL_OPENAPI_URL, INITIAL_TESTNET_OPENAPI_URL } from '@/constant'; +import fetchAdapter from '@vespaiach/axios-fetch-adapter'; import { OpenApiService } from '@rabby-wallet/rabby-api'; import { createPersistStore } from 'background/utils'; export * from '@rabby-wallet/rabby-api/dist/types'; @@ -27,6 +28,7 @@ if (!process.env.DEBUG) { } const service = new OpenApiService({ + adapter: fetchAdapter, store, }); diff --git a/yarn.lock b/yarn.lock index 5b46dd41f3e..a2f3420208f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3527,10 +3527,10 @@ sinon-chrome "^3.0.1" webextension-polyfill-ts "^0.26.0" -"@rabby-wallet/rabby-api@^0.6.20": - version "0.6.20" - resolved "https://registry.yarnpkg.com/@rabby-wallet/rabby-api/-/rabby-api-0.6.20.tgz#809c157a39ba2cb029005d932f2bc49f1aa8a2cb" - integrity sha512-+iQOlmDvDay4TXyurazBERd1pjgS82pp+QtyiXUVZVdp80DBnf0K07CiQ/bEnGCREbmXP4a2XXNYKaFuliqWZw== +"@rabby-wallet/rabby-api@^0.6.22": + version "0.6.22" + resolved "https://registry.yarnpkg.com/@rabby-wallet/rabby-api/-/rabby-api-0.6.22.tgz#4114542e254390d61d8e2562f45c4ff2b4c06eb4" + integrity sha512-bhd5YL3B5fXjCLUAj0X3eqimW7rQd3G+dTO/in8hQgkH9oJRsoYfO6j3kikhQmpvsO7iZaISKv2L8PRZ79xLNA== dependencies: "@rabby-wallet/rabby-sign" "^0.3.3" axios "^0.27.2" From 83e01710d8d977977a74a91c12b918ae60a646f6 Mon Sep 17 00:00:00 2001 From: iower Date: Tue, 3 Oct 2023 17:43:07 +0500 Subject: [PATCH 21/44] ts --- src/background/service/openapi.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/background/service/openapi.ts b/src/background/service/openapi.ts index 493fdf39bf9..02d0ad476fa 100644 --- a/src/background/service/openapi.ts +++ b/src/background/service/openapi.ts @@ -28,6 +28,8 @@ if (!process.env.DEBUG) { } const service = new OpenApiService({ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore adapter: fetchAdapter, store, }); From 7d9efc3e68a3ee21baff6983bacff91da85317d1 Mon Sep 17 00:00:00 2001 From: iower Date: Tue, 3 Oct 2023 18:00:51 +0500 Subject: [PATCH 22/44] Styles --- src/ui/views/SwapRenew/Component/IconRefresh.tsx | 2 +- src/ui/views/SwapRenew/Component/QuoteItem.tsx | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/ui/views/SwapRenew/Component/IconRefresh.tsx b/src/ui/views/SwapRenew/Component/IconRefresh.tsx index e87a5de8acd..6236ef87dc2 100644 --- a/src/ui/views/SwapRenew/Component/IconRefresh.tsx +++ b/src/ui/views/SwapRenew/Component/IconRefresh.tsx @@ -11,7 +11,7 @@ export const IconRefresh = memo((props: React.SVGProps) => { viewBox="-6 -6 36 36" className={clsx( 'arrow-loading cursor-pointer', - className || 'text-blue-light' + className || 'text-gray-content' )} width="36" height="36" diff --git a/src/ui/views/SwapRenew/Component/QuoteItem.tsx b/src/ui/views/SwapRenew/Component/QuoteItem.tsx index 6294be204aa..5b7026c5f0d 100644 --- a/src/ui/views/SwapRenew/Component/QuoteItem.tsx +++ b/src/ui/views/SwapRenew/Component/QuoteItem.tsx @@ -102,7 +102,8 @@ const ItemWrapper = styled.div` &:not(.cex).inSufficient, &:not(.cex).disabled { height: 60px; - border: 1px solid #e5e9ef; + border: 1px solid #333; + background: #1f1f1f; border-radius: 6px; box-shadow: none; } @@ -111,7 +112,7 @@ const ItemWrapper = styled.div` font-weight: 500; font-size: 13px; line-height: 15px; - color: #13141a; + color: #777; height: 48px; background-color: transparent; border: none; @@ -134,7 +135,7 @@ const ItemWrapper = styled.div` font-weight: 500; color: #707280; .toToken { - color: #13141a; + color: #fff; } } } From 202c974512605290817f16a2a165f0debc5b66a5 Mon Sep 17 00:00:00 2001 From: iower Date: Tue, 3 Oct 2023 18:21:06 +0500 Subject: [PATCH 23/44] Styles --- src/ui/views/Swap/Component/loading.tsx | 2 +- src/ui/views/SwapRenew/Component/Quotes.tsx | 9 +++++---- src/ui/views/SwapRenew/Component/loading.tsx | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/ui/views/Swap/Component/loading.tsx b/src/ui/views/Swap/Component/loading.tsx index c8f19b3d306..bfa85fcb2ae 100644 --- a/src/ui/views/Swap/Component/loading.tsx +++ b/src/ui/views/Swap/Component/loading.tsx @@ -23,7 +23,7 @@ export const QuoteLoading = ({
diff --git a/src/ui/views/SwapRenew/Component/Quotes.tsx b/src/ui/views/SwapRenew/Component/Quotes.tsx index 756af4a4b80..7f3cc4bafb7 100644 --- a/src/ui/views/SwapRenew/Component/Quotes.tsx +++ b/src/ui/views/SwapRenew/Component/Quotes.tsx @@ -19,7 +19,8 @@ import { useTranslation } from 'react-i18next'; import { getTokenSymbol } from '@/ui/utils/token'; const CexListWrapper = styled.div` - border: 1px solid #e5e9ef; + background: #1f1f1f; + border: 1px solid #333; border-radius: 6px; &:empty { display: none; @@ -32,7 +33,7 @@ const CexListWrapper = styled.div` position: absolute; width: 328px; height: 0; - border-bottom: 1px solid #e5e9ef; + border-bottom: 1px solid #333; left: 16px; bottom: 0; } @@ -225,11 +226,11 @@ export const Quotes = ({
-
+
{t('page.swap.tradingSettingTips', { viewCount, tradeCount })} {t('page.swap.edit')} diff --git a/src/ui/views/SwapRenew/Component/loading.tsx b/src/ui/views/SwapRenew/Component/loading.tsx index c8f19b3d306..bfa85fcb2ae 100644 --- a/src/ui/views/SwapRenew/Component/loading.tsx +++ b/src/ui/views/SwapRenew/Component/loading.tsx @@ -23,7 +23,7 @@ export const QuoteLoading = ({
From 5b638f3b885813e934f916825694ab5ce76ef69a Mon Sep 17 00:00:00 2001 From: iower Date: Wed, 4 Oct 2023 11:01:16 +0500 Subject: [PATCH 24/44] Fix styles --- src/ui/component/TokenSelector/style.less | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/ui/component/TokenSelector/style.less b/src/ui/component/TokenSelector/style.less index 5cc8cbc5bf2..072ebd8a438 100644 --- a/src/ui/component/TokenSelector/style.less +++ b/src/ui/component/TokenSelector/style.less @@ -88,7 +88,7 @@ white-space: nowrap; font-size: 12px; line-height: 15px; - color: #4b4d59; + color: #ccc; margin-left: 12px; } } @@ -97,14 +97,14 @@ text-align: left; font-size: 12px; line-height: 14px; - color: #4b4d59; + color: #ccc; } &:nth-child(3) { width: 100px; text-align: right; font-size: 13px; line-height: 15px; - color: #13141a; + color: #ccc; font-weight: 500; flex: 1; } @@ -130,7 +130,7 @@ & > div { font-size: 12px; line-height: 14px; - color: #707880; + color: #666; font-weight: normal; &:nth-last-child(1) { flex: 1; @@ -144,8 +144,8 @@ border: 1px solid transparent; &:hover { border-radius: 6px; - border-color: #666; - background: rgba(134, 151, 255, 0.1); + border-color: #333; + background: #292929; } } &.empty { From 377d217e5af760a96935e62dfeabe83deb2576df Mon Sep 17 00:00:00 2001 From: iower Date: Wed, 4 Oct 2023 11:53:58 +0500 Subject: [PATCH 25/44] Improve styles --- .../views/SwapRenew/Component/QuoteItem.tsx | 39 +++++++------------ src/ui/views/SwapRenew/Component/Quotes.tsx | 22 ++--------- src/ui/views/SwapRenew/Component/loading.tsx | 5 +-- 3 files changed, 18 insertions(+), 48 deletions(-) diff --git a/src/ui/views/SwapRenew/Component/QuoteItem.tsx b/src/ui/views/SwapRenew/Component/QuoteItem.tsx index 5b7026c5f0d..3be846a9469 100644 --- a/src/ui/views/SwapRenew/Component/QuoteItem.tsx +++ b/src/ui/views/SwapRenew/Component/QuoteItem.tsx @@ -40,13 +40,10 @@ const ItemWrapper = styled.div` padding: 0 12px; display: flex; align-items: center; - color: #13141a; - - border-radius: 6px; - box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.08); + color: #fff; border-radius: 6px; border: 1px solid transparent; - background: white; + background: transparent; cursor: pointer; .disabled-trade { @@ -58,15 +55,11 @@ const ItemWrapper = styled.div` width: 100%; height: 0; padding-left: 16px; - background: #000000; - border-radius: 6px; display: flex; align-items: center; font-size: 12px; gap: 8px; font-weight: 400; - font-size: 12px; - color: #ffffff; pointer-events: none; &.active { pointer-events: auto; @@ -78,13 +71,8 @@ const ItemWrapper = styled.div` } &:hover:not(.disabled, .inSufficient) { - background: linear-gradient( - 0deg, - rgba(134, 151, 255, 0.1), - rgba(134, 151, 255, 0.1) - ), - #ffffff; - border: 1px solid #8697ff; + background: #292929; + border: 1px solid #333; } &.active { outline: 2px solid #8697ff; @@ -96,14 +84,15 @@ const ItemWrapper = styled.div` background-color: transparent; border-radius: 6px; cursor: not-allowed; + opacity: 0.6; } &.error { } &:not(.cex).inSufficient, &:not(.cex).disabled { height: 60px; - border: 1px solid #333; - background: #1f1f1f; + border: transparent; + background: transparent; border-radius: 6px; box-shadow: none; } @@ -112,7 +101,7 @@ const ItemWrapper = styled.div` font-weight: 500; font-size: 13px; line-height: 15px; - color: #777; + color: #fff; height: 48px; background-color: transparent; border: none; @@ -124,7 +113,7 @@ const ItemWrapper = styled.div` display: flex; align-items: center; gap: 6px; - color: #707280; + color: #ссс; .receiveNum { font-size: 15px; max-width: 130px; @@ -133,7 +122,7 @@ const ItemWrapper = styled.div` text-overflow: ellipsis; white-space: nowrap; font-weight: 500; - color: #707280; + color: #fff; .toToken { color: #fff; } @@ -301,7 +290,7 @@ export const DexQuoteItem = ( if (!quote?.toTokenAmount) { right = ( -
+
{t('page.swap.unable-to-fetch-the-price')}
); @@ -313,7 +302,7 @@ export const DexQuoteItem = ( if (!preExecResult && !inSufficient) { center =
-
; right = ( -
+
{t('page.swap.fail-to-simulate-transaction')}
); @@ -325,7 +314,7 @@ export const DexQuoteItem = ( disable = true; center =
-
; right = ( -
+
{t('page.swap.security-verification-failed')}
); @@ -604,7 +593,7 @@ export const CexQuoteItem = (props: { if (!data?.receive_token?.amount) { right = ( -
+
{t('page.swap.this-token-pair-is-not-supported')}
); diff --git a/src/ui/views/SwapRenew/Component/Quotes.tsx b/src/ui/views/SwapRenew/Component/Quotes.tsx index 7f3cc4bafb7..46a43cd1236 100644 --- a/src/ui/views/SwapRenew/Component/Quotes.tsx +++ b/src/ui/views/SwapRenew/Component/Quotes.tsx @@ -19,25 +19,9 @@ import { useTranslation } from 'react-i18next'; import { getTokenSymbol } from '@/ui/utils/token'; const CexListWrapper = styled.div` - background: #1f1f1f; - border: 1px solid #333; - border-radius: 6px; &:empty { display: none; } - - & > div:not(:last-child) { - position: relative; - &:not(:last-child):before { - content: ''; - position: absolute; - width: 328px; - height: 0; - border-bottom: 1px solid #333; - left: 16px; - bottom: 0; - } - } `; const exchangeCount = Object.keys(DEX).length + Object.keys(CEX).length; @@ -143,7 +127,7 @@ export const Quotes = ({ const dex = sortedList.find((e) => e.isDex) as TDexQuoteData | undefined; return ( -
+
{dex ? ( -
+
{sortedList.map((params, idx) => { const { name, data, isDex } = params; if (!isDex) return null; @@ -204,7 +188,7 @@ export const Quotes = ({
{!noCex && ( -
+
{t('page.swap.rates-from-cex')}
)} diff --git a/src/ui/views/SwapRenew/Component/loading.tsx b/src/ui/views/SwapRenew/Component/loading.tsx index bfa85fcb2ae..4b86b68c3e2 100644 --- a/src/ui/views/SwapRenew/Component/loading.tsx +++ b/src/ui/views/SwapRenew/Component/loading.tsx @@ -21,10 +21,7 @@ export const QuoteLoading = ({ }) => { return (
From 0a1ea3415b33569b6ac05f616cde46f43e35b901 Mon Sep 17 00:00:00 2001 From: iower Date: Wed, 4 Oct 2023 12:36:39 +0500 Subject: [PATCH 26/44] Styles --- src/ui/assets/swap/switch.svg | 8 ++++---- src/ui/assets/swap/verified.svg | 4 ++-- .../views/SwapRenew/Component/QuoteItem.tsx | 2 ++ .../SwapRenew/Component/ReceiveDetail.tsx | 20 +++++++++++-------- 4 files changed, 20 insertions(+), 14 deletions(-) diff --git a/src/ui/assets/swap/switch.svg b/src/ui/assets/swap/switch.svg index dd2537f916c..2a7bac6bb2f 100644 --- a/src/ui/assets/swap/switch.svg +++ b/src/ui/assets/swap/switch.svg @@ -1,6 +1,6 @@ - - - - + + + + diff --git a/src/ui/assets/swap/verified.svg b/src/ui/assets/swap/verified.svg index f08f16b4d12..e7159cb7b8f 100644 --- a/src/ui/assets/swap/verified.svg +++ b/src/ui/assets/swap/verified.svg @@ -1,7 +1,7 @@ - + + transform="rotate(45 6.92969 -0.359375)" fill="#529A66" /> \ No newline at end of file diff --git a/src/ui/views/SwapRenew/Component/QuoteItem.tsx b/src/ui/views/SwapRenew/Component/QuoteItem.tsx index 3be846a9469..28d0fa90ec4 100644 --- a/src/ui/views/SwapRenew/Component/QuoteItem.tsx +++ b/src/ui/views/SwapRenew/Component/QuoteItem.tsx @@ -54,6 +54,8 @@ const ItemWrapper = styled.div` opacity: 0; width: 100%; height: 0; + background: #000; + color: #fff; padding-left: 16px; display: flex; align-items: center; diff --git a/src/ui/views/SwapRenew/Component/ReceiveDetail.tsx b/src/ui/views/SwapRenew/Component/ReceiveDetail.tsx index 6fd3b1375f1..8d8199c4a9b 100644 --- a/src/ui/views/SwapRenew/Component/ReceiveDetail.tsx +++ b/src/ui/views/SwapRenew/Component/ReceiveDetail.tsx @@ -61,11 +61,11 @@ export const WarningOrChecked = ({ const ReceiveWrapper = styled.div` position: relative; margin-top: 24px; - border: 1px solid #e5e9ef; + border: 1px solid #333; border-radius: 4px; padding: 12px; - color: #4b4d59; + color: #ccc; font-size: 13px; .receive-token { font-size: 15px; @@ -115,8 +115,9 @@ const ReceiveWrapper = styled.div` .footer { position: relative; - border-top: 0.5px solid #e5e9ef; + border-top: 0.5px solid #333; padding-top: 8px; + color: #7a7a7a; } .quote-provider { position: absolute; @@ -130,14 +131,17 @@ const ReceiveWrapper = styled.div` font-size: 13px; cursor: pointer; - color: #13141a; + color: #6e6e6e; - background: #e4e8ff; + background: linear-gradient(180deg, #363636 0%, #292929 100%), + linear-gradient( + 180deg, + rgba(255, 255, 255, 0.08) 0%, + rgba(255, 255, 255, 0.04) 100% border-radius: 4px; - border: 1px solid transparent; + border: 1px solid #464646; &:hover { - background: #d4daff; - border: 1px solid rgba(134, 151, 255, 0.5); + filter: brightness(1.2); } } `; From 846868532a1844f927547541d50aeac6681c03a0 Mon Sep 17 00:00:00 2001 From: iower Date: Wed, 4 Oct 2023 12:56:20 +0500 Subject: [PATCH 27/44] More styles --- src/ui/assets/swap/switch.svg | 8 ++++---- src/ui/views/SwapRenew/Component/ReceiveDetail.tsx | 10 ++-------- src/ui/views/SwapRenew/Component/Slippage.tsx | 12 ++++++------ 3 files changed, 12 insertions(+), 18 deletions(-) diff --git a/src/ui/assets/swap/switch.svg b/src/ui/assets/swap/switch.svg index 2a7bac6bb2f..586cb7f4c06 100644 --- a/src/ui/assets/swap/switch.svg +++ b/src/ui/assets/swap/switch.svg @@ -1,6 +1,6 @@ - - - - + + + + diff --git a/src/ui/views/SwapRenew/Component/ReceiveDetail.tsx b/src/ui/views/SwapRenew/Component/ReceiveDetail.tsx index 8d8199c4a9b..daa86fb21b3 100644 --- a/src/ui/views/SwapRenew/Component/ReceiveDetail.tsx +++ b/src/ui/views/SwapRenew/Component/ReceiveDetail.tsx @@ -130,16 +130,10 @@ const ReceiveWrapper = styled.div` justify-content: center; font-size: 13px; cursor: pointer; - - color: #6e6e6e; - - background: linear-gradient(180deg, #363636 0%, #292929 100%), - linear-gradient( - 180deg, - rgba(255, 255, 255, 0.08) 0%, - rgba(255, 255, 255, 0.04) 100% + background: linear-gradient(180deg, #363636 0%, #292929 100%); border-radius: 4px; border: 1px solid #464646; + color: #ccc; &:hover { filter: brightness(1.2); } diff --git a/src/ui/views/SwapRenew/Component/Slippage.tsx b/src/ui/views/SwapRenew/Component/Slippage.tsx index 75aa6e12ba8..74f5cd2d773 100644 --- a/src/ui/views/SwapRenew/Component/Slippage.tsx +++ b/src/ui/views/SwapRenew/Component/Slippage.tsx @@ -24,17 +24,17 @@ export const SlippageItem = styled.div<{ display: flex; justify-content: center; align-items: center; - border: 1px solid transparent; cursor: pointer; border-radius: 6px; width: 52px; height: 28px; font-weight: 500; font-size: 12px; - background: #f5f6fa; + background: linear-gradient(180deg, #a8a29e 0%, #78716c 100%); border-radius: 4px; + color: #1b1a18; &:hover { - background: rgba(134, 151, 255, 0.2); + background: linear-gradient(180deg, #cec7c3 0%, #9b928c 100%); } `; @@ -50,12 +50,12 @@ const Wrapper = styled.section` .input { font-weight: 500; font-size: 12px; - background: #f5f6fa; - border: 1px solid #e5e9ef; + background: #141414; + border: 1px solid #44403c; border-radius: 4px; &:placeholder-shown { - color: #707280; + color: #666; } .ant-input { border-radius: 0; From b3d482685fac6ec2e26d404ed144a05b180705ef Mon Sep 17 00:00:00 2001 From: iower Date: Wed, 4 Oct 2023 15:22:20 +0500 Subject: [PATCH 28/44] Styles --- src/ui/style/antd-overwrite.less | 15 +++++++++++++++ .../Actions/components/LogoWithText.tsx | 2 +- .../components/Actions/components/Table.tsx | 18 +++++++++--------- .../Approval/components/Actions/index.tsx | 2 +- .../Approval/components/TextActions/index.tsx | 4 ++-- .../components/TypedDataActions/index.tsx | 4 ++-- src/ui/views/Approval/style.less | 1 - 7 files changed, 30 insertions(+), 16 deletions(-) diff --git a/src/ui/style/antd-overwrite.less b/src/ui/style/antd-overwrite.less index 21ae85fea15..e9cc2f6dc66 100644 --- a/src/ui/style/antd-overwrite.less +++ b/src/ui/style/antd-overwrite.less @@ -424,3 +424,18 @@ textarea { border: 1px solid #8697ff; } } + +.ant-tabs { + .ant-tabs-tab-btn { + color: #ccc; + } + .ant-tabs-tab.ant-tabs-tab-active .ant-tabs-tab-btn { + color: #fff; + } + .ant-tabs-ink-bar { + background: #fff; + } + .ant-tabs-content { + color: #fff; + } +} diff --git a/src/ui/views/Approval/components/Actions/components/LogoWithText.tsx b/src/ui/views/Approval/components/Actions/components/LogoWithText.tsx index 1f1747fb8c3..21ed4922679 100644 --- a/src/ui/views/Approval/components/Actions/components/LogoWithText.tsx +++ b/src/ui/views/Approval/components/Actions/components/LogoWithText.tsx @@ -14,7 +14,7 @@ const Wrapper = styled.div` font-weight: 500; font-size: 15px; line-height: 18px; - color: #333333; + color: #ccc; margin-right: 4px; overflow: hidden; text-overflow: ellipsis; diff --git a/src/ui/views/Approval/components/Actions/components/Table.tsx b/src/ui/views/Approval/components/Actions/components/Table.tsx index 75caf42e684..b53115a8bd3 100644 --- a/src/ui/views/Approval/components/Actions/components/Table.tsx +++ b/src/ui/views/Approval/components/Actions/components/Table.tsx @@ -5,7 +5,7 @@ import IconQuestionMark from 'ui/assets/sign/tx/question-mark.svg'; import { TooltipWithMagnetArrow } from '@/ui/component/Tooltip/TooltipWithMagnetArrow'; const TableWrapper = styled.div` - border: 1px solid #ededed; + border: 1px solid #333333; border-radius: 8px; `; @@ -21,7 +21,7 @@ const Table = ({ const ColWrapper = styled.div` display: flex; - border-bottom: 1px solid #ededed; + border-bottom: 1px solid #333333; align-items: stretch; width: 100%; &:nth-last-child(1) { @@ -39,7 +39,7 @@ const RowWrapper = styled.div` font-weight: 500; font-size: 15px; line-height: 18px; - color: #333333; + color: #ccc; &:not(.title) { flex: 1; width: 190px; @@ -50,18 +50,18 @@ const RowWrapper = styled.div` &.title { font-size: 15px; line-height: 18px; - color: #333333; - border-right: 1px solid #ededed; + color: #ccc; + border-right: 1px solid #333333; width: 120px; flex-shrink: 0; - background-color: #f6f8ff; + background-color: #292929; .icon-tip { display: inline; } } &.has-bottom-border { flex: 1; - border-bottom: 1px solid #e5e9ef; + border-bottom: 1px solid #333333; width: auto; &:nth-last-child(1) { border-bottom: none; @@ -70,7 +70,7 @@ const RowWrapper = styled.div` .desc-list { font-size: 13px; line-height: 15px; - color: #4b4d59; + color: #666; margin: 0; font-weight: 400; li { @@ -84,7 +84,7 @@ const RowWrapper = styled.div` left: 3px; width: 3px; height: 3px; - background-color: #999; + background-color: #323232; border-radius: 100%; top: 6px; } diff --git a/src/ui/views/Approval/components/Actions/index.tsx b/src/ui/views/Approval/components/Actions/index.tsx index 21397669e67..1c18a18d259 100644 --- a/src/ui/views/Approval/components/Actions/index.tsx +++ b/src/ui/views/Approval/components/Actions/index.tsx @@ -68,7 +68,7 @@ export const SignTitle = styled.div` `; export const ActionWrapper = styled.div` - background-color: #fff; + background-color: #1c1c1c; border-radius: 8px; .action-header { display: flex; diff --git a/src/ui/views/Approval/components/TextActions/index.tsx b/src/ui/views/Approval/components/TextActions/index.tsx index d7ed2c7b000..cef5ecb684e 100644 --- a/src/ui/views/Approval/components/TextActions/index.tsx +++ b/src/ui/views/Approval/components/TextActions/index.tsx @@ -39,11 +39,11 @@ export const SignTitle = styled.div` export const ActionWrapper = styled.div` border-radius: 8px; margin-bottom: 8px; - background-color: #fff; + background-color: #1c1c1c; .action-header { display: flex; justify-content: space-between; - background: #8697ff; + background: #666666; padding: 14px; align-items: center; color: #fff; diff --git a/src/ui/views/Approval/components/TypedDataActions/index.tsx b/src/ui/views/Approval/components/TypedDataActions/index.tsx index 0db072c6e15..c446a984576 100644 --- a/src/ui/views/Approval/components/TypedDataActions/index.tsx +++ b/src/ui/views/Approval/components/TypedDataActions/index.tsx @@ -57,11 +57,11 @@ export const SignTitle = styled.div` export const ActionWrapper = styled.div` border-radius: 8px; margin-bottom: 8px; - background-color: #fff; + background-color: #1c1c1c; .action-header { display: flex; justify-content: space-between; - background: #8697ff; + background: #666666; padding: 14px; align-items: center; color: #fff; diff --git a/src/ui/views/Approval/style.less b/src/ui/views/Approval/style.less index af6050b876d..f2999119c1b 100644 --- a/src/ui/views/Approval/style.less +++ b/src/ui/views/Approval/style.less @@ -1826,7 +1826,6 @@ &-btn { font-size: 15px; font-weight: 500; - color: @color-comment-1; } } .ant-tabs-nav::before { From 68bfd8a817d1edbec3537beb584570eee56b32bd Mon Sep 17 00:00:00 2001 From: iower Date: Wed, 4 Oct 2023 16:23:09 +0500 Subject: [PATCH 29/44] Styles --- src/ui/assets/sign/tx/alert.svg | 6 +++--- src/ui/views/Approval/components/Actions/index.tsx | 3 ++- src/ui/views/Approval/components/TextActions/index.tsx | 3 ++- .../Approval/components/TxComponents/GasSelecter.tsx | 10 +++++----- .../Approval/components/TypedDataActions/index.tsx | 1 + src/ui/views/Approval/style.less | 8 +++++--- 6 files changed, 18 insertions(+), 13 deletions(-) diff --git a/src/ui/assets/sign/tx/alert.svg b/src/ui/assets/sign/tx/alert.svg index 9b281cc344c..6fd7b71a6e3 100644 --- a/src/ui/assets/sign/tx/alert.svg +++ b/src/ui/assets/sign/tx/alert.svg @@ -1,6 +1,6 @@ - - + + - + diff --git a/src/ui/views/Approval/components/Actions/index.tsx b/src/ui/views/Approval/components/Actions/index.tsx index 1c18a18d259..73a1a037366 100644 --- a/src/ui/views/Approval/components/Actions/index.tsx +++ b/src/ui/views/Approval/components/Actions/index.tsx @@ -70,10 +70,11 @@ export const SignTitle = styled.div` export const ActionWrapper = styled.div` background-color: #1c1c1c; border-radius: 8px; + border: 1px solid #333; .action-header { display: flex; justify-content: space-between; - background: #8697ff; + background: #333333; padding: 14px; align-items: center; color: #fff; diff --git a/src/ui/views/Approval/components/TextActions/index.tsx b/src/ui/views/Approval/components/TextActions/index.tsx index cef5ecb684e..09e4ccc0ee6 100644 --- a/src/ui/views/Approval/components/TextActions/index.tsx +++ b/src/ui/views/Approval/components/TextActions/index.tsx @@ -37,13 +37,14 @@ export const SignTitle = styled.div` `; export const ActionWrapper = styled.div` + border: 1px solid #333; border-radius: 8px; margin-bottom: 8px; background-color: #1c1c1c; .action-header { display: flex; justify-content: space-between; - background: #666666; + background: #333333; padding: 14px; align-items: center; color: #fff; diff --git a/src/ui/views/Approval/components/TxComponents/GasSelecter.tsx b/src/ui/views/Approval/components/TxComponents/GasSelecter.tsx index f00381583c5..538e6779ecd 100644 --- a/src/ui/views/Approval/components/TxComponents/GasSelecter.tsx +++ b/src/ui/views/Approval/components/TxComponents/GasSelecter.tsx @@ -180,7 +180,7 @@ const ManuallySetGasLimitAlert = styled.div` `; const ErrorsWrapper = styled.div` - border-top: 1px solid #ededed; + border-top: 1px solid #333333; padding-top: 14px; margin-top: 14px; .item { @@ -188,7 +188,7 @@ const ErrorsWrapper = styled.div` font-weight: 500; font-size: 14px; line-height: 16px; - color: #333333; + color: #ccc; margin-bottom: 10px; align-items: flex-start; .icon-alert { @@ -631,7 +631,7 @@ const GasSelector = ({ ) : (
- + {formatTokenAmount( new BigNumber(gas.gasCostAmount).toString(10) )}{' '} @@ -930,7 +930,7 @@ const GasPriceDesc = styled.ul` margin-top: 12px; margin-bottom: 0; font-size: 13px; - color: #4b4d59; + color: #666666; li { position: relative; margin-bottom: 8px; @@ -944,7 +944,7 @@ const GasPriceDesc = styled.ul` width: 4px; height: 4px; border-radius: 100%; - background-color: #4b4d59; + background-color: #666666; left: 0; top: 8px; } diff --git a/src/ui/views/Approval/components/TypedDataActions/index.tsx b/src/ui/views/Approval/components/TypedDataActions/index.tsx index c446a984576..11d9b568f9f 100644 --- a/src/ui/views/Approval/components/TypedDataActions/index.tsx +++ b/src/ui/views/Approval/components/TypedDataActions/index.tsx @@ -55,6 +55,7 @@ export const SignTitle = styled.div` `; export const ActionWrapper = styled.div` + border: 1px solid #333; border-radius: 8px; margin-bottom: 8px; background-color: #1c1c1c; diff --git a/src/ui/views/Approval/style.less b/src/ui/views/Approval/style.less index f2999119c1b..6fe2b77e3b7 100644 --- a/src/ui/views/Approval/style.less +++ b/src/ui/views/Approval/style.less @@ -973,8 +973,9 @@ .token-balance-change { margin-top: 14px; - background-color: #fff; + background-color: #1c1c1c; border-radius: 8px; + border: 1px solid #333; padding: 15px; .token-balance-change-content { &-header { @@ -1282,8 +1283,9 @@ .gas-selector { margin-top: 15px; - background: #fff; + background: #1c1c1c; border-radius: 6px; + border: 1px solid #333; display: flex; padding: 16px; flex-direction: column; @@ -1323,7 +1325,7 @@ font-weight: 500; font-size: 16px; line-height: 19px; - color: #333333; + color: #ccc; } &-gas { font-weight: 500; From 5339d3d584777ccb8c4021e5baa2856eda141516 Mon Sep 17 00:00:00 2001 From: iower Date: Wed, 4 Oct 2023 17:50:29 +0500 Subject: [PATCH 30/44] Styles --- src/background/service/transactionHistory.ts | 2 +- .../Approval/components/FooterBar/AccountInfo.tsx | 6 +++--- .../components/FooterBar/ActionsContainer.tsx | 4 ++-- .../Approval/components/FooterBar/FooterBar.tsx | 14 +++++++------- src/ui/views/Approval/index.tsx | 2 +- 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/background/service/transactionHistory.ts b/src/background/service/transactionHistory.ts index 88ded044ab4..3b2c61e8035 100644 --- a/src/background/service/transactionHistory.ts +++ b/src/background/service/transactionHistory.ts @@ -220,7 +220,7 @@ class TxHistory { const site = { origin: INTERNAL_REQUEST_ORIGIN, icon: '', - name: 'Rabby Wallet', + name: 'Ally', chain: CHAINS_ENUM.ETH, isSigned: false, isTop: false, diff --git a/src/ui/views/Approval/components/FooterBar/AccountInfo.tsx b/src/ui/views/Approval/components/FooterBar/AccountInfo.tsx index 272a9fc3b42..66b89fb1908 100644 --- a/src/ui/views/Approval/components/FooterBar/AccountInfo.tsx +++ b/src/ui/views/Approval/components/FooterBar/AccountInfo.tsx @@ -49,7 +49,7 @@ export const AccountInfo: React.FC = ({ return (
= ({
{isTestnet ? null : (
${displayBalance} diff --git a/src/ui/views/Approval/components/FooterBar/ActionsContainer.tsx b/src/ui/views/Approval/components/FooterBar/ActionsContainer.tsx index b9b187e3655..fd23842bcdf 100644 --- a/src/ui/views/Approval/components/FooterBar/ActionsContainer.tsx +++ b/src/ui/views/Approval/components/FooterBar/ActionsContainer.tsx @@ -27,8 +27,8 @@ export const ActionsContainer: React.FC> = ({