DAOfi

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

Increased testing of edge cases in complex mathematical operations could have identified at least one issue raised in this report. Additional unit tests are recommended, as well as fuzzing or property-based testing of curve-related operations. Improperly validated interactions with the 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 the DAOfiV1Factory contract can be called directly by anyone without depositing with any router 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.