Platform: Code4rena
Start Date: 07/06/2022
Pot Size: $75,000 USDC
Total HM: 11
Participants: 77
Period: 7 days
Judge: gzeon
Total Solo HM: 7
Id: 124
League: ETH
Rank: 3/77
Findings: 4
Award: $9,998.19
π Selected for report: 3
π Solo Findings: 1
π Selected for report: xiaoming90
Also found by: GreyArt, berndartmueller, jonah1005, kenzo, minhquanym
1806.2399 USDC - $1,806.24
https://github.com/code-423n4/2022-06-notional-coop/blob/6f8c325f604e2576e2fe257b6b57892ca181509a/notional-wrapped-fcash/contracts/wfCashERC4626.sol#L52 https://github.com/code-423n4/2022-06-notional-coop/blob/6f8c325f604e2576e2fe257b6b57892ca181509a/notional-wrapped-fcash/contracts/wfCashERC4626.sol#L134
Per EIP 4626's Security Considerations (https://eips.ethereum.org/EIPS/eip-4626)
Finally, ERC-4626 Vault implementers should be aware of the need for specific, opposing rounding directions across the different mutable and view methods, as it is considered most secure to favor the Vault itself during calculations over its users:
- If (1) itβs calculating how many shares to issue to a user for a certain amount of the underlying tokens they provide or (2) itβs determining the amount of the underlying tokens to transfer to them for returning a certain amount of shares, it should round down.
- If (1) itβs calculating the amount of shares a user has to supply to receive a given amount of the underlying tokens or (2) itβs calculating the amount of underlying tokens a user has to provide to receive a certain amount of shares, it should round up.
Thus, the result of the previewMint
and previewWithdraw
should be rounded up.
The current implementation of convertToShares
function will round down the number of shares returned due to how solidity handles Integer Division. ERC4626 expects the returned value of convertToShares
to be rounded down. Thus, this function behaves as expected.
function convertToShares(uint256 assets) public view override returns (uint256 shares) { uint256 supply = totalSupply(); if (supply == 0) { // Scales assets by the value of a single unit of fCash uint256 unitfCashValue = _getPresentValue(uint256(Constants.INTERNAL_TOKEN_PRECISION)); return (assets * uint256(Constants.INTERNAL_TOKEN_PRECISION)) / unitfCashValue; } return (assets * totalSupply()) / totalAssets(); }
ERC 4626 expects the result returned from previewWithdraw
function to be rounded up. However, within the previewWithdraw
function, it calls the convertToShares
function. Recall earlier that the convertToShares
function returned a rounded down value, thus previewWithdraw
will return a rounded down value instead of round up value. Thus, this function does not behave as expected.
function previewWithdraw(uint256 assets) public view override returns (uint256 shares) { if (hasMatured()) { shares = convertToShares(assets); } else { // If withdrawing non-matured assets, we sell them on the market (i.e. borrow) (uint16 currencyId, uint40 maturity) = getDecodedID(); (shares, /* */, /* */) = NotionalV2.getfCashBorrowFromPrincipal( currencyId, assets, maturity, 0, block.timestamp, true ); } }
previewWithdraw
and previewMint
functions rely on NotionalV2.getfCashBorrowFromPrincipal
and NotionalV2.getDepositFromfCashLend
functions. Due to the nature of time-boxed contest, I was unable to verify if NotionalV2.getfCashBorrowFromPrincipal
and NotionalV2.getDepositFromfCashLend
functions return a rounded down or up value. If a rounded down value is returned from these functions, previewWithdraw
and previewMint
functions would not behave as expected.
Other protocols that integrate with Notional's fCash wrapper might wrongly assume that the functions handle rounding as per ERC4626 expectation. Thus, it might cause some intergration problem in the future that can lead to wide range of issues for both parties.
Ensure that the rounding of vault's functions behave as expected. Following are the expected rounding direction for each vault function:
previewMint(uint256 shares) - Round Up β¬
previewWithdraw(uint256 assets) - Round Up β¬
previewRedeem(uint256 shares) - Round Down β¬
previewDeposit(uint256 assets) - Round Down β¬
convertToAssets(uint256 shares) - Round Down β¬
convertToShares(uint256 assets) - Round Down β¬
previewMint
returns the amount of assets that would be deposited to mint specific amount of shares. Thus, the amount of assets must be rounded up, so that the vault won't be shortchanged.
previewWithdraw
returns the amount of shares that would be burned to withdraw specific amount of asset. Thus, the amount of shares must to be rounded up, so that the vault won't be shortchanged.
Following is the OpenZeppelin's vault implementation for rounding reference:
Alternatively, if such alignment of rounding could not be achieved due to technical limitation, at the minimum, document this limitation in the comment so that the developer performing the integration is aware of this.
#0 - jeffywu
2022-06-15T14:27:59Z
Duplicate #143
#1 - gzeoneth
2022-06-26T13:36:12Z
Judging this and all duplicate regarding EIP4626 implementation as High Risk.
EIP4626 is aimed to create a consistent and robust implementation patterns for Tokenized Vaults. A slight deviation from 4626 would broke composability and potentially lead to loss of fund (POC in https://github.com/code-423n4/2022-06-notional-coop-findings/issues/88 can be an example). It is counterproductive to implement EIP4626 but does not conform to it fully. Especially it does seems that most of the time deposit
would be successful but not withdraw
, making it even more dangerous when an immutable consumer application mistakenly used the wfcash contract.
π Selected for report: xiaoming90
Also found by: berndartmueller
2477.6953 USDC - $2,477.70
https://github.com/code-423n4/2022-06-notional-coop/blob/6f8c325f604e2576e2fe257b6b57892ca181509a/index-coop-notional-trade-module/contracts/protocol/modules/v1/NotionalTradeModule.sol#L309 https://github.com/code-423n4/2022-06-notional-coop/blob/6f8c325f604e2576e2fe257b6b57892ca181509a/index-coop-notional-trade-module/contracts/protocol/modules/v1/NotionalTradeModule.sol#L385
Whenever a setToken is issued or redeemed, the moduleIssueHook
and moduleRedeemHook
will be triggered. These two hooks will in turn call the _redeemMaturedPositions
function to ensure that no matured fCash positions remain in the Set by redeeming any matured fCash position.
/** * @dev Hook called once before setToken issuance * @dev Ensures that no matured fCash positions are in the set when it is issued */ function moduleIssueHook(ISetToken _setToken, uint256 /* _setTokenAmount */) external override onlyModule(_setToken) { _redeemMaturedPositions(_setToken); } /** * @dev Hook called once before setToken redemption * @dev Ensures that no matured fCash positions are in the set when it is redeemed */ function moduleRedeemHook(ISetToken _setToken, uint256 /* _setTokenAmount */) external override onlyModule(_setToken) { _redeemMaturedPositions(_setToken); }
The _redeemMaturedPositions
will loop through all its fCash positions and attempts to redeem any fCash position that has already matured. However, if one of the fCash redemptions fails, it will cause the entire function to revert. If this happens, no one could purchase or redeem the setToken because moduleIssueHook
and modileRedeemHook
hooks will revert every single time. Thus, the setToken issuance and redemption will stop working entirely and this setToken can be considered "bricked".
/** * @dev Redeem all matured fCash positions for the given SetToken */ function _redeemMaturedPositions(ISetToken _setToken) internal { ISetToken.Position[] memory positions = _setToken.getPositions(); uint positionsLength = positions.length; bool toUnderlying = redeemToUnderlying[_setToken]; for(uint256 i = 0; i < positionsLength; i++) { // Check that the given position is an equity position if(positions[i].unit > 0) { address component = positions[i].component; if(_isWrappedFCash(component)) { IWrappedfCashComplete fCashPosition = IWrappedfCashComplete(component); if(fCashPosition.hasMatured()) { (IERC20 receiveToken,) = fCashPosition.getToken(toUnderlying); if(address(receiveToken) == ETH_ADDRESS) { receiveToken = weth; } uint256 fCashBalance = fCashPosition.balanceOf(address(_setToken)); _redeemFCashPosition(_setToken, fCashPosition, receiveToken, fCashBalance, 0); } } } } }
User will not be able to purchase or redeem the setToken. User's fund will stuck in the SetToken Contract. Unable to remove matured fCash positions from SetToken and update positions of its asset token.
This is a problem commonly encountered whenever a method of a smart contract calls another contract β you cannot rely on the other contract to work 100% of the time, and it is dangerous to assume that the external call will always be successful.
It is recommended to:
Consider alternate method of updating the asset position so that the SetToken's core functions (e.g. issuance and redemption) will not be locked if one of the matured fCash redemptions fails.
Evaluate if _redeemMaturedPositions
really need to be called during SetToken's issuance and redemption. If not, consider removing them from the hooks, so that any issue or revert within _redeemMaturedPositions
won't cause the SetToken's issuance and redemption functions to stop working entirely.
Consider implementing additional function to give manager/user an option to specify a list of matured fCash positions to redeem instead of forcing them to redeem all matured fCash positions at one go.
#0 - ckoopmann
2022-06-15T05:19:59Z
The _isWrappedFCash(component)
should make sure that these calls are only executed on valid wrappedfCash instances deployed from the configured factory.
The issue does not outline a practical scenario / test case where this issue would actually arise. If it did the manager could probably still remove / redeem the component either via this module, or one of the other Trade modules.
However I'll have to look into this if I can find a scenario where this would actually arise. I will also consider making this redeem step optional, but I will have to think more about this as this might introduce other more serious issues.
#1 - ckoopmann
2022-06-16T01:10:48Z
@jeffywu Do you see any scenario where the redemption of a matured fCash position might fail in this context`?
EDIT: Just noticed that https://github.com/code-423n4/2022-06-notional-coop-findings/issues/226 mentions (among others) the scenario of USDC tokens being blocked which I guess is unlikely but outside of our control. This makes me think we might want to make the redemption of matured tokens during issuance / redemption optional.
#2 - ckoopmann
2022-06-16T01:29:17Z
Changed label from acknowledged to confirmed, since on second thought I think we likely want to adpot some kind of mitigation strategy for this. However still tentative / unclear which strategy we want to adopt.
π Selected for report: xiaoming90
5505.9897 USDC - $5,505.99
https://github.com/code-423n4/2022-06-notional-coop/blob/6f8c325f604e2576e2fe257b6b57892ca181509a/index-coop-notional-trade-module/contracts/protocol/modules/v1/NotionalTradeModule.sol#L418 https://github.com/code-423n4/2022-06-notional-coop/blob/6f8c325f604e2576e2fe257b6b57892ca181509a/index-coop-notional-trade-module/contracts/protocol/modules/v1/NotionalTradeModule.sol#L493
Whenever _mintFCashPosition
function is called to mint new fCash position, the contract will call the _approve
function to set the allowance to _maxSendAmount
so that the fCash Wrapper contact can pull the payment tokens from the SetToken contract during minting.
function _mintFCashPosition( ISetToken _setToken, IWrappedfCashComplete _fCashPosition, IERC20 _sendToken, uint256 _fCashAmount, uint256 _maxSendAmount ) internal returns(uint256 sentAmount) { if(_fCashAmount == 0) return 0; bool fromUnderlying = _isUnderlying(_fCashPosition, _sendToken); _approve(_setToken, _fCashPosition, _sendToken, _maxSendAmount); uint256 preTradeSendTokenBalance = _sendToken.balanceOf(address(_setToken)); uint256 preTradeReceiveTokenBalance = _fCashPosition.balanceOf(address(_setToken)); _mint(_setToken, _fCashPosition, _maxSendAmount, _fCashAmount, fromUnderlying) ..SNIP.. }
Note that _maxSendAmount
is the maximum amount of payment tokens that is allowed to be consumed during minting. This is not the actual amount of payment tokens consumed during the minting process. Thus, after the minting, there will definitely be some residual allowance since it is unlikely that the fCash wrapper contract will consume the exact maximum amount during minting.
The following piece of code shows that having some residual allowance is expected. The _approve
function will not set the allowance unless there is insufficient allowance.
/** * @dev Approve the given wrappedFCash instance to spend the setToken's sendToken */ function _approve( ISetToken _setToken, IWrappedfCashComplete _fCashPosition, IERC20 _sendToken, uint256 _maxAssetAmount ) internal { if(IERC20(_sendToken).allowance(address(_setToken), address(_fCashPosition)) < _maxAssetAmount) { bytes memory approveCallData = abi.encodeWithSelector(_sendToken.approve.selector, address(_fCashPosition), _maxAssetAmount); _setToken.invoke(address(_sendToken), 0, approveCallData); } }
Having residual allowance increases the risk of the asset tokens being stolen from the SetToken contract. SetToken contract is where all the tokens/assets are held. If the Notional's fCash wrapper contract is compromised, it will allow the compromised fCash wrapper contract to withdraw funds from the SetToken contract due to the residual allowance.
Note that Notional's fCash wrapper contract is not totally immutable, as it is a upgradeable contract. This is an additional risk factor to be considered. If the Notional's deployer account is compromised, the attacker could upgrade the Notional's fCash wrapper contract to a malicious one to withdraw funds from the Index Coop's SetToken contract due to the residual allowance.
Index Coop and Notional are two separate protocols and teams. Thus, it is a good security practice not to place any trust on external party wherever possible to ensure that if one party is compromised, it won't affect the another party. Thus, there should not be any residual allowance that allows Notional's contract to withdraw fund from Index Coop's contract in any circumstance.
In the worst case scenario, a "lazy" manager might simply set the _maxAssetAmount
to type(uint256).max
. Thus, this will result in large amount of residual allowance left, and expose the SetToken contract to significant risk.
Approve the allowance on-demand whenever _mintFCashPosition
is called, and reset the allowance back to zero after each minting process to eliminate any residual allowance.
function _mintFCashPosition( ISetToken _setToken, IWrappedfCashComplete _fCashPosition, IERC20 _sendToken, uint256 _fCashAmount, uint256 _maxSendAmount ) internal returns(uint256 sentAmount) { if(_fCashAmount == 0) return 0; bool fromUnderlying = _isUnderlying(_fCashPosition, _sendToken); _approve(_setToken, _fCashPosition, _sendToken, _maxSendAmount); uint256 preTradeSendTokenBalance = _sendToken.balanceOf(address(_setToken)); uint256 preTradeReceiveTokenBalance = _fCashPosition.balanceOf(address(_setToken)); _mint(_setToken, _fCashPosition, _maxSendAmount, _fCashAmount, fromUnderlying) ..SNIP.. + // Reset the allowance back to zero after minting + _approve(_setToken, _fCashPosition, _sendToken, 0); }
Update the _approve
accordingly to remove the if-statement related to residual allowance.
function _approve( ISetToken _setToken, IWrappedfCashComplete _fCashPosition, IERC20 _sendToken, uint256 _maxAssetAmount ) internal { - if(IERC20(_sendToken).allowance(address(_setToken), address(_fCashPosition)) < _maxAssetAmount) { bytes memory approveCallData = abi.encodeWithSelector(_sendToken.approve.selector, address(_fCashPosition), _maxAssetAmount); _setToken.invoke(address(_sendToken), 0, approveCallData); - } }
#0 - ckoopmann
2022-06-15T05:25:27Z
This looks like a prudent suggestion. Will review and potentially adopt the suggestion mitigation.
π Selected for report: berndartmueller
Also found by: 0x1f8b, 0x29A, 0xDjango, 0xNazgul, 0xNineDec, 0xf15ers, 0xkatana, 0xmint, Bronicle, Chom, Cityscape, Deivitto, Funen, GimelSec, GreyArt, IllIllI, JC, Lambda, Meera, Nethermind, Picodes, PierrickGT, Ruhum, Sm4rty, Tadashi, TerrierLover, TomJ, Trumpero, Waze, _Adam, antonttc, ayeslick, c3phas, catchup, cccz, cloudjunky, cryptphi, csanuragjain, delfin454000, dipp, ellahi, fatherOfBlocks, hake, hansfriese, hyh, joestakey, jonah1005, kenzo, minhquanym, oyc_109, sach1r0, saian, simon135, slywaters, sorrynotsorry, sseefried, unforgiven, xiaoming90, z3s, zzzitron
208.261 USDC - $208.26
The wfCashLogic.mintViaUnderlying
and wfCashLogic.mintViaAsset
functions only accept four (4) parameters.
function mintViaUnderlying( uint256 depositAmountExternal, uint88 fCashAmount, address receiver, uint32 minImpliedRate ) external override function mintViaAsset( uint256 depositAmountExternal, uint88 fCashAmount, address receiver, uint32 minImpliedRate ) external override
However, within the NotionalTradeModule._mint
, it attempts to call the wfCashLogic.mintViaUnderlying
and wfCashLogic.mintViaAsset
functions with five (5) parameters. For consistency, remove the last parameter from the ABI encoding as shown below:
function _mint( ISetToken _setToken, IWrappedfCashComplete _fCashPosition, uint256 _maxAssetAmount, uint256 _fCashAmount, bool _fromUnderlying ) internal { uint32 minImpliedRate = 0; bytes4 functionSelector = _fromUnderlying ? _fCashPosition.mintViaUnderlying.selector : _fCashPosition.mintViaAsset.selector; bytes memory mintCallData = abi.encodeWithSelector( functionSelector, _maxAssetAmount, uint88(_fCashAmount), address(_setToken), minImpliedRate, - _fromUnderlying ); _setToken.invoke(address(_fCashPosition), 0, mintCallData); }
For defence-in-depth purpose, it is recommended to perform additional validation against the amount that the user is attempting to deposit, mint, withdraw and redeem to ensure that the submitted amount is valid.
function deposit(uint256 assets, address receiver) public override returns (uint256) { + require(assets <= maxDeposit(receiver), "deposit more than max"); uint256 shares = previewDeposit(assets); // Will revert if matured _mintInternal(assets, _safeUint88(shares), receiver, 0, true); emit Deposit(msg.sender, receiver, assets, shares); return shares; }
function mint(uint256 shares, address receiver) public override returns (uint256) { + require(shares <= maxMint(receiver), "mint more than max"); uint256 assets = previewMint(shares); // Will revert if matured _mintInternal(assets, _safeUint88(shares), receiver, 0, true); emit Deposit(msg.sender, receiver, assets, shares); return assets; }
function withdraw( uint256 assets, address receiver, address owner ) public override returns (uint256) { + require(assets <= maxWithdraw(owner), "withdraw more than max"); uint256 shares = previewWithdraw(assets); if (msg.sender != owner) { _spendAllowance(owner, msg.sender, shares); } _redeemInternal(shares, receiver, owner); emit Withdraw(msg.sender, receiver, owner, assets, shares); return shares; }
function redeem( uint256 shares, address receiver, address owner ) public override returns (uint256) { + require(shares <= maxRedeem(owner), "redeem more than max"); // It is more accurate and gas efficient to check the balance of the // receiver here than rely on the previewRedeem method. uint256 balanceBefore = IERC20(asset()).balanceOf(receiver); if (msg.sender != owner) { _spendAllowance(owner, msg.sender, shares); } _redeemInternal(shares, receiver, owner); uint256 balanceAfter = IERC20(asset()).balanceOf(receiver); uint256 assets = balanceAfter - balanceBefore; emit Withdraw(msg.sender, receiver, owner, assets, shares); return assets; }
Not calling approve(0) first might cause the protocol to be vulnerable to Front-Run/Double-Spend Attack.
function _approve( ISetToken _setToken, IWrappedfCashComplete _fCashPosition, IERC20 _sendToken, uint256 _maxAssetAmount ) internal { if(IERC20(_sendToken).allowance(address(_setToken), address(_fCashPosition)) < _maxAssetAmount) { bytes memory approveCallData = abi.encodeWithSelector(_sendToken.approve.selector, address(_fCashPosition), _maxAssetAmount); _setToken.invoke(address(_sendToken), 0, approveCallData); } }
Approve with a zero amount first before setting the actual amount.
function _approve( ISetToken _setToken, IWrappedfCashComplete _fCashPosition, IERC20 _sendToken, uint256 _maxAssetAmount ) internal { if(IERC20(_sendToken).allowance(address(_setToken), address(_fCashPosition)) < _maxAssetAmount) { + bytes memory approveZeroCallData = abi.encodeWithSelector(_sendToken.approve.selector, address(_fCashPosition), 0); + _setToken.invoke(address(_sendToken), 0, approveZeroCallData); bytes memory approveCallData = abi.encodeWithSelector(_sendToken.approve.selector, address(_fCashPosition), _maxAssetAmount); _setToken.invoke(address(_sendToken), 0, approveCallData); } }
The contract makes use of the floating-point pragma ^0.8.0. Contracts should be deployed using the same compiler version. Locking the pragma helps ensure that contracts are not unintentionally deployed using another pragma, such as an obsolete version, that may introduce issues in the contract system.
// SPDX-License-Identifier: MIT pragma solidity ^0.8.0; import "./wfCashLogic.sol"; import "../interfaces/IERC4626.sol"; contract wfCashERC4626 is IERC4626, wfCashLogic { constructor(INotionalV2 _notional, WETH9 _weth) wfCashLogic(_notional, _weth) {} ..SNIP..
Consider locking the pragma version. It is advised that floating pragma should not be used in production
Following function was defined, but not used in any of the contracts.
function _safeNegInt88(uint256 x) private pure returns (int88) { int256 y = -int256(x); require(int256(type(int88).min) <= y); return int88(y); }
It is recommended to remove this function if it is not required.
#0 - ckoopmann
2022-06-17T02:12:28Z
Flagging that this QA report contains Approve Not Set To Zero First
which I confirmed as a mid-risk issue elsewhere.
#1 - gzeoneth
2022-06-26T15:15:35Z
Flagging that this QA report contains
Approve Not Set To Zero First
which I confirmed as a mid-risk issue elsewhere.
Thanks but judged that as QA in #221.