diff --git a/apps/lend/src/abis/totalSupply.json b/apps/lend/src/abis/totalSupply.json new file mode 100644 index 000000000..84b36c9a7 --- /dev/null +++ b/apps/lend/src/abis/totalSupply.json @@ -0,0 +1 @@ +{"abi":[{"name":"totalSupply","outputs":[{"type":"uint256","name":""}],"inputs":[],"stateMutability":"view","type":"function"}]} diff --git a/apps/lend/src/components/DetailInfoCrvIncentives.tsx b/apps/lend/src/components/DetailInfoCrvIncentives.tsx new file mode 100644 index 000000000..2d33056fe --- /dev/null +++ b/apps/lend/src/components/DetailInfoCrvIncentives.tsx @@ -0,0 +1,126 @@ +import { t } from '@lingui/macro' + +import React, { useMemo } from 'react' +import styled from 'styled-components' + +import { FORMAT_OPTIONS, formatNumber } from '@/ui/utils' +import { INVALID_ADDRESS } from '@/constants' +import useAbiTotalSupply from '@/hooks/useAbiTotalSupply' +import useStore from '@/store/useStore' +import useSupplyTotalApr from '@/hooks/useSupplyTotalApr' + +import DetailInfo from '@/ui/DetailInfo' +import Icon from '@/ui/Icon' +import TooltipIcon from '@/ui/Tooltip/TooltipIcon' + +type Data = { + label: string + tooltip: string + skeleton: [number, number] + aprCurr: string + aprNew: string + ratio: number +} + +const DetailInfoCrvIncentives = ({ + rChainId, + rOwmId, + lpTokenAmount, +}: { + rChainId: ChainId + rOwmId: string + lpTokenAmount: string +}) => { + const { tooltipValues } = useSupplyTotalApr(rChainId, rOwmId) + const owmData = useStore((state) => state.markets.owmDatasMapper[rChainId]?.[rOwmId]) + const { gauge: gaugeAddress } = owmData?.owm?.addresses ?? {} + const gaugeTotalSupply = useAbiTotalSupply(rChainId, gaugeAddress) + const isGaugeAddressInvalid = gaugeAddress === INVALID_ADDRESS + + const { crvBase = '', incentivesObj = [] } = tooltipValues ?? {} + + const data = useMemo(() => { + let data: Data[] = [] + + if (!isGaugeAddressInvalid) { + if (+crvBase > 0) { + data.push({ + label: t`CRV APR:`, + tooltip: t`As the number of staked vault shares increases, the CRV APR will decrease.`, + skeleton: [50, 23], + ..._getDataApr(crvBase, gaugeTotalSupply, lpTokenAmount), + }) + } + + if (incentivesObj.length > 0) { + incentivesObj.forEach(({ apy, symbol }) => { + data.push({ + label: t`Incentives ${symbol} APR:`, + tooltip: t`As the number of staked vault shares increases, the ${symbol} APR will decrease.`, + skeleton: [60, 23], + ..._getDataApr(apy, gaugeTotalSupply, lpTokenAmount), + }) + }) + } + } + + return data + }, [crvBase, gaugeTotalSupply, incentivesObj, isGaugeAddressInvalid, lpTokenAmount]) + + if (data.length === 0 || isGaugeAddressInvalid) { + return null + } + + return ( + <> + {data.map(({ label, tooltip, skeleton, aprCurr, aprNew, ratio }, idx) => { + return ( + + {tooltip} + + } + > + {aprCurr} + {ratio > 1.25 && ( + <> + {aprNew} + + )}{' '} + + ) + })} + + ) +} + +const StyledTooltipIcon = styled(TooltipIcon)` + margin-left: var(--spacing-1); +` + +const StyledIcon = styled(Icon)` + margin: 0 var(--spacing-1); +` + +export default DetailInfoCrvIncentives + +function _getDataApr( + currApr: string | number | undefined = '', + gaugeTotalSupply: number | null, + lpTokenAmount: string +) { + let resp = { aprCurr: formatNumber(currApr, FORMAT_OPTIONS.PERCENT), aprNew: '', ratio: 0 } + + if (+currApr > 0 && gaugeTotalSupply && +(gaugeTotalSupply || '0') > 0 && +lpTokenAmount > 0) { + const newGaugeTotalLocked = Number(lpTokenAmount) + gaugeTotalSupply + const aprNew = (gaugeTotalSupply / newGaugeTotalLocked) * +currApr + resp.aprNew = formatNumber(aprNew, FORMAT_OPTIONS.PERCENT) + resp.ratio = +currApr / aprNew + } + return resp +} diff --git a/apps/lend/src/components/PageVault/VaultStake/index.tsx b/apps/lend/src/components/PageVault/VaultStake/index.tsx index dc6a035d5..4623f4ea4 100644 --- a/apps/lend/src/components/PageVault/VaultStake/index.tsx +++ b/apps/lend/src/components/PageVault/VaultStake/index.tsx @@ -14,6 +14,7 @@ import { StyledDetailInfoWrapper, StyledInpChip } from '@/components/PageLoanMan import AlertBox from '@/ui/AlertBox' import AlertFormError from '@/components/AlertFormError' import Box from '@/ui/Box' +import DetailInfoCrvIncentives from '@/components/DetailInfoCrvIncentives' import DetailInfoEstimateGas from '@/components/DetailInfoEstimateGas' import InputProvider, { InputDebounced, InputMaxBtn } from '@/ui/InputComp' import LoanFormConnect from '@/components/LoanFormConnect' @@ -159,6 +160,7 @@ const VaultStake = ({ rChainId, rOwmId, rFormType, isLoaded, api, owmData, userA const activeStep = signerAddress ? getActiveStep(steps) : null const disabled = !!formStatus.step + const detailInfoCrvIncentivesComp = DetailInfoCrvIncentives({ rChainId, rOwmId, lpTokenAmount: formValues.amount }) return ( <> @@ -198,15 +200,20 @@ const VaultStake = ({ rChainId, rOwmId, rFormType, isLoaded, api, owmData, userA {/* detail info */} - - {signerAddress && ( - 1 ? { active: activeStep, total: steps.length } : null} - /> - )} - + {(detailInfoCrvIncentivesComp || signerAddress) && ( + + {detailInfoCrvIncentivesComp} + + {signerAddress && ( + 1 ? { active: activeStep, total: steps.length } : null} + /> + )} + + )} {/* actions */} diff --git a/apps/lend/src/hooks/useAbiTotalSupply.tsx b/apps/lend/src/hooks/useAbiTotalSupply.tsx new file mode 100644 index 000000000..e3fa96ac8 --- /dev/null +++ b/apps/lend/src/hooks/useAbiTotalSupply.tsx @@ -0,0 +1,43 @@ +import type { Contract } from 'ethers' + +import { useCallback, useEffect, useState } from 'react' + +import { INVALID_ADDRESS, REFRESH_INTERVAL } from '@/constants' +import { weiToEther } from '@/shared/curve-lib' +import useContract from '@/hooks/useContract' +import usePageVisibleInterval from '@/ui/hooks/usePageVisibleInterval' +import useStore from '@/store/useStore' + +const useAbiTotalSupply = (rChainId: ChainId, contractAddress: string | undefined) => { + const contract = useContract(rChainId, false, 'totalSupply', contractAddress) + const isValidAddress = contractAddress !== INVALID_ADDRESS + + const isPageVisible = useStore((state) => state.isPageVisible) + + const [totalSupply, settotalSupply] = useState(null) + + const getTotalSupply = useCallback(async (contract: Contract) => { + try { + const totalSupply = await contract.totalSupply() + settotalSupply(weiToEther(Number(totalSupply))) + } catch (error) { + console.error(error) + } + }, []) + + useEffect(() => { + if (contract && isValidAddress) getTotalSupply(contract) + }, [contract, isValidAddress, getTotalSupply]) + + usePageVisibleInterval( + () => { + if (contract && isValidAddress) getTotalSupply(contract) + }, + REFRESH_INTERVAL['1m'], + isPageVisible + ) + + return totalSupply +} + +export default useAbiTotalSupply diff --git a/apps/lend/src/hooks/useContract.ts b/apps/lend/src/hooks/useContract.ts new file mode 100644 index 000000000..87a82db0c --- /dev/null +++ b/apps/lend/src/hooks/useContract.ts @@ -0,0 +1,52 @@ +import { Contract, Interface, JsonRpcProvider } from 'ethers' +import { useCallback, useEffect, useState } from 'react' + +import networks from '@/networks' +import useStore from '@/store/useStore' + +const useAbiGaugeTotalSupply = ( + rChainId: ChainId, + signerRequired: boolean, + jsonModuleName: string, + contractAddress: string | undefined +) => { + const getProvider = useStore((state) => state.wallet.getProvider) + + const [contract, setContract] = useState(null) + + const getContract = useCallback( + async (jsonModuleName: string, contractAddress: string, provider: Provider | JsonRpcProvider) => { + try { + const abi = await import(`@/abis/${jsonModuleName}.json`).then((module) => module.default.abi) + + if (!abi) { + console.error('cannot find abi') + return null + } else { + const iface = new Interface(abi) + return new Contract(contractAddress, iface.format(), provider) + } + } catch (error) { + console.error(error) + return null + } + }, + [] + ) + + useEffect(() => { + if (rChainId) { + const provider = signerRequired + ? getProvider('') + : getProvider('') || new JsonRpcProvider(networks[rChainId].rpcUrl) + + if (jsonModuleName && contractAddress && provider) { + ;(async () => setContract(await getContract(jsonModuleName, contractAddress, provider)))() + } + } + }, [contractAddress, getContract, getProvider, jsonModuleName, rChainId, signerRequired]) + + return contract +} + +export default useAbiGaugeTotalSupply diff --git a/apps/lend/src/hooks/useSupplyTotalApr.ts b/apps/lend/src/hooks/useSupplyTotalApr.ts index 367945b19..e7127c693 100644 --- a/apps/lend/src/hooks/useSupplyTotalApr.ts +++ b/apps/lend/src/hooks/useSupplyTotalApr.ts @@ -50,6 +50,7 @@ function _getTooltipValue(lendApr: number, lendApy: number, crvBase: number, crv return { lendApr: formatNumber(lendApr, FORMAT_OPTIONS.PERCENT), lendApy: `${formatNumber(lendApy, FORMAT_OPTIONS.PERCENT)} APY`, + crvBase, crv: crvBase > 0 ? formatNumber(crvBase, FORMAT_OPTIONS.PERCENT) : '', crvBoosted: crvBoost > 0 ? formatNumber(crvBoost, FORMAT_OPTIONS.PERCENT) : '', incentives: others.map((o) => `${formatNumber(o.apy, FORMAT_OPTIONS.PERCENT)} ${o.symbol}`), diff --git a/packages/ui/src/Tooltip/TooltipButton.tsx b/packages/ui/src/Tooltip/TooltipButton.tsx index 5d19009fb..81934108b 100644 --- a/packages/ui/src/Tooltip/TooltipButton.tsx +++ b/packages/ui/src/Tooltip/TooltipButton.tsx @@ -10,6 +10,8 @@ import styled from 'styled-components' import Icon from 'ui/src/Icon' import Tooltip from 'ui/src/Tooltip/Tooltip' +export type IconStyles = { $svgTop?: string } + function TooltipButton({ className = '', children, @@ -17,6 +19,7 @@ function TooltipButton({ customIcon, onClick, increaseZIndex, + iconStyles = {}, ...props }: React.PropsWithChildren< TooltipTriggerProps & @@ -27,6 +30,7 @@ function TooltipButton({ customIcon?: React.ReactNode increaseZIndex?: boolean onClick?: () => void + iconStyles?: IconStyles } >) { const state = useTooltipTriggerState({ delay: 0, ...props }) @@ -61,7 +65,7 @@ function TooltipButton({ {state.isOpen && ( @@ -102,4 +106,9 @@ const Button = styled.span` } ` +const StyledIcon = styled(Icon)` + position: relative; + top: ${({ $svgTop }) => $svgTop || `0.2rem`}; +` + export default TooltipButton diff --git a/packages/ui/src/Tooltip/TooltipIcon.tsx b/packages/ui/src/Tooltip/TooltipIcon.tsx index f5a14050a..e9278fe78 100644 --- a/packages/ui/src/Tooltip/TooltipIcon.tsx +++ b/packages/ui/src/Tooltip/TooltipIcon.tsx @@ -2,7 +2,7 @@ import type { TooltipProps } from 'ui/src/Tooltip/types' import React from 'react' -import TooltipButton from 'ui/src/Tooltip/TooltipButton' +import TooltipButton, { IconStyles } from 'ui/src/Tooltip/TooltipButton' const TooltipIcon = ({ children, @@ -10,6 +10,7 @@ const TooltipIcon = ({ ...props }: React.PropsWithChildren< TooltipProps & { + iconStyles?: IconStyles customIcon?: React.ReactNode } >) => {