From 1b58c2c9d63baf69a521dc298700302aa0682da1 Mon Sep 17 00:00:00 2001 From: LidoKing <55234791+LidoKing@users.noreply.github.com> Date: Thu, 23 Feb 2023 21:17:59 +0800 Subject: [PATCH] chore: money market --- contracts/money-market/ExponentialNoError.sol | 182 ++++ contracts/money-market/FErc20.sol | 79 ++ contracts/money-market/FEther.sol | 92 ++ .../money-market/JumpInterestRateModel.sol | 140 +++ .../money-market/NormalInterestRateModel.sol | 92 ++ contracts/money-market/RiskManager.sol | 758 ++++++++++++++++ contracts/money-market/RiskManagerStorage.sol | 127 +++ contracts/money-market/SimplePriceOracle.sol | 55 ++ contracts/money-market/TokenBase.sol | 822 ++++++++++++++++++ contracts/money-market/TokenStorages.sol | 83 ++ contracts/money-market/interfaces/IFErc20.sol | 23 + .../interfaces/IInterestRateModel.sol | 31 + .../money-market/interfaces/IPriceOracle.sol | 13 + .../money-market/interfaces/IRiskManager.sol | 80 ++ .../money-market/interfaces/ITokenBase.sol | 93 ++ info/MoneyMarket.json | 4 + tasks/deploy/money-market/ferc20.ts | 46 + tasks/deploy/money-market/fether.ts | 27 + .../money-market/jumpInterestRateModel.ts | 31 + .../money-market/normalInterestRateModel.ts | 22 + tasks/deploy/money-market/priceOracle.ts | 18 + tasks/deploy/money-market/riskManager.ts | 22 + tasks/index.ts | 26 +- tasks/money-market/priceOracle.ts | 33 + tasks/money-market/riskManager.ts | 49 ++ tasks/upgrade/money-market/ferc20.ts | 12 + tasks/upgrade/money-market/fether.ts | 13 + tasks/upgrade/money-market/riskManager.ts | 13 + test/money-market/FErc20/FErc20.borrow.ts | 95 ++ test/money-market/FErc20/FErc20.fixture.ts | 106 +++ test/money-market/FErc20/FErc20.liquidate.ts | 357 ++++++++ test/money-market/FErc20/FErc20.redeem.ts | 93 ++ test/money-market/FErc20/FErc20.repay.ts | 89 ++ test/money-market/FErc20/FErc20.supply.ts | 53 ++ test/money-market/FErc20/FErc20.ts | 35 + test/money-market/FEther/FEther.borrow.ts | 85 ++ test/money-market/FEther/FEther.fixture.ts | 71 ++ test/money-market/FEther/FEther.liquidate.ts | 289 ++++++ test/money-market/FEther/FEther.redeem.ts | 90 ++ test/money-market/FEther/FEther.repay.ts | 81 ++ test/money-market/FEther/FEther.supply.ts | 51 ++ test/money-market/FEther/FEther.ts | 34 + .../NIRM.borrowRate.ts | 30 + .../NormalInterestRateModel/NIRM.fixture.ts | 23 + .../NIRM.supplyRate.ts | 31 + .../NormalInterestRateModel/NIRM.ts | 24 + .../NIRM.utilizationRate.ts | 24 + .../RiskManager/RiskManager.admin.ts | 38 + .../RiskManager/RiskManager.fixture.ts | 73 ++ .../RiskManager/RiskManager.market.ts | 54 ++ test/money-market/RiskManager/RiskManager.ts | 32 + test/types.ts | 13 + 52 files changed, 4855 insertions(+), 2 deletions(-) create mode 100644 contracts/money-market/ExponentialNoError.sol create mode 100644 contracts/money-market/FErc20.sol create mode 100644 contracts/money-market/FEther.sol create mode 100644 contracts/money-market/JumpInterestRateModel.sol create mode 100644 contracts/money-market/NormalInterestRateModel.sol create mode 100644 contracts/money-market/RiskManager.sol create mode 100644 contracts/money-market/RiskManagerStorage.sol create mode 100644 contracts/money-market/SimplePriceOracle.sol create mode 100644 contracts/money-market/TokenBase.sol create mode 100644 contracts/money-market/TokenStorages.sol create mode 100644 contracts/money-market/interfaces/IFErc20.sol create mode 100644 contracts/money-market/interfaces/IInterestRateModel.sol create mode 100644 contracts/money-market/interfaces/IPriceOracle.sol create mode 100644 contracts/money-market/interfaces/IRiskManager.sol create mode 100644 contracts/money-market/interfaces/ITokenBase.sol create mode 100644 info/MoneyMarket.json create mode 100644 tasks/deploy/money-market/ferc20.ts create mode 100644 tasks/deploy/money-market/fether.ts create mode 100644 tasks/deploy/money-market/jumpInterestRateModel.ts create mode 100644 tasks/deploy/money-market/normalInterestRateModel.ts create mode 100644 tasks/deploy/money-market/priceOracle.ts create mode 100644 tasks/deploy/money-market/riskManager.ts create mode 100644 tasks/money-market/priceOracle.ts create mode 100644 tasks/money-market/riskManager.ts create mode 100644 tasks/upgrade/money-market/ferc20.ts create mode 100644 tasks/upgrade/money-market/fether.ts create mode 100644 tasks/upgrade/money-market/riskManager.ts create mode 100644 test/money-market/FErc20/FErc20.borrow.ts create mode 100644 test/money-market/FErc20/FErc20.fixture.ts create mode 100644 test/money-market/FErc20/FErc20.liquidate.ts create mode 100644 test/money-market/FErc20/FErc20.redeem.ts create mode 100644 test/money-market/FErc20/FErc20.repay.ts create mode 100644 test/money-market/FErc20/FErc20.supply.ts create mode 100644 test/money-market/FErc20/FErc20.ts create mode 100644 test/money-market/FEther/FEther.borrow.ts create mode 100644 test/money-market/FEther/FEther.fixture.ts create mode 100644 test/money-market/FEther/FEther.liquidate.ts create mode 100644 test/money-market/FEther/FEther.redeem.ts create mode 100644 test/money-market/FEther/FEther.repay.ts create mode 100644 test/money-market/FEther/FEther.supply.ts create mode 100644 test/money-market/FEther/FEther.ts create mode 100644 test/money-market/NormalInterestRateModel/NIRM.borrowRate.ts create mode 100644 test/money-market/NormalInterestRateModel/NIRM.fixture.ts create mode 100644 test/money-market/NormalInterestRateModel/NIRM.supplyRate.ts create mode 100644 test/money-market/NormalInterestRateModel/NIRM.ts create mode 100644 test/money-market/NormalInterestRateModel/NIRM.utilizationRate.ts create mode 100644 test/money-market/RiskManager/RiskManager.admin.ts create mode 100644 test/money-market/RiskManager/RiskManager.fixture.ts create mode 100644 test/money-market/RiskManager/RiskManager.market.ts create mode 100644 test/money-market/RiskManager/RiskManager.ts diff --git a/contracts/money-market/ExponentialNoError.sol b/contracts/money-market/ExponentialNoError.sol new file mode 100644 index 0000000..c029e95 --- /dev/null +++ b/contracts/money-market/ExponentialNoError.sol @@ -0,0 +1,182 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +/** + * @notice Exp is a struct which stores decimals with a fixed precision of 18 decimal places. + * Thus, if we wanted to store the 5.1, mantissa would store 5.1e18. That is: + * `Exp({mantissa: 5100000000000000000})`. + */ +contract ExponentialNoError { + uint256 constant expScale = 1e18; + uint256 constant doubleScale = 1e36; + uint256 constant halfExpScale = expScale / 2; + uint256 constant mantissaOne = expScale; + + struct Exp { + uint256 mantissa; + } + + struct Double { + uint256 mantissa; + } + + /** + * @dev Truncates the given exp to a whole number value. + * For example, truncate(Exp{mantissa: 15 * expScale}) = 15 + */ + function truncate(Exp memory _exp) internal pure returns (uint256) { + // Note: We are not using careful math here as we're performing a division that cannot fail + return _exp.mantissa / expScale; + } + + /** + * @dev Multiply an Exp by a scalar, then truncate to return an unsigned integer. + */ + function mul_ScalarTruncate(Exp memory _a, uint256 _scalar) internal pure returns (uint256) { + Exp memory product = mul_(_a, _scalar); + return truncate(product); + } + + /** + * @dev Multiply an Exp by a scalar, truncate, then add an to an unsigned integer, returning an unsigned integer. + */ + function mul_ScalarTruncateAddUInt( + Exp memory _a, + uint256 _scalar, + uint256 _addend + ) internal pure returns (uint256) { + Exp memory product = mul_(_a, _scalar); + return add_(truncate(product), _addend); + } + + /** + * @dev Multiply an Exp by a scalar, truncate, then minus an unsigned integer, returning an unsigned integer. + */ + function mul_ScalarTruncateSubUInt(Exp memory _a, uint256 _scalar, uint256 _minus) internal pure returns (uint256) { + Exp memory product = mul_(_a, _scalar); + return sub_(truncate(product), _minus); + } + + /** + * @dev Checks if first Exp is less than second Exp. + */ + function lessThanExp(Exp memory _left, Exp memory _right) internal pure returns (bool) { + return _left.mantissa < _right.mantissa; + } + + /** + * @dev Checks if left Exp <= right Exp. + */ + function lessThanOrEqualExp(Exp memory _left, Exp memory _right) internal pure returns (bool) { + return _left.mantissa <= _right.mantissa; + } + + /** + * @dev Checks if left Exp > right Exp. + */ + function greaterThanExp(Exp memory _left, Exp memory _right) internal pure returns (bool) { + return _left.mantissa > _right.mantissa; + } + + /** + * @dev returns true if Exp is exactly zero + */ + function isZeroExp(Exp memory _value) internal pure returns (bool) { + return _value.mantissa == 0; + } + + function safe224(uint256 _n, string memory _errorMessage) internal pure returns (uint224) { + require(_n < 2 ** 224, _errorMessage); + return uint224(_n); + } + + function safe32(uint256 _n, string memory _errorMessage) internal pure returns (uint32) { + require(_n < 2 ** 32, _errorMessage); + return uint32(_n); + } + + function add_(Exp memory _a, Exp memory _b) internal pure returns (Exp memory) { + return Exp({ mantissa: add_(_a.mantissa, _b.mantissa) }); + } + + function add_(Double memory _a, Double memory _b) internal pure returns (Double memory) { + return Double({ mantissa: add_(_a.mantissa, _b.mantissa) }); + } + + function add_(uint256 _a, uint256 _b) internal pure returns (uint256) { + return _a + _b; + } + + function sub_(Exp memory _a, Exp memory _b) internal pure returns (Exp memory) { + return Exp({ mantissa: sub_(_a.mantissa, _b.mantissa) }); + } + + function sub_(Double memory _a, Double memory _b) internal pure returns (Double memory) { + return Double({ mantissa: sub_(_a.mantissa, _b.mantissa) }); + } + + function sub_(uint256 _a, uint256 _b) internal pure returns (uint256) { + return _a - _b; + } + + function mul_(Exp memory _a, Exp memory _b) internal pure returns (Exp memory) { + return Exp({ mantissa: mul_(_a.mantissa, _b.mantissa) / expScale }); + } + + function mul_(Exp memory _a, uint256 _b) internal pure returns (Exp memory) { + return Exp({ mantissa: mul_(_a.mantissa, _b) }); + } + + function mul_(uint256 _a, Exp memory _b) internal pure returns (uint256) { + return mul_(_a, _b.mantissa) / expScale; + } + + function mul_(Double memory _a, Double memory _b) internal pure returns (Double memory) { + return Double({ mantissa: mul_(_a.mantissa, _b.mantissa) / doubleScale }); + } + + function mul_(Double memory _a, uint256 _b) internal pure returns (Double memory) { + return Double({ mantissa: mul_(_a.mantissa, _b) }); + } + + function mul_(uint256 _a, Double memory _b) internal pure returns (uint256) { + return mul_(_a, _b.mantissa) / doubleScale; + } + + function mul_(uint256 _a, uint256 _b) internal pure returns (uint256) { + return _a * _b; + } + + function div_(Exp memory _a, Exp memory _b) internal pure returns (Exp memory) { + return Exp({ mantissa: div_(mul_(_a.mantissa, expScale), _b.mantissa) }); + } + + function div_(Exp memory _a, uint256 _b) internal pure returns (Exp memory) { + return Exp({ mantissa: div_(_a.mantissa, _b) }); + } + + function div_(uint256 _a, Exp memory _b) internal pure returns (uint256) { + return div_(mul_(_a, expScale), _b.mantissa); + } + + function div_(Double memory _a, Double memory _b) internal pure returns (Double memory) { + return Double({ mantissa: div_(mul_(_a.mantissa, doubleScale), _b.mantissa) }); + } + + function div_(Double memory _a, uint256 _b) internal pure returns (Double memory) { + return Double({ mantissa: div_(_a.mantissa, _b) }); + } + + function div_(uint256 _a, Double memory _b) internal pure returns (uint256) { + return div_(mul_(_a, doubleScale), _b.mantissa); + } + + function div_(uint256 _a, uint256 _b) internal pure returns (uint256) { + return _a / _b; + } + + function fraction(uint256 _a, uint256 _b) internal pure returns (Double memory) { + return Double({ mantissa: div_(mul_(_a, doubleScale), _b) }); + } +} diff --git a/contracts/money-market/FErc20.sol b/contracts/money-market/FErc20.sol new file mode 100644 index 0000000..de559ec --- /dev/null +++ b/contracts/money-market/FErc20.sol @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +import "./TokenBase.sol"; +import "./TokenStorages.sol"; +import "./interfaces/IFErc20.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +contract FErc20 is TokenBase, FErc20Storage, IFErc20 { + function initialize( + address _underlying, + address _riskManager, + address _interestRateModel, + address _priceOracle, + address _checker, + string memory _name, + string memory _symbol + ) public initializer { + __TokenBase_init(_riskManager, _interestRateModel, _priceOracle, _checker, _name, _symbol); + + underlying = _underlying; + } + + function supply(uint256 _supplyAmount) external { + // Params: supplier, supply amount + supplyInternal(msg.sender, _supplyAmount); + } + + function redeem(uint256 _redeemTokens) external { + // Params: redeemer, tokens supplied for redemption, amount of underlying to receive + redeemInternal(msg.sender, _redeemTokens, 0); + } + + function redeemUnderlying(uint256 _redeemAmount) external { + // Params: redeemer, tokens supplied for redemption, amount of underlying to receive + redeemInternal(msg.sender, 0, _redeemAmount); + } + + function borrow(uint256 _borrowAmount) external { + // Params: borrower, borrow amount + borrowInternal(msg.sender, _borrowAmount); + } + + function repayBorrow(uint256 _repayAmount) external { + // Params: payer, borrower, repay amount + repayBorrowInternal(msg.sender, msg.sender, _repayAmount); + } + + function repayBorrowBehalf(address _borrower, uint256 _repayAmount) external { + // Params: payer, borrower, repay amount + repayBorrowInternal(msg.sender, _borrower, _repayAmount); + } + + function liquidateBorrow(address _borrower, uint256 _repayAmount, address _fTokenCollateral) external { + // Params: liquidator, borrower, repay amount, collateral token to be seized + liquidateBorrowInternal(msg.sender, _borrower, _repayAmount, _fTokenCollateral); + } + + /******************************* Safe Token *******************************/ + + function getUnderlying() public view override returns (address) { + return underlying; + } + + function doTransferIn(address _from, uint256 _amount) internal override { + IERC20 underlyingToken = IERC20(underlying); + underlyingToken.transferFrom(_from, address(this), _amount); + + totalCash += _amount; + } + + function doTransferOut(address payable _to, uint256 _amount) internal override { + IERC20 underlyingToken = IERC20(underlying); + underlyingToken.transfer(_to, _amount); + + totalCash -= _amount; + } +} diff --git a/contracts/money-market/FEther.sol b/contracts/money-market/FEther.sol new file mode 100644 index 0000000..b30c7bb --- /dev/null +++ b/contracts/money-market/FEther.sol @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +import "./TokenBase.sol"; + +contract FEther is TokenBase { + function initialize( + address _riskManager, + address _interestRateModel, + address _priceOracle, + address _checker + ) public initializer { + __TokenBase_init(_riskManager, _interestRateModel, _priceOracle, _checker, "Furion Ether", "fETH"); + } + + /** + * @notice Sender supplies ETH into the market and receives fETH in exchange + * @dev Reverts upon any failure + */ + function supply() external payable { + // Params: supplier, supply amount + supplyInternal(msg.sender, msg.value); + } + + /** + * @notice Sender redeems fETH in exchange for ETH. + * @dev Accrues interest whether or not the operation succeeds, unless reverted + * @param _redeemTokens The number of fETH to redeem into underlying + */ + function redeem(uint256 _redeemTokens) external { + // Params: redeemer, tokens supplied for redemption, amount of underlying to receive + redeemInternal(msg.sender, _redeemTokens, 0); + } + + /** + * @notice Sender redeems fETH in exchange for a specified amount of ETH. + * @dev Accrues interest whether or not the operation succeeds, unless reverted + * @param _redeemAmount The amount of ETH to redeem + */ + function redeemUnderlying(uint256 _redeemAmount) external { + // Params: redeemer, tokens supplied for redemption, amount of underlying to receive + redeemInternal(msg.sender, 0, _redeemAmount); + } + + /** + * @notice Sender borrows ETH from the protocol to their own address + * @param _borrowAmount The amount of ETH to borrow + */ + function borrow(uint256 _borrowAmount) external { + // Params: borrower, borrow amount + borrowInternal(msg.sender, _borrowAmount); + } + + /** + * @notice Sender repays their own borrow + * @dev Reverts upon any failure + */ + function repayBorrow() external payable { + // Params: payer, borrower, repay amount + repayBorrowInternal(msg.sender, msg.sender, msg.value); + } + + /** + * @notice Sender repays a borrow belonging to borrower + * @dev Reverts upon any failure + * @param _borrower the account with the debt being payed off + */ + function repayBorrowBehalf(address _borrower) external payable { + // Params: payer, borrower, repay amount + repayBorrowInternal(msg.sender, _borrower, msg.value); + } + + function liquidateBorrow(address _borrower, address _fTokenCollateral) external payable { + liquidateBorrowInternal(msg.sender, _borrower, msg.value, _fTokenCollateral); + } + + /******************************* Safe Token *******************************/ + + function doTransferIn(address _from, uint256 _amount) internal override { + require(msg.sender == _from, "FEther: Not owner of account"); + require(msg.value == _amount, "FEther: Not enough ETH supplied"); + + totalCash += _amount; + } + + function doTransferOut(address payable _to, uint256 _amount) internal override { + _to.transfer(_amount); + + totalCash -= _amount; + } +} diff --git a/contracts/money-market/JumpInterestRateModel.sol b/contracts/money-market/JumpInterestRateModel.sol new file mode 100644 index 0000000..d9622ab --- /dev/null +++ b/contracts/money-market/JumpInterestRateModel.sol @@ -0,0 +1,140 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +import "@openzeppelin/contracts/access/Ownable.sol"; +import "./interfaces/IInterestRateModel.sol"; + +contract JumpInterestRateModel is Ownable, IInterestRateModel { + bool public constant IS_INTEREST_RATE_MODEL = true; + + uint256 private constant BASE = 1e18; + + /** + * @notice The approximate number of blocks per year that is assumed by the interest rate model + */ + uint256 public constant blocksPerYear = 2102400; + + /** + * @notice The multiplier of utilization rate that gives the slope of the interest rate + */ + uint256 public multiplierPerBlock; + + /** + * @notice The base interest rate which is the y-intercept when utilization rate is 0 + */ + uint256 public baseRatePerBlock; + + /** + * @notice The multiplierPerBlock after hitting a specified utilization point + */ + uint256 public jumpMultiplierPerBlock; + + /** + * @notice The utilization point at which the jump multiplier is applied + */ + uint256 public kink; + + event NewInterestParams( + uint256 baseRatePerBlock, + uint256 multiplierPerBlock, + uint256 jumpMultiplierPerBlock, + uint256 kink + ); + + /** + * @notice Construct an interest rate model + * @param _baseRatePerYear The approximate target base APR, as a mantissa (scaled by BASE) + * @param _multiplierPerYear The rate of increase in interest rate wrt utilization (scaled by BASE) + * @param _jumpMultiplierPerYear The multiplierPerBlock after hitting a specified utilization point + * @param _kink The utilization point at which the jump multiplier is applied + */ + constructor(uint256 _baseRatePerYear, uint256 _multiplierPerYear, uint256 _jumpMultiplierPerYear, uint256 _kink) { + baseRatePerBlock = _baseRatePerYear / blocksPerYear; + multiplierPerBlock = (_multiplierPerYear * BASE) / (blocksPerYear * _kink); + jumpMultiplierPerBlock = _jumpMultiplierPerYear / blocksPerYear; + kink = _kink; + + emit NewInterestParams(baseRatePerBlock, multiplierPerBlock, jumpMultiplierPerBlock, kink); + } + + function isInterestRateModel() public pure returns (bool) { + return IS_INTEREST_RATE_MODEL; + } + + /** + * @notice Internal function to update the parameters of the interest rate model + * @param _baseRatePerYear The approximate target base APR, as a mantissa (scaled by BASE) + * @param _multiplierPerYear The rate of increase in interest rate wrt utilization (scaled by BASE) + * @param _jumpMultiplierPerYear The multiplierPerBlock after hitting a specified utilization point + * @param _kink The utilization point at which the jump multiplier is applied + */ + function updateJumpRateModel( + uint256 _baseRatePerYear, + uint256 _multiplierPerYear, + uint256 _jumpMultiplierPerYear, + uint256 _kink + ) external onlyOwner { + baseRatePerBlock = _baseRatePerYear / blocksPerYear; + multiplierPerBlock = (_multiplierPerYear * BASE) / (blocksPerYear * _kink); + jumpMultiplierPerBlock = _jumpMultiplierPerYear / blocksPerYear; + kink = _kink; + + emit NewInterestParams(baseRatePerBlock, multiplierPerBlock, jumpMultiplierPerBlock, kink); + } + + /** + * @notice Calculates the utilization rate of the market: `borrows / (cash + borrows - reserves)` + * @param _cash The amount of cash in the market + * @param _borrows The amount of borrows in the market + * @param _reserves The amount of reserves in the market (currently unused) + * @return The utilization rate as a mantissa between [0, BASE] + */ + function utilizationRate(uint256 _cash, uint256 _borrows, uint256 _reserves) public pure returns (uint256) { + // Utilization rate is 0 when there are no borrows + if (_borrows == 0) { + return 0; + } + + return (_borrows * BASE) / (_cash + _borrows - _reserves); + } + + /** + * @notice Calculates the current borrow rate per block, with the error code expected by the market + * @param _cash The amount of cash in the market + * @param _borrows The amount of borrows in the market + * @param _reserves The amount of reserves in the market + * @return The borrow rate percentage per block as a mantissa (scaled by BASE) + */ + function getBorrowRate(uint256 _cash, uint256 _borrows, uint256 _reserves) public view returns (uint256) { + uint256 util = utilizationRate(_cash, _borrows, _reserves); + + if (util <= kink) { + return ((util * multiplierPerBlock) / BASE) + baseRatePerBlock; + } else { + uint256 normalRate = ((kink * multiplierPerBlock) / BASE) + baseRatePerBlock; + uint256 excessUtil = util - kink; + return ((excessUtil * jumpMultiplierPerBlock) / BASE) + normalRate; + } + } + + /** + * @notice Calculates the current supply rate per block + * @param _cash The amount of cash in the market + * @param _borrows The amount of borrows in the market + * @param _reserves The amount of reserves in the market + * @param _reserveFactorMantissa The current reserve factor for the market + * @return The supply rate percentage per block as a mantissa (scaled by BASE) + */ + function getSupplyRate( + uint256 _cash, + uint256 _borrows, + uint256 _reserves, + uint256 _reserveFactorMantissa + ) public view returns (uint256) { + uint256 oneMinusReserveFactor = BASE - _reserveFactorMantissa; + uint256 borrowRate = getBorrowRate(_cash, _borrows, _reserves); + uint256 rateToPool = (borrowRate * oneMinusReserveFactor) / BASE; + return (utilizationRate(_cash, _borrows, _reserves) * rateToPool) / BASE; + } +} diff --git a/contracts/money-market/NormalInterestRateModel.sol b/contracts/money-market/NormalInterestRateModel.sol new file mode 100644 index 0000000..c4041ab --- /dev/null +++ b/contracts/money-market/NormalInterestRateModel.sol @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +import "./interfaces/IInterestRateModel.sol"; + +contract NormalInterestRateModel is IInterestRateModel { + bool public constant IS_INTEREST_RATE_MODEL = true; + + uint256 private constant BASE = 1e18; + + /** + * @notice The approximate number of blocks per year that is assumed by the interest rate model + */ + uint256 public constant blocksPerYear = 2102400; + + /** + * @notice The multiplier of utilization rate that gives the slope of the interest rate + */ + uint256 public multiplierPerBlock; + + /** + * @notice The base interest rate which is the y-intercept when utilization rate is 0 + */ + uint256 public baseRatePerBlock; + + event NewInterestParams(uint256 baseRatePerBlock, uint256 multiplierPerBlock); + + /** + * @notice Construct an interest rate model + * @param _baseRatePerYear The approximate target base APR, as a mantissa (scaled by BASE) + * @param _multiplierPerYear The rate of increase in interest rate wrt utilization (scaled by BASE) + */ + constructor(uint256 _baseRatePerYear, uint256 _multiplierPerYear) { + baseRatePerBlock = _baseRatePerYear / blocksPerYear; + multiplierPerBlock = _multiplierPerYear / blocksPerYear; + + emit NewInterestParams(baseRatePerBlock, multiplierPerBlock); + } + + function isInterestRateModel() public pure returns (bool) { + return IS_INTEREST_RATE_MODEL; + } + + /** + * @notice Calculates the utilization rate of the market: `borrows / (cash + borrows - reserves)` + * @param _cash The amount of cash in the market + * @param _borrows The amount of borrows in the market + * @param _reserves The amount of reserves in the market (currently unused) + * @return The utilization rate as a mantissa between [0, BASE] + */ + function utilizationRate(uint256 _cash, uint256 _borrows, uint256 _reserves) public pure returns (uint256) { + // Utilization rate is 0 when there are no borrows + if (_borrows == 0) { + return 0; + } + + return (_borrows * BASE) / (_cash + _borrows - _reserves); + } + + /** + * @notice Calculates the current borrow rate per block, with the error code expected by the market + * @param _cash The amount of cash in the market + * @param _borrows The amount of borrows in the market + * @param _reserves The amount of reserves in the market + * @return The borrow rate percentage per block as a mantissa (scaled by BASE) + */ + function getBorrowRate(uint256 _cash, uint256 _borrows, uint256 _reserves) public view override returns (uint256) { + uint256 ur = utilizationRate(_cash, _borrows, _reserves); + return ((ur * multiplierPerBlock) / BASE) + baseRatePerBlock; + } + + /** + * @notice Calculates the current supply rate per block + * @param _cash The amount of cash in the market + * @param _borrows The amount of borrows in the market + * @param _reserves The amount of reserves in the market + * @param _reserveFactorMantissa The current reserve factor for the market + * @return The supply rate percentage per block as a mantissa (scaled by BASE) + */ + function getSupplyRate( + uint256 _cash, + uint256 _borrows, + uint256 _reserves, + uint256 _reserveFactorMantissa + ) public view override returns (uint256) { + uint256 oneMinusReserveFactor = BASE - _reserveFactorMantissa; + uint256 borrowRate = getBorrowRate(_cash, _borrows, _reserves); + uint256 rateToPool = (borrowRate * oneMinusReserveFactor) / BASE; + return (utilizationRate(_cash, _borrows, _reserves) * rateToPool) / BASE; + } +} diff --git a/contracts/money-market/RiskManager.sol b/contracts/money-market/RiskManager.sol new file mode 100644 index 0000000..b2c257a --- /dev/null +++ b/contracts/money-market/RiskManager.sol @@ -0,0 +1,758 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +import "./RiskManagerStorage.sol"; +import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import "./interfaces/ITokenBase.sol"; +import "./interfaces/IRiskManager.sol"; +import "./interfaces/IPriceOracle.sol"; +import "@openzeppelin/contracts-upgradeable/token/ERC20/IERC20Upgradeable.sol"; +import "hardhat/console.sol"; + +contract RiskManager is Initializable, RiskManagerStorage, IRiskManager { + function initialize(address _priceOracle) public initializer { + admin = msg.sender; + + maxTier = 3; + discountInterval = 10; + discountIncreaseMantissa = 0.01e18; + + oracle = IPriceOracle(_priceOracle); + } + + modifier onlyAdmin() { + require(msg.sender == admin, "RiskManager: Not authorized to call"); + _; + } + + modifier onlyListed(address _fToken) { + require(markets[_fToken].isListed, "RiskManager: Market is not listed"); + _; + } + + function isRiskManager() public pure returns (bool) { + return IS_RISK_MANAGER; + } + + /** + * @dev Returns the markets an account has entered. + */ + function getMarketsEntered(address _account) external view returns (address[] memory) { + // getAssetsIn + address[] memory entered = marketsEntered[_account]; // accountAssets[] + + return entered; + } + + /** + * @dev Check if the given account has entered in the given asset. + */ + function checkMembership(address _account, address _fToken) external view returns (bool) { + return markets[_fToken].isMember[_account]; + } + + function checkListed(address _fToken) external view returns (bool) { + return markets[_fToken].isListed; + } + + /** + * @dev Add assets to be included in account liquidity calculation + */ + function enterMarkets(address[] memory _fTokens) public override { + uint256 len = _fTokens.length; + + for (uint256 i; i < len; ) { + addToMarketInternal(_fTokens[i], msg.sender); + + unchecked { + ++i; + } + } + } + + /** + * @dev Add the asset for liquidity calculations of borrower + */ + function addToMarketInternal(address _fToken, address _borrower) internal onlyListed(_fToken) { + Market storage marketToJoin = markets[_fToken]; + + if (marketToJoin.isMember[_borrower] == true) { + return; + } + + // survived the gauntlet, add to list + // NOTE: we store these somewhat redundantly as a significant optimization + // this avoids having to iterate through the list for the most common use cases + // that is, only when we need to perform liquidity checks + // and not whenever we want to check if an account is in a particular market + marketToJoin.isMember[_borrower] = true; + marketsEntered[_borrower].push(_fToken); + + emit MarketEntered(_fToken, _borrower); + } + + /** + * @dev Removes asset from sender's account liquidity calculation. + * + * Sender must not have an outstanding borrow balance in the asset, + * or be providing necessary collateral for an outstanding borrow. + */ + function exitMarket(address _fToken) external override { + /// Get fToken balance and amount of underlying asset borrowed + (uint256 tokensHeld, uint256 amountOwed, ) = ITokenBase(_fToken).getAccountSnapshot(msg.sender); + // Fail if the sender has a borrow balance + require(amountOwed == 0, "RiskManager: Borrow balance is not zero"); + + // Fail if the sender is not permitted to redeem all of their tokens + require(redeemAllowed(_fToken, msg.sender, tokensHeld), "RiskManager: Cannot withdraw all tokens"); + + Market storage marketToExit = markets[_fToken]; + + // Already exited market + if (!marketToExit.isMember[msg.sender]) { + return; + } + + // Set fToken membership to false + delete marketToExit.isMember[msg.sender]; + + // Delete fToken from the account’s list of assets + // load into memory for faster iteration + address[] memory assets = marketsEntered[msg.sender]; + uint256 len = assets.length; + uint256 assetIndex; + for (uint256 i; i < len; i++) { + if (assets[i] == _fToken) { + assetIndex = i; + break; + } + } + + // We *must* have found the asset in the list or our redundant data structure is broken + assert(assetIndex < len); + + // Copy last item in list to location of item to be removed, reduce length by 1 + address[] storage storedList = marketsEntered[msg.sender]; + storedList[assetIndex] = storedList[storedList.length - 1]; + storedList.pop(); + + emit MarketExited(_fToken, msg.sender); + } + + /********************************* Admin *********************************/ + + /** + * @notice Begins transfer of admin rights. The newPendingAdmin MUST call + * `acceptAdmin` to finalize the transfer. + * @dev Admin function to begin change of admin. The newPendingAdmin MUST + * call `acceptAdmin` to finalize the transfer. + * @param _newPendingAdmin New pending admin. + */ + function setPendingAdmin(address _newPendingAdmin) external onlyAdmin { + // Save current value, if any, for inclusion in log + address oldPendingAdmin = pendingAdmin; + + // Store pendingAdmin with value newPendingAdmin + pendingAdmin = _newPendingAdmin; + + // Emit NewPendingAdmin(oldPendingAdmin, newPendingAdmin) + emit NewPendingAdmin(oldPendingAdmin, _newPendingAdmin); + } + + /** + * @notice Accepts transfer of admin rights. msg.sender must be pendingAdmin + * @dev Admin function for pending admin to accept role and update admin + */ + function acceptAdmin() external { + // Check caller is pendingAdmin + require(msg.sender == pendingAdmin, "TokenBase: Not pending admin"); + + // Save current values for inclusion in log + address oldAdmin = admin; + address oldPendingAdmin = pendingAdmin; + + // Store admin with value pendingAdmin + admin = pendingAdmin; + + // Clear the pending value + pendingAdmin = address(0); + + emit NewAdmin(oldAdmin, admin); + emit NewPendingAdmin(oldPendingAdmin, pendingAdmin); + } + + /** + * @notice Sets a new price oracle for the comptroller + * @dev Admin function to set a new price oracle + */ + function setPriceOracle(address _newOracle) external onlyAdmin { + // Track the old oracle for the comptroller + address oldOracle = address(oracle); + + // Set comptroller's oracle to newOracle + oracle = IPriceOracle(_newOracle); + + // Emit NewPriceOracle(oldOracle, newOracle) + emit NewPriceOracle(oldOracle, _newOracle); + } + + function setVeToken(address _newVetoken) external onlyAdmin { + veToken = IERC20(_newVetoken); + } + + function setCloseFactor(uint256 _newCloseFactorMantissa) external onlyAdmin { + uint256 oldCloseFactorMantissa = closeFactorMantissa; + closeFactorMantissa = _newCloseFactorMantissa; + + emit NewCloseFactor(oldCloseFactorMantissa, closeFactorMantissa); + } + + /** + * @notice Sets the collateralFactor for a market + * @dev Admin function to set per-market collateralFactor + * @param _fToken The market to set the factor on + * @param _newCollateralFactorMantissa The new collateral factor, scaled by 1e18 + */ + function setCollateralFactor( + address _fToken, + uint256 _newCollateralFactorMantissa + ) external onlyAdmin onlyListed(_fToken) { + Market storage market = markets[_fToken]; + + Exp memory newCollateralFactorExp = Exp({ mantissa: _newCollateralFactorMantissa }); + + // Check collateral factor <= 0.9 + Exp memory limit = Exp({ mantissa: COLLATERAL_FACTOR_MAX_MANTISSA }); + require(lessThanExp(newCollateralFactorExp, limit), "RiskManager: Collateral factor larger than limit"); + + // Fail if price == 0 + (uint256 price, ) = oracle.getUnderlyingPrice(_fToken); + require(price > 0, "RiskManager: Oracle price is 0"); + + // Set market's collateral factor to new collateral factor, remember old value + uint256 oldCollateralFactorMantissa = market.collateralFactorMantissa; + market.collateralFactorMantissa = _newCollateralFactorMantissa; + + // Emit event with asset, old collateral factor, and new collateral factor + emit NewCollateralFactor(_fToken, oldCollateralFactorMantissa, _newCollateralFactorMantissa); + } + + function setTier(address _fToken, uint256 _tier) external onlyAdmin { + require(_tier > 0 && _tier <= maxTier, "RiskManager: Invalid tier"); + + markets[_fToken].tier = _tier; + } + + /** + * @notice Add the market to the markets mapping and set it as listed + * @dev Admin function to set isListed and add support for the market + * @param _fToken The address of the market (token) to list + * @param _tier Tier of the market + */ + function supportMarket(address _fToken, uint256 _collateralFactorMantissa, uint256 _tier) external onlyAdmin { + require(!markets[_fToken].isListed, "RiskManager: Market already listed"); + require(_collateralFactorMantissa <= COLLATERAL_FACTOR_MAX_MANTISSA, "RiskManager: Invalid collateral factor"); + require(_tier <= maxTier, "RiskManager: Invalid tier"); + + ITokenBase(_fToken).isFToken(); // Sanity check to make sure its really a CToken + + Market storage newMarket = markets[_fToken]; + newMarket.isListed = true; + newMarket.collateralFactorMantissa = _collateralFactorMantissa; + newMarket.tier = _tier; + + emit MarketListed(_fToken); + } + + function setSupplyPaused(address _fToken, bool _state) external onlyListed(_fToken) onlyAdmin returns (bool) { + supplyGuardianPaused[_fToken] = _state; + emit ActionPausedMarket(_fToken, "Supply", _state); + return _state; + } + + function setBorrowPaused(address _fToken, bool _state) external onlyListed(_fToken) onlyAdmin returns (bool) { + borrowGuardianPaused[_fToken] = _state; + emit ActionPausedMarket(_fToken, "Borrow", _state); + return _state; + } + + function setTransferPaused(bool _state) external onlyAdmin returns (bool) { + transferGuardianPaused = _state; + emit ActionPausedGlobal("Transfer", _state); + return _state; + } + + function setSeizePaused(bool _state) external onlyAdmin returns (bool) { + seizeGuardianPaused = _state; + emit ActionPausedGlobal("Seize", _state); + return _state; + } + + /********************************* Hooks *********************************/ + + /** + * NOTE: Although the hooks are free to call externally, it is important to + * note that they may not be accurate when called externally by non-Furion + * contracts because accrueInterest() is not called and lastAccrualBlock may + * not be the same as current block number. In other words, market state may + * not be up-to-date. + */ + + /** + * @dev Checks if the account should be allowed to supply tokens in the given market. + */ + function supplyAllowed(address _fToken) external view onlyListed(_fToken) returns (bool) { + // Pausing is a very serious situation - we revert to sound the alarms + require(!supplyGuardianPaused[_fToken], "RiskManager: Supplying is paused"); + + return true; + } + + /** + * @dev Checks if the account should be allowed to redeem fTokens for underlying + * asset in the given market, i.e. check if it will create shortfall / a shortfall + * already exists + * @param _redeemTokens Amount of fTokens used for redemption. + */ + function redeemAllowed( + address _fToken, + address _redeemer, + uint256 _redeemTokens + ) public view onlyListed(_fToken) returns (bool) { + // Can freely redeem if redeemer never entered market, as liquidity calculation is not affected + if (!markets[_fToken].isMember[_redeemer]) { + return true; + } + + // Otherwise, perform a hypothetical liquidity check to guard against shortfall + (, uint256 shortfall, ) = getHypotheticalAccountLiquidity(_redeemer, _fToken, _redeemTokens, 0); + require(shortfall == 0, "RiskManager: Insufficient liquidity"); + + return true; + } + + /** + * @notice Checks if the account should be allowed to borrow the underlying + * asset of the given market. + * @param _fToken The market to verify the borrow against. + * @param _borrower The account which would borrow the asset. + * @param _borrowAmount The amount of underlying the account would borrow. + * + * NOTE: Borrowing is disallowed whenever a bad debt is found, no matter there + * is spare liquidity in other tiers or not because the spare liquidity may be + * used for liquidation (e.g. There may be spare liquidity for cross-tier + + * isolation tier but a shortfall in collateral tier. If liquidation of + * collateral tier collaterals are not enough to cover the debt, cross-tier + * collaterals will also be used). + */ + function borrowAllowed( + address _fToken, + address _borrower, + uint256 _borrowAmount + ) external override onlyListed(_fToken) returns (bool) { + // Pausing is a very serious situation - we revert to sound the alarms + require(!borrowGuardianPaused[_fToken], "RiskManager: Borrow is paused"); + + if (!markets[_fToken].isMember[_borrower]) { + // only fToken contract may call borrowAllowed if borrower not in market + require(msg.sender == _fToken, "RiskManager: Sender must be fToken contract"); + + // attempt to add borrower to the market + addToMarketInternal(_fToken, _borrower); + + // it should be impossible to break the important invariant + assert(markets[_fToken].isMember[_borrower]); + } + + (uint256 price, ) = oracle.getUnderlyingPrice(_fToken); + require(price > 0, "RiskManager: Oracle price is 0"); + + (, uint256 shortfall, ) = getHypotheticalAccountLiquidity(_borrower, _fToken, 0, _borrowAmount); + require(shortfall == 0, "RiskManager: Shortfall created, cannot borrow"); + + /* + uint256 spareLiquidity; + marketTier = markets[_fToken].tier; + + for (uint i = 1; i <= marketTier; ) { + spareLiquidity += liquidities[liquidities.length - i]; + + unchecked { + ++i; + } + } + require(spareLiquidity >= 0); + */ + + return true; + } + + /** + * @notice Checks if the account should be allowed to repay a borrow in the + * given market (if a market is listed) + * @param _fToken The market to verify the repay against + */ + function repayBorrowAllowed(address _fToken) external view onlyListed(_fToken) returns (bool) { + return true; + } + + /** + * @notice Checks if the liquidation should be allowed to occur + * @param _fTokenBorrowed Asset which was borrowed by the borrower + * @param _fTokenCollateral Asset which was used as collateral and will be seized + * @param _borrower The address of the borrower + * @param _repayAmount The amount of underlying being repaid + */ + function liquidateBorrowAllowed( + address _fTokenBorrowed, + address _fTokenCollateral, + address _borrower, + uint256 _repayAmount + ) external view returns (bool) { + uint256 initiationBlockNumber = liquidatableTime[_borrower]; + require(initiationBlockNumber > 0, "RiskManager: Liquidation not yet initiated"); + + require( + markets[_fTokenBorrowed].isListed && markets[_fTokenCollateral].isListed, + "RiskManager: Market is not listed" + ); + + // Stored version used because accrueInterest() has been called at the + // beginning of liquidateBorrowInternal() + uint256 borrowBalance = ITokenBase(_fTokenBorrowed).borrowBalanceCurrent(_borrower); + + (, uint256 shortfall, uint256 highestBorrowTier) = getAccountLiquidity(_borrower); + // The borrower must have shortfall in order to be liquidatable + require(shortfall > 0, "RiskManager: Insufficient shortfall"); + // Cannot liquidate if auction has expired (60 blocks passed since last initiation) + require(initiationBlockNumber + 60 > block.number, "RiskManager: Reset auction required"); + // Liquidation should start from highest tier borrows + // (i.e. first repay collateral tier borrows then cross-tier...) + require( + markets[_fTokenBorrowed].tier == highestBorrowTier, + "RiskManager: Liquidation should start from highest tier" + ); + + // The liquidator may not repay more than what is allowed by the closeFactor + uint256 maxClose = mul_ScalarTruncate(Exp({ mantissa: closeFactorMantissa }), borrowBalance); + require(maxClose > _repayAmount, "RiskManager: Repay too much"); + + return true; + } + + /** + * @notice Checks if the seizing of assets should be allowed to occur + * @param _fTokenCollateral Asset which was used as collateral and will be seized + * @param _fTokenBorrowed Asset which was borrowed by the borrower + * @param _borrower The address of the borrower + */ + function seizeAllowed( + address _fTokenCollateral, + address _fTokenBorrowed, + address _borrower, + uint256 _seizeTokens + ) external view returns (bool allowed, bool isCollateralTier) { + // Pausing is a very serious situation - we revert to sound the alarms + require(!seizeGuardianPaused, "RiskManager: Seize is paused"); + + // Revert if borrower collateral token balance < seizeTokens + require( + IERC20Upgradeable(_fTokenCollateral).balanceOf(_borrower) >= _seizeTokens, + "RiskManager: Seize token amount exceeds collateral" + ); + + require( + markets[_fTokenBorrowed].isListed && markets[_fTokenCollateral].isListed, + "RiskManager: Market is not listed" + ); + + require( + ITokenBase(_fTokenCollateral).getRiskManager() == ITokenBase(_fTokenBorrowed).getRiskManager(), + "RiskManager: Risk manager mismatch" + ); + + allowed = true; + + if (markets[_fTokenCollateral].tier == 1) { + isCollateralTier = true; + } else { + isCollateralTier = false; + } + } + + /** + * @notice Checks if the account should be allowed to transfer tokens in the given market + * @param _fToken The market to verify the transfer against + * @param _src The account which sources the tokens + * @param _amount The number of fTokens to transfer + */ + function transferAllowed(address _fToken, address _src, uint256 _amount) external view returns (bool) { + // Pausing is a very serious situation - we revert to sound the alarms + require(!transferGuardianPaused, "transfer is paused"); + + // Currently the only consideration is whether or not + // the src is allowed to redeem this many tokens + require(redeemAllowed(_fToken, _src, _amount), "RiskManager: Source not allowed to redeem that much fTokens"); + + return true; + } + + /****************************** Liquidation *******************************/ + + /** + * @notice Mark an account as liquidatable, start dutch-auction. + * @param _account Address of account to be marked liquidatable. + * + * NOTE: Auction has to be reset if it didn't close within 60 blocks after + * initiation, either because shortfall is not cleared or there are price + * changes that cleared the shortfall which makes the account no longer + * subject to liquidation. + */ + function initiateLiquidation(address _account) external { + uint256 initiationBlockNumber = liquidatableTime[_account]; + // Either never initiated or auction has to be reset + require( + initiationBlockNumber == 0 || block.number > initiationBlockNumber + 60, + "RiskManager: Already initiated liquidation" + ); + + (, uint256 shortfall, ) = getAccountLiquidity(_account); + // The borrower must have shortfall in order to be liquidatable + require(shortfall > 0, "RiskManager: Insufficient shortfall"); + + liquidatableTime[_account] = block.number; + } + + /** + * @notice Account no longer susceptible to liquidation + * @param _account Address of account to reset tracker + * + * NOTE: The modifier checks if function caller is fToken contract. Only listed + * fTokens will have isLited set as true. + */ + function closeLiquidation(address _account) external onlyListed(msg.sender) { + (, uint256 shortfall, ) = getAccountLiquidity(_account); + + // Reset tracker only if there are no more bad debts + if (shortfall == 0) { + delete liquidatableTime[_account]; + } + } + + function collateralFactorBoost(address _account) public view returns (uint256 boostMantissa) { + uint256 veBalance = veToken.balanceOf(_account); + // How many 0.1% the collateral factor will be increased by. + // Result is rounded down by default which is fine + uint256 multiplier = veBalance / COLLATERAL_FACTOR_BOOST_REQUIRED_TOKEN; + + boostMantissa = COLLATERAL_FACTOR_BOOST_INCREASE_MANTISSA * multiplier; + + if (boostMantissa > COLLATERAL_FACTOR_MAX_BOOST_MANTISSA) { + boostMantissa = COLLATERAL_FACTOR_MAX_BOOST_MANTISSA; + } + } + + /** + * @notice Determine the current account liquidity wrt collateral requirements + * @return liquidities Hypothetical spare liquidity for each asset tier from low to high + * @return shortfall Account shortfall below collateral requirements + */ + function getAccountLiquidity( + address _account + ) public view returns (uint256[] memory liquidities, uint256 shortfall, uint256 highestBorrowTier) { + // address(0) -> no iteractions with market + (liquidities, shortfall, highestBorrowTier) = getHypotheticalAccountLiquidity(_account, address(0), 0, 0); + } + + /** + * @notice Determine what the account liquidity would be if the given amounts + * were redeemed/borrowed + * @param _account The account to determine liquidity for + * @param _fToken The market to hypothetically redeem/borrow in + * @param _redeemToken The number of fTokens to hypothetically redeem + * @param _borrowAmount The amount of underlying to hypothetically borrow + * @dev Note that we calculate the exchangeRateStored for each collateral + * cToken using stored data, without calculating accumulated interest. + * @return liquidities Hypothetical spare liquidity for each asset tier from low to high + * @return shortfall Hypothetical account shortfall below collateral requirements + * + * NOTE: liquidities return sequence [tier 1 liquidity, tier 2 liquidity, + * tier 3 liquidity] + */ + function getHypotheticalAccountLiquidity( + address _account, + address _fToken, + uint256 _redeemToken, + uint256 _borrowAmount + ) public view returns (uint256[] memory liquidities, uint256 shortfall, uint256 highestBorrowTier) { + // First assume highest collateral tier is isolation tier, because if + // left uninitialized, it will remain to be the invalid 0 tier + highestBorrowTier = 3; + + // Holds all our calculation results, see { RiskManagerStorage } + AccountLiquidityLocalVars memory vars; + + vars.maxTierMem = maxTier; + + uint256[] memory tierCollateralValues = new uint256[](vars.maxTierMem); + uint256[] memory tierBorrowValues = new uint256[](vars.maxTierMem); + liquidities = new uint256[](vars.maxTierMem); + + // For each asset the account is in + // Loop through to calculate colalteral and borrow values for each tier + address[] memory assets = marketsEntered[_account]; + for (uint256 i; i < assets.length; ) { + vars.asset = assets[i]; + vars.assetTier = markets[vars.asset].tier; + + // Read the balances and exchange rate from the asset (market) + (vars.tokenBalance, vars.borrowBalance, vars.exchangeRateMantissa) = ITokenBase(vars.asset) + .getAccountSnapshot(_account); + + // If account borrowed in asset market, and has higher tier than the + // current highestBorrowTier + if (vars.borrowBalance > 0 && vars.assetTier < highestBorrowTier) { + highestBorrowTier = vars.assetTier; + } + + vars.collateralFactor = Exp({ + mantissa: markets[vars.asset].collateralFactorMantissa + collateralFactorBoost(_account) + }); + + vars.exchangeRate = Exp({ mantissa: vars.exchangeRateMantissa }); + + // Get the normalized price of the underlying asset of fToken + (vars.oraclePriceMantissa, vars.decimal) = oracle.getUnderlyingPrice(vars.asset); + require(vars.oraclePriceMantissa > 0, "RiskManager: Oracle price is 0"); + vars.oraclePrice = Exp({ mantissa: vars.oraclePriceMantissa }); + + // Pre-compute a conversion factor from tokens -> ether (normalized price value) + vars.collateralValuePerToken = mul_(mul_(vars.oraclePrice, vars.exchangeRate), vars.collateralFactor); + + // Divide by decimal of underlying token because we want the price in mantissa, not in decimals of + // underlying asset + tierCollateralValues[vars.assetTier - 1] += + (vars.tokenBalance * vars.collateralValuePerToken.mantissa) / + vars.decimal; + + tierBorrowValues[vars.assetTier - 1] += (vars.borrowBalance * vars.oraclePriceMantissa) / vars.decimal; + + // Calculate effects of interacting with fToken + if (vars.asset == _fToken) { + // Redeem effect + // Collateral reduced after redemption + tierCollateralValues[vars.assetTier - 1] -= + (_redeemToken * vars.collateralValuePerToken.mantissa) / + vars.decimal; + + // Add amount to hypothetically borrow + // Borrow increased after borrowing + tierBorrowValues[vars.assetTier - 1] += (_borrowAmount * vars.oraclePriceMantissa) / vars.decimal; + } + + unchecked { + ++i; + } + } + + // In most cases, borrowers would prefer to back borrows with the lowest + // tier assets possible (i.e. isolation tier collateral -> isolation tier + // borrow, instead of collateral tier collateral -> isolation tier borrow). + // Therefore, we calculate starting from lowest tier (i.e. highest tier number). + // + // e.g. First iteration (accumulatedShortfall = 0): + // isolation tier collateral > isolation tier borrow, push difference + // to liquidities array; isolation collateral < isolation tier borrow, + // add difference to `accumulatedShortfall` and see if higher tier collaterals + // can back all borrows. + // Second iteration (Assume accumulatedShortfall > 0): + // cross-tier collateral > cross-tier borrow + accumulatedShortfall, push difference + // to liquidities array; cross-tier collateral < cross-tier borrow + accumulatedShortfall, + // accumulate tier shortfall to accumulatedShortfall and see if there are enough + // collateral tier collateral to back the total shortfall + for (uint256 i = vars.maxTierMem; i > 0; ) { + vars.collateral = tierCollateralValues[i - 1]; + vars.threshold = tierBorrowValues[i - 1] + vars.accumulatedShortfall; + + if (vars.collateral >= vars.threshold) { + liquidities[i - 1] = vars.collateral - vars.threshold; + vars.accumulatedShortfall = 0; + } else { + vars.accumulatedShortfall = vars.threshold - vars.collateral; + liquidities[i - 1] = 0; + } + + unchecked { + --i; + } + } + + // Return value + shortfall = vars.accumulatedShortfall; + } + + /** + * @notice Calculate number of tokens of collateral asset to seize given an underlying amount + * @dev Used in liquidation (called in fToken.liquidateBorrowInternal) + * @param _fTokenBorrowed The address of the borrowed cToken + * @param _fTokenCollateral The address of the collateral cToken + * @param _repayAmount The amount of fTokenBorrowed underlying to convert into fTokenCollateral tokens + * @return seizeTokens Number of fTokenCollateral tokens to be seized in a liquidation + */ + function liquidateCalculateSeizeTokens( + address _borrower, + address _fTokenBorrowed, + address _fTokenCollateral, + uint256 _repayAmount + ) external view override returns (uint256 seizeTokens, uint256 repayValue) { + // Read oracle prices for borrowed and collateral markets + (uint256 priceBorrowedMantissa, uint256 borrowedDecimal) = oracle.getUnderlyingPrice(_fTokenBorrowed); + (uint256 priceCollateralMantissa, ) = oracle.getUnderlyingPrice(_fTokenCollateral); + require(priceBorrowedMantissa > 0 && priceCollateralMantissa > 0, "RiskManager: Oracle price is 0"); + + /** + * Get the exchange rate and calculate the number of collateral tokens to seize: + * seizeAmount = actualRepayAmount * liquidationIncentive * priceBorrowed / priceCollateral + * seizeTokens = seizeAmount / exchangeRate + * = actualRepayAmount * (liquidationIncentive * priceBorrowed) / (priceCollateral * exchangeRate) + */ + uint256 amountAfterDiscount = mul_ScalarTruncate( + Exp({ mantissa: liquidateCalculateDiscount(_borrower) }), + _repayAmount + ); + uint256 valueAfterDiscount = (priceBorrowedMantissa * amountAfterDiscount) / borrowedDecimal; + + // Stored version used because accrueInterest() already called at the + // beginning of liquidateBorrowInternal() + uint256 collateralExchangeRateMantissa = ITokenBase(_fTokenCollateral).exchangeRateCurrent(); // Note: reverts on error + + // (value / underyling) * exchangeRate + // = (value /underlying) * (underlying / token) + // = value per token + Exp memory valuePerToken = mul_( + Exp({ mantissa: priceCollateralMantissa }), + Exp({ mantissa: collateralExchangeRateMantissa }) + ); + + // div_: uint, exp -> uint + seizeTokens = div_(valueAfterDiscount, valuePerToken); + repayValue = (priceBorrowedMantissa * _repayAmount) / borrowedDecimal; + } + + /** + * @notice Get the discount for liquidating borrower at current moment + * @param _borrower The account getting liquidated + */ + function liquidateCalculateDiscount(address _borrower) public view returns (uint256 discountMantissa) { + uint256 startBlock = liquidatableTime[_borrower]; + uint256 currentBlock = block.number; + // Solidity rounds down result by default, which is fine + uint256 discountIntervalPassed = (currentBlock - startBlock) / discountInterval; + + discountMantissa = LIQUIDATION_INCENTIVE_MIN_MANTISSA + discountIncreaseMantissa * discountIntervalPassed; + if (discountMantissa > LIQUIDATION_INCENTIVE_MAX_MANTISSA) { + discountMantissa = LIQUIDATION_INCENTIVE_MAX_MANTISSA; + } + } +} diff --git a/contracts/money-market/RiskManagerStorage.sol b/contracts/money-market/RiskManagerStorage.sol new file mode 100644 index 0000000..e27e984 --- /dev/null +++ b/contracts/money-market/RiskManagerStorage.sol @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +import "./ExponentialNoError.sol"; +import "./interfaces/IPriceOracle.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +contract RiskManagerStorage is ExponentialNoError { + bool public constant IS_RISK_MANAGER = true; + + // closeFactorMantissa must be strictly greater than this value + uint256 internal constant CLOSE_FACTOR_MIN_MANTISSA = 5e16; // 5% + + // closeFactorMantissa must not exceed this value + uint256 internal constant CLOSE_FACTOR_MAX_MANTISSA = 9e17; // 90% + + // No collateralFactorMantissa may exceed this value + uint256 internal constant COLLATERAL_FACTOR_MAX_MANTISSA = 9e17; // 90% + + uint256 internal constant COLLATERAL_FACTOR_MAX_BOOST_MANTISSA = 2.5e16; // 2.5% + + uint256 internal constant COLLATERAL_FACTOR_BOOST_INCREASE_MANTISSA = 1e15; // 0.1% + + uint256 internal constant COLLATERAL_FACTOR_BOOST_REQUIRED_TOKEN = 1000000e18; // 1000000 veFUR + + uint256 internal constant LIQUIDATION_INCENTIVE_MIN_MANTISSA = 1.05e18; // 105% + + uint256 internal constant LIQUIDATION_INCENTIVE_MAX_MANTISSA = 1.1e18; // 110% + + /// @notice Administrator for this contract + address public admin; + + /// @notice Pending administrator for this contract + address public pendingAdmin; + + IERC20 public veToken; + + /// @notice Oracle which gives the price of underlying assets + IPriceOracle public oracle; + + uint256 public closeFactorMantissa; + + /// @notice List of assets an account has entered, capped by maxAssets + mapping(address => address[]) public marketsEntered; + + struct Market { + // Whether or not this market is listed + bool isListed; + // Must be between 0 and 1, and stored as a mantissa + // For instance, 0.9 to allow borrowing 90% of collateral value + uint256 collateralFactorMantissa; + // Whether or not an account is entered in this market + mapping(address => bool) isMember; + /** + * @notice Tiers: 1 - collateral, 2 - cross-tier, 3 - isolation + * + * Isolation assets can only be colalteral for isolation assets. + * Cross-tier assets can be colalteral for cross-tier and isolation assets. + * Collateral assets can be collateral for all assets. + * + * NOTE: The smaller the number, the higher the tier. This is because + * lower tier assets may be added in the future. + */ + uint256 tier; + } + + /** + * @notice Mapping of fTokens -> Market metadata + * @dev Used e.g. to determine if a market is supported + */ + mapping(address => Market) public markets; + + // Largest tier number that markets can have, i.e. number for worst tier + uint256 maxTier; + + /** + * @notice The Pause Guardian can pause certain actions as a safety mechanism. + * + * Actions which allow users to remove their own assets cannot be paused. + * Liquidation / seizing / transfer can only be paused globally, not by market. + */ + address public pauseGuardian; + bool public _supplyGuardianPaused; + bool public _borrowGuardianPaused; + bool public transferGuardianPaused; + bool public seizeGuardianPaused; + mapping(address => bool) public supplyGuardianPaused; + mapping(address => bool) public borrowGuardianPaused; + + /** + * @notice Mapping of account -> time when account became liquidatable + * @dev Records the block number when liquidation starts. Used for calculating + * liquidation discount rate. + */ + mapping(address => uint256) public liquidatableTime; + + // After how many blocks will discount rate increase + uint256 discountInterval; + + // By how much discount rate increases each time + uint256 discountIncreaseMantissa; + + /** + * @dev Local vars for avoiding stack-depth limits in calculating account liquidity. + * + * Note: `tokenBalance` is the number of fTokens the account owns in the market, + * `borrowBalance` is the amount of underlying that the account has borrowed. + */ + struct AccountLiquidityLocalVars { + uint256 maxTierMem; + address asset; + uint256 assetTier; + uint256 decimal; + uint256 tokenBalance; + uint256 borrowBalance; + uint256 exchangeRateMantissa; + uint256 oraclePriceMantissa; + uint256 collateral; + uint256 threshold; + uint256 accumulatedShortfall; + Exp collateralFactor; + Exp exchangeRate; + Exp oraclePrice; + Exp collateralValuePerToken; + } +} diff --git a/contracts/money-market/SimplePriceOracle.sol b/contracts/money-market/SimplePriceOracle.sol new file mode 100644 index 0000000..5c2be1f --- /dev/null +++ b/contracts/money-market/SimplePriceOracle.sol @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +import "./interfaces/IPriceOracle.sol"; +import "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/IERC20MetadataUpgradeable.sol"; +import "./interfaces/IFErc20.sol"; + +contract SimplePriceOracle is IPriceOracle { + mapping(address => uint256) prices; + // e.g. A token with 18 decimals, decimals[asset] = 1e18, NOT 18 + mapping(address => uint256) decimals; + event PricePosted( + address asset, + uint256 previousPriceMantissa, + uint256 requestedPriceMantissa, + uint256 newPriceMantissa + ); + + function _getUnderlyingAddress(address _fToken) private view returns (address asset) { + if (compareStrings(IERC20MetadataUpgradeable(_fToken).symbol(), "fETH")) { + asset = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; + } else { + asset = IFErc20(_fToken).getUnderlying(); + } + } + + // Price of 1, not 1e18, underlying token in terms of ETH (mantissa) + function getUnderlyingPrice(address _fToken) public view override returns (uint256, uint256) { + address asset = _getUnderlyingAddress(_fToken); + return (prices[asset], decimals[asset]); + } + + function setUnderlyingPrice(address _fToken, uint256 _underlyingPriceMantissa, uint256 _decimal) public { + address asset = _getUnderlyingAddress(_fToken); + emit PricePosted(asset, prices[asset], _underlyingPriceMantissa, _underlyingPriceMantissa); + prices[asset] = _underlyingPriceMantissa; + decimals[asset] = _decimal; + } + + function setDirectPrice(address _asset, uint256 _price, uint256 _decimal) public { + emit PricePosted(_asset, prices[_asset], _price, _price); + prices[_asset] = _price; + decimals[_asset] = _decimal; + } + + // v1 price oracle interface for use as backing of proxy + function assetPrices(address _asset) external view returns (uint256, uint256) { + return (prices[_asset], decimals[_asset]); + } + + function compareStrings(string memory _a, string memory _b) internal pure returns (bool) { + return (keccak256(abi.encodePacked((_a))) == keccak256(abi.encodePacked((_b)))); + } +} diff --git a/contracts/money-market/TokenBase.sol b/contracts/money-market/TokenBase.sol new file mode 100644 index 0000000..82186ee --- /dev/null +++ b/contracts/money-market/TokenBase.sol @@ -0,0 +1,822 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +import "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/draft-ERC20PermitUpgradeable.sol"; +import "./TokenStorages.sol"; +import "./interfaces/ITokenBase.sol"; +import "./interfaces/IRiskManager.sol"; +import "./interfaces/IInterestRateModel.sol"; +import "./interfaces/IPriceOracle.sol"; +import "./interfaces/IFErc20.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "../IChecker.sol"; +import "hardhat/console.sol"; + +abstract contract TokenBase is ERC20PermitUpgradeable, TokenBaseStorage, ITokenBase { + function __TokenBase_init( + address _riskManager, + address _interestRateModel, + address _priceOracle, + address _checker, + string memory _name, + string memory _symbol + ) internal onlyInitializing { + __ERC20Permit_init(_name); + __ERC20_init(_name, _symbol); + + require(IRiskManager(_riskManager).isRiskManager(), "TokenBase: Not risk manager contract"); + riskManager = IRiskManager(_riskManager); + + lastAccrualBlock = block.number; + borrowIndex = 1e18; + + require( + IInterestRateModel(_interestRateModel).isInterestRateModel(), + "TokenBase: Not interst rate model contract" + ); + interestRateModel = IInterestRateModel(_interestRateModel); + + oracle = IPriceOracle(_priceOracle); + + checker = IChecker(_checker); + + initialExchangeRateMantissa = 50e18; + + admin = msg.sender; + } + + modifier onlyAdmin() { + require(msg.sender == admin, "Not authorized to call"); + _; + } + + /********************************* Admin **********************************/ + + /** + * @notice Begins transfer of admin rights. The newPendingAdmin MUST call + * `acceptAdmin` to finalize the transfer. + * @dev Admin function to begin change of admin. The newPendingAdmin MUST + * call `acceptAdmin` to finalize the transfer. + * @param _newPendingAdmin New pending admin. + */ + function setPendingAdmin(address _newPendingAdmin) external override onlyAdmin { + // Save current value, if any, for inclusion in log + address oldPendingAdmin = pendingAdmin; + + // Store pendingAdmin with value newPendingAdmin + pendingAdmin = _newPendingAdmin; + + // Emit NewPendingAdmin(oldPendingAdmin, newPendingAdmin) + emit NewPendingAdmin(oldPendingAdmin, _newPendingAdmin); + } + + /** + * @notice Accepts transfer of admin rights. msg.sender must be pendingAdmin + * @dev Admin function for pending admin to accept role and update admin + */ + function acceptAdmin() external override { + // Check caller is pendingAdmin + require(msg.sender == pendingAdmin, "TokenBase: Not pending admin"); + + // Save current values for inclusion in log + address oldAdmin = admin; + address oldPendingAdmin = pendingAdmin; + + // Store admin with value pendingAdmin + admin = pendingAdmin; + + // Clear the pending value + pendingAdmin = address(0); + + emit NewAdmin(oldAdmin, admin); + emit NewPendingAdmin(oldPendingAdmin, pendingAdmin); + } + + function setReserveFactor(uint256 _newReserveFactorMantissa) external onlyAdmin { + (uint256 borrows, uint256 reserves, uint256 index) = accrueInfo(); + accrueInterest(index); + totalBorrows = borrows; + totalReserves = reserves; + + require(_newReserveFactorMantissa < RESERVE_FACTOR_MAX_MANTISSA, "TokenBase: Invalid reserve factor"); + + uint256 oldReserveFactorMantissa = reserveFactorMantissa; + reserveFactorMantissa = _newReserveFactorMantissa; + + emit NewReserveFactor(oldReserveFactorMantissa, _newReserveFactorMantissa); + } + + function setPriceOracle(address _newOracle) external onlyAdmin { + address oldOracle = address(oracle); + + oracle = IPriceOracle(_newOracle); + + emit NewPriceOracle(oldOracle, _newOracle); + } + + /********************************** Core **********************************/ + + function isFToken() public pure returns (bool) { + return IS_FTOKEN; + } + + function getLastAccrualBlock() public view returns (uint256) { + return lastAccrualBlock; + } + + function getRiskManager() public view returns (address) { + return address(riskManager); + } + + /** + * @notice Get the underlying balance + * @dev This also accrues interest in a transaction + * @param _account The address of the account to query + * @return The amount of underlying underlying asset + */ + function balanceOfUnderlying(address _account) public view returns (uint256) { + Exp memory exchangeRate = Exp({ mantissa: exchangeRateCurrent() }); + return mul_ScalarTruncate(exchangeRate, balanceOf(_account)); + } + + /** + * @notice Get a snapshot of the account's balances, and the cached exchange rate + * @dev This is used by comptroller to more efficiently perform liquidity checks. + * @param _account Address of the account to snapshot + * @return (token balance, borrow balance, exchange rate mantissa) + */ + function getAccountSnapshot(address _account) external view returns (uint256, uint256, uint256) { + (uint256 borrows, uint256 reserves, uint256 index) = accrueInfo(); + + return (balanceOf(_account), borrowBalanceCalc(_account, index), exchangeRateCalc(borrows, reserves)); + } + + /** + * @notice Returns the current per-block borrow interest rate for this cToken + * @return The borrow interest rate per block, scaled by 1e18 + */ + function borrowRatePerBlock() external view override returns (uint256) { + (uint256 borrows, uint256 reserves, ) = accrueInfo(); + + return interestRateModel.getBorrowRate(totalCash, borrows, reserves); + } + + /** + * @notice Returns the current per-block supply interest rate for this cToken + * @return The supply interest rate per block, scaled by 1e18 + */ + function supplyRatePerBlock() external view override returns (uint256) { + (uint256 borrows, uint256 reserves, ) = accrueInfo(); + + return interestRateModel.getSupplyRate(totalCash, borrows, reserves, reserveFactorMantissa); + } + + function borrowIndexCurrent() public view returns (uint256) { + (, , uint256 index) = accrueInfo(); + + return index; + } + + /** + * @notice Returns the current total borrows plus accrued interest + * @return The total borrows with interest + */ + function totalBorrowsCurrent() public view returns (uint256) { + (uint256 borrows, , ) = accrueInfo(); + + return borrows; + } + + function borrowBalanceCurrent(address _account) public view returns (uint256) { + (, , uint256 index) = accrueInfo(); + + // Get borrowBalance and borrowIndex + BorrowSnapshot memory snapshot = accountBorrows[_account]; + + /* If borrowBalance = 0 then borrowIndex is likely also 0. + * Rather than failing the calculation with a division by 0, we immediately + * return 0 in this case. + */ + if (snapshot.principal == 0) { + return 0; + } + + /* Calculate new borrow balance using the interest index: + * principal * how much borrowIndex has increased + */ + return (snapshot.principal * index) / snapshot.interestIndex; + } + + /** + * @notice Return the borrow balance of account based on stored data + * @param _account The address whose balance should be calculated + * @return The calculated balance + * + * NOTE: Despite being free to call, it may not be accurate when called externally + * by non-Furion contracts because lastAccrualBlock will not be equal to current + * block number provided that accrueInterest() is not called beforehand, meaning + * that market is not up-to-date when the function is called. Call 'current' version + * functions for accurate results. + */ + function borrowBalanceCalc(address _account, uint256 _index) internal view returns (uint256) { + // Get borrowBalance and borrowIndex + BorrowSnapshot memory snapshot = accountBorrows[_account]; + + /* If borrowBalance = 0 then borrowIndex is likely also 0. + * Rather than failing the calculation with a division by 0, we immediately + * return 0 in this case. + */ + if (snapshot.principal == 0) { + return 0; + } + + /* Calculate new borrow balance using the interest index: + * principal * how much borrowIndex has increased + */ + return (snapshot.principal * _index) / snapshot.interestIndex; + } + + /** + * @notice Exchange rate of current block + */ + function exchangeRateCurrent() public view returns (uint256) { + (uint256 borrows, uint256 reserves, ) = accrueInfo(); + + uint256 _totalSupply = totalSupply(); + if (_totalSupply == 0) { + /* + * If there are no tokens minted: + * exchangeRate = initialExchangeRate + */ + return initialExchangeRateMantissa; + } else { + /* + * Otherwise: + * exchangeRate = (totalCash + totalBorrows - totalReserves) / totalSupply + */ + uint256 cashPlusBorrowsMinusReserves = totalCash + borrows - reserves; + uint256 exchangeRate = (cashPlusBorrowsMinusReserves * expScale) / _totalSupply; + + return exchangeRate; + } + } + + /** + * @notice Calculated exchange rate based on given params + */ + function exchangeRateCalc(uint256 _borrows, uint256 _reserves) internal view returns (uint256) { + uint256 _totalSupply = totalSupply(); + if (_totalSupply == 0) { + /* + * If there are no tokens minted: + * exchangeRate = initialExchangeRate + */ + return initialExchangeRateMantissa; + } else { + /* + * Otherwise: + * exchangeRate = (totalCash + totalBorrows - totalReserves) / totalSupply + */ + uint256 cashPlusBorrowsMinusReserves = totalCash + _borrows - _reserves; + uint256 exchangeRate = (cashPlusBorrowsMinusReserves * expScale) / _totalSupply; + + return exchangeRate; + } + } + + /** + * @notice CALCULATE market info of current block + * @return New total borrow, new total reserve, new borrow index + */ + function accrueInfo() internal view returns (uint256, uint256, uint256) { + uint256 borrows = totalBorrows; + uint256 reserves = totalReserves; + uint256 index = borrowIndex; + uint256 blockDelta = block.number - lastAccrualBlock; + + if (blockDelta > 0) { + uint256 borrowsPrior = borrows; + uint256 reservesPrior = reserves; + uint256 indexPrior = index; + + // Calculate the current borrow interest rate + uint256 borrowRatePerBlockMantissa = interestRateModel.getBorrowRate( + totalCash, + borrowsPrior, + reservesPrior + ); + require(borrowRatePerBlockMantissa <= BORROW_RATE_MAX_MANTISSA, "TokenBase: Borrow rate is absurdly high"); + + /* + * Calculate the interest accumulated into borrows and reserves and the new index: + * simpleInterestFactor = borrowRate * blockDelta + * interestAccumulated = simpleInterestFactor * totalBorrows + * totalBorrowsNew = interestAccumulated + totalBorrows + * totalReservesNew = interestAccumulated * reserveFactor + totalReserves + * borrowIndexNew = simpleInterestFactor * borrowIndex + borrowIndex + */ + + Exp memory simpleInterestFactor = mul_(Exp({ mantissa: borrowRatePerBlockMantissa }), blockDelta); + index = mul_ScalarTruncateAddUInt(simpleInterestFactor, indexPrior, indexPrior); + + // Need to use the same method used to calculate borrow balance (i.e. multiply + // by how much borrowIndex increased) of an account to prevent mismatch due + // to roudings during arithmetic operations + borrows = (borrows * index) / indexPrior; + uint256 interestAccumulated = borrows - borrowsPrior; + reserves = mul_ScalarTruncateAddUInt( + Exp({ mantissa: reserveFactorMantissa }), + interestAccumulated, + reservesPrior + ); + } + + return (borrows, reserves, index); + } + + /** + * @notice Update borrow index to keep track of interest accumulation + * @dev Called in all market actions. Borrows and reserves are updated separately + * only when borrowing / repaying to save gas + */ + function accrueInterest(uint256 _borrowIndex) internal { + if (lastAccrualBlock == block.number) { + return; + } + + // We write the calculated values into storage + borrowIndex = _borrowIndex; + lastAccrualBlock = block.number; + } + + /** + * @notice User supplies assets into the market and receives cTokens in exchange + * @dev Assumes interest has already been accrued up to the current block + * @param _supplyAmount The amount of the underlying asset to supply + */ + function supplyInternal(address _supplier, uint256 _supplyAmount) internal { + (uint256 borrows, uint256 reserves, uint256 index) = accrueInfo(); + accrueInterest(index); + + require(riskManager.supplyAllowed(address(this)), "TokenBase: Supply disallowed by risk manager"); + + // Get current exchange rate + Exp memory exchangeRate = Exp({ mantissa: exchangeRateCalc(borrows, reserves) }); + + /* + * We call `doTransferIn` giving the supplier and the supplyAmount. + * Note: The fToken must handle variations between ERC-20 and ETH underlying. + * `doTransferIn` reverts if anything goes wrong, since we can't be sure if + * side-effects occurred. On success, the fToken (market) holds + * an additional `_supplyAmount` of cash. + */ + doTransferIn(_supplier, _supplyAmount); + + // We get the current exchange rate and calculate the number of cTokens to be minted */ + uint256 mintTokens = div_(_supplyAmount, exchangeRate); + _mint(_supplier, mintTokens); + + /* We emit a Supply event, and a Transfer event */ + emit Supply(_supplier, _supplyAmount, mintTokens); + } + + /** + * @notice User redeems cTokens in exchange for the underlying asset + * @dev Assumes interest has already been accrued up to the current block + * @param _redeemer The address of the account which is redeeming the tokens + * @param _redeemTokens The number of fTokens to redeem into underlying + * @param _redeemAmount The number of underlying tokens to receive from redeeming fTokens + */ + function redeemInternal(address _redeemer, uint256 _redeemTokens, uint256 _redeemAmount) internal { + (uint256 borrows, uint256 reserves, uint256 index) = accrueInfo(); + accrueInterest(index); + + require( + _redeemTokens == 0 || _redeemAmount == 0, + "TokenBase: One of redeemTokens or redeemAmount must be zero" + ); + + // Get current exchange rate + Exp memory exchangeRate = Exp({ mantissa: exchangeRateCalc(borrows, reserves) }); + + uint256 redeemTokens; + uint256 redeemAmount; + // Calculate amount that can be redeemed given tokens supplied OR + // tokens needed for redeeming the given amount of underlying asset + if (_redeemTokens > 0) { + redeemTokens = _redeemTokens; + redeemAmount = mul_ScalarTruncate(exchangeRate, _redeemTokens); + } else { + redeemTokens = div_(_redeemAmount, exchangeRate); + redeemAmount = _redeemAmount; + } + + require( + riskManager.redeemAllowed(address(this), _redeemer, redeemTokens), + "TokenBase: Redeem disallowed by risk manager" + ); + // Fail gracefully if protocol has insufficient cash + require(totalCash > redeemAmount, "TokenBase: Market has insufficient cash"); + + ///////////////////////// + // EFFECTS & INTERACTIONS + // (No safe failures beyond this point) + + _burn(_redeemer, redeemTokens); + + /* + * We invoke doTransferOut for the redeemer and the redeemAmount. + * Note: The cToken must handle variations between ERC-20 and ETH underlying. + * On success, the cToken has redeemAmount less of cash. + * doTransferOut reverts if anything goes wrong, since we can't be sure if side effects occurred. + */ + doTransferOut(payable(_redeemer), redeemAmount); + + emit Redeem(_redeemer, redeemAmount, redeemTokens); + } + + /** + * @notice Users borrow assets from the protocol to their own address + * @param _borrowAmount The amount of the underlying asset to borrow + */ + function borrowInternal(address _borrower, uint256 _borrowAmount) internal { + (uint256 borrows, , uint256 index) = accrueInfo(); + accrueInterest(index); + + require( + riskManager.borrowAllowed(address(this), _borrower, _borrowAmount), + "TokenBase: Borrow disallowed by risk manager" + ); + // Fail gracefully if protocol has insufficient cash + require(totalCash > _borrowAmount, "TokenBase: Market has insufficient cash"); + + // We calculate the new borrower and total borrow balances, failing on overflow + uint256 borrowBalanceNew = borrowBalanceCalc(_borrower, index) + _borrowAmount; + uint256 totalBorrowsNew = borrows + _borrowAmount; + + ///////////////////////// + // EFFECTS & INTERACTIONS + // (No safe failures beyond this point) + + /* + * We write the previously calculated values into storage. + * Note: Avoid token reentrancy attacks by writing increased borrow before external transfer. + `*/ + accountBorrows[_borrower].principal = borrowBalanceNew; + accountBorrows[_borrower].interestIndex = index; + totalBorrows = totalBorrowsNew; + + /* + * We invoke doTransferOut for the borrower and the borrowAmount. + * Note: The cToken must handle variations between ERC-20 and ETH underlying. + * On success, the cToken borrowAmount less of cash. + * doTransferOut reverts if anything goes wrong, since we can't be sure if side effects occurred. + */ + doTransferOut(payable(_borrower), _borrowAmount); + + /* We emit a Borrow event */ + emit Borrow(_borrower, _borrowAmount, borrowBalanceNew, totalBorrowsNew); + } + + /** + * @notice Borrows are repaid by another user (possibly the borrower). + * @param _payer the account paying off the borrow + * @param _borrower the account with the debt being payed off + * @param _repayAmount the amount of underlying tokens being returned, or -1 for the full outstanding amount + */ + function repayBorrowInternal(address _payer, address _borrower, uint256 _repayAmount) internal { + (uint256 borrows, , uint256 index) = accrueInfo(); + accrueInterest(index); + + require(riskManager.repayBorrowAllowed(address(this)), "TokenBase: Repay disallowed by risk manager"); + + // We fetch the amount the borrower owes, with accumulated interest + uint256 borrowBalancePrev = borrowBalanceCalc(_borrower, index); + + // If repayAmount == max value of uint256, repay total amount owed, + // else repay given amount + uint256 actualRepayAmount = _repayAmount == type(uint256).max ? borrowBalancePrev : _repayAmount; + + /* + * We call doTransferIn for the payer and the repayAmount + * Note: The cToken must handle variations between ERC-20 and ETH underlying. + * On success, the cToken holds an additional repayAmount of cash. + * doTransferIn reverts if anything goes wrong, since we can't be sure if side effects occurred. + * it returns the amount actually transferred, in case of a fee. + */ + doTransferIn(_payer, actualRepayAmount); + + /* + * We calculate the new borrower and total borrow balances, failing on underflow: + * accountBorrowsNew = accountBorrows - actualRepayAmount + * totalBorrowsNew = totalBorrows - actualRepayAmount + */ + uint256 borrowBalanceNew = actualRepayAmount > borrowBalancePrev ? 0 : borrowBalancePrev - actualRepayAmount; + uint256 totalBorrowsNew = actualRepayAmount > borrows ? 0 : borrows - actualRepayAmount; + + /* We write the previously calculated values into storage */ + accountBorrows[_borrower].principal = borrowBalanceNew; + accountBorrows[_borrower].interestIndex = index; + totalBorrows = totalBorrowsNew; + + /* We emit a RepayBorrow event */ + emit RepayBorrow(_payer, _borrower, actualRepayAmount, borrowBalanceNew, totalBorrowsNew); + } + + /** + * @notice The liquidator liquidates the borrower's collateral. + * The collateral seized is transferred to the liquidator. + * @param _borrower The borrower of this fToken to be liquidated + * @param _liquidator The address repaying the borrow and seizing collateral + * @param _repayAmount The amount of the underlying borrowed asset to repay + * @param _fTokenCollateral The market in which to seize collateral from the borrower + */ + function liquidateBorrowInternal( + address _liquidator, + address _borrower, + uint256 _repayAmount, + address _fTokenCollateral + ) internal { + ITokenBase collateral = ITokenBase(_fTokenCollateral); + + /* Fail if liquidate not allowed */ + require( + riskManager.liquidateBorrowAllowed(address(this), _fTokenCollateral, _borrower, _repayAmount), + "TokenBase: Liquidation disallowed by risk manager" + ); + // Fail if borrower = liquidator + require(_borrower != _liquidator, "TokenBase: Cannot liquidate yourself"); + // Fail if repayAmount = 0 or -1 + require(_repayAmount > 0 && _repayAmount != type(uint256).max, "TokenBase: Invalid repay amount"); + + // Fail if repayBorrow fails + repayBorrowInternal(_liquidator, _borrower, _repayAmount); + + /** + * Call seize functions of fTokenCollateral contract for token seizure. + * If this is also the collateral, run seizeInternal to avoid re-entrancy, + * otherwise make an external call + */ + if (_fTokenCollateral == address(this)) { + seizeInternal(address(this), _liquidator, _borrower, _repayAmount); + } else { + collateral.seize(_liquidator, _borrower, _repayAmount); + } + + // Reset liquidation tracker if there are no more bad debts + riskManager.closeLiquidation(_borrower); + + // We emit a LiquidateBorrow event + emit LiquidateBorrow(_liquidator, _borrower, _repayAmount, _fTokenCollateral); + } + + /** + * @notice Transfers collateral tokens (this market) to the liquidator. + * @dev Called only during an in-kind liquidation, or by liquidateBorrow during + * the liquidation of another fToken. Its absolutely critical to use msg.sender + * as the seizer fToken and not a parameter. + * @param _seizer The contract calling the function for seizing the collateral + * (i.e. borrowed fToken) + * @param _liquidator The account receiving seized collateral + * @param _borrower The account having collateral seized + * @param _repayAmount Amount of underlying tokens of seizer market the liquidator paid + */ + function seizeInternal(address _seizer, address _liquidator, address _borrower, uint256 _repayAmount) internal { + // We calculate the number of collateral tokens that will be seized + (uint256 seizeTotal, uint256 repayValue) = riskManager.liquidateCalculateSeizeTokens( + _borrower, + _seizer, + address(this), + _repayAmount + ); + + // Params: fTokenCollateral, fTokenBorrowed, liquidator, borrower + (bool allowed, bool isCollateralTier) = riskManager.seizeAllowed(address(this), _seizer, _borrower, seizeTotal); + require(allowed, "TokenBase: Token seizure disallowed by risk manager"); + // Fail if borrower = liquidator, already checked in `liquidaetBorrowInterna()` + // require(borrower != liquidator); + + // Initiate liquidation protection if seized asset is collateral tier + if (isCollateralTier && checker.isFurionToken(address(this))) { + // Indirect token transfer through minting and burning + _burn(_borrower, seizeTotal); + // Store seized tokens in market contract + _mint(address(this), seizeTotal); + + bytes32 id = keccak256(abi.encodePacked(block.timestamp, _borrower, liquidationCount[_borrower])); + liquidationCount[_borrower]++; + LiquidationProtection storage lp = liquidationProtection[id]; + lp.borrower = _borrower; + lp.liquidator = _liquidator; + lp.time = uint96(block.timestamp); + lp.value = uint128(repayValue); + lp.tokenSeized = uint128(seizeTotal); + + emit LiquidationProtected(id, _borrower, _liquidator); + } else { + (uint256 liquidatorSeizeTokens, uint256 protocolSeizeAmount, uint256 totalReservesNew) = seizeAllocation( + seizeTotal + ); + + // Indirect token transfer through minting and burning + _burn(_borrower, seizeTotal); + _mint(_liquidator, liquidatorSeizeTokens); + // We write the calculated values into storage + totalReserves = totalReservesNew; + + emit TokenSeized(_borrower, _liquidator, liquidatorSeizeTokens); + emit ReservesAdded(address(this), protocolSeizeAmount, totalReservesNew); + } + } + + /** + * @dev It is safe to set external visibility as seizeAllowed checks whether + * msg.sender is listed and has the same comptroller as current market (the + * market where tokens are seized) + */ + function seize(address _liquidator, address _borrower, uint256 _repayAmount) external { + seizeInternal(msg.sender, _liquidator, _borrower, _repayAmount); + } + + /** + * @notice Calculate how much liquidators get as rewards and how much the market + * gets as reserves given amount of tokens seized + */ + function seizeAllocation( + uint256 _seizeTotal + ) internal view returns (uint256 liquidatorSeizeTokens, uint256 protocolSeizeAmount, uint256 totalReservesNew) { + // mul_: uint, exp -> uint + uint256 protocolSeizeTokens = mul_(_seizeTotal, Exp({ mantissa: protocolSeizeShareMantissa })); + + liquidatorSeizeTokens = _seizeTotal - protocolSeizeTokens; + + // Convert amount of fToken for reserve to underlying asset + Exp memory exchangeRate = Exp({ mantissa: exchangeRateCurrent() }); + // mul_ScalarTruncate: exp, uint -> uint + protocolSeizeAmount = mul_ScalarTruncate(exchangeRate, protocolSeizeTokens); + + totalReservesNew = totalReserves + protocolSeizeAmount; + } + + /** + * @notice Liquidators can claim seized tokens locked for liquidation protection + * if the liquidated account did not pay 1.2x to reclaim tokens after 24 hours + * of the liquidation. + * @param _id Unique ID of liquidation protection + */ + function claimLiquidation(bytes32 _id) external { + LiquidationProtection memory lp = liquidationProtection[_id]; + + require(block.timestamp > uint256(lp.time) + 1 days, "TokenBase: Time limit not passed"); + require(lp.value != 0, "TokenBase: Liquidation protection closed / never existed"); + require(msg.sender == lp.liquidator, "TokenBase: Not liquidator of this liquidation"); + + uint256 tokenSeized256 = uint256(lp.tokenSeized); + + (uint256 liquidatorSeizeTokens, uint256 protocolSeizeAmount, uint256 totalReservesNew) = seizeAllocation( + tokenSeized256 + ); + + // Indirect token transfer through minting and burning + _burn(address(this), tokenSeized256); + _mint(msg.sender, liquidatorSeizeTokens); + // We write the calculated values into storage + totalReserves = totalReservesNew; + + emit TokenSeized(lp.borrower, lp.liquidator, liquidatorSeizeTokens); + emit ReservesAdded(address(this), protocolSeizeAmount, totalReservesNew); + + delete liquidationProtection[_id]; + } + + /** + * @notice Borrowers who get liquidated can reclaim the seized tokens if they + * pay 1.2x the amount liquidators repaid within 24 hours after liquidation. + * @param _id Unique ID of liquidation protection + * + * NOTE: Unit for getUnderlyingPrice of price oracle is ETH, therefore no need + * to query value. + */ + function repayLiquidationWithEth(bytes32 _id) external payable { + LiquidationProtection memory lp = liquidationProtection[_id]; + + require(block.timestamp < uint256(lp.time) + 1 days, "TokenBase: Time limit passed"); + require(lp.value != 0, "TokenBase: Liquidation protection closed / never existed"); + + // 1.2x multiplier + uint256 valueAfterMultiplier = (uint256(lp.value) * 120) / 100; + (uint256 EthPriceMantissa, ) = oracle.getUnderlyingPrice(address(this)); + // div_: uint, exp -> uint + uint256 EthToRepay = div_(valueAfterMultiplier, Exp({ mantissa: EthPriceMantissa })); + + require(msg.value >= EthToRepay, "TokenBase: Not enough ETH given"); + + uint256 spareEth = msg.value - EthToRepay; + + // Contract immediately transfers received ETH to liquidator + payable(lp.liquidator).transfer(EthToRepay); + // Refund spare ETH + if (spareEth > 0) { + payable(msg.sender).transfer(spareEth); + } + + // Transfer collateral fToken to borrower (msg.sender) + uint256 tokenSeized256 = uint256(lp.tokenSeized); + _burn(address(this), tokenSeized256); + _mint(lp.borrower, tokenSeized256); + + delete liquidationProtection[_id]; + } + + /** + * @notice Borrowers who get liquidated can reclaim the seized tokens if they + * pay 1.2x the amount liquidators repaid within 24 hours after liquidation. + * @param _id Unique ID of liquidation protection + * @param _fToken Address of market where the underlying asset is used for repaying + */ + function repayLiquidationWithErc(bytes32 _id, address _fToken) external { + LiquidationProtection memory lp = liquidationProtection[_id]; + + require(block.timestamp < uint256(lp.time) + 1 days, "TokenBase: Time limit passed"); + require(riskManager.checkListed(_fToken), "TokenBase: Market not listed"); + require(lp.value != 0, "TokenBase: Liquidation protection closed / never existed"); + require(msg.sender == lp.borrower, "TokenBase: Not borrower of this liquidation"); + + // 1.2x multiplier + uint256 valueAfterMultiplier = (uint256(lp.value) * 120) / 100; + + (uint256 underlyingPriceMantissa, ) = oracle.getUnderlyingPrice(_fToken); + address underlyingAsset = IFErc20(_fToken).getUnderlying(); + // div_: uint, exp -> uint + uint256 underlyingToRepay = div_(valueAfterMultiplier, Exp({ mantissa: underlyingPriceMantissa })); + + // Pay liquidator + IERC20(underlyingAsset).transferFrom(msg.sender, lp.liquidator, underlyingToRepay); + + // Transfer collateral fToken to borrower (msg.sender) + uint256 tokenSeized256 = uint256(lp.tokenSeized); + _burn(address(this), tokenSeized256); + _mint(msg.sender, tokenSeized256); + + delete liquidationProtection[_id]; + } + + /***************************** ERC20 Override *****************************/ + + /** + * Transferring invokes transferAllowed check which further invokes redeemAllowed + * check. Therefore, market should be up-to-date before transfer to make sure + * liquidity calculation in redeemAllowed is accurate. + */ + + /** + * @dev ERC20 transfer funtions with risk manager trasfer check + */ + function transfer(address to, uint256 amount) public override returns (bool) { + (, , uint256 index) = accrueInfo(); + accrueInterest(index); + + address owner = _msgSender(); + // Risk manager transferAllowed + require( + riskManager.transferAllowed(address(this), owner, amount), + "TokenBase: Transfer disallowed by risk manager" + ); + + _transfer(owner, to, amount); + return true; + } + + function transferFrom(address from, address to, uint256 amount) public override returns (bool) { + (, , uint256 index) = accrueInfo(); + accrueInterest(index); + + // Risk manager transferAllowed + require( + riskManager.transferAllowed(address(this), from, amount), + "TokenBase: Transfer disallowed by risk manager" + ); + + address spender = _msgSender(); + _spendAllowance(from, spender, amount); + _transfer(from, to, amount); + return true; + } + + /******************************* Safe Token *******************************/ + + // Functions with different logics for ERC20 tokens and ETH + + /** + * @dev Performs a transfer in (transfer assets from caller to this contract), reverting upon failure. Returns the amount actually transferred to the protocol, in case of a fee. + */ + function doTransferIn(address _from, uint256 _amount) internal virtual; + + /** + * @dev Performs a transfer out, ideally returning an explanatory error code upon failure rather than reverting. + * If caller has not called checked protocol's balance, may revert due to insufficient cash held in the contract. + * If caller has checked protocol's balance, and verified it is >= amount, this should not revert in normal conditions. + */ + function doTransferOut(address payable _to, uint256 _amount) internal virtual; +} diff --git a/contracts/money-market/TokenStorages.sol b/contracts/money-market/TokenStorages.sol new file mode 100644 index 0000000..07050ce --- /dev/null +++ b/contracts/money-market/TokenStorages.sol @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +import "./ExponentialNoError.sol"; +import "./interfaces/IRiskManager.sol"; +import "./interfaces/IInterestRateModel.sol"; +import "./interfaces/IPriceOracle.sol"; +import "../IChecker.sol"; + +// name, symbol, decimals, totalSupply, balances, allowances in ERC20 contract +contract TokenBaseStorage is ExponentialNoError { + bool public constant IS_FTOKEN = true; + + IRiskManager public riskManager; + + IInterestRateModel public interestRateModel; + + IPriceOracle public oracle; + + IChecker public checker; + + // Administrator for the market + address public admin; + + // Pending administrator for the market + address public pendingAdmin; + + // Max borrow rate per block (0.0005%) + uint256 internal constant BORROW_RATE_MAX_MANTISSA = 5e12; + + // Maximum fraction of interest that can be set aside for reserves + uint256 internal constant RESERVE_FACTOR_MAX_MANTISSA = 1e18; // 100% + + // 50 underlying = 1 fToken + uint256 internal initialExchangeRateMantissa; + + uint256 public reserveFactorMantissa; + + // Block number that interest is last accrued at + uint256 public lastAccrualBlock; + + // Accumulator for calculating interest + uint256 public borrowIndex; + + uint256 public totalCash; + + uint256 public totalBorrows; + + uint256 public totalReserves; + + // Track user borrowing state + struct BorrowSnapshot { + // Borrow balance when last was made + uint256 principal; + // borrowIndex when last borrow was made + uint256 interestIndex; + } + + mapping(address => BorrowSnapshot) internal accountBorrows; + + // Percentage of seized tokens that goes to market reserve, 0 by default + uint256 public protocolSeizeShareMantissa; + + struct LiquidationProtection { + address borrower; + address liquidator; + uint96 time; + uint128 value; + uint128 tokenSeized; + } + + // For generating unique ID for liquidation protection + // How many times one has been liquidated + mapping(address => uint256) public liquidationCount; + + // Unique ID -> liquidation protection detail + mapping(bytes32 => LiquidationProtection) public liquidationProtection; +} + +contract FErc20Storage { + address public underlying; +} diff --git a/contracts/money-market/interfaces/IFErc20.sol b/contracts/money-market/interfaces/IFErc20.sol new file mode 100644 index 0000000..5918a39 --- /dev/null +++ b/contracts/money-market/interfaces/IFErc20.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +interface IFErc20 { + function getUnderlying() external view returns (address); + + function supply(uint256 _mintAmount) external; + + function redeem(uint256 _redeemTokens) external; + + function redeemUnderlying(uint256 _redeemAmount) external; + + function borrow(uint256 _borrowAmount) external; + + function repayBorrow(uint256 _repayAmount) external; + + function repayBorrowBehalf(address _borrower, uint256 _repayAmount) external; + + function liquidateBorrow(address _borrower, uint256 _repayAmount, address _fTokenCollateral) external; + + //function sweepToken(address _token) external; +} diff --git a/contracts/money-market/interfaces/IInterestRateModel.sol b/contracts/money-market/interfaces/IInterestRateModel.sol new file mode 100644 index 0000000..50ae5d4 --- /dev/null +++ b/contracts/money-market/interfaces/IInterestRateModel.sol @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +interface IInterestRateModel { + function isInterestRateModel() external view returns (bool); + + /** + * @notice Calculates the current borrow interest rate per block + * @param _cash The total amount of cash the market has + * @param _borrows The total amount of borrows the market has outstanding + * @param _reserves The total amount of reserves the market has + * @return The borrow rate per block (as a percentage, and scaled by 1e18) + */ + function getBorrowRate(uint256 _cash, uint256 _borrows, uint256 _reserves) external view returns (uint256); + + /** + * @notice Calculates the current supply interest rate per block + * @param _cash The total amount of cash the market has + * @param _borrows The total amount of borrows the market has outstanding + * @param _reserves The total amount of reserves the market has + * @param _reserveFactorMantissa The current reserve factor the market has + * @return The supply rate per block (as a percentage, and scaled by 1e18) + */ + function getSupplyRate( + uint256 _cash, + uint256 _borrows, + uint256 _reserves, + uint256 _reserveFactorMantissa + ) external view returns (uint256); +} diff --git a/contracts/money-market/interfaces/IPriceOracle.sol b/contracts/money-market/interfaces/IPriceOracle.sol new file mode 100644 index 0000000..b4c5e93 --- /dev/null +++ b/contracts/money-market/interfaces/IPriceOracle.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +interface IPriceOracle { + /** + * @notice Get the underlying price of a fToken asset + * @param _fToken Address of the fToken to get the underlying price of + * @return The underlying asset price mantissa (scaled by 1e18). + * Zero means the price is unavailable. + */ + function getUnderlyingPrice(address _fToken) external view returns (uint256, uint256); +} diff --git a/contracts/money-market/interfaces/IRiskManager.sol b/contracts/money-market/interfaces/IRiskManager.sol new file mode 100644 index 0000000..7c6a5d3 --- /dev/null +++ b/contracts/money-market/interfaces/IRiskManager.sol @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +interface IRiskManager { + /// @notice Emitted when pendingAdmin is changed + event NewPendingAdmin(address oldPendingAdmin, address newPendingAdmin); + + /// @notice Emitted when pendingAdmin is accepted, which means admin is updated + event NewAdmin(address oldAdmin, address newAdmin); + + /// @notice Emitted when an admin supports a market + event MarketListed(address fToken); + + /// @notice Emitted when an account enters a market + event MarketEntered(address fToken, address account); + + /// @notice Emitted when an account exits a market + event MarketExited(address fToken, address account); + + /// @notice Emitted when close factor is changed by admin + event NewCloseFactor(uint256 oldCloseFactorMantissa, uint256 newCloseFactorMantissa); + + /// @notice Emitted when a collateral factor is changed by admin + event NewCollateralFactor(address fToken, uint256 oldCollateralFactorMantissa, uint256 newCollateralFactorMantissa); + + /// @notice Emitted when price oracle is changed + event NewPriceOracle(address oldPriceOracle, address newPriceOracle); + + /// @notice Emitted when an action is paused globally + event ActionPausedGlobal(string action, bool pauseState); + + /// @notice Emitted when an action is paused on a market + event ActionPausedMarket(address fToken, string action, bool pauseState); + + function isRiskManager() external returns (bool); + + function getMarketsEntered(address _account) external view returns (address[] memory); + + function checkListed(address _fToken) external view returns (bool); + + function enterMarkets(address[] memory _fTokens) external; + + function exitMarket(address _fToken) external; + + function supplyAllowed(address _fToken) external view returns (bool); + + function redeemAllowed(address _fToken, address _redeemer, uint256 _redeemTokens) external view returns (bool); + + function borrowAllowed(address _fToken, address _borrower, uint256 _borrowAmount) external returns (bool); + + function repayBorrowAllowed(address _fToken) external returns (bool); + + function liquidateBorrowAllowed( + address _fTokenBorrowed, + address _fTokenCollateral, + address _borrower, + uint256 _repayAmount + ) external returns (bool); + + function seizeAllowed( + address _fTokenCollateral, + address _fTokenBorrowed, + address _borrower, + uint256 _seizeTokens + ) external view returns (bool allowed, bool isCollateralTier); + + function transferAllowed(address _fToken, address _src, uint256 _amount) external view returns (bool); + + function initiateLiquidation(address _account) external; + + function closeLiquidation(address _account) external; + + function liquidateCalculateSeizeTokens( + address _borrower, + address _fTokenBorrowed, + address _fTokenCollateral, + uint256 _repayAmount + ) external view returns (uint256 seizeTokens, uint256 repayValue); +} diff --git a/contracts/money-market/interfaces/ITokenBase.sol b/contracts/money-market/interfaces/ITokenBase.sol new file mode 100644 index 0000000..7f9bed1 --- /dev/null +++ b/contracts/money-market/interfaces/ITokenBase.sol @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +interface ITokenBase { + /** + * @notice Event emitted when interest is accrued + */ + event AccrueInterest(uint256 cashPrior, uint256 interestAccumulated, uint256 borrowIndex, uint256 totalBorrows); + + /** + * @notice Event emitted when tokens are minted + */ + event Supply(address supplier, uint256 supplyAmount, uint256 tokensMinted); + + /** + * @notice Event emitted when tokens are redeemed + */ + event Redeem(address redeemer, uint256 redeemAmount, uint256 redeemTokens); + + /** + * @notice Event emitted when underlying is borrowed + */ + event Borrow(address borrower, uint256 borrowAmount, uint256 accountBorrows, uint256 totalBorrows); + + /** + * @notice Event emitted when a borrow is repaid + */ + event RepayBorrow( + address payer, + address borrower, + uint256 repayAmount, + uint256 accountBorrows, + uint256 totalBorrows + ); + + /** + * @notice Event emitted when a borrow is liquidated + */ + event LiquidateBorrow(address liquidator, address borrower, uint256 repayAmount, address fTokenCollateral); + + event LiquidationProtected(bytes32 _id, address _borrower, address _liquidator); + + event TokenSeized(address from, address to, uint256 amount); + + /** + * @notice Event emitted when the reserves are added + */ + event ReservesAdded(address benefactor, uint256 addAmount, uint256 newTotalReserves); + + /*** Admin Events ***/ + + /** + * @notice Event emitted when pendingAdmin is changed + */ + event NewPendingAdmin(address oldPendingAdmin, address newPendingAdmin); + + /** + * @notice Event emitted when pendingAdmin is accepted, which means admin is updated + */ + event NewAdmin(address oldAdmin, address newAdmin); + + event NewReserveFactor(uint256 oldReserveFactor, uint256 newReserveFactor); + + event NewPriceOracle(address oldOracle, address newOracle); + + function isFToken() external view returns (bool); + + function balanceOfUnderlying(address _account) external returns (uint256); + + function getAccountSnapshot(address _account) external view returns (uint256, uint256, uint256); + + function getLastAccrualBlock() external view returns (uint256); + + function getRiskManager() external view returns (address); + + function borrowRatePerBlock() external view returns (uint256); + + function supplyRatePerBlock() external view returns (uint256); + + //function totalBorrowsCurrent() external returns (uint256); + + function borrowBalanceCurrent(address _account) external view returns (uint256); + + function exchangeRateCurrent() external view returns (uint256); + + function seize(address _liquidator, address _borrower, uint256 _seizeTokens) external; + + /*** Admin ***/ + function setPendingAdmin(address newPendingAdmin) external; + + function acceptAdmin() external; +} diff --git a/info/MoneyMarket.json b/info/MoneyMarket.json new file mode 100644 index 0000000..9778abe --- /dev/null +++ b/info/MoneyMarket.json @@ -0,0 +1,4 @@ +{ + "localhost": [], + "goerli": [] +} diff --git a/tasks/deploy/money-market/ferc20.ts b/tasks/deploy/money-market/ferc20.ts new file mode 100644 index 0000000..9725b70 --- /dev/null +++ b/tasks/deploy/money-market/ferc20.ts @@ -0,0 +1,46 @@ +import { task } from "hardhat/config"; +import type { TaskArguments } from "hardhat/types"; + +import { readAddressList } from "../../../scripts/contractAddress"; +import { deployUpgradeable, getNetwork, writeMarketDeployment } from "../../helpers"; + +task("deploy:FErc20", "Deploy FErc20 contract") + .addParam("underlying", "Address of underlying asset") + .addParam("jump", "Whether jump interest rate model is used, true/false") + .addParam("name", "Name of market token") + .addParam("symbol", "Symbol of market token") + .setAction(async function (taskArguments: TaskArguments, { ethers, upgrades }) { + const network = getNetwork(); + const addressList = readAddressList(); + + let args; + if (taskArguments.jump == "true") { + args = [ + taskArguments.underlying, + addressList[network].RiskManager, + addressList[network].JumpInterestRateModel, + addressList[network].PriceOracle, + addressList[network].Checker, + taskArguments.name, + taskArguments.symbol, + ]; + } else { + args = [ + taskArguments.underlying, + addressList[network].RiskManager, + addressList[network].NormalInterestRateModel, + addressList[network].PriceOracle, + addressList[network].Checker, + taskArguments.name, + taskArguments.symbol, + ]; + } + + const ferc = await deployUpgradeable(ethers, upgrades, "FErc20", args); + + console.log(); + console.log(`${taskArguments.symbol} deployed to: ${ferc.address} on ${network}`); + + const implementation = await upgrades.erc1967.getImplementationAddress(ferc.address); + writeMarketDeployment(network, taskArguments.symbol, ferc.address, implementation); + }); diff --git a/tasks/deploy/money-market/fether.ts b/tasks/deploy/money-market/fether.ts new file mode 100644 index 0000000..e260a2e --- /dev/null +++ b/tasks/deploy/money-market/fether.ts @@ -0,0 +1,27 @@ +import { task } from "hardhat/config"; +import type { TaskArguments } from "hardhat/types"; + +import { readAddressList } from "../../../scripts/contractAddress"; +import { deployUpgradeable, getNetwork, writeMarketDeployment } from "../../helpers"; + +task("deploy:FEther", "Deploy FEther contract").setAction(async function ( + taskArguments: TaskArguments, + { ethers, upgrades }, +) { + const network = getNetwork(); + const addressList = readAddressList(); + + const args = [ + addressList[network].RiskManager, + addressList[network].NormalInterestRateModel, + addressList[network].PriceOracle, + addressList[network].Checker, + ]; + const feth = await deployUpgradeable(ethers, upgrades, "FEther", args); + + console.log(); + console.log(`FEther deployed to: ${feth.address}`); + + const implementation = await upgrades.erc1967.getImplementationAddress(feth.address); + writeMarketDeployment(network, "fETH", feth.address, implementation); +}); diff --git a/tasks/deploy/money-market/jumpInterestRateModel.ts b/tasks/deploy/money-market/jumpInterestRateModel.ts new file mode 100644 index 0000000..c709156 --- /dev/null +++ b/tasks/deploy/money-market/jumpInterestRateModel.ts @@ -0,0 +1,31 @@ +import { task } from "hardhat/config"; +import type { TaskArguments } from "hardhat/types"; + +import { deploy, getNetwork, writeDeployment } from "../../helpers"; + +task("deploy:JumpInterestRateModel", "Deploy jump interest rate model contract") + .addParam("baserate", "Base rate per year") + .addParam("multiplier", "Multiplier per year") + .addParam("jumpmultiplier", "Jump multiplier per year") + .addParam("kink", "Utilization rate where jump multiplier is used") + .setAction(async function (taskArguments: TaskArguments, { ethers }) { + const baseRateMantissa = ethers.utils.parseUnits(taskArguments.baserate, 18); + const multiplierMantissa = ethers.utils.parseUnits(taskArguments.multiplier, 18); + const jumpMultiplierMantissa = ethers.utils.parseUnits(taskArguments.jumpmultiplier, 18); + const kinkMantissa = ethers.utils.parseUnits(taskArguments.kink, 18); + + const network = getNetwork(); + + const args = [ + baseRateMantissa.toString(), + multiplierMantissa.toString(), + jumpMultiplierMantissa.toString(), + kinkMantissa.toString(), + ]; + const jirm = await deploy(ethers, "JumpInterestRateModel", args); + + console.log(); + console.log(`Jump interest rate model deployed to: ${jirm.address} on ${network}`); + + writeDeployment(network, "JumpInterestRateModel", jirm.address, args); + }); diff --git a/tasks/deploy/money-market/normalInterestRateModel.ts b/tasks/deploy/money-market/normalInterestRateModel.ts new file mode 100644 index 0000000..c11b774 --- /dev/null +++ b/tasks/deploy/money-market/normalInterestRateModel.ts @@ -0,0 +1,22 @@ +import { task } from "hardhat/config"; +import type { TaskArguments } from "hardhat/types"; + +import { deploy, getNetwork, writeDeployment } from "../../helpers"; + +task("deploy:NormalInterestRateModel", "Deploy normal interest rate model contract") + .addParam("baserate", "Base rate per year") + .addParam("multiplier", "Multiplier per year") + .setAction(async function (taskArguments: TaskArguments, { ethers }) { + const baseRateMantissa = ethers.utils.parseUnits(taskArguments.baserate, 18); + const multiplierMantissa = ethers.utils.parseUnits(taskArguments.multiplier, 18); + + const network = getNetwork(); + + const args = [baseRateMantissa.toString(), multiplierMantissa.toString()]; + const nirm = await deploy(ethers, "NormalInterestRateModel", args); + + console.log(); + console.log(`Normal interest rate model deployed to: ${nirm.address} on ${network}`); + + writeDeployment(network, "NormalInterestRateModel", nirm.address, args); + }); diff --git a/tasks/deploy/money-market/priceOracle.ts b/tasks/deploy/money-market/priceOracle.ts new file mode 100644 index 0000000..3361340 --- /dev/null +++ b/tasks/deploy/money-market/priceOracle.ts @@ -0,0 +1,18 @@ +import { task } from "hardhat/config"; +import type { TaskArguments } from "hardhat/types"; + +import { deploy, getNetwork, writeDeployment } from "../../helpers"; + +task("deploy:PriceOracle", "Deploy price oracle contract").setAction(async function ( + taskArguments: TaskArguments, + { ethers }, +) { + const network = getNetwork(); + + const po = await deploy(ethers, "SimplePriceOracle", []); + + console.log(); + console.log(`Price oracle deployed to: ${po.address} on ${network}`); + + writeDeployment(network, "PriceOracle", po.address, []); +}); diff --git a/tasks/deploy/money-market/riskManager.ts b/tasks/deploy/money-market/riskManager.ts new file mode 100644 index 0000000..352ed5e --- /dev/null +++ b/tasks/deploy/money-market/riskManager.ts @@ -0,0 +1,22 @@ +import { task } from "hardhat/config"; +import type { TaskArguments } from "hardhat/types"; + +import { readAddressList } from "../../../scripts/contractAddress"; +import { deployUpgradeable, getNetwork, writeUpgradeableDeployment } from "../../helpers"; + +task("deploy:RiskManager", "Deploy risk manager contract").setAction(async function ( + taskArguments: TaskArguments, + { ethers, upgrades }, +) { + const network = getNetwork(); + const addressList = readAddressList(); + + const args = [addressList[network].PriceOracle]; + const rm = await deployUpgradeable(ethers, upgrades, "RiskManager", args); + + console.log(); + console.log(`Risk manager deployed to: ${rm.address} on ${network}`); + + const implementation = await upgrades.erc1967.getImplementationAddress(rm.address); + writeUpgradeableDeployment(network, "RiskManager", rm.address, implementation); +}); diff --git a/tasks/index.ts b/tasks/index.ts index 7035abe..6888790 100644 --- a/tasks/index.ts +++ b/tasks/index.ts @@ -2,7 +2,19 @@ const deployChecker = require("./deploy/furion-pools/checker"); const deploySeparatePoolFactory = require("./deploy/furion-pools/separatePoolFactory"); const deployFurionPricingOracle = require("./deploy/furion-pools/furionPricingOracle"); const dpeloyAggregatePoolFactory = require("./deploy/furion-pools/aggregatePoolFactory"); -const deployTestFurionPools = require("./deploy/furion-pools/testFurionPools"); + +const deployFErc20 = require("./deploy/money-market/ferc20"); +const deployFEther = require("./deploy/money-market/fether"); +const deployNormalInterestRateModel = require("./deploy/money-market/normalInterestRateModel"); +const deployJumpInterestRateModel = require("./deploy/money-market/jumpInterestRateModel"); +const deployPriceOracle = require("./deploy/money-market/priceOracle"); +const deployRiskManager = require("./deploy/money-market/riskManager"); +const riskManager = require("./money-market/riskManager"); +const priceOracle = require("./money-market/priceOracle"); + +const upgradeFEther = require("./upgrade/money-market/fether"); +const upgradeFErc20 = require("./upgrade/money-market/ferc20"); +const upgradeRiskManager = require("./upgrade/money-market/riskManager"); const checker = require("./furion-pools/checker"); const createSeparatePool = require("./furion-pools/separatePoolFactory"); @@ -22,10 +34,20 @@ export { deploySeparatePoolFactory, deployFurionPricingOracle, dpeloyAggregatePoolFactory, - deployTestFurionPools, createSeparatePool, createAggregatePool, deployFurion, deployMockNFT, setNftPrice, + deployFErc20, + deployFEther, + deployNormalInterestRateModel, + deployJumpInterestRateModel, + deployPriceOracle, + deployRiskManager, + riskManager, + priceOracle, + upgradeFEther, + upgradeFErc20, + upgradeRiskManager, }; diff --git a/tasks/money-market/priceOracle.ts b/tasks/money-market/priceOracle.ts new file mode 100644 index 0000000..d93f3d8 --- /dev/null +++ b/tasks/money-market/priceOracle.ts @@ -0,0 +1,33 @@ +import { task } from "hardhat/config"; +import type { TaskArguments } from "hardhat/types"; + +import { readAddressList, readMarketList } from "../../scripts/contractAddress"; +import { getNetwork } from "../helpers"; + +task("PO:SetUnderlyingPrice", "Set price of underlying asset of a market") + .addParam("market", "Address of market to interact with") + .addParam("price", "Price of underlying asset") + .addParam("decimals", "Decimals of underlying asset") + .setAction(async function (taskArguments: TaskArguments, { ethers }) { + const network = getNetwork(); + + const addressList = readAddressList(); + const marketList = readMarketList(); + + for (let market of marketList[network]) { + if (taskArguments.market == market.address) { + const po = await ethers.getContractAt("SimplePriceOracle", addressList[network].PriceOracle); + await po.setUnderlyingPrice( + market.address, + ethers.utils.parseUnits(taskArguments.price, 18), + ethers.utils.parseUnits("1", taskArguments.decimals), + ); + + console.log(`Price of ${market.name.substring(1)} set to be $${taskArguments.price}`); + + return; + } + } + + console.log("Market not found"); + }); diff --git a/tasks/money-market/riskManager.ts b/tasks/money-market/riskManager.ts new file mode 100644 index 0000000..38c8fd0 --- /dev/null +++ b/tasks/money-market/riskManager.ts @@ -0,0 +1,49 @@ +import { task } from "hardhat/config"; +import type { TaskArguments } from "hardhat/types"; + +import { readAddressList, readMarketList, storeMarketList } from "../../scripts/contractAddress"; +import { getNetwork } from "../helpers"; + +task("RM:SupportMarket", "Support given market") + .addParam("market", "Address of market to support") + .addParam("collateralfactor", "Collateral factor (0-0.9)") + .addParam("tier", "Tier of market") + .setAction(async function (taskArguments: TaskArguments, { ethers }) { + const network = getNetwork(); + + const addressList = readAddressList(); + const marketList = readMarketList(); + + for (let market of marketList[network]) { + if (taskArguments.market == market.address) { + const rm = await ethers.getContractAt("RiskManager", addressList[network].RiskManager); + await rm.supportMarket( + market.address, + ethers.utils.parseUnits(taskArguments.collateralfactor, 18), + taskArguments.tier, + ); + + market.tier = taskArguments.tier; + storeMarketList(marketList); + console.log( + `${market.name} is now supported with collateral factor ${taskArguments.collateralfactor} and tier ${taskArguments.tier}`, + ); + + return; + } + } + + console.log("Market not found"); + }); + +task("RM:SetVeFUR", "Set veFUR for calculating collateral factor boost") + //.addParam("vefur", "Address of veFUR") + .setAction(async function (taskArguments: TaskArguments, { ethers }) { + const network = getNetwork(); + const addressList = readAddressList(); + + const rm = await ethers.getContractAt("RiskManager", addressList[network].RiskManager); + await rm.setVeToken(addressList[network].VoteEscrowedFurion); + + console.log("veFUR set"); + }); diff --git a/tasks/upgrade/money-market/ferc20.ts b/tasks/upgrade/money-market/ferc20.ts new file mode 100644 index 0000000..29517d8 --- /dev/null +++ b/tasks/upgrade/money-market/ferc20.ts @@ -0,0 +1,12 @@ +import { task } from "hardhat/config"; +import type { TaskArguments } from "hardhat/types"; + +import { upgrade } from "../../helpers"; + +task("upgrade:FErc20", "Upgrade FErc20 contract") + .addParam("address", "Address of proxy contract to upgrade") + .setAction(async function (taskArguments: TaskArguments, { ethers, upgrades }) { + await upgrade(ethers, upgrades, "FErc20", taskArguments.address); + + console.log("FErc20 upgraded"); + }); diff --git a/tasks/upgrade/money-market/fether.ts b/tasks/upgrade/money-market/fether.ts new file mode 100644 index 0000000..89390d3 --- /dev/null +++ b/tasks/upgrade/money-market/fether.ts @@ -0,0 +1,13 @@ +import { task } from "hardhat/config"; +import type { TaskArguments } from "hardhat/types"; + +import { upgrade } from "../../helpers"; + +task("upgrade:FEther", "Upgrade FEther contract").setAction(async function ( + taskArguments: TaskArguments, + { ethers, upgrades }, +) { + await upgrade(ethers, upgrades, "FEther", "0xc04609A609af7ED23856a4C26cBbD222C128D2Cb"); + + console.log("FEther upgraded"); +}); diff --git a/tasks/upgrade/money-market/riskManager.ts b/tasks/upgrade/money-market/riskManager.ts new file mode 100644 index 0000000..68c5b27 --- /dev/null +++ b/tasks/upgrade/money-market/riskManager.ts @@ -0,0 +1,13 @@ +import { task } from "hardhat/config"; +import type { TaskArguments } from "hardhat/types"; + +import { upgrade } from "../../helpers"; + +task("upgrade:RiskManager", "Upgrade risk manager contract").setAction(async function ( + taskArguments: TaskArguments, + { ethers, upgrades }, +) { + await upgrade(ethers, upgrades, "RiskManager", "0x16625d22c9045707de8f90E0c6b22B5aFA4320FF"); + + console.log("Risk manager upgraded"); +}); diff --git a/test/money-market/FErc20/FErc20.borrow.ts b/test/money-market/FErc20/FErc20.borrow.ts new file mode 100644 index 0000000..ff96ec5 --- /dev/null +++ b/test/money-market/FErc20/FErc20.borrow.ts @@ -0,0 +1,95 @@ +import { BigNumber } from "@ethersproject/bignumber"; +import type { SignerWithAddress } from "@nomiclabs/hardhat-ethers/dist/src/signer-with-address"; +import { expect } from "chai"; +import { ethers } from "hardhat"; +import hre from "hardhat"; + +import { mineBlocks } from "../../utils"; + +function mantissa(amount: string): BigNumber { + return ethers.utils.parseUnits(amount, 18); +} + +export function borrowTest(): void { + describe("Borrow F-NFT by using fF-NFT as collateral", async function () { + let bob: SignerWithAddress; + //let alice: SignerWithAddress; + + before(async function () { + const signers: SignerWithAddress[] = await ethers.getSigners(); + bob = signers[1]; + //alice = signers[2]; + }); + + // F-NFT balance + let spBalanceBob: BigNumber = mantissa("4000"); + // Supply 3000 F-NFT and mint fF-NFT + const spSupplied: BigNumber = mantissa("3000"); + const borrowAmount: BigNumber = mantissa("1000"); // 1000 F-NFT + const cashNew: BigNumber = spSupplied.sub(borrowAmount); + + beforeEach(async function () { + // Supply 5 ETH before each test + await this.ferc.connect(bob).supply(spSupplied); + spBalanceBob = await this.sp.balanceOf(bob.address); + }); + + it("should succeed with borrow amount within collateral factor limit", async function () { + // F-NFT market is automatically entered + await expect(this.ferc.connect(bob).borrow(borrowAmount)) + .to.emit(this.ferc, "Borrow") + .withArgs(bob.address, borrowAmount, borrowAmount, borrowAmount); + spBalanceBob = spBalanceBob.add(borrowAmount); + + expect(await this.sp.balanceOf(bob.address)).to.equal(spBalanceBob); + expect(await this.ferc.totalBorrows()).to.equal(borrowAmount); + expect(await this.ferc.totalCash()).to.equal(cashNew); + }); + + it("should fail with borrow amount greater than collateral factor limit", async function () { + // F-NFT collateral factor = 0.4 = 60/100 + // Max borrow: 3000 * 0.6 = 1800 F-NFT + const maxBorrow: BigNumber = spSupplied.mul(60).div(100); + + // Attempt to borrow 1801 F-NFT + await expect(this.ferc.connect(bob).borrow(maxBorrow.add(mantissa("1")))).to.be.revertedWith( + "RiskManager: Shortfall created, cannot borrow", + ); + }); + + it("should fail with existing shortfall", async function () { + // F-NFT collateral factor = 0.6 = 60/100 + // Max borrow: 3000 * 0.6 = 1800 F-NFT + const maxBorrow: BigNumber = spSupplied.mul(60).div(100); + await this.ferc.connect(bob).borrow(maxBorrow); + + // Attempt to borrow 1 F-NFT + await expect(this.ferc.connect(bob).borrow(mantissa("1"))).to.be.revertedWith( + "RiskManager: Shortfall created, cannot borrow", + ); + }); + + it("should fail with attempt to borrow higher tier assets (ETH)", async function () { + // Attempt to borrow 1 ETH + await expect(this.feth.borrow(mantissa("1"))).to.be.revertedWith("RiskManager: Shortfall created, cannot borrow"); + }); + + it("should accrue interest every block", async function () { + await this.ferc.connect(bob).borrow(borrowAmount); + + const borrowRatePerBlockMantissa: BigNumber = await this.ferc.borrowRatePerBlock(); + // 2102400 is the no. of blocks in a year according to interest rate model + const borrowRatePerYearMantissa: BigNumber = borrowRatePerBlockMantissa.mul(2102400); + const oldBorrowIndex: BigNumber = await this.ferc.borrowIndexCurrent(); + const multiplierMantissa: BigNumber = borrowRatePerYearMantissa.add(mantissa("1")); + const calNewBorrowIndex: BigNumber = oldBorrowIndex.mul(multiplierMantissa).div(mantissa("1")); + + // Mine 2102400 blocks + await mineBlocks(2102400); + + // Get new borrow balance + const newBorrowIndex: BigNumber = await this.ferc.borrowIndexCurrent(); + expect(newBorrowIndex).to.equal(calNewBorrowIndex); + }); + }); +} diff --git a/test/money-market/FErc20/FErc20.fixture.ts b/test/money-market/FErc20/FErc20.fixture.ts new file mode 100644 index 0000000..9a0a2cc --- /dev/null +++ b/test/money-market/FErc20/FErc20.fixture.ts @@ -0,0 +1,106 @@ +import { BigNumber } from "@ethersproject/bignumber"; +import type { SignerWithAddress } from "@nomiclabs/hardhat-ethers/dist/src/signer-with-address"; +import { expect } from "chai"; +import { ethers, upgrades } from "hardhat"; + +import { deploy, deployUpgradeable } from "../../utils"; + +function mantissa(amount: string): BigNumber { + return ethers.utils.parseUnits(amount, 18); +} + +// Initial NFT balances: (id) +// bob: four NFT (0, 1, 2, 3) +// alice: two NFT (4, 5) + +export async function deployFErcFixture(): Promise<{ + checker: Checker; + sp: SeparatePool; + spo: SimplePriceOracle; + rm: RiskManager; + nirm: NormalInterestRateModel; + feth: FEther; + ferc: FErc20; +}> { + const signers: SignerWithAddress[] = await ethers.getSigners(); + const admin: SignerWithAddress = signers[0]; + const bob: SignerWithAddress = signers[1]; + const alice: SignerWithAddress = signers[2]; + + // Deploy dummy NFT + const nft = await deploy("NFTest", [ + [bob.address, bob.address, bob.address, bob.address, alice.address, alice.address], + ]); + + // Deploy FUR + const fur = await deploy("FurionTokenTest", [[admin.address, bob.address, alice.address]]); + + // Deploy veFUR + const veFur = await deployUpgradeable("VoteEscrowedFurion", [fur.address]); + + // Deploy checker + const checker = await deploy("Checker", []); + + // Deploy separate pool factory + const spf = await deploy("SeparatePoolFactory", [admin.address, checker.address, fur.address]); + + // Set factory + await checker.connect(admin).setSPFactory(spf.address); + + // Create separate pool (F-NFT token) + const poolAddress = await spf.callStatic.createPool(nft.address); + await spf.connect(admin).createPool(nft.address); + const sp = await ethers.getContractAt("SeparatePool", poolAddress); + + // Deploy price oracle + const spo = await deploy("SimplePriceOracle", []); + + // Deploy risk manager + const rm = await deployUpgradeable("RiskManager", [spo.address]); + + // Deploy interest rate model + const nirm = await deploy("NormalInterestRateModel", [mantissa("0.03"), mantissa("0.2")]); + + // Deploy FEther + const feth = await deployUpgradeable("FEther", [rm.address, nirm.address, spo.address, checker.address]); + + // Deploy FErc20 (fF-NFT market) + const ferc = await deployUpgradeable("FErc20", [ + sp.address, + rm.address, + nirm.address, + spo.address, + checker.address, + "Furion F-NFT", + "fF-NFT", + ]); + + // Sell NFT to get F-NFT + await nft.connect(bob).setApprovalForAll(sp.address, true); + await nft.connect(alice).setApprovalForAll(sp.address, true); + await sp.connect(bob).sellBatch([0, 1, 2, 3]); + await sp.connect(alice).sellBatch([4, 5]); + + // Approve F-NFT spending + await sp.connect(bob).approve(ferc.address, mantissa("10000")); + await sp.connect(bob).approve(feth.address, mantissa("10000")); + await sp.connect(alice).approve(ferc.address, mantissa("10000")); + await sp.connect(alice).approve(feth.address, mantissa("10000")); + + // Set close factor + await rm.connect(admin).setCloseFactor(mantissa("0.5")); + // Set veFUR for Risk Manager + await rm.connect(admin).setVeToken(veFur.address); + + // Set fETH market underlying price + await spo.connect(admin).setUnderlyingPrice(feth.address, mantissa("1700"), mantissa("1")); + // Set fF-NFT market underlying price (1000 F-NFT = 10 ETH) + await spo.connect(admin).setUnderlyingPrice(ferc.address, mantissa("17"), mantissa("1")); + + // List fETH market + await rm.connect(admin).supportMarket(feth.address, mantissa("0.85"), 1); + // List fF-NFT market (cross-tier) + await rm.connect(admin).supportMarket(ferc.address, mantissa("0.6"), 2); + + return { checker, sp, spo, rm, nirm, feth, ferc }; +} diff --git a/test/money-market/FErc20/FErc20.liquidate.ts b/test/money-market/FErc20/FErc20.liquidate.ts new file mode 100644 index 0000000..db13efd --- /dev/null +++ b/test/money-market/FErc20/FErc20.liquidate.ts @@ -0,0 +1,357 @@ +import { BigNumber } from "@ethersproject/bignumber"; +import type { SignerWithAddress } from "@nomiclabs/hardhat-ethers/dist/src/signer-with-address"; +import { expect } from "chai"; +import { ethers } from "hardhat"; +import hre from "hardhat"; + +import { mineBlocks } from "../../utils"; + +function mantissa(amount: string): BigNumber { + return ethers.utils.parseUnits(amount, 18); +} + +export function liquidateTest(): void { + describe("Liquidate borrowed F-NFT", async function () { + let admin: SignerWithAddress; + let bob: SignerWithAddress; + let alice: SignerWithAddress; + + before(async function () { + const signers: SignerWithAddress[] = await ethers.getSigners(); + admin = signers[0]; + bob = signers[1]; + alice = signers[2]; + }); + + let spBalanceBob: BigNumber = mantissa("4000"); // 4000 F-NFT + const spBalanceAlice: BigNumber = mantissa("2000"); // 2000 F-NFT + const spSupplied: BigNumber = mantissa("3000"); // 3000 F-NFT + const ethSupplied: BigNumber = mantissa("5"); // 5 ETH + // Borrower fETH balance before liquidation + let fethBalancePreLiquidationBob: BigNumber; + // Borrower fF-NFT balance before liquidation; + let fspBalancePreLiquidationBob: BigNumber; + // Borrow max amount possible: 3000 * 0.6 + 500 * 0.85 = 2225 F-NFT + const borrowAmount: BigNumber = spSupplied.mul(60).div(100).add(ethSupplied.mul(100).mul(85).div(100)); + //let newBorrowBalance: BigNumber; + const repayAmount: BigNumber = mantissa("400"); // 400 F-NFT + const repayAmountSmall: BigNumber = mantissa("100"); // 100 F-NFT + // fETH tokens seized + let seizeTokens: BigNumber; + let liquidateTimestamp: number; + let liquidateId: number; + // Price of ETH in terms of ETH + const ethPriceMantissa: BigNumber = mantissa("1700"); + // Price of fF-NFT in terms of ETH + const priceMantissa: BigNumber = mantissa("17"); + const _mantissa: BigNumber = mantissa("1"); + + beforeEach(async function () { + // Supply 3000 F-NFT and 10 ETH before each test + await this.ferc.connect(bob).supply(spSupplied); + fspBalancePreLiquidationBob = await this.ferc.balanceOf(bob.address); + await this.feth.connect(bob).supply({ value: ethSupplied }); + fethBalancePreLiquidationBob = await this.feth.balanceOf(bob.address); + // Enter fETH and fF-NFT markets for liquidity calculation + await this.rm.connect(bob).enterMarkets([this.ferc.address, this.feth.address]); + // Borrow 2050 F-NFT before each test + await this.ferc.connect(bob).borrow(borrowAmount); + + spBalanceBob = await this.sp.balanceOf(bob.address); + + /* + const borrowRatePerBlockMantissa: BigNumber = await this.ferc.borrowRatePerBlock(); + const borrowRatePerYearMantissa: BigNumber = borrowRatePerBlockMantissa.mul(3); + const oldBorrowIndex: BigNumber = await this.ferc.borrowIndex(); + const multiplierMantissa: BigNumber = borrowRatePerYearMantissa.add(mantissa("1")); + const newBorrowIndex: BigNumber = oldBorrowIndex.mul(multiplierMantissa).div(mantissa("1")); + // Borrow balance after 3 blocks + newBorrowBalance = borrowAmount.mul(newBorrowIndex).div(oldBorrowIndex); + */ + }); + + it("should succeed with fETH seized", async function () { + // Mark Bob's account as liquidatable, mining the 2nd block + await this.rm.connect(alice).initiateLiquidation(bob.address); + + // Alice liquidates Bob's borrow and seize ETH collateral with 5% discount + await expect(this.ferc.connect(alice).liquidateBorrow(bob.address, repayAmount, this.feth.address)) + .to.emit(this.ferc, "LiquidateBorrow") + .withArgs(alice.address, bob.address, repayAmount, this.feth.address); + + // Get exchange rate where block number is same as when liquidateBorrow() is called + const exchangeRateMantissa: BigNumber = await this.feth.exchangeRateCurrent(); + const valuePerCollateralTokenMantissa: BigNumber = ethPriceMantissa.mul(exchangeRateMantissa).div(_mantissa); + // repayAmountAfterDiscount * ethPrice + const seizeValue: BigNumber = repayAmount.mul(105).div(100).mul(priceMantissa).div(_mantissa); + // Amount of fETH to seize + seizeTokens = seizeValue.mul(_mantissa).div(valuePerCollateralTokenMantissa); + + // Seized fETH sent to Alice + expect(await this.feth.balanceOf(bob.address)).to.equal(fethBalancePreLiquidationBob.sub(seizeTokens)); + expect(await this.feth.balanceOf(alice.address)).to.equal(seizeTokens); + // Alice's F-NFT balance + expect(await this.sp.balanceOf(alice.address)).to.equal(spBalanceAlice.sub(repayAmount)); + // Liquidation closed as shortfall is cleared + expect(await this.rm.liquidatableTime(bob.address)).to.equal(0); + }); + + it("should succeed with fF-NFT seized", async function () { + // Mark Bob's account as liquidatable, mining the 2nd block + await this.rm.connect(alice).initiateLiquidation(bob.address); + + // Alice liquidates Bob's borrow and seize F-NFT collateral with 5% discount, mining the 3rd block + await expect(this.ferc.connect(alice).liquidateBorrow(bob.address, repayAmount, this.ferc.address)) + .to.emit(this.ferc, "LiquidateBorrow") + .withArgs(alice.address, bob.address, repayAmount, this.ferc.address); + + // Get exchange rate where block number is same as when liquidateBorrow() is called + const exchangeRateMantissa: BigNumber = await this.ferc.exchangeRateCurrent(); + const valuePerCollateralTokenMantissa: BigNumber = priceMantissa.mul(exchangeRateMantissa).div(mantissa("1")); + // repayAmountAfterDiscount * F-NFT price (in terms of ETH) + const seizeValue: BigNumber = repayAmount.mul(105).div(100).mul(priceMantissa).div(mantissa("1")); + // Amount of fF-NFT to seize + seizeTokens = seizeValue.mul(mantissa("1")).div(valuePerCollateralTokenMantissa); + + // Seized token is not collateral tier, liquidation protection not triggered + expect(await this.ferc.balanceOf(bob.address)).to.equal(fspBalancePreLiquidationBob.sub(seizeTokens)); + expect(await this.ferc.balanceOf(alice.address)).to.equal(seizeTokens); + // Alice's F-NFT balance + expect(await this.sp.balanceOf(alice.address)).to.equal(spBalanceAlice.sub(repayAmount)); + // Liquidation closed as shortfall is cleared + expect(await this.rm.liquidatableTime(bob.address)).to.equal(0); + }); + + it("should succeed with higher liquidation discount", async function () { + // Mark Bob's account as liquidatable + await this.rm.connect(alice).initiateLiquidation(bob.address); + // Mine 15 blocks, expect discount to be (15+1)/10 = 1.6 -> 5 + 1 = 6% + await mineBlocks(15); + // Alice liquidates Bob's borrow with 6% discount, mining the 18th block + await this.ferc.connect(alice).liquidateBorrow(bob.address, repayAmount, this.feth.address); + + // Get exchange rate where block number is same as when liquidateBorrow() is called + const exchangeRateMantissa: BigNumber = await this.feth.exchangeRateCurrent(); + // seizeValue = repayAmountAfterDiscount * F-NFT price (in terms of ETH) + const seizeValue: BigNumber = repayAmount.mul(106).div(100).mul(priceMantissa).div(_mantissa); + const valuePerCollateralTokenMantissa: BigNumber = ethPriceMantissa.mul(exchangeRateMantissa).div(_mantissa); + seizeTokens = seizeValue.mul(_mantissa).div(valuePerCollateralTokenMantissa); + + // Seized tokens sent to Alice + expect(await this.feth.balanceOf(bob.address)).to.equal(fethBalancePreLiquidationBob.sub(seizeTokens)); + expect(await this.feth.balanceOf(alice.address)).to.equal(seizeTokens); + }); + + it("should succeed with auction reset", async function () { + // Mark Bob's account as liquidatable, mining the 2nd block + await this.rm.connect(alice).initiateLiquidation(bob.address); + // Mine 60 blocks + await mineBlocks(60); + // Reset auction as 60 blocks has passed since last initiation + await this.rm.connect(alice).initiateLiquidation(bob.address); + // Repay F-NFT and seize fETH + await this.ferc.connect(alice).liquidateBorrow(bob.address, repayAmount, this.feth.address); + + // Get exchange rate where block number is same as when liquidateBorrow() is called + const exchangeRateMantissa: BigNumber = await this.feth.exchangeRateCurrent(); + const valuePerCollateralTokenMantissa: BigNumber = ethPriceMantissa.mul(exchangeRateMantissa).div(_mantissa); + // repayAmountAfterDiscount * ethPrice (in terms of ETH) + const seizeValue: BigNumber = repayAmount.mul(105).div(100).mul(priceMantissa).div(_mantissa); + // Amount of fETH to seize + seizeTokens = seizeValue.mul(_mantissa).div(valuePerCollateralTokenMantissa); + + // Seized fETH sent to Alice + expect(await this.feth.balanceOf(bob.address)).to.equal(fethBalancePreLiquidationBob.sub(seizeTokens)); + expect(await this.feth.balanceOf(alice.address)).to.equal(seizeTokens); + // Liquidation closed as shortfall is cleared + expect(await this.rm.liquidatableTime(bob.address)).to.equal(0); + }); + + it("should succeed with shortfall reduced but not cleared", async function () { + // Mine 999990 blocks + await mineBlocks(999990); + + // Mark Bob's account as liquidatable + await this.rm.connect(alice).initiateLiquidation(bob.address); + const initiateBlockNumber: number = await ethers.provider.getBlockNumber(); + + // Alice liquidates Bob's borrow with 5% discount + await this.ferc.connect(alice).liquidateBorrow(bob.address, repayAmountSmall, this.feth.address); + // Liquidation continues as shortfall is not cleared + expect(await this.rm.liquidatableTime(bob.address)).to.equal(initiateBlockNumber); + }); + + it("should fail with auction not yet initiated", async function () { + await expect( + this.ferc.connect(alice).liquidateBorrow(bob.address, repayAmount, this.feth.address), + ).to.be.revertedWith("RiskManager: Liquidation not yet initiated"); + }); + + it("should fail with auction expired", async function () { + // Mark Bob's account as liquidatable + await this.rm.connect(alice).initiateLiquidation(bob.address); + + // Mine 60 blocks + await mineBlocks(60); + + await expect( + this.ferc.connect(alice).liquidateBorrow(bob.address, repayAmount, this.feth.address), + ).to.be.revertedWith("RiskManager: Reset auction required"); + }); + + it("should fail with no shortfall", async function () { + // Mark Bob's account as liquidatable + await this.rm.connect(alice).initiateLiquidation(bob.address); + + // Bob repays 500 F-NFT, clearing shortfall + await this.ferc.connect(bob).repayBorrow(mantissa("500")); + + await expect( + this.ferc.connect(alice).liquidateBorrow(bob.address, repayAmount, this.feth.address), + ).to.be.revertedWith("RiskManager: Insufficient shortfall"); + }); + + it("should fail with repay amount exceeding close factor limit", async function () { + // Mark Bob's account as liquidatable, mining the 2nd block + await this.rm.connect(alice).initiateLiquidation(bob.address); + + const repayAmountFail: BigNumber = mantissa("1113"); // 1113 F-NFT > 2225/2 + await expect( + this.ferc.connect(alice).liquidateBorrow(bob.address, repayAmountFail, this.feth.address), + ).to.be.revertedWith("RiskManager: Repay too much"); + }); + + it("should fail with tokens to be seized exceeding borrower's balance", async function () { + // Mark Bob's account as liquidatable, mining the 2nd block + await this.rm.connect(alice).initiateLiquidation(bob.address); + + // Repay amount that will cause tx to fail + const repayAmountFail: BigNumber = mantissa("500"); // 500 F-NFT + // Attempt to seize 500 * 1.05 * 0.01 = 5.25 ETH + await expect( + this.ferc.connect(alice).liquidateBorrow(bob.address, repayAmountFail, this.feth.address), + ).to.be.revertedWith("RiskManager: Seize token amount exceeds collateral"); + }); + + context("with liquidation protection", async function () { + beforeEach(async function () { + await this.checker.connect(admin).addToken(this.feth.address); + // Mark Bob's account as liquidatable + await this.rm.connect(alice).initiateLiquidation(bob.address); + // Alice liquidates Bob's borrow with 5% discount + await this.ferc.connect(alice).liquidateBorrow(bob.address, repayAmount, this.feth.address); + + const blockNumber: number = await ethers.provider.getBlockNumber(); + const block = await ethers.provider.getBlock(blockNumber); + liquidateTimestamp = block.timestamp; + liquidateId = ethers.utils.solidityKeccak256( + ["uint256", "address", "uint256"], + [liquidateTimestamp, bob.address, 0], + ); + + // Get exchange rate where block number is same as when liquidateBorrow() is called + const exchangeRateMantissa: BigNumber = await this.feth.exchangeRateCurrent(); + const valuePerCollateralTokenMantissa: BigNumber = ethPriceMantissa.mul(exchangeRateMantissa).div(_mantissa); + // repayAmountAfterDiscount * ethPrice + const seizeValue: BigNumber = repayAmount.mul(105).div(100).mul(priceMantissa).div(_mantissa); + // Amount of fETH to seize + seizeTokens = seizeValue.mul(_mantissa).div(valuePerCollateralTokenMantissa); + }); + + it("should succeed with correct data", async function () { + // Check liquidation protection + const lp = await this.feth.liquidationProtection(liquidateId); + + expect(lp[0]).to.equal(bob.address); + expect(lp[1]).to.equal(alice.address); + expect(lp[2]).to.equal(liquidateTimestamp); + // repayValue = repayAmount * F-NFT price + expect(lp[3]).to.equal(repayAmount.mul(priceMantissa).div(_mantissa)); + expect(lp[4]).to.equal(seizeTokens); + }); + + it("should succeed with liquidator claiming after time limit", async function () { + // Fast forward to 24 hours and 1 sec after liquidation protection is triggered + await ethers.provider.send("evm_mine", [liquidateTimestamp + 24 * 3600 + 1]); + + await this.feth.connect(alice).claimLiquidation(liquidateId); + + // Check contract and liquidator fETH balance + expect(await this.feth.balanceOf(this.feth.address)).to.equal(0); + expect(await this.feth.balanceOf(alice.address)).to.equal(seizeTokens); + }); + + it("should fail with liquidator claiming before time limit", async function () { + // Fast forward to 5 hours after liquidation protection is triggered + await ethers.provider.send("evm_mine", [liquidateTimestamp + 5 * 3600]); + + await expect(this.feth.connect(alice).claimLiquidation(liquidateId)).to.be.revertedWith( + "TokenBase: Time limit not passed", + ); + }); + + it("should fail with non-liquidator claiming", async function () { + // Fast forward to 24 hours and 1 sec after liquidation protection is triggered + await ethers.provider.send("evm_mine", [liquidateTimestamp + 24 * 3600 + 1]); + + await expect(this.feth.connect(bob).claimLiquidation(liquidateId)).to.be.revertedWith( + "TokenBase: Not liquidator of this liquidation", + ); + }); + + it("should succeed with borrower repaying at 1.2x price using F-NFT within time limit", async function () { + // Fast forward to 1 min after liquidation protection is triggered + await ethers.provider.send("evm_mine", [liquidateTimestamp + 60]); + + // F-NFT to repay + const claimRepayAmount: BigNumber = repayAmount.mul(120).div(100); + await this.feth.connect(bob).repayLiquidationWithErc(liquidateId, this.ferc.address); + + expect(await this.sp.balanceOf(bob.address)).to.equal(spBalanceBob.sub(claimRepayAmount)); + // Check contract and borrower fETH balance + expect(await this.feth.balanceOf(this.feth.address)).to.equal(0); + expect(await this.feth.balanceOf(bob.address)).to.equal(fethBalancePreLiquidationBob); + }); + + it("should succeed with borrower repaying at 1.2x price using ETH within time limit", async function () { + // Fast forward to 1 min after liquidation protection is triggered + await ethers.provider.send("evm_mine", [liquidateTimestamp + 60]); + + const repayValue: BigNumber = repayAmount.mul(priceMantissa).div(mantissa("1")); + // ETH to repay + const claimRepayAmount: BigNumber = repayValue.mul(120).div(100).div(1700); + await expect( + await this.feth.connect(bob).repayLiquidationWithEth(liquidateId, { value: claimRepayAmount }), + ).to.changeEtherBalance(bob, claimRepayAmount.mul(-1)); + + // Check contract and borrower fETH balance + expect(await this.feth.balanceOf(this.feth.address)).to.equal(0); + expect(await this.feth.balanceOf(bob.address)).to.equal(fethBalancePreLiquidationBob); + }); + + it("should fail with borrower repaying after time limit", async function () { + // Fast forward to 24 hours and 1 sec after liquidation protection is triggered + await ethers.provider.send("evm_mine", [liquidateTimestamp + 24 * 3600 + 1]); + + const repayValue: BigNumber = repayAmount.mul(priceMantissa).div(mantissa("1")); + // ETH to repay + const claimRepayAmount: BigNumber = repayValue.mul(120).div(100); + await expect( + this.feth.connect(bob).repayLiquidationWithEth(liquidateId, { value: claimRepayAmount }), + ).to.be.revertedWith("TokenBase: Time limit passed"); + }); + + it("should fail with borrower repaying at less than 1.2x price", async function () { + // Fast forward to 1 min after liquidation protection is triggered + await ethers.provider.send("evm_mine", [liquidateTimestamp + 60]); + + const repayValue: BigNumber = repayAmount.mul(priceMantissa).div(mantissa("1")); + // ETH to repay, only 1.1x + const claimRepayAmount: BigNumber = repayValue.mul(110).div(100).div(1700); + await expect( + this.feth.connect(bob).repayLiquidationWithEth(liquidateId, { value: claimRepayAmount }), + ).to.be.revertedWith("TokenBase: Not enough ETH given"); + }); + }); + }); +} diff --git a/test/money-market/FErc20/FErc20.redeem.ts b/test/money-market/FErc20/FErc20.redeem.ts new file mode 100644 index 0000000..fa35549 --- /dev/null +++ b/test/money-market/FErc20/FErc20.redeem.ts @@ -0,0 +1,93 @@ +import { BigNumber } from "@ethersproject/bignumber"; +import type { SignerWithAddress } from "@nomiclabs/hardhat-ethers/dist/src/signer-with-address"; +import { expect } from "chai"; +import { ethers } from "hardhat"; + +function mantissa(amount: string): BigNumber { + return ethers.utils.parseUnits(amount, 18); +} + +// Initial F-NFT balances: +// bob: 4000 +// alice: 2000 + +export function redeemTest(): void { + describe("Redeem F-NFT by burning fF-NFT", async function () { + let bob: SignerWithAddress; + + before(async function () { + const signers: SignerWithAddress[] = await ethers.getSigners(); + bob = signers[1]; + }); + + // F-NFT balance + let spBalanceBob: BigNumber = mantissa("4000"); + // Supply 3000 F-NFT and mint fF-NFT + const spSupplied: BigNumber = mantissa("3000"); + // fF-NFT balance + let tokenBalanceBob: BigNumber; + // Burn half of fF-NFT owned to redeem F-NFT + // F-NFT redeemed is also half of spSupplied because there is no interest accrued + const redeemAmount: BigNumber = spSupplied.div(2); // 1500 F-NFT + let redeemTokens: BigNumber; + let tokenSupply: BigNumber; + // Amount of F-NFT remaining in market after redemption + const cashNew: BigNumber = spSupplied.sub(redeemAmount); + + beforeEach(async function () { + // Supply 3000 F-NFT before each test + await this.ferc.connect(bob).supply(spSupplied); + spBalanceBob = await this.sp.balanceOf(bob.address); + tokenBalanceBob = await this.ferc.balanceOf(bob.address); + redeemTokens = tokenBalanceBob.div(2); + tokenSupply = await this.ferc.totalSupply(); + }); + + it("should succeed with amount of fF-NFT to burn provided", async function () { + // Burn amount of fETH provided to redeem ETH + // Check for event first to avoid result mismatch due to interest accrual + await expect(this.ferc.connect(bob).redeem(redeemTokens)) + .to.emit(this.ferc, "Redeem") + .withArgs(bob.address, redeemAmount, redeemTokens); + spBalanceBob = spBalanceBob.add(redeemAmount); + tokenBalanceBob = tokenBalanceBob.sub(redeemTokens); + tokenSupply = tokenSupply.sub(redeemTokens); + + expect(await this.sp.balanceOf(bob.address)).to.equal(spBalanceBob); + expect(await this.ferc.balanceOf(bob.address)).to.equal(tokenBalanceBob); + expect(await this.ferc.totalCash()).to.equal(cashNew); + expect(await this.ferc.totalSupply()).to.equal(tokenSupply); + }); + + it("should succeed with amount of ETH to redeem provided", async function () { + // Check for event first to avoid result mismatch due to interest accrual + await expect(this.ferc.connect(bob).redeemUnderlying(redeemAmount)) + .to.emit(this.ferc, "Redeem") + .withArgs(bob.address, redeemAmount, redeemTokens); + spBalanceBob = spBalanceBob.add(redeemAmount); + tokenBalanceBob = tokenBalanceBob.sub(redeemTokens); + tokenSupply = tokenSupply.sub(redeemTokens); + + //expect(await this.sp.balanceOf(bob.address)).to.equal(spBalanceBob); + expect(await this.ferc.balanceOf(bob.address)).to.equal(tokenBalanceBob); + expect(await this.ferc.totalCash()).to.equal(cashNew); + expect(await this.ferc.totalSupply()).to.equal(tokenSupply); + }); + + it("should fail with redeem amount greater than or equal to cash in market", async function () { + await expect(this.ferc.connect(bob).redeemUnderlying(spSupplied)).to.be.revertedWith( + "TokenBase: Market has insufficient cash", + ); + }); + + it("should fail with redemption causing shortfall", async function () { + // Borrow 1100 F-NFT + await this.ferc.connect(bob).borrow(mantissa("1100")); + + // Attempt to redeem 1500 F-NFT + await expect(this.ferc.connect(bob).redeemUnderlying(redeemAmount)).to.be.revertedWith( + "RiskManager: Insufficient liquidity", + ); + }); + }); +} diff --git a/test/money-market/FErc20/FErc20.repay.ts b/test/money-market/FErc20/FErc20.repay.ts new file mode 100644 index 0000000..cd39de4 --- /dev/null +++ b/test/money-market/FErc20/FErc20.repay.ts @@ -0,0 +1,89 @@ +import { BigNumber } from "@ethersproject/bignumber"; +import type { SignerWithAddress } from "@nomiclabs/hardhat-ethers/dist/src/signer-with-address"; +import { expect } from "chai"; +import { ethers } from "hardhat"; +import hre from "hardhat"; + +import { mineBlocks } from "../../utils"; + +function mantissa(amount: string): BigNumber { + return ethers.utils.parseUnits(amount, 18); +} + +export function repayTest(): void { + describe("Repay borrowed F-NFT including interest", async function () { + let bob: SignerWithAddress; + let alice: SignerWithAddress; + + before(async function () { + const signers: SignerWithAddress[] = await ethers.getSigners(); + bob = signers[1]; + alice = signers[2]; + }); + + // F-NFT balance + let spBalanceBob: BigNumber = mantissa("4000"); + const spBalanceAlice: BigNumber = mantissa("2000"); + // Supply 3000 F-NFT and mint fF-NFT + const spSupplied: BigNumber = mantissa("3000"); + const borrowAmount: BigNumber = mantissa("500"); // 500 F-NFT + let newBorrowBalance: BigNumber; + + beforeEach(async function () { + // Supply 3000 F-NFT before each test + await this.ferc.connect(bob).supply(spSupplied); + // Borrow 500 F-NFT before each test + await this.ferc.connect(bob).borrow(borrowAmount); + spBalanceBob = await this.sp.balanceOf(bob.address); + + const borrowRatePerBlockMantissa: BigNumber = await this.ferc.borrowRatePerBlock(); + // 2102400 is the no. of blocks in a year according to interest rate model + const borrowRatePerYearMantissa: BigNumber = borrowRatePerBlockMantissa.mul(2102400); + const oldBorrowIndex: BigNumber = await this.ferc.borrowIndex(); + const multiplierMantissa: BigNumber = borrowRatePerYearMantissa.add(mantissa("1")); + const newBorrowIndex: BigNumber = oldBorrowIndex.mul(multiplierMantissa).div(mantissa("1")); + // Borrow balance after 1 year + newBorrowBalance = borrowAmount.mul(newBorrowIndex).div(oldBorrowIndex); + + // Mine 2102399 blocks + await mineBlocks(2102399); + }); + + it("should succeed with repayer being borrower", async function () { + // Repay whole borrow balance + const repayAmount: BigNumber = newBorrowBalance; + await expect(this.ferc.connect(bob).repayBorrow(repayAmount)) + .to.emit(this.ferc, "RepayBorrow") + .withArgs(bob.address, bob.address, repayAmount, 0, 0); + + expect(await this.sp.balanceOf(bob.address)).to.equal(spBalanceBob.sub(repayAmount)); + expect(await this.ferc.borrowBalanceCurrent(bob.address)).to.equal(0); + expect(await this.ferc.totalBorrows()).to.equal(0); + }); + + it("should succeed with repayer being non-borrower", async function () { + // Repay whole borrow balance + const repayAmount: BigNumber = newBorrowBalance; + await expect(this.ferc.connect(alice).repayBorrowBehalf(bob.address, repayAmount)) + .to.emit(this.ferc, "RepayBorrow") + .withArgs(alice.address, bob.address, repayAmount, 0, 0); + + expect(await this.sp.balanceOf(alice.address)).to.equal(spBalanceAlice.sub(repayAmount)); + expect(await this.ferc.borrowBalanceCurrent(bob.address)).to.equal(0); + expect(await this.ferc.totalBorrows()).to.equal(0); + }); + + it("should succeed with partial repayment", async function () { + // Repay 200 F-NFT + const repayAmount: BigNumber = mantissa("200"); + const borrowBalanceAfterRepay: BigNumber = newBorrowBalance.sub(repayAmount); + await expect(this.ferc.connect(bob).repayBorrow(repayAmount)) + .to.emit(this.ferc, "RepayBorrow") + .withArgs(bob.address, bob.address, repayAmount, borrowBalanceAfterRepay, borrowBalanceAfterRepay); + + expect(await this.sp.balanceOf(bob.address)).to.equal(spBalanceBob.sub(repayAmount)); + expect(await this.ferc.borrowBalanceCurrent(bob.address)).to.equal(borrowBalanceAfterRepay); + expect(await this.ferc.totalBorrows()).to.equal(borrowBalanceAfterRepay); + }); + }); +} diff --git a/test/money-market/FErc20/FErc20.supply.ts b/test/money-market/FErc20/FErc20.supply.ts new file mode 100644 index 0000000..c949da4 --- /dev/null +++ b/test/money-market/FErc20/FErc20.supply.ts @@ -0,0 +1,53 @@ +import { BigNumber } from "@ethersproject/bignumber"; +import type { SignerWithAddress } from "@nomiclabs/hardhat-ethers/dist/src/signer-with-address"; +import { expect } from "chai"; +import { ethers } from "hardhat"; + +function mantissa(amount: string): BigNumber { + return ethers.utils.parseUnits(amount, 18); +} + +// Initial F-NFT balances: +// bob: 4000 +// alice: 2000 + +export function supplyTest(): void { + describe("Supply F-NFT to mint fF-NFT", async function () { + let admin: SignerWithAddress; + let bob: SignerWithAddress; + + before(async function () { + const signers: SignerWithAddress[] = await ethers.getSigners(); + admin = signers[0]; + bob = signers[1]; + }); + + // F-NFT balance + let spBalanceBob: BigNumber = mantissa("4000"); + // F-NFT supplied + const spSupplied: BigNumber = mantissa("3000"); + // Initial exchange rate is 1 fToken = 50 underlying + const calMintAmount: BigNumber = spSupplied.div(50); + + it("should succeed", async function () { + // Bob supplies 3000 F-NFT to get fF-NFT + // Check for event first to avoid result mismatch due to interest accrual + await expect(this.ferc.connect(bob).supply(spSupplied)) + .to.emit(this.ferc, "Supply") + .withArgs(bob.address, spSupplied, calMintAmount); + spBalanceBob = spBalanceBob.sub(spSupplied); + + expect(await this.sp.balanceOf(bob.address)).to.equal(spBalanceBob); + expect(await this.ferc.balanceOf(bob.address)).to.equal(calMintAmount); + expect(await this.ferc.totalSupply()).to.equal(calMintAmount); + expect(await this.ferc.totalCash()).to.equal(spSupplied); + }); + + it("should fail when supply is paused", async function () { + // Admin pauses supply function of ferc market + await this.rm.connect(admin).setSupplyPaused(this.ferc.address, true); + + await expect(this.ferc.connect(bob).supply(spSupplied)).to.be.revertedWith("RiskManager: Supplying is paused"); + }); + }); +} diff --git a/test/money-market/FErc20/FErc20.ts b/test/money-market/FErc20/FErc20.ts new file mode 100644 index 0000000..7621081 --- /dev/null +++ b/test/money-market/FErc20/FErc20.ts @@ -0,0 +1,35 @@ +import { loadFixture } from "@nomicfoundation/hardhat-network-helpers"; + +import { borrowTest } from "./FErc20.borrow"; +import { deployFErcFixture } from "./FErc20.fixture"; +import { liquidateTest } from "./FErc20.liquidate"; +import { redeemTest } from "./FErc20.redeem"; +import { repayTest } from "./FErc20.repay"; +import { supplyTest } from "./FErc20.supply"; + +describe("FErc20", function () { + before(async function () { + this.loadFixture = loadFixture; + }); + + beforeEach(async function () { + const { checker, sp, spo, rm, nirm, feth, ferc } = await this.loadFixture(deployFErcFixture); + this.checker = checker; + this.sp = sp; + this.spo = spo; + this.rm = rm; + this.nirm = nirm; + this.feth = feth; + this.ferc = ferc; + }); + + supplyTest(); + + redeemTest(); + + borrowTest(); + + repayTest(); + + liquidateTest(); +}); diff --git a/test/money-market/FEther/FEther.borrow.ts b/test/money-market/FEther/FEther.borrow.ts new file mode 100644 index 0000000..512372c --- /dev/null +++ b/test/money-market/FEther/FEther.borrow.ts @@ -0,0 +1,85 @@ +import { BigNumber } from "@ethersproject/bignumber"; +import type { SignerWithAddress } from "@nomiclabs/hardhat-ethers/dist/src/signer-with-address"; +import { expect } from "chai"; +import { ethers } from "hardhat"; + +import { mineBlocks } from "../../utils"; + +function mantissa(amount: string): BigNumber { + return ethers.utils.parseUnits(amount, 18); +} + +export function borrowTest(): void { + describe("Borrow ETH by using ETH as collateral", async function () { + let bob: SignerWithAddress; + + before(async function () { + const signers: SignerWithAddress[] = await ethers.getSigners(); + bob = signers[1]; + }); + + const ethSupplied: BigNumber = mantissa("5"); // 5 ETH + const borrowAmount: BigNumber = mantissa("1"); // 1 ETH + const cashNew: BigNumber = ethSupplied.sub(borrowAmount); + + beforeEach(async function () { + // Supply 5 ETH before each test + await this.feth.connect(bob).supply({ value: ethSupplied }); + }); + + it("should succeed", async function () { + // ETH market is automatically entered + // Check for event first to avoid result mismatch due to interest accrual + await expect(this.feth.connect(bob).borrow(borrowAmount)) + .to.emit(this.feth, "Borrow") + .withArgs(bob.address, borrowAmount, borrowAmount, borrowAmount); + + expect(await this.feth.totalBorrows()).to.equal(borrowAmount); + expect(await this.feth.totalCash()).to.equal(cashNew); + + // Check for ETH credited to wallet from borrowing + await expect(await this.feth.connect(bob).borrow(borrowAmount)).to.changeEtherBalance(bob, borrowAmount); + }); + + it("should fail with borrow amount greater than collateral factor limit", async function () { + // ETH collateral factor = 0.85 = 85/100 + const maxBorrow: BigNumber = ethSupplied.mul(85).div(100); + + await expect(this.feth.connect(bob).borrow(maxBorrow.add(1))).to.be.revertedWith( + "RiskManager: Shortfall created, cannot borrow", + ); + }); + + it("should fail with existing shortfall", async function () { + // ETH collateral factor = 0.85 = 85/100 + const maxBorrow: BigNumber = ethSupplied.mul(85).div(100); + await this.feth.connect(bob).borrow(maxBorrow); + + // Accrue interest which creates shortfall + await mineBlocks(1); + + // Attempt to borrow 0.1 ETH + await expect(this.feth.connect(bob).borrow(mantissa("0.1"))).to.be.revertedWith( + "RiskManager: Shortfall created, cannot borrow", + ); + }); + + it("should accrue interest every block", async function () { + await this.feth.connect(bob).borrow(borrowAmount); + + const borrowRatePerBlockMantissa: BigNumber = await this.feth.borrowRatePerBlock(); + // 2102400 is the no. of blocks in a year according to interest rate model + const borrowRatePerYearMantissa: BigNumber = borrowRatePerBlockMantissa.mul(2102400); + const oldBorrowIndex: BigNumber = await this.feth.borrowIndex(); + const multiplierMantissa: BigNumber = borrowRatePerYearMantissa.add(mantissa("1")); + const calNewBorrowIndex: BigNumber = oldBorrowIndex.mul(multiplierMantissa).div(mantissa("1")); + + // Mine 2102400 blocks + await mineBlocks(2102400); + + // Get new borrow balance + const newBorrowIndex: BigNumber = await this.feth.borrowIndexCurrent(); + expect(newBorrowIndex).to.equal(calNewBorrowIndex); + }); + }); +} diff --git a/test/money-market/FEther/FEther.fixture.ts b/test/money-market/FEther/FEther.fixture.ts new file mode 100644 index 0000000..04faf1b --- /dev/null +++ b/test/money-market/FEther/FEther.fixture.ts @@ -0,0 +1,71 @@ +import { BigNumber } from "@ethersproject/bignumber"; +import type { SignerWithAddress } from "@nomiclabs/hardhat-ethers/dist/src/signer-with-address"; +import { ethers, upgrades } from "hardhat"; + +import { deploy, deployUpgradeable } from "../../utils"; + +function mantissa(amount: string): BigNumber { + return ethers.utils.parseUnits(amount, 18); +} + +export async function deployFEtherFixture(): Promise<{ + checker: Checker; + spo: SimplePriceOracle; + rm: RiskManager; + nirm: NormalInterestRateModel; + feth: FEther; + ffur: FErc20; +}> { + const signers: SignerWithAddress[] = await ethers.getSigners(); + const admin: SignerWithAddress = signers[0]; + const alice: SignerWithAddress = signers[2]; + + // Deploy FUR + const fur = await deploy("FurionTokenTest", [[admin.address, alice.address]]); + + // Deploy veFUR + const veFur = await deployUpgradeable("VoteEscrowedFurion", [fur.address]); + + // Deploy Checker + const checker = await deploy("Checker", []); + + // Deploy Price Oracle + const spo = await deploy("SimplePriceOracle", []); + + // Deploy Risk Manager + const rm = await deployUpgradeable("RiskManager", [spo.address]); + + // Deploy interest rate model + const nirm = await deploy("NormalInterestRateModel", [mantissa("0.03"), mantissa("0.2")]); + + // Deploy fETH + const feth = await deployUpgradeable("FEther", [rm.address, nirm.address, spo.address, checker.address]); + + // Deploy fFUR + const ffur = await deployUpgradeable("FErc20", [ + fur.address, + rm.address, + nirm.address, + spo.address, + checker.address, + "Furion FUR", + "fFUR", + ]); + + await fur.connect(admin).approve(ffur.address, mantissa("1000")); + await fur.connect(alice).approve(ffur.address, mantissa("1000")); + // Set close factor + await rm.connect(admin).setCloseFactor(mantissa("0.5")); + // Set fETH, fFUR market underlying price ($1700, $17000, $2) + await spo.connect(admin).setUnderlyingPrice(feth.address, mantissa("1700"), mantissa("1")); + await spo.connect(admin).setUnderlyingPrice(ffur.address, mantissa("2"), mantissa("1")); + // List fETH, fFUR market + await rm.connect(admin).supportMarket(feth.address, mantissa("0.85"), 1); + await rm.connect(admin).supportMarket(ffur.address, mantissa("0.6"), 2); + // Set veFUR for Risk Manager + await rm.connect(admin).setVeToken(veFur.address); + // Admin supplies FUR + await ffur.connect(admin).supply(mantissa("1000")); + + return { checker, spo, rm, nirm, feth, ffur }; +} diff --git a/test/money-market/FEther/FEther.liquidate.ts b/test/money-market/FEther/FEther.liquidate.ts new file mode 100644 index 0000000..d87b30e --- /dev/null +++ b/test/money-market/FEther/FEther.liquidate.ts @@ -0,0 +1,289 @@ +import { BigNumber } from "@ethersproject/bignumber"; +import type { SignerWithAddress } from "@nomiclabs/hardhat-ethers/dist/src/signer-with-address"; +import { expect } from "chai"; +import { ethers } from "hardhat"; +import hre from "hardhat"; + +import { getLatestBlockTimestamp, mineBlocks } from "../../utils"; + +function mantissa(amount: string): BigNumber { + return ethers.utils.parseUnits(amount, 18); +} + +export function liquidateTest(): void { + describe("Liquidate borrowed ETH", async function () { + let admin: SignerWithAddress; + let bob: SignerWithAddress; + let alice: SignerWithAddress; + + before(async function () { + const signers: SignerWithAddress[] = await ethers.getSigners(); + admin = signers[0]; + bob = signers[1]; + alice = signers[2]; + }); + + const ethSupplied: BigNumber = mantissa("5"); // 5 ETH + // Borrower fETH balance before liquidation + let fethBalancePreLiquidationBob: BigNumber; + // Borrow max amount possible + const borrowAmount: BigNumber = ethSupplied.mul(85).div(100); // 4.25 ETH + const repayAmount: BigNumber = mantissa("2"); // 2 ETH, eliminate shortfall + const repayAmountSmall: BigNumber = mantissa("0.1"); // 0.1 ETH, reduce shortfall + let liquidateTimestamp: number; + let liquidateId; + let seizeTokens: BigNumber; + // Price of ETH + const ethPriceMantissa: BigNumber = mantissa("1700"); + const fnftPriceMantissa: BigNumber = mantissa("17000"); + const _mantissa: BigNumber = mantissa("1"); + + beforeEach(async function () { + // Supply 5 ETH before each test + await this.feth.connect(bob).supply({ value: ethSupplied }); + fethBalancePreLiquidationBob = await this.feth.balanceOf(bob.address); + + // Borrow 4.25 ETH before each test + await this.feth.connect(bob).borrow(borrowAmount); + }); + + it("should succeed", async function () { + // Mark Bob's account as liquidatable + await this.rm.connect(alice).initiateLiquidation(bob.address); + + // Alice liquidates Bob's borrow with 5% discount + await expect(this.feth.connect(alice).liquidateBorrow(bob.address, this.feth.address, { value: repayAmount })) + .to.emit(this.feth, "LiquidateBorrow") + .withArgs(alice.address, bob.address, repayAmount, this.feth.address); + + // Get exchange rate where block number is same as when liquidateBorrow() is called + const exchangeRateMantissa: BigNumber = await this.feth.exchangeRateCurrent(); + // repayAmountAfterDiscount * ethPrice + const seizeValue: BigNumber = repayAmount.mul(105).div(100).mul(1700); + const valuePerTokenMantissa: BigNumber = ethPriceMantissa.mul(exchangeRateMantissa).div(_mantissa); + seizeTokens = seizeValue.mul(_mantissa).div(valuePerTokenMantissa); + + // Seized tokens sent to Alice, as liquidation protection is not triggered + expect(await this.feth.balanceOf(bob.address)).to.equal(fethBalancePreLiquidationBob.sub(seizeTokens)); + expect(await this.feth.balanceOf(alice.address)).to.equal(seizeTokens); + // Liquidation closed as shortfall is cleared + expect(await this.rm.liquidatableTime(bob.address)).to.equal(0); + }); + + it("should succeed with higher liquidation discount", async function () { + // Mark Bob's account as liquidatable + await this.rm.connect(alice).initiateLiquidation(bob.address); + // Mine 15 blocks, expects discount to be (15+1)/10 = 1.6 -> 5 + 1 = 6% + await mineBlocks(15); + // Alice liquidates Bob's borrow with 6% discount, mining one more block + await this.feth.connect(alice).liquidateBorrow(bob.address, this.feth.address, { value: repayAmount }); + + // Get exchange rate where block number is same as when liquidateBorrow() is called + const exchangeRateMantissa: BigNumber = await this.feth.exchangeRateCurrent(); + // repayAmountAfterDiscount * ethPrice + const seizeValue: BigNumber = repayAmount.mul(106).div(100).mul(1700); + const valuePerTokenMantissa: BigNumber = ethPriceMantissa.mul(exchangeRateMantissa).div(_mantissa); + seizeTokens = seizeValue.mul(_mantissa).div(valuePerTokenMantissa); + + // Seized tokens sent to Alice, as liquidation protection is not triggered + expect(await this.feth.balanceOf(bob.address)).to.equal(fethBalancePreLiquidationBob.sub(seizeTokens)); + expect(await this.feth.balanceOf(alice.address)).to.equal(seizeTokens); + }); + + it("should succeed with auction reset", async function () { + // Mark Bob's account as liquidatable + await this.rm.connect(alice).initiateLiquidation(bob.address); + // Mine 60 blocks + await mineBlocks(60); + // Reset auction as 60 blocks has passed since last initiation + await this.rm.connect(alice).initiateLiquidation(bob.address); + // Repay ETH and seize fETH + await this.feth.connect(alice).liquidateBorrow(bob.address, this.feth.address, { value: repayAmount }); + + // Get exchange rate where block number is same as when liquidateBorrow() is called + const exchangeRateMantissa: BigNumber = await this.feth.exchangeRateCurrent(); + const valuePerCollateralTokenMantissa: BigNumber = ethPriceMantissa.mul(exchangeRateMantissa).div(_mantissa); + // repayAmountAfterDiscount * ethPrice (in terms of ETH) + const seizeValue: BigNumber = repayAmount.mul(105).div(100).mul(ethPriceMantissa).div(mantissa("1")); + // Amount of fETH to seize + seizeTokens = seizeValue.mul(_mantissa).div(valuePerCollateralTokenMantissa); + + // Seized fETH sent to Alice + expect(await this.feth.balanceOf(bob.address)).to.equal(fethBalancePreLiquidationBob.sub(seizeTokens)); + expect(await this.feth.balanceOf(alice.address)).to.equal(seizeTokens); + // Liquidation closed as shortfall is cleared + expect(await this.rm.liquidatableTime(bob.address)).to.equal(0); + }); + + it("should succeed with shortfall reduced but not cleared", async function () { + // Mine 999998 blocks + await hre.network.provider.send("hardhat_mine", [ethers.utils.hexValue(999998)]); + + // Mark Bob's account as liquidatable + await this.rm.connect(alice).initiateLiquidation(bob.address); + const initiateBlockNumber: number = await ethers.provider.getBlockNumber(); + + // Alice liquidates Bob's borrow with 5% discount + await this.feth.connect(alice).liquidateBorrow(bob.address, this.feth.address, { value: repayAmountSmall }); + // Liquidation continues as shortfall is not cleared + expect(await this.rm.liquidatableTime(bob.address)).to.equal(initiateBlockNumber); + }); + + it("should fail with auction not yet initiated", async function () { + await expect( + this.feth.connect(alice).liquidateBorrow(bob.address, this.feth.address, { value: repayAmount }), + ).to.be.revertedWith("RiskManager: Liquidation not yet initiated"); + }); + + it("should fail with auction expired", async function () { + // Mark Bob's account as liquidatable, mining the 2nd block + await this.rm.connect(alice).initiateLiquidation(bob.address); + + // Mine 60 blocks + await mineBlocks(60); + + await expect( + this.feth.connect(alice).liquidateBorrow(bob.address, this.feth.address, { value: repayAmount }), + ).to.be.revertedWith("RiskManager: Reset auction required"); + }); + + it("should fail with no shortfall", async function () { + // Mark Bob's account as liquidatable + await this.rm.connect(alice).initiateLiquidation(bob.address); + + // Bob repays 0.2 ETH, clearing shortfall + await this.feth.connect(bob).repayBorrow({ value: mantissa("0.2") }); + + await expect( + this.feth.connect(alice).liquidateBorrow(bob.address, this.feth.address, { value: repayAmount }), + ).to.be.revertedWith("RiskManager: Insufficient shortfall"); + }); + + it("should fail with liquidation not starting from highest tier", async function () { + // Repay 1 ETH + await this.feth.connect(bob).repayBorrow({ value: mantissa("1") }); + // Borrow 849 FUR (1 ETH = 850 FUR) + await this.ffur.connect(bob).borrow(mantissa("849")); + + // Mine 100000 blocks to create shortfall + await mineBlocks(100000); + await this.rm.connect(alice).initiateLiquidation(bob.address); + + // Alice attempts to liquidate Bob by repaying 100 FUR which is of lower tier than ETH + await expect( + this.ffur.connect(alice).liquidateBorrow(bob.address, mantissa("100"), this.feth.address), + ).to.be.revertedWith("RiskManager: Liquidation should start from highest tier"); + }); + + it("should fail with repay amount exceeding close factor limit", async function () { + // Mark Bob's account as liquidatable + await this.rm.connect(alice).initiateLiquidation(bob.address); + + const repayAmountFail: BigNumber = mantissa("3"); // 3 ETH > 4.25/2 + await expect( + this.feth.connect(alice).liquidateBorrow(bob.address, this.feth.address, { value: repayAmountFail }), + ).to.be.revertedWith("RiskManager: Repay too much"); + }); + + context("with liquidation protection", async function () { + beforeEach(async function () { + await this.checker.connect(admin).addToken(this.feth.address); + // Mark Bob's account as liquidatable + await this.rm.connect(alice).initiateLiquidation(bob.address); + // Alice liquidates Bob's borrow with 5% discount, repays 0.1 F-NFT + await this.feth.connect(alice).liquidateBorrow(bob.address, this.feth.address, { value: repayAmount }); + + const blockNumber: number = await ethers.provider.getBlockNumber(); + const block = await ethers.provider.getBlock(blockNumber); + liquidateTimestamp = block.timestamp; + liquidateId = ethers.utils.solidityKeccak256( + ["uint256", "address", "uint256"], + [liquidateTimestamp, bob.address, 0], + ); + + // Get exchange rate where block number is same as when liquidateBorrow() is called + const exchangeRateMantissa: BigNumber = await this.feth.exchangeRateCurrent(); + // repayAmountAfterDiscount * ETH price + const seizeValue: BigNumber = repayAmount.mul(105).div(100).mul(ethPriceMantissa).div(_mantissa); + const valuePerTokenMantissa: BigNumber = ethPriceMantissa.mul(exchangeRateMantissa).div(_mantissa); + seizeTokens = seizeValue.mul(_mantissa).div(valuePerTokenMantissa); + }); + + it("should succeed with correct data", async function () { + // Check liquidation protection + const lp = await this.feth.liquidationProtection(liquidateId); + + expect(lp[0]).to.equal(bob.address); + expect(lp[1]).to.equal(alice.address); + expect(lp[2]).to.equal(liquidateTimestamp); + // repayValue = repayAmountSmall * ETH price + expect(lp[3]).to.equal(repayAmount.mul(ethPriceMantissa).div(_mantissa)); + expect(lp[4]).to.equal(seizeTokens); + }); + + it("should succeed with liquidator claiming after time limit", async function () { + // Fast forward to 24 hours and 1 sec after liquidation protection is triggered + await ethers.provider.send("evm_mine", [liquidateTimestamp + 24 * 3600 + 1]); + + await this.feth.connect(alice).claimLiquidation(liquidateId); + + // Check contract and liquidator fETH balance + expect(await this.feth.balanceOf(this.feth.address)).to.equal(0); + expect(await this.feth.balanceOf(alice.address)).to.equal(seizeTokens); + }); + + it("should fail with liquidator claiming before time limit", async function () { + // Fast forward to 5 hours after liquidation protection is triggered + await ethers.provider.send("evm_mine", [liquidateTimestamp + 5 * 3600]); + + await expect(this.feth.connect(alice).claimLiquidation(liquidateId)).to.be.revertedWith( + "TokenBase: Time limit not passed", + ); + }); + + it("should fail with non-liquidator claiming", async function () { + // Fast forward to 24 hours and 1 sec after liquidation protection is triggered + await ethers.provider.send("evm_mine", [liquidateTimestamp + 24 * 3600 + 1]); + + await expect(this.feth.connect(bob).claimLiquidation(liquidateId)).to.be.revertedWith( + "TokenBase: Not liquidator of this liquidation", + ); + }); + + it("should succeed with borrower repaying at 1.2x price using ETH within time limit", async function () { + // Fast forward to 1 min after liquidation protection is triggered + await ethers.provider.send("evm_mine", [liquidateTimestamp + 60]); + + const claimRepayAmount: BigNumber = repayAmount.mul(120).div(100); + await expect( + await this.feth.connect(bob).repayLiquidationWithEth(liquidateId, { value: claimRepayAmount }), + ).to.changeEtherBalance(alice, claimRepayAmount); + + // Check contract and borrower fETH balance + expect(await this.feth.balanceOf(this.feth.address)).to.equal(0); + expect(await this.feth.balanceOf(bob.address)).to.equal(fethBalancePreLiquidationBob); + }); + + it("should fail with borrower repaying after time limit", async function () { + // Fast forward to 24 hours and 1 sec after liquidation protection is triggered + await ethers.provider.send("evm_mine", [liquidateTimestamp + 24 * 3600 + 1]); + + const claimRepayAmount: BigNumber = repayAmount.mul(120).div(100); + await expect( + this.feth.connect(bob).repayLiquidationWithEth(liquidateId, { value: claimRepayAmount }), + ).to.be.revertedWith("TokenBase: Time limit passed"); + }); + + it("should fail with borrower repaying at less than 1.2x price", async function () { + // Fast forward to 1 min after liquidation protection is triggered + await ethers.provider.send("evm_mine", [liquidateTimestamp + 60]); + + // Only 1.1x + const claimRepayAmount: BigNumber = repayAmount.mul(110).div(100); + await expect( + this.feth.connect(bob).repayLiquidationWithEth(liquidateId, { value: claimRepayAmount }), + ).to.be.revertedWith("TokenBase: Not enough ETH given"); + }); + }); + }); +} diff --git a/test/money-market/FEther/FEther.redeem.ts b/test/money-market/FEther/FEther.redeem.ts new file mode 100644 index 0000000..b66bf94 --- /dev/null +++ b/test/money-market/FEther/FEther.redeem.ts @@ -0,0 +1,90 @@ +import { BigNumber } from "@ethersproject/bignumber"; +import type { SignerWithAddress } from "@nomiclabs/hardhat-ethers/dist/src/signer-with-address"; +import { expect } from "chai"; +import { ethers } from "hardhat"; +import hre from "hardhat"; + +function mantissa(amount: string): BigNumber { + return ethers.utils.parseUnits(amount, 18); +} + +export function redeemTest(): void { + describe("Redeem ETH by burning fETH", async function () { + let bob: SignerWithAddress; + + before(async function () { + const signers: SignerWithAddress[] = await ethers.getSigners(); + bob = signers[1]; + }); + + // Supply 5 ETH and mint fETH + const ethSupplied: BigNumber = mantissa("5"); // 5 ETH + let tokenBalance: BigNumber; + // Burn 1/5 of fETH owned to redeem ETH + // ETH redeemed is also 1/5 of ethSupplied because there is no interest accrued + const redeemAmount: BigNumber = ethSupplied.div(5); // 1 ETH + let redeemTokens: BigNumber; + // Amount of ETH remaining in market after redemption + const cashNew: BigNumber = ethSupplied.sub(redeemAmount); + + beforeEach(async function () { + // Supply 5 ETH before each test + await this.feth.connect(bob).supply({ value: mantissa("5") }); + tokenBalance = await this.feth.balanceOf(bob.address); + redeemTokens = tokenBalance.div(5); + }); + + it("should succeed with amount of fETH to burn provided", async function () { + // Burn amount of fETH provided to redeem ETH + // Check for event first to avoid result mismatch due to interest accrual + await expect(this.feth.connect(bob).redeem(redeemTokens)) + .to.emit(this.feth, "Redeem") + .withArgs(bob.address, redeemAmount, redeemTokens); + tokenBalance = tokenBalance.sub(redeemTokens); + + expect(await this.feth.totalCash()).to.equal(cashNew); + expect(await this.feth.balanceOf(bob.address)).to.equal(tokenBalance); + + // Check for ETH credited to wallet from redeeming + await expect(await this.feth.connect(bob).redeem(redeemTokens)).to.changeEtherBalance(bob, redeemAmount); + }); + + it("should succeed with amount of ETH to redeem provided", async function () { + // Disable mining immediately upon receiving tx + // Check for event first to avoid result mismatch due to interest accrual + await expect(this.feth.connect(bob).redeemUnderlying(redeemAmount)) + .to.emit(this.feth, "Redeem") + .withArgs(bob.address, redeemAmount, redeemTokens); + tokenBalance = tokenBalance.sub(redeemTokens); + + expect(await this.feth.totalCash()).to.equal(cashNew); + expect(await this.feth.balanceOf(bob.address)).to.equal(tokenBalance); + + // Check for ETH credited to wallet from redeeming + await expect(await this.feth.connect(bob).redeemUnderlying(redeemAmount)).to.changeEtherBalance( + bob, + redeemAmount, + ); + }); + + it("should fail with redeem amount greater than amount supplied", async function () { + await expect(this.feth.connect(bob).redeemUnderlying(ethSupplied.add(1))).to.be.reverted; + }); + + it("should fail with redeem amount greater than or equal to cash in market", async function () { + await expect(this.feth.connect(bob).redeemUnderlying(ethSupplied)).to.be.revertedWith( + "TokenBase: Market has insufficient cash", + ); + }); + + it("should fail with redemption causing shortfall", async function () { + // Borrow 4 ETH + await this.feth.connect(bob).borrow(mantissa("4")); + + // Attempt to redeem 1 ETH + await expect(this.feth.connect(bob).redeemUnderlying(redeemAmount)).to.be.revertedWith( + "RiskManager: Insufficient liquidity", + ); + }); + }); +} diff --git a/test/money-market/FEther/FEther.repay.ts b/test/money-market/FEther/FEther.repay.ts new file mode 100644 index 0000000..26ccead --- /dev/null +++ b/test/money-market/FEther/FEther.repay.ts @@ -0,0 +1,81 @@ +import { BigNumber } from "@ethersproject/bignumber"; +import type { SignerWithAddress } from "@nomiclabs/hardhat-ethers/dist/src/signer-with-address"; +import { expect } from "chai"; +import { ethers } from "hardhat"; + +import { mineBlocks } from "../../utils"; + +function mantissa(amount: string): BigNumber { + return ethers.utils.parseUnits(amount, 18); +} + +export function repayTest(): void { + describe("Repay borrowed ETH including interest", async function () { + let bob: SignerWithAddress; + let alice: SignerWithAddress; + + before(async function () { + const signers: SignerWithAddress[] = await ethers.getSigners(); + bob = signers[1]; + alice = signers[2]; + }); + + // Supply 5 ETH and mint fETH + const ethSupplied: BigNumber = mantissa("5"); // 5 ETH + const borrowAmount: BigNumber = mantissa("1"); // 1 ETH + let newBorrowBalance: BigNumber; + + beforeEach(async function () { + // Supply 5 ETH before each test + await this.feth.connect(bob).supply({ value: ethSupplied }); + // Borrow 1 ETH before each test + await this.feth.connect(bob).borrow(borrowAmount); + + const borrowRatePerBlockMantissa: BigNumber = await this.feth.borrowRatePerBlock(); + // 2102400 is the no. of blocks in a year according to interest rate model + const borrowRatePerYearMantissa: BigNumber = borrowRatePerBlockMantissa.mul(2102400); + const oldBorrowIndex: BigNumber = await this.feth.borrowIndex(); + const multiplierMantissa: BigNumber = borrowRatePerYearMantissa.add(mantissa("1")); + const newBorrowIndex: BigNumber = oldBorrowIndex.mul(multiplierMantissa).div(mantissa("1")); + // Borrow balance after 1 year + newBorrowBalance = borrowAmount.mul(newBorrowIndex).div(oldBorrowIndex); + + // Mine 2102399 blocks + await mineBlocks(2102399); + }); + + it("should succeed with repayer being borrower", async function () { + // Repay whole borrow balance + const repayAmount: BigNumber = newBorrowBalance; + await expect(this.feth.connect(bob).repayBorrow({ value: repayAmount })) + .to.emit(this.feth, "RepayBorrow") + .withArgs(bob.address, bob.address, repayAmount, 0, 0); + + expect(await this.feth.borrowBalanceCurrent(bob.address)).to.equal(0); + expect(await this.feth.totalBorrows()).to.equal(0); + }); + + it("should succeed with repayer not being borrower", async function () { + // Repay whole borrow balance + const repayAmount: BigNumber = newBorrowBalance; + await expect(this.feth.connect(alice).repayBorrowBehalf(bob.address, { value: repayAmount })) + .to.emit(this.feth, "RepayBorrow") + .withArgs(alice.address, bob.address, repayAmount, 0, 0); + + expect(await this.feth.borrowBalanceCurrent(bob.address)).to.equal(0); + expect(await this.feth.totalBorrows()).to.equal(0); + }); + + it("should succeed with borrow balance not fully repaid", async function () { + // Repay 1 ETH + const repayAmount: BigNumber = mantissa("1"); + const borrowBalanceAfterRepay: BigNumber = newBorrowBalance.sub(repayAmount); + await expect(this.feth.connect(bob).repayBorrow({ value: repayAmount })) + .to.emit(this.feth, "RepayBorrow") + .withArgs(bob.address, bob.address, repayAmount, borrowBalanceAfterRepay, borrowBalanceAfterRepay); + + expect(await this.feth.borrowBalanceCurrent(bob.address)).to.equal(borrowBalanceAfterRepay); + expect(await this.feth.totalBorrows()).to.equal(borrowBalanceAfterRepay); + }); + }); +} diff --git a/test/money-market/FEther/FEther.supply.ts b/test/money-market/FEther/FEther.supply.ts new file mode 100644 index 0000000..ff65414 --- /dev/null +++ b/test/money-market/FEther/FEther.supply.ts @@ -0,0 +1,51 @@ +import { BigNumber } from "@ethersproject/bignumber"; +import type { SignerWithAddress } from "@nomiclabs/hardhat-ethers/dist/src/signer-with-address"; +import { expect } from "chai"; +import { ethers } from "hardhat"; + +function mantissa(amount: string): BigNumber { + return ethers.utils.parseUnits(amount, 18); +} + +export function supplyTest(): void { + describe("Supply ETH to mint fETH", async function () { + let admin: SignerWithAddress; + let bob: SignerWithAddress; + + before(async function () { + const signers: SignerWithAddress[] = await ethers.getSigners(); + admin = signers[0]; + bob = signers[1]; + }); + + const ethSupplied: BigNumber = mantissa("5"); + // Initial exchange rate is 1 fToken = 50 underlying + const calMintAmount: BigNumber = mantissa("5").div(50); + + it("should succeed", async function () { + // Bob supplies 5 ETH to get fETH + // Check for event first to avoid result mismatch due to interest accrual + await expect(this.feth.connect(bob).supply({ value: ethSupplied })) + .to.emit(this.feth, "Supply") + .withArgs(bob.address, ethSupplied, calMintAmount); + + expect(await this.feth.totalSupply()).to.equal(calMintAmount); + expect(await this.feth.totalCash()).to.equal(ethSupplied); + + // Check for ETH reduced from wallet after supplying + await expect(await this.feth.connect(bob).supply({ value: ethSupplied })).to.changeEtherBalance( + bob, + ethSupplied.mul(-1), + ); + }); + + it("should fail when supply is paused", async function () { + // Admin pauses supply function of fETH market + await this.rm.connect(admin).setSupplyPaused(this.feth.address, true); + + await expect(this.feth.connect(bob).supply({ value: ethSupplied })).to.be.revertedWith( + "RiskManager: Supplying is paused", + ); + }); + }); +} diff --git a/test/money-market/FEther/FEther.ts b/test/money-market/FEther/FEther.ts new file mode 100644 index 0000000..6f8f9bf --- /dev/null +++ b/test/money-market/FEther/FEther.ts @@ -0,0 +1,34 @@ +import { loadFixture } from "@nomicfoundation/hardhat-network-helpers"; + +import { borrowTest } from "./FEther.borrow"; +import { deployFEtherFixture } from "./FEther.fixture"; +import { liquidateTest } from "./FEther.liquidate"; +import { redeemTest } from "./FEther.redeem"; +import { repayTest } from "./FEther.repay"; +import { supplyTest } from "./FEther.supply"; + +describe("FEther", function () { + before(async function () { + this.loadFixture = loadFixture; + }); + + beforeEach(async function () { + const { checker, spo, rm, nirm, feth, ffur } = await this.loadFixture(deployFEtherFixture); + this.checker = checker; + this.spo = spo; + this.rm = rm; + this.nirm = nirm; + this.feth = feth; + this.ffur = ffur; + }); + + //supplyTest(); + + //redeemTest(); + + //borrowTest(); + + //repayTest(); + + liquidateTest(); +}); diff --git a/test/money-market/NormalInterestRateModel/NIRM.borrowRate.ts b/test/money-market/NormalInterestRateModel/NIRM.borrowRate.ts new file mode 100644 index 0000000..45563b1 --- /dev/null +++ b/test/money-market/NormalInterestRateModel/NIRM.borrowRate.ts @@ -0,0 +1,30 @@ +import { BigNumber } from "@ethersproject/bignumber"; +import { expect } from "chai"; +import { ethers } from "hardhat"; + +function mantissa(amount: string): BigNumber { + return ethers.utils.parseUnits(amount, 18); +} + +export function borrowRateTest(): void { + describe("Borrow Rate", function () { + // Utilization rate = 0.5 + const cash: BigNumber = mantissa("10500"); + const borrow: BigNumber = mantissa("10000"); + const reserve: BigNumber = mantissa("500"); + const multiplierPerYear: BigNumber = mantissa("0.2"); + const basePerYear: BigNumber = mantissa("0.03"); + + it("should be correctly calculated", async function () { + const blocksPerYear: BigNumber = await this.nirm.blocksPerYear(); + const multiplierPerBlock: BigNumber = multiplierPerYear.div(blocksPerYear); + const basePerBlock: BigNumber = basePerYear.div(blocksPerYear); + // Utilization rate = 1/2 + const calculatedBorrowRatePerBlock: BigNumber = multiplierPerBlock.div(2).add(basePerBlock); + + // Borrow rate per block from contract + const contractBorrowRatePerBlock: BigNumber = await this.nirm.getBorrowRate(cash, borrow, reserve); + expect(contractBorrowRatePerBlock).to.equal(calculatedBorrowRatePerBlock); + }); + }); +} diff --git a/test/money-market/NormalInterestRateModel/NIRM.fixture.ts b/test/money-market/NormalInterestRateModel/NIRM.fixture.ts new file mode 100644 index 0000000..5245e40 --- /dev/null +++ b/test/money-market/NormalInterestRateModel/NIRM.fixture.ts @@ -0,0 +1,23 @@ +import { BigNumber } from "@ethersproject/bignumber"; +import type { SignerWithAddress } from "@nomiclabs/hardhat-ethers/dist/src/signer-with-address"; +import { ethers } from "hardhat"; + +import type { NormalInterestRateModel } from "../../../src/types/contracts/money-market/NormalInterestRateModel"; +import type { NormalInterestRateModel__factory } from "../../../src/types/factories/contracts/money-market/NormalInterestRateModel__factory"; + +function mantissa(amount: string): BigNumber { + return ethers.utils.parseUnits(amount, 18); +} + +export async function deployNirmFixture(): Promise<{ nirm: NormalInterestRateModel }> { + // Signers declaration + const signers: SignerWithAddress[] = await ethers.getSigners(); + const admin: SignerWithAddress = signers[0]; + + // Deploy interest rate model + const nirmFactory: NormalInterestRateModel__factory = await ethers.getContractFactory("NormalInterestRateModel"); + const nirm = await nirmFactory.connect(admin).deploy(mantissa("0.03"), mantissa("0.2")); + await nirm.deployed(); + + return { nirm }; +} diff --git a/test/money-market/NormalInterestRateModel/NIRM.supplyRate.ts b/test/money-market/NormalInterestRateModel/NIRM.supplyRate.ts new file mode 100644 index 0000000..5627301 --- /dev/null +++ b/test/money-market/NormalInterestRateModel/NIRM.supplyRate.ts @@ -0,0 +1,31 @@ +import { BigNumber } from "@ethersproject/bignumber"; +import { expect } from "chai"; +import { ethers } from "hardhat"; + +function mantissa(amount: string): BigNumber { + return ethers.utils.parseUnits(amount, 18); +} + +export function supplyRateTest(): void { + describe("Supply Rate", function () { + // Utilization rate = 0.5 + const cash: BigNumber = mantissa("10500"); + const borrow: BigNumber = mantissa("10000"); + const reserve: BigNumber = mantissa("500"); + + it("should be correctly calculated", async function () { + const contractBorrowRatePerBlock: BigNumber = await this.nirm.getBorrowRate(cash, borrow, reserve); + // Reserve factor = 1- 2% = 98% = 0.98 = 98/100 + const calculatedSupplyRatePerBlock: BigNumber = contractBorrowRatePerBlock.mul(98).div(100).div(2); + + // Supply rate per block from contract, assume 2% reserve factor + const contractSupplyRatePerBlock: BigNumber = await this.nirm.getSupplyRate( + cash, + borrow, + reserve, + mantissa("0.02"), + ); + expect(contractSupplyRatePerBlock).to.equal(calculatedSupplyRatePerBlock); + }); + }); +} diff --git a/test/money-market/NormalInterestRateModel/NIRM.ts b/test/money-market/NormalInterestRateModel/NIRM.ts new file mode 100644 index 0000000..031b185 --- /dev/null +++ b/test/money-market/NormalInterestRateModel/NIRM.ts @@ -0,0 +1,24 @@ +import { loadFixture } from "@nomicfoundation/hardhat-network-helpers"; + +import { borrowRateTest } from "./NIRM.borrowRate"; +import { deployNirmFixture } from "./NIRM.fixture"; +import { supplyRateTest } from "./NIRM.supplyRate"; +import { utilizationRateTest } from "./NIRM.utilizationRate"; + +describe("NormalInterestRateModel", function () { + // Signers declaration + before(async function () { + this.loadFixture = loadFixture; + }); + + beforeEach(async function () { + const { nirm } = await this.loadFixture(deployNirmFixture); + this.nirm = nirm; + }); + + utilizationRateTest(); + + borrowRateTest(); + + supplyRateTest(); +}); diff --git a/test/money-market/NormalInterestRateModel/NIRM.utilizationRate.ts b/test/money-market/NormalInterestRateModel/NIRM.utilizationRate.ts new file mode 100644 index 0000000..a43bd20 --- /dev/null +++ b/test/money-market/NormalInterestRateModel/NIRM.utilizationRate.ts @@ -0,0 +1,24 @@ +import { BigNumber } from "@ethersproject/bignumber"; +import { expect } from "chai"; +import { ethers } from "hardhat"; + +function mantissa(amount: string): BigNumber { + return ethers.utils.parseUnits(amount, 18); +} + +export function utilizationRateTest(): void { + describe("Utilization Rate", function () { + const cash: BigNumber = mantissa("10500"); + const borrow0: number = 0; + const borrow: BigNumber = mantissa("10000"); + const reserve: BigNumber = mantissa("500"); + + it("should be correctly calculated", async function () { + const result0: BigNumber = await this.nirm.utilizationRate(cash, borrow0, reserve); + expect(result0).to.equal(0); + + const result: BigNumber = await this.nirm.utilizationRate(cash, borrow, reserve); + expect(result).to.equal(mantissa("0.5")); + }); + }); +} diff --git a/test/money-market/RiskManager/RiskManager.admin.ts b/test/money-market/RiskManager/RiskManager.admin.ts new file mode 100644 index 0000000..292ba42 --- /dev/null +++ b/test/money-market/RiskManager/RiskManager.admin.ts @@ -0,0 +1,38 @@ +import { BigNumber } from "@ethersproject/bignumber"; +import type { SignerWithAddress } from "@nomiclabs/hardhat-ethers/dist/src/signer-with-address"; +import { expect } from "chai"; +import { ethers } from "hardhat"; + +function mantissa(amount: string): BigNumber { + return ethers.utils.parseUnits(amount, 18); +} + +export function adminTest(): void { + describe("Admin Functions", function () { + let admin: SignerWithAddress; + let bob: SignerWithAddress; + + before(async function () { + const signers: SignerWithAddress[] = await ethers.getSigners(); + admin = signers[0]; + bob = signers[1]; + }); + + it("should deploy correctly", async function () { + expect(await this.rm.admin()).to.equal(admin.address); + }); + + it("should only allow admin to list markets", async function () { + // Non-admin tries to list fETH market + await expect(this.rm.connect(bob).supportMarket(this.feth.address, mantissa("0.85"), 1)).to.be.revertedWith( + "RiskManager: Not authorized to call", + ); + + // Admin lists fETH market with 0.85 collateral factor and as tier 1 + await expect(this.rm.connect(admin).supportMarket(this.feth.address, mantissa("0.85"), 1)) + .to.emit(this.rm, "MarketListed") + .withArgs(this.feth.address); + expect(await this.rm.checkListed(this.feth.address)).to.equal(true); + }); + }); +} diff --git a/test/money-market/RiskManager/RiskManager.fixture.ts b/test/money-market/RiskManager/RiskManager.fixture.ts new file mode 100644 index 0000000..59150b7 --- /dev/null +++ b/test/money-market/RiskManager/RiskManager.fixture.ts @@ -0,0 +1,73 @@ +import { BigNumber } from "@ethersproject/bignumber"; +import type { SignerWithAddress } from "@nomiclabs/hardhat-ethers/dist/src/signer-with-address"; +import { ethers, upgrades } from "hardhat"; + +import type { Checker } from "../../../typechain/contracts/Checker"; +import type { veFUR } from "../../../typechain/contracts/furion-staking/VoteEscrowedFurion"; +import type { FEther } from "../../../typechain/contracts/money-market/FEther"; +import type { NormalInterestRateModel } from "../../../typechain/contracts/money-market/NormalInterestRateModel"; +import type { RiskManager } from "../../../typechain/contracts/money-market/RiskManager"; +import type { SimplePriceOracle } from "../../../typechain/contracts/money-market/SimplePriceOracle"; +import type { FurionToken } from "../../../typechain/contracts/tokens/FurionToken"; +import type { Checker__factory } from "../../../typechain/factories/contracts/Checker__factory"; +import type { veFUR__factory } from "../../../typechain/factories/contracts/furion-staking/VoteEscrowedFurion__factory"; +import type { FEther__factory } from "../../../typechain/factories/contracts/money-market/FEther__factory"; +import type { NormalInterestRateModel__factory } from "../../../typechain/factories/contracts/money-market/NormalInterestRateModel__factory"; +import type { RiskManager__factory } from "../../../typechain/factories/contracts/money-market/RiskManager__factory"; +import type { SimplePriceOracle__factory } from "../../../typechain/factories/contracts/money-market/SimplePriceOracle__factory"; +import type { FurionToken__factory } from "../../../typechain/factories/contracts/tokens/FurionToken__factory"; + +function mantissa(amount: string): BigNumber { + return ethers.utils.parseUnits(amount, 18); +} + +export async function deployRiskManagerFixture(): Promise<{ + spo: SimplePriceOracle; + rm: RiskManager; + nirm: NormalInterestRateModel; + feth: FEther; +}> { + const signers: SignerWithAddress[] = await ethers.getSigners(); + const admin: SignerWithAddress = signers[0]; + + // Deploy price oracle + const spoFactory: SimplePriceOracle__factory = await ethers.getContractFactory("SimplePriceOracle"); + const spo = await spoFactory.connect(admin).deploy(); + await spo.deployed(); + + // Deploy risk manager + const rmFactory: RiskManager__factory = await ethers.getContractFactory("RiskManager"); + const rm = await upgrades.deployProxy(rmFactory, [spo.address]); + await rm.deployed(); + + // Deploy FUR & veFUR -> set veFUR for Risk Manager + const furFactory: FurionTokenTest__factory = await ethers.getContractFactory("FurionToken"); + const fur = await furFactory.connect(admin).deploy(); + await fur.deployed(); + const veFurFactory: veFUR__factory = await ethers.getContractFactory("VoteEscrowedFurion"); + const veFur = await upgrades.deployProxy(veFurFactory, [fur.address]); + await veFur.deployed(); + await rm.setVeToken(veFur.address); + + // Deploy interest rate model + const nirmFactory: NormalInterestRateModel__factory = await ethers.getContractFactory("NormalInterestRateModel"); + const nirm = await nirmFactory.connect(admin).deploy(mantissa("0.03"), mantissa("0.2")); + await nirm.deployed(); + + // Deploy checker + const checkerFactory: Checker__factory = await ethers.getContractFactory("Checker"); + const checker = await checkerFactory.connect(admin).deploy(); + await checker.deployed(); + + // Deploy FEther + const fethFactory = await ethers.getContractFactory("FEther"); + const feth = ( + await upgrades.deployProxy(fethFactory, [rm.address, nirm.address, spo.address, checker.address]) + ); + await feth.deployed(); + + // Set fETH market underlying price + await spo.connect(admin).setUnderlyingPrice(feth.address, mantissa("1700"), mantissa("1")); + + return { spo, rm, nirm, feth }; +} diff --git a/test/money-market/RiskManager/RiskManager.market.ts b/test/money-market/RiskManager/RiskManager.market.ts new file mode 100644 index 0000000..0182c2e --- /dev/null +++ b/test/money-market/RiskManager/RiskManager.market.ts @@ -0,0 +1,54 @@ +import { BigNumber } from "@ethersproject/bignumber"; +import type { SignerWithAddress } from "@nomiclabs/hardhat-ethers/dist/src/signer-with-address"; +import { expect } from "chai"; +import { ethers } from "hardhat"; + +function mantissa(amount: string): BigNumber { + return ethers.utils.parseUnits(amount, 18); +} + +export function marketTest(): void { + describe("Market Functions", function () { + let admin: SignerWithAddress; + let bob: SignerWithAddress; + + before(async function () { + const signers: SignerWithAddress[] = await ethers.getSigners(); + admin = signers[0]; + bob = signers[1]; + }); + + context("Enter markets", function () { + it("should succeed", async function () { + // List fETH market + await this.rm.connect(admin).supportMarket(this.feth.address, mantissa("0.85"), 1); + + // Bob enters fETH market + await expect(this.rm.connect(bob).enterMarkets([this.feth.address])) + .to.emit(this.rm, "MarketEntered") + .withArgs(this.feth.address, bob.address); + expect(await this.rm.checkMembership(bob.address, this.feth.address)).to.equal(true); + }); + + it("should fail with unlisted market", async function () { + await expect(this.rm.connect(bob).enterMarkets([this.feth.address])).to.be.revertedWith( + "RiskManager: Market is not listed", + ); + }); + }); + + context("Exit markets", function () { + it("should succeed with no borrow balance", async function () { + // List fETH market + await this.rm.connect(admin).supportMarket(this.feth.address, mantissa("0.85"), 1); + // Enter fETH market + await this.rm.connect(bob).enterMarkets([this.feth.address]); + + // Bob exits fETH market + await expect(this.rm.connect(bob).exitMarket(this.feth.address)) + .to.emit(this.rm, "MarketExited") + .withArgs(this.feth.address, bob.address); + }); + }); + }); +} diff --git a/test/money-market/RiskManager/RiskManager.ts b/test/money-market/RiskManager/RiskManager.ts new file mode 100644 index 0000000..00f51f7 --- /dev/null +++ b/test/money-market/RiskManager/RiskManager.ts @@ -0,0 +1,32 @@ +import { loadFixture } from "@nomicfoundation/hardhat-network-helpers"; +import type { SignerWithAddress } from "@nomiclabs/hardhat-ethers/dist/src/signer-with-address"; +import { ethers } from "hardhat"; + +import type { Signers } from "../../types"; +import { adminTest } from "./RiskManager.admin"; +import { deployRiskManagerFixture } from "./RiskManager.fixture"; +import { marketTest } from "./RiskManager.market"; + +describe("RiskManager", function () { + // Signers declaration + before(async function () { + this.signers = {} as Signers; + const signers: SignerWithAddress[] = await ethers.getSigners(); + this.signers.admin = signers[0]; + this.signers.bob = signers[1]; + + this.loadFixture = loadFixture; + }); + + beforeEach(async function () { + const { spo, rm, nirm, feth } = await this.loadFixture(deployRiskManagerFixture); + this.spo = spo; + this.rm = rm; + this.nirm = nirm; + this.feth = feth; + }); + + adminTest(); + + marketTest(); +}); diff --git a/test/types.ts b/test/types.ts index fcae720..9792978 100644 --- a/test/types.ts +++ b/test/types.ts @@ -4,11 +4,17 @@ import type { AggregatePool, AggregatePoolFactory, Checker, + FErc20, + FEther, FractionalAggregatePool, FurionToken, + JumpInterestRateModel, MockERC721, + NormalInterestRateModel, + RiskManager, SeparatePool, SeparatePoolFactory, + SimplePriceOracle, } from "../src/types"; type Fixture = () => Promise; @@ -26,6 +32,13 @@ declare module "mocha" { fap: FractionalAggregatePool; checker: Checker; fur: FurionToken; + ferc: FErc20; + feth: FEther; + ffur: FErc20; + nirm: NormalInterestRateModel; + jirm: JumpInterestRateModel; + rm: RiskManager; + spo: SimplePriceOracle; loadFixture: (fixture: Fixture) => Promise; signers: Signers; }