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: 29/147
Findings: 3
Award: $176.43
๐ Selected for report: 0
๐ Solo Findings: 0
๐ 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
The token fails to inherit from Pausable and lacks functionality to pause minting and transfers in an emergency situation.
The USDe contract inherits from ERC20 and ERC20Burnable interfaces to define its core token functionality. However, it does not also inherit from Pausable which would allow an authorized party to pause all minting, burning and transfers on the token in an emergency situation.
Without a Pausable implementation, there is no built-in mechanism to freeze the contract and prevent further activity if a vulnerability is discovered or the system requires downtime. This poses an important security and risk management shortcoming
A malicious actor could potentially exploit a bug or vulnerability to rapidly mint new tokens, transfer value out of the contract, or otherwise manipulate the token supply. Without pausability, there would be no built-in standardized method for contract administrators to freeze the system and contain the issue.
This risks undermining the stability and integrity of the USDe token in an emergency scenario where quick action is required. It also does not follow best practice standards for token implementations.
File: contracts/USDe.sol import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol"; import "@openzeppelin/contracts/token/ERC20/extensions/draft-ERC20Permit.sol"; import "@openzeppelin/contracts/access/Ownable2Step.sol";
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/USDe.sol#L4-L7
Critical actions like minting lack emitted events, reducing transparency into token supply changes.
While the USDe contract implements the ERC20 standard, it is missing crucial events for minting actions. Whenever new tokens are minted via the mint() function, no corresponding Transfer or Mint event is emitted.
Without events tracking minting, there is no programmatic or on-chain way to verify changes to the total supply over time. External observers have reduced visibility into minting activity and changes to circulating supply.
File: contracts/USDe.sol function mint(address to, uint256 amount) external { if (msg.sender != minter) revert OnlyMinter(); _mint(to, amount); }
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/USDe.sol#L28-L32
The delegated signature functionality lacks an expiration, allowing indefinite delegation of signing permissions.
The EthenaMinting contract allows contracts to delegate their signing capabilities to an external account via the setDelegatedSigner() function. However, there is no specified expiration set on these delegations.
Once a delegation is made, the external account has indefinite powers to sign orders on behalf of the contract. The contract owner has no built-in ability to later revoke these permissions.
File: contracts/EthenaMinting.sol function setDelegatedSigner(address _delegateTo) external { delegatedSigner[_delegateTo][msg.sender] = true; emit DelegatedSignerAdded(_delegateTo, msg.sender); }
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/EthenaMinting.sol#L235-L238
The lack of expirations on delegated signatures centralizes long-term signing control in the delegated account. If compromised, it could be used to automatically sign fraudulent orders over a lengthy period.
The route verification logic only checks array lengths match but does not validate the actual route details meet requirements.
The verifyRoute() function checks that the route address and ratio arrays passed to mint() match lengths. However, it does not implement the full route verification logic.
Such logic should check things like: addresses are valid custodians, ratios sum to 100%, no address receives 0%, etc.
Without this, any route could be passed even if the ratios/addresses do not actually represent a valid custody transfer path.
Impact: This overly permissive validation allows fraudulent or invalid routes to be used for minting. It undermines the intended asset transfer guarantees that routes are expected to provide. It risks assets being incorrectly transferred or even stolen if invalid routes can still trigger minting. This reduces integrity of the custody mechanism.
File: contracts/EthenaMinting.sol function verifyRoute(Route calldata route, OrderType orderType) public view override returns (bool) { // routes only used to mint if (orderType == OrderType.REDEEM) { return true; } uint256 totalRatio = 0; if (route.addresses.length != route.ratios.length) { return false; } if (route.addresses.length == 0) { return false; } for (uint256 i = 0; i < route.addresses.length; ++i) { if (!_custodianAddresses.contains(route.addresses[i]) || route.addresses[i] == address(0) || route.ratios[i] == 0) { return false; } totalRatio += route.ratios[i]; } if (totalRatio != 10_000) { return false; } return true; }
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/EthenaMinting.sol#L351C1-L374C4
#0 - c4-pre-sort
2023-11-02T01:11:16Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-11-02T01:13:09Z
raymondfam marked the issue as low quality report
#2 - c4-pre-sort
2023-11-02T01:14:52Z
raymondfam marked the issue as sufficient quality report
#3 - c4-judge
2023-11-14T17:13:23Z
fatherGoose1 marked the issue as grade-b
๐ Selected for report: 0xVolcano
Also found by: 0x11singh99, 0xAadi, 0xAnah, 0xgrbr, 0xhacksmithh, 0xhex, 0xpiken, 0xta, J4X, JCK, K42, Raihan, Rolezn, SAQ, SM3_SS, Sathish9098, SovaSlava, ThreeSigma, Udsen, arjun16, aslanbek, brakelessak, castle_chain, evmboi32, hunter_w3b, lsaudit, naman1778, niser93, nuthan2x, oakcobalt, pavankv, petrichor, phenom80, radev_sw, shamsulhaq123, tabriz, thekmj, unique, yashgoel72, ybansal2403
83.1792 USDC - $83.18
While striving for enhanced code clarity in the provided snippets, certain functions have been abbreviated to emphasize affected sections.
Developers should remain vigilant during the incorporation of these proposed modifications to avert potential vulnerabilities. Despite prior testing of the optimizations, developers bear the responsibility of conducting comprehensive reevaluation.
Conducting code reviews and additional testing is highly recommended to mitigate any plausible hazards that may arise from the refactoring endeavor.
These optimizations aim to reduce the gas costs associated with contract deployment and execution. Below is a summary of the key points and issues discussed:
Finding | Issues | Instances |
---|---|---|
1 | Use assembly to validate msg.sender | 4 |
2 | Using a positive conditional flow to save a NOT opcode | 11 |
3 | Access mappings directly rather than using accessor functions | 2 |
4 | Use assembly for small keccak256 hashes, in order to save gas | 2 |
5 | Using this to access functions result in an external call, wasting gas | 2 |
6 | Expensive operation inside a for loop | 2 |
7 | Cache external calls outside of loop to avoid re-calling function on each iteration | 2 |
8 | Avoid having ERC20 token balances go to zero, always keep a small amount | 4 |
9 | Timestamps and block numbers in storage do not need to be uint256 | 5 |
10 | Count from n to zero instead of counting from zero to n | 2 |
11 | Use selfdestruct in the constructor if the contract is one-time use | 5 |
12 | Use transfer hooks for tokens instead of initiating a transfer from the destination smart contract | 3 |
13 | Use fallback or receive instead of deposit() when transferring Ether | 1 |
14 | Avoid contract calls by making the architecture monolithic | 4 |
15 | Use one ERC1155 or ERC6909 token instead of several ERC20 tokens | 4 |
16 | Consider using alternatives to OpenZeppelin | - |
17 | Use vanity addresses (safely!) | 1 |
18 | Using assembly to revert with an error message | 52 |
19 | Use assembly to reuse memory space when making more than one external call | 6 |
20 | It is sometimes cheaper to cache calldata | 2 |
21 | Avoid contract existence checks by using low level calls | 8 |
22 | Expressions for constant values such as a call to keccak256(), should use immutable rather than constant | 12 |
23 | <X> += <Y> ย costs more gas than <X> = <X> + <Y> ย for state variables | 4 |
24 | ++i/i++ ย should be unchecked{++i}/unchecked{i++} when it's not possible for them to overflow, as is the case when used in for | 4 |
25 | Should use arguments instead of state variable in emit to save some amount of gas | 6 |
26 | Use bitmaps instead of bools when a significant amount of booleans are used | 3 |
27 | The result of function calls should be cached rather than re-calling the function | 2 |
28 | A modifier used only once and not being inherited should be inlined to save gas | 3 |
29 | abi.encode()ย is less efficient thanย abi.encodePacked() | 3 |
30 | Using > 0 costs more gas than != 0 | 3 |
31 | Use assembly to reuse memory space when making more than one external call | 9 |
32 | Short-circuit booleans | 11 |
33 | Unnecessary casting as variable is already of the same type | 5 |
34 | Use hardcode address instead address(this) | 5 |
35 | Use assembly for math (add, sub, mul, div) | 11 |
36 | Use bytes.concat() instead of abi.encodePacked(), since this is preferred since 0.8.4 | 1 |
37 | onlyRole(MINTER_ROLE) no uses of the nonReentrant modifier | 3 |
38 | State variables which are not modified within functions should be set as constants or immutable for values set at deployment | 2 |
You can use inline assembly to validate msg.sender and potentially save gas. Here's an example of how to do it:
// SPDX-License-Identifier: MIT pragma solidity ^0.8.0; contract ValidateSender { address public owner; constructor() { owner = msg.sender; } function validateSender() public view returns (bool) { bool isValid; assembly { // Retrieve the current sender address let sender := caller() // Load the owner's address from storage let ownerAddress := sload(owner.slot) // Compare the sender with the owner's address isValid := eq(sender, ownerAddress) } return isValid; } }
The validateSender function uses inline assembly to compare msg.sender (retrieved with caller()) with the owner's address stored in storage (retrieved with sload). If they match, isValid is set to true, indicating that the sender is the owner. Using inline assembly for this simple comparison can potentially save gas compared to a standard Solidity statement due to the reduced gas overhead of inline assembly.
File: contracts/USDe.sol 29 if (msg.sender != minter) revert OnlyMinter();
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/USDe.sol#L29
File: contracts/EthenaMinting.sol 138 if (msg.sender != _admin) {
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/EthenaMinting.sol#L138
File: contracts/USDeSilo.sol 24 if (msg.sender != STAKING_VAULT) revert OnlyStakingVault();
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/USDeSilo.sol#L24
File: contracts/SingleAdminAccessControl.sol 32 if (msg.sender != _pendingDefaultAdmin) revert NotPendingAdmin();
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/SingleAdminAccessControl.sol#L32
In the code snippet below, the second function avoids an unnecessary negation. In theory, the extra ! increases the computational cost. But as we noted at the top of the article, you should benchmark both methods because the compiler is can sometimes optimize this.
function cond() public { if (!condition) { action1(); } else { action2(); } } function cond() public { if (condition) { action2(); } else { action1(); } }
File: contracts/EthenaMinting.sol 171 if (!verifyRoute(route, order.order_type)) revert InvalidRoute(); 172 if (!_deduplicateOrder(order.benefactor, order.nonce)) revert Duplicate(); 203 if (!_deduplicateOrder(order.benefactor, order.nonce)) revert Duplicate(); 251 if (!success) revert TransferFailed(); 260 if (!_supportedAssets.remove(asset)) revert InvalidAssetAddress(); 271 if (!_custodianAddresses.remove(custodian)) revert InvalidCustodianAddress(); 342 if (!(signer == order.benefactor || delegatedSigner[signer][order.benefactor])) revert InvalidSignature(); 364 if (!_custodianAddresses.contains(route.addresses[i]) || route.addresses[i] == address(0) || route.ratios[i] == 0) 405 if (!success) revert TransferFailed(); 407 if (!_supportedAssets.contains(asset)) revert UnsupportedAsset(); 421 if (!_supportedAssets.contains(asset) || asset == NATIVE_TOKEN) revert UnsupportedAsset();
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/EthenaMinting.sol#L171
When you have a mapping, accessing its values through accessor functions involves an additional layer of indirection, which can incur some gas cost. This is because accessing a value from a mapping typically involves two steps: first, locating the key in the mapping, and second, retrieving the corresponding value.
File: 98 if (mintedPerBlock[block.number] + mintAmount > maxMintPerBlock) revert MaxMintPerBlockExceeded(); 105 if (redeemedPerBlock[block.number] + redeemAmount > maxRedeemPerBlock) revert MaxRedeemPerBlockExceeded();
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/EthenaMinting.sol#L98
If the arguments to the encode call can fit into the scratch space (two words or fewer), then it's more efficient to use assembly to generate the hash.
Using assembly to hash up to 96 bytes of data
bytes32 public hash; struct Values { uint256 a; uint256 b; uint256 c; } Values values; // cost: 113155function setOnchainHash(Values calldata _values) external { hash = keccak256(abi.encode(_values)); values = _values; } } contract CheapHasher { bytes32 public hash; struct Values { uint256 a; uint256 b; uint256 c; } Values values; // cost: 112107 function setOnchainHash(Values calldata _values) external { assembly { // cache the free memory pointer because we are about to override it let fmp := mload(0x40) // use 0x00 to 0x60 calldatacopy(0x00, 0x04, 0x60) sstore(hash.slot, keccak256(0x00, 0x60)) // restore the cache value of free memory pointer mstore(0x40, fmp) } values = _values; } }
In the above example, similar to the first one, we use assembly to store values in the first 96 bytes of memory which saves us 1,000+ gas. Also notice that in this instance, because we still break back into solidity code, we cached and updated our free memory pointer at the start and end of our assembly block. This is to make sure that the solidity compilerโs assumptions on what is stored in memory remains compatible.
File: contracts/EthenaMinting.sol 317 return ECDSA.toTypedDataHash(getDomainSeparator(), keccak256(encodeOrder(order))); 452 return keccak256(abi.encode(EIP712_DOMAIN, EIP_712_NAME, EIP712_REVISION, block.chainid, address(this)));
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/EthenaMinting.sol#L317
External calls have an overhead ofย 100 gas, which can be avoided by not referencing the function usingย this. Contractsย are allowedย to override their parents' functions and change the visibility fromย external to public, so make this change if it's required in order to call the function internally.
File: contracts/StakedUSDe.sol 96 IERC20(asset()).safeTransferFrom(msg.sender, address(this), amount); 167 return IERC20(asset()).balanceOf(address(this)) - getUnvestedAmount();
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/StakedUSDe.sol#L96
Performing an expensive operation inside a for loop in Solidity can be a gas-inefficient practice, as it can significantly increase the transaction cost.
File: contracts/EthenaMinting.sol 363 for (uint256 i = 0; i < route.addresses.length; ++i) { 364 if (!_custodianAddresses.contains(route.addresses[i]) || route.addresses[i] == address(0) || route.ratios[i] == 0) 365 { 366 return false; 367 } 368 totalRatio += route.ratios[i]; 369 } 424 for (uint256 i = 0; i < addresses.length; ++i) { 425 uint256 amountToTransfer = (amount * ratios[i]) / 10_000; 426 token.safeTransferFrom(benefactor, addresses[i], amountToTransfer); 427 totalTransferred += amountToTransfer; 428 }
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/EthenaMinting.sol#L363-L369
Performing STATICCALL that do not depend on variables incremented in loops should always try to be avoided within the loop. In the following instances, we are able to cache the external calls outside of the loop to save a STATICCALL (100 gas) per loop iteration.
Cache _custodianAddresses.contains(route.addresses[i]) outside of loop to save 1 STATICCALL per loop iteration
File: 363 for (uint256 i = 0; i < route.addresses.length; ++i) { 364 if (!_custodianAddresses.contains(route.addresses[i]) || route.addresses[i] == address(0) || route.ratios[i] == 0) 365 { 366 return false; 367 } 368 totalRatio += route.ratios[i]; 369 } 424 for (uint256 i = 0; i < addresses.length; ++i) { 425 uint256 amountToTransfer = (amount * ratios[i]) / 10_000; 426 token.safeTransferFrom(benefactor, addresses[i], amountToTransfer); 427 totalTransferred += amountToTransfer; 428 }
This is related to the avoiding zero writes section above, but itโs worth calling out separately because the implementation is a bit subtle.
If an address is frequently emptying (and reloading) itโs account balance, this will lead to a lot of zero to one writes.
File: contracts/EthenaMinting.sol 253 IERC20(asset).safeTransfer(wallet, amount); 408 IERC20(asset).safeTransfer(beneficiary, amount);
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/EthenaMinting.sol#L253
File: contracts/StakedUSDe.sol 96 IERC20(asset()).safeTransferFrom(msg.sender, address(this), amount); 140 IERC20(token).safeTransfer(to, amount);
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/StakedUSDe.sol#L96
A timestamp of size uint48 will work for millions of years into the future. A block number increments once every 12 seconds. This should give you a sense of the size of numbers that are sensible.
File: contracts/StakedUSDe.sol 45 uint256 public lastDistributionTimestamp;
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/StakedUSDe.sol#L45
File: contracts/StakedUSDeV2.sol 100 cooldowns[owner].cooldownEnd = uint104(block.timestamp) + cooldownDuration; 116 cooldowns[owner].cooldownEnd = uint104(block.timestamp) + cooldownDuration;
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/StakedUSDeV2.sol#L100
File: contracts/EthenaMinting.sol 81 mapping(uint256 => uint256) public mintedPerBlock; 83 mapping(uint256 => uint256) public redeemedPerBlock;
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/EthenaMinting.sol#L81
When setting a storage variable to zero, a refund is given, so the net gas spent on counting will be less if the final state of the storage variable is zero.
File: contracts/StakedUSDeV2.sol 83 userCooldown.cooldownEnd = 0; 84 userCooldown.underlyingAmount = 0;
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/StakedUSDeV2.sol#L83
Sometimes, contracts are used to deploy several contracts in one transaction, which necessitates doing it in the constructor.
If the contractโs only use is the code in the constructor, then selfdestructing at the end of the operation will save gas.
Although selfdestruct is set for removal in an upcoming hardfork, it will still be supported in the constructor per EIP 6780
File: contracts/USDe.sol 18 constructor(address admin) ERC20("USDe", "USDe") ERC20Permit("USDe") {
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/USDe.sol#L18
File: contracts/EthenaMinting.sol 111 constructor(
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/EthenaMinting.sol#L111
File: contracts/StakedUSDe.sol 70 constructor(IERC20 _asset, address _initialRewarder, address _owner)
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/StakedUSDe.sol#L70
File: contracts/StakedUSDeV2.sol 42 constructor(IERC20 _asset, address initialRewarder, address owner) StakedUSDe(_asset, initialRewarder, owner) {
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/StakedUSDeV2.sol#L42
File: contracts/USDeSilo.sol 18 constructor(address stakingVault, address usde) {
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/USDeSilo.sol#L18
Letโs say you have contract A which accepts token B (an NFT or an ERC1363 token). The naive workflow is as follows:
msg.sender approves contract A to accept token B
msg.sender calls contract A to transfer tokens from msg.sender to A
Contract A then calls token B to do the transfer
Token B does the transfer, and calls onTokenReceived() in contract A
Contract A returns a value from onTokenReceived() to token B
Token B returns execution to contract A
This is very inefficient. Itโs better for msg.sender to call contract B to do a transfer which calls the tokenReceived hook in contract A.
Note that:
All ERC1155 tokens include a transfer hook
safeTransfer and safeMint in ERC721 have a transfer hook
ERC1363 has transferAndCall
ERC777 has a transfer hook but has been deprecated. Use ERC1363 or ERC1155 instead if you need fungible tokens
If you need to pass arguments to contract A, simply use the data field and parse that in contract A.
File: contracts/EthenaMinting.sol 426 token.safeTransferFrom(benefactor, addresses[i], amountToTransfer); 431 token.safeTransferFrom(benefactor, addresses[addresses.length - 1], remainingBalance);
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/EthenaMinting.sol#L426
File: contracts/StakedUSDe.sol 96 IERC20(asset()).safeTransferFrom(msg.sender, address(this), amount);
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/StakedUSDe.sol#L96
Similar to above, you can โjust transferโ ether to a contract and have it respond to the transfer instead of using a payable function. This of course, depends on the rest of the contractโs architecture.
Example Deposit in AAVE
contract AddLiquidity{ receive() external payable { IWETH(weth).deposit{msg.value}(); AAVE.deposit(weth, msg.value, msg.sender, REFERRAL_CODE) } }
The fallback function is capable of receiving bytes data which can be parsed with abi.decode. This servers as an alternative to supplying arguments to a deposit function.
File: contracts/StakedUSDe.sol 203 function _deposit(address caller, address receiver, uint256 assets, uint256 shares)
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/StakedUSDe.sol#L203
Contract calls are expensive, and the best way to save gas on them is by not using them at all. There is a natural tradeoff with this, but having several contracts that talk to each other can sometimes increase gas and complexity rather than manage it.
File: contracts/EthenaMinting.sol 250 (bool success,) = wallet.call{value: amount}(""); 404 (bool success,) = (beneficiary).call{value: amount}("");
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/EthenaMinting.sol#L250
File: contracts/StakedUSDe.sol 96 IERC20(asset()).safeTransferFrom(msg.sender, address(this), amount); 140 IERC20(token).safeTransfer(to, amount);
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/StakedUSDe.sol#L96
This was the original intent of the ERC1155 token. Each individual token behaves like and ERC20, but only one contract needs to be deployed.
The drawback of this approach is that the tokens will not be compatible with most DeFi swapping primitives.
ERC1155 uses callbacks on all of the transfer methods. If this is not desired, ERC6909 can be used instead.
File: contracts/USDe.sol 4 import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/USDe.sol#L4
File: contracts/EthenaMinting.sol 10 import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/EthenaMinting.sol#L10
File: contracts/StakedUSDe.sol 9 import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/StakedUSDe.sol#L9
File: contracts/USDeSilo.sol 4 import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/USDeSilo.sol#L4
OpenZeppelin is a great and popular smart contract library, but there are other alternatives that are worth considering. These alternatives offer better gas efficiency and have been tested and recommended by developers.
Two examples of such alternatives are Solmate and Solady.
Solmate is a library that provides a number of gas-efficient implementations of common smart contract patterns. Solady is another gas-efficient library that places a strong emphasis on using assembly.
It is cheaper to use vanity addresses with leading zeros, this saves calldata gas cost.
A good example is OpenSea Seaport contract with this address: 0x00000000000000ADc04C56Bf30aC9d3c0aAF14dC.
This will not save gas when calling the address directly. However, if that contractโs address is used as an argument to a function, that function call will cost less gas due to having more zeros in the calldata.
This is also true of passing EOAs with a lot of zeros as a function argument โ it saves gas for the same reason.
Just be aware that there have been hacks from generating vanity addresses for wallets with insufficiently random private keys. This is not a concert for smart contracts vanity addresses created with finding a salt for create2, because smart contracts do not have private keys.
File: contracts/EthenaMinting.sol 52 address private constant NATIVE_TOKEN = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE;
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/EthenaMinting.sol#L52
When reverting in solidity code, it is common practice to use a require or revert statement to revert execution with an error message. This can in most cases be further optimized by using assembly to revert with the error message.
Hereโs an example;
// calling restrictedAction(2) with a non-owner address: 24042 contract SolidityRevert { address owner; uint256 specialNumber = 1; constructor() { owner = msg.sender; } function restrictedAction(uint256 num) external { require(owner == msg.sender, "caller is not owner"); specialNumber = num; } } // calling restrictedAction(2) with a non-owner address: 23734 contract AssemblyRevert { address owner; uint256 specialNumber = 1; constructor() { owner = msg.sender; } function restrictedAction(uint256 num) external { assembly { if sub(caller(), sload(owner.slot)) { mstore(0x00, 0x20) // store offset to where length of revert message is stored mstore(0x20, 0x13) // store length (19) mstore(0x40, 0x63616c6c6572206973206e6f74206f776e657200000000000000000000000000) // store hex representation of message revert(0x00, 0x60) // revert with data } } specialNumber = num; } }
From the example above we can see that we get a gas saving of over 300 gas when reverting wth the same error message with assembly against doing so in solidity. This gas savings come from the memory expansion costs and extra type checks the solidity compiler does under the hood.
File: contracts/USDe.sol 19 if (admin == address(0)) revert ZeroAddressException(); 29 if (msg.sender != minter) revert OnlyMinter(); 34 revert CantRenounceOwnership();
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/USDe.sol#L19
File: contracts/EthenaMinting.sol 98 if (mintedPerBlock[block.number] + mintAmount > maxMintPerBlock) revert MaxMintPerBlockExceeded(); 105 if (redeemedPerBlock[block.number] + redeemAmount > maxRedeemPerBlock) revert MaxRedeemPerBlockExceeded(); 119 if (address(_usde) == address(0)) revert InvalidUSDeAddress(); 120 if (_assets.length == 0) revert NoAssetsProvided(); 121 if (_admin == address(0)) revert InvalidZeroAddress(); 169 if (order.order_type != OrderType.MINT) revert InvalidOrder(); 171 if (!verifyRoute(route, order.order_type)) revert InvalidRoute(); 172 if (!_deduplicateOrder(order.benefactor, order.nonce)) revert Duplicate(); 201 if (order.order_type != OrderType.REDEEM) revert InvalidOrder(); 203 if (!_deduplicateOrder(order.benefactor, order.nonce)) revert Duplicate(); 248 if (wallet == address(0) || !_custodianAddresses.contains(wallet)) revert InvalidAddress(); 251 if (!success) revert TransferFailed(); 260 if (!_supportedAssets.remove(asset)) revert InvalidAssetAddress(); 271 if (!_custodianAddresses.remove(custodian)) revert InvalidCustodianAddress(); 292 revert InvalidAssetAddress(); 300 revert InvalidCustodianAddress(); 342 if (!(signer == order.benefactor || delegatedSigner[signer][order.benefactor])) revert InvalidSignature(); 343 if (order.beneficiary == address(0)) revert InvalidAmount(); 344 if (order.collateral_amount == 0) revert InvalidAmount(); 345 if (order.usde_amount == 0) revert InvalidAmount(); 346 if (block.timestamp > order.expiry) revert SignatureExpired(); 378 if (nonce == 0) revert InvalidNonce(); 383 if (invalidator & invalidatorBit != 0) revert InvalidNonce(); 403 if (address(this).balance < amount) revert InvalidAmount(); 405 if (!success) revert TransferFailed(); 407 if (!_supportedAssets.contains(asset)) revert UnsupportedAsset(); 421 if (!_supportedAssets.contains(asset) || asset == NATIVE_TOKEN) revert UnsupportedAsset();
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/EthenaMinting.sol#L98
File: contracts/StakedUSDe.sol 51 if (amount == 0) revert InvalidAmount(); 57 if (target == owner()) revert CantBlacklistOwner(); 76 revert InvalidZeroAddress(); 90 if (getUnvestedAmount() > 0) revert StillVesting(); 139 if (address(token) == asset()) revert InvalidToken(); 157 revert OperationNotAllowed(); 193 if (_totalSupply > 0 && _totalSupply < MIN_SHARES) revert MinSharesViolation(); 211 revert OperationNotAllowed(); 233 revert OperationNotAllowed(); 247 revert OperationNotAllowed(); 250 revert OperationNotAllowed(); 258 revert OperationNotAllowed();
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/StakedUSDe.sol#L51
File: contracts/StakedUSDeV2.sol 28 if (cooldownDuration != 0) revert OperationNotAllowed(); 34 if (cooldownDuration == 0) revert OperationNotAllowed(); 88 revert InvalidCooldown(); 96 if (assets > maxWithdraw(owner)) revert ExcessiveWithdrawAmount(); 112 if (shares > maxRedeem(owner)) revert ExcessiveRedeemAmount(); 128 revert InvalidCooldown();
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/StakedUSDeV2.sol#L28
File: contracts/USDeSilo.sol 24 if (msg.sender != STAKING_VAULT) revert OnlyStakingVault();
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/USDeSilo.sol#L24
File: contracts/SingleAdminAccessControl.sol 18 if (role == DEFAULT_ADMIN_ROLE) revert InvalidAdminChange(); 26 if (newAdmin == msg.sender) revert InvalidAdminChange(); 32 if (msg.sender != _pendingDefaultAdmin) revert NotPendingAdmin();
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/SingleAdminAccessControl.sol#L18
An operation that causes the solidity compiler to expand memory is making external calls. When making external calls the compiler has to encode the function signature of the function it wishes to call on the external contract alongside itโs arguments in memory. As we know, solidity does not clear or reuse memory memory so itโll have to store these data in the next free memory pointer which expands memory further.
With inline assembly, we can either use the scratch space and free memory pointer offset to store this data (as above) if the function arguments do not take up more than 96 bytes in memory. Better still, if we are making more than one external call we can reuse the same memory space as the first calls to store the new arguments in memory without expanding memory unnecessarily. Solidity in this scenario would expand memory by as much as the returned data length is. This is because the returned data is stored in memory (in most cases). If the return data is less than 96 bytes, we can use the scratch space to store it to prevent expanding memory.
See the example below;
contract Called { function add(uint256 a, uint256 b) external pure returns(uint256) { return a + b; } } contract Solidity { // cost: 7262 function call(address calledAddress) external pure returns(uint256) { Called called = Called(calledAddress); uint256 res1 = called.add(1, 2); uint256 res2 = called.add(3, 4); uint256 res = res1 + res2; return res; } } contract Assembly { // cost: 5281 function call(address calledAddress) external view returns(uint256) { assembly { // check that calledAddress has code deployed to it if iszero(extcodesize(calledAddress)) { revert(0x00, 0x00) } // first call mstore(0x00, hex"771602f7") mstore(0x04, 0x01) mstore(0x24, 0x02) let success := staticcall(gas(), calledAddress, 0x00, 0x44, 0x60, 0x20) if iszero(success) { revert(0x00, 0x00) } let res1 := mload(0x60) // second call mstore(0x04, 0x03) mstore(0x24, 0x4) success := staticcall(gas(), calledAddress, 0x00, 0x44, 0x60, 0x20) if iszero(success) { revert(0x00, 0x00) } let res2 := mload(0x60) // add results let res := add(res1, res2) // return data mstore(0x60, res) return(0x60, 0x20) } } }
We save approximately 2,000 gas by using the scratch space to store the function selector and itโs arguments and also reusing the same memory space for the second call while storing the returned data in the zero slot thus not expanding memory.
If the arguments of the external function you wish to call is above 64 bytes and if you are making one external call, it wouldnโt save any significant gas writing it in assembly. However, if making more than one call. You can still save gas by reusing the same memory slot for the 2 calls using inline assembly.
Note: Always remember to update the free memory pointer if the offset it points to is already used, to avoid solidity overriding the data stored there or using the value stored there in an unexpected way.
Also note to avoid overwriting the zero slot (0x60 memory offset) if you have undefined dynamic memory values within that call stack. An alternative is to explicitly define dynamic memory values or if used, to set the slot back to 0x00 before exiting the assembly block.
File: contracts/EthenaMinting.sol 248 if (wallet == address(0) || !_custodianAddresses.contains(wallet)) revert InvalidAddress(); 253 IERC20(asset).safeTransfer(wallet, amount); 407 if (!_supportedAssets.contains(asset)) revert UnsupportedAsset(); 408 IERC20(asset).safeTransfer(beneficiary, amount); 426 token.safeTransferFrom(benefactor, addresses[i], amountToTransfer); 431 token.safeTransferFrom(benefactor, addresses[addresses.length - 1], remainingBalance);
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/EthenaMinting.sol#L248
Although the calldataload instruction is a cheap opcode, the solidity compiler will sometimes output cheaper code if you cache calldataload. This will not always be the case, so you should test both possibilities.
contract LoopSum { function sumArr(uint256[] calldata arr) public pure returns (uint256 sum) { uint256 len = arr.length; for (uint256 i = 0; i < len; ) { sum += arr[i]; unchecked { ++i; } } } }
File: contracts/EthenaMinting.sol 413 function _transferCollateral( 414 uint256 amount, 415 address asset, 416 address benefactor, 417 address[] calldata addresses, 418 uint256[] calldata ratios 419 ) internal {
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/EthenaMinting.sol#L413-L419
Prior to 0.8.10 the compiler inserted extra code, includingย EXTCODESIZEย (100 gas), to check for contract existence for external function calls. In more recent solidity versions, the compiler will not insert these checks if the external call has a return value. Similar behavior can be achieved in earlier versions by using low-level calls, since low level calls never check for contract existence.
File: contracts/EthenaMinting.sol 178 usde.mint(order.beneficiary, order.usde_amount); 206 usde.burnFrom(order.benefactor, order.usde_amount); 253 IERC20(asset).safeTransfer(wallet, amount); 408 IERC20(asset).safeTransfer(beneficiary, amount); 426 token.safeTransferFrom(benefactor, addresses[i], amountToTransfer); 431 token.safeTransferFrom(benefactor, addresses[addresses.length - 1], remainingBalance);
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/EthenaMinting.sol#L178
File: contracts/StakedUSDe.sol 96 IERC20(asset()).safeTransferFrom(msg.sender, address(this), amount); 140 IERC20(token).safeTransfer(to, amount);
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/StakedUSDe.sol#L96
The immutable keyword was introduced to optimize the storage of constant values, especially those that can be computed at compile-time, like the result of keccak256(). When you use immutable, the value is computed at compile-time and included directly in the contract's bytecode. It becomes a part of the contract's storage and is available without any runtime computation. This approach reduces gas when accessing the constant value because there's no need to compute it at runtime. So by using immutable, you save gas because you eliminate the need for runtime computation of constant values. The value is readily available in the contract's bytecode.
File: contracts/EthenaMinting.sol 28 bytes32 private constant EIP712_DOMAIN = 29 keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"); 32 bytes32 private constant ROUTE_TYPE = keccak256("Route(address[] addresses,uint256[] ratios)"); 35 bytes32 private constant ORDER_TYPE = keccak256( 36 "Order(uint8 order_type,uint256 expiry,uint256 nonce,address benefactor,address beneficiary,address collateral_asset,uint256 collateral_amount,uint256 usde_amount)" 37 ); 40 bytes32 private constant MINTER_ROLE = keccak256("MINTER_ROLE"); 43 bytes32 private constant REDEEMER_ROLE = keccak256("REDEEMER_ROLE"); 46 bytes32 private constant GATEKEEPER_ROLE = keccak256("GATEKEEPER_ROLE"); 49 bytes32 private constant EIP712_DOMAIN_TYPEHASH = keccak256(abi.encodePacked(EIP712_DOMAIN)); 55 bytes32 private constant EIP_712_NAME = keccak256("EthenaMinting"); 58 bytes32 private constant EIP712_REVISION = keccak256("1");
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/EthenaMinting.sol#L28
File: contracts/StakedUSDe.sol 26 bytes32 private constant REWARDER_ROLE = keccak256("REWARDER_ROLE"); 30 bytes32 private constant BLACKLIST_MANAGER_ROLE = keccak256("BLACKLIST_MANAGER_ROLE"); 32 bytes32 private constant SOFT_RESTRICTED_STAKER_ROLE = keccak256("SOFT_RESTRICTED_STAKER_ROLE");
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/StakedUSDe.sol#L26
<X> += <Y>
ย costs more gas than <X> = <X> + <Y>
ย for state variables<x> = <x> + <y>
is often more gas-efficient than <x> += <y>
for state variables. The += operator can incur higher gas costs because it involves both a read and a write operation on the state variable. In contrast, using <x> = <x> + <y>
explicitly reads and updates the state variable in a more optimized manner, reducing gas consumption.
File: contracts/EthenaMinting.sol 174 mintedPerBlock[block.number] += order.usde_amount; 205 redeemedPerBlock[block.number] += order.usde_amount;
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/EthenaMinting.sol#L174
File: contracts/StakedUSDeV2.sol 101 cooldowns[owner].underlyingAmount += assets; 117 cooldowns[owner].underlyingAmount += assets;
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/StakedUSDeV2.sol#L101
++i/i++
ย should be unchecked{++i}/unchecked{i++} when it's not possible for them to overflow, as is the case when used in for and while-loopsWhen you're sure that an operation won't lead to overflow or underflow, you can use unchecked arithmetic to potentially save gas and increase efficiency. For example, in cases where ++i and i++ won't result in overflow or underflow, you can use unchecked{++i} and unchecked{i++} to indicate that these operations are safe.
File: contracts/EthenaMinting.sol 126 for (uint256 i = 0; i < _assets.length; i++) { 130 for (uint256 j = 0; j < _custodians.length; j++) { 363 for (uint256 i = 0; i < route.addresses.length; ++i) { 424 for (uint256 i = 0; i < addresses.length; ++i) {
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/EthenaMinting.sol#L126
File: contracts/EthenaMinting.sol 439 emit MaxMintPerBlockChanged(oldMaxMintPerBlock, maxMintPerBlock); 446 emit MaxRedeemPerBlockChanged(oldMaxRedeemPerBlock, maxRedeemPerBlock);
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/EthenaMinting.sol#L439
File: contracts/SingleAdminAccessControl.sol 28 emit AdminTransferRequested(_currentDefaultAdmin, newAdmin); 74 emit AdminTransferred(_currentDefaultAdmin, account);
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/SingleAdminAccessControl.sol#L28
File: contracts/StakedUSDeV2.sol 133 emit CooldownDurationUpdated(previousDuration, cooldownDuration);
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/StakedUSDeV2.sol#L133
File: contracts/USDe.sol 24 emit MinterUpdated(newMinter, minter);
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/USDe.sol#L24
A common pattern, especially in airdrops, is to mark an address as โalready usedโ when claiming the airdrop or NFT mint.
However, since it only takes one bit to store this information, and each slot is 256 bits, that means one can store a 256 flags/booleans with one storage slot.
File: contracts/EthenaMinting.sol 86 mapping(address => mapping(address => bool)) public delegatedSigner; 236 delegatedSigner[_delegateTo][msg.sender] = true; 242 delegatedSigner[_removedSigner][msg.sender] = false;
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/EthenaMinting.sol#L86
Caching the result of function calls is a common gas optimization technique in Solidity. When a function's result doesn't change between multiple uses within the same transaction or block, caching can save gas by preventing redundant computations.
File: contracts/StakedUSDe.sol 149 if (hasRole(FULL_RESTRICTED_STAKER_ROLE, from) && !hasRole(FULL_RESTRICTED_STAKER_ROLE, to)) { 210 if (hasRole(SOFT_RESTRICTED_STAKER_ROLE, caller) || hasRole(SOFT_RESTRICTED_STAKER_ROLE, receiver)) {
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/StakedUSDe.sol#L149
Inlining a modifier that is used only once and not inherited can help save gas by reducing the overhead of function call and stack management associated with applying a modifier. Inlining essentially means directly including the code of the modifier within the function where it's used. This is a manual optimization that can be applied in cases where the modifier is simple and only used in one place.
File: contracts/EthenaMinting.sol // @audit This modifier is only used in mint() function it's gas saving to use inline 97 modifier belowMaxMintPerBlock(uint256 mintAmount) { 98 if (mintedPerBlock[block.number] + mintAmount > maxMintPerBlock) revert MaxMintPerBlockExceeded(); 99 _; 100 } // @audit This modifier is only used in redeem() function it's gas saving to use inline 104 modifier belowMaxRedeemPerBlock(uint256 redeemAmount) { 105 if (redeemedPerBlock[block.number] + redeemAmount > maxRedeemPerBlock) revert MaxRedeemPerBlockExceeded(); 106 _; 107 }
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/EthenaMinting.sol#L97-L107
File: contracts/USDeSilo.sol // @audit This modifier is only used in this withdraw() function it's gas saving to use inline 23 modifier onlyStakingVault() { 24 if (msg.sender != STAKING_VAULT) revert OnlyStakingVault(); 25 _; 26 }
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/USDeSilo.sol#L23-L26
abi.encode() is more gas-efficient when encoding function calls according to the Ethereum ABI. It includes the necessary function selectors and parameter encoding.abi.encodePacked() is more efficient for simply concatenating raw data without ABI-related encoding, making it a preferred choice when ABI structure isn't required.Your choice between the two functions should be based on whether you need ABI compliance for contract interactions or a more lightweight data concatenation.
File: contracts/EthenaMinting.sol 321 return abi.encode( 335 return abi.encode(ROUTE_TYPE, route.addresses, route.ratios); 425 return keccak256(abi.encode(EIP712_DOMAIN, EIP_712_NAME, EIP712_REVISION, block.chainid, address(this)));
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/EthenaMinting.sol#L321
Using != 0 is more gas-efficient than > 0 when checking if a value is non-zero because the != comparison directly checks for inequality, resulting in lower gas costs.
File: contracts/EthenaMinting.sol 430 if (remainingBalance > 0) {
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/EthenaMinting.sol#L430
File: contracts/StakedUSDe.sol 90 if (getUnvestedAmount() > 0) revert StillVesting(); 193 if (_totalSupply > 0 && _totalSupply < MIN_SHARES) revert MinSharesViolation();
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/StakedUSDe.sol#L90
An operation that causes the solidity compiler to expand memory is making external calls. When making external calls the compiler has to encode the function signature of the function it wishes to call on the external contract alongside itโs arguments in memory. As we know, solidity does not clear or reuse memory memory so itโll have to store these data in the next free memory pointer which expands memory further.
With inline assembly, we can either use the scratch space and free memory pointer offset to store this data (as above) if the function arguments do not take up more than 96 bytes in memory. Better still, if we are making more than one external call we can reuse the same memory space as the first calls to store the new arguments in memory without expanding memory unnecessarily. Solidity in this scenario would expand memory by as much as the returned data length is. This is because the returned data is stored in memory (in most cases). If the return data is less than 96 bytes, we can use the scratch space to store it to prevent expanding memory.
See the example below;
contract Called { function add(uint256 a, uint256 b) external pure returns(uint256) { return a + b; } } contract Solidity { // cost: 7262 function call(address calledAddress) external pure returns(uint256) { Called called = Called(calledAddress); uint256 res1 = called.add(1, 2); uint256 res2 = called.add(3, 4); uint256 res = res1 + res2; return res; } } contract Assembly { // cost: 5281 function call(address calledAddress) external view returns(uint256) { assembly { // check that calledAddress has code deployed to it if iszero(extcodesize(calledAddress)) { revert(0x00, 0x00) } // first call mstore(0x00, hex"771602f7") mstore(0x04, 0x01) mstore(0x24, 0x02) let success := staticcall(gas(), calledAddress, 0x00, 0x44, 0x60, 0x20) if iszero(success) { revert(0x00, 0x00) } let res1 := mload(0x60) // second call mstore(0x04, 0x03) mstore(0x24, 0x4) success := staticcall(gas(), calledAddress, 0x00, 0x44, 0x60, 0x20) if iszero(success) { revert(0x00, 0x00) } let res2 := mload(0x60) // add results let res := add(res1, res2) // return data mstore(0x60, res) return(0x60, 0x20) } } }
We save approximately 2,000 gas by using the scratch space to store the function selector and itโs arguments and also reusing the same memory space for the second call while storing the returned data in the zero slot thus not expanding memory.
If the arguments of the external function you wish to call is above 64 bytes and if you are making one external call, it wouldnโt save any significant gas writing it in assembly. However, if making more than one call. You can still save gas by reusing the same memory slot for the 2 calls using inline assembly.
Note: Always remember to update the free memory pointer if the offset it points to is already used, to avoid solidity overriding the data stored there or using the value stored there in an unexpected way.
Also note to avoid overwriting the zero slot (0x60 memory offset) if you have undefined dynamic memory values within that call stack. An alternative is to explicitly define dynamic memory values or if used, to set the slot back to 0x00 before exiting the assembly block.
File: contracts/EthenaMinting.sol 248 if (wallet == address(0) || !_custodianAddresses.contains(wallet)) revert InvalidAddress(); 250 (bool success,) = wallet.call{value: amount}(""); 253 IERC20(asset).safeTransfer(wallet, amount); 404 (bool success,) = (beneficiary).call{value: amount}(""); 407 if (!_supportedAssets.contains(asset)) revert UnsupportedAsset(); 408 IERC20(asset).safeTransfer(beneficiary, amount); 421 if (!_supportedAssets.contains(asset) || asset == NATIVE_TOKEN) revert UnsupportedAsset(); 426 token.safeTransferFrom(benefactor, addresses[i], amountToTransfer); 431 token.safeTransferFrom(benefactor, addresses[addresses.length - 1], remainingBalance);
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/EthenaMinting.sol#L248
In Solidity, when you evaluate a boolean expression (e.g the || (logical or) or && (logical and) operators), in the case of || the second expression will only be evaluated if the first expression evaluates to false and in the case of && the second expression will only be evaluated if the first expression evaluates to true. This is called short-circuiting.
For example, the expression require(msg.sender == owner || msg.sender == manager) will pass if the first expression msg.sender == owner evaluates to true. The second expression msg.sender == manager will not be evaluated at all.
However, if the first expression msg.sender == owner evaluates to false, the second expression msg.sender == manager will be evaluated to determine whether the overall expression is true or false. Here, by checking the condition that is most likely to pass firstly, we can avoid checking the second condition thereby saving gas in majority of successful calls.
This is similar for the expression require(msg.sender == owner && msg.sender == manager). If the first expression msg.sender == owner evaluates to false, the second expression msg.sender == manager will not be evaluated because the overall expression cannot be true. For the overall statement to be true, both side of the expression must evaluate to true. Here, by checking the condition that is most likely to fail firstly, we can avoid checking the second condition thereby saving gas in majority of call reverts.
Short-circuiting is useful and itโs recommended to place the less expensive expression first, as the more costly one might be bypassed. If the second expression is more important than the first, it might be worth reversing their order so that the cheaper one gets evaluated first.
File: contracts/EthenaMinting.sol 248 if (wallet == address(0) || !_custodianAddresses.contains(wallet)) revert InvalidAddress(); 291 if (asset == address(0) || asset == address(usde) || !_supportedAssets.add(asset)) { 299 if (custodian == address(0) || custodian == address(usde) || !_custodianAddresses.add(custodian)) { 364 if (!_custodianAddresses.contains(route.addresses[i]) || route.addresses[i] == address(0) || route.ratios[i] == 0) 421 if (!_supportedAssets.contains(asset) || asset == NATIVE_TOKEN) revert UnsupportedAsset();
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/EthenaMinting.sol#L248
File: contracts/StakedUSDe.sol 75 if (_owner == address(0) || _initialRewarder == address(0) || address(_asset) == address(0)) { 149 if (hasRole(FULL_RESTRICTED_STAKER_ROLE, from) && !hasRole(FULL_RESTRICTED_STAKER_ROLE, to)) { 193 if (_totalSupply > 0 && _totalSupply < MIN_SHARES) revert MinSharesViolation(); 246 if (hasRole(FULL_RESTRICTED_STAKER_ROLE, from) && to != address(0)) { 210 if (hasRole(SOFT_RESTRICTED_STAKER_ROLE, caller) || hasRole(SOFT_RESTRICTED_STAKER_ROLE, receiver)) { 232 if (hasRole(FULL_RESTRICTED_STAKER_ROLE, caller) || hasRole(FULL_RESTRICTED_STAKER_ROLE, receiver)) {
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/StakedUSDe.sol#L75
File: contracts/StakedUSDe.sol 139 if (address(token) == asset()) revert InvalidToken();
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/StakedUSDe.sol#L139C1-L140C1
StakedUSDeV2.sol.cooldownAssets(): silo should not be cast to address as itโs declared as an address
File: contracts/StakedUSDeV2.sol 103 _withdraw(_msgSender(), address(silo), owner, assets, shares); 119 _withdraw(_msgSender(), address(silo), owner, assets, shares);
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/StakedUSDeV2.sol#L103
File: contracts/EthenaMinting.sol // @audit usde unnecessary casting the usde 291 if (asset == address(0) || asset == address(usde) || !_supportedAssets.add(asset)) { 299 if (custodian == address(0) || custodian == address(usde) || !_custodianAddresses.add(custodian)) {
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/EthenaMinting.sol#L291C1
It can be more gas-efficient to use a hardcoded address instead of the address(this) expression, especially if you need to use the same address multiple times in your contract.
The reason for this is that using address(this) requires an additional EXTCODESIZE operation to retrieve the contract's address from its bytecode, which can increase the gas cost of your contract. By pre-calculating and using a hardcoded address, you can avoid this additional operation and reduce the overall gas cost of your contract.
File: contracts/EthenaMinting.sol 403 if (address(this).balance < amount) revert InvalidAmount(); 452 return keccak256(abi.encode(EIP712_DOMAIN, EIP_712_NAME, EIP712_REVISION, block.chainid, address(this)));
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/EthenaMinting.sol#L403
File: contracts/StakedUSDe.sol 96 IERC20(asset()).safeTransferFrom(msg.sender, address(this), amount); 167 return IERC20(asset()).balanceOf(address(this)) - getUnvestedAmount();
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/StakedUSDe.sol#L167
File: contracts/StakedUSDeV2.sol 43 silo = new USDeSilo(address(this), address(_asset));
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/StakedUSDeV2.sol#L43
Using assembly for math operations like addition, subtraction, multiplication, and division in Solidity can be more gas-efficient in certain cases. Here's an example of how to perform these operations using inline assembly:
// SPDX-License-Identifier: MIT pragma solidity ^0.8.0; contract MathOperations { function add(uint256 a, uint256 b) public pure returns (uint256 result) { assembly { result := add(a, b) } } function subtract(uint256 a, uint256 b) public pure returns (uint256 result) { assembly { result := sub(a, b) } } function multiply(uint256 a, uint256 b) public pure returns (uint256 result) { assembly { result := mul(a, b) } } function divide(uint256 a, uint256 b) public pure returns (uint256 result) { assembly { result := div(a, b) } } }
In this example, we've defined functions for addition, subtraction, multiplication, and division using inline assembly. The add, sub, mul, and div assembly instructions perform these mathematical operations
File: contracts/EthenaMinting.sol 98 if (mintedPerBlock[block.number] + mintAmount > maxMintPerBlock) revert MaxMintPerBlockExceeded(); 105 if (redeemedPerBlock[block.number] + redeemAmount > maxRedeemPerBlock) revert MaxRedeemPerBlockExceeded(); 425 uint256 amountToTransfer = (amount * ratios[i]) / 10_000; 429 uint256 remainingBalance = amount - totalTransferred; 431 token.safeTransferFrom(benefactor, addresses[addresses.length - 1], remainingBalance);
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/EthenaMinting.sol#L98
File: contracts/StakedUSDe.sol 91 uint256 newVestingAmount = amount + getUnvestedAmount(); 167 return IERC20(asset()).balanceOf(address(this)) - getUnvestedAmount(); 174 uint256 timeSinceLastDistribution = block.timestamp - lastDistributionTimestamp; 180 return ((VESTING_PERIOD - timeSinceLastDistribution) * vestingAmount) / VESTING_PERIOD;
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/StakedUSDe.sol#L91
File: contracts/StakedUSDeV2.sol 100 cooldowns[owner].cooldownEnd = uint104(block.timestamp) + cooldownDuration; 116 cooldowns[owner].cooldownEnd = uint104(block.timestamp) + cooldownDuration;
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/StakedUSDeV2.sol#L100
It's recommended to use the bytes.concat() function instead of abi.encodePacked() for concatenating byte arrays. bytes.concat() is a more gas-efficient and safer way to concatenate byte arrays, and it's considered a best practice in newer Solidity versions.
File: contracts/EthenaMinting.sol 49 bytes32 private constant EIP712_DOMAIN_TYPEHASH = keccak256(abi.encodePacked(EIP712_DOMAIN));
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/EthenaMinting.sol#L49
onlyRole(MINTER_ROLE)
no uses of the nonReentrant modifierFile: contracts/EthenaMinting.sol 162 function mint(Order calldata order, Route calldata route, Signature calldata signature) 163 external 164 override 165 nonReentrant 166 onlyRole(MINTER_ROLE) 167 belowMaxMintPerBlock(order.usde_amount) 168 { 194 function redeem(Order calldata order, Signature calldata signature) 195 external 196 override 197 nonReentrant 198 onlyRole(REDEEMER_ROLE) 199 belowMaxRedeemPerBlock(order.usde_amount) 200 { 247 function transferToCustody(address wallet, address asset, uint256 amount) external nonReentrant onlyRole(MINTER_ROLE) {
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/EthenaMinting.sol#L162-L168
Variables that are never updated should be immutable or constant In Solidity, variables which are not intended to be updated should be constant or immutable.
This is because constants and immutable values are embedded directly into the bytecode of the contract which they are defined and does not use storage because of this.
// SPDX-License-Identifier: MIT pragma solidity 0.8.20; contract Constants { uint256 constant MAX_UINT256 = 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff; function get_max_value() external pure returns (uint256) { return MAX_UINT256; } } // This uses more gas than the above contract contract NoConstants { uint256 MAX_UINT256 = 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff; function get_max_value() external view returns (uint256) { return MAX_UINT256; } }
This saves a lot of gas as we do not make any storage reads which are costly.
File: contracts/EthenaMinting.sol 66 EnumerableSet.AddressSet internal _supportedAssets;
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/EthenaMinting.sol#L66
File: contracts/StakedUSDeV2.sol 22 uint24 public MAX_COOLDOWN_DURATION = 90 days;
https://github.com/code-423n4/2023-10-ethena/blob/main/contracts/StakedUSDeV2.sol#L22
#0 - c4-pre-sort
2023-11-01T18:51:30Z
raymondfam marked the issue as sufficient quality report
#1 - c4-judge
2023-11-10T20:21:20Z
fatherGoose1 marked the issue as grade-a
๐ 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
88.7348 USDC - $88.73
Ethena is a synthetic dollar protocol built on Ethereum, providing a censorship-resistant and scalable crypto-native solution for money. Its stablecoin, USDe, is fully backed and composed throughout DeFi, ensuring delta-hedging and mint/redeem mechanisms for peg stability. Ethena introduces the 'Internet Bond,' combining yield from staked Ethereum and funding from perpetual and futures markets, serving as a dollar-denominated savings instrument. The goal is to offer a permissionless stablecoin, USDe, and incentivize users by allowing them to stake USDe for stUSDe, which increases in value as the protocol earns yield, similar to rETH and ETH relationship.
The key contracts of Ethena Labs protocol for this Audit are:
Auditing the key contracts of the Ethena Labs Protocol is essential to ensure the security of the protocol. Focusing on them first will provide a solid foundation for understanding the protocol's operation and how it manages user assets and stability. Here's why it's important to audit these specific contracts:
EthenaMinting.sol: This contract have a central role in the minting and redemption processes, and it handles funds. The MINTER and REDEEMER roles are crucial for the security of the system. If an attacker compromises the MINTER role, they could potentially mint a large amount of USDe. Similarly, if REDEEMER is compromised, it could lead to a significant loss of collateral. Therefore, auditing this contract first is a priority to ensure the security of minting and redemption processes.
USDe.sol: This is a stablecoin contract. It is essential to ensure that this contract correctly interacts with EthenaMinting.sol, specifically with regards to minting. Any issues in this contract could have a significant impact on your entire ecosystem. Ensure that the ownership mechanisms are secure and that the minter address is appropriately managed to prevent unauthorized minting.
StakedUSDeV2.sol: This contract allows users to stake USDe and earn yield. While it's not directly involved in the minting and redemption processes, it's still a part of your financial ecosystem. You should audit it to ensure that staking, unstaking, and the yield distribution functions work as intended and that the cooldown period is correctly implemented. Be mindful of the different roles (SOFT_RESTRICTED_STAKER_ROLE and FULL_RESTRICTED_STAKER_ROLE) and their permissions.
USDeSilo.sol: This contract is essential for the unstaking process and the cooldown period. You need to ensure that funds are correctly held during the cooldown and released to users as intended. Any vulnerabilities in this contract could affect the ability of users to withdraw their funds.
SingleAdminAccessControl.sol: This contract is used by EthenaMinting.sol, so it's important to ensure that the access control mechanisms are correctly implemented. Any issues with this contract could affect the permissions of EthenaMinting.sol.
As the audit of these contracts, I checked for potential security vulnerabilities, such as reentrancy, access control issues, and logic flaws. Additionally, thoroughly test the functions and roles defined in these contracts to make sure they behave as expected.
USDe.sol: This contract is a stablecoin contract called USDe. inheriting key features from OpenZeppelin contracts. The contract allows for the minting of new tokens, but only a single approved minter can perform this action. Ownership is managed through a two-step transfer mechanism. The contract initializes with the "USDe" name and symbol, along with an initial admin address. Additionally, it enforces strict control over minting and ownership changes.
EthenaMinting.sol: EthenaMinting contract is designed for minting and redeeming the "USDe" stablecoin in a single, atomic, trustless transaction. It enforces roles for minting, redemption, and emergency control. Additionally, it manages supported assets and custodian addresses. The contract sets maximum limits for minting and redemption per block to ensure security. It allows for delegation of signing authority to external accounts and ensures the validity of orders and routes. Overall, this contract provides a robust and controlled mechanism for handling the "USDe" stablecoin's minting and redemption.
StakedUSDe.sol: The StakedUSDe contract enables users to stake "USDe" tokens and earn protocol rewards. It features various roles for reward distribution, blacklist management, and restricting users from staking. The contract holds a vesting mechanism for rewards and enforces a minimum shares requirement. Users can deposit, withdraw, and transfer their staked assets, with specific restrictions in place. Additionally, the contract includes functions for token rescue and redistribution of locked amounts, as well as address blacklisting and un-blacklisting.
StakedUSDeV2.sol: This contract allows users to stake USDe tokens and earn rewards in the form of protocol LST and perpetual yield. The rewards are allocated to stakers based on a governance-voted yield distribution algorithm. The contract implements a cooldown mechanism, where users can stake or redeem their assets and initiate a cooldown period before claiming the underlying assets. The cooldown duration can be set by the contract owner and determines the length of the cooldown period. If the cooldown duration is set to zero, the contract follows the ERC4626 standard and disables certain functions related to cooldown. The contract also includes a silo contract (USDeSilo) that handles the withdrawal and storage of assets.
USDeSilo.sol: The contract used to store USDe tokens during the stake cooldown process. The contract takes two parameters in its constructor: the address of the staking vault and the address of the USDe token. The staking vault is the only entity that is allowed to interact with this contract, as indicated by the onlyStakingVault modifier. The contract provides a withdraw function that allows the staking vault to transfer USDe tokens from the silo to a specified recipient address. SafeERC20 is used to ensure secure token transfers.
SingleAdminAccessControl.sol: The SingleAdminAccessControl contract establishes a single admin role and admin transfer functionality. It uses OpenZeppelin's AccessControl for role management. Users can grant and revoke roles, except for the admin role. The contract also supports role renunciation. It enforces a transition process to change the admin role.
Some privileged roles exercise powers over the Controller contract:
modifier notAdmin(bytes32 role) { if (role == DEFAULT_ADMIN_ROLE) revert InvalidAdminChange(); _; }
modifier notOwner(address target) { if (target == owner()) revert CantBlacklistOwner(); _; }
modifier onlyStakingVault() { if (msg.sender != STAKING_VAULT) revert OnlyStakingVault(); _; }
USDe minter - can mint any amount of USDe tokens to any address. Expected to be the EthenaMinting contract USDe owner - can set token minter and transfer ownership to another address USDe token holder - can not just transfer tokens but burn them and sign permits for others to spend their balance StakedUSDe admin - can rescue tokens from the contract and also to redistribute a fully restricted staker's stUSDe balance, as well as give roles to other addresses (for example the FULL_RESTRICTED_STAKER_ROLE role) StakedUSDeV2 admin - has all power of "StakedUSDe admin" and can also call the setCooldownDuration method REWARDER_ROLE - can transfer rewards into the StakedUSDe contract that will be vested over the next 8 hours BLACKLIST_MANAGER_ROLE - can do/undo full or soft restriction on a holder of stUSDe SOFT_RESTRICTED_STAKER_ROLE - address with this role can't stake his USDe tokens or get stUSDe tokens minted to him FULL_RESTRICTED_STAKER_ROLE - address with this role can't burn his stUSDe tokens to unstake his USDe tokens, neither to transfer stUSDe tokens. His balance can be manipulated by the admin of StakedUSDe MINTER_ROLE - can actually mint USDe tokens and also transfer EthenaMinting's token or ETH balance to a custodian address REDEEMER_ROLE - can redeem collateral assets for burning USDe EthenaMinting admin - can set the maxMint/maxRedeem amounts per block and add or remove supported collateral assets and custodian addresses, grant/revoke roles GATEKEEPER_ROLE - can disable minting/redeeming of USDe and remove MINTER_ROLE and REDEEMER_ROLE roles from authorized accounts
Accordingly, I analyzed and audited the subject in the following steps;
Core Protocol Contract Overview:
I focused on thoroughly understanding the codebase and providing recommendations to improve its functionality. The main goal was to take a close look at the important contracts and how they work together in the Ethena Labs Protocol. I started with the contract that is at the core of the system and from which other contracts interact or depend on. In this case, I start with the following contracts, which play crucial roles in the Ethena system:
Main Contracts I Looked At
USDe.sol EthenaMinting.sol StakedUSDeV2.sol
I started my analysis by examining the USDe.sol contract. Begin by understanding stablecoin contract, that serves as the base currency within the Ethena system. By starting with I gain a clear understanding of the currency that all other operations and contracts revolve around. It sets the foundation for the entire ecosystem.
Then, I turned our attention to the EthenaMinting.sol, this contract is responsible for minting and redemption processes. These are critical operations in the system, and understanding how they work is essential. after understand minting and redemption, I better grasp the flow of funds and value within the system.
Then Dive into StakedUSDeV2.sol contract, nce I've grasped the minting and redemption processes, now I can explore the staking contract, StakedUSDeV2.sol. It allows users to stake their stablecoin and earn stUSDe. While not as foundational as USDe and EthenaMinting, it adds an additional layer to the ecosystem.
Documentation Review:
Then went to Review This Docs for a more detailed and technical explanation of Ethena protocol.
Manuel Code Review In this phase, I initially conducted a line-by-line analysis, following that, I engaged in a comparison mode.
Line by Line Analysis: Pay close attention to the contract's intended functionality and compare it with its actual behavior on a line-by-line basis.
Comparison Mode: Compare the implementation of each function with established standards or existing implementations, focusing on the function names to identify any deviations.
Architecture of the key contracts that are part of the Ethena Labs protocol:
This diagram illustrates the flow for the USDe token minting/redeeming of the Ethena Labs
Validation of the Order
Ethena validates the incoming order as follows:
Calculates the hash of the order using verifyOrder()
, resulting in a hashed order.
Recovers the signer's address from the order using ECDSA and verifyOrder()
, obtaining the signer.
Validates the order:
Processing Valid Orders
If the order is valid and not a duplicate, Ethena processes the redemption:
increaseRedeemedPerBlock()
to handle the following:
burnFrom
function.Handling Duplicate Orders
If the order is a duplicate, Ethena takes the following actions:
This process ensures the security and validity of redemption orders while handling ETH and token transfers according to the asset type specified in the order.
Implement multisig for admin and rewarder roles rather than single addresses. This could be a 3/5 multisig controlled by DAO members.
Introduce on-chain governance for parameters like vesting periods, blacklisting rules, etc. Stakers can vote on these to distribute control.
Add price feeds from multiple decentralized oracles rather than fully relying on USDe stability alone. This reduces counterparty risk, Fully off-chain components like collateral custody also introduce counterparty risks. Transparency could be improved through on-chain reserves tracking.
Consider alternative collateral models that don't rely on USDe, like overcollateralized staking or basket of assets.
Consider migrating to EIP-2612 standard for upgrades to introduce future improvements securely.
Add on-chain decentralized governance for parameters like minter approval in USDe.sol contract.
Overall, I consider the quality of the Ethena codebase to be excellent. The code appears to be very mature and well-developed. We have noticed the implementation of various standards adhere to appropriately. Details are explained below:
Codebase Quality Categories | Comments |
---|---|
Code Maintainability and Reliability | The Ethena Labs Protocol contracts demonstrates good maintainability through modular structure, consistent naming, and efficient use of libraries. It also prioritizes reliability by handling errors, conducting security checks, and using "immutable" variables. However, for enhanced reliability, consider professional security audits and documentation improvements. Regular updates are crucial in the evolving space. |
Code Comments | The Ethena Labs codebase contains comments that briefly explain the purpose of important code elements, such as data structures, constants, and function modifiers. While these comments offer some clarity, further in-code comments are needed for individual functions and complex logic to enhance code maintainability and comprehension. Although there are some comments, adding more detailed explanations for complex functions, data structures, and key decisions can be helpful to future developers and auditors. Consider following standardized commenting conventions, such as NatSpec. |
Documentation | The documentation of the Ethena Labs project is quite comprehensive and detailed, providing a solid overview of how Ethena Labs is structured and how its various aspects function. However, we have noticed that there is room for additional details, such as diagrams, to gain a deeper understanding of how different contracts interact and the functions they implement. With considerable enthusiasm. We are confident that these diagrams will bring significant value to the protocol as they can be seamlessly integrated into the existing documentation, enriching it and providing a more comprehensive and detailed understanding for users, developers and auditors. |
Testing | The audit scope of the contracts to be audited is 70% and it should be aimed to be 100%. |
Code Structure and Formatting | The codebase contracts are well-structured and formatted. It inherits from multiple components, adhering for-example The USDe contract follows best practices, utilizes OpenZeppelin libraries, and ensures robust access control and error handling for safe and trustworthy operations. It's designed to be a critical part of a larger system that involves USDe and custodian addresses.The contract initializes immutable variables in the constructor. |
The analysis provided highlights several significant systemic and centralization risks present in the Ethena Labs protocol. These risks encompass concentration risk in EthenaMinting, StakedUSDe, StakedUSDeV2 risk and more, third-party dependency risk, and centralization risks arising from the existence of an โownerโ role in specific contracts. However, the documentation lacks clarity on whether this address represents an externally owned account (EOA) or a contract, warranting the need for clarification. Additionally, the absence of fuzzing and invariant tests could also pose risks to the protocolโs security.
Here's an analysis of potential systemic and centralization risks in the provided contracts:
No having, fuzzing and invariant tests could open the door to future vulnerabilities.
If the assets backing the stablecoin lose value significantly (e.g. due to market crash), it could cause a run on the stablecoin and collapse of its peg. This could spread risk to other DeFi protocols using USDe.
If the stablecoin becomes widely used in DeFi but later fails or exits, it could cause liquidity issues and losses across many applications.
A compromise of the admin, gatekeeper or minter roles would allow unauthorized minting/redeeming, threatening stability
StakedUSDe.sol contract relies on users staking USDe tokens. If a significant number of users decide to stake or unstake their tokens simultaneously, it may lead to potential issues such as front-running, network congestion, and gas price spikes and the contract depends on rewards being distributed from another contract (the rewarder). Delays or issues in rewards distribution can affect the staking and unstaking experience for users, potentially leading to dissatisfaction or withdrawal of staked assets.
In StakedUSDe.sol contract includes a vesting period for rewards. If the vesting period is too long or if it's not properly enforced, it can result in users feeling like their rewards are locked up for an extended period.
The contract USDe.sol allows only a single approved minter to mint new tokens. This introduces a significant centralization risk because the entire minting process depends on this single entity. If the minter's private key is compromised, it could lead to unauthorized minting of tokens, affecting the stability and trustworthiness of the system and While not visible in the provided code, if there are custodian addresses involved in managing assets, the centralization risk increases. Custodians hold significant power over the assets, and their compromise or mismanagement can impact the stability of the stablecoin.
Off-chain custodians hold the backing assets in EthenaMinting contract, but there is no on-chain transparency into reserves or attestations of their backing. Users must fully trust custodians.
In this contract EthenaMinting the ability for contracts to delegate signing to EOA addresses could introduce centralization risk, as it allows external addresses to act on behalf of the contract. Care must be taken to ensure that only trusted entities can delegate.
The REWARDER_ROLE can control the distribution of rewards to the contract in StakedUSDe.sol. The initial rewarder and any subsequent rewarder role holders have significant control over the rewards allocated to stakers, potentially centralizing power in the hands of a few entities and the contract can restrict transfers and staking for certain addresses based on roles. While this feature can be used for security and compliance reasons, it also centralizes the decision-making process.
Properly managing these risks and implementing best practices in security and decentralization will contribute to the sustainability and long-term success of the Ethena Labs protocol.
In general, the Ethena Labs project exhibits an interesting and well-developed architecture we believe the team has done a good job regarding the code, but the identified risks need to be addressed, and measures should be implemented to protect the protocol from potential malicious use cases. Additionally, it is recommended to improve the documentation and comments in the code to enhance understanding and collaboration among developers. It is also highly recommended that the team continues to invest in security measures such as mitigation reviews, audits, and bug bounty programs to maintain the security and reliability of the project.
12 hours
#0 - c4-pre-sort
2023-11-01T14:54:25Z
raymondfam marked the issue as sufficient quality report
#1 - c4-judge
2023-11-10T19:55:59Z
fatherGoose1 marked the issue as grade-a