diff --git a/_raw/locales/en/messages.json b/_raw/locales/en/messages.json index 0e0c3c7d1d3..43fadcb3705 100644 --- a/_raw/locales/en/messages.json +++ b/_raw/locales/en/messages.json @@ -957,6 +957,12 @@ } }, "bridge": { + "From": "From", + "To": "To", + "Balance": "Balance: ", + "Select": "Select", + "select-chain": "Select Chain", + "no-quote-found": "No quote found. Please try other token pairs.", "title": "Bridge", "history": "Bridge history", "the-following-bridge-route-are-found": "Found following route", @@ -992,6 +998,17 @@ "no-route-found": "No route found", "aggregator-not-enabled": "This aggregator is not enabled to trade by you.", "enable-it": "Enable it", + "recommendFromToken": "Bridge from <1> for an available quote", + "est-payment": "Est. Payment:", + "est-receiving": "Est. Receiving:", + "est-difference": "Est. Difference:", + "loss-tips": "You're losing {{usd}}. Try a different amount.", + "price-impact": "price impact", + + "showMore": { + "title": "Show More", + "source": "Bridge Source" + }, "settingModal": { "title": "Enable Bridge Aggregators to trade", "confirm": "Confirm", @@ -2376,11 +2393,19 @@ "title": "USD VALUE" } }, + "bridge": { + "token": "Token", + "value": "Value", + "liquidity": "Liquidity", + "liquidityTips": "The higher the historical trade volume, the more likely bridge will be successful.", + "low": "Low", + "high": "High" + }, "searchInput": { "placeholder": "Search by Name / Address" }, "header": { - "title": "Select a token" + "title": "Select Token" }, "noTokens": "No Tokens", "noMatch": "No Match", diff --git a/package.json b/package.json index 4425fd40589..72bbdf56849 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ "@rabby-wallet/gnosis-sdk": "1.3.9", "@rabby-wallet/page-provider": "0.4.2", "@rabby-wallet/rabby-action": "0.1.4", - "@rabby-wallet/rabby-api": "0.8.3", + "@rabby-wallet/rabby-api": "0.8.4-beta.0", "@rabby-wallet/rabby-security-engine": "2.0.7", "@rabby-wallet/rabby-swap": "0.0.40", "@rabby-wallet/widgets": "1.0.9", diff --git a/src/background/controller/wallet.ts b/src/background/controller/wallet.ts index 57e89aad4c1..e2e92c371dc 100644 --- a/src/background/controller/wallet.ts +++ b/src/background/controller/wallet.ts @@ -1760,6 +1760,9 @@ export class WalletController extends BaseController { getBridgeSortIncludeGasFee = bridgeService.getBridgeSortIncludeGasFee; setBridgeSortIncludeGasFee = bridgeService.setBridgeSortIncludeGasFee; setBridgeSettingFirstOpen = bridgeService.setBridgeSettingFirstOpen; + setBridgeAutoSlippage = bridgeService.setAutoSlippage; + setBridgeIsCustomSlippage = bridgeService.setIsCustomSlippage; + setBridgeSlippage = bridgeService.setSlippage; getGasAccountData = gasAccountService.getGasAccountData; getGasAccountSig = gasAccountService.getGasAccountSig; diff --git a/src/background/service/bridge.ts b/src/background/service/bridge.ts index 87fdac7f756..7027fbe4959 100644 --- a/src/background/service/bridge.ts +++ b/src/background/service/bridge.ts @@ -19,6 +19,10 @@ export type BridgeRecord = { }; export type BridgeServiceStore = { + autoSlippage: boolean; + isCustomSlippage?: boolean; + slippage: string; + selectedChain: CHAINS_ENUM | null; selectedFromToken?: TokenItem; selectedToToken?: TokenItem; @@ -48,6 +52,8 @@ class BridgeService { unlimitedAllowance: false, sortIncludeGasFee: true, firstOpen: true, + autoSlippage: true, + slippage: '1', }; init = async () => { @@ -59,6 +65,8 @@ class BridgeService { sortIncludeGasFee: true, txQuotes: {}, firstOpen: true, + autoSlippage: true, + slippage: '1', }, }); @@ -140,6 +148,18 @@ class BridgeService { }); } }; + + setAutoSlippage = (auto: boolean) => { + this.store.autoSlippage = auto; + }; + + setIsCustomSlippage = (isCustomSlippage: boolean) => { + this.store.isCustomSlippage = isCustomSlippage; + }; + + setSlippage = (slippage: string) => { + this.store.slippage = slippage; + }; } export default new BridgeService(); diff --git a/src/ui/assets/bridge/switch-arrow-cc.svg b/src/ui/assets/bridge/switch-arrow-cc.svg new file mode 100644 index 00000000000..bd0a2840134 --- /dev/null +++ b/src/ui/assets/bridge/switch-arrow-cc.svg @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/src/ui/assets/bridge/tiny-down-arrow-cc.svg b/src/ui/assets/bridge/tiny-down-arrow-cc.svg new file mode 100644 index 00000000000..a2d589f4849 --- /dev/null +++ b/src/ui/assets/bridge/tiny-down-arrow-cc.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/ui/assets/warning-cc.svg b/src/ui/assets/warning-cc.svg new file mode 100644 index 00000000000..48f66b173fe --- /dev/null +++ b/src/ui/assets/warning-cc.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/src/ui/component/ChainSelector/InForm.tsx b/src/ui/component/ChainSelector/InForm.tsx index 90d1cbc964b..5e32eeb8d2f 100644 --- a/src/ui/component/ChainSelector/InForm.tsx +++ b/src/ui/component/ChainSelector/InForm.tsx @@ -1,19 +1,16 @@ import React, { InsHTMLAttributes, useEffect, useMemo } from 'react'; import clsx from 'clsx'; -import { CHAINS, CHAINS_ENUM } from '@debank/common'; +import { CHAINS_ENUM } from '@debank/common'; import { useState } from 'react'; import { SelectChainListProps } from '@/ui/component/ChainSelector/components/SelectChainList'; import ChainSelectorModal from '@/ui/component/ChainSelector/Modal'; -import { ChainSelectorPurpose } from '@/ui/hooks/useChain'; import styled from 'styled-components'; import ChainIcon from '@/ui/component/ChainIcon'; -import ImgArrowDown, { - ReactComponent as RcImgArrowDown, -} from '@/ui/assets/swap/arrow-down.svg'; +import { ReactComponent as RcImgArrowDown } from '@/ui/assets/swap/arrow-down.svg'; import { useWallet } from '@/ui/utils'; -import { SWAP_SUPPORT_CHAINS } from '@/constant'; import { findChain } from '@/utils/chain'; +import { useTranslation } from 'react-i18next'; const ChainWrapper = styled.div` /* height: 40px; */ @@ -28,6 +25,24 @@ const ChainWrapper = styled.div` cursor: pointer; font-size: 16px; font-weight: 500; + &.mini { + width: auto; + height: 28px; + font-size: 13px; + padding: 0 6px; + + & > { + .down { + margin-left: auto; + width: 14px; + height: 14px; + } + .name { + color: var(--r-neutral-title-1, #192945); + line-height: normal; + } + } + } &:hover { background: rgba(134, 151, 255, 0.2); } @@ -49,19 +64,25 @@ export const ChainRender = ({ readonly, className, arrowDownComponent, + mini, ...other }: { - chain: CHAINS_ENUM; + chain?: CHAINS_ENUM; readonly: boolean; arrowDownComponent?: React.ReactNode; + mini?: boolean; } & InsHTMLAttributes) => { const wallet = useWallet(); - + const { t } = useTranslation(); const chainInfo = useMemo(() => { return findChain({ enum: chain }); }, [chain]); const [customRPC, setCustomRPC] = useState(''); const getCustomRPC = async () => { + if (!chain) { + setCustomRPC(''); + return; + } const rpc = await wallet.getCustomRpcByChain(chain); setCustomRPC(rpc?.enable ? rpc.url : ''); }; @@ -73,32 +94,37 @@ export const ChainRender = ({ className={clsx( { 'cursor-default hover:bg-r-neutral-bg-2': readonly, + mini: mini, }, className )} {...other} > {/* {CHAINS[chain].name} */} - - {chainInfo?.name} + {chain && ( + + )} + + {chainInfo?.name || t('page.bridge.Select')} + {/* {!readonly && } */} {!readonly && (arrowDownComponent ? ( arrowDownComponent ) : ( - + ))} ); }; interface ChainSelectorProps { - value: CHAINS_ENUM; + value?: CHAINS_ENUM; onChange?(value: CHAINS_ENUM): void; readonly?: boolean; showModal?: boolean; @@ -108,6 +134,9 @@ interface ChainSelectorProps { title?: React.ReactNode; chainRenderClassName?: string; arrowDownComponent?: React.ReactNode; + mini?: boolean; + hideTestnetTab?: boolean; + excludeChains?: CHAINS_ENUM[]; } export default function ChainSelectorInForm({ value, @@ -119,6 +148,9 @@ export default function ChainSelectorInForm({ supportChains, chainRenderClassName, arrowDownComponent, + mini, + hideTestnetTab = false, + excludeChains, }: ChainSelectorProps) { const [showSelectorModal, setShowSelectorModal] = useState(showModal); @@ -146,9 +178,12 @@ export default function ChainSelectorInForm({ readonly={readonly} className={chainRenderClassName} arrowDownComponent={arrowDownComponent} + mini={mini} /> {!readonly && ( { const handleCancel = () => { onCancel(); @@ -158,8 +160,8 @@ const ChainSelectorModal = ({ const history = useHistory(); const { - matteredList, - unmatteredList, + matteredList: _matteredList, + unmatteredList: _unmatteredList, handleStarChange, handleSort, search, @@ -170,6 +172,15 @@ const ChainSelectorModal = ({ netTabKey: !hideMainnetTab ? selectedTab : 'testnet', }); + const [matteredList, unmatteredList] = useMemo(() => { + if (excludeChains?.length) { + return [_matteredList, _unmatteredList].map((chains) => + chains.filter((e) => !excludeChains.includes(e.enum)) + ); + } + return [_matteredList, _unmatteredList]; + }, [excludeChains, _matteredList, _unmatteredList]); + useEffect(() => { if (!value || !visible) return; diff --git a/src/ui/component/TokenSelect/index.tsx b/src/ui/component/TokenSelect/index.tsx index 49af1b1e6b5..7c2a3e08f59 100644 --- a/src/ui/component/TokenSelect/index.tsx +++ b/src/ui/component/TokenSelect/index.tsx @@ -56,7 +56,7 @@ export interface TokenSelectProps { token?: TokenItem; onChange?(amount: string): void; onTokenChange(token: TokenItem): void; - chainId: string; + chainId?: string; useSwapTokenList?: boolean; excludeTokens?: TokenItem['id'][]; type?: ComponentProps['type']; @@ -225,19 +225,21 @@ const TokenSelect = ({ {typeof tokenRender === 'function' ? tokenRender?.({ token, openTokenModal: handleSelectToken }) : tokenRender} - + {queryConds.chainServerId && ( + + )} ); } @@ -286,19 +288,21 @@ const TokenSelect = ({ /> )} - + {queryConds.chainServerId && ( + + )} ); }; diff --git a/src/ui/component/TokenSelector/index.tsx b/src/ui/component/TokenSelector/index.tsx index 8ad440e3c2e..3fe6b7645cc 100644 --- a/src/ui/component/TokenSelector/index.tsx +++ b/src/ui/component/TokenSelector/index.tsx @@ -39,7 +39,7 @@ export interface TokenSelectorProps { } ); onRemoveChainFilter?(ctx: SearchCallbackCtx); - type?: 'default' | 'swapFrom' | 'swapTo'; + type?: 'default' | 'swapFrom' | 'swapTo' | 'bridgeFrom'; placeholder?: string; chainId: string; disabledTips?: string; @@ -160,7 +160,7 @@ const TokenSelector = ({ {Array(isSwapType ? 8 : 10) .fill(1) .map((_, i) => ( - + ))} ) : ( @@ -210,6 +210,92 @@ const TokenSelector = ({ } }, [type, query, isSwapType, displayList, query, chainServerId]); + const Header = React.useMemo(() => { + if (type === 'bridgeFrom') { + return ( +
  • +
    {t('component.TokenSelector.bridge.token')}
    +
    +
    {t('component.TokenSelector.bridge.value')}
    +
  • + ); + } + return ( +
  • +
    + {/* ASSET / AMOUNT */} + {t('component.TokenSelector.listTableHead.assetAmount.title')} +
    +
    + {/* PRICE */} + {t('component.TokenSelector.listTableHead.price.title')} +
    +
    + {/* USD VALUE */} + {t('component.TokenSelector.listTableHead.usdValue.title')} +
    +
  • + ); + }, [type, t]); + + const bridgeFromItemRender = (token: TokenItem) => { + const chainItem = findChain({ serverId: token.chain }); + const disabled = + !!supportChains?.length && + chainItem && + !supportChains.includes(chainItem.enum); + + return ( + +
  • !disabled && onConfirm(token)} + > +
    + +
    + + {getTokenSymbol(token)} + + + {chainItem?.name} + +
    +
    + +
    + +
    +
    + {formatTokenAmount(token.amount)} +
    +
    + {formatUsdValue( + new BigNumber(token.price || 0).times(token.amount).toFixed() + )} +
    +
    +
  • +
    + ); + }; + return (
    - {chainItem && ( + {chainItem && type !== 'bridgeFrom' && ( <>
    -
  • -
    - {/* ASSET / AMOUNT */} - {t('component.TokenSelector.listTableHead.assetAmount.title')} -
    -
    - {/* PRICE */} - {t('component.TokenSelector.listTableHead.price.title')} -
    -
    - {/* USD VALUE */} - {t('component.TokenSelector.listTableHead.usdValue.title')} -
    -
  • + {Header} {isEmpty ? NoDataUI : displayList.map((token) => { + if (type === 'bridgeFrom') { + return bridgeFromItemRender(token); + } const chainItem = findChain({ serverId: token.chain }); const disabled = !!supportChains?.length && @@ -444,7 +520,7 @@ const TokenSelector = ({ ); }; -const DefaultLoading = () => ( +const DefaultLoading = ({ type }: { type: TokenSelectorProps['type'] }) => (
    (
    - + {type !== 'bridgeFrom' && ( + + )}
    { const chainServerId = token.chain; const chain = findChain({ serverId: chainServerId, }); + + const chainStyle = useMemo( + () => ({ + width: chainSize, + height: chainSize, + }), + [chainSize] + ); return (
    - + ) : ( - + ))}
    ); diff --git a/src/ui/models/bridge.ts b/src/ui/models/bridge.ts index 07744891841..ae41f8e98ee 100644 --- a/src/ui/models/bridge.ts +++ b/src/ui/models/bridge.ts @@ -13,6 +13,8 @@ export const bridge = createModel()({ name: 'bridge', state: { + slippage: '1', + autoSlippage: true, supportedChains: DEFAULT_BRIDGE_SUPPORTED_CHAIN, aggregatorsListInit: false, aggregatorsList: DEFAULT_BRIDGE_AGGREGATOR, @@ -115,7 +117,7 @@ export const bridge = createModel()({ }, async fetchSupportedChains(_: void, store) { - const chains = await store.app.wallet.openapi.getBridgeSupportChain(); + const chains = await store.app.wallet.openapi.getBridgeSupportChainV2(); if (chains.length) { const mappings = Object.values(CHAINS).reduce((acc, chain) => { acc[chain.serverId] = chain.enum; @@ -126,5 +128,21 @@ export const bridge = createModel()({ }); } }, + + async setAutoSlippage(autoSlippage: boolean, store) { + await store.app.wallet.setBridgeAutoSlippage(autoSlippage); + console.log('autoSlippage set', autoSlippage); + this.setField({ autoSlippage }); + }, + + async setIsCustomSlippage(isCustomSlippage: boolean, store) { + await store.app.wallet.setBridgeIsCustomSlippage(isCustomSlippage); + this.setField({ isCustomSlippage }); + }, + + async setSlippage(slippage: string, store) { + await store.app.wallet.setBridgeSlippage(slippage); + this.setField({ slippage }); + }, }), }); diff --git a/src/ui/utils/token.ts b/src/ui/utils/token.ts index d6bb7b11b2c..5654bb16ddd 100644 --- a/src/ui/utils/token.ts +++ b/src/ui/utils/token.ts @@ -4,8 +4,8 @@ import { Contract, providers } from 'ethers'; import { hexToString } from 'web3-utils'; import { AbstractPortfolioToken } from './portfolio/types'; import { CustomTestnetToken } from '@/background/service/customTestnet'; -import { findChain } from '@/utils/chain'; -import { MINIMUM_GAS_LIMIT } from '@/constant'; +import { findChain, findChainByEnum } from '@/utils/chain'; +import { CHAINS_ENUM, MINIMUM_GAS_LIMIT } from '@/constant'; export const geTokenDecimals = async ( id: string, @@ -258,3 +258,29 @@ export function checkIfTokenBalanceEnough( customLevel, }; } + +export function tokenAmountBn(token: TokenItem) { + return new BigNumber(token?.raw_amount_hex_str || 0, 16).div( + 10 ** (token?.decimals || 1) + ); +} + +export function getChainDefaultToken(chain: CHAINS_ENUM) { + const chainInfo = findChainByEnum(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; +} diff --git a/src/ui/views/Bridge/Component/BridgeContent.tsx b/src/ui/views/Bridge/Component/BridgeContent.tsx index a3c08a0383d..db3f550df2f 100644 --- a/src/ui/views/Bridge/Component/BridgeContent.tsx +++ b/src/ui/views/Bridge/Component/BridgeContent.tsx @@ -6,7 +6,7 @@ import React, { useState, } from 'react'; import { useRabbySelector } from '@/ui/store'; -import { useTokenPair } from '../hooks/token'; +import { useBridge } from '../hooks/token'; import { Alert, Button, Input, message, Modal } from 'antd'; import BigNumber from 'bignumber.js'; import { @@ -25,7 +25,7 @@ import { useRbiSource } from '@/ui/utils/ga-event'; import { useCss } from 'react-use'; import { getTokenSymbol } from '@/ui/utils/token'; import ChainSelectorInForm from '@/ui/component/ChainSelector/InForm'; -import { findChainByServerID } from '@/utils/chain'; +import { findChainByEnum, findChainByServerID } from '@/utils/chain'; import type { SelectChainItemProps } from '@/ui/component/ChainSelector/components/SelectChainItem'; import i18n from '@/i18n'; import { useTranslation } from 'react-i18next'; @@ -39,8 +39,13 @@ import { MaxButton } from '../../SendToken/components/MaxButton'; import { MiniApproval } from '../../Approval/components/MiniSignTx'; import { useMemoizedFn, useRequest } from 'ahooks'; import { useCurrentAccount } from '@/ui/hooks/backgroundState/useAccount'; -import { KEYRING_CLASS, KEYRING_TYPE } from '@/constant'; +import { CHAINS_ENUM, KEYRING_CLASS, KEYRING_TYPE } from '@/constant'; import { useHistory } from 'react-router-dom'; +import { BridgeToken } from './BridgeToken'; +import { TokenItem } from '@rabby-wallet/rabby-api/dist/types'; +import { BridgeShowMore, RecommendFromToken } from './BridgeShowMore'; +import { BridgeSwitchBtn } from './BridgeSwitchButton'; +import { ReactComponent as RcIconWarningCC } from '@/ui/assets/warning-cc.svg'; const tipsClassName = clsx('text-r-neutral-body text-12 mb-8 pt-14'); @@ -99,20 +104,19 @@ export const BridgeContent = () => { })); const { - chain, - switchChain, - - payToken, - setPayToken, - receiveToken, - setReceiveToken, - + fromChain, + fromToken, + setFromToken, + switchFromChain, + toChain, + toToken, + setToToken, + switchToChain: setToChain, + switchToken, + amount, handleAmountChange, - setPayAmount, - handleBalance, - debouncePayAmount, - inputAmount, + recommendFromToken, inSufficient, @@ -125,30 +129,14 @@ export const BridgeContent = () => { setSelectedBridgeQuote, expired, - } = useTokenPair(userAddress); - - const payAmountLoading = useMemo(() => inputAmount !== debouncePayAmount, [ - inputAmount, - debouncePayAmount, - ]); - const quoteOrAmountLoading = quoteLoading || payAmountLoading; - - const amountAvailable = useMemo(() => Number(debouncePayAmount) > 0, [ - debouncePayAmount, - ]); - - const aggregatorIds = useRabbySelector( - (s) => s.bridge.aggregatorsList.map((e) => e.id) || [] - ); + slippage, + slippageState, + setSlippage, + setSlippageChanged, + } = useBridge(); - const inputRef = useRef(); - - useLayoutEffect(() => { - if ((payToken?.id, receiveToken?.id)) { - inputRef.current?.focus(); - } - }, [payToken?.id, receiveToken?.id]); + const amountAvailable = useMemo(() => Number(amount) > 0, [amount]); const visible = useQuoteVisible(); @@ -159,9 +147,9 @@ export const BridgeContent = () => { const { t } = useTranslation(); const btnText = useMemo(() => { - if (selectedBridgeQuote && expired) { - return t('page.bridge.price-expired-refresh-route'); - } + // if (selectedBridgeQuote && expired) { + // return t('page.bridge.price-expired-refresh-route'); + // } if (selectedBridgeQuote?.shouldApproveToken) { return t('page.bridge.approve-and-bridge', { name: selectedBridgeQuote?.aggregator.name || '', @@ -179,15 +167,14 @@ export const BridgeContent = () => { const wallet = useWallet(); const rbiSource = useRbiSource(); - const supportedChains = useRabbySelector((s) => s.bridge.supportedChains); const [fetchingBridgeQuote, setFetchingBridgeQuote] = useState(false); const [isShowSign, setIsShowSign] = useState(false); const gotoBridge = useCallback(async () => { if ( !inSufficient && - payToken && - receiveToken && + fromToken && + toToken && selectedBridgeQuote?.bridge_id ) { try { @@ -197,25 +184,25 @@ export const BridgeContent = () => { wallet.openapi.getBridgeQuote({ aggregator_id: selectedBridgeQuote.aggregator.id, bridge_id: selectedBridgeQuote.bridge_id, - from_token_id: payToken.id, + from_token_id: fromToken.id, user_addr: userAddress, - from_chain_id: payToken.chain, - from_token_raw_amount: new BigNumber(debouncePayAmount) - .times(10 ** payToken.decimals) + from_chain_id: fromToken.chain, + from_token_raw_amount: new BigNumber(amount) + .times(10 ** fromToken.decimals) .toFixed(0, 1) .toString(), - to_chain_id: receiveToken.chain, - to_token_id: receiveToken.id, + to_chain_id: toToken.chain, + to_token_id: toToken.id, }), { retries: 1 } ); stats.report('bridgeQuoteResult', { aggregatorIds: selectedBridgeQuote.aggregator.id, bridgeId: selectedBridgeQuote.bridge_id, - fromChainId: payToken.chain, - fromTokenId: payToken.id, - toTokenId: receiveToken.id, - toChainId: receiveToken.chain, + fromChainId: fromToken.chain, + fromTokenId: fromToken.id, + toTokenId: toToken.id, + toChainId: toToken.chain, status: tx ? 'success' : 'fail', }); wallet.bridgeToken( @@ -223,23 +210,23 @@ export const BridgeContent = () => { to: tx.to, value: tx.value, data: tx.data, - payTokenRawAmount: new BigNumber(debouncePayAmount) - .times(10 ** payToken.decimals) + payTokenRawAmount: new BigNumber(amount) + .times(10 ** fromToken.decimals) .toFixed(0, 1) .toString(), chainId: tx.chainId, shouldApprove: !!selectedBridgeQuote.shouldApproveToken, shouldTwoStepApprove: !!selectedBridgeQuote.shouldTwoStepApprove, - payTokenId: payToken.id, - payTokenChainServerId: payToken.chain, + payTokenId: fromToken.id, + payTokenChainServerId: fromToken.chain, info: { aggregator_id: selectedBridgeQuote.aggregator.id, bridge_id: selectedBridgeQuote.bridge_id, - from_chain_id: payToken.chain, - from_token_id: payToken.id, - from_token_amount: debouncePayAmount, - to_chain_id: receiveToken.chain, - to_token_id: receiveToken.id, + from_chain_id: fromToken.chain, + from_token_id: fromToken.id, + from_token_amount: amount, + to_chain_id: toToken.chain, + to_token_id: toToken.id, to_token_amount: selectedBridgeQuote.to_token_amount, tx: tx, rabby_fee: selectedBridgeQuote.rabby_fee.usd_value, @@ -259,10 +246,10 @@ export const BridgeContent = () => { stats.report('bridgeQuoteResult', { aggregatorIds: selectedBridgeQuote.aggregator.id, bridgeId: selectedBridgeQuote.bridge_id, - fromChainId: payToken.chain, - fromTokenId: payToken.id, - toTokenId: receiveToken.id, - toChainId: receiveToken.chain, + fromChainId: fromToken.chain, + fromTokenId: fromToken.id, + toTokenId: toToken.id, + toChainId: toToken.chain, status: 'fail', }); console.error(error); @@ -272,8 +259,8 @@ export const BridgeContent = () => { } }, [ inSufficient, - payToken, - receiveToken, + fromToken, + toToken, selectedBridgeQuote?.tx, selectedBridgeQuote?.shouldApproveToken, selectedBridgeQuote?.shouldTwoStepApprove, @@ -281,43 +268,44 @@ export const BridgeContent = () => { selectedBridgeQuote?.bridge_id, selectedBridgeQuote?.to_token_amount, wallet, - debouncePayAmount, + amount, rbiSource, ]); const buildTxs = useMemoizedFn(async () => { if ( !inSufficient && - payToken && - receiveToken && + fromToken && + toToken && selectedBridgeQuote?.bridge_id ) { try { setFetchingBridgeQuote(true); const { tx } = await pRetry( () => - wallet.openapi.getBridgeQuote({ + wallet.openapi.getBridgeQuoteTxV2({ aggregator_id: selectedBridgeQuote.aggregator.id, bridge_id: selectedBridgeQuote.bridge_id, - from_token_id: payToken.id, + from_chain_id: fromToken.chain, + from_token_id: fromToken.id, user_addr: userAddress, - from_chain_id: payToken.chain, - from_token_raw_amount: new BigNumber(debouncePayAmount) - .times(10 ** payToken.decimals) + from_token_raw_amount: new BigNumber(amount) + .times(10 ** fromToken.decimals) .toFixed(0, 1) .toString(), - to_chain_id: receiveToken.chain, - to_token_id: receiveToken.id, + to_chain_id: toToken.chain, + to_token_id: toToken.id, + slippage: new BigNumber(slippageState).div(100).toString(10), }), { retries: 1 } ); stats.report('bridgeQuoteResult', { aggregatorIds: selectedBridgeQuote.aggregator.id, bridgeId: selectedBridgeQuote.bridge_id, - fromChainId: payToken.chain, - fromTokenId: payToken.id, - toTokenId: receiveToken.id, - toChainId: receiveToken.chain, + fromChainId: fromToken.chain, + fromTokenId: fromToken.id, + toTokenId: toToken.id, + toChainId: toToken.chain, status: tx ? 'success' : 'fail', }); return wallet.buildBridgeToken( @@ -325,23 +313,23 @@ export const BridgeContent = () => { to: tx.to, value: tx.value, data: tx.data, - payTokenRawAmount: new BigNumber(debouncePayAmount) - .times(10 ** payToken.decimals) + payTokenRawAmount: new BigNumber(amount) + .times(10 ** fromToken.decimals) .toFixed(0, 1) .toString(), chainId: tx.chainId, shouldApprove: !!selectedBridgeQuote.shouldApproveToken, shouldTwoStepApprove: !!selectedBridgeQuote.shouldTwoStepApprove, - payTokenId: payToken.id, - payTokenChainServerId: payToken.chain, + payTokenId: fromToken.id, + payTokenChainServerId: fromToken.chain, info: { aggregator_id: selectedBridgeQuote.aggregator.id, bridge_id: selectedBridgeQuote.bridge_id, - from_chain_id: payToken.chain, - from_token_id: payToken.id, - from_token_amount: debouncePayAmount, - to_chain_id: receiveToken.chain, - to_token_id: receiveToken.id, + from_chain_id: fromToken.chain, + from_token_id: fromToken.id, + from_token_amount: amount, + to_chain_id: toToken.chain, + to_token_id: toToken.id, to_token_amount: selectedBridgeQuote.to_token_amount, tx: tx, rabby_fee: selectedBridgeQuote.rabby_fee.usd_value, @@ -360,10 +348,10 @@ export const BridgeContent = () => { stats.report('bridgeQuoteResult', { aggregatorIds: selectedBridgeQuote.aggregator.id, bridgeId: selectedBridgeQuote.bridge_id, - fromChainId: payToken.chain, - fromTokenId: payToken.id, - toTokenId: receiveToken.id, - toChainId: receiveToken.chain, + fromChainId: fromToken.chain, + fromTokenId: fromToken.id, + toTokenId: toToken.id, + toChainId: toToken.chain, status: 'fail', }); console.error(error); @@ -429,132 +417,90 @@ export const BridgeContent = () => { selectedBridgeQuote?.shouldApproveToken ? 'pb-[130px]' : 'pb-[110px]' )} > -
    -
    {t('page.bridge.bridgeTo')}
    - } +
    + - -
    - {t('page.bridge.BridgeTokenPair')} + +
    +
    +
    -
    - { - setPayToken(value.from); - setReceiveToken(value.to); +
    + {selectedBridgeQuote && ( + { + setSlippageChanged(true); + setSlippage(e); }} - value={useMemo( - () => - payToken && receiveToken - ? { - from: payToken, - to: receiveToken, - } - : undefined, - [payToken, receiveToken] - )} - aggregatorIds={aggregatorIds || []} - chain={chain} + fromToken={fromToken} + toToken={toToken} + amount={amount || 0} + toAmount={selectedBridgeQuote?.to_token_amount} + openQuotesList={openQuotesList} /> -
    - -
    -
    - {t('page.bridge.Amount', { - symbol: payToken ? getTokenSymbol(payToken) : '', - })} -
    -
    - {t('global.Balance')}: {formatAmount(payToken?.amount || 0)} - { - handleBalance(); - }} - > - {t('page.swap.max')} - -
    -
    - - {inputAmount - ? `≈ ${formatUsdValue( - new BigNumber(inputAmount) - .times(payToken?.price || 0) - .toString(10) - )}` - : ''} - - } - /> - - {quoteOrAmountLoading && - amountAvailable && - !inSufficient && - !selectedBridgeQuote?.manualClick && } - - {payToken && - !inSufficient && - receiveToken && - amountAvailable && - (!quoteOrAmountLoading || selectedBridgeQuote?.manualClick) && ( - + )} + {fromToken && + toToken && + Number(amount) > 0 && + !quoteLoading && + !quoteList?.length && + recommendFromToken && ( + )}
    - {inSufficient ? ( + {inSufficient || + (fromToken && + toToken && + Number(amount) > 0 && + !quoteLoading && + !quoteList?.length && + !recommendFromToken) ? ( } banner message={ - {t('page.bridge.insufficient-balance')} + {inSufficient + ? t('page.bridge.insufficient-balance') + : t('page.bridge.no-quote-found')} } /> @@ -611,18 +557,17 @@ export const BridgeContent = () => { handleBridge(); }} disabled={ - !payToken || - !receiveToken || + !fromToken || + !toToken || !amountAvailable || inSufficient || - payAmountLoading || !selectedBridgeQuote } > {btnText}
    - {payToken && receiveToken && chain ? ( + {fromToken && toToken ? ( { setVisible(false); }} userAddress={userAddress} - chain={chain} - payToken={payToken} - payAmount={debouncePayAmount} - receiveToken={receiveToken} + payToken={fromToken} + payAmount={amount} + receiveToken={toToken} inSufficient={inSufficient} setSelectedBridgeQuote={setSelectedBridgeQuote} /> diff --git a/src/ui/views/Bridge/Component/BridgeQuotes.tsx b/src/ui/views/Bridge/Component/BridgeQuotes.tsx index c2de8e3e4c1..d75c0535462 100644 --- a/src/ui/views/Bridge/Component/BridgeQuotes.tsx +++ b/src/ui/views/Bridge/Component/BridgeQuotes.tsx @@ -12,7 +12,6 @@ import { BridgeQuoteItem } from './BridgeQuoteItem'; import { ReactComponent as RCIconCCEmpty } from 'ui/assets/bridge/empty-cc.svg'; interface QuotesProps { - chain: CHAINS_ENUM; userAddress: string; loading: boolean; inSufficient: boolean; diff --git a/src/ui/views/Bridge/Component/BridgeShowMore.tsx b/src/ui/views/Bridge/Component/BridgeShowMore.tsx new file mode 100644 index 00000000000..f542ed86fc8 --- /dev/null +++ b/src/ui/views/Bridge/Component/BridgeShowMore.tsx @@ -0,0 +1,204 @@ +import { TokenWithChain } from '@/ui/component'; +import { getTokenSymbol } from '@/ui/utils/token'; +import { TokenItem } from '@rabby-wallet/rabby-api/dist/types'; +import { Button, Tooltip } from 'antd'; +import clsx from 'clsx'; +import React, { PropsWithChildren, ReactNode, useMemo, useState } from 'react'; +import { Trans, useTranslation } from 'react-i18next'; +import { ReactComponent as IconArrowDownCC } from 'ui/assets/bridge/tiny-down-arrow-cc.svg'; +import { ReactComponent as RcIconInfo } from 'ui/assets/info-cc.svg'; +import { BridgeSlippage } from './BridgeSlippage'; +import { tokenPriceImpact } from '../hooks'; + +const dottedClassName = + 'h-0 flex-1 border-b-[0.5px] border-dotted border-rabby-neutral-line'; + +export const BridgeShowMore = ({ + openQuotesList, + sourceName, + sourceLogo, + slippage, + displaySlippage, + onSlippageChange, + fromToken, + toToken, + amount, + toAmount, +}: { + openQuotesList: () => void; + sourceName: string; + sourceLogo: string; + slippage: string; + displaySlippage: string; + onSlippageChange: (n: string) => void; + showLoss?: boolean; + fromToken?: TokenItem; + toToken?: TokenItem; + amount?: string | number; + toAmount?: string | number; +}) => { + const { t } = useTranslation(); + const [show, setShow] = useState(false); + + const data = useMemo( + () => tokenPriceImpact(fromToken, toToken, amount, toAmount), + [fromToken, toToken, amount, toAmount] + ); + + return ( +
    +
    +
    +
    setShow((e) => !e)} + > + {t('page.bridge.showMore.title')} + +
    +
    +
    + +
    + {data?.showLoss && ( +
    +
    + {t('page.bridge.price-impact')} + + -{data.diff}% + +
    + {t('page.bridge.est-payment')} {amount} + {getTokenSymbol(fromToken)} ≈ {data.fromUsd} +
    +
    + {t('page.bridge.est-receiving')} {toAmount} + {getTokenSymbol(toToken)} ≈ {data.toUsd} +
    +
    + {t('page.bridge.est-difference')} {data.lossUsd} +
    +
    + } + > + + + +
    +
    + {t('page.bridge.loss-tips', { + usd: '$374', + })} +
    +
    + )} + + +
    + {sourceLogo && ( + {sourceName} + )} + {sourceName} +
    +
    + + +
    +
    + ); +}; + +function ListItem({ + name, + className, + children, +}: PropsWithChildren<{ name: React.ReactNode; className?: string }>) { + return ( +
    + {name} +
    {children}
    +
    + ); +} + +export const RecommendFromToken = ({ + token, + className, +}: { + token: TokenItem; + className?: string; +}) => { + const { t } = useTranslation(); + return ( +
    +
    + + Bridge from +
    + + {getTokenSymbol(token)} +
    + for an available quote +
    +
    + +
    + ); +}; diff --git a/src/ui/views/Bridge/Component/BridgeSlippage.tsx b/src/ui/views/Bridge/Component/BridgeSlippage.tsx new file mode 100644 index 00000000000..5a1de910700 --- /dev/null +++ b/src/ui/views/Bridge/Component/BridgeSlippage.tsx @@ -0,0 +1,284 @@ +import clsx from 'clsx'; +import { + memo, + useMemo, + useCallback, + ChangeEventHandler, + useState, + useEffect, +} from 'react'; +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'; +import { useBridgeSlippageStore } from '../hooks/slippage'; + +const SlippageItem = styled.div` + position: relative; + display: flex; + justify-content: center; + align-items: center; + border: 1px solid transparent; + cursor: pointer; + border-radius: 6px; + width: 58px; + height: 32px; + font-weight: 500; + font-size: 13px; + background: var(--r-neutral-card-1, #fff); + border-radius: 6px; + overflow: hidden; + + &.input { + border: 1px solid var(--r-neutral-line, #e0e5ec); + background: var(--r-neutral-card-1, #fff); + } + + &:hover, + &.active { + background: var(--r-blue-light1, #eef1ff); + border: 1px solid var(--r-blue-default, #7084ff); + } + + &.error, + &.active.error, + &.error:hover { + border: 1px solid var(--r-red-default, #e34935); + background: var(--r-red-light, #fff2f0); + } +`; + +const SLIPPAGE = ['0.5', '1']; + +const MAX_SLIPPAGE = 10; + +const Wrapper = styled.section` + .slippage { + display: flex; + align-items: center; + gap: 8px; + } + + .input { + font-weight: 500; + font-size: 12px; + border: none; + border-radius: 4px; + background: transparent; + + &:placeholder-shown { + color: #707280; + } + .ant-input { + border-radius: 0; + font-weight: 500; + font-size: 12px; + } + } + + .warning { + padding: 8px; + border-radius: 4px; + border: 0.5px solid var(--r-red-default, #e34935); + background: var(--r-red-light, #fff2f0); + color: var(--r-red-default, #e34935); + font-size: 13px; + font-style: normal; + font-weight: 400; + line-height: normal; + position: relative; + margin-top: 8px; + } +`; +interface SlippageProps { + value: string; + displaySlippage: string; + onChange: (n: string) => void; + recommendValue?: number; +} +export const BridgeSlippage = memo((props: SlippageProps) => { + const { t } = useTranslation(); + + const { value, displaySlippage, onChange, recommendValue } = props; + + const { + autoSlippage, + isCustomSlippage, + setAutoSlippage, + setIsCustomSlippage, + } = useBridgeSlippageStore(); + + const [slippageOpen, setSlippageOpen] = useState(false); + + const [isLow, isHigh] = useMemo(() => { + return [ + value?.trim() !== '' && Number(value || 0) < 0.2, + value?.trim() !== '' && Number(value || 0) > 3, + ]; + }, [value]); + + const setRecommendValue = useCallback(() => { + onChange(new BigNumber(recommendValue || 0).times(100).toString()); + setAutoSlippage(false); + setIsCustomSlippage(false); + }, [onChange, recommendValue, setAutoSlippage, setIsCustomSlippage]); + + 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 onInputChange: ChangeEventHandler = useCallback( + (e) => { + setAutoSlippage(false); + setIsCustomSlippage(true); + const v = e.target.value; + if (/^\d*(\.\d*)?$/.test(v)) { + onChange(Number(v) > MAX_SLIPPAGE ? `${MAX_SLIPPAGE}` : v); + } + }, + [onChange, setAutoSlippage, setIsCustomSlippage] + ); + + useEffect(() => { + if (tips) { + setSlippageOpen(true); + } + }, [tips]); + + return ( +
    +
    { + setSlippageOpen((e) => !e); + }} + > + + {t('page.swap.slippage-tolerance')} + + + + {displaySlippage}% + + {/* */} + +
    + +
    + { + if (autoSlippage) { + return; + } + event.stopPropagation(); + onChange(value); + setAutoSlippage(true); + setIsCustomSlippage(false); + }} + className={clsx(autoSlippage && 'active')} + > + {t('page.swap.Auto')} + + {SLIPPAGE.map((e) => ( + { + event.stopPropagation(); + setIsCustomSlippage(false); + setAutoSlippage(false); + onChange(e); + }} + className={clsx( + !autoSlippage && !isCustomSlippage && e === value && 'active' + )} + > + {e}% + + ))} + { + event.stopPropagation(); + setAutoSlippage(false); + setIsCustomSlippage(true); + }} + className={clsx( + 'input', + 'flex-1', + isCustomSlippage && 'active', + tips && 'error' + )} + > + { + setAutoSlippage(false); + setIsCustomSlippage(true); + }} + placeholder="0.5" + suffix={ +
    %
    + } + /> +
    +
    + + {!!tips &&
    {tips}
    } +
    +
    + ); +}); diff --git a/src/ui/views/Bridge/Component/BridgeSwitchButton.tsx b/src/ui/views/Bridge/Component/BridgeSwitchButton.tsx new file mode 100644 index 00000000000..e710e2f3c4e --- /dev/null +++ b/src/ui/views/Bridge/Component/BridgeSwitchButton.tsx @@ -0,0 +1,27 @@ +import clsx from 'clsx'; +import React from 'react'; +import { ReactComponent as RcIconSwitchCC } from 'ui/assets/bridge/switch-arrow-cc.svg'; + +export const BridgeSwitchBtn = ({ + className, + ...others +}: React.DetailedHTMLProps< + React.HTMLAttributes, + HTMLDivElement +>) => { + return ( +
    + +
    + ); +}; diff --git a/src/ui/views/Bridge/Component/BridgeToTokenSelect.tsx b/src/ui/views/Bridge/Component/BridgeToTokenSelect.tsx new file mode 100644 index 00000000000..6d23995a8d2 --- /dev/null +++ b/src/ui/views/Bridge/Component/BridgeToTokenSelect.tsx @@ -0,0 +1,585 @@ +import React, { useMemo, useState, useEffect } from 'react'; +import { Drawer, Input, Skeleton, Tooltip } from 'antd'; +import { TokenItem } from 'background/service/openapi'; +import { getTokenSymbol } from 'ui/utils/token'; +import styled from 'styled-components'; +import LessPalette, { ellipsis } from '@/ui/style/var-defs'; +import { ReactComponent as SvgIconArrowDownTriangle } from '@/ui/assets/swap/arrow-caret-down2.svg'; +import { useRabbySelector } from '@/ui/store'; +import { uniqBy } from 'lodash'; +import { CHAINS_ENUM, SWAP_SUPPORT_CHAINS } from '@/constant'; +import useSortToken from '@/ui/hooks/useSortTokens'; +import { useAsync, useDebounce } from 'react-use'; +import { useWallet } from '@/ui/utils'; +import { TokenWithChain } from '@/ui/component'; + +import MatchImage from 'ui/assets/match.svg'; +import IconSearch from 'ui/assets/search.svg'; +import { ReactComponent as RcIconCloseCC } from 'ui/assets/component/close-cc.svg'; +import { useTranslation } from 'react-i18next'; +import clsx from 'clsx'; +import { findChain, findChainByServerID } from '@/utils/chain'; +import { ReactComponent as RcIconInfoCC } from '@/ui/assets/info-cc.svg'; +import { TooltipWithMagnetArrow } from '@/ui/component/Tooltip/TooltipWithMagnetArrow'; + +const Wrapper = styled.div` + background-color: transparent; + border-radius: 4px; + display: flex; + justify-content: space-between; + align-items: center; + cursor: pointer; + + & .ant-input { + background-color: transparent; + border-color: transparent; + color: #161819; + flex: 1; + font-weight: 500; + font-size: 22px; + line-height: 26px; + + text-align: right; + color: #13141a; + padding-right: 0; + + &:placeholder { + color: #707280; + } + } +`; + +const Text = styled.span` + font-weight: 500; + font-size: 20px; + line-height: 23px; + color: ${LessPalette['@color-title']}; + max-width: 100px; + ${ellipsis()} +`; + +export interface TokenSelectProps { + type: 'from' | 'to'; + fromChainId?: string; + fromTokenId?: string; + token?: TokenItem; + onChange?(amount: string): void; + onTokenChange(token: TokenItem): void; + chainId?: string; + excludeTokens?: TokenItem['id'][]; + placeholder?: string; + hideChainIcon?: boolean; + value?: string; + loading?: boolean; + tokenRender?: + | (({ + token, + openTokenModal, + }: { + token?: TokenItem; + openTokenModal: () => void; + }) => React.ReactNode) + | React.ReactNode; +} + +const defaultExcludeTokens = []; + +const BridgeToTokenSelect = ({ + fromChainId, + fromTokenId, + type, + token, + onChange, + onTokenChange, + chainId, + excludeTokens = defaultExcludeTokens, + placeholder, + hideChainIcon = true, + value, + loading = false, + tokenRender, +}: TokenSelectProps) => { + const [queryConds, setQueryConds] = useState({ + keyword: '', + }); + const [tokenSelectorVisible, setTokenSelectorVisible] = useState(false); + const currentAccount = useRabbySelector( + (state) => state.account.currentAccount + ); + const wallet = useWallet(); + + const handleCurrentTokenChange = (token: TokenItem) => { + onChange && onChange(''); + onTokenChange(token); + setTokenSelectorVisible(false); + + setQueryConds((prev) => ({ ...prev })); + }; + + const handleTokenSelectorClose = () => { + setTokenSelectorVisible(false); + + setQueryConds((prev) => ({ + ...prev, + })); + }; + + const handleSelectToken = () => { + setTokenSelectorVisible(true); + }; + + const { + value: swapTokenList, + loading: swapTokenListLoading, + } = useAsync(async () => { + if (fromChainId) { + const list = await wallet.openapi.getBridgeToTokenList({ + from_chain_id: fromChainId, + from_token_id: fromTokenId, + // @ts-expect-error 123 + to_chain_id: chainId, + q: queryConds.keyword, + }); + return list?.token_list; + } + return []; + }, [currentAccount, tokenSelectorVisible]); + + const allDisplayTokens = useMemo(() => { + return swapTokenList; + }, [swapTokenList]); + + const availableToken = useMemo(() => { + const allTokens = allDisplayTokens; + return uniqBy(allTokens, (token) => { + return `${token.chain}-${token.id}`; + }).filter((e) => !excludeTokens.includes(e.id)); + }, [allDisplayTokens, excludeTokens, queryConds]); + + const displayTokenList = useSortToken(availableToken); + + const isListLoading = swapTokenListLoading; + + const handleSearchTokens = React.useCallback(async (ctx) => { + setQueryConds({ + keyword: ctx.keyword, + }); + }, []); + + const [input, setInput] = useState(''); + + const handleInput: React.ChangeEventHandler = (e) => { + const v = e.target.value; + if (!/^\d*(\.\d*)?$/.test(v)) { + return; + } + setInput(v); + onChange && onChange(v); + }; + + useEffect(() => { + setQueryConds((prev) => ({ + ...prev, + chainServerId: chainId, + })); + }, [chainId]); + + if (tokenRender) { + return ( + <> + {typeof tokenRender === 'function' + ? tokenRender?.({ token, openTokenModal: handleSelectToken }) + : tokenRender} + {chainId && ( + + )} + + ); + } + + return ( + <> + +
    + {token ? ( + + + {getTokenSymbol(token)} + + + ) : ( + + Select Token + + + )} +
    + {loading ? ( + + ) : ( + + )} +
    + + + ); +}; + +const TokenWrapper = styled.div` + /* width: 92px; */ + /* height: 30px; */ + display: flex; + align-items: center; + justify-content: center; + gap: 5px; + padding: 4px; + border-radius: 4px; + &:hover { + background: rgba(134, 151, 255, 0.3); + } +`; + +const SelectTips = styled.div` + display: flex; + align-items: center; + justify-content: center; + width: 150px; + height: 32px; + color: #fff; + background: var(--r-blue-default, #7084ff); + border-radius: 4px; + font-weight: 500; + font-size: 20px; + line-height: 23px; + & svg { + margin-left: 4px; + filter: brightness(1000); + } +`; + +export interface TokenSelectorProps { + visible: boolean; + list: TokenItem[]; + isLoading?: boolean; + onConfirm(item: TokenItem): void; + onCancel(): void; + placeholder?: string; + chainId?: string; + disabledTips?: string; + supportChains?: CHAINS_ENUM[] | undefined; + itemRender?: ( + token: TokenItem, + supportChains?: CHAINS_ENUM[] + ) => React.ReactNode; + onSearch: (q: string) => void; +} + +const TokenSelector = ({ + visible, + list, + onConfirm, + onCancel, + onSearch, + isLoading = false, + placeholder, + chainId: chainServerId, + disabledTips, + supportChains, + itemRender, +}: TokenSelectorProps) => { + const { t } = useTranslation(); + const [query, setQuery] = useState(''); + const [isInputActive, setIsInputActive] = useState(false); + + useDebounce( + () => { + onSearch(query); + }, + 150, + [query] + ); + + const handleQueryChange = (value: string) => { + setQuery(value); + }; + + const displayList = useMemo(() => { + return list || []; + }, [list]); + + const handleInputFocus = () => { + setIsInputActive(true); + }; + + const handleInputBlur = () => { + setIsInputActive(false); + }; + + useEffect(() => { + if (!visible) { + setQuery(''); + } + }, [visible]); + + const isEmpty = list.length <= 0; + + const isSearchAddr = useMemo(() => { + const v = query?.trim() || ''; + return v.length === 42 && v.toLowerCase().startsWith('0x'); + }, [query]); + + const NoDataUI = useMemo( + () => + isLoading ? ( +
    + {Array(10) + .fill(1) + .map((_, i) => ( + + ))} +
    + ) : ( +
    + no site + + {!query || isSearchAddr ? ( +

    + {t('component.TokenSelector.noTokens')} +

    + ) : ( + <> +

    + {t('component.TokenSelector.noMatch')} +

    +

    + {/* Try to search contract address on {{ chainName }} */} + {t('component.TokenSelector.noMatchSuggestion', { + chainName: + findChain({ + serverId: chainServerId, + })?.name || 'chain', + })} +

    + + )} +
    + ), + [isLoading, t, isSearchAddr, chainServerId] + ); + + return ( + + } + > + {/* Select a token */} +
    {t('component.TokenSelector.header.title')}
    +
    + } + // Search by Name / Address + placeholder={ + placeholder ?? t('component.TokenSelector.searchInput.placeholder') + } + allowClear + value={query} + onChange={(e) => handleQueryChange(e.target.value)} + autoFocus + onFocus={handleInputFocus} + onBlur={handleInputBlur} + /> +
    + + { +
      +
    • +
      {t('component.TokenSelector.bridge.token')}
      +
      + {t('component.TokenSelector.bridge.liquidity')} + + + +
      +
    • + {isEmpty + ? NoDataUI + : displayList.map((_token) => { + const token = (_token as any) as TokenItem & { + trade_volume_level: 'low' | 'high'; + }; + if (itemRender) { + return itemRender(token, supportChains); + } + const chainItem = findChain({ serverId: token.chain }); + const disabled = + !!supportChains?.length && + chainItem && + !supportChains.includes(chainItem.enum); + + return ( + +
    • !disabled && onConfirm(token)} + > +
      + +
      + + {getTokenSymbol(token)} + + + {findChainByServerID(token.chain)?.name || ''} + +
      +
      + +
      + +
      +
      +
      + + {token?.trade_volume_level === 'high' + ? t('component.TokenSelector.bridge.high') + : t('component.TokenSelector.bridge.low')} + +
      +
      +
    • +
      + ); + })} +
    + } +
    + ); +}; + +const DefaultLoading = () => ( +
    +
    + +
    + + +
    +
    +
    +
    + +
    +
    +); + +export default BridgeToTokenSelect; diff --git a/src/ui/views/Bridge/Component/BridgeToken.tsx b/src/ui/views/Bridge/Component/BridgeToken.tsx new file mode 100644 index 00000000000..7b2690a59fa --- /dev/null +++ b/src/ui/views/Bridge/Component/BridgeToken.tsx @@ -0,0 +1,226 @@ +import ChainSelectorInForm from '@/ui/component/ChainSelector/InForm'; +import TokenSelect from '@/ui/component/TokenSelect'; +import { findChainByEnum } from '@/utils/chain'; +import { CHAINS_ENUM } from '@debank/common'; +import { TokenItem } from '@rabby-wallet/rabby-api/dist/types'; +import { Input } from 'antd'; +import clsx from 'clsx'; +import React, { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { TokenRender } from '../../Swap/Component/TokenRender'; +import { formatTokenAmount, formatUsdValue } from '@/ui/utils'; +import BigNumber from 'bignumber.js'; +import { MaxButton } from '../../SendToken/components/MaxButton'; +import { tokenAmountBn } from '@/ui/utils/token'; +import SkeletonInput from 'antd/lib/skeleton/Input'; +import styled from 'styled-components'; +import BridgeToTokenSelect from './BridgeToTokenSelect'; +import { ReactComponent as RcIconInfoCC } from 'ui/assets/info-cc.svg'; +import { useSetSettingVisible } from '../hooks'; + +const StyledInput = styled(Input)` + /* height: 46px; */ + color: var(--r-neutral-title1, #192945); + font-size: 24px; + font-style: normal; + font-weight: 500; + line-height: normal; + /* border: 1px solid var(--r-neutral-line, #d3d8e0); */ + background: transparent !important; + & > .ant-input { + color: var(--r-neutral-title1, #192945); + font-size: 24px; + font-style: normal; + font-weight: 500; + line-height: normal; + border-width: 0px !important; + border-color: transparent; + } + /* &.ant-input-affix-wrapper:not(.ant-input-affix-wrapper-disabled):hover { + border-width: 1px !important; + } */ + + /* &:active { + border: 1px solid transparent; + } */ + /* &:focus, + &:focus-within { + border-width: 1px !important; + border-color: var(--r-blue-default, #7084ff) !important; + } + &:hover { + border-width: 1px !important; + border-color: var(--r-blue-default, #7084ff) !important; + box-shadow: none; + } */ + + &:placeholder-shown { + color: var(--r-neutral-foot, #6a7587); + } + &::-webkit-inner-spin-button, + &::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; + } +`; + +export const BridgeToken = ({ + type = 'from', + token, + chain, + // excludeTokens, + excludeChains, + onChangeToken, + onChangeChain, + value, + onInputChange, + valueLoading, + fromChainId, + fromTokenId, +}: { + type?: 'from' | 'to'; + token?: TokenItem; + chain?: CHAINS_ENUM; + // excludeTokens?: TokenItem['id'][]; + excludeChains?: CHAINS_ENUM[]; + onChangeToken: (token: TokenItem) => void; + onChangeChain: (chain: CHAINS_ENUM) => void; + value?: string | number; + onInputChange?: (v: string) => void; + valueLoading?: boolean; + fromChainId?: string; + fromTokenId?: string; +}) => { + const { t } = useTranslation(); + + const isFromToken = type === 'from'; + + const name = type === 'from' ? t('page.bridge.From') : t('page.bridge.To'); + const chainObj = findChainByEnum(chain); + + const openFeePopup = useSetSettingVisible(); + + const useValue = useMemo(() => { + if (token && value) { + return formatUsdValue( + new BigNumber(value).multipliedBy(token.price || 0).toString() + ); + } + return '$0.00'; + }, [token?.price, value]); + + const inputChange = React.useCallback( + (e: React.ChangeEvent) => { + onInputChange?.(e.target.value); + }, + [onInputChange] + ); + + const handleMax = React.useCallback(() => { + if (token) { + onInputChange?.(tokenAmountBn(token)?.toString(10)); + } + }, [token?.raw_amount_hex_str, onInputChange]); + + return ( +
    +
    + {name} + +
    + +
    +
    + {valueLoading ? ( + + ) : ( + + )} + {type === 'to' ? ( + } + /> + ) : ( + } + /> + )} +
    + +
    +
    + {useValue} + {type === 'to' && !!value && ( + openFeePopup(true)} + viewBox="0 0 14 14" + className="w-14 h-14 text-r-neutral-foot cursor-pointer" + /> + )} +
    +
    + + {t('page.bridge.Balance')} + + {token + ? formatTokenAmount(tokenAmountBn(token).toString(10)) || '0' + : 0} + + {isFromToken && ( + {t('page.swap.max')} + )} +
    +
    +
    +
    + ); +}; diff --git a/src/ui/views/Bridge/hooks/history.tsx b/src/ui/views/Bridge/hooks/history.tsx index b0d9ee089b6..1a5c350b04e 100644 --- a/src/ui/views/Bridge/hooks/history.tsx +++ b/src/ui/views/Bridge/hooks/history.tsx @@ -134,7 +134,6 @@ export const useBridgeHistory = () => { useEffect(() => { if (!noMore && inViewport && !loadingMore && loadMore && isInBridge) { - console.log('loadMore'); loadMore(); } }, [inViewport, loadMore, loading, loadingMore, noMore, isInBridge]); diff --git a/src/ui/views/Bridge/hooks/slippage.tsx b/src/ui/views/Bridge/hooks/slippage.tsx new file mode 100644 index 00000000000..c50f5f2f5f3 --- /dev/null +++ b/src/ui/views/Bridge/hooks/slippage.tsx @@ -0,0 +1,67 @@ +import { useRabbyDispatch, useRabbySelector } from '@/ui/store'; +import { useCallback, useMemo, useState } from 'react'; + +export const useBridgeSlippage = () => { + const previousSlippage = useRabbySelector((s) => s.bridge.slippage || '1'); + const [slippageState, setSlippageState] = useState(previousSlippage); + + const setSlippageOnStore = useRabbyDispatch().bridge.setSlippage; + + const slippage = useMemo(() => slippageState || '0.03', [slippageState]); + const [slippageChanged, setSlippageChanged] = useState(false); + + const setSlippage = useCallback( + (slippage: string) => { + setSlippageOnStore(slippage); + setSlippageState(slippage); + }, + [setSlippageOnStore] + ); + + const [isSlippageLow, isSlippageHigh] = useMemo(() => { + return [ + slippageState?.trim() !== '' && Number(slippageState || 0) < 0.2, + slippageState?.trim() !== '' && Number(slippageState || 0) > 3, + ]; + }, [slippageState]); + + return { + slippageChanged, + setSlippageChanged, + slippageState, + isSlippageLow, + isSlippageHigh, + slippage, + setSlippage, + }; +}; + +export const useBridgeSlippageStore = () => { + const { autoSlippage, isCustomSlippage } = useRabbySelector((store) => ({ + autoSlippage: store.bridge.autoSlippage, + isCustomSlippage: !!store.bridge.isCustomSlippage, + })); + + const dispatch = useRabbyDispatch(); + + const setAutoSlippage = useCallback( + (bool: boolean) => { + dispatch.bridge.setAutoSlippage(bool); + }, + [dispatch] + ); + + const setIsCustomSlippage = useCallback( + (bool: boolean) => { + dispatch.bridge.setIsCustomSlippage(bool); + }, + [dispatch] + ); + + return { + autoSlippage, + isCustomSlippage, + setAutoSlippage, + setIsCustomSlippage, + }; +}; diff --git a/src/ui/views/Bridge/hooks/token.tsx b/src/ui/views/Bridge/hooks/token.tsx index 85806f8b771..dcf107c42a3 100644 --- a/src/ui/views/Bridge/hooks/token.tsx +++ b/src/ui/views/Bridge/hooks/token.tsx @@ -1,43 +1,91 @@ -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 BigNumber from 'bignumber.js'; +import { CHAINS_ENUM, ETH_USDT_CONTRACT } from '@/constant'; +import { useAsyncInitializeChainList } from '@/ui/hooks/useChain'; +import { useRabbySelector } from '@/ui/store'; +import { formatUsdValue, isSameAddress, useWallet } from '@/ui/utils'; +import { findChain, findChainByEnum, findChainByServerID } from '@/utils/chain'; +import { BridgeQuote, TokenItem } from '@rabby-wallet/rabby-api/dist/types'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useAsync, useDebounce } from 'react-use'; - +import { useAsyncFn, useDebounce } from 'react-use'; +import useAsync from 'react-use/lib/useAsync'; import { useQuoteVisible, useRefreshId, useSetQuoteVisible, useSetRefreshId, - useSettingVisible, } from './context'; -import { useAsyncInitializeChainList } from '@/ui/hooks/useChain'; -import { ETH_USDT_CONTRACT } from '@/constant'; -import { findChain } from '@/utils/chain'; -import { BridgeQuote } from '@/background/service/openapi'; +import { getChainDefaultToken, tokenAmountBn } from '@/ui/utils/token'; +import BigNumber from 'bignumber.js'; import stats from '@/stats'; -import useDebounceValue from '@/ui/hooks/useDebounceValue'; - -const useTokenInfo = ({ - userAddress, - chain, - defaultToken, -}: { - userAddress?: string; - chain?: CHAINS_ENUM; - defaultToken?: TokenItem; -}) => { +import { useBridgeSlippage, useBridgeSlippageStore } from './slippage'; +import { isNaN } from 'lodash'; + +export interface SelectedBridgeQuote extends Omit { + shouldApproveToken?: boolean; + shouldTwoStepApprove?: boolean; + loading?: boolean; + tx?: BridgeQuote['tx']; + manualClick?: boolean; +} + +export const tokenPriceImpact = ( + fromToken?: TokenItem, + toToken?: TokenItem, + fromAmount?: string | number, + toAmount?: string | number +) => { + const notReady = [fromToken, toToken, fromAmount, toAmount].some((e) => + isNaN(e) + ); + + if (notReady) { + return; + } + + const fromUsdBn = new BigNumber(fromAmount || 0).times(fromToken?.price || 0); + const toUsdBn = new BigNumber(toAmount || 0).times(toToken?.price || 0); + + const cut = toUsdBn.minus(fromUsdBn).div(fromUsdBn).times(100); + + return { + showLoss: cut.lte(-5), + lossUsd: formatUsdValue(toUsdBn.minus(fromUsdBn).abs().toString()), + diff: cut.abs().toFixed(2), + fromUsd: formatUsdValue(fromUsdBn.toString(10)), + toUsd: formatUsdValue(toUsdBn.toString(10)), + }; +}; + +const useToken = (type: 'from' | 'to') => { const refreshId = useRefreshId(); + + const userAddress = useRabbySelector( + (s) => s.account.currentAccount?.address + ); const wallet = useWallet(); - const [token, setToken] = useState(defaultToken); + + const [chain, setChain] = useState(); + + const [token, setToken] = useState(); + + const switchChain = useCallback( + (changeChain?: CHAINS_ENUM, resetToken = true) => { + setChain(changeChain); + if (resetToken) { + if (type === 'from') { + setToken(changeChain ? getChainDefaultToken(changeChain) : undefined); + } else { + setToken(undefined); + } + } + }, + [type] + ); const { value, loading, error } = useAsync(async () => { if (userAddress && token?.id && chain) { const data = await wallet.openapi.getToken( userAddress, - CHAINS[chain].serverId, + findChainByEnum(chain)!.serverId, token.id ); return data; @@ -54,171 +102,176 @@ const useTokenInfo = ({ [value, error, loading] ); - if (error) { - console.error('token info error', chain, token?.symbol, token?.id, error); - } - return [token, setToken] as const; + return [chain, token, setToken, switchChain] as const; }; -export interface SelectedBridgeQuote extends Omit { - shouldApproveToken?: boolean; - shouldTwoStepApprove?: boolean; - loading?: boolean; - tx?: BridgeQuote['tx']; - manualClick?: boolean; -} - -export const useTokenPair = (userAddress: string) => { - const dispatch = useRabbyDispatch(); +export const useBridge = () => { + const userAddress = useRabbySelector( + (s) => s.account.currentAccount?.address + ); const refreshId = useRefreshId(); - const { - initialSelectedChain, - oChain, - defaultSelectedFromToken, - defaultSelectedToToken, - } = useRabbySelector((state) => { - return { - initialSelectedChain: state.bridge.$$initialSelectedChain, - oChain: state.bridge.selectedChain || CHAINS_ENUM.ETH, - defaultSelectedFromToken: state.bridge.selectedFromToken, - defaultSelectedToToken: state.bridge.selectedToToken, - }; - }); - - const [chain, setChain] = useState(oChain); - const handleChain = (c: CHAINS_ENUM) => { - setChain(c); - dispatch.bridge.setSelectedChain(c); - }; - - const [payToken, setPayToken] = useTokenInfo({ - userAddress, - chain: defaultSelectedFromToken?.chain - ? findChain({ serverId: defaultSelectedFromToken?.chain })?.enum - : undefined, - defaultToken: defaultSelectedFromToken, - }); - - const [receiveToken, setReceiveToken] = useTokenInfo({ - userAddress, - chain, - defaultToken: defaultSelectedToToken, - }); - - const setSelectedBridgeQuote: React.Dispatch< - React.SetStateAction - > = useCallback((p) => { - if (expiredTimer.current) { - clearTimeout(expiredTimer.current); - } - setExpired(false); - expiredTimer.current = setTimeout(() => { - setExpired(true); - }, 1000 * 30); - setOriSelectedBridgeQuote(p); - }, []); + const setRefreshId = useSetRefreshId(); - const switchChain = useCallback( - (c: CHAINS_ENUM, opts?: { payTokenId?: string; changeTo?: boolean }) => { - handleChain(c); - setPayToken(undefined); - setReceiveToken(undefined); - setPayAmount(''); - setSelectedBridgeQuote(undefined); - }, - [handleChain, setSelectedBridgeQuote, setPayToken, setReceiveToken] + const wallet = useWallet(); + const [fromChain, fromToken, setFromToken, switchFromChain] = useToken( + 'from' ); + const [toChain, toToken, setToToken, switchToChain] = useToken('to'); - const supportedChains = useRabbySelector((s) => s.bridge.supportedChains); - - useAsyncInitializeChainList({ - // NOTICE: now `useTokenPair` is only used for swap page, so we can use `SWAP_SUPPORT_CHAINS` here - supportChains: supportedChains, - onChainInitializedAsync: (firstEnum) => { - // only init chain if it's not cached before - if (!initialSelectedChain) { - switchChain(firstEnum); - } - }, - }); - - useEffect(() => { - dispatch.bridge.setSelectedFromToken(payToken); - }, [payToken]); - - useEffect(() => { - dispatch.bridge.setSelectedToToken(receiveToken); - }, [receiveToken]); + const [amount, setAmount] = useState(''); - const [inputAmount, setPayAmount] = useState(''); + const slippageObj = useBridgeSlippage(); - const debouncePayAmount = useDebounceValue(inputAmount, 300); + const [recommendFromToken, setRecommendFromToken] = useState(); const [selectedBridgeQuote, setOriSelectedBridgeQuote] = useState< SelectedBridgeQuote | undefined >(); - const expiredTimer = useRef(); const [expired, setExpired] = useState(false); - const handleAmountChange: React.ChangeEventHandler = useCallback( - (e) => { - const v = e.target.value; - if (!/^\d*(\.\d*)?$/.test(v)) { - return; + const expiredTimer = useRef(); + + const inSufficient = useMemo( + () => + fromToken + ? tokenAmountBn(fromToken).lt(amount) + : new BigNumber(0).lt(amount), + [fromToken, amount] + ); + + const getRecommendToChain = async (chain: CHAINS_ENUM) => { + if (userAddress) { + const latestTx = await wallet.openapi.getBridgeHistoryList({ + user_addr: userAddress, + start: 0, + limit: 1, + }); + const latestToToken = latestTx?.history_list?.[0]?.to_token; + if (latestToToken) { + const chainEnum = findChainByServerID(latestToToken.chain); + if (chainEnum) { + switchToChain(chainEnum.enum); + setToToken(latestToToken); + } + } else { + const data = await wallet.openapi.getRecommendBridgeToChain({ + from_chain_id: findChainByEnum(chain)!.serverId, + }); + switchToChain(findChainByServerID(data.to_chain_id)?.enum); + } + } + }; + + const { + value: isSameToken, + loading: isSameTokenLoading, + } = useAsync(async () => { + if (fromChain && fromToken?.id && toChain && toToken?.id) { + try { + const data = await wallet.openapi.isSameBridgeToken({ + from_chain_id: findChainByEnum(fromChain)!.serverId, + from_token_id: fromToken?.id, + to_chain_id: findChainByEnum(toChain)!.serverId, + to_token_id: toToken?.id, + }); + return data?.every((e) => e.is_same); + } catch (error) { + return false; } - setPayAmount(v); + } + return false; + }, [fromChain, fromToken?.id, toChain, toToken?.id]); + + const { autoSlippage } = useBridgeSlippageStore(); + + useEffect(() => { + if (!isSameTokenLoading && autoSlippage) { + slippageObj.setSlippage(isSameToken ? '1' : '3'); + } + }, [autoSlippage, isSameToken, isSameTokenLoading]); + + const supportedChains = useRabbySelector((s) => s.bridge.supportedChains); + // the most worth chain is the first + useAsyncInitializeChainList({ + supportChains: supportedChains, + onChainInitializedAsync: (firstEnum) => { + switchFromChain(firstEnum); + getRecommendToChain(firstEnum); }, - [] - ); + }); - const handleBalance = useCallback(() => { - if (payToken) { - setPayAmount(tokenAmountBn(payToken).toString(10)); + const handleAmountChange = useCallback((v: string) => { + if (!/^\d*(\.\d*)?$/.test(v)) { + return; } - }, [payToken]); + setAmount(v); + }, []); - const inSufficient = useMemo( - () => - payToken - ? tokenAmountBn(payToken).lt(debouncePayAmount) - : new BigNumber(0).lt(debouncePayAmount), - [payToken, debouncePayAmount] - ); + const switchToken = useCallback(() => { + switchFromChain(toChain, false); + switchToChain(fromChain, false); + setFromToken(toToken); + setToToken(fromToken); + }, [ + setFromToken, + toToken, + setToToken, + fromToken, + switchFromChain, + toChain, + switchToChain, + fromChain, + ]); const [quoteList, setQuotesList] = useState([]); + const setSelectedBridgeQuote: React.Dispatch< + React.SetStateAction + > = useCallback((p) => { + if (expiredTimer.current) { + clearTimeout(expiredTimer.current); + } + setExpired(false); + expiredTimer.current = setTimeout(() => { + setExpired(true); + setRefreshId((e) => e + 1); + }, 1000 * 30); + setOriSelectedBridgeQuote(p); + }, []); + useEffect(() => { setQuotesList([]); setSelectedBridgeQuote(undefined); - }, [payToken?.id, receiveToken?.id, chain, debouncePayAmount, inSufficient]); + }, [fromToken?.id, toToken?.id, fromChain, toChain, amount, inSufficient]); const visible = useQuoteVisible(); - useEffect(() => { - if (!visible) { - setQuotesList([]); - } - }, [visible]); - - const setRefreshId = useSetRefreshId(); + // useEffect(() => { + // if (!visible) { + // setQuotesList([]); + // } + // }, [visible]); const aggregatorsList = useRabbySelector( (s) => s.bridge.aggregatorsList || [] ); const fetchIdRef = useRef(0); - const wallet = useWallet(); - const { loading: quoteLoading, error: quotesError } = useAsync(async () => { + const [ + { loading: quoteLoading, error: quotesError }, + getQuoteList, + ] = useAsyncFn(async () => { if ( !inSufficient && userAddress && - payToken?.id && - receiveToken?.id && - receiveToken && - chain && - Number(debouncePayAmount) > 0 && + fromToken?.id && + toToken?.id && + toToken && + fromChain && + toChain && + Number(amount) > 0 && aggregatorsList.length > 0 ) { fetchIdRef.current += 1; @@ -236,32 +289,74 @@ export const useTokenPair = (userAddress: string) => { setSelectedBridgeQuote(undefined); - const originData = await wallet.openapi - .getBridgeQuoteList({ - aggregator_ids: aggregatorsList.map((e) => e.id).join(','), - from_token_id: payToken.id, - user_addr: userAddress, - from_chain_id: payToken.chain, - from_token_raw_amount: new BigNumber(debouncePayAmount) - .times(10 ** payToken.decimals) - .toFixed(0, 1) - .toString(), - to_chain_id: receiveToken.chain, - to_token_id: receiveToken.id, - }) - .catch((e) => { - if (currentFetchId === fetchIdRef.current) { - stats.report('bridgeQuoteResult', { - aggregatorIds: aggregatorsList.map((e) => e.id).join(','), - fromChainId: payToken.chain, - fromTokenId: payToken.id, - toTokenId: receiveToken.id, - toChainId: receiveToken.chain, - status: 'fail', - }); - } - }) - .finally(() => {}); + const originData: Omit[] = []; + + const getQUoteV2 = async (alternativeToken?: TokenItem) => + await Promise.allSettled( + aggregatorsList.map(async (bridgeAggregator) => { + const data = await wallet.openapi + .getBridgeQuoteV2({ + aggregator_id: bridgeAggregator.id, + user_addr: userAddress, + from_chain_id: alternativeToken?.chain || fromToken.chain, + from_token_id: alternativeToken?.id || fromToken.id, + from_token_raw_amount: alternativeToken + ? new BigNumber(amount) + .times(fromToken.price) + .div(alternativeToken.price) + .times(10 ** alternativeToken.decimals) + .toFixed(0, 1) + .toString() + : new BigNumber(amount) + .times(10 ** fromToken.decimals) + .toFixed(0, 1) + .toString(), + to_chain_id: toToken.chain, + to_token_id: toToken.id, + slippage: new BigNumber(slippageObj.slippageState) + .div(100) + .toString(10), + }) + .catch((e) => { + if ( + currentFetchId === fetchIdRef.current && + !alternativeToken + ) { + stats.report('bridgeQuoteResult', { + aggregatorIds: bridgeAggregator.id, + fromChainId: fromToken.chain, + fromTokenId: fromToken.id, + toTokenId: toToken.id, + toChainId: toToken.chain, + status: 'fail', + }); + } + }); + + if (alternativeToken) { + if (data && currentFetchId === fetchIdRef.current) { + setRecommendFromToken(alternativeToken); + return; + } + } + if (data && currentFetchId === fetchIdRef.current) { + originData.push(...data); + } + if (currentFetchId === fetchIdRef.current) { + stats.report('bridgeQuoteResult', { + aggregatorIds: bridgeAggregator.id, + fromChainId: fromToken.chain, + fromTokenId: fromToken.id, + toTokenId: toToken.id, + toChainId: toToken.chain, + status: data?.length ? 'success' : 'none', + }); + } + return data; + }) + ); + + await getQUoteV2(); const data = originData?.filter( (quote) => @@ -272,12 +367,39 @@ export const useTokenPair = (userAddress: string) => { ); if (currentFetchId === fetchIdRef.current) { + setPending(false); + + if (data.length < 1) { + try { + const recommendFromToken = await wallet.openapi.getRecommendFromToken( + { + user_addr: userAddress, + from_chain_id: fromToken.chain, + from_token_id: fromToken.id, + from_token_amount: new BigNumber(amount) + .times(10 ** fromToken.decimals) + .toFixed(0, 1) + .toString(), + to_chain_id: toToken.chain, + to_token_id: toToken.id, + } + ); + if (recommendFromToken?.token_list?.[0]) { + await getQUoteV2(recommendFromToken?.token_list?.[0]); + } else { + setRecommendFromToken(undefined); + } + } catch (error) { + setRecommendFromToken(undefined); + } + } + stats.report('bridgeQuoteResult', { aggregatorIds: aggregatorsList.map((e) => e.id).join(','), - fromChainId: payToken.chain, - fromTokenId: payToken.id, - toTokenId: receiveToken.id, - toChainId: receiveToken.chain, + fromChainId: fromToken.chain, + fromTokenId: fromToken.id, + toTokenId: toToken.id, + toChainId: toToken.chain, status: data ? (data?.length === 0 ? 'none' : 'success') : 'fail', }); } @@ -294,23 +416,23 @@ export const useTokenPair = (userAddress: string) => { } let tokenApproved = false; let allowance = '0'; - const fromChain = findChain({ serverId: payToken?.chain }); - if (payToken?.id === fromChain?.nativeTokenAddress) { + const fromChain = findChain({ serverId: fromToken?.chain }); + if (fromToken?.id === fromChain?.nativeTokenAddress) { tokenApproved = true; } else { allowance = await wallet.getERC20Allowance( - payToken.chain, - payToken.id, + fromToken.chain, + fromToken.id, quote.approve_contract_id ); tokenApproved = new BigNumber(allowance).gte( - new BigNumber(debouncePayAmount).times(10 ** payToken.decimals) + new BigNumber(amount).times(10 ** fromToken.decimals) ); } let shouldTwoStepApprove = false; if ( fromChain?.enum === CHAINS_ENUM.ETH && - isSameAddress(payToken.id, ETH_USDT_CONTRACT) && + isSameAddress(fromToken.id, ETH_USDT_CONTRACT) && Number(allowance) !== 0 && !tokenApproved ) { @@ -356,12 +478,53 @@ export const useTokenPair = (userAddress: string) => { aggregatorsList, refreshId, userAddress, - payToken?.id, - receiveToken?.id, - chain, - debouncePayAmount, + fromToken?.id, + toToken?.id, + fromChain, + toChain, + amount, + slippageObj.slippage, ]); + const [pending, setPending] = useState(false); + + useEffect(() => { + if ( + !inSufficient && + userAddress && + fromToken?.id && + toToken?.id && + toToken && + fromChain && + toChain && + Number(amount) > 0 && + aggregatorsList.length > 0 + ) { + setPending(true); + } else { + setPending(false); + } + }, [ + inSufficient, + userAddress, + fromToken?.id, + toToken?.id, + toToken, + fromChain, + toChain, + Number(amount), + aggregatorsList.length, + refreshId, + ]); + + const [, cancelDebounce] = useDebounce( + () => { + getQuoteList(); + }, + 300, + [getQuoteList] + ); + const [bestQuoteId, setBestQuoteId] = useState< | { bridgeId: string; @@ -373,20 +536,20 @@ export const useTokenPair = (userAddress: string) => { const openQuote = useSetQuoteVisible(); const openQuotesList = useCallback(() => { - setQuotesList([]); - setRefreshId((e) => e + 1); + // setQuotesList([]); + // setRefreshId((e) => e + 1); openQuote(true); }, []); useEffect(() => { - if (!quoteLoading && receiveToken && quoteList.every((e) => !e.loading)) { + if (!quoteLoading && toToken && quoteList.every((e) => !e.loading)) { const sortedList = quoteList?.sort((b, a) => { return new BigNumber(a.to_token_amount) - .times(receiveToken.price || 1) + .times(toToken.price || 1) .minus(a.gas_fee.usd_value) .minus( new BigNumber(b.to_token_amount) - .times(receiveToken.price || 1) + .times(toToken.price || 1) .minus(b.gas_fee.usd_value) ) .toNumber(); @@ -406,7 +569,7 @@ export const useTokenPair = (userAddress: string) => { ); } } - }, [quoteList, quoteLoading, receiveToken]); + }, [quoteList, quoteLoading, toToken]); if (quotesError) { console.error('quotesError', quotesError); @@ -415,39 +578,49 @@ export const useTokenPair = (userAddress: string) => { useEffect(() => { setExpired(false); setSelectedBridgeQuote(undefined); - }, [payToken?.id, receiveToken?.id, chain, debouncePayAmount, inSufficient]); + setRecommendFromToken(undefined); + }, [fromToken?.id, toToken?.id, fromChain, toChain, amount, inSufficient]); + + const showLoss = useMemo(() => { + if (selectedBridgeQuote) { + return !!tokenPriceImpact( + fromToken, + toToken, + amount, + selectedBridgeQuote?.to_token_amount + )?.showLoss; + } + return false; + }, [fromToken, toToken, amount, selectedBridgeQuote]); return { - chain, - switchChain, + fromChain, + fromToken, + setFromToken, + switchFromChain, + toChain, + toToken, + setToToken, + switchToChain, + switchToken, + + recommendFromToken, - payToken, - setPayToken, - receiveToken, - setReceiveToken, - - handleAmountChange, - handleBalance, - debouncePayAmount, - setPayAmount, - inputAmount, + expired, inSufficient, + amount, + handleAmountChange, + showLoss, - quoteLoading, - quoteList, - selectedBridgeQuote, - setSelectedBridgeQuote, openQuotesList, + quoteLoading: pending || quoteLoading, + quoteList, bestQuoteId, + selectedBridgeQuote, - expired, + setSelectedBridgeQuote, + ...slippageObj, }; }; - -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/Swap/Component/TokenRender.tsx b/src/ui/views/Swap/Component/TokenRender.tsx index 9b32e24381d..d44d2df4056 100644 --- a/src/ui/views/Swap/Component/TokenRender.tsx +++ b/src/ui/views/Swap/Component/TokenRender.tsx @@ -5,6 +5,8 @@ import { ReactComponent as RcIconRcArrowDownTriangle } from '@/ui/assets/swap/ar import { TokenItem } from '@rabby-wallet/rabby-api/dist/types'; import { getTokenSymbol } from '@/ui/utils/token'; import { useTranslation } from 'react-i18next'; +import { ReactComponent as RcImgArrowDown } from '@/ui/assets/swap/arrow-down.svg'; + const TokenRenderWrapper = styled.div` width: 150px; height: 46px; @@ -18,6 +20,17 @@ const TokenRenderWrapper = styled.div` color: #13141a; border: 1px solid transparent; cursor: pointer; + &.bridge { + height: 40px; + width: auto; + border-radius: 8px; + background: var(--r-neutral-card2, #f2f4f7); + padding: 8px 12px; + + .token { + gap: 6px; + } + } &:hover { background: rgba(134, 151, 255, 0.2); } @@ -55,13 +68,17 @@ const TokenRenderWrapper = styled.div` export const TokenRender = ({ openTokenModal, token, + type = 'swap', }: { token?: TokenItem | undefined; openTokenModal: () => void; + type?: 'swap' | 'bridge'; }) => { const { t } = useTranslation(); + const isBridge = type === 'bridge'; + return ( - + {token ? (
    {getTokenSymbol(token)} - + {isBridge ? ( + + ) : ( + + )}
    ) : (
    {t('page.swap.select-token')} - + {isBridge ? ( + + ) : ( + + )}
    )}
    diff --git a/yarn.lock b/yarn.lock index 87e6c1ad51b..f0f55338848 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4781,10 +4781,10 @@ resolved "https://registry.yarnpkg.com/@rabby-wallet/rabby-action/-/rabby-action-0.1.4.tgz#c82e7c8b538b7dfd94506c4f89f78aca7ca880ef" integrity sha512-6ttnlpGHcO2v/qyYo8epBbSutfS9OZXfXr9mfapuveoBvzqUwvE6ej3bsmdtc+qFhFeH7HeiwASpY6xaTM4E1w== -"@rabby-wallet/rabby-api@0.8.3": - version "0.8.3" - resolved "https://registry.yarnpkg.com/@rabby-wallet/rabby-api/-/rabby-api-0.8.3.tgz#1454f0d6df905c0e98a6bc0a0c55ae7aee3df74d" - integrity sha512-lcZ3zo+VgnHmsk95d+WoplpCnBeW/5WoAxG1oj6r6iWKiQbndpy9UvKX6lvJb7SWrR7xDc3eXXuCepgVFPQoqg== +"@rabby-wallet/rabby-api@0.8.4-beta.0": + version "0.8.4-beta.0" + resolved "https://registry.yarnpkg.com/@rabby-wallet/rabby-api/-/rabby-api-0.8.4-beta.0.tgz#1ae4418a79a6e9d3b50305f50d8951ae0a82e0cb" + integrity sha512-XsLXk2rtacIG4fh5NUa3CYT3oYvu9V9+56P+YdgsxBZGMpKQL+NJJ2Bq8oN7gLoz1g13FtRAWDHjHuR9DOiuSw== dependencies: "@rabby-wallet/rabby-sign" "0.4.0" axios "^0.27.2" @@ -19220,8 +19220,7 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.2.3: - name string-width-cjs +"string-width-cjs@npm:string-width@^4.2.0": version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -19248,6 +19247,15 @@ string-width@^4.1.0, string-width@^4.2.0: is-fullwidth-code-point "^3.0.0" strip-ansi "^6.0.0" +string-width@^4.2.3: + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + string-width@^5.0.1, string-width@^5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" @@ -19300,8 +19308,7 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.1: - name strip-ansi-cjs +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -19322,6 +19329,13 @@ strip-ansi@^6.0.0: dependencies: ansi-regex "^5.0.0" +strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + strip-ansi@^7.0.0: version "7.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.0.1.tgz#61740a08ce36b61e50e65653f07060d000975fb2" @@ -21309,8 +21323,7 @@ wildcard@^2.0.0: resolved "https://registry.yarnpkg.com/wildcard/-/wildcard-2.0.0.tgz#a77d20e5200c6faaac979e4b3aadc7b3dd7f8fec" integrity sha512-JcKqAHLPxcdb9KM49dufGXn2x3ssnfjbcaQdLlfZsL9rH9wgDQjUtDxbo8NE0F6SFvydeu1VhZe7hZuHsB2/pw== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: - name wrap-ansi-cjs +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -21327,6 +21340,15 @@ wrap-ansi@^2.0.0: string-width "^1.0.1" strip-ansi "^3.0.1" +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"