GithubHelp home page GithubHelp logo

2022-09-notional-judging's People

Contributors

devanshbatham avatar evert0x avatar hrishibhat avatar rcstanciu avatar sherlock-admin avatar

Stargazers

 avatar  avatar  avatar  avatar

Watchers

 avatar  avatar

2022-09-notional-judging's Issues

Arbitrary-Execution - The check against `debtOutstandingAboveMinBorrow` in `_depositLiquidatorAmount` should be `<=` and not `<`

Arbitrary-Execution

medium

The check against debtOutstandingAboveMinBorrow in _depositLiquidatorAmount should be <= and not <

Summary

The check against debtOutstandingAboveMinBorrow in _depositLiquidatorAmount should be <= and not <

Vulnerability Detail

The private function _depositLiquidatorAmount is used by deleverageAccount in VaultAccountAction.sol to calculate the amount a liquidator must deposit in order to liquidate a specific vault account. As part of the calculation, _depositLiquidatorAmount checks to ensure that a partial liquidation does not put a vault account into a state where it would be under the minimum borrow capacity for the vault and subsequently unprofitable to liquidate again. To avoid unprofitable liquidations, a liquidator is required to leave at least the minimum borrow amount in a vault. Otherwise if this is not possible, then the liquidator must liquidate the entire borrow amount for a vault account:

// NOTE: deposit amount external is always positive in this method
if (depositAmountExternal < maxLiquidatorDepositExternal) {
    // If liquidating past the debt outstanding above the min borrow, then the entire debt outstanding
    // must be liquidated (that is set to maxLiquidatorDepositExternal)
    require(depositAmountExternal < assetToken.convertToExternal(debtOutstandingAboveMinBorrow), "Must Liquidate All Debt");
} else {
    // In the other case, limit the deposited amount to the maximum
    depositAmountExternal = maxLiquidatorDepositExternal;
}

However, the require statement should use the comparison <= and not < as a vault account can borrow an amount equal to the minimum borrow for a vault (inclusive).

Impact

While this does not greatly impact a liquidator's ability to liquidate vault accounts, liquidators usually look to extract the maximum value possible from a liquidation. This means liquidators who want to liquidate an account such that it is equivalent to the minimum borrow for a vault would have their transaction revert unexpectedly.

Code Snippet

https://github.com/notional-finance/contracts-v2/blob/cf05d8e3e4e4feb0b0cef2c3f188c91cdaac38e0/contracts/external/actions/VaultAccountAction.sol#L381-L391

Failing test (add to tests/stateful/vaults/test_vault_deleverage.py):

def test_deleverage_account_min_balance(environment, accounts, vault):
    environment.notional.updateVault(
        vault.address,
        get_vault_config(currencyId=2, flags=set_flags(0, ENABLED=True)),
        100_000_000e8,
    )
    maturity = environment.notional.getActiveMarkets(1)[0][1]

    environment.notional.enterVault(
        accounts[1], vault.address, 50_000e18, maturity, 200_000e8, 0, "", {"from": accounts[1]}
    )

    vault.setExchangeRate(0.95e18)

    vaultConfig = environment.notional.getVaultConfig(vault.address)
    print(vaultConfig.dict())

    # Liquidator should be allowed to deleverage up to (inclusive) the minimum borrow amount for the vault
    environment.notional.deleverageAccount(
        accounts[1], vault.address, accounts[2], 5_000_000e8, False, "", {"from": accounts[2]}
    )

Tool used

Manual Review

Recommendation

Consider changing the comparison in the require statement from < to <=:

require(depositAmountExternal <= assetToken.convertToExternal(debtOutstandingAboveMinBorrow), "Must Liquidate All Debt");

0x52 - StrategyUtils#_executeDynamicTradeExactIn returns incorrect amountBought if buyToken is wstETH and tradeUnwrapped is true

0x52

medium

StrategyUtils#_executeDynamicTradeExactIn returns incorrect amountBought if buyToken is wstETH and tradeUnwrapped is true

Summary

Wrapping logic is flawed when buyToken = wstETH and tradeUnwrapped = true. The result is that the stETH received from the trade isn't wrapped and it returns the amount of stETH that was bought rather than the amount of wstETH that was bought. Any function that uses this will receive an incorrect value for amountBought which will propagate errors forward.

Vulnerability Detail

StrategyUtils.sol#L61-L90

    if (params.tradeUnwrapped && buyToken == address(Deployments.WRAPPED_STETH)) {
        buyToken = Deployments.WRAPPED_STETH.stETH();
    }

    // Sell residual secondary balance
    Trade memory trade = Trade(
        params.tradeType,
        sellToken,
        buyToken,
        amount,
        0,
        block.timestamp, // deadline
        params.exchangeData
    );

    (amountSold, amountBought) = trade._executeTradeWithDynamicSlippage(
        params.dexId, tradingModule, params.oracleSlippagePercent
    );

    if (
        params.tradeUnwrapped && 
        buyToken == address(Deployments.WRAPPED_STETH) &&
        amountBought > 0
    ) {
        IERC20(buyToken).checkApprove(address(Deployments.WRAPPED_STETH), amountBought);
        uint256 wrappedAmount = Deployments.WRAPPED_STETH.balanceOf(address(this));
        /// @notice the amount returned by wrap is not always accurate for some reason
        Deployments.WRAPPED_STETH.wrap(amountBought);
        amountBought = Deployments.WRAPPED_STETH.balanceOf(address(this)) - wrappedAmount;
    }

When buyToken = wstETH and tradeUnwrapped = true L62 sets buyToken to the contract address of stETH. Then when the trade is executed, _executeTradeWithDynamicSlippage will return the amount of stETH that was purchased during the swap in L76. The issues arrises when it tries to check the buyToken in L82. The buyToken address has been changed to stETH so it will return false, skipping L85-89. This results in 2 issues:

  1. Contracts calling this one expect to receive wstETH back from the trade but instead are receiving stETH, because it never wraps the stETH to wstETH.

  2. The function returns the amount of stETH that was purchased because the lines that wrap the stETH and adjust amountBought never execute. The problem is that this return value is assumed to be the amount of wstETH, which is not the case.

Impact

StrategyUtils#_executeDynamicTradeExactIn doesn't properly wrap the stETH and returns an incorrect value. If stETH is not a reward token then the token will become lost with no way to retrieve it.

Code Snippet

StrategyUtils.sol#L41-L91

Tool used

Manual Review

Recommendation

Change L82 to check for stETH as buyToken rather than wstETH:

    if (
        params.tradeUnwrapped && 
-       buyToken == address(Deployments.WRAPPED_STETH) && 
+       buyToken == Deployments.WRAPPED_STETH.stETH() &&
        amountBought > 0
    ) {

Duplicate of #99

Arbitrary-Execution - Users are required to redeem a non-zero `vaultSharesToRedeem` when calling `exitVault` prior to maturity

Arbitrary-Execution

medium

Users are required to redeem a non-zero vaultSharesToRedeem when calling exitVault prior to maturity

Summary

Users are required to redeem a non-zero vaultSharesToRedeemwhen calling exitVault prior to maturity

Vulnerability Detail

exitVault in VaultAccountAction.sol can be called prior to an account's maturity timestamp to either completely exit a vault or improve the collateral ratio of a position. In the scenario that a user wants to improve the collateral ratio of their position in a vault, they can choose to decrease their fCash borrow by 'lending' a specified amount of fCash and subsequently paying off the lend immediately. Additionally, users must supply a vaultSharesToRedeem value, which will be converted to strategyTokens and subsequently used to help pay off the fCash lend:

uint256 strategyTokens = vaultState.exitMaturity(vaultAccount, vaultSharesToRedeem);
...
if (strategyTokens > 0) {
    underlyingToReceiver = vaultConfig.redeemWithDebtRepayment(
        vaultAccount, receiver, strategyTokens, vaultState.maturity, exitVaultData
    );
}

However, in order to call the function redeemWithDebtRepayment which contains the logic to repay the new fCash lend, a user must supply a value for vaultSharesToRedeem such that when converted to strategyTokens the value is non-zero. This logic is incorrect, as redeemWithDebtRepayment is able to recover a deficit by transferring tokens directly out of an account address. Since redeemWithDebtRepayment is not called, vaultAccount.tempCashBalance is not cleared as it would be set to 0 in that function. Therefore, when setVaultAccount is called to save the state of the account after all the operations in exitVault have occurred, it will fail the following require statement as tempCashBalance is still non-zero:

// The temporary cash balance must be cleared to zero by the end of the transaction
require(vaultAccount.tempCashBalance == 0); // dev: cash balance not cleared

Impact

A user is required to sell a non-zero amount of strategyTokens when improving the collateral ratio of their position by calling exitVault prior to maturity. While the workaround is simple (have vaultSharesToRedeem be a small number), exitVault failing when a user is able to improve their collateral ratio accordingly is still an unexpected revert and should be fixed.

Code Snippet

https://github.com/notional-finance/contracts-v2/blob/cf05d8e3e4e4feb0b0cef2c3f188c91cdaac38e0/contracts/external/actions/VaultAccountAction.sol#L209-L230

Failing test (add to tests/stateful/vaults/test_vault_exit.py):

def test_exit_vault_transfer_from_account_no_strategy_token_sell(environment, vault, accounts):
    environment.notional.updateVault(
        vault.address,
        get_vault_config(flags=set_flags(0, ENABLED=True), currencyId=2, minAccountBorrowSize=1_000),
        100_000_000e8,
    )
    maturity = environment.notional.getActiveMarkets(1)[0][1]

    environment.notional.enterVault(
        accounts[1], vault.address, 100_000e18, maturity, 100_000e8, 0, "", {"from": accounts[1]}
    )

    (collateralRatioBefore, _, _) = environment.notional.getVaultAccountCollateralRatio(
        accounts[1], vault
    )
    vaultAccountBefore = environment.notional.getVaultAccount(accounts[1], vault).dict()

    # This is the failing call that should work
    environment.notional.exitVault(
        accounts[1], vault.address, accounts[1], 0, 50_000e8, 0, "", {"from": accounts[1]}
    )

    vaultAccount = environment.notional.getVaultAccount(accounts[1], vault).dict()
    (collateralRatioAfter, _, _) = environment.notional.getVaultAccountCollateralRatio(
        accounts[1], vault
    )
    vaultState = environment.notional.getVaultState(vault, maturity)

    assert collateralRatioBefore < collateralRatioAfter

    assert vaultAccount["fCash"] == -50_000e8 # fCash is a negative number when accessed with getVaultAccount
    assert vaultAccount["maturity"] == maturity
    assert vaultAccount["vaultShares"] == vaultAccountBefore["vaultShares"] # not selling vaultShares

    assert vaultState["totalfCash"] == -50_000e8 # fCash is a negative number when accessed with getVaultState
    assert vaultState["totalAssetCash"] == 0
    assert vaultState["totalStrategyTokens"] == vaultAccount["vaultShares"]
    assert vaultState["totalStrategyTokens"] == vaultState["totalVaultShares"]

    check_system_invariants(environment, accounts, [vault])

Tool used

Manual Review

Recommendation

Consider removing the if (strategyTokens > 0) statement and always call redeemWithDebtRepayment in exitVault.

jacksanford - Test

jacksanford

unlabeled

Test

Summary

Hello Evert, Devansh and Rishi!

Vulnerability Detail

Impact

Code Snippet

Tool used

Manual Review

Recommendation

0xNazgul - [NAZ-M1] Use `safeTransfer()/safeTransferFrom()` Instead of `transfer()/transferFrom()`

0xNazgul

medium

[NAZ-M1] Use safeTransfer()/safeTransferFrom() Instead of transfer()/transferFrom()

Summary

There are a few uses of transfer()/transferFrom() when there should be the use of safeTransfer()/safeTransferFrom().

Vulnerability Detail

It is a good idea to add a require() statement that checks the return value of ERC20 token transfers or to use something like OpenZeppelin’s safeTransfer()/safeTransferFrom() unless one is sure the given token reverts in case of a failure. Failure to do so will cause silent failures of transfers and affect token accounting in contract.

However, using require() to check transfer return values could lead to issues with non-compliant ERC20 tokens which do not return a boolean value. Therefore, it's highly advised to use OpenZeppelin’s safeTransfer()/safeTransferFrom().

Impact

Use of transfer()/transferFrom() can can cause silent failures and affect token accounting in the contract.

Code Snippet

VaultAccount.sol#L272, VaultConfiguration.sol#L509, VaultAccountAction.sol#L394

Tool used

Manual Review

Recommendation

Consider using safeTransfer()/safeTransferFrom() instead of transfer()/transferFrom().

Bnke0x0 - User's may accidentally overpay in depositVaultCashToStrategyTokens()/repaySecondaryCurrencyFromVault()/_redeem() and the excess will be paid to the vault creator

Bnke0x0

medium

User's may accidentally overpay in depositVaultCashToStrategyTokens()/repaySecondaryCurrencyFromVault()/_redeem() and the excess will be paid to the vault creator

Summary

User's may accidentally overpay in depositVaultCashToStrategyTokens()/repaySecondaryCurrencyFromVault()/_redeem() and the excess will be paid to the vault creator

Vulnerability Detail

Impact

It is possible for a user purchasing an option to accidentally overpay the premium during depositVaultCashToStrategyTokens()/repaySecondaryCurrencyFromVault()/_redeem() .

Any excess funds paid for in excess of the premium will be transferred to the vault creator.

Code Snippet

  1. https://github.com/sherlock-audit/2022-09-notional/blob/main/contracts-v2/contracts/external/actions/VaultAction.sol#L191

         'require(vaultConfig.minCollateralRatio <= collateralRatio, "Insufficient Collateral");'
    
  2. https://github.com/sherlock-audit/2022-09-notional/blob/main/contracts-v2/contracts/external/actions/VaultAction.sol#L336

         ' require(balanceTransferred >= underlyingExternalToRepay, "Insufficient Repay");'
    
  3. https://github.com/sherlock-audit/2022-09-notional/blob/main/contracts-v2/contracts/internal/vaults/VaultConfiguration.sol#L635

         'require(residualRequired <= msg.value, "Insufficient repayment");'
    
  4. https://github.com/sherlock-audit/2022-09-notional/blob/main/contracts-v2/contracts/internal/vaults/VaultConfiguration.sol#L651

         ' require(amountTransferred >= underlyingExternalToRepay, "Insufficient repayment");'
    

Tool used

Manual Review

Recommendation

Consider changing '>=' to '=='

  1. https://github.com/sherlock-audit/2022-09-notional/blob/main/contracts-v2/contracts/external/actions/VaultAction.sol#L191

         'require(vaultConfig.minCollateralRatio == collateralRatio, "Insufficient Collateral");'
    
  2. https://github.com/sherlock-audit/2022-09-notional/blob/main/contracts-v2/contracts/external/actions/VaultAction.sol#L336

         ' require(balanceTransferred == underlyingExternalToRepay, "Insufficient Repay");'
    
  3. https://github.com/sherlock-audit/2022-09-notional/blob/main/contracts-v2/contracts/internal/vaults/VaultConfiguration.sol#L635

         'require(residualRequired == msg.value, "Insufficient repayment");'
    
  4. https://github.com/sherlock-audit/2022-09-notional/blob/main/contracts-v2/contracts/internal/vaults/VaultConfiguration.sol#L651

         ' require(amountTransferred == underlyingExternalToRepay, "Insufficient repayment");'
    

Lambda - ExchangeRate age not checked

Lambda

medium

ExchangeRate age not checked

Summary

The age of exchange rates is not checked, which can result in situations where old exchange rates are used.

Vulnerability Detail

_calculateSecondaryDebt calls buildExchangeRate, which queries the oracle. However, the updatedAt timestamp is ignored, meaning that the returned rate can be arbitrarily old.

Impact

When the exchange rate was not updated in a long time, this enables arbitrage opportunities, as Notional is using a wrong exchange rate.

Code Snippet

https://github.com/sherlock-audit/2022-09-notional/blob/main/contracts-v2/contracts/internal/vaults/VaultConfiguration.sol#L412

Tool used

Manual Review

Recommendation

Ensure that the exchange rate is not too old.

Duplicate of #133

Lambda - transfer should not be used for transferring ETH

Lambda

medium

transfer should not be used for transferring ETH

Summary

transfer is used in multiple places, leading to problems with smart contract wallets.

Vulnerability Detail

The system uses transfer in multiple places. Because of the 2300 gas limit, this can lead to problems with smart contract wallets or integrations with other smart contracts, because they may have some logic in their receive function that consumes more gas.

Impact

When a receiver uses more than 2300 gas in its receive function, retrieving the ETH will not be possible.

Code Snippet

https://github.com/sherlock-audit/2022-09-notional/blob/main/contracts-v2/contracts/internal/vaults/VaultConfiguration.sol#L638
https://github.com/sherlock-audit/2022-09-notional/blob/main/leveraged-vaults/contracts/vaults/BaseStrategyVault.sol#L181

Tool used

Manual Review

Recommendation

Use call instead of transfer.

Duplicate of #63

0x52 - Settlement slippage is not implemented correctly which may lead to some vaults being impossible to settle

0x52

high

Settlement slippage is not implemented correctly which may lead to some vaults being impossible to settle

Summary

The contract is supposed to implement a different max slippage value depending on the settlement type, but these values have no impact because they are never actually applied. Instead, regardless of settlement type or function inputs, max slippage will always be limited to the value of balancerPoolSlippageLimitPercent. This can be problematic because the default value allows only 1% slippage. If settlement slippage goes outside of 1% then settlement of any kind will become impossible.

Vulnerability Detail

Boosted3TokenAuraHelper.sol#L95-L99

    params.minPrimary = poolContext._getTimeWeightedPrimaryBalance(
        oracleContext, strategyContext, bptToSettle
    );

    params.minPrimary = params.minPrimary * strategyContext.vaultSettings.balancerPoolSlippageLimitPercent / 
        uint256(BalancerConstants.VAULT_PERCENT_BASIS);

Boosted3TokenAuraHelper#_executeSettlement first sets params.minPrimary overwriting any value from function input. Next it adjusts minPrimary by balancerPoolSlippageLimitPercent, which is a constant set at pool creation; however it doesn't ever adjust it by Params.DynamicTradeParams.oracleSlippagePercent. This means that the max possible slippage regardless of settlement type is limited to the slippage allowed by balancerPoolSlippageLimitPercent. If the max slippage ever goes outside of this range, then settlement of any kind will become impossible.

Impact

Settlement may become impossible

Code Snippet

Boosted3TokenAuraHelper.sol#L85-L113

Tool used

Manual Review

Recommendation

Params.DynamicTradeParams.oracleSlippagePercent is validated in every scenario before Boosted3TokenAuraHelper#_executeSettlement is called, so we can apply these values directly when calculating minPrimary:

    params.minPrimary = poolContext._getTimeWeightedPrimaryBalance(
        oracleContext, strategyContext, bptToSettle
    );

+   DynamicTradeParams memory callbackData = abi.decode(
+       params.secondaryTradeParams, (DynamicTradeParams)
+   );

-   params.minPrimary = params.minPrimary * strategyContext.vaultSettings.balancerPoolSlippageLimitPercent / 
+   params.minPrimary = params.minPrimary * 
+      (strategyContext.vaultSettings.balancerPoolSlippageLimitPercent - callbackData.oracleSlippagePercent) / 
       uint256(BalancerConstants.VAULT_PERCENT_BASIS);

Waze - safetransferfrom doesn't check the codesize of the token address, which may lead to fund loss.

Waze

high

safetransferfrom doesn't check the codesize of the token address, which may lead to fund loss.

Summary

safetransferfrom doesn't check the existence of code at the token address. This is a known issue while using GenericToken libraries.

Vulnerability Detail

This vulnerability may lead to miscalculation of funds and may lead to loss of funds , because if safetransferfrom() are called on a token address that doesn't have contract in it, it will always return success, bypassing the return value check. Due to this protocol will think that funds has been transferred and successful , and records will be accordingly calculated, but in reality funds were never transferred. So this will lead to miscalculation and possibly loss of funds.

Impact

lead to miscalculation and possibly loss of funds due to safetransferfrom doesn't check the existence of code at the token address.

Code Snippet

https://github.com/sherlock-audit/2022-09-notional/blob/main/contracts-v2/contracts/internal/vaults/VaultConfiguration.sol#L458

Tool used

Manual Review

Recommendation

implement a code existence check at the token address.

Arbitrary-Execution - `checkReturnCode` function in `GenericToken` library lacks type safety

Arbitrary-Execution

medium

checkReturnCode function in GenericToken library lacks type safety

Summary

checkReturnCode function in GenericToken library lacks type safety

Vulnerability Detail

The GenericToken library has functions that are used to transfer tokens and ether to and from addresses. More specifically, the safeTransferFrom function is used by the transferUnderlyingToVaultDirect function in VaultConfiguration.sol, which is used to transfer tokens from an account into a vault. safeTransferFrom uses the function checkReturnCode to cover cases where a token may not strictly conform to the ERC-20 token standard. It does this by checking the size of the return data and acting accordingly:

bool success;
uint256[1] memory result;

assembly {
    switch returndatasize()
        case 0 {
            // This is a non-standard ERC-20
            success := 1 // set success to true
        }
        case 32 {
            // This is a compliant ERC-20
            returndatacopy(result, 0, 32)
            success := mload(result) // Set `success = returndata` of external call
        }
        default {
            // This is an excessively non-compliant ERC-20, revert.
            revert(0, 0)
        }
}

However, by performing these actions in assembly, there is no type safety. This means that if the return data is 32 bytes but is not a boolean and should not be converted to one, the conversion will not fail.

Note: While GenericToken.sol is not explicitly in-scope, the library is compiled into several files that are in scope.

Impact

If an excessively non-compliant ERC-20 token returns with data that is 32 bytes, the return data will simply be loaded into bool success and be interpreted as a boolean even though the token is non-compliant and the call should revert. However, this scenario is relatively unlikely given that tokens have to be explicitly allowed by Notional.

Code Snippet

https://github.com/notional-finance/contracts-v2/blob/cf05d8e3e4e4feb0b0cef2c3f188c91cdaac38e0/contracts/internal/balances/protocols/GenericToken.sol#L60-L81

Tool used

Manual Review

Recommendation

Consider using OpenZeppelin's SafeERC20.sol library, which also checks the return data but uses an abi.decode to ensure type safety.

Arbitrary-Execution - When `ONLY_VAULT_DELEVERAGE` is enabled a vault can force an arbitrary address to liquidate an unhealthy vault account

Arbitrary-Execution

high

When ONLY_VAULT_DELEVERAGE is enabled a vault can force an arbitrary address to liquidate an unhealthy vault account

Summary

When ONLY_VAULT_DELEVERAGE is enabled a vault can force an arbitrary address to liquidate an unhealthy vault account

Vulnerability Detail

When the ONLY_VAULT_DELEVERAGE flag is set, only the vault address itself can call deleverageAccount in VaultAccountAction.sol. This is enforced via the _authenticateDeleverage function:

// Authorization rules for deleveraging
if (vaultConfig.getFlag(VaultConfiguration.ONLY_VAULT_DELEVERAGE)) {
    require(msg.sender == vault, "Unauthorized");
} else {
    require(msg.sender == liquidator, "Unauthorized");
}

However, inside the if block when the ONLY_VAULT_DELEVERAGE flag is set, the require statement only checks that msg.sender == vault. This check is different from the other check in the else block as it does not check that the passed-in liquidator address is also the vault address. This means the vault address can supply any address as the liquidator, which is ultimately the address that has to pay the token amount to liquidate an unhealthy vault account. As long as the unwilling liquidator meets the following conditions it can be forced to liquidate accounts by the vault:

  1. The liquidator has enough of the underlying tokens necessary for liquidation.
  2. The liquidator has approved the Notional proxy address with enough tokens necessary for liquidation.
  3. The liquidator does not already have a position in the vault whose maturity is different from the one of the unhealthy account.

Impact

A vault can force any address to liquidate an unhealthy vault account so long as the above conditions are met, even if the liquidation would be unprofitable for the liquidator.

Code Snippet

https://github.com/notional-finance/contracts-v2/blob/cf05d8e3e4e4feb0b0cef2c3f188c91cdaac38e0/contracts/external/actions/VaultAccountAction.sol#L343-L348

PoC (add to tests/stateful/vaults/test_vault_deleverage.py):

def test_deleverage_account_from_vault_with_different_account(environment, accounts, vault):
    environment.notional.updateVault(
        vault.address,
        get_vault_config(currencyId=2, flags=set_flags(0, ENABLED=True, ONLY_VAULT_DELEVERAGE=1)),
        100_000_000e8,
    )
    maturity = environment.notional.getActiveMarkets(1)[0][1]
    vaultAccount = accounts.at(vault.address, force=True)

    # The vault account does not have any DAI/cDAI that would be necessary to liquidate an account
    assert environment.token["DAI"].balanceOf(vaultAccount) == 0
    assert environment.cToken["DAI"].balanceOf(vaultAccount) == 0

    environment.notional.enterVault(
        accounts[1], vault.address, 50_000e18, maturity, 200_000e8, 0, "", {"from": accounts[1]}
    )

    vault.setExchangeRate(0.95e18)

    accountInfo = environment.notional.getVaultAccount(accounts[2], vault)
    assert accountInfo["maturity"] == 0

    # Notice how the liquidator here is accounts[2], but the caller is the vault account.
    # accounts[2] meets the 3 requirements:
    # 1) holds enough DAI/cDAI to liquidate an account
    # 2) has approved the Notional proxy to transfer tokens (maybe they are interacting with other
    #    functionality within Notional)
    # 3) does not currently hold a maturity in the vault
    environment.notional.deleverageAccount(
        accounts[1], vault.address, accounts[2], 1_000e8, True, "", {"from": vaultAccount}
    )

    accountInfo = environment.notional.getVaultAccount(accounts[2], vault)
    # accounts[2] now has a position in the vault due to the forced liquidation
    assert accountInfo["maturity"] != 0

Tool used

Manual Review

Recommendation

Consider adding a check to _authenticateDeleverage to ensure that when the ONLY_VAULT_DELEVERAGE flag is set the vault account can only set the liquidator to be itself or its owner address.

8olidity - TransferUnderlyingToVaultDirect () error may lead to vault not receive money

8olidity

medium

TransferUnderlyingToVaultDirect () error may lead to vault not receive money

Summary

TransferUnderlyingToVaultDirect () error may lead to vault not receive money

Vulnerability Detail

TransferUnderlyingToVaultDirect () will the ETH sent to vault address, but he is calling transferNativeTokenOut (), this function has no check whether the transfer is successful, It is also recommended to use call() instead of ### transfer() to transfer ETH

// contracts-v2/contracts/internal/vaults/VaultConfiguration.sol
function transferUnderlyingToVaultDirect(
    VaultConfig memory vaultConfig,
    address transferFrom,
    uint256 depositAmountExternal
) internal returns (uint256) {
    if (depositAmountExternal == 0) return 0;

    Token memory assetToken = TokenHandler.getAssetToken(vaultConfig.borrowCurrencyId);
    Token memory underlyingToken = assetToken.tokenType == TokenType.NonMintable ? 
        assetToken :
        TokenHandler.getUnderlyingToken(vaultConfig.borrowCurrencyId);

    address vault = vaultConfig.vault;
    // Tokens with transfer fees are not allowed in vaults
    require(!underlyingToken.hasTransferFee);
    if (underlyingToken.tokenType == TokenType.Ether) {
        require(msg.value == depositAmountExternal, "Invalid ETH");
        // Forward all the ETH to the vault
        GenericToken.transferNativeTokenOut(vault, msg.value);
        
        
// contracts-v2/contracts/internal/balances/protocols/GenericToken.sol
function transferNativeTokenOut(
  address account,
  uint256 amount
) internal {
  // This does not work with contracts, but is reentrancy safe. If contracts want to withdraw underlying
  // ETH they will have to withdraw the cETH token and then redeem it manually.
  payable(account).transfer(amount);
}

Impact

TransferUnderlyingToVaultDirect () error may lead to vault not receive money

Code Snippet

https://github.com/sherlock-audit/2022-09-notional/blob/main/contracts-v2/contracts/internal/vaults/VaultConfiguration.sol#L454

Tool used

vscode
Manual Review

Recommendation

1.check return
2.use call()

Duplicate of #63

Arbitrary-Execution - `deleverageAccount` can be used by an address to enter a vault that would otherwise be restricted by the `requireValidAccount` check in `enterVault`

Arbitrary-Execution

medium

deleverageAccount can be used by an address to enter a vault that would otherwise be restricted by the requireValidAccount check in enterVault

Summary

deleverageAccount can be used by an address to enter a vault that would otherwise be restricted by the requireValidAccount check in enterVault

Vulnerability Detail

When enterVault in VaultAccountAction.sol is called, the first function that is called is requireValidAccount. This function checks to ensure that the passed-in account parameter is not a system-level account address:

require(account != Constants.RESERVE); // Reserve address is address(0)
require(account != address(this));
(
    uint256 isNToken,
    /* incentiveAnnualEmissionRate */,
    /* lastInitializedTime */,
    /* assetArrayLength */,
    /* parameters */
) = nTokenHandler.getNTokenContext(account);
require(isNToken == 0);

With the above checks, requireValidAccount ensures that any Notional system-level account cannot enter a vault. However, deleverageAccount in VaultAccountAction.sol allows liquidators to transfer vault shares from a liquidated account into their own account. In the case that a liquidator is not already entered into a vault, then deleverageAccount will instantiate a vault account for them (using _transferLiquidatorProfits) before depositing the liquidated account's vault shares into the newly-instantiated account. This effectively circumvents the requireValidAccount check in enterVault.

Impact

Any address that would otherwise be restricted from entering vaults via the requireValidAccount check would be able to circumvent that function using deleverageAccount. I assume these system-level accounts are restricted from entering vaults as they have access to internal Notional state and are used across the protocol, so having them be able to enter vaults could negatively impact Notional.

Assuming that all the relevant Notional system accounts are smart contracts that do not allow arbitrary calls, then having any of the system accounts themselves trigger this issue is infeasible. However, as a result of another issue it is possible for a vault to force an arbitrary address to deleverage accounts, which could be used to force a Notional system account to enter into a vault.

Code Snippet

https://github.com/notional-finance/contracts-v2/blob/cf05d8e3e4e4feb0b0cef2c3f188c91cdaac38e0/contracts/external/actions/VaultAccountAction.sol#L412-L419

PoC (add to tests/stateful/vaults/test_vault_deleverage.py);

def test_deleverage_account_instantiate_liquidator_maturity(environment, accounts, vault):
    environment.notional.updateVault(
        vault.address,
        get_vault_config(currencyId=2, flags=set_flags(0, ENABLED=True)),
        100_000_000e8,
    )
    maturity = environment.notional.getActiveMarkets(1)[0][1]
    systemAccount = accounts.at(environment.nToken[1], force=True)
    environment.token["DAI"].transfer(systemAccount, 10_000_000e18, {"from": accounts[0]})
    environment.cToken["DAI"].transfer(systemAccount, 10_000_000e8, {"from": accounts[0]})
    environment.token["DAI"].approve(environment.notional.address, 2 ** 256 - 1, {"from": systemAccount})
    environment.cToken["DAI"].approve(environment.notional.address, 2 ** 256 - 1, {"from": systemAccount})

    with brownie.reverts():
        # nToken address is not allowed to enter a vault
        environment.notional.enterVault(
            systemAccount,
            vault.address,
            100_000e18,
            maturity,
            100_000e8,
            0,
            "",
            {"from": systemAccount},
        )

    environment.notional.enterVault(
        accounts[1], vault.address, 50_000e18, maturity, 200_000e8, 0, "", {"from": accounts[1]}
    )

    vault.setExchangeRate(0.95e18)

    environment.notional.deleverageAccount(
        accounts[1], vault.address, systemAccount, 1_000e8, True, "", {"from": systemAccount}
    )

    # System account now has a position in the vault
    systemAccountInfo = environment.notional.getVaultAccount(systemAccount, vault)
    assert systemAccountInfo["maturity"] != 0

Tool used

Manual Review

Recommendation

Consider updating the require statement in _transferLiquidatorProfits to the following:

require(liquidator.maturity == maturity, "Vault Shares Mismatch"); // dev: has vault shares

Removing the option of allowing addresses that do not have a maturity in the respective vault to receive shares and therefore implicitly enter a vault prevents Notional system accounts from being able to enter into vaults.

0x52 - TradingUtils#_approve is problematic for tokens like USDT that requires allowance to be zero before calling approve

0x52

medium

TradingUtils#_approve is problematic for tokens like USDT that requires allowance to be zero before calling approve

Summary

Exact out swaps will break the ability to swap any ERC20 tokens that require allowance to be zero before calling approve, like USDT. After an exact out swap, there will be residual allowance left for spender which will cause all subsequent trades to the spender to revert.

Vulnerability Detail

TradingUtils.sol#L113-L116

function _approve(Trade memory trade, address spender) private {
    uint256 allowance = _isExactIn(trade) ? trade.amount : trade.limit;
    IERC20(trade.sellToken).checkApprove(spender, allowance);
}

_approve is called each time _executeInternal is called which is subsequently called during each swap. For exact in swaps, the lines above will never be an issue since the entire allowance will always be used up. For exact out swaps the above lines approve the spender up to the trade limit. In a majority of cases it won't use the full allowance leaving a residual allowance after the trade is completed. Some ERC20 tokens, notably including Tether, require that the current allowance for the spender to be zero or else the approval call will revert. The result is that subsequent swaps will revert for that token after an exact out swap.

Impact

All subsequent swaps for the affected token to the same spender will revert

Code Snippet

TokenUtils.sol#L18-L23

Tool used

Manual Review

Recommendation

Change TokenUtils#checkApprove to first reset allowance to 0 then set to desired allowance, which will resolve incompatibilities with the tokens described above:

function checkApprove(IERC20 token, address spender, uint256 amount) internal {
    if (address(token) == address(0)) return;

++  IEIP20NonStandard(address(token)).approve(spender, 0);
    IEIP20NonStandard(address(token)).approve(spender, amount);

    _checkReturnCode();
}

duplicate of #59

joestakey - Notional Governors can use `reduceMaxBorrowCapacity` on a vault to increase `maxBorrowCapacity`, which can grief users of the vault.

joestakey

medium

Notional Governors can use reduceMaxBorrowCapacity on a vault to increase maxBorrowCapacity, which can grief users of the vault.

Summary

Notional Governors can increase the risk of a vault at any time by using reduceMaxBorrowCapacity to increase maxBorrowCapacity, which can lead to insolvency of users.

Vulnerability Detail

VaultAction.reduceMaxBorrowCapacity() is meant to

reduce the max borrow capacity on the vault and force
the redemption of strategy tokens to cash to reduce the overall risk of the vault.
This method is intended to be used in emergencies to mitigate insolvency risk. The effect
of this method will mean that the overall max borrow capacity is reduced, the total used
capacity will be unchanged (redeemStrategyTokensToCash does not do any lending to reduce
the outstanding fCash), and accounts will be locked out of entering the maturity which was
targeted by this method

The function calls VaultConfiguration.setMaxBorrowCapacity, which sets the new maxBorrowCapacity without any check on the new value being written.

This means it is technically possible for Notional governors to actually increase the maxBorrowCapacity.

Impact

As detailed in the function comment Other maturities for that vault may still be entered depending on whether or not the vault is above or below the max vault borrow capacity.
This would effectively increase the risk on users having entered the vaults on these other maturities:

The higher borrowing capacity means new accounts can keep borrowing, increasing here totalVaultShares.

  • VaultState.getCashValueOfShare() returns assetCashValue which will be lower due to getPoolShare return values being lower as vaultState.totalVaultShares is higher
  • In VaultConfiguration.calculateCollateralRatio(), vaultShareValue is lower , which also results in netAssetValue being lower. If too low:
  • either the account is insolvent
  • if high enough to be > 0, the return value collateralRatio will trigger this check to pass, meaning that the account can be liquidated.

Code Snippet

https://github.com/notional-finance/contracts-v2/blob/cf05d8e3e4e4feb0b0cef2c3f188c91cdaac38e0/contracts/external/actions/VaultAction.sol#L103

Tool used

Manual Review

Recommendation

VaultConfiguration.setMaxBorrowCapacity should check the new maxBorrowCapacity to ensure it is not greater than the current one.
https://github.com/notional-finance/contracts-v2/blob/cf05d8e3e4e4feb0b0cef2c3f188c91cdaac38e0/contracts/internal/vaults/VaultConfiguration.sol#L224

        VaultBorrowCapacityStorage storage cap = LibStorage.getVaultBorrowCapacity()[vault][currencyId];
+       require(cap.maxBorrowCapacity > maxBorrowCapacity, "invalid new maxBorrowCapacity");
        cap.maxBorrowCapacity = maxBorrowCapacity;

Arbitrary-Execution - `getRouterImplementation` is susceptible to function selector collisions

Arbitrary-Execution

unlabeled

getRouterImplementation is susceptible to function selector collisions

Summary

getRouterImplementation is susceptible to function selector collisions

Vulnerability Detail

getRouterImplementation in Router.sol determines which contract address to forward a function call to by comparing the called function selector to several function selectors per contract:

if (
    sig == NotionalProxy.batchBalanceAction.selector ||
    sig == NotionalProxy.batchBalanceAndTradeAction.selector ||
    sig == NotionalProxy.batchBalanceAndTradeActionWithCallback.selector ||
    sig == NotionalProxy.batchLend.selector
) {
    return BATCH_ACTION;
} else if (
  ...

There appears to be no checks in the contract or external tests to ensure that the current function selectors in getRouterImplementation are unique, and that any future additions to getRouterImplementation are unique. While function selector collisions are uncommon, the low number of bytes used in selectors means collisions are still possible.

Impact

Should a function selector collision occur in getRouterImplementation at a minimum users would only be able to call the function that occurs first in the block of if/else if/else statements. In the worst case, this means a users may call a function that they were not intending to call.

Code Snippet

N/A

Tool used

Manual Review

Recommendation

Consider implementing the diamond proxy-pattern as Router.sol is a good candidate for this proxy-pattern. Otherwise, consider implementing a test to ensure that there are no function selector collisions in any of the compiled contracts.

0xSmartContract - Low-level transfer via call() can fail silently

0xSmartContract

medium

Low-level transfer via call() can fail silently

Summary

TradingUtils.sol#L139

Vulnerability Detail

Solidity docs:"The low-level functions call, delegatecall and staticcall return true as their first return value if the account called is non-existent, as part of the design of the EVM. Account existence must be checked prior to calling if needed."

Therefore, transfers may fail silently.

Impact

in the TradingUtils.sol a call is executed with the following code in _executeTrade function

Code Snippet

call function on line 139

  118:     function _executeTrade(
  119:         address target,
  120:         uint256 msgValue,
  121:         bytes memory params,
  122:         address spender,
  123:         Trade memory trade
  124:     ) private {
  125:         uint256 preTradeBalance;
  126: 
  127:         if (trade.sellToken == address(Deployments.WETH) && spender == Deployments.ETH_ADDRESS) {
  128:             preTradeBalance = address(this).balance;
  129:             // Curve doesn't support Deployments.WETH (spender == address(0))
  130:             uint256 withdrawAmount = _isExactIn(trade) ? trade.amount : trade.limit;
  131:             Deployments.WETH.withdraw(withdrawAmount);
  132:         } else if (trade.sellToken == Deployments.ETH_ADDRESS && spender != Deployments.ETH_ADDRESS) {
  133:             preTradeBalance = IERC20(address(Deployments.WETH)).balanceOf(address(this));
  134:             // UniswapV3 doesn't support ETH (spender != address(0))
  135:             uint256 depositAmount = _isExactIn(trade) ? trade.amount : trade.limit;
  136:             Deployments.WETH.deposit{value: depositAmount }();
  137:         }
  138: 
  139:         (bool success, bytes memory returnData) = target.call{value: msgValue}(params);
  140:         if (!success) revert TradeExecution(returnData);
  141: 
  142:         if (trade.buyToken == address(Deployments.WETH)) {
  143:             if (address(this).balance > preTradeBalance) {
  144:                 // If the caller specifies that they want to receive Deployments.WETH but we have received ETH,
  145:                 // wrap the ETH to Deployments.WETH.
  146:                 uint256 depositAmount;
  147:                 unchecked { depositAmount = address(this).balance - preTradeBalance; }
  148:                 Deployments.WETH.deposit{value: depositAmount}();
  149:             }
  150:         } else if (trade.buyToken == Deployments.ETH_ADDRESS) {
  151:             uint256 postTradeBalance = IERC20(address(Deployments.WETH)).balanceOf(address(this));
  152:             if (postTradeBalance > preTradeBalance) {
  153:                 // If the caller specifies that they want to receive ETH but we have received Deployments.WETH,
  154:                 // unwrap the Deployments.WETH to ETH.
  155:                 uint256 withdrawAmount;
  156:                 unchecked { withdrawAmount = postTradeBalance - preTradeBalance; }
  157:                 Deployments.WETH.withdraw(withdrawAmount);
  158:             }
  159:         }
  160:     }
  161  

Tool used

Manual Review

Recommendation

Check for the account's existence prior to low level call
call is not recommended in most situations for contract function calls because it bypasses type checking, function existence check, and argument packing. It is preferred to import the interface of the contract to call functions on it.

rajatbeladiya - chainlink’s latestRoundData might return stale or incorrect price

rajatbeladiya

medium

chainlink’s latestRoundData might return stale or incorrect price

Vulnerability Detail

Insufficient validation of latestRoundData() will lead to stale prices according to Chainlink documentation.

Impact

there will be pricing errors leading to the mis-pricing of assets/risk.

Code Snippet

https://github.com/sherlock-audit/2022-09-notional/blob/main/leveraged-vaults/contracts/trading/TradingModule.sol#L220-L226

Tool used

Manual Review

Recommendation

Add validation for the roundId

  (uint80 roundID, int256 basePrice, /* */, uint256 bpUpdatedAt, uint80 answeredInRound) =                         
  baseOracle.oracle.latestRoundData();
  require(answeredInRound >= roundID, "Stale price");
  require(block.timestamp - bpUpdatedAt <= maxOracleFreshnessInSeconds);
   require(basePrice > 0);

Duplicate of #133

Arbitrary-Execution - `requireValidAccount` does not prevent the vault address itself from opening a position

Arbitrary-Execution

medium

requireValidAccount does not prevent the vault address itself from opening a position

Summary

requireValidAccount does not prevent a vault from opening a position in its own vault

Vulnerability Detail

requireValidAccount is called from enterVault in VaultAccountAction.sol used to ensure that:

These accounts cannot receive deposits, transfers, fCash or any other types of value transfers

Where the accounts in question are: the reserve address (aka address(0)), the VaultAccountAction contract address, and any nToken contract address. This list, however, does not include the vault address itself.

Impact

If a vault were to open a position on itself, that could cause a vault to incorrectly convert underlying assets to strategy tokens and vice versa if the vault uses token balance to determine amount and calculate value.

Code Snippet

https://github.com/notional-finance/contracts-v2/blob/cf05d8e3e4e4feb0b0cef2c3f188c91cdaac38e0/contracts/external/actions/ActionGuards.sol#L35-L48

Failing test case (add to tests/stateful/vaults/test_vault_entry.py):

def test_no_entry_vault_address(environment, vault, accounts):
    environment.notional.updateVault(
        vault.address,
        get_vault_config(flags=set_flags(0, ENABLED=True), currencyId=2),
        100_000_000e8,
    )
    maturity = environment.notional.getActiveMarkets(1)[0][1]
    with brownie.reverts():
        environment.notional.enterVault(
            vault.address,
            vault.address,
            0,
            maturity,
            0,
            0,
            "",
            {"from": vault.address},
        )

Tool used

Manual Review

Recommendation

Consider adding the following require statement to enterVault:

require(account != vault);

0x52 - Deployments.sol uses the wrong address for UNIV2 router which causes all Uniswap V2 calls to fail

0x52

medium

Deployments.sol uses the wrong address for UNIV2 router which causes all Uniswap V2 calls to fail

Summary

Deployments.sol accidentally uses the Uniswap V3 router address for UNIV2_ROUTER which causes all Uniswap V2 calls to fail

Vulnerability Detail

IUniV2Router2 internal constant UNIV2_ROUTER = IUniV2Router2(0xE592427A0AEce92De3Edee1F18E0157C05861564);

The constant UNIV2_ROUTER contains the address for the Uniswap V3 router, which doesn't contain the "swapExactTokensForTokens" or "swapTokensForExactTokens" methods. As a result, all calls made to Uniswap V2 will revert.

Impact

Uniswap V2 is totally unusable

Code Snippet

Deployments.sol#L25

Tool used

Manual Review

Recommendation

Change UNIV2_ROUTER to the address of the V2 router:

IUniV2Router2 internal constant UNIV2_ROUTER = IUniV2Router2(0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D);

0x52 - TwoTokenPoolMixin allows secondary token to have decimals >18 due to incorrect require statement

0x52

medium

TwoTokenPoolMixin allows secondary token to have decimals >18 due to incorrect require statement

Summary

TwoTokenPoolMixin incorrectly check the number of decimals on the secondary token allows it to have >18 decimals, which is explicitly incompatible

Vulnerability Detail

TwoTokenPoolMixin.sol#L61-L66

uint256 secondaryDecimals = address(SECONDARY_TOKEN) ==
    Deployments.ETH_ADDRESS
    ? 18
    : SECONDARY_TOKEN.decimals();
require(primaryDecimals <= 18);
SECONDARY_DECIMALS = uint8(secondaryDecimals);

In TwoTokenPoolMixin#constructor secondary tokens are supposed to be check to block tokens that have decimals >18. The require statement is incorrect and checks the decimals of the primary token again instead of the decimals of the secondary token, allowing secondary tokens > 18 decimals to slip through.

Impact

Important security check bypassed, allowing explicitly incompatible tokens to be added

Code Snippet

TwoTokenPoolMixin.sol#L65

Tool used

Manual Review

Recommendation

Rewrite require to check secondary decimals rather than primary

-    require(primaryDecimals <= 18);
+    require(secondaryDecimals <= 18);

Duplicate of #88

joestakey - `BaseStrategyVault.redeemFromNotional()` can fail if `receiver` has a fallback function

joestakey

medium

BaseStrategyVault.redeemFromNotional() can fail if receiver has a fallback function

Summary

BaseStrategyVault.redeemFromNotional() can fail because of the use of the native ETH transfer method.

Vulnerability Detail

BaseStrategyVault.redeemFromNotional() transfers transferToReceiver to the receiver using the native transfer() function. The problem is that transfer() only allows the recipient to use 2300 gas. If the recipient uses more than that, transfers will fail. This can be the case if receiver is a smart contract wallet that performs some logic in its fallback() function - such as splitting payment to the wallet owners.
In the future gas costs might change increasing the likelihood of that happening.

Proof Of Concept

Run the test in Transfer.t.sol from this private gist.

It tries to send ETH using a function via transfer() to two wallets:

  • wallet1, which is a payment splitter wallet that transfers ETH to defined beneficiaries upon receiving ETH.
  • wallet2, a simple smart contract wallet with no logic.

You can see that the transfer to wallet1 fails with an out of gas error, while the transfer to wallet2 works properly.

    ├─ [12002] PoCTransfer::withdraw(Wallet: [0x5b851f32f9ab7fb2c76480c608034a5ed1f16cfc], 1000000000000000000) 
    │   ├─ [2277] Wallet::fallback{value: 1000000000000000000}() 
    │   │   └─ ← "EvmError: OutOfGas"
    │   └─ ← "EvmError: Revert"
    └─ ← "EvmError: Revert"

Code Snippet

https://github.com/sherlock-audit/2022-09-notional/blob/main/leveraged-vaults/contracts/vaults/BaseStrategyVault.sol#L181

Tool used

Manual Review, Foundry

Recommendation

Use call() instead

-181:             if (transferToReceiver > 0) payable(receiver).transfer(transferToReceiver);
-182:             if (transferToNotional > 0) payable(address(NOTIONAL)).transfer(transferToNotional);
+181:             if (transferToReceiver > 0) payable(receiver).call{ value: transferToReceiver}(new bytes(0));
+182:             if (transferToNotional > 0) payable(address(NOTIONAL)).call{ value: transferToNotional}(new bytes(0));

Duplicate of #63

0x52 - UniV2Adapter#getExecutionData doesn't properly handle native ETH swaps

0x52

medium

UniV2Adapter#getExecutionData doesn't properly handle native ETH swaps

Summary

UniV2Adapter#getExecutionData doesn't properly account for native ETH trades which makes them impossible. Neither method selected supports direct ETH trades, and sender/target are not set correctly for TradingUtils_executeTrade to automatically convert

Vulnerability Detail

spender = address(Deployments.UNIV2_ROUTER);
target = address(Deployments.UNIV2_ROUTER);
// msgValue is always zero for uniswap

if (
    tradeType == TradeType.EXACT_IN_SINGLE ||
    tradeType == TradeType.EXACT_IN_BATCH
) {
    executionCallData = abi.encodeWithSelector(
        IUniV2Router2.swapExactTokensForTokens.selector,
        trade.amount,
        trade.limit,
        data.path,
        from,
        trade.deadline
    );
} else if (
    tradeType == TradeType.EXACT_OUT_SINGLE ||
    tradeType == TradeType.EXACT_OUT_BATCH
) {
    executionCallData = abi.encodeWithSelector(
        IUniV2Router2.swapTokensForExactTokens.selector,
        trade.amount,
        trade.limit,
        data.path,
        from,
        trade.deadline
    );
}

UniV2Adapter#getExecutionData either returns the swapTokensForExactTokens or swapExactTokensForTokens, neither of with support native ETH. It also doesn't set spender and target like UniV3Adapter, so _executeTrade won't automatically convert it to a WETH call. The result is that all Uniswap V2 calls made with native ETH will fail. Given that Notional operates in native ETH rather than WETH, this is an important feature that currently does not function.

Impact

Uniswap V2 calls won't support native ETH

Code Snippet

UniV2Adapter.sol#L12-L52

Tool used

Manual Review

Recommendation

There are two possible solutions:

  1. Change the way that target and sender are set to match the implementation in UniV3Adapter
  2. Modify the return data to return the correct selector for each case (swapExactETHForTokens, swapTokensForExactETH, etc.)

Given that the infrastructure for Uniswap V3 already exists in TradingUtils_executeTrade the first option would be the easiest, and would give the same results considering it's basically the same as what the router is doing internally anyways.

8olidity - MaxBorrowMarketIndex does not limit borrowAndEnterVault() DOS

8olidity

medium

MaxBorrowMarketIndex does not limit borrowAndEnterVault() DOS

Summary

MaxBorrowMarketIndex does not limit borrowAndEnterVault() DOS

Vulnerability Detail

poc

  1. UpdateVault () updates the vaultConfig file

  2. Set maxBorrowMarketIndex to a small value

  3. When a user calls borrowAndEnterVault() it will run to checkValidMaturity()

  4. Require (marketIndex <= maxBorrowMarketIndex, "Invalid Maturity"); The judgment may fail, causing the function to fail

Impact

MaxBorrowMarketIndex does not limit borrowAndEnterVault() DOS

Code Snippet

https://github.com/sherlock-audit/2022-09-notional/blob/main/contracts-v2/contracts/external/actions/VaultAction.sol#L27

// contracts-v2/contracts/external/actions/VaultAction.sol
function updateVault(
    address vaultAddress,
    VaultConfigStorage calldata vaultConfig,
    uint80 maxPrimaryBorrowCapacity
) external override onlyOwner {
    VaultConfiguration.setVaultConfig(vaultAddress, vaultConfig);
    VaultConfiguration.setMaxBorrowCapacity(vaultAddress, vaultConfig.borrowCurrencyId, maxPrimaryBorrowCapacity);
    bool enabled = (vaultConfig.flags & VaultConfiguration.ENABLED) == VaultConfiguration.ENABLED;
    emit VaultUpdated(vaultAddress, enabled, maxPrimaryBorrowCapacity);
}

// contracts-v2/contracts/internal/vaults/VaultAccount.sol
function borrowAndEnterVault(
    VaultAccount memory vaultAccount,
    VaultConfig memory vaultConfig,
    uint256 maturity,
    uint256 fCashToBorrow,
    uint32 maxBorrowRate,
    bytes calldata vaultData,
    uint256 strategyTokenDeposit,
    uint256 additionalUnderlyingExternal
) internal returns (uint256 strategyTokensAdded) {
        ......    
        VaultConfiguration.checkValidMaturity(
            vaultConfig.borrowCurrencyId,
            maturity,
            vaultConfig.maxBorrowMarketIndex,
            block.timestamp
        );

// contracts-v2/contracts/internal/vaults/VaultConfiguration.sol
function checkValidMaturity(
    uint16 currencyId,
    uint256 maturity,
    uint256 maxBorrowMarketIndex,
    uint256 blockTime
) internal view returns (uint256 marketIndex) {
    bool isIdiosyncratic;
    uint8 maxMarketIndex = CashGroup.getMaxMarketIndex(currencyId);
    (marketIndex, isIdiosyncratic) = DateTime.getMarketIndex(maxMarketIndex, maturity, blockTime);
    require(marketIndex <= maxBorrowMarketIndex, "Invalid Maturity");
    require(!isIdiosyncratic, "Invalid Maturity");
}

Tool used

vscode
Manual Review

Recommendation

check maxBorrowMarketIndex

8olidity - use safecast

8olidity

medium

use safecast

Summary

The unsafe casting of the recovered amount from uint256 to int256 means the users’ funds will be lost.

Vulnerability Detail

Many places convert the Uint256 type directly to INT256

Impact

Loss of funds.

Code Snippet

https://github.com/sherlock-audit/2022-09-notional/blob/main/contracts-v2/contracts/internal/vaults/VaultConfiguration.sol#L856-L857

// contracts-v2/contracts/internal/vaults/VaultConfiguration.sol
function _updateAccountDebtShares(
    VaultConfig memory vaultConfig,
    address account,
    uint16 currencyId,
    uint256 maturity,
    int256 netAccountDebtShares
) private {
    VaultAccountSecondaryDebtShareStorage storage s = 
        LibStorage.getVaultAccountSecondaryDebtShare()[account][vaultConfig.vault];
    uint256 accountMaturity = s.maturity;
    require(accountMaturity == maturity || accountMaturity == 0, "Invalid Secondary Maturity");
    int256 accountDebtSharesOne = int256(uint256(s.accountDebtSharesOne));
    int256 accountDebtSharesTwo = int256(uint256(s.accountDebtSharesTwo));
    
    

function _getVaultConfig(
    address vaultAddress
) private view returns (VaultConfig memory vaultConfig) {
    mapping(address => VaultConfigStorage) storage store = LibStorage.getVaultConfig();
    VaultConfigStorage storage s = store[vaultAddress];

    vaultConfig.vault = vaultAddress;
    vaultConfig.flags = s.flags;
    vaultConfig.borrowCurrencyId = s.borrowCurrencyId;
    vaultConfig.minAccountBorrowSize = int256(s.minAccountBorrowSize).mul(Constants.INTERNAL_TOKEN_PRECISION);
    vaultConfig.feeRate = int256(uint256(s.feeRate5BPS).mul(Constants.FIVE_BASIS_POINTS));
    vaultConfig.minCollateralRatio = int256(uint256(s.minCollateralRatioBPS).mul(Constants.BASIS_POINT));
    vaultConfig.maxDeleverageCollateralRatio = int256(uint256(s.maxDeleverageCollateralRatioBPS).mul(Constants.BASIS_POINT));
    // This is used in 1e9 precision on the stack (no overflow possible)
    vaultConfig.liquidationRate = (int256(uint256(s.liquidationRate)) * Constants.RATE_PRECISION) / Constants.PERCENTAGE_DECIMALS;
    vaultConfig.reserveFeeShare = int256(uint256(s.reserveFeeShare));
    
function snapshotSecondaryBorrowAtSettlement(
  VaultConfig memory vaultConfig,
  uint16 currencyId,
  uint256 maturity
) internal returns (int256 totalfCashBorrowedInPrimary) {
  if (currencyId == 0) return 0;

  // Updates storage for the specific maturity so we can track this on chain.
  VaultSecondaryBorrowStorage storage balance = 
      LibStorage.getVaultSecondaryBorrow()[vaultConfig.vault][maturity][currencyId];
  // The snapshot value can only be set once when settlement is initiated
  require(!balance.hasSnapshotBeenSet, "Cannot Reset Snapshot");

  int256 totalfCashBorrowed = int256(uint256(balance.totalfCashBorrowed));
  
  
function updateUsedBorrowCapacity(
    address vault,
    uint16 currencyId,
    int256 netfCash
) internal returns (int256 totalUsedBorrowCapacity) {
    VaultBorrowCapacityStorage storage cap = LibStorage.getVaultBorrowCapacity()[vault][currencyId];

    // Update the total used borrow capacity, when borrowing this number will increase (netfCash < 0),
    // when lending this number will decrease (netfCash > 0). 
    totalUsedBorrowCapacity = int256(uint256(cap.totalUsedBorrowCapacity)).sub(netfCash);
    if (netfCash < 0) {
        // Always allow lending to reduce the total used borrow capacity to satisfy the case when the max borrow
        // capacity has been reduced by governance below the totalUsedBorrowCapacity. When borrowing, it cannot
        // go past the limit.
        require(totalUsedBorrowCapacity <= int256(uint256(cap.maxBorrowCapacity)), "Max Capacity");
    }

Tool used

vscode
Manual Review

Recommendation

use safecast
https://docs.openzeppelin.com/contracts/3.x/api/utils#SafeCast-toInt256-uint256-

Arbitrary-Execution - `deleverageAccount` can still be called when a vault is paused

Arbitrary-Execution

medium

deleverageAccount can still be called when a vault is paused

Summary

deleverageAccount can still be called when a vault is paused

Vulnerability Detail

Every vault has an ENABLED flag that can be toggled on an off, and is used to prevent certain vault account functions from being called in VaultAccountAction.sol when a vault is 'Paused'; these functions include: enterVault and rollVaultPosition. However, deleverageAccount is still able to be called even when a vault is paused.

Impact

When the ENABLED flag is not set, meaning a vault is paused, liquidators will still be able to liquidate vault account positions. However, users are still able to call exitVault to either fully exit their position or lower their collateral ratio if necessary to avoid liquidation.

Code Snippet

https://github.com/notional-finance/contracts-v2/blob/cf05d8e3e4e4feb0b0cef2c3f188c91cdaac38e0/contracts/external/actions/VaultAccountAction.sol#L261

Failing test (add to tests/stateful/vaults/test_vault_deleverage.py):

def test_deleverage_paused(environment, accounts, vault):
    environment.notional.updateVault(
        vault.address,
        get_vault_config(currencyId=2, flags=set_flags(0, ENABLED=False)),
        100_000_000e8,
    )
    maturity = environment.notional.getActiveMarkets(1)[0][1]

    environment.notional.enterVault(
        accounts[1], vault.address, 25_000e18, maturity, 100_000e8, 0, "", {"from": accounts[1]}
    )
    vault.setExchangeRate(0.85e18)
    (cr, _, _) = environment.notional.getVaultAccountCollateralRatio(accounts[1], vault)
    assert cr < 0.2e9

    # would expect this call to revert when a vault is paused
    with brownie.reverts("Cannot Enter"):
        environment.notional.deleverageAccount(
            accounts[1], vault.address, accounts[2], 25_000e18, False, "", {"from": accounts[2]}
        )

Tool used

Manual Review

Recommendation

Consider adding the following require statement to deleverageAccount:

require(vaultConfig.getFlag(VaultConfiguration.ENABLED), "Cannot Enter");

hyh - TwoTokenPoolUtils's _getOraclePairPrice produces incorrect oraclePairPrice when balancerOracleWeight is set to be bigger than BALANCER_ORACLE_WEIGHT_PRECISION

hyh

medium

TwoTokenPoolUtils's _getOraclePairPrice produces incorrect oraclePairPrice when balancerOracleWeight is set to be bigger than BALANCER_ORACLE_WEIGHT_PRECISION

Summary

TwoTokenPoolUtils's _getOraclePairPrice() will produce bloated oraclePairPrice as long as oracleContext.balancerOracleWeight is bigger than BALANCER_ORACLE_WEIGHT_PRECISION.

As TwoTokenPoolUtils is a helper contract, it accepts any settings from a Vault. However, _getOraclePairPrice() logic breaks up when balancerOracleWeight > BALANCER_ORACLE_WEIGHT_PRECISION. Currently there are no controls that ensures that this will not take place.

Vulnerability Detail

Whenever balancerOracleWeight is initialized with value that exceeds BalancerConstants.BALANCER_ORACLE_WEIGHT_PRECISION, the _getOraclePairPrice() returned price becomes greater than actual price by the oracleContext.balancerOracleWeight / BalancerConstants.BALANCER_ORACLE_WEIGHT_PRECISION ratio.

In particular, it can be magnitudes higher than Oracle reported values as the most probable scenario here is using one precision instead of another. For example, if balancerOracleWeight be supplied out of 18 decimals precision, the _getOraclePairPrice() will be 10^10 higher than actual values.

Impact

_getOraclePairPrice() is used to obtain a price of a strategy in the units of the underlying token, i.e. the function supplies the marker to market strategy price for subsequent decision making in the Vault. The impact of such price being magnitudes off will be the liquidations of the healthy positions and vice versa, the prohibition of the liquidations of the healthy ones, i.e. scenarios leading to direct losses for Vaults' users.

The probability of setting oracleContext.balancerOracleWeight without regard to BalancerConstants.BALANCER_ORACLE_WEIGHT_PRECISION isn't too low as there are several precision bases used in the system that can be easily messed up (it's actually the case for one of the example Vaults in this repo as it's shown in an another issue). As this is still a precondition, setting the severity to be medium.

Code Snippet

_getOraclePairPrice() logic is based on an assumption that balancerOracleWeight is a part of the whole BalancerConstants.BALANCER_ORACLE_WEIGHT_PRECISION:

https://github.com/sherlock-audit/2022-09-notional/blob/main/leveraged-vaults/contracts/vaults/balancer/internal/pool/TwoTokenPoolUtils.sol#L66-L114

    /// @notice Gets the oracle price pair price between two tokens using a weighted
    /// average between a chainlink oracle and the balancer TWAP oracle.
    /// @param poolContext oracle context variables
    /// @param oracleContext oracle context variables
    /// @param tradingModule address of the trading module
    /// @return oraclePairPrice oracle price for the pair in 18 decimals
    function _getOraclePairPrice(
        TwoTokenPoolContext memory poolContext,
        OracleContext memory oracleContext, 
        ITradingModule tradingModule
    ) internal view returns (uint256 oraclePairPrice) {
        // NOTE: this balancer price is denominated in 18 decimal places
        uint256 balancerWeightedPrice;
        if (oracleContext.balancerOracleWeight > 0) {
            uint256 balancerPrice = BalancerUtils._getTimeWeightedOraclePrice(
                address(poolContext.basePool.pool),
                IPriceOracle.Variable.PAIR_PRICE,
                oracleContext.oracleWindowInSeconds
            );

            if (poolContext.primaryIndex == 1) {
                // If the primary index is the second token, we need to invert
                // the balancer price.
                balancerPrice = BalancerConstants.BALANCER_PRECISION_SQUARED / balancerPrice;
            }

            balancerWeightedPrice = balancerPrice * oracleContext.balancerOracleWeight;
        }

        uint256 chainlinkWeightedPrice;
        if (oracleContext.balancerOracleWeight < BalancerConstants.BALANCER_ORACLE_WEIGHT_PRECISION) {
            (int256 rate, int256 decimals) = tradingModule.getOraclePrice(
                poolContext.primaryToken, poolContext.secondaryToken
            );
            require(rate > 0);
            require(decimals >= 0);

            if (uint256(decimals) != BalancerConstants.BALANCER_PRECISION) {
                rate = (rate * int256(BalancerConstants.BALANCER_PRECISION)) / decimals;
            }

            // No overflow in rate conversion, checked above
            chainlinkWeightedPrice = uint256(rate) * 
                (BalancerConstants.BALANCER_ORACLE_WEIGHT_PRECISION - oracleContext.balancerOracleWeight);
        }

        oraclePairPrice = (balancerWeightedPrice + chainlinkWeightedPrice) / 
            BalancerConstants.BALANCER_ORACLE_WEIGHT_PRECISION;
    }

As BALANCER_ORACLE_WEIGHT_PRECISION = 1e8, the BalancerConstants.BALANCER_ORACLE_WEIGHT_PRECISION > 0 check is satisfied.

Different cases here are:

  • balancerOracleWeight == 0: ok

  • 0 < balancerOracleWeight < BalancerConstants.BALANCER_ORACLE_WEIGHT_PRECISION: ok

  • balancerOracleWeight == BalancerConstants.BALANCER_ORACLE_WEIGHT_PRECISION: ok

  • balancerOracleWeight > BalancerConstants.BALANCER_ORACLE_WEIGHT_PRECISION:
    wrong as the price now is
    oraclePairPrice = (balancerWeightedPrice + chainlinkWeightedPrice) / BalancerConstants.BALANCER_ORACLE_WEIGHT_PRECISION
    while it needs to be
    oraclePairPrice = (balancerWeightedPrice + chainlinkWeightedPrice) / oracleContext.balancerOracleWeight
    as while chainlinkWeightedPrice == 0, the balancerWeightedPrice is weighted with oracleContext.balancerOracleWeight.

The latter case is possible as oracleContext.balancerOracleWeight is set via BalancerVaultStorage's setStrategyVaultSettings, that controls its value with a caller-supplied argument:

https://github.com/sherlock-audit/2022-09-notional/blob/main/leveraged-vaults/contracts/vaults/balancer/internal/BalancerVaultStorage.sol#L25-L47

    function setStrategyVaultSettings(
        StrategyVaultSettings memory settings, 
        uint32 maxOracleQueryWindow,
        uint16 balancerOracleWeight
    ) internal {
        require(settings.oracleWindowInSeconds <= maxOracleQueryWindow);
        require(settings.settlementCoolDownInMinutes <= BalancerConstants.MAX_SETTLEMENT_COOLDOWN_IN_MINUTES);
        require(settings.postMaturitySettlementCoolDownInMinutes <= BalancerConstants.MAX_SETTLEMENT_COOLDOWN_IN_MINUTES);
        require(settings.maxRewardTradeSlippageLimitPercent <= BalancerConstants.SLIPPAGE_LIMIT_PRECISION);
        require(settings.balancerOracleWeight <= balancerOracleWeight);
        require(settings.maxBalancerPoolShare <= BalancerConstants.VAULT_PERCENT_BASIS);
        require(settings.settlementSlippageLimitPercent <= BalancerConstants.SLIPPAGE_LIMIT_PRECISION);
        require(settings.postMaturitySettlementSlippageLimitPercent <= BalancerConstants.SLIPPAGE_LIMIT_PRECISION);
        require(settings.emergencySettlementSlippageLimitPercent <= BalancerConstants.SLIPPAGE_LIMIT_PRECISION);
        require(settings.feePercentage <= BalancerConstants.VAULT_PERCENT_BASIS);
        require(settings.oraclePriceDeviationLimitPercent <= BalancerConstants.VAULT_PERCENT_BASIS);

        mapping(uint256 => StrategyVaultSettings) storage store = _settings();
        // Hardcode to the zero slot
        store[0] = settings;

        emit BalancerEvents.StrategyVaultSettingsUpdated(settings);
    }

The value of balancerOracleWeight argument isn't controlled, it is up to Vault designer to set any value.

This way if for any reason settings.balancerOracleWeight in setStrategyVaultSettings() be set to be greater than BALANCER_ORACLE_WEIGHT_PRECISION, the _getOraclePairPrice() become up to magnitudes wrong. Say if decimals got messed up to the upside, say balancerOracleWeight can be set out of 18 decimals, while BalancerConstants.BALANCER_ORACLE_WEIGHT_PRECISION is only 8:

https://github.com/sherlock-audit/2022-09-notional/blob/main/leveraged-vaults/contracts/vaults/balancer/internal/BalancerConstants.sol#L7-L12

    uint256 internal constant BALANCER_ORACLE_WEIGHT_PRECISION = 1e8;
    uint32 internal constant SLIPPAGE_LIMIT_PRECISION = 1e8;

    /// @notice Precision for all percentages used by the vault
    /// 1e4 = 100% (i.e. maxBalancerPoolShare)
    uint16 internal constant VAULT_PERCENT_BASIS = 1e4;

In this case _getOraclePairPrice() price becomes circa 10^10 times greater than the actual price the oracles reported.

_getOraclePairPrice() is used for the current strategy evaluation via the following call sequences:

convertStrategyToUnderlying -> _convertStrategyToUnderlying, _executeSettlement -> _getTimeWeightedPrimaryBalance -> _getOraclePairPrice,

settleVault, settleVaultEmergency -> _executeSettlement -> _getTimeWeightedPrimaryBalance -> _getOraclePairPrice.

Tool used

Manual Review

Recommendation

The usage of BALANCER_ORACLE_WEIGHT_PRECISION is fixed in the logic, so setting a balancerOracleWeight outside it can easily lead to the malfunction of the approach. As TwoTokenPoolUtils library logic above needs to be uniform, while the Vaults can vary, the only way to avoid this is to control the setting so it always matches the logic.

Consider controlling the balancerOracleWeight limit in setStrategyVaultSettings()

Upper limit is straightforward, while lower is a kind of useful heuristic:

https://github.com/sherlock-audit/2022-09-notional/blob/main/leveraged-vaults/contracts/vaults/balancer/internal/BalancerVaultStorage.sol#L25-L35

    function setStrategyVaultSettings(
        StrategyVaultSettings memory settings, 
        uint32 maxOracleQueryWindow,
        uint16 balancerOracleWeight
    ) internal {
+       require(balancerOracleWeight == 0 ||
                (balancerOracleWeight >= BalancerConstants.BALANCER_ORACLE_WEIGHT_PRECISION / 100 &&
                 balancerOracleWeight <= BalancerConstants.BALANCER_ORACLE_WEIGHT_PRECISION));
        require(settings.oracleWindowInSeconds <= maxOracleQueryWindow);
        require(settings.settlementCoolDownInMinutes <= BalancerConstants.MAX_SETTLEMENT_COOLDOWN_IN_MINUTES);
        require(settings.postMaturitySettlementCoolDownInMinutes <= BalancerConstants.MAX_SETTLEMENT_COOLDOWN_IN_MINUTES);
        require(settings.maxRewardTradeSlippageLimitPercent <= BalancerConstants.SLIPPAGE_LIMIT_PRECISION);
        require(settings.balancerOracleWeight <= balancerOracleWeight);
        require(settings.maxBalancerPoolShare <= BalancerConstants.VAULT_PERCENT_BASIS);

Jeiwan - Deprecated Balancer Price Oracles could lead to locked funds in the Balancer strategy vaults

Jeiwan

medium

Deprecated Balancer Price Oracles could lead to locked funds in the Balancer strategy vaults

Summary

The Balancer strategy vaults (Boosted3TokenAuraVault and MetaStable2TokenAuraVault) use the price oracle of related Balancer vaults during settlement. However, price oracles in Balancer vaults were deprecated. It's likely that liquidity will be drained from such vaults and will be moved to new vaults. Lowered liquidity will result it price deviation, which will lead to failing settlement due to the cross-checking with Chainlink oracles.

Vulnerability Detail

During settlement of the Balancer strategy vaults, token spot prices are queried from Balancer Price Oracle. The spot prices are then compared to those reported by Chainlink. If the difference is too big, settlement will fail. Lowered liquidity in the deprecated Balancer vaults will result in high price deviation, blocked settlement, and locked funds.

Impact

Since Balancer has deprecated price oracles in its vaults and advised against using the vaults with price oracles enabled (they won't be disabled), it's likely that liquidity will be removed from such vaults and will be moved to new Balancer vaults that don't have the price oracle functionality. Since the Balancer strategy vaults of Notional are integrated with such deprecated Balancer vaults, it's likely that the strategy vaults will be impacted by lowered liquidity of the Balancer vaults. Lower liquidity will result in higher slippage, which means higher deviation of Balancer price oracle reported spot prices compared to those of Chainlink. In the case when price deviation is higher than defined in the oraclePriceDeviationLimitPercent setting (which is very likely due to Balancer recommending against using the deprecated vaults), settlement won't be possible and funds will be locked.

Code Snippet

https://github.com/sherlock-audit/2022-09-notional/blob/main/leveraged-vaults/contracts/vaults/balancer/internal/math/Stable2TokenOracleMath.sol#L76-L77
https://github.com/sherlock-audit/2022-09-notional/blob/main/leveraged-vaults/contracts/vaults/balancer/internal/math/Stable2TokenOracleMath.sol#L43-L65

Tool used

Manual Review

Recommendation

Short term, don't revert (Errors.InvalidPrice) in case of a price deviation and use the Chainlink price instead. Long term, migrate to the new Balancer vaults that don't have a price oracle and use Chainlink only.

Bnke0x0 - underlying Token can be stuck into the Strategy contract

Bnke0x0

high

underlying Token can be stuck into the Strategy contract

Summary

underlying token can be stuck into the Strategy contract

Vulnerability Detail

Impact

At every '_redeem()'/'repaySecondaryCurrencyFromVault()' it checks the balance before the claim and after, to calculate the auraBAL earned, so every underlyingToken transferred to the strategy address, not during this call, won't be swapped to Notional.

Code Snippet

  1. https://github.com/sherlock-audit/2022-09-notional/blob/main/contracts-v2/contracts/external/actions/VaultAction.sol#L329-L337

          'uint256 balanceBefore = underlyingToken.balanceOf(address(this));
                    // Tells the vault will redeem the strategy token amount and transfer asset tokens back to Notional
                    returnData = IStrategyVault(msg.sender).repaySecondaryBorrowCallback(
                        underlyingToken.tokenAddress, underlyingExternalToRepay, callbackData
                    );
                    uint256 balanceAfter = underlyingToken.balanceOf(address(this));
                    balanceTransferred = balanceAfter.sub(balanceBefore);
                    require(balanceTransferred >= underlyingExternalToRepay, "Insufficient Repay");
                                      }'
    
  2. https://github.com/sherlock-audit/2022-09-notional/blob/main/contracts-v2/contracts/internal/vaults/VaultConfiguration.sol#L600-L624

            'uint256 amountTransferred;
                if (params.strategyTokens > 0) {
                    uint256 balanceBefore = underlyingToken.balanceOf(address(this));
                    // There are four possibilities here during the transfer:
                    //   1. If the account == vaultConfig.vault then the strategy vault must always transfer
                    //      tokens back to Notional. underlyingToReceiver will equal 0, amountTransferred will
                    //      be the value of the redemption.
                    //   2. If the account has debt to repay and is redeeming sufficient tokens to repay the debt,
                    //      the vault will transfer back underlyingExternalToRepay and transfer underlyingToReceiver
                    //      directly to the receiver.
                    //   3. If the account has redeemed insufficient tokens to repay the debt, the vault will transfer
                    //      back as much as it can (less than underlyingExternalToRepay) and underlyingToReceiver will
                    //      be zero. If this occurs, then the next if block will be triggered where we attempt to recover
                    //      the shortfall from the account's wallet.
                    //   4. During liquidation, the liquidator will redeem their strategy token profits without any debt
                    //      to repay (underlyingExternalToRepay == 0). This means that all the profits will be returned
                    //      to the liquidator (params.receiver) from the vault (underlyingToReceiver will be the full value
                    //      of the redemption) and amountTransferred will equal 0. A similar scenario will occur when
                    //      accounts exit post maturity and have no debt associated with their account.
                    underlyingToReceiver = IStrategyVault(vaultConfig.vault).redeemFromNotional(
                        params.account, params.receiver, params.strategyTokens, params.maturity, underlyingExternalToRepay, data
                    );
                    uint256 balanceAfter = underlyingToken.balanceOf(address(this));
                    amountTransferred = balanceAfter.sub(balanceBefore);
                           }'
    

Tool used

Manual Review

Recommendation

Instead of calculating the balance before and after the claim

cccz - When tokenType != Ether, need to check msg.value == 0

cccz

medium

When tokenType != Ether, need to check msg.value == 0

Summary

When tokenType != Ether, need to check msg.value == 0

Vulnerability Detail

In the depositForRollPosition function of the VaultAccountLib library and the transferUnderlyingToVaultDirect and _redeem functions of the VaultConfiguration library, there is no check for msg.value == 0 when tokenType != Ether. If the user mistakenly sends ETH to the contract when tokenType != Ether, the ETH will be locked in the contract.

Impact

If the user mistakenly sends ETH to the contract when tokenType != Ether, the ETH will be locked in the contract.

Code Snippet

https://github.com/sherlock-audit/2022-09-notional/blob/main/contracts-v2/contracts/internal/vaults/VaultAccount.sol#L268-L275
https://github.com/sherlock-audit/2022-09-notional/blob/main/contracts-v2/contracts/internal/vaults/VaultConfiguration.sol#L451-L460
https://github.com/sherlock-audit/2022-09-notional/blob/main/contracts-v2/contracts/internal/vaults/VaultConfiguration.sol#L634-L647

Tool used

Manual Review

Recommendation

        if (underlyingToken.tokenType == TokenType.Ether) {
            require(depositAmountExternal == msg.value, "Invalid ETH");
            amountTransferred = msg.value;
        } else {
+          require(msg.value == 0);
            amountTransferred = underlyingToken.transfer(
                vaultAccount.account, vaultConfig.borrowCurrencyId, depositAmountExternal.toInt()
            ).toUint();
        }
...
        if (underlyingToken.tokenType == TokenType.Ether) {
            require(msg.value == depositAmountExternal, "Invalid ETH");
            // Forward all the ETH to the vault
            GenericToken.transferNativeTokenOut(vault, msg.value);

            return msg.value;
        } else {
+          require(msg.value == 0);
            GenericToken.safeTransferFrom(underlyingToken.tokenAddress, transferFrom, vault, depositAmountExternal);
            return depositAmountExternal;
        }
...
            if (underlyingToken.tokenType == TokenType.Ether) {
                require(residualRequired <= msg.value, "Insufficient repayment");
                // Transfer out the unused part of msg.value, we've received all underlying external required
                // at this point
                GenericToken.transferNativeTokenOut(params.account, msg.value - residualRequired);
                amountTransferred = underlyingExternalToRepay;
            } else {
+             require(msg.value == 0);
                // actualTransferExternal is a positive number here to signify assets have entered
                // the protocol
                int256 actualTransferExternal = underlyingToken.transfer(
                    params.account, vaultConfig.borrowCurrencyId, residualRequired.toInt()
                );
                amountTransferred = amountTransferred.add(actualTransferExternal.toUint());
            }

Arbitrary-Execution - Vault owner has outsized control over user accounts

Arbitrary-Execution

medium

Vault owner has outsized control over user accounts

Summary

Vault owner has outsized control over user accounts

Vulnerability Detail

Each vault has a number of different configuration flags that can be set. In addition to a standard 'pause' style flag, there are several other flags that can be used to restrict which actions can be performed on a vault and by whom the actions can be performed. The flags can be seen in VaultConfiguration.sol and below:

uint16 internal constant ENABLED                         = 1 << 0;
uint16 internal constant ALLOW_ROLL_POSITION             = 1 << 1;
// These flags switch the authentication on the vault methods such that all
// calls must come from the vault itself.
uint16 internal constant ONLY_VAULT_ENTRY                = 1 << 2;
uint16 internal constant ONLY_VAULT_EXIT                 = 1 << 3;
uint16 internal constant ONLY_VAULT_ROLL                 = 1 << 4;
uint16 internal constant ONLY_VAULT_DELEVERAGE           = 1 << 5;
uint16 internal constant ONLY_VAULT_SETTLE               = 1 << 6;
// External vault methods will have re-entrancy protection on by default, however, some
// vaults may need to call back into Notional so we can whitelist them for re-entrancy.
uint16 internal constant ALLOW_REENTRANCY                = 1 << 7;

However, allowing the vault to have such large control over user's actions is restrictive. This is especially pertinent for the ONLY_VAULT_EXIT flag as when this flag is set only the vault itself is allowed to call exitVault.

Impact

Setting any of the ONLY_VAULT_* flags above could greatly impact users, especially ONLY_VAULT_EXIT which would prevent users from being able to exit their position in a vault and withdraw their collateral.

Code Snippet

https://github.com/notional-finance/contracts-v2/blob/cf05d8e3e4e4feb0b0cef2c3f188c91cdaac38e0/contracts/external/actions/VaultAccountAction.sol#L179

Tool used

Manual Review

Recommendation

Consider removing the ONLY_VAULT_EXIT flag and corresponding logic as contracts should never be able to prevent users from withdrawing their collateral when their position is healthy.

Waze - Latest RoundData from Chainlink May Return Outdated Results

Waze

medium

Latest RoundData from Chainlink May Return Outdated Results

Summary

Out of date price from chainlink can lead some fund lost.

Vulnerability Detail

Across these contracts, you are using Chainlink's latestRoundData API, but there is only a check on baseAnswer.

Impact

The result of latestRoundData API will be used across various functions, therefore, an outdated price from Chainlink can lead to loss of funds to end-users.

Code Snippet

https://github.com/sherlock-audit/2022-09-notional/blob/main/leveraged-vaults/contracts/trading/oracles/wstETHChainlinkOracle.sol#L26-L43

Tool used

Manual Review

Recommendation

Consider adding the missing checks for stale data.

For example:
require(answeredInRound >= roundId, "Out of date price");
require(updateAt != 0, "Round not complete");

Duplicate of #133

0xSmartContract - Storage Write Removal Bug On Conditional Early Termination

0xSmartContract

high

Storage Write Removal Bug On Conditional Early Termination

Summary

On September 5, 2022, a bug in Solidity’s Yul optimizer was found by differential fuzzing.

The bug was Solidity version 0.8.17, released on September 08, 2022, provides a fix. The bug is significantly easier to trigger with optimized via-IR code generation, but can theoretically also occur in optimized legacy code generation.

The bug may result in storage writes being incorrectly considered redundant and removed by the optimizer. The problem manifests in presence of assembly functions that may conditionally terminate the external EVM call using the return() or stop() opcode.

Impact

Soliditylang assigned the bug a severity of “medium/high”.

https://blog.soliditylang.org/2022/09/08/storage-write-removal-before-conditional-termination/

Vulnerability Detail

Similar to the problem mentioned above, there is a return in the following inline assembly block in the project.
TokenUtils.sol#L38-L59

Code Snippet

    function _checkReturnCode() private pure {
        bool success;
        uint256[1] memory result;
        assembly {
            switch returndatasize()
                case 0 {
                    // This is a non-standard ERC-20
                    success := 1 // set success to true
                }
                case 32 {
                    // This is a compliant ERC-20
                    returndatacopy(result, 0, 32)
                    success := mload(result) // Set `success = returndata` of external call
                }
                default {
                    // This is an excessively non-compliant ERC-20, revert.
                    revert(0, 0)
                }
        }

        if (!success) revert ERC20Error();
    }

Tool used

Manual Review

Recommendation

Use Solidity version 0.8.17 (https://github.com/ethereum/solidity/releases/tag/v0.8.17)

Waze - Unsafe transfer when using transferNativeTokenOut() from GenericToken Lib can result in revert.

Waze

medium

Unsafe transfer when using transferNativeTokenOut() from GenericToken Lib can result in revert.

Summary

Unsafe transfer happen when using transferNativeTokenOut(). Transfer need to be check for value indicating success.

Vulnerability Detail

To transfer native token, the return value must be checked if transfer native token is successful or not. Transfer native token with transfer is no longer recommended. transferNativeTokenOut in GenericToken Lib doesn't accommodate that.

Impact

to avoid transfer failed but return false instead. token thats dont actually perform the transfer and return false are till counted as a correct transfer.

Code Snippet

https://github.com/sherlock-audit/2022-09-notional/blob/main/contracts-v2/contracts/internal/vaults/VaultConfiguration.sol#L454

https://github.com/sherlock-audit/2022-09-notional/blob/main/contracts-v2/contracts/internal/vaults/VaultConfiguration.sol#L638

https://github.com/sherlock-audit/2022-09-notional/blob/main/contracts-v2/contracts/internal/balances/protocols/GenericToken.sol#L23-L30

Tool used

Manual Review

Recommendation

implement check value in transferNativeTokenOut() from GenericToken lib using call() instead of transfer().
Example:
(bool succeeded, ) = account.call{value: _amount}("");
require(succeeded, "Transfer failed.");

Duplicate of #63

Arbitrary-Execution - Idiosyncratic fCash can prevent users from exiting a vault pre-maturity

Arbitrary-Execution

high

Idiosyncratic fCash can prevent users from exiting a vault pre-maturity

Summary

Idiosyncratic fCash can prevent users from exiting a vault prior to maturity

Vulnerability Detail

When exitVault in VaultAccountAction.sol is called prior to the maturity timestamp of a vault account position, lendToExitVault is called in order to 'lend' an fCash amount back to Notional. This in turn sets the tempCashBalance for a vault account which can then be paid off in a number of ways. Within lendToExitVault, executeTrade is called to determine the actual asset cash cost to lend the specified fCash amount. Before executeTrade crafts the trade to send to the Notional AMM, it checks to ensure the given maturity is valid via the checkValidMaturity function call in VaultConfiguration.sol:

function checkValidMaturity(...) ... {
    bool isIdiosyncratic;
    uint8 maxMarketIndex = CashGroup.getMaxMarketIndex(currencyId);
    (marketIndex, isIdiosyncratic) = DateTime.getMarketIndex(maxMarketIndex, maturity, blockTime);
    require(marketIndex <= maxBorrowMarketIndex, "Invalid Maturity");
    require(!isIdiosyncratic, "Invalid Maturity");
}

Notice that one of the checks ensures that the given maturity is not idiosyncratic. According to the Notional docs, idiosyncratic fCash occurs when an fCash asset's maturity does not correspond to an active liquidity pool. The Notional docs list an example of when this can occur:

For example, consider a user who took out a loan from the one-year liquidity pool. At the time of the next quarterly roll, that user's fCash would mature in nine months, and the nine-month liquidity pool would reset after the quarterly roll to be a new one-year liquidity pool. This would result in the user's nine-month fCash becoming idiosyncratic.

In the above example, a 12-month fCash maturity will be idiosyncratic for at least one quarter (3 months). Since the checkValidMaturity call reverts when it encounters an idiosyncratic fCash maturity, lendToExitVault, and by extension exitVault, will not work for the duration of time that a maturity is idiosyncratic.

Impact

For users who have vault account positions where the maturity may become idiosyncratic (maturity date of 1 year or greater), there will be some length of time where they will not be able to call exitVault. In the event that a vault is compromised or any other scenario where a user needs to exit prior to maturity but their maturity is idiosyncratic, the user will be unable to do so. This is true for all users of an idiosyncratic maturity, even if a user has not borrowed any fCash in their vault position.

Code Snippet

High-level call that reverts in lendToExitVault: https://github.com/notional-finance/contracts-v2/blob/cf05d8e3e4e4feb0b0cef2c3f188c91cdaac38e0/contracts/internal/vaults/VaultAccount.sol#L235

Underlying call that reverts in executeTrade: https://github.com/notional-finance/contracts-v2/blob/cf05d8e3e4e4feb0b0cef2c3f188c91cdaac38e0/contracts/internal/vaults/VaultConfiguration.sol#L935

Update the following values in scripts/config.py:

CurrencyDefaults = {
    ...
    "maxMarketIndex": 3,
    ...
}

nTokenDefaults = {
    "Deposit": [
        # Deposit shares
        [int(0.25e8), int(0.35e8), int(0.4e8)],
        # Leverage thresholds
        [int(0.80e9), int(0.80e9), int(0.81e9)],
    ],
    "Initialization": [
        # Annualized anchor rate
        [int(0.03e9), int(0.03e9), int(0.03e9)],
        # Target proportion
        [int(0.55e9), int(0.55e9), int(0.55e9)],
    ],
    ...
}

PoC (add to tests/stateful/vaults/test_vault_exit.py):

def test_exit_vault_idiosyncratic_maturity(environment, vault, accounts):
    environment.notional.updateVault(
        vault.address,
        get_vault_config(flags=set_flags(0, ENABLED=True), currencyId=2, maxBorrowMarketIndex=3),
        100_000_000e8,
    )

    # retrieve the 12-month maturity timestamp that the account will enter
    maturity = environment.notional.getActiveMarkets(2)[2][1]

    # accounts[1] enters a vault with a 12-month maturity and only deposits their own collateral
    # borrowing fCash is not required to enter a vault
    environment.notional.enterVault(
        accounts[1], vault.address, 25_000e18, maturity, 0, 0, "", {"from": accounts[1]}
    )
    accountPosition = environment.notional.getVaultAccount(accounts[1], vault)

    # block time is rolled 1 quarter
    chain.mine(1, timestamp=chain.time() + SECONDS_IN_QUARTER)
    # after 1 quarter each Notional market is re-initialized
    environment.notional.initializeMarkets(2, False)

    # due to some unforeseen circumstance (hack, poor vault strategy, etc.) accounts[1] attempts to
    # exit the vault prior to the maturity being settled and retrieve their collateral
    # despite not having borrowed any fCash, the maturity they entered into is now idiosyncratic and
    # thus exitVault will fail
    environment.notional.exitVault(
        accounts[1],
        vault.address,
        accounts[1],
        accountPosition["vaultShares"],
        0,
        0,
        "",
        {"from": accounts[1]},
    )

Tool used

Manual Review

Recommendation

exitVault requires a user to lend fCash via lendToExitVault in order to facilitate paying off any debts and exiting a vault. However, the actual function that repays the debt in exitVault is redeemWithDebtRepayment in VaultConfiguration.sol, which has logic to repay debts via the use of transferring underlying tokens from a user to the vault. Therefore, consider adding logic to exitVault that would enable users to directly exit a vault and pay off debt exclusively using underlying tokens, which would avoid the issue of attempting to execute a trade when their fCash maturity is idiosyncratic. Additionally, consider adding logic to lendToExitVault to return early when the passed-in fCash value is 0.

Jeiwan - Flawed decimals check could lock funds in a 2-token Balancer Strategy Vault

Jeiwan

medium

Flawed decimals check could lock funds in a 2-token Balancer Strategy Vault

Summary

A flawed check for the decimals of the secondary token in the 2-token Balancer Strategy Vault contract allows to deploy a vault that uses a token with more than 18 decimals. Such vault, however, won't be able to settle due to a decimals check in Stable2TokenOracleMath. Thus, funds will remain locked in the contract.

Vulnerability Detail

The MetaStable2TokenAuraVault contract inherits from TwoTokenPoolMixin. The strategy vault manages two tokens and deposits them in a Balancer vault. The strategy vault expects that both tokens have no more than 18 decimals:

  1. https://github.com/sherlock-audit/2022-09-notional/blob/main/leveraged-vaults/contracts/vaults/balancer/mixins/TwoTokenPoolMixin.sol#L58
  2. https://github.com/sherlock-audit/2022-09-notional/blob/main/leveraged-vaults/contracts/vaults/balancer/mixins/TwoTokenPoolMixin.sol#L65

However, the second check checks primaryDecimals, not secondaryDecimals.

During settlement, the oracle of a Balancer vault is called to calculate tokens amounts for redeemed BPT tokens. The oracle requires both tokens to have less than 19 decimals. Thus, if the secondary token has more than 18 tokens (which is allowed by the flawed check during deployment), the _getSpotPrice function would always revert, as well as the settlement function.

Impact

In the case when a vault is deployed and the secondary token is a token with more than 18 decimals, it won't be possible to settle the vault due to the price oracle of the related Balancer vault not allowing tokens with more than 18 decimals. Funds would remain locked in the strategy vault.

Code Snippet

https://github.com/sherlock-audit/2022-09-notional/blob/main/leveraged-vaults/contracts/vaults/balancer/mixins/TwoTokenPoolMixin.sol#L65
https://github.com/sherlock-audit/2022-09-notional/blob/main/leveraged-vaults/contracts/vaults/balancer/internal/math/Stable2TokenOracleMath.sol#L24

Tool used

Manual Review

Recommendation

It's recommended to fix the secondaryDecimals check in the constructor of TwoTokenPoolMixin to avoid such situations.

Duplicate of #88

joestakey - Accounts cannot raise their collateral if vault is paused

joestakey

medium

Accounts cannot raise their collateral if vault is paused

Summary

When a vault is paused, accounts cannot raise their collateral to avoid liquidation

Vulnerability Detail

VaultAccountAction.enterVault() is not only used by users who wish to borrow, it can also be used to raise their collateral ratio if they are close to liquidation by passing fCashToBorrow == 0. But if a vault is paused, the function will revert because of the check line 54.

Impact

When a vault is paused, a user close to liquidation will not be able to raise their collateral, which means they have no way to avoid liquidation as their loan approaches undercollateralization.

Code Snippet

https://github.com/notional-finance/contracts-v2/blob/cf05d8e3e4e4feb0b0cef2c3f188c91cdaac38e0/contracts/external/actions/VaultAccountAction.sol#L54

Tool used

Manual Review

Recommendation

VaultAccountAction.enterVault should check the vault enabled/disabled status only if fCash > 0 - ie in the case the account is borrowing.

+if (fCash > 0) {
54        require(vaultConfig.getFlag(VaultConfiguration.ENABLED), "Cannot Enter");
+}

Bnke0x0 - Malicious governance can use updateVault()/updateSecondaryBorrowCapacity() to steal WETH from buyers

Bnke0x0

medium

Malicious governance can use updateVault()/updateSecondaryBorrowCapacity() to steal WETH from buyers

Summary

A malicious or compromised governance can set the transfer gas cost to an unreasonable amount and steal approved WETH from buyers.

Vulnerability Detail

Impact

A malicious or compromised governance can set the transfer gas cost to an unreasonable amount and steal approved WETH from buyers.

Code Snippet

  1. https://github.com/sherlock-audit/2022-09-notional/blob/main/contracts-v2/contracts/external/actions/VaultAction.sol#L27-L36

            'function updateVault(
                     address vaultAddress,
                     VaultConfigStorage calldata vaultConfig,
                     uint80 maxPrimaryBorrowCapacity
                    ) external override onlyOwner {
                     VaultConfiguration.setVaultConfig(vaultAddress, vaultConfig);
                     VaultConfiguration.setMaxBorrowCapacity(vaultAddress, vaultConfig.borrowCurrencyId, maxPrimaryBorrowCapacity);
                     bool enabled = (vaultConfig.flags & VaultConfiguration.ENABLED) == VaultConfiguration.ENABLED;
                     emit VaultUpdated(vaultAddress, enabled, maxPrimaryBorrowCapacity);
                           }'
    
  2. https://github.com/sherlock-audit/2022-09-notional/blob/main/contracts-v2/contracts/external/actions/VaultAction.sol#L57-L79

            'function updateSecondaryBorrowCapacity(
                     address vaultAddress,
                     uint16 secondaryCurrencyId,
                     uint80 maxBorrowCapacity
                     ) external override onlyOwner {
                     VaultConfig memory vaultConfig = VaultConfiguration.getVaultConfigStateful(vaultAddress);
                     // Tokens with transfer fees create lots of issues with vault mechanics, we prevent them
                     // from being listed here.
                     Token memory assetToken = TokenHandler.getAssetToken(secondaryCurrencyId);
                     Token memory underlyingToken = TokenHandler.getUnderlyingToken(secondaryCurrencyId);
                     require(!assetToken.hasTransferFee && !underlyingToken.hasTransferFee); 
    
                     // The secondary borrow currency must be white listed on the configuration before we can set a max
                     // capacity.
                     require(
                     secondaryCurrencyId == vaultConfig.secondaryBorrowCurrencies[0] ||
                     secondaryCurrencyId == vaultConfig.secondaryBorrowCurrencies[1],
                     "Invalid Currency"
                     );
    
                     VaultConfiguration.setMaxBorrowCapacity(vaultAddress, secondaryCurrencyId, maxBorrowCapacity);
                     emit VaultUpdateSecondaryBorrowCapacity(vaultAddress, secondaryCurrencyId, maxBorrowCapacity);
    
                                    }'
    

Tool used

Manual Review

Recommendation

Set a sanity check in updateVault()/updateSecondaryBorrowCapacity() so governance can't set it to unreasonable value. Consider using timelock for setting governance settings.

Arbitrary-Execution - Idiosyncratic fCash can prevent a user from improving their collateral ratio

Arbitrary-Execution

high

Idiosyncratic fCash can prevent a user from improving their collateral ratio

Summary

Idiosyncratic fCash can prevent a user from improving their collateral ratio

Vulnerability Detail

enterVault in VaultAccountAction.sol can be used by users to instantiate a position on a vault, increase the borrow on a position in a vault, and decrease the borrow position on a vault by supplying a non-zero depositAmountExternal with a zero additional fCash borrow. Importantly, using enterVault to decrease a borrow position allows a user to decrease their collateral ratio and avoid a liquidation. Within enterVault, borrowAndEnterVault in VaultAccount.sol is ultimately the function that will borrow fCash as requested and set a user's vault account position post-calculations. Inside borrowAndEnterVault there is a check to ensure that if a user does not borrow any fCash that the maturity they are requesting to enter a position into is still valid:

if (fCashToBorrow > 0) {
    ...
} else {
    // Ensure that the maturity is a valid one if we are not borrowing (borrowing will fail)
    // against an invalid market.
    VaultConfiguration.checkValidMaturity(
        vaultConfig.borrowCurrencyId,
        maturity,
        vaultConfig.maxBorrowMarketIndex,
        block.timestamp
    );
}

Note that this check will happen so long as a user is not attempting to borrow fCash, i.e. fCashToBorrow = 0. Most notably this check will still happen even when a user has a valid maturity and the user is attempting to lower their collateral ratio by supplying external tokens to their position without borrowing more fCash.

The reason why this is problematic is due to the logic in checkValidMaturity:

function checkValidMaturity(...) ... {
    bool isIdiosyncratic;
    uint8 maxMarketIndex = CashGroup.getMaxMarketIndex(currencyId);
    (marketIndex, isIdiosyncratic) = DateTime.getMarketIndex(maxMarketIndex, maturity, blockTime);
    require(marketIndex <= maxBorrowMarketIndex, "Invalid Maturity");
    require(!isIdiosyncratic, "Invalid Maturity");
}

Notice that one of the checks ensures that the given maturity is not idiosyncratic. According to the Notional docs, idiosyncratic fCash occurs when an fCash asset's maturity does not correspond to an active liquidity pool. The Notional docs list an example of when this can occur:

For example, consider a user who took out a loan from the one-year liquidity pool. At the time of the next quarterly roll, that user's fCash would mature in nine months, and the nine-month liquidity pool would reset after the quarterly roll to be a new one-year liquidity pool. This would result in the user's nine-month fCash becoming idiosyncratic.

Therefore, when a maturity for a position is idiosyncratic, checkValidMaturity will revert. This will cause any function that uses checkValidMaturity to also revert, so borrowAndEnterVault and by extension enterVault will revert when a maturity is idiosyncratic.

Impact

For users who have vault account positions where the maturity may become idiosyncratic (maturity date of 1 year or greater), there will be some length of time where they will be unable to improve their collateral ratio using enterVault and thus are extremely susceptible to liquidations.

Code Snippet

https://github.com/notional-finance/contracts-v2/blob/cf05d8e3e4e4feb0b0cef2c3f188c91cdaac38e0/contracts/internal/vaults/VaultAccount.sol#L141-L159

Update the following values in scripts/config.py:

CurrencyDefaults = {
    ...
    "maxMarketIndex": 3,
    ...
}

nTokenDefaults = {
    "Deposit": [
        # Deposit shares
        [int(0.25e8), int(0.35e8), int(0.4e8)],
        # Leverage thresholds
        [int(0.80e9), int(0.80e9), int(0.81e9)],
    ],
    "Initialization": [
        # Annualized anchor rate
        [int(0.03e9), int(0.03e9), int(0.03e9)],
        # Target proportion
        [int(0.55e9), int(0.55e9), int(0.55e9)],
    ],
    ...
}

PoC (add to tests/stateful/vaults/test_vault_entry.py):

def test_reenter_vault_idiosyncratic_maturity(environment, vault, accounts):
    environment.notional.updateVault(
        vault.address,
        get_vault_config(flags=set_flags(0, ENABLED=True), currencyId=2, maxBorrowMarketIndex=3),
        100_000_000e8,
    )

    # retrieve the 12-month maturity timestamp that the account will enter
    maturity = environment.notional.getActiveMarkets(1)[2][1]

    # accounts[1] enters a vault with a 12-month maturity
    environment.notional.enterVault(
        accounts[1], vault.address, 25_000e18, maturity, 0, 0, "", {"from": accounts[1]}
    )

    # block time is rolled 1 quarter
    chain.mine(1, timestamp=chain.time() + SECONDS_IN_QUARTER)
    # after 1 quarter each Notional market is re-initialized
    environment.notional.initializeMarkets(2, False)

    # 1 year maturity is now idiosyncratic, attempt to enter the vault again
    environment.notional.enterVault(
        accounts[1], vault.address, 25_000e18, maturity, 0, 0, "", {"from": accounts[1]}
    )

Tool used

Manual Review

Recommendation

Consider adding another check before calling checkValidMaturity in borrowAndEnterVault that ensures a maturity is only checked when a user is entering into a position for the first time i.e. vaultAccount.maturity == 0. This is safe to add even when rollVaultPosition in VaultAccountAction.sol is occurring because rollVaultPosition forces a user to borrow a non-zero fCash amount into the new position thus it will always enter the if block in borrowAndEnterVault and therefore check that the new maturity is valid.

Duplicate of #21

0x52 - TradingUtils#_executeTrade contains logical error that can cause loss of funds if trade.buyToken is ETH or WETH

0x52

high

TradingUtils#_executeTrade contains logical error that can cause loss of funds if trade.buyToken is ETH or WETH

Summary

TradingUtils#_executeTrade contains a logical error that leads to incorrect deposits (withdrawals) of ETH (WETH) balance when the buyToken is WETH (ETH). These deposits (withdrawals) are treated as swap outputs which can result in either valid transactions reverting or failure of TradingUtils#_executeTrade to enforce desired slippage bounds. If this error occurs when using ZeroEx, the user may lose part or all of their slippage protection which can cause loss of user funds.

Vulnerability Detail

TradingUtils.sol#L140-L159

if (trade.buyToken == address(Deployments.WETH)) {
    if (address(this).balance > preTradeBalance) {
        // If the caller specifies that they want to receive Deployments.WETH but we have received ETH,
        // wrap the ETH to Deployments.WETH.
        uint256 depositAmount;
        unchecked { depositAmount = address(this).balance - preTradeBalance; }
        Deployments.WETH.deposit{value: depositAmount}();
    }
} else if (trade.buyToken == Deployments.ETH_ADDRESS) {
    uint256 postTradeBalance = IERC20(address(Deployments.WETH)).balanceOf(address(this));
    if (postTradeBalance > preTradeBalance) {
        // If the caller specifies that they want to receive ETH but we have received Deployments.WETH,
        // unwrap the Deployments.WETH to ETH.
        uint256 withdrawAmount;
        unchecked { withdrawAmount = postTradeBalance - preTradeBalance; }
        Deployments.WETH.withdraw(withdrawAmount);
    }
}

The above lines are executed after the contract call in TradingUtils#_executeTrade. The purpose it to deposit (withdraw) ETH (WETH) to match the token requested by the user. The logic contains an error and will always result in the user's entire balance of ETH (WETH) being deposited (withdrawn) when the buyToken is WETH (ETH). When the sell token isn't ETH or WETH preTradeBalance will not be initialized correctly and will be 0. If the user had any ETH (WETH) before the trade it won't be accounted for by preTradeBalance and will be deposited (withdrawn) after the call. This will artificially inflate the postTradeBuyBalance in TradingUtils#_executeInternal potentially causing several issues:

  1. Exact out swaps will revert because in TradingUtils#_postValidate amountReceived will be greater than trade.amount
  2. Slippage protection enforced by TradingUtils#_postValidate will not function correctly because part of the balance wasn't from the trade. For most exchanges, this is not an issue because slippage bounds are enforced by the swap router. When using ZeroEx, slippage bounds are not enforced on the swap level and instead rely on the protection from TradingUtils#_postValidate. The result is that when using ZeroEx with a buy token of ETH or WETH, intended slippage protection can be lost, resulting in loss of user funds.

Example:

_executeTrade is called with trade.sellToken = USDC and trade.buyToken = WETH. sellToken is USDC so L127 and L132 return false and preTradeBalance = 0. Assuming the user has a balance of ETH from before the trade. postTradeBalance now returns their ETH balance. Since preTradeBalance = 0, their full balance will be deposited into WETH. When _getBalances queries their WETH balance it will count the deposited ETH balance as value obtained from the swap, even though it was just their own balance deposited.

Dev comments in ZeroExAdapter state: "executeTrade validates pre and post trade balances and also sets and revokes all approvals. We are also only calling a trusted zero ex proxy in this case. Therefore no order validation is done to allow for flexibility." By design, when using ZeroExAdapter slippage values are not enforces as they are with other adapters, leaving validation of slippage exclusively to _executeTrade. As shown above, _executeTrade slippage checks can fail leaving ZeroEx swaps completely vulnerable to sandwich attacks which could cause loss of funds for users

Impact

Loss of user funds or valid transactions reverting

Code Snippet

TradingUtils.sol#L118-L160

Tool used

Manual Review

Recommendation

If buyToken is ETH/WETH the preTradeBalance should be logged as shown below:

    if (trade.sellToken == address(Deployments.WETH) && spender == Deployments.ETH_ADDRESS) {
        preTradeBalance = address(this).balance;
        // Curve doesn't support Deployments.WETH (spender == address(0))
        uint256 withdrawAmount = _isExactIn(trade) ? trade.amount : trade.limit;
        Deployments.WETH.withdraw(withdrawAmount);
    } else if (trade.sellToken == Deployments.ETH_ADDRESS && spender != Deployments.ETH_ADDRESS) {
        preTradeBalance = IERC20(address(Deployments.WETH)).balanceOf(address(this));
        // UniswapV3 doesn't support ETH (spender != address(0))
        uint256 depositAmount = _isExactIn(trade) ? trade.amount : trade.limit;
        Deployments.WETH.deposit{value: depositAmount }();
+   } else if (trade.buyToken == address(Deployments.WETH) {
+       preTradeBalance = address(this).balance;
+   } else if (trade.buyToken == Deployments.ETH_ADDRESS) {
+       preTradeBalance = IERC20(address(Deployments.WETH)).balanceOf(address(this));
    }

Duplicate of #110

Waze - Low-level delegatecall function may not revert as it fails.

Waze

medium

Low-level delegatecall function may not revert as it fails.

Summary

The low-level delegatecall will not notice anything went wrong. This may result in user funds lost because funds were transferred. Low-level delegatecall fails but doesn't revert.

Vulnerability Detail

It is stated in the solidity documentation: https://docs.soliditylang.org/en/v0.8.16/control-structures.html#error-handling-assert-require-revert-and-exceptions.
In the warning part, it is written as: "The low-level functions call, delegatecall, and staticcall return true as their first return value if the account called is non-existent, as part of the design of the EVM. Account existence must be checked prior to calling if needed."

Impact

The low-level function "delegatecall" may return true for the boolean parameter "success" even if it was a failure. This could result in fund loss.

Code Snippet

https://github.com/sherlock-audit/2022-09-notional/blob/main/leveraged-vaults/contracts/trading/TradeHandler.sol#L19-L22
https://github.com/sherlock-audit/2022-09-notional/blob/main/leveraged-vaults/contracts/trading/TradeHandler.sol#L36-L37

Tool used

Manual Review

Recommendation

Checking the address before the delegatecall occurs

supernova - Lack of storage Gap in Upgradeable contracts

supernova

medium

Lack of storage Gap in Upgradeable contracts

Summary

While creating Upgradeable contracts, special care must be taken when dealing with storage slots. When the contract is upgraded in the future, the storage slots specifications remain same .

Vulnerability Detail

It will hamper the ability of the protocol to upgrade to new contract and allow new dex.

Impact

This can make a critical impact on the protocol functioning as storage slots can be mixed up .

Code Snippet

https://github.com/sherlock-audit/2022-09-notional/blob/main/leveraged-vaults/contracts/trading/TradingModule.sol#L24

Tool used

Vscode
Manual Review

Recommendation

To avoid collision with existing storage slots, a gap in storage is recommended for future upgrades.

Add a uint array with private visibility .
Openzeppelin 's article on this

uint[40] private _gap;

Duplicate of #64

Arbitrary-Execution - `ALLOW_REENTRANCY` logic is dangerous and should be carefully considered

Arbitrary-Execution

medium

ALLOW_REENTRANCY logic is dangerous and should be carefully considered

Summary

ALLOW_REENTRANCY logic is dangerous and should be carefully considered

Vulnerability Detail

Strategy vaults are designed to earn profit by interacting with other market makers/protocols. Notional also allows vaults to interact with other components of the Notional protocol. To accomplish this, Notional added an ALLOW_REENTRANCY flag and logic that resets the reentrancyStatus mutex lock to allow vaults to call back into the Notional protocol. If reentrancyStatus was not cleared, Notional protocol-based strategy vaults would not be possible as the reentrancyStatus lock is shared across all functions within the Notional protocol as a result of Notional's router-based proxy (i.e. Router.sol). However, because reentrancyStatus is the lock for the entire Notional protocol, clearing it allows full access to all subsystems within Notional and not just the AMM. Therefore, if a user was granted execution via a reentrant token (not ether as Notional specifically uses transfer to avoid reentrancy) or if a vault was granted execution to interact with the Notional protocol, subsystems within Notional that are susceptible to reentrancy would no longer be protected. This is especially relevant to VaultAccountAction.sol as the functions in that contract do not follow the checks-effects-interactions pattern and thus are extremely susceptible to reentrancy attacks.

Impact

Should a user or a vault be allowed to reenter into the Notional protocol, subsystems that were once protected by the reentrancyStatus storage variable may be susceptible to reentrancy which could critically impact the protocol.

Code Snippet

Example of logic that allows reentrancy when the ALLOW_REENTRANCY flag is set:
https://github.com/notional-finance/contracts-v2/blob/cf05d8e3e4e4feb0b0cef2c3f188c91cdaac38e0/contracts/external/actions/VaultAccountAction.sol#L48-L51

Tool used

Manual Review

Recommendation

Notional should carefully consider the risks of allowing reentrant calls back into the protocol.

Bnke0x0 - Overpayment of native ETH is not refunded to the buyer

Bnke0x0

high

Overpayment of native ETH is not refunded to the buyer

Summary

Overpayment of native ETH is not refunded to the buyer

Vulnerability Detail

Impact

VaultConfiguration accepts payments in native ETH, but does not return overpayments to the buyer. Overpayments are likely in the case of auction orders priced in native ETH.

The end user is likely to send more ETH than the final calculated price in order to ensure their transaction succeeds, since price is a function of block.timestamp, and the user cannot predict the timestamp at which their transaction will be included.

Code Snippet

https://github.com/sherlock-audit/2022-09-notional/blob/main/contracts-v2/contracts/internal/vaults/VaultConfiguration.sol#L449-L452

                        ' require(!underlyingToken.hasTransferFee);
                               if (underlyingToken.tokenType == TokenType.Ether) {
                                   require(msg.value == depositAmountExternal, "Invalid ETH"); '

Tool used

Manual Review

Recommendation

Calculate and refund overpayment amounts to callers.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.