Quantstamp reviewed USDE (ERD) (Ethereum Reserve Dollar (ERD)) on 30 Oct 2023: 33 findings, 0 critical; 9 marked fixed, 21 acknowledged, 3 open.
Report hosted on certificate.quantstamp.com.
Trustblock does not review code.
Statuses are the security firm’s own, from its report.
| Severity | Open | Acknowledged | Fixed |
|---|---|---|---|
| Critical | 0 | 0 | 0 |
| High | 2 | 3 | 2 |
| Medium | 0 | 9 | 2 |
| Low | 1 | 9 | 5 |
`CollateralManager.priceUpdate()` iterates through the supported collaterals and fetches an updated price from their oracles. However, the for loop starts iterating from index `1`. Nothing in `addCollateral()` prevents any particular collateral type from occupying index `0`. Such a collateral would never have its price updated.
A liquidator can open a trove using a flashloan to deposit a large amount of collateral, increasing the number of rewards per unit staked to which they are entitled. In the same transaction they can trigger a liquidation and receive a portion of the redistributed rewards. They can then close their trove, also within the same transaction. This attack effectively steals a portion of the collateral rewards that should be going to existing trove holders. Note that the effectiveness of this attack increases as the size of the liquidation increases and as the amount of the collateral in the system decreases (so that they can more easily get a larger share).
When a user deposits stETH into a trove they recieve eTokens equivalent to the deposit amount. However, stETH rebases such that an address holding stETH will gradually receive more stETH over time. As of now, the codebase does not display any mechanism for allowing borrowers who collateralize stETH to claim these rewards. This will result in excess stETH accumulating in `ActivePool` and `DefaultPool`.
When liquidating a trove, one possibility is for the debt and collateral of that trove to be liquidated across all remaining active troves. This is done by adding the funds to the `DefaultPool` and applying the debt and collateral to an applicable trove (via `TroveManager.applyPendingRewards()`) when it is being adjusted, when it is being closed, and when it is being redeemed against. However, while debt is held in `DefaultPool`, interest is not accrued against it as it does not count towards the debt of an active trove. As a result, it would be in a borrower's interest to, with all else being equal, wait for as long as possible to adjust a trove or close it, as doing so would allow them to avoid paying interest on pending debt rewards.
Privileged roles are used extensively throughout the protocol for the purpose of configuration. However, we have identified a number of functions and roles that can lead to the loss of funds. * `USDEToken`: * `troveManagerAddress` and `borrowerOperationsAddress` can call `mintToTreasury()` and send mint arbitrary amounts of USDE to the `treasuryAddress`. * `stabilityPoolAddress` and `troveManagerLiquidationsAddress` can transfer any amount of USDE tokens between any addresses. * `ActivePool`: The `borrowerOperationsAddress`, `troveManagerAddress`, `stabilityPoolAddress`, `troveManagerRedemptionsAddress`, and `troveManagerLiquidationsAddress` can transfer all held collaterals to the `treasuryAddress` and `liquidityIncentiveAddress`. **All of these addresses can be set and changed by the contract owner.** * `DefaultPool`: The `troveManagerAddress` can transfer all held collaterals to the `activePoolAddress`. **All of these addresses can be set and changed by the contract owner.** * `LiquidityIncentive`: The owner has unrestricted access to transferring the contract's tokens. * `StabilityPool`: `borrowerOperations` has unlimited approval for the contract's `WETH`. **The owner can set and change the `borrowerOperations` address as well as the `WETH` address to another ERC-20 token.** * `Treasury`: The owner has unrestricted access to transferring the contract's tokens.
When a trove is inserted into `SortedTroves`, the ICR is recorded in the trove's corresponding `Node` struct as `Node.ICR`. However, as time passes, this recorded ICR is bound to become inaccurate with regard to the actual trove's ICR—the price of ETH is variable, and so is the amount of debt for which a trove is held responsible. Furthermore, the various collateral types are bound to fluctuate in price as well; notably, rETH is expected to increase in price with respect to time. Therefore, the sorting of the troves according to the stored ICR fields will change from that of a hypothetical sorted list that uses the actual ICR values. It is worth bearing in mind that there exists the `TroveManagerRedemptions.updateTroves()` function that allows the caller to update the trove sorting by reinserting specified troves—each reinsertion involves recording the trove's freshly updated ICR. However, this imposes a significant gas burden for the lifetime of the protocol on whatever parties take on the responsibility of the updating the trove sorting. Moreover, when reinserting the troves, while the ICR of the reinserted trove will be fresh, its location will depend on the potentially stale ICRs recorded in `SortedTroves`. Therefore, keeping the troves in the correct order may require repeatedly updating the ICRs of ALL the nodes in the list. In terms of protocol security, improper sorting of the troves poses a problem because trove sorting is the basis for the correct ordering of liquidations and redemptions: for as long as the sorting remains incorrect, troves are liable to redeemed against in the incorrect order—redemptions are made according to the trove ordering such that troves with lower ICRs (albeit above MCR) are redeemed against first—and liquidations that are made with the sequential liquidation function may liquidate troves in the wrong order (and lower ICR troves may be skipped over). It should be noted that documentation makes the following false statement as well: > Nodes also remain sorted as the ETH:USD price varies, since price fluctuations change the collateral value of each Trove by the same proportion.
The function `CollateralManager.removeCollateral()` removes a collateral type from the list of supported collaterals. Removal of a collateral type could result in a very sudden drop of users' ICRs. To take an extreme example, if a user has deposited only stETH and it becomes unsupported, their ICR would drop to zero. This would have a corresponding effect on TCR as well. The consequent effects may include multiple troves being put up for liquidation simultaneously as well as triggering of recovery mode.
The function `CollateralManager.removeCollateral()` removes a collateral type from the list of supported collaterals. Once no longer supported, users cannot withdraw their collateral in `BorrowerOperations.withdrawCollateral()`. Any users with a non-zero EToken balance would have no way of reclaiming their collateral. Additionally, if the EToken is changed by calling `CollateralManager.setEToken()`, the total supply of the previous EToken would map to irredeemable collateral. Furthermore, removal of a collateral type could result in a very sudden drop of users' ICRs. To take an extreme example, if a user has deposited only stETH and it becomes unsupported, their ICR would drop to zero. This would have a corresponding effect on TCR as well. Finally, if an EToken is transferred to an address without a trove, such users would be unable to redeem their collateral until they opened a trove.
Not checking return values can lead to unintended consequences, especially when the return value indicates the success of a process. Consider the following instances where return values are unchecked: * `BorrowerOperations.sol#257` * `BorrowerOperations.sol#723` * `BorrowerOperations.sol#869` * `BorrowerOperations.sol#871` * `CollateralManager.sol#249` * `CollateralManager.sol#618` * `StETHOracle.sol#276-282` * `StETHOracle.sol#285-301` * `StETHOracle.sol#314-331` * `PriceFeed.sol#613-628` * `PriceFeed.sol#637-643` * `PriceFeed.sol#646-662` * `PriceFeed.sol#674-691` * `StabilityPool.sol#276` * `StabilityPool.sol#1271` * `TroveManager.sol#392-396` * `TroveManager.sol#1270` * `TroveManagerRedemptions.sol#214` * `EToken.sol#104` * `USDEToken.sol#192`
All of the collateral in the protocol is stored between `ActivePool` and `DefaultPool`. Both contracts inherit from `OwnableUpgradeable`, which means that both can be upgraded by a user with a privileged role _immediately_. Moreover, both pools' funds can be accessed through other contracts inheriting from `OwnableUpgradeable`, which means if any of these other contracts' owners are compromised, they may also be able to siphon funds from the pools with great speed and ease. This is also possible through updating `EToken`, which is a token that can be used to redeem from troves. The last possibility is also applicable to `USDEToken`; if upgraded in a particular way (say, by letting an owner transfer the tokens indiscriminately), all the tokens can be stolen.
Both the `DefaultPool` and `ActivePool` track their USDE debt. The function `increaseUSDEDebt()` will always increase the debt by the full `_amount`. The function `decreaseUSDEDebt()` will decrease its debt by the `min(_amount, USDEDebt)` to prevent underflow. These functions are often used to transfer debt from one pool to another. Given the asymmetry between `increaseUSDEDebt()` and `decreaseUSDEDebt()`, it is possible for a pool to increase its debt by a quantity more than the other pool decreased its debt. This would create a surplus of debt where none exists.
Users are charged a borrowing fee that is added to their total USDE Debt, which accumulates interest. However, the borrowing fee is minted immediately to the treasury and is never received by the borrower. The borrowing fee should not accumulate interest as it is not loaned to the borrower.
`HintHelpers.getApproxHint()` attempts to generate an approximate hint for the proper placement of a trove in the `SortedTroves` list during insertion. However, it does this based on the ICRs of the troves in `SortedTroves` using price data from the latest oracles' updates. On the other hand, the insertion of the troves into `SortedTroves` is dependent on the ICR of these troves at the time of insertion, and this quantity may be very stale, potentially going back to the time of the first insertion. Therefore, as ICR is bound to change, `getApproxHint()` is not using the best means of discerning a good hint.
Many contracts have both an `initialize()` function as well as a `setAddresses()` function. Almost all of the variable initialization takes place in `setAddresses()` for most contracts. Given that `setAddresses()` can be called more than once, all addresses can be reassigned at the owners discretion. Additionally, some contracts contain an additional `init()` function that initializes variables not assigned in either `initialize()` or in `setAddresses()`. The ability to reassign all variables increases the attack surface in the event of a compromised owner.
The frontend functionality in the `StabilityPool` contract with functions such as `registerFrontend()`, `getCompoundedFrontEndStake()`, `_updateFrontEndStakeAndSnapshots` is designed to provide a monetization mechanism for frontends that receive a large amount of user interaction. If the ERD protocol does not intend for their to be a marketplace of front-ends then this functionality is superfluous and may even work against the goals of the developer team.
One of the design decisions made for this protocol was to have the variable interest rate increase as the total collateralization rate (TCR) of the protocol increases. We have decided to list this as an issue because in certain market conditions, this may create a positive feedback loop: depositors refuse to withdraw USDE because interest rates are high, leading to even more depositors refusing to withdraw USDE. We acknowledge that the disadvantages of such a setup may not be so severe depending on the following conditions: 1. The majority of USDE will be withdrawn by well-funded borrowers, which will allow them to noticeably influence the interest rate; 1. The typical interest rate is expected to be competitive with regard to interest rates in traditional finance.
Division before multiplication may result in a loss of precision when the operations are carried over integer numbers. `CollateralManager._calcValue()` contains an example of this at lines 302-309. This issue is found in the following instances: * `CollateralManager.sol#302-309` * `TroveManagerLiquidations.sol#448-450` * `TroveManagerLiquidations.sol#544-546`
At `PriceFeed.sol#265`, it is required that both Chainlink and Tellor are working in order for the price to be updated. This means that if Chainlink is restored while Tellor is not, the feed will continue to use the "last good price" instead of proceeding with the latest Chainlink price. The vice versa case is also true: if Tellor is restored while Chainlink is not, the feed will continue to use the "last good price" instead of proceeding with the latest Tellor price. Hence stale data will end up being preferred to fresh data. A similar situation occurs at `PriceFeed.sol#234`, where if Chainlink becomes operational at the same time as Tellor becomes unreliable, fresh Chainlink data will not be used.
The trove with the lowest ICR can be redeemed by any user. This creates an incentive for every participant that wants to keep their position open from being the trove with the lowest ICR. This can cause a race to the bottom dynamic which may negate or undermine the incentives created to keep troves as capital efficient as possible.
When dealing with collateral redemption in the form of eTokens, the protocol often calles `CollateralManger.getShares()` which in turn calls `EToken.getShare()` however, this function simply returns the input, making it a redundant function. This in turn complicates the logic within certain function. Take, for example, the `CollSurplusPool.accountSurplus()` function, which contains the following section: ``` address[] memory collaterals = collateralManager.getCollateralSupport(); uint256[] memory shares = collateralManager.getShares( collaterals, _amount ); ``` The conversion of `_amount` to shares can be entirely removed since this in effect just returns the same array. Furthermore functions such as `getShares()` and `getShare()` in the `CollateralManager` contract can be entirely removed.
In every case where `fetchPrice()` is called, either a price is pulled from an oracle or `lastGoodPrice` is called. Hence if both oracles are down, only the last good price can be resorted to. However, this means that even if both oracles are down or broken for a significant period of time, the last good price will be used indefinitely. This may result in unfavorable conditions for the protocol.
The report names a repository or files. Open the report to see its scope.
Repository and files the report names (57)
236 projects reviewed · 332 reports · latest Oct 2026
See Quantstamp’s auditsSomething wrong on this page? A missing report, a wrong date, a finding that was fixed since. Tell us and we check it against its source.
Tell us