Introduction
After auditing the first version of the protocol, the Empty Set Squad team asked us to audit their second version of the protocol, namely emptyset
(formerly named dollar-continuous
).
The repository can be found here and we audited the master
branch on commit bf9753ef9cd5b17236036257f290e0d0b850a029
.
The scope of the audit has been limited to the contracts present in the protocol/contracts/src
folder. These were:
common/IImplementation.sol
common/Implementation.sol
common/ProxyRoot.sol
governance/GovernerAlpha.sol
governance/Stake.sol
incentivizer/Incentivizer.sol
Interfaces.sol
lib/Decimal.sol
lib/TimeUtils.sol
migrator/Migrator.sol
oracle/Oracle.sol
registry/Registry.sol
reserve/ReserveComptroller.sol
reserve/ReserveImpl.sol
reserve/ReserveIssuer.sol
reserve/ReserveState.sol
reserve/ReserveSwapper.sol
reserve/ReserveVault.sol
stabilizer/StabilizerComptroller.sol
stabilizer/StabilizerImpl.sol
stabilizer/StabilizerState.sol
stabilizer/StabilizerToken.sol
token/Dollar.sol
vester/Vester.sol
The following contracts were left out from the scope:
governance/Timelock.sol
lib/LibEIP712.sol
lib/UniswapV2Library.sol
lib/UniswapV2OracleLibrary
token/Permittable.sol
vester/TokenVesting.sol
All other project’s files and directories (including tests), along with external dependencies and projects, game theory, and incentive design, were also excluded from the scope of this audit.
Project overview
The new version of the protocol introduces several differences from the previous one. The new protocol creates a stablecoin called ESD
which relies on different mechanisms to stabilize the ESD
price around 1 USDC
. The new protocol consists in 3 new tokens: ESD
, which is represented by the Dollar
contract, ESDS
, which is represented by the Stake
contract, and sESD
, implemented by the StabilizerToken
, all of 3 described below.
The project consists of the following parts:
Governance
The GovernorAlpha
contract implements the main governance logic. It is forked from the Compound’s governance system and it makes use of a Timelock
contract and a governance token represented by the Stake
contract. The Timelock
contract is used to queue successfully voted proposals and to allow users to eventually exit the protocol if they do not agree with the queued proposals. Similarly, the Stake
contract provides the ESDS
governance tokens to let users have vote powers, and choose a delegatee to vote on proposals.
The GovernorAlpha
contract has a special role called the guardian
which is set in the constructor and has special permissions to execute the following actions:
- Cancel any non-executed proposal via the
cancel
function. - Bypass the governance mechanism to change the Timelock‘s admin at will (calling the
GovernorAlpha
contract__queueSetTimelockPendingAdmin
and__executeSetTimelockPendingAdmin
functions), meaning that the Governor’s guardian can override any queued proposal that attempts to change theTimelock
contract’s admin (i.e., a proposal that calls thesetPendingAdmin
function), as the guardian can call__queueSetTimelockPendingAdmin
with any eta. - Make the
GovernorAlpha
contract accept administrative powers over the Timelock contract, calling the__acceptAdmin
function. - Abdicate, by calling the
__abdicate
function.
Registry
It is used as a unique source of information, almost all contracts query protocol contract addresses to the Registry
contract.
This contract is owned by the Timelock
contract, which is the one responsible, through governance actions, to execute proposal that will set contract addresses in the Registry
contract.
Incentivizer
The Incentivizer
contract is a general mechanism forked from the Synthetix project which aims to let users stake an underlyingToken
and receive rewards from it in the form of a rewardToken
, that can be either different or equal to the first asset.
Rewards are distributed according to a rewardRate
and in a continuous-in-time behavior, dispersed in the contract for each second that has passed up to a certain timestamp given by the rewardComplete
parameter.
Both parameters can be updated to new values before the reward program ends by calling the updateRewardProgram
.
Users can stake
underlying tokens, withdraw
parts of them, claim
all accrued rewards, or call the exit
function that will withdraw and claim all the assets at the same time.
Eventually, there is also the rescue
method that will send stucked ERC20
tokens, or a surplus of reward tokens, to the Reserve
contract described below.
Migrator
The Migrator
contract is in charge of handling the conversion of ESD
tokens from v1 to ESDS
governance tokens in a fixed ratio,
while bonded ESD
in the previous version will be converted to ESDS
into a 1-to-1 ratio.
Users from v1 will have to redeem their coupons before migrating to the new version since the Migrator
contract will not take them into account. All v1 assets which are migrated are also burned and the contract must be funded with enough ESDS
beforehand to supply the demand. Several of actions must be performed before migrating the assets, including execution of governance proposals in the governance system of the v1, which were left out of scope.
Vester
The Vester
contract is a general purpose contract inspired by a deprecated OpenZeppelin contract's draft
that is able to release to a beneficiary
an amount of tokens over the course of the time. It is intended to be used with governance ESDS
tokens and the releasing schedule is linear on time.
The beneficiary
can transfer its status to another address or withdraw the vested tokens at any time. Finally, the beneficiary
can also delegate
the voting powers associated with the vested and unvested tokens to a delegatee
making the tokens usable while being under the vesting period.
Reserve
The Reserve
contract is a core module of the current system and it is made of several sub-contracts that it composes together.
- It manages market orders of arbitrary tokens pairs that the governance can list to let users trade against them.
- It can mint and burn both
ESD
andESDS
tokens. This contract will be the owner of those tokens contract and it is owned by the governance. - It handles the state of a borrower party, namely the
Stabilizer
described below, that will borrowESD
unbacked tokens, generating debt inside theReserve
. - It handles debt modifications associated to the minting and burning of
ESD
together with the deposits and withdraws ofUSDC
. - It will deposit owned
USDC
on a Compound’s vault obtainingcUSDC
tokens in exchange. It will also redeem and supply moreUSDC
to the vault on-demand, whenever needed. It will also claimCOMP
rewards accrued for depositingUSDC
in the vault. - It is upgradeable, together with the
Stabilizer
.
Users can interact with the Reserve
through the following methods:
mint
: will mint 1ESD
for eachUSDC
provided in the function call.settle
: will burnESD
and transfer back to the user the same amount inUSDC
. This method can only be called if the same amount of burnedESD
can be deducted from the borrower debt without underflowing.redeem
: will instantly burn an amount ofESD
tokens and give back to the user an amount ofUSDC
that depends on aredeemPrice
which finally depends on thereserveRatio
betweenESD
andUSDC
reserves and on a fixed and artificialredemptionTax
.
Stabilizer
The Stabilizer
is another core module for the entire protocol. As the Reserve
does, it is created by composition of several subcontracts. It is in charge of taking out from circulation ESD
while holders of sESD
will increase over time their value in ESD
.
- It uses the result of the
Oracle
, described below, with anexponential moving average EMA
function to look up at the price of theESD/USDC
market pair from aUniswapV2 Pool
. - It let users supply
ESD
tokens to the contract and receive “Saved Empty Set Dollar”sESD
tokens, anERC20
token reimplementing internal accounting of balances and supply, but still adhering to theERC20
standard. - It will borrow unbacked
ESD
tokens from the reserve in an amount calculated by the use of the market price given by theOracle
periodically over time. - It let users redeem accrued rewards in the form of
ESD
tokens by providing theirsESD
. TheredeemAmount
amount will definitively depend on the ratio of the balances ofUSDC
andESD
of the contract.
Users can interact with the Stabilizer
through the following methods:
supply
: will transfer into the contract an amount ofESD
tokens and will mint back to the user an amount ofsESD
dependent on the ratio betweensESD
andESD
supplies.redeem
: will burn an amount ofsESD
tokens and will transfer back to the user an amount ofESD
dependent on the ration betweenESD
andsESD
supplies.redeemUnderlying
: will act the same as the one above but the user will specify the amount directly inESD
and the contract will calculate how muchsESD
will have to be burned from the user.
Oracle
The Oracle
contract is an implementation of a UniswapV2 TWAP oracle. It will be owned by the Stabilizer
and, once setup, will retrieve the average time-weighted price from the UniswapV2 ESD/USDC
pool.
Summary
While we were auditing the code, the Empty Set Squad team detected and fixed an issue. The Reserve
contract was using the ReentrancyGuard
contract provided by OpenZeppelin, but being this a contract not compatible with the upgradeable pattern chosen by the developer team, the code has been refactored in PR#1.
Update: After the audit ended, the Empty Set Squad team found and fixed an issue on pull request 3. In the ReserveComptroller
contract, if an user tries to mint a small number of ESD
tokens then the rounding operation could allow the user to mint ESD
without paying any collateral. However, this operation is capped by 10^12 - 1 wei
of ESD
, which represents a very small amount.
Update: After the audit ended, the Empty Set Squad team decided to remove the whole Stabilizer module from the protocol. The module was meant to take ESD
out of circulation by depositing it into the contract and accrue more ESD
over time. However, under certain edge conditions and parameters, factors that cannot be battle-tested in an early stage of the protocol, the mechanism could increase even further the ratio of supply to collateral. More reasons behind the removal of the module, along with other changes of the codebase, can be found in pull request 19. Due to this, some findings that point to code that has changed may not be valid or accurate. In particular, issues M01, M10, M11, L02, L04, L14, N02, N07, and N08 have been affected. It is worth mentioning that the redemptionTax
variable has been removed from the contracts and it is not used anymore in the calculation of the redeemPrice
value, and that with the removal of the Stabilizer module, the ReserveComptroller
contract reduced its functionalities to only mint new ESD
and to redeem them for a redeemPrice <= 1
. The Stabilizer module was an example of a possible mechanism that could be added into the protocol to achieve the desired peg and stability, and it could be added in a near future once the protocol has been tested in real-time scenarios.
This audit was not meant to put attention into the game theory design and its incentives to stabilize the final price of ESD
. We assume proper due diligence in initializing the contracts and setting the right permissions and supplies to the corresponding parts of the protocol.
Overall, we found the code to be clear to follow and read, with most functions and contracts properly documented. However, the lack of an extended supportive documentation made difficult the understanding of some parts of the protocol. We highly recommend the team to improve the supporting documentation to describe every single contract and function. Moreover, there was a lack of comprehensive tests that could tackle edge scenarios and a coverage report. Furthermore, we strongly suggest to run economic simulations that could validate the hypothesis behind the code base, and test a mock-up version of the protocol in a controlled environment such as a public test network.
The code has been audited during the course of 3.5 weeks by two auditors and here we present our findings.
Critical severity
None.
High severity
[H01] Rescue of ERC20 tokens can fail
The Incentivizer
contract has the rescue
function that can be called to send to the reserve
any ERC20 token that is left in the contract.
The fact that an arbitrary token
address is passed proves that it is intended to move all types of ERC20 tokens.
However, the call in the rescue
function of the verifyRewardBalance
function will revert anytime there are no sufficient reward assets in the contract, as required in lines 193-194.
The docstrings of the function states that this function can transfer any ERC20 token, but this is not always true if not enough rewards of rewardTokens
are in the balance of the contract, even though the tokens to be rescued do not count for that balance.
Consider either restricting the rescue
function to only exclusively rescue rewardsTokens
or to exclusively call the verifyRewardBalance
if the tokens to be rescued are reward tokens.
Update: Fixed on pull request 14. The EmptySetSquad team stated the following for this issue:
verifyRewardBalance() couldn’t ever fail unless either token is rewardToken or the contract has been hacked and drained, this is purely an extra defensive check rather than something that could ever be hit under normal usage.
[H02] Test are not following contracts logic
The code base presents tests in the protocol/test directory.
Almost all modules (except for the Governance
contracts along with all libraries) have unit tests under their respective folders and the execution shows that all test pass.
However, some of the tests are not following the logic of the contracts implementation and are not testing appropriate conditions that should revert contract calls.
One example is the ReserveSwapper
test file.
According to the contract implementation, the swap
function of the ReserveSwapper
contract should revert whenever the ESD
token address is used either as a takerToken
or makerToken
.
However, in the corresponding test of this function , the ESD
address is passed as parameter and the function doesn’t revert in this case.
The test passes only because the ESD
address hasn’t been set in the Registry
contract at test execution time, so the line 75 is comparing the passed input address with the zero address.
In this specific case, the test that actually sets the ESD
address in the Registry
contract is the ReserveComptroller
test suite.
This is considered a major shortcoming in the project, as there is no way to determine if the current implementation matches the system’s expected behavior.
Consider being careful when coding tests to validate the expected behavior even on edge cases.
Update: Fixed on pull request 16.
Medium severity
[M01] Governance can be tricked into performing external calls to a malicious contract
The Incentivizer
contract allows the protocol to give rewards to users that stake underlying assets.
In case of having unused assets by the Incentivizer
contract, the governance can call the rescue
function transferring those into the reserve.
However, this opens a backdoor which a malicious user could exploit. An user could create a malicious contract that simulates to be a ERC20 compliant token that deposits tokens in the Incentivizer
contract, and when the governance passes a proposal to rescue those tokens and send them into the reserve, an external call to the malicious contract will be executed without checks on the other end.
Furthermore, once received into the reserve, the governance could create an order to swap them and when the attacker makes the call to the swap
function from the ReserveSwapper
contract, the malicious external call would now be originated from the reserve.
Although the contract is shielded by the OpenZeppelin’s ReentrancyGuard
contract and no funds cannot be moved with this attack, reducing the attack surface on the code, and specially on contracts that handle the assets such as the ReserveSwapper
and the Incentivizer
contracts, is always recommendable to prevent that future versions of the protocol could be affected by this attack.
Moreover, as discussed in the introduction, the ReentrancyGuard
shield has been removed from the code while the audit was being performed.
Consider restricting the possibility to perform external calls to untrusted contracts to reduce the attack surfaces in the protocol.
Update: Acknowledged. The EmptySetSquad team statement for this issue:
Updated docs with a guide on acceptable ERC20 properties for governance.
[M02] Documentation issues
Overall, the code base presents well documented functions and clear docstrings. However, there are some designs choices and parts of the code which lack of a proper description.
The ReserveSwapper
contract allows the governance to list orders on ERC20 token swaps. Any user can trade against those orders once listed. Tokens to be swapped must be owned by the reserve itself but
how this contract is going to be used and which ERC20 are going to be traded is not documented anywhere. Moreover, in line 51 from ReserveSwapper.sol
it is clear that the reserve can have an unlimited amount
of tokens to be sold but it’s not clear nor documented this design choice and in which cases the governance can list an unlimited amount order for a specific token.
Consider adding proper documentation about the intentions over the use of this feature.
The Stake
contract defines all the properties of the ESDS
governance token. Balances and allowances for this token are accounted as uint96
variables in order to pack the CheckPoint
struct into 128 bits.
Because of this, every place in the code base that makes use of the ESDS
tokens must carefully manage big balances or allowances in order to avoid overflows. As an example, the ReserveIssuer
contract is defining the mintStake
and burnStake
functions that mint
and burn
ESDS
tokens, but those functions are not properly documented to reflect the internal behavior of the ESDS
token.
Consider carefully document any use of ESDS
tokens outside of the Stake
contract to prevent developers and users from performing miscalculations.
The redeem
function from the ReserveComptroller
contract retrieves USDC
tokens in exchange of ESD
tokens, which these are then burned.
Because of the redemptionTax
that can be set to artificially manipulate the price, the actual redeemed amount, can be artificially lowered to any value, including zero.
Consider explicitly warn users about this possibility and properly document this behavior.
In line 104-107
from ReserveComptroller.sol
, the docstrings are not clear about the fact that the user must first approve the contract to transfer tokens.
Consider adding this to the docstrings of the function.
Update: Fixed on pull request 15.
[M03] Missing test coverage report
There is no automated test coverage report. Without this report it is impossible to know whether there are parts of the code never executed by the automated tests; so for every change, a full manual test suite has to be executed to make sure that nothing is broken or misbehaving.
Consider adding the test coverage report and making it reach at least 95% of the source code.
Update: Acknowledged. The EmptySetSquad team statement for this issue:
Won’t currently fix – Solidity coverage tool currently incompatible with our setup.
[M04] Orders registration can be frontrun
The governance can call the ReserveSwapper
contract to register a market order for a token pair. Once the order is registered, users can start calling the swap
function and exchange one token for the other.
If the governance tries to update an already existing order, an order with a limited amount of tokens, that call can be frontrun by a malicious actor, which can call the swap
function for the same token pair, zeroing the order amount in the accounting balance.
If this happens, after the new governance order has been mined, any further call to the swap
function can unexpectedly fail in transferring the tokens, since the actual balances of the contract are not taken into account when registering the order’s amount.
Consider checking if the contract has enough balance of the respective tokens before setting any non-limited order amount.
Update: Acknowledged. The EmptySetSquad team statement for this issue:
Won’t fix – generally speaking, this module is meant to be holdover until we can create a more robust DAO treasury management system as part of a larger effort. Included note in governance on how to properly account for these limitations.
[M05] Same transaction on different proposal could revert
The GovernorAlpha
contract implements the functionality to propose, vote, and execute proposals, among other actions.
Each proposal can have multiple transactions to process when it is being executed. When a proposal succeeds, these transactions are queued in the Timelock
contract by passing all the transaction’s information and the time in which the transaction will be able allowed for execution.
However, because different proposals could have one same transaction with the same parameters among many different transactions, if two proposals are queued under the same block, then the first time in which the transaction could be executed will be the same, meaning that the hash used to identify that transaction will be taken. Therefore, during the queue process of the second proposal, the requirement that checks if it was already queued will revert.
Consider adding a proposal nonce as part of the hashed data to identify identical transactions as different ones on different proposals.
Update: Acknowledged. The EmptySetSquad team statement for this issue:
Won’t fix – safe to assume two proposals will never need to be committed in the same block, and we don’t want to modify widely-used forked code unnecessarily.
[M06] Underlying tokens could get stuck
The Incentivizer
contract allows users to receive reward tokens when staking underlying assets into the contract.
However, because the contract requests underlying assets to be transferred into the contract in order to proceed, and due to human nature, users may transfer directly those underlying tokens with a simple ERC20 transfer transaction instead of calling the stake
function.
Although the contract has a function that can be used to move any token into the reserve, the rescue
function, this function works with any token except the underlying asset. Therefore, any underlying asset sent through a regular transfer transaction will get stuck in the contract without any possibility of taking it back.
Because underlying assets may be sent directly into the contract on a daily basis , consider implementing the possibility to withdraw those stuck assets from the contract.
Update: Fixed on pull request 14.
[M07] Deprecated TokenVesting draft contract is used
The Vester
contract is using the TokenVesting
contract provided in a previous deprecated draft version provided by OpenZeppelin.
Using deprecated contracts which are in draft status and not properly audited can be dangerous and increase the attack surface.
Consider properly documenting this and correctly inform users of its use.
Update: Acknowledged.
[M08] Unnecessary ABIEncoderV2
The ProxyRoot
and Registry
contracts are using the experimental ABIEncoderV2
pragma
directive but there is no explicit use of it in the contract.
Furthermore, the usage of the experimental ABIEncoderV2
is discouraged in older Solidity versions since there have been important fixes for this encoder since then. The risk can be mitigated by being extra thorough on the testing process of the project at all levels. However, even with great tests there is always a chance to miss important issues that will affect the project.
Consider removing the directive from the contract files, and consider also more conservative options, or upgrading the Solidity version used to a newer version where the ABIEncoderV2 is stable, or rewriting the project to use the current stable encoder.
Update: Fixed on pull request 13. The ProxyRoot
contract has been removed along with the usage of the ABIEncoderV2
pragma in the Registry
contract.
[M09] Untested custom SafeMath library in use
Several contracts implement arithmetical operation functions based on OpenZeppelin’s SafeMath library, customized to support safe mathematical operations for uint256
, such as the GovernorAlpha
contract, uint96
, such as the Stake
contract. Yet, none of the functions are tested, which may hinder users and developers’ trust in this custom library. Moreover, any change in the library is not going to be detected by the current test suite, rendering all business logic depending on it vulnerable to potential security issues introduced by these future modifications.
Consider including thorough and extensive unit tests for those functions.
Update: Acknowledged. The EmptySetSquad team statement for this issue:
Won’t fix – widely-used forked code already tested externally.
[M10] Updating storage variables could soft-brick the protocol
The Implementation
contract has the setRegistry
function which is used to update the address of the Registry
contract in all contracts that inherit from the Implementation
contract, such as the ReserveAccessors
contract contract or the StabilizerAccessors
contract.
In Line 78 of the same function, the require statements force either:
- To have not set the
registry
address yet. - That the new
Registry
which is going to be set, has the sametimelock
address of the oldRegistry
contract.
The Registry
contract is owned by the Timelock
contract, that executes transaction to set addresses on the Registry
contract on behalf of the governance.
All the setter functions from the Registry
contract are lacking from input validation, so it is possible, by mistakenly executing a bad proposal, to set the Timelock
contract’s address in the Registry
contract to an erroneous address (either an EOA or another contract).
If this happens, the setRegistry
function of the Implementation
contract will revert at any call trying to update the Registry
contract’s address, freezing entirely the functionality.
Consider validating that the timelock
address is a valid contract address whenever it is set in the Registry
contract. This can be done using the OpenZeppelin Address.isContract
function or eventually, consider also implementing the EIP1820
for this use case.
Update: Fixed on pull request 12.
[M11] Possible compromised storage due to hierarchy composition
The design chosen for the upgradeability of certain contracts, such as the Reserve
and the Stabilizer
contracts, consists of composing those contracts by means of smaller ones and use an upgradeable proxy pattern with the resulting contract.
For this reason, the storage variables of both modules are stored respectively in the ReserveState
and the StabilizerState
contracts.
The intention is to have a unique contract for all the state variables of each module and have less overhead when upgrading through proxy patterns. This can be a good design decision whenever the composition of the contracts is strictly followed in order.
However, there are several cases where some contracts extend and import the same contracts multiple times and in different orders. Some examples are:
- The
StabilizerImpl
contract extends theImplementation
contract, however it is already been inherited by theStabilizerAdmin
contract through theStabilizerComptroller
contract. - The
StabilizerState.sol
file has duplicated imports. Moreover theImplementation
contract already imports theIImplementation
interface. - The
ReserveImpl
contract imports are already imported inReserveIssuer
andReserveSwapper
contracts. Moreover, theImplementation
import is not used. - All imports of the
ReserveSwapper
, except for theReserveComptroller
import, are already imported in theReserveComptroller
. - The
Dollar
contract imports theERC20Detailed
contract but this is already imported by thePermittable
contract.
Changing the order of the composition can result in different contracts layouts if imports are mixed in different orders. However this is unlikely to happens since the storage is centralized in one unique contract.
Consider reviewing all orders imports and duplications to ensure a clear composition order and reduce the possibility of compromising storage when upgrading the Reserve
or the Stabilizer
implementations.
Update: Fixed on pull request 11.
[M12] Wrong arithmetic operation when normalizing prices
The Oracle
contract implements the logic to retrieve the updated price of a listed asset.
When a price is updated, the _normalize
function is called to adjust the difference between the decimals of the pair of assets involved. The value of 6 for USDC
is compared against the one for the queried token and the price value is multiplied by the difference between each other.
However, when the other token has less than 6 decimals, the price is also multiplied by the difference of decimals instead of dividing by it.
Although currently the other token used will be ESD
with 18 decimals, which means that it will not encounter this issue, consider dividing instead of multiplying the difference of decimals when the token has less than 6 decimals.
Update: Fixed on pull request 10.
Low severity
[L01] Lack of guardian-role transfer
The guardian
address from the GovernorAlpha
contract has a special role inside the system, which is allowed to either cancel a proposal or proceed with the admin-change process, among others.
However, if the address associated with the guardian
gets compromised, there is no way to transfer those special roles to another address.
Consider creating a function to transfer the guardian
role to another address or consider documenting this limitation.
Update: Acknowledged. The EmptySetSquad team statement for this issue:
Won’t fix – don’t want to modify widely-used forked code unnecessarily.
[L02] ESD price defaults to one
In the StabilizerComptroller
contract, whenever the oracle becomes unhealthy, the protocol sets immediately the ema
to 1.
Whether this choice is intended to protect or stabilize the price of the ESD
to a fixed price, it is not clear and straightforward why that would be the best mitigation to an unhealthy oracle.
Consider properly justifying this choice or the assumptions that are taken in place whenever the oracle becomes unhealthy.
Update: Acknowledged. The EmptySetSquad team statement for this issue:
Expected behavior since a stablecoin’s neutral price is $1.00.
[L03] ERC20 compliant assets may not be used
In the Oracle
contract, the _normalize
function is used to normalize the difference in decimals between USDC and a certain token. Although it is meant to be used against ESD, the Oracle
contract generality allows other assets to be queried there.
Nevertheless, because the ERC20 decimals
getter is optional, using any asset without the getter in the protocol will fail at retrieving the number of decimals used for their internal accounting, and with it the transaction will revert.
Consider implementing the functionality to allow the usage of ERC20 compliant assets that do not have a decimals
getter or taking this into account when whitelisting assets.
Update: Acknowledged. The EmptySetSquad team statement for this issue:
Updated docs with a guide on acceptable ERC20 properties for governance.
[L04] Implementation functions sparsed into different contracts
The Implementation
contract declares and implements the logic to get the respective logic’s implementation and the admin’s address.
However, there are some function declarations that lack its definition, such as setting the registry’s address or the owner of the contract.
These two functions are declared in the StabilizerState
and in the ReserveState
contracts, and both have the same functionality.
Whether this is meant to be consistent to keep all state variables in the respective contracts or not, having the implementation of a function in different contracts without a proper reason produces a more complex code and lower its readability.
To improve the code’s readability and factorization, consider defining both functions in the Implementation
contract.
Update: Fixed in pull request 20. The IImplementation
interface has been removed, while the Implementation
contract has been refactored to host definition and implementation of the getters and setters of the owner
, registry
and notEntered
parameters. The Implementation
contract does that by an unstructured pattern of choosing specific slots to store and set the variables. Since the ReserveAdmin
and the StabilizerAdmin
contracts are already extending from the Implementation
, the StabilizerAccessors
and ReserveAccessors
contracts (that extends from those) have been refactored too.
[L05] Hardcoded parameters in Governance
In the GovernorAlpha
contract, the quorumVotes
, proposalThreshold
, proposalMaxOperations
, votingDelay
, and votingPeriod
functions are returning hardcoded values for the corresponding parameters.
Having hardcoded parameters forces developers to deploy a new contract if one of those parameters needs to be changed.
An example would be the votingPeriod
, initially set to 17280 blocks (3 days, assuming 15 seconds for each block). The assumption will be false whenever the community integrates EIPs that can change seconds per block directly.
For this reason, consider adding proper setter functions to those parameters. These functions could be called by the guardian
of the GovernorAlpha
contract or by the Timelock
contract through a proposal.
Update: Acknowledged. The EmptySetSquad team statement for this issue:
Won’t fix – don’t want to modify widely-used forked code unnecessarily.
[L06] High value ERC20 operations may revert
The ESDS
token is the token used to vote proposals in the governance and it stores its total supply under an uint256
variable. However, the accounts balances are stored in uint96
variables.
Furthermore, the Vester
contract is in charge of gradually distributing ERC20 tokens, such as the ESDS
token, during the lifespan of the contract. Although the contract has been implemented using uint256
variables, the same outcome as mentioned would happen when balances come close to the maximum number of an uint96
.
If external projects adopt the protocol, the expected behavior of a complaint ERC20 may result in unexpected transaction reverts.
Consider either using uint256
variables for the balances or documenting the reason to use uint96
variables.
Update: Acknowledged. The EmptySetSquad team statement for this issue:
Won’t fix – don’t want to modify widely-used forked code unnecessarily.
[L07] Implicit casting
The lack of explicit casting between different variable types, hinders code’s readability, making it more error-prone and hard to maintain.
In GovernerAlpha.sol
, in lines 301, 303, and 310 implicit castings from uint96
to uint256
for the votes
input are performed.
In the same way, in Stake.sol
, in line 108 and 126 implicit castings of amount
from uint96
to uint256
are performed. Finally in line 179 of the same contract, the returned value should be a uint256
but it’s used an uint96
.
Consider explicitly casting all integer values to their expected type when sending them as parameters of functions and events. It is advisable to review the entire codebase and apply this recommendation to all segments of code where the issue is found.
Update: Acknowledged. The EmptySetSquad team statement for this issue:
Won’t fix – don’t want to modify widely-used forked code unnecessarily.
[L08] Lack of event emission after sensitive actions
The __acceptAdmin
and the __abdicate
functions of the GovernorAlpha
contract do not emit relevant events after executing their sensitive actions.
Consider emitting events after sensitive changes take place, to facilitate tracking and notify off-chain clients following the contracts’ activity.
Update: Acknowledged. The EmptySetSquad team statement for this issue:
Won’t fix – don’t want to modify widely-used forked code unnecessarily.
[L09] Lack of input validation
There are several places in the code base where input parameters are passed in function calls without any kind of validation of their values. Examples are:
- The
amount
variable of thestake
andwithdraw
functions from theIncentivizer
contract is not validated to be non zero. - The
propose
function from theGovernorAlpha
contract is not checking whether thedescription
is empty or not. - The
approve
function from theStake
contract is not validating thespender
input parameter.
Even though this issue does not pose a security risk, the lack of validation on user-controlled parameters may result in erroneous transactions or useless use of gas.
Consider adding proper checks on the values passed in function calls.
Update: Partially fixed on pull request 9. The GovernorAlpha
and Stake
contracts have not been changed. The EmptySetSquad team statement for this issue:
Leaves GovernorAlpha and Stake as-is to not modify forked code unnecessarily.
[L10] Missing tests
No tests were found for the governance system implemented in the contracts. This is considered a major shortcoming in the project, as there is no way to determine if the current implementation matches the system’s expected behavior.
Consider adding tests for diverse cases and edge scenarios.
Update: Acknowledged. The EmptySetSquad team statement for this issue:
Won’t fix – widely-used forked code already tested externally.
[L11] View function creates a pointer to storage
The GovernorAlpha
contract implements the functionality to propose, vote, and execute proposals.
To know what a proposal will execute, an user can call the getActions
function which will retrieve all the information of the transactions.
Although this function is public view
and it is not meant to change the data in storage, the pointer created points to storage instead of memory.
Whether this is a design choise or not, consider changing it to memory to reduce the attack surface in the contract.
Update: Acknowledged. The EmptySetSquad team statement for this issue:
Won’t fix – don’t want to modify widely-used forked code unnecessarily.
[L12] Order of arithmetic operations reduces the outcome’s precision
In the Oracle
contract the price update is done by first dividing the difference between the current accumulated price and the stored one by the elapsed time, and then dividing again by 2**112
, using the ratio
method from the Decimal
library, to get the output in price units.
However, the precision of the outcome could be increased by altering the order of the operations by multiplying the time elapsed and the 2**112
factor to then take the ratio of it, which internally multiplies first the numerator with the BASE
constant.
Consider changing the order of the arithmetic operations to improve the outcome’s precision.
Update: Acknowledged. The EmptySetSquad team statement for this issue:
Won’t fix – effect on oracle is negligible, so we’d prefer to leave as is to increase readability by separating the numeric computation from the base conversion.
[L13] Centralized registry is not adopted in the whole code base
Having the Registry
contract allows the protocol to have a unique source of information of the key components of the system.
Coupled together with the fact that the Registry
contract is triggered by the governance, this should ensure a robust system to have system contracts depending on a registry where addresses can be updated anytime through a proposal.
However, not all contracts are using the Registry
contract and its specific state variables. Some examples are:
- The
stake
andtimelock
variables from theGovernorAlpha
contract. - The
reserve
variable from theIncentivizer
contract.
Consider using the Registry
contract in all the contracts that need to read system contract addresses to improve consistency and to have a homogeneous functionality in the system.
Update: Partially fixed on pull request 8. The GovernorAlpha
contract does not inherit the functionalities of a centralized registry from the RegistryAccessor
contract.
[L14] Re-implementing ECDSA signature recovery
The castVoteBySig
function from the GovernorAlpha
contract includes an implementation of the ECDSA signature recovery function. This function is already part of the OpenZeppelin contracts, which has been audited and is constantly reviewed by the community.
Consider importing and using the recover
function from OpenZeppelin’s ECDSA library not only to benefit from bug fixes to be applied in future releases, but also to reduce the code’s attack surface.
Update: Acknowledged. The EmptySetSquad team statement for this issue:
Won’t fix – don’t want to modify widely-used forked code unnecessarily.
[L15] New Oracle instance has to be deployed after a new StabilizerImpl deployment
The Oracle
contract has the functionality to adapt and retrieve the prices for the ESD
token. It is owned by the StabilizerComptroller
contract.
To set the configuration on both contracts, the owner of the StabilizerComptroller
contract has to call the setup
function which would set as one the conversion between ESD
and USDC
but also it will call the setup
function from the Oracle
contract. There, the new pair will be created and it will be stored in the variables.
However, in case a new StabilizerComptroller
is deployed, the setup of it will not be able to succeed by using the same Oracle
contract, because a requirement that checks if the pair has been already setup for ESD
will revert, preventing to set as one the moving average for that token.
Consider either allowing to set the initial value of the moving average in the StabilizerComptroller
when deploying a new version of it without deploying a new Oracle
contract, or documenting the reason to setup both contracts under the same call.
Update: Acknowledged. The EmptySetSquad team statement for this issue:
Won’t fix – this behavior is ok.
[L16] Uncommon ERC20 are not managed
The ReserveSwapper
contract allows the Governance
to list limit orders of ERC20 tokens.
There is no limitation on the type of ERC20 tokens that can be listed and swapped, but parallelly, tokens like the ERC20 Tether USDT
can have implicit fees that are accounted when transferring. However, this internal fees are not being handled by the ReserveSwapper
contract.
Therefore, the governance should be aware that the protocol currently does not fully support tokens that charge fees during transfers, and whitelisting such tokens can result in unexpected behavior, such as insolvency. If the protocol is expected to be compatible with these type of tokens, consider modifying the internal accounting mechanism to use the actual amount of assets deposited. One plausible approach would be to query the contract’s token balance before and after the execution of the token transfers to obtain the real amount of tokens deposited. For reference, consider how Compound’s protocol handles this scenario in their CErc20 contract.
Update: Acknowledged. The EmptySetSquad team statement for this issue:
Updated docs with a guide on acceptable ERC20 properties for governance.
[L17] Unhandled return value
The execute
function from the GovernorAlpha
contract is internally calling the executeTransaction
function from the Timelock
contract.
The executeTransaction
function returns the data returned by the contract call in the returnData
parameter, but this parameter is not read nor stored anywhere in the GovernorAlpha
contract.
Whether the returnData
is needed or not, consider reading or storing it. Alternatively, consider modifying the executeTransaction
function to not return any parameter at all if it is not needed or documenting the reason to not use its returned value.
Similarly, the delegate
function from the Stake
contract is declared as not returning any value, but it is actually using the return
keyword in its implementation.
Consider removing the return
instruction.
Update: Acknowledged. The EmptySetSquad team statement for this issue:
Won’t fix – don’t want to modify widely-used forked code unnecessarily.
[L18] An account can trigger an approval event on behalf of another account
The StabilierToken
contract implements the functionalities of the sESD
ERC20 token.
One of those is the transferFrom
function that allows to transfer tokens on behalf of another user when the allowance is properly set.
However, if a non-allowed user tries to transfer zero number of tokens on behalf of another another account by calling the transferFrom
function, the transaction not only will succeed but it will trigger both Transfer
and Approval
event emission, meaning that the malicious user could disguise this harmless transaction as an approval on a non-owned account.
Consider requiring a non-zero value transfer when using the transferFrom
function to prevent users from being deceived and to improve the readability of off-chain services that track event logs.
Update: Acknowledged. The EmptySetSquad team statement for this issue:
Won’t fix – will stick with current behavior to conform to OZs implementation of ERC20.
Notes & Additional Information
[N01] Not following the Checks-Effects-Interactions pattern
The Dollar
contract extends the functionality of the ERC20Detailed
and Permittable
.
The transferFrom
function check if the sender is allowed by the holder to send the tokens but this check is done after the transfer has been made.
Solidity recommends the usage of the Checks-Effects-Interactions Pattern to avoid potential security issues, such as reentrancy.
Although in this scenario it does not represent a possible security issue, consider always following the “Check-Effects-Interactions” pattern, thus checking the parameters before modifying the contract’s state.
Update: Acknowledged. The EmptySetSquad team statement for this issue:
Won’t fix – preserve for code clarity.
[N02] Gas optimization
There are some examples in the code base where gas consumption can be optimized by returning or failing earlier.
- The requirement performed on Line 287 of
ReserveComptroller.sol
can be done after line 281 using thenewBorrowed
variable. - The order of lines 94-95 of
StabilizerToken.sol
can be inverted to fail earlier.
Consider reviewing the code base for occurrences like these and refactor the code to be more gas efficient.
Update: Partially fixed on pull request 7. Only the operation order in the StabilizerToken
contract was changed.
[N03] Modifier could replace repeated requirements
In the GovernorAlpha
contract there are certain functions that can only be called by the guardian
address. These are to cancel a proposal, accept an admin, abdicate the guardian role, set the pending admin, and execute the pending admin process.
On all of these functions, the requirement is copied instead of using a modifier that restricts the call of the functions to only the guardian. This not only cost more at the time of the deployment but also renders the readability of the code.
Consider replacing all the guardian only-access requirements to a modifier that accomplishes the same functionality.
Update: Acknowledged. The EmptySetSquad team statement for this issue:
Won’t fix – don’t want to modify widely-used forked code unnecessarily.
[N04] Implicit and default values
In the code base, there are several occurrences of parameter’s values that imply a certain purpose.
In particular, uint(-1)
and uint96(-1)
are used in the Stake
contract to indicate an infinite approval for token spender, or in the ReserveSwapper
to indicate an unlimited order amount.
Similarly, the Pending
status of the ProposalState
struct from the GovernorAlpha
contract is the default value of the enum and it is being used to tag a pending proposal status instead of having a non-existent proposal state.
To improve the code’s readability, consider replacing hardcoded values for explicit constants that properly describe their purposes. Consider also not using the default values for specific meanings, as default values are the ones that appear before an object is initialized, which may lead to confusion.
Update: Acknowledged. The EmptySetSquad team statement for this issue:
Won’t fix – don’t want to modify widely-used forked code unnecessarily.
[N05] Inconsistent coding style
In the code base, there are some inconsistencies in the style used for the code. Some examples are:
In the Incentivizer
contract:
- The
verifyRewardBalance
function is private and placed in the // ADMIN section. It should have a starting_
in the name as it is done for other private functions and being placed under a new “// PRIVATE” section. - The
settle
andsettleAccount
functions are internal and placed under the // FLYWHEEL section, but then, internal functions are defined under the // INTERNAL section and all start with a_
.
In the ReserveComptroller
contract:
- In line 183 it is used a variable to store the
ESD
address but it is not done the same on line 195 where the output from the registry is used directly. Furthermore, theIERC20
andIManagedToken
interfaces are used for the same contract. In this case, consider merging and standardizing the two interfaces if they are used exclusively by the same contract.
In the GovernorAlpha
contract:
- In line 225, the
execute
function condenses the requirement of checking the status of the proposal in one line, however thecancel
function does the same but in 2 lines. - Revert messages of the
GovernorAlpha
follow a design which is not used by all other contracts.
In order to improve readability, consider following a unique consistent style while coding the contracts.
Update: Partially fixed on pull request 18. The EmptySetSquad team statement for this issue:
ReserveComptroller Leaves as-is, the pattern being: don’t utilizes intermediary variables unless the variable would be accessed more than once in the resulting code.
GovernorAlpha Leaves as-is to not modify forked code unnecessarily.
[N06] Lack of indexed parameters in events
There are places in the code base where events defined in the contracts do not index any parameter. Some examples are:
- The
GovernorAlpha
contract. - The events from the
IImplementation
contract.
Consider indexing event parameters to avoid hindering the task of off-chain services searching and filtering for specific events.
Update: Partially fixed on pull request 6. Events on the GovernorAlpha
contract are still not indexed.
[N07] Misleading or erroneous docstrings
In the code base, there are docstrings that may confuse the user or that are incorrect. Some examples are:
- The
IComptroller
interface from theReserveVault
contract has a docstring that statesICErc20
where it should beIComptroller
. - Line 133 from
ReserveState.sol
should say “Redemption tax”. - Line 84 from
ReserveComptroller.sol
states “Rhe” where it should be “The”. - Line 85 from
ReserveVault.sol
states “…rewards the external protocol” where it should be “…rewards from the external protocol”. - Lines 112-115 from the
StabilizerComptroller.sol
are the docstrings of thesetup
function but those are copied from the docstrings of therate
function below.
In order to improve readability, consider fixing all incorrect docstrings in the code base.
Update: Fixed on pull requests 5 and 1.
[N08] Several contracts developed per file
In the code base, the StabilizerState.sol
file together with the ReserverVault.sol
, ReserverState.sol
, Migrator.sol
, GovernerAlpha.sol
, and Interfaces.sol
files all present multiple contracts or interfaces definitions inside of the same file.
To improve understandability and readability, but also to have a more modular code base, consider having one contract or interface per Solidity file.
Update: Acknowledged. The EmptySetSquad team statement for this issue:
Won’t fix directly – significantly cleaned up via pull request 19 and pull request 20.
[N09] Naming issues
In the code base, there are several occurrences of unclear or confusing names. For example:
- The
GovernerAlpha.sol
file defines a contract calledGovernorAlpha
. - The
getPartial
function’s name of theDecimal
library doesn’t reflect correctly the functionality of the function. - The name of the
_paid
mapping of theIncentivizer
contract doesn’t suit with the purpose, it is used to track the latest reward per unit instead of the amount of paid rewards as suggested by its docstring.
To improve consistency and readability of the code base, consider changing variable, function, and file names to better reflect their intentions.
Update: Partially fixed on pull request 4. The getPartial
function still has a name that does not reflect its functionality.
[N10] Declare uint as uint256
In the code base, there are several places where uint
is used instead of uint256
. Some examples are:
- In the GovernorAlpha contract.
- In the ICErc20 interface.
To favor explicitness, consider changing all instances of uint
to uint256
.
Update: Acknowledged. The EmptySetSquad team statement for this issue:
Won’t fix – don’t want to modify widely-used forked code unnecessarily.
Conclusions
No critical and 2 high vulnerabilities were found among other issues with lower severities. Recommendations and comments have been provided to improve code quality and readability and some security measures have been recommended to improve the overall health of the system.