1 Executive Summary
This report presents the results of our engagement with DAOfi to review the daofi-v1-core
and daofi-v1-periphery
smart contracts.
The review was conducted by Nicholas Ward and Sergii Kravchenko over the course of 20 person-days between February 15 and February 26, 2021.
2 Scope
Our review started with the following commit hashes:
Repository | Commit hash |
---|---|
daofi-v1-core |
0dfe2caf3a2a7a1b16aff26434f78f0b29491c06 |
daofi-v1-periphery |
fbdbd6aabe235aa01cc2002ef73ceb34776dd857 |
At the conclusion of the first week, the review shifted to the following commit hashes, for which all of the findings and recommendations of this report apply:
Repository | Commit hash |
---|---|
daofi-v1-core |
328e6dae9709a93852bb4acb098ea09202702dba |
daofi-v1-periphery |
5ae517c97d5a12522c33e1c87fdf401b489332fc |
The list of files in scope can be found in the Appendix.
3 Recommendations
3.1 Remove stale comments
Remove inline comments that suggest the two uint256
values DAOfiV1Pair.reserveBase
and DAOfiV1Pair.reserveQuote
are stored in the same storage slot. This is likely a carryover from the UniswapV2Pair
contract, in which reserve0
, reserve1
, and blockTimestampLast
are packed into a single storage slot.
code/daofi-v1-core/contracts/DAOfiV1Pair.sol:L44-L45
uint256 private reserveBase; // uses single storage slot, accessible via getReserves
uint256 private reserveQuote; // uses single storage slot, accessible via getReserves
3.2 Remove unnecessary call to DAOfiV1Factory.formula()
The DAOfiV1Pair
functions initialize()
, getBaseOut()
, and getQuoteOut()
all use the private function _getFormula()
, which makes a call to the factory to retrieve the address of the BancorFormula
contract. The formula
address in the factory is set in the constructor and cannot be changed, so these calls can be replaced with an immutable value in the pair contract that is set in its constructor.
code/daofi-v1-core/contracts/DAOfiV1Pair.sol:L94-L96
function _getFormula() private view returns (IBancorFormula) {
return IBancorFormula(IDAOfiV1Factory(factory).formula());
}
3.3 Ensure users are aware that the system is incompatible with rebasing and fee-on-transfer tokens
DAOfiV1Pair
should not be used with rebasing tokens – that is, tokens in which an account’s balance increases or decreases along with expansions or contractions in supply. The contract provides no mechanism to update its reserves in response to these unexpected balance adjustments, and funds may be lost as a result.
DAOfiV1Router01
should not be used with fee-on-transfer tokens – that is, tokens in which the balance of the recipient of a transfer may not be increased by the amount of the transfer. Some router functions make strict checks on the resulting balances and would fail for such tokens.
The development team has acknowledged these limitations, and it is recommended to continue to ensure that users are aware of them as well.
3.4 Deeper validation of curve math
BancorFormula
contract are seen to fail in unanticipated and potentially dangerous ways, so care should be taken to validate inputs and prevent pathological curve parameters.4 Findings
Each issue has an assigned severity:
- Minor issues are subjective in nature. They are typically suggestions around best practices or readability. Code maintainers should use their own judgment as to whether to address such issues.
- Medium issues are objective in nature but are not security vulnerabilities. These should be addressed unless there is a clear reason not to.
- Major issues are security vulnerabilities that may not be directly exploitable or may require certain conditions in order to be exploited. All major issues should be addressed.
- Critical issues are directly exploitable security vulnerabilities that need to be fixed.
4.1 Token approvals can be stolen in DAOfiV1Router01.addLiquidity()
Critical
Description
DAOfiV1Router01.addLiquidity()
creates the desired pair contract if it does not already exist, then transfers tokens into the pair and calls DAOfiV1Pair.deposit()
. There is no validation of the address to transfer tokens from, so an attacker could pass in any address with nonzero token approvals to DAOfiV1Router
. This could be used to add liquidity to a pair contract for which the attacker is the pairOwner
, allowing the stolen funds to be retrieved using DAOfiV1Pair.withdraw()
.
code/daofi-v1-periphery/contracts/DAOfiV1Router01.sol:L57-L85
function addLiquidity(
LiquidityParams calldata lp,
uint deadline
) external override ensure(deadline) returns (uint256 amountBase) {
if (IDAOfiV1Factory(factory).getPair(
lp.tokenBase,
lp.tokenQuote,
lp.slopeNumerator,
lp.n,
lp.fee
) == address(0)) {
IDAOfiV1Factory(factory).createPair(
address(this),
lp.tokenBase,
lp.tokenQuote,
msg.sender,
lp.slopeNumerator,
lp.n,
lp.fee
);
}
address pair = DAOfiV1Library.pairFor(
factory, lp.tokenBase, lp.tokenQuote, lp.slopeNumerator, lp.n, lp.fee
);
TransferHelper.safeTransferFrom(lp.tokenBase, lp.sender, pair, lp.amountBase);
TransferHelper.safeTransferFrom(lp.tokenQuote, lp.sender, pair, lp.amountQuote);
amountBase = IDAOfiV1Pair(pair).deposit(lp.to);
}
Recommendation
Transfer tokens from msg.sender
instead of lp.sender
.
4.2 The deposit of a new pair can be stolen Critical
Description
To create a new pair, a user is expected to call the same addLiquidity()
(or the addLiquidityETH()
) function of the router contract seen above:
code/daofi-v1-periphery/contracts/DAOfiV1Router01.sol:L57-L85
function addLiquidity(
LiquidityParams calldata lp,
uint deadline
) external override ensure(deadline) returns (uint256 amountBase) {
if (IDAOfiV1Factory(factory).getPair(
lp.tokenBase,
lp.tokenQuote,
lp.slopeNumerator,
lp.n,
lp.fee
) == address(0)) {
IDAOfiV1Factory(factory).createPair(
address(this),
lp.tokenBase,
lp.tokenQuote,
msg.sender,
lp.slopeNumerator,
lp.n,
lp.fee
);
}
address pair = DAOfiV1Library.pairFor(
factory, lp.tokenBase, lp.tokenQuote, lp.slopeNumerator, lp.n, lp.fee
);
TransferHelper.safeTransferFrom(lp.tokenBase, lp.sender, pair, lp.amountBase);
TransferHelper.safeTransferFrom(lp.tokenQuote, lp.sender, pair, lp.amountQuote);
amountBase = IDAOfiV1Pair(pair).deposit(lp.to);
}
This function checks if the pair already exists and creates a new one if it does not. After that, the first and only deposit is made to that pair.
The attacker can front-run that call and create a pair with the same parameters (thus, with the same address) by calling the createPair
function of the DAOfiV1Factory
contract. By calling that function directly, the attacker does not have to make the deposit when creating a new pair. The initial user will make this deposit, whose funds can now be withdrawn by the attacker.
Recommendation
There are a few factors/bugs that allowed this attack. All or some of them should be fixed:
- The
createPair
function of theDAOfiV1Factory
contract can be called directly by anyone without depositing with anyrouter
address as the parameter. The solution could be to allow only the router to create a pair. - The
addLiquidity
function checks that the pair does not exist yet. If the pair exists already, a deposit should only be made by the owner of the pair. But in general, a new pair shouldn’t be deployed without depositing in the same transaction. - The pair’s address does not depend on the owner/creator. It might make sense to add that information to the salt.
4.3 Incorrect token decimal conversions can lead to loss of funds Major
Description
The _convert()
function in DAOfiV1Pair
is used to accommodate tokens with varying decimals()
values. There are three cases in which it implicitly returns 0 for any amount
, the most notable of which is when token.decimals() == resolution
.
As a result of this, getQuoteOut()
reverts any time either baseToken
or quoteToken
have decimals == INTERNAL_DECIMALS
(currently hardcoded to 8).
getBaseOut()
also reverts in most cases when either baseToken
or quoteToken
have decimals() == INTERNAL_DECIMALS
. The exception is when getBaseOut()
is called while supply
is 0, as is the case in deposit()
. This causes getBaseOut()
to succeed, returning an incorrect value.
The result of this is that no swaps can be performed in one of these pools, and the deposit()
function will return an incorrect amountBaseOut
of baseToken
to the depositor, the balance of which can then be withdrawn by the pairOwner
.
code/daofi-v1-core/contracts/DAOfiV1Pair.sol:L108-L130
function _convert(address token, uint256 amount, uint8 resolution, bool to) private view returns (uint256 converted) {
uint8 decimals = IERC20(token).decimals();
uint256 diff = 0;
uint256 factor = 0;
converted = 0;
if (decimals > resolution) {
diff = uint256(decimals.sub(resolution));
factor = 10 ** diff;
if (to && amount >= factor) {
converted = amount.div(factor);
} else if (!to) {
converted = amount.mul(factor);
}
} else if (decimals < resolution) {
diff = uint256(resolution.sub(decimals));
factor = 10 ** diff;
if (to) {
converted = amount.mul(factor);
} else if (!to && amount >= factor) {
converted = amount.div(factor);
}
}
}
Recommendation
The _convert()
function should return amount
when token.decimals() == resolution
. Additionally, implicit return values should be avoided whenever possible, especially in functions that implement complex mathematical operations.
BancorFormula.power(baseN, baseD, _, _)
does not support baseN < baseD
, and checks should be added to ensure that any call to the BancorFormula
conforms to the expected input ranges.
4.4 The swapExactTokensForETH
checks the wrong return value Major
Description
The following lines are intended to check that the amount of tokens received from a swap is greater than the minimum amount expected from this swap (sp.amountOut
):
code/daofi-v1-periphery/contracts/DAOfiV1Router01.sol:L341-L345
uint amountOut = IWETH10(WETH).balanceOf(address(this));
require(
IWETH10(sp.tokenOut).balanceOf(address(this)).sub(balanceBefore) >= sp.amountOut,
'DAOfiV1Router: INSUFFICIENT_OUTPUT_AMOUNT'
);
Instead, it calculates the difference between the initial receiver’s balance and the balance of the router.
Recommendation
Check the intended value.
4.5 DAOfiV1Pair.deposit()
accepts deposits of zero, blocking the pool Medium
Description
DAOfiV1Pair.deposit()
is used to deposit liquidity into the pool. Only a single deposit can be made, so no liquidity can ever be added to a pool where deposited == true
. The deposit()
function does not check for a nonzero deposit amount in either token, so a malicious user that does not hold any of the baseToken
or quoteToken
can lock the pool by calling deposit()
without first transferring any funds to the pool.
code/daofi-v1-core/contracts/DAOfiV1Pair.sol:L223-L239
function deposit(address to) external override lock returns (uint256 amountBaseOut) {
require(msg.sender == router, 'DAOfiV1: FORBIDDEN_DEPOSIT');
require(deposited == false, 'DAOfiV1: DOUBLE_DEPOSIT');
reserveBase = IERC20(baseToken).balanceOf(address(this));
reserveQuote = IERC20(quoteToken).balanceOf(address(this));
// this function is locked and the contract can not reset reserves
deposited = true;
if (reserveQuote > 0) {
// set initial supply from reserveQuote
supply = amountBaseOut = getBaseOut(reserveQuote);
if (amountBaseOut > 0) {
_safeTransfer(baseToken, to, amountBaseOut);
reserveBase = reserveBase.sub(amountBaseOut);
}
}
emit Deposit(msg.sender, reserveBase, reserveQuote, amountBaseOut, to);
}
Recommendation
Require a minimum deposit amount in both baseToken
and quoteToken
, and do not rely on any assumptions about the distribution of baseToken
as part of the security model.
4.6 Restricting DAOfiV1Pair
functions to calls from router
makes DAOfiV1Router01
security critical Medium
Description
The DAOfiV1Pair
functions deposit()
, withdraw()
, and swap()
are all restricted to calls from the router in order to avoid losses from user error. However, this means that any unidentified issue in the Router could render all pair contracts unusable, potentially locking the pair owner’s funds.
Additionally, DAOfiV1Factory.createPair()
allows any nonzero address to be provided as the router
, so pairs can be initialized with a malicious router that users would be forced to interact with to utilize the pair contract.
code/daofi-v1-core/contracts/DAOfiV1Pair.sol:L223-L224
function deposit(address to) external override lock returns (uint256 amountBaseOut) {
require(msg.sender == router, 'DAOfiV1: FORBIDDEN_DEPOSIT');
code/daofi-v1-core/contracts/DAOfiV1Pair.sol:L250-L251
function withdraw(address to) external override lock returns (uint256 amountBase, uint256 amountQuote) {
require(msg.sender == router, 'DAOfiV1: FORBIDDEN_WITHDRAW');
code/daofi-v1-core/contracts/DAOfiV1Pair.sol:L292-L293
function swap(address tokenIn, address tokenOut, uint256 amountIn, uint256 amountOut, address to) external override lock {
require(msg.sender == router, 'DAOfiV1: FORBIDDEN_SWAP');
Recommendation
Do not restrict DAOfiV1Pair
functions to calls from router
, but encourage users to use a trusted router to avoid losses from user error. If this restriction is kept, consider including the router address in the deployment salt for the pair or hardcoding the address of a trusted router in DAOfiV1Factory
instead of taking the router as a parameter to createPair()
.
4.7 Pair contracts can be easily blocked Minor
Description
The parameters used to define a unique pair are the baseToken
, quoteToken
, slopeNumerator
, n
, and fee
. There is only one accepted value for n
, and there are eleven accepted values for fee
. This makes the number of possible “interesting” pools for each token pair somewhat limited, and pools can be easily blocked by front-running deployments and depositing zero liquidity or immediately withdrawing deposited liquidity. Because liquidity can only be added once, these pools are permanently blocked.
The existing mitigation for this issue is to create a new pool with slightly different parameters. This creates significant cost for the creator of a pair, forces them to deploy a pair with sub-optimal parameters, and could potentially block all interesting pools for a token pair.
The salt used to determine unique pair contracts in DAOfiV1Factory.createPair()
:
code/daofi-v1-core/contracts/DAOfiV1Factory.sol:L77-L84
require(getPair(baseToken, quoteToken, slopeNumerator, n, fee) == address(0), 'DAOfiV1: PAIR_EXISTS'); // single check is sufficient
bytes memory bytecode = type(DAOfiV1Pair).creationCode;
bytes32 salt = keccak256(abi.encodePacked(baseToken, quoteToken, slopeNumerator, n, fee));
assembly {
pair := create2(0, add(bytecode, 32), mload(bytecode), salt)
}
IDAOfiV1Pair(pair).initialize(router, baseToken, quoteToken, pairOwner, slopeNumerator, n, fee);
pairs[salt] = pair;
Recommendation
Consider adding additional parameters to the salt that defines a unique pair, such as the pairOwner
. Modifying the parameters included in the salt can also be used to partially mitigate other security concerns raised in this report.
4.8 DAOfiV1Router01.removeLiquidityETH()
does not support tokens with no return value Minor
Description
While the rest of the system uses the safeTransfer*
pattern, allowing tokens that do not return a boolean value on transfer()
or transferFrom()
, DAOfiV1Router01.removeLiquidityETH()
throws and consumes all remaining gas if the base token does not return true
.
Note that the deposit in this case can still be withdrawn without unwrapping the Eth using removeLiquidity()
.
code/daofi-v1-periphery/contracts/DAOfiV1Router01.sol:L157-L167
function removeLiquidityETH(
LiquidityParams calldata lp,
uint deadline
) external override ensure(deadline) returns (uint amountToken, uint amountETH) {
IDAOfiV1Pair pair = IDAOfiV1Pair(DAOfiV1Library.pairFor(factory, lp.tokenBase, WETH, lp.slopeNumerator, lp.n, lp.fee));
require(msg.sender == pair.pairOwner(), 'DAOfiV1Router: FORBIDDEN');
(amountToken, amountETH) = pair.withdraw(address(this));
assert(IERC20(lp.tokenBase).transfer(lp.to, amountToken));
IWETH10(WETH).withdraw(amountETH);
TransferHelper.safeTransferETH(lp.to, amountETH);
}
Recommendation
Be consistent with the use of safeTransfer*
, and do not use assert()
in cases where the condition can be false.
Appendix 1 - Files in Scope
This review covered the following files, with SHA-1 hashes computed for daofi-v1-core
at commit hash 328e6da
and daofi-v1-periphery
at commit hash 5ae517c
:
File | SHA-1 hash |
---|---|
daofi-v1-core/DAOfiV1Pair.sol | a27c969b2716f233dd6c74375c30287628b1dc7b |
daofi-v1-core/DAOfiV1Factory.sol | 0fef2b496bcd76d9f6824fb7383283edd99e0b60 |
daofi-v1-core/libraries/SafeMath.sol | 62c7ef91200539f7974c2b6823d77e4c091e59b7 |
daofi-v1-core/interfaces/IDAOfiV1Pair.sol | 0449a5773b5ba5cc80e8e583c48dbcdf4cac8a91 |
daofi-v1-core/interfaces/IDAOfiV1Factory.sol | d3727708fb5becfc785b552d792f31dcb824bdea |
daofi-v1-core/interfaces/IERC20.sol | deeda8921aa5f752effd3ab114d13e9fe46df1e4 |
daofi-v1-periphery/DAOfiV1Router01.sol | 31c9e9fa1a5c885a83a744d1123292f2ef150de2 |
daofi-v1-periphery/libraries/DAOfiV1Library.sol | 792df2936dab584bc7e7776052c76e939cf67ad5 |
daofi-v1-periphery/libraries/SafeMath.sol | 62c7ef91200539f7974c2b6823d77e4c091e59b7 |
daofi-v1-periphery/interfaces/IERC20.sol | deeda8921aa5f752effd3ab114d13e9fe46df1e4 |
daofi-v1-periphery/interfaces/IERC2612.sol | 7da8db97d5056bd78c88132dd6a5b3698c965152 |
daofi-v1-periphery/interfaces/IDAOfiV1Router01.sol | df65a68be60aff44cf666185bb7376d81f776c17 |
daofi-v1-periphery/interfaces/IWxDAI.sol | 29c8b63b6826e6d297a7692e83637f66a8e3762b |
daofi-v1-periphery/interfaces/IWETH10.sol | 39ab6ca3cf34d4c90edc468c709eb9aeb52770eb |
Appendix 2 - Disclosure
ConsenSys Diligence (“CD”) typically receives compensation from one or more clients (the “Clients”) for performing the analysis contained in these reports (the “Reports”). The Reports may be distributed through other means, including via ConsenSys publications and other distributions.
The Reports are not an endorsement or indictment of any particular project or team, and the Reports do not guarantee the security of any particular project. This Report does not consider, and should not be interpreted as considering or having any bearing on, the potential economics of a token, token sale or any other product, service or other asset. Cryptographic tokens are emergent technologies and carry with them high levels of technical risk and uncertainty. No Report provides any warranty or representation to any Third-Party in any respect, including regarding the bugfree nature of code, the business model or proprietors of any such business model, and the legal compliance of any such business. No third party should rely on the Reports in any way, including for the purpose of making any decisions to buy or sell any token, product, service or other asset. Specifically, for the avoidance of doubt, this Report does not constitute investment advice, is not intended to be relied upon as investment advice, is not an endorsement of this project or team, and it is not a guarantee as to the absolute security of the project. CD owes no duty to any Third-Party by virtue of publishing these Reports.
PURPOSE OF REPORTS The Reports and the analysis described therein are created solely for Clients and published with their consent. The scope of our review is limited to a review of Solidity code and only the Solidity code we note as being within the scope of our review within this report. The Solidity language itself remains under development and is subject to unknown risks and flaws. The review does not extend to the compiler layer, or any other areas beyond Solidity that could present security risks. Cryptographic tokens are emergent technologies and carry with them high levels of technical risk and uncertainty.
CD makes the Reports available to parties other than the Clients (i.e., “third parties”) – on its website. CD hopes that by making these analyses publicly available, it can help the blockchain ecosystem develop technical best practices in this rapidly evolving area of innovation.
LINKS TO OTHER WEB SITES FROM THIS WEB SITE You may, through hypertext or other computer links, gain access to web sites operated by persons other than ConsenSys and CD. Such hyperlinks are provided for your reference and convenience only, and are the exclusive responsibility of such web sites’ owners. You agree that ConsenSys and CD are not responsible for the content or operation of such Web sites, and that ConsenSys and CD shall have no liability to you or any other person or entity for the use of third party Web sites. Except as described below, a hyperlink from this web Site to another web site does not imply or mean that ConsenSys and CD endorses the content on that Web site or the operator or operations of that site. You are solely responsible for determining the extent to which you may use any content at any other web sites to which you link from the Reports. ConsenSys and CD assumes no responsibility for the use of third party software on the Web Site and shall have no liability whatsoever to any person or entity for the accuracy or completeness of any outcome generated by such software.
TIMELINESS OF CONTENT The content contained in the Reports is current as of the date appearing on the Report and is subject to change without notice. Unless indicated otherwise, by ConsenSys and CD.