diff --git a/.lycheeignore b/.lycheeignore index ca4aa3b3fa..1a5ddb5b24 100644 --- a/.lycheeignore +++ b/.lycheeignore @@ -37,6 +37,7 @@ https://github.com/webb-tools/webb-dapp/releases/tag/v1.0.10 https://github.com/webb-tools/webb-dapp/releases/tag/v1.0.11 https://github.com/webb-tools/webb-dapp/releases/tag/v1.0.12 https://github.com/webb-tools/webb-dapp/releases/tag/v1.0.13 +https://stats.tangle.tools # Something happened with conventional commits link, temporary disabled to fix the CI https://www.conventionalcommits.org/en/v1.0.0/ diff --git a/apps/bridge-dapp/CHANGELOG.md b/apps/bridge-dapp/CHANGELOG.md index 08b992ad08..54e36dec81 100644 --- a/apps/bridge-dapp/CHANGELOG.md +++ b/apps/bridge-dapp/CHANGELOG.md @@ -40,19 +40,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Relayer filter by environment: https://github.com/webb-tools/webb-dapp/commit/5c2ef97cd7e7788b858c414f4b9546bdcfcfc2d8 -- Relayer fee and refund support: https://github.com/webb-tools/webb-dapp/commit/32ac5b0c7494c7ca746c6e56bda65cbfd692118f -- Added liquidity check on withdrawal: https://github.com/webb-tools/webb-dapp/commit/f9617e364f40b577f0a5bd4d9dc2bc2cea9f3168 -- Added failed transaction monitoring: https://github.com/webb-tools/webb-dapp/commit/f9617e364f40b577f0a5bd4d9dc2bc2cea9f3168 -- Added max fee calculation: https://github.com/webb-tools/webb-dapp/commit/6f3fa726513accda142dac87b03e2a06e7d094c3 +- Relayer filter by environment +- Relayer fee and refund support +- Added liquidity check on withdrawal +- Added failed transaction monitoring +- Added max fee calculation ### Changed -- Updated confirmation cards UI: https://github.com/webb-tools/webb-dapp/commit/094b85dbc469f1c8b2250e8030b9b02dcb30d9b1 +- Updated confirmation cards UI ### Fixed -- Fixed balance calculation: https://github.com/webb-tools/webb-dapp/commit/dece224d7fa739a7b9a02ee3397c9591330e9e9b +- Fixed balance calculation ## [0.0.3] - 2023-04-25 diff --git a/apps/tangle-dapp/app/blueprints/[name]/OperatorsTable.tsx b/apps/tangle-dapp/app/blueprints/[name]/OperatorsTable.tsx index ffbd8dbdd1..baa5f9e2a4 100644 --- a/apps/tangle-dapp/app/blueprints/[name]/OperatorsTable.tsx +++ b/apps/tangle-dapp/app/blueprints/[name]/OperatorsTable.tsx @@ -23,7 +23,11 @@ const OperatorsTable: FC = () => { restakersCount: o.restakersCount, concentrationPercentage: o.concentration, tvlInUsd: o.liquidity.usdValue, - vaultTokens: o.vaults, + vaultTokens: o.vaults.map((v) => ({ + name: v, + symbol: v, + amount: 0, + })), }))} /> diff --git a/apps/tangle-dapp/app/blueprints/[name]/VaultAssetsTable.tsx b/apps/tangle-dapp/app/blueprints/[name]/VaultAssetsTable.tsx index 257bfa4fff..10a39d594b 100644 --- a/apps/tangle-dapp/app/blueprints/[name]/VaultAssetsTable.tsx +++ b/apps/tangle-dapp/app/blueprints/[name]/VaultAssetsTable.tsx @@ -16,17 +16,7 @@ const VaultAssetsTable: FC = ({ }) => { const data = useVaultAssets(LSTTokenIcon); - return ( - ({ - id: d.id, - selfStake: d.myStake, - symbol: d.symbol, - tvl: d.tvl, - }))} - /> - ); + return ; }; export default VaultAssetsTable; diff --git a/apps/tangle-dapp/app/blueprints/[name]/useOperators.ts b/apps/tangle-dapp/app/blueprints/[name]/useOperators.ts index 830e58fd69..324b2904db 100644 --- a/apps/tangle-dapp/app/blueprints/[name]/useOperators.ts +++ b/apps/tangle-dapp/app/blueprints/[name]/useOperators.ts @@ -6,7 +6,7 @@ import { LiquidStakingToken } from '../../../types/liquidStaking'; export default function useOperators(): Operator[] { return [ { - address: 'tgDRbUxr3dV3j5pCW7DrpmswiABCMTN2NxioeDfA9TXLw6X1u', + address: 'tgC2pxrFu34VuBNGsz3yes6PYnE8cf6VQ5JroY96NTUW5cmU2', identityName: 'PIKACHU.COM', restakersCount: 43, concentration: 50.123, @@ -21,7 +21,7 @@ export default function useOperators(): Operator[] { ], }, { - address: 'tgDRbUxr3dV3j5pCW7DrpmswiABCMTN2NxioeDfA9TXLw6X1u', + address: 'tgCFwkpaXGNNfaFQ4dKDCrGkARQztJp82ekxwRJav9MhMiqD1', identityName: 'CHARIZARD.COM', restakersCount: 24, concentration: 60.89, @@ -52,7 +52,7 @@ export default function useOperators(): Operator[] { ], }, { - address: 'tgDRbUxr3dV3j5pCW7DrpmswiABCMTN2NxioeDfA9TXLw6X1u', + address: 'tgCZuFEMk6yFqTzjSs9sZrdSHAWUXRuneA2e1TRxE8GcLQyfS', identityName: 'GENGAR.COM', restakersCount: 12, concentration: 55, diff --git a/apps/tangle-dapp/app/blueprints/[name]/useVaultAssets.tsx b/apps/tangle-dapp/app/blueprints/[name]/useVaultAssets.tsx index 9ba7a53acc..4d411d0cb9 100644 --- a/apps/tangle-dapp/app/blueprints/[name]/useVaultAssets.tsx +++ b/apps/tangle-dapp/app/blueprints/[name]/useVaultAssets.tsx @@ -5,32 +5,37 @@ export default function useVaultAssets(_: string) { { id: '31234', symbol: 'tgDOT_A', + decimals: 18, tvl: 5588.23, - myStake: 10.12, + selfStake: BigInt('1012000000000000000000'), }, { id: '31235', symbol: 'tgDOT_B', + decimals: 18, tvl: 2044.12, - myStake: 0, + selfStake: BigInt(0), }, { id: '31236', symbol: 'tgDOT_C', + decimals: 18, tvl: 123.12, - myStake: 16, + selfStake: BigInt('16000000000000000000'), }, { id: '31237', symbol: 'tgDOT_D', + decimals: 18, tvl: 6938.87, - myStake: 100, + selfStake: BigInt('100000000000000000000'), }, { id: '31238', symbol: 'tgDOT_E', + decimals: 18, tvl: 0, - myStake: 0, + selfStake: BigInt(0), }, ]; } diff --git a/apps/tangle-dapp/app/restake/OperatorsTable.tsx b/apps/tangle-dapp/app/restake/OperatorsTable.tsx index e10c7a5fd5..8a06c18b62 100644 --- a/apps/tangle-dapp/app/restake/OperatorsTable.tsx +++ b/apps/tangle-dapp/app/restake/OperatorsTable.tsx @@ -2,21 +2,31 @@ import { Search } from '@webb-tools/icons/Search'; import { Input } from '@webb-tools/webb-ui-components/components/Input'; -import { ComponentProps, useMemo, useState } from 'react'; +import { type ComponentProps, type FC, useMemo, useState } from 'react'; +import { formatUnits } from 'viem'; import OperatorsTableUI from '../../components/tables/Operators'; import { useRestakeContext } from '../../context/RestakeContext'; -import useRestakeOperatorMap from '../../data/restake/useRestakeOperatorMap'; import useIdentities from '../../data/useIdentities'; +import type { OperatorMap } from '../../types/restake'; type OperatorUI = NonNullable< ComponentProps['data'] >[number]; -const OperatorsTable = () => { +type Props = { + operatorConcentration?: Record; + operatorMap: OperatorMap; + operatorTVL?: Record; +}; + +const OperatorsTable: FC = ({ + operatorConcentration, + operatorMap, + operatorTVL, +}) => { const [globalFilter, setGlobalFilter] = useState(''); - const { operatorMap } = useRestakeOperatorMap(); const { assetMap } = useRestakeContext(); const { result: identities } = useIdentities( @@ -26,24 +36,38 @@ const OperatorsTable = () => { const operators = useMemo( () => Object.entries(operatorMap).map( - ([address, { delegationCount, delegations }]) => { - const vaultTokens = delegations - .map((delegation) => assetMap[delegation.assetId]?.symbol) - .filter(Boolean); + ([address, { delegations }]) => { + const vaultAssets = delegations + .map((delegation) => ({ + asset: assetMap[delegation.assetId], + amount: delegation.amount, + })) + .filter((vaultAsset) => Boolean(vaultAsset.asset)); + + const restakerSet = delegations.reduce((restakerSet, delegation) => { + restakerSet.add(delegation.delegatorAccountId); + return restakerSet; + }, new Set()); + + const tvlInUsd = operatorTVL?.[address] ?? null; + const concentrationPercentage = + operatorConcentration?.[address] ?? null; return { address, - // TODO: Calculate concentration percentage - concentrationPercentage: 0, + concentrationPercentage, identityName: identities[address]?.name ?? '', - restakersCount: delegationCount, - // TODO: Calculate tvl in USD - tvlInUsd: 0, - vaultTokens, + restakersCount: restakerSet.size, + tvlInUsd, + vaultTokens: vaultAssets.map(({ asset, amount }) => ({ + amount: +formatUnits(amount, asset.decimals), + name: asset.name, + symbol: asset.symbol, + })), }; }, ), - [assetMap, identities, operatorMap], + [assetMap, identities, operatorConcentration, operatorMap, operatorTVL], ); return ( @@ -52,7 +76,7 @@ const OperatorsTable = () => { id="search-validators" rightIcon={} placeholder="Search identity or address" - className="w-1/3 mb-4 ml-auto -mt-[54px]" + className="w-1/3 mb-1.5 ml-auto -mt-[54px]" isControlled debounceTime={500} value={globalFilter} diff --git a/apps/tangle-dapp/app/restake/TableTabs.tsx b/apps/tangle-dapp/app/restake/TableTabs.tsx index 7719d8987b..cdae1b1c1c 100644 --- a/apps/tangle-dapp/app/restake/TableTabs.tsx +++ b/apps/tangle-dapp/app/restake/TableTabs.tsx @@ -1,12 +1,15 @@ 'use client'; +import { ZERO_BIG_INT } from '@webb-tools/dapp-config/constants'; import { TableAndChartTabs } from '@webb-tools/webb-ui-components/components/TableAndChartTabs'; import { TabContent } from '@webb-tools/webb-ui-components/components/Tabs/TabContent'; -import { type ComponentProps, useMemo } from 'react'; +import { type ComponentProps, type FC, useMemo } from 'react'; import VaultAssetsTable from '../../components/tables/VaultAssets'; import VaultsTable from '../../components/tables/Vaults'; import { useRestakeContext } from '../../context/RestakeContext'; +import useRestakeRewardConfig from '../../data/restake/useRestakeRewardConfig'; +import type { DelegatorInfo, OperatorMap } from '../../types/restake'; import OperatorsTable from './OperatorsTable'; const RESTAKE_VAULTS_TAB = 'Restake Vaults'; @@ -19,9 +22,27 @@ type VaultAssetUI = NonNullable< ComponentProps['data'] >[number]; -const TableTabs = () => { +type Props = { + delegatorInfo: DelegatorInfo | null; + delegatorTVL?: Record; + operatorConcentration?: Record; + operatorMap: OperatorMap; + operatorTVL?: Record; + vaultTVL?: Record; +}; + +const TableTabs: FC = ({ + delegatorInfo, + delegatorTVL, + operatorConcentration, + operatorMap, + operatorTVL, + vaultTVL, +}) => { const { assetMap } = useRestakeContext(); + const { rewardConfig } = useRestakeRewardConfig(); + // Recalculate vaults (pools) data from assetMap const vaults = useMemo(() => { const vaults: Record = {}; @@ -30,17 +51,18 @@ const TableTabs = () => { if (poolId === null) continue; if (vaults[poolId] === undefined) { + const apyPercentage = rewardConfig.configs[poolId]?.apy ?? null; + const tvlInUsd = vaultTVL?.[poolId] ?? null; + vaults[poolId] = { id: poolId, - // TODO: Calculate APY - apyPercentage: 0, + apyPercentage, // TODO: Find out a proper way to get the pool name, now it's the first token name name: name, // TODO: Find out a proper way to get the pool symbol, now it's the first token symbol representToken: symbol, tokensCount: 1, - // TODO: Calculate tvl in USD - tvlInUsd: 0, + tvlInUsd, }; } else { vaults[poolId].tokensCount += 1; @@ -48,7 +70,25 @@ const TableTabs = () => { } return vaults; - }, [assetMap]); + }, [assetMap, rewardConfig.configs, vaultTVL]); + + const delegatorTotalRestakedAssets = useMemo(() => { + if (!delegatorInfo?.delegations) { + return {}; + } + + return delegatorInfo.delegations.reduce>( + (acc, { amountBonded, assetId }) => { + if (acc[assetId] === undefined) { + acc[assetId] = amountBonded; + } else { + acc[assetId] += amountBonded; + } + return acc; + }, + {}, + ); + }, [delegatorInfo?.delegations]); const tableProps = useMemo['tableProps']>( () => ({ @@ -60,17 +100,20 @@ const TableTabs = () => { const poolId = row.original.id; const vaultAssets = Object.values(assetMap) .filter((asset) => asset.poolId === poolId) - .map( - (asset) => - ({ - id: asset.id, - symbol: asset.symbol, - // TODO: Calculate tvl - tvl: 0, - // TODO: Calculate self stake - selfStake: 0, - }) satisfies VaultAssetUI, - ); + .map((asset) => { + const selfStake = + delegatorTotalRestakedAssets[asset.id] ?? ZERO_BIG_INT; + + const tvl = delegatorTVL?.[asset.id] ?? null; + + return { + id: asset.id, + symbol: asset.symbol, + decimals: asset.decimals, + tvl, + selfStake, + } satisfies VaultAssetUI; + }); return (
@@ -82,7 +125,7 @@ const TableTabs = () => { ); }, }), - [assetMap], + [assetMap, delegatorTVL, delegatorTotalRestakedAssets], ); return ( @@ -95,7 +138,11 @@ const TableTabs = () => { - + ); diff --git a/apps/tangle-dapp/app/restake/deposit/DepositForm.tsx b/apps/tangle-dapp/app/restake/deposit/DepositForm.tsx index 1b431a1952..42ce62e3a0 100644 --- a/apps/tangle-dapp/app/restake/deposit/DepositForm.tsx +++ b/apps/tangle-dapp/app/restake/deposit/DepositForm.tsx @@ -1,5 +1,6 @@ 'use client'; +import { ZERO_BIG_INT } from '@webb-tools/dapp-config/constants'; import isDefined from '@webb-tools/dapp-types/utils/isDefined'; import { ArrowRight } from '@webb-tools/icons/ArrowRight'; import { @@ -8,6 +9,7 @@ import { } from '@webb-tools/webb-ui-components/components/ListCard/types'; import { Modal } from '@webb-tools/webb-ui-components/components/Modal'; import { useModal } from '@webb-tools/webb-ui-components/hooks/useModal'; +import { useQueryState } from 'nuqs'; import { type ComponentProps, useCallback, @@ -33,6 +35,7 @@ import ViewTxOnExplorer from '../../../data/restake/ViewTxOnExplorer'; import useIdentities from '../../../data/useIdentities'; import useActiveTypedChainId from '../../../hooks/useActiveTypedChainId'; import { useRpcSubscription } from '../../../hooks/usePolkadotApi'; +import { QueryParamKey } from '../../../types'; import { DepositFormFields } from '../../../types/restake'; import AssetList from '../AssetList'; import AvatarWithText from '../AvatarWithText'; @@ -73,6 +76,10 @@ const DepositForm = ({ ...props }: DepositFormProps) => { }, }); + const [poolIdParam, setPoolIdParam] = useQueryState( + QueryParamKey.RESTAKE_VAULT, + ); + const { assetMap, assetWithBalances } = useRestakeContext(); const { operatorMap } = useRestakeOperatorMap(); const { result: operatorIdentities } = useIdentities( @@ -107,6 +114,38 @@ const DepositForm = ({ ...props }: DepositFormProps) => { resetField('amount'); }, [activeTypedChainId, resetField]); + useEffect(() => { + if (!poolIdParam) return; + + const defaultAsset = assetWithBalances + .filter((asset) => asset.metadata.poolId === poolIdParam) + .sort((a, b) => { + const aBalance = a.balance?.balance ?? ZERO_BIG_INT; + const bBalance = b.balance?.balance ?? ZERO_BIG_INT; + + if (aBalance === bBalance) return 0; + + return aBalance > bBalance ? -1 : 1; + }) + // Find the first asset with balance + .find( + (asset) => + asset.balance?.balance && asset.balance.balance > ZERO_BIG_INT, + ); + + if (!defaultAsset?.balance?.balance) return; + + // Select the first asset in the pool by default + setValue('depositAssetId', defaultAsset.assetId); + setValue( + 'amount', + formatUnits(defaultAsset.balance.balance, defaultAsset.metadata.decimals), + ); + + // Remove the param to prevent reuse after initial load + setPoolIdParam(null); + }, [assetWithBalances, poolIdParam, setPoolIdParam, setValue]); + const sourceTypedChainId = watch('sourceTypedChainId'); // Subscribe to sourceTypedChainId and update customRpc diff --git a/apps/tangle-dapp/app/restake/page.tsx b/apps/tangle-dapp/app/restake/page.tsx index 5c100f58dd..c6dc89852f 100644 --- a/apps/tangle-dapp/app/restake/page.tsx +++ b/apps/tangle-dapp/app/restake/page.tsx @@ -1,9 +1,15 @@ +'use client'; + import Button from '@webb-tools/webb-ui-components/components/buttons/Button'; import { Typography } from '@webb-tools/webb-ui-components/typography/Typography'; import { twMerge } from 'tailwind-merge'; import GlassCard from '../../components/GlassCard/GlassCard'; import StatItem from '../../components/StatItem'; +import useRestakeDelegatorInfo from '../../data/restake/useRestakeDelegatorInfo'; +import useRestakeOperatorMap from '../../data/restake/useRestakeOperatorMap'; +import useRestakeTVL from '../../data/restake/useRestakeTVL'; +import getTVLToDisplay from '../../utils/getTVLToDisplay'; import TableTabs from './TableTabs'; export const dynamic = 'force-static'; @@ -22,6 +28,18 @@ const CONTENT = { const minHeightClsx = 'min-h-[233px]'; export default function RestakePage() { + const { delegatorInfo } = useRestakeDelegatorInfo(); + const { operatorMap } = useRestakeOperatorMap(); + + const { + delegatorTVL, + operatorConcentration, + operatorTVL, + poolTVL, + totalDelegatorTVL, + totalNetworkTVL, + } = useRestakeTVL(operatorMap, delegatorInfo); + return (
@@ -41,10 +59,15 @@ export default function RestakePage() {
- {/* TODO: Calculate these values */} - + - +
@@ -75,7 +98,14 @@ export default function RestakePage() {
- +
); } diff --git a/apps/tangle-dapp/app/restake/stake/page.tsx b/apps/tangle-dapp/app/restake/stake/page.tsx index a47c2504bc..bf8f405a3d 100644 --- a/apps/tangle-dapp/app/restake/stake/page.tsx +++ b/apps/tangle-dapp/app/restake/stake/page.tsx @@ -12,6 +12,7 @@ import { Typography } from '@webb-tools/webb-ui-components/typography/Typography import entries from 'lodash/entries'; import keys from 'lodash/keys'; import Link from 'next/link'; +import { useQueryState } from 'nuqs'; import { useCallback, useEffect, useMemo } from 'react'; import { SubmitHandler, useForm } from 'react-hook-form'; import { formatUnits, parseUnits } from 'viem'; @@ -31,7 +32,7 @@ import ViewTxOnExplorer from '../../../data/restake/ViewTxOnExplorer'; import useIdentities from '../../../data/useIdentities'; import useActiveTypedChainId from '../../../hooks/useActiveTypedChainId'; import { useRpcSubscription } from '../../../hooks/usePolkadotApi'; -import { PagePath } from '../../../types'; +import { PagePath, QueryParamKey } from '../../../types'; import type { DelegationFormFields } from '../../../types/restake'; import AssetList from '../AssetList'; import AvatarWithText from '../AvatarWithText'; @@ -58,6 +59,10 @@ export default function Page() { mode: 'onBlur', }); + const [operatorParam, setOperatorParam] = useQueryState( + QueryParamKey.RESTAKE_OPERATOR, + ); + const setValue = useCallback( (...params: Parameters) => { setFormValue(params[0], params[1], { @@ -109,10 +114,16 @@ export default function Page() { } }, [defaultAssetId, setValue]); - // Reset form when active chain changes useEffect(() => { - reset(); - }, [activeTypedChainId, reset]); + if (!operatorParam) return; + + if (!operatorMap[operatorParam]) return; + + setFormValue('operatorAccountId', operatorParam); + + // Remove the param to prevent reuse after initial load + setOperatorParam(null); + }, [operatorMap, operatorParam, setFormValue, setOperatorParam]); const { status: isChainModalOpen, diff --git a/apps/tangle-dapp/components/Breadcrumbs/utils.tsx b/apps/tangle-dapp/components/Breadcrumbs/utils.tsx index 530037f379..484c1af8b1 100644 --- a/apps/tangle-dapp/components/Breadcrumbs/utils.tsx +++ b/apps/tangle-dapp/components/Breadcrumbs/utils.tsx @@ -25,7 +25,7 @@ const BREADCRUMB_ICONS: Record JSX.Element> = { [PagePath.SERVICES]: GridFillIcon, [PagePath.RESTAKE]: TokenSwapFill, [PagePath.RESTAKE_DEPOSIT]: TokenSwapFill, - [PagePath.RESTAKE_DELEGATE]: TokenSwapFill, + [PagePath.RESTAKE_STAKE]: TokenSwapFill, [PagePath.BRIDGE]: ShuffleLine, [PagePath.LIQUID_STAKING]: WaterDropletIcon, [PagePath.LIQUID_STAKING_OVERVIEW]: WaterDropletIcon, diff --git a/apps/tangle-dapp/components/tables/Operators/VaultsDropdown.tsx b/apps/tangle-dapp/components/tables/Operators/VaultsDropdown.tsx new file mode 100644 index 0000000000..611a6b103c --- /dev/null +++ b/apps/tangle-dapp/components/tables/Operators/VaultsDropdown.tsx @@ -0,0 +1,73 @@ +import { + createColumnHelper, + getCoreRowModel, + useReactTable, +} from '@tanstack/react-table'; +import { + Dropdown, + DropdownBasicButton, + DropdownBody, +} from '@webb-tools/webb-ui-components/components/Dropdown'; +import { Table } from '@webb-tools/webb-ui-components/components/Table'; +import { Typography } from '@webb-tools/webb-ui-components/typography/Typography'; +import { getRoundedAmountString } from '@webb-tools/webb-ui-components/utils/getRoundedAmountString'; +import cx from 'classnames'; +import { FC } from 'react'; + +import LsTokenIcon from '../../LsTokenIcon'; +import type { VaultToken } from './types'; + +const columnHelper = createColumnHelper(); + +const columns = [ + columnHelper.accessor('name', { + header: () => Token, + cell: (props) => ( +
+ + + {props.getValue()} +
+ ), + }), + columnHelper.accessor('amount', { + header: () => ( + + Amount + + ), + cell: (props) => ( + + {getRoundedAmountString(props.getValue())} + + ), + }), +]; + +const VaultsDropdown: FC<{ vaultTokens: VaultToken[] }> = ({ vaultTokens }) => { + const table = useReactTable({ + columns, + data: vaultTokens, + getCoreRowModel: getCoreRowModel(), + }); + + return ( + + + {vaultTokens.map(({ name, symbol }, idx) => ( + + ))} + + + + + + + ); +}; + +export default VaultsDropdown; diff --git a/apps/tangle-dapp/components/tables/Operators/index.tsx b/apps/tangle-dapp/components/tables/Operators/index.tsx index d0094e011f..018ab559c7 100644 --- a/apps/tangle-dapp/components/tables/Operators/index.tsx +++ b/apps/tangle-dapp/components/tables/Operators/index.tsx @@ -11,7 +11,6 @@ import { import { Avatar, Button, - getRoundedAmountString, shortenString, Table, Typography, @@ -20,12 +19,15 @@ import Link from 'next/link'; import { FC } from 'react'; import { twMerge } from 'tailwind-merge'; +import { EMPTY_VALUE_PLACEHOLDER } from '../../../constants'; +import { PagePath, QueryParamKey } from '../../../types'; +import getTVLToDisplay from '../../../utils/getTVLToDisplay'; import { getSortAddressOrIdentityFnc } from '../../../utils/table'; -import LsTokenIcon from '../../LsTokenIcon'; import { TableStatus } from '../../TableStatus'; import { sharedTableStatusClxs } from '../shared'; import TableCellWrapper from '../TableCellWrapper'; import type { OperatorData, Props } from './types'; +import VaultsDropdown from './VaultsDropdown'; const columnHelper = createColumnHelper(); @@ -79,17 +81,23 @@ const columns = [ }), columnHelper.accessor('concentrationPercentage', { header: () => 'Concentration', - cell: (props) => ( - - - {props.getValue().toFixed(2)}% - - - ), + cell: (props) => { + const value = props.getValue(); + + return ( + + + {typeof value !== 'number' + ? EMPTY_VALUE_PLACEHOLDER + : `${value.toFixed(2)}%`} + + + ); + }, }), columnHelper.accessor('tvlInUsd', { header: () => 'TVL', @@ -99,7 +107,7 @@ const columns = [ variant="body1" className="text-mono-120 dark:text-mono-100" > - ${getRoundedAmountString(props.getValue())} + {getTVLToDisplay(props.getValue())} ), @@ -112,14 +120,7 @@ const columns = [ return ( {tokensList.length > 0 ? ( -
- {props - .getValue() - .sort() // sort alphabetically - .map((vault, index) => ( - - ))} -
+ ) : ( No vaults )} @@ -131,12 +132,13 @@ const columns = [ columnHelper.display({ id: 'actions', header: () => null, - cell: () => ( + cell: (props) => (
{/* TODO: add proper href */} @@ -106,7 +115,7 @@ const columns = [
diff --git a/apps/tangle-dapp/components/tables/Vaults/types.ts b/apps/tangle-dapp/components/tables/Vaults/types.ts index f85386aab5..2ff75ad3e2 100644 --- a/apps/tangle-dapp/components/tables/Vaults/types.ts +++ b/apps/tangle-dapp/components/tables/Vaults/types.ts @@ -6,9 +6,9 @@ import type { TableStatus } from '../../TableStatus'; export type VaultData = { id: string; name: string; - apyPercentage: number; + apyPercentage: number | null; tokensCount: number; - tvlInUsd: number; + tvlInUsd: number | null; representToken: string; }; diff --git a/apps/tangle-dapp/data/restake/useRestakeAssetMap.ts b/apps/tangle-dapp/data/restake/useRestakeAssetMap.ts index db0599e3dd..bf576ccd56 100644 --- a/apps/tangle-dapp/data/restake/useRestakeAssetMap.ts +++ b/apps/tangle-dapp/data/restake/useRestakeAssetMap.ts @@ -14,6 +14,7 @@ import usePolkadotApi from '../../hooks/usePolkadotApi'; import type { AssetMap, AssetMetadata } from '../../types/restake'; import hasAssetsPallet from '../../utils/hasAssetsPallet'; import filterNativeAsset from '../../utils/restaking/filterNativeAsset'; +import { fetchSingleTokenPrice, fetchTokenPrices } from '../tokenPrice'; import useRestakeAssetIds from './useRestakeAssetIds'; /** @@ -114,6 +115,15 @@ const mapAssetDetails = async ( api.queryMulti[]>(assetPoolIdQueries), ] as const); + // Get list of token symbols for fetching the prices + const tokenSymbols = nonNativeAssetIds.map((_, idx) => { + const metadata = assetMetadatas[idx]; + return hexToString(metadata.symbol.toHex()); + }); + + // Fetch the prices of the tokens + const tokenPrices = await fetchTokenPrices(tokenSymbols); + const initialAssetMap: AssetMap = hasNative ? await (async () => { const nativeAsset = await getNativeAsset(nativeCurrentcy, api); @@ -153,6 +163,8 @@ const mapAssetDetails = async ( decimals: metadata.decimals.toNumber(), status: detail.status.type, poolId: u128ToPoolId(poolId), + priceInUsd: + typeof tokenPrices[idx] === 'number' ? tokenPrices[idx] : null, }, } satisfies AssetMap); }, initialAssetMap); @@ -176,10 +188,13 @@ const getNativeAsset = async ( assetId, ); + const priceInUsd = await fetchSingleTokenPrice(nativeCurrency.symbol); + return { ...nativeCurrency, id: assetId, status: 'Live', poolId: u128ToPoolId(poolId), + priceInUsd: typeof priceInUsd === 'number' ? priceInUsd : null, } satisfies AssetMetadata; }; diff --git a/apps/tangle-dapp/data/restake/useRestakeOperatorMap.ts b/apps/tangle-dapp/data/restake/useRestakeOperatorMap.ts index 0507e7e177..a3104648b6 100644 --- a/apps/tangle-dapp/data/restake/useRestakeOperatorMap.ts +++ b/apps/tangle-dapp/data/restake/useRestakeOperatorMap.ts @@ -4,9 +4,10 @@ import type { PalletMultiAssetDelegationOperatorOperatorBondLessRequest, PalletMultiAssetDelegationOperatorOperatorStatus, } from '@polkadot/types/lookup'; -import { useObservable, useObservableState } from 'observable-hooks'; +import isDefined from '@webb-tools/dapp-types/utils/isDefined'; +import { useObservableState } from 'observable-hooks'; import { useMemo } from 'react'; -import { map, type Observable, of, switchMap } from 'rxjs'; +import { map, type Observable, of } from 'rxjs'; import usePolkadotApi from '../../hooks/usePolkadotApi'; import type { OperatorMap, OperatorMetadata } from '../../types/restake'; @@ -34,49 +35,38 @@ interface PalletMultiAssetDelegationOperatorOperatorMetadata extends Struct { export default function useRestakeOperatorMap(): UseRestakeOperatorMapReturnType { const { apiRx } = usePolkadotApi(); - const entries$ = useMemo( - () => - apiRx.query.multiAssetDelegation?.operators !== undefined - ? apiRx.query.multiAssetDelegation.operators.entries< - Option - >() - : of([]), - [apiRx.query.multiAssetDelegation?.operators], - ); - - const operatorMap$ = useObservable( - (input$) => - input$.pipe( - switchMap(([entries$]) => { - return entries$.pipe( - map((entries) => - entries.reduce( - (operatorsMap, [accountStorage, operatorMetadata]) => { - if (operatorMetadata.isNone) return operatorsMap; - - const accountId = accountStorage.args[0]; - const operator = operatorMetadata.unwrap(); - - const operatorMetadataPrimitive = { - stake: operator.stake.toBigInt(), - delegationCount: operator.delegationCount.toNumber(), - bondLessRequest: toPrimitiveRequest(operator.request), - delegations: toPrimitiveDelegations(operator.delegations), - status: toPrimitiveStatus(operator.status), - } satisfies OperatorMetadata; - - return Object.assign(operatorsMap, { - [accountId.toString()]: operatorMetadataPrimitive, - } satisfies OperatorMap); - }, - {} as OperatorMap, - ), - ), + const operatorMap$ = useMemo(() => { + if (!isDefined(apiRx.query?.multiAssetDelegation?.operators?.entries)) + return of({}); + + return apiRx.query.multiAssetDelegation.operators + .entries>() + .pipe( + map((entries) => { + return entries.reduce( + (operatorsMap, [accountStorage, operatorMetadata]) => { + if (operatorMetadata.isNone) return operatorsMap; + + const accountId = accountStorage.args[0]; + const operator = operatorMetadata.unwrap(); + + const operatorMetadataPrimitive = { + stake: operator.stake.toBigInt(), + delegationCount: operator.delegationCount.toNumber(), + bondLessRequest: toPrimitiveRequest(operator.request), + delegations: toPrimitiveDelegations(operator.delegations), + status: toPrimitiveStatus(operator.status), + } satisfies OperatorMetadata; + + return Object.assign(operatorsMap, { + [accountId.toString()]: operatorMetadataPrimitive, + } satisfies OperatorMap); + }, + {} as OperatorMap, ); }), - ), - [entries$], - ); + ); + }, [apiRx.query.multiAssetDelegation?.operators]); const operatorMap = useObservableState(operatorMap$, {}); diff --git a/apps/tangle-dapp/data/restake/useRestakeTVL.ts b/apps/tangle-dapp/data/restake/useRestakeTVL.ts new file mode 100644 index 0000000000..29c21507b3 --- /dev/null +++ b/apps/tangle-dapp/data/restake/useRestakeTVL.ts @@ -0,0 +1,141 @@ +import { useObservable, useObservableState } from 'observable-hooks'; +import { of, switchMap } from 'rxjs'; + +import { useRestakeContext } from '../../context/RestakeContext'; +import type { DelegatorInfo, OperatorMap } from '../../types/restake'; +import safeFormatUnits from '../../utils/safeFormatUnits'; + +export default function useRestakeTVL( + operatorMap: OperatorMap, + delegatorInfo: DelegatorInfo | null, +) { + const { assetMap } = useRestakeContext(); + + const tvl$ = useObservable( + (input$) => + input$.pipe( + switchMap(([operatorMap, delegatorInfo, assetMap]) => { + const { operatorTVL, poolTVL } = Object.entries(operatorMap).reduce( + (acc, [operatorId, operatorData]) => { + const operatorTVL = operatorData.delegations.reduce( + (sum, delegation) => { + const asset = assetMap[delegation.assetId]; + const assetPrice = asset?.priceInUsd ?? null; + + if (typeof assetPrice !== 'number') { + return sum; + } + + const result = safeFormatUnits( + delegation.amount, + asset.decimals, + ); + + if (!result.sucess) { + return sum; + } + + const formattedAmount = Number(result.value); + + // Calculate operator TVL + sum += formattedAmount * assetPrice; + + // Calculate pool TVL + const poolId = asset.poolId; + if (poolId !== null) { + acc.poolTVL[poolId] = + (acc.poolTVL[poolId] || 0) + formattedAmount * assetPrice; + } + + return sum; + }, + 0, + ); + + acc.operatorTVL[operatorId] = operatorTVL; + return acc; + }, + { + operatorTVL: {} as Record, + poolTVL: {} as Record, + }, + ); + + const totalNetworkTVL = Object.values(poolTVL).reduce( + (sum, tvl) => sum + tvl, + 0, + ); + + const operatorConcentration = Object.entries(operatorTVL).reduce( + (acc, [operatorId, operatorTVL]) => { + acc[operatorId] = + totalNetworkTVL > 0 + ? (operatorTVL / totalNetworkTVL) * 100 + : null; + return acc; + }, + {} as Record, + ); + + const delegatorTVL = + delegatorInfo?.delegations.reduce( + (acc, delegation) => { + const assetData = assetMap[delegation.assetId]; + if (assetData === undefined) { + return acc; + } + + const assetPrice = assetData.priceInUsd ?? null; + + if (typeof assetPrice !== 'number') { + return acc; + } + + const result = safeFormatUnits( + delegation.amountBonded, + assetData.decimals, + ); + + if (!result.sucess) { + return acc; + } + + const formattedAmount = Number(result.value); + + acc[delegation.assetId] = + (acc[delegation.assetId] || 0) + formattedAmount * assetPrice; + + return acc; + }, + {} as Record, + ) || {}; + + const totalDelegatorTVL = Object.values(delegatorTVL).reduce( + (sum, tvl) => sum + tvl, + 0, + ); + + return of({ + delegatorTVL, + operatorConcentration, + operatorTVL, + poolTVL, + totalDelegatorTVL, + totalNetworkTVL, + }); + }), + ), + [operatorMap, delegatorInfo, assetMap], + ); + + const tvl = useObservableState(tvl$, { + delegatorTVL: {}, + operatorConcentration: {}, + operatorTVL: {}, + poolTVL: {}, + totalDelegatorTVL: 0, + totalNetworkTVL: 0, + }); + + return tvl; +} diff --git a/apps/tangle-dapp/data/tokenPrice/fetchers/coinbase.ts b/apps/tangle-dapp/data/tokenPrice/fetchers/coinbase.ts new file mode 100644 index 0000000000..45cdc31451 --- /dev/null +++ b/apps/tangle-dapp/data/tokenPrice/fetchers/coinbase.ts @@ -0,0 +1,37 @@ +import axios from 'axios'; +import z from 'zod'; + +import ensureError from '../../../utils/ensureError'; +import type { TokenPriceFetcher } from '../types'; + +export const coinbaseTokenPriceFetcher = { + endpoint: 'https://api.coinbase.com/v2/exchange-rates', + + isBatchSupported: false, + + async fetchTokenPrice(token: string): Promise { + try { + const response = await axios.get(this.endpoint, { + params: { + currency: token, + }, + }); + + const Schema = z.object({ + data: z.object({ + rates: z.object({ + USD: z.string(), + }), + }), + }); + + const result = Schema.safeParse(response.data); + if (result.success === false) + throw new Error('Invalid response from coinbase'); + + return Number(result.data.data.rates.USD); + } catch (error) { + return ensureError(error); + } + }, +} as const satisfies TokenPriceFetcher; diff --git a/apps/tangle-dapp/data/tokenPrice/fetchers/coincap.ts b/apps/tangle-dapp/data/tokenPrice/fetchers/coincap.ts new file mode 100644 index 0000000000..eb54c18e49 --- /dev/null +++ b/apps/tangle-dapp/data/tokenPrice/fetchers/coincap.ts @@ -0,0 +1,40 @@ +import axios from 'axios'; +import z from 'zod'; + +import ensureError from '../../../utils/ensureError'; +import type { TokenPriceFetcher } from '../types'; + +export const coincapTokenPriceFetcher = { + endpoint: 'https://api.coincap.io/v2/assets', + + isBatchSupported: false, + + async fetchTokenPrice(token: string): Promise { + try { + const response = await axios.get(this.endpoint, { + params: { + search: token, + }, + }); + + const Schema = z.object({ + data: z.array( + z.object({ + priceUsd: z.string(), + }), + ), + }); + + const result = Schema.safeParse(response.data); + if (result.success === false) + throw new Error('Invalid response from coincap'); + + if (result.data.data.length === 0) + throw new Error('Token not found on coincap'); + + return Number(result.data.data[0].priceUsd); + } catch (error) { + return ensureError(error); + } + }, +} as const satisfies TokenPriceFetcher; diff --git a/apps/tangle-dapp/data/tokenPrice/index.ts b/apps/tangle-dapp/data/tokenPrice/index.ts new file mode 100644 index 0000000000..07eb285544 --- /dev/null +++ b/apps/tangle-dapp/data/tokenPrice/index.ts @@ -0,0 +1,49 @@ +/** + * Fetches the prices of multiple tokens, return `Error` + * if failed to fetch the price of that token. + * + * @param {string[]} tokens - An array of token symbols. + * @returns {Promise<(number | Error)[]>} A promise that resolves to an array of token prices. + * + * @example + * const tokens = ['ETH', 'BTC', 'USDT']; + * fetchTokenPrices(tokens).then(prices => { + * console.log(prices); // [2000, 30000, 1] + * }); + * + * @remarks + * This function currently returns an array of `Error` and logs a warning to the console. + * The actual implementation to fetch real token prices needs to be added. + */ +export const fetchTokenPrices = async ( + tokens: string[], +): Promise<(number | Error)[]> => { + // TODO: Implement the proper logic to fetch the price of the tokens + console.warn('fetchTokenPrices not implemented'); + return tokens.map(() => new Error('Token not found')); +}; + +/** + * Fetches the price of a single token, returns `Error` if failed to fetch the price. + * + * @param {string} _token - The symbol of the token. + * @returns {Promise} A promise that resolves to the token price. + * + * @example + * const token = 'ETH'; + * fetchSingleTokenPrice(token).then(price => { + * console.log(price); // 2000 + * }); + * + * @remarks + * This function currently returns `Error` and logs a warning to the console. + * The actual implementation to fetch the real token price needs to be added. + * The function will return `Error` when failed to fetch the price. + */ +export const fetchSingleTokenPrice = async ( + _token: string, +): Promise => { + // TODO: Implement the proper logic to fetch the price of the token + console.warn('fetchSingleTokenPrice not implemented'); + return new Error('Token not found'); +}; diff --git a/apps/tangle-dapp/data/tokenPrice/types.ts b/apps/tangle-dapp/data/tokenPrice/types.ts new file mode 100644 index 0000000000..bcee5ecd9f --- /dev/null +++ b/apps/tangle-dapp/data/tokenPrice/types.ts @@ -0,0 +1,9 @@ +export interface TokenPriceFetcher { + endpoint: string; + + isBatchSupported: TIsBatchSupported; + + fetchTokenPrice( + token: TIsBatchSupported extends true ? string[] : string, + ): Promise<(TIsBatchSupported extends true ? number[] : number) | Error>; +} diff --git a/apps/tangle-dapp/hooks/useQueryParamKey.ts b/apps/tangle-dapp/hooks/useQueryParamKey.ts index e3dde43372..9630e7720c 100644 --- a/apps/tangle-dapp/hooks/useQueryParamKey.ts +++ b/apps/tangle-dapp/hooks/useQueryParamKey.ts @@ -25,6 +25,10 @@ function validateQueryParam( switch (key) { case QueryParamKey.DELEGATIONS_AND_PAYOUTS_TAB: return z.nativeEnum(DelegationsAndPayoutsTab).safeParse(value).success; + + case QueryParamKey.RESTAKE_OPERATOR: + case QueryParamKey.RESTAKE_VAULT: + return z.string().safeParse(value).success; } } diff --git a/apps/tangle-dapp/types/index.ts b/apps/tangle-dapp/types/index.ts index d28e8c40a9..6f185859ba 100755 --- a/apps/tangle-dapp/types/index.ts +++ b/apps/tangle-dapp/types/index.ts @@ -15,13 +15,15 @@ export enum PagePath { SERVICES = '/services', RESTAKE = '/restake', RESTAKE_DEPOSIT = '/restake/deposit', - RESTAKE_DELEGATE = '/restake/delegate', + RESTAKE_STAKE = '/restake/stake', LIQUID_STAKING = '/liquid-staking', LIQUID_STAKING_OVERVIEW = '/liquid-staking/overview', } export enum QueryParamKey { DELEGATIONS_AND_PAYOUTS_TAB = 'tab', + RESTAKE_VAULT = 'vault', + RESTAKE_OPERATOR = 'operator', } export type QueryParamKeyOf = diff --git a/apps/tangle-dapp/types/restake.ts b/apps/tangle-dapp/types/restake.ts index b8a1b669bf..e646d7bfaa 100644 --- a/apps/tangle-dapp/types/restake.ts +++ b/apps/tangle-dapp/types/restake.ts @@ -95,7 +95,9 @@ export type AssetMetadata = { */ readonly status: TransformEnum; - poolId: string | null; + readonly poolId: string | null; + + readonly priceInUsd: number | null; }; /** diff --git a/apps/tangle-dapp/utils/getTVLToDisplay.ts b/apps/tangle-dapp/utils/getTVLToDisplay.ts new file mode 100644 index 0000000000..c786636b02 --- /dev/null +++ b/apps/tangle-dapp/utils/getTVLToDisplay.ts @@ -0,0 +1,9 @@ +import { getRoundedAmountString } from '@webb-tools/webb-ui-components/utils/getRoundedAmountString'; + +import { EMPTY_VALUE_PLACEHOLDER } from '../constants'; + +export default function getTVLToDisplay(tvl: number | null) { + if (tvl === null || tvl === 0) return EMPTY_VALUE_PLACEHOLDER; + + return `$${getRoundedAmountString(tvl)}`; +} diff --git a/libs/webb-ui-components/src/components/Table/Table.tsx b/libs/webb-ui-components/src/components/Table/Table.tsx index 8d02283838..f934e963a2 100644 --- a/libs/webb-ui-components/src/components/Table/Table.tsx +++ b/libs/webb-ui-components/src/components/Table/Table.tsx @@ -1,5 +1,5 @@ import { type Row, type RowData, flexRender } from '@tanstack/react-table'; -import React, { useCallback } from 'react'; +import React, { Fragment, useCallback } from 'react'; import { ArrowDropDownFill, ArrowDropUpFill } from '@webb-tools/icons'; import { twMerge } from 'tailwind-merge'; @@ -101,9 +101,8 @@ export const Table = ({
{table.getRowModel().rows.map((row) => ( - <> + @@ -122,13 +121,13 @@ export const Table = ({ {getExpandedRowContent && row.getIsExpanded() && ( - + )} - + ))} {isDisplayFooter && (
{getExpandedRowContent(row)}