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',