diff --git a/package.json b/package.json index 8769e9db50c..27e14e4a2ae 100644 --- a/package.json +++ b/package.json @@ -136,6 +136,7 @@ "@ubeswap/moola": "^0.2.0", "@ubeswap/sdk": "^2.1.2", "@uniswap/default-token-list": "^2.0.0", + "apexcharts": "^3.33.0", "ajv-formats": "^2.1.1", "eventemitter3": "^4.0.7", "graphql": "^15.6.1", @@ -145,7 +146,11 @@ "prop-types": "^15.7.2", "randombytes": "^2.1.0", "rc-drawer": "^4.4.3", + "react-apexcharts": "^1.3.9", + "react-circular-progressbar": "^2.0.4", + "react-countdown": "^2.3.2", "react-is": "^17.0.2", + "react-minimal-pie-chart": "^8.2.0", "react-router": "^5.2.0", "rebass": "^4.0.7", "redux": "^4.1.0", diff --git a/public/locales/en.json b/public/locales/en.json index 36384b2cae8..2a967832d57 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -98,6 +98,7 @@ "liquidityProviderRewardsDesc": "Liquidity providers earn a 0.25% fee on all trades proportional to their share of the pool. Fees are added to the pool, accrue in real time and can be claimed by withdrawing your liquidity.", "liquidityProviderRewardsReadMore": "Read more about providing liquidity", "liquidityTokens": "liquidity tokens.", + "Liquidity": "Liquidity", "Lists": "Lists", "Loading": "Loading", "manage": "Manage", @@ -126,6 +127,11 @@ "poolRate": "Pool $t(rate)", "poolTokens": "Pool Tokens", "PoolTokensInRewardsPool": "Pool tokens in rewards pool", + "portfolio": "Portfolio", + "portfolioDistribution": "Portfolio Distribution", + "portfolioTokenHoldings": "Token Holdings", + "portfolioLiquidityHoldings": "Liquidity Holdings", + "portfolioValue": "Portfolio Value", "priceChange": "Expected price slippage", "rate": "rate", "Rate": "Rate", diff --git a/src/components/Header/index.tsx b/src/components/Header/index.tsx index adabd95e4f3..8ade63f3971 100644 --- a/src/components/Header/index.tsx +++ b/src/components/Header/index.tsx @@ -403,6 +403,9 @@ export default function Header() { {t('stake')} + + {t('portfolio')} + diff --git a/src/components/portfolio/LiquidityCard.tsx b/src/components/portfolio/LiquidityCard.tsx new file mode 100644 index 00000000000..16b2f4de014 --- /dev/null +++ b/src/components/portfolio/LiquidityCard.tsx @@ -0,0 +1,137 @@ +import { LPPortfolio } from 'pages/Portfolio/usePortfolio' +import React from 'react' +import ReactApexChart from 'react-apexcharts' +import { useTranslation } from 'react-i18next' +import { useIsDarkMode } from 'state/user/hooks' +import styled from 'styled-components' + +import { colors, TYPE } from '../../theme' +import { AutoColumn } from '../Column' +import DoubleCurrencyLogo from '../DoubleLogo' +import { RowBetween } from '../Row' + +interface Props { + lpPortfolio: LPPortfolio +} + +const DataRow = styled(RowBetween)` + flex-direction: row; + padding: 0.5em; + width: 100%; +` +const LiquidityRowLeft = styled(RowBetween)` + flex-direction: row; + justify-content: flex-start; +` + +const LiquidityRowRight = styled(RowBetween)` + flex-direction: row; + justify-content: flex-end; +` + +const Wrapper = styled(AutoColumn)<{ showBackground: boolean; bgColor: any }>` + border-radius: 12px; + border-style: solid; + border-width: 0px; + border-color: grey; + margin: 20px; + padding: 20px; + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + flex-grow: 1; +` + +const ChartWrapper = styled(AutoColumn)` + width: 100%; +` + +interface LiquidityRowProps { + lpPortfolioData: TokenPortfolioData + valueCUSD: TokenAmount +} + +const LiquidityRow: React.FC = ({ lpPortfolioData, valueCUSD }: LiquidityRowProps) => { + const lpFraction = parseFloat(lpPortfolioData.cusdAmount.divide(valueCUSD).toFixed(4) * 100).toFixed(2) + const valueLP = parseFloat(lpPortfolioData.cusdAmount.toFixed(2)) + return ( + + + + + {lpPortfolioData.pair.token0.symbol}-{lpPortfolioData.pair.token1.symbol} + + + + ${valueLP} + ({lpFraction}%) + + + ) +} + +export const LiquidityCard: React.FC = ({ lpPortfolio }: Props) => { + const { t } = useTranslation() + + const darkMode = useIsDarkMode() + const themeColors = colors(darkMode) + + if (!(lpPortfolio && lpPortfolio.tokens.length)) { + return <> + } + + const valueLP = parseFloat(lpPortfolio.valueCUSD.toFixed(2)) + + const sortedTokens = lpPortfolio.tokens.sort((token1, token2) => { + return token2.cusdAmount.greaterThan(token1.cusdAmount) ? 1 : -1 + }) + + const series = lpPortfolio.tokens.map((token) => parseFloat(token.cusdAmount.toFixed(2))) + + const chartOptions = { + chart: { + width: '100%', + type: 'pie', + }, + labels: lpPortfolio.tokens.map((token) => `${token.pair.token0.symbol}-${token.pair.token1.symbol}`), + theme: { + monochrome: { + enabled: true, + color: themeColors.primary1, + shadeTo: 'light', + shadeIntensity: 0.6, + }, + }, + plotOptions: { + pie: { + dataLabels: { + offset: -5, + }, + }, + }, + legend: { + show: false, + }, + } + return ( + + + {t('portfolioLiquidityHoldings')}: ${valueLP} + + + + + {sortedTokens && + sortedTokens.map((lpPortfolioData) => { + return ( + + ) + })} + + ) +} diff --git a/src/components/portfolio/PortfolioCard.tsx b/src/components/portfolio/PortfolioCard.tsx new file mode 100644 index 00000000000..f3d5b371951 --- /dev/null +++ b/src/components/portfolio/PortfolioCard.tsx @@ -0,0 +1,111 @@ +import { LPPortfolio, TokenPortfolio } from 'pages/Portfolio/usePortfolio' +import React from 'react' +import ReactApexChart from 'react-apexcharts' +import { useTranslation } from 'react-i18next' +import { useIsDarkMode } from 'state/user/hooks' +import styled from 'styled-components' + +import { colors, TYPE } from '../../theme' +import { AutoColumn } from '../Column' +import { RowBetween } from '../Row' + +const Wrapper = styled(AutoColumn)<{ showBackground: boolean; bgColor: any }>` + border-radius: 12px; + border-style: solid; + border-width: 0px; + border-color: grey; + margin: 20px; + padding: 20px; + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + flex-grow: 1; +` + +const ChartWrapper = styled(AutoColumn)` + width: 100%; +` + +interface Props { + tokenPortfolio: TokenPortfolio + lpPortfolio: LPPortfolio +} + +const DataRow = styled(RowBetween)` + flex-direction: row; + padding: 0.5em; + width: 100%; +` + +const DataRowRight = styled(RowBetween)` + flex-direction: row; + justify-content: flex-end; +` + +export const PortfolioCard: React.FC = ({ tokenPortfolio, lpPortfolio }: Props) => { + const { t } = useTranslation() + + const darkMode = useIsDarkMode() + const themeColors = colors(darkMode) + + if (!(tokenPortfolio && tokenPortfolio.tokens.length) || !(lpPortfolio && lpPortfolio.tokens.length)) { + return <> + } + + const valueTokens = parseFloat(tokenPortfolio.valueCUSD.toFixed(2)) + const valueLiquidity = parseFloat(lpPortfolio.valueCUSD.toFixed(2)) + const totalValue = tokenPortfolio.valueCUSD.add(lpPortfolio.valueCUSD) + + const fractionTokens = (parseFloat(tokenPortfolio.valueCUSD.divide(totalValue).toFixed(2)) * 100).toFixed(2) + const fractionLiquidity = (parseFloat(lpPortfolio.valueCUSD.divide(totalValue).toFixed(2)) * 100).toFixed(2) + + const series = [valueTokens, valueLiquidity] + + const chartOptions = { + chart: { + type: 'pie', + }, + labels: [t('Tokens'), t('StakedUnstakedLiquidity')], + plotOptions: { + pie: { + dataLabels: { + offset: -5, + }, + }, + }, + legend: { + show: false, + }, + theme: { + monochrome: { + enabled: true, + color: themeColors.primary1, + shadeTo: 'light', + shadeIntensity: 0.6, + }, + }, + } + return ( + + {t('portfolioDistribution')} + + + + + {t('Tokens')}: + + ${valueTokens} + ({fractionTokens}%) + + + + {t('Liquidity')}: + + ${valueLiquidity} + ({fractionLiquidity}%) + + + + ) +} diff --git a/src/components/portfolio/TokenCard.tsx b/src/components/portfolio/TokenCard.tsx new file mode 100644 index 00000000000..3c8dc2f7173 --- /dev/null +++ b/src/components/portfolio/TokenCard.tsx @@ -0,0 +1,136 @@ +import { TokenPortfolio, TokenPortfolioData } from 'pages/Portfolio/usePortfolio' +import React from 'react' +import ReactApexChart from 'react-apexcharts' +import { useTranslation } from 'react-i18next' +import { useIsDarkMode } from 'state/user/hooks' +import styled from 'styled-components' + +import { colors, TYPE } from '../../theme' +import { AutoColumn } from '../Column' +import CurrencyLogo from '../CurrencyLogo' +import { RowBetween } from '../Row' + +interface Props { + tokenPortfolio: TokenPortfolio +} + +const DataRow = styled(RowBetween)` + flex-direction: row; + padding: 0.5em; + width: 100%; +` +const TokenRowLeft = styled(RowBetween)` + flex-direction: row; + justify-content: flex-start; +` + +const TokenRowRight = styled(RowBetween)` + flex-direction: row; + justify-content: flex-end; +` + +const Wrapper = styled(AutoColumn)<{ showBackground: boolean; bgColor: any }>` + border-radius: 12px; + border-style: solid; + border-width: 0px; + border-color: grey; + margin: 20px; + padding: 20px; + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + flex-grow: 1; +` + +const ChartWrapper = styled(AutoColumn)` + width: 100%; +` + +interface TokenRowProps { + tokenPortfolioData: TokenPortfolioData + valueCUSD: TokenAmount +} + +const TokenRow: React.FC = ({ tokenPortfolioData, valueCUSD }: TokenRowProps) => { + const tokenFraction = parseFloat(tokenPortfolioData.cusdAmount.divide(valueCUSD).toFixed(4) * 100).toFixed(2) + const valueToken = parseFloat(tokenPortfolioData.cusdAmount.toFixed(2)) + return ( + + + {CurrencyLogo({ currency: tokenPortfolioData.token, size: '24px' })} + {tokenPortfolioData.token.symbol} + + + ${valueToken} + ({tokenFraction}%) + + + ) +} + +export const TokenCard: React.FC = ({ tokenPortfolio }: Props) => { + const { t } = useTranslation() + + const darkMode = useIsDarkMode() + const themeColors = colors(darkMode) + + if (!(tokenPortfolio && tokenPortfolio.tokens.length)) { + return <> + } + + const valueTokens = parseFloat(tokenPortfolio.valueCUSD.toFixed(2)) + + const sortedTokens = tokenPortfolio.tokens.sort((token1, token2) => { + return token2.cusdAmount.greaterThan(token1.cusdAmount) ? 1 : -1 + }) + + const series = tokenPortfolio.tokens.map((token) => parseFloat(token.cusdAmount.toFixed(2))) + + const chartOptions = { + chart: { + width: '100%', + type: 'pie', + }, + labels: tokenPortfolio.tokens.map((token) => token.token.symbol), + theme: { + monochrome: { + enabled: true, + color: themeColors.primary1, + shadeTo: 'light', + shadeIntensity: 0.6, + }, + }, + plotOptions: { + pie: { + dataLabels: { + offset: -5, + }, + }, + }, + legend: { + show: false, + }, + } + return ( + + + {t('portfolioTokenHoldings')}: ${valueTokens} + + + + + {sortedTokens && + sortedTokens.map((tokenPortfolioData) => { + return ( + + ) + })} + + ) +} diff --git a/src/data/TotalSupply.ts b/src/data/TotalSupply.ts index 6ec319f636c..d2fb7a730e9 100644 --- a/src/data/TotalSupply.ts +++ b/src/data/TotalSupply.ts @@ -1,8 +1,11 @@ import { BigNumber } from '@ethersproject/bignumber' -import { Token, TokenAmount } from '@ubeswap/sdk' +import { JSBI, Token, TokenAmount } from '@ubeswap/sdk' +import { useMemo } from 'react' +import ERC20_INTERFACE from '../constants/abis/erc20' import { useTokenContract } from '../hooks/useContract' -import { useSingleCallResult } from '../state/multicall/hooks' +import { useMultipleContractSingleData, useSingleCallResult } from '../state/multicall/hooks' +import { isAddress } from '../utils' // returns undefined if input token is undefined, or fails to get token contract, // or contract total supply cannot be fetched @@ -13,3 +16,31 @@ export function useTotalSupply(token?: Token): TokenAmount | undefined { return token && totalSupply ? new TokenAmount(token, totalSupply.toString()) : undefined } + +export function useTotalSupplies(tokens?: (Token | undefined)[]): { [tokenAddress: string]: TokenAmount | undefined } { + const validatedTokens: Token[] = useMemo( + () => tokens?.filter((t?: Token): t is Token => isAddress(t?.address) !== false) ?? [], + [tokens] + ) + + const supplies = useMultipleContractSingleData( + validatedTokens.map((token) => token.address), + ERC20_INTERFACE, + 'totalSupply' + ) + + return useMemo( + () => + validatedTokens.length > 0 + ? validatedTokens.reduce<{ [tokenAddress: string]: TokenAmount | undefined }>((memo, token, i) => { + const value = supplies?.[i]?.result?.[0] + const amount = value ? JSBI.BigInt(value.toString()) : undefined + if (amount) { + memo[token.address] = new TokenAmount(token, amount) + } + return memo + }, {}) + : {}, + [validatedTokens, supplies] + ) +} diff --git a/src/pages/App.tsx b/src/pages/App.tsx index 578e1a7d541..8c29052e91b 100644 --- a/src/pages/App.tsx +++ b/src/pages/App.tsx @@ -21,6 +21,7 @@ import Earn from './Earn' import Manage from './Earn/Manage' import Pool from './Pool' import PoolFinder from './PoolFinder' +import Portfolio from './Portfolio' import RemoveLiquidity from './RemoveLiquidity' import { RedirectOldRemoveLiquidityPathStructure } from './RemoveLiquidity/redirects' import Send from './Send' @@ -106,6 +107,7 @@ export default function App() { + diff --git a/src/pages/Portfolio/index.tsx b/src/pages/Portfolio/index.tsx new file mode 100644 index 00000000000..5040477d3a8 --- /dev/null +++ b/src/pages/Portfolio/index.tsx @@ -0,0 +1,58 @@ +import React from 'react' +import { useTranslation } from 'react-i18next' +import { Text } from 'rebass' +import styled from 'styled-components' + +import { AutoColumn, TopSection } from '../../components/Column' +import { LiquidityCard } from '../../components/portfolio/LiquidityCard' +import { PortfolioCard } from '../../components/portfolio/PortfolioCard' +import { TokenCard } from '../../components/portfolio/TokenCard' +import { TYPE } from '../../theme' +import { useCombinedLPPortfolio, useTokenPortfolio } from './usePortfolio' + +const PageWrapper = styled.div` + width: 100%; + max-width: 800px; + display: flex; + flex-direction: column; + align-items: center; +` + +const CeloTaxRow = styled.div` + padding: 20px; + display: flex; + flex-direction: row; +` + +export default function Portfolio() { + const { t } = useTranslation() + + const tokenPortfolio = useTokenPortfolio() + const combinedLPPortfolio = useCombinedLPPortfolio() + + const portfolioValueString = + combinedLPPortfolio?.valueCUSD && tokenPortfolio?.valueCUSD + ? combinedLPPortfolio.valueCUSD.add(tokenPortfolio.valueCUSD).toFixed(2) + : '-' + + return ( + + + + + {t('portfolioValue')}: ${portfolioValueString} + + + + + + + + + {' '} + Need help with DeFi taxes? Check out celo.tax. + + + + ) +} diff --git a/src/pages/Portfolio/usePortfolio.ts b/src/pages/Portfolio/usePortfolio.ts new file mode 100644 index 00000000000..5797147a669 --- /dev/null +++ b/src/pages/Portfolio/usePortfolio.ts @@ -0,0 +1,328 @@ +import { useContractKit } from '@celo-tools/use-contractkit' +import { Interface } from '@ethersproject/abi' +import { cUSD, Fraction, Pair, Price, Token, TokenAmount } from '@ubeswap/sdk' +import DUAL_REWARDS_ABI from 'constants/abis/moola/MoolaStakingRewards.json' +import { useMemo } from 'react' +import { useMultipleContractSingleData } from 'state/multicall/hooks' +import { useOwnerStakedPools } from 'state/stake/useOwnerStakedPools' +import { useAllTokenBalances, useTokenBalances } from 'state/wallet/hooks' +import { useCUSDPrices } from 'utils/useCUSDPrice' +import { toWei } from 'web3-utils' + +import { usePairs } from '../../data/Reserves' +import { useTotalSupplies } from '../../data/TotalSupply' +import { useAllTokens } from '../../hooks/Tokens' +import { toV2LiquidityToken, useTrackedTokenPairs } from '../../state/user/hooks' +import { useFarmRegistry } from '../Earn/useFarmRegistry' + +export interface TokenPortfolioData { + token: Token + amount: TokenAmount + cusdPrice: Price // cUSD price of a unit token + cusdAmount: TokenAmount +} + +export interface TokenPortfolio { + tokens: TokenPortofolioData[] + valueCUSD: TokenAmount // Total cUSD value of all token holdings +} + +const MIN_CUSD_TOKEN_VALUE = new Fraction(toWei('.01'), toWei('1')) + +export const useTokenPortfolio = (): TokenPortfolio => { + const { network } = useContractKit() + const chainId = network.chainId as unknown as ChainId + + const allTokenBalances = useAllTokenBalances() + + const nonZeroTokenAmounts = useMemo(() => { + const nonZeroTokenAmounts: TokenAmount[] = [] + for (const [, tokenAmount] of Object.entries(allTokenBalances)) { + if (tokenAmount.greaterThan('0')) { + nonZeroTokenAmounts.push(tokenAmount) + } + } + return nonZeroTokenAmounts + }, [allTokenBalances]) + + const cusdPrices: Price[] = useCUSDPrices(nonZeroTokenAmounts.map((tokenAmount) => tokenAmount.token)) + + return useMemo(() => { + const tokens: TokenPortfolioData[] = [] + let valueCUSD = new TokenAmount(cUSD[chainId], '0') + + const tokenAmountsAndPrices = nonZeroTokenAmounts.map((tokenAmount, i) => [tokenAmount, cusdPrices[i]]) + for (const [tokenAmount, cusdPrice] of tokenAmountsAndPrices) { + if (!cusdPrice) { + continue + } + const tokenPortfolioData: TokenPortfolioData = { + token: tokenAmount.token, + amount: tokenAmount, + cusdPrice: cusdPrice, + cusdAmount: cusdPrice.quote(tokenAmount), + } + // Only include tokens whose cUSD value exceeds some small threshold, to avoid noise. + if (tokenPortfolioData.cusdAmount.greaterThan(MIN_CUSD_TOKEN_VALUE)) { + tokens.push(tokenPortfolioData) + valueCUSD = valueCUSD.add(tokenPortfolioData.cusdAmount) + } + } + return { + tokens, + valueCUSD, + } + }, [chainId, nonZeroTokenAmounts, cusdPrices]) +} + +export interface LPPortfolioData { + pair: Pair + amount: TokenAmount + cusdPrice: Price // cUSD price of a unit token + cusdAmount: TokenAmount +} + +export interface LPPortfolio { + tokens: LPPortofolioData[] + valueCUSD: TokenAmount // Total cUSD value of all token holdings +} + +export const useLPPortfolio = (): LPPortfolio => { + const { address: account } = useContractKit() + + // fetch the user's balances of all tracked V2 LP tokens + const trackedTokenPairs = useTrackedTokenPairs() + const tokenPairsWithLiquidityTokens = useMemo( + () => trackedTokenPairs.map((tokens) => ({ liquidityToken: toV2LiquidityToken(tokens), tokens })), + [trackedTokenPairs] + ) + const liquidityTokens = useMemo( + () => tokenPairsWithLiquidityTokens.map((tpwlt) => tpwlt.liquidityToken), + [tokenPairsWithLiquidityTokens] + ) + const v2PairsBalances = useTokenBalances(account ?? undefined, liquidityTokens) + + // fetch the reserves for all V2 pools in which the user has a balance + const liquidityTokensWithBalances = useMemo( + () => + tokenPairsWithLiquidityTokens + .filter(({ liquidityToken }) => v2PairsBalances[liquidityToken.address]?.greaterThan('0')) + .map(({ tokens }) => tokens), + [tokenPairsWithLiquidityTokens, v2PairsBalances] + ) + + const v2Pairs = usePairs(liquidityTokensWithBalances) + + return useCalculateLPPortfolio(v2Pairs, v2PairsBalances) +} + +export const useStakedLPPortfolio = (): LPPortfolio => { + const { address: owner } = useContractKit() + + const farmSummaries = useFarmRegistry() + const { stakedFarms } = useOwnerStakedPools(farmSummaries) + + // Get balance of each stakedFarm + const data = useMultipleContractSingleData( + stakedFarms.map((farmSummaries) => farmSummaries.stakingAddress), + new Interface(DUAL_REWARDS_ABI), + 'balanceOf', + [owner || undefined] + ) + + const stakedAmounts: Record = data.reduce((acc, curr, idx) => { + acc[stakedFarms[idx].lpAddress] = curr?.result?.[0] + return acc + }, {}) + + // Get all liquidity tokens + const trackedTokenPairs = useTrackedTokenPairs() + const tokenPairsWithLiquidityTokens = useMemo( + () => trackedTokenPairs.map((tokens) => ({ liquidityToken: toV2LiquidityToken(tokens), tokens })), + [trackedTokenPairs] + ) + + // Map from LP token address to TPWLT data + const liquidityTokenAddressMap = useMemo( + () => + tokenPairsWithLiquidityTokens.reduce((acc, curr) => { + acc[curr.liquidityToken.address] = curr + return acc + }, {}), + [tokenPairsWithLiquidityTokens] + ) + + const liquidityTokens = useMemo( + () => tokenPairsWithLiquidityTokens.map((tpwlt) => tpwlt.liquidityToken), + [tokenPairsWithLiquidityTokens] + ) + + const v2PairsBalances = useMemo( + () => + liquidityTokens.reduce((acc, curr) => { + if (stakedAmounts[curr.address]) { + acc[curr.address] = new TokenAmount(curr, stakedAmounts[curr.address]) + } + return acc + }, {}), + [liquidityTokens, stakedAmounts] + ) + + // fetch the reserves for all V2 pools in which the user has a balance + const liquidityTokensWithBalances = useMemo(() => { + return liquidityTokens + .filter((liquidityToken) => + stakedFarms.map((farmSummary) => farmSummary.lpAddress).includes(liquidityToken?.address) + ) + .map((liquidityToken) => liquidityTokenAddressMap[liquidityToken.address].tokens) + }, [liquidityTokens, stakedFarms, liquidityTokenAddressMap]) + const v2Pairs = usePairs(liquidityTokensWithBalances) + + return useCalculateLPPortfolio(v2Pairs, v2PairsBalances) +} + +export const useCombinedLPPortfolio = (): LPPortfolio => { + const stakedLPPortfolio = useStakedLPPortfolio() + const lpPortfolio = useLPPortfolio() + + // Take the staked and unstaked positions and merge them into one + return useMemo(() => { + const tokens = [] + for (const stakedToken of stakedLPPortfolio.tokens) { + const unstakedToken = lpPortfolio.tokens.find( + (unstakedToken) => unstakedToken.pair.liquidityToken.address === stakedToken.pair.liquidityToken.address + ) + if (unstakedToken) { + tokens.push({ + pair: unstakedToken.pair, + amount: unstakedToken.amount.add(stakedToken.amount), + cusdPrice: unstakedToken.cusdPrice, + cusdAmount: unstakedToken.cusdAmount.add(stakedToken.cusdAmount), + }) + } else { + tokens.push(stakedToken) + } + } + + for (const unstakedToken of lpPortfolio.tokens) { + const combinedToken = tokens.find( + (combinedToken) => combinedToken.pair.liquidityToken.address === unstakedToken.pair.liquidityToken.address + ) + if (!combinedToken) { + tokens.push(unstakedToken) + } + } + + return { + valueCUSD: stakedLPPortfolio.valueCUSD.add(lpPortfolio.valueCUSD), + tokens, + } + }, [stakedLPPortfolio, lpPortfolio]) +} + +const useCalculateLPPortfolio = (v2Pairs, v2PairsBalances): LPPortfolio => { + const allTokens = useAllTokens() + + const { network } = useContractKit() + const chainId = network.chainId as unknown as ChainId + // Get all the underlying tokens for the LP pairs with balance so we can lookup their prices + const baseTokens: Record = useMemo(() => { + const tokenMap: Record = {} + for (const [, pair] of v2Pairs) { + if (pair) { + tokenMap[pair.tokenAmounts[0].token.address] = pair.tokenAmounts[0].token + tokenMap[pair.tokenAmounts[1].token.address] = pair.tokenAmounts[1].token + } + } + return tokenMap + }, [v2Pairs]) + + const baseTokenPrices: Price[] = useCUSDPrices(Object.values(baseTokens)) + // We now have a map of base token address to price, which we can reuse to calculate LP price + const baseTokenPricesMap: Record = useMemo(() => { + const baseTokenPricesMap: Record = {} + baseTokenPrices.forEach((price) => { + if (price) { + baseTokenPricesMap[price.baseCurrency.address] = price + } + }) + return baseTokenPricesMap + }, [baseTokenPrices]) + + // We need the total supply of LP tokens in order to calculate cUSD price later + const totalPoolTokensMap: Record = useTotalSupplies( + v2Pairs.map((pair) => pair[1]?.liquidityToken) + ) + + return useMemo(() => { + const tokens: LPPortfolioData[] = [] + let valueCUSD = new TokenAmount(cUSD[chainId], '0') + + for (const [, pair] of v2Pairs) { + if (!pair) { + continue + } + const pairAddress = pair.liquidityToken.address + + if (!totalPoolTokensMap[pairAddress]) { + continue + } + const token0Amount = pair.getLiquidityValue( + pair.token0, + totalPoolTokensMap[pairAddress], + v2PairsBalances[pairAddress], + false + ) + const token1Amount = pair.getLiquidityValue( + pair.token1, + totalPoolTokensMap[pairAddress], + v2PairsBalances[pairAddress], + false + ) + + // Skip this pair for now if we can't get the base token prices + if ( + !baseTokenPricesMap[pair.tokenAmounts[0].token.address] || + !baseTokenPricesMap[pair.tokenAmounts[1].token.address] + ) { + continue + } + + const token0CusdAmount = baseTokenPricesMap[pair.tokenAmounts[0].token.address].quote(token0Amount) + const token1CusdAmount = baseTokenPricesMap[pair.tokenAmounts[1].token.address].quote(token1Amount) + const cusdAmount = token0CusdAmount.add(token1CusdAmount) + + // Price of a unit LP token will be the total cUSD value of the balance divided by the balance itself + const conversionFraction = cusdAmount.divide(v2PairsBalances[pairAddress]) + const cusdPrice = new Price( + pair.liquidityToken, + cUSD[chainId], + conversionFraction.denominator, + conversionFraction.numerator + ) + + // Need to construct a new Pair using the tokens from the useAllTokens hook since some tokens may not be WrappedTokenInfo + // objects, which causes issues when fetching token images. + const newPair = new Pair( + new TokenAmount(allTokens[pair.token0.address], pair.tokenAmounts[0].raw), + new TokenAmount(allTokens[pair.token1.address], pair.tokenAmounts[1].raw) + ) + + const lpPortfolioData: LPPortfolioData = { + pair: newPair, + amount: v2PairsBalances[pairAddress], + cusdPrice: cusdPrice, + cusdAmount: cusdAmount, + } + // Only include tokens whose cUSD value exceeds some small threshold, to avoid noise. + if (lpPortfolioData.cusdAmount.greaterThan(MIN_CUSD_TOKEN_VALUE)) { + tokens.push(lpPortfolioData) + valueCUSD = valueCUSD.add(lpPortfolioData.cusdAmount) + } + } + return { + tokens, + valueCUSD, + } + }, [baseTokenPricesMap, totalPoolTokensMap, allTokens, chainId, v2Pairs, v2PairsBalances]) +} diff --git a/yarn.lock b/yarn.lock index f03286f9f75..3a99677f0b5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4697,6 +4697,18 @@ anymatch@^3.0.0, anymatch@^3.0.3, anymatch@~3.1.2: normalize-path "^3.0.0" picomatch "^2.0.4" +apexcharts@^3.33.0: + version "3.33.0" + resolved "https://registry.yarnpkg.com/apexcharts/-/apexcharts-3.33.0.tgz#8fb807fb6c5a55a37a1168f0dbf0548d1ae69fdb" + integrity sha512-gOc0qZijuomtXTThLbb0sKn+mZJkVQADyK/Zw9vQ0JjKVbMYxzek61xk40hT49i1Sq6/MUqsz0WgUXYpqqf8Mg== + dependencies: + svg.draggable.js "^2.2.2" + svg.easing.js "^2.0.0" + svg.filter.js "^2.0.2" + svg.pathmorphing.js "^0.1.3" + svg.resize.js "^1.4.3" + svg.select.js "^3.0.1" + aproba@^1.0.3, aproba@^1.1.1: version "1.2.0" resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.2.0.tgz#6802e6264efd18c790a1b0d517f0f2627bf2c94a" @@ -13681,6 +13693,15 @@ prompts@^2.0.1: kleur "^3.0.3" sisteransi "^1.0.5" +prop-types@^15.5.7: + version "15.8.1" + resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" + integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg== + dependencies: + loose-envify "^1.4.0" + object-assign "^4.1.1" + react-is "^16.13.1" + prop-types@^15.5.8, prop-types@^15.6.0, prop-types@^15.6.2, prop-types@^15.7.2: version "15.7.2" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5" @@ -13940,6 +13961,13 @@ rc@^1.2.7: minimist "^1.2.0" strip-json-comments "~2.0.1" +react-apexcharts@^1.3.9: + version "1.3.9" + resolved "https://registry.yarnpkg.com/react-apexcharts/-/react-apexcharts-1.3.9.tgz#d97e53fd513dc6ff73b90c2364c3bdd88d8dad01" + integrity sha512-KPonT5uQPHOHSVgTNEzpB0HhCkZtoicQYGjR9P+3DRDSgTsC+DM2vDUfo/B2Fn1m+wdgVeDXWL0VJYDc6JD/tw== + dependencies: + prop-types "^15.5.7" + react-app-polyfill@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/react-app-polyfill/-/react-app-polyfill-2.0.0.tgz#a0bea50f078b8a082970a9d853dc34b6dcc6a3cf" @@ -13952,6 +13980,11 @@ react-app-polyfill@^2.0.0: regenerator-runtime "^0.13.7" whatwg-fetch "^3.4.1" +react-circular-progressbar@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/react-circular-progressbar/-/react-circular-progressbar-2.0.4.tgz#5b04a9cf6be8c522c180e4e99ec6db63677335b0" + integrity sha512-OfX0ThSxRYEVGaQSt0DlXfyl5w4DbXHsXetyeivmoQrh9xA9bzVPHNf8aAhOIiwiaxX2WYWpLDB3gcpsDJ9oww== + react-clientside-effect@^1.2.5: version "1.2.5" resolved "https://registry.yarnpkg.com/react-clientside-effect/-/react-clientside-effect-1.2.5.tgz#e2c4dc3c9ee109f642fac4f5b6e9bf5bcd2219a3" @@ -13959,6 +13992,13 @@ react-clientside-effect@^1.2.5: dependencies: "@babel/runtime" "^7.12.13" +react-countdown@^2.3.2: + version "2.3.2" + resolved "https://registry.yarnpkg.com/react-countdown/-/react-countdown-2.3.2.tgz#4cc27f28f2dcd47237ee66e4b9f6d2a21fc0b0ad" + integrity sha512-Q4SADotHtgOxNWhDdvgupmKVL0pMB9DvoFcxv5AzjsxVhzOVxnttMbAywgqeOdruwEAmnPhOhNv/awAgkwru2w== + dependencies: + prop-types "^15.7.2" + react-dev-utils@^11.0.3, react-dev-utils@^11.0.4: version "11.0.4" resolved "https://registry.yarnpkg.com/react-dev-utils/-/react-dev-utils-11.0.4.tgz#a7ccb60257a1ca2e0efe7a83e38e6700d17aa37a" @@ -14069,6 +14109,11 @@ react-loader-spinner@^4.0.0: dependencies: prop-types "^15.7.2" +react-minimal-pie-chart@^8.2.0: + version "8.2.0" + resolved "https://registry.yarnpkg.com/react-minimal-pie-chart/-/react-minimal-pie-chart-8.2.0.tgz#22b53af2363f040d818331721658dfa7a1ea847a" + integrity sha512-RhrHzprJt3KfBe4L3sE0Ha6fj4kYcwQtesQgscnld9Umf64+nZnxxInycnbimKsbIjxJONv77JIZp+qRbJD+bA== + react-modal@^3.14.3: version "3.14.3" resolved "https://registry.yarnpkg.com/react-modal/-/react-modal-3.14.3.tgz#7eb7c5ec85523e5843e2d4737cc17fc3f6aeb1c0" @@ -15830,6 +15875,61 @@ svg-parser@^2.0.2: resolved "https://registry.yarnpkg.com/svg-parser/-/svg-parser-2.0.4.tgz#fdc2e29e13951736140b76cb122c8ee6630eb6b5" integrity sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ== +svg.draggable.js@^2.2.2: + version "2.2.2" + resolved "https://registry.yarnpkg.com/svg.draggable.js/-/svg.draggable.js-2.2.2.tgz#c514a2f1405efb6f0263e7958f5b68fce50603ba" + integrity sha512-JzNHBc2fLQMzYCZ90KZHN2ohXL0BQJGQimK1kGk6AvSeibuKcIdDX9Kr0dT9+UJ5O8nYA0RB839Lhvk4CY4MZw== + dependencies: + svg.js "^2.0.1" + +svg.easing.js@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/svg.easing.js/-/svg.easing.js-2.0.0.tgz#8aa9946b0a8e27857a5c40a10eba4091e5691f12" + integrity sha1-iqmUawqOJ4V6XEChDrpAkeVpHxI= + dependencies: + svg.js ">=2.3.x" + +svg.filter.js@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/svg.filter.js/-/svg.filter.js-2.0.2.tgz#91008e151389dd9230779fcbe6e2c9a362d1c203" + integrity sha1-kQCOFROJ3ZIwd5/L5uLJo2LRwgM= + dependencies: + svg.js "^2.2.5" + +svg.js@>=2.3.x, svg.js@^2.0.1, svg.js@^2.2.5, svg.js@^2.4.0, svg.js@^2.6.5: + version "2.7.1" + resolved "https://registry.yarnpkg.com/svg.js/-/svg.js-2.7.1.tgz#eb977ed4737001eab859949b4a398ee1bb79948d" + integrity sha512-ycbxpizEQktk3FYvn/8BH+6/EuWXg7ZpQREJvgacqn46gIddG24tNNe4Son6omdXCnSOaApnpZw6MPCBA1dODA== + +svg.pathmorphing.js@^0.1.3: + version "0.1.3" + resolved "https://registry.yarnpkg.com/svg.pathmorphing.js/-/svg.pathmorphing.js-0.1.3.tgz#c25718a1cc7c36e852ecabc380e758ac09bb2b65" + integrity sha512-49HWI9X4XQR/JG1qXkSDV8xViuTLIWm/B/7YuQELV5KMOPtXjiwH4XPJvr/ghEDibmLQ9Oc22dpWpG0vUDDNww== + dependencies: + svg.js "^2.4.0" + +svg.resize.js@^1.4.3: + version "1.4.3" + resolved "https://registry.yarnpkg.com/svg.resize.js/-/svg.resize.js-1.4.3.tgz#885abd248e0cd205b36b973c4b578b9a36f23332" + integrity sha512-9k5sXJuPKp+mVzXNvxz7U0uC9oVMQrrf7cFsETznzUDDm0x8+77dtZkWdMfRlmbkEEYvUn9btKuZ3n41oNA+uw== + dependencies: + svg.js "^2.6.5" + svg.select.js "^2.1.2" + +svg.select.js@^2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/svg.select.js/-/svg.select.js-2.1.2.tgz#e41ce13b1acff43a7441f9f8be87a2319c87be73" + integrity sha512-tH6ABEyJsAOVAhwcCjF8mw4crjXSI1aa7j2VQR8ZuJ37H2MBUbyeqYr5nEO7sSN3cy9AR9DUwNg0t/962HlDbQ== + dependencies: + svg.js "^2.2.5" + +svg.select.js@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/svg.select.js/-/svg.select.js-3.0.1.tgz#a4198e359f3825739226415f82176a90ea5cc917" + integrity sha512-h5IS/hKkuVCbKSieR9uQCj9w+zLHoPh+ce19bBYyqF53g6mnPB8sAtIbe1s9dh2S2fCmYX2xel1Ln3PJBbK4kw== + dependencies: + svg.js "^2.6.5" + svgo@^1.0.0, svgo@^1.2.2: version "1.3.2" resolved "https://registry.yarnpkg.com/svgo/-/svgo-1.3.2.tgz#b6dc511c063346c9e415b81e43401145b96d4167"