Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat/taostats api #1182

Merged
merged 15 commits into from
Oct 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/apps.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,8 @@ jobs:
BASEROW_EXPLORE_AUTH,
BASEROW_CROWDLOANS_AUTH,
LIDO_REWARDS_ADDRESS,
SIMPLESWAP_API_KEY
SIMPLESWAP_API_KEY,
TAOSTATS_API_KEY
environmentVariableVariables: >-
APPLICATION_NAME
environmentVariablePrefix: REACT_APP_
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/qa.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ jobs:
BASEROW_EXPLORE_AUTH,
BASEROW_CROWDLOANS_AUTH,
LIDO_REWARDS_ADDRESS,
SIMPLESWAP_API_KEY
SIMPLESWAP_API_KEY,
TAOSTATS_API_KEY
environmentVariableVariables: >-
APPLICATION_NAME
environmentVariablePrefix: REACT_APP_
Expand Down
2 changes: 2 additions & 0 deletions apps/portal/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,5 @@ REACT_APP_POSTHOG_AUTH_TOKEN=
REACT_APP_BASEROW_EXPLORE_AUTH=
REACT_APP_BASEROW_CROWDLOANS_AUTH=
REACT_APP_LIDO_REWARDS_ADDRESS=
REACT_APP_SIMPLESWAP_API_KEY=
REACT_APP_TAOSTATS_API_KEY=
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { DEFAULT_DELEGATE, type Delegate } from '../../../../domains/staking/sub
import { useAllDelegateInfos } from '../../../../domains/staking/subtensor/hooks/useAllDelegateInfos'
import { useDelegates } from '../../../../domains/staking/subtensor/hooks/useDelegates'
import StakeTargetSelectorDialog from '../../../recipes/StakeTargetSelectorDialog'
import { useDelegatesStats } from '@/domains/staking/subtensor/hooks/useDelegatesStats'
import { useState } from 'react'
import { useRecoilValue_TRANSITION_SUPPORT_UNSTABLE as useRecoilValue } from 'recoil'

Expand All @@ -15,6 +16,7 @@ type DelegateSelectorDialogProps = {
export const DelegateSelectorDialog = (props: DelegateSelectorDialogProps) => {
const delegates = useDelegates()
const allDelegateInfos = useAllDelegateInfos()
const delegatesStats = useDelegatesStats()

const [highlighted, setHighlighted] = useState(delegates[DEFAULT_DELEGATE] ?? Object.values(delegates)[0])
const nativeTokenAmount = useRecoilValue(useNativeTokenAmountState())
Expand All @@ -38,6 +40,9 @@ export const DelegateSelectorDialog = (props: DelegateSelectorDialogProps) => {
: 1,
'Number of stakers': (a, b) =>
parseInt(b.props.count?.toString?.() ?? '0') - parseInt(a.props.count?.toString?.() ?? '0'),
'Estimated APR': (a, b) =>
parseFloat(b.props.estimatedApr?.replace('%', '') ?? '0') -
parseFloat(a.props.estimatedApr?.replace('%', '') ?? '0'),
// 'Estimated return': (a, b) =>
// BigInt(b.props.estimatedReturn ?? 0n) === BigInt(a.props.estimatedReturn ?? 0n)
// ? 0
Expand All @@ -46,34 +51,40 @@ export const DelegateSelectorDialog = (props: DelegateSelectorDialogProps) => {
// : 1,
}}
>
{Object.values(delegates).map(delegate => (
<StakeTargetSelectorDialog.Item
key={delegate.address}
balanceDescription="Total staked with this delegate"
countDescription="Number of delegate stakers"
estimatedAprDescription="Estimated APR"
talismanRecommendedDescription="Talisman top recommended delegate"
rating={3}
selected={delegate.address === props.selected?.address}
highlighted={delegate.address === highlighted?.address}
name={delegate.name}
talismanRecommended={delegate.address === DEFAULT_DELEGATE}
detailUrl={delegate.url}
count={allDelegateInfos[delegate.address]?.nominators?.length ?? 0}
balance={
nativeTokenAmount
.fromPlanckOrUndefined(allDelegateInfos[delegate.address]?.totalDelegated)
.decimalAmount?.toLocaleString() ?? ''
}
balancePlanck={
nativeTokenAmount.fromPlanckOrUndefined(allDelegateInfos[delegate.address]?.totalDelegated).decimalAmount
?.planck
}
// estimatedReturn={allDelegateInfos[delegate.address]?.return_per_1000}
// estimatedApr={allDelegateInfos[delegate.address]?.apr}
onClick={() => setHighlighted(delegate)}
/>
))}
{Object.values(delegates).map(delegate => {
const formattedApr = Number(
delegatesStats.find(stat => stat.hot_key.ss58 === delegate.address)?.apr
).toLocaleString(undefined, { style: 'percent', maximumFractionDigits: 2 })

return (
<StakeTargetSelectorDialog.Item
key={delegate.address}
balanceDescription="Total staked with this delegate"
countDescription="Number of delegate stakers"
estimatedAprDescription="Estimated APR"
estimatedApr={formattedApr}
talismanRecommendedDescription="Talisman top recommended delegate"
rating={3}
selected={delegate.address === props.selected?.address}
highlighted={delegate.address === highlighted?.address}
name={delegate.name}
talismanRecommended={delegate.address === DEFAULT_DELEGATE}
detailUrl={delegate.url}
count={allDelegateInfos[delegate.address]?.nominators?.length ?? 0}
balance={
nativeTokenAmount
.fromPlanckOrUndefined(allDelegateInfos[delegate.address]?.totalDelegated)
.decimalAmount?.toLocaleString() ?? ''
}
balancePlanck={
nativeTokenAmount.fromPlanckOrUndefined(allDelegateInfos[delegate.address]?.totalDelegated).decimalAmount
?.planck
}
// estimatedReturn={allDelegateInfos[delegate.address]?.return_per_1000}
onClick={() => setHighlighted(delegate)}
/>
)
})}
</StakeTargetSelectorDialog>
)
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { type Account } from '../../../../domains/accounts'
import { useChainState, useNativeTokenAmountState } from '../../../../domains/chains'
import { useNativeTokenAmountState } from '../../../../domains/chains'
import { useAddStakeForm } from '../../../../domains/staking/subtensor/hooks/forms'
import { useApr } from '../../../../domains/staking/subtensor/hooks/useApr'
import { useDelegateApr } from '../../../../domains/staking/subtensor/hooks/useApr'
import { useStake } from '../../../../domains/staking/subtensor/hooks/useStake'
import { SubtensorStakingForm } from './SubtensorStakingForm'
import { CircularProgressIndicator } from '@talismn/ui'
Expand Down Expand Up @@ -53,6 +53,7 @@ export const StakeForm = (props: StakeFormProps) => {
estimatedRewards={
<Suspense fallback={<CircularProgressIndicator size="1em" />}>
<EstimatedRewards
delegateHotkey={props.delegate}
amount={(amount.decimalAmount?.planck ?? 0n) + (stake.totalStaked.decimalAmount?.planck ?? 0n)}
/>
</Suspense>
Expand Down Expand Up @@ -84,13 +85,13 @@ export const IncompleteSelectionStakeForm = (props: IncompleteSelectionStakeForm
/>
)

const EstimatedRewards = (props: { amount: bigint }) => {
const EstimatedRewards = (props: { amount: bigint; delegateHotkey: string }) => {
const tokenAmount = useRecoilValue(useNativeTokenAmountState())
const genesisHash = useRecoilValue(useChainState())?.genesisHash
const apr = useApr(genesisHash)
// const apr = useApr()
const delegateApr = useDelegateApr(props.delegateHotkey)
const amount = useMemo(
() => tokenAmount.fromPlanck(new BN(props.amount.toString()).muln(apr).toString()),
[apr, props.amount, tokenAmount]
() => tokenAmount.fromPlanck(new BN(props.amount.toString()).muln(delegateApr).toString()),
[delegateApr, props.amount, tokenAmount]
)

if (amount.decimalAmount === undefined) return null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
substrateApiState,
useTokenAmountFromPlanck,
} from '../../../../domains/common'
import { useAprFormatted } from '../../../../domains/staking/subtensor/hooks/useApr'
import { useHighestAprFormatted } from '../../../../domains/staking/subtensor/hooks/useApr'
import StakeProvider from '../../../recipes/StakeProvider'
import AnimatedFiatNumber from '../../AnimatedFiatNumber'
import ErrorBoundary from '../../ErrorBoundary'
Expand All @@ -20,7 +20,7 @@ import { useMemo } from 'react'
import { Link } from 'react-router-dom'
import { useRecoilValue, waitForAll } from 'recoil'

const Apr = ({ genesisHash }: { genesisHash?: string }) => <>{useAprFormatted(genesisHash)}</>
const Apr = () => <>{useHighestAprFormatted()}</>

const useAvailableBalance = () => {
const apiId = usePolkadotApiId()
Expand Down Expand Up @@ -90,7 +90,7 @@ const StakeProviderItem = () => {
logo={logo}
chain={name}
chainId={chain?.id}
apr={<Apr genesisHash={chain.genesisHash} />}
apr={<Apr />}
type="Delegation"
provider={name}
unbondingPeriod={<UnlockDuration />}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@ import {
type ChainInfo,
} from '../../../../domains/chains'
import { DEFAULT_DELEGATE, MIN_SUBTENSOR_STAKE } from '../../../../domains/staking/subtensor/atoms/delegates'
import { useAprFormatted } from '../../../../domains/staking/subtensor/hooks/useApr'
import { Delegate } from '../../../../domains/staking/subtensor/atoms/delegates'
import { useDelegateAprFormatted } from '../../../../domains/staking/subtensor/hooks/useApr'
import { useDelegates } from '../../../../domains/staking/subtensor/hooks/useDelegates'
import { useTaostatsToken } from '../../../../domains/staking/subtensor/hooks/useTaostatsToken'
import { useTaostatsVolume24hFormatted } from '../../../../domains/staking/subtensor/hooks/useTaostatsVolume24h'
import { useTotalTaoStakedFormatted } from '../../../../domains/staking/subtensor/hooks/useTotalTaoStakedFormatted'
import { Maybe } from '../../../../util/monads'
import { TalismanHandLoader } from '../../../legacy/TalismanHandLoader'
import { useAccountSelector } from '../../AccountSelector'
Expand All @@ -28,18 +28,22 @@ type StakeSideSheetProps = {
onRequestDismiss: () => unknown
}

const StakeSideSheetContent = (props: Omit<StakeSideSheetProps, 'onRequestDismiss'>) => {
type StakeSideSheetContentProps = Omit<StakeSideSheetProps, 'onRequestDismiss'> & {
delegate: Delegate | undefined
setDelegate: React.Dispatch<React.SetStateAction<Delegate | undefined>>
}

const StakeSideSheetContent = (props: StakeSideSheetContentProps) => {
const [searchParams] = useSearchParams()

const chain = useRecoilValue(useChainState())
const delegates = useDelegates()
const [[account], accountSelector] = useAccountSelector(
useRecoilValue(writeableSubstrateAccountsState),
searchParams.get('account') === null
? 0
: accounts => accounts?.find(x => x.address === searchParams.get('account'))
)
const [delegate, setDelegate] = useState(delegates[DEFAULT_DELEGATE] ?? Object.values(delegates)[0])
const { delegate, setDelegate } = props

const [delegateSelectorOpen, setDelegateSelectorOpen] = useState(false)
const [delegateSelectorInTransition, startDelegateSelectorTransition] = useTransition()
Expand Down Expand Up @@ -101,10 +105,12 @@ const StakeSideSheetContent = (props: Omit<StakeSideSheetProps, 'onRequestDismis
}

const StakeSideSheetForChain = (props: StakeSideSheetProps) => {
const genesisHash = useRecoilValue(useChainState())?.genesisHash
const volume24h = useTaostatsVolume24hFormatted(genesisHash)
const rewards = useAprFormatted(genesisHash)
const token = useTaostatsToken(genesisHash)
const delegates = useDelegates()
const [delegate, setDelegate] = useState(delegates[DEFAULT_DELEGATE] ?? Object.values(delegates)[0])
const { nativeToken } = useRecoilValue(useChainState())

const totalStaked = useTotalTaoStakedFormatted()
const delegateApr = useDelegateAprFormatted(delegate?.address ?? DEFAULT_DELEGATE)

return (
<SubtensorStakingSideSheet
Expand All @@ -113,16 +119,16 @@ const StakeSideSheetForChain = (props: StakeSideSheetProps) => {
info={useMemo(
() => [
{
title: '24h Volume',
content: <>{volume24h}</>,
title: 'Total Staked',
content: <>{totalStaked}</>,
},
{ title: 'Estimated APR', content: <>{rewards}</> },
{ title: 'Estimated APR', content: <>{delegateApr}</> },
],
[rewards, volume24h]
[delegateApr, totalStaked]
)}
minimumStake={
<>
{MIN_SUBTENSOR_STAKE} {token}
{MIN_SUBTENSOR_STAKE} {nativeToken?.symbol}
</>
}
>
Expand All @@ -144,7 +150,7 @@ const StakeSideSheetForChain = (props: StakeSideSheetProps) => {
</div>
}
>
<StakeSideSheetContent {...props} />
<StakeSideSheetContent {...props} delegate={delegate!} setDelegate={setDelegate} />
</Suspense>
</ErrorBoundary>
</SubtensorStakingSideSheet>
Expand Down
97 changes: 59 additions & 38 deletions apps/portal/src/domains/staking/subtensor/atoms/taostats.ts
Original file line number Diff line number Diff line change
@@ -1,54 +1,75 @@
import { ValidatorsData } from '../types'
import { delegatesAtom } from './delegates'
import { atom } from 'jotai'
import { atomFamily } from 'jotai/utils'

const TAOSTATS_DATA_URL = 'https://taostats.io/data.json'

// The taostats endpoint has a `network` field, which is the name of the network.
// This field is the only way to tell which network a given set of stats is for.
// This map lets us identify which `network` value to use as an id for each given genesisHash.
const TaostatsNetworkByGenesisHash = new Map([
['0x2f0555cc76fc2840a25a6ea3b9637146806f1f44b090c175ffde2a7e5ab36c03', 'Bittensor'],
])

type Taostats = Array<{
network?: string // 'Bittensor'
token?: string // 'TAO'
price?: string // '365.73'
'24h_change'?: string // '12.69'
'24h_volume'?: string // '66990486.579526'
current_supply?: string // '6923726'
total_supply?: string // '21000000'
delegated_supply?: string // '5744443'
market_cap?: string // '2532203699'
next_halvening?: string // '22 October 2025'
daily_return_per_1000t?: string // '0.0010277758870616'
validating_apy?: string // '18.77'
staking_apy?: string // '17.08'
last_updated?: string // '12 June 2024 16:54:08 GMT'
}>
const TAOSTATS_API_KEY = import.meta.env.REACT_APP_TAOSTATS_API_KEY
const TAOSTATS_API_URL = 'https://api.taostats.io/api/v1'

export const taostatsAtom = atom(async () => {
const fetchTaoStats = async ({ page = 1, limit = 200 }: { page: number; limit: number }): Promise<ValidatorsData> => {
try {
const stats = await (await fetch(TAOSTATS_DATA_URL)).json()
return Array.isArray(stats) ? (stats as Taostats) : []
return await (
await fetch(`${TAOSTATS_API_URL}/validator?page=${page}&limit=${limit}`, {
method: 'GET',
headers: {
Authorization: TAOSTATS_API_KEY,
'Content-Type': 'application/json',
},
})
).json()
} catch (cause) {
console.error('Failed to fetch TAO stats', { cause })
return []
throw new Error('Failed to fetch TAO stats', { cause })
}
}

export const taostatsAtom = atom(async () => {
const stats: ValidatorsData = { count: 0, validators: [] }

let page = 1
while (stats.count === 0 || stats.count > stats.validators.length) {
const taoStats = await fetchTaoStats({ page: page, limit: 200 })
stats.count = taoStats.count
stats.validators.push(...taoStats.validators)
page++
}

return stats
})

export const taostatsByChainAtomFamily = atomFamily((genesisHash: string | undefined) => {
if (!genesisHash) return atom(() => Promise.resolve(undefined))
export const activeTaoDelegatesStatsAtom = atom(async get => {
const taostats = await get(taostatsAtom)
const delegates = await get(delegatesAtom)

const activeDelegatesHotKeys = Object.keys(delegates)

const networkName = TaostatsNetworkByGenesisHash.get(genesisHash)
if (!networkName) return atom(() => Promise.resolve(undefined))
const activeDelegates = taostats.validators.filter(validator =>
activeDelegatesHotKeys.includes(validator.hot_key.ss58)
)

return atom(async get => (await get(taostatsAtom))?.find(stats => stats.network === networkName))
return activeDelegates
})

export const stakingAprByChainAtomFamily = atomFamily((genesisHash: string | undefined) =>
export const highestAprTaoValidatorAtom = atom(async get => {
const activeDelegatesStats = await get(activeTaoDelegatesStatsAtom)
const highestAprValidatorStats = activeDelegatesStats.reduce((acc, validator) => {
if (parseFloat(validator.apr) > parseFloat(acc.apr)) {
acc = validator
}
return acc
})

return highestAprValidatorStats
})

export const taoDelegateStatsAtomFamily = atomFamily((hotKey: string) =>
atom(async get => {
const taostats = await get(taostatsByChainAtomFamily(genesisHash))
return parseFloat(taostats?.staking_apy ?? '0.0') / 100
const activeDelegatesStats = await get(activeTaoDelegatesStatsAtom)
return activeDelegatesStats.find(validator => validator.hot_key.ss58 === hotKey)
})
)

export const taoTotalStakedTaoAtom = atom(async get => {
const { system_total_stake } = await get(highestAprTaoValidatorAtom)

return system_total_stake
})
Loading
Loading