Platform: Code4rena
Start Date: 24/10/2023
Pot Size: $36,500 USDC
Total HM: 4
Participants: 147
Period: 6 days
Judge: 0xDjango
Id: 299
League: ETH
Rank: 1/147
Findings: 5
Award: $2,133.16
π Selected for report: 0
π Solo Findings: 0
161.7958 USDC - $161.80
stUSDe can still be withdrawn for SOFT_RESTRICTED users, which breaks protocol intention.
Document states:
Addresses under this category will be soft restricted. They cannot deposit USDe to get stUSDe or withdraw stUSDe for USDe. However they can participate in earning yield by buying and selling stUSDe on the open market.
Note that SOFT_RESTRICTED users cannot deposit USDe for stUSDe or redeem stUSDe for USDe. From the code in StakedUSDe.sol, it is true that SOFT_RESTRICTED users cannot deposit USDe for stUSDE. _deposit()
is overriden from ERC4626. This means that when the user calls deposit()
or mint()
, which is a public function, _deposit()
will be called. _deposit()
will revert if the user has a SOFT_RESTRICTED role.
function _deposit(address caller, address receiver, uint256 assets, uint256 shares) internal override nonReentrant notZero(assets) notZero(shares) { //@audit checks the SOFT_RESTRICTED role if (hasRole(SOFT_RESTRICTED_STAKER_ROLE, caller) || hasRole(SOFT_RESTRICTED_STAKER_ROLE, receiver)) { revert OperationNotAllowed(); } super._deposit(caller, receiver, assets, shares); _checkMinShares(); }
However, the same cannot be said for withdrawals. _withdraw()
is also overridden, but this time, FULL_RESTRICTED_STAKER_ROLE is checked instead of SOFT_RESTRICTED_STAKER_ROLE. This means that SOFT_RESTRICTED users can still withdraw/redeem stUSDe for USDe.
function _withdraw(address caller, address receiver, address _owner, uint256 assets, uint256 shares) internal override nonReentrant notZero(assets) notZero(shares) { // Restricts FULL instead of SOFT if (hasRole(FULL_RESTRICTED_STAKER_ROLE, caller) || hasRole(FULL_RESTRICTED_STAKER_ROLE, receiver)) { revert OperationNotAllowed(); } super._withdraw(caller, receiver, _owner, assets, shares); _checkMinShares(); }
There is no need to restrict FULL_RESTRICTED in deposit and withdraw, since the _beforeTransferToken
hook takes care of the restrictions already. _deposit()
in ERC4626 will call _mint()
in ERC20, which will run the hook. If msg.sender or receiver is FULL_RESTRICTED, revert the function.
VSCode
Recommend changing FULL_RESTRICTED_STAKER_ROLE to SOFT_RESTRICTED_STAKER_ROLE in the overridden _withdraw()
function, like how _deposit()
does in StakedUSDe.sol.
function _withdraw(address caller, address receiver, address _owner, uint256 assets, uint256 shares) internal override nonReentrant notZero(assets) notZero(shares) { - if (hasRole(FULL_RESTRICTED_STAKER_ROLE, caller) || hasRole(FULL_RESTRICTED_STAKER_ROLE, receiver)) { - revert OperationNotAllowed(); - } + if (hasRole(SOFT_RESTRICTED_STAKER_ROLE, caller) || hasRole(SOFT_RESTRICTED_STAKER_ROLE, receiver)) { + revert OperationNotAllowed(); + } super._withdraw(caller, receiver, _owner, assets, shares); _checkMinShares(); }
Invalid Validation
#0 - c4-pre-sort
2023-10-31T18:46:31Z
raymondfam marked the issue as low quality report
#1 - c4-pre-sort
2023-10-31T18:46:42Z
raymondfam marked the issue as duplicate of #52
#2 - c4-judge
2023-11-10T21:42:52Z
fatherGoose1 marked the issue as satisfactory
π Selected for report: adeolu
Also found by: Eeyore, Madalad, Mike_Bello90, Shubham, jasonxiale, josephdara, peanuts
1432.1788 USDC - $1,432.18
The admin can choose whether to have a cooldown period when withdrawing/redeeming stUSDe. If there is a cooldown period and the admin sets it to zero (say for a special event or perhaps a breach in contract), users who already called cooldownAssets()
or cooldownShares()
will not be able to withdraw their USDe inside the silo.
function cooldownAssets(uint256 assets, address owner) external ensureCooldownOn returns (uint256) { if (assets > maxWithdraw(owner)) revert ExcessiveWithdrawAmount(); uint256 shares = previewWithdraw(assets); cooldowns[owner].cooldownEnd = uint104(block.timestamp) + cooldownDuration; cooldowns[owner].underlyingAmount += assets; _withdraw(_msgSender(), address(silo), owner, assets, shares); return shares; }
If cooldown is set to zero, users can call the withdraw()
function immediately to convert stUSDe to USDe.
function withdraw(uint256 assets, address receiver, address owner) public virtual override ensureCooldownOff returns (uint256) { return super.withdraw(assets, receiver, owner); }
But those who already initiated withdrawals will have to wait until the cooldown is finished to withdraw from the silo via unstake()
.
function unstake(address receiver) external { UserCooldown storage userCooldown = cooldowns[msg.sender]; uint256 assets = userCooldown.underlyingAmount; if (block.timestamp >= userCooldown.cooldownEnd) { userCooldown.cooldownEnd = 0; userCooldown.underlyingAmount = 0; silo.withdraw(receiver, assets); } else { revert InvalidCooldown(); } }
USDe will still be locked in the silo when cooldown duration is set to 0.
VSCode
Recommend letting those that have their USDe locked inside the silo contract to be able to immediately withdraw when cooldown is set to zero.
function unstake(address receiver) external { UserCooldown storage userCooldown = cooldowns[msg.sender]; uint256 assets = userCooldown.underlyingAmount; //@audit Add an optional route if cooldownDuration is 0 if (cooldownDuration == 0) { userCooldown.cooldownEnd = 0; userCooldown.underlyingAmount = 0; silo.withdraw(receiver,assets); break; } if (block.timestamp >= userCooldown.cooldownEnd) { userCooldown.cooldownEnd = 0; userCooldown.underlyingAmount = 0; silo.withdraw(receiver, assets); } else { revert InvalidCooldown(); } }
Timing
#0 - c4-pre-sort
2023-11-01T00:42:01Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-11-01T00:42:15Z
raymondfam marked the issue as duplicate of #29
#2 - c4-judge
2023-11-13T19:04:46Z
fatherGoose1 marked the issue as satisfactory
#3 - c4-judge
2023-11-17T02:45:06Z
fatherGoose1 changed the severity to QA (Quality Assurance)
#4 - c4-judge
2023-11-17T16:47:07Z
This previously downgraded issue has been upgraded by fatherGoose1
#5 - c4-judge
2023-11-27T19:58:04Z
fatherGoose1 marked the issue as not a duplicate
#6 - c4-judge
2023-11-27T19:58:13Z
fatherGoose1 marked the issue as duplicate of #198
π Selected for report: ayden
Also found by: 0xWaitress, Madalad, Yanchuan, cartlex_, ciphermarco, critical-or-high, d3e4, mert_eren, peanuts, pontifex, trachev, twcctop
520.4229 USDC - $520.42
When any collateral, eg stUSDe is converted to USDe, yield is already being generated either through LSD rebase or perps contracts.
From documentation:
Users mint USDe with stETH, and Ethena opens an equvilant short ETH perps position on perps exchanges. stETH yields 3-4% annualized, while short ETH perps yield 6-8%.
The combined long and short position daily yield is sent to an insurance fund, and then sent to the staking contract every 8 hours.
The rewarder can deposit USDe into the staking contract through StakedUSDe.transferInRewards()
.
function transferInRewards(uint256 amount) external nonReentrant onlyRole(REWARDER_ROLE) notZero(amount) { if (getUnvestedAmount() > 0) revert StillVesting(); uint256 newVestingAmount = amount + getUnvestedAmount(); vestingAmount = newVestingAmount; lastDistributionTimestamp = block.timestamp; // transfer assets from rewarder to this contract IERC20(asset()).safeTransferFrom(msg.sender, address(this), amount); emit RewardsReceived(amount, newVestingAmount); }
stUSDe token uses the ERC4626 functionality. If USDe yield is sent to the staking contract before any staking happens, the accounting of shares will break.
Normal Accounting:
deposit()
in ERC4626 will be called which calls previewDeposit()
and subsequently _convertToShares()
. This is the _convertToShares()
formula.function _convertToShares(uint256 assets, Math.Rounding rounding) internal view virtual returns (uint256) { return assets.mulDiv(totalSupply() + 10 ** _decimalsOffset(), totalAssets() + 1, rounding); }
User A will get 1e18 * (0+1) / (0+1) = 1e18 * 1 / 1 = 1e18 worth of stUSDe shares.
User B will get 1e18 * (1e18 + 1) / (1e18 + 1) = 1e18 worth of stUSDe shares
Current totalAssets: 3e18 (USDe) Current totalSupply: 2e18 (stUSDe)
_convertToAssets()
is calledfunction _convertToAssets(uint256 shares, Math.Rounding rounding) internal view virtual returns (uint256) { return shares.mulDiv(totalAssets() + 1, totalSupply() + 10 ** _decimalsOffset(), rounding); }
User A will get 1e18 * (3e18 + 1) / (2e18 + 1) = 1e18 * 3e18 / 2e18 = 1.5e18 worth of USDe
User B will get 1e18 * (1.5e18 + 1) / (1e18 + 1) = 1.5e18 worth of USDe.
This is the normal accounting, both of them gets 1.5e18 USDe when depositing 1e18 USDe and rewarder depositing 1e18 USDe.
Now, let's look at what happens when the rewarder deposits the yield first
Total assets = 1e18 (USDe) Total supply = 0 (stUSDe)
assets.mulDiv(totalSupply() + 10 ** _decimalsOffset(), totalAssets() + 1, rounding);
1e18 * 1 / 1e18 = 1 stUSDe share, which will be reverted since min shares received must be 1e18. Since totalSupply()
is still 0, the shares returned will always be <1e18, which will revert.
_checkMinShares()
after depositing USDe.
function _checkMinShares() internal view { uint256 _totalSupply = totalSupply(); if (_totalSupply > 0 && _totalSupply < MIN_SHARES) revert MinSharesViolation(); }
(This is also a problem if someone directly deposits USDe into the contract before any stUSDe shares are minted)
If USDe rewards is transferred before any stUSDe is minted, the shares distribution will break.
VSCode
Make sure USDe is not transferred before any stUSDe tokens are minted. Best if there is a check to ensure that some stUSDe tokens are already minted / the protocol mints them, before depositing USDe directly into the staking contract.
ERC4626
#0 - c4-pre-sort
2023-11-01T00:46:27Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-11-01T00:49:29Z
raymondfam marked the issue as duplicate of #32
#2 - c4-judge
2023-11-10T20:59:44Z
fatherGoose1 marked the issue as satisfactory
π Selected for report: 0xmystery
Also found by: 0x11singh99, 0xAadi, 0xAlix2, 0xG0P1, 0xStalin, 0xWaitress, 0x_Scar, 0xhacksmithh, 0xhunter, 0xpiken, Al-Qa-qa, Arz, Avci, Bauchibred, BeliSesir, Breeje, Bughunter101, DarkTower, Eeyore, Fitro, HChang26, Imlazy0ne, J4X, JCK, Kaysoft, Kral01, Madalad, Mike_Bello90, Noro, PASCAL, PENGUN, Proxy, Rickard, Shubham, SovaSlava, Strausses, Team_Rocket, ThreeSigma, Topmark, Udsen, Walter, Yanchuan, Zach_166, ZanyBonzy, adam-idarrha, adeolu, almurhasan, arjun16, ast3ros, asui, ayden, btk, cartlex_, castle_chain, cccz, chainsnake, codynhat, critical-or-high, cryptonue, csanuragjain, deepkin, degensec, dirk_y, erebus, foxb868, ge6a, hunter_w3b, jasonxiale, kkkmmmsk, lanrebayode77, lsaudit, marchev, matrix_0wl, max10afternoon, nuthan2x, oakcobalt, oxchsyston, pavankv, peanuts, pep7siup, pipidu83, pontifex, ptsanev, qpzm, radev_sw, rokinot, rotcivegaf, rvierdiiev, sorrynotsorry, squeaky_cactus, supersizer0x, tnquanghuy0512, twcctop, twicek, young, zhaojie, ziyou-
4.5226 USDC - $4.52
In StakedUSDe.transferInRewards(), the first line checks if the return value of getUnvestedAmount()
is greater than 0. If the return value is greater, revert. Else, continue. The second addition line is redundant as getUnvestedAmount()
will always be 0.
function transferInRewards(uint256 amount) external nonReentrant onlyRole(REWARDER_ROLE) notZero(amount) { //@audit getUnvestedAmount must return 0 if (getUnvestedAmount() > 0) revert StillVesting(); //@audit If getUnvestedAmount can only return 0 for function to proceed, this next addition is redundant since getUnvestedAmount always returns 0 uint256 newVestingAmount = amount + getUnvestedAmount();
Remove the second addition line.
uint256 newVestingAmount = amount + getUnvestedAmount();
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/StakedUSDe.sol#L173
In 5.0, token hooks were removed from ERC20, ERC721, and ERC1155, favoring a single
_update
function. This can also be overridden for custom behaviors in anymint
,transfer
, orburn
operation.
Take note when compiling the contracts or upgrading to a new contract. The override of _beforeTokenTransfer()
will not work and the full restricted blacklisted users can still trade their tokens. Best practice is to override the _update function in the new ERC20 contract and use the latest openzeppelin version.
https://blog.openzeppelin.com/introducing-openzeppelin-contracts-5.0
In EthenaMinting, there is a benefactor and beneficiary, which I assume one is the user and one is a trusted address. For the user to get his token, he must wait for the minter to mint his tokens, and then wait for the beneficiary to give him his USDe. There will be a lot of waiting time, which increases the centralization risks of the protocol. It may be hard for the user to call mint()
directly since the protocol has to specify how much USDe they can mint for their given collateral, but the USDe should be directly minted to the user themselves.
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/EthenaMinting.sol#L431
Although the parent hook is empty now, it is good to call the parent function in case OpenZeppelin implements new functionalities in the parent contract.
Always call the parentβs hook in your override using super. This will make sure all hooks in the inheritance tree are called: contracts like ERC20Pausable rely on this behavior.
https://docs.openzeppelin.com/contracts/4.x/extending-contracts#rules_of_hooks
#0 - c4-pre-sort
2023-11-02T02:24:48Z
raymondfam marked the issue as sufficient quality report
#1 - c4-judge
2023-11-14T17:05:44Z
fatherGoose1 marked the issue as grade-b
π Selected for report: radev_sw
Also found by: 0xSmartContract, 0xweb3boy, Al-Qa-qa, Bauchibred, Bulletprime, D_Auditor, J4X, JCK, K42, Kral01, Sathish9098, ZanyBonzy, albahaca, catellatech, clara, digitizeworx, fouzantanveer, hunter_w3b, invitedtea, jauvany, oakcobalt, pavankv, peanuts, xiao
14.2357 USDC - $14.24
Depositing collateral (stETH) and getting USDe
mint()
in EthenaMinting.sol is called. Take note that the minter is calling the function, and not the user. The user probably has to approve some functions for the contract to use his assetsRedeeming collateral (stETH) from USDe
redeem()
in EthenaMinting.sol is called. Once again, the minter is calling the function, and not the user.Minting stUSDe
deposit()
directly from ERC4626.Withdrawing stUSDe
cooldownAssets()
is called in StakedUSDeV2. User inputs how much assets (USDe) he wants to get back and burns the appropriate amount of shares (stUSDe). For example, if user wants to get back 1000 USDe, he has to burn 100 stUSDe shares. ensureCooldownOn
modifier is called to make sure that there is a cooldown set. Cooldowns mapping is updated, and _withdraw()
in StakedUSDe.sol will be called._withdraw()
checks whether the assets and shares are non zero value, checks if the caller or receive has FULL_RESTRICTED_STAKER_ROLE
before calling ERC4626 _withdraw()
._withdraw()
will burn the shares from the owner and transfer the asset to the receiver. If cooldown is on, USDe will be transferred to the silo contract. If cooldown is off, USDe will be directly transferred to the receiver._withdraw()
in StakedUSDe.sol will check whether the total supply of stUSDe is above 1e18 to prevent inflation attack.unstake()
in StakedUSDeV2 is called. Function will check whether cooldown is over and transfer userCooldown.underlyingAmount
of USDe to the user. Cooldown mapping is set to zero.Contract | Function | Explanation | Comments |
---|---|---|---|
StakedUSDe | addToBlacklist | Add a user to the blacklist | Blacklistmanager, Either full or soft restricted |
StakedUSDe | removeFromBlacklist | Remove a user from the blacklist | Blacklistmanager, Either full or soft restricted |
StakedUSDe | rescueTokens | Rescue tokens from contract | Admin, rescue any tokens except USDe |
StakedUSDe | redistributeLockedAmount | Burns stUSDe from full res and gives to another acc | Admin, huge centralization risk |
StakedUSDe | totalAssets | Returns the amount of USDe tokens vested | subtracts total balance with getUnvestedAmount() |
StakedUSDe | getUnvestedAmount | Returns the unvested USDe tokens | blacklistmanager Either full or soft restricted |
StakedUSDe | _checkMinShares | Check whether stUSDe minted or redeem is >1e18 | internal, to prevent inflation attack |
StakedUSDe | _deposit | deposit USDe and get stUSDe | override ERC4626, checks role and call parent _deposit |
StakedUSDe | _withdraw | withdraw stUSDe and get USDe | override ERC4626, checks role and call parent _withdraw |
StakedUSDe | _beforeTokenTransfer | make sure full res cannot transfer stUSDe | override stUSDe ERC20, take note of deprecation in OZ 5.0 |
StakedUSDeV2 | ensureCooldownOff | ensure cooldownDuration = 0 | If no cooldown, can withdraw / redeem stUSDe immediately |
StakedUSDeV2 | ensureCooldownOn | ensure cooldownDuration != 0 | If cooldown, redeeming USDe have to wait for a fixed duration |
StakedUSDeV2 | unstake | Claims staking amount in silo after cooldown | Is called after cooldownAssets / cooldownShares |
StakedUSDeV2 | cooldownShares | burns shares of stUSDe, get USDe assets back | Input shares to burn for assets, cooldowns mapping is updated |
StakedUSDeV2 | cooldownAssets | burns shares of stUSDe, get USDe assets back | Input assets to get back, calculate shares. cooldowns mapping is updated |
StakedUSDeV2 | setCooldownDuration | Sets the cooldown duration | Admin, set the cooldown duration. Cannot be > 90 days |
USDeSilo.sol | withdraw | Withdraws USDe | onlyStakingVault, used in conjunction with StakedUSDeV2.unstake() |
USDe.sol | mint | mints USDe | onlyMinter, which is Ethena |
USDe.sol | setMinter | sets minter | onlyOwner, sets the minter of USDe |
USDe.sol | renounceOwnership | Renounce Ownership of role | onlyOwner, override revert |
SingleAdminAccessControl | transferAdmin | Transfers admin role to new admin | onlyAdmin role, 2 step process, works with acceptAdmin() |
SingleAdminAccessControl | acceptAdmin | Accepts new admin role | onlyAdmin role, 2 step process, works with transferAdmin() |
EthenaMinting | mint | Mints stablecoins from assets | onlyMinter, verify route, order, transferCollateral and mint USDe |
EthenaMinting | redeem | Redeem stablecoins for assets | onlyReedemer, verify route, order, transfer assets and burns USDe |
EthenaMinting | setMaxMintPerBlock | Sets the max mintPerBlock limit | Admin, only a certain amount of USDe can be minted per block |
EthenaMinting | setMaxRedeemPerBlock | Sets the max redeemPerBlock limit | Admin, only a certain amount of USDe can be redeemed per block |
EthenaMinting | disableMintRedeem | Disables the mint and redeem | Gatekeeper, Sets max mint and redeem to 0 |
EthenaMinting | transferToCustody | Transfers an asset to a custody wallet | onlyMinter, transfers asset to custodian address |
EthenaMinting | verifyOrder | Assert validity of signed order | checks signer, beneficiary, collateral_amt, usde_amt, expiry of order |
EthenaMinting | verifyRoute | Assert validity of route object per type | OrderType Mint or Redeem. If mint, check total ratio == 10,000 and addresses |
EthenaMinting | verifyNonce | Verify validity of nonce by checking its presence | Not sure, used in conjunction with deduplicate order |
EthenaMinting | _transferToBeneficiary | Transfer supported asset to beneficiary address | Called during redeem, after USDe is burned |
EthenaMinting | _transferCollateral | Verify validity of nonce by checking its presence | Called during mint, before USDe is minted |
Contract | Role | Risk | Explanation |
---|---|---|---|
StakedUSDe | Admin | High | Able to redistribute any blacklisted user's stUSDE |
StakedUSDe | Blacklist Manager | High | Blacklists a user, cannot transfer stUSDe |
StakedUSDe | FULL_RESTRICTED_STAKER_ROLE | None | Cannot transfer stUSDe, only can burn |
StakedUSDe | SOFT_RESTRICTED_STAKER_ROLE | None | Cannot deposit USDe |
StakedUSDe | Rewarder | None | Transfers USDe into the contract as rewards |
StakedUSDeV2 | Admin | Medium | Controls the cooldown duration, max 90 days |
USDeSilo | Staking Vault | None | Withdraws USDe in the silo |
USDe | Owner | High | Controls the setting of minter role, can mint unlimited USDe |
USDe | Minter | High | Can mint unlimited USDe |
SingleAdminAccessControl | Admin | High | Able to grant and revoke any other roles but not its own |
EthenaMinting | Minter | High | Controls mint |
EthenaMinting | Redeemer | High | Controls redeem |
EthenaMinting | Admin | High | Controls max mint and redeem limit |
EthenaMinting | Gatekeeper | Medium | Blocks all mint and redeem, admin can override |
transferInRewards()
, the malicious user can frontrun this deposit by staking 10,000 USDe. Afterwards, he can immediately withdraw his staked USDe to get a portion of the rewards. To prevent this from happening, getUnvestedAmount()
is used so that malicious user cannot take advantage of the rewards.30 hours
#0 - c4-pre-sort
2023-11-01T14:23:49Z
raymondfam marked the issue as sufficient quality report
#1 - c4-judge
2023-11-10T19:36:19Z
fatherGoose1 marked the issue as grade-b