diff --git a/earn/src/components/lend/BorrowingWidget.tsx b/earn/src/components/lend/BorrowingWidget.tsx index 80fb849f..5cf75755 100644 --- a/earn/src/components/lend/BorrowingWidget.tsx +++ b/earn/src/components/lend/BorrowingWidget.tsx @@ -1,20 +1,28 @@ -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; -import { SendTransactionResult } from '@wagmi/core'; +import { SendTransactionResult, Provider } from '@wagmi/core'; +import JSBI from 'jsbi'; import TokenIcon from 'shared/lib/components/common/TokenIcon'; +import TokenIcons from 'shared/lib/components/common/TokenIcons'; import { Display, Text } from 'shared/lib/components/common/Typography'; +import { UNISWAP_NONFUNGIBLE_POSITION_MANAGER_ADDRESS } from 'shared/lib/data/constants/ChainSpecific'; import { GREY_600, GREY_700 } from 'shared/lib/data/constants/Colors'; +import { GetNumericFeeTier } from 'shared/lib/data/FeeTier'; import { GN } from 'shared/lib/data/GoodNumber'; +import { useChainDependentState } from 'shared/lib/data/hooks/UseChainDependentState'; import { Token } from 'shared/lib/data/Token'; import { formatTokenAmount, roundPercentage } from 'shared/lib/util/Numbers'; import styled from 'styled-components'; +import { Address, Chain } from 'wagmi'; import { computeLTV } from '../../data/BalanceSheet'; import { BorrowerNftBorrower } from '../../data/BorrowerNft'; import { LendingPair, LendingPairBalancesMap } from '../../data/LendingPair'; +import { fetchUniswapNFTPositions, UniswapNFTPosition } from '../../data/Uniswap'; import { rgba } from '../../util/Colors'; import HealthGauge from '../common/HealthGauge'; import BorrowModal from './modal/BorrowModal'; +import BorrowModalUniswap from './modal/BorrowModalUniswap'; import UpdateBorrowerModal from './modal/UpdateBorrowerModal'; import UpdateCollateralModal from './modal/UpdateCollateralModal'; @@ -113,6 +121,10 @@ type SelectedBorrower = { }; export type BorrowingWidgetProps = { + // Alternatively, could get these 3 from `ChainContext`, `useProvider`, and `useAccount`, respectively + chain: Chain; + provider: Provider; + userAddress?: Address; borrowers: BorrowerNftBorrower[] | null; lendingPairs: LendingPair[]; uniqueTokens: Token[]; @@ -141,16 +153,42 @@ function filterBySelection(lendingPairs: LendingPair[], selection: Token | null) return Array.from(reverseTokenMap.entries()).map((entry) => ({ token: entry[0], matchingPairs: entry[1] })); } +function borrowEntriesForUniswapCollateral(lendingPairs: LendingPair[], selection: UniswapNFTPosition) { + const pair = lendingPairs.find((x) => x.token0.equals(selection.token0) && x.token1.equals(selection.token1)); + + if (pair === undefined) return []; + return [ + { token: pair.token0, matchingPairs: [pair] }, + { token: pair.token1, matchingPairs: [pair] }, + ]; +} + +type Collateral = Token | UniswapNFTPosition; + +function collateralIsUniswapPosition(collateral: Collateral | null): collateral is UniswapNFTPosition { + return collateral != null && Object.hasOwn(collateral, 'liquidity'); +} + export default function BorrowingWidget(props: BorrowingWidgetProps) { - const { borrowers, lendingPairs, tokenBalances, tokenColors, setPendingTxn } = props; + const { chain, provider, userAddress, borrowers, lendingPairs, tokenBalances, tokenColors, setPendingTxn } = props; // selection/hover state for Available Table - const [selectedCollateral, setSelectedCollateral] = useState(null); + const [selectedCollateral, setSelectedCollateral] = useState(null); const [selectedBorrows, setSelectedBorrows] = useState(null); const [hoveredPair, setHoveredPair] = useState(null); // selection/hover state for Active Table const [selectedBorrower, setSelectedBorrower] = useState(null); const [hoveredBorrower, setHoveredBorrower] = useState(null); + // uniswap positions + const [uniswapPositions, setUniswapPositions] = useChainDependentState([], chain.id); + + useEffect(() => { + (async () => { + if (!userAddress) return; + const mapOfPositions = await fetchUniswapNFTPositions(userAddress, provider); + setUniswapPositions(Array.from(mapOfPositions.values())); + })(); + }, [userAddress, provider, setUniswapPositions]); const filteredCollateralEntries = useMemo( () => filterBySelection(lendingPairs, selectedBorrows), @@ -158,10 +196,70 @@ export default function BorrowingWidget(props: BorrowingWidgetProps) { ); const filteredBorrowEntries = useMemo( - () => filterBySelection(lendingPairs, selectedCollateral), + () => + collateralIsUniswapPosition(selectedCollateral) + ? borrowEntriesForUniswapCollateral(lendingPairs, selectedCollateral) + : filterBySelection(lendingPairs, selectedCollateral), [lendingPairs, selectedCollateral] ); + const filteredUniswapPositions = useMemo( + () => + uniswapPositions.filter( + (pos) => + JSBI.GT(pos.liquidity, '0') && + lendingPairs.some( + (x) => + x.token0.equals(pos.token0) && + x.token1.equals(pos.token1) && + GetNumericFeeTier(x.uniswapFeeTier) === pos.fee + ) && + ((selectedBorrows?.equals(pos.token0) ?? true) || (selectedBorrows?.equals(pos.token1) ?? true)) + ), + [uniswapPositions, lendingPairs, selectedBorrows] + ); + + let borrowModal: JSX.Element | null = null; + + if (selectedBorrows != null && selectedCollateral != null) { + if (collateralIsUniswapPosition(selectedCollateral)) { + borrowModal = ( + x.token.equals(selectedBorrows))!.matchingPairs[0] + } + selectedCollateral={selectedCollateral} + selectedBorrow={selectedBorrows} + setIsOpen={() => { + setSelectedBorrows(null); + setSelectedCollateral(null); + }} + setPendingTxn={setPendingTxn} + /> + ); + } else { + borrowModal = ( + x.token.equals(selectedBorrows))!.matchingPairs[0] + } + selectedCollateral={selectedCollateral} + selectedBorrow={selectedBorrows} + userBalance={tokenBalances.get(selectedCollateral.address)?.gn ?? GN.zero(selectedCollateral.decimals)} + setIsOpen={() => { + setSelectedBorrows(null); + setSelectedCollateral(null); + }} + setPendingTxn={setPendingTxn} + /> + ); + } + } + return ( <> {(borrowers?.length || 0) > 0 && ( @@ -181,8 +279,12 @@ export default function BorrowingWidget(props: BorrowingWidgetProps) {
{borrowers && borrowers.map((account) => { - const hasNoCollateral = account.assets.token0Raw === 0 && account.assets.token1Raw === 0; + const hasNoCollateral = + account.assets.token0Raw === 0 && + account.assets.token1Raw === 0 && + (account.uniswapPositions ?? []).every((pos) => JSBI.EQ(pos.liquidity, '0')); if (hasNoCollateral) return null; + const uniswapPosition = account.uniswapPositions?.at(0); const collateral = account.assets.token0Raw > 0 ? account.token0 : account.token1; const collateralAmount = collateral.equals(account.token0) ? account.assets.token0Raw @@ -195,27 +297,33 @@ export default function BorrowingWidget(props: BorrowingWidgetProps) { $gradColorA={collateralColor && rgba(collateralColor, 0.25)} $gradColorB={GREY_700} key={account.tokenId} - onMouseEnter={() => { - setHoveredBorrower(account); - }} - onMouseLeave={() => { - setHoveredBorrower(null); - }} + onMouseEnter={() => setHoveredBorrower(account)} + onMouseLeave={() => setHoveredBorrower(null)} className={account === hoveredBorrower ? 'active' : ''} - onClick={() => { + onClick={() => setSelectedBorrower({ borrower: account, type: 'supply', - }); - }} + }) + } > -
- - - {formatTokenAmount(collateralAmount)}  {collateral.symbol} - -
- {roundPercentage(ltvPercentage, 3)}%  LTV + {uniswapPosition !== undefined ? ( +
+ + Uniswap Position + + {uniswapPosition.lower} ⇔ {uniswapPosition.upper} + +
+ ) : ( +
+ + + {formatTokenAmount(collateralAmount)}  {collateral.symbol} + +
+ )} + {roundPercentage(ltvPercentage, 2)}%  LLTV ); })} @@ -247,16 +355,21 @@ export default function BorrowingWidget(props: BorrowingWidgetProps) {
{borrowers && borrowers.map((account) => { - const hasNoCollateral = account.assets.token0Raw === 0 && account.assets.token1Raw === 0; + const hasNoCollateral = + account.assets.token0Raw === 0 && + account.assets.token1Raw === 0 && + (account.uniswapPositions?.length || 0) === 0; if (hasNoCollateral) return null; - const collateral = account.assets.token0Raw > 0 ? account.token0 : account.token1; - const isBorrowingToken0 = !collateral.equals(account.token0); + + const isBorrowingToken0 = account.liabilities.amount0 > 0; const liability = isBorrowingToken0 ? account.token0 : account.token1; const liabilityAmount = isBorrowingToken0 ? account.liabilities.amount0 : account.liabilities.amount1; const liabilityColor = tokenColors.get(liability.address); - const lendingPair = lendingPairs.find((pair) => pair.uniswapPool === account.uniswapPool); + const lendingPair = lendingPairs.find( + (pair) => pair.uniswapPool === account.uniswapPool.toLowerCase() + ); const apr = (lendingPair?.[isBorrowingToken0 ? 'kitty0Info' : 'kitty1Info'].borrowAPR || 0) * 100; const roundedApr = Math.round(apr * 100) / 100; @@ -318,6 +431,36 @@ export default function BorrowingWidget(props: BorrowingWidgetProps) {
+ {filteredUniswapPositions.map((uniswapPosition, idx) => { + const lendingPair = lendingPairs.find( + (pair) => pair.token0.equals(uniswapPosition.token0) && pair.token1.equals(uniswapPosition.token1) + ); + const openSeaLink = `https://opensea.io/assets/${chain.network}/${ + UNISWAP_NONFUNGIBLE_POSITION_MANAGER_ADDRESS[chain.id] + }/${uniswapPosition.tokenId}`; + return ( + setSelectedCollateral(uniswapPosition)} + onMouseEnter={() => { + if (selectedBorrows !== null && lendingPair) setHoveredPair(lendingPair); + }} + onMouseLeave={() => setHoveredPair(null)} + className={selectedCollateral === uniswapPosition ? 'selected' : ''} + > +
+ + Uniswap Position #{uniswapPosition.tokenId} +
+ + {((lendingPair?.ltv || 0) * 100).toFixed(0)}%  LLTV + +
+ ); + })} {filteredCollateralEntries.map((entry, index) => { const isSelected = selectedCollateral === entry.token; @@ -357,7 +500,7 @@ export default function BorrowingWidget(props: BorrowingWidgetProps) {
- {ltvText}  LTV + {ltvText}  LLTV ); @@ -432,28 +575,14 @@ export default function BorrowingWidget(props: BorrowingWidgetProps) {
- {selectedBorrows != null && selectedCollateral != null && ( - x.token.equals(selectedCollateral))!.matchingPairs[0] - } - selectedCollateral={selectedCollateral} - selectedBorrow={selectedBorrows} - userBalance={tokenBalances.get(selectedCollateral.address)?.gn ?? GN.zero(selectedCollateral.decimals)} - setIsOpen={() => { - setSelectedBorrows(null); - setSelectedCollateral(null); - }} - setPendingTxn={setPendingTxn} - /> - )} + {borrowModal} {selectedBorrower != null && selectedBorrower.type === 'borrow' && ( pair.uniswapPool === selectedBorrower.borrower.uniswapPool)} + lendingPair={lendingPairs.find( + (pair) => pair.uniswapPool === selectedBorrower.borrower.uniswapPool.toLowerCase() + )} setIsOpen={() => { setSelectedBorrower(null); }} @@ -464,6 +593,7 @@ export default function BorrowingWidget(props: BorrowingWidgetProps) { { setSelectedBorrower(null); }} diff --git a/earn/src/components/lend/modal/BorrowModal.tsx b/earn/src/components/lend/modal/BorrowModal.tsx index d72cf3a5..99a5667f 100644 --- a/earn/src/components/lend/modal/BorrowModal.tsx +++ b/earn/src/components/lend/modal/BorrowModal.tsx @@ -352,7 +352,7 @@ export default function BorrowModal(props: BorrowModalProps) { if (!selectedBorrow) return null; return ( - +
diff --git a/earn/src/components/lend/modal/BorrowModalUniswap.tsx b/earn/src/components/lend/modal/BorrowModalUniswap.tsx new file mode 100644 index 00000000..eb4d2b85 --- /dev/null +++ b/earn/src/components/lend/modal/BorrowModalUniswap.tsx @@ -0,0 +1,426 @@ +import { useContext, useMemo, useState } from 'react'; + +import { SendTransactionResult } from '@wagmi/core'; +import Big from 'big.js'; +import { BigNumber, ethers } from 'ethers'; +import { borrowerAbi } from 'shared/lib/abis/Borrower'; +import { borrowerNftAbi } from 'shared/lib/abis/BorrowerNft'; +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 TokenIcon from 'shared/lib/components/common/TokenIcon'; +import TokenIcons from 'shared/lib/components/common/TokenIcons'; +import { Display, Text } from 'shared/lib/components/common/Typography'; +import { + ALOE_II_BORROWER_NFT_ADDRESS, + ALOE_II_BORROWER_NFT_SIMPLE_MANAGER_ADDRESS, + ALOE_II_ORACLE_ADDRESS, + ALOE_II_UNISWAP_NFT_MANAGER_ADDRESS, + UNISWAP_NONFUNGIBLE_POSITION_MANAGER_ADDRESS, +} from 'shared/lib/data/constants/ChainSpecific'; +import { Q32, TERMS_OF_SERVICE_URL } from 'shared/lib/data/constants/Values'; +import { GN, GNFormat } from 'shared/lib/data/GoodNumber'; +import { Token } from 'shared/lib/data/Token'; +import { formatNumberInput, formatTokenAmount } from 'shared/lib/util/Numbers'; +import { generateBytes12Salt } from 'shared/lib/util/Salt'; +import { erc721ABI, useAccount, useBalance, useContractRead, useContractWrite, usePrepareContractWrite } from 'wagmi'; + +import { ChainContext } from '../../../App'; +import { maxBorrowAndWithdraw } from '../../../data/BalanceSheet'; +import { LendingPair } from '../../../data/LendingPair'; +import { RateModel, yieldPerSecondToAPR } from '../../../data/RateModel'; +import { UniswapNFTPosition, zip } from '../../../data/Uniswap'; + +const MAX_BORROW_PERCENTAGE = 0.8; +const SECONDARY_COLOR = '#CCDFED'; +const TERTIARY_COLOR = '#4b6980'; + +enum ConfirmButtonState { + READY, + APPROVE_NFT_MANAGER, + LOADING, + INSUFFICIENT_ASSET, + INSUFFICIENT_COLLATERAL, + WAITING_FOR_USER, + WAITING_FOR_TRANSACTION, + INSUFFICIENT_ANTE, + CONNECT_WALLET, + DISABLED, +} + +function getConfirmButton(state: ConfirmButtonState, token: Token): { text: string; enabled: boolean } { + switch (state) { + case ConfirmButtonState.READY: + return { text: 'Confirm', enabled: true }; + case ConfirmButtonState.LOADING: + return { text: 'Loading', enabled: false }; + case ConfirmButtonState.APPROVE_NFT_MANAGER: + return { text: `Approve NFT Transfer`, enabled: true }; + case ConfirmButtonState.WAITING_FOR_USER: + return { text: 'Check Wallet', enabled: false }; + case ConfirmButtonState.WAITING_FOR_TRANSACTION: + return { text: 'Pending', enabled: false }; + case ConfirmButtonState.INSUFFICIENT_ASSET: + return { text: 'Insufficient Asset', enabled: false }; + case ConfirmButtonState.INSUFFICIENT_COLLATERAL: + return { text: 'Insufficient Collateral', enabled: false }; + case ConfirmButtonState.INSUFFICIENT_ANTE: + return { text: 'Insufficient Ante', enabled: false }; + case ConfirmButtonState.CONNECT_WALLET: + return { text: 'Connect Wallet', enabled: false }; + case ConfirmButtonState.DISABLED: + default: + return { text: 'Confirm', enabled: false }; + } +} + +export type BorrowModalProps = { + isOpen: boolean; + selectedLendingPair: LendingPair; + selectedCollateral: UniswapNFTPosition; + selectedBorrow: Token; + setIsOpen: (isOpen: boolean) => void; + setPendingTxn: (pendingTxn: SendTransactionResult | null) => void; +}; + +export default function BorrowModalUniswap(props: BorrowModalProps) { + const { + isOpen, + selectedLendingPair, + selectedCollateral: uniswapPosition, + selectedBorrow, + setIsOpen, + setPendingTxn, + } = props; + const [borrowAmountStr, setBorrowAmountStr] = useState(''); + const [isApproving, setIsApproving] = useState(false); + const { activeChain } = useContext(ChainContext); + + const { address: userAddress } = useAccount(); + + const { data: consultData } = useContractRead({ + abi: volatilityOracleAbi, + address: ALOE_II_ORACLE_ADDRESS[activeChain.id], + args: [selectedLendingPair?.uniswapPool || '0x', Q32], + functionName: 'consult', + enabled: selectedLendingPair !== undefined, + }); + + const { data: ethBalanceData } = useBalance({ + address: userAddress, + chainId: activeChain.id, + enabled: Boolean(userAddress), + watch: false, + }); + + const { refetch: refetchGetApprovedData, data: getApprovedData } = useContractRead({ + address: UNISWAP_NONFUNGIBLE_POSITION_MANAGER_ADDRESS[activeChain.id], + abi: erc721ABI, + functionName: 'getApproved', + args: [BigNumber.from(uniswapPosition.tokenId)] as const, + chainId: activeChain.id, + }); + const { writeAsync: writeApproveAsync } = useContractWrite({ + address: UNISWAP_NONFUNGIBLE_POSITION_MANAGER_ADDRESS[activeChain.id], + abi: erc721ABI, + functionName: 'approve', + mode: 'recklesslyUnprepared', + chainId: activeChain.id, + }); + const isApproved = getApprovedData === ALOE_II_UNISWAP_NFT_MANAGER_ADDRESS[activeChain.id]; + + const selectedCollateral = uniswapPosition.token0.equals(selectedBorrow) + ? uniswapPosition.token1 + : uniswapPosition.token0; + + const borrowAmount = GN.fromDecimalString(borrowAmountStr || '0', selectedBorrow.decimals); + const ante = selectedLendingPair.factoryData.ante; + const ethBalance = GN.fromDecimalString(ethBalanceData?.formatted ?? '0', 18); + + const isBorrowingToken0 = useMemo( + () => selectedBorrow.equals(selectedLendingPair.token0), + [selectedLendingPair, selectedBorrow] + ); + + const maxBorrowSupplyConstraint = useMemo(() => { + if (selectedBorrow === undefined) { + return null; + } + const lenderInfo = selectedLendingPair[isBorrowingToken0 ? 'kitty0Info' : 'kitty1Info']; + return lenderInfo.inventory * (1 - lenderInfo.utilization); + }, [selectedBorrow, selectedLendingPair, isBorrowingToken0]); + + const maxBorrowHealthConstraint = useMemo(() => { + if (consultData === undefined || selectedBorrow === undefined) { + return null; + } + + const [max0, max1] = maxBorrowAndWithdraw( + { + token0Raw: 0, + token1Raw: 0, + uni0: 0, + uni1: 0, + }, + { amount0: 0, amount1: 0 }, + [uniswapPosition], + new Big(consultData[1].toString()), + selectedLendingPair.iv, + selectedLendingPair.factoryData.nSigma, + selectedLendingPair.token0.decimals, + selectedLendingPair.token1.decimals + ); + + return selectedBorrow.equals(selectedLendingPair.token0) ? max0 : max1; + }, [consultData, uniswapPosition, selectedBorrow, selectedLendingPair]); + + const maxBorrowAmount = + maxBorrowSupplyConstraint == null || maxBorrowHealthConstraint == null + ? null + : Math.min(maxBorrowSupplyConstraint, maxBorrowHealthConstraint); + + const eightyPercentMaxBorrowAmountStr = + maxBorrowAmount === null ? null : formatTokenAmount(maxBorrowAmount * MAX_BORROW_PERCENTAGE); + + const estimatedApr = useMemo(() => { + const { kitty0Info, kitty1Info } = selectedLendingPair; + + const numericLenderTotalAssets = isBorrowingToken0 ? kitty0Info.totalSupply : kitty1Info.totalSupply; + const lenderTotalAssets = GN.fromNumber(numericLenderTotalAssets, selectedBorrow.decimals); + + const lenderUtilization = isBorrowingToken0 ? kitty0Info.utilization : kitty1Info.utilization; + const lenderUsedAssets = GN.fromNumber(numericLenderTotalAssets * lenderUtilization, selectedBorrow.decimals); + + const remainingAvailableAssets = lenderTotalAssets.sub(lenderUsedAssets).sub(borrowAmount); + const newUtilization = lenderTotalAssets.isGtZero() + ? 1 - remainingAvailableAssets.div(lenderTotalAssets).toNumber() + : 0; + + return yieldPerSecondToAPR(RateModel.computeYieldPerSecond(newUtilization)) * 100; + }, [selectedLendingPair, selectedBorrow, isBorrowingToken0, borrowAmount]); + + // The NFT index we will use if minting + const { data: nextNftPtrIdx } = useContractRead({ + address: ALOE_II_BORROWER_NFT_ADDRESS[activeChain.id], + abi: borrowerNftAbi, + functionName: 'balanceOf', + args: [userAddress ?? '0x'], + chainId: activeChain.id, + enabled: Boolean(userAddress), + }); + + const generatedSalt = useMemo(() => generateBytes12Salt(), []); + + // Prepare for actual import/mint transaction + const borrowerNft = useMemo(() => new ethers.utils.Interface(borrowerNftAbi), []); + // First, we `mint` so that they have a `Borrower` to put stuff in + const encodedMint = useMemo(() => { + if (!userAddress) return null; + const to = userAddress; + const pools = [selectedLendingPair.uniswapPool]; + const salts = [generatedSalt]; + return borrowerNft.encodeFunctionData('mint', [to, pools, salts]) as `0x${string}`; + }, [userAddress, selectedLendingPair, generatedSalt, borrowerNft]); + + // Then we use the UniswapNFTManager to import the Uniswap NFT as collateral + const encodedImportCall = useMemo(() => { + return ethers.utils.defaultAbiCoder.encode( + ['uint256', 'int24', 'int24', 'int128', 'uint208'], + [ + uniswapPosition.tokenId, + uniswapPosition.lower, + uniswapPosition.upper, + `-${uniswapPosition.liquidity.toString(10)}`, + zip([uniswapPosition], '0x83ee755b'), + ] + ) as `0x${string}`; + }, [uniswapPosition]); + + // Finally, we borrow the requested tokens + const encodedBorrowCall = useMemo(() => { + if (!userAddress) return null; + const borrower = new ethers.utils.Interface(borrowerAbi); + const amount0 = selectedBorrow.equals(selectedLendingPair.token0) + ? borrowAmount + : GN.zero(selectedLendingPair.token0.decimals); + const amount1 = selectedBorrow.equals(selectedLendingPair.token1) + ? borrowAmount + : GN.zero(selectedLendingPair.token1.decimals); + + return borrower.encodeFunctionData('borrow', [amount0.toBigNumber(), amount1.toBigNumber(), userAddress]); + }, [borrowAmount, selectedBorrow, selectedLendingPair, userAddress]); + + const encodedModify = useMemo(() => { + if (!userAddress || nextNftPtrIdx === undefined || !encodedBorrowCall) return null; + const owner = userAddress; + const indices = [nextNftPtrIdx, nextNftPtrIdx]; + const managers = [ + ALOE_II_UNISWAP_NFT_MANAGER_ADDRESS[activeChain.id], + ALOE_II_BORROWER_NFT_SIMPLE_MANAGER_ADDRESS[activeChain.id], + ]; + const datas = [encodedImportCall, encodedBorrowCall]; + const antes = [ante.toBigNumber().div(1e13), BigNumber.from(0)]; + return borrowerNft.encodeFunctionData('modify', [owner, indices, managers, datas, antes]) as `0x${string}`; + }, [userAddress, nextNftPtrIdx, ante, activeChain.id, encodedImportCall, encodedBorrowCall, borrowerNft]); + + const { config: multicallConfig } = usePrepareContractWrite({ + address: ALOE_II_BORROWER_NFT_ADDRESS[activeChain.id], + abi: borrowerNftAbi, + functionName: 'multicall', + args: [[encodedMint ?? '0x', encodedModify ?? '0x']], + overrides: { value: ante.toBigNumber() }, + chainId: activeChain.id, + enabled: userAddress && Boolean(encodedMint) && Boolean(encodedModify) && isApproved, + }); + const gasLimit = multicallConfig.request?.gasLimit.mul(110).div(100); + const { write: multicallWrite, isLoading: isAskingUserToMulticall } = useContractWrite({ + ...multicallConfig, + request: { + ...multicallConfig.request, + gasLimit, + }, + onSuccess(data) { + setIsOpen(false); + setPendingTxn(data); + }, + }); + + let confirmButtonState: ConfirmButtonState; + + if (!userAddress) { + confirmButtonState = ConfirmButtonState.CONNECT_WALLET; + } else if (isApproving) { + confirmButtonState = ConfirmButtonState.WAITING_FOR_TRANSACTION; + } else if (!isApproved) { + confirmButtonState = ConfirmButtonState.APPROVE_NFT_MANAGER; + } else if (ante === undefined || maxBorrowAmount == null) { + confirmButtonState = ConfirmButtonState.LOADING; + } else if (isAskingUserToMulticall) { + confirmButtonState = ConfirmButtonState.WAITING_FOR_USER; + } else if (borrowAmount.toNumber() > maxBorrowAmount) { + confirmButtonState = ConfirmButtonState.INSUFFICIENT_COLLATERAL; + } else if (ethBalance.lt(ante)) { + confirmButtonState = ConfirmButtonState.INSUFFICIENT_ANTE; + } else if (borrowAmountStr === '') { + confirmButtonState = ConfirmButtonState.DISABLED; + } else { + confirmButtonState = ConfirmButtonState.READY; + } + + const confirmButton = getConfirmButton(confirmButtonState, selectedCollateral); + + if (!selectedBorrow) return null; + + return ( + +
+ + Collateral + +
+ + Uniswap Position #{uniswapPosition.tokenId} +
+ + Borrow + +
+
+ + {selectedBorrow.symbol} +
+ ) => { + const output = formatNumberInput(event.target.value); + if (output != null) { + setBorrowAmountStr(output); + } + }} + value={borrowAmountStr} + onMaxClick={() => { + if (eightyPercentMaxBorrowAmountStr) { + setBorrowAmountStr(eightyPercentMaxBorrowAmountStr); + } + }} + maxDisabled={ + eightyPercentMaxBorrowAmountStr === null || eightyPercentMaxBorrowAmountStr === borrowAmountStr + } + maxButtonText='80% Max' + placeholder='0.00' + fullWidth={true} + inputClassName={borrowAmountStr !== '' ? 'active' : ''} + /> +
+
+ + Summary + + + You're importing Uniswap Position #{uniswapPosition.tokenId} as collateral and using it to + borrow{' '} + + {borrowAmountStr || '0.00'} {selectedBorrow.symbol} + + . You'll also receive a{' '} + + {selectedLendingPair?.token0.symbol}/{selectedLendingPair?.token1.symbol} + {' '} + Borrower NFT. + + {ante.isGtZero() && ( + + You will need to provide an additional {ante.toString(GNFormat.LOSSY_HUMAN)} ETH to cover the gas fees in + the event that you are liquidated. + + )} +
+ APR: + {estimatedApr.toFixed(2)}% +
+
+ { + if (!isApproved) { + writeApproveAsync?.({ + recklesslySetUnpreparedArgs: [ + ALOE_II_UNISWAP_NFT_MANAGER_ADDRESS[activeChain.id], + BigNumber.from(uniswapPosition.tokenId), + ], + recklesslySetUnpreparedOverrides: { gasLimit: BigNumber.from(100000) }, + }) + .then((txnResult) => { + setIsApproving(true); + txnResult + .wait(1) + .then(() => refetchGetApprovedData()) + .finally(() => { + setIsApproving(false); + }); + }) + .catch((_err) => { + setIsApproving(false); + }); + } else { + multicallWrite?.(); + } + }} + > + {confirmButton.text} + +
+ + By borrowing, you agree to our{' '} + + Terms of Service + {' '} + and acknowledge that you may lose your money. Aloe Labs is not responsible for any losses you may incur. It is + your duty to educate yourself and be aware of the risks. + +
+ ); +} diff --git a/earn/src/components/lend/modal/UpdateCollateralModal.tsx b/earn/src/components/lend/modal/UpdateCollateralModal.tsx index 89ffa229..3fabcabe 100644 --- a/earn/src/components/lend/modal/UpdateCollateralModal.tsx +++ b/earn/src/components/lend/modal/UpdateCollateralModal.tsx @@ -2,14 +2,17 @@ import { Fragment, useState } from 'react'; import { Tab } from '@headlessui/react'; import { SendTransactionResult } from '@wagmi/core'; +import { BigNumber } from 'ethers'; import Modal from 'shared/lib/components/common/Modal'; import { Text } from 'shared/lib/components/common/Typography'; import { GREY_700 } from 'shared/lib/data/constants/Colors'; import styled from 'styled-components'; import { BorrowerNftBorrower } from '../../../data/BorrowerNft'; +import { UniswapNFTPosition } from '../../../data/Uniswap'; import AddCollateralModalContent from './content/AddCollateralModalContent'; import RemoveCollateralModalContent from './content/RemoveCollateralModalContent'; +import ToUniswapNFTModalContent from './content/ToUniswapNFTModalContent'; export enum ConfirmationType { DEPOSIT = 'DEPOSIT', @@ -49,14 +52,38 @@ const TabButton = styled.button` export type UpdateCollateralModalProps = { isOpen: boolean; borrower: BorrowerNftBorrower; + uniswapPositions: UniswapNFTPosition[]; setIsOpen: (isOpen: boolean) => void; setPendingTxn: (pendingTxn: SendTransactionResult | null) => void; }; export default function UpdateCollateralModal(props: UpdateCollateralModalProps) { - const { isOpen, borrower, setIsOpen, setPendingTxn } = props; + const { isOpen, borrower, uniswapPositions, setIsOpen, setPendingTxn } = props; const [confirmationType, setConfirmationType] = useState(ConfirmationType.DEPOSIT); + if ((borrower.uniswapPositions?.length || 0) > 0) { + const positionToWithdraw = borrower.uniswapPositions![0]; + const uniswapNftId = uniswapPositions.find( + (nft) => nft.lower === positionToWithdraw.lower && nft.upper === positionToWithdraw.upper + )?.tokenId; + + if (uniswapNftId === undefined) return null; + + return ( + +
+ +
+
+ ); + } + return (
diff --git a/earn/src/components/lend/modal/content/BorrowModalContent.tsx b/earn/src/components/lend/modal/content/BorrowModalContent.tsx index 72c13107..564f2b2f 100644 --- a/earn/src/components/lend/modal/content/BorrowModalContent.tsx +++ b/earn/src/components/lend/modal/content/BorrowModalContent.tsx @@ -203,9 +203,8 @@ export default function BorrowModalContent(props: BorrowModalContentProps) { const accountEtherBalance = accountEtherBalanceResult && GN.fromBigNumber(accountEtherBalanceResult.value, 18); - // TODO: This assumes that the borrowing token is always the opposite of the collateral token - // and that only one token is borrowed and one token is collateralized - const isBorrowingToken0 = borrower.assets.token1Raw > 0; + // TODO: This assumes that only one token is borrowed and one token is collateralized + const isBorrowingToken0 = borrower.liabilities.amount0 > 0; // TODO: This logic needs to change once we support more complex borrowing const borrowToken = isBorrowingToken0 ? borrower.token0 : borrower.token1; @@ -233,7 +232,7 @@ export default function BorrowModalContent(props: BorrowModalContentProps) { const maxBorrowsBasedOnHealth = maxBorrowAndWithdraw( borrower.assets, borrower.liabilities, - [], // TODO: add uniswap positions + borrower.uniswapPositions ?? [], borrower.sqrtPriceX96, borrower.iv, borrower.nSigma, @@ -256,7 +255,7 @@ export default function BorrowModalContent(props: BorrowModalContentProps) { const { health: newHealth } = isHealthy( borrower.assets, newLiabilities, - [], // TODO: add uniswap positions + borrower.uniswapPositions ?? [], borrower.sqrtPriceX96, borrower.iv, borrower.nSigma, diff --git a/earn/src/components/lend/modal/content/RepayModalContent.tsx b/earn/src/components/lend/modal/content/RepayModalContent.tsx index 99c493f6..9c5c6804 100644 --- a/earn/src/components/lend/modal/content/RepayModalContent.tsx +++ b/earn/src/components/lend/modal/content/RepayModalContent.tsx @@ -202,9 +202,8 @@ export default function RepayModalContent(props: RepayModalContentProps) { const { address: userAddress } = useAccount(); const { activeChain } = useContext(ChainContext); - // TODO: This assumes that the borrowing token is always the opposite of the collateral token - // and that only one token is borrowed and one token is collateralized - const isRepayingToken0 = borrower.assets.token1Raw > 0; + // TODO: This assumes that only one token is borrowed and one token is collateralized + const isRepayingToken0 = borrower.liabilities.amount0 > 0; // TODO: This logic needs to change once we support more complex borrowing const repayToken = isRepayingToken0 ? borrower.token0 : borrower.token1; @@ -239,7 +238,7 @@ export default function RepayModalContent(props: RepayModalContentProps) { const { health: newHealth } = isHealthy( borrower.assets, newLiabilities, - [], // TODO: add uniswap positions + borrower.uniswapPositions ?? [], borrower.sqrtPriceX96, borrower.iv, borrower.nSigma, diff --git a/earn/src/components/lend/modal/content/ToUniswapNFTModalContent.tsx b/earn/src/components/lend/modal/content/ToUniswapNFTModalContent.tsx new file mode 100644 index 00000000..7ba7df2f --- /dev/null +++ b/earn/src/components/lend/modal/content/ToUniswapNFTModalContent.tsx @@ -0,0 +1,199 @@ +import { useContext, useMemo } from 'react'; + +import { SendTransactionResult } from '@wagmi/core'; +import { BigNumber, ethers } from 'ethers'; +import { borrowerNftAbi } from 'shared/lib/abis/BorrowerNft'; +import { FilledStylizedButton } from 'shared/lib/components/common/Buttons'; +import { Text } from 'shared/lib/components/common/Typography'; +import { + ALOE_II_BORROWER_NFT_ADDRESS, + ALOE_II_BORROWER_NFT_WITHDRAW_MANAGER_ADDRESS, + ALOE_II_UNISWAP_NFT_MANAGER_ADDRESS, +} from 'shared/lib/data/constants/ChainSpecific'; +import { TERMS_OF_SERVICE_URL } from 'shared/lib/data/constants/Values'; +import { useAccount, useContractWrite, usePrepareContractWrite } from 'wagmi'; + +import { ChainContext } from '../../../../App'; +import { isHealthy } from '../../../../data/BalanceSheet'; +import { BorrowerNftBorrower } from '../../../../data/BorrowerNft'; +import { UniswapPosition, zip } from '../../../../data/Uniswap'; +import HealthBar from '../../../borrow/HealthBar'; + +const GAS_ESTIMATE_WIGGLE_ROOM = 110; +const SECONDARY_COLOR = '#CCDFED'; +const TERTIARY_COLOR = '#4b6980'; + +enum ConfirmButtonState { + CONTRACT_ERROR, + WAITING_FOR_USER, + PENDING, + LOADING, + DISABLED, + READY, +} + +function getConfirmButton(state: ConfirmButtonState): { text: string; enabled: boolean } { + switch (state) { + case ConfirmButtonState.CONTRACT_ERROR: + return { text: 'Error', enabled: false }; + case ConfirmButtonState.LOADING: + return { text: 'Loading...', enabled: false }; + case ConfirmButtonState.PENDING: + return { text: 'Pending', enabled: false }; + case ConfirmButtonState.WAITING_FOR_USER: + return { text: 'Check Wallet', enabled: false }; + case ConfirmButtonState.READY: + return { text: 'Confirm', enabled: true }; + case ConfirmButtonState.DISABLED: + default: + return { text: 'Confirm', enabled: false }; + } +} + +type ConfirmButtonProps = { + borrower: BorrowerNftBorrower; + positionToWithdraw: UniswapPosition; + uniswapNftId: BigNumber; + setIsOpen: (open: boolean) => void; + setPendingTxnResult: (result: SendTransactionResult | null) => void; +}; + +function ConfirmButton(props: ConfirmButtonProps) { + const { borrower, positionToWithdraw, uniswapNftId, setIsOpen, setPendingTxnResult } = props; + const { activeChain } = useContext(ChainContext); + + const { address: userAddress } = useAccount(); + + const encodedData = useMemo(() => { + return ethers.utils.defaultAbiCoder.encode( + ['uint256', 'int24', 'int24', 'int128', 'uint208'], + [ + uniswapNftId, + positionToWithdraw.lower, + positionToWithdraw.upper, + positionToWithdraw.liquidity.toString(10), + zip( + borrower.uniswapPositions!.filter((position) => { + return position.lower !== positionToWithdraw.lower || position.upper !== positionToWithdraw.upper; + }), + '0x83ee755b' + ), + ] + ) as `0x${string}`; + }, [borrower, positionToWithdraw, uniswapNftId]); + + const { config: withdrawConfig, error: withdrawError } = usePrepareContractWrite({ + address: ALOE_II_BORROWER_NFT_ADDRESS[activeChain.id], + abi: borrowerNftAbi, + functionName: 'modify', + args: [ + userAddress ?? '0x', + [borrower.index, borrower.index], + [ + ALOE_II_UNISWAP_NFT_MANAGER_ADDRESS[activeChain.id], + ALOE_II_BORROWER_NFT_WITHDRAW_MANAGER_ADDRESS[activeChain.id], + ], + [encodedData, '0x'], + [0, 0], + ], + enabled: Boolean(userAddress), + chainId: activeChain.id, + }); + const gasLimit = withdrawConfig.request?.gasLimit.mul(GAS_ESTIMATE_WIGGLE_ROOM).div(100); + const { write: contractWrite, isLoading: isAskingUserToConfirm } = useContractWrite({ + ...withdrawConfig, + request: { + ...withdrawConfig.request, + gasLimit, + }, + onSuccess(data) { + setIsOpen(false); + setPendingTxnResult(data); + }, + }); + + let confirmButtonState = ConfirmButtonState.READY; + if (withdrawError !== null) { + confirmButtonState = ConfirmButtonState.CONTRACT_ERROR; + } else if (contractWrite === undefined || !withdrawConfig.request) { + confirmButtonState = ConfirmButtonState.LOADING; + } else if (isAskingUserToConfirm) { + confirmButtonState = ConfirmButtonState.WAITING_FOR_USER; + } + + const confirmButton = getConfirmButton(confirmButtonState); + + return ( + + {confirmButton.text} + + ); +} + +export type RemoveCollateralModalContentProps = { + borrower: BorrowerNftBorrower; + positionToWithdraw: UniswapPosition; + uniswapNftId: BigNumber; + setIsOpen: (isOpen: boolean) => void; + setPendingTxnResult: (result: SendTransactionResult | null) => void; +}; + +export default function ToUniswapNFTModalContent(props: RemoveCollateralModalContentProps) { + const { borrower, positionToWithdraw, uniswapNftId, setIsOpen, setPendingTxnResult } = props; + + const { health: newHealth } = isHealthy( + borrower.assets, + borrower.liabilities, + [], + borrower.sqrtPriceX96, + borrower.iv, + borrower.nSigma, + borrower.token0.decimals, + borrower.token1.decimals + ); + + const canExecute = newHealth > 1.0; + + // TODO: could provide links (in summary) to both the BorrowerNFT and UniswapNFT on OpenSea + return ( + <> +
+ + + Summary + + {canExecute ? ( + + You have an {borrower.token0.symbol}/{borrower.token1.symbol} Uniswap Position in the range{' '} + {positionToWithdraw.lower} ⇔ {positionToWithdraw.upper}. You're moving it from an Aloe Borrower + NFT back to a plain Uniswap NFT (#{uniswapNftId.toString()}). It will no longer act as collateral. + + ) : ( + + This Uniswap NFT is the only thing keeping your position healthy. Before you can withdraw, you must repay + some (or all) borrows. + + )} +
+ {canExecute && ( +
+ + + By withdrawing, you agree to our{' '} + + Terms of Service + {' '} + and acknowledge that you may lose your money. Aloe Labs is not responsible for any losses you may incur. It + is your duty to educate yourself and be aware of the risks. + +
+ )} + + ); +} diff --git a/earn/src/data/BorrowerNft.ts b/earn/src/data/BorrowerNft.ts index 229ecfce..1c638bb4 100644 --- a/earn/src/data/BorrowerNft.ts +++ b/earn/src/data/BorrowerNft.ts @@ -8,7 +8,7 @@ import { ALOE_II_BORROWER_NFT_ADDRESS, ALOE_II_BORROWER_NFT_SIMPLE_MANAGER_ADDRESS, ALOE_II_PERMIT2_MANAGER_ADDRESS, - ALOE_II_SIMPLE_MANAGER_ADDRESS, + ALOE_II_UNISWAP_NFT_MANAGER_ADDRESS, MULTICALL_ADDRESS, } from 'shared/lib/data/constants/ChainSpecific'; import { filterNullishValues } from 'shared/lib/util/Arrays'; @@ -164,9 +164,9 @@ export async function fetchListOfFuse2BorrowNfts( includeFreshBorrowers: false, // TODO: change later onlyCheckMostRecentModify: true, // TODO: Hayden has concerns (as usual) validManagerSet: new Set([ - ALOE_II_SIMPLE_MANAGER_ADDRESS[chainId], ALOE_II_PERMIT2_MANAGER_ADDRESS[chainId], ALOE_II_BORROWER_NFT_SIMPLE_MANAGER_ADDRESS[chainId], + ALOE_II_UNISWAP_NFT_MANAGER_ADDRESS[chainId], ]), validUniswapPool: uniswapPool, }); diff --git a/earn/src/data/MarginAccount.ts b/earn/src/data/MarginAccount.ts index 78ff604e..9d1e6a4c 100644 --- a/earn/src/data/MarginAccount.ts +++ b/earn/src/data/MarginAccount.ts @@ -1,6 +1,7 @@ import Big from 'big.js'; import { ContractCallContext, Multicall } from 'ethereum-multicall'; import { ethers } from 'ethers'; +import JSBI from 'jsbi'; import { borrowerAbi } from 'shared/lib/abis/Borrower'; import { borrowerLensAbi } from 'shared/lib/abis/BorrowerLens'; import { erc20Abi } from 'shared/lib/abis/ERC20'; @@ -21,6 +22,7 @@ import { Address } from 'wagmi'; import { ContractCallReturnContextEntries, convertBigNumbersForReturnContexts } from '../util/Multicall'; import { TOPIC0_CREATE_BORROWER_EVENT } from './constants/Signatures'; +import { UniswapPosition } from './Uniswap'; export type Assets = { token0Raw: number; @@ -51,6 +53,7 @@ export type MarginAccount = { lender1: Address; iv: number; nSigma: number; + uniswapPositions?: UniswapPosition[]; }; /** @@ -190,16 +193,16 @@ export async function fetchBorrowerDatas( contractAddress: ALOE_II_BORROWER_LENS_ADDRESS[chainId], abi: borrowerLensAbi as any, calls: [ - // { - // reference: 'getLiabilities', - // methodName: 'getLiabilities', - // methodParameters: [accountAddress, true], - // }, { reference: 'getHealth', methodName: 'getHealth', methodParameters: [accountAddress], }, + { + reference: 'getUniswapPositions', + methodName: 'getUniswapPositions', + methodParameters: [accountAddress], + }, ], context: { fee: fee, @@ -282,6 +285,20 @@ export async function fetchBorrowerDatas( amount1: toImpreciseNumber(liabilitiesData[1], token1.decimals), }; + const uniswapPositionData = lensReturnContexts[1].returnValues; + const uniswapPositionBounds = uniswapPositionData[0] as number[]; + const uniswapPositionLiquidity = uniswapPositionData[1] as { hex: `0x${string}` }[]; + + const uniswapPositions: UniswapPosition[] = []; + uniswapPositionLiquidity.forEach((liquidity, i) => { + uniswapPositions.push({ + lower: uniswapPositionBounds[i * 2], + upper: uniswapPositionBounds[i * 2 + 1], + liquidity: JSBI.BigInt(liquidity.hex), + }); + }); + // const uniswapPositionFees = uniswapPositionData[2]; + const lender0 = accountReturnContexts[0].returnValues[0]; const lender1 = accountReturnContexts[1].returnValues[0]; const oracleReturnValues = convertBigNumbersForReturnContexts(oracleResults.callsReturnContext)[0].returnValues; @@ -299,6 +316,7 @@ export async function fetchBorrowerDatas( lender0, lender1, nSigma, + uniswapPositions, }; marginAccounts.push(marginAccount); }); diff --git a/earn/src/data/Uniswap.ts b/earn/src/data/Uniswap.ts index de8224e8..1d808909 100644 --- a/earn/src/data/Uniswap.ts +++ b/earn/src/data/Uniswap.ts @@ -490,7 +490,7 @@ function modQ24(value: number) { return value & 0b00000000111111111111111111111111; } -export function zip(uniswapPositions: readonly UniswapPosition[]) { +export function zip(uniswapPositions: readonly UniswapPosition[], tag?: `0x${string}`) { const positions: number[] = []; uniswapPositions.forEach((position) => { if (!JSBI.EQ(position.liquidity, JSBI.BigInt(0))) { @@ -512,6 +512,11 @@ export function zip(uniswapPositions: readonly UniswapPosition[]) { return JSBI.add(prev, JSBI.leftShift(JSBI.BigInt(curr), JSBI.BigInt(24 * i))); }, JSBI.BigInt(0)); + if (tag) { + const shiftedTag = JSBI.leftShift(JSBI.BigInt(tag), JSBI.BigInt(144)); + return JSBI.add(shiftedTag, zipped).toString(10); + } + return zipped.toString(10); } diff --git a/earn/src/pages/MarketsPage.tsx b/earn/src/pages/MarketsPage.tsx index 045cc2a7..7f137d02 100644 --- a/earn/src/pages/MarketsPage.tsx +++ b/earn/src/pages/MarketsPage.tsx @@ -275,6 +275,9 @@ export default function MarketsPage() { case HeaderOptions.Borrow: tabContent = (