ERC20

ERC20 Protocol is one of the most commonly used token standards. The ERC20 protocol defines the basic functionality and standardized interfaces for token contracts. We can divide the ERC20 protocol into three parts:

  • Basic metadata, including name, symbol, and decimals. The focus of auditing this part is on decimals, ensuring that the decimal setting is reasonable.

  • Basic operations, including total supply and balance query, token transfer, and authorization. The key point of auditing this part is to verify whether the query interface correctly handles boundary and exceptional cases, to prevent attackers from manipulating the token supply and balance through special inputs. It also involves checking for vulnerabilities that could lead to abnormal transfers or financial losses, such as reentrancy attacks and overflow vulnerabilities. It is important to validate the correct implementation of the authorization function and ensure proper verification and validation of authorization operations.

  • Token issuance business, where token minting and burning are implemented according to the issuance plan controlled by the owner. The focus of auditing this part is to verify the correctness of the issuance plan and token issuance logic, and to identify vulnerabilities related to unauthorized operations, such as failure to properly verify the identity of operators or implement proper permission controls.

There is also a special type of ERC20 token called Double-Entry Point Token. In addition to the token contract, there is another entry point contract that provides additional functionality and interaction methods. This design enhances the flexibility and extensibility of the token.

The following provides case explanations for common and significantly harmful vulnerabilities found in standard ERC20 token contracts and Double-Entry Point Token contracts.

Numerics Vulnerability in Decimals

Vulnerability Category: Numerics

The main functionality of the invest function is to transfer a specified amount of collateral to a designated strategy address and record the corresponding investment amount and collateral token information. However, this function does not check the decimal places of the token. This means that users can invest using tokens with different decimal places, which can result in accounting errors and potential financial losses.

function invest(
    address strategy,
    uint256 collateralAmount,
    uint256 collateralIndex
) external whenNotPaused whenTreasuryActive onlyFundManager {
    IERC20 collateralToken = collateral[collateralIndex].collateralToken;
    require(
        address(collateralToken) != address(0),
        "Treasury: Cannot used a removed collateral token"
    );
    //Require that the strategy address is approved
    require(
        hasRole(strategy,Roles.STRATEGY_CONTRACT),
        "Treasury:Must send funds to an approved strategy contract"
    );
    //Scale up invested amount
    investedAmount += _scaleUp(collateralAmount, collateralIndex);
    //Account for investment in strategyInvestedAmounts
    strategyInvestedAmounts[strategy] += collateralAmount;
    //Transfer collateral to strategy
    collateralToken.safeTransfer(strategy, collateralAmount);
}

Suggestion: Adding a new require statement to check if the investment token has the same number of decimal places as the first collateral token. If the condition is not met, an exception will be thrown, preventing the investment operation from proceeding. This ensures that only tokens with the same decimal places can be invested in the same strategy, thereby avoiding accounting errors and potential financial losses.

// Check if the collateral token has the same decimals as the first collateral token
require(
    collateralToken.decimals() == collateral[0].collateralToken.decimals(),
    "Treasury: Collateral token must have the same decimals as the first collateral token"
);

Access Control Vulnerability in Mint

Vulnerability Category: Access Control

The mint function has an improper access control configuration. The visibility of the mint function is set to public and does not have the onlyOwner modifier. This means that anyone can call the mint function, which in turn calls the _mint function to mint tokens.

The _mint function is an internal function and can only be called within the contract. It is used to mint a specified amount of tokens for the account address and update the relevant account and supply information. The constructor calls the internal function _mint to generate a specified quantity of tokens for a specified account. This prevents anyone from arbitrarily minting tokens, thus protecting the token's supply and maintaining its value stability.

However, the mint function can be called by anyone to mint tokens, which may lead to malicious users abusing this functionality and generating an unlimited number of tokens.

constructor(
    string memory name,
    string memory symbol,
    uint8 decimals,
    address account,
    uint256 amount
) public {
    _name = name;
    _symbol = symbol;
    _decimals = decimals;
    _mint(account, amount * 10 ** uint256(decimals));
}
function mint(
    address account,
    uint256 amount,
    string memory txId
) public returns (bool) {
    _mint(account, amount);
    emit Minted(account, amount, txId);
    return true;
}
function _mint(address account, uint256 amount) internal {
    require(account != address(0), "ERC20: mint to the zero address");
    _totalSupply = _totalSupply.add(amount);
    _balances[account] = _balances[account].add(amount);
    emit Transfer(address(0), account, amount);
}

Suggestion: Add an access control mechanism where the mint() function requires onlyOwner verification, meaning that only the owner address can mint tokens.

modifier onlyOwner() {
    require(msg.sender == owner(), "Only contract owner can call this function");
    _;
}
function mint(
    address account,
    uint256 amount,
    string memory txId
) public onlyOwner returns (bool) {
    _mint(account, amount);
    emit Minted(account, amount, txId);
    return true;
}

Double Spending in Double-Entry Point Tokens

Issue: Double Spending

The following code implements the asset registration feature in the Reserve Protocol. This feature allows users to register any ERC20 token or tokens with double-entry point attributes as assets in an asset bucket. In this function, before registering an asset, the token address is checked for duplicates, and if it does not exist in the asset bucket erc20s, it is registered. As a result, there is a possibility that the addresses of two double-entry point tokens are successfully registered.

function _register(IAsset asset) internal returns (bool registered) {
    require(!_erc20s.contains(address(asset.erc20())) || assets[asset.erc20()] == asset,"duplicate ERC20 detected");
    registered = _registerIgnoringCollisions(asset);
}
function _registerIgnoringCollisions(IAsset asset) private returns (bool swapped) {
    IERC20Metadata erc20 = asset.erc20();
    if (_erc20s.contains(address(erc20))) {
        if (assets[erc20] == asset) return false;
        else emit AssetUnregistered(erc20, assets[erc20]);
    } else {
        _erc20s.add(address(erc20));
    }
    assets[erc20] = asset;
    emit AssetRegistered(erc20, asset);
    // Refresh to ensure it does not revert, and to save a recent lastPrice
    asset.refresh();
    return true;
}

The following code implements the token issuance functionality. When a user wants to issue amtRToken, it loops through the information in the erc20s asset bucket. For double-entry point tokens, it performs two safeTransferFrom operations, resulting in the same transfer being executed twice, which leads to double spending. This behavior is not intended or expected.

function issue(address recipient, uint256 amtRToken) public notPausedOrFrozen returns (uint256) {
    ...
    _mint(recipient, amtRToken);
    for (uint256 i = 0; i < erc20s.length; ++i) {
        IERC20Upgradeable(erc20s[i]).safeTransferFrom(
            issuer,
            address(backingManager),
            deposits[i]
         );
     }
     ...
}

Suggestion: To prevent double spending issues with double-entry point tokens, additional checks and handling can be implemented during asset registration. Within the _registerIgnoringCollisions function, when registering an asset, check if a double-entry point token has already been registered. If it has, unregister the old asset and register the new one, ensuring that each address is only registered once.

function _registerIgnoringCollisions(IAsset asset) private returns (bool swapped) {
    IERC20Metadata erc20 = asset.erc20();
    if (_erc20s.contains(address(erc20))) {
        if (assets[erc20] == asset) return false;
        else {
            // Handle the double-entry point token
            // Unregister the existing asset
            emit AssetUnregistered(erc20, assets[erc20]);
            delete assets[erc20];
            // Register the new asset
            _erc20s.add(address(erc20));
            assets[erc20] = asset;
            emit AssetRegistered(erc20, asset);
        }
    } else {
        _erc20s.add(address(erc20));
        assets[erc20] = asset;
        emit AssetRegistered(erc20, asset);
    }
    // Refresh to ensure it does not revert, and to save a recent lastPrice
    asset.refresh();
    return true;
}

Last updated