diff --git a/earn/src/components/advanced/BorrowMetrics.tsx b/earn/src/components/advanced/BorrowMetrics.tsx index bfa26c89..67d1bdc0 100644 --- a/earn/src/components/advanced/BorrowMetrics.tsx +++ b/earn/src/components/advanced/BorrowMetrics.tsx @@ -1,14 +1,20 @@ -import { useMemo } from 'react'; +import { ReactNode, useContext, useEffect, useMemo, useState } from 'react'; +import { formatDistanceToNowStrict } from 'date-fns'; import Tooltip from 'shared/lib/components/common/Tooltip'; import { Display, Text } from 'shared/lib/components/common/Typography'; +import { MANAGER_NAME_MAP } from 'shared/lib/data/constants/ChainSpecific'; import { GREY_700 } from 'shared/lib/data/constants/Colors'; +import useSafeState from 'shared/lib/data/hooks/UseSafeState'; +import { getEtherscanUrlForChain } from 'shared/lib/util/Chains'; import { formatTokenAmount } from 'shared/lib/util/Numbers'; import styled from 'styled-components'; +import { Address } from 'wagmi'; -import { computeLiquidationThresholds, getAssets, sqrtRatioToPrice, sqrtRatioToTick } from '../../data/BalanceSheet'; +import { ChainContext } from '../../App'; +import { auctionCurve, sqrtRatioToTick } from '../../data/BalanceSheet'; +import { BorrowerNftBorrower } from '../../data/BorrowerNft'; import { RESPONSIVE_BREAKPOINT_MD, RESPONSIVE_BREAKPOINT_SM } from '../../data/constants/Breakpoints'; -import { MarginAccount } from '../../data/MarginAccount'; const BORROW_TITLE_TEXT_COLOR = 'rgba(130, 160, 182, 1)'; const MAX_HEALTH = 10; @@ -116,14 +122,14 @@ function MetricCard(props: { label: string; value: string }) { ); } -function HorizontalMetricCard(props: { label: string; value: string }) { - const { label, value } = props; +function HorizontalMetricCard(props: { label: string; value?: string; children?: ReactNode }) { + const { label, value, children } = props; return ( {label} - {value} + {children === undefined ? {value} : children} ); } @@ -157,7 +163,7 @@ function HealthMetricCard(props: { health: number }) { } export type BorrowMetricsProps = { - marginAccount?: MarginAccount; + marginAccount?: BorrowerNftBorrower; dailyInterest0: number; dailyInterest1: number; userHasNoMarginAccounts: boolean; @@ -166,62 +172,30 @@ export type BorrowMetricsProps = { export function BorrowMetrics(props: BorrowMetricsProps) { const { marginAccount, dailyInterest0, dailyInterest1, userHasNoMarginAccounts } = props; + const { activeChain } = useContext(ChainContext); + + const [, setCurrentTime] = useState(Date.now()); + const [mostRecentModifyTime, setMostRecentModifyTime] = useSafeState(null); + const [token0Collateral, token1Collateral] = useMemo( () => marginAccount?.assets.amountsAt(sqrtRatioToTick(marginAccount.sqrtPriceX96)) ?? [0, 0], [marginAccount] ); - const maxSafeCollateralFall = useMemo(() => { - if (!marginAccount) return null; - - const { lowerSqrtRatio, upperSqrtRatio, minSqrtRatio, maxSqrtRatio } = computeLiquidationThresholds( - marginAccount.assets, - marginAccount.liabilities, - marginAccount.assets.uniswapPositions, - marginAccount.sqrtPriceX96, - marginAccount.iv, - marginAccount.nSigma, - marginAccount.token0.decimals, - marginAccount.token1.decimals - ); - - if (lowerSqrtRatio.eq(minSqrtRatio) && upperSqrtRatio.eq(maxSqrtRatio)) return Number.POSITIVE_INFINITY; - - const [current, lower, upper] = [marginAccount.sqrtPriceX96, lowerSqrtRatio, upperSqrtRatio].map((sp) => - sqrtRatioToPrice(sp, marginAccount.token0.decimals, marginAccount.token1.decimals) - ); - - const assets = getAssets(marginAccount.assets, lowerSqrtRatio, upperSqrtRatio); - - // Compute the value of all assets (collateral) at 3 different prices (current, lower, and upper) - // Denominated in units of token1 - let assetValueCurrent = token0Collateral * current + token1Collateral; - let assetValueAtLower = assets.amount0AtA * lower + assets.amount1AtA; - let assetValueAtUpper = assets.amount0AtB * upper + assets.amount1AtB; - - // If there are no assets, further results would be spurious, so return null - if (assetValueCurrent < Number.EPSILON) return null; - - // Compute how much the collateral can drop in value while remaining solvent - const percentChange1A = Math.abs(assetValueCurrent - assetValueAtLower) / assetValueCurrent; - const percentChange1B = Math.abs(assetValueCurrent - assetValueAtUpper) / assetValueCurrent; - const percentChange1 = Math.min(percentChange1A, percentChange1B); - - // Now change to units of token0 - assetValueCurrent /= current; - assetValueAtLower /= lower; - assetValueAtUpper /= upper; - - // Again compute how much the collateral can drop in value while remaining solvent, - // but this time percentages are based on units of token0 - const percentChange0A = Math.abs(assetValueCurrent - assetValueAtLower) / assetValueCurrent; - const percentChange0B = Math.abs(assetValueCurrent - assetValueAtUpper) / assetValueCurrent; - const percentChange0 = Math.min(percentChange0A, percentChange0B); - - // Since we don't know whether the user is thinking in terms of "X per Y" or "Y per X", - // we return the minimum. Error on the side of being too conservative. - return Math.min(percentChange0, percentChange1); - }, [marginAccount, token0Collateral, token1Collateral]); + useEffect(() => { + (async () => { + setMostRecentModifyTime(null); + if (!marginAccount?.mostRecentModify) return; + const block = await marginAccount.mostRecentModify.getBlock(); + setMostRecentModifyTime(new Date(block.timestamp * 1000)); + })(); + }, [marginAccount, setMostRecentModifyTime]); + + useEffect(() => { + const interval = setInterval(() => setCurrentTime(Date.now()), 200); + if (!marginAccount?.warningTime) clearInterval(interval); + return () => clearInterval(interval); + }, [marginAccount?.warningTime]); if (!marginAccount) return ( @@ -236,14 +210,39 @@ export function BorrowMetrics(props: BorrowMetricsProps) { + ); - let liquidationDistanceText = '-'; - if (maxSafeCollateralFall !== null) { - if (maxSafeCollateralFall === Number.POSITIVE_INFINITY) liquidationDistanceText = '∞'; - else liquidationDistanceText = `${(maxSafeCollateralFall * 100).toPrecision(2)}% drop in collateral value`; + const etherscanUrl = getEtherscanUrlForChain(activeChain); + + const mostRecentManager = marginAccount.mostRecentModify + ? (marginAccount.mostRecentModify.args!['manager'] as Address) + : '0x'; + const mostRecentManagerName = Object.hasOwn(MANAGER_NAME_MAP, mostRecentManager) + ? MANAGER_NAME_MAP[mostRecentManager] + : undefined; + const mostRecentManagerUrl = `${etherscanUrl}/address/${mostRecentManager}`; + + const mostRecentModifyHash = marginAccount.mostRecentModify?.transactionHash; + const mostRecentModifyUrl = `${etherscanUrl}/tx/${mostRecentModifyHash}`; + const mostRecentModifyTimeStr = mostRecentModifyTime + ? formatDistanceToNowStrict(mostRecentModifyTime, { + addSuffix: true, + roundingMethod: 'round', + }) + : ''; + + let liquidationAuctionStr = 'Not started'; + if (marginAccount.warningTime > 0) { + const auctionStartTime = marginAccount.warningTime + 5 * 60; + const currentTime = Date.now() / 1000; + if (currentTime < auctionStartTime) { + liquidationAuctionStr = `Begins in ${(auctionStartTime - currentTime).toFixed(1)} seconds`; + } else { + liquidationAuctionStr = `${(auctionCurve(currentTime - auctionStartTime) * 100 - 100).toFixed(2)}% incentive`; + } } return ( @@ -268,7 +267,6 @@ export function BorrowMetrics(props: BorrowMetricsProps) { - + {mostRecentModifyHash && ( + + + + {mostRecentModifyTimeStr} + {' '} + using {mostRecentManagerName ? 'the ' : 'an '} + + {mostRecentManagerName ?? 'unknown manager'} + + + + )} + + {marginAccount.userDataHex} + + {marginAccount.warningTime > 0 && ( + + {liquidationAuctionStr} + + )} ); diff --git a/earn/src/components/advanced/modal/RemoveCollateralModal.tsx b/earn/src/components/advanced/modal/RemoveCollateralModal.tsx index 22c16097..6d171c05 100644 --- a/earn/src/components/advanced/modal/RemoveCollateralModal.tsx +++ b/earn/src/components/advanced/modal/RemoveCollateralModal.tsx @@ -177,7 +177,9 @@ export default function RemoveCollateralModal(props: RemoveCollateralModalProps) }, [isOpen, borrower.token0]); const tokenOptions = [borrower.token0, borrower.token1]; - const isToken0 = collateralToken.address === borrower.token0.address; + if (!tokenOptions.some((token) => token.equals(collateralToken))) return null; + + const isToken0 = collateralToken.equals(borrower.token0); const existingCollateral = isToken0 ? borrower.assets.amount0 : borrower.assets.amount1; const collateralAmount = GN.fromDecimalString(collateralAmountStr || '0', collateralToken.decimals); diff --git a/earn/src/components/advanced/modal/tab/AddCollateralTab.tsx b/earn/src/components/advanced/modal/tab/AddCollateralTab.tsx index c8f355af..91038724 100644 --- a/earn/src/components/advanced/modal/tab/AddCollateralTab.tsx +++ b/earn/src/components/advanced/modal/tab/AddCollateralTab.tsx @@ -164,6 +164,7 @@ export function AddCollateralTab(props: AddCollateralTabProps) { }, [marginAccount.token0]); const tokenOptions = [marginAccount.token0, marginAccount.token1]; + if (!tokenOptions.some((token) => token.equals(collateralToken))) return null; const isToken0 = collateralToken.address === marginAccount.token0.address; diff --git a/earn/src/data/BalanceSheet.ts b/earn/src/data/BalanceSheet.ts index 431c0927..fe59a165 100644 --- a/earn/src/data/BalanceSheet.ts +++ b/earn/src/data/BalanceSheet.ts @@ -357,3 +357,14 @@ export function computeLTV(iv: number, nSigma: number) { const ltv = 1 / ((1 + 1 / ALOE_II_MAX_LEVERAGE + 1 / ALOE_II_LIQUIDATION_INCENTIVE) * Math.exp(iv * nSigma)); return Math.max(0.1, Math.min(ltv, 0.9)); } + +const Q = 22.8811827075; +const R = 103567.889099532; +const S = 0.95; +const M = 20.405429; +const N = 7 * 24 * 60 * 60 - 5 * 60; + +export function auctionCurve(auctionTimeSeconds: number) { + if (auctionTimeSeconds >= N) return Infinity; + return S + R / (N - auctionTimeSeconds) - Q / (M + auctionTimeSeconds); +} diff --git a/earn/src/data/BorrowerNft.ts b/earn/src/data/BorrowerNft.ts index f3542348..300a8be9 100644 --- a/earn/src/data/BorrowerNft.ts +++ b/earn/src/data/BorrowerNft.ts @@ -20,11 +20,13 @@ export type BorrowerNft = { borrowerAddress: Address; tokenId: string; index: number; + mostRecentModify?: ethers.Event; }; export type BorrowerNftBorrower = MarginAccount & { tokenId: string; index: number; + mostRecentModify?: ethers.Event; }; type BorrowerNftFilterParams = { @@ -66,6 +68,8 @@ export async function fetchListOfBorrowerNfts( // Create a mapping from (borrowerAddress => managerSet), which we'll need for filtering const borrowerManagerSets: Map> = new Map(); + // Also store the most recent event (solely for displaying on the advanced paged) + const borrowerMostRecentManager: Map = new Map(); modifys.forEach((modify) => { const borrower = modify.args!['borrower'] as Address; const manager = modify.args!['manager'] as Address; @@ -75,6 +79,7 @@ export async function fetchListOfBorrowerNfts( } else { // If there's no managerSet yet, create one borrowerManagerSets.set(borrower, new Set
([manager])); + borrowerMostRecentManager.set(borrower, modify); } }); orderedTokenIdStrs.forEach((orderedTokenIdStr) => { @@ -152,6 +157,7 @@ export async function fetchListOfBorrowerNfts( borrowerAddress: borrower, tokenId, index, + mostRecentModify: borrowerMostRecentManager.get(borrower), }; }) ); diff --git a/earn/src/data/MarginAccount.ts b/earn/src/data/MarginAccount.ts index a170e8cb..2e662f35 100644 --- a/earn/src/data/MarginAccount.ts +++ b/earn/src/data/MarginAccount.ts @@ -1,6 +1,6 @@ import Big from 'big.js'; import { ContractCallContext, Multicall } from 'ethereum-multicall'; -import { ethers } from 'ethers'; +import { BigNumber, ethers } from 'ethers'; import JSBI from 'jsbi'; import { borrowerAbi } from 'shared/lib/abis/Borrower'; import { borrowerLensAbi } from 'shared/lib/abis/BorrowerLens'; @@ -73,6 +73,8 @@ export type MarginAccount = { lender1: Address; iv: number; nSigma: number; + userDataHex: `0x${string}`; + warningTime: number; }; /** @@ -164,6 +166,11 @@ export async function fetchBorrowerDatas( methodName: 'LENDER1', methodParameters: [], }, + { + reference: 'slot0', + methodName: 'slot0', + methodParameters: [], + }, { reference: 'getLiabilities', methodName: 'getLiabilities', @@ -283,7 +290,7 @@ export async function fetchBorrowerDatas( const feeTier = NumericFeeTierToEnum(fee); const token0 = getToken(chainId, token0Address)!; const token1 = getToken(chainId, token1Address)!; - const liabilitiesData = accountReturnContexts[2].returnValues; + const liabilitiesData = accountReturnContexts[3].returnValues; const token0Balance = token0ReturnContexts[0].returnValues[0]; const token1Balance = token1ReturnContexts[0].returnValues[0]; const healthData = lensReturnContexts[0].returnValues; @@ -315,8 +322,10 @@ export async function fetchBorrowerDatas( uniswapPositions ); - const lender0 = accountReturnContexts[0].returnValues[0]; - const lender1 = accountReturnContexts[1].returnValues[0]; + const slot0 = accountReturnContexts[2].returnValues[0] as BigNumber; + const userDataHex = slot0.shr(144).mask(64).toHexString() as `0x${string}`; + const warningTime = slot0.shr(208).mask(40).toNumber(); + const oracleReturnValues = convertBigNumbersForReturnContexts(oracleResults.callsReturnContext)[0].returnValues; const marginAccount: MarginAccount = { address: accountAddress, @@ -329,9 +338,11 @@ export async function fetchBorrowerDatas( health, token0, token1, - lender0, - lender1, + lender0: accountReturnContexts[0].returnValues[0], + lender1: accountReturnContexts[1].returnValues[0], nSigma, + userDataHex, + warningTime, }; marginAccounts.push(marginAccount); }); diff --git a/earn/src/data/Uniboost.ts b/earn/src/data/Uniboost.ts index 12f0f600..156d8746 100644 --- a/earn/src/data/Uniboost.ts +++ b/earn/src/data/Uniboost.ts @@ -328,6 +328,8 @@ export async function fetchBoostBorrower( lender1, iv, nSigma, + userDataHex: '0x', + warningTime: 0, }; return { diff --git a/earn/src/pages/AdvancedPage.tsx b/earn/src/pages/AdvancedPage.tsx index e9c9b8a6..1139d691 100644 --- a/earn/src/pages/AdvancedPage.tsx +++ b/earn/src/pages/AdvancedPage.tsx @@ -195,6 +195,7 @@ export default function AdvancedPage() { ...borrower, tokenId: borrowerNfts[i].tokenId, index: borrowerNfts[i].index, + mostRecentModify: borrowerNfts[i].mostRecentModify, })); setBorrowerNftBorrowers(fetchedBorrowerNftBorrowers); })(); diff --git a/earn/src/pages/boost/ImportBoostPage.tsx b/earn/src/pages/boost/ImportBoostPage.tsx index 1c104c9c..eae993b9 100644 --- a/earn/src/pages/boost/ImportBoostPage.tsx +++ b/earn/src/pages/boost/ImportBoostPage.tsx @@ -207,6 +207,8 @@ export default function ImportBoostPage() { lender1: '0x', iv, nSigma, + userDataHex: '0x', + warningTime: 0, }, { ...position, diff --git a/shared/src/data/constants/ChainSpecific.tsx b/shared/src/data/constants/ChainSpecific.tsx index 13a62dd5..a29315f2 100644 --- a/shared/src/data/constants/ChainSpecific.tsx +++ b/shared/src/data/constants/ChainSpecific.tsx @@ -42,6 +42,19 @@ export const APPROX_SECONDS_PER_BLOCK: { [chainId: number]: number } = { [base.id]: 2, }; +export const MANAGER_NAME_MAP: { [manager: Address]: string } = { + '0xBb5A35B80b15A8E5933fDC11646A20f6159Dd061': 'SimpleManager', + '0x2b7E3A41Eac757CC1e8e9E61a4Ad5C9D6421516e': 'BorrowerNFTMultiManager', + '0xA07FD687882FfE7380A044e7542bDAc6F8672Bf7': 'BorrowerNFTSimpleManager', + '0xe1Bf15D99330E684020622856916F854c9322CB6': 'BorrowerNFTWithdrawManager', + '0x3EE236D69F6950525ff317D7a872439F09902C65': 'UniswapNFTManager', + '0x7357E37a60839DE89A52861Cf50851E317FFBE71': 'UniswapNFTManager', + '0x3Bb9F64b0e6b15dD5792A008c06E5c4Dc9d23D8f': 'FrontendManager', + '0xB6B7521cd3bd116432FeD94c2262Dd02BA616Db4': 'BoostManager', + '0x8E287b280671700EBE66A908A56C648f930b73b4': 'BoostManager', + '0x6BDa468b1d473028938585a04eC3c62dcFF5309B': 'Permit2Manager', +}; + export const MULTICALL_ADDRESS: { [chainId: number]: Address } = { [mainnet.id]: '0xcA11bde05977b3631167028862bE2a173976CA11', [optimism.id]: '0xcA11bde05977b3631167028862bE2a173976CA11',