From a31b76f84839ebc9936d1053a6f2a089f60dba37 Mon Sep 17 00:00:00 2001 From: Daniel McCartney Date: Wed, 26 Jul 2023 00:49:28 -0400 Subject: [PATCH] feat: show RPL stake status tooltip --- web/src/components/CurrencyValue.js | 44 +- web/src/components/NodeRewardsSummaryCard.js | 688 ++++++++++++++++-- web/src/components/SafeSweepCard.js | 4 +- web/src/contracts.js | 5 + .../contracts/RocketNetworkPrices.json | 160 ++++ web/src/hooks/useNodeRplStatus.js | 21 + web/src/hooks/useRplEthPrice.js | 7 + web/src/utils.js | 6 +- 8 files changed, 885 insertions(+), 50 deletions(-) create mode 100644 web/src/generated/contracts/RocketNetworkPrices.json create mode 100644 web/src/hooks/useNodeRplStatus.js create mode 100644 web/src/hooks/useRplEthPrice.js diff --git a/web/src/components/CurrencyValue.js b/web/src/components/CurrencyValue.js index f098075..c4dd01e 100644 --- a/web/src/components/CurrencyValue.js +++ b/web/src/components/CurrencyValue.js @@ -26,10 +26,13 @@ const typeVariantBySize = { }; export default function CurrencyValue({ value = ethers.constants.Zero, + prefix = "", currency = "eth", + perCurrency = null, size = (v) => (v.gte(ethers.utils.parseUnits("1000")) ? "small" : "medium"), placeholder = "-.---", - decimalPlaces = 3, + maxDecimals = 4, + trimZeroWhole = false, hideCurrency = false, ...props }) { @@ -42,7 +45,11 @@ export default function CurrencyValue({ let valueText = placeholder; if (value && !value.isZero()) { valueText = trimValue( - ethers.utils.formatUnits(value.abs() || ethers.constants.Zero) + ethers.utils.formatUnits(value.abs() || ethers.constants.Zero), + { + maxDecimals, + trimZeroWhole, + } ); } if (value && value.isNegative()) { @@ -59,9 +66,10 @@ export default function CurrencyValue({ variant={typeVariants.value} color={(theme) => theme.palette.text.primary} > + {prefix} {valueText} - {hideCurrency ? null : ( + {hideCurrency ? null : !perCurrency ? ( - {" "} {currency.toUpperCase()} + ) : ( + + {" "} + + theme.palette[currency] ? theme.palette[currency].main : "default" + } + > + {currency.toUpperCase()} + + / + + theme.palette[perCurrency] + ? theme.palette[perCurrency].main + : "default" + } + > + {perCurrency.toUpperCase()} + + )} ); diff --git a/web/src/components/NodeRewardsSummaryCard.js b/web/src/components/NodeRewardsSummaryCard.js index a86d67e..1d9c92a 100644 --- a/web/src/components/NodeRewardsSummaryCard.js +++ b/web/src/components/NodeRewardsSummaryCard.js @@ -12,76 +12,141 @@ import { Divider, FormHelperText, Stack, + Tooltip, Typography, useTheme, } from "@mui/material"; import { bnSum, rocketscanUrl, trimValue } from "../utils"; import { ethers } from "ethers"; -import { ResponsiveContainer, Treemap } from "recharts"; +import { + ResponsiveContainer, + Treemap, + LineChart, + XAxis, + YAxis, + ReferenceLine, + Label, + Rectangle, + ReferenceArea, + ReferenceDot, +} from "recharts"; import WalletChip from "./WalletChip"; import { useEnsName } from "wagmi"; import { Link } from "react-router-dom"; -import { AllInclusive, EventRepeat, OpenInNew } from "@mui/icons-material"; +import { + AllInclusive, + Done, + Error, + EventRepeat, + OpenInNew, + Warning, +} from "@mui/icons-material"; import CurrencyValue from "./CurrencyValue"; -import useNodeFinalizedRewardSnapshots from "../hooks/useNodeFinalizedRewardSnapshots"; -import useNodeDetails from "../hooks/useNodeDetails"; import useNodeContinuousRewards from "../hooks/useNodeContinuousRewards"; +import useNodeDetails from "../hooks/useNodeDetails"; +import useNodeFinalizedRewardSnapshots from "../hooks/useNodeFinalizedRewardSnapshots"; +import useNodeRplStatus from "../hooks/useNodeRplStatus"; +import useRplEthPrice from "../hooks/useRplEthPrice"; import { useState } from "react"; function SummaryCardHeader({ nodeAddress }) { const continuous = useNodeContinuousRewards({ nodeAddress }); const { data: details } = useNodeDetails({ nodeAddress }); - let rplStakeText = "-.---"; - if (details?.rplStake) { - rplStakeText = trimValue(ethers.utils.formatUnits(details?.rplStake), { - maxDecimals: 0, - }); - } + let { rplStake } = details || { + rplStake: ethers.constants.Zero, + }; + // rplStake = rplStake.div(ethers.BigNumber.from(2)); + const rplStatus = useNodeRplStatus({ nodeAddress }); + let rplStakeText = trimValue(ethers.utils.formatUnits(rplStake), { + maxDecimals: 0, + }); return ( } action={ - - - - - {continuous?.minipoolCount} - - } - /> - - minipools - - - - {rplStakeText} - } - /> - - - staked - theme.palette.rpl.light} + + + + + + {continuous?.minipoolCount} + + } + /> + + minipools + + + ) + } + > + - {"RPL"} - - + + , + close: , + over: , + }[rplStatus] + } + label={ + + {rplStakeText} + + } + /> + + + staked + theme.palette.rpl.light} + > + {"RPL"} + + + + } @@ -372,6 +437,541 @@ function usePeriodicRewards({ nodeAddress }) { return { unclaimed, unclaimedEthTotal, unclaimedRplTotal }; } +function RplPriceRangeAxis({ sx, nodeAddress }) { + const theme = useTheme(); + const { data: details } = useNodeDetails({ nodeAddress }); + const rplEthPrice = useRplEthPrice(); + let { rplStake, minimumRPLStake, maximumRPLStake } = details || { + rplStake: ethers.constants.Zero, + minimumRPLStake: ethers.constants.Zero, + maximumRPLStake: ethers.constants.Zero, + }; + let rplStakeOrOne = rplStake.isZero() ? ethers.constants.One : rplStake; + return ( + + + + + <-> + + + + ETH + + / + + RPL + + + + + + ); +} + +function RplStakeEthRangeAxis({ sx, nodeAddress }) { + const theme = useTheme(); + const { data: details } = useNodeDetails({ nodeAddress }); + let rplStatus = useNodeRplStatus({ nodeAddress }); + let { ethMatched, minipoolCount } = details || { + ethMatched: ethers.constants.Zero, + minipoolCount: ethers.constants.Zero, + }; + const ethSupplied = minipoolCount + .mul(32) + .mul(ethers.constants.WeiPerEther) + .sub(ethMatched); + return ( + <> + + + + 10% of{" "} + + {trimValue(ethers.utils.formatUnits(ethMatched), { + maxDecimals: 0, + })} + + + + borrowed + + + + ETH + + + + 150% of{" "} + + {trimValue(ethers.utils.formatUnits(ethSupplied), { + maxDecimals: 0, + })} + + + + supplied + + {/**/} + + + + + + <-> + + + + + ); +} + +function RplStakeChart({ sx, nodeAddress }) { + const theme = useTheme(); + const { data: details } = useNodeDetails({ nodeAddress }); + const rplEthPrice = useRplEthPrice(); + let rplStatus = useNodeRplStatus({ nodeAddress }); + let { rplStake, minimumRPLStake, maximumRPLStake } = details || { + rplStake: ethers.constants.Zero, + minimumRPLStake: ethers.constants.Zero, + maximumRPLStake: ethers.constants.Zero, + }; + const rplStakeEth = rplStake + ?.mul(rplEthPrice) + .div(ethers.constants.WeiPerEther); + const minRplPrice = rplStake.isZero() + ? 0 + : Number( + ethers.utils.formatUnits( + minimumRPLStake + .mul(rplEthPrice) + .div(rplStake.isZero() ? ethers.constants.One : rplStake) + ) + ); + const rplPrice = Number(ethers.utils.formatUnits(rplEthPrice)) || 0; + const maxRplPrice = rplStake.isZero() + ? 0 + : Number( + ethers.utils.formatUnits( + maximumRPLStake + .mul(rplEthPrice) + .div(rplStake.isZero() ? ethers.constants.One : rplStake) + ) + ); + const data = [ + { + name: "min", + rplPrice: minRplPrice, + value: 1, + }, + { + name: "current", + rplPrice: rplPrice, + value: 1, + }, + { + name: "max", + rplPrice: maxRplPrice, + value: 1, + }, + ]; + const priceLineY = 0.5; + + const rplPriceText = trimValue(ethers.utils.formatUnits(rplEthPrice), { + maxDecimals: 4, + trimZeroWhole: true, + }); + const rplStakeEthPriceText = trimValue( + ethers.utils.formatUnits(rplStakeEth), + { maxDecimals: 2 } + ); + return ( + + + ≈ } + value={rplStakeEth} + maxDecimals={2} + currency={"eth"} + size="medium" + /> + + + @ + + + + , + close: , + over: , + effective: , + }[rplStatus] + } + label={ + + {rplStatus} + + } + /> + + + + + + + + + + + + + + + + + + + + + + + + + + } + fontSize={12} + tickFormatter={(value) => value.toFixed(4)} + tickSize={0} + tickMargin={5} + tickLine + interval={0} + // allowDataOverflow + domain={[0, maxRplPrice + Math.min(0.01, minRplPrice)]} + /> + + + {/**/} + + + + + + + {/**/} + + { + return ( + + ); + }} + > + + { + return ( + + ); + }} + > + + + + + + ); +} + const ten = ethers.utils.parseUnits("10"); function SummaryCardContent({ nodeAddress, size = "large" }) { const periodic = usePeriodicRewards({ nodeAddress }); diff --git a/web/src/components/SafeSweepCard.js b/web/src/components/SafeSweepCard.js index 75e3de1..2543a4b 100644 --- a/web/src/components/SafeSweepCard.js +++ b/web/src/components/SafeSweepCard.js @@ -942,7 +942,9 @@ export default function SafeSweepCard({ sx, nodeAddress }) { gasAmount={overall.gas} ethTotal={overall.eth} rplTotal={isClaimingInterval ? totalRpl : ethers.constants.Zero} - stakeAmountRpl={isClaimingInterval ? stakeAmountRpl : ethers.constants.Zero} + stakeAmountRpl={ + isClaimingInterval ? stakeAmountRpl : ethers.constants.Zero + } > diff --git a/web/src/contracts.js b/web/src/contracts.js index 4d606bb..9b50b59 100644 --- a/web/src/contracts.js +++ b/web/src/contracts.js @@ -2,6 +2,7 @@ import RocketMerkleDistributorMainnet from "./generated/contracts/RocketMerkleDi import RocketMinipoolBase from "./generated/contracts/RocketMinipoolBase.json"; import RocketMinipoolDelegate from "./generated/contracts/RocketMinipoolDelegate.json"; import RocketMinipoolManager from "./generated/contracts/RocketMinipoolManager.json"; +import RocketNetworkPrices from "./generated/contracts/RocketNetworkPrices.json"; import RocketNodeDistributorInterface from "./generated/contracts/RocketNodeDistributorInterface.json"; import RocketNodeManager from "./generated/contracts/RocketNodeManager.json"; import RocketRewardsPool from "./generated/contracts/RocketRewardsPool.json"; @@ -26,6 +27,10 @@ const contracts = { ], abi: RocketMinipoolManager.abi, }, + RocketNetworkPrices: { + address: "0x751826b107672360b764327631cc5764515ffc37", + abi: RocketNetworkPrices.abi, + }, RocketNodeDistributorInterface: { // see RocketNodeManager.Read.getNodeDetails -> .feeDistributorAddress abi: RocketNodeDistributorInterface.abi, diff --git a/web/src/generated/contracts/RocketNetworkPrices.json b/web/src/generated/contracts/RocketNetworkPrices.json new file mode 100644 index 0000000..248c615 --- /dev/null +++ b/web/src/generated/contracts/RocketNetworkPrices.json @@ -0,0 +1,160 @@ +{ + "contractName": "RocketNetworkPrices", + "abi": [ + { + "inputs": [ + { + "internalType": "contract RocketStorageInterface", + "name": "_rocketStorageAddress", + "type": "address" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "block", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "rplPrice", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "time", + "type": "uint256" + } + ], + "name": "PricesSubmitted", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "block", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "rplPrice", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "time", + "type": "uint256" + } + ], + "name": "PricesUpdated", + "type": "event" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_block", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_rplPrice", + "type": "uint256" + } + ], + "name": "executeUpdatePrices", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "getLatestReportableBlock", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "getPricesBlock", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "getRPLPrice", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_block", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_rplPrice", + "type": "uint256" + } + ], + "name": "submitPrices", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "version", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + } + ] +} diff --git a/web/src/hooks/useNodeRplStatus.js b/web/src/hooks/useNodeRplStatus.js new file mode 100644 index 0000000..39023f8 --- /dev/null +++ b/web/src/hooks/useNodeRplStatus.js @@ -0,0 +1,21 @@ +import useNodeDetails from "./useNodeDetails"; +import { ethers } from "ethers"; + +export default function useNodeRplStatus({ nodeAddress }) { + const { data: details } = useNodeDetails({ nodeAddress }); + let { rplStake, minimumRPLStake, maximumRPLStake } = details || { + rplStake: ethers.constants.Zero, + minimumRPLStake: ethers.constants.Zero, + maximumRPLStake: ethers.constants.Zero, + }; + // rplStake = rplStake.div(ethers.BigNumber.from(2)); + return !rplStake + ? "effective" + : rplStake?.lte(minimumRPLStake) + ? "under" + : rplStake?.lte(minimumRPLStake.mul(3).div(2)) + ? "close" + : rplStake?.gt(maximumRPLStake) + ? "over" + : "effective"; +} diff --git a/web/src/hooks/useRplEthPrice.js b/web/src/hooks/useRplEthPrice.js new file mode 100644 index 0000000..a705636 --- /dev/null +++ b/web/src/hooks/useRplEthPrice.js @@ -0,0 +1,7 @@ +import useK from "./useK"; +import { ethers } from "ethers"; + +export default function useRplEthPrice() { + let { data: rplEthPrice } = useK.RocketNetworkPrices.Read.getRPLPrice(); + return rplEthPrice || ethers.constants.Zero; +} diff --git a/web/src/utils.js b/web/src/utils.js index 9340c85..b5e4a6a 100644 --- a/web/src/utils.js +++ b/web/src/utils.js @@ -32,7 +32,7 @@ export function BNSortComparator(a, b) { // convert "1234.123415123123123123" into "1,234.1234" export function trimValue( amountEthish, - { maxDecimals = 4, maxLength = 11 } = {} + { maxDecimals = 4, maxLength = 11, trimZeroWhole = false } = {} ) { if (amountEthish.indexOf(".") !== -1) { amountEthish = amountEthish.slice( @@ -40,6 +40,10 @@ export function trimValue( amountEthish.indexOf(".") + maxDecimals + (maxDecimals ? 1 : 0) ); } + if (trimZeroWhole && amountEthish.indexOf("0.") === 0) { + amountEthish = amountEthish.substring(1); + return amountEthish.substring(0, maxLength); + } if (amountEthish.length <= maxLength) { return `${ethers.utils.commify(amountEthish)}`; }