diff --git a/packages/smart-contracts/contracts/operators/CorePoolLiquidationOperator.sol b/packages/smart-contracts/contracts/operators/CorePoolLiquidationOperator.sol new file mode 100644 index 00000000..c03ca278 --- /dev/null +++ b/packages/smart-contracts/contracts/operators/CorePoolLiquidationOperator.sol @@ -0,0 +1,198 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.25; + +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import { IVToken, IVBep20, IVAIController, IVBNB } from "@venusprotocol/venus-protocol/contracts/InterfacesV8.sol"; +import { Liquidator } from "@venusprotocol/venus-protocol/contracts/Liquidator/Liquidator.sol"; + +import { LiquidityProvider } from "../flash-swap/common.sol"; +import { FlashHandler } from "../flash-swap/FlashHandler.sol"; +import { ExactOutputFlashSwap } from "../flash-swap/ExactOutputFlashSwap.sol"; +import { FlashLoan } from "../flash-swap/FlashLoan.sol"; +import { IPancakeSwapRouter } from "../third-party/interfaces/IPancakeSwapRouter.sol"; +import { IUniswapRouter } from "../third-party/interfaces/IUniswapRouter.sol"; +import { IWETH9 } from "../third-party/IWETH.sol"; +import { transferAll } from "../util/transferAll.sol"; +import { Token } from "../util/Token.sol"; +import { checkDeadline, validatePathStart, validatePathEnd } from "../util/validators.sol"; + +contract CorePoolLiquidationOperator is ExactOutputFlashSwap, FlashLoan { + /// @notice Liquidation parameters + struct FlashLiquidationParameters { + /// @notice AMM providing liquidity (either Uniswap or PancakeSwap) + LiquidityProvider liquidityProvider; + /// @notice The receiver of the liquidated collateral + address beneficiary; + /// @notice vToken for the borrowed underlying + address vTokenBorrowed; + /// @notice Borrower whose position is being liquidated + address borrower; + /// @notice Amount of borrowed tokens to repay + uint256 repayAmount; + /// @notice Collateral vToken to seize + address vTokenCollateral; + /// @notice Reversed (!) swap path to use for liquidation. For regular (not in-kind) + /// liquidations it should start with the borrowed token and end with the collateral + /// token. For in-kind liquidations, must consist of a single PancakeSwap pool to + /// source the liquidity from. + bytes path; + } + + /// @notice Liquidation data to pass between the calls + struct FlashLiquidationData { + /// @notice The receiver of the liquidated collateral + address beneficiary; + /// @notice The borrowed underlying + Token borrowedUnderlying; + /// @notice vToken for the borrowed underlying + address vTokenBorrowed; + /// @notice Borrower whose position is being liquidated + address borrower; + /// @notice Amount of borrowed tokens to repay + uint256 repayAmount; + /// @notice Collateral asset + Token collateralUnderlying; + /// @notice Collateral vToken to seize + address vTokenCollateral; + } + + Liquidator public immutable CORE_POOL_LIQUIDATOR; + IVBNB public immutable VNATIVE; + IVAIController public immutable VAI_CONTROLLER; + Token public immutable VAI; + IWETH9 public immutable WRAPPED_NATIVE; + + /// @notice Thrown if vToken.redeem(...) returns a nonzero error code + error RedeemFailed(uint256 errorCode); + + /// @notice Thrown if receiving native assets from an unexpected sender + error UnexpectedSender(); + + /// @param uniswapSwapRouter_ Uniswap SwapRouter contract + /// @param pcsSwapRouter_ PancakeSwap SmartRouter contract + /// @param corePoolLiquidator_ Core pool Liquidator contract + constructor( + IUniswapRouter uniswapSwapRouter_, + IPancakeSwapRouter pcsSwapRouter_, + Liquidator corePoolLiquidator_ + ) FlashHandler(uniswapSwapRouter_, pcsSwapRouter_) { + CORE_POOL_LIQUIDATOR = corePoolLiquidator_; + VNATIVE = corePoolLiquidator_.vBnb(); + VAI_CONTROLLER = corePoolLiquidator_.vaiController(); + VAI = Token.wrap(VAI_CONTROLLER.getVAIAddress()); + WRAPPED_NATIVE = IWETH9(corePoolLiquidator_.wBNB()); + } + + receive() external payable { + if (msg.sender != address(WRAPPED_NATIVE) && msg.sender != address(VNATIVE)) { + revert UnexpectedSender(); + } + } + + /// @notice Liquidates a borrower's position using flash swap or a flash loan + /// @param params Liquidation parameters + function liquidate(FlashLiquidationParameters calldata params, uint256 deadline) external { + checkDeadline(deadline); + + address borrowedTokenAddress = _underlying(params.vTokenBorrowed); + address collateralTokenAddress = _underlying(params.vTokenCollateral); + + uint256 repayAmount; + if (params.repayAmount == type(uint256).max) { + repayAmount = _borrowBalanceCurrent(params.vTokenBorrowed, params.borrower); + } else { + repayAmount = params.repayAmount; + } + + validatePathStart(params.path, borrowedTokenAddress); + FlashLiquidationData memory data = FlashLiquidationData({ + beneficiary: params.beneficiary, + borrowedUnderlying: Token.wrap(borrowedTokenAddress), + vTokenBorrowed: params.vTokenBorrowed, + borrower: params.borrower, + repayAmount: repayAmount, + collateralUnderlying: Token.wrap(collateralTokenAddress), + vTokenCollateral: params.vTokenCollateral + }); + + if (collateralTokenAddress == borrowedTokenAddress) { + _flashLoan(params.liquidityProvider, repayAmount, params.path, abi.encode(data)); + } else { + validatePathEnd(params.path, collateralTokenAddress); + _flashSwap(params.liquidityProvider, repayAmount, params.path, abi.encode(data)); + } + } + + function _onMoneyReceived(bytes memory data_) internal override returns (Token tokenIn, uint256 maxAmountIn) { + FlashLiquidationData memory data = abi.decode(data_, (FlashLiquidationData)); + + _liquidateBorrow( + data.vTokenBorrowed, + data.borrowedUnderlying, + data.borrower, + data.repayAmount, + data.vTokenCollateral + ); + _redeem(data.vTokenCollateral, IVToken(data.vTokenCollateral).balanceOf(address(this))); + + return (data.collateralUnderlying, data.collateralUnderlying.balanceOfSelf()); + } + + function _onFlashCompleted(bytes memory data_) internal override { + FlashLiquidationData memory data = abi.decode(data_, (FlashLiquidationData)); + data.collateralUnderlying.transferAll(data.beneficiary); + } + + /// @dev Redeems ERC-20 tokens from the given vToken + /// @param vToken The vToken to redeem tokens from + /// @param vTokenAmount The amount of vTokens to redeem + function _redeem(address vToken, uint256 vTokenAmount) internal { + uint256 errorCode = IVToken(vToken).redeem(vTokenAmount); + if (errorCode != 0) { + revert RedeemFailed(errorCode); + } + if (vToken == address(VNATIVE)) { + IWETH9(WRAPPED_NATIVE).deposit{ value: address(this).balance }(); + } + } + + function _liquidateBorrow( + address vTokenBorrowed, + Token borrowedUnderlying, + address borrower, + uint256 repayAmount, + address vTokenCollateral + ) internal { + if (vTokenBorrowed == address(VNATIVE)) { + IWETH9(WRAPPED_NATIVE).withdraw(repayAmount); + CORE_POOL_LIQUIDATOR.liquidateBorrow{ value: repayAmount }( + vTokenBorrowed, + borrower, + repayAmount, + IVToken(vTokenCollateral) + ); + } else { + borrowedUnderlying.approve(address(CORE_POOL_LIQUIDATOR), repayAmount); + CORE_POOL_LIQUIDATOR.liquidateBorrow(vTokenBorrowed, borrower, repayAmount, IVToken(vTokenCollateral)); + borrowedUnderlying.approve(address(CORE_POOL_LIQUIDATOR), 0); + } + } + + function _borrowBalanceCurrent(address vToken, address borrower) internal returns (uint256) { + if (vToken == address(VAI_CONTROLLER)) { + VAI_CONTROLLER.accrueVAIInterest(); + return VAI_CONTROLLER.getVAIRepayAmount(borrower); + } + return IVToken(vToken).borrowBalanceCurrent(borrower); + } + + function _underlying(address vToken) internal view returns (address) { + if (vToken == address(VNATIVE)) { + return address(WRAPPED_NATIVE); + } + if (vToken == address(VAI_CONTROLLER)) { + return VAI.addr(); + } + return IVBep20(vToken).underlying(); + } +} diff --git a/packages/smart-contracts/contracts/third-party/IWETH.sol b/packages/smart-contracts/contracts/third-party/IWETH.sol new file mode 100644 index 00000000..416eedc1 --- /dev/null +++ b/packages/smart-contracts/contracts/third-party/IWETH.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.25; + +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +/// @title Interface for WETH9 +interface IWETH9 is IERC20 { + /// @notice Deposit ether to get wrapped ether + function deposit() external payable; + + /// @notice Withdraw wrapped ether to get ether + function withdraw(uint256) external; +} diff --git a/packages/smart-contracts/fork-tests/CorePoolLiquidationOperator.ts b/packages/smart-contracts/fork-tests/CorePoolLiquidationOperator.ts new file mode 100644 index 00000000..1aabc999 --- /dev/null +++ b/packages/smart-contracts/fork-tests/CorePoolLiquidationOperator.ts @@ -0,0 +1,243 @@ +import { anyValue } from "@nomicfoundation/hardhat-chai-matchers/withArgs"; +import { loadFixture } from "@nomicfoundation/hardhat-network-helpers"; +import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"; +import { + DiamondConsolidated__factory, + VAIController__factory, + VBNB__factory, + VBep20Delegate__factory, + WhitePaperInterestRateModel__factory, +} from "@venusprotocol/venus-protocol/dist/typechain"; +import { expect } from "chai"; +import { concat, hexlify, parseEther, parseUnits } from "ethers/lib/utils"; +import { ethers } from "hardhat"; + +import { CorePoolLiquidationOperator, CorePoolLiquidationOperator__factory, IERC20__factory } from "../typechain"; +import ADDRESSES from "./config/addresses"; +import { LiquidityProviders } from "./constants"; +import { connect, deploy, forking, getBlockTimestamp, initUser } from "./framework"; + +interface LiquidationOperatorFixture { + operator: CorePoolLiquidationOperator; +} + +const makePath = (parts: string[]) => hexlify(concat(parts)); + +const deployZeroRateModel = async () => { + return deploy(WhitePaperInterestRateModel__factory, 0, 0); +}; + +forking({ bscmainnet: 42584000 } as const, network => { + const addresses = ADDRESSES[network]; + + describe("CorePoolLiquidationOperator", () => { + const USDT_SUPPLY = parseUnits("500", 18); + const BNB_SUPPLY = parseEther("1"); + const BNB_BORROW = parseEther("1"); + const VAI_BORROW = parseUnits("50", 18); + const comptroller = connect(DiamondConsolidated__factory, addresses.Unitroller); + const vai = connect(IERC20__factory, addresses.VAI); + const usdt = connect(IERC20__factory, addresses.USDT); + const wbnb = connect(IERC20__factory, addresses.WBNB); + const vBNB = connect(VBNB__factory, addresses.vBNB); + const vUSDT = connect(VBep20Delegate__factory, addresses.vUSDT); + const vaiController = connect(VAIController__factory, addresses.VaiUnitroller); + let operator: CorePoolLiquidationOperator; + let root: SignerWithAddress; + let borrower: SignerWithAddress; + let beneficiary: SignerWithAddress; + + const liquidationOperatorFixture = async (): Promise => { + const zeroRateModel = await deployZeroRateModel(); + const timelock = await initUser(addresses.NormalTimelock, parseEther("1")); + const [, , borrower] = await ethers.getSigners(); + const treasury = await initUser(addresses.VTreasury, parseEther("1")); + await vBNB.connect(timelock)._setInterestRateModel(zeroRateModel.address); + await vUSDT.connect(timelock)._setInterestRateModel(zeroRateModel.address); + await usdt.connect(treasury).transfer(borrower.address, USDT_SUPPLY); + await usdt.connect(borrower).approve(vUSDT.address, USDT_SUPPLY); + await vUSDT.connect(borrower).mint(USDT_SUPPLY); + await comptroller.connect(borrower).enterMarkets([vUSDT.address, vBNB.address]); + await vBNB.connect(borrower).mint({ value: BNB_SUPPLY }); + await vBNB.connect(borrower).borrow(BNB_BORROW); + await vaiController.connect(timelock).toggleOnlyPrimeHolderMint(); + await vaiController.connect(borrower).mintVAI(VAI_BORROW); + await comptroller.connect(timelock)._setCollateralFactor(vUSDT.address, parseUnits("0", 18)); + await vaiController.connect(timelock).setBaseRate(0); + await vaiController.connect(timelock).setFloatRate(0); + const operator = await deploy( + CorePoolLiquidationOperator__factory, + addresses.UniswapRouter, + addresses.PancakeSwapRouter, + addresses.Liquidator, + ); + return { operator }; + }; + + beforeEach(async () => { + [root, beneficiary, borrower] = await ethers.getSigners(); + ({ operator } = await loadFixture(liquidationOperatorFixture)); + }); + + describe("Cross-liquidation", () => { + const repayAmount = parseEther("0.5"); + const path = { + bscmainnet: makePath([addresses.WBNB, "0x0009c4", addresses.USDT]), + }[network]; + let now: number; + + const makeCommonParams = () => ({ + liquidityProvider: LiquidityProviders["PancakeSwap"], + beneficiary: beneficiary.address, + vTokenBorrowed: vBNB.address, + borrower: borrower.address, + repayAmount, + vTokenCollateral: vUSDT.address, + path, + }); + + beforeEach(async () => { + now = await getBlockTimestamp(); + }); + + it("fails if deadline has passed", async () => { + const tooEarly = now; + const tx = operator.connect(root).liquidate(makeCommonParams(), tooEarly); + await expect(tx).to.be.revertedWithCustomError(operator, "DeadlinePassed").withArgs(anyValue, tooEarly); + }); + + it("fails if path start != borrowed token", async () => { + const wrongPath = makePath([addresses.VAI, "0x0009c4", addresses.USDT]); + const tx = operator.connect(root).liquidate({ ...makeCommonParams(), path: wrongPath }, now + 6000); + await expect(tx) + .to.be.revertedWithCustomError(operator, "InvalidSwapStart") + .withArgs(addresses.WBNB, addresses.VAI); + }); + + it("fails if path end != collateral token", async () => { + const wrongPath = makePath([addresses.WBNB, "0x0009c4", addresses.VAI]); + const tx = operator.connect(root).liquidate({ ...makeCommonParams(), path: wrongPath }, now + 6000); + await expect(tx) + .to.be.revertedWithCustomError(operator, "InvalidSwapEnd") + .withArgs(addresses.USDT, addresses.VAI); + }); + + it("repays exactly the specified amount, up to rounding errors", async () => { + const borrowBalanceBefore = await vBNB.callStatic.borrowBalanceCurrent(borrower.address); + await operator.connect(root).liquidate(makeCommonParams(), now + 6000); + const borrowBalanceAfter = await vBNB.callStatic.borrowBalanceCurrent(borrower.address); + expect(borrowBalanceBefore.sub(borrowBalanceAfter)).to.be.closeTo(repayAmount, parseUnits("1", 10)); + }); + + it("sends the income to beneficiary", async () => { + const expectedIncome = parseUnits("12.263641821560471737", 18); + const beneficiaryBalanceBefore = await usdt.balanceOf(beneficiary.address); + await operator.connect(root).liquidate(makeCommonParams(), now + 6000); + const beneficiaryBalanceAfter = await usdt.balanceOf(beneficiary.address); + expect(beneficiaryBalanceAfter.sub(beneficiaryBalanceBefore)).to.equal(expectedIncome); + }); + + it("leaves no money in the operator contract", async () => { + await operator.connect(root).liquidate(makeCommonParams(), now + 6000); + expect(await usdt.balanceOf(operator.address)).to.equal(0); + expect(await wbnb.balanceOf(operator.address)).to.equal(0); + expect(await ethers.provider.getBalance(operator.address)).to.equal(0); + }); + }); + + describe("VAI liquidation", () => { + const repayAmount = parseUnits("25", 18); + const path = { + bscmainnet: makePath([addresses.VAI, "0x000064", addresses.USDT, "0x0009c4", addresses.WBNB]), + }[network]; + let now: number; + + const makeCommonParams = () => ({ + liquidityProvider: LiquidityProviders["PancakeSwap"], + beneficiary: beneficiary.address, + vTokenBorrowed: vaiController.address, + borrower: borrower.address, + repayAmount, + vTokenCollateral: vBNB.address, + path, + }); + + beforeEach(async () => { + now = await getBlockTimestamp(); + }); + + it("repays exactly the specified amount", async () => { + const borrowBalanceBefore = await vaiController.callStatic.getVAIRepayAmount(borrower.address); + await operator.connect(root).liquidate(makeCommonParams(), now + 6000); + const borrowBalanceAfter = await vaiController.callStatic.getVAIRepayAmount(borrower.address); + expect(borrowBalanceBefore.sub(borrowBalanceAfter)).to.equal(repayAmount); + }); + + it("sends the income to beneficiary", async () => { + const expectedIncome = parseUnits("0.001900435932176771", 18); + const beneficiaryBalanceBefore = await wbnb.balanceOf(beneficiary.address); + await operator.connect(root).liquidate(makeCommonParams(), now + 6000); + const beneficiaryBalanceAfter = await wbnb.balanceOf(beneficiary.address); + expect(beneficiaryBalanceAfter.sub(beneficiaryBalanceBefore)).to.equal(expectedIncome); + }); + + it("leaves no money in the operator contract", async () => { + await operator.connect(root).liquidate(makeCommonParams(), now + 6000); + expect(await vai.balanceOf(operator.address)).to.equal(0); + expect(await wbnb.balanceOf(operator.address)).to.equal(0); + expect(await ethers.provider.getBalance(operator.address)).to.equal(0); + }); + }); + + describe("In-kind liquidation", () => { + const repayAmount = parseUnits("0.5", 18); + const path = { + bscmainnet: makePath([addresses.WBNB, "0x0009c4", addresses.USDT]), + }[network]; + let now: number; + + const makeCommonParams = () => ({ + liquidityProvider: LiquidityProviders["PancakeSwap"], + beneficiary: beneficiary.address, + vTokenBorrowed: vBNB.address, + borrower: borrower.address, + repayAmount, + vTokenCollateral: vBNB.address, + path, + }); + + beforeEach(async () => { + now = await getBlockTimestamp(); + }); + + it("fails if path start != token being liquidated", async () => { + const wrongPath = makePath([addresses.USDT, "0x0009c4", addresses.WBNB]); + const tx = operator.connect(root).liquidate({ ...makeCommonParams(), path: wrongPath }, now + 6000); + await expect(tx) + .to.be.revertedWithCustomError(operator, "InvalidSwapStart") + .withArgs(addresses.WBNB, addresses.USDT); + }); + + it("repays exactly the specified amount, up to rounding errors", async () => { + const borrowBalanceBefore = await vBNB.callStatic.borrowBalanceCurrent(borrower.address); + await operator.connect(root).liquidate(makeCommonParams(), now + 6000); + const borrowBalanceAfter = await vBNB.callStatic.borrowBalanceCurrent(borrower.address); + expect(borrowBalanceBefore.sub(borrowBalanceAfter)).to.be.closeTo(repayAmount, parseUnits("1", 10)); + }); + + it("sends the income to beneficiary", async () => { + const expectedIncome = parseUnits("0.02375", 18); + const beneficiaryBalanceBefore = await wbnb.balanceOf(beneficiary.address); + await operator.connect(root).liquidate(makeCommonParams(), now + 6000); + const beneficiaryBalanceAfter = await wbnb.balanceOf(beneficiary.address); + expect(beneficiaryBalanceAfter.sub(beneficiaryBalanceBefore)).to.be.closeTo(expectedIncome, parseUnits("1", 9)); + }); + + it("leaves no money in the operator contract", async () => { + await operator.connect(root).liquidate(makeCommonParams(), now + 6000); + expect(await wbnb.balanceOf(operator.address)).to.equal(0); + expect(await ethers.provider.getBalance(operator.address)).to.equal(0); + }); + }); + }); +}); diff --git a/packages/smart-contracts/fork-tests/index.ts b/packages/smart-contracts/fork-tests/index.ts index 9254cbb5..ad328933 100644 --- a/packages/smart-contracts/fork-tests/index.ts +++ b/packages/smart-contracts/fork-tests/index.ts @@ -1,3 +1,4 @@ import "./BatchTokenConverterOperator"; +import "./CorePoolLiquidationOperator"; import "./LiquidationOperator"; import "./TokenConverterOperator";