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"