diff --git a/src/abi/CoolerConsolidation.json b/src/abi/CoolerConsolidation.json new file mode 100644 index 0000000000..74190aaaad --- /dev/null +++ b/src/abi/CoolerConsolidation.json @@ -0,0 +1,434 @@ +{ + "abi": [ + { + "type": "constructor", + "inputs": [ + { + "name": "gohm_", + "type": "address", + "internalType": "address" + }, + { + "name": "sdai_", + "type": "address", + "internalType": "address" + }, + { + "name": "dai_", + "type": "address", + "internalType": "address" + }, + { + "name": "owner_", + "type": "address", + "internalType": "address" + }, + { + "name": "lender_", + "type": "address", + "internalType": "address" + }, + { + "name": "collector_", + "type": "address", + "internalType": "address" + }, + { + "name": "feePercentage_", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "ONE_HUNDRED_PERCENT", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "VERSION", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "pure" + }, + { + "type": "function", + "name": "collateralRequired", + "inputs": [ + { + "name": "clearinghouse_", + "type": "address", + "internalType": "address" + }, + { + "name": "cooler_", + "type": "address", + "internalType": "address" + }, + { + "name": "ids_", + "type": "uint256[]", + "internalType": "uint256[]" + } + ], + "outputs": [ + { + "name": "consolidatedLoanCollateral", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "existingLoanCollateral", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "additionalCollateral", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "collector", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "consolidateWithFlashLoan", + "inputs": [ + { + "name": "clearinghouse_", + "type": "address", + "internalType": "address" + }, + { + "name": "cooler_", + "type": "address", + "internalType": "address" + }, + { + "name": "ids_", + "type": "uint256[]", + "internalType": "uint256[]" + }, + { + "name": "useFunds_", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "sdai_", + "type": "bool", + "internalType": "bool" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "dai", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract IERC20" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "feePercentage", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getProtocolFee", + "inputs": [ + { + "name": "totalDebt_", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "gohm", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract IERC20" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "lender", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract IERC3156FlashLender" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "onFlashLoan", + "inputs": [ + { + "name": "initiator_", + "type": "address", + "internalType": "address" + }, + { + "name": "", + "type": "address", + "internalType": "address" + }, + { + "name": "amount_", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "lenderFee_", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "params_", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "owner", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "requiredApprovals", + "inputs": [ + { + "name": "clearinghouse_", + "type": "address", + "internalType": "address" + }, + { + "name": "cooler_", + "type": "address", + "internalType": "address" + }, + { + "name": "ids_", + "type": "uint256[]", + "internalType": "uint256[]" + } + ], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + }, + { + "name": "", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "sdai", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract IERC4626" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "setCollector", + "inputs": [ + { + "name": "collector_", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "setFeePercentage", + "inputs": [ + { + "name": "feePercentage_", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "transferOwnership", + "inputs": [ + { + "name": "newOwner", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "event", + "name": "OwnershipTransferred", + "inputs": [ + { + "name": "user", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "newOwner", + "type": "address", + "indexed": true, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "error", + "name": "InsufficientCoolerCount", + "inputs": [] + }, + { + "type": "error", + "name": "OnlyCoolerOwner", + "inputs": [] + }, + { + "type": "error", + "name": "OnlyLender", + "inputs": [] + }, + { + "type": "error", + "name": "OnlyThis", + "inputs": [] + }, + { + "type": "error", + "name": "Params_FeePercentageOutOfRange", + "inputs": [] + }, + { + "type": "error", + "name": "Params_InvalidAddress", + "inputs": [] + }, + { + "type": "error", + "name": "Params_UseFundsOutOfBounds", + "inputs": [] + } + ] +} diff --git a/src/constants/addresses.ts b/src/constants/addresses.ts index cf6f569b07..67e8b266f3 100644 --- a/src/constants/addresses.ts +++ b/src/constants/addresses.ts @@ -282,3 +282,8 @@ export const COOLER_CLEARING_HOUSE_V2_ADDRESSES = { [NetworkId.MAINNET]: "0xE6343ad0675C9b8D3f32679ae6aDbA0766A2ab4c", [NetworkId.TESTNET_GOERLI]: "0xbfe14B5950a530A5CE572Cd2FaC6d44c718A3C47", }; + +export const COOLER_CONSOLIDATION_ADDRESSES = { + [NetworkId.MAINNET]: "0xB15bcb1b6593d85890f5287Baa2245B8A29F464a", + [NetworkId.TESTNET_GOERLI]: "", +}; diff --git a/src/constants/contracts.ts b/src/constants/contracts.ts index b8479b8caa..85cf09aac3 100644 --- a/src/constants/contracts.ts +++ b/src/constants/contracts.ts @@ -6,6 +6,7 @@ import { BOND_FIXED_TERM_TELLER_ADDRESSES, COOLER_CLEARING_HOUSE_V1_ADDRESSES, COOLER_CLEARING_HOUSE_V2_ADDRESSES, + COOLER_CONSOLIDATION_ADDRESSES, CROSS_CHAIN_BRIDGE_ADDRESSES, CROSS_CHAIN_BRIDGE_ADDRESSES_TESTNET, DEV_FAUCET, @@ -27,6 +28,7 @@ import { BondFixedExpiryTeller__factory, BondFixedTermTeller__factory, CoolerClearingHouse__factory, + CoolerConsolidation__factory, CrossChainBridge__factory, CrossChainBridgeTestnet__factory, CrossChainMigrator__factory, @@ -162,3 +164,9 @@ export const COOLER_CLEARING_HOUSE_CONTRACT_V2 = new Contract({ name: "Cooler Clearing House Contract V2", addresses: COOLER_CLEARING_HOUSE_V2_ADDRESSES, }); + +export const COOLER_CONSOLIDATION_CONTRACT = new Contract({ + factory: CoolerConsolidation__factory, + name: "Cooler Consolidation Utils", + addresses: COOLER_CONSOLIDATION_ADDRESSES, +}); diff --git a/src/views/Lending/Cooler/hooks/useConsolidateCooler.tsx b/src/views/Lending/Cooler/hooks/useConsolidateCooler.tsx new file mode 100644 index 0000000000..5b34d0c4f3 --- /dev/null +++ b/src/views/Lending/Cooler/hooks/useConsolidateCooler.tsx @@ -0,0 +1,62 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import toast from "react-hot-toast"; +import { COOLER_CONSOLIDATION_CONTRACT } from "src/constants/contracts"; +import { trackGAEvent, trackGtagEvent } from "src/helpers/analytics/trackGAEvent"; +import { balanceQueryKey } from "src/hooks/useBalance"; +import { contractAllowanceQueryKey } from "src/hooks/useContractAllowance"; +import { useTestableNetworks } from "src/hooks/useTestableNetworks"; +import { CoolerConsolidation__factory } from "src/typechain"; +import { useSigner } from "wagmi"; + +export const useConsolidateCooler = () => { + const { data: signer } = useSigner(); + const queryClient = useQueryClient(); + const networks = useTestableNetworks(); + + return useMutation( + async ({ + coolerAddress, + clearingHouseAddress, + loanIds, + }: { + coolerAddress: string; + clearingHouseAddress: string; + loanIds: number[]; + }) => { + if (!signer) throw new Error(`Please connect a wallet`); + const contractAddress = COOLER_CONSOLIDATION_CONTRACT.addresses[networks.MAINNET]; + const contract = CoolerConsolidation__factory.connect(contractAddress, signer); + const cooler = await contract.consolidateWithFlashLoan(clearingHouseAddress, coolerAddress, loanIds, 0, false, { + gasLimit: loanIds.length <= 30 ? loanIds.length * 1000000 : 30000000, + }); + const receipt = await cooler.wait(); + return receipt; + }, + { + onError: (error: Error) => { + toast.error(error.message); + }, + onSuccess: async tx => { + queryClient.invalidateQueries({ queryKey: ["getCoolerLoans"] }); + queryClient.invalidateQueries({ queryKey: [balanceQueryKey()] }); + queryClient.invalidateQueries({ queryKey: [contractAllowanceQueryKey()] }); + if (tx.transactionHash) { + trackGAEvent({ + category: "Cooler", + action: "Consolidate Cooler", + dimension1: tx.transactionHash, + dimension2: tx.from, // the signer, not necessarily the receipient + }); + + trackGtagEvent("Cooler", { + event_category: "Consolidate Cooler", + address: tx.from.slice(2), // the signer, not necessarily the receipient + txHash: tx.transactionHash.slice(2), + }); + } + + toast(`Coolers Consolidated Successfully`); + }, + }, + ); +}; diff --git a/src/views/Lending/Cooler/positions/ConsolidateLoan.tsx b/src/views/Lending/Cooler/positions/ConsolidateLoan.tsx new file mode 100644 index 0000000000..146174c1c7 --- /dev/null +++ b/src/views/Lending/Cooler/positions/ConsolidateLoan.tsx @@ -0,0 +1,195 @@ +import { Box, SvgIcon, Typography } from "@mui/material"; +import { InfoNotification, Modal, PrimaryButton } from "@olympusdao/component-library"; +import { BigNumber, ethers } from "ethers"; +import { formatEther } from "ethers/lib/utils.js"; +import { useEffect, useState } from "react"; +import lendAndBorrowIcon from "src/assets/icons/lendAndBorrow.svg?react"; +import { TokenAllowanceGuard } from "src/components/TokenAllowanceGuard/TokenAllowanceGuard"; +import { COOLER_CONSOLIDATION_ADDRESSES, DAI_ADDRESSES, GOHM_ADDRESSES } from "src/constants/addresses"; +import { formatNumber } from "src/helpers"; +import { DecimalBigNumber } from "src/helpers/DecimalBigNumber/DecimalBigNumber"; +import { useBalance } from "src/hooks/useBalance"; +import { useTestableNetworks } from "src/hooks/useTestableNetworks"; +import { useConsolidateCooler } from "src/views/Lending/Cooler/hooks/useConsolidateCooler"; +import { useGetCoolerLoans } from "src/views/Lending/Cooler/hooks/useGetCoolerLoans"; + +export const ConsolidateLoans = ({ + coolerAddress, + clearingHouseAddress, + loans, + duration, + debtAddress, +}: { + coolerAddress: string; + clearingHouseAddress: string; + loans: NonNullable["data"]>; + duration: string; + debtAddress: string; +}) => { + const coolerMutation = useConsolidateCooler(); + const networks = useTestableNetworks(); + const [open, setOpen] = useState(false); + const loanIds = loans.map(loan => loan.loanId); + const totals = loans.reduce( + (acc, loan) => { + acc.principal = acc.principal.add(loan.principal); + acc.interest = acc.interest.add(loan.interestDue); + acc.collateral = acc.collateral.add(loan.collateral); + return acc; + }, + { principal: BigNumber.from(0), interest: BigNumber.from(0), collateral: BigNumber.from(0) }, + ); + const maturityDate = new Date(); + maturityDate.setDate(maturityDate.getDate() + Number(duration || 0)); + const { data: daiBalance } = useBalance({ [networks.MAINNET]: debtAddress || "" })[networks.MAINNET]; + const [insufficientCollateral, setInsufficientCollateral] = useState(); + useEffect(() => { + if (!daiBalance) { + setInsufficientCollateral(undefined); + return; + } + + if (Number(daiBalance) < parseFloat(formatEther(totals.interest))) { + setInsufficientCollateral(true); + } else { + setInsufficientCollateral(false); + } + }, [daiBalance, totals.interest]); + + console.log("consolidate loans"); + return ( + <> + setOpen(!open)}>Consolidate Loans + + Consolidate Loans + + } + onClose={() => setOpen(false)} + > + <> + + All existing open loans for this Cooler and Clearinghouse will be repaid and consolidated into a new loan + with a {duration} day duration. You must hold enough DAI in your wallet to cover the interest owed at + consolidation. + + + Loans to Consolidate + + {loans.length} + + + + New Principal Amount + + + {formatNumber(parseFloat(formatEther(totals.principal)), 4)} DAI + + + + + Interest Owed At Consolidation + + + {formatNumber(parseFloat(formatEther(totals.interest)), 4)} DAI + + + + + New Maturity Date + + + {maturityDate.toLocaleDateString([], { + month: "long", + day: "numeric", + year: "numeric", + }) || ""}{" "} + {maturityDate.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + })} + + + + {insufficientCollateral ? ( + + Insufficient DAI Balance + + ) : ( + Approve DAI for Spending on the Consolidation Contract} + spendAmount={new DecimalBigNumber(ethers.constants.MaxUint256, 18)} + approvalText="Approve DAI for Spending" + > + Approve gOHM for Spending on the Consolidation Contract} + spendAmount={new DecimalBigNumber(totals.collateral, 18)} + approvalText="Approve gOHM for Spending" + > + { + coolerMutation.mutate( + { + coolerAddress, + clearingHouseAddress, + loanIds, + }, + { + onSuccess: () => { + setOpen(false); + }, + }, + ); + }} + loading={coolerMutation.isLoading} + disabled={coolerMutation.isLoading} + fullWidth + > + Consolidate Loans + + + + )} + + + + ); +}; diff --git a/src/views/Lending/Cooler/positions/Positions.tsx b/src/views/Lending/Cooler/positions/Positions.tsx index dd1d6cd88c..50e1f1b8df 100644 --- a/src/views/Lending/Cooler/positions/Positions.tsx +++ b/src/views/Lending/Cooler/positions/Positions.tsx @@ -21,6 +21,7 @@ import { BorrowRate, OutstandingPrincipal, WeeklyCapacityRemaining } from "src/v import { useGetClearingHouse } from "src/views/Lending/Cooler/hooks/useGetClearingHouse"; import { useGetCoolerForWallet } from "src/views/Lending/Cooler/hooks/useGetCoolerForWallet"; import { useGetCoolerLoans } from "src/views/Lending/Cooler/hooks/useGetCoolerLoans"; +import { ConsolidateLoans } from "src/views/Lending/Cooler/positions/ConsolidateLoan"; import { CreateOrRepayLoan } from "src/views/Lending/Cooler/positions/CreateOrRepayLoan"; import { ExtendLoan } from "src/views/Lending/Cooler/positions/ExtendLoan"; import { useAccount } from "wagmi"; @@ -87,7 +88,7 @@ export const CoolerPositions = () => { {clearingHouseV1 && loansV1 && loansV1.length > 0 && ( - +