Ethereum Vault Connector (EVC)
Mick de Graaf, Kasper Pawlowski, Dariusz Glowinski, Michael Bentley, Doug Hoyte
Introduction
The Ethereum Vault Connector (EVC) is a foundational layer designed to facilitate the core functionality required for a lending market. It serves as a base building block for various protocols, providing a robust and flexible framework for developers to build upon. The EVC primarily mediates between vaults, contracts that implement the ERC-4626 interface and contain additional logic for interfacing with other vaults. The EVC not only provides a common base ecosystem but also reduces complexity in the core lending/borrowing contracts, allowing them to focus on their differentiating factors.
To illustrate the process of vault mediation, let's consider a straightforward example. When a user wishes to borrow, they must link their accounts and collateral vaults to the borrowed-from vault via the EVC. The liability vault, also known as the "controller", is then consulted whenever a user wants to perform an action potentially impacting account's solvency, such as withdrawing collateral. The EVC is responsible for calling the controller which determines whether the action is allowed or if it should be blocked to prevent account insolvency.
In addition to vault mediation, the EVC contains the functionality required to build flexible products, both for EOAs and smart contracts. Here are some of the benefits of building on the EVC:
- Network effect thanks to unified liquidity and interoperability: Participating protocols can accept deposits in other vaults as collateral suitable for their vaults, providing convenience for users who no longer need to move their collateral assets from one protocol to another.
- Flexibility in asset properties: The EVC does not enforce specific properties about the assets used as collateral or liabilities, allowing users to create vaults backed by irregular asset classes, such as NFTs, Real World Assets (RWAs), uncollateralised IOUs, or synthetics.
- Standardized approach to account liquidity checks and vault global constraints enforcement: The EVC allows for deferral of the liquidity checks and vault status checks, preventing transient violations from causing a failure. The EVC exposes an interface which abstracts management of the checks away from the vault.
- Batching: Multiple operations affecting multiple vaults and external smart contracts can be performed within a single batch operation. This is more convenient for UI users, more gas efficient, and allows deferring liquidity checks until the end of the batch.
- Sub-accounts: A feature that allows users to create multiple isolated positions within their single owner account, and easily rebalance collateral/liabilities between them without the need for approvals and without requiring any special logic to be implemented by the vaults.
- Operators: Users can attach external contracts to act on behalf of a sub-account. This is a generalisation of the token approval system and will unlock powerful functionality, even for EOAs. For example, intents support, stop-loss/take-profit/trailing-stop/etc modifiers can be added to positions, or entire layered position managers can be built on top.
- Gasless transactions (meta-transactions): They can be supported out of the box for both EOAs and contract wallets.
- Simulations: The EVC exposes the optimal interface for simulating the effects of a set of operations and pre-visualising their effects in a UI for all EVC users.
- A common language for liquidations: Vaults can implement a core liquidation interface that will allow them to rely on an existing network of liquidators to keep their depositors safe.
As already mentioned, the EVC not only provides the above features to a common base ecosystem, but also reduces complexity in the core lending/borrowing contracts, allowing them to focus on their differentiating factors such as pricing and risk management.
Controller
The primary task of the EVC is to maintain a user's voluntary associations with vaults. Typically, a user will deposit funds into one or more collateral vaults, and call enableCollateral
for each that is intended to be used as a collateral. This adds the vault to the given account's collateral set. Users should obviously be careful which vaults they deposit to, since a malicious vault could refuse to return their funds.
After simply depositing and enabling collaterals, users are not obligated or bound in any way by the EVC, and could freely withdraw funds from the vaults and/or call disableCollateral
to remove the vaults from the account's collateral set.
However, suppose a user wants to take out a borrow from a separate vault. In this case, the user must call enableController
to add this vault to the account's controller set. This is a significant action because the user is now entirely submitting the account to the rules encoded in the controller vault's code. All the funds in all the collateral vaults are now indirectly under control of the controller vault. In particular, if a user attempts to withdraw collateral or disableCollateral
to remove a vault from the collateral set, the controller could cause the transaction to fail. Moreover, the controller can allow the collateral to be seized in order to repay the debt, using controlCollateral
.
- When requested to perform an action such as borrow, a liability vault must call into the EVC's
isControllerEnabled
function to ensure that the account has in fact enabled the vault as a controller. - Only the controller itself can call
disableController
on the EVC. This should typically happen upon an account repaying its debt in full. Vaults must be coded carefully to not have edge cases such as unrepayable dust, otherwise accounts could become permanently associated with a controller. - The order of an account's collateral set can be changed with
reorderCollaterals
. Because some controller vaults will loop over an account's collateral in sequence and return early if sufficient value is found, this can save considerable gas. This feature was inspired by Gearbox'scollateralHints
.
Given that enabling a controller subjects the specified account to the rules encoded in the controller's code, users must only enable trusted, audited controllers. If the controller is malicious or incorrectly coded, it may result in the loss of the user's funds or even render the account unusable.
Account Status Checks
Account status checks are implemented by vaults to enforce account solvency. Vaults must expose an external checkAccountStatus
function that will receive an account's address and this account's list of enabled collateral vaults. If the account has not borrowed anything from this vault then the function should return a special magic success value (the function selector for the checkAccountStatus
method). Otherwise, the vault should evaluate application-specific logic to determine whether or not the account is in an acceptable state. If so, it should return the special magic success value, otherwise throw an exception.
Collateral Validity
Within the checkAccountStatus
callback, vaults should inspect the provided list of collaterals and determine whether or not they are acceptable. Vaults can limit themselves to a small set of collaterals, or can be more general-purpose and allow borrowing using any assets they can get prices for. Alternately, a vault could always fail, if it is only intended to be a collateral vault.
Vaults have the freedom to price all the assets according to their preference (both liability and accepted collaterals), without depending on potentially untrustworthy oracles.
While it might be tempting for the controller to allow a broad range of collateral vaults to encourage borrowing, the controller vault creators must exercise caution when deciding which vaults to accept as collateral. A malicious or incorrectly coded vault could, among other things, misrepresent the amount of assets it holds, reject liquidations when a user is in violation, or fail to require account status checks when necessary. Therefore, vaults should limit allowed collaterals to a set of audited addresses known to be reliable, or verify the addresses in a registry or factory contract to ensure they were created by trustworthy, audited contracts.
One of areas of concern when selecting suitable collateral vaults for a controller involves evaluating potential differences in collateral behavior depending on the state of the EVC execution context flags, such as controlCollateralInProgress
and others. While the flags exposed by the EVC can potentially be misused by collateral contracts, leading to unintended consequences in the system, they also offer an opportunity to enhance system stability when used appropriately.
Execution Flow
Although the vaults themselves implement checkAccountStatus
, there is no need for them to invoke this function directly. It will be called by the EVC when necessary. Instead, after performing any operation that could affect an account's liquidity, a vault should invoke requireAccountStatusCheck
on the EVC to schedule a future callback. Additionally, operations that can affect the liquidity of a separate account will need their own requireAccountStatusCheck
calls.
Upon a requireAccountStatusCheck
call, the EVC will determine whether the current execution context is in a checks-deferrable call and if so, it will defer checking the status for this account until the end of the execution context. Otherwise, the account status check will be performed immediately.
There is a subtle complication that vault implementations should consider if they use re-entrancy guards (which is recommended). When a vault is invoked without account status checks being deferred (ie, directly, not via the EVC), if it calls requireAccountStatusCheck
on the EVC, the EVC will immediately call back into the vault's checkAccountStatus
function. A normal re-entrancy guard would fail upon re-entering at this point. To avoid this, vaults may wish to use the call EVC function.
Single Controller
At the time of the account status check, an account can have at most one controller. This is how single-liability-per-account is enforced. Multiple controllers are disallowed because it is unlikely two independent controllers would be able to behave consistently in the presence of "shared" accounts. If this is ever required, a multi-controller controller could be created with the desired sharing logic.
Although having more than one controller is disallowed when the account status check is performed, multiple controllers can be transiently attached while these checks are deferred. As long as all or all but one controllers release themselves during the execution of the checks-deferrable call, the account status check will succeed.
Last Account Status Check Timestamp
A successful checkAccountStatus
call to the enabled controller vault will record the timestamp of the last successful account status check for a given account in the EVC's storage. This timestamp can be later retrieved using the getLastAccountStatusCheckTimestamp
function on the EVC.
Forgiveness
If a controller wants to waive the liquidity check for an account it is controlling, it can "forgive" an account. This removes it from the set of accounts that will be checked at the end of the call. Controllers can only forgive accounts that they are the sole controller of.
Needless to say, this functionality should be used with care. It should only be necessary in certain advanced liquidation flows where the collateral is seized from an unhealthy account but the seizure of funds is still not enough to bring the account to a sufficiently healthy level to pass the account status check.
When doing so, it is important that vaults verify that no other collaterals have unexpectedly been withdrawn during the seizure, in the event that a vault makes any unexpected external calls in its transfer/withdraw/etc method.
Vault Status Checks
Some vaults may have constraints that should be enforced globally. For example, supply and/or borrow caps that restrict the maximum amount of assets that can be supplied or borrowed, as a risk minimisation.
It does not necessarily make sense to enforce these checks when checking account status. First of all, if many accounts are affected within a batch, checking these global constraints each time would be redundant.
Secondly, some types of checks require an initial snapshot of the vault state before any operations have been performed. In the case of a borrow cap, it could be that the borrow cap has been exceeded for some reason (perhaps due to a price movement, or the borrow cap itself was reduced). The vault would still want to permit repaying debts, even if the repay was insufficient to bring the total borrows below the borrow cap.
If vaults have constraints that need to be enforced globally, they may expose an external checkVaultStatus
function. In that function, the vault should evaluate application-specific logic to determine whether or not the vault is in an acceptable state. If so, it should return a special magic success value (the function selector for the checkVaultStatus
method), otherwise throw an exception.
Although the vaults themselves implement checkVaultStatus
, there is no need for them to invoke this function directly. It will be called by the EVC when necessary. Instead, after performing any operation that could affect the vault's status, a vault should invoke requireVaultStatusCheck
on the EVC to schedule a future callback.
Upon receiving a requireVaultStatusCheck
call, the EVC will determine whether the current execution context defers the checks and if so, it will defer checking the status for this vault until the end of the execution context. Otherwise, the vault status check will be performed immediately.
In order to evaluate the vault status, checkVaultStatus
may need access to a snapshot of the initial vault state. If so, the recommended pattern as implemented in the reference vaults is as follows:
- Before performing any actions, each operation that requires a vault status check should first make an appropriate snapshot and store the data in transient storage (if a snapshot has not already been made)
- The operations should be performed
- The vault should then call
requireVaultStatusCheck
- When the
checkVaultStatus
callback is invoked, it should evaluate the vault status by unpacking the snapshot data stored in transient storage and compare it against the current state of the vault, and return a special magic success value, or revert if there is a violation.
As with the account status check, there is a subtle complication that vault implementations should consider if they use re-entrancy guards (which is recommended). When a vault is invoked without vault status checks being deferred (ie, directly, not via the EVC), if it calls requireVaultStatusCheck
on the EVC, the EVC will immediately call back into the vault's checkVaultStatus
function. A normal re-entrancy guard would fail upon re-entering at this point. To avoid this, vaults may wish to use the call EVC function.
Execution
Checks-deferrable Call
The EVC exposes multiple functions, each with its own characteristics, that allow for the Account Status Checks and Vault Status Checks to be deferred. call, batch and controlCollateral so called checks-deferrable call functions, can be nested and allow for the checks to be deferred until the end of the execution of the outermost function call.
call
The call
function on the EVC allows users to invoke functions on vaults and other target smart contracts, including the EVC itself. Unless the msg.sender
is the same as the onBehalfOfAccount
, users must go through this function rather than calling the vaults directly. This is because vaults themselves don't understand sub-accounts or operators, and defer their authorisation logic to the EVC (see the Authentication By Vaults section).
call
also allows users to invoke arbitrary contracts, with arbitrary calldata. These other contracts will see the EVC as msg.sender
. For this reason, it is critical that the EVC itself never be given any special privileges, or hold any token or native currency balances (except for a few corner cases where it is temporarily safe, see the EVC Contract Privileges section).
If the target contract is the EVC, in order to preserve the msg.sender
, the EVC will be self-call
ed using the delegatecall
.
If the target contract is NOT msg.sender
, the EVC will only allow the target contract to be called if the msg.sender
is the owner or the operator of the onBehalfOfAccount
provided. If that condition is met, the EVC will create a context and call into the target contract with the provided calldata and the onBehalfOfAccount
account set in the context.
Because vaults can be called directly without going through the EVC, checks may not be deferred when they are invoked. In that case, vaults can use the call
function so that they can assume that they are always executing within a checks deferred context. If the calling vault specifies the target contract to be their own address (target contract is msg.sender
), the EVC will create a context and call back into the caller with the provided calldata and the onBehalfOfAccount
account set to whatever was provided by the calling vault. The vault should use msg.sender
as onBehalfOfAccount
. In theory a vault could supply any address, but the only other contract that will see this onBehalfOfAccount
is the vault itself: recall that the onBehalfOfAccount
should only be trusted when msg.sender
is the EVC itself. To use call
in this manner, it is recommended that vaults use a special modifier callThroughEVC
before its re-entrancy guard modifier. This will take care of routing the calls through the EVC, and the vault can operate under the assumption that the checks are always deferred.
The call
function also allows to forward the provided value (or full balance of the EVC if max uint256
was specified). Therefore it can also be used to recover any remaining value in the EVC.
batch
At the time of this writing, public/private key pair Ethereum accounts (EOAs) cannot directly perform multiple operations within a single transaction, except by invoking a smart contract that will do so on their behalf. The EVC exposes a batch
function that allows multiple operations to be executed together. This has several advantages for users:
- Atomicity: The user knows that either all of the operations in a batch will execute, or none of them will, so there is no risk of being left with partial or inconsistent positions.
- Gas savings: If contracts are invoked multiple times, then the cost of "cold" access can be amortised across all of the invocations.
- Status check deferrals: Sometimes multiple operations in a batch may require status checks or it is more convenient or efficient to perform some operation that would leave an account/vault in an invalid state, but fix this state in a subsequent operation in a batch. For example, you may want to perform withdrawal and borrow in one batch or borrow and swap before you deposit your collateral. With batches, these checks can be performed once at the end of a batch (which can also itself be more gas efficient).
Same as call
, batches can be composed of both calls to the EVC itself and external calls. Calling the EVC is how users can enable collateral from within a batch, for example. In order to preserve msg.sender
, EVC self-call
s are in fact done with delegatecall
.
Batches will often be a mixture of external calls, some of which call vaults and some of which call other unrelated contracts. For example, a user might withdraw from one vault, then perform a swap on Uniswap, and then deposit into another vault. Each batch item specifies onBehalfOfAccount
for which the authentication rules are the same as for call
.
controlCollateral
The controlCollateral
function can only be used in one specific case: when a controller vault wants to invoke a function on a collateral vault on behalf of the account under its control. The typical use-case for this is a liquidation. The controller vault would detect that an account entered violation due to a price movement, and seize some collateral assets to repay the debt.
This is accomplished by the controller vault calling controlCollateral
. It passes in the collateral vault as the target collateral and the violator as onBehalfOfAccount
. The controller would construct a withdraw
call using its own address as the receiver
. From the collateral vault's perspective, this appears as a regular withdrawal, and it does not need to know that the funds are being withdrawn due to a liquidation.
permit
Instead of invoking the EVC directly, signed messages called permit
s can also be provided to the EVC. Permits can be invoked by the specified sender (or anyone under certain circumstances), but they will execute on behalf of the signer of the permit message. They are useful for implementing "gasless" transactions.
Permits are EIP-712 typed data messages with the following fields:
signer
: The address to execute the operation on behalf of.sender
: The address of themsg.sender
which is expected to execute the data signed by thesigner
.nonceNamespace
andnonce
: Values used to prevent replaying permit messages, and for sequencing (see below)deadline
: A timestamp after which the permit becomes invalid.value
: The value of native currency that is expected to be sent to the EVCdata
: Arbitrary calldata that will be used to invoke the EVC. Typically this will contain an invocation of thebatch
method.
There are two types of signature methods supported by permits: ECDSA, which is used by EOAs, and ERC-1271 which is used by smart contract wallets. In both cases, the permit
method can be invoked by any unprivileged address, such as a keeper. If the signature is exactly 65 bytes long, an ecrecover
is attempted. If the recovered address does not match signer
, or for signature lengths other than 65, then an ERC-1271 verification is attempted, by staticcalling isValidSignature
on signer
.
After verifying sender
, deadline
, signature
, nonce
, and nonceNamespace
, the data
will be used to invoke the EVC, forwarding the specified value
(or the full balance of the EVC contract if max uint256
was specified). Although other methods can be invoked, the most general purpose method to use is batch
. Inside a batch, each batch item can specify an onBehalfOfAccount
address. This can be any sub-account of the owner, meaning a signed batch can affect multiple sub-accounts, just as a regular non-permit invocation of batch
can. If the signer
is an operator of another account, then the other account can also be specified -- this could be useful for gaslessly invoking a restricted "hot wallet" operator.
Internally, permit
works by call
ing address(this)
, which has the effect of setting msg.sender
to the EVC itself, indicating to the EVC that the actually authenticated user should be taken from the execution context. It is critical that a permit is the only way for this to happen, otherwise the authentication could be bypassed. Note that the EVC can be self-invoked via call
and batch
, but this is done with delegatecall, leaving msg.sender
unchanged.
Nonce Namespaces
With nonces, Ethereum transactions enforce that transactions cannot be mined multiple times, and that they are included in the same sequence they were created (with no gaps).
permit
messages contain two uint256
fields that can be used to enforce the same restrictions: nonceNamespace
and nonce
. Each account owner has a mapping that maps from nonceNamespace
to nonce
. In order for a permit message to be valid, the nonce
value inside the specified nonceNamespace
must be equal to the nonce
field in the permit message. Using the permit increments the nonce by one.
The separation of nonceNamespace
and nonce
allows users to optionally relax the sequencing restrictions. There are several ways that an owner may choose to use the namespaces:
- Always set
nonceNamespace
to0
, and sign sequentially increasingnonce
s. These permit messages will function like Ethereum transactions, and must be mined in order, with no gaps. - Derive the
nonceNamespace
deterministically from the permit message (for example, a hash of the message fields, excludingnonceNamespace
), and always set thenonce
to0
. These permit messages can be mined in any order, and some may never be mined. - Some combination of the two approaches. For example, a user could have "regular" and "high priority" namespaces. Normal orders would be included in the regular sequence, while high priority permits are allowed to bypass this queue.
Note that any time sequencing restrictions are relaxed, users should be aware that different orderings of their transactions can have different MEV potential, and they should prepare for their transactions executing in the least favourable order (for them).
Permit messages can be cancelled in three ways:
- Creating a new message with the same nonce and having it included before the unwanted permit (as with Ethereum transactions).
- Invoking the
setNonce
method. This allows users to increase their nonce up to a specified value, potentially cancelling many outstanding permit messages in the process. Note that there is no danger of rendering an account non-functional: Even if a nonce is set to the maxuint256
, there are an effectively unlimited number of other namespaces available. - Implicitly, by waiting until the
deadline
timestamp expires
Authorisation
Inside each checks-deferrable call function, an onBehalfOfAccount
can be specified. The function will determine whether or not msg.sender
is authorised to perform operations on this account:
- If
msg.sender
has never before interacted with the EVC, if it shares the first 19 bytes with theonBehalfOfAccount
, thenonBehalfOfAccount
is considered to be a sub-account ofmsg.sender
and thereforemsg.sender
is authorised. Upon that first interaction with the EVC,msg.sender
address is stored in EVC's storage as an owner of the group of 256 accounts having the same first 19 bytes. - If
msg.sender
has interacted with the EVC before and it shares the first 19 bytes with theonBehalfOfAccount
, its address is supposed to match the one stored in the EVC's storage. If it does, then it is authorised. - If
msg.sender
has previously been authorized as an operator for theonBehalfOfAccount
, it is authorised. - If the
msg.sender
is the EVC itself, then this must be from a permit and the effective sender is taken from the execution context - In all other cases, the caller is invalid, and the entire transaction will fail.
Sub-Accounts
Sub-accounts allow users access to multiple (up to 256) virtual accounts that are entirely isolated from one another. Although multiple separate Ethereum addresses could be used, sub-accounts are often more efficient and convenient because their operations can be grouped together in a batch without setting approvals.
Since an account can only have one controller at a time (except for mid-transaction), sub-accounts are also the only way an Ethereum account can hold multiple Vault borrows concurrently.
The EVC also maintains a look-up mapping ownerLookup
so sub-accounts can be easily resolved to owner addresses, on- or off-chain. This mapping is populated when an address interacts with the EVC for the first time. In order to resolve a sub-account, the getAccountOwner
function should be called with a sub-account address. It will either return the account's primary address, or address(0)
if the account has not yet interacted with the EVC.
Operators
Operators are a more flexible and powerful version of approvals. While in effect, the operator contract can act on behalf of the specified account. This includes interacting with vaults (ie, withdrawing/borrowing funds), enabling vaults as collateral, etc. Because of this, it is recommended that only trusted and audited contracts, or EOAs held by a trusted individuals, be installed as operators.
Operators have many use cases. For instance, a user might want to install a modifier such as stop-loss/take-profit/trailing-stop to a position in an account. To accomplish this, a special operator contract that allows a "keeper" to close out the user's position when certain conditions are met can be selected as an operator. Multiple operators can be installed per account. Note however that the operators may implement contradictory logic, so care should be taken when installing multiple operators for a single account.
An operator is similar to a controller, in that an account gives considerable permissions to a smart contract (that presumably has been well audited). However, the important difference is that an account owner can always revoke an operator's privileges at any time, however they can not do so with a controller. Instead, the controller must release its own privileges. Another difference is that controllers can not change the account's collateral or controller sets, whereas an operator can.
Execution Contexts
As mentioned above, when interacting with the EVC, it is often useful to defer certain checks until the end of the transaction. This allows a user to temporarily violate some of the constraints imposed by the vaults, so long as the constraints are satisfied at the end of the transaction.
In order to implement this, the EVC maintains an execution context which holds two sets of addresses in regular or transient storage (if supported): accountStatusChecks
and vaultStatusChecks
. The execution context will also contain the onBehalfOfAccount
that has currently been authenticated, so it can be queried by a vault (see Security Considerations).
An execution context will exist for the duration of the checks-deferrable call, and is then discarded. Only one execution context can exist at a time. However, nesting calls is allowed (see below).
When the execution context ends, the address sets are iterated over:
- For each address in
accountStatusChecks
, confirm that at most one controller is installed (itsaccountControllers
set is of size 0 or 1). If a controller is installed, invokecheckAccountStatus
on the controller for this account and ensure that the controller is satisfied. If no controller is installed,checkAccountStatus
is not invoked and the account status is considered valid by default. Hence,disableController
must be used with care. - For each address in
vaultStatusChecks
, callcheckVaultStatus
on the vault address stored in the set and ensure that the vault is satisfied.
Additionally, the execution context contains some locks that protect critical regions from re-entrancy (see below).
Nested Execution Contexts
If a vault or other contract is invoked via the EVC, and that contract in turn re-invokes the EVC to call another vault/contract, then the execution context is considered nested. The execution context is however not treated as a stack. The sets of deferred account and vault status checks are added to, and only after unwinding the final execution context will they be validated.
Internally, the execution context stores a checksDeferred
flag that is set each time a checks-deferrable call is started and cleared only when its value before the call was false
. Once the flag is cleared, the deferred checks get performed. Nesting calls is useful because otherwise calling contracts via the EVC that themselves want to call other contracts through the EVC would be more complicated.
The previous value of onBehalfOfAccount
is stored in a local "cache" variable and is subsequently restored after invoking the target contract. This ensures that contracts can rely on the onBehalfOfAccount
at all times when msg.sender
is the EVC (see Authentication by Vaults). However, when msg.sender
is not the EVC, vaults cannot rely on onBehalfOfAccount
because it could have been changed by a nested context.
checksInProgress
Because the EVC invokes the checkAccountStatus
and checkVaultStatus
callbacks that could re-enter the EVC, either directly or by a contract they invoke, the EVC's execution context maintains a checksInProgress
mutex that is acquired before unwinding the sets of accounts and vaults that need checking. This mutex is also checked during operations that alter these sets. If it did not do this, then information cached by the higher-level unwinding function (such as the sizes of the sets) could become inconsistent with the underlying storage, which could be used to bypass these critical checks.
controlCollateralInProgress
The typical use-case for collateral control is for a liability vault to seize collateral assets during a liquidation flow.
However, when interacting with complicated vaults that may invoke external contracts during a withdraw/transfer, a liability vault may want to ensure that no other collaterals are removed during the seizure.
In order to simplify the implementation of this check, the controlCollateralInProgress
mutex is locked while invoking a collateral vault during the controlCollateral
flow. While locked, no accounts' collateral or controller sets can be modified.
Additionally, during collateral control, the EVC cannot be re-entered via call
, batch
, controlCollateral
or permit
.
Extra Information
The execution context also indicates some extra information, which could be useful if a contract wants to know extra information about the EVC's authentication. This includes information about simulation and operator authentication status.
Simulations
The EVC also supports executing batches in a "simulation" mode. This is only intended to be invoked "off-chain", and is useful for user interfaces because they can show the user what the expected outcome of a sequence of operations will be.
Simulations work by actually performing the requested operations but then reverting, which (if called on-chain) reverts all the effects. Although simple in principle, there are a number of design elements involved:
- Intermediate read-only queries can be inserted into a batch to gather simulated data useful for display
- The results are available even if status checks would cause a failure, for example so that a user can see exactly what is causing the failure
- Although internally simulations work by reverting, the recommended interface returns it as regular return data, which causes fewer compatibility problems (sometimes error data is mangled or dropped). This is the reason for
batchRevert
: You can't do a "try/catch" without an external call, so this must be an external function, although we recommend using thebatchSimulation
entry point instead. - Simulations don't have the side-effect of making regular batches create large return-data (which would be gas inefficient)
While in simulation, the EVC sets the execution context simulationInProgress
flag that can be observed by external smart contracts. While the introduction of this flag was meant to increase the UX, it must be noted that malicious vaults or external systems could use this information to act differently during simulation mode, i.e. they can trick the user into thinking that the vault/external system is not malicious.
As with any other EVC feature, users should only use the EVC simulation with trusted and recognized smart contracts that do not aim to trick or harm them in any way. Considering the EVC simulation features are mostly meant to be used by the UI applications, this is the natural place where user protection should be applied. Simulations should not be used as a security measure to determine the effects of a batch if the systems with which the batch interacts are untrusted. If the user aims to faithfully evaluate the outcome of the simulation to assess the security of the to be executed transaction, they should resort to other methods and available commercial solutions.
Transient Storage
In order to maintain the execution context, access to the same variables must occur from different invocations of the EVC. This means that they must be held in storage, and not memory. Unfortunately, storage is expensive compared to memory. Luckily, the EVM protocol may soon specify a new type of memory lifetime: transient storage that is accessible to multiple invocations, but is inexpensive to access.
In order to take advantage of transient storage, the contracts have been structured to keep all the variables that should be stored in transient storage in a separate base class contract TransientStorage
. By optionally overriding this at compile-time, both old and new networks can be supported.
Security Considerations
Lockdown Mode
To improve user security, the EVC introduces the LOCKDOWN MODE
. This mode, which can only be activated by the owner, applies to all their accounts simultaneously. Once activated, the EVC significantly reduces its functionality across the owner's accounts. In this state, the owner is restricted to managing operators and nonces, while operators are restricted to revoke their own permissions. With LOCKDOWN MODE
active, neither the owner nor the operators can carry any other operations on the EVC. Notably, calling external smart contracts on behalf of the owner or any of their accounts is prohibited. However, authorized controllers can still control collaterals for the accounts even under lockdown. This mode is particularly useful in emergency situations, such as when a malicious operator has been added or a harmful permit message has been signed, necessitating immediate action to safeguard the user's assets.
Permit Disabled Mode
Another feature to improve user security is the PERMIT DISABLED MODE
. This mode, which can only be activated by the owner, applies to all their accounts simultaneously. Once activated, the EVC no longer allows executing permits that were signed by the owner which activated this mode. This mode is particularly useful in emergency situations, such as when a harmful permit message has been signed, necessitating immediate action to safeguard the user's assets.
Denial of Service
The EVC has been designed to function as a glorified multi-call contract allowing users to execute calls into any other addresses, including contracts containing malicious code. As with any other system of such a type, it is the user's responsibility to carefully select contracts they interact with. If not careful, malicious and non-compatible contracts can cause denial of service attacks, i.e. by requesting an account or vault status check for the account or vault for which the check fails. Arbitrary non malicious code can also introduce EVC failures by including a number of vault or account status checks that exceeds the maximum of 10 (SET_MAX_ELEMENTS
). This behavior should never pose a greater security threat to the system as a whole and with user’s care, can easily be avoided.
Authentication by Vaults
Vaults out-source their authentication to the EVC, but are responsible for authorisation themselves.
In order to support sub-accounts, operators, and being able to control collateral (ie, in liquidations), vaults can be invoked via the EVC's call
, batch
, or controlCollateral
functions, which will then execute the desired operations on the vault. However, the vault will see the EVC as the msg.sender
.
When a vault detects that msg.sender
is the EVC, it should call back into the EVC to retrieve the current execution context using getCurrentOnBehalfOfAccount
. This will tell the vault two things:
- The
onBehalfOfAccount
which indicates the account that has been authenticated by the EVC. The vault should consider this the "true" value ofmsg.sender
for authorisation purposes. - The
controllerEnabled
which indicates whether or not a vault is currently enabled as a controller for theonBehalfOfAccount
account. This information is needed if the vault is performing an operation (such as a borrow) that requires it to be the controller for an account. The caller ofgetCurrentOnBehalfOfAccount
itself passes the vault it is interested in via thecontrollerToCheck
parameter. WhencontrollerToCheck
is set to the zero address, the value returned is alwaysfalse
.
EVC Contract Privileges
Because the EVC contract can be made to invoke any arbitrary target contract with any arbitrary calldata, it should never be given any privileges, or hold any native currency or tokens.
The only exception to this is mid-transaction inside of a batch. If one batch item temporarily moves value or tokens into the EVC, but a subsequent batch item moves it out, then as long as no untrusted code runs in between, it is safe. However, moving tokens to the EVC is often unnecessary because tokens can be moved immediately to their final destination with transferFrom
and by setting various recipient
parameters in contracts.
One exception to this is wrapping ETH into WETH. The deposit method will always credit the caller with the WETH tokens. In this case, the user must transfer the WETH in a subsequent batch item (ideally the batch item immediately after the deposit using call
function).
It is important to note that calls and batch items in the EVC can transfer the entire value balance of the EVC by setting the value input parameter to type(uint256).max
. This can carry unintended consequences when calls are nested, even in the presence of trusted contracts only. Consider a scenario where a user performs a batch call with three actions: A, B, and C. A withdraws some value into the EVC, B performs some arbitrary operation on trusted vaults, and C deposits the value somewhere, using type(uint256).max
as the input parameter. If B performs an action on the EVC that uses its value balance, then C would fail to deposit the expected amount received in A, but the failure will in general not result in a revert. This can be problematic if B triggers malicious code, but it can also fail when the action performed by B is correct but also performs an EVC call with type(uint256).max
value, unintentionally using the whole value amount from A. The problem is not present when using a specified value in C, because the C action would cause a revert. Therefore, the use of type(uint256).max
as value is safe only when no intermediate action exists that transfers value to the EVC.
One area where the untrustable EVC address may cause problems is tokens that implement hooks/callbacks, such as ERC-777 tokens. In this case, somebody could install a hook for the EVC as a recipient, and cause inbound transfers to fail, or possibly even be redirected. The EVC doesn't attempt to solve this issue and care should be taken when interacting with contracts which implement hooks/callbacks.
Read-only Re-entrancy
The non-transient storage maintained by the EVC can be read while checks are deferred. In particular, this includes the lists of collaterals and controllers registered for a given account.
This should not result in "read-only re-entrancy" problems, because each individual operation will leave these lists in a consistent state. In particular, for a controller to be released, that controller itself must invoke the release, which typically means the debt has been repaid.
If an external contract attempted to read the collateral or controller states of an account in order to enforce some policy of its own, then it is possible that a user could defer its liquidity check, repay the loan, invoke the external contract, and then re-take the loan. In this case the external contract would see the controller as being released. However, this same action could be done outside of a checks-deferrable call by simply taking a flash loan from an external system, rather than using the checks-deferrable call.