diff --git a/projects/sdk/src/constants/addresses.ts b/projects/sdk/src/constants/addresses.ts index 3ac0a7243e..edb309b2d1 100644 --- a/projects/sdk/src/constants/addresses.ts +++ b/projects/sdk/src/constants/addresses.ts @@ -15,7 +15,7 @@ export const addresses = { DEPOT: Address.make("0xDEb0f00071497a5cc9b4A6B96068277e57A82Ae2"), PIPELINE: Address.make("0xb1bE0000C6B3C62749b5F0c92480146452D15423"), ROOT: Address.make("0x77700005BEA4DE0A78b956517f099260C2CA9a26"), - USD_ORACLE: Address.make("0xE0AdBED7e2ac72bc7798c5DC33aFD77B068db7Fd"), + USD_ORACLE: Address.make("0xb24a70b71e4cca41eb114c2f61346982aa774180"), UNWRAP_AND_SEND_ETH_JUNCTION: Address.make("0x737Cad465B75CDc4c11B3E312Eb3fe5bEF793d96"), // ---------------------------------------- diff --git a/projects/ui/.eslintrc.js b/projects/ui/.eslintrc.js index 3fbec13c29..1cb064bc03 100644 --- a/projects/ui/.eslintrc.js +++ b/projects/ui/.eslintrc.js @@ -86,7 +86,7 @@ module.exports = { 'no-trailing-spaces': 0, // -- Emotion css prop on DOM element override - https://emotion.sh/docs/eslint-plugin-react - "react/no-unknown-property": ["error", { "ignore": ["css"] }], + 'react/no-unknown-property': ['error', { ignore: ['css'] }], // -- Other (to categorize) 'react/button-has-type': 0, @@ -101,8 +101,17 @@ module.exports = { 'no-continue': 0, 'import/extensions': 0, 'newline-per-chained-call': 0, - 'no-use-before-define': 0, - '@typescript-eslint/no-use-before-define': 'error', + 'no-use-before-define': 'off', + '@typescript-eslint/no-use-before-define': [ + 'error', + { + functions: false, + classes: true, + variables: true, + typedefs: true, + allowNamedExports: true, + }, + ], 'import/prefer-default-export': 0, 'react/jsx-props-no-spreading': 0, 'jsx-a11y/label-has-associated-control': 0, diff --git a/projects/ui/.prettierignore b/projects/ui/.prettierignore new file mode 100644 index 0000000000..5bf8b8a237 --- /dev/null +++ b/projects/ui/.prettierignore @@ -0,0 +1,7 @@ +./src/generated +./src/graph/graphql.schema.json +./src/graph/schema-bean.graphql +./src/graph/schema-beanft.graphql +./src/graph/schema-beanstalk.graphql +./src/graph/schema-snapshot1.graphql +./src/graph/schema-snapshot2.graphql diff --git a/projects/ui/src/components/Analytics/AdvancedChart.tsx b/projects/ui/src/components/Analytics/AdvancedChart.tsx index 3c04e6a35a..a673a78de2 100644 --- a/projects/ui/src/components/Analytics/AdvancedChart.tsx +++ b/projects/ui/src/components/Analytics/AdvancedChart.tsx @@ -13,14 +13,16 @@ import SelectDialog from './SelectDialog'; import { useChartSetupData } from './useChartSetupData'; import CalendarButton from './CalendarButton'; -type QueryData = { - time: Time, - value: number, +export type ChartQueryData = { + time: Time; + value: number; customValues: { - season: number + season: number; }; }; +type QueryData = ChartQueryData; + const AdvancedChart: FC<{ isMobile?: boolean }> = ({ isMobile = false }) => { const season = useSeason(); const chartSetupData = useChartSetupData(); @@ -92,17 +94,20 @@ const AdvancedChart: FC<{ isMobile?: boolean }> = ({ isMobile = false }) => { && timestamps.get(seasonData.season - 1) !== timestamps.get(seasonData.season) ) { const formattedTime = timestamps.get(seasonData.season); + const dataFormatter = chartSetupData[chartId].dataFormatter; + const _seasonData = dataFormatter ? dataFormatter(seasonData) : seasonData; + const formattedValue = chartSetupData[ chartId ].valueFormatter( - seasonData[chartSetupData[chartId].priceScaleKey] + _seasonData[chartSetupData[chartId].priceScaleKey] ); if (formattedTime > 0) { - output[chartId][seasonData.season] = { - time: formattedTime, + output[chartId][_seasonData.season] = { + time: formattedTime, value: formattedValue, customValues: { - season: seasonData.season + season: _seasonData.season } }; }; diff --git a/projects/ui/src/components/Analytics/CalendarButton.tsx b/projects/ui/src/components/Analytics/CalendarButton.tsx index fbb611c99b..77d46b7579 100644 --- a/projects/ui/src/components/Analytics/CalendarButton.tsx +++ b/projects/ui/src/components/Analytics/CalendarButton.tsx @@ -37,9 +37,8 @@ import CloseIcon from '@mui/icons-material/Close'; import { Range, Time } from 'lightweight-charts'; type CalendarProps = { - setTimePeriod: React.Dispatch< - React.SetStateAction | undefined> - >; + setTimePeriod: React.Dispatch | undefined>>; + storageKeyPrefix?: string; }; type CalendarContentProps = { @@ -47,13 +46,10 @@ type CalendarContentProps = { isMobile: boolean; range: DateRange | undefined; selectedPreset: string; - handleChange: ( - newRange?: DateRange, - _preset?: string - ) => void; + handleChange: (newRange?: DateRange, _preset?: string) => void; }; -const presetRanges: { +export const CalendarPresetRanges: { key: string; from: Date | undefined; to: Date | undefined; @@ -161,7 +157,7 @@ const CalendarContent: FC = ({ if (type === 'date') { const currentValue = inputValue; const currentTime = inputTime; - + setInputValue({ from: target === 'from' ? value : currentValue.from, to: target === 'to' ? value : currentValue.to, @@ -179,20 +175,30 @@ const CalendarContent: FC = ({ minutes: 5, }); - const currentDateFrom = currentValue.from ? parse(currentValue.from, 'MM/dd/yyyy', new Date()) : undefined; - const currentDateTo = currentValue.to ? parse(currentValue.to, 'MM/dd/yyyy', new Date()) : undefined; + const currentDateFrom = currentValue.from + ? parse(currentValue.from, 'MM/dd/yyyy', new Date()) + : undefined; + const currentDateTo = currentValue.to + ? parse(currentValue.to, 'MM/dd/yyyy', new Date()) + : undefined; if (isValid(parsedDate)) { - handleChange({ - from: target === 'from' ? parsedDate : currentDateFrom, - to: target === 'to' ? parsedDate : currentDateTo, - }, 'CUSTOM'); + handleChange( + { + from: target === 'from' ? parsedDate : currentDateFrom, + to: target === 'to' ? parsedDate : currentDateTo, + }, + 'CUSTOM' + ); setMonth(parsedDate); } else { - handleChange({ - from: undefined, - to: undefined, - }, 'ALL'); + handleChange( + { + from: undefined, + to: undefined, + }, + 'ALL' + ); } } else if (type === 'time') { const currentValue = inputTime; @@ -266,65 +272,75 @@ const CalendarContent: FC = ({ }} > {['from', 'to'].map((inputType) => { - const dateRange = range?.[inputType as keyof typeof inputValue]; - const formattedDate = dateRange ? format(dateRange, 'MM/dd/yyy') : undefined; - const formattedHour = dateRange ? format(dateRange, 'HH:mm') : undefined; + const formattedDate = dateRange + ? format(dateRange, 'MM/dd/yyy') + : undefined; + const formattedHour = dateRange + ? format(dateRange, 'HH:mm') + : undefined; - return ( - - { - handleInputChange('date', inputType, e.target.value); - }} - /> - - - - ), - }} - onChange={(e) => { - handleInputChange('time', inputType, e.target.value); - }} - onBlur={(e) => { - formatInputTimeOnBlur(inputType, e.target.value); - }} - /> - - )})} + return ( + + { + handleInputChange('date', inputType, e.target.value); + }} + /> + + + + ), + }} + onChange={(e) => { + handleInputChange('time', inputType, e.target.value); + }} + onBlur={(e) => { + formatInputTimeOnBlur(inputType, e.target.value); + }} + /> + + ); + })} = ({ flexGrow={1} justifyContent="space-between" > - {presetRanges.map((preset) => ( + {CalendarPresetRanges.map((preset) => ( + ))} + + + + )} + + + + + ); +}; + +export type SingleAdvancedChartProps = { + seriesData: ChartQueryData[]; + queryLoading?: boolean; + queryError?: boolean; +} & OmmitedV2DataProps & + ChartProps; + +const SingleAdvancedChart = (props: SingleAdvancedChartProps) => ( + + + +); + +export default SingleAdvancedChart; diff --git a/projects/ui/src/components/Analytics/formatters.ts b/projects/ui/src/components/Analytics/formatters.ts index 023180e84a..3c18cbab90 100644 --- a/projects/ui/src/components/Analytics/formatters.ts +++ b/projects/ui/src/components/Analytics/formatters.ts @@ -16,4 +16,6 @@ export const tickFormatUSD = (v: NumberLike) => `$${tickFormatTruncated(v)}`; export const tickFormatBeanPrice = (v: NumberLike) => `$${v.valueOf().toLocaleString('en-us', { minimumFractionDigits: 4 })}`; export const tickFormatRRoR = (value: any) => `${(parseFloat(value) * 100).toFixed(2)}`; export const valueFormatBeanAmount = (value: any) => Number(formatUnits(value, 6)); -export const tickFormatBeanAmount = (value: number) => value.toLocaleString('en-US', { maximumFractionDigits: 0 }); \ No newline at end of file +export const tickFormatBeanAmount = (value: number) => value.toLocaleString('en-US', { maximumFractionDigits: 0 }); +export const tickFormatSmallBN = (decimals?: number) => + (v: NumberLike) => new BigNumber(v.valueOf()).toExponential(decimals || 4) \ No newline at end of file diff --git a/projects/ui/src/components/Analytics/useChartSetupData.tsx b/projects/ui/src/components/Analytics/useChartSetupData.tsx index f7553de4bd..3babd375ce 100644 --- a/projects/ui/src/components/Analytics/useChartSetupData.tsx +++ b/projects/ui/src/components/Analytics/useChartSetupData.tsx @@ -1,4 +1,3 @@ - import React, { useMemo } from 'react'; import { LiquiditySupplyRatioDocument, @@ -97,6 +96,10 @@ type ChartSetupBase = { * price scales. */ shortTickFormatter: (v: number) => string | undefined; + /** + * + */ + dataFormatter?: (v: any) => any; }; type ChartSetup = ChartSetupBase & { diff --git a/projects/ui/src/components/App/index.tsx b/projects/ui/src/components/App/index.tsx index afef37f8c9..480fb7ddba 100644 --- a/projects/ui/src/components/App/index.tsx +++ b/projects/ui/src/components/App/index.tsx @@ -61,6 +61,7 @@ import FarmerDelegationsUpdater from '~/state/farmer/delegations/updater'; import VotingPowerPage from '~/pages/governance/votingPower'; import MorningUpdater from '~/state/beanstalk/sun/morning'; import MorningFieldUpdater from '~/state/beanstalk/field/morning'; +import BeanstalkCaseUpdater from '~/state/beanstalk/case/updater'; // import Snowflakes from './theme/winter/Snowflakes'; BigNumber.set({ EXPONENTIAL_AT: [-12, 20] }); @@ -121,6 +122,7 @@ export default function App() { + {/* ----------------------- * Farmer Updaters * ----------------------- */} diff --git a/projects/ui/src/components/Balances/SiloBalancesHistory.tsx b/projects/ui/src/components/Balances/SiloBalancesHistory.tsx index 3924cb0c28..909d4b563a 100644 --- a/projects/ui/src/components/Balances/SiloBalancesHistory.tsx +++ b/projects/ui/src/components/Balances/SiloBalancesHistory.tsx @@ -11,10 +11,10 @@ import { SEASON_RANGE_TO_COUNT, SeasonRange, } from '~/hooks/beanstalk/useSeasonsQuery'; +import useFarmerSiloHistory from '~/hooks/farmer/useFarmerSiloHistory'; import MockPlot from '../Silo/MockPlot'; import BlurComponent from '../Common/ZeroState/BlurComponent'; import WalletButton from '../Common/Connection/WalletButton'; -import useFarmerSiloHistory from '~/hooks/farmer/useFarmerSiloHistory'; const SiloBalancesHistory: React.FC<{}> = () => { // @@ -60,14 +60,16 @@ const SiloBalancesHistory: React.FC<{}> = () => { height={300} StatProps={{ title: 'Value Deposited', - titleTooltip: + titleTooltip: ( <> - The historical USD value of your Silo Deposits at the beginning of every Season.
+ The historical USD value of your Silo Deposits at the beginning + of every Season.
- Note: Unripe assets are valued based on the current Chop Rate. Earned Beans are shown upon Plant. + Note: Unripe assets are valued based on the current Chop Rate. + Earned Beans are shown upon Plant. - , + ), gap: 0.25, }} timeTabParams={timeTabParams} diff --git a/projects/ui/src/components/Nav/Buttons/PriceButton.tsx b/projects/ui/src/components/Nav/Buttons/PriceButton.tsx index def578050f..fbb269a02d 100644 --- a/projects/ui/src/components/Nav/Buttons/PriceButton.tsx +++ b/projects/ui/src/components/Nav/Buttons/PriceButton.tsx @@ -32,6 +32,7 @@ import wstETHLogo from '~/img/tokens/wsteth-logo.svg'; import { FC } from '~/types'; import useDataFeedTokenPrices from '~/hooks/beanstalk/useDataFeedTokenPrices'; import useSdk from '~/hooks/sdk'; +import useTwaDeltaB from '~/hooks/beanstalk/useTwaDeltaB'; import FolderMenu from '../FolderMenu'; const poolLinks: { [key: string]: string } = { @@ -56,6 +57,9 @@ const PriceButton: FC = ({ ...props }) => { (state) => state._bean.pools ); + const { data: twaDeltaBs } = useTwaDeltaB(); + const twaDeltaB = twaDeltaBs?.total || ZERO_BN; + const toggleDisplayedPools = (e: React.MouseEvent) => { e.stopPropagation(); e.nativeEvent.stopImmediatePropagation(); @@ -155,7 +159,7 @@ const PriceButton: FC = ({ ...props }) => { Total TWA deltaB:{' '} {beanTokenData.deltaB.gte(0) && '+'} - {displayBN(beanTokenData.deltaB, true)} + {displayBN(twaDeltaB, true)} ) : ( diff --git a/projects/ui/src/components/Nav/Buttons/SeasonalLiquidityAndPriceByPool.graphql b/projects/ui/src/components/Nav/Buttons/SeasonalLiquidityAndPriceByPool.graphql new file mode 100644 index 0000000000..e2ad50c298 --- /dev/null +++ b/projects/ui/src/components/Nav/Buttons/SeasonalLiquidityAndPriceByPool.graphql @@ -0,0 +1,27 @@ +query SeasonalLiquidityAndPriceByPool( + $first: Int, + $season_lte: Int! + $season_gte: Int! + $pools: [String!] +) { + seasons: poolHourlySnapshots( + first: $first + orderBy: season + orderDirection: desc + where: { + season_lte: $season_lte, + season_gte: $season_gte, + liquidityUSD_not: "0", + pool_in: $pools + } + ) { + id + season + pool { + id + lastPrice + liquidityUSD + } + createdAt + } +} diff --git a/projects/ui/src/components/Nav/Buttons/SunButton.graphql b/projects/ui/src/components/Nav/Buttons/SunButton.graphql index 0ea0aeabc8..9e94f1dc76 100644 --- a/projects/ui/src/components/Nav/Buttons/SunButton.graphql +++ b/projects/ui/src/components/Nav/Buttons/SunButton.graphql @@ -1,27 +1,47 @@ -query SunButton { +query SunButton($season_lte: Int!) { seasons( - first: 24, - orderBy: season, + # 25 to get data for the 24th + first: 25 + orderBy: season orderDirection: desc + where: { season_lte: $season_lte } ) { id season price - deltaBeans # total change in supply - rewardBeans # amount from Reward on Sunrise + deltaBeans + rewardBeans + beans + deltaB } fields: fieldHourlySnapshots( - first: 24, - where: { + first: 25 + where: { field: "0xc1e088fc1323b20bcbee9bd1b9fc9546db5624c5" + season_lte: $season_lte + caseId_not: null } - orderBy: season, + orderBy: season orderDirection: desc ) { id season issuedSoil - temperature + temperature podRate + soilSoldOut + blocksToSoldOutSoil + sownBeans + caseId + } + silo: siloHourlySnapshots( + first: 25 + orderBy: season + orderDirection: desc + where: { season_lte: $season_lte, beanToMaxLpGpPerBdvRatio_gt: 0 } + ) { + id + season + beanToMaxLpGpPerBdvRatio } } \ No newline at end of file diff --git a/projects/ui/src/components/Nav/Buttons/SunButton.tsx b/projects/ui/src/components/Nav/Buttons/SunButton.tsx index e580260529..dc88c63ee3 100644 --- a/projects/ui/src/components/Nav/Buttons/SunButton.tsx +++ b/projects/ui/src/components/Nav/Buttons/SunButton.tsx @@ -1,212 +1,365 @@ -import React, { useMemo } from 'react'; +import React from 'react'; import { ButtonProps, Stack, - Typography, - useMediaQuery, - Box, Grid, Divider, + Breakpoint, + GridProps, + Typography, + Box, + useTheme, } from '@mui/material'; import BigNumber from 'bignumber.js'; -import { useSelector } from 'react-redux'; import drySeasonIcon from '~/img/beanstalk/sun/dry-season.svg'; import rainySeasonIcon from '~/img/beanstalk/sun/rainy-season.svg'; -import SunriseButton from '~/components/Sun/SunriseButton'; -import { SunButtonQuery, useSunButtonQuery } from '~/generated/graphql'; import useSeason from '~/hooks/beanstalk/useSeason'; -import { toTokenUnitsBN } from '~/util'; -import { BEAN } from '~/constants/tokens'; -import { NEW_BN } from '~/constants'; -import { AppState } from '~/state'; +import { NEW_BN, ZERO_BN } from '~/constants'; +import { useAppSelector } from '~/state'; +import { FC } from '~/types'; +import useSeasonsSummary, { + SeasonSummary, +} from '~/hooks/beanstalk/useSeasonsSummary'; +import Row from '~/components/Common/Row'; +import { IconSize } from '~/components/App/muiTheme'; +import SunriseButton from '~/components/Sun/SunriseButton'; +import { LibCases } from '~/lib/Beanstalk/LibCases'; import FolderMenu from '../FolderMenu'; import SeasonCard from '../../Sun/SeasonCard'; -import usePeg from '~/hooks/beanstalk/usePeg'; -import { FC } from '~/types'; -const castField = (data: SunButtonQuery['fields'][number]) => ({ - season: new BigNumber(data.season), - issuedSoil: toTokenUnitsBN(data.issuedSoil, BEAN[1].decimals), - temperature: new BigNumber(data.temperature), - podRate: new BigNumber(data.podRate), -}); -const castSeason = (data: SunButtonQuery['seasons'][number]) => ({ - season: new BigNumber(data.season), - price: new BigNumber(data.price), - rewardBeans: toTokenUnitsBN( - data.season <= 6074 ? data.deltaBeans : data.rewardBeans, - BEAN[1].decimals - ), -}); +type GridConfigProps = Pick; -const MAX_ITEMS = 8; +export type SeasonSummaryColumn = { + title: string; + widths: GridConfigProps; + subtitle?: string; + align?: 'left' | 'right'; + render: (d: SeasonSummary) => string | JSX.Element; +}; -const PriceButton: FC = ({ ...props }) => { - /// DATA - const season = useSeason(); - const awaiting = useSelector( - (state) => state._beanstalk.sun.sunrise.awaiting - ); - const { data } = useSunButtonQuery({ fetchPolicy: 'cache-and-network' }); - const beanstalkField = useSelector( - (state) => state._beanstalk.field - ); - const peg = usePeg(); +const getDelta = (value: BigNumber | undefined) => { + if (!value) return ''; + return value.gte(0) ? '+' : '-'; +}; - const bySeason = useMemo(() => { - if (data?.fields && data?.seasons) { - type MergedSeason = ReturnType & - ReturnType; +const maxBean2MaxLPRatio = new BigNumber( + LibCases.MAX_BEAN_MAX_LP_GP_PER_BDV_RATIO +).div(1e18); +const minBean2MaxLPRatio = new BigNumber( + LibCases.MIN_BEAN_MAX_LP_GP_PER_BDV_RATIO +).div(1e18); - // Build mapping of season => data - const merged: { [key: number]: MergedSeason } = {}; - data.fields.forEach((_f) => { - // fixme: need intermediate type? - // @ts-ignore - if (_f) merged[_f.season] = { ...castField(_f) }; - }); - data.seasons.forEach((_s) => { - if (_s) merged[_s.season] = { ...merged[_s.season], ...castSeason(_s) }; - }); +const colConfig: Record = { + season: { + title: 'Season', + align: 'left', + widths: { xs: 0.65 }, + render: ({ beanMints, season }) => ( + + {beanMints.value && beanMints.value?.lte(0) ? ( + + ) : ( + + )} + + {season.value?.toString() || '-'} + + + ), + }, + beanMints: { + title: 'New Beans', + subtitle: 'Beans minted', + widths: { xs: 1.15 }, + render: ({ beanMints: { value } }) => ( + + + {`${value?.gt(0) ? '+' : ''}${value?.abs().toFormat(0) || 0}`} + + + ), + }, + maxSoil: { + title: 'Max Soil', + subtitle: 'Beans to lend', + widths: { xs: 1.35 }, + render: ({ maxSoil }) => ( + + {maxSoil.value?.abs().toFormat(2) || 0} + + ), + }, + maxTemperature: { + title: 'Max Temperature', + subtitle: 'Max interest rate', + widths: { xs: 1.65 }, + render: ({ maxTemperature }) => ( + + + {maxTemperature.value?.abs().toFormat(0) || '-'}%{' '} + + {`(${getDelta(maxTemperature.delta)}${maxTemperature?.delta?.abs().toFormat() || '-'}%)`} + + + {maxTemperature.display && ( + + {maxTemperature.display} + + )} + + ), + }, + bean2MaxLPScalar: { + title: 'Bean:Max LP Ratio', + subtitle: 'Relative reward for Dep. Bean', + widths: { xs: 1.65 }, + render: ({ bean2MaxLPRatio }) => { + const isAtMinOrMax = + bean2MaxLPRatio.value?.eq(maxBean2MaxLPRatio) || + bean2MaxLPRatio.value?.eq(minBean2MaxLPRatio); - // Sort latest season first and return as array - return Object.keys(merged) - .sort((a, b) => parseInt(b, 10) - parseInt(a, 10)) - .reduce((prev, curr) => { - prev.push(merged[curr as unknown as number]); - return prev; - }, []); - } - return []; - }, [data]); + const delta = isAtMinOrMax + ? ZERO_BN + : bean2MaxLPRatio?.delta?.div(100).abs(); + + return ( + + + {bean2MaxLPRatio.value?.toFormat(1) || '-'}{' '} + {bean2MaxLPRatio.delta && ( + + {`(${getDelta(bean2MaxLPRatio.delta)}${ + delta?.toFormat() || '-' + }%)`} + + )} + + {bean2MaxLPRatio.display && ( + + {bean2MaxLPRatio.display} + + )} + + ); + }, + }, + price: { + title: 'Price', + subtitle: 'Price of Bean', + widths: { xs: 1.5 }, + render: ({ price }) => ( + + + ${price.value?.toFixed(2) || '-'} + + + {price.display || '-'} + + + ), + }, + l2sr: { + title: 'Liquidity to Supply Ratio', + subtitle: 'Amount of Liquidity / Supply', + widths: { xs: 1.7 }, + render: ({ l2sr }) => ( + + + {l2sr.value?.times(100).toFormat(0) || '-'}% + + + {l2sr.display || '-'} + + + ), + }, + podRate: { + title: 'Pod Rate', + subtitle: 'Debt ratio', + widths: { xs: 1.25 }, + render: ({ podRate }) => ( + + + {`${podRate.value?.times(100).toFormat(0) || '-'}%`} + + + {podRate.display || '-'} + + + ), + }, + deltaPodDemand: { + title: 'Delta Demand', + subtitle: 'Change in Soil', + widths: { xs: 1.1 }, + render: ({ deltaPodDemand }) => ( + + + {deltaPodDemand.display || '-'} + + + ), + }, +}; - /// Theme - const isTiny = useMediaQuery('(max-width:350px)'); +const MAX_ITEMS = 5; - /// Button Content - const isLoading = season.eq(NEW_BN); - const startIcon = isTiny ? undefined : ( - - ); +const MAX_TABLE_WIDTH = 1568; - /// Table Content - const tableContent = ( - - {/* Past Seasons */} - - {/* table header */} - - - - Season - - - New Beans - - - Max Soil - - - - Max Temperature - - - ) => ( + ({ + position: 'relative', + width: `min(calc(100vw - 20px), ${MAX_TABLE_WIDTH}px)`, + [t.breakpoints.up('lg')]: { + width: `min(calc(100vw - 40px), ${MAX_TABLE_WIDTH}px)`, + }, + })} + > + + + + + {/* Header */} + - Pod Rate - - + {Object.values(colConfig).map((col) => ( + + + + {col.title} + + {col.subtitle && ( + + {col.subtitle} + + )} + + + ))} + + + {/* Rows */} + - Delta Demand - - + + {seasonsSummary.map((summary, i) => ( + + ))} + + - - {bySeason.map((s, i) => { - const deltaTemperature = - bySeason[i + 1]?.temperature && s.temperature - ? s.temperature.minus(bySeason[i + 1].temperature) - : undefined; - return ( - - ); - })} - + + + +); + +const SeasonIcon = ({ beanMints }: { beanMints: BigNumber | undefined }) => { + const awaiting = useAppSelector((s) => s._beanstalk.sun.sunrise.awaiting); + return ( + + ); +}; + +const PriceButton: FC = ({ ...props }) => { + /// DATA + const season = useSeason(); + const theme = useTheme(); + const summary = useSeasonsSummary(); + + /// Button Content + const isLoading = season.eq(NEW_BN) || summary.loading; return ( + } buttonContent={<>{isLoading ? '0000' : season.toFixed()}} - drawerContent={{tableContent}} - popoverContent={tableContent} + drawerContent={ + + + + } + popoverContent={} hideTextOnMobile - popperWidth="700px" + popperWidth="100%" hotkey="opt+2, alt+2" - zIndex={997} + zIndex={100} zeroTopLeftRadius zeroTopRightRadius + popperSx={{ + [`@media (min-width: ${theme.breakpoints.values.lg - 1}px)`]: { + paddingRight: '20px', + }, + }} {...props} /> ); diff --git a/projects/ui/src/components/Nav/FolderMenu.tsx b/projects/ui/src/components/Nav/FolderMenu.tsx index 1dbf22d540..fced56e075 100644 --- a/projects/ui/src/components/Nav/FolderMenu.tsx +++ b/projects/ui/src/components/Nav/FolderMenu.tsx @@ -9,6 +9,8 @@ import { PopperPlacementType, Typography, useMediaQuery, + Theme, + SxProps, } from '@mui/material'; import { useTheme } from '@mui/material/styles'; import DropdownIcon from '~/components/Common/DropdownIcon'; @@ -45,6 +47,7 @@ const FolderMenu: FC< zeroTopLeftRadius?: boolean; popoverPlacement?: PopperPlacementType; navDrawer?: boolean; + popperSx?: SxProps; } & ButtonProps > = ({ startIcon, @@ -63,6 +66,7 @@ const FolderMenu: FC< zeroTopLeftRadius, popoverPlacement, navDrawer, + popperSx, ...buttonProps }) => { // Theme @@ -116,6 +120,7 @@ const FolderMenu: FC< } window.addEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { @@ -186,6 +191,7 @@ const FolderMenu: FC< placement={popoverPlacement || 'bottom-start'} disablePortal sx={{ + ...popperSx, zIndex: zIndex, visibility: mobileWindow ? 'hidden' : 'visible', }} @@ -206,7 +212,7 @@ const FolderMenu: FC< onResizeCapture={undefined} > ({ + sx={{ background: BeanstalkPalette.white, width: popperWidth !== undefined ? popperWidth : '325px', borderBottomLeftRadius: borderRadius * 1, @@ -217,13 +223,11 @@ const FolderMenu: FC< borderWidth: 1, borderStyle: 'solid', boxSizing: 'border-box', - // px: 1, - // py: 1, - boxShadow: _theme.shadows[0], + boxShadow: theme.shadows[0], // Should be below the zIndex of the Button. zIndex: zIndex, mt: '-1px', - })} + }} > {popoverContent} diff --git a/projects/ui/src/components/Silo/Actions/Withdraw.tsx b/projects/ui/src/components/Silo/Actions/Withdraw.tsx index e6473cdb41..9bc805e939 100644 --- a/projects/ui/src/components/Silo/Actions/Withdraw.tsx +++ b/projects/ui/src/components/Silo/Actions/Withdraw.tsx @@ -101,11 +101,12 @@ const WithdrawForm: FC< ); const claimableTokens = useMemo( - // FIXME: Temporarily disabled Withdraws of Bean:ETH LP in Bean/WETH, needs routing code + // FIXME: Disable remove single sided liquidity for Well tokens for now. () => [ whitelistedToken, ...((whitelistedToken.isLP && whitelistedToken !== sdk.tokens.BEAN_ETH_WELL_LP && + whitelistedToken !== sdk.tokens.BEAN_WSTETH_WELL_LP && pool?.tokens) || []), ], diff --git a/projects/ui/src/components/Silo/Overview.tsx b/projects/ui/src/components/Silo/Overview.tsx index a6430ec63c..72ab48cdc7 100644 --- a/projects/ui/src/components/Silo/Overview.tsx +++ b/projects/ui/src/components/Silo/Overview.tsx @@ -74,7 +74,7 @@ const Overview: FC<{ const _season = dataPoint ? dataPoint.season : season; const _date = dataPoint ? dataPoint.date : latestData ? latestData.date : ''; - const _value = dataPoint ? BigNumber(dataPoint.value) : breakdown.states.deposited.value; + const _value = BigNumber(dataPoint?.value ?? latestData?.value ?? 0); return ( )}, - [breakdown.states.deposited.value, data.deposits, season] + [data.deposits, season] ); const stalkStats = useCallback((dataPoint: BaseDataPoint | undefined) => { diff --git a/projects/ui/src/components/Silo/SeedGauge/Bean2MaxLPRatio.tsx b/projects/ui/src/components/Silo/SeedGauge/Bean2MaxLPRatio.tsx new file mode 100644 index 0000000000..7857caafff --- /dev/null +++ b/projects/ui/src/components/Silo/SeedGauge/Bean2MaxLPRatio.tsx @@ -0,0 +1,253 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { Box, Link, Stack, Typography } from '@mui/material'; +import useSeedGauge from '~/hooks/beanstalk/useSeedGauge'; +import useSdk from '~/hooks/sdk'; +import TokenIcon from '~/components/Common/TokenIcon'; +import useElementDimensions from '~/hooks/display/useElementDimensions'; +import { BeanstalkPalette } from '~/components/App/muiTheme'; +import useBeanstalkCaseData from '~/hooks/beanstalk/useBeanstalkCaseData'; +import { displayFullBN } from '~/util'; +import { ZERO_BN } from '~/constants'; +import BigNumber from 'bignumber.js'; + +type IBean2MaxLPRatio = { + data: ReturnType['data']; +}; + +const BAR_WIDTH = 8; +const BAR_HEIGHT = 55; +const SELECTED_BAR_HEIGHT = 65; +const MIN_SPACING = 10; +const MAX_SPACING = 12; + +const Bar = ({ + isSelected, + isFlashing, +}: { + isSelected: boolean; + isFlashing: boolean; +}) => ( + +); + +/* + * We calculate the number of bars with spacing using the formula: + * + * w = component width + * b = bar width + * s = spacing + * x = number of bars + * + * w = b(x) + s(x - 1) + * x = Floor((w + s) / (b + s)) + */ +const calculateNumBarsWithSpacing = (width: number, spacing: number) => { + const relativeWidth = width + spacing; + const unitWidth = BAR_WIDTH + spacing; + return Math.floor(relativeWidth / unitWidth); +}; + +const getBarIndex = ( + min: BigNumber, + max: BigNumber, + value: BigNumber, + numBars: number +) => { + const normalizedValue = value.minus(min).div(max.minus(min)); + const barIndex = normalizedValue.times(numBars - 1); + + return Math.floor(barIndex.toNumber()); +}; + +const LPRatioShiftChart = ({ data }: IBean2MaxLPRatio) => { + const containerRef = React.useRef(null); + const { width } = useElementDimensions(containerRef); + const [minBars, setMinBars] = useState(0); + const [maxBars, setMaxBars] = useState(0); + const [numBars, setNumBars] = useState(0); + + const caseData = useBeanstalkCaseData(); + + const setValues = (values: [min: number, max: number, num: number]) => { + setMinBars(values[0]); + setMaxBars(values[1]); + setNumBars(values[2]); + }; + + useEffect(() => { + const _maxBars = calculateNumBarsWithSpacing(width, MIN_SPACING); + const _minBars = calculateNumBarsWithSpacing(width, MAX_SPACING); + + const values = [_minBars, _maxBars, undefined]; + + if (numBars === 0 || maxBars <= _minBars) { + values[2] = _maxBars; + } else if (minBars >= _maxBars) { + values[2] = _minBars; + } + + if (values[2] !== undefined) { + setValues(values as [number, number, number]); + } + }, [numBars, width, minBars, maxBars]); + + const arr = Array.from({ length: numBars }); + + const bean2MaxLP = data.bean2MaxLPRatio.value; + const bean2MaxLPScalar = caseData?.delta.bean2MaxLPGPPerBdvScalar; + const min = data.bean2MaxLPRatio.min; + const max = data.bean2MaxLPRatio.max; + + const isAtMax = bean2MaxLP && bean2MaxLP.eq(data.bean2MaxLPRatio.max); + const isAtMin = bean2MaxLP && bean2MaxLP.eq(data.bean2MaxLPRatio.min); + + const increasing = !isAtMax && bean2MaxLPScalar?.gt(0); + const decreasing = !isAtMin && bean2MaxLPScalar?.lt(0); + + const selectedIndex = + bean2MaxLP && getBarIndex(min, max, bean2MaxLP, numBars); + + const addIndex = increasing ? 1 : decreasing ? -1 : 0; + + const neighborIndex = + (increasing || decreasing) && selectedIndex && selectedIndex + addIndex; + + const deltaPct = isAtMax || isAtMin ? ZERO_BN : bean2MaxLPScalar; + + const SubTitle = () => { + let displayStr = ''; + if (isAtMax || isAtMin) { + displayStr = `Already at ${isAtMin ? 'minimum' : 'maximum'}`; + } else { + displayStr = `Expected ${!decreasing ? 'increase' : 'decrease'} of ${ + deltaPct?.eq(0) ? '0' : deltaPct?.abs().toFormat(1) + }% next Season`; + } + + return {displayStr}; + }; + + return ( + + + + {bean2MaxLP ? displayFullBN(bean2MaxLP, 2) : '--'}%{' '} + + Bean to Max LP Ratio + + + + + + {arr.map((_, i) => { + const isSelected = !!(bean2MaxLP && selectedIndex === i); + const flashing = !!(bean2MaxLP && neighborIndex === i); + return ( + + ); + })} + + + + + {data.bean2MaxLPRatio.min.toFormat(0)}% + + Minimum + + + + {data.bean2MaxLPRatio.max.toFormat(0)}% + + Maximum + + + + ); +}; + +const Bean2MaxLPRatio = ({ data }: IBean2MaxLPRatio) => { + const sdk = useSdk(); + + const maxLP = useMemo(() => { + if (!data?.gaugeData) return; + const arr = Object.entries(data.gaugeData); + const sorted = [...arr].sort(([_ak, a], [_bk, b]) => { + const diff = Number( + b.gaugePointsPerBdv.minus(a.gaugePointsPerBdv).toString() + ); + return diff; + }); + + return sdk.tokens.findByAddress(sorted[0][0] || ''); + }, [data?.gaugeData, sdk]); + + return ( + + + + + + Seed reward for Deposited Beans as a % of the Seed reward for the + Max LP token + + + Beanstalk adjusts the Seed reward of Beans and LP each Season to + change the incentives for Conversions, which contributes to peg + maintenance. + + + + {maxLP && ( + + )} + + {maxLP?.symbol || '--'} is currently the Max LP, i.e., the LP + token with the highest Gauge Points per BDV. + + + + Read more about the Bean to Max LP Ratio + + + + + + + + ); +}; + +export default Bean2MaxLPRatio; diff --git a/projects/ui/src/components/Silo/SeedGauge/SeasonsToCatchUpInfo.tsx b/projects/ui/src/components/Silo/SeedGauge/SeasonsToCatchUpInfo.tsx new file mode 100644 index 0000000000..f64f3291e5 --- /dev/null +++ b/projects/ui/src/components/Silo/SeedGauge/SeasonsToCatchUpInfo.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { + Box, + Divider, + Link as MuiLink, + Stack, + Typography, +} from '@mui/material'; +import SingleAdvancedChart from '~/components/Analytics/SingleAdvancedChart'; +import { ChartQueryData } from '~/components/Analytics/AdvancedChart'; +import useChartTimePeriodState from '~/hooks/display/useChartTimePeriodState'; +import BigNumber from 'bignumber.js'; + +type SeasonsToCatchUpInfoProps = { + seriesData: ChartQueryData[]; + queryLoading: boolean; + queryError: boolean; + timeState: ReturnType; +}; + +const SeasonsToCatchUpInfo = (props: SeasonsToCatchUpInfoProps) => ( + + + + + Target Seasons to Catch Up is set to 4320 Seasons, or 6 months. + + + This determines the rate at which new Depositors catch up to existing + Depositors in terms of Grown Stalk per BDV. + + + + During periods of many new Deposits, the Grown Stalk per BDV will + decrease. During periods of few new Deposits, the Grown Stalk per BDV + will increase. + + + Read more about the Target Seasons to Catch Up + + + + + new BigNumber(val).toFormat(6)} + drawPegLine={false} + {...props} + /> + + +); +export default SeasonsToCatchUpInfo; diff --git a/projects/ui/src/components/Silo/SeedGauge/SeedGaugeTable.tsx b/projects/ui/src/components/Silo/SeedGauge/SeedGaugeTable.tsx new file mode 100644 index 0000000000..ab6b66cd88 --- /dev/null +++ b/projects/ui/src/components/Silo/SeedGauge/SeedGaugeTable.tsx @@ -0,0 +1,479 @@ +import React, { useMemo, useState } from 'react'; +import { + Box, + Breakpoint, + Card, + Chip, + Grid, + GridProps, + Stack, + Switch, + Tooltip, + Typography, +} from '@mui/material'; +import { ERC20Token } from '@beanstalk/sdk'; +import BigNumber from 'bignumber.js'; +import useSdk from '~/hooks/sdk'; +import { displayFullBN } from '~/util'; +import useSeedGauge, { + TokenSeedGaugeInfo, +} from '~/hooks/beanstalk/useSeedGauge'; +import { + IconSize, + BeanstalkPalette as Palette, +} from '~/components/App/muiTheme'; +import { ArrowDownward, ArrowUpward, ArrowRight } from '@mui/icons-material'; +import logo from '~/img/tokens/bean-logo.svg'; +import { Link as RouterLink } from 'react-router-dom'; +import { ZERO_BN } from '~/constants'; +import HelpOutlineIcon from '@mui/icons-material/HelpOutline'; + +type GridConfigProps = Pick; + +type ISeedGaugeRow = { + token: ERC20Token; + gaugePointRatio: BigNumber; +} & TokenSeedGaugeInfo; + +type ISeedGaugeColumn = { + key: string; + header: string; + headerTooltip?: string; + render: (data: ISeedGaugeRow) => string | JSX.Element; + align?: 'left' | 'right'; + mobileAlign?: 'left' | 'right'; +}; + +const GridConfig: Record< + string, + { + advanced: GridConfigProps; + // basic is optional b/c the default view limits which ones are shown + basic?: GridConfigProps; + } +> = { + token: { + advanced: { xs: 2, lg: 2 }, + basic: { xs: 2, sm: 4.5 }, + }, + totalBDV: { + advanced: { xs: 2 }, + }, + gaugePoints: { + advanced: { xs: 2 }, + }, + gaugePointsPerBDV: { + advanced: { xs: 2 }, + }, + optimalBDVPct: { + advanced: { xs: 2, lg: 2 }, + basic: { xs: 5, sm: 3 }, + }, + currentLPBDVPct: { + advanced: { xs: 2 }, + basic: { xs: 5, sm: 4.5 }, + }, +}; + +const displayBNValue = ( + value: BigNumber | undefined, + defaultValue?: string | number +) => { + if (!value || value.eq(0)) return defaultValue?.toString() || 'N/A'; + return displayFullBN(value, 2); +}; + +const isNonZero = (value: BigNumber | undefined) => value && !value.eq(0); + +const TokenColumn: ISeedGaugeColumn = { + key: 'token', + header: 'Token', + align: 'left', + render: ({ token }) => ( + + + {token.symbol} + + ), +}; + +const chipSx = { + '& .MuiChip-label': { + padding: '4px', + }, + height: 'unset', + width: 'unset', + borderRadius: '4px', + fontSize: '16px', // set manually + lineHeight: '16px', // set manually + background: Palette.lightestGreen, + color: Palette.logoGreen, +}; + +const BDVPctColumns: ISeedGaugeColumn[] = [ + { + key: 'optimalBDVPct', + header: 'Optimal LP BDV %', + headerTooltip: + 'The Beanstalk DAO sets an optimal distribution of Deposited LP BDV amongst whitelisted LP tokens. Seed rewards adjust for a given whitelisted LP token based on the difference between the current and optimal distribution.', + render: ({ optimalPctDepositedBdv }) => { + if (optimalPctDepositedBdv.eq(0)) { + return N/A; + } + return ( + + ); + }, + }, + { + key: 'currentLPBDVPct', + header: 'Current LP BDV %', + render: ({ + optimalPctDepositedBdv, + currentPctDepositedBdv, + isAllocatedGP, + }) => { + if (!isAllocatedGP) { + return N/A; + } + const isOptimal = currentPctDepositedBdv.eq(optimalPctDepositedBdv); + + return ( + + ); + }, + }, +]; + +const basicViewColumns: ISeedGaugeColumn[] = [TokenColumn, ...BDVPctColumns]; + +const advancedViewColumns: ISeedGaugeColumn[] = [ + TokenColumn, + { + key: 'totalBDV', + header: 'Total BDV', + render: ({ totalBdv }) => ( + + + + {displayBNValue(totalBdv, 0)} + + + ), + }, + { + key: 'gaugePoints', + header: 'Gauge Points', + headerTooltip: + 'Gauge Points determine how the Grown Stalk issued in a Season should be distributed between whitelisted LP tokens.', + render: ({ gaugePoints, isAllocatedGP }) => ( + + {isAllocatedGP ? displayBNValue(gaugePoints, 0) : 'N/A'} + + ), + }, + { + key: 'gaugePointsPerBDV', + header: 'Gauge Points per BDV', + headerTooltip: + 'The whitelisted LP token with the highest Gauge Points per BDV is the Max LP token.', + render: ({ gaugePointsPerBdv, isAllocatedGP }) => ( + + {isAllocatedGP + ? gaugePointsPerBdv.eq(0) + ? 0 + : gaugePointsPerBdv.lte(0.0001) + ? '<0.0001' + : gaugePointsPerBdv.toFormat(4) + : 'N/A'} + + ), + }, + ...BDVPctColumns, +]; + +const useTableConfig = ( + advancedView: boolean, + gaugeData: ReturnType['data'] +) => { + const sdk = useSdk(); + const rowData = useMemo(() => { + const baseTokens = [...sdk.tokens.siloWhitelistedWellLP] as ERC20Token[]; + const tokens = advancedView + ? [ + sdk.tokens.BEAN, + ...baseTokens, + sdk.tokens.UNRIPE_BEAN, + sdk.tokens.UNRIPE_BEAN_WSTETH, + ] + : baseTokens; + + const totalGaugePoints = Object.values( + gaugeData.gaugeData + ).reduce( + (prev, curr) => prev.plus(curr.gaugePoints || 0), + ZERO_BN + ); + + const mappedData = tokens.reduce((prev, token) => { + const gaugeInfo = gaugeData?.gaugeData?.[token.address]; + + if (gaugeInfo) { + const ratio = gaugeInfo.gaugePoints.div(totalGaugePoints); + + prev.push({ + token, + ...gaugeInfo, + gaugePointRatio: totalGaugePoints.gt(0) ? ratio : ZERO_BN, + }); + } + + return prev; + }, []); + + return mappedData; + }, [sdk, advancedView, gaugeData?.gaugeData]); + + return rowData; +}; + +const ExpectedSeedRewardDirection = (row: ISeedGaugeRow) => { + const delta = row.optimalPctDepositedBdv + .minus(row.currentPctDepositedBdv) + .abs(); + + // Gauge points don't change if between 0.01% + const optimal = delta.lt(0.01); + + // seed rewards don't increase if every well has all gauge points + const maxed = row.gaugePointRatio.eq(1); + + if (!row.gaugePoints || row.gaugePoints.lte(0) || optimal || maxed) { + return null; + } + + const isBelow = row.currentPctDepositedBdv?.lt(row.optimalPctDepositedBdv); + + const direction = isBelow ? 'increase' : 'decrease'; + const Arrow = isBelow ? ArrowUpward : ArrowDownward; + return ( + + + + Expected Seed Reward {direction} next Season + + + ); +}; + +const GridColumn = ({ + column, + isAdvanced, + ...gridProps +}: { + column: ISeedGaugeColumn; + isAdvanced: boolean; +} & GridProps) => { + const configKey = isAdvanced ? 'advanced' : 'basic'; + const config = GridConfig[column.key]; + const selectedConfig = config?.[configKey]; + + if (!(column.key in GridConfig) || !selectedConfig) return null; + + return ( + ({ + [breakpoints.down('lg')]: { + textAlign: column.mobileAlign || column.align || 'right', + justifyContent: + (column.mobileAlign || column.align) === 'left' + ? 'flex-start' + : 'flex-end', + }, + ...gridProps.sx, + })} + /> + ); +}; + +const ARROW_WIDTH = '20px'; + +const RowSx = { + display: 'flex', + py: 1.5, + px: 2, + borderWidth: '0.5px', + borderColor: 'divider', + background: 'white', + '&:hover': { + borderColor: 'primary.main', + backgroundColor: 'primary.light', + }, +}; + +const lastChildSx = { + '&:last-child': { + pr: { + xs: 0, + md: ARROW_WIDTH, + }, + }, +}; + +const ArrowRightAdornment = () => ( + + + +); + +const SeedGaugeTable = ({ + data, + onToggleAdvancedMode, +}: { + data: ReturnType['data']; + onToggleAdvancedMode: (v: boolean) => void; +}) => { + const [isAdvanced, setIsAdvanced] = useState(false); + const rows = useTableConfig(isAdvanced, data); + const cols = isAdvanced ? advancedViewColumns : basicViewColumns; + + return ( + + + + + {/* Show Advanced */} + + + Show additional information + + { + setIsAdvanced((prev) => !prev); + onToggleAdvancedMode(isAdvanced); + }} + inputProps={{ 'aria-label': 'controlled' }} + /> + + + {/* Headers */} + + + {cols.map((column) => ( + + + {column.header} + {column.headerTooltip ? ( + + + + ) : null} + + + ))} + + + + + + {/* Rows */} + + {rows.map((row, i) => ( + + + + + + {cols.map((column, j) => ( + + + {column.render(row)} + {j === cols.length - 1 && } + + + ))} + + + + + + + ))} + + + + ); +}; + +export default SeedGaugeTable; diff --git a/projects/ui/src/components/Silo/SeedGauge/index.tsx b/projects/ui/src/components/Silo/SeedGauge/index.tsx new file mode 100644 index 0000000000..dbd2cf4016 --- /dev/null +++ b/projects/ui/src/components/Silo/SeedGauge/index.tsx @@ -0,0 +1,286 @@ +import { Box, Card, Grid, Stack, Typography } from '@mui/material'; +import React, { useEffect, useMemo, useState } from 'react'; +import DropdownIcon from '~/components/Common/DropdownIcon'; +import useSeedGauge, { + TokenSeedGaugeInfo, +} from '~/hooks/beanstalk/useSeedGauge'; +import { displayFullBN } from '~/util'; +import { ZERO_BN } from '~/constants'; +import useSdk from '~/hooks/sdk'; +import { Token } from '@beanstalk/sdk'; +import BeanProgressIcon from '~/components/Common/BeanProgressIcon'; +import useChartTimePeriodState from '~/hooks/display/useChartTimePeriodState'; +import useAvgSeedsPerBDV from '~/hooks/beanstalk/useAvgSeedsPerBDV'; +import SeasonsToCatchUpInfo from './SeasonsToCatchUpInfo'; +import SeedGaugeTable from './SeedGaugeTable'; +import Bean2MaxLPRatio from './Bean2MaxLPRatio'; + +const TARGET_SEASONS_TO_CATCH_UP = 4320; +const MONTHS_TO_CATCH_UP = 6; + +interface ISeedGaugeCardInfo { + title: string; + subtitle: string | JSX.Element; +} + +interface ISeedGaugeInfoCardProps extends ISeedGaugeCardInfo { + active: boolean; + loading?: boolean; + setActive: () => void; +} + +const scrollToBottom = () => { + window.scrollTo({ + top: document.body.scrollHeight, + behavior: 'smooth', + }); +}; + +const SeedGaugeInfoCard = ({ + title, + subtitle, + active, + loading, + setActive, +}: ISeedGaugeInfoCardProps) => ( + {}} + sx={({ breakpoints }) => ({ + display: 'flex', + flexDirection: 'column', + width: '100%', + height: '100%', + cursor: 'pointer', + borderColor: active && 'primary.main', + ':hover': { + borderColor: 'primary.main', + backgroundColor: 'primary.light', + }, + [breakpoints.down('md')]: { + backgroundColor: 'light.main', + }, + })} + > + + + {title} + {typeof subtitle === 'string' ? ( + {subtitle} + ) : ( + subtitle + )} + + + {loading ? ( + + ) : ( + + )} + + + +); + +const SeedGaugeSelect = ({ + gaugeQuery: { data, isLoading }, + activeIndex, + setActiveIndex, +}: { + gaugeQuery: ReturnType; + activeIndex: number; + setActiveIndex: React.Dispatch>; +}) => { + const sdk = useSdk(); + + const handleSetActiveIndex = (index: number) => { + const newIndex = activeIndex === index ? -1 : index; + setActiveIndex(newIndex); + }; + + const cardData = useMemo(() => { + const arr: ISeedGaugeCardInfo[] = []; + + arr.push({ + title: 'Target Seasons to Catch Up', + subtitle: ( + + {TARGET_SEASONS_TO_CATCH_UP} Seasons, ~{MONTHS_TO_CATCH_UP} months + + ), + }); + + arr.push({ + title: 'Bean to Max LP Ratio', + subtitle: ( + + + {data?.bean2MaxLPRatio.value?.toFormat(1) || '--'}% + {' '} + Seed for Beans vs. the Max LP token + + ), + }); + + type TokenWithGP = { + token: Token; + } & TokenSeedGaugeInfo; + + const tokensWithGP: TokenWithGP[] = []; + + if (data?.gaugeData) { + // filter out tokens with 0 optimalPercentDepositedBdv + for (const token of [...sdk.tokens.siloWhitelist]) { + const setting = data.gaugeData[token.address]; + if (setting?.optimalPctDepositedBdv?.gt(0)) { + tokensWithGP.push({ token: token, ...setting }); + } + } + // sort by optimalPercentDepositedBdv + tokensWithGP.sort( + (a, b) => + b.optimalPctDepositedBdv + ?.minus(a.optimalPctDepositedBdv || ZERO_BN) + .toNumber() || 0 + ); + } + + const clipped = tokensWithGP.slice(0, 2); + arr.push({ + title: 'Optimal Distribution of LP', + subtitle: ( + + {clipped.length + ? clipped.map((datum, i) => { + const symbol = datum.token.symbol; + const pct = datum.optimalPctDepositedBdv; + + return ( + + {symbol}{' '} + + {pct ? displayFullBN(pct, 0) : '-'}% + + {i !== clipped.length - 1 ? ', ' : ''} + {/* hacky implementation */} + {tokensWithGP.length > clipped.length && + i === clipped.length - 1 + ? '...' + : ''} + + ); + }) + : '--'} + + ), + }); + return arr; + }, [sdk, data]); + + return ( + + {cardData.map((info, i) => ( + + { + handleSetActiveIndex(i); + }} + loading={isLoading} + {...info} + /> + + ))} + + ); +}; + +const allowedTabs = new Set([0, 1, 2]); + +const SeedGaugeInfoSelected = ({ + activeIndex, + data, + setWhitelistVisible, +}: { + activeIndex: number; + data: ReturnType['data']; + setWhitelistVisible: (val: boolean, callback?: () => void) => void; +}) => { + // load the data at the top level + const [skip, setSkip] = useState(true); + + const timeState = useChartTimePeriodState('silo-avg-seeds-per-bdv'); + + const [seriesData, isLoading, isError] = useAvgSeedsPerBDV( + timeState[0], + skip + ); + + useEffect(() => { + // Fetch only if we open the seasonsToCatchUp Tab + if (activeIndex === 0 && skip) { + setSkip(false); + } + }, [activeIndex, skip]); + + if (!allowedTabs.has(activeIndex)) return null; + + return ( + + {activeIndex === 0 ? ( + + ) : null} + {activeIndex === 1 ? : null} + {activeIndex === 2 ? ( + + ) : null} + + ); +}; + +const SeedGaugeDetails = ({ + setWhitelistVisible, +}: { + setWhitelistVisible: (val: boolean, callback?: () => void) => void; +}) => { + const [activeIndex, setActiveIndex] = useState(-1); + const query = useSeedGauge(); + + useEffect(() => { + if (activeIndex !== 2) { + setWhitelistVisible(true, scrollToBottom); + } + }, [activeIndex, setWhitelistVisible]); + + return ( + + + + + ); +}; + +export default SeedGaugeDetails; diff --git a/projects/ui/src/components/Silo/Whitelist.tsx b/projects/ui/src/components/Silo/Whitelist.tsx index 8ec4215f64..f145b6f99d 100644 --- a/projects/ui/src/components/Silo/Whitelist.tsx +++ b/projects/ui/src/components/Silo/Whitelist.tsx @@ -95,653 +95,310 @@ const Whitelist: FC<{ return ( - {/* Header */} - - - - Token - - - - Rewards - - - - - - - vAPY 24H - - | - - 7D - - | - - 30D - - } - onClick={undefined} - size="small" - /> - - - - - - {' '} - vAPY - - - - - - TVD - - - - Amount Deposited - - + + {/* Header */} + - - The value of your Silo deposits for each whitelisted token, - denominated in {denomination === 'bdv' ? 'Beans' : 'USD'}. -
- - Switch to {denomination === 'bdv' ? 'USD' : 'Beans'}: Option - + F + + + Token + + + + Rewards + + + + + + + vAPY 24H + + | + + 7D + + | + + 30D + + } + onClick={undefined} + size="small" + /> + + + + + + {' '} + vAPY - - } - > - Value Deposited - - - -
- {/* Rows */} - - {config.whitelist.map((token) => { - const deposited = farmerSilo.balances[token.address]?.deposited; - const isUnripe = token === urBean || token === urBeanWstETH; - const isUnripeLP = - isUnripe && token.address === UNRIPE_BEAN_WSTETH[1].address; - const isDeprecated = checkIfDeprecated(token.address); - - // Unripe data - const underlyingToken = isUnripe - ? unripeUnderlyingTokens[token.address] - : null; - const pctUnderlyingDeposited = isUnripe - ? ( - beanstalkSilo.balances[token.address]?.deposited.amount || - ZERO_BN - ).div(unripeTokens[token.address]?.supply || ONE_BN) - : ONE_BN; - - const wlSx = { - textAlign: 'left', - px: 2, - py: 1.5, - borderColor: 'divider', - borderWidth: '0.5px', - background: BeanstalkPalette.white, - '&:hover': { - borderColor: 'primary.main', - backgroundColor: 'primary.light', - }, - }; - - const depSx = { - textAlign: 'left', - px: 2, - py: 1.5, - height: '90px', - borderColor: '#d2ebfd', - borderWidth: '0.5px', - background: BeanstalkPalette.white, - '&:hover': { - borderColor: '#dae8f2', - backgroundColor: 'primary.light', - }, - }; - - return ( - - - - ); - })} - + + + + + + + +
+ + )} +
+ +
+ ); + })} + +
+ ); }; diff --git a/projects/ui/src/components/Sun/SeasonCard.tsx b/projects/ui/src/components/Sun/SeasonCard.tsx index 3e2f0538cd..93de1a6e0c 100644 --- a/projects/ui/src/components/Sun/SeasonCard.tsx +++ b/projects/ui/src/components/Sun/SeasonCard.tsx @@ -1,36 +1,27 @@ import React from 'react'; -import { Typography, Box, Grid } from '@mui/material'; -import BigNumber from 'bignumber.js'; -import rainySeasonIcon from '~/img/beanstalk/sun/rainy-season.svg'; -import drySeasonIcon from '~/img/beanstalk/sun/dry-season.svg'; -import { displayBN, displayFullBN } from '../../util'; -import { FontSize, IconSize } from '../App/muiTheme'; +import { Typography, Box, Grid, Stack } from '@mui/material'; import Row from '~/components/Common/Row'; import { FC } from '~/types'; +import { SeasonSummary } from '~/hooks/beanstalk/useSeasonsSummary'; +import { FontSize } from '../App/muiTheme'; +import { SeasonSummaryColumn } from '../Nav/Buttons/SunButton'; -export interface SeasonCardProps { - season: BigNumber; - rewardBeans: BigNumber | undefined; - issuedSoil: BigNumber | undefined; - temperature: BigNumber | undefined; - deltaTemperature: BigNumber | undefined; - podRate: BigNumber; - deltaDemand: BigNumber | undefined; +export type SeasonCardProps = { + // pass in index to ensure that the key is unique + index: number; + summary: SeasonSummary; + columns: Record; isNew?: boolean; -} +}; const SeasonCard: FC = ({ - season, - rewardBeans, - issuedSoil, - podRate, - temperature, - deltaTemperature, - deltaDemand, + index, + summary, + columns, isNew = false, }) => ( -
+ .next-season': { display: 'block' }, @@ -63,8 +54,8 @@ const SeasonCard: FC = ({ textAlign="left" color="text.primary" > - The forecast for Season {season.toString()} is based on data in - the current Season. + The forecast for Season {summary.season.value?.toString() || '--'}{' '} + is based on data in the current Season. @@ -80,83 +71,17 @@ const SeasonCard: FC = ({ }} > - {/* Season */} - - - {rewardBeans && rewardBeans.lte(0) ? ( - - ) : ( - - )} - - {season?.toString() || '-'} - - - - {/* New Beans */} - - - {rewardBeans ? `+ ${displayBN(rewardBeans)}` : '-'} - - - {/* Soil */} - - - {issuedSoil - ? issuedSoil.lt(0.01) - ? '<0.01' - : displayFullBN(issuedSoil, 2, 2) - : '-'} - - - {/* Temperature */} - - - - {temperature ? `${displayBN(temperature)}%` : '-'} - - - ( {deltaTemperature && deltaTemperature.lt(0) ? '-' : '+'} - {deltaTemperature?.abs().toString() || '0'}% ) - - - - {/* Pod Rate */} - - - {podRate?.gt(0) ? `${displayBN(podRate.times(100))}%` : '-'} - - - {/* Delta Demand */} - - - {deltaDemand - ? deltaDemand.lt(-10_000 / 100) || deltaDemand.gt(10_000 / 100) - ? `${deltaDemand.lt(0) ? '-' : ''}∞` - : `${displayBN(deltaDemand.div(100), true)}%` - : '-'} - - + {Object.values(columns).map((col, i) => ( + + + {col.render(summary)} + + + ))} -
+ ); export default SeasonCard; diff --git a/projects/ui/src/constants/abi/Beanstalk/abiSnippets.ts b/projects/ui/src/constants/abi/Beanstalk/abiSnippets.ts new file mode 100644 index 0000000000..1d78cfb03c --- /dev/null +++ b/projects/ui/src/constants/abi/Beanstalk/abiSnippets.ts @@ -0,0 +1,92 @@ +const getGaugePointsPerBdvForToken = [ + { + inputs: [ + { + internalType: 'address', + name: 'token', + type: 'address', + }, + ], + name: 'getGaugePointsPerBdvForToken', + outputs: [ + { + internalType: 'uint256', + name: '', + type: 'uint256', + }, + ], + stateMutability: 'view', + type: 'function', + }, +] as const; + +const tokenSettings = [ + { + inputs: [{ internalType: 'address', name: 'token', type: 'address' }], + name: 'tokenSettings', + outputs: [ + { + components: [ + { internalType: 'bytes4', name: 'selector', type: 'bytes4' }, + { + internalType: 'uint32', + name: 'stalkEarnedPerSeason', + type: 'uint32', + }, + { internalType: 'uint32', name: 'stalkIssuedPerBdv', type: 'uint32' }, + { internalType: 'uint32', name: 'milestoneSeason', type: 'uint32' }, + { internalType: 'int96', name: 'milestoneStem', type: 'int96' }, + { internalType: 'bytes1', name: 'encodeType', type: 'bytes1' }, + { + internalType: 'int24', + name: 'deltaStalkEarnedPerSeason', + type: 'int24', + }, + { internalType: 'bytes4', name: 'gpSelector', type: 'bytes4' }, + { internalType: 'bytes4', name: 'lwSelector', type: 'bytes4' }, + { internalType: 'uint128', name: 'gaugePoints', type: 'uint128' }, + { + internalType: 'uint64', + name: 'optimalPercentDepositedBdv', + type: 'uint64', + }, + ], + internalType: 'struct Storage.SiloSettings', + name: '', + type: 'tuple', + }, + ], + stateMutability: 'view', + type: 'function', + }, +] as const; + +const poolDeltaB = [ + { + inputs: [ + { + internalType: 'address', + name: 'pool', + type: 'address', + }, + ], + name: 'poolDeltaB', + outputs: [ + { + internalType: 'int256', + name: '', + type: 'int256', + }, + ], + stateMutability: 'view', + type: 'function', + }, +] as const; + +const BEANSTALK_ABI_SNIPPETS = { + getGaugePointsPerBdvForToken: getGaugePointsPerBdvForToken, + tokenSettings: tokenSettings, + poolDeltaB: poolDeltaB, +} as const; + +export default BEANSTALK_ABI_SNIPPETS; diff --git a/projects/ui/src/constants/index.ts b/projects/ui/src/constants/index.ts index 04c4a9848f..2771f1dab2 100644 --- a/projects/ui/src/constants/index.ts +++ b/projects/ui/src/constants/index.ts @@ -2,6 +2,7 @@ import BigNumber from 'bignumber.js'; import Pool from '~/classes/Pool'; import Token from '~/classes/Token'; +import BEANSTALK_ABI_SNIPPETS from '~/constants/abi/Beanstalk/abiSnippets'; // -------------- // Utilities @@ -58,3 +59,4 @@ export * from './links'; export * from './values'; export * from './rpc'; export * from './tooltips'; +export { BEANSTALK_ABI_SNIPPETS as ABISnippets }; diff --git a/projects/ui/src/graph/client.ts b/projects/ui/src/graph/client.ts index 66db9bac62..86186ad7a4 100644 --- a/projects/ui/src/graph/client.ts +++ b/projects/ui/src/graph/client.ts @@ -98,7 +98,7 @@ const mergeUsingSeasons: (keyArgs: string[]) => FieldPolicy = (keyArgs) => ({ // merged[2] = ... for (let i = 0; i < incoming.length; i += 1) { const season = readField('season', incoming[i]); - if (((season as number) - 1) < 0) continue; + if ((season as number) - 1 < 0) continue; if (!season) throw new Error('Seasons queried without season'); // Season 1 = Index 0 merged[(season as number) - 1] = incoming[i]; @@ -193,6 +193,7 @@ const beanftLink = new HttpLink({ /// ///////////////////////// Client //////////////////////////// export const apolloClient = new ApolloClient({ + connectToDevTools: true, link: ApolloLink.split( (operation) => operation.getContext().subgraph === 'bean', beanLink, // true diff --git a/projects/ui/src/graph/graphql.schema.json b/projects/ui/src/graph/graphql.schema.json index 44d20282a0..94333f469b 100644 --- a/projects/ui/src/graph/graphql.schema.json +++ b/projects/ui/src/graph/graphql.schema.json @@ -177174,7 +177174,9 @@ "name": "derivedFrom", "description": "creates a virtual field on the entity that may be queried but cannot be set manually through the mappings API.", "isRepeatable": false, - "locations": ["FIELD_DEFINITION"], + "locations": [ + "FIELD_DEFINITION" + ], "args": [ { "name": "field", @@ -177198,14 +177200,20 @@ "name": "entity", "description": "Marks the GraphQL type as indexable entity. Each type that should be an entity is required to be annotated with this directive.", "isRepeatable": false, - "locations": ["OBJECT"], + "locations": [ + "OBJECT" + ], "args": [] }, { "name": "include", "description": "Directs the executor to include this field or fragment only when the `if` argument is true.", "isRepeatable": false, - "locations": ["FIELD", "FRAGMENT_SPREAD", "INLINE_FRAGMENT"], + "locations": [ + "FIELD", + "FRAGMENT_SPREAD", + "INLINE_FRAGMENT" + ], "args": [ { "name": "if", @@ -177229,7 +177237,11 @@ "name": "skip", "description": "Directs the executor to skip this field or fragment when the `if` argument is true.", "isRepeatable": false, - "locations": ["FIELD", "FRAGMENT_SPREAD", "INLINE_FRAGMENT"], + "locations": [ + "FIELD", + "FRAGMENT_SPREAD", + "INLINE_FRAGMENT" + ], "args": [ { "name": "if", @@ -177253,7 +177265,9 @@ "name": "specifiedBy", "description": "Exposes a URL that specifies the behavior of this scalar.", "isRepeatable": false, - "locations": ["SCALAR"], + "locations": [ + "SCALAR" + ], "args": [ { "name": "url", @@ -177277,7 +177291,9 @@ "name": "subgraphId", "description": "Defined a Subgraph ID for an object type", "isRepeatable": false, - "locations": ["OBJECT"], + "locations": [ + "OBJECT" + ], "args": [ { "name": "id", @@ -177299,4 +177315,4 @@ } ] } -} +} \ No newline at end of file diff --git a/projects/ui/src/hooks/beanstalk/useAvgSeedsPerBDV.ts b/projects/ui/src/hooks/beanstalk/useAvgSeedsPerBDV.ts new file mode 100644 index 0000000000..9e92a36bff --- /dev/null +++ b/projects/ui/src/hooks/beanstalk/useAvgSeedsPerBDV.ts @@ -0,0 +1,360 @@ +import { BigNumber } from 'bignumber.js'; +import { useCallback, useEffect, useState } from 'react'; +import { DocumentNode, gql } from '@apollo/client'; +import { BeanstalkSDK, Token } from '@beanstalk/sdk'; +import { Time, Range } from 'lightweight-charts'; + +import { ChartQueryData } from '~/components/Analytics/AdvancedChart'; +import useSdk from '~/hooks/sdk'; +import { apolloClient } from '~/graph/client'; +import { toBNWithDecimals } from '~/util'; +import { ZERO_BN } from '~/constants'; + +type SeasonMap = { [season: number]: T }; + +type SiloAssetsReturn = { + season: number; + depositedBDV: string; + createdAt: string; +}; + +type WhitelistReturn = { + season: number; + stalkEarnedPerSeason: string; + createdAt: string; +}; + +type MergedQueryData = { + season: number; + depositedBDV: BigNumber; + grownStalkPerSeason: BigNumber; + createdAt: string; + grownStalkPerBDV: BigNumber; + totalBDV: BigNumber; +}; + +type SiloTokenDataBySeason = SeasonMap<{ + [address: string]: Partial; +}>; + +const MAX_DATA_PER_QUERY = 1000; + +const SEED_GAUGE_DEPLOYMENT_SEASON = 21798; + +const SEED_GAUGE_DEPLOYMENT_TIMESTAMP = 1716408000; + +const apolloFetch = async ( + document: DocumentNode, + first: number, + season: number +) => + apolloClient.query({ + query: document, + variables: { first, season_lte: season }, + fetchPolicy: 'no-cache', + notifyOnNetworkStatusChange: true, + }); + +// Main hook with improved error handling and performance +const useAvgSeedsPerBDV = ( + range: Range