From f1e9976674419ec7e4ab7a035fa69dd534cdf669 Mon Sep 17 00:00:00 2001 From: Hayden Shively <17186559+haydenshively@users.noreply.github.com> Date: Fri, 26 Jan 2024 19:29:34 -0600 Subject: [PATCH 01/10] Replace MarketCard/LendCard widgets with one big table on Stats page --- .../components/boost/ImportBoostWidget.tsx | 2 +- earn/src/components/info/StatsTable.tsx | 380 ++++++++++++++++++ earn/src/pages/InfoPage.tsx | 64 +-- earn/src/pages/LendPage.tsx | 2 +- earn/src/pages/PortfolioPage.tsx | 4 +- shared/src/components/common/TokenIcon.tsx | 17 +- shared/src/components/common/TokenIcons.tsx | 9 +- shared/src/data/TokenData.ts | 8 +- 8 files changed, 416 insertions(+), 70 deletions(-) create mode 100644 earn/src/components/info/StatsTable.tsx diff --git a/earn/src/components/boost/ImportBoostWidget.tsx b/earn/src/components/boost/ImportBoostWidget.tsx index 3d1576263..e156b0ffe 100644 --- a/earn/src/components/boost/ImportBoostWidget.tsx +++ b/earn/src/components/boost/ImportBoostWidget.tsx @@ -222,7 +222,7 @@ export default function ImportBoostWidget(props: ImportBoostWidgetProps) { } const tokenQuoteData: TokenQuote[] = Object.entries(prResponse).map(([key, value]) => { return { - token: getTokenBySymbol(activeChain.id, key), + token: getTokenBySymbol(activeChain.id, key)!, price: value.price, }; }); diff --git a/earn/src/components/info/StatsTable.tsx b/earn/src/components/info/StatsTable.tsx new file mode 100644 index 000000000..840522133 --- /dev/null +++ b/earn/src/components/info/StatsTable.tsx @@ -0,0 +1,380 @@ +import { useContext, useMemo, useState } from 'react'; + +import { SendTransactionResult } from '@wagmi/core'; +import { format, formatDistanceToNowStrict } from 'date-fns'; +import { factoryAbi } from 'shared/lib/abis/Factory'; +import { volatilityOracleAbi } from 'shared/lib/abis/VolatilityOracle'; +import DownArrow from 'shared/lib/assets/svg/DownArrow'; +import OpenIcon from 'shared/lib/assets/svg/OpenNoPad'; +import UnknownTokenIcon from 'shared/lib/assets/svg/tokens/unknown_token.svg'; +import UpArrow from 'shared/lib/assets/svg/UpArrow'; +import { FilledGreyButton } from 'shared/lib/components/common/Buttons'; +import Pagination from 'shared/lib/components/common/Pagination'; +import TokenIcons from 'shared/lib/components/common/TokenIcons'; +import { Text, Display } from 'shared/lib/components/common/Typography'; +import { ALOE_II_FACTORY_ADDRESS, ALOE_II_ORACLE_ADDRESS } from 'shared/lib/data/constants/ChainSpecific'; +import { GREY_600 } from 'shared/lib/data/constants/Colors'; +import { Q32 } from 'shared/lib/data/constants/Values'; +import { FeeTier, PrintFeeTier } from 'shared/lib/data/FeeTier'; +import { GN, GNFormat } from 'shared/lib/data/GoodNumber'; +import useSortableData from 'shared/lib/data/hooks/UseSortableData'; +import { getTokenBySymbol } from 'shared/lib/data/TokenData'; +import { getEtherscanUrlForChain } from 'shared/lib/util/Chains'; +import styled from 'styled-components'; +import { Address, useContractWrite } from 'wagmi'; + +import { ChainContext } from '../../App'; + +const PAGE_SIZE = 20; +const SECONDARY_COLOR = 'rgba(130, 160, 182, 1)'; +const GREEN_COLOR = 'rgba(0, 189, 63, 1)'; +const YELLOW_COLOR = 'rgba(242, 201, 76, 1)'; +const RED_COLOR = 'rgba(234, 87, 87, 0.75)'; + +const TableContainer = styled.div` + width: 100%; + overflow-x: auto; + border: 2px solid ${GREY_600}; + border-radius: 6px; +`; + +const Table = styled.table` + width: 100%; +`; + +const TableHeader = styled.thead` + border-bottom: 2px solid ${GREY_600}; + text-align: start; +`; + +const HoverableRow = styled.tr` + &:hover { + background-color: rgba(130, 160, 182, 0.1); + } +`; + +const SortButton = styled.button` + display: inline-flex; + align-items: center; + gap: 4px; +`; + +const StyledDownArrow = styled(DownArrow)` + width: 12px; + height: 12px; + + path { + stroke: rgba(130, 160, 182, 1); + stroke-width: 3px; + } +`; + +const StyledUpArrow = styled(UpArrow)` + width: 12px; + height: 12px; + + path { + stroke: rgba(130, 160, 182, 1); + stroke-width: 3px; + } +`; + +const OpenIconLink = styled.a` + svg { + path { + stroke: ${SECONDARY_COLOR}; + } + } +`; + +type SortArrowProps = { + isSorted: boolean; + isSortedDesc: boolean; +}; + +function SortArrow(props: SortArrowProps) { + const { isSorted, isSortedDesc } = props; + if (!isSorted) { + return null; + } + if (isSortedDesc) { + return ; + } else { + return ; + } +} + +function getManipulationColor(manipulationMetric: number, manipulationThreshold: number) { + // If the manipulation metric is greater than or equal to the threshold, the color is red. + // If the manipulation metric is less than the threshold, but is within 20% of the threshold, + // the color is yellow. + // Otherwise, the color is green. + if (manipulationMetric >= manipulationThreshold) { + return RED_COLOR; + } else if (manipulationMetric >= manipulationThreshold * 0.8) { + return YELLOW_COLOR; + } else { + return GREEN_COLOR; + } +} + +export type StatsTableRowProps = { + nSigma: number; + ltv: number; + ante: GN; + pausedUntilTime: number; + manipulationMetric: number; + manipulationThreshold: number; + lenderSymbols: [string, string]; + lenderAddresses: [Address, Address]; + poolAddress: string; + feeTier: FeeTier; + lastUpdatedTimestamp?: number; + reserveFactors: number[]; + rateModels: Address[]; + setPendingTxn: (data: SendTransactionResult) => void; +}; + +function StatsTableRow(props: StatsTableRowProps) { + const { + nSigma, + ltv, + ante, + pausedUntilTime, + manipulationMetric, + manipulationThreshold, + lenderSymbols, + poolAddress, + feeTier, + lastUpdatedTimestamp, + reserveFactors, + lenderAddresses, + setPendingTxn, + } = props; + const { activeChain } = useContext(ChainContext); + + const uniswapLink = `${getEtherscanUrlForChain(activeChain)}/address/${poolAddress}`; + const token0Symbol = lenderSymbols[0].slice(0, lenderSymbols[0].length - 1); + const token1Symbol = lenderSymbols[1].slice(0, lenderSymbols[1].length - 1); + + const manipulationColor = getManipulationColor(manipulationMetric, manipulationThreshold); + const manipulationInequality = manipulationMetric < manipulationThreshold ? '<' : '>'; + + const lastUpdated = lastUpdatedTimestamp + ? formatDistanceToNowStrict(new Date(lastUpdatedTimestamp * 1000), { addSuffix: true, roundingMethod: 'round' }) + : 'Never'; + const minutesSinceLastUpdate = lastUpdatedTimestamp ? (Date.now() / 1000 - lastUpdatedTimestamp) / 60 : 0; + const canUpdateLTV = minutesSinceLastUpdate > 240 || lastUpdatedTimestamp === undefined; + + const isPaused = pausedUntilTime > Date.now() / 1000; + const canBorrowingBeDisabled = manipulationMetric >= manipulationThreshold; + + const token0 = getTokenBySymbol(activeChain.id, token0Symbol) ?? { + name: 'Unknown Token', + symbol: token0Symbol, + logoURI: UnknownTokenIcon, + }; + const token1 = getTokenBySymbol(activeChain.id, token1Symbol) ?? { + name: 'Unknown Token', + symbol: token1Symbol, + logoURI: UnknownTokenIcon, + }; + + const lenderLinks = lenderAddresses.map((addr) => `${getEtherscanUrlForChain(activeChain)}/address/${addr}`); + + const { writeAsync: pause } = useContractWrite({ + address: ALOE_II_FACTORY_ADDRESS[activeChain.id], + abi: factoryAbi, + functionName: 'pause', + mode: 'recklesslyUnprepared', + args: [poolAddress as Address, Q32], + chainId: activeChain.id, + onSuccess: (data: SendTransactionResult) => setPendingTxn(data), + }); + + const { writeAsync: updateLTV } = useContractWrite({ + address: ALOE_II_ORACLE_ADDRESS[activeChain.id], + abi: volatilityOracleAbi, + functionName: 'update', + mode: 'recklesslyUnprepared', + args: [poolAddress as Address, Q32], + chainId: activeChain.id, + onSuccess: (data: SendTransactionResult) => setPendingTxn(data), + }); + + return ( + + +
+ +
+ + +
+ + {token0Symbol}/{token1Symbol} + + + + +
+ + {PrintFeeTier(feeTier)} + + + + {ante.toString(GNFormat.LOSSY_HUMAN)}  ETH + + + {nSigma} + + + + {reserveFactors[0]}% / {reserveFactors[1]}% + + + +
+
+
+ + {manipulationMetric.toFixed(0)} + + +   {manipulationInequality}  {manipulationThreshold.toFixed(0)} + +
+ + Borrows {isPaused ? `paused until ${format(pausedUntilTime * 1000, 'h:mmaaa')}` : 'enabled'} + +
+ {true && ( + pause()} + disabled={!canBorrowingBeDisabled} + backgroundColor={canBorrowingBeDisabled ? RED_COLOR : SECONDARY_COLOR} + > + Report + + )} +
+ + +
+
+ {(ltv * 100).toFixed(0)}% + + Updated {lastUpdated} + +
+ {true && ( + updateLTV()} disabled={!canUpdateLTV}> + Update + + )} +
+ +
+ ); +} + +export type StatsTableProps = { + rows: StatsTableRowProps[]; +}; + +export default function StatsTable(props: StatsTableProps) { + const { rows } = props; + const [currentPage, setCurrentPage] = useState(1); + const { sortedRows, requestSort, sortConfig } = useSortableData(rows, { + primaryKey: 'manipulationMetric', + secondaryKey: 'ltv', + direction: 'descending', + }); + + const pages: StatsTableRowProps[][] = useMemo(() => { + const pages: StatsTableRowProps[][] = []; + for (let i = 0; i < sortedRows.length; i += PAGE_SIZE) { + pages.push(sortedRows.slice(i, i + PAGE_SIZE)); + } + return pages; + }, [sortedRows]); + if (pages.length === 0) { + return null; + } + return ( + <> + + + + + + + + + + + + + + + {pages[currentPage - 1].map((row, index) => ( + + ))} + + + + + + +
+ + Market + + + + Uniswap + + + + Ante + + + σ + + + Reserve Factor + + + requestSort('manipulationMetric')}> + + Oracle Manipulation + + + + + requestSort('ltv')}> + + LTV + + + +
+ setCurrentPage(page)} + /> +
+
+ + ); +} diff --git a/earn/src/pages/InfoPage.tsx b/earn/src/pages/InfoPage.tsx index 10ea9de97..de7131afd 100644 --- a/earn/src/pages/InfoPage.tsx +++ b/earn/src/pages/InfoPage.tsx @@ -1,4 +1,4 @@ -import { Fragment, useContext, useEffect, useState } from 'react'; +import { useContext, useEffect, useState } from 'react'; import { SendTransactionResult } from '@wagmi/core'; import { ContractCallContext, Multicall } from 'ethereum-multicall'; @@ -18,13 +18,11 @@ import { FeeTier, NumericFeeTierToEnum } from 'shared/lib/data/FeeTier'; import { GN } from 'shared/lib/data/GoodNumber'; import { useChainDependentState } from 'shared/lib/data/hooks/UseChainDependentState'; import { getToken } from 'shared/lib/data/TokenData'; -import styled from 'styled-components'; import { Address, useBlockNumber, useProvider } from 'wagmi'; import { ChainContext } from '../App'; import PendingTxnModal, { PendingTxnModalStatus } from '../components/common/PendingTxnModal'; -import LenderCard from '../components/info/LenderCard'; -import MarketCard from '../components/info/MarketCard'; +import StatsTable from '../components/info/StatsTable'; import { computeLTV } from '../data/BalanceSheet'; import { UNISWAP_POOL_DENYLIST } from '../data/constants/Addresses'; import { TOPIC0_CREATE_MARKET_EVENT, TOPIC0_UPDATE_ORACLE } from '../data/constants/Signatures'; @@ -56,17 +54,6 @@ type LenderInfo = { totalSupply: GN; }; -const InfoGrid = styled.div` - display: grid; - grid-template-columns: max-content max-content max-content; - gap: 0px; - row-gap: 32px; - width: 100%; - justify-content: safe center; - margin-left: auto; - overflow: auto; -`; - export default function InfoPage() { const { activeChain } = useContext(ChainContext); const provider = useProvider({ chainId: activeChain.id }); @@ -347,43 +334,16 @@ export default function InfoPage() { return ( - - {Array.from(poolInfo?.entries() ?? []).map(([addr, info]) => { - return ( - - - - - - ); - })} - + ({ + ...info, + poolAddress: addr, + reserveFactors: info.lenderReserveFactors, + rateModels: info.lenderRateModels, + lenderAddresses: info.lenders, + setPendingTxn, + }))} + /> { return { - token: getTokenBySymbol(activeChain.id, key), + token: getTokenBySymbol(activeChain.id, key)!, price: value.price, }; }); diff --git a/earn/src/pages/PortfolioPage.tsx b/earn/src/pages/PortfolioPage.tsx index c9f6da0ae..88e6fbead 100644 --- a/earn/src/pages/PortfolioPage.tsx +++ b/earn/src/pages/PortfolioPage.tsx @@ -175,13 +175,13 @@ export default function PortfolioPage() { } const tokenQuoteData: TokenQuote[] = Object.entries(latestPriceResponse).map(([symbol, data]) => { return { - token: getTokenBySymbol(activeChain.id, symbol), + token: getTokenBySymbol(activeChain.id, symbol)!, price: data.price, }; }); const tokenPriceData: TokenPriceData[] = Object.entries(historicalPriceResponse).map(([symbol, data]) => { return { - token: getTokenBySymbol(activeChain.id, symbol), + token: getTokenBySymbol(activeChain.id, symbol)!, priceEntries: data.prices, }; }); diff --git a/shared/src/components/common/TokenIcon.tsx b/shared/src/components/common/TokenIcon.tsx index c9cffc612..d46bf908e 100644 --- a/shared/src/components/common/TokenIcon.tsx +++ b/shared/src/components/common/TokenIcon.tsx @@ -1,4 +1,3 @@ -import { Token } from '../../data/Token'; import styled from 'styled-components'; const StyledTokenIcon = styled.img.attrs((props: { width: number; height: number; borderRadius: string }) => props)` @@ -11,15 +10,17 @@ const StyledTokenIcon = styled.img.attrs((props: { width: number; height: number `; export type TokenIconProps = { - token: Token; + token: { name: string; logoURI: string }; width?: number; height?: number; borderRadius?: string; + link?: string; }; export default function TokenIcon(props: TokenIconProps) { - const { token, width, height, borderRadius } = props; - return ( + const { token, width, height, borderRadius, link } = props; + + const icon = ( ); + + return link ? ( + + {icon} + + ) : ( + icon + ); } diff --git a/shared/src/components/common/TokenIcons.tsx b/shared/src/components/common/TokenIcons.tsx index 3c8f63c68..6eb3656b9 100644 --- a/shared/src/components/common/TokenIcons.tsx +++ b/shared/src/components/common/TokenIcons.tsx @@ -1,5 +1,4 @@ import styled from 'styled-components'; -import { Token } from '../../data/Token'; import TokenIcon from './TokenIcon'; const Container = styled.div` @@ -22,18 +21,20 @@ const Container = styled.div` `; export type TokenIconsProps = { - tokens: Token[]; + tokens: { name: string; symbol: string; logoURI: string }[]; width?: number; height?: number; + links?: string[]; }; export default function TokenIcons(props: TokenIconsProps) { - const { tokens, width, height } = props; + const { tokens, width, height, links } = props; + return ( {tokens.map((token, index) => (
- +
))}
diff --git a/shared/src/data/TokenData.ts b/shared/src/data/TokenData.ts index e6b0ca099..ce6db7e4f 100644 --- a/shared/src/data/TokenData.ts +++ b/shared/src/data/TokenData.ts @@ -431,12 +431,8 @@ export function getToken(chainId: number, address: Address): Token | undefined { return TOKEN_DATA[chainId][getLowercaseAddress(address)]; } -export function getTokenBySymbol(chainId: number, symbol: string): Token { - const token = Object.values(TOKEN_DATA[chainId]).find((token) => token.symbol.toUpperCase() === symbol.toUpperCase()); - if (!token) { - throw new Error(`Could not find token with symbol ${symbol}`); - } - return token; +export function getTokenBySymbol(chainId: number, symbol: string): Token | undefined { + return Object.values(TOKEN_DATA[chainId]).find((token) => token.symbol.toUpperCase() === symbol.toUpperCase()); } function getLowercaseAddress(address: Address): Address { From c4a6edd6eff008a7ca267dbd4f0bf6521c68eb24 Mon Sep 17 00:00:00 2001 From: Hayden Shively <17186559+haydenshively@users.noreply.github.com> Date: Sun, 28 Jan 2024 22:35:16 -0600 Subject: [PATCH 02/10] Small refactor of LendingPair fetching --- earn/src/data/LendingPair.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/earn/src/data/LendingPair.ts b/earn/src/data/LendingPair.ts index 07316ba14..ccc17d943 100644 --- a/earn/src/data/LendingPair.ts +++ b/earn/src/data/LendingPair.ts @@ -26,6 +26,7 @@ import { Address } from 'wagmi'; import { ContractCallReturnContextEntries, convertBigNumbersForReturnContexts } from '../util/Multicall'; import { computeLTV } from './BalanceSheet'; import { UNISWAP_POOL_DENYLIST } from './constants/Addresses'; +import { TOPIC0_CREATE_MARKET_EVENT } from './constants/Signatures'; import { borrowAPRToLendAPY } from './RateModel'; export interface KittyInfo { @@ -78,32 +79,37 @@ export async function getAvailableLendingPairs( const multicall = new Multicall({ ethersProvider: provider, multicallCustomContractAddress: MULTICALL_ADDRESS[chainId], + tryAggregate: true, }); + + // Fetch all the Aloe II markets let logs: ethers.providers.Log[] = []; try { logs = await provider.getLogs({ fromBlock: 0, toBlock: 'latest', address: ALOE_II_FACTORY_ADDRESS[chainId], - topics: ['0x3f53d2c2743b2b162c0aa5d678be4058d3ae2043700424be52c04105df3e2411'], + topics: [TOPIC0_CREATE_MARKET_EVENT], }); } catch (e) { console.error(e); } if (logs.length === 0) return []; + // Get all of the lender and pool addresses from the logs const addresses: { pool: string; kitty0: string; kitty1: string }[] = logs.map((item: any) => { + const lenderAddresses = ethers.utils.defaultAbiCoder.decode(['address', 'address'], item.data); return { - pool: item.topics[1].slice(26), - kitty0: `0x${item.data.slice(26, 66)}`, - kitty1: `0x${item.data.slice(90, 134)}`, + pool: `0x${item.topics[1].slice(-40)}` as Address, + kitty0: lenderAddresses[0], + kitty1: lenderAddresses[1], }; }); const contractCallContexts: ContractCallContext[] = []; addresses.forEach((market) => { - if (UNISWAP_POOL_DENYLIST.includes(`0x${market.pool.toLowerCase()}`)) { + if (UNISWAP_POOL_DENYLIST.includes(market.pool.toLowerCase())) { return; } From d93130687167e4e3600ddbad7ac31b363683d0bb Mon Sep 17 00:00:00 2001 From: Hayden Shively <17186559+haydenshively@users.noreply.github.com> Date: Mon, 29 Jan 2024 01:14:42 -0600 Subject: [PATCH 03/10] Refactor InfoPage to use lendingPair context instead of re-fetching --- earn/src/components/info/StatsTable.tsx | 165 +++++----- earn/src/components/lend/LendPairCard.tsx | 4 +- .../src/components/lend/modal/BorrowModal.tsx | 4 +- .../portfolio/modal/EarnInterestModal.tsx | 2 +- earn/src/data/LendingPair.ts | 98 +++--- earn/src/pages/InfoPage.tsx | 310 ++---------------- earn/src/pages/LendPage.tsx | 4 +- earn/src/pages/MarketsPage.tsx | 4 +- earn/src/pages/PortfolioPage.tsx | 4 +- 9 files changed, 172 insertions(+), 423 deletions(-) diff --git a/earn/src/components/info/StatsTable.tsx b/earn/src/components/info/StatsTable.tsx index 840522133..dd4313f39 100644 --- a/earn/src/components/info/StatsTable.tsx +++ b/earn/src/components/info/StatsTable.tsx @@ -6,7 +6,6 @@ import { factoryAbi } from 'shared/lib/abis/Factory'; import { volatilityOracleAbi } from 'shared/lib/abis/VolatilityOracle'; import DownArrow from 'shared/lib/assets/svg/DownArrow'; import OpenIcon from 'shared/lib/assets/svg/OpenNoPad'; -import UnknownTokenIcon from 'shared/lib/assets/svg/tokens/unknown_token.svg'; import UpArrow from 'shared/lib/assets/svg/UpArrow'; import { FilledGreyButton } from 'shared/lib/components/common/Buttons'; import Pagination from 'shared/lib/components/common/Pagination'; @@ -15,15 +14,16 @@ import { Text, Display } from 'shared/lib/components/common/Typography'; import { ALOE_II_FACTORY_ADDRESS, ALOE_II_ORACLE_ADDRESS } from 'shared/lib/data/constants/ChainSpecific'; import { GREY_600 } from 'shared/lib/data/constants/Colors'; import { Q32 } from 'shared/lib/data/constants/Values'; -import { FeeTier, PrintFeeTier } from 'shared/lib/data/FeeTier'; -import { GN, GNFormat } from 'shared/lib/data/GoodNumber'; +import { PrintFeeTier } from 'shared/lib/data/FeeTier'; +import { GNFormat } from 'shared/lib/data/GoodNumber'; import useSortableData from 'shared/lib/data/hooks/UseSortableData'; -import { getTokenBySymbol } from 'shared/lib/data/TokenData'; import { getEtherscanUrlForChain } from 'shared/lib/util/Chains'; +import { roundPercentage } from 'shared/lib/util/Numbers'; import styled from 'styled-components'; import { Address, useContractWrite } from 'wagmi'; import { ChainContext } from '../../App'; +import { LendingPair } from '../../data/LendingPair'; const PAGE_SIZE = 20; const SECONDARY_COLOR = 'rgba(130, 160, 182, 1)'; @@ -119,75 +119,21 @@ function getManipulationColor(manipulationMetric: number, manipulationThreshold: } export type StatsTableRowProps = { - nSigma: number; - ltv: number; - ante: GN; - pausedUntilTime: number; - manipulationMetric: number; - manipulationThreshold: number; - lenderSymbols: [string, string]; - lenderAddresses: [Address, Address]; - poolAddress: string; - feeTier: FeeTier; + lendingPair: LendingPair; lastUpdatedTimestamp?: number; - reserveFactors: number[]; - rateModels: Address[]; setPendingTxn: (data: SendTransactionResult) => void; }; function StatsTableRow(props: StatsTableRowProps) { - const { - nSigma, - ltv, - ante, - pausedUntilTime, - manipulationMetric, - manipulationThreshold, - lenderSymbols, - poolAddress, - feeTier, - lastUpdatedTimestamp, - reserveFactors, - lenderAddresses, - setPendingTxn, - } = props; + const { lendingPair: pair, lastUpdatedTimestamp, setPendingTxn } = props; const { activeChain } = useContext(ChainContext); - const uniswapLink = `${getEtherscanUrlForChain(activeChain)}/address/${poolAddress}`; - const token0Symbol = lenderSymbols[0].slice(0, lenderSymbols[0].length - 1); - const token1Symbol = lenderSymbols[1].slice(0, lenderSymbols[1].length - 1); - - const manipulationColor = getManipulationColor(manipulationMetric, manipulationThreshold); - const manipulationInequality = manipulationMetric < manipulationThreshold ? '<' : '>'; - - const lastUpdated = lastUpdatedTimestamp - ? formatDistanceToNowStrict(new Date(lastUpdatedTimestamp * 1000), { addSuffix: true, roundingMethod: 'round' }) - : 'Never'; - const minutesSinceLastUpdate = lastUpdatedTimestamp ? (Date.now() / 1000 - lastUpdatedTimestamp) / 60 : 0; - const canUpdateLTV = minutesSinceLastUpdate > 240 || lastUpdatedTimestamp === undefined; - - const isPaused = pausedUntilTime > Date.now() / 1000; - const canBorrowingBeDisabled = manipulationMetric >= manipulationThreshold; - - const token0 = getTokenBySymbol(activeChain.id, token0Symbol) ?? { - name: 'Unknown Token', - symbol: token0Symbol, - logoURI: UnknownTokenIcon, - }; - const token1 = getTokenBySymbol(activeChain.id, token1Symbol) ?? { - name: 'Unknown Token', - symbol: token1Symbol, - logoURI: UnknownTokenIcon, - }; - - const lenderLinks = lenderAddresses.map((addr) => `${getEtherscanUrlForChain(activeChain)}/address/${addr}`); - const { writeAsync: pause } = useContractWrite({ address: ALOE_II_FACTORY_ADDRESS[activeChain.id], abi: factoryAbi, functionName: 'pause', mode: 'recklesslyUnprepared', - args: [poolAddress as Address, Q32], + args: [pair.uniswapPool as Address, Q32], chainId: activeChain.id, onSuccess: (data: SendTransactionResult) => setPendingTxn(data), }); @@ -197,41 +143,83 @@ function StatsTableRow(props: StatsTableRowProps) { abi: volatilityOracleAbi, functionName: 'update', mode: 'recklesslyUnprepared', - args: [poolAddress as Address, Q32], + args: [pair.uniswapPool as Address, Q32], chainId: activeChain.id, onSuccess: (data: SendTransactionResult) => setPendingTxn(data), }); + const uniswapLink = `${getEtherscanUrlForChain(activeChain)}/address/${pair.uniswapPool}`; + + const manipulationMetric = pair.oracleData.manipulationMetric; + const manipulationThreshold = pair.manipulationThreshold; + const canBorrowingBeDisabled = manipulationMetric >= manipulationThreshold; + const manipulationColor = getManipulationColor(manipulationMetric, manipulationThreshold); + const manipulationInequality = manipulationMetric < manipulationThreshold ? '<' : '>'; + + let lastUpdatedText = '━━━━━━━━━━'; + let canUpdateLTV: boolean | null = null; + + if (lastUpdatedTimestamp === undefined) { + lastUpdatedText = '━━━━━━━━━━'; + canUpdateLTV = null; + } else if (lastUpdatedTimestamp === -1) { + lastUpdatedText = 'Updated never'; + canUpdateLTV = true; + } else if (lastUpdatedTimestamp === 0) { + lastUpdatedText = 'Missing update block'; + canUpdateLTV = true; + } else { + lastUpdatedText = `Updated ${formatDistanceToNowStrict(new Date(lastUpdatedTimestamp * 1000), { + addSuffix: true, + roundingMethod: 'round', + })}`; + const minutesSinceLastUpdate = (Date.now() / 1000 - lastUpdatedTimestamp) / 60; + canUpdateLTV = minutesSinceLastUpdate > 240; + } + + const pausedUntilTime = pair.factoryData.pausedUntilTime; + const isPaused = pausedUntilTime > Date.now() / 1000; + + const lenderLinks = [pair.kitty0.address, pair.kitty1.address].map( + (addr) => `${getEtherscanUrlForChain(activeChain)}/address/${addr}` + ); + + const reserveFactorTexts = [pair.kitty0Info.reserveFactor, pair.kitty1Info.reserveFactor].map((rf) => + roundPercentage(100 / rf, 2) + ); + const reserveFactorText = + reserveFactorTexts[0] === reserveFactorTexts[1] + ? `${reserveFactorTexts[0]}%` + : `${reserveFactorTexts[0]}% / ${reserveFactorTexts[1]}%`; + return (
- +
- {token0Symbol}/{token1Symbol} + {pair.token0.symbol}/{pair.token1.symbol}
- {PrintFeeTier(feeTier)} + {PrintFeeTier(pair.uniswapFeeTier)} - {ante.toString(GNFormat.LOSSY_HUMAN)}  ETH + {pair.factoryData.ante.toString(GNFormat.LOSSY_HUMAN)}  ETH - {nSigma} + {pair.factoryData.nSigma} - - {reserveFactors[0]}% / {reserveFactors[1]}% - + {reserveFactorText}
@@ -263,9 +251,9 @@ function StatsTableRow(props: StatsTableRowProps) {
- {(ltv * 100).toFixed(0)}% + {(pair.ltv * 100).toFixed(0)}% - Updated {lastUpdated} + {lastUpdatedText}
{true && ( @@ -279,16 +267,21 @@ function StatsTableRow(props: StatsTableRowProps) { ); } -export type StatsTableProps = { - rows: StatsTableRowProps[]; -}; - -export default function StatsTable(props: StatsTableProps) { +export default function StatsTable(props: { rows: StatsTableRowProps[] }) { const { rows } = props; const [currentPage, setCurrentPage] = useState(1); - const { sortedRows, requestSort, sortConfig } = useSortableData(rows, { - primaryKey: 'manipulationMetric', - secondaryKey: 'ltv', + + // workaround to get sort data in the outermost scope + const sortableRows = useMemo(() => { + return rows.map((row) => ({ + ...row, + sortA: row.lendingPair.oracleData.manipulationMetric, + sortB: row.lendingPair.ltv, + })); + }, [rows]); + const { sortedRows, requestSort, sortConfig } = useSortableData(sortableRows, { + primaryKey: 'sortA', + secondaryKey: 'sortB', direction: 'descending', }); @@ -332,23 +325,23 @@ export default function StatsTable(props: StatsTableProps) { - requestSort('manipulationMetric')}> + requestSort('sortA')}> Oracle Manipulation - requestSort('ltv')}> + requestSort('sortB')}> LTV @@ -356,8 +349,8 @@ export default function StatsTable(props: StatsTableProps) { - {pages[currentPage - 1].map((row, index) => ( - + {pages[currentPage - 1].map((row) => ( + ))} diff --git a/earn/src/components/lend/LendPairCard.tsx b/earn/src/components/lend/LendPairCard.tsx index 4c2a2f2e3..3442b645e 100644 --- a/earn/src/components/lend/LendPairCard.tsx +++ b/earn/src/components/lend/LendPairCard.tsx @@ -134,7 +134,7 @@ export default function LendPairCard(props: LendPairCardProps) { - {roundPercentage(kitty0Info.lendAPY)}% APY + {roundPercentage(kitty0Info.lendAPY * 100)}% APY
@@ -157,7 +157,7 @@ export default function LendPairCard(props: LendPairCardProps) { - {roundPercentage(kitty1Info.lendAPY)}% APY + {roundPercentage(kitty1Info.lendAPY * 100)}% APY
diff --git a/earn/src/components/lend/modal/BorrowModal.tsx b/earn/src/components/lend/modal/BorrowModal.tsx index 26fdfc154..76e2f61ec 100644 --- a/earn/src/components/lend/modal/BorrowModal.tsx +++ b/earn/src/components/lend/modal/BorrowModal.tsx @@ -153,7 +153,7 @@ export default function BorrowModal(props: BorrowModalProps) { return null; } const sqrtPriceX96 = GN.fromBigNumber(consultData?.[1] ?? BigNumber.from('0'), 96, 2); - const nSigma = selectedLendingPair?.nSigma ?? 0; + const nSigma = selectedLendingPair?.factoryData.nSigma ?? 0; const iv = consultData[2].div(1e6).toNumber() / 1e6; const ltv = computeLTV(iv, nSigma); @@ -169,7 +169,7 @@ export default function BorrowModal(props: BorrowModalProps) { consultData, selectedBorrow, selectedCollateral.address, - selectedLendingPair?.nSigma, + selectedLendingPair?.factoryData.nSigma, selectedLendingPair?.token0.address, ]); diff --git a/earn/src/components/portfolio/modal/EarnInterestModal.tsx b/earn/src/components/portfolio/modal/EarnInterestModal.tsx index 5a8e6afa5..c029b935b 100644 --- a/earn/src/components/portfolio/modal/EarnInterestModal.tsx +++ b/earn/src/components/portfolio/modal/EarnInterestModal.tsx @@ -347,7 +347,7 @@ export default function EarnInterestModal(props: EarnInterestModalProps) { /> - {roundPercentage(activeKittyInfo?.lendAPY ?? 0, 2).toFixed(2)}% + {roundPercentage((activeKittyInfo?.lendAPY ?? 0) * 100, 2).toFixed(2)}%
diff --git a/earn/src/data/LendingPair.ts b/earn/src/data/LendingPair.ts index ccc17d943..2aef63190 100644 --- a/earn/src/data/LendingPair.ts +++ b/earn/src/data/LendingPair.ts @@ -29,18 +29,32 @@ import { UNISWAP_POOL_DENYLIST } from './constants/Addresses'; import { TOPIC0_CREATE_MARKET_EVENT } from './constants/Signatures'; import { borrowAPRToLendAPY } from './RateModel'; -export interface KittyInfo { - borrowAPR: number; - lendAPY: number; - // The amount of underlying owed to all Kitty token holders - // (both the amount currently sitting in contract, and the amount that has been lent out) - inventory: number; - // The total number of outstanding Kitty tokens - totalSupply: number; - // What percentage of inventory that has been lent out to borrowers - utilization: number; +class KittyInfo { + public readonly lendAPY: number; + + constructor( + public readonly inventory: number, + public readonly totalSupply: number, + public readonly borrowAPR: number, + public readonly utilization: number, + public readonly reserveFactor: number + ) { + this.lendAPY = borrowAPRToLendAPY(borrowAPR, utilization, reserveFactor); + } } +type FactoryData = { + ante: GN; + nSigma: number; + manipulationThresholdDivisor: number; + pausedUntilTime: number; +}; + +type OracleData = { + iv: GN; + manipulationMetric: number; +}; + export class LendingPair { constructor( public token0: Token, @@ -51,16 +65,27 @@ export class LendingPair { public kitty1Info: KittyInfo, public uniswapPool: Address, public uniswapFeeTier: FeeTier, - public iv: number, - public nSigma: number, - public ltv: number, public rewardsRate0: number, - public rewardsRate1: number + public rewardsRate1: number, + public factoryData: FactoryData, + public oracleData: OracleData ) {} equals(other: LendingPair) { return other.kitty0.address === this.kitty0.address && other.kitty1.address === this.kitty1.address; } + + get iv() { + return this.oracleData.iv.toNumber(); + } + + get ltv() { + return computeLTV(this.iv, this.factoryData.nSigma); + } + + get manipulationThreshold() { + return -Math.log(this.ltv) / Math.log(1.0001) / this.factoryData.manipulationThresholdDivisor; + } } export type LendingPairBalances = { @@ -237,17 +262,17 @@ export async function getAvailableLendingPairs( const totalSupply0 = toImpreciseNumber(basics0[5], kitty0.decimals); const totalSupply1 = toImpreciseNumber(basics1[5], kitty1.decimals); - const reserveFactor0 = basics0[6]; - const reserveFactor1 = basics1[6]; - - const rewardsRate0 = toImpreciseNumber(basics0[7], 18); - const rewardsRate1 = toImpreciseNumber(basics1[7], 18); - - const iv = ethers.BigNumber.from(oracleResult[2]).div(1e6).toNumber() / 1e6; - - const nSigma = (factoryResult[1] as number) / 10; + const oracleData = { + iv: GN.fromBigNumber(oracleResult[2], 12), + manipulationMetric: oracleResult[0].toNumber(), + }; - const ltv = computeLTV(iv, nSigma); + const factoryData = { + ante: GN.fromBigNumber(factoryResult[0], 18), + nSigma: (factoryResult[1] as number) / 10, + manipulationThresholdDivisor: factoryResult[2], + pausedUntilTime: factoryResult[3], + }; lendingPairs.push( new LendingPair( @@ -255,27 +280,14 @@ export async function getAvailableLendingPairs( token1, kitty0, kitty1, - { - borrowAPR: borrowAPR0 * 100, - lendAPY: borrowAPRToLendAPY(borrowAPR0, utilization0, reserveFactor0) * 100, - inventory: inventory0, - totalSupply: totalSupply0, - utilization: utilization0 * 100.0, // Percentage - }, - { - borrowAPR: borrowAPR1 * 100, - lendAPY: borrowAPRToLendAPY(borrowAPR1, utilization1, reserveFactor1) * 100, - inventory: inventory1, - totalSupply: totalSupply1, - utilization: utilization1 * 100.0, // Percentage - }, + new KittyInfo(inventory0, totalSupply0, borrowAPR0, utilization0, basics0[6]), + new KittyInfo(inventory1, totalSupply1, borrowAPR1, utilization1, basics1[6]), uniswapPool as Address, NumericFeeTierToEnum(feeTier[0]), - iv * Math.sqrt(365), - nSigma, - ltv, - rewardsRate0, - rewardsRate1 + toImpreciseNumber(basics0[7], 18), // rewardsRate0 + toImpreciseNumber(basics1[7], 18), // rewardsRate1 + factoryData, + oracleData ) ); }); diff --git a/earn/src/pages/InfoPage.tsx b/earn/src/pages/InfoPage.tsx index de7131afd..793eb3b0b 100644 --- a/earn/src/pages/InfoPage.tsx +++ b/earn/src/pages/InfoPage.tsx @@ -1,58 +1,17 @@ import { useContext, useEffect, useState } from 'react'; import { SendTransactionResult } from '@wagmi/core'; -import { ContractCallContext, Multicall } from 'ethereum-multicall'; import { ethers } from 'ethers'; -import { factoryAbi } from 'shared/lib/abis/Factory'; -import { lenderAbi } from 'shared/lib/abis/Lender'; -import { uniswapV3PoolAbi } from 'shared/lib/abis/UniswapV3Pool'; -import { volatilityOracleAbi } from 'shared/lib/abis/VolatilityOracle'; import AppPage from 'shared/lib/components/common/AppPage'; -import { - ALOE_II_FACTORY_ADDRESS, - ALOE_II_ORACLE_ADDRESS, - MULTICALL_ADDRESS, -} from 'shared/lib/data/constants/ChainSpecific'; -import { Q32 } from 'shared/lib/data/constants/Values'; -import { FeeTier, NumericFeeTierToEnum } from 'shared/lib/data/FeeTier'; -import { GN } from 'shared/lib/data/GoodNumber'; +import { ALOE_II_ORACLE_ADDRESS } from 'shared/lib/data/constants/ChainSpecific'; import { useChainDependentState } from 'shared/lib/data/hooks/UseChainDependentState'; -import { getToken } from 'shared/lib/data/TokenData'; -import { Address, useBlockNumber, useProvider } from 'wagmi'; +import { useBlockNumber, useProvider } from 'wagmi'; import { ChainContext } from '../App'; import PendingTxnModal, { PendingTxnModalStatus } from '../components/common/PendingTxnModal'; import StatsTable from '../components/info/StatsTable'; -import { computeLTV } from '../data/BalanceSheet'; -import { UNISWAP_POOL_DENYLIST } from '../data/constants/Addresses'; -import { TOPIC0_CREATE_MARKET_EVENT, TOPIC0_UPDATE_ORACLE } from '../data/constants/Signatures'; -import { ContractCallReturnContextEntries, convertBigNumbersForReturnContexts } from '../util/Multicall'; - -type AloeMarketInfo = { - lenders: [Address, Address]; - lenderSymbols: [string, string]; - lenderDecimals: [number, number]; - lenderRateModels: [Address, Address]; - lenderReserveFactors: [number, number]; - lenderTotalSupplies: [GN, GN]; - nSigma: number; - iv: number; - ltv: number; - ante: GN; - pausedUntilTime: number; - manipulationMetric: number; - manipulationThreshold: number; - feeTier: FeeTier; - lastUpdatedTimestamp?: number; -}; - -type LenderInfo = { - reserveFactor: number; - rateModel: Address; - symbol: string; - decimals: number; - totalSupply: GN; -}; +import { TOPIC0_UPDATE_ORACLE } from '../data/constants/Signatures'; +import { useLendingPairs } from '../data/hooks/UseLendingPairs'; export default function InfoPage() { const { activeChain } = useContext(ChainContext); @@ -60,10 +19,9 @@ export default function InfoPage() { const [pendingTxn, setPendingTxn] = useState(null); const [isPendingTxnModalOpen, setIsPendingTxnModalOpen] = useState(false); const [pendingTxnModalStatus, setPendingTxnModalStatus] = useState(null); - const [poolInfo, setPoolInfo] = useChainDependentState | undefined>( - undefined, - activeChain.id - ); + const [latestTimestamps, setLatestTimestamps] = useChainDependentState<(number | undefined)[]>([], activeChain.id); + + const { lendingPairs } = useLendingPairs(); const { data: blockNumber, refetch } = useBlockNumber({ chainId: activeChain.id, @@ -87,169 +45,6 @@ export default function InfoPage() { (async () => { const chainId = (await provider.getNetwork()).chainId; - // Fetch all the Aloe II markets - let logs: ethers.providers.Log[] = []; - try { - logs = await provider.getLogs({ - fromBlock: 0, - toBlock: 'latest', - address: ALOE_II_FACTORY_ADDRESS[chainId], - topics: [TOPIC0_CREATE_MARKET_EVENT], - }); - } catch (e) { - console.error(e); - } - - // Get all of the lender addresses from the logs - const lenderAddresses = logs.map((log) => { - return ethers.utils.defaultAbiCoder.decode(['address', 'address'], log.data); - }); - - // Get all of the pool addresses from the logs - const poolAddresses = logs - .map((e) => `0x${e.topics[1].slice(-40)}` as Address) - .filter((addr) => { - return !UNISWAP_POOL_DENYLIST.includes(addr.toLowerCase()); - }); - - const multicall = new Multicall({ - ethersProvider: provider, - multicallCustomContractAddress: MULTICALL_ADDRESS[chainId], - tryAggregate: true, - }); - - // Get all of the lender info - const lenderCallContexts: ContractCallContext[] = []; - - lenderAddresses - .flatMap((addr) => addr) - .forEach((addr) => { - lenderCallContexts.push({ - reference: addr, - contractAddress: addr, - abi: lenderAbi as any, - calls: [ - { - reference: 'reserveFactor', - methodName: 'reserveFactor', - methodParameters: [], - }, - { - reference: 'rateModel', - methodName: 'rateModel', - methodParameters: [], - }, - { - reference: 'symbol', - methodName: 'symbol', - methodParameters: [], - }, - { - reference: 'decimals', - methodName: 'decimals', - methodParameters: [], - }, - { - reference: 'totalSupply', - methodName: 'totalSupply', - methodParameters: [], - }, - { - reference: 'asset', - methodName: 'asset', - methodParameters: [], - }, - ], - }); - }); - - const lenderCallResults = (await multicall.call(lenderCallContexts)).results; - - // Lender address -> Lender info - const lenderResults: Map = new Map(); - - Object.entries(lenderCallResults).forEach(([key, value]) => { - const updatedCallsReturnContext = convertBigNumbersForReturnContexts(value.callsReturnContext); - const reserveFactor = (1 / updatedCallsReturnContext[0].returnValues[0]) * 100; - const rateModel = updatedCallsReturnContext[1].returnValues[0] as Address; - let symbol = updatedCallsReturnContext[2].returnValues[0] as string; - if (symbol === undefined) { - const assetSymbol = getToken(chainId, updatedCallsReturnContext[5].returnValues[0])?.symbol; - symbol = assetSymbol === undefined ? 'UNKNOWN' : `${assetSymbol}+`; - } - const decimals = updatedCallsReturnContext[3].returnValues[0] as number; - const totalSupply = GN.fromBigNumber( - updatedCallsReturnContext[4].returnValues[0] as ethers.BigNumber, - decimals - ); - - lenderResults.set(key, { - reserveFactor, - rateModel, - symbol, - decimals, - totalSupply, - }); - }); - - // Get all of the pool info - const poolCallContexts: ContractCallContext[] = []; - - poolAddresses.forEach((addr) => { - poolCallContexts.push({ - reference: `${addr}-uniswap`, - contractAddress: addr, - abi: uniswapV3PoolAbi as any, - calls: [ - { - reference: 'fee', - methodName: 'fee', - methodParameters: [], - }, - ], - }); - poolCallContexts.push({ - reference: `${addr}-oracle`, - contractAddress: ALOE_II_ORACLE_ADDRESS[chainId], - abi: volatilityOracleAbi as any, - calls: [ - { - reference: 'consult', - methodName: 'consult', - methodParameters: [addr, Q32], - }, - ], - }); - poolCallContexts.push({ - reference: `${addr}-factory`, - contractAddress: ALOE_II_FACTORY_ADDRESS[chainId], - abi: factoryAbi as any, - calls: [ - { - reference: 'getParameters', - methodName: 'getParameters', - methodParameters: [addr], - }, - ], - }); - }); - - const poolCallResults = (await multicall.call(poolCallContexts)).results; - - // Pool address -> Pool info - const correspondingPoolResults: Map = new Map(); - - Object.entries(poolCallResults).forEach(([key, value]) => { - const entryAccountAddress = key.split('-')[0]; - const entryType = key.split('-')[1]; - const existingValue = correspondingPoolResults.get(entryAccountAddress); - if (existingValue) { - existingValue[entryType] = value; - } else { - correspondingPoolResults.set(entryAccountAddress, { [entryType]: value }); - } - }); - // Get the time at which each pool was last updated via the oracle (using the update event and getLogs) let updateLogs: ethers.providers.Log[] = []; try { @@ -262,85 +57,34 @@ export default function InfoPage() { } catch (e) { console.error(e); } - const reversedLogs = updateLogs.filter((log) => log.removed === false).reverse(); - const latestTimestamps = await Promise.all( - poolAddresses.map(async (addr) => { - const latestUpdate = reversedLogs.find( - (log) => log.topics[1] === `0x000000000000000000000000${addr.slice(2)}` - ); - try { - if (latestUpdate) { - return (await provider.getBlock(latestUpdate.blockNumber)).timestamp; - } - } catch (e) { - console.error(e); - } + const logs = updateLogs.filter((log) => log.removed === false).reverse(); + const latestUpdateBlockNumbers = lendingPairs.map( + (pair) => + logs.find((log) => log.topics[1] === `0x000000000000000000000000${pair.uniswapPool.slice(2)}`)?.blockNumber + ); + // --> Map block numbers to times + const blockNumbersToTimestamps = new Map(); + await Promise.all( + Array.from(new Set(latestUpdateBlockNumbers)).map(async (blockNumber) => { + if (blockNumber === undefined) return; + blockNumbersToTimestamps.set(blockNumber, (await provider.getBlock(blockNumber)).timestamp); }) ); - - const poolInfoMap = new Map(); - poolAddresses.forEach((addr, i) => { - const lender0 = lenderAddresses[i][0] as Address; - const lender1 = lenderAddresses[i][1] as Address; - const lender0Info = lenderResults.get(lender0)!; - const lender1Info = lenderResults.get(lender1)!; - const poolResult = correspondingPoolResults.get(addr); - const uniswapResult = poolResult?.uniswap?.callsReturnContext?.[0].returnValues; - const oracleResult = convertBigNumbersForReturnContexts(poolResult?.oracle?.callsReturnContext ?? [])?.[0] - .returnValues; - const factoryResult = convertBigNumbersForReturnContexts(poolResult?.factory?.callsReturnContext ?? [])?.[0] - .returnValues; - - // Uniswap parameters - const feeTier = NumericFeeTierToEnum(uniswapResult?.[0] as number); - - // Factory parameters - const ante = GN.fromBigNumber(factoryResult[0], 18); - const nSigma = (factoryResult[1] as number) / 10; - const manipulationThresholdDivisor = factoryResult[2] as number; - const pausedUntilTime = factoryResult[3] as number; - - // Oracle results - const manipulationMetric = ethers.BigNumber.from(oracleResult[0]).toNumber(); - const iv = ethers.BigNumber.from(oracleResult[2]).div(1e6).toNumber() / 1e6; - - // Stuff we can compute from other stuff - const ltv = computeLTV(iv, nSigma); - const manipulationThreshold = -Math.log(ltv) / Math.log(1.0001) / manipulationThresholdDivisor; - - const lastUpdatedTimestamp = latestTimestamps[i]; - - poolInfoMap.set(addr, { - lenders: [lender0, lender1], - lenderSymbols: [lender0Info.symbol, lender1Info.symbol], - lenderDecimals: [lender0Info.decimals, lender1Info.decimals], - lenderRateModels: [lender0Info.rateModel, lender1Info.rateModel], - lenderReserveFactors: [lender0Info.reserveFactor, lender1Info.reserveFactor], - lenderTotalSupplies: [lender0Info.totalSupply, lender1Info.totalSupply], - nSigma, - iv: iv * Math.sqrt(365), - ltv, - ante, - pausedUntilTime, - manipulationMetric, - manipulationThreshold, - feeTier, - lastUpdatedTimestamp, - }); + // --> Put it all together to get the most recent `Update` timestamps + const newLatestTimestamps = latestUpdateBlockNumbers.map((blockNumber) => { + if (blockNumber === undefined) return -1; + return blockNumbersToTimestamps.get(blockNumber) || 0; }); - setPoolInfo(poolInfoMap); + setLatestTimestamps(newLatestTimestamps); })(); - }, [provider, setPoolInfo, blockNumber /* just here to trigger refetch */]); + }, [provider, setLatestTimestamps, lendingPairs, blockNumber /* just here to trigger refetch */]); return ( ({ - ...info, - poolAddress: addr, - reserveFactors: info.lenderReserveFactors, - rateModels: info.lenderRateModels, - lenderAddresses: info.lenders, + rows={lendingPairs.map((lendingPair, i) => ({ + lendingPair, + lastUpdatedTimestamp: latestTimestamps.at(i), setPendingTxn, }))} /> diff --git a/earn/src/pages/LendPage.tsx b/earn/src/pages/LendPage.tsx index bad3e0228..b443f0829 100644 --- a/earn/src/pages/LendPage.tsx +++ b/earn/src/pages/LendPage.tsx @@ -202,7 +202,7 @@ export default function LendPage() { token: pair.kitty0, balance: lendingPairBalances?.[i]?.kitty0Balance || 0, balanceUSD: (lendingPairBalances?.[i]?.kitty0Balance || 0) * token0Price, - apy: pair.kitty0Info.lendAPY, + apy: pair.kitty0Info.lendAPY * 100, isKitty: true, pairName, }, @@ -210,7 +210,7 @@ export default function LendPage() { token: pair.kitty1, balance: lendingPairBalances?.[i]?.kitty1Balance || 0, balanceUSD: (lendingPairBalances?.[i]?.kitty1Balance || 0) * token1Price, - apy: pair.kitty1Info.lendAPY, + apy: pair.kitty1Info.lendAPY * 100, isKitty: true, pairName, }, diff --git a/earn/src/pages/MarketsPage.tsx b/earn/src/pages/MarketsPage.tsx index 7edd1767f..3e9987459 100644 --- a/earn/src/pages/MarketsPage.tsx +++ b/earn/src/pages/MarketsPage.tsx @@ -217,7 +217,7 @@ export default function MarketsPage() { rows.push({ asset: pair.token0, kitty: pair.kitty0, - apy: pair.kitty0Info.lendAPY, + apy: pair.kitty0Info.lendAPY * 100, rewardsRate: pair.rewardsRate0, collateralAssets: [pair.token1], totalSupply: pair.kitty0Info.inventory, @@ -231,7 +231,7 @@ export default function MarketsPage() { rows.push({ asset: pair.token1, kitty: pair.kitty1, - apy: pair.kitty1Info.lendAPY, + apy: pair.kitty1Info.lendAPY * 100, rewardsRate: pair.rewardsRate1, collateralAssets: [pair.token0], totalSupply: pair.kitty1Info.inventory, diff --git a/earn/src/pages/PortfolioPage.tsx b/earn/src/pages/PortfolioPage.tsx index 88e6fbead..cbc231095 100644 --- a/earn/src/pages/PortfolioPage.tsx +++ b/earn/src/pages/PortfolioPage.tsx @@ -268,7 +268,7 @@ export default function PortfolioPage() { token: pair.kitty0, balance: lendingPairBalances?.[i]?.kitty0Balance || 0, balanceUSD: (lendingPairBalances?.[i]?.kitty0Balance || 0) * token0Price, - apy: pair.kitty0Info.lendAPY, + apy: pair.kitty0Info.lendAPY * 100, isKitty: true, pairName, otherToken: pair.token1, @@ -277,7 +277,7 @@ export default function PortfolioPage() { token: pair.kitty1, balance: lendingPairBalances?.[i]?.kitty1Balance || 0, balanceUSD: (lendingPairBalances?.[i]?.kitty1Balance || 0) * token1Price, - apy: pair.kitty1Info.lendAPY, + apy: pair.kitty1Info.lendAPY * 100, isKitty: true, pairName, otherToken: pair.token0, From 09433c657f6d95593aabddff5003a591d01b3322 Mon Sep 17 00:00:00 2001 From: Hayden Shively <17186559+haydenshively@users.noreply.github.com> Date: Mon, 29 Jan 2024 01:40:16 -0600 Subject: [PATCH 04/10] Move Stats to Monitor tab on MarketsPage --- earn/src/App.tsx | 2 - earn/src/components/header/Header.tsx | 8 +-- earn/src/components/info/InfoTab.tsx | 74 +++++++++++++++++++++++++++ earn/src/pages/MarketsPage.tsx | 59 +++++++++++++++------ 4 files changed, 122 insertions(+), 21 deletions(-) create mode 100644 earn/src/components/info/InfoTab.tsx diff --git a/earn/src/App.tsx b/earn/src/App.tsx index ddc7e09f1..8c083df2f 100644 --- a/earn/src/App.tsx +++ b/earn/src/App.tsx @@ -30,7 +30,6 @@ import ManageBoostPage from './pages/boost/ManageBoostPage'; import BoostPage from './pages/BoostPage'; import BorrowPage from './pages/BorrowPage'; import ClaimPage from './pages/ClaimPage'; -import InfoPage from './pages/InfoPage'; import LeaderboardPage from './pages/LeaderboardPage'; import LendPage from './pages/LendPage'; import MarketsPage from './pages/MarketsPage'; @@ -137,7 +136,6 @@ function AppBodyWrapper() { } /> } /> } /> - } /> } /> {isAllowed && ( <> diff --git a/earn/src/components/header/Header.tsx b/earn/src/components/header/Header.tsx index 6f9a0cfcd..84de154ce 100644 --- a/earn/src/components/header/Header.tsx +++ b/earn/src/components/header/Header.tsx @@ -48,10 +48,10 @@ export default function Header(props: HeaderProps) { const isAllowed = useGeoFencing(activeChain); const navLinks: NavBarLink[] = [ ...(isAllowed ? EXTENDED_NAV_LINKS : DEFAULT_NAV_LINKS), - { - label: 'Stats', - to: '/stats', - }, + // { + // label: 'Stats', + // to: '/stats', + // }, ]; return ( diff --git a/earn/src/components/info/InfoTab.tsx b/earn/src/components/info/InfoTab.tsx new file mode 100644 index 000000000..ea9c9ff17 --- /dev/null +++ b/earn/src/components/info/InfoTab.tsx @@ -0,0 +1,74 @@ +import { useEffect } from 'react'; + +import { SendTransactionResult, Provider } from '@wagmi/core'; +import { ethers } from 'ethers'; +import { ALOE_II_ORACLE_ADDRESS } from 'shared/lib/data/constants/ChainSpecific'; +import { useChainDependentState } from 'shared/lib/data/hooks/UseChainDependentState'; + +import { TOPIC0_UPDATE_ORACLE } from '../../data/constants/Signatures'; +import { LendingPair } from '../../data/LendingPair'; +import StatsTable from './StatsTable'; + +export type InfoTabProps = { + // Alternatively, could get these 2 from `ChainContext` and `useProvider`, respectively + chainId: number; + provider: Provider; + // Remaining 3 should be passed in for sure though + blockNumber: number | undefined; + lendingPairs: LendingPair[]; + setPendingTxn: (data: SendTransactionResult) => void; +}; + +export default function InfoTab(props: InfoTabProps) { + const { chainId, provider, blockNumber, lendingPairs, setPendingTxn } = props; + + const [latestTimestamps, setLatestTimestamps] = useChainDependentState<(number | undefined)[]>([], chainId); + + useEffect(() => { + (async () => { + const chainId = (await provider.getNetwork()).chainId; + + // Get the time at which each pool was last updated via the oracle (using the update event and getLogs) + let updateLogs: ethers.providers.Log[] = []; + try { + updateLogs = await provider.getLogs({ + address: ALOE_II_ORACLE_ADDRESS[chainId], + topics: [TOPIC0_UPDATE_ORACLE], + fromBlock: 0, + toBlock: 'latest', + }); + } catch (e) { + console.error(e); + } + const logs = updateLogs.filter((log) => log.removed === false).reverse(); + const latestUpdateBlockNumbers = lendingPairs.map( + (pair) => + logs.find((log) => log.topics[1] === `0x000000000000000000000000${pair.uniswapPool.slice(2)}`)?.blockNumber + ); + // --> Map block numbers to times + const blockNumbersToTimestamps = new Map(); + await Promise.all( + Array.from(new Set(latestUpdateBlockNumbers)).map(async (blockNumber) => { + if (blockNumber === undefined) return; + blockNumbersToTimestamps.set(blockNumber, (await provider.getBlock(blockNumber)).timestamp); + }) + ); + // --> Put it all together to get the most recent `Update` timestamps + const newLatestTimestamps = latestUpdateBlockNumbers.map((blockNumber) => { + if (blockNumber === undefined) return -1; + return blockNumbersToTimestamps.get(blockNumber) || 0; + }); + setLatestTimestamps(newLatestTimestamps); + })(); + }, [provider, setLatestTimestamps, lendingPairs, blockNumber /* just here to trigger refetch */]); + + return ( + ({ + lendingPair, + lastUpdatedTimestamp: latestTimestamps.at(i), + setPendingTxn, + }))} + /> + ); +} diff --git a/earn/src/pages/MarketsPage.tsx b/earn/src/pages/MarketsPage.tsx index 3e9987459..b5d00d570 100644 --- a/earn/src/pages/MarketsPage.tsx +++ b/earn/src/pages/MarketsPage.tsx @@ -12,6 +12,7 @@ import { Address, useAccount, useBlockNumber, useProvider } from 'wagmi'; import { ChainContext } from '../App'; import PendingTxnModal, { PendingTxnModalStatus } from '../components/common/PendingTxnModal'; +import InfoTab from '../components/info/InfoTab'; import BorrowingWidget from '../components/lend/BorrowingWidget'; import SupplyTable, { SupplyTableRow } from '../components/lend/SupplyTable'; import { BorrowerNftBorrower, fetchListOfFuse2BorrowNfts } from '../data/BorrowerNft'; @@ -62,6 +63,7 @@ export type TokenBalance = { enum HeaderOptions { Supply, Borrow, + Monitor, } type TokenSymbol = string; @@ -246,6 +248,39 @@ export default function MarketsPage() { return rows; }, [balancesMap, lendingPairs, tokenQuotes]); + let tabContent: JSX.Element; + + switch (selectedHeaderOption) { + default: + case HeaderOptions.Supply: + tabContent = ; + break; + case HeaderOptions.Borrow: + tabContent = ( + + ); + break; + case HeaderOptions.Monitor: + tabContent = ( + + ); + break; + } + return (
@@ -270,24 +305,18 @@ export default function MarketsPage() { > Borrow + setSelectedHeaderOption(HeaderOptions.Monitor)} + role='tab' + aria-selected={selectedHeaderOption === HeaderOptions.Monitor} + > + Monitor +
- {selectedHeaderOption === HeaderOptions.Supply ? ( - - ) : ( -
- -
- )} + {tabContent} Date: Thu, 1 Feb 2024 00:03:37 -0600 Subject: [PATCH 05/10] Rename Oracle Manipulation to Oracle Guardian --- earn/src/components/info/StatsTable.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/earn/src/components/info/StatsTable.tsx b/earn/src/components/info/StatsTable.tsx index dd4313f39..a69bc9aaf 100644 --- a/earn/src/components/info/StatsTable.tsx +++ b/earn/src/components/info/StatsTable.tsx @@ -327,7 +327,7 @@ export default function StatsTable(props: { rows: StatsTableRowProps[] }) { requestSort('sortA')}> - Oracle Manipulation + Oracle Guardian Date: Sun, 4 Feb 2024 20:03:44 -0600 Subject: [PATCH 06/10] Show LTV histories on the Markets>Monitor page --- earn/src/App.tsx | 25 +-- earn/src/components/graph/LineGraph.tsx | 96 +++++++++ earn/src/components/info/InfoGraph.tsx | 177 ++++++++++++++++ earn/src/components/info/InfoGraphTooltip.tsx | 55 +++++ earn/src/components/info/InfoTab.tsx | 198 ++++++++++++++---- earn/src/components/info/StatsTable.tsx | 54 ++--- earn/src/pages/InfoPage.tsx | 1 + earn/src/pages/MarketsPage.tsx | 20 +- earn/tailwind.config.js | 15 +- shared/src/abis/VolatilityOracle.ts | 175 +++++----------- shared/src/data/constants/ChainSpecific.tsx | 11 +- 11 files changed, 606 insertions(+), 221 deletions(-) create mode 100644 earn/src/components/graph/LineGraph.tsx create mode 100644 earn/src/components/info/InfoGraph.tsx create mode 100644 earn/src/components/info/InfoGraphTooltip.tsx diff --git a/earn/src/App.tsx b/earn/src/App.tsx index 8c083df2f..b43c7b742 100644 --- a/earn/src/App.tsx +++ b/earn/src/App.tsx @@ -1,6 +1,6 @@ import React, { Suspense, useEffect } from 'react'; -import { ApolloClient, InMemoryCache, HttpLink, gql } from '@apollo/react-hooks'; +import { ApolloClient, InMemoryCache, HttpLink } from '@apollo/react-hooks'; import { Route, Routes, Navigate, useNavigate } from 'react-router-dom'; import AccountBlockedModal from 'shared/lib/components/common/AccountBlockedModal'; import Footer from 'shared/lib/components/common/Footer'; @@ -167,7 +167,6 @@ function AppBodyWrapper() { function App() { const [activeChain, setActiveChain] = React.useState(DEFAULT_CHAIN); - const [blockNumber, setBlockNumber] = useSafeState(null); const [accountRisk, setAccountRisk] = useSafeState({ isBlocked: false, isLoading: true }); const [geoFencingResponse, setGeoFencingResponse] = React.useState(null); const [lendingPairs, setLendingPairs] = useChainDependentState(null, activeChain.id); @@ -176,18 +175,6 @@ function App() { const provider = useProvider({ chainId: activeChain.id }); const value = { activeChain, setActiveChain }; - const twentyFourHoursAgo = Date.now() / 1000 - 24 * 60 * 60; - const BLOCK_QUERY = gql` - { - blocks(first: 1, orderBy: timestamp, orderDirection: asc, where: {timestamp_gt: "${twentyFourHoursAgo.toFixed( - 0 - )}"}) { - id - number - timestamp - } - } - `; useEffectOnce(() => { let mounted = true; @@ -214,16 +201,6 @@ function App() { })(); }, [userAddress, setAccountRisk]); - useEffect(() => { - const queryBlocks = async () => { - const response = await theGraphEthereumBlocksClient.query({ query: BLOCK_QUERY }); - setBlockNumber(response.data.blocks[0].number); - }; - if (blockNumber === null) { - queryBlocks(); - } - }); - useEffect(() => { let mounted = true; diff --git a/earn/src/components/graph/LineGraph.tsx b/earn/src/components/graph/LineGraph.tsx new file mode 100644 index 000000000..3e0a9cb50 --- /dev/null +++ b/earn/src/components/graph/LineGraph.tsx @@ -0,0 +1,96 @@ +import React from 'react'; + +import { Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; +import { CurveType } from 'recharts/types/shape/Curve'; + +export type GraphChart = { + uniqueId: string; + type: CurveType; + dataKey: string; + stroke: string; + strokeWidth: number; + strokeDasharray?: string; + activeDot?: JSX.Element; +}; + +export type GraphProps = { + data: any; + charts: GraphChart[]; + linearGradients?: React.SVGProps[]; + CustomTooltip?: JSX.Element; + tooltipPosition?: { x: number | undefined; y: number | undefined }; + tooltipOffset?: number; + tooltipCursor?: React.SVGProps; + size?: { width: number | '100%'; height: number }; + aspectRatio?: number; +}; + +export default function LineGraph(props: GraphProps) { + const { data, charts, linearGradients, CustomTooltip, tooltipPosition, tooltipOffset, tooltipCursor } = props; + + const responsiveContainerProps = props.aspectRatio ? { aspect: props.aspectRatio } : props.size; + + return ( + + + + {linearGradients && + linearGradients.map((gradient, index) => {gradient})} + + + + + {charts.map((chart, index) => ( + + ))} + + + ); +} diff --git a/earn/src/components/info/InfoGraph.tsx b/earn/src/components/info/InfoGraph.tsx new file mode 100644 index 000000000..cf1d429f8 --- /dev/null +++ b/earn/src/components/info/InfoGraph.tsx @@ -0,0 +1,177 @@ +import { SVGProps, useEffect, useMemo, useState } from 'react'; + +import { Text } from 'shared/lib/components/common/Typography'; +import { RESPONSIVE_BREAKPOINTS, RESPONSIVE_BREAKPOINT_TABLET } from 'shared/lib/data/constants/Breakpoints'; +import { GREY_600 } from 'shared/lib/data/constants/Colors'; +import styled from 'styled-components'; + +import { LendingPair } from '../../data/LendingPair'; +import LineGraph, { GraphChart } from '../graph/LineGraph'; +import InfoGraphTooltip from './InfoGraphTooltip'; + +const MOBILE_HEIGHT = '320'; +const FULL_HEIGHT = '642'; +const FULL_WIDTH = '260'; + +const TableContainer = styled.div` + overflow-x: auto; + border: 2px solid ${GREY_600}; + border-radius: 6px; + + height: 100%; + min-width: ${FULL_WIDTH}px; + max-width: ${FULL_WIDTH}px; + width: ${FULL_WIDTH}px; + + @media (max-width: ${RESPONSIVE_BREAKPOINT_TABLET}) { + min-width: 100%; + max-width: 100%; + width: 100%; + } +`; + +const Table = styled.table` + border-spacing: 0; + border-collapse: separate; +`; + +const TableHeaderElement = styled.th` + border-bottom: 2px solid ${GREY_600}; +`; + +export type InfoGraphLabel = `${string}/${string}`; +export type InfoGraphData = Map; +export type InfoGraphColors = Map; + +function getWindowDimensions() { + const { innerWidth: width, innerHeight: height } = window; + return { width, height }; +} + +export default function InfoGraph(props: { + graphData: InfoGraphData | undefined; + graphColors: InfoGraphColors; + hoveredPair: LendingPair | undefined; +}) { + const { graphData, graphColors, hoveredPair } = props; + const labels = Array.from(graphData?.keys() ?? []); + + const [windowDimensions, setWindowDimensions] = useState(getWindowDimensions()); + const isTabletOrBigger = windowDimensions.width > RESPONSIVE_BREAKPOINTS['TABLET']; + + useEffect(() => { + const handleResize = () => setWindowDimensions(getWindowDimensions()); + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, []); + + const flattenedGraphData: { [k: InfoGraphLabel]: number; x: Date }[] = []; + graphData?.forEach((arr, label) => + arr.forEach((point) => { + const entry = { + x: point.x, + [label]: point.ltv * 100, + } as { [k: InfoGraphLabel]: number; x: Date }; + + flattenedGraphData.push(entry); + }) + ); + + flattenedGraphData.sort((a, b) => a.x.getTime() - b.x.getTime()); + + const displayedGraphData: { [k: InfoGraphLabel]: number; x: number }[] = []; + for (let i = 0; i < flattenedGraphData.length; i++) { + const entry = { ...flattenedGraphData[i], x: flattenedGraphData[i].x.getTime() }; + + if (entry.x !== displayedGraphData.at(-1)?.x) { + displayedGraphData.push({ + ...(displayedGraphData.at(-1) ?? {}), + ...entry, + }); + continue; + } + + const previousEntry = displayedGraphData.at(-1)!; + Object.assign(previousEntry, entry); + } + + const charts: GraphChart[] = useMemo( + () => + labels.map((label) => { + const shouldBeColored = + hoveredPair === undefined || label === `${hoveredPair!.token0.symbol}/${hoveredPair!.token1.symbol}`; + let stroke = 'rgba(43, 64, 80, 0.5)'; + if (shouldBeColored) { + stroke = graphColors.has(label) ? `url(#${label.replace('/', '-')})` : 'white'; + } + + const shouldBeThick = shouldBeColored && hoveredPair !== undefined; + + return { + uniqueId: label, + dataKey: label, + stroke, + strokeWidth: shouldBeThick ? 4 : 2, + type: 'monotone', + }; + }), + [labels, graphColors, hoveredPair] + ); + + const linearGradients = useMemo(() => { + const arr: SVGProps[] = []; + + graphColors.forEach((v, k) => { + arr.push( + + + + + ); + }); + + return arr; + }, [graphColors]); + + return ( + + + + + + + LTV History + + + + + + + + + +
+ {graphData && ( + } + tooltipPosition={{ x: 0, y: 0 }} + charts={charts} + data={displayedGraphData} + size={ + isTabletOrBigger + ? { + width: Number(FULL_WIDTH), + height: Number(FULL_HEIGHT) - 48, + } + : { + width: windowDimensions.width - 32, + height: Number(MOBILE_HEIGHT) - 48, + } + } + /> + )} +
+
+ ); +} diff --git a/earn/src/components/info/InfoGraphTooltip.tsx b/earn/src/components/info/InfoGraphTooltip.tsx new file mode 100644 index 000000000..cb96f68aa --- /dev/null +++ b/earn/src/components/info/InfoGraphTooltip.tsx @@ -0,0 +1,55 @@ +import { format } from 'date-fns'; +import { Text } from 'shared/lib/components/common/Typography'; +import styled from 'styled-components'; +import tw from 'twin.macro'; + +const TOOLTIP_BG_COLOR = 'rgba(0, 0, 0, 0.4)'; +const TOOLTIP_BORDER_COLOR = 'rgba(255, 255, 255, 0.1)'; +const TOOLTIP_TEXT_COLOR = 'rgba(130, 160, 182, 1)'; + +const TooltipContainer = styled.div` + ${tw`rounded-md shadow-md`} + background: ${TOOLTIP_BG_COLOR}; + border: 1px solid ${TOOLTIP_BORDER_COLOR}; +`; + +const TooltipTitleContainer = styled.div` + ${tw`flex flex-col justify-center align-middle pt-3 px-3 pb-1`} + border-bottom: 1px solid ${TOOLTIP_BORDER_COLOR}; +`; + +export default function InfoGraphTooltip(data: any, active = false) { + if (!active || data.label === undefined) return null; + + const datetime = new Date(data.label); + const formattedDate = datetime ? format(datetime, 'MMM dd, yyyy') : ''; + const formattedTime = datetime ? format(datetime, 'hh:mm a') : ''; + + const payload = data.payload.concat().sort((a: any, b: any) => b.value - a.value); + const tooltipValues = payload.map((item: any, index: number) => { + return ( +
+ + {item.name} + + + {item.value.toFixed(0)}% + +
+ ); + }); + + return ( + + + + {formattedDate} + + + {formattedTime} + + +
{tooltipValues}
+
+ ); +} diff --git a/earn/src/components/info/InfoTab.tsx b/earn/src/components/info/InfoTab.tsx index ea9c9ff17..eeea778f7 100644 --- a/earn/src/components/info/InfoTab.tsx +++ b/earn/src/components/info/InfoTab.tsx @@ -1,12 +1,17 @@ -import { useEffect } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { SendTransactionResult, Provider } from '@wagmi/core'; +import { secondsInDay } from 'date-fns'; import { ethers } from 'ethers'; -import { ALOE_II_ORACLE_ADDRESS } from 'shared/lib/data/constants/ChainSpecific'; +import { volatilityOracleAbi } from 'shared/lib/abis/VolatilityOracle'; +import { ALOE_II_ORACLE_ADDRESS, APPROX_SECONDS_PER_BLOCK } from 'shared/lib/data/constants/ChainSpecific'; +import { GN } from 'shared/lib/data/GoodNumber'; import { useChainDependentState } from 'shared/lib/data/hooks/UseChainDependentState'; +import { Address } from 'wagmi'; -import { TOPIC0_UPDATE_ORACLE } from '../../data/constants/Signatures'; +import { computeLTV } from '../../data/BalanceSheet'; import { LendingPair } from '../../data/LendingPair'; +import InfoGraph, { InfoGraphColors, InfoGraphData, InfoGraphLabel } from './InfoGraph'; import StatsTable from './StatsTable'; export type InfoTabProps = { @@ -16,59 +21,168 @@ export type InfoTabProps = { // Remaining 3 should be passed in for sure though blockNumber: number | undefined; lendingPairs: LendingPair[]; + tokenColors: Map; setPendingTxn: (data: SendTransactionResult) => void; }; +const MIN_NUM_DAYS_TO_FETCH = 30; +const MAX_NUM_UPDATE_LOGS_PER_POOL_PER_DAY = 6; +const MAX_NUM_LOGS_PER_ALCHEMY_REQUEST = 2000; + export default function InfoTab(props: InfoTabProps) { - const { chainId, provider, blockNumber, lendingPairs, setPendingTxn } = props; + const { chainId, provider, blockNumber, lendingPairs, tokenColors, setPendingTxn } = props; - const [latestTimestamps, setLatestTimestamps] = useChainDependentState<(number | undefined)[]>([], chainId); + const [oracleLogs, setOracleLogs] = useChainDependentState(new Map(), chainId); + const [blockNumbersToTimestamps, setBlockNumbersToTimestamps] = useChainDependentState( + new Map(), + chainId + ); + const [hoveredPair, setHoveredPair] = useState(undefined); + // Fetch `oracleLogs` useEffect(() => { (async () => { - const chainId = (await provider.getNetwork()).chainId; - - // Get the time at which each pool was last updated via the oracle (using the update event and getLogs) - let updateLogs: ethers.providers.Log[] = []; - try { - updateLogs = await provider.getLogs({ - address: ALOE_II_ORACLE_ADDRESS[chainId], - topics: [TOPIC0_UPDATE_ORACLE], - fromBlock: 0, - toBlock: 'latest', - }); - } catch (e) { - console.error(e); + if (lendingPairs.length === 0) return; + const [chainId, currentBlockNumber] = await Promise.all([ + provider.getNetwork().then((resp) => resp.chainId), + provider.getBlockNumber(), + ]); + // Calculate how many requests are necessary to fetch the desired number of days, given + // Alchemy's `eth_getLogs` constraints. + const worstCaseNumUpdateLogsPerDay = MAX_NUM_UPDATE_LOGS_PER_POOL_PER_DAY * lendingPairs.length; + const worstCaseNumDays = MAX_NUM_LOGS_PER_ALCHEMY_REQUEST / worstCaseNumUpdateLogsPerDay; + const safeNumBlocks = Math.round((worstCaseNumDays * secondsInDay) / APPROX_SECONDS_PER_BLOCK[chainId]); + const numRequests = Math.ceil(MIN_NUM_DAYS_TO_FETCH / worstCaseNumDays); + + const volatilityOracle = new ethers.Contract(ALOE_II_ORACLE_ADDRESS[chainId], volatilityOracleAbi, provider); + // Make requests + const requests: Promise[] = []; + for (let i = numRequests; i > 0; i -= 1) { + requests.push( + volatilityOracle.queryFilter( + volatilityOracle.filters.Update(), + currentBlockNumber - safeNumBlocks * i, + currentBlockNumber - safeNumBlocks * (i - 1) + ) + ); } - const logs = updateLogs.filter((log) => log.removed === false).reverse(); - const latestUpdateBlockNumbers = lendingPairs.map( - (pair) => - logs.find((log) => log.topics[1] === `0x000000000000000000000000${pair.uniswapPool.slice(2)}`)?.blockNumber - ); - // --> Map block numbers to times - const blockNumbersToTimestamps = new Map(); + + // Flatten into one big `logs` array and parse out into a pool-specific `map` + const logs = (await Promise.all(requests)).flat(); + const map = new Map(); + for (const log of logs) { + if (log.removed || log.args === undefined) continue; + + const pool = log.args['pool'].toLowerCase(); + if (map.has(pool)) { + map.get(pool)!.push(log); + } else { + map.set(pool, [log]); + } + } + setOracleLogs(map); + })(); + }, [provider, lendingPairs, setOracleLogs, blockNumber /* just here to trigger refetch */]); + + // Fetch `blockNumbersToTimestamps` + useEffect(() => { + (async () => { + const blockNumbers = new Set(); + let oldestBlockNumber = Infinity; + // Include block numbers for each pool's latest `Update`, while also searching for oldest one + oracleLogs.forEach((value) => { + if (value.length === 0) return; + blockNumbers.add(value.at(-1)!.blockNumber); + oldestBlockNumber = Math.min(oldestBlockNumber, value[0].blockNumber); + }); + // Include `oldestBlockNumber` + if (oldestBlockNumber !== Infinity) blockNumbers.add(oldestBlockNumber); + // Fetch times + const map = new Map(); await Promise.all( - Array.from(new Set(latestUpdateBlockNumbers)).map(async (blockNumber) => { - if (blockNumber === undefined) return; - blockNumbersToTimestamps.set(blockNumber, (await provider.getBlock(blockNumber)).timestamp); + Array.from(blockNumbers).map(async (blockNumber) => { + map.set(blockNumber, (await provider.getBlock(blockNumber)).timestamp); }) ); - // --> Put it all together to get the most recent `Update` timestamps - const newLatestTimestamps = latestUpdateBlockNumbers.map((blockNumber) => { - if (blockNumber === undefined) return -1; - return blockNumbersToTimestamps.get(blockNumber) || 0; - }); - setLatestTimestamps(newLatestTimestamps); + setBlockNumbersToTimestamps(map); })(); - }, [provider, setLatestTimestamps, lendingPairs, blockNumber /* just here to trigger refetch */]); + }, [provider, oracleLogs, setBlockNumbersToTimestamps]); + + // Compute `latestTimestamps` for table + const latestTimestamps = useMemo(() => { + return lendingPairs.map((pair) => { + // If we're still fetching logs, `latestTimestamp` is undefined + if (oracleLogs.size === 0) return undefined; + // Once logs are fetched, lack of an entry means the Oracle has never been updated for this pair + const logs = oracleLogs.get(pair.uniswapPool); + if (logs === undefined || logs.length === 0) return -1; + // Otherwise, return val depends on whether `blockNumbersToTimestamps` has loaded. + // If it has, we return a proper timestamp; if not, return undefined. + const blockNumber = logs[logs.length - 1].blockNumber; + return blockNumbersToTimestamps.get(blockNumber); + }); + }, [lendingPairs, oracleLogs, blockNumbersToTimestamps]); + + // Compute graph data + const graphData: InfoGraphData | undefined = useMemo(() => { + if (blockNumbersToTimestamps.size === 0) return undefined; + + // It's not feasible to fetch the exact `block.timestamp` for every log, + // so we do some math here to set up a `approxTime` function. It pretends that + // blocks are evenly spaced along our time axis, which is nearly true on these + // timescales. + let minBlockNumber = Infinity; + let maxBlockNumber = -Infinity; + blockNumbersToTimestamps.forEach((v, k) => { + if (k < minBlockNumber) minBlockNumber = k; + if (k > maxBlockNumber) maxBlockNumber = k; + }); + const minTime = blockNumbersToTimestamps.get(minBlockNumber)!; + const maxTime = blockNumbersToTimestamps.get(maxBlockNumber)!; + const slope = (maxTime - minTime) / (maxBlockNumber - minBlockNumber); + const approxTime = (blockNumber: number) => { + return slope * (blockNumber - minBlockNumber) + minTime; + }; + + // Populate a map from market labels (e.g. 'USDC/WETH') to data points + const map = new Map(); + oracleLogs.forEach((logs, uniswapPool) => { + const pair = lendingPairs.find((pair) => pair.uniswapPool === uniswapPool); + if (pair === undefined) return; + const points = logs.map((log) => ({ + x: new Date(1000 * approxTime(log.blockNumber)), + ltv: computeLTV(GN.fromBigNumber(log.args!['iv'], 12).toNumber(), pair.factoryData.nSigma), + })); + map.set(`${pair.token0.symbol}/${pair.token1.symbol}`, points); + }); + + return map; + }, [blockNumbersToTimestamps, oracleLogs, lendingPairs]); + + // Populate a map from market labels (e.g. 'USDC/WETH') to token colors + const graphColors: InfoGraphColors = useMemo(() => { + const map: InfoGraphColors = new Map(); + lendingPairs.forEach((pair) => { + if (!tokenColors.has(pair.token0.address) || !tokenColors.has(pair.token1.address)) return; + map.set(`${pair.token0.symbol}/${pair.token1.symbol}`, { + color0: `rgb(${tokenColors.get(pair.token0.address)!})`, + color1: `rgb(${tokenColors.get(pair.token1.address)!})`, + }); + }); + return map; + }, [lendingPairs, tokenColors]); return ( - ({ - lendingPair, - lastUpdatedTimestamp: latestTimestamps.at(i), - setPendingTxn, - }))} - /> +
+ ({ + lendingPair, + lastUpdatedTimestamp: latestTimestamps.at(i), + setPendingTxn, + onMouseEnter: setHoveredPair, + }))} + /> + +
); } diff --git a/earn/src/components/info/StatsTable.tsx b/earn/src/components/info/StatsTable.tsx index a69bc9aaf..f23215f2f 100644 --- a/earn/src/components/info/StatsTable.tsx +++ b/earn/src/components/info/StatsTable.tsx @@ -11,11 +11,13 @@ import { FilledGreyButton } from 'shared/lib/components/common/Buttons'; import Pagination from 'shared/lib/components/common/Pagination'; import TokenIcons from 'shared/lib/components/common/TokenIcons'; import { Text, Display } from 'shared/lib/components/common/Typography'; +import { RESPONSIVE_BREAKPOINTS } from 'shared/lib/data/constants/Breakpoints'; import { ALOE_II_FACTORY_ADDRESS, ALOE_II_ORACLE_ADDRESS } from 'shared/lib/data/constants/ChainSpecific'; import { GREY_600 } from 'shared/lib/data/constants/Colors'; import { Q32 } from 'shared/lib/data/constants/Values'; import { PrintFeeTier } from 'shared/lib/data/FeeTier'; import { GNFormat } from 'shared/lib/data/GoodNumber'; +import useMediaQuery from 'shared/lib/data/hooks/UseMediaQuery'; import useSortableData from 'shared/lib/data/hooks/UseSortableData'; import { getEtherscanUrlForChain } from 'shared/lib/util/Chains'; import { roundPercentage } from 'shared/lib/util/Numbers'; @@ -25,7 +27,6 @@ import { Address, useContractWrite } from 'wagmi'; import { ChainContext } from '../../App'; import { LendingPair } from '../../data/LendingPair'; -const PAGE_SIZE = 20; const SECONDARY_COLOR = 'rgba(130, 160, 182, 1)'; const GREEN_COLOR = 'rgba(0, 189, 63, 1)'; const YELLOW_COLOR = 'rgba(242, 201, 76, 1)'; @@ -38,21 +39,11 @@ const TableContainer = styled.div` border-radius: 6px; `; -const Table = styled.table` - width: 100%; -`; - const TableHeader = styled.thead` border-bottom: 2px solid ${GREY_600}; text-align: start; `; -const HoverableRow = styled.tr` - &:hover { - background-color: rgba(130, 160, 182, 0.1); - } -`; - const SortButton = styled.button` display: inline-flex; align-items: center; @@ -122,10 +113,11 @@ export type StatsTableRowProps = { lendingPair: LendingPair; lastUpdatedTimestamp?: number; setPendingTxn: (data: SendTransactionResult) => void; + onMouseEnter: (pair: LendingPair | undefined) => void; }; function StatsTableRow(props: StatsTableRowProps) { - const { lendingPair: pair, lastUpdatedTimestamp, setPendingTxn } = props; + const { lendingPair: pair, lastUpdatedTimestamp, setPendingTxn, onMouseEnter } = props; const { activeChain } = useContext(ChainContext); const { writeAsync: pause } = useContractWrite({ @@ -193,7 +185,11 @@ function StatsTableRow(props: StatsTableRowProps) { : `${reserveFactorTexts[0]}% / ${reserveFactorTexts[1]}%`; return ( - + onMouseEnter(pair)} + onMouseLeave={() => onMouseEnter(undefined)} + >
@@ -263,7 +259,7 @@ function StatsTableRow(props: StatsTableRowProps) { )}
-
+ ); } @@ -271,11 +267,15 @@ export default function StatsTable(props: { rows: StatsTableRowProps[] }) { const { rows } = props; const [currentPage, setCurrentPage] = useState(1); + const isTabletOrBigger = useMediaQuery(RESPONSIVE_BREAKPOINTS['TABLET']); + const pageSize = isTabletOrBigger ? 10 : 5; + // workaround to get sort data in the outermost scope const sortableRows = useMemo(() => { return rows.map((row) => ({ ...row, - sortA: row.lendingPair.oracleData.manipulationMetric, + // it's the ratio between these two that matters for oracle stability, so that's what we sort by + sortA: row.lendingPair.oracleData.manipulationMetric / row.lendingPair.manipulationThreshold, sortB: row.lendingPair.ltv, })); }, [rows]); @@ -287,18 +287,18 @@ export default function StatsTable(props: { rows: StatsTableRowProps[] }) { const pages: StatsTableRowProps[][] = useMemo(() => { const pages: StatsTableRowProps[][] = []; - for (let i = 0; i < sortedRows.length; i += PAGE_SIZE) { - pages.push(sortedRows.slice(i, i + PAGE_SIZE)); + for (let i = 0; i < sortedRows.length; i += pageSize) { + pages.push(sortedRows.slice(i, i + pageSize)); } return pages; - }, [sortedRows]); + }, [pageSize, sortedRows]); if (pages.length === 0) { return null; } return ( <> - +
- {pages[currentPage - 1].map((row) => ( - - ))} + {Array(pageSize) + .fill(0) + .map((_, i) => { + if (i < pages[currentPage - 1].length) { + const row = pages[currentPage - 1][i]; + return ; + } + return ; + })} -
@@ -349,16 +349,22 @@ export default function StatsTable(props: { rows: StatsTableRowProps[] }) {
setCurrentPage(page)} @@ -366,7 +372,7 @@ export default function StatsTable(props: { rows: StatsTableRowProps[] }) {
+
); diff --git a/earn/src/pages/InfoPage.tsx b/earn/src/pages/InfoPage.tsx index 793eb3b0b..df1149b6d 100644 --- a/earn/src/pages/InfoPage.tsx +++ b/earn/src/pages/InfoPage.tsx @@ -86,6 +86,7 @@ export default function InfoPage() { lendingPair, lastUpdatedTimestamp: latestTimestamps.at(i), setPendingTxn, + onMouseEnter: () => {}, }))} /> (HeaderOptions.Supply); // MARK: custom hooks - const availablePools = useAvailablePools(); const { lendingPairs } = useLendingPairs(); + // NOTE: Instead of `useAvailablePools()`, we're able to compute `availablePools` from `lendingPairs`. + // This saves a lot of data. + const availablePools = useMemo(() => { + const poolInfoMap = new Map(); + lendingPairs.forEach((pair) => + poolInfoMap.set(pair.uniswapPool.toLowerCase(), { + token0: pair.token0, + token1: pair.token1, + fee: GetNumericFeeTier(pair.uniswapFeeTier), + }) + ); + return poolInfoMap; + }, [lendingPairs]); + // MARK: wagmi hooks const { address: userAddress } = useAccount(); const provider = useProvider({ chainId: activeChain.id }); @@ -275,6 +288,7 @@ export default function MarketsPage() { provider={provider} blockNumber={blockNumber} lendingPairs={lendingPairs} + tokenColors={tokenColors} setPendingTxn={setPendingTxn} /> ); diff --git a/earn/tailwind.config.js b/earn/tailwind.config.js index dd1cd199d..70a90aa9f 100644 --- a/earn/tailwind.config.js +++ b/earn/tailwind.config.js @@ -6,8 +6,15 @@ module.exports = { theme: { extend: { transitionProperty: { - 'height': 'height' - } + 'height': 'height', + }, + }, + screens: { + 'sm': '480px', + 'md': '768px', + 'lg': '992px', + 'xl': '1200px', + '2xl': '1400px', }, colors: { transparent: 'transparent', @@ -48,6 +55,8 @@ module.exports = { 900: '#E4EDF6', 1000: '#FFFFFF', }, + 'background': '#070e12', + 'row-hover': '#82a0b6', // ... }, }, @@ -55,4 +64,4 @@ module.exports = { extend: {}, }, plugins: [], -} \ No newline at end of file +} diff --git a/shared/src/abis/VolatilityOracle.ts b/shared/src/abis/VolatilityOracle.ts index 41179a925..28a2f4e55 100644 --- a/shared/src/abis/VolatilityOracle.ts +++ b/shared/src/abis/VolatilityOracle.ts @@ -1,200 +1,137 @@ export const volatilityOracleAbi = [ { - anonymous: false, - inputs: [ - { - indexed: true, - internalType: 'contract IUniswapV3Pool', - name: 'pool', - type: 'address', - }, - { - indexed: false, - internalType: 'uint160', - name: 'sqrtMeanPriceX96', - type: 'uint160', - }, - { - indexed: false, - internalType: 'uint256', - name: 'iv', - type: 'uint256', - }, - ], - name: 'Update', - type: 'event', - }, - { + type: 'function', + name: 'cachedMetadata', inputs: [ { - internalType: 'contract IUniswapV3Pool', name: '', type: 'address', + internalType: 'contract IUniswapV3Pool', }, ], - name: 'cachedMetadata', outputs: [ - { - internalType: 'uint24', - name: 'gamma0', - type: 'uint24', - }, - { - internalType: 'uint24', - name: 'gamma1', - type: 'uint24', - }, - { - internalType: 'int24', - name: 'tickSpacing', - type: 'int24', - }, + { name: 'gamma0', type: 'uint24', internalType: 'uint24' }, + { name: 'gamma1', type: 'uint24', internalType: 'uint24' }, + { name: 'tickSpacing', type: 'int24', internalType: 'int24' }, ], stateMutability: 'view', - type: 'function', }, { + type: 'function', + name: 'consult', inputs: [ { - internalType: 'contract IUniswapV3Pool', name: 'pool', type: 'address', + internalType: 'contract IUniswapV3Pool', }, - { - internalType: 'uint40', - name: 'seed', - type: 'uint40', - }, + { name: 'seed', type: 'uint40', internalType: 'uint40' }, ], - name: 'consult', outputs: [ - { - internalType: 'uint56', - name: '', - type: 'uint56', - }, - { - internalType: 'uint160', - name: '', - type: 'uint160', - }, - { - internalType: 'uint256', - name: '', - type: 'uint256', - }, + { name: '', type: 'uint56', internalType: 'uint56' }, + { name: '', type: 'uint160', internalType: 'uint160' }, + { name: '', type: 'uint256', internalType: 'uint256' }, ], stateMutability: 'view', - type: 'function', }, { + type: 'function', + name: 'feeGrowthGlobals', inputs: [ { - internalType: 'contract IUniswapV3Pool', name: '', type: 'address', + internalType: 'contract IUniswapV3Pool', }, - { - internalType: 'uint256', - name: '', - type: 'uint256', - }, + { name: '', type: 'uint256', internalType: 'uint256' }, ], - name: 'feeGrowthGlobals', outputs: [ { - internalType: 'uint256', name: 'feeGrowthGlobal0X128', type: 'uint256', + internalType: 'uint256', }, { - internalType: 'uint256', name: 'feeGrowthGlobal1X128', type: 'uint256', + internalType: 'uint256', }, - { - internalType: 'uint32', - name: 'timestamp', - type: 'uint32', - }, + { name: 'timestamp', type: 'uint32', internalType: 'uint32' }, ], stateMutability: 'view', - type: 'function', }, { + type: 'function', + name: 'lastWrites', inputs: [ { - internalType: 'contract IUniswapV3Pool', name: '', type: 'address', + internalType: 'contract IUniswapV3Pool', }, ], - name: 'lastWrites', outputs: [ - { - internalType: 'uint8', - name: 'index', - type: 'uint8', - }, - { - internalType: 'uint32', - name: 'time', - type: 'uint32', - }, - { - internalType: 'uint216', - name: 'iv', - type: 'uint216', - }, + { name: 'index', type: 'uint8', internalType: 'uint8' }, + { name: 'time', type: 'uint40', internalType: 'uint40' }, + { name: 'oldIV', type: 'uint104', internalType: 'uint104' }, + { name: 'newIV', type: 'uint104', internalType: 'uint104' }, ], stateMutability: 'view', - type: 'function', }, { + type: 'function', + name: 'prepare', inputs: [ { - internalType: 'contract IUniswapV3Pool', name: 'pool', type: 'address', + internalType: 'contract IUniswapV3Pool', }, ], - name: 'prepare', outputs: [], stateMutability: 'nonpayable', - type: 'function', }, { + type: 'function', + name: 'update', inputs: [ { - internalType: 'contract IUniswapV3Pool', name: 'pool', type: 'address', + internalType: 'contract IUniswapV3Pool', }, - { - internalType: 'uint40', - name: 'seed', - type: 'uint40', - }, + { name: 'seed', type: 'uint40', internalType: 'uint40' }, ], - name: 'update', outputs: [ + { name: '', type: 'uint56', internalType: 'uint56' }, + { name: '', type: 'uint160', internalType: 'uint160' }, + { name: '', type: 'uint256', internalType: 'uint256' }, + ], + stateMutability: 'nonpayable', + }, + { + type: 'event', + name: 'Update', + inputs: [ { - internalType: 'uint56', - name: '', - type: 'uint56', + name: 'pool', + type: 'address', + indexed: true, + internalType: 'contract IUniswapV3Pool', }, { - internalType: 'uint160', - name: '', + name: 'sqrtMeanPriceX96', type: 'uint160', + indexed: false, + internalType: 'uint160', }, { - internalType: 'uint256', - name: '', - type: 'uint256', + name: 'iv', + type: 'uint104', + indexed: false, + internalType: 'uint104', }, ], - stateMutability: 'nonpayable', - type: 'function', + anonymous: false, }, ] as const; diff --git a/shared/src/data/constants/ChainSpecific.tsx b/shared/src/data/constants/ChainSpecific.tsx index d8b557615..3f4fe06c8 100644 --- a/shared/src/data/constants/ChainSpecific.tsx +++ b/shared/src/data/constants/ChainSpecific.tsx @@ -19,12 +19,11 @@ export const CHAIN_LOGOS: { [chainId: number]: JSX.Element } = { [base.id]: , }; -export const ANTES: { [chainId: number]: GN } = { - [mainnet.id]: GN.fromDecimalString('0.001', 18), - [goerli.id]: GN.fromDecimalString('0.001', 18), - [optimism.id]: GN.fromDecimalString('0.001', 18), - [arbitrum.id]: GN.fromDecimalString('0.001', 18), - [base.id]: GN.fromDecimalString('0.001', 18), +export const APPROX_SECONDS_PER_BLOCK: { [chainId: number]: number } = { + [mainnet.id]: 12.07, + [optimism.id]: 2, + [arbitrum.id]: 0.25, + [base.id]: 2, }; export const MULTICALL_ADDRESS: { [chainId: number]: Address } = { From 777bc995484a4b285bdd66a7ed89af60817a4f9a Mon Sep 17 00:00:00 2001 From: Hayden Shively <17186559+haydenshively@users.noreply.github.com> Date: Mon, 5 Feb 2024 23:43:27 -0600 Subject: [PATCH 07/10] Always stack vertically --- earn/src/components/info/InfoGraph.tsx | 157 +++++++++--------------- earn/src/components/info/InfoTab.tsx | 2 +- earn/src/components/info/StatsTable.tsx | 16 +-- earn/tailwind.config.js | 7 -- 4 files changed, 62 insertions(+), 120 deletions(-) diff --git a/earn/src/components/info/InfoGraph.tsx b/earn/src/components/info/InfoGraph.tsx index cf1d429f8..f8dcd9ce6 100644 --- a/earn/src/components/info/InfoGraph.tsx +++ b/earn/src/components/info/InfoGraph.tsx @@ -1,7 +1,6 @@ -import { SVGProps, useEffect, useMemo, useState } from 'react'; +import { SVGProps, useMemo } from 'react'; import { Text } from 'shared/lib/components/common/Typography'; -import { RESPONSIVE_BREAKPOINTS, RESPONSIVE_BREAKPOINT_TABLET } from 'shared/lib/data/constants/Breakpoints'; import { GREY_600 } from 'shared/lib/data/constants/Colors'; import styled from 'styled-components'; @@ -9,33 +8,17 @@ import { LendingPair } from '../../data/LendingPair'; import LineGraph, { GraphChart } from '../graph/LineGraph'; import InfoGraphTooltip from './InfoGraphTooltip'; -const MOBILE_HEIGHT = '320'; -const FULL_HEIGHT = '642'; -const FULL_WIDTH = '260'; +const FULL_HEIGHT = '342'; -const TableContainer = styled.div` - overflow-x: auto; +const Container = styled.div` border: 2px solid ${GREY_600}; border-radius: 6px; height: 100%; - min-width: ${FULL_WIDTH}px; - max-width: ${FULL_WIDTH}px; - width: ${FULL_WIDTH}px; - - @media (max-width: ${RESPONSIVE_BREAKPOINT_TABLET}) { - min-width: 100%; - max-width: 100%; - width: 100%; - } + width: 100%; `; -const Table = styled.table` - border-spacing: 0; - border-collapse: separate; -`; - -const TableHeaderElement = styled.th` +const Header = styled.div` border-bottom: 2px solid ${GREY_600}; `; @@ -43,11 +26,6 @@ export type InfoGraphLabel = `${string}/${string}`; export type InfoGraphData = Map; export type InfoGraphColors = Map; -function getWindowDimensions() { - const { innerWidth: width, innerHeight: height } = window; - return { width, height }; -} - export default function InfoGraph(props: { graphData: InfoGraphData | undefined; graphColors: InfoGraphColors; @@ -56,44 +34,39 @@ export default function InfoGraph(props: { const { graphData, graphColors, hoveredPair } = props; const labels = Array.from(graphData?.keys() ?? []); - const [windowDimensions, setWindowDimensions] = useState(getWindowDimensions()); - const isTabletOrBigger = windowDimensions.width > RESPONSIVE_BREAKPOINTS['TABLET']; - - useEffect(() => { - const handleResize = () => setWindowDimensions(getWindowDimensions()); - window.addEventListener('resize', handleResize); - return () => window.removeEventListener('resize', handleResize); - }, []); - - const flattenedGraphData: { [k: InfoGraphLabel]: number; x: Date }[] = []; - graphData?.forEach((arr, label) => - arr.forEach((point) => { - const entry = { - x: point.x, - [label]: point.ltv * 100, - } as { [k: InfoGraphLabel]: number; x: Date }; - - flattenedGraphData.push(entry); - }) - ); - - flattenedGraphData.sort((a, b) => a.x.getTime() - b.x.getTime()); - - const displayedGraphData: { [k: InfoGraphLabel]: number; x: number }[] = []; - for (let i = 0; i < flattenedGraphData.length; i++) { - const entry = { ...flattenedGraphData[i], x: flattenedGraphData[i].x.getTime() }; - - if (entry.x !== displayedGraphData.at(-1)?.x) { - displayedGraphData.push({ - ...(displayedGraphData.at(-1) ?? {}), - ...entry, - }); - continue; + const displayedGraphData = useMemo(() => { + const flattened: { [k: InfoGraphLabel]: number; x: Date }[] = []; + graphData?.forEach((arr, label) => + arr.forEach((point) => { + const entry = { + x: point.x, + [label]: point.ltv * 100, + } as { [k: InfoGraphLabel]: number; x: Date }; + + flattened.push(entry); + }) + ); + + flattened.sort((a, b) => a.x.getTime() - b.x.getTime()); + + const displayed: { [k: InfoGraphLabel]: number; x: number }[] = []; + for (let i = 0; i < flattened.length; i++) { + const entry = { ...flattened[i], x: flattened[i].x.getTime() }; + + if (entry.x !== displayed.at(-1)?.x) { + displayed.push({ + ...(displayed.at(-1) ?? {}), + ...entry, + }); + continue; + } + + const previousEntry = displayed.at(-1)!; + Object.assign(previousEntry, entry); } - const previousEntry = displayedGraphData.at(-1)!; - Object.assign(previousEntry, entry); - } + return displayed; + }, [graphData]); const charts: GraphChart[] = useMemo( () => @@ -133,45 +106,25 @@ export default function InfoGraph(props: { return arr; }, [graphColors]); + if (!graphData) return null; + return ( - - - - - - - LTV History - - - - - - - - - -
- {graphData && ( - } - tooltipPosition={{ x: 0, y: 0 }} - charts={charts} - data={displayedGraphData} - size={ - isTabletOrBigger - ? { - width: Number(FULL_WIDTH), - height: Number(FULL_HEIGHT) - 48, - } - : { - width: windowDimensions.width - 32, - height: Number(MOBILE_HEIGHT) - 48, - } - } - /> - )} -
-
+ +
+ + LTV History + +
+ } + charts={charts} + data={displayedGraphData} + size={{ + width: '100%', + height: Number(FULL_HEIGHT) - 48, + }} + /> +
); } diff --git a/earn/src/components/info/InfoTab.tsx b/earn/src/components/info/InfoTab.tsx index eeea778f7..53f89ac8c 100644 --- a/earn/src/components/info/InfoTab.tsx +++ b/earn/src/components/info/InfoTab.tsx @@ -173,7 +173,7 @@ export default function InfoTab(props: InfoTabProps) { }, [lendingPairs, tokenColors]); return ( -
+
({ lendingPair, diff --git a/earn/src/components/info/StatsTable.tsx b/earn/src/components/info/StatsTable.tsx index f23215f2f..a5b22fdde 100644 --- a/earn/src/components/info/StatsTable.tsx +++ b/earn/src/components/info/StatsTable.tsx @@ -11,13 +11,11 @@ import { FilledGreyButton } from 'shared/lib/components/common/Buttons'; import Pagination from 'shared/lib/components/common/Pagination'; import TokenIcons from 'shared/lib/components/common/TokenIcons'; import { Text, Display } from 'shared/lib/components/common/Typography'; -import { RESPONSIVE_BREAKPOINTS } from 'shared/lib/data/constants/Breakpoints'; import { ALOE_II_FACTORY_ADDRESS, ALOE_II_ORACLE_ADDRESS } from 'shared/lib/data/constants/ChainSpecific'; import { GREY_600 } from 'shared/lib/data/constants/Colors'; import { Q32 } from 'shared/lib/data/constants/Values'; import { PrintFeeTier } from 'shared/lib/data/FeeTier'; import { GNFormat } from 'shared/lib/data/GoodNumber'; -import useMediaQuery from 'shared/lib/data/hooks/UseMediaQuery'; import useSortableData from 'shared/lib/data/hooks/UseSortableData'; import { getEtherscanUrlForChain } from 'shared/lib/util/Chains'; import { roundPercentage } from 'shared/lib/util/Numbers'; @@ -27,6 +25,7 @@ import { Address, useContractWrite } from 'wagmi'; import { ChainContext } from '../../App'; import { LendingPair } from '../../data/LendingPair'; +const PAGE_SIZE = 5; const SECONDARY_COLOR = 'rgba(130, 160, 182, 1)'; const GREEN_COLOR = 'rgba(0, 189, 63, 1)'; const YELLOW_COLOR = 'rgba(242, 201, 76, 1)'; @@ -267,9 +266,6 @@ export default function StatsTable(props: { rows: StatsTableRowProps[] }) { const { rows } = props; const [currentPage, setCurrentPage] = useState(1); - const isTabletOrBigger = useMediaQuery(RESPONSIVE_BREAKPOINTS['TABLET']); - const pageSize = isTabletOrBigger ? 10 : 5; - // workaround to get sort data in the outermost scope const sortableRows = useMemo(() => { return rows.map((row) => ({ @@ -287,11 +283,11 @@ export default function StatsTable(props: { rows: StatsTableRowProps[] }) { const pages: StatsTableRowProps[][] = useMemo(() => { const pages: StatsTableRowProps[][] = []; - for (let i = 0; i < sortedRows.length; i += pageSize) { - pages.push(sortedRows.slice(i, i + pageSize)); + for (let i = 0; i < sortedRows.length; i += PAGE_SIZE) { + pages.push(sortedRows.slice(i, i + PAGE_SIZE)); } return pages; - }, [pageSize, sortedRows]); + }, [sortedRows]); if (pages.length === 0) { return null; } @@ -349,7 +345,7 @@ export default function StatsTable(props: { rows: StatsTableRowProps[] }) { - {Array(pageSize) + {Array(PAGE_SIZE) .fill(0) .map((_, i) => { if (i < pages[currentPage - 1].length) { @@ -364,7 +360,7 @@ export default function StatsTable(props: { rows: StatsTableRowProps[] }) { setCurrentPage(page)} diff --git a/earn/tailwind.config.js b/earn/tailwind.config.js index 70a90aa9f..9bbed4922 100644 --- a/earn/tailwind.config.js +++ b/earn/tailwind.config.js @@ -9,13 +9,6 @@ module.exports = { 'height': 'height', }, }, - screens: { - 'sm': '480px', - 'md': '768px', - 'lg': '992px', - 'xl': '1200px', - '2xl': '1400px', - }, colors: { transparent: 'transparent', current: 'currentColor', From b012414d65f3ab1da8e0e4429da242e2af67aeca Mon Sep 17 00:00:00 2001 From: Hayden Shively <17186559+haydenshively@users.noreply.github.com> Date: Mon, 5 Feb 2024 23:49:33 -0600 Subject: [PATCH 08/10] Stop inserting extra rows --- earn/src/components/info/StatsTable.tsx | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/earn/src/components/info/StatsTable.tsx b/earn/src/components/info/StatsTable.tsx index a5b22fdde..089e39ec2 100644 --- a/earn/src/components/info/StatsTable.tsx +++ b/earn/src/components/info/StatsTable.tsx @@ -345,15 +345,9 @@ export default function StatsTable(props: { rows: StatsTableRowProps[] }) { - {Array(PAGE_SIZE) - .fill(0) - .map((_, i) => { - if (i < pages[currentPage - 1].length) { - const row = pages[currentPage - 1][i]; - return ; - } - return ; - })} + {pages[currentPage - 1].map((row) => ( + + ))} From aff51d8817c6b833efa00b2c185d22579a826118 Mon Sep 17 00:00:00 2001 From: Hayden Shively <17186559+haydenshively@users.noreply.github.com> Date: Tue, 6 Feb 2024 15:27:15 -0600 Subject: [PATCH 09/10] Fix graph gradient for lines that don't span full area --- earn/src/components/info/InfoGraph.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/earn/src/components/info/InfoGraph.tsx b/earn/src/components/info/InfoGraph.tsx index f8dcd9ce6..80b9595e8 100644 --- a/earn/src/components/info/InfoGraph.tsx +++ b/earn/src/components/info/InfoGraph.tsx @@ -96,7 +96,7 @@ export default function InfoGraph(props: { graphColors.forEach((v, k) => { arr.push( - + From 4fedebde7a1a75d2562896fe22f8fbd0cc00b969 Mon Sep 17 00:00:00 2001 From: Hayden Shively <17186559+haydenshively@users.noreply.github.com> Date: Wed, 7 Feb 2024 00:34:32 -0600 Subject: [PATCH 10/10] Show that time is approximate --- earn/src/components/info/InfoGraphTooltip.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/earn/src/components/info/InfoGraphTooltip.tsx b/earn/src/components/info/InfoGraphTooltip.tsx index cb96f68aa..1756b5bb6 100644 --- a/earn/src/components/info/InfoGraphTooltip.tsx +++ b/earn/src/components/info/InfoGraphTooltip.tsx @@ -46,7 +46,7 @@ export default function InfoGraphTooltip(data: any, active = false) { {formattedDate} - {formattedTime} + ~{formattedTime}
{tooltipValues}