diff --git a/earn/src/components/lend/BorrowingWidget.tsx b/earn/src/components/lend/BorrowingWidget.tsx
index a38fe083..2e9d9d68 100644
--- a/earn/src/components/lend/BorrowingWidget.tsx
+++ b/earn/src/components/lend/BorrowingWidget.tsx
@@ -10,6 +10,7 @@ import { ALOE_II_LIQUIDATION_INCENTIVE, ALOE_II_MAX_LEVERAGE } from '../../data/
import { LendingPair } from '../../data/LendingPair';
import { MarginAccount } from '../../data/MarginAccount';
import { rgba } from '../../util/Colors';
+import BorrowModal from './modal/BorrowModal';
const SECONDARY_COLOR = 'rgba(130, 160, 182, 1)';
const SECONDARY_COLOR_LIGHT = 'rgba(130, 160, 182, 0.1)';
@@ -167,216 +168,234 @@ export default function BorrowingWidget(props: BorrowingWidgetProps) {
}, [collateralEntries, selectedBorrows]);
return (
-
-
- Collateral
-
-
-
-
- Active
-
-
-
- {marginAccounts &&
- marginAccounts.map((account) => {
- const hasAssetsForToken0 = account.assets.token0Raw > 0;
- const hasAssetsForToken1 = account.assets.token1Raw > 0;
- const hasLiabilitiesForToken0 = account.liabilities.amount0 > 0;
- const hasLiabilitiesForToken1 = account.liabilities.amount1 > 0;
- const AvailableContainerToken0 =
- hasAssetsForToken0 && !hasLiabilitiesForToken1
- ? AvailableContainer
- : AvailableContainerConnectedLeft;
- const AvailableContainerToken1 =
- hasAssetsForToken1 && !hasLiabilitiesForToken0
- ? AvailableContainer
- : AvailableContainerConnectedRight;
- const fact = 1 + 1 / ALOE_II_MAX_LEVERAGE + 1 / ALOE_II_LIQUIDATION_INCENTIVE;
- let ltv = 1 / (Math.exp((account.nSigma * account.iv) / 10) * fact);
- ltv = Math.max(0.1, Math.min(ltv, 0.9)) * 100;
- const token0Color = tokenColors.get(account.token0.address);
- const token0Gradient = token0Color
- ? `linear-gradient(90deg, ${rgba(token0Color, 0.25)} 0%, ${GREY_700} 100%)`
- : undefined;
- const token1Color = tokenColors.get(account.token1.address);
- const token1Gradient = token1Color
- ? `linear-gradient(90deg, ${rgba(token1Color, 0.25)} 0%, ${GREY_700} 100%)`
- : undefined;
+ <>
+
+
+ Collateral
+
+
+
+
+ Active
+
+
+
+ {marginAccounts &&
+ marginAccounts.map((account) => {
+ const hasAssetsForToken0 = account.assets.token0Raw > 0;
+ const hasAssetsForToken1 = account.assets.token1Raw > 0;
+ const hasLiabilitiesForToken0 = account.liabilities.amount0 > 0;
+ const hasLiabilitiesForToken1 = account.liabilities.amount1 > 0;
+ const AvailableContainerToken0 =
+ hasAssetsForToken0 && !hasLiabilitiesForToken1
+ ? AvailableContainer
+ : AvailableContainerConnectedLeft;
+ const AvailableContainerToken1 =
+ hasAssetsForToken1 && !hasLiabilitiesForToken0
+ ? AvailableContainer
+ : AvailableContainerConnectedRight;
+ const fact = 1 + 1 / ALOE_II_MAX_LEVERAGE + 1 / ALOE_II_LIQUIDATION_INCENTIVE;
+ let ltv = 1 / (Math.exp((account.nSigma * account.iv) / 10) * fact);
+ ltv = Math.max(0.1, Math.min(ltv, 0.9)) * 100;
+ const token0Color = tokenColors.get(account.token0.address);
+ const token0Gradient = token0Color
+ ? `linear-gradient(90deg, ${rgba(token0Color, 0.25)} 0%, ${GREY_700} 100%)`
+ : undefined;
+ const token1Color = tokenColors.get(account.token1.address);
+ const token1Gradient = token1Color
+ ? `linear-gradient(90deg, ${rgba(token1Color, 0.25)} 0%, ${GREY_700} 100%)`
+ : undefined;
+ return (
+ // TODO: use borrowerNFT id as key
+
+ {hasAssetsForToken0 && (
+
+
+ {account.assets.token0Raw}
+ {account.token0.symbol}
+
+ {roundPercentage(ltv, 3)}% LTV
+
+ )}
+ {hasLiabilitiesForToken0 && !hasAssetsForToken1 && }
+ {hasAssetsForToken1 && (
+
+
+ {account.assets.token1Raw}
+ {account.token1.symbol}
+
+ {roundPercentage(ltv, 3)}% LTV
+
+ )}
+ {hasLiabilitiesForToken1 && !hasAssetsForToken0 && }
+
+ );
+ })}
+
+
+
+
+
+ Available
+
+ {
+ setSelectedCollateral(null);
+ }}
+ >
+ Clear
+
+
+
+ {filteredCollateralEntries.map((entry, index) => {
+ const minLtv = entry.matchingPairs.reduce(
+ (min, current) => Math.min(current.ltv * 100, min),
+ Infinity
+ );
+ const maxLtv = entry.matchingPairs.reduce(
+ (max, current) => Math.max(current.ltv * 100, max),
+ -Infinity
+ );
+ const roundedLtvs = [minLtv, maxLtv].map((ltv) => Math.round(ltv));
+ const areLtvsEqual = roundedLtvs[0] === roundedLtvs[1];
+ const ltvText = areLtvsEqual ? `${roundedLtvs[0]}% LTV` : `${roundedLtvs[0]}-${roundedLtvs[1]}% LTV`;
return (
-
- {hasAssetsForToken0 && (
-
-
- {account.assets.token0Raw}
- {account.token0.symbol}
-
- {roundPercentage(ltv, 3)}% LTV
-
- )}
- {hasLiabilitiesForToken0 && !hasAssetsForToken1 && }
- {hasAssetsForToken1 && (
-
-
- {account.assets.token1Raw}
- {account.token1.symbol}
-
- {roundPercentage(ltv, 3)}% LTV
-
- )}
- {hasLiabilitiesForToken1 && !hasAssetsForToken0 && }
-
+
{
+ setSelectedCollateral(entry);
+ }}
+ className={selectedCollateral === entry ? 'selected' : ''}
+ >
+
+ {formatTokenAmount(entry.balance)}
+ {entry.asset.symbol}
+
+ {ltvText}
+
);
})}
-
-
-
-
-
- Available
-
- {
- setSelectedCollateral(null);
- }}
- >
- Clear
-
-
-
- {filteredCollateralEntries.map((entry, index) => {
- const minLtv = entry.matchingPairs.reduce((min, current) => Math.min(current.ltv * 100, min), Infinity);
- const maxLtv = entry.matchingPairs.reduce(
- (max, current) => Math.max(current.ltv * 100, max),
- -Infinity
- );
- const roundedLtvs = [minLtv, maxLtv].map((ltv) => Math.round(ltv));
- const areLtvsEqual = roundedLtvs[0] === roundedLtvs[1];
- const ltvText = areLtvsEqual ? `${roundedLtvs[0]}% LTV` : `${roundedLtvs[0]}-${roundedLtvs[1]}% LTV`;
- return (
-
{
- setSelectedCollateral(entry);
- }}
- className={selectedCollateral === entry ? 'selected' : ''}
- >
-
- {formatTokenAmount(entry.balance)}
- {entry.asset.symbol}
-
- {ltvText}
-
- );
- })}
-
-
-
-
-
- Borrows
-
-
-
-
- Active
-
-
-
- {marginAccounts &&
- marginAccounts.map((account) => {
- const hasAssetsForToken0 = account.assets.token0Raw > 0;
- const hasAssetsForToken1 = account.assets.token1Raw > 0;
- const hasLiabilitiesForToken0 = account.liabilities.amount0 > 0;
- const hasLiabilitiesForToken1 = account.liabilities.amount1 > 0;
- const token0Color = tokenColors.get(account.token0.address);
- const token0Gradient = token0Color
- ? `linear-gradient(90deg, ${GREY_700} 0%, ${rgba(token0Color, 0.25)} 100%)`
- : undefined;
- const token1Color = tokenColors.get(account.token1.address);
- const token1Gradient = token1Color
- ? `linear-gradient(90deg, ${GREY_700} 0%, ${rgba(token1Color, 0.25)} 100%)`
- : undefined;
- return (
-
- {hasLiabilitiesForToken0 && (
-
-
- 3% APY
-
-
-
- {formatTokenAmount(account.liabilities.amount0)}
+
+
+
+
+
+ Borrows
+
+
+
+
+ Active
+
+
+
+ {marginAccounts &&
+ marginAccounts.map((account) => {
+ const hasAssetsForToken0 = account.assets.token0Raw > 0;
+ const hasAssetsForToken1 = account.assets.token1Raw > 0;
+ const hasLiabilitiesForToken0 = account.liabilities.amount0 > 0;
+ const hasLiabilitiesForToken1 = account.liabilities.amount1 > 0;
+ const token0Color = tokenColors.get(account.token0.address);
+ const token0Gradient = token0Color
+ ? `linear-gradient(90deg, ${GREY_700} 0%, ${rgba(token0Color, 0.25)} 100%)`
+ : undefined;
+ const token1Color = tokenColors.get(account.token1.address);
+ const token1Gradient = token1Color
+ ? `linear-gradient(90deg, ${GREY_700} 0%, ${rgba(token1Color, 0.25)} 100%)`
+ : undefined;
+ return (
+ // TODO: use borrowerNFT id as key
+
+ {hasLiabilitiesForToken0 && (
+
+
+ 3% APY
-
- {account.token0.symbol}
+
+
+ {formatTokenAmount(account.liabilities.amount0)}
+
+
+ {account.token0.symbol}
+
+
+
+ )}
+ {hasAssetsForToken0 && !hasLiabilitiesForToken1 && }
+ {hasLiabilitiesForToken1 && (
+
+
+ 3% APY
-
-
- )}
- {hasAssetsForToken0 && !hasLiabilitiesForToken1 && }
- {hasLiabilitiesForToken1 && (
-
-
- 3% APY
-
-
-
- {formatTokenAmount(account.liabilities.amount1)}
-
-
- {account.token1.symbol}
-
-
-
- )}
- {hasAssetsForToken1 && !hasLiabilitiesForToken0 && }
-
+
+
+ {formatTokenAmount(account.liabilities.amount1)}
+
+
+ {account.token1.symbol}
+
+
+
+ )}
+ {hasAssetsForToken1 && !hasLiabilitiesForToken0 &&
}
+
+ );
+ })}
+
+
+
+
+ {
+ setSelectedBorrows(null);
+ }}
+ >
+ Clear
+
+
+ Available
+
+
+
+ {Object.entries(filteredBorrowEntries).map(([key, entry]) => {
+ const minApy = entry.reduce((min, current) => (current.apy < min ? current.apy : min), Infinity);
+ const maxApy = entry.reduce((max, current) => (current.apy > max ? current.apy : max), -Infinity);
+ const roundedApys = [minApy, maxApy].map((apy) => Math.round(apy * 100) / 100);
+ const areApysEqual = roundedApys[0] === roundedApys[1];
+ const apyText = areApysEqual ? `${roundedApys[0]}% APY` : `${roundedApys[0]}-${roundedApys[1]}% APY`;
+ const isSelected =
+ selectedBorrows != null && selectedBorrows.some((borrow) => borrow.asset.symbol === key);
+ return (
+
{
+ setSelectedBorrows(entry);
+ }}
+ >
+ {apyText}
+ {key}
+
);
})}
-
-
-
-
- {
- setSelectedBorrows(null);
- }}
- >
- Clear
-
-
- Available
-
-
-
- {Object.entries(filteredBorrowEntries).map(([key, entry]) => {
- const minApy = entry.reduce((min, current) => (current.apy < min ? current.apy : min), Infinity);
- const maxApy = entry.reduce((max, current) => (current.apy > max ? current.apy : max), -Infinity);
- const roundedApys = [minApy, maxApy].map((apy) => Math.round(apy * 100) / 100);
- const areApysEqual = roundedApys[0] === roundedApys[1];
- const apyText = areApysEqual ? `${roundedApys[0]}% APY` : `${roundedApys[0]}-${roundedApys[1]}% APY`;
- const isSelected =
- selectedBorrows != null && selectedBorrows.some((borrow) => borrow.asset.symbol === key);
- return (
-
{
- setSelectedBorrows(entry);
- }}
- >
- {apyText}
- {key}
-
- );
- })}
-
-
-
-
-
+
+
+
+
+
+ {selectedBorrows != null && selectedCollateral != null && (
+ {
+ setSelectedBorrows(null);
+ setSelectedCollateral(null);
+ }}
+ />
+ )}
+ >
);
}
diff --git a/earn/src/components/lend/modal/BorrowModal.tsx b/earn/src/components/lend/modal/BorrowModal.tsx
new file mode 100644
index 00000000..b4cc7833
--- /dev/null
+++ b/earn/src/components/lend/modal/BorrowModal.tsx
@@ -0,0 +1,182 @@
+import { useContext, useMemo, useState } from 'react';
+
+import { BigNumber } from 'ethers';
+import { volatilityOracleAbi } from 'shared/lib/abis/VolatilityOracle';
+import { FilledGradientButton } from 'shared/lib/components/common/Buttons';
+import { SquareInputWithMax } from 'shared/lib/components/common/Input';
+import Modal from 'shared/lib/components/common/Modal';
+import TokenAmountInput from 'shared/lib/components/common/TokenAmountInput';
+import { Text } from 'shared/lib/components/common/Typography';
+import { ALOE_II_ORACLE_ADDRESS } from 'shared/lib/data/constants/ChainSpecific';
+import { Q32 } from 'shared/lib/data/constants/Values';
+import { GN, GNFormat } from 'shared/lib/data/GoodNumber';
+import { formatNumberInput } from 'shared/lib/util/Numbers';
+import { useContractRead } from 'wagmi';
+
+import { ChainContext } from '../../../App';
+import { ALOE_II_LIQUIDATION_INCENTIVE, ALOE_II_MAX_LEVERAGE } from '../../../data/constants/Values';
+import { BorrowEntry, CollateralEntry } from '../BorrowingWidget';
+
+const MAX_BORROW_PERCENTAGE = 0.8;
+
+enum ConfirmButtonState {
+ WAITING_FOR_USER,
+ READY,
+ LOADING,
+ INSUFFICIENT_ASSET,
+ DISABLED,
+}
+
+function getConfirmButton(state: ConfirmButtonState): { text: string; enabled: boolean } {
+ switch (state) {
+ case ConfirmButtonState.WAITING_FOR_USER:
+ return { text: 'Check Wallet', enabled: false };
+ case ConfirmButtonState.READY:
+ return { text: 'Confirm', enabled: true };
+ case ConfirmButtonState.LOADING:
+ return { text: 'Loading', enabled: false };
+ case ConfirmButtonState.INSUFFICIENT_ASSET:
+ return { text: 'Insufficient Asset', enabled: false };
+ case ConfirmButtonState.DISABLED:
+ default:
+ return { text: 'Confirm', enabled: false };
+ }
+}
+
+export type BorrowModalProps = {
+ isOpen: boolean;
+ selectedBorrows: BorrowEntry[];
+ selectedCollateral: CollateralEntry;
+ setIsOpen: (isOpen: boolean) => void;
+};
+
+export default function BorrowModal(props: BorrowModalProps) {
+ const { isOpen, selectedBorrows, selectedCollateral, setIsOpen } = props;
+ const [collateralAmountStr, setCollateralAmountStr] = useState('');
+ const [borrowAmountStr, setBorrowAmountStr] = useState('');
+ const { activeChain } = useContext(ChainContext);
+
+ const selectedBorrow = selectedBorrows.find(
+ (borrow) => borrow.collateral.address === selectedCollateral.asset.address
+ );
+
+ const selectedLendingPair = selectedCollateral.matchingPairs.find(
+ (pair) => selectedBorrow?.asset?.equals(pair.token0) || selectedBorrow?.asset?.equals(pair.token1)
+ );
+
+ const { data: consultData } = useContractRead({
+ abi: volatilityOracleAbi,
+ address: ALOE_II_ORACLE_ADDRESS[activeChain.id],
+ args: [selectedLendingPair?.uniswapPool || '0x', Q32],
+ functionName: 'consult',
+ enabled: selectedLendingPair !== undefined,
+ });
+
+ const userBalance = GN.fromNumber(selectedCollateral.balance, selectedCollateral.asset.decimals);
+ const collateralAmount = GN.fromDecimalString(collateralAmountStr || '0', selectedCollateral.asset.decimals);
+ const borrowAmount = GN.fromDecimalString(borrowAmountStr || '0', selectedBorrow?.asset.decimals ?? 0);
+
+ const maxBorrowAmount = useMemo(() => {
+ if (consultData === undefined || selectedBorrow === undefined) {
+ return null;
+ }
+ const sqrtPriceX96 = GN.fromBigNumber(consultData?.[1] ?? BigNumber.from('0'), 96, 2);
+ const nSigma = selectedLendingPair?.nSigma ?? 0;
+ const iv = consultData[2].div(1e6).toNumber() / 1e6;
+ let ltv = 1 / ((1 + 1 / ALOE_II_MAX_LEVERAGE + 1 / ALOE_II_LIQUIDATION_INCENTIVE) * Math.exp(nSigma * iv));
+ ltv = Math.max(0.1, Math.min(ltv, 0.9));
+
+ let inTermsOfBorrow = collateralAmount;
+ if (selectedLendingPair?.token0.address === selectedCollateral.asset.address) {
+ inTermsOfBorrow = inTermsOfBorrow
+ .mul(sqrtPriceX96)
+ .mul(sqrtPriceX96)
+ .setResolution(selectedBorrow.asset.decimals);
+ } else {
+ inTermsOfBorrow = inTermsOfBorrow
+ .div(sqrtPriceX96)
+ .div(sqrtPriceX96)
+ .setResolution(selectedBorrow.asset.decimals);
+ }
+ const maxBorrowSupplyConstraint = GN.fromNumber(selectedBorrow.supply, selectedBorrow.asset.decimals);
+ const maxBorrowHealthConstraint = inTermsOfBorrow.recklessMul(ltv);
+ return GN.min(maxBorrowSupplyConstraint, maxBorrowHealthConstraint).recklessMul(MAX_BORROW_PERCENTAGE);
+ }, [
+ consultData,
+ selectedBorrow,
+ selectedLendingPair?.nSigma,
+ selectedLendingPair?.token0.address,
+ collateralAmount,
+ selectedCollateral.asset.address,
+ ]);
+
+ let confirmButtonState: ConfirmButtonState;
+ if (collateralAmount.gt(userBalance)) {
+ confirmButtonState = ConfirmButtonState.INSUFFICIENT_ASSET;
+ } else if (collateralAmountStr === '') {
+ confirmButtonState = ConfirmButtonState.DISABLED;
+ } else {
+ confirmButtonState = ConfirmButtonState.READY;
+ }
+
+ const confirmButton = getConfirmButton(confirmButtonState);
+
+ if (!selectedBorrow) return null;
+
+ return (
+
+
+
+
+ Collateral
+
+ {
+ const output = formatNumberInput(value);
+ if (output != null) {
+ setCollateralAmountStr(output);
+ }
+ }}
+ />
+
+
+
+ Borrow
+
+
+
+ {selectedBorrow.asset.symbol}
+
+ ) => {
+ const output = formatNumberInput(event.target.value);
+ if (output != null) {
+ setBorrowAmountStr(output);
+ }
+ }}
+ value={borrowAmountStr}
+ onMaxClick={() => {
+ if (maxBorrowAmount) {
+ setBorrowAmountStr(maxBorrowAmount.toString(GNFormat.DECIMAL));
+ }
+ }}
+ maxDisabled={maxBorrowAmount === null || borrowAmount.eq(maxBorrowAmount)}
+ maxButtonText='80% Max'
+ placeholder='0.00'
+ fullWidth={true}
+ inputClassName={borrowAmountStr !== '' ? 'active' : ''}
+ />
+
+
+
+ {confirmButton.text}
+
+
+
+ );
+}
diff --git a/earn/src/data/LendingPair.ts b/earn/src/data/LendingPair.ts
index ba2daa0b..551d12aa 100644
--- a/earn/src/data/LendingPair.ts
+++ b/earn/src/data/LendingPair.ts
@@ -44,6 +44,7 @@ export class LendingPair {
public kitty1: Kitty,
public kitty0Info: KittyInfo,
public kitty1Info: KittyInfo,
+ public uniswapPool: Address,
public uniswapFeeTier: FeeTier,
public iv: number,
public nSigma: number,
@@ -175,7 +176,7 @@ export async function getAvailableLendingPairs(
const lendingPairs: LendingPair[] = [];
- correspondingLendingPairResults.forEach((value) => {
+ Array.from(correspondingLendingPairResults.entries()).forEach(([uniswapPool, value]) => {
const { basics: basicsResults, feeTier: feeTierResults, oracle: oracleResults, factory: factoryResults } = value;
const basicsReturnContexts = convertBigNumbersForReturnContexts(basicsResults.callsReturnContext);
const feeTierReturnContexts = convertBigNumbersForReturnContexts(feeTierResults.callsReturnContext);
@@ -256,6 +257,7 @@ export async function getAvailableLendingPairs(
totalSupply: totalSupply1,
utilization: utilization1 * 100.0, // Percentage
},
+ uniswapPool as Address,
NumericFeeTierToEnum(feeTier[0]),
iv * Math.sqrt(365),
nSigma,
diff --git a/shared/src/data/TokenData.ts b/shared/src/data/TokenData.ts
index 89d17524..a373c29d 100644
--- a/shared/src/data/TokenData.ts
+++ b/shared/src/data/TokenData.ts
@@ -124,10 +124,19 @@ const UNI_OPTIMISM = new Token(
UniLogo
);
-const USDC_OPTIMISM = new Token(
+const BRIDGED_USDC_OPTIMISM = new Token(
optimism.id,
'0x7f5c764cbc14f9669b88837ca1490cca17c31607',
6,
+ 'USDC.e',
+ 'USD Coin',
+ UsdcLogo
+);
+
+const USDC_OPTIMISM = new Token(
+ optimism.id,
+ '0x0b2c639c533813f4aa9d7837caf62653d097ff85',
+ 6,
'USDC',
'USD Coin',
UsdcLogo
@@ -291,6 +300,7 @@ const TOKEN_DATA: { [chainId: number]: { [address: Address]: Token } } = {
[WETH_GOERLI.address]: WETH_GOERLI,
},
[optimism.id]: {
+ [BRIDGED_USDC_OPTIMISM.address]: BRIDGED_USDC_OPTIMISM,
[DAI_OPTIMISM.address]: DAI_OPTIMISM,
[FRAX_OPTIMISM.address]: FRAX_OPTIMISM,
[LYRA_OPTIMISM.address]: LYRA_OPTIMISM,