Ethena Labs - hunter_w3b's results

Enabling The Internet Bond

General Information

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

Ethena Labs

Findings Distribution

Researcher Performance

Rank: 29/147

Findings: 3

Award: $176.43

QA:
grade-b
Gas:
grade-a
Analysis:
grade-a

๐ŸŒŸ Selected for report: 0

๐Ÿš€ Solo Findings: 0

[L-01] Overreliance on ERC20 interface without Pausable token functionality - no pause minting/transfers in emergency

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

[L-02] No events on critical actions like minting - lack of transparency into token supply changes

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

[L-03] Delegated signatures have no expiration, risk of indefinite delegation.

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.

[L-04] Route verification only checks arrays match, not actual logic.

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

Awards

83.1792 USDC - $83.18

Labels

bug
G (Gas Optimization)
grade-a
sufficient quality report
G-23

External Links

Gas Optimization For Ethena Labs Protocol

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:

Summary

FindingIssuesInstances
1Use assembly to validate msg.sender4
2Using a positive conditional flow to save a NOT opcode11
3Access mappings directly rather than using accessor functions2
4Use assembly for small keccak256 hashes, in order to save gas2
5Using this to access functions result in an external call, wasting gas2
6Expensive operation inside a for loop2
7Cache external calls outside of loop to avoid re-calling function on each iteration2
8Avoid having ERC20 token balances go to zero, always keep a small amount4
9Timestamps and block numbers in storage do not need to be uint2565
10Count from n to zero instead of counting from zero to n2
11Use selfdestruct in the constructor if the contract is one-time use5
12Use transfer hooks for tokens instead of initiating a transfer from the destination smart contract3
13Use fallback or receive instead of deposit() when transferring Ether1
14Avoid contract calls by making the architecture monolithic4
15Use one ERC1155 or ERC6909 token instead of several ERC20 tokens4
16Consider using alternatives to OpenZeppelin-
17Use vanity addresses (safely!)1
18Using assembly to revert with an error message52
19Use assembly to reuse memory space when making more than one external call6
20It is sometimes cheaper to cache calldata2
21Avoid contract existence checks by using low level calls8
22Expressions for constant values such as a call to keccak256(), should use immutable rather than constant12
23<X> += <Y>ย costs more gas than <X> = <X> + <Y>ย for state variables4
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 for4
25Should use arguments instead of state variable in emit to save some amount of gas6
26Use bitmaps instead of bools when a significant amount of booleans are used3
27The result of function calls should be cached rather than re-calling the function2
28A modifier used only once and not being inherited should be inlined to save gas3
29abi.encode()ย is less efficient thanย abi.encodePacked()3
30Using > 0 costs more gas than != 03
31Use assembly to reuse memory space when making more than one external call9
32Short-circuit booleans11
33Unnecessary casting as variable is already of the same type5
34Use hardcode address instead address(this)5
35Use assembly for math (add, sub, mul, div)11
36Use bytes.concat() instead of abi.encodePacked(), since this is preferred since 0.8.41
37onlyRole(MINTER_ROLE) no uses of the nonReentrant modifier3
38State variables which are not modified within functions should be set as constants or immutable for values set at deployment2

These optimization techniques and recommendations can help developers reduce gas costs and improve the efficiency of the Ethena Labs Protocol contracts.It's essential for developers to exercise caution and conduct thorough testing and code reviews when implementing these optimizations to ensure the security and correctness of their contracts.

[G-01] Use assembly to validate msg.sender

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

[G-02] Using a positive conditional flow to save a NOT opcode

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

[G-03] Access mappings directly rather than using accessor functions

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

[G-04] Use assembly for small keccak256 hashes, in order to save gas

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

[G-05] Using this to access functions result in an external call, wasting gas

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

[G-06] Expensive operation inside a for loop

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

[G-07] Cache external calls outside of loop to avoid re-calling function on each iteration

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    }

[G-08] Avoid having ERC20 token balances go to zero, always keep a small amount

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

[G-09] Timestamps and block numbers in storage do not need to be uint256

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

[G-10] Count from n to zero instead of counting from zero to n

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

[G-11] Use selfdestruct in the constructor if the contract is one-time use

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

[G-12] Use transfer hooks for tokens instead of initiating a transfer from the destination smart contract

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

[G-13] Use fallback or receive instead of deposit() when transferring Ether

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

[G-14] Avoid contract calls by making the architecture monolithic

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

[G-15] Use one ERC1155 or ERC6909 token instead of several ERC20 tokens

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

[G-16] Consider using alternatives to OpenZeppelin

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.

[G-17] Use vanity addresses (safely!)

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

[G-18] Using assembly to revert with an error message

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

[G-19] Use assembly to reuse memory space when making more than one external call

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

[G-20] It is sometimes cheaper to cache calldata

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

[G-21] Avoid contract existence checks by using low level calls

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

[G-22] Expressions for constant values such as a call to keccak256(), should use immutable rather than constant

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

[G-23] <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

[G-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 and while-loops

When 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

[G-25] Should use arguments instead of state variable in emit to save some amount of gas

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

[G-26] Use bitmaps instead of bools when a significant amount of booleans are used

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

[G-27] The result of function calls should be cached rather than re-calling the function

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

[G-28] A modifier used only once and not being inherited should be inlined to save gas

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

[G-29] abi.encode()ย is less efficient thanย abi.encodePacked()

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

[G-30] Using > 0 costs more gas than != 0

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

[G-31] Use assembly to reuse memory space when making more than one external call

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

[G-32] Short-circuit booleans

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

[G-33] Unnecessary casting as variable is already of the same type

StakedUSDe.sol.rescueTokens(): token should not be cast to address as itโ€™s declared as an address

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

[G-34] Use hardcode address instead address(this)

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

[G-35] Use assembly for math (add, sub, mul, div)

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

[G-36] Use bytes.concat() instead of abi.encodePacked(), since this is preferred since 0.8.4

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

[G-37] onlyRole(MINTER_ROLE) no uses of the nonReentrant modifier

onlyRole(MINTER_ROLE) modifier is used if this is trusted there is no need of nonReentrant modifier this save significant amount of gas

File: 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

[G-38] State variables which are not modified within functions should be set as constants or immutable for values set at deployment

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

Awards

88.7348 USDC - $88.73

Labels

analysis-advanced
grade-a
sufficient quality report
A-03

External Links

Analysis - Ethena Labs Contest

Ethena Labs

Description overview of The Ethena Labs Contest

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.

System Overview

Scope

  • 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.

Privileged Roles

Some privileged roles exercise powers over the Controller contract:

  • Admin
  modifier notAdmin(bytes32 role) {
    if (role == DEFAULT_ADMIN_ROLE) revert InvalidAdminChange();
    _;
  }
  • Owner
  modifier notOwner(address target) {
    if (target == owner()) revert CantBlacklistOwner();
    _;
  }
  • Staking-Vault
  modifier onlyStakingVault() {
    if (msg.sender != STAKING_VAULT) revert OnlyStakingVault();
    _;
  }

Trusted Roles

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

Approach Taken-in Evaluating The Ethena Labs Protocol

Accordingly, I analyzed and audited the subject in the following steps;

  1. 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.

  2. Documentation Review:

    Then went to Review This Docs for a more detailed and technical explanation of Ethena protocol.

  3. 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 Description and Diagram

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

Ethena-Diagram-Overview
  1. Off-Chain User Interaction
  • A user sends a signed order to Ethena.
  1. On-Chain Ethena Actions
  • Validation of the Order

    Ethena validates the incoming order as follows:

    1. Calculates the hash of the order using verifyOrder(), resulting in a hashed order.

    2. Recovers the signer's address from the order using ECDSA and verifyOrder(), obtaining the signer.

    3. Validates the order:

      • If the order type is not "redeem," it reverts.
      • Ensures the signer is not the benefactor or an approved entity; otherwise, it reverts.
      • Verifies that the beneficiary address is not 0; otherwise, it reverts.
      • Checks that the collateral amount is not 0; if it is, it reverts.
      • Validates that the USDe amount is not 0; otherwise, it reverts.
      • Ensures the order has not expired; if it has, it reverts.
  • Processing Valid Orders

    If the order is valid and not a duplicate, Ethena processes the redemption:

    1. Calls increaseRedeemedPerBlock() to handle the following:
      • Burns USDe tokens from the user's balance using the burnFrom function.
      • For asset = ETH:
        • Checks if the contract's ETH balance is less than the specified amount; if so, it reverts.
        • Transfers ETH to the user.
      • For asset = token:
        • Transfers tokens to the user using a safe transfer mechanism.
  • Handling Duplicate Orders

    If the order is a duplicate, Ethena takes the following actions:

    1. Sets the order as invalid in storage.
    2. Verifies if the nonce used in the order is 0; if so, it reverts, as the nonce must be unique.

    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.

Architecture Feedback

  1. Implement multisig for admin and rewarder roles rather than single addresses. This could be a 3/5 multisig controlled by DAO members.

  2. Introduce on-chain governance for parameters like vesting periods, blacklisting rules, etc. Stakers can vote on these to distribute control.

  3. 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.

  4. Consider alternative collateral models that don't rely on USDe, like overcollateralized staking or basket of assets.

  5. Consider migrating to EIP-2612 standard for upgrades to introduce future improvements securely.

  6. Add on-chain decentralized governance for parameters like minter approval in USDe.sol contract.

Codebase Quality

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 CategoriesComments
Code Maintainability and ReliabilityThe 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 CommentsThe 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.
DocumentationThe 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.
TestingThe audit scope of the contracts to be audited is 70% and it should be aimed to be 100%.
Code Structure and FormattingThe 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.

Systemic & Centralization Risks

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:

Systemic Risks:

  1. No having, fuzzing and invariant tests could open the door to future vulnerabilities.

  2. 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.

  3. If the stablecoin becomes widely used in DeFi but later fails or exits, it could cause liquidity issues and losses across many applications.

  4. A compromise of the admin, gatekeeper or minter roles would allow unauthorized minting/redeeming, threatening stability

  5. 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.

  6. 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.

Centralization Risks:

  1. 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.

  2. 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.

  3. 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.

  4. 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.

Conclusion

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.

Time spent:

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

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax ยฉ 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter