Back to blogs
Written by
immeas
Published on
June 5, 2025

How Vault Withdrawals Work in EulerSwap: Full Flow Explained

Dive deep into how vault withdrawals work in EulerSwap, from EVC authentication to post-execution status checks. A clear, step-by-step technical breakdown.

Table of Contents

0. Introduction

As part of my research before the Cyfrin EulerSwap audit, I explored the complexities of the interactions between the Ethereum Vault Connector (EVC), a contract that connects lending/borrowing vaults, and Euler Vault Kit (EVK), Euler lending and borrowing market vaults.

On a very high level, the EVC is the controller responsible for authentication, authorization, and execution checks, while the EVK executes the validated actions. This separation of duties allows the Euler Vault to delegate verification responsibilities to the EVC. This ensures that all the affected system states are correct at the end of execution.

Since vault withdrawals in EulerSwap, pass through both EVC and EVK, it’s vital to understand how the flow works:

The line that calls the EVC is FundsLib::withdrawAssets#L151:

IEVC(evc).call(vault, p.eulerAccount, 0, abi.encodeCall(IERC4626.withdraw, (avail, to, p.eulerAccount)));


We enter:

1. Ethereum Vault Connector

The execution starts in EthereumVaultConnector::call:

/// @inheritdoc IEVC
function call(
    address targetContract,
    address onBehalfOfAccount,
    uint256 value,
    bytes calldata data
) public payable virtual nonReentrantChecksAndControlCollateral returns (bytes memory result) {
    EC contextCache = executionContext;
    executionContext = contextCache.setChecksDeferred();

    bool success;
    (success, result) = callWithAuthenticationInternal(targetContract, onBehalfOfAccount, value, data);

    if (!success) revertBytes(result);

    restoreExecutionContext(contextCache);
}

contextCache.setChecksDeferred() means that account checks, like account health checks and supply/borrow caps, will be postponed until restoreExecutionContext() is executed.  This defers validation until after execution, allowing the operation to proceed freely as long as the final state passes all checks.

The contextCache state is also cached here, allowing the EVC to revert to its pre-call state after all checks are completed.

Execution enters EthereumVaultConnector::callWithAuthenticationInternal:

/// @notice Internal function to call a target contract with necessary authentication.
/// @dev This function decides whether to use delegatecall or a regular call based on the target contract.
/// If the target contract is this contract, it uses delegatecall to preserve msg.sender for authentication.
/// Otherwise, it authenticates the caller if needed and proceeds with a regular call.
/// @param targetContract The contract address to call.
/// @param onBehalfOfAccount The account address on behalf of which the call is made.
/// @param value The amount of value to send with the call.
/// @param data The calldata to send with the call.
/// @return success A boolean indicating if the call was successful.
/// @return result The bytes returned from the call.
function callWithAuthenticationInternal(
    address targetContract,
    address onBehalfOfAccount,
    uint256 value,
    bytes calldata data
) internal virtual returns (bool success, bytes memory result) {
    if (targetContract == address(this)) {
        if (onBehalfOfAccount != address(0)) {
            revert EVC_InvalidAddress();
        }

        if (value != 0) {
            revert EVC_InvalidValue();
        }

        // delegatecall is used here to preserve msg.sender in order to be able to perform authentication
        (success, result) = address(this).delegatecall(data);
    } else {
        // when the target contract is equal to the msg.sender, both in call() and batch(), authentication is not
        // required
        if (targetContract != msg.sender) {
            authenticateCaller({account: onBehalfOfAccount, allowOperator: true, checkLockdownMode: true});
        }

        (success, result) = callWithContextInternal(targetContract, onBehalfOfAccount, value, data);
    }
}


The params passed were:

function callWithAuthenticationInternal(
    address targetContract,    // vault
    address onBehalfOfAccount, // p.eulerAccount
    uint256 value,             // 0
    bytes calldata data        // abi.encodeCall(IERC4626.withdraw, (avail, to, p.eulerAccount))
)


Execution enters the else flow as targetContract != address(this), i.e., the EVC contract. Since the targetContract (vault) is not equal to msg.sender (EulerSwap), the call proceeds into  EthereumVaultConnector::authenticateCaller with checkLockDownMode == true. Lockdown mode is a feature that an account owner can use in emergencies to disable their account:

/// @notice Authenticates the caller of a function.
/// @dev This function checks if the caller is the owner or an authorized operator of the account, and if the
/// account is not in lockdown mode.
/// @param account The account address to authenticate the caller against.
/// @param allowOperator A boolean indicating if operators are allowed to authenticate as the caller.
/// @param checkLockdownMode A boolean indicating if the function should check for lockdown mode on the account.
/// @return The address of the authenticated caller.
function authenticateCaller(
    address account,
    bool allowOperator,
    bool checkLockdownMode
) internal virtual returns (address) {
    bytes19 addressPrefix = getAddressPrefixInternal(account);
    address owner = ownerLookup[addressPrefix].owner;
    bool lockdownMode = ownerLookup[addressPrefix].isLockdownMode;
    address msgSender = _msgSender();
    bool authenticated = false;

    // check if the caller is the owner of the account
    if (haveCommonOwnerInternal(account, msgSender)) {
        // if the owner is not registered, register it
        if (owner == address(0)) {
            ownerLookup[addressPrefix].owner = owner = msgSender;
            emit OwnerRegistered(addressPrefix, msgSender);
            authenticated = true;
        } else if (owner == msgSender) {
            authenticated = true;
        }
    }

    // if the caller is not the owner, check if it is an operator if operators are allowed
    if (!authenticated && allowOperator && isAccountOperatorAuthorizedInternal(account, msgSender)) {
        authenticated = true;
    }

    // if the authenticated account is non-owner, prevent its account from being a smart contract
    if (authenticated && owner != account && account.code.length != 0) {
        authenticated = false;
    }

    // must revert if neither the owner nor the operator were authenticated
    if (!authenticated) {
        revert EVC_NotAuthorized();
    }

    // revert if the account is in lockdown mode unless the lockdown mode is not being checked
    if (checkLockdownMode && lockdownMode) {
        revert EVC_LockdownMode();
    }

    return msgSender;
}


Let's break this down part by part:

bytes19 addressPrefix = getAddressPrefixInternal(account);     // first 19 bytes of `p.eulerAccount`
address owner = ownerLookup[addressPrefix].owner;              // owner of the euler account
bool lockdownMode = ownerLookup[addressPrefix].isLockdownMode; // probably false
address msgSender = _msgSender();                              // EulerSwap
bool authenticated = false;


The conditional if (haveCommonOwnerInternal(account, msgSender)) is not executed as msgSender is the EulerSwap contract, and it does not have a common owner with account (p.eulerAccount). This is because EulerSwap and p.eulerAccount are structurally unrelated and cannot be subaccounts. It’s the EVC abstraction that lets an account owner create separate (sub)accounts. 

A subaccount will have the same first 19 bytes as the owner address. Thus, there can be only 256 of them. As EulerSwap is a separately deployed account, it is astronomically unlikely that it will share the first 19 bytes with the owner address and can therefore not be a subaccount.

Next:

// if the caller is not the owner, check if it is an operator if operators are allowed
if (!authenticated && allowOperator && isAccountOperatorAuthorizedInternal(account, msgSender)) {
    authenticated = true;
}


At this point, authenticated is false, and allowOperator: true was passed, so execution goes into EthereumVaultConnector::isAccountOperatorAuthorizedInternal:

/// @notice Checks if an operator is authorized for a specific account.
/// @dev Determines operator authorization by checking if the operator's bit is set in the operator's bit field for
/// the account's address prefix. If the owner is not registered (address(0)), it implies the operator cannot be
/// authorized, hence returns false. The bitMask is calculated by shifting 1 left by the XOR of the owner's and
/// account's address, effectively checking the operator's authorization for the specific account.
/// @param account The account address to check the operator authorization for.
/// @param operator The operator address to check authorization status.
/// @return isAuthorized True if the operator is authorized for the account, false otherwise.
function isAccountOperatorAuthorizedInternal(
    address account,
    address operator
) internal view returns (bool isAuthorized) {
    bytes19 addressPrefix = getAddressPrefixInternal(account);
    address owner = ownerLookup[addressPrefix].owner;

    // if the owner is not registered yet, it means that the operator couldn't have been authorized
    if (owner == address(0)) return false;

    // The bitMask defines which accounts the operator is authorized for. The bitMask is created from the account
    // number which is a number up to 2^8 in binary, or 256. 1 << (uint160(owner) ^ uint160(account)) transforms
    // that number in an 256-position binary array like 0...010...0, marking the account positionally in a uint256.
    uint256 bitMask = 1 << (uint160(owner) ^ uint160(account));

    return operatorLookup[addressPrefix][operator] & bitMask != 0;
}


This check returns true because a requirement for EulerSwap is that the owner has set their EulerSwap instance as an authorized operator, which is also verified during deployment of the EulerSwap instance.

You can refer to EthereumVaultConnector::setAccountOperator, which essentially flips the corresponding authorization bit to 1.

Hence, authenticated becomes true.

The next if is a bit tricky:

// if the authenticated account is non-owner, prevent its account from being a smart contract
if (authenticated && owner != account && account.code.length != 0) {
    authenticated = false;
}


authenticated is true from above, and for simplicity, let’s assume that owner is the same as account. As the comment notes, if p.eulerAccount is not the owner, it can't be a smart contract (a rule enforced by the Euler team).

The final checks are pretty simple: revert if authentication fails, or if the account is in lockDownMode:

// must revert if neither the owner nor the operator were authenticated
if (!authenticated) {
    revert EVC_NotAuthorized();
}

// revert if the account is in lockdown mode unless the lockdown mode is not being checked
if (checkLockdownMode && lockdownMode) {
    revert EVC_LockdownMode();
}

return msgSender;


Authentication is now complete, and the call proceeds to the vault via EthereumVaultConnector::callWithContextInternal:

/// @notice Internal function to make a call to a target contract with a specific context.
/// @dev This function sets the execution context for the duration of the call.
/// @param targetContract The contract address to call.
/// @param onBehalfOfAccount The account address on behalf of which the call is made.
/// @param value The amount of value to send with the call.
/// @param data The calldata to send with the call.
function callWithContextInternal(
    address targetContract,
    address onBehalfOfAccount,
    uint256 value,
    bytes calldata data
) internal virtual returns (bool success, bytes memory result) {
    if (value == type(uint256).max) {
        value = address(this).balance;
    } else if (value > address(this).balance) {
        revert EVC_InvalidValue();
    }

    EC contextCache = executionContext;
    address msgSender = _msgSender();

    // set the onBehalfOfAccount in the execution context for the duration of the external call.
    // considering that the operatorAuthenticated is only meant to be observable by external
    // contracts, it is sufficient to set it here rather than in the authentication function.
    // apart from the usual scenario (when an owner operates on behalf of its account),
    // the operatorAuthenticated should be cleared when about to execute the permit self-call, when
    // target contract is equal to the msg.sender in call() and batch(), or when the controlCollateral is in
    // progress (in which case the operatorAuthenticated is not relevant)
    if (
        haveCommonOwnerInternal(onBehalfOfAccount, msgSender) || targetContract == msg.sender
            || targetContract == address(this) || contextCache.isControlCollateralInProgress()
    ) {
        executionContext = contextCache.setOnBehalfOfAccount(onBehalfOfAccount).clearOperatorAuthenticated();
    } else {
        executionContext = contextCache.setOnBehalfOfAccount(onBehalfOfAccount).setOperatorAuthenticated();
    }

    emit CallWithContext(
        msgSender, getAddressPrefixInternal(onBehalfOfAccount), onBehalfOfAccount, targetContract, bytes4(data)
    );

    (success, result) = targetContract.call{value: value}(data);

    executionContext = contextCache;
}


No native value was passed, so the first if is skipped.

The next part:

// set the onBehalfOfAccount in the execution context for the duration of the external call.
// considering that the operatorAuthenticated is only meant to be observable by external
// contracts, it is sufficient to set it here rather than in the authentication function.
// apart from the usual scenario (when an owner operates on behalf of its account),
// the operatorAuthenticated should be cleared when about to execute the permit self-call, when
// target contract is equal to the msg.sender in call() and batch(), or when the controlCollateral is in
// progress (in which case the operatorAuthenticated is not relevant)
if (
    haveCommonOwnerInternal(onBehalfOfAccount, msgSender) || targetContract == msg.sender
        || targetContract == address(this) || contextCache.isControlCollateralInProgress()
) {
    executionContext = contextCache.setOnBehalfOfAccount(onBehalfOfAccount).clearOperatorAuthenticated();
} else {
    executionContext = contextCache.setOnBehalfOfAccount(onBehalfOfAccount).setOperatorAuthenticated();
}

The whole if statement resolves to false:

  • haveCommonOwnerInternal(onBehalfOfAccount, msgSender):  onBehalfOfAccount (p.eulerAccount) and msg.sender (EulerSwap) do not share a common owner
  • targetContract == msg.sender: targetContract (vault) is not msg.sender (EulerSwap)
  • targetContract == address(this): targetContract (vault) is not address(this) (the EVC contract)
  • contextCache.isControlCollateralInProgress(): no control collateral, used when liquidating a position, is active

Therefore, the executionContext is updated to include the OnBehalfOfAccount and mark the operator as authenticated:

executionContext = contextCache.setOnBehalfOfAccount(onBehalfOfAccount).setOperatorAuthenticated();


Next, the actual call is executed:

(success, result) = targetContract.call{value: value}(data);


Which, in this case, is:

vault.withdraw(avail, to, p.eulerAccount);

2. Euler Vault

Now, the call to the actual vault happens in EVault::withdraw:

function withdraw(uint256 amount, address receiver, address owner) public virtual override callThroughEVC use(MODULE_VAULT) returns (uint256) {}


This is an interesting pattern: EVault::withdraw is an empty wrapper function. The actual logic is contained in the modifiers: callThroughEVC, and use(MODULE_VAULT).

First, the modifier Dispatch::callThroughEVC is called:

// Modifier ensures, that the body of the function is always executed from the EVC call.
// It is accomplished by intercepting calls incoming directly to the vault and passing them
// to the EVC.call function. EVC calls the vault back with original calldata. As a result, the account
// and vault status checks are always executed in the checks deferral frame, at the end of the call,
// outside of the vault's re-entrancy protections.
// The modifier is applied to all functions which schedule account or vault status checks.
modifier callThroughEVC() {
    if (msg.sender == address(evc)) {
        _;
    } else {
        callThroughEVCInternal();
    }
}


In this case, msg.sender is address(evc), so the modifier allows execution to proceed immediately. This ensures that calls to the Euler vaults will always happen through the EVC, as the vault has delegated many of its accounting checks to the EVC.

Next is Dispatch::use, where the actual withdrawal logic is executed:

// Modifier proxies the function call to a module and low-level returns the result
modifier use(address module) {
    _; // when using the modifier, it is assumed the function body is empty.
    delegateToModule(module);
}


It first executes the empty function body from above (i.e., EVault::withdraw), then enters Dispatch::delegateToModule:

function delegateToModule(address module) private {
    assembly {
        calldatacopy(0, 0, calldatasize())
        let result := delegatecall(gas(), module, 0, calldatasize(), 0, 0)
        returndatacopy(0, 0, returndatasize())
        switch result
        case 0 { revert(0, returndatasize()) }
        default { return(0, returndatasize()) }
    }
}


The module used here is use(MODULE_VAULT), which means the delegatecall routes execution into Vault::withdraw:

/// @inheritdoc IERC4626
function withdraw(uint256 amount, address receiver, address owner) public virtual nonReentrant returns (uint256) {
    (VaultCache memory vaultCache, address account) = initOperation(OP_WITHDRAW, owner);

    Assets assets = amount.toAssets();
    if (assets.isZero()) return 0;

    Shares shares = assets.toSharesUp(vaultCache);

    finalizeWithdraw(vaultCache, assets, shares, account, receiver, owner);

    return shares.toUint();
}


initOperation(OP_WITHDRAW, owner) will do the validations and setup through Base::initOperation:

// Generate a vault snapshot and store it.
// Queue vault and maybe account checks in the EVC (caller, current, onBehalfOf or none).
// If needed, revert if this contract is not the controller of the authenticated account.
// Returns the VaultCache and active account.
function initOperation(uint32 operation, address accountToCheck)
    internal
    virtual
    returns (VaultCache memory vaultCache, address account)
{
    vaultCache = updateVault();
    account = EVCAuthenticateDeferred(CONTROLLER_NEUTRAL_OPS & operation == 0);

    callHook(vaultCache.hookedOps, operation, account);
    EVCRequireStatusChecks(accountToCheck == CHECKACCOUNT_CALLER ? account : accountToCheck);

    // The snapshot is used only to verify that supply increased when checking the supply cap, and to verify that
    // the borrows increased when checking the borrowing cap. Caps are not checked when the capped variables
    // decrease (become safer). For this reason, the snapshot is disabled if both caps are disabled.
    // The snapshot is cleared during the vault status check hence the vault status check must not be forgiven.
    if (
        !vaultCache.snapshotInitialized
            && !(vaultCache.supplyCap == type(uint256).max && vaultCache.borrowCap == type(uint256).max)
    ) {
        vaultStorage.snapshotInitialized = vaultCache.snapshotInitialized = true;
        snapshot.set(vaultCache.cash, vaultCache.totalBorrows.toAssetsUp());
    }
}

Cache::updateVault handles interest accrual, fees, and the tracking of total supply and borrows. 

callHook allows vault-specific logic to run. For brevity, let’s assume the vault has no hooks.

Execution then continues with:

account = EVCAuthenticateDeferred(CONTROLLER_NEUTRAL_OPS & operation == 0);


The vault will verify if a controller must be enabled for this operation. A controller is the vault responsible for validating that a position is healthy. Since we’re only withdrawing, it is not needed. CONTROLLER_NEUTRAL_OPS is defined as:

uint32 constant CONTROLLER_NEUTRAL_OPS = OP_DEPOSIT | OP_MINT | OP_WITHDRAW | OP_REDEEM | OP_TRANSFER | OP_SKIM
    | OP_REPAY | OP_REPAY_WITH_SHARES | OP_CONVERT_FEES | OP_FLASHLOAN | OP_TOUCH | OP_VAULT_STATUS_CHECK;


Since OP_WITHDRAW is part of CONTROLLER_NEUTRAL_OPS the statement  CONTROLLER_NEUTRAL_OPS & operation == 0 will be false

This result leads to a call to EVCClient::EVCAuthenticateDeferred, with the controller check disabled:

// Authenticate the account and the controller, making sure the call is made through EVC and the status checks are
// deferred
function EVCAuthenticateDeferred(bool checkController) internal view virtual returns (address) {
    assert(msg.sender == address(evc)); // this ensures that callThroughEVC modifier was utilized

    (address onBehalfOfAccount, bool controllerEnabled) =
        evc.getCurrentOnBehalfOfAccount(checkController ? address(this) : address(0));

    if (checkController && !controllerEnabled) revert E_ControllerDisabled();

    return onBehalfOfAccount;
}


Since checkController == false, the getCurrentOnBehalfOfAccount is called with address(0), skipping controller validation.

EthereumVaultConnector::getCurrentOnBehalfOfAccount returns both the onBehalfOfAccount and, if a controller is enabled:

/// @inheritdoc IEVC
function getCurrentOnBehalfOfAccount(address controllerToCheck)
    external
    view
    returns (address onBehalfOfAccount, bool controllerEnabled)
{
    onBehalfOfAccount = executionContext.getOnBehalfOfAccount();

    // for safety, revert if no account has been authenticated
    if (onBehalfOfAccount == address(0)) {
        revert EVC_OnBehalfOfAccountNotAuthenticated();
    }

    controllerEnabled =
        controllerToCheck == address(0) ? false : accountControllers[onBehalfOfAccount].contains(controllerToCheck);
}


Previously set before the call to the EVault, onBehalfOfAccount is p.eulerAccount.

Next line in initOperation is EVCClient::EVCRequireStatusChecks:

function EVCRequireStatusChecks(address account) internal virtual {
    assert(account != CHECKACCOUNT_CALLER); // the special value should be resolved by now

    if (account == CHECKACCOUNT_NONE) {
        evc.requireVaultStatusCheck();
    } else {
        evc.requireAccountAndVaultStatusCheck(account);
    }
}


Since account (p.eulerAccount) is not CHECKACCOUNT_NONE, the EVault tells EVC that a status check is required after the operation is done, EthereumVaultConnector::requireAccountAndVaultStatusCheck:

/// @inheritdoc IEVC
function requireAccountAndVaultStatusCheck(address account) public payable virtual {
    if (executionContext.areChecksDeferred()) {
        accountStatusChecks.insert(account);
        vaultStatusChecks.insert(msg.sender);
    } else {
        requireAccountStatusCheckInternalNonReentrantChecks(account);
        requireVaultStatusCheckInternalNonReentrantChecks(msg.sender);
    }
}


Checks were deferred since entering EthereumVaultConnector::call. Thus, the EVault and the account (p.eulerAccount) are entered into the status checks list.

This ensures that the status checks run after execution, not during, maintaining the deferred-checks model.

Lastly, initOperation stores incoming snapshots of the vault's available assets and borrows, which will be used later in the vault status verification:

// The snapshot is used only to verify that supply increased when checking the supply cap, and to verify that
// the borrows increased when checking the borrowing cap. Caps are not checked when the capped variables
// decrease (become safer). For this reason, the snapshot is disabled if both caps are disabled.
// The snapshot is cleared during the vault status check hence the vault status check must not be forgiven.
if (
    !vaultCache.snapshotInitialized
        && !(vaultCache.supplyCap == type(uint256).max && vaultCache.borrowCap == type(uint256).max)
) {
    vaultStorage.snapshotInitialized = vaultCache.snapshotInitialized = true;
    snapshot.set(vaultCache.cash, vaultCache.totalBorrows.toAssetsUp());
}


The remaining steps in Vault::withdraw are just standard ERC-4626 withdrawals:

Assets assets = amount.toAssets();
if (assets.isZero()) return 0;

Shares shares = assets.toSharesUp(vaultCache);

finalizeWithdraw(vaultCache, assets, shares, account, receiver, owner);

return shares.toUint();


Nothing unexpected here: conversion, accounting, and finalization. 

With that complete, execution returns to EthereumVaultConnector:

3. Ethereum Vault Connector

Execution enters the final lines of EthereumVaultConnector::call:

bool success;
(success, result) = callWithAuthenticationInternal(targetContract, onBehalfOfAccount, value, data);

if (!success) revertBytes(result);

restoreExecutionContext(contextCache);


The first line handles failure propagation. Assuming our call succeeds without reverting, execution proceeds to EthereumVaultConnector::restoreExecutionContext:

/// @notice Restores the execution context from a cached state.
/// @dev This function restores the execution context to a previously cached state, performing necessary status
/// checks if they are no longer deferred. If checks are no longer deferred, it sets the execution context to
/// indicate checks are in progress and clears the 'on behalf of' account. It then performs status checks for both
/// accounts and vaults before restoring the execution context to the cached state.
/// @param contextCache The cached execution context to restore from.
function restoreExecutionContext(EC contextCache) internal virtual {
    if (!contextCache.areChecksDeferred()) {
        executionContext = contextCache.setChecksInProgress().setOnBehalfOfAccount(address(0));

        checkStatusAll(SetType.Account);
        checkStatusAll(SetType.Vault);
    }

    executionContext = contextCache;
}


restoreExecutionContext is called with the cached state from before the vault operation. Because contextCache.areChecksDeferred() returns false (assuming there were no interactions with the EVC before our swap) and checks are done, the system proceeds to perform post-operation status validation via EthereumVaultConnector::checkStatusAll:

/// @notice Checks the status of all entities in a set, either accounts or vaults, and clears the checks.
/// @dev Iterates over either accountStatusChecks or vaultStatusChecks based on the setType and performs status
/// checks.
/// Clears the checks while performing them.
/// @param setType The type of set to perform the status checks on, either accounts or vaults.
function checkStatusAll(SetType setType) internal virtual {
    setType == SetType.Account
        ? accountStatusChecks.forEachAndClear(requireAccountStatusCheckInternal)
        : vaultStatusChecks.forEachAndClear(requireVaultStatusCheckInternal);
}


checkStatusAll is called for both Account and Vault. This performs validation on account health and ensures the Vault borrow and supply caps are not exceeded.

It delegates to forEachAndClear, which just calls the provided function for each item and then removes it.

EthereumVaultConnector::requireAccountStatusCheckInternal calls the status checks and handles reverts.

function requireAccountStatusCheckInternal(address account) internal virtual {
    (bool isValid, bytes memory result) = checkAccountStatusInternal(account);

    if (!isValid) {
        revertBytes(result);
    }
}


This simply delegates to EthereumVaultConnector::checkAccountStatusInternal, which performs the actual evaluation:

/// @notice Checks the status of an account internally.
/// @dev This function first checks the number of controllers for the account. If there are no controllers enabled,
/// it returns true immediately, indicating the account status is valid without further checks. If there is more
/// than one controller, it reverts with an EVC_ControllerViolation error. For a single controller, it proceeds to
/// call the controller to check the account status.
/// @param account The account address to check the status for.
/// @return isValid A boolean indicating if the account status is valid.
/// @return result The bytes returned from the controller call, indicating the account status.
function checkAccountStatusInternal(address account) internal virtual returns (bool isValid, bytes memory result) {
    SetStorage storage accountControllersStorage = accountControllers[account];
    uint256 numOfControllers = accountControllersStorage.numElements;
    address controller = accountControllersStorage.firstElement;
    uint8 stamp = accountControllersStorage.stamp;

    if (numOfControllers == 0) return (true, "");
    else if (numOfControllers > 1) return (false, abi.encodeWithSelector(EVC_ControllerViolation.selector));

    bool success;
    (success, result) = controller.staticcall(
        abi.encodeCall(IVault.checkAccountStatus, (account, accountCollaterals[account].get()))
    );

    isValid = success && result.length == 32
        && abi.decode(result, (bytes32)) == bytes32(IVault.checkAccountStatus.selector);

    if (isValid) {
        accountControllersStorage.numElements = uint8(numOfControllers);
        accountControllersStorage.firstElement = controller;
        accountControllersStorage.metadata = uint80(block.timestamp);
        accountControllersStorage.stamp = stamp;
    }

    emit AccountStatusCheck(account, controller);
}


The controllers are queried for the account status, RiskManager::checkAccountStatus:

/// @inheritdoc IRiskManager
/// @dev The function doesn't have a reentrancy lock, because onlyEVCChecks provides equivalent behaviour. It
/// ensures that the caller is the EVC, in 'checks in progress' state. In this state EVC will not accept any calls.
/// Since all the functions which modify vault state use callThroughEVC modifier, they are effectively blocked while
/// the function executes. There are non-view functions without `callThroughEVC` modifier (`flashLoan`,
/// `disableController`), but they don't change the vault's storage.
function checkAccountStatus(address account, address[] calldata collaterals)
    public
    view
    virtual
    reentrantOK
    onlyEVCChecks
    returns (bytes4 magicValue)
{
    checkLiquidity(loadVault(), account, collaterals);

    magicValue = IEVCVault.checkAccountStatus.selector;
}


The liquidity check is handled by LiquidityUtils::checkAccountStatus:

// Check that there is no liability, or the value of the collateral, adjusted for borrowing LTV, is greater than the
// liability value. Since this function uses bid/ask prices, it should only be used within the account status check,
// and not for determining whether an account can be liquidated (which uses mid-point prices).
function checkLiquidity(VaultCache memory vaultCache, address account, address[] memory collaterals)
    internal
    view
    virtual
{
    validateOracle(vaultCache);

    Owed owed = vaultStorage.users[account].getOwed();
    if (owed.isZero()) return;

    uint256 liabilityValue = getLiabilityValue(vaultCache, account, owed, false);

    uint256 collateralValue;
    for (uint256 i; i < collaterals.length; ++i) {
        collateralValue += getCollateralValue(vaultCache, account, collaterals[i], false);
        if (collateralValue > liabilityValue) return;
    }

    revert E_AccountLiquidity();
}


Without going into every detail, the code confirms that the account (e.g., p.eulerAccount) remains healthy and the withdrawal has not put it at risk of liquidation.

The final validation step is the vault status check, handled by EthereumVaultConnector::requireVaultStatusCheckInternal:

function requireVaultStatusCheckInternal(address vault) internal virtual {
    (bool isValid, bytes memory result) = checkVaultStatusInternal(vault);

    if (!isValid) {
        revertBytes(result);
    }
}


Which directs to EthereumVaultConnector::checkVaultStatusInternal:

/// @notice Checks the status of a vault internally.
/// @dev This function makes an external call to the vault to check its status.
/// @param vault The address of the vault to check the status for.
/// @return isValid A boolean indicating if the vault status is valid.
/// @return result The bytes returned from the vault call, indicating the vault status.
function checkVaultStatusInternal(address vault) internal virtual returns (bool isValid, bytes memory result) {
    bool success;
    (success, result) = vault.call(abi.encodeCall(IVault.checkVaultStatus, ()));

    isValid =
        success && result.length == 32 && abi.decode(result, (bytes32)) == bytes32(IVault.checkVaultStatus.selector);

    emit VaultStatusCheck(vault);
}


This makes an external call to vault.checkVaultStatus(), which in this case resolves to RiskManager::checkVaultStatus:

/// @inheritdoc IRiskManager
/// @dev See comment about reentrancy for `checkAccountStatus`
function checkVaultStatus() public virtual reentrantOK onlyEVCChecks returns (bytes4 magicValue) {
    // Use the updating variant to make sure interest is accrued in storage before the interest rate update.
    // Because of interest rate retargetting during the vault status check, the vault status check must not be
    // forgiven.
    VaultCache memory vaultCache = updateVault();
    uint256 newInterestRate = computeInterestRate(vaultCache);

    logVaultStatus(vaultCache, newInterestRate);

    // We use the snapshot to check if the borrows or supply grew, and if so then we check the borrow and supply
    // caps. If snapshot is initialized, then caps are configured. If caps are set in the middle of a batch, then
    // snapshots represent the state of the vault at that time.
    if (vaultCache.snapshotInitialized) {
        vaultStorage.snapshotInitialized = vaultCache.snapshotInitialized = false;

        Assets snapshotCash = snapshot.cash;
        Assets snapshotBorrows = snapshot.borrows;

        uint256 prevBorrows = snapshotBorrows.toUint();
        uint256 borrows = vaultCache.totalBorrows.toAssetsUp().toUint();

        if (borrows > vaultCache.borrowCap && borrows > prevBorrows) revert E_BorrowCapExceeded();

        uint256 prevSupply = snapshotCash.toUint() + prevBorrows;

        // Borrows are rounded down, because total assets could increase during repays.
        // This could happen when repaid user debt is rounded up to assets and used to increase cash,
        // while totalBorrows would be adjusted by only the exact debt, less than the increase in cash.
        // If multiple accounts need to repay while the supply cap is exceeded they should do so in
        // separate batches.
        uint256 supply = vaultCache.cash.toUint() + vaultCache.totalBorrows.toAssetsDown().toUint();


        if (supply > vaultCache.supplyCap && supply > prevSupply) revert E_SupplyCapExceeded();


        snapshot.reset();
    }

    callHookWithLock(vaultCache.hookedOps, OP_VAULT_STATUS_CHECK, address(evc));

    magicValue = IEVCVault.checkVaultStatus.selector;
}


The snapshot saved earlier (during initOperation) is used to verify that supply caps are not exceeded. Note: It's okay to exceed supply or borrow caps if they were already broken before; however, the total supply or borrow must have decreased afterward. Otherwise, the call will revert.

Lastly, an optional call to a hook is made. If all these conditions are met, the withdrawal is complete, and the EulerSwap can proceed.

4. Conclusion

In summary, a withdrawal initiated through EulerSwap triggers a sequence of tightly controlled steps: authentication via EVC, contextual execution, deferred validation, and finally, status checks on both the account and vault. Each component cooperates to ensure that the withdrawal leaves the system in a valid, safe state. Only then does the execution resume in EulerSwap.

Secure your protocol today

Join some of the biggest protocols and companies in creating a better internet. Our security researchers will help you throughout the whole process.
Stay on the bleeding edge of security
Carefully crafted, short smart contract security tips and news freshly delivered every week.