diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs index 981b7c05fa367..a55dd1bb9da68 100644 --- a/apps/web/next.config.mjs +++ b/apps/web/next.config.mjs @@ -19,21 +19,21 @@ const withVanillaExtract = createVanillaExtractPlugin() const sentryWebpackPluginOptions = process.env.VERCEL_ENV === 'production' ? { - // Additional config options for the Sentry Webpack plugin. Keep in mind that - // the following options are set automatically, and overriding them is not - // recommended: - // release, url, org, project, authToken, configFile, stripPrefix, - // urlPrefix, include, ignore - silent: false, // Logging when deploying to check if there is any problem - validate: true, - hideSourceMaps: false, - // https://github.com/getsentry/sentry-webpack-plugin#options. - } + // Additional config options for the Sentry Webpack plugin. Keep in mind that + // the following options are set automatically, and overriding them is not + // recommended: + // release, url, org, project, authToken, configFile, stripPrefix, + // urlPrefix, include, ignore + silent: false, // Logging when deploying to check if there is any problem + validate: true, + hideSourceMaps: false, + // https://github.com/getsentry/sentry-webpack-plugin#options. + } : { - hideSourceMaps: false, - silent: true, // Suppresses all logs - dryRun: !process.env.SENTRY_AUTH_TOKEN, - } + hideSourceMaps: false, + silent: true, // Suppresses all logs + dryRun: !process.env.SENTRY_AUTH_TOKEN, + } const workerDeps = Object.keys(smartRouterPkgs.dependencies) .map((d) => d.replace('@pancakeswap/', 'packages/')) @@ -56,6 +56,7 @@ const config = { }, transpilePackages: [ '@pancakeswap/farms', + '@pancakeswap/position-managers', '@pancakeswap/localization', '@pancakeswap/hooks', '@pancakeswap/utils', diff --git a/apps/web/package.json b/apps/web/package.json index 01c5269a89906..1089a58dddc3d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -41,6 +41,7 @@ "@pancakeswap/localization": "workspace:*", "@pancakeswap/multicall": "workspace:*", "@pancakeswap/pools": "workspace:*", + "@pancakeswap/position-managers": "workspace:*", "@pancakeswap/sdk": "workspace:*", "@pancakeswap/smart-router": "workspace:*", "@pancakeswap/swap-sdk-core": "workspace:*", diff --git a/apps/web/public/images/position-manager/position-manager-bunny.png b/apps/web/public/images/position-manager/position-manager-bunny.png new file mode 100644 index 0000000000000..29c513e5a8b0c Binary files /dev/null and b/apps/web/public/images/position-manager/position-manager-bunny.png differ diff --git a/apps/web/src/components/CurrencyInput/index.tsx b/apps/web/src/components/CurrencyInput/index.tsx new file mode 100644 index 0000000000000..631dfc44eccc5 --- /dev/null +++ b/apps/web/src/components/CurrencyInput/index.tsx @@ -0,0 +1,62 @@ +import { useMemo, useCallback, ReactNode, MouseEvent } from 'react' +import { Currency, CurrencyAmount } from '@pancakeswap/sdk' +import { CurrencyLogo } from '@pancakeswap/widgets-internal' +import { BalanceInput, Text, Flex, Button } from '@pancakeswap/uikit' + +interface Props { + value: string | number + onChange: (val: string) => void + currency?: Currency + balance?: CurrencyAmount + balanceText?: ReactNode + maxText?: ReactNode +} + +export function CurrencyInput({ currency, balance, value, onChange, balanceText, maxText = 'Max', ...rest }: Props) { + const isMax = useMemo(() => balance && value && balance.toExact() === value, [balance, value]) + const onMaxClick = useCallback( + (e: MouseEvent) => { + e.stopPropagation() + e.preventDefault() + onChange?.(balance?.toExact() || '') + }, + [onChange, balance], + ) + + const currencyDisplay = currency ? ( + + + + {currency.symbol} + + + ) : null + + const balanceDisplay = balance ? ( + + + {balanceText} + + + + ) : null + + return ( + + ) +} diff --git a/apps/web/src/components/Menu/config/config.ts b/apps/web/src/components/Menu/config/config.ts index 56301655d15be..675d30d467b06 100644 --- a/apps/web/src/components/Menu/config/config.ts +++ b/apps/web/src/components/Menu/config/config.ts @@ -1,5 +1,6 @@ import { ContextApi } from '@pancakeswap/localization' import { SUPPORTED_CHAIN_IDS as POOL_SUPPORTED_CHAINS } from '@pancakeswap/pools' +import { SUPPORTED_CHAIN_IDS as POSITION_MANAGERS_SUPPORTED_CHAINS } from '@pancakeswap/position-managers' import { NftIcon, NftFillIcon, @@ -114,6 +115,12 @@ const config: ( href: '/pools', supportChainIds: POOL_SUPPORTED_CHAINS, }, + { + label: t('Position Manager'), + href: '/position-managers', + supportChainIds: POSITION_MANAGERS_SUPPORTED_CHAINS, + status: { text: t('New'), color: 'success' }, + }, { label: t('Liquid Staking'), href: '/liquid-staking', diff --git a/apps/web/src/hooks/useContract.ts b/apps/web/src/hooks/useContract.ts index e4936149740d5..057a8beca4184 100644 --- a/apps/web/src/hooks/useContract.ts +++ b/apps/web/src/hooks/useContract.ts @@ -12,6 +12,8 @@ import { getBCakeFarmBoosterContract, getBCakeFarmBoosterProxyFactoryContract, getBCakeFarmBoosterV3Contract, + getPositionManagerWrapperContract, + getPositionManagerAdapterContract, getBCakeProxyContract, getBunnyFactoryContract, getCakeFlexibleSideVaultV2Contract, @@ -313,6 +315,24 @@ export function useBCakeFarmBoosterV3Contract() { return useMemo(() => getBCakeFarmBoosterV3Contract(signer ?? undefined, chainId), [signer, chainId]) } +export function usePositionManagerWrapperContract(address: Address) { + const { chainId } = useActiveChainId() + const { data: signer } = useWalletClient() + return useMemo( + () => getPositionManagerWrapperContract(address, signer ?? undefined, chainId), + [signer, chainId, address], + ) +} + +export function usePositionManagerAdepterContract(address: Address) { + const { chainId } = useActiveChainId() + const { data: signer } = useWalletClient() + return useMemo( + () => getPositionManagerAdapterContract(address, signer ?? undefined, chainId), + [signer, chainId, address], + ) +} + export function useBCakeFarmBoosterProxyFactoryContract() { const { data: signer } = useWalletClient() return useMemo(() => getBCakeFarmBoosterProxyFactoryContract(signer ?? undefined), [signer]) @@ -433,5 +453,5 @@ export const useFixedStakingContract = () => { const { data: signer } = useWalletClient() - return useMemo(() => getFixedStakingContract(signer, chainId), [chainId, signer]) + return useMemo(() => getFixedStakingContract(signer ?? undefined, chainId), [chainId, signer]) } diff --git a/apps/web/src/hooks/usePositionPrices.ts b/apps/web/src/hooks/usePositionPrices.ts new file mode 100644 index 0000000000000..29d6aeaa3a2d6 --- /dev/null +++ b/apps/web/src/hooks/usePositionPrices.ts @@ -0,0 +1,80 @@ +import { Currency } from '@pancakeswap/sdk' +import { tickToPrice } from '@pancakeswap/v3-sdk' +import { useCallback, useMemo, useState } from 'react' + +interface PositionInfo { + currencyA?: Currency + currencyB?: Currency + tickLower?: number + tickUpper?: number + tickCurrent?: number +} + +export function usePositionPrices({ + currencyA: initialBaseCurrency, + currencyB: initialQuoteCurrency, + tickLower, + tickUpper, + tickCurrent, +}: PositionInfo) { + const [invert, setInvert] = useState(false) + const toggleInvert = useCallback(() => setInvert(!invert), [invert]) + const currencyA = useMemo( + () => (invert ? initialQuoteCurrency : initialBaseCurrency), + [invert, initialBaseCurrency, initialQuoteCurrency], + ) + const currencyB = useMemo( + () => (invert ? initialBaseCurrency : initialQuoteCurrency), + [invert, initialBaseCurrency, initialQuoteCurrency], + ) + + const sorted = useMemo( + () => + Boolean( + currencyA?.wrapped && + currencyB?.wrapped && + !currencyA.wrapped.equals(currencyB.wrapped) && + currencyA.wrapped.sortsBefore(currencyB.wrapped), + ), + [currencyA, currencyB], + ) + + const tickLowerPrice = useMemo( + () => + currencyA?.wrapped && + currencyB?.wrapped && + typeof tickLower === 'number' && + tickToPrice(currencyA.wrapped, currencyB.wrapped, tickLower), + [tickLower, currencyA, currencyB], + ) + const tickUpperPrice = useMemo( + () => + currencyA?.wrapped && + currencyB?.wrapped && + typeof tickUpper === 'number' && + tickToPrice(currencyA.wrapped, currencyB.wrapped, tickUpper), + [tickUpper, currencyA, currencyB], + ) + const [priceLower, priceUpper] = useMemo( + () => (sorted ? [tickLowerPrice, tickUpperPrice] : [tickUpperPrice, tickLowerPrice]), + [sorted, tickLowerPrice, tickUpperPrice], + ) + const priceCurrent = useMemo( + () => + currencyA?.wrapped && + currencyB?.wrapped && + typeof tickCurrent === 'number' && + tickToPrice(currencyA.wrapped, currencyB.wrapped, tickCurrent), + [tickCurrent, currencyA, currencyB], + ) + + return { + currencyA, + currencyB, + priceLower, + priceUpper, + priceCurrent, + invert: toggleInvert, + inverted: invert, + } +} diff --git a/apps/web/src/pages/position-managers/[[...slug]].tsx b/apps/web/src/pages/position-managers/[[...slug]].tsx new file mode 100644 index 0000000000000..13bcedb56d1ae --- /dev/null +++ b/apps/web/src/pages/position-managers/[[...slug]].tsx @@ -0,0 +1,32 @@ +import { SUPPORTED_CHAIN_IDS } from '@pancakeswap/position-managers' +import type { GetStaticPaths, GetStaticProps } from 'next' + +import { PositionManagers } from 'views/PositionManagers' + +const Page = () => + +Page.chains = SUPPORTED_CHAIN_IDS + +export const getStaticProps: GetStaticProps = async () => { + return { props: {} } +} + +export const getStaticPaths: GetStaticPaths = async () => { + return { + paths: [ + { + params: { + slug: [], + }, + }, + { + params: { + slug: ['history'], + }, + }, + ], + fallback: false, + } +} + +export default Page diff --git a/apps/web/src/utils/contractHelpers.ts b/apps/web/src/utils/contractHelpers.ts index c43e260cded90..6034a73648dde 100644 --- a/apps/web/src/utils/contractHelpers.ts +++ b/apps/web/src/utils/contractHelpers.ts @@ -61,6 +61,7 @@ import { affiliateProgramABI } from 'config/abi/affiliateProgram' import { bCakeFarmBoosterABI } from 'config/abi/bCakeFarmBooster' import { bCakeFarmBoosterProxyFactoryABI } from 'config/abi/bCakeFarmBoosterProxyFactory' import { bCakeFarmBoosterV3ABI } from 'config/abi/bCakeFarmBoosterV3' +import { positionManagerAdapterABI, positionManagerWrapperABI } from '@pancakeswap/position-managers' import { bCakeProxyABI } from 'config/abi/bCakeProxy' import { bunnyFactoryABI } from 'config/abi/bunnyFactory' import { chainlinkOracleABI } from 'config/abi/chainlinkOracle' @@ -247,6 +248,24 @@ export const getBCakeFarmBoosterV3Contract = (signer?: WalletClient, chainId?: n return getContract({ abi: bCakeFarmBoosterV3ABI, address: getBCakeFarmBoosterV3Address(chainId), signer, chainId }) } +export const getPositionManagerWrapperContract = (address: `0x${string}`, signer?: WalletClient, chainId?: number) => { + return getContract({ + abi: positionManagerWrapperABI, + address, + signer, + chainId, + }) +} + +export const getPositionManagerAdapterContract = (address: `0x${string}`, signer?: WalletClient, chainId?: number) => { + return getContract({ + abi: positionManagerAdapterABI, + address, + signer, + chainId, + }) +} + export const getBCakeFarmBoosterProxyFactoryContract = (signer?: WalletClient) => { return getContract({ abi: bCakeFarmBoosterProxyFactoryABI, @@ -348,7 +367,7 @@ export const getMasterChefV3Contract = (signer?: WalletClient, chainId?: number) return mcv3Address ? getContract({ abi: masterChefV3ABI, - address: getMasterChefV3Address(chainId), + address: mcv3Address, chainId, signer, }) diff --git a/apps/web/src/views/PositionManagers/components/AddLiquidity.tsx b/apps/web/src/views/PositionManagers/components/AddLiquidity.tsx new file mode 100644 index 0000000000000..4192c7bda31e7 --- /dev/null +++ b/apps/web/src/views/PositionManagers/components/AddLiquidity.tsx @@ -0,0 +1,459 @@ +import { useTranslation } from '@pancakeswap/localization' +import { MANAGER } from '@pancakeswap/position-managers' +import { Currency, CurrencyAmount, Percent } from '@pancakeswap/sdk' +import { Button, Flex, LinkExternal, ModalV2, RowBetween, Text, useToast } from '@pancakeswap/uikit' +import tryParseAmount from '@pancakeswap/utils/tryParseAmount' +import { FeeAmount } from '@pancakeswap/v3-sdk' +import { useWeb3React } from '@pancakeswap/wagmi' +import { ConfirmationPendingContent } from '@pancakeswap/widgets-internal' +import BigNumber from 'bignumber.js' +import { CurrencyInput } from 'components/CurrencyInput' +import { ToastDescriptionWithTx } from 'components/Toast' +import { ApprovalState, useApproveCallback } from 'hooks/useApproveCallback' +import useCatchTxError from 'hooks/useCatchTxError' +import { usePositionManagerWrapperContract } from 'hooks/useContract' +import { memo, useCallback, useMemo, useState } from 'react' +import { styled } from 'styled-components' +import { formatCurrencyAmount } from 'utils/formatCurrencyAmount' +import { Address } from 'viem' +import { DYORWarning } from 'views/PositionManagers/components/DYORWarning' +import { SingleTokenWarning } from 'views/PositionManagers/components/SingleTokenWarning' +import { StyledModal } from 'views/PositionManagers/components/StyledModal' +import { FeeTag } from 'views/PositionManagers/components/Tags' +import { useApr } from 'views/PositionManagers/hooks/useApr' +import { AprDataInfo } from '../hooks' +import { AprButton } from './AprButton' + +interface Props { + id: string | number + manager: { + id: MANAGER + name: string + } + isOpen?: boolean + onDismiss?: () => void + vaultName: string + feeTier: FeeAmount + currencyA: Currency + currencyB: Currency + ratio: number + isSingleDepositToken: boolean + allowDepositToken0: boolean + allowDepositToken1: boolean + onAmountChange?: (info: { value: string; currency: Currency; otherAmount: CurrencyAmount }) => { + otherAmount: CurrencyAmount + } + refetch?: () => void + contractAddress: Address + userCurrencyBalances: { + token0Balance: CurrencyAmount | undefined + token1Balance: CurrencyAmount | undefined + } + userVaultPercentage?: Percent + poolToken0Amount?: bigint + poolToken1Amount?: bigint + token0PriceUSD?: number + token1PriceUSD?: number + rewardPerSecond: string + earningToken: Currency + aprDataInfo: { + info: AprDataInfo | undefined + isLoading: boolean + } + rewardEndTime: number + rewardStartTime: number + onAdd?: (params: { amountA: CurrencyAmount; amountB: CurrencyAmount }) => Promise + totalAssetsInUsd: number + totalStakedInUsd: number + userLpAmounts?: bigint + totalSupplyAmounts?: bigint + precision?: bigint + strategyInfoUrl?: string + learnMoreAboutUrl?: string +} + +const StyledCurrencyInput = styled(CurrencyInput)` + flex: 1; +` + +export const AddLiquidity = memo(function AddLiquidity({ + id, + manager, + ratio, + isOpen, + vaultName, + currencyA, + currencyB, + feeTier, + isSingleDepositToken, + allowDepositToken1, + allowDepositToken0, + contractAddress, + userCurrencyBalances, + poolToken0Amount, + poolToken1Amount, + token0PriceUSD, + token1PriceUSD, + rewardPerSecond, + earningToken, + aprDataInfo, + rewardEndTime, + rewardStartTime, + refetch, + onDismiss, + totalAssetsInUsd, + userLpAmounts, + totalSupplyAmounts, + precision, + totalStakedInUsd, + strategyInfoUrl, + learnMoreAboutUrl, +}: Props) { + const [valueA, setValueA] = useState('') + const [valueB, setValueB] = useState('') + const { + t, + currentLanguage: { locale }, + } = useTranslation() + const { account, chain } = useWeb3React() + const tokenPairName = useMemo(() => `${currencyA.symbol}-${currencyB.symbol}`, [currencyA, currencyB]) + + const onInputChange = useCallback( + ({ + value, + setValue, + setOtherValue, + isToken0, + }: { + value: string + currency: Currency + otherValue: string + otherCurrency: Currency + setValue: (value: string) => void + setOtherValue: (value: string) => void + isToken0: boolean + }) => { + setValue(value) + setOtherValue((Number(value) * (isToken0 ? 1 / ratio : ratio)).toString()) + }, + [ratio], + ) + + const onCurrencyAChange = useCallback( + (value: string) => + onInputChange({ + value, + currency: currencyA, + otherValue: valueB, + otherCurrency: currencyB, + setValue: setValueA, + setOtherValue: setValueB, + isToken0: true, + }), + [currencyA, currencyB, valueB, onInputChange], + ) + + const onCurrencyBChange = useCallback( + (value: string) => + onInputChange({ + value, + currency: currencyB, + otherValue: valueA, + otherCurrency: currencyA, + setValue: setValueB, + setOtherValue: setValueA, + isToken0: false, + }), + [currencyA, currencyB, valueA, onInputChange], + ) + + const amountA = useMemo( + () => tryParseAmount(valueA, currencyA) || CurrencyAmount.fromRawAmount(currencyA, '0'), + [valueA, currencyA], + ) + const amountB = useMemo( + () => tryParseAmount(valueB, currencyB) || CurrencyAmount.fromRawAmount(currencyB, '0'), + [valueB, currencyB], + ) + + const userVaultPercentage = useMemo(() => { + const totalPoolToken0Usd = new BigNumber(amountA?.toSignificant() ?? 0).times(token0PriceUSD ?? 0)?.toNumber() + const totalPoolToken1Usd = new BigNumber(amountB?.toSignificant() ?? 0).times(token1PriceUSD ?? 0)?.toNumber() + const userTotalDepositUSD = + (allowDepositToken0 ? totalPoolToken0Usd : 0) + (allowDepositToken1 ? totalPoolToken1Usd : 0) + + return ((userTotalDepositUSD + totalAssetsInUsd) / (totalStakedInUsd + userTotalDepositUSD)) * 100 + }, [ + allowDepositToken0, + allowDepositToken1, + amountA, + amountB, + token0PriceUSD, + token1PriceUSD, + totalStakedInUsd, + totalAssetsInUsd, + ]) + + const apr = useApr({ + currencyA, + currencyB, + poolToken0Amount, + poolToken1Amount, + token0PriceUSD, + token1PriceUSD, + rewardPerSecond, + earningToken, + avgToken0Amount: aprDataInfo?.info?.token0 ?? 0, + avgToken1Amount: aprDataInfo?.info?.token1 ?? 0, + rewardEndTime, + rewardStartTime, + }) + + const displayBalanceText = useCallback( + (balanceAmount: CurrencyAmount | undefined) => + balanceAmount ? `Balances: ${balanceAmount?.toSignificant(6)}` : '', + [], + ) + + const onDone = useCallback(() => { + onDismiss?.() + refetch?.() + }, [onDismiss, refetch]) + + const disabled = useMemo(() => { + const balanceAmountMoreThenValueA = + allowDepositToken0 && + amountA.greaterThan('0') && + Number(userCurrencyBalances?.token0Balance?.toSignificant()) < Number(amountA?.toSignificant()) + + const balanceAmountMoreThenValueB = + allowDepositToken1 && + amountB.greaterThan('0') && + Number(userCurrencyBalances?.token1Balance?.toSignificant()) < Number(amountB?.toSignificant()) + return ( + (allowDepositToken0 && (amountA.equalTo('0') || balanceAmountMoreThenValueA)) || + (allowDepositToken1 && (amountB.equalTo('0') || balanceAmountMoreThenValueB)) + ) + }, [allowDepositToken0, allowDepositToken1, amountA, amountB, userCurrencyBalances]) + + const positionManagerWrapperContract = usePositionManagerWrapperContract(contractAddress) + const { fetchWithCatchTxError, loading: pendingTx } = useCatchTxError() + const { toastSuccess } = useToast() + + const mintThenDeposit = useCallback(async () => { + const receipt = await fetchWithCatchTxError(() => + positionManagerWrapperContract.write.mintThenDeposit( + [allowDepositToken0 ? amountA?.numerator ?? 0n : 0n, allowDepositToken1 ? amountB?.numerator ?? 0n : 0n, '0x'], + { + account: account ?? '0x', + chain, + }, + ), + ) + + if (receipt?.status) { + toastSuccess( + `${t('Staked')}!`, + + {t('Your funds have been staked in position manager.')} + , + ) + onDone() + } + }, [ + amountA, + amountB, + positionManagerWrapperContract, + account, + chain, + toastSuccess, + t, + fetchWithCatchTxError, + onDone, + allowDepositToken0, + allowDepositToken1, + ]) + + const translationData = useMemo( + () => ({ + amountA: allowDepositToken0 ? formatCurrencyAmount(amountA, 4, locale) : '', + symbolA: allowDepositToken0 ? currencyA.symbol : '', + amountB: allowDepositToken1 ? formatCurrencyAmount(amountB, 4, locale) : '', + symbolB: allowDepositToken1 ? currencyB.symbol : '', + }), + [allowDepositToken0, allowDepositToken1, amountA, amountB, currencyA.symbol, currencyB.symbol, locale], + ) + + const pendingText = useMemo( + () => + !isSingleDepositToken + ? t('Supplying %amountA% %symbolA% and %amountB% %symbolB%', translationData) + : t('Supplying %amountA% %symbolA% %amountB% %symbolB%', translationData), + [t, isSingleDepositToken, translationData], + ) + + return ( + + + {pendingTx ? ( + + ) : ( + <> + + {t('Adding')}: + + + {tokenPairName} + + + {vaultName} + + + + + {allowDepositToken0 && ( + + + + )} + {allowDepositToken1 && ( + + + + )} + + + {t('Your share in the vault')}: + {`${userVaultPercentage?.toFixed(2)}%`} + + + {t('APR')}: + + + + {isSingleDepositToken && } + + + + + + )} + + + ) +}) + +interface AddLiquidityButtonProps { + amountA: CurrencyAmount | undefined + amountB: CurrencyAmount | undefined + contractAddress: `0x${string}` + disabled?: boolean + onAddLiquidity?: () => void + isLoading?: boolean + learnMoreAboutUrl?: string +} + +export const AddLiquidityButton = memo(function AddLiquidityButton({ + amountA, + amountB, + contractAddress, + disabled, + onAddLiquidity, + isLoading, + learnMoreAboutUrl, +}: AddLiquidityButtonProps) { + const { t } = useTranslation() + + const { approvalState: approvalStateToken0, approveCallback: approveCallbackToken0 } = useApproveCallback( + amountA, + contractAddress, + ) + const { approvalState: approvalStateToken1, approveCallback: approveCallbackToken1 } = useApproveCallback( + amountB, + contractAddress, + ) + + const showAmountButtonA = useMemo( + () => amountA && approvalStateToken0 === ApprovalState.NOT_APPROVED, + [amountA, approvalStateToken0], + ) + const showAmountButtonB = useMemo( + () => amountB && approvalStateToken1 === ApprovalState.NOT_APPROVED, + [amountB, approvalStateToken1], + ) + const isConfirmButtonDisabled = useMemo( + () => + disabled || + (amountA && approvalStateToken0 !== ApprovalState.APPROVED) || + (amountB && approvalStateToken1 !== ApprovalState.APPROVED), + [amountA, amountB, disabled, approvalStateToken0, approvalStateToken1], + ) + + return ( + <> + {showAmountButtonA && ( + + )} + {showAmountButtonB && ( + + )} + + + {t('Learn more about the strategy')} + + + ) +}) diff --git a/apps/web/src/views/PositionManagers/components/AprButton.tsx b/apps/web/src/views/PositionManagers/components/AprButton.tsx new file mode 100644 index 0000000000000..a1c110b09cf0f --- /dev/null +++ b/apps/web/src/views/PositionManagers/components/AprButton.tsx @@ -0,0 +1,113 @@ +import { useTranslation } from '@pancakeswap/localization' +import { Flex, RoiCalculatorModal, Skeleton, Text, useModal, useTooltip } from '@pancakeswap/uikit' +import BigNumber from 'bignumber.js' +import { useCakePrice } from 'hooks/useCakePrice' +import { memo, useMemo } from 'react' +import { styled } from 'styled-components' +import { useAccount } from 'wagmi' +import { AprResult } from '../hooks' + +interface Props { + id: number | string + apr: AprResult + isAprLoading: boolean + lpSymbol: string + totalAssetsInUsd: number + userLpAmounts?: bigint + totalSupplyAmounts?: bigint + precision?: bigint +} + +const AprText = styled(Text)` + text-underline-offset: 0.125em; + text-decoration: dotted underline; + cursor: pointer; +` + +export const AprButton = memo(function YieldInfo({ + id, + apr, + isAprLoading, + totalAssetsInUsd, + lpSymbol, + userLpAmounts, + precision, +}: Props) { + const { t } = useTranslation() + + const { address: account } = useAccount() + const cakePriceBusd = useCakePrice() + const tokenBalance = useMemo( + () => new BigNumber(Number(((userLpAmounts ?? 0n) * 10000n) / (precision ?? 1n)) / 10000 ?? 0), + [userLpAmounts, precision], + ) + + const tokenPrice = useMemo( + () => totalAssetsInUsd / (Number(((userLpAmounts ?? 0n) * 10000n) / (precision ?? 1n)) / 10000 ?? 0), + [userLpAmounts, precision, totalAssetsInUsd], + ) + const { targetRef, tooltip, tooltipVisible } = useTooltip( + <> + + {t('Combined APR')}:{' '} + + {`${apr.combinedApr}%`} + + +
    + {apr.isInCakeRewardDateRange && ( +
  • + {t('CAKE APR')}:{' '} + + {`${apr.cakeYieldApr}%`} + +
  • + )} +
  • + {t('LP APR')}:{' '} + + {apr.lpApr}% + +
  • +
+ + {t('Calculated based on previous 7 days average data.')} + + , + { + placement: 'top', + }, + ) + + const [onPresentApyModal] = useModal( + , + false, + true, + `PositionManagerModal${id}`, + ) + + return ( + + {apr && !isAprLoading ? ( + + {`${apr.combinedApr}%`} + {tooltipVisible && tooltip} + + ) : ( + + )} + + ) +}) diff --git a/apps/web/src/views/PositionManagers/components/CardLayout.tsx b/apps/web/src/views/PositionManagers/components/CardLayout.tsx new file mode 100644 index 0000000000000..42b2506e2cbed --- /dev/null +++ b/apps/web/src/views/PositionManagers/components/CardLayout.tsx @@ -0,0 +1,15 @@ +import { styled } from 'styled-components' +import { FlexLayout, CardHeader as CardHeaderComp } from '@pancakeswap/uikit' + +export const CardLayout = styled(FlexLayout)` + justify-content: flex-start; +` + +export const CardHeader = styled(CardHeaderComp)` + background: none; + display: flex; + justify-content: space-between; + flex-direction: row; + align-items: flex-start; + padding: 1.5em 1.5em 0 1.5em; +` diff --git a/apps/web/src/views/PositionManagers/components/CardSection.tsx b/apps/web/src/views/PositionManagers/components/CardSection.tsx new file mode 100644 index 0000000000000..bf49ca29b8a05 --- /dev/null +++ b/apps/web/src/views/PositionManagers/components/CardSection.tsx @@ -0,0 +1,30 @@ +import { styled } from 'styled-components' +import { SpaceProps } from 'styled-system' +import { PropsWithChildren, ReactNode, memo } from 'react' +import { Box, Text } from '@pancakeswap/uikit' + +interface Props extends SpaceProps { + title: ReactNode +} + +const Section = styled(Box)` + & + & { + margin-top: 1em; + } +` + +const Title = styled(Text).attrs({ + color: 'textSubtle', + textTransform: 'uppercase', + fontSize: '12px', + bold: true, +})`` + +export const CardSection = memo(function CardSection({ title, children, ...props }: PropsWithChildren) { + return ( +
+ {title} + {children} +
+ ) +}) diff --git a/apps/web/src/views/PositionManagers/components/CardTitle.tsx b/apps/web/src/views/PositionManagers/components/CardTitle.tsx new file mode 100644 index 0000000000000..fea242ea049fd --- /dev/null +++ b/apps/web/src/views/PositionManagers/components/CardTitle.tsx @@ -0,0 +1,68 @@ +import { memo, useMemo } from 'react' +import { Currency } from '@pancakeswap/sdk' +import { FeeAmount } from '@pancakeswap/v3-sdk' +import { Flex, Text } from '@pancakeswap/uikit' + +import { CardHeader } from './CardLayout' +import { TokenPairLogos } from './TokenPairLogos' +import { FeeTag, FarmTag, SingleTokenTag } from './Tags' + +interface Props { + currencyA: Currency + currencyB: Currency + vaultName: string + feeTier: FeeAmount + isSingleDepositToken: boolean + allowDepositToken1: boolean + autoFarm?: boolean + autoCompound?: boolean +} + +export const CardTitle = memo(function CardTitle({ + currencyA, + currencyB, + vaultName, + feeTier, + isSingleDepositToken, + autoFarm, + autoCompound, + allowDepositToken1, +}: Props) { + const isTokenDisplayReverse = useMemo( + () => isSingleDepositToken && allowDepositToken1, + [isSingleDepositToken, allowDepositToken1], + ) + const displayCurrencyA = useMemo( + () => (isTokenDisplayReverse ? currencyB : currencyA), + [isTokenDisplayReverse, currencyA, currencyB], + ) + const displayCurrencyB = useMemo( + () => (isTokenDisplayReverse ? currencyA : currencyB), + [isTokenDisplayReverse, currencyA, currencyB], + ) + const tokenPairName = useMemo( + () => `${displayCurrencyA.symbol}-${displayCurrencyB.symbol}`, + [displayCurrencyA, displayCurrencyB], + ) + + return ( + + + + + + {tokenPairName} + + + {vaultName} + + + + + {autoFarm && } + {isSingleDepositToken && } + + + + ) +}) diff --git a/apps/web/src/views/PositionManagers/components/ControlsContainer.tsx b/apps/web/src/views/PositionManagers/components/ControlsContainer.tsx new file mode 100644 index 0000000000000..22436bb6ae7b0 --- /dev/null +++ b/apps/web/src/views/PositionManagers/components/ControlsContainer.tsx @@ -0,0 +1,37 @@ +import { Flex } from '@pancakeswap/uikit' +import { styled } from 'styled-components' + +export const ControlsContainer = styled(Flex).attrs({ + alignItems: 'center', + justifyContent: 'space-between', + flexDirection: 'column', +})` + width: 100%; + position: relative; + + margin-bottom: 2em; + + ${({ theme }) => theme.mediaQueries.sm} { + flex-direction: row; + flex-wrap: wrap; + padding: 1em 2em; + margin-bottom: 0; + } +` + +export const ControlGroup = styled(Flex)` + width: 100%; + align-items: ${(props) => props.alignItems || 'center'}; + flex-direction: ${(props) => props.flexDirection || 'row'}; + justify-content: ${(props) => props.justifyContent || 'space-between'}; + margin-bottom: 1em; + + &:last-child { + margin-bottom: 0; + } + + ${({ theme }) => theme.mediaQueries.sm} { + width: auto; + margin-bottom: 0; + } +` diff --git a/apps/web/src/views/PositionManagers/components/DYORWarning.tsx b/apps/web/src/views/PositionManagers/components/DYORWarning.tsx new file mode 100644 index 0000000000000..a4578ccfac7bc --- /dev/null +++ b/apps/web/src/views/PositionManagers/components/DYORWarning.tsx @@ -0,0 +1,52 @@ +import { useMemo } from 'react' +import { useTranslation } from '@pancakeswap/localization' +import { Message, MessageText, Text, Flex, Box, Link } from '@pancakeswap/uikit' +import { MANAGER, baseManagers, BaseManager } from '@pancakeswap/position-managers' + +interface DYORWarningProps { + manager: { + id: MANAGER + name: string + } +} + +export const DYORWarning: React.FC = ({ manager }) => { + const { t } = useTranslation() + const managerInfo: BaseManager = useMemo(() => baseManagers[manager.id], [manager]) + + if (!managerInfo?.name && !managerInfo?.introLink) { + return null + } + + return ( + + + + + + {t('You are providing liquidity via a 3rd party liquidity manager')} + + + {managerInfo.name} + + + {t('which is responsible for managing the underlying assets.')} + + + + {t('Please always DYOR before depositing your assets.')} + + + + + ) +} diff --git a/apps/web/src/views/PositionManagers/components/DuoTokenVaultCard.tsx b/apps/web/src/views/PositionManagers/components/DuoTokenVaultCard.tsx new file mode 100644 index 0000000000000..b820342e18a48 --- /dev/null +++ b/apps/web/src/views/PositionManagers/components/DuoTokenVaultCard.tsx @@ -0,0 +1,230 @@ +import { MANAGER, Strategy } from '@pancakeswap/position-managers' +import { Card, CardBody } from '@pancakeswap/uikit' +import { Currency, Percent, Price, CurrencyAmount } from '@pancakeswap/sdk' +import { FeeAmount } from '@pancakeswap/v3-sdk' +import { Address } from 'viem' +import { ReactNode, memo, PropsWithChildren, useMemo } from 'react' +import { styled } from 'styled-components' +import { useApr } from 'views/PositionManagers/hooks/useApr' +import { CardTitle } from './CardTitle' +import { YieldInfo } from './YieldInfo' +import { ManagerInfo } from './ManagerInfo' +import { LiquidityManagement } from './LiquidityManagement' +import { getVaultName } from '../utils' +import { ExpandableSection } from './ExpandableSection' +import { VaultInfo } from './VaultInfo' +import { VaultLinks } from './VaultLinks' +import { AprDataInfo } from '../hooks' + +const StyledCard = styled(Card)` + align-self: baseline; + max-width: 100%; + margin: 0 0 24px 0; + ${({ theme }) => theme.mediaQueries.sm} { + max-width: 350px; + margin: 0 12px 46px; + } +` + +interface Props { + currencyA: Currency + currencyB: Currency + earningToken: Currency + name: string + id: string | number + feeTier: FeeAmount + ratio: number + strategy: Strategy + manager: { + id: MANAGER + name: string + } + managerFee?: Percent + autoFarm?: boolean + autoCompound?: boolean + info?: ReactNode + isSingleDepositToken: boolean + allowDepositToken0?: boolean + allowDepositToken1?: boolean + contractAddress: Address + poolToken0Amount?: bigint + poolToken1Amount?: bigint + stakedToken0Amount?: bigint + stakedToken1Amount?: bigint + token0PriceUSD?: number + token1PriceUSD?: number + pendingReward: bigint | undefined + userVaultPercentage?: Percent + managerAddress: Address + managerInfoUrl: string + strategyInfoUrl: string + projectVaultUrl?: string + learnMoreAboutUrl?: string + rewardPerSecond: string + aprDataInfo: { + info: AprDataInfo | undefined + isLoading: boolean + } + rewardEndTime: number + rewardStartTime: number + refetch?: () => void + totalAssetsInUsd: number + totalStakedInUsd: number + userLpAmounts?: bigint + totalSupplyAmounts?: bigint + precision?: bigint +} + +export const DuoTokenVaultCard = memo(function DuoTokenVaultCard({ + currencyA, + currencyB, + earningToken, + name, + id, + feeTier, + autoFarm, + autoCompound, + manager, + managerFee, + strategy, + ratio, + isSingleDepositToken, + allowDepositToken0 = true, + allowDepositToken1 = true, + contractAddress, + stakedToken0Amount, + stakedToken1Amount, + poolToken0Amount, + poolToken1Amount, + token0PriceUSD, + token1PriceUSD, + pendingReward, + managerInfoUrl, + strategyInfoUrl, + projectVaultUrl, + rewardPerSecond, + aprDataInfo, + rewardEndTime, + refetch, + rewardStartTime, + totalAssetsInUsd, + userLpAmounts, + totalSupplyAmounts, + precision, + managerAddress, + totalStakedInUsd, + learnMoreAboutUrl, +}: PropsWithChildren) { + const apr = useApr({ + currencyA, + currencyB, + poolToken0Amount, + poolToken1Amount, + token0PriceUSD, + token1PriceUSD, + rewardPerSecond, + earningToken, + avgToken0Amount: aprDataInfo?.info?.token0 ?? 0, + avgToken1Amount: aprDataInfo?.info?.token1 ?? 0, + rewardEndTime, + rewardStartTime, + }) + + const price = new Price(currencyA, currencyB, 100000n, 100000n) + const vaultName = useMemo(() => getVaultName(id, name), [name, id]) + const staked0Amount = stakedToken0Amount ? CurrencyAmount.fromRawAmount(currencyA, stakedToken0Amount) : undefined + const staked1Amount = stakedToken1Amount ? CurrencyAmount.fromRawAmount(currencyB, stakedToken1Amount) : undefined + + const withCakeReward: boolean = useMemo(() => earningToken.symbol === 'CAKE', [earningToken]) + + return ( + + + + + + + + + + + + + ) +}) diff --git a/apps/web/src/views/PositionManagers/components/ExpandableSection.tsx b/apps/web/src/views/PositionManagers/components/ExpandableSection.tsx new file mode 100644 index 0000000000000..a908b56bb570e --- /dev/null +++ b/apps/web/src/views/PositionManagers/components/ExpandableSection.tsx @@ -0,0 +1,24 @@ +import { SpaceProps } from 'styled-system' +import { PropsWithChildren, memo, useCallback, useState } from 'react' +import { Flex, ExpandableLabel, Text } from '@pancakeswap/uikit' +import { useTranslation } from '@pancakeswap/localization' + +export const ExpandableSection = memo(function ExpandableSection({ + children, + ...props +}: PropsWithChildren) { + const { t } = useTranslation() + const [expanded, setExpanded] = useState(false) + const toggle = useCallback(() => setExpanded(!expanded), [expanded]) + + return ( + + + + {expanded ? t('Hide') : t('Info')} + + + {expanded ? children : null} + + ) +}) diff --git a/apps/web/src/views/PositionManagers/components/Filters.tsx b/apps/web/src/views/PositionManagers/components/Filters.tsx new file mode 100644 index 0000000000000..ba33c2951ef50 --- /dev/null +++ b/apps/web/src/views/PositionManagers/components/Filters.tsx @@ -0,0 +1,85 @@ +import { Flex, Text, Select, OptionProps, SearchInput } from '@pancakeswap/uikit' +import { styled } from 'styled-components' +import { useTranslation } from '@pancakeswap/localization' +import { useMemo, useCallback, ChangeEvent } from 'react' + +import { useSearch, useSortBy } from '../hooks' + +const LabelWrapper = styled.div` + > ${Text} { + font-size: 12px; + } +` + +const ControlStretch = styled(Flex)` + > div { + flex: 1; + } +` + +export function SortFilter() { + const { t } = useTranslation() + const [sortBy, setSortBy] = useSortBy() + const options = useMemo( + () => [ + { + label: t('Hot'), + value: 'hot', + }, + { + label: t('APR'), + value: 'apr', + }, + { + label: t('Earned'), + value: 'earned', + }, + { + label: t('Total staked'), + value: 'totalStaked', + }, + { + label: t('Latest'), + value: 'latest', + }, + ], + [t], + ) + const selected = useMemo(() => { + const index = options.findIndex((option) => option.value === sortBy) + // FIXME weird design of Select component. Need further refactor + return index >= 0 ? index + 1 : 0 + }, [options, sortBy]) + + const handleSortOptionChange = useCallback((option: OptionProps) => setSortBy(option.value), [setSortBy]) + + return ( + + + {t('Sort by')} + + +