diff --git a/components/OrchestratorList/OrchestratorActionsMenu.tsx b/components/OrchestratorList/OrchestratorActionsMenu.tsx new file mode 100644 index 00000000..958fed3b --- /dev/null +++ b/components/OrchestratorList/OrchestratorActionsMenu.tsx @@ -0,0 +1,121 @@ +import PopoverLink from "@components/PopoverLink"; +import { + Box, + Flex, + IconButton, + Popover, + PopoverContent, + PopoverTrigger, + Text, +} from "@livepeer/design-system"; +import { DotsHorizontalIcon } from "@radix-ui/react-icons"; + +type OrchestratorActionsMenuProps = { + accountId: string; + isMobile?: boolean; +}; + +export function OrchestratorActionsMenu({ + accountId, + isMobile = false, +}: OrchestratorActionsMenuProps) { + return ( + + { + e.stopPropagation(); + }} + asChild + > + + + + + { + e.stopPropagation(); + }} + onPointerEnterCapture={undefined} + onPointerLeaveCapture={undefined} + placeholder={undefined} + > + + + Actions + + + + Delegate + + + + + Account Details + + + + Orchestrating + + + Delegating + + + History + + + + + ); +} diff --git a/components/OrchestratorList/OrchestratorCard.tsx b/components/OrchestratorList/OrchestratorCard.tsx new file mode 100644 index 00000000..602fe315 --- /dev/null +++ b/components/OrchestratorList/OrchestratorCard.tsx @@ -0,0 +1,444 @@ +import IdentityAvatar from "@components/IdentityAvatar"; +import { formatTimeHorizon, ROIFactors, ROITimeHorizon } from "@lib/roi"; +import { textTruncate } from "@lib/utils"; +import { + Badge, + Box, + Flex, + Link as A, + Popover, + PopoverContent, + PopoverTrigger, + Text, +} from "@livepeer/design-system"; +import { ChevronDownIcon } from "@radix-ui/react-icons"; +import { useEnsData } from "hooks"; +import { useOrchestratorRowViewModel } from "hooks/useOrchestratorRowViewModel"; +import Link from "next/link"; +import numbro from "numbro"; + +import { OrchestratorActionsMenu } from "./OrchestratorActionsMenu"; + +type OrchestratorCardProps = { + rowData: { + id: string; + earningsComputed: { + feeShare: number | string; + rewardCut: number | string; + rewardCalls: number; + rewardCallLength: number; + isNewlyActive: boolean; + roi: { + delegatorPercent: { + fees: number; + rewards: number; + }; + delegator: { + fees: number; + rewards: number; + }; + }; + totalStake: number; + ninetyDayVolumeETH: number; + }; + }; + rowId: string; + timeHorizon: ROITimeHorizon; + factors: ROIFactors; +}; + +export function OrchestratorCard({ + rowData, + rowId, + timeHorizon, + factors, +}: OrchestratorCardProps) { + const identity = useEnsData(rowData.id); + const earnings = rowData.earningsComputed; + const { feeCut, rewardCut, rewardCalls, isNewlyActive } = + useOrchestratorRowViewModel(earnings); + + return ( + + + + + + {+rowId + 1} + + + + + {identity?.name ? ( + + + {textTruncate(identity.name, 20, "…")} + + + {rowData.id.substring(0, 6)} + + + ) : ( + + {rowData.id.replace(rowData.id.slice(7, 37), "…")} + + )} + + + + + + + + + + + + + Forecasted Yield + + {isNewlyActive ? ( + + NEW ✨ + + ) : ( + + + + {numbro( + earnings.roi.delegatorPercent.fees + + earnings.roi.delegatorPercent.rewards + ).format({ mantissa: 1, output: "percent" })} + + + + + + + + + {`Yield (${formatTimeHorizon(timeHorizon)})`} + + {factors !== "eth" && ( + + + Rewards ( + {numbro(earnings.roi.delegatorPercent.rewards).format({ + mantissa: 1, + output: "percent", + })} + ): + + + {numbro(earnings.roi.delegator.rewards).format({ + mantissa: 1, + })}{" "} + LPT + + + )} + {factors !== "lpt" && ( + + + Fees ( + {numbro(earnings.roi.delegatorPercent.fees).format({ + mantissa: 1, + output: "percent", + })} + ): + + + {numbro(earnings.roi.delegator.fees).format({ + mantissa: 3, + })}{" "} + ETH + + + )} + + + + )} + + + + + Delegated Stake + + + {numbro(earnings.totalStake).format({ + mantissa: 0, + thousandSeparated: true, + })}{" "} + LPT + + + + + + Trailing 90D Fees + + + {numbro(earnings.ninetyDayVolumeETH).format({ + mantissa: 2, + average: true, + })}{" "} + ETH + + + + + + Orchestrator Details + + + + + Reward Cut + + + {rewardCut} + + + + + + Fee Cut + + + {feeCut} + + + + + + Reward Call Ratio (90d) + + + {rewardCalls} + + + + + + ); +} diff --git a/components/OrchestratorList/YieldAssumptionsControls.tsx b/components/OrchestratorList/YieldAssumptionsControls.tsx new file mode 100644 index 00000000..512f2155 --- /dev/null +++ b/components/OrchestratorList/YieldAssumptionsControls.tsx @@ -0,0 +1,437 @@ +import { + formatFactors, + formatTimeHorizon, + ROIFactors, + ROIInflationChange, + ROITimeHorizon, +} from "@lib/roi"; +import { + Badge, + Box, + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuTrigger, + Flex, + Popover, + PopoverContent, + PopoverTrigger, + Text, + TextField, +} from "@livepeer/design-system"; +import { Pencil1Icon } from "@radix-ui/react-icons"; +import { ProtocolQueryResult } from "apollo"; +import numbro from "numbro"; + +import YieldChartIcon from "../../public/img/yield-chart.svg"; + +type YieldAssumptionsControlsProps = { + principle: number; + setPrinciple: (value: number) => void; + timeHorizon: ROITimeHorizon; + setTimeHorizon: (value: ROITimeHorizon) => void; + factors: ROIFactors; + setFactors: (value: ROIFactors) => void; + inflationChange: ROIInflationChange; + setInflationChange: (value: ROIInflationChange) => void; + protocolData: + | NonNullable["protocol"] + | undefined; + maxSupplyTokens: number; + formatPercentChange: (change: ROIInflationChange) => string; +}; + +export function YieldAssumptionsControls({ + principle, + setPrinciple, + timeHorizon, + setTimeHorizon, + factors, + setFactors, + inflationChange, + setInflationChange, + protocolData, + maxSupplyTokens, + formatPercentChange, +}: YieldAssumptionsControlsProps) { + return ( + + + + + + + {"Forecasted Yield Assumptions"} + + + *": { + marginLeft: "$1", + marginTop: "$1", + }, + }} + > + + { + e.stopPropagation(); + }} + asChild + > + + + + + + + {"Time horizon:"} + + + {formatTimeHorizon(timeHorizon)} + + + + + + setTimeHorizon("half-year")} + > + {"6 months"} + + setTimeHorizon("one-year")} + > + {"1 year"} + + setTimeHorizon("two-years")} + > + {"2 years"} + + setTimeHorizon("three-years")} + > + {"3 years"} + + setTimeHorizon("four-years")} + > + {"4 years"} + + + + + + + { + e.stopPropagation(); + }} + asChild + > + + + + + + {"Delegation:"} + + + {numbro(principle).format({ mantissa: 1, average: true })} + {" LPT"} + + + + + + + { + setPrinciple( + Number(e.target.value) > maxSupplyTokens + ? maxSupplyTokens + : Number(e.target.value) + ); + }} + min="1" + max={`${Number(protocolData?.totalSupply || 1e7).toFixed( + 0 + )}`} + /> + + LPT + + + + + + + + + { + e.stopPropagation(); + }} + asChild + > + + + + + + + {"Factors:"} + + + {formatFactors(factors)} + + + + + + setFactors("lpt+eth")} + > + {formatFactors("lpt+eth")} + + setFactors("lpt")} + > + {formatFactors("lpt")} + + setFactors("eth")} + > + {formatFactors("eth")} + + + + + + + + { + e.stopPropagation(); + }} + asChild + > + + + + + + + {"Inflation change:"} + + + {formatPercentChange(inflationChange)} + + + + + + setInflationChange("none")} + > + {formatPercentChange("none")} + + setInflationChange("positive")} + > + {formatPercentChange("positive")} + + setInflationChange("negative")} + > + {formatPercentChange("negative")} + + + + + + + + ); +} diff --git a/components/OrchestratorList/index.tsx b/components/OrchestratorList/index.tsx index 2e277352..9ef68580 100644 --- a/components/OrchestratorList/index.tsx +++ b/components/OrchestratorList/index.tsx @@ -1,69 +1,33 @@ import { ExplorerTooltip } from "@components/ExplorerTooltip"; import IdentityAvatar from "@components/IdentityAvatar"; -import PopoverLink from "@components/PopoverLink"; import Table from "@components/Table"; -import { bondingManager } from "@lib/api/abis/main/BondingManager"; import { AVERAGE_L1_BLOCK_TIME } from "@lib/chains"; -import dayjs from "@lib/dayjs"; -import { - calculateROI, - ROIFactors, - ROIInflationChange, - ROITimeHorizon, -} from "@lib/roi"; +import { formatTimeHorizon } from "@lib/roi"; import { formatAddress, textTruncate } from "@lib/utils"; import { Badge, Box, - DropdownMenu, - DropdownMenuContent, - DropdownMenuGroup, - DropdownMenuItem, - DropdownMenuTrigger, Flex, - IconButton, Link as A, Popover, PopoverContent, PopoverTrigger, Text, - TextField, } from "@livepeer/design-system"; import { ArrowTopRightIcon } from "@modulz/radix-icons"; -import { - ChevronDownIcon, - DotsHorizontalIcon, - Pencil1Icon, -} from "@radix-ui/react-icons"; +import { ChevronDownIcon } from "@radix-ui/react-icons"; import { OrchestratorsQueryResult, ProtocolQueryResult } from "apollo"; import { useEnsData } from "hooks"; -import { useBondingManagerAddress } from "hooks/useContracts"; +import { useOrchestratorRowViewModel } from "hooks/useOrchestratorRowViewModel"; +import { useOrchestratorViewModel } from "hooks/useOrchestratorViewModel"; import Link from "next/link"; import numbro from "numbro"; -import { useCallback, useMemo, useState } from "react"; -import { useReadContract } from "wagmi"; - -import YieldChartIcon from "../../public/img/yield-chart.svg"; - -const formatTimeHorizon = (timeHorizon: ROITimeHorizon) => - timeHorizon === "one-year" - ? `1Y` - : timeHorizon === "half-year" - ? `6M` - : timeHorizon === "three-years" - ? `3Y` - : timeHorizon === "two-years" - ? `2Y` - : timeHorizon === "four-years" - ? `4Y` - : "N/A"; +import { useMemo } from "react"; +import { useWindowSize } from "react-use"; -const formatFactors = (factors: ROIFactors) => - factors === "lpt+eth" - ? `LPT + ETH` - : factors === "lpt" - ? `LPT Only` - : `ETH Only`; +import { OrchestratorActionsMenu } from "./OrchestratorActionsMenu"; +import { OrchestratorCard } from "./OrchestratorCard"; +import { YieldAssumptionsControls } from "./YieldAssumptionsControls"; const OrchestratorList = ({ data, @@ -78,163 +42,25 @@ const OrchestratorList = ({ | NonNullable["transcoders"] | undefined; }) => { - const formatPercentChange = useCallback( - (change: ROIInflationChange) => - change === "none" - ? `Fixed at ${numbro( - Number(protocolData?.inflation) / 1000000000 - ).format({ - mantissa: 3, - output: "percent", - })}` - : `${numbro(Number(protocolData?.inflationChange) / 1000000000).format({ - mantissa: 5, - output: "percent", - forceSign: true, - })} per round`, + const { + filters, + setPrinciple, + setTimeHorizon, + setFactors, + setInflationChange, + mappedData, + formattedPrinciple, + maxSupplyTokens, + formatPercentChange, + } = useOrchestratorViewModel({ data, protocolData }); - [protocolData?.inflation, protocolData?.inflationChange] - ); + const { principle, timeHorizon, factors, inflationChange } = filters; - const [principle, setPrinciple] = useState(150); - const [inflationChange, setInflationChange] = - useState("none"); - const [factors, setFactors] = useState("lpt+eth"); - const [timeHorizon, setTimeHorizon] = useState("one-year"); - const maxSupplyTokens = useMemo( - () => Math.floor(Number(protocolData?.totalSupply || 1e7)), - [protocolData] - ); - const formattedPrinciple = useMemo( - () => - numbro(Number(principle) || 150).format({ mantissa: 0, average: true }), - [principle] - ); - const { data: bondingManagerAddress } = useBondingManagerAddress(); - const { data: treasuryRewardCutRate = BigInt(0.0) } = useReadContract({ - query: { enabled: Boolean(bondingManagerAddress) }, - address: bondingManagerAddress, - abi: bondingManager, - functionName: "treasuryRewardCutRate", - }); + // Mobile detection + const { width } = useWindowSize(); + const isMobile = width < 768; - const mappedData = useMemo(() => { - return data - ?.map((row) => { - const pools = row.pools ?? []; - const rewardCalls = - pools.length > 0 ? pools.filter((r) => r?.rewardTokens).length : 0; - const rewardCallRatio = rewardCalls / pools.length; - - const activation = dayjs.unix(row.activationTimestamp); - - const isNewlyActive = dayjs().diff(activation, "days") < 45; - - const feeShareDaysSinceChange = dayjs().diff( - dayjs.unix(row.feeShareUpdateTimestamp), - "days" - ); - const rewardCutDaysSinceChange = dayjs().diff( - dayjs.unix(row.rewardCutUpdateTimestamp), - "days" - ); - - const roi = calculateROI({ - inputs: { - principle: Number(principle), - timeHorizon, - inflationChange, - factors, - }, - orchestratorParams: { - totalStake: Number(row.totalStake), - }, - feeParams: { - ninetyDayVolumeETH: Number(row.ninetyDayVolumeETH), - feeShare: Number(row.feeShare) / 1000000, - lptPriceEth: Number(protocolData?.lptPriceEth), - }, - rewardParams: { - inflation: Number(protocolData?.inflation) / 1000000000, - inflationChangePerRound: - Number(protocolData?.inflationChange) / 1000000000, - totalSupply: Number(protocolData?.totalSupply), - totalActiveStake: Number(protocolData?.totalActiveStake), - roundLength: Number(protocolData?.roundLength), - - rewardCallRatio, - rewardCut: Number(row.rewardCut) / 1000000, - treasuryRewardCut: - Number(treasuryRewardCutRate / BigInt(1e18)) / 1e9, - }, - }); - - // Pre-compute formatted values to avoid useMemo in Cell render functions - const formattedFeeCut = numbro( - 1 - Number(row.feeShare) / 1000000 - ).format({ mantissa: 0, output: "percent" }); - const formattedRewardCut = numbro( - Number(row.rewardCut) / 1000000 - ).format({ mantissa: 0, output: "percent" }); - const formattedRewardCalls = - pools.length > 0 - ? `${numbro(rewardCalls) - .divide(pools.length) - .format({ mantissa: 0, output: "percent" })}` - : "0%"; - - return { - ...row, - daysSinceChangeParams: - (feeShareDaysSinceChange < rewardCutDaysSinceChange - ? feeShareDaysSinceChange - : rewardCutDaysSinceChange) ?? 0, - daysSinceChangeParamsFormatted: - (feeShareDaysSinceChange < rewardCutDaysSinceChange - ? dayjs.unix(row.feeShareUpdateTimestamp).fromNow() - : dayjs.unix(row.rewardCutUpdateTimestamp).fromNow()) ?? "", - earningsComputed: { - roi, - activation, - isNewlyActive, - rewardCalls, - rewardCallLength: pools.length, - rewardCallRatio, - feeShare: row.feeShare, - rewardCut: row.rewardCut, - ninetyDayVolumeETH: Number(row.ninetyDayVolumeETH), - totalActiveStake: Number(protocolData?.totalActiveStake), - totalStake: Number(row.totalStake), - // Pre-formatted values for Cell rendering - formattedFeeCut, - formattedRewardCut, - formattedRewardCalls, - }, - }; - }) - .sort((a, b) => - a.earningsComputed.isNewlyActive - ? 1 - : b.earningsComputed.isNewlyActive - ? -1 - : a.earningsComputed.roi.delegatorPercent.fees + - a.earningsComputed.roi.delegatorPercent.rewards > - b.earningsComputed.roi.delegatorPercent.fees + - b.earningsComputed.roi.delegatorPercent.rewards - ? -1 - : 1 - ); - }, [ - data, - inflationChange, - protocolData, - principle, - timeHorizon, - factors, - treasuryRewardCutRate, - ]); - - const columns = useMemo( + const desktopColumns = useMemo( () => [ { Header: ( @@ -357,13 +183,8 @@ const OrchestratorList = ({ accessor: (row) => row.earningsComputed, id: "earnings", Cell: ({ row }) => { - // Use pre-computed values from accessor instead of useMemo in render - const { - isNewlyActive, - formattedFeeCut: feeCut, - formattedRewardCut: rewardCut, - formattedRewardCalls: rewardCalls, - } = row.values.earnings; + const { feeCut, rewardCut, rewardCalls, isNewlyActive } = + useOrchestratorRowViewModel(row.values.earnings); return ( @@ -862,113 +683,79 @@ const OrchestratorList = ({ Header: <>, id: "actions", Cell: ({ row }) => ( - - { - e.stopPropagation(); - }} - asChild - > - - - - - - - { - e.stopPropagation(); - }} - onPointerEnterCapture={undefined} - onPointerLeaveCapture={undefined} - placeholder={undefined} - > - - - Actions - - - - Delegate - - - - - Account Details - - - - Orchestrating - - - Delegating - - - History - - - - + + + ), }, ], [formattedPrinciple, timeHorizon, factors] ); + const mobileColumns = useMemo( + () => [ + { + Header: <>, + id: "earnings", + // Use same accessor as desktop for proper sorting + accessor: (row) => row.earningsComputed, + Cell: ({ row }) => { + const rowData = row.original as NonNullable< + typeof mappedData + >[number]; + return ( + + ); + }, + // Use same sortType as desktop earnings column + sortType: (rowA, rowB) => { + return rowA.values.earnings.isNewlyActive + ? -1 + : rowB.values.earnings.isNewlyActive + ? 1 + : rowA.values.earnings.roi.delegatorPercent.fees + + rowA.values.earnings.roi.delegatorPercent.rewards > + rowB.values.earnings.roi.delegatorPercent.fees + + rowB.values.earnings.roi.delegatorPercent.rewards + ? 1 + : -1; + }, + }, + ], + [timeHorizon, factors] + ); + + const columns = isMobile ? mobileColumns : desktopColumns; + + // Yield assumptions controls + const yieldAssumptionsControls = ( + + ); + return ( - - - - - - {"Forecasted Yield Assumptions"} - - - - - { - e.stopPropagation(); - }} - asChild - > - - - - - - - {"Time horizon:"} - - - {formatTimeHorizon(timeHorizon)} - - - - - - setTimeHorizon("half-year")} - > - {"6 months"} - - setTimeHorizon("one-year")} - > - {"1 year"} - - setTimeHorizon("two-years")} - > - {"2 years"} - - setTimeHorizon("three-years")} - > - {"3 years"} - - setTimeHorizon("four-years")} - > - {"4 years"} - - - - - - - { - e.stopPropagation(); - }} - asChild - > - - - - - - {"Delegation:"} - - - {numbro(principle).format({ mantissa: 1, average: true })} - {" LPT"} - - - - - - - { - setPrinciple( - Number(e.target.value) > maxSupplyTokens - ? maxSupplyTokens - : Number(e.target.value) - ); - }} - min="1" - max={`${Number( - protocolData?.totalSupply || 1e7 - ).toFixed(0)}`} - /> - - LPT - - - - - - - - - { - e.stopPropagation(); - }} - asChild - > - - - - - - - {"Factors:"} - - - {formatFactors(factors)} - - - - - - setFactors("lpt+eth")} - > - {formatFactors("lpt+eth")} - - setFactors("lpt")} - > - {formatFactors("lpt")} - - setFactors("eth")} - > - {formatFactors("eth")} - - - - - - - - { - e.stopPropagation(); - }} - asChild - > - - - - - - - {"Inflation change:"} - - - {formatPercentChange(inflationChange)} - - - - - - setInflationChange("none")} - > - {formatPercentChange("none")} - - setInflationChange("positive")} - > - {formatPercentChange("positive")} - - setInflationChange("negative")} - > - {formatPercentChange("negative")} - - - - - - - - } + input={yieldAssumptionsControls} + constrainWidth={isMobile} /> ); }; diff --git a/components/Table/PaginationControls.tsx b/components/Table/PaginationControls.tsx new file mode 100644 index 00000000..dd626159 --- /dev/null +++ b/components/Table/PaginationControls.tsx @@ -0,0 +1,62 @@ +import { Box, Flex } from "@livepeer/design-system"; +import { ArrowLeftIcon, ArrowRightIcon } from "@radix-ui/react-icons"; + +type PaginationControlsProps = { + canPreviousPage: boolean; + canNextPage: boolean; + pageIndex: number; + pageCount: number; + previousPage: () => void; + nextPage: () => void; +}; + +export function PaginationControls({ + canPreviousPage, + canNextPage, + pageIndex, + pageCount, + previousPage, + nextPage, +}: PaginationControlsProps) { + return ( + + { + if (canPreviousPage) { + previousPage(); + } + }} + /> + + Page {pageIndex + 1} of{" "} + {pageCount} + + { + if (canNextPage) { + nextPage(); + } + }} + /> + + ); +} diff --git a/components/Table/index.tsx b/components/Table/index.tsx index 6e9832fa..0a675905 100644 --- a/components/Table/index.tsx +++ b/components/Table/index.tsx @@ -8,12 +8,7 @@ import { Thead, Tr, } from "@livepeer/design-system"; -import { - ArrowLeftIcon, - ArrowRightIcon, - ChevronDownIcon, - ChevronUpIcon, -} from "@radix-ui/react-icons"; +import { ChevronDownIcon, ChevronUpIcon } from "@radix-ui/react-icons"; import { ReactNode } from "react"; import { Column, @@ -26,18 +21,22 @@ import { useTable, } from "react-table"; +import { PaginationControls } from "./PaginationControls"; + function DataTable({ heading = null, input = null, data, columns, initialState = {}, + constrainWidth = false, }: { heading?: ReactNode; input?: ReactNode; data: T[]; columns: Column[]; initialState: object; + constrainWidth?: boolean; }) { const { getTableProps, @@ -53,7 +52,7 @@ function DataTable({ state: { pageIndex }, } = useTable( { - columns, + columns: columns || [], data, initialState, }, @@ -80,7 +79,11 @@ function DataTable({ <> {input && ( @@ -98,11 +101,15 @@ function DataTable({ css={{ borderCollapse: "collapse", tableLayout: "auto", - minWidth: 980, width: "100%", - "@bp4": { - width: "100%", - }, + minWidth: 0, + ...(constrainWidth + ? {} + : { + "@bp1": { + minWidth: 980, + }, + }), }} > @@ -231,6 +238,13 @@ function DataTable({ paddingLeft: i === 0 ? "$5" : "$1", paddingRight: i === 0 ? "$5" : "$1", width: i === 0 ? "40px" : "auto", + ...(constrainWidth && { + maxWidth: "100%", + minWidth: 0, + boxSizing: "border-box", + wordWrap: "break-word", + overflow: "hidden", + }), }} > {cell.render("Cell")} @@ -243,45 +257,14 @@ function DataTable({
- - { - if (canPreviousPage) { - previousPage(); - } - }} - /> - - Page {pageIndex + 1} of{" "} - {pageCount} - - { - if (canNextPage) { - nextPage(); - } - }} - /> - + diff --git a/hooks/useOrchestratorRowViewModel.ts b/hooks/useOrchestratorRowViewModel.ts new file mode 100644 index 00000000..a1099dd4 --- /dev/null +++ b/hooks/useOrchestratorRowViewModel.ts @@ -0,0 +1,45 @@ +import numbro from "numbro"; +import { useMemo } from "react"; + +type EarningsData = { + feeShare: number | string; + rewardCut: number | string; + rewardCalls: number; + rewardCallLength: number; + isNewlyActive: boolean; +}; + +export function useOrchestratorRowViewModel(earnings: EarningsData) { + const feeCut = useMemo( + () => + numbro(1 - Number(earnings.feeShare) / 1000000).format({ + mantissa: 0, + output: "percent", + }), + [earnings.feeShare] + ); + + const rewardCut = useMemo( + () => + numbro(Number(earnings.rewardCut) / 1000000).format({ + mantissa: 0, + output: "percent", + }), + [earnings.rewardCut] + ); + + const rewardCalls = useMemo( + () => + `${numbro(earnings.rewardCalls) + .divide(earnings.rewardCallLength) + .format({ mantissa: 0, output: "percent" })}`, + [earnings.rewardCalls, earnings.rewardCallLength] + ); + + return { + feeCut, + rewardCut, + rewardCalls, + isNewlyActive: earnings.isNewlyActive, + }; +} diff --git a/hooks/useOrchestratorViewModel.ts b/hooks/useOrchestratorViewModel.ts new file mode 100644 index 00000000..40e9a51b --- /dev/null +++ b/hooks/useOrchestratorViewModel.ts @@ -0,0 +1,194 @@ +import { bondingManager } from "@lib/api/abis/main/BondingManager"; +import dayjs from "@lib/dayjs"; +import { + calculateROI, + ROIFactors, + ROIInflationChange, + ROITimeHorizon, +} from "@lib/roi"; +import { OrchestratorsQueryResult, ProtocolQueryResult } from "apollo"; +import { useBondingManagerAddress } from "hooks/useContracts"; +import numbro from "numbro"; +import { useCallback, useMemo, useState } from "react"; +import { useReadContract } from "wagmi"; + +type UseOrchestratorViewModelParams = { + data: + | NonNullable["transcoders"] + | undefined; + protocolData: + | NonNullable["protocol"] + | undefined; +}; + +export function useOrchestratorViewModel({ + data, + protocolData, +}: UseOrchestratorViewModelParams) { + // Filter state + const [principle, setPrinciple] = useState(150); + const [inflationChange, setInflationChange] = + useState("none"); + const [factors, setFactors] = useState("lpt+eth"); + const [timeHorizon, setTimeHorizon] = useState("one-year"); + + // Derived values + const maxSupplyTokens = useMemo( + () => Math.floor(Number(protocolData?.totalSupply || 1e7)), + [protocolData] + ); + + const formattedPrinciple = useMemo( + () => + numbro(Number(principle) || 150).format({ mantissa: 0, average: true }), + [principle] + ); + + const formatPercentChange = useCallback( + (change: ROIInflationChange) => + change === "none" + ? `Fixed at ${numbro( + Number(protocolData?.inflation) / 1000000000 + ).format({ + mantissa: 3, + output: "percent", + })}` + : `${numbro(Number(protocolData?.inflationChange) / 1000000000).format({ + mantissa: 5, + output: "percent", + forceSign: true, + })} per round`, + [protocolData?.inflation, protocolData?.inflationChange] + ); + + // Contract data + const { data: bondingManagerAddress } = useBondingManagerAddress(); + const { data: treasuryRewardCutRate = BigInt(0.0) } = useReadContract({ + query: { enabled: Boolean(bondingManagerAddress) }, + address: bondingManagerAddress, + abi: bondingManager, + functionName: "treasuryRewardCutRate", + }); + + // Computed data + const mappedData = + useMemo(() => { + if (!data) return []; + return data + .map((row) => { + const pools = row.pools ?? []; + const rewardCalls = + pools.length > 0 ? pools.filter((r) => r?.rewardTokens).length : 0; + const rewardCallRatio = rewardCalls / pools.length; + + const activation = dayjs.unix(row.activationTimestamp); + + const isNewlyActive = dayjs().diff(activation, "days") < 45; + + const feeShareDaysSinceChange = dayjs().diff( + dayjs.unix(row.feeShareUpdateTimestamp), + "days" + ); + const rewardCutDaysSinceChange = dayjs().diff( + dayjs.unix(row.rewardCutUpdateTimestamp), + "days" + ); + + const roi = calculateROI({ + inputs: { + principle: Number(principle), + timeHorizon, + inflationChange, + factors, + }, + orchestratorParams: { + totalStake: Number(row.totalStake), + }, + feeParams: { + ninetyDayVolumeETH: Number(row.ninetyDayVolumeETH), + feeShare: Number(row.feeShare) / 1000000, + lptPriceEth: Number(protocolData?.lptPriceEth), + }, + rewardParams: { + inflation: Number(protocolData?.inflation) / 1000000000, + inflationChangePerRound: + Number(protocolData?.inflationChange) / 1000000000, + totalSupply: Number(protocolData?.totalSupply), + totalActiveStake: Number(protocolData?.totalActiveStake), + roundLength: Number(protocolData?.roundLength), + + rewardCallRatio, + rewardCut: Number(row.rewardCut) / 1000000, + treasuryRewardCut: + Number(treasuryRewardCutRate / BigInt(1e18)) / 1e9, + }, + }); + + return { + ...row, + daysSinceChangeParams: + (feeShareDaysSinceChange < rewardCutDaysSinceChange + ? feeShareDaysSinceChange + : rewardCutDaysSinceChange) ?? 0, + daysSinceChangeParamsFormatted: + (feeShareDaysSinceChange < rewardCutDaysSinceChange + ? dayjs.unix(row.feeShareUpdateTimestamp).fromNow() + : dayjs.unix(row.rewardCutUpdateTimestamp).fromNow()) ?? "", + earningsComputed: { + roi, + activation, + isNewlyActive, + rewardCalls, + rewardCallLength: pools.length, + rewardCallRatio, + feeShare: row.feeShare, + rewardCut: row.rewardCut, + ninetyDayVolumeETH: Number(row.ninetyDayVolumeETH), + totalActiveStake: Number(protocolData?.totalActiveStake), + totalStake: Number(row.totalStake), + }, + }; + }) + .sort((a, b) => + a.earningsComputed.isNewlyActive + ? 1 + : b.earningsComputed.isNewlyActive + ? -1 + : a.earningsComputed.roi.delegatorPercent.fees + + a.earningsComputed.roi.delegatorPercent.rewards > + b.earningsComputed.roi.delegatorPercent.fees + + b.earningsComputed.roi.delegatorPercent.rewards + ? -1 + : 1 + ); + }, [ + data, + inflationChange, + protocolData, + principle, + timeHorizon, + factors, + treasuryRewardCutRate, + ]) ?? []; + + return { + // Filter state + filters: { + principle, + timeHorizon, + factors, + inflationChange, + }, + // Filter setters + setPrinciple, + setTimeHorizon, + setFactors, + setInflationChange, + // Computed data + mappedData, + // Derived values + formattedPrinciple, + maxSupplyTokens, + formatPercentChange, + }; +} diff --git a/lib/roi.ts b/lib/roi.ts index 332da0b6..053602d3 100644 --- a/lib/roi.ts +++ b/lib/roi.ts @@ -52,6 +52,36 @@ export const getMonthsForTimeHorizon = (timeHorizon: ROITimeHorizon) => ? 48 : 12; +export function formatTimeHorizon(timeHorizon: ROITimeHorizon): string { + switch (timeHorizon) { + case "half-year": + return "6 months"; + case "one-year": + return "1 year"; + case "two-years": + return "2 years"; + case "three-years": + return "3 years"; + case "four-years": + return "4 years"; + default: + return "1 year"; + } +} + +export function formatFactors(factors: ROIFactors): string { + switch (factors) { + case "lpt+eth": + return "LPT + ETH"; + case "lpt": + return "LPT Only"; + case "eth": + return "ETH Only"; + default: + return "LPT + ETH"; + } +} + export function calculateROI({ inputs: { principle,