Skip to content

Commit

Permalink
feat: fetch all LP user data (#6032)
Browse files Browse the repository at this point in the history
* wip: fetch all LP user data

* feat: infinity StaleTime

* fix: ts shenanigans

* feat: more perfy

* feat: cleanup

* fix: types

* fix: roon

* fix: dedupe and add TODO

* feat: use composite key for AccountIds future-proofing
  • Loading branch information
gomesalexandre authored Jan 19, 2024
1 parent 345bf8a commit ce65093
Show file tree
Hide file tree
Showing 10 changed files with 386 additions and 110 deletions.
10 changes: 9 additions & 1 deletion src/lib/utils/thorchain/lp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@ import { type BN, bn, bnOrZero } from 'lib/bignumber/bignumber'
import type { MidgardPoolResponse } from 'lib/swapper/swappers/ThorchainSwapper/types'
import { assetIdToPoolAssetId } from 'lib/swapper/swappers/ThorchainSwapper/utils/poolAssetHelpers/poolAssetHelpers'
import { thorService } from 'lib/swapper/swappers/ThorchainSwapper/utils/thorService'
import type { AsymSide } from 'pages/ThorChainLP/hooks/useUserLpData'
import { isUtxoChainId } from 'state/slices/portfolioSlice/utils'

import { fromThorBaseUnit, getAccountAddresses } from '.'
import type {
AsymSide,
MidgardLiquidityProvider,
MidgardLiquidityProvidersList,
MidgardPool,
Expand Down Expand Up @@ -537,3 +537,11 @@ export const calculateEarnings = (

return { totalEarningsFiatUserCurrency, assetEarnings, runeEarnings }
}

export const calculatePoolOwnershipPercentage = ({
userLiquidityUnits,
totalPoolUnits,
}: {
userLiquidityUnits: string
totalPoolUnits: string
}): string => bn(userLiquidityUnits).div(totalPoolUnits).times(100).toFixed()
30 changes: 30 additions & 0 deletions src/lib/utils/thorchain/lp/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { AccountId, AssetId } from '@shapeshiftoss/caip'
import type { BN } from 'lib/bignumber/bignumber'

export type ThorNodeLiquidityProvider = {
Expand Down Expand Up @@ -194,3 +195,32 @@ export type ConfirmedQuote = {
slippageRune: string
opportunityId: string
}
export enum AsymSide {
Asset = 'asset',
Rune = 'rune',
}

export type UserLpDataPosition = {
dateFirstAdded: string
liquidityUnits: string
underlyingAssetAmountCryptoPrecision: string
underlyingRuneAmountCryptoPrecision: string
isAsymmetric: boolean
asymSide: AsymSide | null
underlyingAssetValueFiatUserCurrency: string
underlyingRuneValueFiatUserCurrency: string
totalValueFiatUserCurrency: string
poolOwnershipPercentage: string
opportunityId: string
poolShare: string

// DO NOT REMOVE these two. While it looks like this would be superfluous because we already have AccountId, that's not exactly true.
// AccountId refers to the AccountId the position was *fetched* with/for, e.g ETH account 0 or ROON account 0.
// However, for sym., the position will be present in both ETH and RUNE /member/<address> responses, so we need to keep track of both addresses
// for reliable deduplication
runeAddress: string
assetAddress: string
accountId: AccountId

assetId: AssetId
}
181 changes: 115 additions & 66 deletions src/pages/ThorChainLP/YourPositions.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import type { GridProps } from '@chakra-ui/react'
import { Button, Flex, SimpleGrid, Skeleton, Stack, Tag } from '@chakra-ui/react'
import { Box, Button, Flex, SimpleGrid, Skeleton, Stack, Tag } from '@chakra-ui/react'
import type { AssetId } from '@shapeshiftoss/caip'
import { thorchainAssetId } from '@shapeshiftoss/caip'
import { useQuery } from '@tanstack/react-query'
import axios from 'axios'
import { getConfig } from 'config'
import uniq from 'lodash/uniq'
import { useCallback, useMemo } from 'react'
import { generatePath, useHistory } from 'react-router'
import { Amount } from 'components/Amount/Amount'
Expand All @@ -15,14 +16,16 @@ import { RawText, Text } from 'components/Text'
import { bn } from 'lib/bignumber/bignumber'
import type { ThornodePoolResponse } from 'lib/swapper/swappers/ThorchainSwapper/types'
import { assetIdToPoolAssetId } from 'lib/swapper/swappers/ThorchainSwapper/utils/poolAssetHelpers/poolAssetHelpers'
import { isSome } from 'lib/utils'
import { calculateEarnings, getEarnings } from 'lib/utils/thorchain/lp'
import type { UserLpDataPosition } from 'lib/utils/thorchain/lp/types'
import { selectAssetById, selectMarketDataById } from 'state/slices/selectors'
import { useAppSelector } from 'state/store'

import { PoolIcon } from './components/PoolIcon'
import { PoolsHeader } from './components/PoolsHeader'
import { useAllUserLpData } from './hooks/useAllUserLpData'
import { usePools } from './hooks/usePools'
import { useUserLpData } from './hooks/useUserLpData'

export const lendingRowGrid: GridProps['gridTemplateColumns'] = {
base: 'minmax(150px, 1fr) repeat(1, minmax(40px, max-content))',
Expand Down Expand Up @@ -59,41 +62,43 @@ type PositionButtonProps = {
assetId: AssetId
name: string
opportunityId: string
accountId: string
apy: string
userPoolData: UserLpDataPosition
}

const PositionButton = ({ apy, assetId, name, opportunityId }: PositionButtonProps) => {
const PositionButton = ({
apy,
assetId,
name,
accountId,
opportunityId,
userPoolData,
}: PositionButtonProps) => {
const history = useHistory()
const asset = useAppSelector(state => selectAssetById(state, assetId))
const runeAsset = useAppSelector(state => selectAssetById(state, thorchainAssetId))

const { data: userData, isLoading } = useUserLpData({ assetId })

const foundUserPool = (userData ?? []).find(pool => pool.opportunityId === opportunityId)

const handlePoolClick = useCallback(() => {
if (!foundUserPool) return

const { opportunityId, accountId } = foundUserPool
history.push(
generatePath('/pools/poolAccount/:accountId/:opportunityId', { accountId, opportunityId }),
)
}, [foundUserPool, history])
}, [accountId, history, opportunityId])

const poolAssetIds = useMemo(() => {
if (!foundUserPool) return []

return [assetId, thorchainAssetId]
}, [assetId, foundUserPool])
}, [assetId])

const assetMarketData = useAppSelector(state => selectMarketDataById(state, assetId))
const runeMarketData = useAppSelector(state => selectMarketDataById(state, thorchainAssetId))

const { data: thornodePoolData } = useQuery({
enabled: Boolean(foundUserPool),
queryKey: ['thornodePoolData', foundUserPool?.assetId ?? ''],
enabled: Boolean(true),
// We may or may not want to revisit this, but this will prevent overfetching for now
staleTime: Infinity,
queryKey: ['thornodePoolData', assetId],
queryFn: async () => {
const poolAssetId = assetIdToPoolAssetId({ assetId: foundUserPool?.assetId ?? '' })
const poolAssetId = assetIdToPoolAssetId({ assetId })
const { data: poolData } = await axios.get<ThornodePoolResponse>(
`${getConfig().REACT_APP_THORCHAIN_NODE_URL}/lcd/thorchain/pool/${poolAssetId}`,
)
Expand All @@ -102,28 +107,29 @@ const PositionButton = ({ apy, assetId, name, opportunityId }: PositionButtonPro
},
})

const { data: earnings } = useQuery({
enabled: Boolean(foundUserPool && thornodePoolData),
queryKey: ['thorchainearnings', foundUserPool?.dateFirstAdded ?? ''],
queryFn: () =>
foundUserPool ? getEarnings({ from: foundUserPool.dateFirstAdded }) : undefined,
const { data: earnings, isLoading: isEarningsLoading } = useQuery({
enabled: Boolean(thornodePoolData),
// We may or may not want to revisit this, but this will prevent overfetching for now
staleTime: Infinity,
queryKey: ['thorchainearnings', userPoolData.dateFirstAdded],
queryFn: () => getEarnings({ from: userPoolData.dateFirstAdded }),
select: data => {
if (!data || !foundUserPool || !thornodePoolData) return null
const poolAssetId = assetIdToPoolAssetId({ assetId: foundUserPool.assetId })
if (!data || !thornodePoolData) return null
const poolAssetId = assetIdToPoolAssetId({ assetId })
const foundHistoryPool = data.meta.pools.find(pool => pool.pool === poolAssetId)
if (!foundHistoryPool) return null

return calculateEarnings(
foundHistoryPool.assetLiquidityFees,
foundHistoryPool.runeLiquidityFees,
foundUserPool.poolShare,
userPoolData.poolShare,
runeMarketData.price,
assetMarketData.price,
)
},
})

if (!foundUserPool || !asset || !runeAsset) return null
if (!asset || !runeAsset) return null

return (
<Stack mx={listMargin}>
Expand All @@ -148,37 +154,33 @@ const PositionButton = ({ apy, assetId, name, opportunityId }: PositionButtonPro
</Tag>
</Flex>
<Stack spacing={0} alignItems={alignItems}>
<Skeleton isLoaded={!isLoading}>
<Amount.Fiat value={foundUserPool.totalValueFiatUserCurrency} />
</Skeleton>
<Skeleton isLoaded={!isLoading}>
<Amount.Crypto
value={foundUserPool.underlyingAssetAmountCryptoPrecision}
symbol={asset.symbol}
fontSize='sm'
color='text.subtle'
/>
<Amount.Crypto
value={foundUserPool.underlyingRuneAmountCryptoPrecision}
symbol={runeAsset.symbol}
fontSize='sm'
color='text.subtle'
/>
</Skeleton>
<Amount.Fiat value={userPoolData.totalValueFiatUserCurrency} />
<Amount.Crypto
value={userPoolData.underlyingAssetAmountCryptoPrecision}
symbol={asset.symbol}
fontSize='sm'
color='text.subtle'
/>
<Amount.Crypto
value={userPoolData.underlyingRuneAmountCryptoPrecision}
symbol={runeAsset.symbol}
fontSize='sm'
color='text.subtle'
/>
</Stack>
<Stack display={mobileDisplay} spacing={0}>
<Skeleton isLoaded={!isLoading}>
<Skeleton isLoaded={!isEarningsLoading}>
<Amount.Fiat value={earnings?.totalEarningsFiatUserCurrency ?? '0'} />
</Skeleton>
<Skeleton isLoaded={!isLoading}>
<Skeleton isLoaded={!isEarningsLoading}>
<Amount.Crypto
value={earnings?.assetEarnings ?? '0'}
symbol={asset.symbol}
fontSize='sm'
color='text.subtle'
/>
</Skeleton>
<Skeleton isLoaded={!isLoading}>
<Skeleton isLoaded={!isEarningsLoading}>
<Amount.Crypto
value={earnings?.runeEarnings ?? '0'}
symbol={'RUNE'}
Expand All @@ -187,12 +189,12 @@ const PositionButton = ({ apy, assetId, name, opportunityId }: PositionButtonPro
/>
</Skeleton>
</Stack>
<Skeleton isLoaded={!isLoading} display={largeDisplay}>
<Box display={largeDisplay}>
<Amount.Percent
options={{ maximumFractionDigits: 8 }}
value={bn(foundUserPool.poolOwnershipPercentage).div(100).toFixed()}
value={bn(userPoolData.poolOwnershipPercentage).div(100).toFixed()}
/>
</Skeleton>
</Box>
</Button>
</Stack>
)
Expand All @@ -202,24 +204,35 @@ export const YourPositions = () => {
const headerComponent = useMemo(() => <PoolsHeader />, [])
const emptyIcon = useMemo(() => <PoolsIcon />, [])

const { data: parsedPools, isLoading } = usePools()
const { data: parsedPools } = usePools()
const poolAssetIds = useMemo(
() => uniq((parsedPools ?? []).map(pool => pool.assetId)),
[parsedPools],
)
const allUserLpData = useAllUserLpData({ assetIds: poolAssetIds })
// If some are loading, we are loading, but that's not it yet, we also need to check if some are not loaded
const someLoading = useMemo(
() => allUserLpData.some(position => position.isLoading),
[allUserLpData],
)
// If we have some position data, then *some* is loaded, which means we can instantly display the data but
// should still display skeleton for the others that are still loading
const someLoaded = useMemo(
() => allUserLpData.length && allUserLpData.some(query => Boolean(query.isSuccess)),
[allUserLpData],
)

const isEmpty = false
const activePositions = useMemo(() => {
return allUserLpData.filter(query => query.data?.positions.length)
}, [allUserLpData])

const positionRows = useMemo(() => {
if (isLoading) return new Array(2).fill(null).map((_, i) => <Skeleton height={16} key={i} />)
const rows = parsedPools?.map(pool => {
return (
<PositionButton
assetId={pool.assetId}
name={pool.name}
opportunityId={pool.opportunityId}
apy={pool.poolAPY}
key={pool.opportunityId}
/>
)
})
const allLoaded = useMemo(() => {
return allUserLpData.length && allUserLpData.every(query => query.isSuccess)
}, [allUserLpData])

const isEmpty = useMemo(() => allLoaded && !activePositions.length, [allLoaded, activePositions])

const positionRows = useMemo(() => {
if (isEmpty) {
return (
<ResultsEmpty
Expand All @@ -230,8 +243,44 @@ export const YourPositions = () => {
)
}

return rows
}, [emptyIcon, isEmpty, isLoading, parsedPools])
const skeletons = new Array(2).fill(null).map((_, i) => <Skeleton height={16} key={i} />)

const rows = someLoaded
? activePositions.map(position => {
// This should never happen because of isLoading above but just for type safety
if (!position.data) return null
if (!position.data.positions.length) return null

return position.data.positions
.map(userPosition => {
if (!userPosition) return null

const parsedPool = parsedPools?.find(
pool =>
pool.assetId === userPosition.assetId && pool.asymSide === userPosition.asymSide,
)

if (!parsedPool) return null

return (
<PositionButton
accountId={userPosition.accountId}
assetId={userPosition.assetId}
name={parsedPool.name}
opportunityId={userPosition.opportunityId}
apy={parsedPool.poolAPY}
key={userPosition.opportunityId}
userPoolData={userPosition}
/>
)
})
.filter(isSome)
.flat()
})
: []

return rows.concat(someLoading ? skeletons : [])
}, [activePositions, emptyIcon, isEmpty, parsedPools, someLoaded, someLoading])

const renderHeader = useMemo(() => {
if (!isEmpty) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,8 @@ import { SlideTransition } from 'components/SlideTransition'
import { RawText } from 'components/Text'
import { Timeline, TimelineItem } from 'components/Timeline/Timeline'
import { getChainAdapterManager } from 'context/PluginProvider/chainAdapterSingleton'
import type { ConfirmedQuote } from 'lib/utils/thorchain/lp/types'
import { AsymSide, type ConfirmedQuote } from 'lib/utils/thorchain/lp/types'
import { usePools } from 'pages/ThorChainLP/hooks/usePools'
import { AsymSide } from 'pages/ThorChainLP/hooks/useUserLpData'
import { selectAssetById } from 'state/slices/assetsSlice/selectors'
import { useAppSelector } from 'state/store'

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,8 @@ import { bn, bnOrZero, convertPrecision } from 'lib/bignumber/bignumber'
import { isSome } from 'lib/utils'
import { THOR_PRECISION } from 'lib/utils/thorchain/constants'
import { estimateAddThorchainLiquidityPosition } from 'lib/utils/thorchain/lp'
import type { ConfirmedQuote } from 'lib/utils/thorchain/lp/types'
import { AsymSide, type ConfirmedQuote } from 'lib/utils/thorchain/lp/types'
import { usePools } from 'pages/ThorChainLP/hooks/usePools'
import { AsymSide } from 'pages/ThorChainLP/hooks/useUserLpData'
import { selectAssetById, selectAssets, selectMarketDataById } from 'state/slices/selectors'
import { useAppSelector } from 'state/store'

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,8 @@ import { CircularProgress } from 'components/CircularProgress/CircularProgress'
import { Row } from 'components/Row/Row'
import { SlideTransition } from 'components/SlideTransition'
import { RawText } from 'components/Text'
import type { ConfirmedQuote } from 'lib/utils/thorchain/lp/types'
import { AsymSide, type ConfirmedQuote } from 'lib/utils/thorchain/lp/types'
import { usePools } from 'pages/ThorChainLP/hooks/usePools'
import { AsymSide } from 'pages/ThorChainLP/hooks/useUserLpData'
import { selectAssetById } from 'state/slices/selectors'
import { useAppSelector } from 'state/store'

Expand Down
Loading

0 comments on commit ce65093

Please sign in to comment.