WATCHPUGSherlock / Euler eIP 14

[S-1] Changes on computeExchangeRate() can be replaced by requiring >= INITIAL_RESERVES amount of deposit in doActivateMarket()

Per the doc:

computeExchangeRate has been modified to support the case where ETokens exist on the pool but no actual deposits have been made yet.

https://github.com/euler-xyz/euler-contracts/blob/0fade57d9ede7b010f943fa8ad3ad74b9c30e283/contracts/BaseLogic.sol#L287-L291

function computeExchangeRate(AssetCache memory assetCache) private pure returns (uint) {
    uint totalAssets = assetCache.poolSize + (assetCache.totalBorrows / INTERNAL_DEBT_PRECISION);
    if (totalAssets == 0 || assetCache.totalBalances == 0) return 1e18;
    return totalAssets * 1e18 / assetCache.totalBalances;
}

We believe this will create a special and inconsistent state where totalAssets == 0, exchangeRate == 1e18 and totalBalances > 0, in which case, totalAssets != totalBalances * exchangeRate.

Recommendation

Another approach to solving the problem will be to change the Markets#doActivateMarket(), and require the caller to deposit at least INITIAL_RESERVES of the underlying token at an exchangeRate of 1e18.

For example:

  • If the underlying asset is DAI with a decimals of 18, then the caller must deposit 1e6 wei (INITIAL_RESERVES == 1e6) of DAI, the initial reserveBalance and totalBalances will be 1e6; totalAssets and assetCache.poolSize will also be 1e6.

  • If the underlying asset is WBTC with a decimals of 8, then the caller must deposit 1 wei of WBTC, the initial reserveBalance and totalBalances will be 1e10; totalAssets and assetCache.poolSize will also be 1e10.

[M-2] DToken.sol#flashLoan() can be used for exchangeRate manipulation

The newly added changes introduced in PR #148: make view methods that loadAssetCacheRO check the re-entrancy guard is unlocked have rendered this issue invalid.

https://github.com/euler-xyz/euler-contracts/blob/0fade57d9ede7b010f943fa8ad3ad74b9c30e283/contracts/modules/DToken.sol#L154-L164

function flashLoan(uint amount, bytes calldata data) external nonReentrant {
    (address underlying,,, address msgSender) = CALLER();

    uint origBalance = IERC20(underlying).balanceOf(address(this));

    Utils.safeTransfer(underlying, msgSender, amount);

    IFlashLoan(msgSender).onFlashLoan(data);

    require(IERC20(underlying).balanceOf(address(this)) >= origBalance, "e/flash-loan-not-repaid");
}

A set of public/external functions will utilize computeExchangeRate() to provide information about underlying value to the caller:

For instances:

However, computeExchangeRate() will read the real time balanceOf as poolSize and compute the exchange rate based on that:

https://github.com/euler-xyz/euler-contracts/blob/0fade57d9ede7b010f943fa8ad3ad74b9c30e283/contracts/BaseLogic.sol#L197

DToken.sol#flashLoan() makes it possible to manipulate the exchangeRate (downwards) very easily and at almost 0 cost, simply by taking a huge amount of underlying out of the contract and use the manipulated price in IFlashLoan(msgSender).onFlashLoan(data)/

This can cause problems if the exchange rate based public/external functions listed above are being used in 3rd party protocols for important accounting related computations.

[H-3] Lack of price freshness check in RiskManager#callChainlinkLatestAnswer() may cause a stale price to be used

https://github.com/euler-xyz/euler-contracts/blob/0fade57d9ede7b010f943fa8ad3ad74b9c30e283/contracts/modules/RiskManager.sol#L187-L201

function callChainlinkLatestAnswer(address chainlinkAggregator) private view returns (uint price) {
    (bool success, bytes memory data) = chainlinkAggregator.staticcall(abi.encodeWithSelector(IChainlinkAggregatorV2V3.latestAnswer.selector));

    if (!success) {
        return 0;
    }

    int256 answer = abi.decode(data, (int256));
    if (answer <= 0) {
        return 0;
    }

    price = uint(answer);
    if (price > 1e36) price = 1e36;
}

Chainlink's latestAnswer() wont return the updated time, therefore, the freshness check can not be done. This could lead to stale prices being used.

If the market price of the token drops very quickly ("flash crashes"), and Chainlink's feed does not get updated in time, the Euler protocol will continue to believe the token is worth more than the market value.

Attackers can exploit it by depositing huge amounts of overvalued tokens as collateral and borrowing good assets, as the market value of the collateral is actually lower than the borrowed funds, those borrowers won't repay their debts.

A similar issue caused the Venus protocol on BSC to accrue $14.2M of bad debt during the lunacrash.

Recommendation

Consider using latestRoundData() instead and adding missing checks for stale price:

(uint80 roundID, int256 answer, , uint256 updatedAt, uint80 answeredInRound) = chainlinkAggregator.latestRoundData();
if (block.timestamp - updatedAt > validPeriod)) {
    return 0;
}
if (answer <= 0) {
    return 0;
}
...

[L-4] Governance: Missing lower bound check for newPricingType in setPricingConfig()

https://github.com/euler-xyz/euler-contracts/blob/0fade57d9ede7b010f943fa8ad3ad74b9c30e283/contracts/modules/Governance.sol#L48-L64

function setPricingConfig(address underlying, uint16 newPricingType, uint32 newPricingParameter) external nonReentrant governorOnly {
    address eTokenAddr = underlyingLookup[underlying].eTokenAddress;
    require(eTokenAddr != address(0), "e/gov/underlying-not-activated");
    require(newPricingType < PRICINGTYPE__OUT_OF_BOUNDS, "e/gov/bad-pricing-type");

    AssetStorage storage assetStorage = eTokenLookup[eTokenAddr];
    AssetCache memory assetCache = loadAssetCache(underlying, assetStorage);

    assetStorage.pricingType = assetCache.pricingType = newPricingType;
    assetStorage.pricingParameters = assetCache.pricingParameters = newPricingParameter;

    if (newPricingType == PRICINGTYPE__CHAINLINK) {
        require(chainlinkPriceFeedLookup[underlying] != address(0), "e/gov/chainlink-price-feed-not-initialized");
    }

    emit GovSetPricingConfig(underlying, newPricingType, newPricingParameter);
}

While it checks for newPricingType not exceeding the upper bound, there is no check to ensure it's greater than the lower bound of 0.

Recommendation

Change to:

function setPricingConfig(address underlying, uint16 newPricingType, uint32 newPricingParameter) external nonReentrant governorOnly {
    address eTokenAddr = underlyingLookup[underlying].eTokenAddress;
    require(eTokenAddr != address(0), "e/gov/underlying-not-activated");
    require(newPricingType > 0 && newPricingType < PRICINGTYPE__OUT_OF_BOUNDS, "e/gov/bad-pricing-type");

[L-5] Governance: loadAssetCache() and memory writes to assetCache is unnecessary in setPricingConfig()

https://github.com/euler-xyz/euler-contracts/blob/0fade57d9ede7b010f943fa8ad3ad74b9c30e283/contracts/modules/Governance.sol#L48-L64

function setPricingConfig(address underlying, uint16 newPricingType, uint32 newPricingParameter) external nonReentrant governorOnly {
    address eTokenAddr = underlyingLookup[underlying].eTokenAddress;
    require(eTokenAddr != address(0), "e/gov/underlying-not-activated");
    require(newPricingType < PRICINGTYPE__OUT_OF_BOUNDS, "e/gov/bad-pricing-type");

    AssetStorage storage assetStorage = eTokenLookup[eTokenAddr];
    AssetCache memory assetCache = loadAssetCache(underlying, assetStorage);

    assetStorage.pricingType = assetCache.pricingType = newPricingType;
    assetStorage.pricingParameters = assetCache.pricingParameters = newPricingParameter;

    if (newPricingType == PRICINGTYPE__CHAINLINK) {
        require(chainlinkPriceFeedLookup[underlying] != address(0), "e/gov/chainlink-price-feed-not-initialized");
    }

    emit GovSetPricingConfig(underlying, newPricingType, newPricingParameter);
}
  1. Memory writes to assetCache at L56-57 are unnecessary as assetCache wont be read again;
  2. loadAssetCache() is unnecessary as assetCache will not be read, and changing pricing config won't impact interests. Therefore, the settlement of interests in loadAssetCache() -> initAssetCache() can be done later or we could say it's not relevant.

Recommendation

Consdier removing loadAssetCache() and the memory writes to assetCache at L56-57.

[G-6] Governance.sol#convertReserves() Validation of reserveBalance can be done earlier to save gas

https://github.com/euler-xyz/euler-contracts/blob/0fade57d9ede7b010f943fa8ad3ad74b9c30e283/contracts/modules/Governance.sol#L80-L105

function convertReserves(address underlying, address recipient, uint amount) external nonReentrant governorOnly {
    address eTokenAddress = underlyingLookup[underlying].eTokenAddress;
    require(eTokenAddress != address(0), "e/gov/underlying-not-activated");

    updateAverageLiquidity(recipient);

    AssetStorage storage assetStorage = eTokenLookup[eTokenAddress];
    AssetCache memory assetCache = loadAssetCache(underlying, assetStorage);

    if (amount == type(uint).max) amount = assetStorage.reserveBalance - INITIAL_RESERVES;
    require(amount <= assetStorage.reserveBalance, "e/gov/insufficient-reserves");

    assetStorage.reserveBalance = assetCache.reserveBalance = assetCache.reserveBalance - uint96(amount);
    // Decrease totalBalances because increaseBalance will increase it by amount
    assetStorage.totalBalances = assetCache.totalBalances = encodeAmount(assetCache.totalBalances - amount);

    require(assetStorage.reserveBalance >= INITIAL_RESERVES, "e/gov/reserves-depleted");

    increaseBalance(assetStorage, assetCache, eTokenAddress, recipient, amount);

    if (assetStorage.users[recipient].owed != 0) checkLiquidity(recipient);

    logAssetStatus(assetCache);

    emit GovConvertReserves(underlying, recipient, balanceToUnderlyingAmount(assetCache, amount));
}

Recommendation

Change to:

https://github.com/euler-xyz/euler-contracts/blob/0fade57d9ede7b010f943fa8ad3ad74b9c30e283/contracts/modules/Governance.sol#L80-L106

function convertReserves(address underlying, address recipient, uint amount) external nonReentrant governorOnly {
    address eTokenAddress = underlyingLookup[underlying].eTokenAddress;
    require(eTokenAddress != address(0), "e/gov/underlying-not-activated");

    updateAverageLiquidity(recipient);

    AssetStorage storage assetStorage = eTokenLookup[eTokenAddress];
    require(assetStorage.reserveBalance >= INITIAL_RESERVES, "e/gov/reserves-depleted");

    AssetCache memory assetCache = loadAssetCache(underlying, assetStorage);

    uint maxAmount = assetCache.reserveBalance - INITIAL_RESERVES;
    if (amount == type(uint).max) amount = maxAmount;
    require(amount <= maxAmount, "e/gov/insufficient-reserves");

    assetStorage.reserveBalance = assetCache.reserveBalance = assetCache.reserveBalance - uint96(amount);
    // Decrease totalBalances because increaseBalance will increase it by amount
    assetStorage.totalBalances = assetCache.totalBalances = encodeAmount(assetCache.totalBalances - amount);

    increaseBalance(assetStorage, assetCache, eTokenAddress, recipient, amount);

    if (assetStorage.users[recipient].owed != 0) checkLiquidity(recipient);

    logAssetStatus(assetCache);

    emit GovConvertReserves(underlying, recipient, balanceToUnderlyingAmount(assetCache, amount));
}

[I-7] Using chainlink feed as price oracle rather than Uniswap v3 TWAP may result in less timely liquidation

The eIP introduced a new setChainlinkPriceFeed function allows the configuration of a chainlink feed for an asset.

https://github.com/euler-xyz/euler-contracts/blob/cd3036e0087280365819f99ad531141894d0b7ee/contracts/modules/Governance.sol#L107-L115

Per to Chainlink's docs:

Chainlink Price Feeds do not provide streaming data. Rather, the aggregator updates its latestAnswer when the value deviates beyond a specified threshold or when the heartbeat idle time has passed. You can find the heartbeat and deviation values for each data feed at data.chain.link or in the Contract Addresses lists.

And the Deviation and Heartbeat on Ethereum Mainnet can be quite large and long, usually 2% for Deviation and 24h as Heartbeat.

Source: https://docs.chain.link/docs/ethereum-addresses/

This means that the price of Chainlink feed can be significantly delayed and deviated in comparison to the real time Uniswap v3 TWAP price.

As a result, the liquidation of assets priced with a chainlink feed, may not be as timely as the assets priced based on Uniswap v3's TWAP.

Recommendation

There are no immediate risk from using chainlink feed, but we encourage you to check and confirm the Deviation and Heartbeat for the feed to be added, and also consider setting a lower collateral factor ratio to mitigate the risk of delayed liquidation due to delayed oracle price.