Euler Vault Kit
Dariusz Glowinski, Mick de Graaf, Kasper Pawlowski, Anton Totomanov, Tanya Roze, Alberto Cuesta Cañada, Michael Bentley, Doug Hoyte
- Introduction
- Creation
- Accounting
- Interest
- Risk Management
- Price Oracles
- Liquidation
- Perspectives
- Composing Vaults
- Synthetic Asset Vaults
- Interaction Patterns
- Appendices
Introduction
The Euler Vault Kit (EVK) is a system for constructing credit vaults. Credit vaults are ERC-4626 vaults with added borrowing functionality. Unlike typical ERC-4626 vaults which earn yield by actively investing deposited funds, credit vaults are passive lending pools.
Users can borrow from a credit vault as long as they have sufficient collateral deposited in other credit vaults. The liability vault (the one that was borrowed from) decides which credit vaults are acceptable as collateral. Interest is charged to borrowers by continuously increasing the amount of their outstanding liability and this interest results in yield for the depositors.
Vaults are integrated with the Ethereum Vault Connector contract (EVC), which keeps track of the vaults used as collateral by each account. In the event a liquidation is necessary, the EVC allows a liability vault to withdraw collateral on a user's behalf.
The EVC is also an alternate entry-point for interacting with vaults. It provides multicall-like batching, simulations, gasless transactions, and flash liquidity for efficient refinancing of loans. External contracts can be invoked without needing special adaptors, and all functionality is accessible to both EOAs and contract wallets. Although each address is only allowed one outstanding liability at any given time, the EVC provides it with 256 virtual addresses, called sub-accounts (from here on, just accounts). Sub-account addresses are internal to the EVC and compatible vaults, and care should be taken to ensure that these addresses are not used by other contracts.
The EVC is responsible for authentication, and vaults are responsible for authorisation. For example, if a user attempts to redeem a certain amount, the EVC makes sure the request actually came from the user, and the vault makes sure the user actually has this amount.
Creation
A vault consists of several communicating components:
- The underlying asset is the ERC-20 token that will be held by the vault. Each vault holds exactly one underlying asset.
EVault
is the primary entry-point contract and implements the logic that is common to all vaults, such as tracking which addresses have deposited and which have outstanding borrows, validating the health of positions, and permitting liquidations. For code organisation purposes, some of its logic is delegated to static (non-upgradeable) modules.PriceOracle
components interface with external pricing systems to compute the values of collaterals and liabilities in real time.- IRM (Interest Rate Model) components compute interest rates in order to incentivise more or less borrowing.
ProtocolConfig
is a global protocol-level configuration contract. This configuration has no direct influence on the vault except for controlling the destination address of protocol fees, and the (bounded) portion of interest fees to be split between the protocol and vault governor.
Before creating a vault, PriceOracle
and IRM
contracts should be created (or re-used). These will be installed in the EVault
. During creation, EVault
creates a side-car DToken contract to expose a read-only ERC-20 interface for debt amounts.
Upgradeable vs Immutable
EVault
instances are created by a factory contract. This factory has an implementation
address in its storage that points to the actual code-containing contract. Anyone can create proxies that reference this implementation contract, each of which is a vault. When creating a proxy, a boolean upgradeable
flag is specified:
- If
upgradeable
is true, the factory will create a beacon proxy, with the factory itself set as the beacon contract. - If
upgradeable
is false, the factory will create a minimal proxy contract inspired by EIP-3448 MetaProxy, with the current value ofimplementation
as the target contract.
The factory has an upgradeAdmin
address that can change the value of implementation
, but this will only affect vaults that were created as upgradeable
. This allows vault creators to choose whether they want the factory admin to be able to upgrade their vaults, or if instead they should be immutable. In order to prevent the implementation from being changed after validating but before vault creation, a desiredImplementation
parameter can be specified. Alternatively, the vault's implementation can be confirmed to be the desired version after creation.
Verifying that a vault was created by a trusted factory verifies that the vault's code too can be trusted. Users should perform due diligence on vaults created by unknown factories because they could be malicious. User interfaces may choose to only display vaults created by certain factories.
Governed vs Finalised
Immediately after creation, the factory will call initialize()
on the proxy, passing in the creator's address as a parameter. The vault will set its governor to the creator's address. This governor can invoke methods that modify the configuration of the vault.
At this point, the creator should configure the vault as desired and then decide if the vault is to be governed or not.
- If so, the creator retains the governor role or transfers it to another address.
- If not, then the ownership is revoked by setting the governor to
address(0)
. No more governance changes can happen on this vault and it is considered finalised.
If limited governance is desired, the creator can transfer ownership to a smart contract that can only invoke a sub-set of governance methods, perhaps with only certain parameters, or under certain conditions.
Using governance methods for vault configuration even for vaults that ultimately will be finalised simplifies the initialisation interface, and is inspired by unix's fork-exec separation.
The vault uses EVC authentication for the governor, which means that governor actions can be batched together and simulated. However, the vault does not accept advanced EVC authentication methods like operators, sub-accounts, and controlCollateral
.
Using the same code-base and factories, the Euler Vault Kit allows construction of both managed and unmanaged lending products. Managed vaults are intended to be long-lived and are therefore suitable for passive deposits. If market conditions change, an active governor can reconfigure the vault to optimise or protect users. Alternatively, unmanaged vaults are configured statically, and the users themselves (or a higher-level contract) must actively monitor for risks/opportunities and shift their deposits and positions to new vaults as necessary.
Governance Risk
Upgradeable/Immutable and Governed/Finalised are orthogonal properties of a vault, and can be set in any combination. The Euler Vault Kit is an agnostic vault-construction system. It is up to the vault creator to decide the parameters and the governance structure (if any), and ultimately up to the market to decide which vaults should be rewarded with liquidity.
The following table indicates the governance risk profile that results from the vault creator's decisions:
Upgradeable | Immutable | |
---|---|---|
Governed | Factory Admin + Governor | Governor |
Finalised | Factory Admin | None |
Note that there are also risks to creating immutable/finalised vaults:
- If market conditions change such that a formerly safe collateral asset or price oracle becomes unsafe, only governed vaults can be reconfigured.
- If critical bugs are found in the Vault Kit code, the factory admin will only be able to fix upgradeable vaults.
In the event of discovered bugs, a difficult situation can arise for the factory admin. By fixing the bug for upgradeable vaults, sufficient information to exploit the non-upgradeable vaults may be revealed. Factory admins should specify a clear security upgrade policy, and responsibly evaluate the impact of upgrades on a case-by-case basis.
Name and Symbol
Vaults have fixed names and symbols that are determined at creation time. The symbol has three components: An e
prefix, the underlying's symbol, and a numeric ID. Here is a possible example for a USDC
vault:
- Symbol:
eUSDC-1
- Name:
EVK Vault eUSDC-1
The numeric ID is allocated using a simple SequenceRegistry
contract which maintains sequentially-increasing counters for opaque string designators. Vaults use the underlying symbols as designators but anybody can reserve new IDs for any designators at any time. The only guarantee is that no two reservations for the same designator will return the same ID.
If the underlying asset does not implement the symbol()
method, the symbol will be replaced with "UNDEFINED"
.
Standard security properties with names and symbols continue to hold. There is nothing stopping anybody else from deploying an unrelated vault that also has the symbol eUSDC-1
, so symbols cannot be assumed to be globally unique.
Accounting
EVault
contracts are (mostly) standard-conforming ERC-4626 vaults with additional functionality to implement borrowing. Each vault instance holds only one type of token: the vault's underlying asset (simply called asset
in the code). Because ERC-4626 is a superset of ERC-20, vaults are also tokens, called vault shares or ETokens. These shares represent a proportional claim on the vault's assets, and are exchangeable for larger amounts of the underlying asset over time as interest is accrued. As recommended by ERC-4626, shares have the same number of decimals as their underlying asset's token. If the underlying asset does not specify decimals, the vault assumes 18
.
Exchange Rate
A vault has two categories of assets that shares have a claim to:
cash
: Underlying tokens currently held by the vault (in underlying assets)totalBorrows
: Outstanding borrows, including accrued interest (in underlying assets)
The exchange rate represents how much of the underlying asset each vault share is worth. As interest accrues over time, the exchange rate will grow. Conceptually, the exchange rate can be computed by dividing the vault's total assets (cash + totalBorrows
) by the number of outstanding shares, which is tracked as totalShares
.
The vault contract avoids directly computing the exchange rate as a ratio in order to prevent precision loss from affecting tokens with a small number of decimals. For this reason, external users who also wish to convert between assets and shares are advised to use the convertToAssets
and convertToShares
functions from the ERC-4626 standard.
When a vault is first created, it has 0 outstanding shares, meaning that the simple description of the exchange rate above would be undefined (dividing by 0). Furthermore, when there are a very small number of shares and underlying assets, the effects of rounding are more pronounced. For these reasons, vaults apply a "virtual deposit" to the exchange rate calculation. The virtual deposit can be considered a deposit at a 1:1 exchange rate, followed by burning the received shares (see OpenZeppelin's article for more details).
Incorporating the virtual deposit, the full exchange rate equation is the following:
exchangeRate = (cash + totalBorrows + VIRTUAL_DEPOSIT)
/ (totalShares + VIRTUAL_DEPOSIT)
totalBorrows
does not necessarily correspond to the sum of all the individual debts because each account has its debt rounded up, whereas totalBorrows
is rounded up only once.
Since the virtual deposit shares are incorporated into the exchange rate, they will increase in value as interest is accrued, as any other shares do. This interest will remain permanently locked in the vault. Although in most circumstances this locked interest will be negligible, it may be problematic for tokens with high unit value and/or low decimals (WBTC, GUSD). In order to create vaults for such tokens which are expected to contain small deposits, an 18-decimal wrapper contract can be used around the token.
Token Transfers
In order to move tokens in or out of the vault, the vault code has internal abstractions pullAssets
and pushAssets
.
pullAssets
will first attempt to call transferFrom
on the underlying asset using the vault's address as the recipient. If the user has given sufficient approval for the vault, this will succeed. Otherwise, pullAssets
will attempt to use Permit2 to transfer the assets into the vault. Permit2 can enable better user experiences because approvals can be created as signed messages that are bundled into the same EVC batch as a deposit
(for example). Although the user does need to first add an approval for the Permit2 contract, this is a one-time operation and many users will already have done this when interacting with Uniswap or other apps.
pushAssets
always simply uses transfer
on the underlying asset. However, in order to prevent loss of funds, pushAssets
will first check with the EVC to see if the recipient address is a known non-owner address (a virtual sub-account address). If so, it will refuse to transfer the assets. If the underlying asset is EVC-aware (perhaps a nested vault), then the CFG_EVC_COMPATIBLE_ASSET
config flag can be enabled, which prevents this check.
In the event that these abstractions are bypassed and underlying asset tokens are directly transferred to a vault, these tokens can be recovered with the vault's skim()
function. The first user who calls skim()
will claim them by performing an implicit deposit. In certain situations, this can be useful as a more gas-efficient deposit method, however care must be taken that nobody else skims the tokens in between.
Internal Balance Tracking
In order to retrieve the quantity of the underlying asset it currently possesses (cash
), a vault-like contract has two options:
- Read its own balance from the underlying asset by calling
underlying.balanceOf(address(this))
- Keep an internal copy of the expected value in storage, and update it whenever tokens are transferred in or out
The Euler Vault Kit uses the second method, called internal balance tracking. Tracking the balance internally prevents users from manipulating the exchange rate by directly transferring underlying assets to the vault, which could be dangerous when attempting to price the vault's shares. Internal balance tacking also uses slightly less gas for many common operations.
On the other hand, tokens that change balances outside of explicit transfers, such as rebasing/fee-on-transfer tokens, are not supported because the vault is not aware of the unexpected balance changes. This is not viewed as a significant problem since lending such tokens has several well known problems, and regularised wrapper contracts can always be built around them.
Rounding
Internally, debts are tracked with extra precision relative to the liability token's decimals. Externally, debt amounts are always rounded up to the next smallest increment expressible in the liability token. This ensures that borrowers always repay at least what they borrowed and plus any accrued interest.
The quantity of assets that can be redeemed for a given number of shares is always rounded down, and the number of shares required to withdraw a given quantity of assets is rounded up. This ensures that depositors cannot withdraw more than they deposited plus earned interest.
These two behaviours ensure that all impact of rounding happens in favour of the vault (ie, the remaining depositors). However, because the rounding represents an implicit donation to the vault, this means that it can affect the exchange rate. This is one of the reasons for the virtual deposit: even with an empty vault, the effect of rounding on the exchange rate is insignificant.
In the degenerate case of a quantity rounding to exactly 0
, the operation is treated as a no-op (nothing happens).
DToken
Although debts are tracked in the vault storage along with balances and can be read with debtOf()
, vaults also provide DToken
as a read-only ERC-20 interface to the debts. Whenever a debt amount changes, the vault calls into the DToken
contract to trigger a Transfer
log. Note that the DToken
logs are the net of the change in debt (accrued interest plus/minus the borrow/repay amount).
The main purpose of DToken
is for off-chain analysis: Debt modifications are shown clearly in block explorers, and borrow/repay events can be tracked by tax-accounting software. Because the contract does not support transfers or approvals, advanced users who want debt portability should use the pullDebt()
function on the vault contract. This allows anyone to voluntarily assume anyone else's debt, providing the controller vault permits it (usually as long as the puller has enabled the controller and has sufficient collateral).
The DToken
contract is the first (and only) contract created by EVault
, so its address can be calculated from the vault's address and the nonce 1
. This method may not work on all EVM chains.
Balance Forwarding
Extra incentives beyond pure interest can be important drivers for liquidity. These reward incentives can be built into the vault contracts themselves (ie Compound) or allocated entirely off-chain (ie Euler V1). Incorporating reward logic directly into the vault contract itself allows instant and trustless distribution, but could increase gas costs for everyone (even if their rewards are insignificant) and, more importantly, might require deciding on a static rewards issuance policy at vault deployment time.
Balance forwarding is an attempt to get the best of both worlds. If an account opts in, every time its balance changes an external contract will be notified of the updated balance. This external contract has no special privileges in the vault, however it should take care to ensure that its balanceTrackerHook
method does not revert or use up all gas, since this could cause critical vault operations to fail.
A general-purpose system called Reward Streams is the recommended implementation for the balance forwarding contract. It allows anyone to permissionlessly incentivise any vault with any token at any time.
Interest
Compounding
Interest is accumulated on the first transaction in a block: Actual time must elapse for any interest to be accrued. However, a new interest rate is targetted whenever balance or debt amounts change. This is done in the checkVaultStatus
function so that batches that interact with a vault multiple times only need to re-target the interest rate once.
Vaults compound interest deterministically every second using exponentation. Because accrued interest is added to totalBorrows
, it increases utilisation (the proportion of the vault's assets that are loaned out). Other than this (and the effect of accumulator rounding), the amount of interest owed/earned is independent of how frequently the contract is interacted with.
Interest Rate Models
Interest Rate Models (IRMs) are contracts which determine the interest rate that should be charged given the state of a vault. Typically they are pure functions based on the utilisation, but this is not required. Today the most common function is a "linear-kink" model, which starts off with a gradual slope and then suddenly becomes steep at a particular target utilisation value. The computeInterestRate
function of the IIRM
interface accepts the bare minimum necessary to compute utilisation.
Vaults invoke their IRMs during the vault status check. This means that if a vault is interacted with multiple times in an EVC batch, the IRM only needs to be called once, at the end of the batch. Vaults cache this interest rate in their storage so that on the first operation of a subsequent block the interest can be accrued. The interest rate must be pre-computed and cached so that non-pure IRMs will not be able to retroactively change observed interest accurals. Non-pure IRMs can be invoked to re-target the interest rate at any time with the touch
method (depositors should touch when it would re-target upwards, borrowers otherwise).
IRMs can query the vault for additional information without triggering read-only re-entrancy protection because this lock is released at the time of the vault status check. Examples where this might be useful are with nested vaults which may partially derive their interest rates from their parent vaults' state, or with vaults that implement synthetic assets.
IRMs return the interest rate that borrowers must pay. Depositors (aka suppliers) will typically receive a lesser rate than this because the received interest is proportionally split up amongst all the depositors and the interestFee
.
The IRM interface specifies interest rates in terms of "second percent yield" (SPY) values which are per-second compounded interest rates scaled by 1e27
. Although not used by the contracts themselves, for consistency we recommend that conversion of SPY to annualised equivalents (in UIs and elsewhere) should use the number of seconds in the average Gregorian calendar year of 365.2425 days.
When a vault has address(0)
installed as an IRM, an interest rate of 0%
is assumed. If a call to the vault's IRM fails, the vault will ignore this failure and continue with the previous interest rate. An operation that runs out of gas in the IRM call but otherwise has enough gas to successfully complete could delay updating the interest rate, but this will be corrected on the next interaction with the vault.
Although most IRMs implement pure functions, when re-targeting the interest rate, vaults do not invoke them with staticcall
in order to support stateful or reactive IRMs. The computeInterestRate()
method should verify that msg.sender == vault
. IRMs also must implement a corresponding computeInterestRateView()
method which does not update state. Even though vaults store the current interest rate in their storage, computeInterestRateView()
is useful for obtaining updated interest rates mid-batch (especially during a simulation) since the stored rate may be stale.
Fees
Whenever interest is accrued, a portion of the interest is allocated to fees. This is analogous to the concept of reserves in other protocols. The fraction of interest that is allocated to fees is controlled by the governor as the parameter interestFee
, although this is validated by the ProtocolConfig's isValidInterestFee()
method.
Whenever interest is accrued, each borrower's liability increases accordingly. The interest fees are charged by creating the amount of shares necessary to dilute depositors by the interestFee
fraction of the interest (see the Euler V1 whitepaper for the derivation). Since fees are denominated in vault shares, not the underlying asset, unwithdrawn fees themselves earn compound interest over time.
Currently when calculating the amount of shares to create for fees, the new exchange rate is used and the virtual deposit is ignored. This has the effect of fees typically being slightly lower than they otherwise would be, although it should be negligible in practice.
Fee Share
For efficiency, accrued interest fees are internally tracked in a special virtual account inside the vault. In order to convert these into regular shares that can be redeemed for the underlying asset, the convertFees
method can be invoked on the vault. Anyone can invoke this method at any time.
convertFees
computes the amount of new shares that have accrued since the previous call, and then calls the ProtocolConfig's protocolFeeConfig()
method. This returns the fee share, which is the portion of the interest fees due to the Euler DAO and its receiver address. The vault validates the fee share and then transfers this proportion of accrued interest to the indicated address. The remaining is transferred to the feeReceiver
address specified by the vault's governor. If no feeReceiver
was specified, then this portion is also transferred to the Euler DAO's receiver.
Note that because the ProtocolConfig
can change the fee share at any time, the portion of the accrued interest sent to the Euler DAO is not finalised until convertFees
is invoked. If a vault governor is concern about this, convertFees
should be invoked frequently.
ProtocolConfig
The ProtocolConfig
contract is the representative of the Euler DAO's interests in the vault kit ecosystem. What vaults allow this contract to control is strictly limited. For non-upgradeable vaults these limits are enforced permanently.
While parameters such as interestFee
negotiate the relationship between depositors and the vault governor, ProtocolConfig
negotiates between the vault governor and the Euler DAO.
ProtocolConfig
exposes the following methods that are called by the vaults:
isValidInterestFee()
: Determines whether the value of an interest fee is allowed. Vaults only invoke this if the governor attempts to set an interest fee outside of the range 10% to 100% (the guaranteed range).feeConfig()
: This method is called when fees are converted. It returns the following:feeReceiver
: The recipient address for the DAO's share of the fees.protocolFeeShare
: The fraction of the interest fees that should be sent to the DAO. If theProtocolConfig
sends back a value greater than 50%, then vaults will ignore this value and use 50% instead.
Risk Management
In order for it to detect impermissible user actions, the EVC requires that vaults implement the following two methods:
checkAccountStatus
: Is the specified account in violation? If an account has any liabilities, is the value of its collateral sufficient?checkVaultStatus
: Is the vault itself healthy? Have vault-level limits like borrow and supply caps been exceeded?
These methods are invoked by the EVC at appropriate times. Often this will be after all operations in a batch have been performed, because the EVC allows users to defer these checks as a type of flash liquidity. If a vault would like to indicate that a status check has failed, it reverts, aborting the transaction and all performed operations.
LTV
In order for borrowing to occur, the governor of the liability vault must decide on a set of collateral assets and their maximum allowed Loan To Value ratios (from here on just called LTVs). LTVs should be chosen carefully. In addition to the risk inherent in the underlying asset, the vault that contains the asset should also be considered. If the collateral itself can be loaned out against risky collateral or uses unsafe pricing oracles, lower LTVs may be appropriate.
In order to add a collateral and configure its LTVs, the governor calls the setLTV()
method. The specified LTVs are fractions between 0 and 1 (scaled by 10,000), and correspond to the risk adjustment that is applied to collateral value when determining whether an account is in violation of its LTV. While the EVC ensures that each account has at most one liability, a loan can be backed by multiple collaterals.
Risk Adjustment
In order to ensure excess collateral is available to pay a liquidation discount if necessary, each account's collateral value must be larger than its liability by some safety buffer. The process of risk-adjustment is used to determine the size of this safety buffer.
To compute the risk-adjusted collateral value, the vault converts all collateral balances to a common currency, multiplies each by their corresponding LTV, and then sums them. Because LTVs are always less than 1, risk-adjustment always decreases the collateral value.
In order for an account to be healthy, the risk-adjusted value of its collateral assets must be strictly greater than the value of its liability, or the liability amount itself must be exactly zero. When an account is not healthy, it is said to be in violation.
When determining if an account is healthy, a liability vault will iterate over the account's collaterals and keep a running sum of the risk-adjusted value. If this sum ever exceeds the liability's value, then it will stop the iteration: the account is already known to be healthy. This can save substantial gas if expensive pricing oracles are used. Users can optimise the order of their collateral entries with the EVC's reorderCollaterals
function. This implementation was inspired by Gearbox. If the governor has not set an LTV for a collateral (or it is 0), then this will not contribute anything to the account's risk-adjusted collateral value and the vault will not waste gas trying to price it.
Borrowing vs Liquidation LTV
setLTV()
accepts separate borrowing and liquidation LTV values. The borrowing LTV must be less than or equal to the liquidation LTV. The risk-adjustment process is identical for both types of LTV. The context determines which one is used:
- The borrowing LTV is used within the account status check, which has the effect of limiting the size of new borrows. If an account is in violation here, it means it cannot perform any operations except those that can improve its health (deposit and repay), unless those operations solve the violation.
- The liquidation LTV is used when an attempt is made to liquidate the account. Only accounts that are in violation are allowed to be liquidated. This means that the liquidation LTV has the effect of limiting existing borrows.
The primary purpose for having two LTVs is to compensate for pricing delays and uncertainty. Suppose an attacker knows that a very large price movement is about to happen, either because the price feed is delayed or because they are manipulating the oracle. The attacker can deposit some collateral, borrow as much as possible, and then wait for the price to update. If the price update is sufficient to push the account beyond the LTV safety buffer, then the account's liability will be bad debt (liability value exceeds collateral value). The attacker could then liquidate themselves to recover all the collateral, leaving the vault's depositors with a loss. Below we describe another variation of this attack.
Adding a gap between the borrowing LTV and the liquidation LTV creates an additional hurdle to exploitation by requiring attackers to find or cause proportionally larger price movements. Although it can be a useful risk management tool, it does not solve the fundamental issue and comes with tradeoffs: Conceptually, when adding a gap to a vault, either the liquidation LTV can be raised, increasing risk to the vault by delaying liquidations, or the borrowing LTV can be lowered, decreasing the leverage available to borrowers and making the vault less attractive.
Another way to effectively add a gap is by using price oracles with a non-zero bid-ask spread. This allows the components with the most context on a pricing source's accuracy to communicate real-time pricing risks to the vault, which can then apply this information as a dynamic borrowing LTV.
Untrusted Collaterals
Selecting appropriate vaults as allowed collateral is critical for the security of a vault. All vaults must be EVC-compatible, and must use the same EVC deployment. The vault must also be priceable by the configured price oracle.
Using vaults with illiquid or manipulable underlying assets could threaten the safety of depositors. And vaults that have dangerous configurations -- like extremely high LTVs or themselves have poorly chosen collaterals -- might encounter bad debt situations.
It is also critical to evaluate the smart contract code that implements each collateral vault. A badly coded or malicious vault could refuse to release funds in a liquidation event, or simply lie about the value of its holdings. For this reason it is recommended to only use vaults created by a known-good factory. A vault can be verified to have been created by a factory by calling the factory's isProxy()
function.
When evaluating whether a new or customised vault implementation is trustworthy, all the usual checks should be performed, such as verifying the code was audited, does not contain backdoors, etc. In addition, it is very important to verify that the transfer
method does not invoke any external contracts that could run attacker code. This is because the EVK implementation forgives the account status check of an account after liquidation, and a malicious user could perform actions that get unexpectedly forgiven.
For this reason, an important property of liquidation is that assets without an LTV can never be seized by liquidation (users can install any vaults in their EVC collateral set, trusted or not).
Cleared versus 0 LTVs
By default, all prospective collaterals are considered to have unset, or cleared LTVs. Only by calling setLTV()
do they become available as collateral. If a governor decides that a vault is no longer suitable as collateral, it can either have its liquidation LTV (and necessarily its borrowing LTV) set to 0, or it can be cleared with the clearLTV()
function.
There is a subtle but important difference between the two: Former collaterals with a 0 LTV are still eligible to be seized in a liquidation, but cleared ones are not, for the reason described in Untrusted Collaterals.
So, if a vault can no longer be collateral for economic/market reasons, its LTV should be set to 0 (either with a ramp or in drastic cases without). On the other hand, if the vault's code is discovered to be buggy or malicious, its LTV should be cleared immediately.
Non-collateral Deposits
Although only vaults that were explicitly configured with an LTV can be used as collateral to support debt, when an account is not healthy the account's functionality will become limited. This includes failing withdrawals of non-collateral assets. Each account is considered a single position and when the position is unhealthy, the controller vault is considered within its rights to incentivize the user to repay their debt by any means. To fully segregate assets, use different sub-accounts to store deposits even when they aren't used as collateral.
LTV Ramping
The LTV for one or more collateral assets can be modified by the governor. If the LTV is suddenly reduced, any oustanding borrowers might instantly be put into violation. Because of the reverse dutch auction liquidation system, these borrowers might unfairly lose a significant amount of value due to this action.
One solution to this might be to keep the liquidation LTV high for existing borrows, but reduce it for new borrows. This would be more fair, but has the undesirable effect of keeping these high-LTV borrows on the vault's books for an indefinite amount of time.
Instead governors can specify a ramp duration when changing the liquidation LTV. Ramps only affect liquidation LTVs -- new borrows will immediately be required to use the new value. Ramps cause the liquidation LTV to linearly decrease to the new value over the specified duration. At some point in this window, affected positions will become unhealthy, but only slightly so. At this point, or soon after, the positions will be liquidated at a minimal loss to the borrower.
If a ramp is initiated while another ramp is already in progress, the current location on the previous ramp becomes the start of the new ramp, so as to prevent a sudden jump (only the slope is affected).
Even finalised vaults could benefit from LTV Ramping by installing a limited governor contract that can initiate a graceful wind-down under certain conditions.
Supply and Borrow Caps
The governor may configure a supply cap, a borrow cap, or both. The supply cap is a limit on the amount of underlying assets that can be deposited into the vault, and the borrow cap is a limit on the amount that can be borrowed. Both are denominated in the underlying asset, but are packed into 2-byte decimal floating point values.
Caps can be transiently violated since they are only enforced at the end of a batch. If a cap was not in violation at the start of a batch but is at the end, then the transaction will be reverted.
Even though it can't be the direct result of a user action, in some cases caps can become persistently violated. For example if the governor reduces a cap or if accrued interest causes total borrows to exceed the borrow cap. If for one of these reasons a cap was in violation at the start of a batch, then the transaction will only succeed if the cap violation has lessened (or at least not gotten worse).
Note that this behaviour can in principle be exploited by opportunistically wrapping gasless transactions that withdraw/repay into a surrounding batch that deposits/borrows an equivalent amount. The executor is effectively able to transfer the user's supply/borrow quota into their own account instead of reducing the capped value.
Hooks
Vaults support a limited hooking functionality. In order to make use of this, the governor should install a hook config, which consists of two parameters:
- The hook target: the address of a contract, or
address(0)
- The hooked ops: a bitfield that specifies the operations to be affected by hooks
Most user-invokable external functions are allocated constants. For example, the deposit function has OP_DEPOSIT
. The hooked ops bitfield is the bitwise OR of these constants.
When a function is invoked, the vault checks if the corresponding operation is set in hooked ops. If so, the hook target is call
ed using the same msg.data
that was provided to the vault, along with the EVC-authenticated caller appended as trailing calldata. If the call to the hook target fails, then the vault operation will fail too. If the hook target is address(0)
(or any non-contract address), then the operation fails unconditionally.
In addition to user-invokable functions, hooks can also hook checkVaultStatus
. This will be invoked when the EVC calls checkVaultStatus
on the vault, which is typically at the end of any batch that has interacted with the vault. This hook can be used to reject operations that violate "post-condition" properties of the vault.
The installed hook target contract address can bypass the vault's read-only reentrancy protection. This means that hook functions can call view methods on the vault. However, hooks cannot perform state changing operations because of the normal reentrancy lock.
Some hook configurations may cause the vault to not be entirely ERC-4626 compliant;
Hook Use-Cases
The main purpose of the hook system is to disable vault functionality, either permanently or under certain conditions. The vault governor cannot use hooks to break core accounting invariants in the vault.
- Pause Guardian: The governor may be set as a contract that functions as a pause guardian. This contract could allow certain trusted users to pause and unpause individual vault operations, including deposit, withdraw, borrow, repay, transfer, and liquidate. This could potentially allow a safe wind-down of a distressed vault, hack prevention, or the safe recovery of funds after a hack. If the vault governor wants to ensure that some combinations of pause operations are disallowed (such as preventing liquidations when repay is disabled) then the pause guardian contract should enforce this.
- Synthetic Asset Vaults: These are specialised vaults that require some restrictions provided by hooks. In particular, only the underlying asset itself is allowed to deposit into them, and certain other operations are disabled outright.
- Permissioned/RWA Vaults: Certain vault creators may wish to restrict who can deposit and/or borrow from the vault, either for compliance reasons or because the borrowing is under-collateralised.
- Flash loan fees: Normally the
flashLoan
function takes no fee. However, a hook can enforce a certain percentage of the flash loan is paid as a fee prior to allowing the loan to proceed. - Utilisation caps: A hook could prevent a vault's utilisation from exceeding a particular level by reverting in
checkVaultStatus
. Note that depending on the exact requirements, a hook target may need to implement "snapshots", where the initial state of the vault is recorded in (potentially transient) storage upon operation initiation, and then read and cleared incheckVaultStatus
. - Minimum debt sizes: In order to prevent users from creating dust positions that are too small to profitably liquidate, a hook could be installed for every operation that changes debt. These hooks would record the accounts in a transient
Set
, and then verify thatdebtOf()
for each is above a threshold in thecheckVaultStatus
hook. - Restrict number of collaterals: The EVC limits the number of collateral assets an account can have enabled at any given time to
10
. This is important in order to maintain a reasonable upper-bound on the cost of liquidations. However, perhaps because they have configured especially expensive oracles, vaults may choose to limit this to a smaller number,
Price Oracles
Pricing Shares
Inside a vault, each collateral is configured as the address of another vault, not the underlying asset (unless the asset is specially constructed to also function as a collateral vault). This means that the value of a user's collateral is in fact the value of the vault's shares. A vault share is not necessarily equal to a unit of the underlying asset because of the exchange rate.
Because converting quantities of shares to underlying asset amounts is itself a pricing operation, this responsibility is delegated to the price oracle.
For vaults created with the Euler Vault Kit, the ERC-4626 convertToAssets
function can be used to price shares in units of the underlying asset. This function is designed to be a reliable oracle. Internal balance tracking prevents manipulation with direct transfer donations, and the virtual deposit minimises the impact of rounding-based "stealth deposits". See our article for more details on these protections.
In some cross-chain designs, the price oracle is also responsible for determining the exchange rate of a corresponding vault on a separate chain.
IPriceOracle
Each vault has the address of a price oracle installed. This address is immutable and cannot be changed, even by the vault governor. If updates to pricing sources are desired, this address should be a governed EulerRouter
pricing component. All oracles must implement the IPriceOracle interface, specifically the following two functions:
/// @notice One-sided price: How much quote token you would get for inAmount of base token, assuming no price spread
function getQuote(uint inAmount, address base, address quote) external view returns (uint outAmount);
/// @notice Two-sided price: How much quote token you would get/spend for selling/buying inAmount of base token
function getQuotes(uint inAmount, address base, address quote) external view returns (uint bidOutAmount, uint askOutAmount);
These methods do not expose any pricing-level configuration. Instead, for custom pricing configurations, a new oracle contract must be deployed.
Quotes
Price fractions are never directly returned by the interface. Instead, the oracle acts as though it is quoting swap amounts. This avoids certain catastrophic losses of precisions, especially with low-decimal tokens (see an example with SHIB/USDC).
Price oracles may legitimately return 0
to indicate that a requested quote amount is worthless, either because the price is very low or the quote amount was very small, or both. Legitimate pricing errors are signalled by a the oracle reverting. This in turn causes the vault to revert, which is dangerous since it can break functionality of the vault (most importantly, liquidation). Because of this, only reliable pricing oracles should be used.
The getQuote
function returns how many quote
tokens an inAmount
of base
tokens would purchase at the current marginal (no price impact) price. The getQuotes
method (plural) may return both the bid and ask quote
amounts for the specified quantity of base
. Although simple oracles will return the same amounts for both bid and ask, more advanced vaults may use this to expose current market spreads. Alternatively, confidence intervals can be expressed, potentially by querying multiple oracles, sorting them, and returning the lowest amount as the bid and the highest as the ask.
Vaults use the difference between bid and ask amounts as a proxy for market depth or uncertainty. When verifying if a new loan is acceptable, the LTV is computed using bids for collaterals and the ask for the liability. This can be considered a dynamic adjustment to the borrowing LTV relative to the width of the bid-ask spread. On the other hand, when liquidating an existing loan, the getQuote
mid-point price is used so that loans aren't liquidated by temporarily-wide price spreads. The vault assumes that the price oracle respects the following invariant: bid <= mid-point <= ask
.
Because the interface accepts both base
and quote
addresses, oracles may be configured to compute cross prices which combine several pricing sources. This is similar in concept to how swaps are sometimes routed through a sequence of DEX pools. At the very least, oracles should be able to cross price the shares to assets exchange rate with the underlying asset's price.
Vault Configuration
Although each vault has an oracle address specified at creation time, the oracle does not need to use a homogenous source for all its pricing. For example, a vault with an underlying asset of USDC that allows DAI, USDT, WETH, and UNI vaults to be used as collateral may choose to use a 1:1 peg for its DAI/USDC price, Chainlink for USDT/USDC, Uniswap3 TWAP for WETH/USDC, and to forward its pricing for UNI to another IPriceOracle
implementation. Because of shares pricing, even two vaults with the same underlying asset could use different oracles.
Note that the pricing oracle configuration is always local to the liability vault and the collateral vaults don't know or care about this configuration. If the DAI vault in our example allows borrowing against USDC collateral, it could use an entirely different oracle for its own pricing. This is analogous to traditional finance where it is the lender's responsibility to adequately assess collateral value to secure a loan. Since various vaults can use different pricing to assess the same collateral vault, there is no need to split liquidity by oracle-type.
Unit of Account
Vaults also specify a unit of account parameter (sometimes called a "reference asset"). This parameter is immutable and cannot be changed, even by the vault governor. The unit of account is passed to the price oracle as the quote
parameter in all queries so that all collaterals and liabilities are priced in a common asset. If the unit of account is the same as the vault's underlying asset then one fewer price conversion is required. For some risk configurations this may also improve price quality: Consider borrowing USDC with DAI, or stETH with ETH. In these cases, pricing via an unrelated intermediate asset could result in unnecessary extra volaitilty. By pricing directly, spurious liquidations can be avoided and higher LTV ratios can be configured.