2022-09-notional-judging's People
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
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
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:
-
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.
-
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
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 vaultSharesToRedeem
when 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
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
-
'require(vaultConfig.minCollateralRatio <= collateralRatio, "Insufficient Collateral");'
-
' require(balanceTransferred >= underlyingExternalToRepay, "Insufficient Repay");'
-
'require(residualRequired <= msg.value, "Insufficient repayment");'
-
' require(amountTransferred >= underlyingExternalToRepay, "Insufficient repayment");'
Tool used
Manual Review
Recommendation
Consider changing '>=' to '=='
-
'require(vaultConfig.minCollateralRatio == collateralRatio, "Insufficient Collateral");'
-
' require(balanceTransferred == underlyingExternalToRepay, "Insufficient Repay");'
-
'require(residualRequired == msg.value, "Insufficient repayment");'
-
' 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
Tool used
Manual Review
Recommendation
Ensure that the exchange rate is not too old.
Duplicate of #133
GimelSec - -
GimelSec
unlabeled
-
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);
evertkors - test
evertkors
unlabeled
test
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
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
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:
- The liquidator has enough of the underlying tokens necessary for liquidation.
- The liquidator has approved the Notional proxy address with enough tokens necessary for liquidation.
- 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
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
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
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
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
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 togetPoolShare
return values being lower asvaultState.totalVaultShares
is higher- In
VaultConfiguration.calculateCollateralRatio()
,vaultShareValue
is lower , which also results innetAssetValue
being lower. If too low: - either the account is insolvent
- if high enough to be
> 0
, the return valuecollateralRatio
will trigger this check to pass, meaning that the account can be liquidated.
Code Snippet
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
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.
GimelSec - -
GimelSec
unlabeled
-
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
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
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
Tool used
Manual Review
Recommendation
Change UNIV2_ROUTER to the address of the V2 router:
IUniV2Router2 internal constant UNIV2_ROUTER = IUniV2Router2(0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D);
GimelSec - -
GimelSec
unlabeled
-
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
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
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
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
Tool used
Manual Review
Recommendation
There are two possible solutions:
- Change the way that target and sender are set to match the implementation in UniV3Adapter
- 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
-
UpdateVault () updates the vaultConfig file
-
Set maxBorrowMarketIndex to a small value
-
When a user calls borrowAndEnterVault() it will run to checkValidMaturity()
-
Require (marketIndex <= maxBorrowMarketIndex, "Invalid Maturity"); The judgment may fail, causing the function to fail
Impact
MaxBorrowMarketIndex does not limit borrowAndEnterVault() DOS
Code Snippet
// 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
// 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
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
:
/// @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 whilechainlinkWeightedPrice == 0
, thebalancerWeightedPrice
is weighted withoracleContext.balancerOracleWeight
.
The latter case is possible as oracleContext.balancerOracleWeight
is set via BalancerVaultStorage's setStrategyVaultSettings, that controls its value with a caller-supplied argument:
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
:
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:
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
-
'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"); }'
-
'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
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
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
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
GimelSec - -
GimelSec
unlabeled
-
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:
- https://github.com/sherlock-audit/2022-09-notional/blob/main/leveraged-vaults/contracts/vaults/balancer/mixins/TwoTokenPoolMixin.sol#L58
- 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
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
-
'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); }'
-
'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
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
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:
- Exact out swaps will revert because in TradingUtils#_postValidate amountReceived will be greater than trade.amount
- 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
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
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
' 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
A declarative, efficient, and flexible JavaScript library for building user interfaces.
-
Vue.js
🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.
-
Typescript
TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
-
TensorFlow
An Open Source Machine Learning Framework for Everyone
-
Django
The Web framework for perfectionists with deadlines.
-
Laravel
A PHP framework for web artisans
-
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.
-
Visualization
Some thing interesting about visualization, use data art
-
Game
Some thing interesting about game, make everyone happy.
Recommend Org
-
Facebook
We are working to build community through open source technology. NB: members must have two-factor auth.
-
Microsoft
Open source projects and samples from Microsoft.
-
Google
Google ❤️ Open Source for everyone.
-
Alibaba
Alibaba Open Source for everyone
-
D3
Data-Driven Documents codes.
-
Tencent
China tencent open source team.