EVC Basics Part 2: Borrowable-Vault


The next part of our lending market is going to be a borrowable vault which wraps an asset that can be lent out against the collateral in our deposit only vault. In order to do this we will need to extend our VaultSimple
contract with additional functionality. The additional functionality added to our borrowable vault will implement a controller, allowing our borrowable vault to control
an accounts deposited collateral in order to be able to seize them in the event of a collateral requirement violation aka a liquidation event.
Full code can, once again, be found in the EVC Playground Repo
To get started, we will import the VaultSimple
we will be extending and define some borrow specific events.
// SPDX-License-Identifier: GPL-2.0-or-later
pragma solidity ^0.8.19;
import "./VaultSimple.sol";
/// @title VaultSimpleBorrowable
/// @notice This contract extends VaultSimple to add borrowing functionality.
/// @notice In this contract, the EVC is authenticated before any action that may affect the state of the vault or an
/// account. This is done to ensure that if it's EVC calling, the account is correctly authorized and the vault is
/// enabled as a controller if needed. This contract does not take the account health into account when calculating max
/// withdraw and max redeem values. This contract does not implement the interest accrual hence it returns raw values of
/// total borrows and 0 for the interest accumulator in the interest accrual-related functions.
contract VaultSimpleBorrowable is VaultSimple {
using SafeTransferLib for ERC20;
using FixedPointMathLib for uint256;
event BorrowCapSet(uint256 newBorrowCap);
event Borrow(address indexed caller, address indexed owner, uint256 assets);
event Repay(address indexed caller, address indexed receiver, uint256 assets);
error BorrowCapExceeded();
error AccountUnhealthy();
error OutstandingDebt();
uint256 public borrowCap;
uint256 internal _totalBorrowed;
mapping(address account => uint256 assets) internal owed;
constructor(
address _evc,
ERC20 _asset,
string memory _name,
string memory _symbol
) VaultSimple(_evc, _asset, _name, _symbol) {}
}
The borrowable vault will also need a setter function to allow the vault governor (owner
) to set the borrow cap of the vault
/// @notice Sets the borrow cap.
/// @param newBorrowCap The new borrow cap.
function setBorrowCap(uint256 newBorrowCap) external onlyOwner {
borrowCap = newBorrowCap;
emit BorrowCapSet(newBorrowCap);
}
Next, the vault will implement two view function to get information about the debt. The first being the totalBorrowed
amount for the entire vault. This implementation will call the _accrueInterestCalculation
, which is an internal function that will be implemented later on, to return the total borrowed including outstanding interest. For now, we simply assume 0 interest rate as we will define an interest rate model later on.
/// @notice Returns the total borrowed assets from the vault.
/// @return The total borrowed assets from the vault.
function totalBorrowed() public view virtual returns (uint256) {
(uint256 currentTotalBorrowed,,) = _accrueInterestCalculate();
return currentTotalBorrowed;
}
/// @notice Calculates the accrued interest.
/// @dev Because this contract does not implement the interest accrual, this function does not need to calculate the
/// interest, but only returns the current value of total borrows, 0 for the interest accumulator and false for the
/// update flag. This function is needed so that it can be overriden by child contracts without a need to override
/// other functions which use it.
/// @return The total borrowed amount, the interest accumulator and a boolean value that indicates whether the data
/// should be updated.
function _accrueInterestCalculate() internal view virtual returns (uint256, uint256, bool) {
return (_totalBorrowed, 0, false);
}
Additionally, the borrowable vault will expose a view function for the users account debt, implemented by calling an internal function to return the accounts mapping of its outstanding debt.
/// @notice Returns the debt of an account.
/// @param account The account to check.
/// @return The debt of the account.
function debtOf(address account) public view virtual returns (uint256) {
return _debtOf(account);
}
/// @notice Returns the debt of an account.
/// @param account The account to check.
/// @return The debt of the account.
function _debtOf(address account) internal view virtual returns (uint256) {
return owed[account];
}
Next, we implement function overrides for maxWithdraw
and maxRedeem
since, in the context of our borrowable vault, the maximum amount withdraw-able must be adjusted to account for the fact that some funds from the vault might be lent out at the time of withdrawal. Thus the maximum amount that can be withdrawn from the vault is either the user's deposited balance or the total amount in the vault if some of the user's balance happens to be lent out.
/// @notice Returns the maximum amount that can be withdrawn by an owner.
/// @dev This function is overridden to take into account the fact that some of the assets may be borrowed.
/// @param owner The owner of the assets.
/// @return The maximum amount that can be withdrawn.
function maxWithdraw(address owner) public view virtual override returns (uint256) {
uint256 totAssets = _totalAssets;
uint256 ownerAssets = _convertToAssets(balanceOf[owner], false);
return ownerAssets > totAssets ? totAssets : ownerAssets;
}
/// @notice Returns the maximum amount that can be redeemed by an owner.
/// @dev This function is overridden to take into account the fact that some of the assets may be borrowed.
/// @param owner The owner of the assets.
/// @return The maximum amount that can be redeemed.
function maxRedeem(address owner) public view virtual override returns (uint256) {
uint256 totAssets = _totalAssets;
uint256 ownerShares = balanceOf[owner];
return _convertToAssets(ownerShares, false) > totAssets ? _convertToShares(totAssets, false) : ownerShares;
}
Now since our borrowable vault has a different application requirements than our deposit only vault, we need to override the vault snapshot function to return the vault's state values that are relevant to the calculations required by our application. In the case of a borrowable vault, this would be the total assets and the currentTotalBorrowed. Further, since this function is called to cache the state of the vault prior to any actions that effect the vault's state (i.e before a borrow, repayment etc) we use this opportunity to update the _accrueInterest
calculation ensuring the totalBorrowed value remains consistent and up to date.
/// @notice Creates a snapshot of the vault.
/// @dev This function is called before any action that may affect the vault's state. Considering that and the fact
/// that this function is only called once per the EVC checks deferred context, it can be also used to accrue
/// interest.
/// @return A snapshot of the vault's state.
function doCreateVaultSnapshot() internal virtual override returns (bytes memory) {
(uint256 currentTotalBorrowed,) = _accrueInterest();
// make total assets and total borrows snapshot:
return abi.encode(_totalAssets, currentTotalBorrowed);
}
/// @notice Accrues interest.
/// @dev Because this contract does not implement the interest accrual, this function does not need to update the
/// state, but only returns the current value of total borrows and 0 for the interest accumulator. This function is
/// needed so that it can be overriden by child contracts without a need to override other functions which use it.
/// @return The current values of total borrowed and interest accumulator.
function _accrueInterest() internal virtual returns (uint256, uint256) {
return (_totalBorrowed, 0);
}
Additionally, we will need to update the vault status check logic, doCheckVaultStatus
is a check that is called after an action that may change a vault's state. Thus, this function takes the previous vault snapshot as an argument and implements logic the validate that the new state of the vault, after an action has been called, remains consistent and conforms to the constraints of our vaults requirements. In this case, that means we check that the supplyCap
and the borrowCap
have not been violated. Further, since the interest rate of a lending market is a function of the amount borrowed and the total assets in the vault, both of which may have been updated in the action preceding this check, we use this opportunity to call the internal _updateInteres()
function. For now, we will leave this unimplemented as we are not charging interest in this example
/// @notice Checks the vault's status.
/// @dev This function is called after any action that may affect the vault's state. Considering that and the fact
/// that this function is only called once per the EVC checks deferred context, it can be also used to update the
/// interest rate. `IVault.checkVaultStatus` can only be called from the EVC and only while checks are in progress
/// because of the `onlyEVCWithChecksInProgress` modifier. So it can't be called at any other time to reset the
/// snapshot mid-batch.
/// @param oldSnapshot The snapshot of the vault's state before the action.
function doCheckVaultStatus(bytes memory oldSnapshot) internal virtual override {
// sanity check in case the snapshot hasn't been taken
if (oldSnapshot.length == 0) revert SnapshotNotTaken();
// use the vault status hook to update the interest rate (it should happen only once per transaction).
// EVC.forgiveVaultStatus check should never be used for this vault, otherwise the interest rate will not be
// updated.
// this contract doesn't implement the interest accrual, so this function does nothing. needed for the sake of
// inheritance
_updateInterest();
// validate the vault state here:
(uint256 initialAssets, uint256 initialBorrowed) = abi.decode(oldSnapshot, (uint256, uint256));
uint256 finalAssets = _totalAssets;
(uint256 finalBorrowed,,) = _accrueInterestCalculate();
// the supply cap can be implemented like this:
if (
supplyCap != 0 && finalAssets + finalBorrowed > supplyCap
&& finalAssets + finalBorrowed > initialAssets + initialBorrowed
) {
revert SupplyCapExceeded();
}
// or the borrow cap can be implemented like this:
if (borrowCap != 0 && finalBorrowed > borrowCap && finalBorrowed > initialBorrowed) {
revert BorrowCapExceeded();
}
}
/// @notice Updates the interest rate.
function _updateInterest() internal virtual {}
Along with requiring vault checks after an action updates the vault's state, our borrowable vault will also require checks after an account's state is updated. For the purposes of our lending market, these checks will be centered around ensuring that any action does not result in our account ending up in an unhealthy state. To do this we will first call the internal _calculateLiabilityAndCollateral
call and then check that the liability value is not greater than the value of our collateral. Since our EVC lending market is modular and extendable to any arbitrary collateral vault we use the account and a list of collaterals for the account as arguments.
In this example we will simply create a placeholder collateralValue calculation, implementing a check that requires the only collateral value returned be the same asset deposited into this (the borrowable vault) at 90% of it's value. Later on will modify this function to integrate with our initial deposit only vault. We will also save gas by offering a flag bypassing the collateral check if the account has no liabilities (debt)
/// @notice Checks the status of an account.
/// @dev This function is called after any action that may affect the account's state.
/// @param account The account to check.
/// @param collaterals The collaterals of the account.
function doCheckAccountStatus(address account, address[] calldata collaterals) internal view virtual override {
(, uint256 liabilityValue, uint256 collateralValue) =
_calculateLiabilityAndCollateral(account, collaterals, true);
if (liabilityValue > collateralValue) {
revert AccountUnhealthy();
}
}
/// @notice Calculates the liability and collateral of an account.
/// @param account The account.
/// @param collaterals The collaterals of the account.
/// @param skipCollateralIfNoLiability A flag indicating whether to skip collateral calculation if the account has
/// no liability.
/// @return liabilityAssets The liability assets.
/// @return liabilityValue The liability value.
/// @return collateralValue The risk-adjusted collateral value.
function _calculateLiabilityAndCollateral(
address account,
address[] memory collaterals,
bool skipCollateralIfNoLiability
) internal view virtual returns (uint256 liabilityAssets, uint256 liabilityValue, uint256 collateralValue) {
liabilityAssets = _debtOf(account);
if (liabilityAssets == 0 && skipCollateralIfNoLiability) {
return (0, 0, 0);
} else if (liabilityAssets > 0) {
// pricing doesn't matter
liabilityValue = liabilityAssets;
}
// in this simple example, let's say that it's only possible to borrow against
// the same asset up to 90% of its value
for (uint256 i = 0; i < collaterals.length; ++i) {
if (collaterals[i] == address(this)) {
collateralValue = _convertToAssets(balanceOf[account], false) * 9 / 10;
break;
}
}
}
Now, two of our view function from our SimpleVault
will need to be overridden. The _convertToAssets
and _convertToShares
use the totalAssets to determine the share to asset conversion, however in the context of our lending vault we need to account for the totalBorrowed amount in this calculation.
/// @dev This function is overridden to take into account the fact that some of the assets may be borrowed.
function _convertToShares(uint256 assets, bool roundUp) internal view virtual override returns (uint256) {
(uint256 currentTotalBorrowed,,) = _accrueInterestCalculate();
return roundUp
? assets.mulDivUp(totalSupply + 1, _totalAssets + currentTotalBorrowed + 1)
: assets.mulDivDown(totalSupply + 1, _totalAssets + currentTotalBorrowed + 1);
}
/// @dev This function is overridden to take into account the fact that some of the assets may be borrowed.
function _convertToAssets(uint256 shares, bool roundUp) internal view virtual override returns (uint256) {
(uint256 currentTotalBorrowed,,) = _accrueInterestCalculate();
return roundUp
? shares.mulDivUp(_totalAssets + currentTotalBorrowed + 1, totalSupply + 1)
: shares.mulDivDown(_totalAssets + currentTotalBorrowed + 1, totalSupply + 1);
}
Our vault will also need to implement methods to increase and decrease the amount owed by any account taking a loan from the vault.
/// @notice Increases the owed amount of an account.
/// @param account The account.
/// @param assets The assets.
function _increaseOwed(address account, uint256 assets) internal virtual {
owed[account] += assets;
_totalBorrowed += assets;
}
/// @notice Decreases the owed amount of an account.
/// @param account The account.
/// @param assets The assets.
function _decreaseOwed(address account, uint256 assets) internal virtual {
owed[account] -= assets;
uint256 __totalBorrowed = _totalBorrowed;
_totalBorrowed = __totalBorrowed >= assets ? __totalBorrowed - assets : 0;
}
Finally, we need to implement the lending functionality for our vault. First, we provide a view function to get the liability status of an account. We also need to provide the functionality for a users account to disassociate with the vault as a controller if it has no debt.
/// @notice Disables the controller.
/// @dev The controller is only disabled if the account has no debt. If the account has outstanding debt, the
/// function reverts.
function disableController() external virtual override nonReentrant {
// ensure that the account does not have any liabilities before disabling controller
address msgSender = _msgSender();
if (_debtOf(msgSender) == 0) {
EVCClient.disableController(msgSender);
} else {
revert OutstandingDebt();
}
}
/// @notice Retrieves the liability and collateral value of a given account.
/// @dev Account status is considered healthy if the collateral value is greater than or equal to the liability.
/// @param account The address of the account to retrieve the liability and collateral value for.
/// @return liabilityValue The total liability value of the account.
/// @return collateralValue The total collateral value of the account.
function getAccountLiabilityStatus(address account)
external
view
virtual
returns (uint256 liabilityValue, uint256 collateralValue)
{
(, liabilityValue, collateralValue) = _calculateLiabilityAndCollateral(account, getCollaterals(account), false);
}
We now have all the functionality necessary to write our borrow logic. To do this we must first add a modifier to the function requiring that it be called through the EVC to ensure that borrow conforms to the constraints of our vault by requiring it to be called via the EVC.
By setting the msgSender
to _msgSenderForBorrow()
we ensure that, since the function can only be called via the EVC, the EVC caller is calling the operation on behalf of an account which it is authorized to do so. This means it checks whether the caller an the authenticated EVC caller or an enabled controller vault.
Next, the function creates a vault snapshot to make sure the state variables remain consistent throughout the operation. We can than increase the amount owed by the account, emit a borrow event and transfer the borrowed assets to the receiver.
Finally, the accounting is finalized by reducing the global _totalAssets
counter by the amount borrowed. Note that, the global _totalBorrowed
amount is already updated via the _increseOwed()
function's logic. This operation requires that we call the checks function for the vault and account after all state updating operations, ensuring the maximum borrow amount for the vault is respected and that the account health requirement for the borrower is also respected.
/// @notice Borrows assets.
/// @param assets The amount of assets to borrow.
/// @param receiver The receiver of the assets.
function borrow(uint256 assets, address receiver) external callThroughEVC nonReentrant {
address msgSender = _msgSenderForBorrow();
createVaultSnapshot();
require(assets != 0, "ZERO_ASSETS");
_increaseOwed(msgSender, assets);
emit Borrow(msgSender, receiver, assets);
asset.safeTransfer(receiver, assets);
_totalAssets -= assets;
requireAccountAndVaultStatusCheck(msgSender);
}
The next function our lending market is going to need to implement is the ability for a debt to be repaid. Again, this function will be modified to require calling it through the EVC. However, the _msgSender()
for this function will set to the result of the evc.getCurrentOnBehalfOfAccount()
call. This is the account which has authenticated the call with the EVC. Since anyone can payoff the debt of an account, we don't need to call messageSenderForBorrow() which ensures the caller is controllerEnabled.
Once the call is authenticated and before any state is updated, we again need to create a vault snapshot. We are then free to transfer the repayment assets from the msgSender
to the vault contract. update the _totalAssets
global variable and call the _decreaseOwed
function on the account being paid off. Once again, since we are updating an account and vault's state, we need to call requireAccountAndVaultStatusCheck
after all operation have been completed to ensure consistency. However, keeping in mind our application specific requirements (a lending market) we can reason that we may want to allow an account to be unhealthy after repaying debt.
For example, if an account begins this operation in an unhealthy state and is paying back an amount that does not fully restore the accounts solvency we may still want to allow the operation to complete since it benefits the stability of our lending market. So, instead of passing the account being operated on as the status check argument, we can pass address(0)
which tells the logic of the requireAccountAndVaultStatusCheck
to simply call the evc.requireVaultStatusCheck()
along.
/// @notice Repays a debt.
/// @dev This function transfers the specified amount of assets from the caller to the vault.
/// @param assets The amount of assets to repay.
/// @param receiver The receiver of the repayment.
function repay(uint256 assets, address receiver) external callThroughEVC nonReentrant {
address msgSender = _msgSender();
// sanity check: the receiver must be under control of the EVC. otherwise, we allowed to disable this vault as
// the controller for an account with debt
if (!isControllerEnabled(receiver, address(this))) {
revert ControllerDisabled();
}
createVaultSnapshot();
require(assets != 0, "ZERO_ASSETS");
asset.safeTransferFrom(msgSender, address(this), assets);
_totalAssets += assets;
_decreaseOwed(receiver, assets);
emit Repay(msgSender, receiver, assets);
requireAccountAndVaultStatusCheck(address(0));
}
The final functionality we may want to implement for our lending pool vault is pullDebt
. This will allow a caller to rebalance the debt from a given account onto their own account. No funds will actually be transferred in this process, the accounting of the debt will simply be moved from one account to another. This is still equivalent to the caller creating a borrow, so we must authenticate the msgSender with _msgSenderForBorrow
in the same way we did in the borrow
function. After creating a snapshot of the vault and checking the arguments are valid, we can update the state of both accounts; decreasing the debt of the from
parameter and increasing the debt of the caller.
We finally require a vault check and an account status check on the function caller (msgSender
)
/// @notice Pulls debt from an account.
/// @dev This function decreases the debt of one account and increases the debt of another.
/// @dev Despite the lack of asset transfers, this function emits Repay and Borrow events.
/// @param from The account to pull the debt from.
/// @param assets The amount of debt to pull.
/// @return A boolean indicating whether the operation was successful.
function pullDebt(address from, uint256 assets) external callThroughEVC nonReentrant returns (bool) {
address msgSender = _msgSenderForBorrow();
// sanity check: the account from which the debt is pulled must be under control of the EVC.
// _msgSenderForBorrow() checks that `msgSender` is controlled by this vault
if (!isControllerEnabled(from, address(this))) {
revert ControllerDisabled();
}
createVaultSnapshot();
require(assets != 0, "ZERO_AMOUNT");
require(msgSender != from, "SELF_DEBT_PULL");
_decreaseOwed(from, assets);
_increaseOwed(msgSender, assets);
emit Repay(msgSender, from, assets);
emit Borrow(msgSender, msgSender, assets);
requireAccountAndVaultStatusCheck(msgSender);
return true;
}
With that we have implemented all of the initial functionality to create a borrowable vault in our lending market.
Subscribe to my newsletter
Read articles from Yield Dev directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
