diff --git a/projects/dex-ui/codegen.ts b/projects/dex-ui/codegen.ts index 492bd59a3c..07d4686e04 100644 --- a/projects/dex-ui/codegen.ts +++ b/projects/dex-ui/codegen.ts @@ -2,7 +2,11 @@ import { CodegenConfig } from "@graphql-codegen/cli"; const config: CodegenConfig = { overwrite: true, - schema: "graphql.schema.json", + schema: [ + "graphql.schema.json", + // beanstalk subgraph + "https://graph.node.bean.money/subgraphs/name/beanstalk" + ], documents: "src/**/*.graphql", ignoreNoDocuments: true, generates: { diff --git a/projects/dex-ui/src/abi/MULTI_PUMP_ABI.json b/projects/dex-ui/src/abi/MULTI_PUMP_ABI.json new file mode 100644 index 0000000000..07a62c411c --- /dev/null +++ b/projects/dex-ui/src/abi/MULTI_PUMP_ABI.json @@ -0,0 +1,115 @@ +[ + { + "inputs": [ + { "internalType": "bytes16", "name": "_maxPercentIncrease", "type": "bytes16" }, + { "internalType": "bytes16", "name": "_maxPercentDecrease", "type": "bytes16" }, + { "internalType": "uint256", "name": "_capInterval", "type": "uint256" }, + { "internalType": "bytes16", "name": "_alpha", "type": "bytes16" } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { "inputs": [], "name": "NoTimePassed", "type": "error" }, + { "inputs": [], "name": "NotInitialized", "type": "error" }, + { + "inputs": [], + "name": "ALPHA", + "outputs": [{ "internalType": "bytes16", "name": "", "type": "bytes16" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "CAP_INTERVAL", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "LOG_MAX_DECREASE", + "outputs": [{ "internalType": "bytes16", "name": "", "type": "bytes16" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "LOG_MAX_INCREASE", + "outputs": [{ "internalType": "bytes16", "name": "", "type": "bytes16" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [{ "internalType": "address", "name": "well", "type": "address" }], + "name": "readCappedReserves", + "outputs": [{ "internalType": "uint256[]", "name": "cappedReserves", "type": "uint256[]" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { "internalType": "address", "name": "well", "type": "address" }, + { "internalType": "bytes", "name": "", "type": "bytes" } + ], + "name": "readCumulativeReserves", + "outputs": [{ "internalType": "bytes", "name": "cumulativeReserves", "type": "bytes" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { "internalType": "address", "name": "well", "type": "address" }, + { "internalType": "bytes", "name": "", "type": "bytes" } + ], + "name": "readInstantaneousReserves", + "outputs": [{ "internalType": "uint256[]", "name": "emaReserves", "type": "uint256[]" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [{ "internalType": "address", "name": "well", "type": "address" }], + "name": "readLastCappedReserves", + "outputs": [{ "internalType": "uint256[]", "name": "lastCappedReserves", "type": "uint256[]" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [{ "internalType": "address", "name": "well", "type": "address" }], + "name": "readLastCumulativeReserves", + "outputs": [{ "internalType": "bytes16[]", "name": "cumulativeReserves", "type": "bytes16[]" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [{ "internalType": "address", "name": "well", "type": "address" }], + "name": "readLastInstantaneousReserves", + "outputs": [{ "internalType": "uint256[]", "name": "emaReserves", "type": "uint256[]" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { "internalType": "address", "name": "well", "type": "address" }, + { "internalType": "bytes", "name": "startCumulativeReserves", "type": "bytes" }, + { "internalType": "uint256", "name": "startTimestamp", "type": "uint256" }, + { "internalType": "bytes", "name": "", "type": "bytes" } + ], + "name": "readTwaReserves", + "outputs": [ + { "internalType": "uint256[]", "name": "twaReserves", "type": "uint256[]" }, + { "internalType": "bytes", "name": "cumulativeReserves", "type": "bytes" } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { "internalType": "uint256[]", "name": "reserves", "type": "uint256[]" }, + { "internalType": "bytes", "name": "", "type": "bytes" } + ], + "name": "update", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + } +] diff --git a/projects/dex-ui/src/assets/images/start-sparkle.svg b/projects/dex-ui/src/assets/images/start-sparkle.svg new file mode 100644 index 0000000000..cd5d139d4d --- /dev/null +++ b/projects/dex-ui/src/assets/images/start-sparkle.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/projects/dex-ui/src/breakpoints.ts b/projects/dex-ui/src/breakpoints.ts index d723f3efa9..d47f2c786c 100644 --- a/projects/dex-ui/src/breakpoints.ts +++ b/projects/dex-ui/src/breakpoints.ts @@ -9,30 +9,32 @@ const mediaSizes = { desktop: 1200 }; -/// we add 1px to the mobile and tablet sizes so that the media queries don't overlap +const BP_GAP = 0.05; + +/// we subtract 0.05px to some queries to prevent overlapping export const mediaQuery = { sm: { // 769px & above up: `@media (min-width: ${mediaSizes.mobile}px)`, - // 768px & below - only: `@media (max-width: ${mediaSizes.mobile - 1}px)` + // 768.95px & below + only: `@media (max-width: ${mediaSizes.mobile - BP_GAP}px)` }, md: { // 1024px & above up: `@media (min-width: ${mediaSizes.tablet}px)`, - // between 769px & 1024px - only: `@media (min-width: ${mediaSizes.mobile}px) and (max-width: ${mediaSizes.tablet - 1}px)`, + // between 769px & 1023.95px + only: `@media (min-width: ${mediaSizes.mobile}px) and (max-width: ${mediaSizes.tablet - BP_GAP}px)`, // 1024px & below - down: `@media (max-width: ${mediaSizes.tablet}px)` + down: `@media (max-width: ${mediaSizes.tablet - BP_GAP}px)` }, lg: { // 1200px & below - down: `@media (max-width: ${mediaSizes.desktop}px)`, + down: `@media (max-width: ${mediaSizes.desktop - BP_GAP}px)`, // 1200px & above only: `@media (min-width: ${mediaSizes.desktop}px)` }, between: { - // between 769px & 1200px - smAndLg: `@media (min-width: ${mediaSizes.mobile}px) and (max-width: ${mediaSizes.desktop - 1}px)` + // between 769px & 1199.95px + smAndLg: `@media (min-width: ${mediaSizes.mobile}px) and (max-width: ${mediaSizes.desktop - BP_GAP}px)` } }; diff --git a/projects/dex-ui/src/components/Frame/ContractInfoMarquee.tsx b/projects/dex-ui/src/components/Frame/ContractInfoMarquee.tsx index 2f40b627b8..748de04023 100644 --- a/projects/dex-ui/src/components/Frame/ContractInfoMarquee.tsx +++ b/projects/dex-ui/src/components/Frame/ContractInfoMarquee.tsx @@ -7,7 +7,7 @@ type ContractMarqueeInfo = Record { const data = Object.entries(CarouselData); diff --git a/projects/dex-ui/src/components/Liquidity/RemoveLiquidity.tsx b/projects/dex-ui/src/components/Liquidity/RemoveLiquidity.tsx index 001c3c035e..71f9031933 100644 --- a/projects/dex-ui/src/components/Liquidity/RemoveLiquidity.tsx +++ b/projects/dex-ui/src/components/Liquidity/RemoveLiquidity.tsx @@ -22,6 +22,7 @@ import { Checkbox } from "../Checkbox"; import { size } from "src/breakpoints"; import { displayTokenSymbol } from "src/utils/format"; import { LoadingTemplate } from "../LoadingTemplate"; +import { useLPPositionSummary } from "src/tokens/useLPPositionSummary"; type BaseRemoveLiquidityProps = { slippage: number; @@ -42,11 +43,13 @@ const RemoveLiquidityContent = ({ well, slippage, slippageSettingsClickHandler, const [singleTokenIndex, setSingleTokenIndex] = useState(0); const [amounts, setAmounts] = useState([]); const [prices, setPrices] = useState<(TokenValue | null)[]>(); - const [hasEnoughBalance, setHasEnoughBalance] = useState(false); const [tokenAllowance, setTokenAllowance] = useState(false); - const sdk = useSdk(); + const { getPositionWithWell } = useLPPositionSummary(); const { reserves: wellReserves, refetch: refetchWellReserves } = useWellReserves(well); + const sdk = useSdk(); + + const lpBalance = useMemo(() => getPositionWithWell(well)?.external, [getPositionWithWell, well]); useEffect(() => { const run = async () => { @@ -70,6 +73,8 @@ const RemoveLiquidityContent = ({ well, slippage, slippageSettingsClickHandler, const { oneTokenQuote } = oneToken; const { customRatioQuote } = custom; + const hasEnoughBalance = !address || !wellLpToken || !lpTokenAmount || !lpBalance ? false : lpTokenAmount.lte(lpBalance); + useEffect(() => { if (well.lpToken) { let lpTokenWithMetadata = well.lpToken; @@ -79,28 +84,6 @@ const RemoveLiquidityContent = ({ well, slippage, slippageSettingsClickHandler, } }, [well.lpToken]); - useEffect(() => { - const checkBalances = async () => { - if (!address || !wellLpToken || !lpTokenAmount) { - setHasEnoughBalance(false); - return; - } - - let insufficientBalances = false; - - if (lpTokenAmount.gt(TokenValue.ZERO)) { - const balance = await wellLpToken.getBalance(address); - if (lpTokenAmount.gt(balance)) { - insufficientBalances = true; - } - } - - setHasEnoughBalance(!insufficientBalances); - }; - - checkBalances(); - }, [address, lpTokenAmount, wellLpToken]); - useEffect(() => { if (customRatioQuote) { setLpTokenAmount(customRatioQuote.quote); @@ -318,6 +301,7 @@ const RemoveLiquidityContent = ({ well, slippage, slippageSettingsClickHandler, canChangeValue={removeLiquidityMode !== REMOVE_LIQUIDITY_MODE.Custom} showBalance={true} loading={false} + clamp /> diff --git a/projects/dex-ui/src/components/Swap/BasicInput.tsx b/projects/dex-ui/src/components/Swap/BasicInput.tsx index 12bd1afc90..4db0123811 100644 --- a/projects/dex-ui/src/components/Swap/BasicInput.tsx +++ b/projects/dex-ui/src/components/Swap/BasicInput.tsx @@ -13,6 +13,7 @@ type Props = { onFocus?: FocusEventHandler; onBlur?: FocusEventHandler; canChangeValue?: boolean; + max?: TokenValue; }; export const BasicInput: FC = ({ @@ -24,7 +25,8 @@ export const BasicInput: FC = ({ onFocus, onBlur, inputRef, - canChangeValue = true + canChangeValue = true, + max }) => { const [id, _] = useState(_id ?? Math.random().toString(36).substring(2, 7)); const [displayValue, setDisplayValue] = useState(value); @@ -42,6 +44,15 @@ export const BasicInput: FC = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [value]); + const maxNum = max && parseFloat(max.toHuman()); + const clamp = useCallback( + (amount: string) => { + if (amount === "" || amount === ".") return amount; + return maxNum !== undefined ? Math.min(parseFloat(amount), maxNum).toString() : amount; + }, + [maxNum] + ); + const handleChange = useCallback( (e: React.ChangeEvent) => { let rawValue = e.target.value; @@ -55,10 +66,10 @@ export const BasicInput: FC = ({ rawValue = `0${rawValue}`; } - setDisplayValue(rawValue); - onChange?.(cleanValue); + setDisplayValue(clamp(rawValue)); + onChange?.(clamp(cleanValue)); }, - [onChange] + [onChange, clamp] ); const filterKeyDown = useCallback( diff --git a/projects/dex-ui/src/components/Swap/TokenInput.tsx b/projects/dex-ui/src/components/Swap/TokenInput.tsx index 3879ffd12d..e07ef2a779 100644 --- a/projects/dex-ui/src/components/Swap/TokenInput.tsx +++ b/projects/dex-ui/src/components/Swap/TokenInput.tsx @@ -29,6 +29,7 @@ type TokenInput = { onTokenChange?: (t: Token) => void; canChangeValue?: boolean; debounceTime?: number; + clamp?: boolean; }; export const TokenInput: FC = ({ @@ -44,7 +45,8 @@ export const TokenInput: FC = ({ loading = false, allowNegative = false, canChangeValue = true, - debounceTime = 500 + debounceTime = 500, + clamp = false }) => { const inputRef = useRef(null); @@ -97,6 +99,7 @@ export const TokenInput: FC = ({ inputRef={inputRef} allowNegative={allowNegative} canChangeValue={!!canChangeValue} + max={clamp ? balance?.[token.symbol] : undefined} /> diff --git a/projects/dex-ui/src/components/Tooltip.tsx b/projects/dex-ui/src/components/Tooltip.tsx index 016eae8a30..b9bfe07ccc 100644 --- a/projects/dex-ui/src/components/Tooltip.tsx +++ b/projects/dex-ui/src/components/Tooltip.tsx @@ -11,26 +11,36 @@ type Props = { arrowOffset: number; side: string; width?: number; + bgColor?: "black" | "white"; }; -export const Tooltip: FC = ({ children, content, offsetX, offsetY, arrowSize, arrowOffset, side, width }) => { +export const Tooltip: FC = ({ children, content, offsetX, offsetY, arrowSize, arrowOffset, side, width, bgColor = "black" }) => { return ( {children} - + {content} ); }; -type TooltipProps = { +export type TooltipProps = { offsetX: number; offsetY: number; arrowSize: number; arrowOffset: number; side: string; width?: number; + bgColor?: "black" | "white"; }; const TooltipContainer = styled.div` @@ -40,11 +50,12 @@ const TooltipContainer = styled.div` const TooltipBox = styled.div` padding: 8px; border-radius: 2px; - background: #000; - color: #fff; + background: ${(props) => (props.bgColor === "white" ? "#FFF" : "#000")}; + color: ${(props) => (props.bgColor === "white" ? "#000" : "#FFF")}; position: absolute; transform: translateX(${(props) => props.offsetX}%); width: ${(props) => (props.width ? props.width : 200)}px; + border: ${(props) => (props.bgColor === "white" ? "1px solid #000" : "none")}; line-height: 18px; font-size: 14px; visibility: hidden; diff --git a/projects/dex-ui/src/components/Well/Chart/Chart.tsx b/projects/dex-ui/src/components/Well/Chart/Chart.tsx index 93f08f3f3c..5eb4dbb9e5 100644 --- a/projects/dex-ui/src/components/Well/Chart/Chart.tsx +++ b/projects/dex-ui/src/components/Well/Chart/Chart.tsx @@ -1,13 +1,14 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { FC } from "src/types"; import { ChartContainer } from "./ChartStyles"; import { createChart } from "lightweight-charts"; import { useRef } from "react"; import styled from "styled-components"; +import { IChartDataItem } from "./ChartSection"; type Props = { legend: string; - data: any; + data: IChartDataItem[]; }; function formatToUSD(value: any) { @@ -15,7 +16,7 @@ function formatToUSD(value: any) { return formattedValue; } -export const Chart: FC = ({ legend, data }) => { +export const Chart: FC = ({ legend, data: _data }) => { const chartContainerRef = useRef(); const chart = useRef(); const lineSeries = useRef(); @@ -23,6 +24,13 @@ export const Chart: FC = ({ legend, data }) => { const [dataPoint, setDataPoint] = useState(); const [dataPointValue, setDataPointValue] = useState(); + const data = useMemo(() => { + return _data.map(({ time, value }) => ({ + time, + value: parseFloat(value) + })); + }, [_data]); + useEffect(() => { if (!chartContainerRef.current) return; diff --git a/projects/dex-ui/src/components/Well/Chart/ChartSection.tsx b/projects/dex-ui/src/components/Well/Chart/ChartSection.tsx index 4f5e9ddcf8..698f18c4b4 100644 --- a/projects/dex-ui/src/components/Well/Chart/ChartSection.tsx +++ b/projects/dex-ui/src/components/Well/Chart/ChartSection.tsx @@ -11,12 +11,67 @@ import { ChartContainer } from "./ChartStyles"; import { BottomDrawer } from "src/components/BottomDrawer"; import { mediaQuery, size } from "src/breakpoints"; import { LoadingTemplate } from "src/components/LoadingTemplate"; +import { IWellHourlySnapshot } from "src/wells/chartDataLoader"; +import { TokenValue } from "@beanstalk/sdk"; function timeToLocal(originalTime: number) { const d = new Date(originalTime * 1000); return Date.UTC(d.getFullYear(), d.getMonth(), d.getDate(), d.getHours(), d.getMinutes(), d.getSeconds(), d.getMilliseconds()) / 1000; } +type TimeToHourlySnapshotItem = { + data: Pick; + count: number; +}; + +export type IChartDataItem = { + time: number; + value: string; +}; + +// some snapshots are duplicated, so we need to deduplicate them +const parseAndDeduplicateSnapshots = (arr: IWellHourlySnapshot[]) => { + const snapshotMap = arr.reduce>((memo, snapshot) => { + const timeKey = timeToLocal(Number(snapshot.lastUpdateTimestamp)).toString(); + const deltaVolumeUSD = Number(snapshot.deltaVolumeUSD); + const totalLiquidityUSD = Number(snapshot.totalLiquidityUSD); + + if (!(timeKey in memo)) { + memo[timeKey] = { + data: { deltaVolumeUSD, totalLiquidityUSD }, + count: 1 + }; + } else { + memo[timeKey].data.deltaVolumeUSD += deltaVolumeUSD; + memo[timeKey].data.totalLiquidityUSD += totalLiquidityUSD; + memo[timeKey].count++; + } + + return memo; + }, {}); + + const liquidityData: IChartDataItem[] = []; + const volumeData: IChartDataItem[] = []; + + for (const [time, { data, count }] of Object.entries(snapshotMap)) { + const timeKey = Number(time); + + liquidityData.push({ + time: timeKey, + value: Number(TokenValue.ZERO.add(data.totalLiquidityUSD).div(count).toHuman()).toFixed(2) + }); + volumeData.push({ + time: timeKey, + value: Number(TokenValue.ZERO.add(data.deltaVolumeUSD).div(count).toHuman()).toFixed(2) + }); + } + + return { + liquidityData, + volumeData + }; +}; + const ChartSectionContent: FC<{ well: Well }> = ({ well }) => { const [tab, setTab] = useState(0); const [showDropdown, setShowDropdown] = useState(false); @@ -25,28 +80,17 @@ const ChartSectionContent: FC<{ well: Well }> = ({ well }) => { const { data: chartData, refetch, error, isLoading: chartDataLoading } = useWellChartData(well, timePeriod); - const [liquidityData, setLiquidityData] = useState([]); - const [volumeData, setVolumeData] = useState([]); + const [liquidityData, setLiquidityData] = useState([]); + const [volumeData, setVolumeData] = useState([]); const [isChartTypeDrawerOpen, setChartTypeDrawerOpen] = useState(false); const [isChartRangeDrawerOpen, setChartRangeDrawerOpen] = useState(false); useEffect(() => { if (!chartData) return; - let _liquidityData: any = []; - let _volumeData: any = []; - for (let i = 0; i < chartData.length; i++) { - _liquidityData.push({ - time: timeToLocal(Number(chartData[i].lastUpdateTimestamp)), - value: Number(chartData[i].totalLiquidityUSD).toFixed(2) - }); - _volumeData.push({ - time: timeToLocal(Number(chartData[i].lastUpdateTimestamp)), - value: Number(chartData[i].deltaVolumeUSD).toFixed(2) - }); - } - setLiquidityData([..._liquidityData]); - setVolumeData([..._volumeData]); + const dedupliated = parseAndDeduplicateSnapshots(chartData); + setLiquidityData(dedupliated.liquidityData); + setVolumeData(dedupliated.volumeData); }, [chartData]); useEffect(() => { @@ -182,16 +226,19 @@ const ChartSectionContent: FC<{ well: Well }> = ({ well }) => { {error !== null && {`Error Loading Chart Data :(`}} - {chartDataLoading && ( + {chartDataLoading ? ( + ) : ( + <> + {tab === 0 && !error && } + {tab === 1 && !error && } + )} - {tab === 0 && !error && !chartDataLoading && } - {tab === 1 && !error && !chartDataLoading && } ); }; diff --git a/projects/dex-ui/src/components/Well/LearnPump.tsx b/projects/dex-ui/src/components/Well/LearnPump.tsx index 2b388893bd..4f7549d2f1 100644 --- a/projects/dex-ui/src/components/Well/LearnPump.tsx +++ b/projects/dex-ui/src/components/Well/LearnPump.tsx @@ -10,7 +10,7 @@ function PumpDetails() {
Pumps are the oracle framework of Basin. Well deployers can define the conditions under which the Well - should write new reserve data to the Pump, which can be used as a price feed. + should write new reserve data to the Pump, which can be used as a data feed.
The Multi Flow Pump is @@ -27,7 +27,7 @@ export const LearnPump: FC = () => { 🔮 {" "} - What’s a pump? + What is a Pump? diff --git a/projects/dex-ui/src/components/Well/LearnYield.tsx b/projects/dex-ui/src/components/Well/LearnYield.tsx index c6cd01eab6..246deb3865 100644 --- a/projects/dex-ui/src/components/Well/LearnYield.tsx +++ b/projects/dex-ui/src/components/Well/LearnYield.tsx @@ -18,7 +18,7 @@ function YieldDetails() { target="_blank" rel="noopener noreferrer" > - Beanstalk UI + Beanstalk UI.
diff --git a/projects/dex-ui/src/components/Well/LiquidityBox.tsx b/projects/dex-ui/src/components/Well/LiquidityBox.tsx index 75b6f83936..d259b8b05b 100644 --- a/projects/dex-ui/src/components/Well/LiquidityBox.tsx +++ b/projects/dex-ui/src/components/Well/LiquidityBox.tsx @@ -4,7 +4,7 @@ import styled from "styled-components"; import { TokenValue } from "@beanstalk/sdk"; import { mediaQuery } from "src/breakpoints"; -import { BodyCaps, BodyS, LinksButtonText, TextNudge } from "src/components/Typography"; +import { BodyCaps, BodyS, BodyXS, LinksButtonText, TextNudge } from "src/components/Typography"; import { InfoBox } from "src/components/InfoBox"; import { TokenLogo } from "src/components/TokenLogo"; import { Tooltip } from "src/components/Tooltip"; @@ -16,6 +16,7 @@ import { useLPPositionSummary } from "src/tokens/useLPPositionSummary"; import { useBeanstalkSiloWhitelist } from "src/wells/useBeanstalkSiloWhitelist"; import { LoadingItem } from "src/components/LoadingItem"; import { Well } from "@beanstalk/sdk/Wells"; +import { Info } from "src/components/Icons"; type Props = { well: Well | undefined; @@ -80,11 +81,57 @@ export const LiquidityBox: FC = ({ well: _well, loading }) => { {!loading && isWhitelisted ? ( <> - Deposited in the Silo + + + In the Beanstalk Silo + + BEANETH LP token holders can Deposit their LP tokens in the{" "} + + Beanstalk Silo + +  for yield. + + } + offsetX={-40} + offsetY={350} + side="bottom" + arrowSize={0} + arrowOffset={0} + width={270} + > + + + + {displayTV(position?.silo)} - In my Farm Balance + + + In my Beanstalk Farm Balance + + + Farm Balances + +  allow Beanstalk users to hold assets in the protocol on their behalf. Using Farm Balances can reduce gas costs + and facilitate efficient movement of assets within Beanstalk. + + } + offsetX={-40} + offsetY={525} + arrowOffset={0} + side="bottom" + arrowSize={0} + width={270} + > + + + + {displayTV(position?.internal)} @@ -102,7 +149,6 @@ export const LiquidityBox: FC = ({ well: _well, loading }) => { {"Wallet: "}
${externalUSD.toHuman("short")}
- {"Silo Deposits: "}
${siloUSD.toHuman("short")}
@@ -162,3 +208,20 @@ const BreakdownRow = styled.div` justify-content: space-between; gap: 4px; `; + +const TooltipContainer = styled.div` + display: inline-flex; + gap: 4px; + + .tooltip-content { + ${BodyXS} + } + + .underline { + text-decoration: underline; + + &:visited { + color: #fff; + } + } +`; diff --git a/projects/dex-ui/src/components/Well/MultiFlowPumpTooltip.tsx b/projects/dex-ui/src/components/Well/MultiFlowPumpTooltip.tsx new file mode 100644 index 0000000000..3eb6fec35e --- /dev/null +++ b/projects/dex-ui/src/components/Well/MultiFlowPumpTooltip.tsx @@ -0,0 +1,175 @@ +import React, { FC } from "react"; +import { Info } from "src/components/Icons"; +import { Tooltip, TooltipProps } from "src/components/Tooltip"; +import { mediaQuery } from "src/breakpoints"; +import styled from "styled-components"; +import { Item, Row } from "src/components/Layout"; +import { BodyS } from "src/components/Typography"; +import { Well } from "@beanstalk/sdk/Wells"; +import { TokenLogo } from "src/components/TokenLogo"; +import { TokenValue } from "@beanstalk/sdk"; +import { formatNum } from "src/utils/format"; + +export const MultiFlowPumpTooltip: FC<{ + well: Well; + twaReserves: TokenValue[] | undefined; + children?: React.ReactNode; // if no children, then the tooltip icon is rendered + tooltipProps?: TooltipProps; +}> = ({ well, children, tooltipProps, twaReserves }) => { + const token1 = well.tokens?.[0]; + const reserve1 = well.reserves?.[0]; + + const token2 = well.tokens?.[1]; + const reserve2 = well.reserves?.[1]; + + const twaReserves1 = twaReserves?.[0]; + const twaReserves2 = twaReserves?.[1]; + + if (!token1 || !token2 || !reserve1 || !reserve2) return null; + + return ( + + +
Multi Flow Pump
+
+ The  + + Multi Flow Pump + + , an inter-block MEV manipulation resistant oracle, stores reserve data from this Well. In particular, Multi Flow stores + reserve data in two formats: +
+
+ + +
Instantaneous reserves
+ + +
+ + {token1.symbol} +
+ {formatNum(reserve1, { minDecimals: 2 })} +
+
+ + +
+ + {token2.symbol} +
+ {formatNum(reserve2, { minDecimals: 2 })} +
+
+
+ {twaReserves1 && twaReserves2 && ( + +
Time-weighted average reserves
+ + +
+ + {token1.symbol} +
+ {formatNum(twaReserves1, { minDecimals: 2 })} +
+
+ + +
+ + {token2.symbol} +
+ {formatNum(twaReserves2, { minDecimals: 2 })} +
+
+
+ )} +
+ + } + offsetX={0} + offsetY={0} + arrowSize={0} + arrowOffset={0} + side="top" + bgColor="white" + width={370} + {...tooltipProps} + > + {children ? children : } +
+ ); +}; + +const Container = styled.div` + display: flex; + flex-direction: column; + gap: 16px; + width: 100%; + padding: 12px; + box-sizing: border-box; + + ${mediaQuery.sm.only} { + gap: 16px; + } +`; + +const TitleAndContentContainer = styled(Item)` + width: 100%; + + gap: 8px; + ${BodyS} + + .container-title { + font-weight: 600; + } + + .content { + color: #4B556; + + .content-link { + color: #46b955; + cursor: pointer; + text-decoration: none; + + :focus { + text-decoration: none; + } + } + } +`; + +const ReservesInfo = styled(Item)` + display: flex; + flex-direction: column; + gap: 8px; +`; + +const ReserveData = styled(Item)` + font-weight: 600; + gap: 4px; + + .reserve-type { + ${BodyS} + font-weight: 400; + color: #4B556; + } + + .reserve-token-container { + display: flex; + flex-direction: row; + gap: 4px; + } +`; + +const StyledItem = styled(Item)` + gap: 4px; +`; + +const StyledRow = styled(Row)` + width: 100%; + justify-content: space-between; +`; diff --git a/projects/dex-ui/src/components/Well/Reserves.tsx b/projects/dex-ui/src/components/Well/Reserves.tsx index 794a3c82a6..fce88a82de 100644 --- a/projects/dex-ui/src/components/Well/Reserves.tsx +++ b/projects/dex-ui/src/components/Well/Reserves.tsx @@ -6,26 +6,48 @@ import { Token, TokenValue } from "@beanstalk/sdk"; import { TokenLogo } from "../TokenLogo"; import { Item, Row } from "../Layout"; import { size } from "src/breakpoints"; +import { formatNum, formatPercent } from "src/utils/format"; -type Props = { +import { MultiFlowPumpTooltip } from "./MultiFlowPumpTooltip"; +import { Well } from "@beanstalk/sdk/Wells"; +import { useBeanstalkSiloWhitelist } from "src/wells/useBeanstalkSiloWhitelist"; +import { TooltipProps } from "../Tooltip"; +import { useIsMobile } from "src/utils/ui/useIsMobile"; + +export type ReservesProps = { + well: Well | undefined; reserves: { token: Token; amount: TokenValue; dollarAmount: TokenValue | null; percentage: TokenValue | null; }[]; + twaReserves: TokenValue[] | undefined; }; -export const Reserves: FC = ({ reserves }) => { + +export const Reserves: FC = ({ reserves, well, twaReserves }) => { + const { getIsMultiPumpWell } = useBeanstalkSiloWhitelist(); + const isMobile = useIsMobile(); + + if (!well) return null; + const rows = (reserves ?? []).map((r, i) => ( - {r.token?.symbol} + + {r.token?.symbol} + {getIsMultiPumpWell(well) && ( +
+ +
+ )} +
- {r.amount.toHuman("short")} + {formatNum(r.amount, { minDecimals: 2 })} - {`(${r.percentage?.mul(100).toHuman("short")}%)`} + {formatPercent(r.percentage)}
@@ -35,12 +57,20 @@ export const Reserves: FC = ({ reserves }) => { }; const Symbol = styled.div` + display: inline-flex; + flex-direction: row; + ${BodyL} color: #4B5563; @media (max-width: ${size.mobile}) { ${BodyS} } + + .info-icon { + margin-left: 6px; + } `; + const Wrapper = styled.div` display: flex; flex-direction: row; @@ -68,3 +98,17 @@ const Percent = styled.div` ${BodyS} } `; + +const baseTooltipProps = { offsetX: 0, offsetY: 0, arrowSize: 0, arrowOffset: 0, side: "top" } as TooltipProps; + +const getTooltipProps = (isMobile: boolean, index: number) => { + const copy = { ...baseTooltipProps }; + if (!isMobile) return copy; + + copy.width = 300; + + if (index === 0) copy.offsetX = -15; + else copy.offsetX = -70; + + return copy; +}; diff --git a/projects/dex-ui/src/components/Well/Table/WellDetailRow.tsx b/projects/dex-ui/src/components/Well/Table/WellDetailRow.tsx index 9f151385d3..6e9c735845 100644 --- a/projects/dex-ui/src/components/Well/Table/WellDetailRow.tsx +++ b/projects/dex-ui/src/components/Well/Table/WellDetailRow.tsx @@ -8,6 +8,8 @@ import { mediaQuery, size } from "src/breakpoints"; import { formatNum } from "src/utils/format"; import { Well } from "@beanstalk/sdk/Wells"; import { Skeleton } from "src/components/Skeleton"; +import { WellYieldWithTooltip } from "../WellYieldWithTooltip"; +import { Item } from "src/components/Layout"; /// format value with 2 decimals, if value is less than 1M, otherwise use short format const formatMayDecimals = (tv: TokenValue | undefined) => { @@ -51,7 +53,9 @@ export const WellDetailRow: FC<{ {functionName || "Price Function"} - 0.00% + + + ${liquidity ? liquidity.toHuman("short") : "-.--"} @@ -91,7 +95,7 @@ export const WellDetailLoadingRow: FC<{}> = () => { - + @@ -210,3 +214,7 @@ const WellPricing = styled.div` const TokenLogoWrapper = styled.div` margin-bottom: 2px; `; + +const TooltipContainer = styled.div` + display: flex; +`; diff --git a/projects/dex-ui/src/components/Well/WellYieldWithTooltip.tsx b/projects/dex-ui/src/components/Well/WellYieldWithTooltip.tsx new file mode 100644 index 0000000000..699d9c36f1 --- /dev/null +++ b/projects/dex-ui/src/components/Well/WellYieldWithTooltip.tsx @@ -0,0 +1,192 @@ +import React, { useMemo } from "react"; +import styled from "styled-components"; +import { BodyL, BodyS } from "../Typography"; +import { TokenLogo } from "../TokenLogo"; +import useSdk from "src/utils/sdk/useSdk"; +import { Tooltip, TooltipProps } from "../Tooltip"; +import { TokenValue } from "@beanstalk/sdk"; + +import StartSparkle from "src/assets/images/start-sparkle.svg"; +import { useIsMobile } from "src/utils/ui/useIsMobile"; +import { Well } from "@beanstalk/sdk/Wells"; +import { useBeanstalkSiloAPYs } from "src/wells/useBeanstalkSiloAPYs"; +import { mediaQuery } from "src/breakpoints"; + +type Props = { + well: Well | undefined; + apy?: TokenValue; + loading?: boolean; + tooltipProps?: Partial>; +}; + +export const WellYieldWithTooltip: React.FC = ({ tooltipProps, well }) => { + const sdk = useSdk(); + + const bean = sdk.tokens.BEAN; + const isMobile = useIsMobile(); + + const { getSiloAPYWithWell } = useBeanstalkSiloAPYs(); + + const apy = useMemo(() => { + const data = getSiloAPYWithWell(well); + + if (!data) return undefined; + return `${data.mul(100).toHuman("short")}%`; + }, [well, getSiloAPYWithWell]); + + const tooltipWidth = isMobile ? 250 : 360; + + if (!apy) { + return <>{"-"}; + } + + return ( + + + +
Well Yield
+
+
+
+ +
+ Bean vAPY +
+ {apy} +
+
+ +
+ The Variable Bean APY (vAPY) uses historical data of Beans earned by{" "} + + Silo Depositors + +  to estimate future returns. +
+
+ + } + offsetY={tooltipProps?.offsetY || 0} + offsetX={tooltipProps?.offsetX || 0} + arrowOffset={0} + arrowSize={0} + side={tooltipProps?.side || "top"} + bgColor="white" + width={tooltipWidth} + > + + +
{apy} vAPY
+
+
+
+ ); +}; + +const Container = styled.div` + display: flex; + flex-direction: column; + gap: 24px; + width: 100%; + padding: 4px; + box-sizing: border-box; + + .underlined { + text-decoration: underline; + + &:visited { + color: #000; + } + } + + ${mediaQuery.sm.only} { + gap: 16px; + } +`; + +const TitleContainer = styled.div` + display: flex; + flex-direction: column; + align-items: flex-start; + width: 100%; + gap: 8px; + + .title { + ${BodyS} + font-weight: 600; + } + + .label-value { + display: flex; + flex-direction: row; + width: 100%; + justify-content: space-between; + align-items: center; + ${BodyS} + color: #46b955; + font-weight: 600; + + .logo-wrapper { + position: relative; + margin-top: 2px; + } + + .label { + display: flex; + flex-direction: row; + gap: 4px; + } + } +`; + +const ContentContainer = styled.div` + display: flex; + width: 100%; + ${BodyS} + text-align: left; +`; + +const StyledImg = styled.img` + display: flex; + width: 24px; + height: 24px; + padding: 3px 2px 3px 3px; + justify-content: center; + align-items: center; + box-sizing: border-box; + + ${mediaQuery.sm.only} { + height: 20px; + width: 20px; + } +`; + +const ChildContainer = styled.div` + display: flex; + flex-direction: row; + align-items: center; + gap: 4px; + background: #edf8ee; + padding: 4px; + color: #46b955; + width: max-content; + border-radius: 4px; + + ${BodyL} + font-weight: 600; + + ${mediaQuery.sm.only} { + ${BodyS} + } +`; + +const TooltipContainer = styled.div` + width: max-content; +`; diff --git a/projects/dex-ui/src/pages/Home.tsx b/projects/dex-ui/src/pages/Home.tsx index 0ce89f0893..c3a5308f36 100644 --- a/projects/dex-ui/src/pages/Home.tsx +++ b/projects/dex-ui/src/pages/Home.tsx @@ -8,15 +8,15 @@ import { BodyL } from "src/components/Typography"; import { ContractInfoMarquee } from "src/components/Frame/ContractInfoMarquee"; const copy = { - build: "Use components written, audited and deployed by other developers for your custom liquidity pool.", - deploy: "Liquidity pools with unique pricing functions for more granular market making.", - fees: "Trade assets using liquidity pools that don’t impose trading fees." + build: "Use DEX components written, audited and deployed by other developers for your custom liquidity pool.", + deploy: "Deploy liquidity in pools with unique pricing functions for more granular market making.", + fees: "Exchange assets in liquidity pools that don't impose trading fees." }; const links = { multiFlowPump: "/multi-flow-pump.pdf", whitepaper: "/basin.pdf", - docs: "https://docs.basin.exchange/", + docs: "https://docs.basin.exchange/implementations/overview", wells: "/#/wells", swap: "/#/swap" }; @@ -29,10 +29,10 @@ export const Home = () => { - Multi-Flow Pump is here! + Multi Flow Pump is here!
- Explore the multi-block MEV manipulation resistant Oracle framework, with easy - integration for everyone. + Explore the inter-block MEV manipulation resistant oracle implementation used by + the BEAN:WETH Well.
@@ -42,7 +42,7 @@ export const Home = () => {
- A Composable EVM-native DEX + A Composable EVM-Native DEX Customizable liquidity pools with shared components.  @@ -172,6 +172,8 @@ const MevTitle = styled.div` `; const GetStartedContainer = styled.a` + text-decoration: none; + :focus { text-decoration: none; } diff --git a/projects/dex-ui/src/pages/Well.tsx b/projects/dex-ui/src/pages/Well.tsx index d257382c2c..4f6e48d073 100644 --- a/projects/dex-ui/src/pages/Well.tsx +++ b/projects/dex-ui/src/pages/Well.tsx @@ -1,4 +1,4 @@ -import React, { ReactNode, useCallback, useEffect, useRef, useState } from "react"; +import React, { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { getPrice } from "src/utils/price/usePrice"; import useSdk from "src/utils/sdk/useSdk"; @@ -26,14 +26,24 @@ import { Error } from "src/components/Error"; import { useWellWithParams } from "src/wells/useWellWithParams"; import { LoadingItem } from "src/components/LoadingItem"; import { LoadingTemplate } from "src/components/LoadingTemplate"; +import { WellYieldWithTooltip } from "src/components/Well/WellYieldWithTooltip"; +import { useIsMobile } from "src/utils/ui/useIsMobile"; +import { useLagLoading } from "src/utils/ui/useLagLoading"; +import { useBeanstalkSiloAPYs } from "src/wells/useBeanstalkSiloAPYs"; +import { useMultiFlowPumpTWAReserves } from "src/wells/useMultiFlowPumpTWAReserves"; export const Well = () => { - const { well, loading: loading, error } = useWellWithParams(); + const { well, loading: dataLoading, error } = useWellWithParams(); + const { isLoading: apysLoading } = useBeanstalkSiloAPYs(); + const { isLoading: twaLoading, getTWAReservesWithWell } = useMultiFlowPumpTWAReserves(); + + const loading = useLagLoading(dataLoading || apysLoading || twaLoading); const sdk = useSdk(); const navigate = useNavigate(); const [prices, setPrices] = useState<(TokenValue | null)[]>([]); const [wellFunctionName, setWellFunctionName] = useState("-"); + const isMobile = useIsMobile(); const [tab, setTab] = useState(0); const showTab = useCallback((e: React.MouseEvent, i: number) => { @@ -84,6 +94,8 @@ export const Well = () => { reserve.percentage = reserve.dollarAmount && totalUSD.gt(TokenValue.ZERO) ? reserve.dollarAmount.div(totalUSD) : TokenValue.ZERO; }); + const twaReserves = useMemo(() => getTWAReservesWithWell(well), [well, getTWAReservesWithWell]); + const goLiquidity = () => navigate(`./liquidity`); const goSwap = () => @@ -140,6 +152,16 @@ export const Well = () => { {title} +
+ +
@@ -154,7 +176,7 @@ export const Well = () => { */} }> - + @@ -281,7 +303,7 @@ const ContentWrapper = styled.div` width: 100%; ${mediaQuery.lg.only} { - height: 1400px; + height: 1600px; } ${mediaQuery.between.smAndLg} { @@ -306,6 +328,10 @@ const Header = styled.div` font-size: 24px; gap: 8px; } + + .silo-yield-section { + align-self: center; + } `; const TokenLogos = styled.div` diff --git a/projects/dex-ui/src/pages/Wells.tsx b/projects/dex-ui/src/pages/Wells.tsx index 80b848139b..660f1f8528 100644 --- a/projects/dex-ui/src/pages/Wells.tsx +++ b/projects/dex-ui/src/pages/Wells.tsx @@ -17,6 +17,8 @@ import { useLPPositionSummary } from "src/tokens/useLPPositionSummary"; import { WellDetailLoadingRow, WellDetailRow } from "src/components/Well/Table/WellDetailRow"; import { MyWellPositionLoadingRow, MyWellPositionRow } from "src/components/Well/Table/MyWellPositionRow"; +import { useBeanstalkSiloAPYs } from "src/wells/useBeanstalkSiloAPYs"; +import { useLagLoading } from "src/utils/ui/useLagLoading"; export const Wells = () => { const { data: wells, isLoading, error } = useWells(); @@ -27,8 +29,10 @@ export const Wells = () => { const [tab, showTab] = useState(0); const { data: lpTokenPrices } = useWellLPTokenPrice(wells); + const { hasPositions, getPositionWithWell, isLoading: positionsLoading } = useLPPositionSummary(); + const { isLoading: apysLoading } = useBeanstalkSiloAPYs(); - const { hasPositions, getPositionWithWell } = useLPPositionSummary(); + const loading = useLagLoading(isLoading || apysLoading || positionsLoading); useMemo(() => { const run = async () => { @@ -77,7 +81,7 @@ export const Wells = () => { - + {tab === 0 ? ( @@ -101,7 +105,7 @@ export const Wells = () => { )} - {isLoading ? ( + {loading ? ( <> {Array(5) .fill(null) @@ -146,11 +150,15 @@ export const Wells = () => { )} -
+ ); }; +const StyledTable = styled(Table)` + overflow: auto; +`; + const TableRow = styled(Row)` @media (max-width: ${size.mobile}) { height: 66px; diff --git a/projects/dex-ui/src/queries/GetSiloAPY.graphql b/projects/dex-ui/src/queries/GetSiloAPY.graphql new file mode 100644 index 0000000000..cdea1b57bd --- /dev/null +++ b/projects/dex-ui/src/queries/GetSiloAPY.graphql @@ -0,0 +1,13 @@ +query BeanstalkSiloLatestAPY { + siloYields(first: 1, orderBy: season, orderDirection: desc) { + id + season + zeroSeedBeanAPY + twoSeedBeanAPY + fourSeedBeanAPY + threeSeedBeanAPY + threePointTwoFiveSeedBeanAPY + fourPointFiveSeedBeanAPY + beansPerSeasonEMA + } +} diff --git a/projects/dex-ui/src/settings/development.ts b/projects/dex-ui/src/settings/development.ts index 71cab7fca7..23cafd70c4 100644 --- a/projects/dex-ui/src/settings/development.ts +++ b/projects/dex-ui/src/settings/development.ts @@ -5,6 +5,7 @@ export const DevSettings: DexSettings = { AQUIFER_ADDRESS: import.meta.env.VITE_AQUIFER_ADDRESS, SUBGRAPH_URL: "https://graph.node.bean.money/subgraphs/name/basin", // SUBGRAPH_URL: "http://127.0.0.1:8000/subgraphs/name/beanstalk-wells", + BEANSTALK_SUBGRAPH_URL: "https://graph.node.bean.money/subgraphs/name/beanstalk", WELLS_ORIGIN_BLOCK: parseInt(import.meta.env.VITE_WELLS_ORIGIN_BLOCK) || 17977922, LOAD_HISTORY_FROM_GRAPH: !!parseInt(import.meta.env.VITE_LOAD_HISTORY_FROM_GRAPH) || false }; diff --git a/projects/dex-ui/src/settings/index.ts b/projects/dex-ui/src/settings/index.ts index 89b822a62a..4adc6711b6 100644 --- a/projects/dex-ui/src/settings/index.ts +++ b/projects/dex-ui/src/settings/index.ts @@ -11,6 +11,7 @@ export type DexSettings = { PRODUCTION: boolean; AQUIFER_ADDRESS: Address; SUBGRAPH_URL: string; + BEANSTALK_SUBGRAPH_URL: string; WELLS_ORIGIN_BLOCK: number; LOAD_HISTORY_FROM_GRAPH: boolean; NETLIFY_CONTEXT?: string; diff --git a/projects/dex-ui/src/settings/production.ts b/projects/dex-ui/src/settings/production.ts index 304196a329..b53be9a87c 100644 --- a/projects/dex-ui/src/settings/production.ts +++ b/projects/dex-ui/src/settings/production.ts @@ -4,6 +4,7 @@ export const ProdSettings: DexSettings = { PRODUCTION: true, AQUIFER_ADDRESS: import.meta.env.VITE_AQUIFER_ADDRESS, SUBGRAPH_URL: "https://graph.node.bean.money/subgraphs/name/basin", + BEANSTALK_SUBGRAPH_URL: "https://graph.node.bean.money/subgraphs/name/beanstalk", WELLS_ORIGIN_BLOCK: 17977922, LOAD_HISTORY_FROM_GRAPH: true }; diff --git a/projects/dex-ui/src/utils/addresses.ts b/projects/dex-ui/src/utils/addresses.ts new file mode 100644 index 0000000000..15752e4abe --- /dev/null +++ b/projects/dex-ui/src/utils/addresses.ts @@ -0,0 +1,7 @@ +/// All addresses are in lowercase for consistency + +/// Well LP Tokens +export const BEANETH_ADDRESS = "0xbea0e11282e2bb5893bece110cf199501e872bad"; + +/// Pump Addresses +export const BEANETH_MULTIPUMP_ADDRESS = "0xba510f10e3095b83a0f33aa9ad2544e22570a87c"; diff --git a/projects/dex-ui/src/utils/format.ts b/projects/dex-ui/src/utils/format.ts index ac12fea358..ebda31e637 100644 --- a/projects/dex-ui/src/utils/format.ts +++ b/projects/dex-ui/src/utils/format.ts @@ -1,6 +1,6 @@ import { Token, TokenValue } from "@beanstalk/sdk"; -type NumberPrimitive = string | number | TokenValue | undefined; +type NumberPrimitive = string | number | TokenValue | undefined | null; /** * We can for the most part use TokenValue.toHuman("short"), @@ -15,13 +15,13 @@ export const formatNum = ( maxDecimals?: number; } ) => { - if (val === undefined) return options?.defaultValue || "-.--"; + if (val === undefined || val === null) return options?.defaultValue || "-.--"; const normalised = val instanceof TokenValue ? val.toHuman() : val.toString(); return Number(normalised).toLocaleString("en-US", { - minimumFractionDigits: 0 || options?.minDecimals, - maximumFractionDigits: 2 || options?.maxDecimals + minimumFractionDigits: options?.minDecimals || 0, + maximumFractionDigits: options?.maxDecimals || 2 }); }; @@ -34,6 +34,28 @@ export const formatUSD = ( return `$${formatNum(val || TokenValue.ZERO, { minDecimals: 2, maxDecimals: 2, ...options })}`; }; +const normaliseAsTokenValue = (val: NumberPrimitive) => { + if (val instanceof TokenValue) return val; + const num = val ? (typeof val === "string" ? Number(val) : val) : 0; + return TokenValue.ZERO.add(num); +}; + +/** + * Formats a number as a percentage. + * - If value to format is 0.01, it will be formatted as 1.00%. + * - If value is undefined, it will be formatted as "--%" or options.defaultValue. + * - If value is < (0.0001) (0.01%), it will be formatted as "<0.01%" + */ +export const formatPercent = (val: NumberPrimitive, options?: { defaultValue: string }) => { + if (!val) return `${options?.defaultValue || "--"}%`; + + const pct = normaliseAsTokenValue(val).mul(100); + + if (pct.lt(0.01)) return "<0.01%"; + + return `${formatNum(pct, { minDecimals: 2, maxDecimals: 2, ...options })}%`; +}; + const TokenSymbolMap = { BEANWETHCP2w: "BEANETH LP" }; diff --git a/projects/dex-ui/src/utils/ui/useLagLoading.ts b/projects/dex-ui/src/utils/ui/useLagLoading.ts new file mode 100644 index 0000000000..61c8ebdf54 --- /dev/null +++ b/projects/dex-ui/src/utils/ui/useLagLoading.ts @@ -0,0 +1,34 @@ +import { useEffect, useRef, useState } from "react"; + +/** + * Purpose of this hook is to prevent loading indicators + * from flashing due to fast load times + */ +export const useLagLoading = (_loading: boolean, _lagTime?: number) => { + const mountTime = useRef(Date.now()); + const [loading, setDataLoading] = useState(_loading); + + const lagTime = _lagTime || 500; + + useEffect(() => { + if (!_loading) return; + + const now = Date.now(); + const diff = Math.abs(mountTime.current - now); + + const run = async () => { + if (diff > lagTime) { + setDataLoading(false); + } else { + const remaining = lagTime - diff; + setTimeout(() => { + setDataLoading(false); + }, remaining); + } + }; + + run(); + }, [loading, _loading, mountTime, lagTime]); + + return loading; +}; diff --git a/projects/dex-ui/src/wells/apyFetcher.ts b/projects/dex-ui/src/wells/apyFetcher.ts new file mode 100644 index 0000000000..28d79e0e57 --- /dev/null +++ b/projects/dex-ui/src/wells/apyFetcher.ts @@ -0,0 +1,72 @@ +import { TokenValue } from "@beanstalk/sdk"; +import { Log } from "src/utils/logger"; +import { BeanstalkSiloLatestApyDocument } from "src/generated/graph/graphql"; +import { fetchFromSubgraphRequest } from "./subgraphFetch"; + +export type SiloAPYResult = { + id: string; + season: number; + zeroSeedBeanAPY: TokenValue; + twoSeedBeanAPY: TokenValue; + threeSeedBeanAPY: TokenValue; + threePointTwoFiveSeedBeanAPY: TokenValue; + fourSeedBeanAPY: TokenValue; + fourPointFiveSeedBeanAPY: TokenValue; + beansPerSeasonEMA: TokenValue; +}; + +const defaultResult: SiloAPYResult = { + id: "", + season: 0, + zeroSeedBeanAPY: TokenValue.ZERO, + twoSeedBeanAPY: TokenValue.ZERO, + fourSeedBeanAPY: TokenValue.ZERO, + threeSeedBeanAPY: TokenValue.ZERO, + threePointTwoFiveSeedBeanAPY: TokenValue.ZERO, + fourPointFiveSeedBeanAPY: TokenValue.ZERO, + beansPerSeasonEMA: TokenValue.ZERO +}; + +const normalise = (data: string | number) => { + return TokenValue.ZERO.add(parseFloat(typeof data === "string" ? data : data.toString())); +}; + +const fetchAPYFromSubgraph = async () => { + Log.module("SiloAPYData").debug("Loading APY data from Graph"); + const fetch = await fetchFromSubgraphRequest(BeanstalkSiloLatestApyDocument, undefined, { useBeanstalkSubgraph: true }); + + const result = await fetch() + .then((response) => { + if (!response.siloYields.length) return { ...defaultResult }; + + return response.siloYields.reduce( + (_, datum) => { + return { + id: datum.id, + season: datum.season, + zeroSeedBeanAPY: normalise(datum.zeroSeedBeanAPY), + twoSeedBeanAPY: normalise(datum.twoSeedBeanAPY), + fourSeedBeanAPY: normalise(datum.fourSeedBeanAPY), + threeSeedBeanAPY: normalise(datum.threeSeedBeanAPY), + threePointTwoFiveSeedBeanAPY: normalise(datum.threePointTwoFiveSeedBeanAPY), + fourPointFiveSeedBeanAPY: normalise(datum.fourPointFiveSeedBeanAPY), + beansPerSeasonEMA: normalise(datum.beansPerSeasonEMA) + }; + }, + { ...defaultResult } + ); + }) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + .catch((e) => { + // console.error("FAILED TO FETCH SILO APYS: ", e); + return { ...defaultResult }; + }); + + Log.module("SiloAPYData").debug("result: ", result); + + return result; +}; + +export const loadSiloAPYData = async () => { + return fetchAPYFromSubgraph(); +}; diff --git a/projects/dex-ui/src/wells/chartDataLoader.ts b/projects/dex-ui/src/wells/chartDataLoader.ts index 57b67c9c4a..c143854a27 100644 --- a/projects/dex-ui/src/wells/chartDataLoader.ts +++ b/projects/dex-ui/src/wells/chartDataLoader.ts @@ -1,10 +1,12 @@ import { BeanstalkSDK } from "@beanstalk/sdk"; import { fetchFromSubgraphRequest } from "./subgraphFetch"; import { Well } from "@beanstalk/sdk/Wells"; -import { GetWellChartDataDocument } from "src/generated/graph/graphql"; +import { GetWellChartDataDocument, GetWellChartDataQuery } from "src/generated/graph/graphql"; import { Log } from "src/utils/logger"; -const loadFromGraph = async (sdk: BeanstalkSDK, well: Well, timePeriod: string) => { +export type IWellHourlySnapshot = NonNullable["hourlySnapshots"][number]; + +const loadFromGraph = async (sdk: BeanstalkSDK, well: Well, timePeriod: string): Promise => { if (!well) return []; Log.module("wellChartData").debug("Loading chart data from Graph"); @@ -13,7 +15,7 @@ const loadFromGraph = async (sdk: BeanstalkSDK, well: Well, timePeriod: string) const HISTORY_DAYS_AGO_BLOCK_TIMESTAMP = HISTORY_DAYS === 0 ? 0 : Math.floor(new Date(Date.now() - HISTORY_DAYS * 24 * 60 * 60 * 1000).getTime() / 1000); - let results: any[] = []; + let results: IWellHourlySnapshot[] = []; let goToNextPage: boolean = false; let nextPage: number = 0; let skipAmount: number = 0; @@ -39,8 +41,7 @@ const loadFromGraph = async (sdk: BeanstalkSDK, well: Well, timePeriod: string) } else { goToNextPage = false; } - } - + } while (goToNextPage === true); return results; diff --git a/projects/dex-ui/src/wells/subgraphFetch.ts b/projects/dex-ui/src/wells/subgraphFetch.ts index 0f07cc9a9a..2ce23c76a7 100644 --- a/projects/dex-ui/src/wells/subgraphFetch.ts +++ b/projects/dex-ui/src/wells/subgraphFetch.ts @@ -2,9 +2,19 @@ import request from "graphql-request"; import { type TypedDocumentNode } from "@graphql-typed-document-node/core"; import { Settings } from "src/settings"; +type AdditionalSubgraphFetchOptions = { + useBeanstalkSubgraph?: boolean; +}; + export function fetchFromSubgraphRequest( document: TypedDocumentNode, - variables: TVariables extends Record ? undefined : TVariables + variables: TVariables extends Record ? undefined : TVariables, + options?: AdditionalSubgraphFetchOptions ): () => Promise { - return async () => request(Settings.SUBGRAPH_URL, document, variables ? variables : undefined); + return async () => + request( + options?.useBeanstalkSubgraph ? Settings.BEANSTALK_SUBGRAPH_URL : Settings.SUBGRAPH_URL, + document, + variables ? variables : undefined + ); } diff --git a/projects/dex-ui/src/wells/useBeanstalkSiloAPYs.tsx b/projects/dex-ui/src/wells/useBeanstalkSiloAPYs.tsx new file mode 100644 index 0000000000..8564ccbf11 --- /dev/null +++ b/projects/dex-ui/src/wells/useBeanstalkSiloAPYs.tsx @@ -0,0 +1,55 @@ +import { useQuery } from "@tanstack/react-query"; +import { loadSiloAPYData } from "./apyFetcher"; +import { Well } from "@beanstalk/sdk/Wells"; +import { useCallback } from "react"; +import { useBeanstalkSiloWhitelist } from "./useBeanstalkSiloWhitelist"; + +// TODO: BIP39 will change the APYs we get from the subgraph +export const useBeanstalkSiloAPYs = () => { + const { getSeedsWithWell } = useBeanstalkSiloWhitelist(); + + const query = useQuery( + ["wells", "APYs"], + async () => { + const data = await loadSiloAPYData(); + return data; + }, + { + staleTime: 1000 * 60, + refetchOnWindowFocus: false + } + ); + + const getSiloAPYWithWell = useCallback( + (well: Well | undefined) => { + const seeds = getSeedsWithWell(well); + if (!query.data || !seeds) return undefined; + + const seedsStr = parseFloat(seeds.toHuman()).toString(); + const d = query.data; + + switch (seedsStr) { + case "0": + return d.zeroSeedBeanAPY; + case "2": + return d.twoSeedBeanAPY; + case "3": + return d.threeSeedBeanAPY; + case "3.5": + return d.threePointTwoFiveSeedBeanAPY; + case "4": + return d.fourSeedBeanAPY; + case "4.5": + return d.fourPointFiveSeedBeanAPY; + default: + return undefined; + } + }, + [getSeedsWithWell, query.data] + ); + + return { + ...query, + getSiloAPYWithWell + }; +}; diff --git a/projects/dex-ui/src/wells/useBeanstalkSiloWhitelist.ts b/projects/dex-ui/src/wells/useBeanstalkSiloWhitelist.ts index 817f9d99cd..63a859e4ef 100644 --- a/projects/dex-ui/src/wells/useBeanstalkSiloWhitelist.ts +++ b/projects/dex-ui/src/wells/useBeanstalkSiloWhitelist.ts @@ -1,24 +1,36 @@ -import { useMemo } from "react"; +import { useCallback } from "react"; import { Well } from "@beanstalk/sdk/Wells"; +import { BEANETH_MULTIPUMP_ADDRESS } from "src/utils/addresses"; +import useSdk from "src/utils/sdk/useSdk"; -const WHITELIST_MAP = { - /// BEANWETHCP2w (BEANETH LP) - "0xbea0e11282e2bb5893bece110cf199501e872bad": { - address: "0xBEA0e11282e2bB5893bEcE110cF199501e872bAd", - lpTokenAddress: "0xbea0e11282e2bb5893bece110cf199501e872bad" - } -}; - -/// set of wells that are whitelisted for the Beanstalk silo export const useBeanstalkSiloWhitelist = () => { - const whitelistedAddresses = useMemo(() => Object.keys(WHITELIST_MAP), []); + const sdk = useSdk(); + + const getIsWhitelisted = useCallback( + (well: Well | undefined) => { + if (!well?.lpToken) return false; + const token = sdk.tokens.findByAddress(well.lpToken.address); + return Boolean(token && sdk.tokens.siloWhitelist.has(token)); + }, + [sdk.tokens] + ); - const getIsWhitelisted = (well: Well | undefined) => { - if (!well) return false; - const wellAddress = well.address.toLowerCase(); + const getSeedsWithWell = useCallback( + (well: Well | undefined) => { + if (!well?.lpToken) return undefined; + return sdk.tokens.findByAddress(well.lpToken.address)?.getSeeds(); + }, + [sdk.tokens] + ); - return wellAddress in WHITELIST_MAP; - }; + const getIsMultiPumpWell = useCallback((well: Well | undefined) => { + if (!well?.pumps) return false; + return !!well.pumps.find((pump) => pump.address.toLowerCase() === BEANETH_MULTIPUMP_ADDRESS); + }, []); - return { whitelist: whitelistedAddresses, getIsWhitelisted } as const; + return { + getIsWhitelisted, + getSeedsWithWell, + getIsMultiPumpWell + } as const; }; diff --git a/projects/dex-ui/src/wells/useMultiFlowPumpTWAReserves.tsx b/projects/dex-ui/src/wells/useMultiFlowPumpTWAReserves.tsx new file mode 100644 index 0000000000..e6a872bd40 --- /dev/null +++ b/projects/dex-ui/src/wells/useMultiFlowPumpTWAReserves.tsx @@ -0,0 +1,86 @@ +import useSdk from "src/utils/sdk/useSdk"; +import { useWells } from "./useWells"; +import { useBeanstalkSiloWhitelist } from "./useBeanstalkSiloWhitelist"; + +import { multicall } from "@wagmi/core"; +import MULTI_PUMP_ABI from "src/abi/MULTI_PUMP_ABI.json"; +import { TokenValue } from "@beanstalk/sdk"; +import { useQuery } from "@tanstack/react-query"; +import { Well } from "@beanstalk/sdk/Wells"; +import { useCallback } from "react"; + +export const useMultiFlowPumpTWAReserves = () => { + const { data: wells } = useWells(); + const { getIsMultiPumpWell } = useBeanstalkSiloWhitelist(); + const sdk = useSdk(); + + const query = useQuery( + ["wells", "multiFlowPumpTWAReserves"], + async () => { + const whitelistedWells = (wells || []).filter((well) => getIsMultiPumpWell(well)); + + const [{ timestamp: seasonTimestamp }, ...wellOracleSnapshots] = await Promise.all([ + sdk.contracts.beanstalk.time(), + ...whitelistedWells.map((well) => sdk.contracts.beanstalk.wellOracleSnapshot(well.address)) + ]); + + const calls: any[] = whitelistedWells.reduce((prev, well, idx) => { + well.pumps?.forEach((pump) => { + prev.push({ + address: pump.address as `0x{string}`, + abi: MULTI_PUMP_ABI, + functionName: "readTwaReserves", + args: [well.address, wellOracleSnapshots[idx], seasonTimestamp.toString(), "0x"] + }); + }); + + return prev; + }, []); + + const twaReservesResult: any[] = await multicall({ contracts: calls }); + + const mapping: Record = {}; + let index = 0; + + whitelistedWells.forEach((well) => { + const twa = [TokenValue.ZERO, TokenValue.ZERO]; + const numPumps = well.pumps?.length || 1; + + well.pumps?.forEach((_pump) => { + const twaResult = twaReservesResult[index]; + const token1 = well.tokens?.[0]; + const token2 = well.tokens?.[1]; + + const reserves = twaResult["twaReserves"]; + + if (token1 && token2 && reserves.length === 2) { + twa[0] = twa[0].add(TokenValue.fromBlockchain(reserves[0], token1.decimals)); + twa[1] = twa[1].add(TokenValue.fromBlockchain(reserves[1], token2.decimals)); + } + index += 1; + }); + + /// In case there is more than one pump, divide the reserves by the number of pumps + /// Is this how to handle the case where there is more than one pump? + mapping[well.address.toLowerCase()] = [twa[0].div(numPumps), twa[1].div(numPumps)]; + }); + return mapping; + }, + { + staleTime: 1000 * 60, + enabled: !!wells?.length, + refetchOnMount: true + } + ); + + const getTWAReservesWithWell = useCallback( + (well: Well | undefined) => { + if (!well || !query.data) return undefined; + + return query.data[well.address.toLowerCase()]; + }, + [query.data] + ); + + return { ...query, getTWAReservesWithWell }; +}; diff --git a/projects/dex-ui/src/wells/useWellChartData.tsx b/projects/dex-ui/src/wells/useWellChartData.tsx index 686d3def61..4f288314f7 100644 --- a/projects/dex-ui/src/wells/useWellChartData.tsx +++ b/projects/dex-ui/src/wells/useWellChartData.tsx @@ -1,13 +1,13 @@ import { useQuery } from "@tanstack/react-query"; import useSdk from "src/utils/sdk/useSdk"; -import { loadChartData } from "./chartDataLoader"; +import { IWellHourlySnapshot, loadChartData } from "./chartDataLoader"; import { Well } from "@beanstalk/sdk/Wells"; const useWellChartData = (well: Well, timePeriod: string) => { const sdk = useSdk(); - return useQuery( + return useQuery( ["wells", "wellChartData", well.address], async () => { const data = await loadChartData(sdk, well, timePeriod); diff --git a/projects/examples/src/wells/deployNewWell.ts b/projects/examples/src/wells/deployNewWell.ts new file mode 100644 index 0000000000..eaba432b33 --- /dev/null +++ b/projects/examples/src/wells/deployNewWell.ts @@ -0,0 +1,24 @@ +import { provider, signer } from "../setup"; +import { WellsSDK, Well, Aquifer, WellFunction } from "@beanstalk/sdk-wells"; + +const DEPLOYED_AQUIFER_ADDRESS = "0xBA51AAAA95aeEFc1292515b36D86C51dC7877773"; + +main().catch((e) => { + console.log("[ERROR]:", e); +}); + +async function main() { + const wellsSDK = new WellsSDK({ provider, signer }); + + const wellTokens = [wellsSDK.tokens.BEAN, wellsSDK.tokens.WETH]; + + const aquifer = new Aquifer(wellsSDK, DEPLOYED_AQUIFER_ADDRESS); + + console.log("Building Well w/ Constant Product Well Function..."); + const wellFunction = await WellFunction.BuildConstantProduct(wellsSDK); + + console.log("Deploying Well with Aquifer: ", aquifer.address); + const well = await Well.DeployViaAquifer(wellsSDK, aquifer, wellTokens, wellFunction, []); + + console.log("[DEPLOYED WELL/address]", well.address); +} diff --git a/projects/ui/src/graph/graphql.schema.json b/projects/ui/src/graph/graphql.schema.json index d6ec9eb16e..9de27bcc7c 100644 --- a/projects/ui/src/graph/graphql.schema.json +++ b/projects/ui/src/graph/graphql.schema.json @@ -137392,6 +137392,18 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "turbo", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "twitter", "description": null, @@ -157278,9 +157290,7 @@ "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", @@ -157304,20 +157314,14 @@ "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", @@ -157341,11 +157345,7 @@ "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", @@ -157369,9 +157369,7 @@ "name": "specifiedBy", "description": "Exposes a URL that specifies the behavior of this scalar.", "isRepeatable": false, - "locations": [ - "SCALAR" - ], + "locations": ["SCALAR"], "args": [ { "name": "url", @@ -157395,9 +157393,7 @@ "name": "subgraphId", "description": "Defined a Subgraph ID for an object type", "isRepeatable": false, - "locations": [ - "OBJECT" - ], + "locations": ["OBJECT"], "args": [ { "name": "id", @@ -157419,4 +157415,4 @@ } ] } -} \ No newline at end of file +}