Halborn Logo

Token Sale & Comptroller Updates - Moonwell


Prepared by:

Halborn Logo

HALBORN

Last Updated 06/11/2024

Date of Engagement by: April 5th, 2022 - June 17th, 2022

Summary

83% of all REPORTED Findings have been addressed

All findings

18

Critical

0

High

0

Medium

3

Low

1

Informational

14


1. INTRODUCTION

Moonwell Finance engaged Halborn to conduct a security audit on their smart contracts beginning on 2022-04-05 and ending on 2022-06-17. The security assessment was scoped to the smart contracts provided to the Halborn team.

2. AUDIT SUMMARY

The team at Halborn was provided a week for the engagement and assigned a full-time security engineer to audit the security of the smart contract. The security engineer is a blockchain and smart-contract security expert with advanced penetration testing, smart-contract hacking, and deep knowledge of multiple blockchain protocols.

The purpose of this audit is to:

    • Ensure that smart contract functions operate as intended.

    • Identify potential security issues with the smart contracts.

In summary, Halborn identified some security risks that were addressed by the Moonwell Finance team.

3. TEST APPROACH & METHODOLOGY

Halborn performed a combination of manual and automated security testing to balance efficiency, timeliness, practicality, and accuracy regarding the scope of the smart contract audit. While manual testing is recommended to uncover flaws in logic, process, and implementation; automated testing techniques help enhance coverage of smart contracts and can quickly identify items that do not follow security best practices. The following phases and associated tools were used throughout the term of the audit:

    • Research into architecture and purpose.

    • Smart Contract manual code review and walkthrough.

    • Graphing out functionality and contract logic/connectivity/functions(solgraph)

    • Manual Assessment of use and safety for the critical Solidity variables and functions in scope to identify any arithmetic related vulnerability classes.

    • Dynamic Analysis (ganache-cli, brownie, hardhat).

4. SCOPE

\begin{enumerate} \item Moonwell Finance Token Sale Contracts \begin{enumerate} \item Repository: \href{https://github.com/moonwell-fi/moonwell-contracts-private/tree/726dcbaef18670d344fa5621c23c4db0e403583a/contracts/tokensale}{Token Sale} \item Commit ID: \href{https://github.com/moonwell-fi/moonwell-contracts-private/tree/726dcbaef18670d344fa5621c23c4db0e403583a/contracts/tokensale}{726dcbaef18670d344fa5621c23c4db0e403583a} \item New PR : \href{ https://github.com/moonwell-fi/moonwell-contracts-private/pull/43 }{PR} \item New Commit : \href{ https://github.com/moonwell-fi/moonwell-contracts-private/tree/9c51e4860c3f768190036ddcc7dbc4ef3d497c1f/contracts/tokensale }{New Commit} \end{enumerate} \item Out-of-Scope \begin{enumerate} \item test/*.sol \end{enumerate} \end{enumerate}

Out-of-scope: External contract, libraries and financial related attacks.

FIX Commit ID : 762cdc4cd9a8d09f29765f9e143b25af0ebe9720

TAG : artemis-v1

5. RISK METHODOLOGY

Vulnerabilities or issues observed by Halborn are ranked based on the risk assessment methodology by measuring the LIKELIHOOD of a security incident and the IMPACT should an incident occur. This framework works for communicating the characteristics and impacts of technology vulnerabilities. The quantitative model ensures repeatable and accurate measurement while enabling users to see the underlying vulnerability characteristics that were used to generate the Risk scores. For every vulnerability, a risk level will be calculated on a scale of 5 to 1 with 5 being the highest likelihood or impact.
RISK SCALE - LIKELIHOOD
  • 5 - Almost certain an incident will occur.
  • 4 - High probability of an incident occurring.
  • 3 - Potential of a security incident in the long term.
  • 2 - Low probability of an incident occurring.
  • 1 - Very unlikely issue will cause an incident.
RISK SCALE - IMPACT
  • 5 - May cause devastating and unrecoverable impact or loss.
  • 4 - May cause a significant level of impact or loss.
  • 3 - May cause a partial impact or loss to many.
  • 2 - May cause temporary impact or loss.
  • 1 - May cause minimal or un-noticeable impact.
The risk level is then calculated using a sum of these two values, creating a value of 10 to 1 with 10 being the highest level of security risk.
Critical
High
Medium
Low
Informational
  • 10 - CRITICAL
  • 9 - 8 - HIGH
  • 7 - 6 - MEDIUM
  • 5 - 4 - LOW
  • 3 - 1 - VERY LOW AND INFORMATIONAL

6. SCOPE

Out-of-Scope: New features/implementations after the remediation commit IDs.

7. Assessment Summary & Findings Overview

Critical

0

High

0

Medium

3

Low

1

Informational

14

Impact x Likelihood

HAL-02

HAL-06

HAL-01

HAL-03

HAL-04

HAL-05

HAL-07

HAL-08

HAL-09

HAL-10

HAL-11

HAL-13

HAL-14

HAL-15

HAL-16

HAL-17

HAL-18

HAL-12

Security analysisRisk levelRemediation Date
OLD TOKENS ARE NOT RECOVERABLE WHEN THE NEW TOKEN IS SETMediumSolved - 04/14/2022
EXPIRED TOKENS ARE NOT CONSIDERED IN THE VOTING POWERMediumSolved - 06/17/2022
OWNER CAN RESET ALLOCATIONS - DELEGATIONSMediumRisk Accepted
UNNEEDED INITIALIZATION OF UINT256 VARIABLES TO 0LowSolved - 04/14/2022
MISSING ZERO ADDRESS CHECKSInformational-
MISSING EVENTS FOR ADMIN ONLY FUNCTIONS THAT CHANGE CRITICAL PARAMETERSInformational-
USING ++I CONSUMES LESS GAS THAN I+=1 IN LOOPSInformationalSolved - 04/14/2022
CACHING THE LENGTH IN THE FOR LOOPSInformationalSolved - 04/14/2022
REVERT STRING SIZE OPTIMIZATIONInformationalAcknowledged
MISSING CHECKS FOR NON-ZERO TRANSFER VALUE CALLSInformationalSolved - 04/14/2022
BLOCK WITH GAS LIMITInformationalAcknowledged
EXPERIMENTAL KEYWORD USAGEInformationalSolved - 04/14/2022
UPGRADE AT LEAST PRAGMA 0.8.10InformationalSolved - 04/14/2022
OPEN TODOSInformationalSolved - 06/17/2022
CHANGING FUNCTION VISIBILITY FROM PUBLIC TO EXTERNAL CAN SAVE GASInformationalSolved - 06/17/2022
DIRECT USAGE OF ECRECOVER ALLOWS SIGNATURE MALLEABILITYInformationalSolved - 06/17/2022
MISSING EVENTSInformationalSolved - 06/17/2022
OPTIMIZE UNSIGNED INTEGER COMPARISONInformational-

8. Findings & Tech Details

8.1 OLD TOKENS ARE NOT RECOVERABLE WHEN THE NEW TOKEN IS SET

// Medium

Description

The privileged address can set the token. However, when the token is set to another address, the old tokens cannot be retrieved using the contract.

  • Admin sets allocation via setAllocations function.
  • After the time, the user claims allocations.
  • Admin sets new token on the contract.
  • Old tokens are not recoverable by the contract. The withdraw function only takes an amount of argument.

Code Location

TokenSaleDistributor.sol

    function setTokenAddress(address newTokenAddress) external adminOnly {
        require(tokenAddress == address(0), "Address already set");
        tokenAddress = newTokenAddress;
    }
Score
Impact: 3
Likelihood: 3
Recommendation

SOLVED: The Moonwell Team solved this issue by implementing the withdraw function in the TokenSaleDistributor.sol contract.

Commit ID: Commit ID

8.2 EXPIRED TOKENS ARE NOT CONSIDERED IN THE VOTING POWER

// Medium

Description

During the code review, It has been observed that expired tokens are not included in the voting power. Although the expired tokens are not used in the contract depends on the protocol behavior that can directly affect voting. The voting power should be carefully designed with expired tokens.

Code Location

TokenSaleDistributor.sol

    function totalVotingPower(address user) public view returns (uint) {
        uint totalAllocatedToUser = totalAllocated(user);
        uint totalClaimedByUser = totalClaimed(user);
        return totalAllocatedToUser - totalClaimedByUser;
    }
Score
Impact: 4
Likelihood: 2
Recommendation

SOLVED: The Moonwell Team solved this issue by deleting expired tokens from the code base.

Commit ID: Commit ID

8.3 OWNER CAN RESET ALLOCATIONS - DELEGATIONS

// Medium

Description

During the code review, It has been noticed that the owner can delete all delegations from any account.

Code Location

TokenSaleDistributor.sol

    function resetAllocationsByUser(address[] memory recipients) external adminOnly {
        uint length = recipients.length;
        for (uint i; i < length; ++i) {
            uint votingPower = totalVotingPower(recipients[i]);
            _moveDelegates(delegates[recipients[i]], address(0), votingPower);
            delete allocations[recipients[i]];
        }
    }
Score
Impact: 3
Likelihood: 3
Recommendation

RISK ACCEPTED: The Moonwell Team states that they will do this if they found a critical vulnerability after deployment, and they needed to claw back all tokens to prevent them from being stolen. In that case, they will deploy a new contract and re-add the claims as well. On the other hand, The Moonwell Team commented the feature on the code with the following PR.

Commit ID: Commit ID

8.4 UNNEEDED INITIALIZATION OF UINT256 VARIABLES TO 0

// Low

Description

Since i is an uint256, it is already initialized to 0. uint256 i = 0 reassigns the 0 to i which wastes gas.

Code Location

TokenSaleDistributor.sol

  • Line 157: for (uint i = 0; i < recipients.length; i += 1) {
Score
Impact: 3
Likelihood: 1
Recommendation

SOLVED: The Moonwell Team solved this issue by removing initialization.

Commit ID: Commit ID

8.5 MISSING ZERO ADDRESS CHECKS

// Informational

Description
Finding description placeholder
Score
Impact:
Likelihood:

8.6 MISSING EVENTS FOR ADMIN ONLY FUNCTIONS THAT CHANGE CRITICAL PARAMETERS

// Informational

Description
Finding description placeholder
Score
Impact:
Likelihood:

8.7 USING ++I CONSUMES LESS GAS THAN I+=1 IN LOOPS

// Informational

Description

In the loop below, the variable i is incremented using i+=. It is known that, in loops, using ++i costs less gas per iteration than i+=1.

Code Location

  TokenSaleDistributor.sol::31 => for (uint i; i < allocations[msg.sender].length; i += 1) {
  TokenSaleDistributor.sol::59 => for (uint i; i < allocations[recipient].length; i += 1) {
  TokenSaleDistributor.sol::71 => for (uint i; i < allocations[recipient].length; i += 1) {
  TokenSaleDistributor.sol::83 => for (uint i; i < allocations[recipient].length; i += 1) {
  TokenSaleDistributor.sol::95 => for (uint i; i < allocations[recipient].length; i += 1) {
  TokenSaleDistributor.sol::107 => for (uint i; i < allocations[recipient].length; i += 1) {
  TokenSaleDistributor.sol::157 => for (uint i = 0; i < recipients.length; i += 1) {
  TokenSaleDistributor.sol::180 => for (uint i; i < recipients.length; i += 1) {

For example, based on the following test contract:

Test.sol

//SPDX-License-Identifier: MIT
pragma solidity 0.8.9;

contract test {
    function postiincrement(uint256 iterations) public {
        for (uint256 i = 0; i < iterations; i+=1) {
        }
    }
    function preiincrement(uint256 iterations) public {
        for (uint256 i = 0; i < iterations; ++i) {
        }
    }
}

We can see the difference in gas costs: 659e68a5a1aa3698c0e743be

Score
Impact: 1
Likelihood: 1
Recommendation

SOLVED: The Moonwell Team solved this issue by following the above recommendation.

Commit ID: Commit ID

8.8 CACHING THE LENGTH IN THE FOR LOOPS

// Informational

Description

The solidity compiler will always read the length of the array during each iteration.

  • If it is a storage array, this is an additional sload operation (100 additional extra gas (EIP-2929) for each iteration except the first)
  • If it is a memory array, this is an extra mload operation (3 additional gas for each iteration except the first),
  • If it is a calldata array, it is an extra calldataload operation (3 additional gas for each iteration except the first).

Code Location

  TokenSaleDistributor.sol::31 => for (uint i; i < allocations[msg.sender].length; i += 1) {
  TokenSaleDistributor.sol::59 => for (uint i; i < allocations[recipient].length; i += 1) {
  TokenSaleDistributor.sol::71 => for (uint i; i < allocations[recipient].length; i += 1) {
  TokenSaleDistributor.sol::83 => for (uint i; i < allocations[recipient].length; i += 1) {
  TokenSaleDistributor.sol::95 => for (uint i; i < allocations[recipient].length; i += 1) {
  TokenSaleDistributor.sol::107 => for (uint i; i < allocations[recipient].length; i += 1) {
  TokenSaleDistributor.sol::157 => for (uint i = 0; i < recipients.length; i += 1) {
  TokenSaleDistributor.sol::180 => for (uint i; i < recipients.length; i += 1) {
Score
Impact: 1
Likelihood: 1
Recommendation

SOLVED: The Moonwell Team solved this issue by caching arrays.

Commit ID: Commit ID

8.9 REVERT STRING SIZE OPTIMIZATION

// Informational

Description

Shortening the revert strings to fit within 32 bytes will decrease deployment time gas and decrease runtime gas when the revert condition is met.

Revert strings that are longer than 32 bytes require at least one additional mstore, along with additional overhead to calculate memory offset, etc.

Code Location

TokenSaleDistributor.sol

    function becomeImplementation(TokenSaleDistributorProxy proxy) external {
        require(msg.sender == proxy.admin(), "Only proxy admin can change the implementation");
        proxy.acceptPendingImplementation();
    }
Score
Impact: 1
Likelihood: 1
Recommendation

ACKNOWLEDGED: The Moonwell Team acknowledged this issue. This is a long-tail gas estimation issue, and the Moonwell Team would rather have clean error messages than fix gas on a revert the user could have detected.

8.10 MISSING CHECKS FOR NON-ZERO TRANSFER VALUE CALLS

// Informational

Description

Checking for non-zero transfer values can prevent an external call to save gas.

Code Location

TokenSaleDistributor.sol

  • Line 189: function withdraw(uint amount) external adminOnly {
Score
Impact: 1
Likelihood: 1
Recommendation

SOLVED: The Moonwell Team solved this issue by implementing amount check.

Commit ID: Commit ID

8.11 BLOCK WITH GAS LIMIT

// Informational

Description

There are several loops in the contract that can eventually grow large enough to make future operations of the contract cost too much gas to fit in one block.

Code Location

TokenSaleDistributor.sol

  TokenSaleDistributor.sol::31 => for (uint i; i < allocations[msg.sender].length; i += 1) {
  TokenSaleDistributor.sol::59 => for (uint i; i < allocations[recipient].length; i += 1) {
  TokenSaleDistributor.sol::71 => for (uint i; i < allocations[recipient].length; i += 1) {
  TokenSaleDistributor.sol::83 => for (uint i; i < allocations[recipient].length; i += 1) {
  TokenSaleDistributor.sol::95 => for (uint i; i < allocations[recipient].length; i += 1) {
  TokenSaleDistributor.sol::107 => for (uint i; i < allocations[recipient].length; i += 1) {
  TokenSaleDistributor.sol::157 => for (uint i = 0; i < recipients.length; i += 1) {
  TokenSaleDistributor.sol::180 => for (uint i; i < recipients.length; i += 1) {
Score
Impact: 1
Likelihood: 1
Recommendation

ACKNOWLEDGED: The Moonwell Team acknowledged this issue.

8.12 EXPERIMENTAL KEYWORD USAGE

// Informational

Description

ABIEncoderV2 is enabled and using experimental features could be dangerous in live deployments. The experimental ABI encoder does not correctly handle non-integer values less than 32 bytes. This applies to bytesNN types, bool, enum and other types when they are part of an array or a struct and encoded directly from storage. This means these storage references have to be used directly inside abi.encode(...) as arguments in external function calls or in event data without prior assignment to a local variable. The types bytesNN and bool will result in corrupted data, while enum could cause an invalid revert.

Code Location

pragma experimental ABIEncoderV2

TokenSaleDistributor.sol - Line#4
Score
Impact: 1
Likelihood: 2
Recommendation

SOLVED: The Moonwell Team solved this issue by deleting experimental check.

Commit ID: Commit ID

8.13 UPGRADE AT LEAST PRAGMA 0.8.10

// Informational

Description

Additional gas optimizations and security checks are available for free when using newer versions of the compiler and optimizer.

  • Safemath by default since 0.8.0 (maybe more efficient on gas than library-based safemath.)
  • Low level inliner : As of 0.8.2, leads to cheaper gas runtime. Especially relevant when the contract has small functions. For example, OpenZeppelin libraries typically have a lot of small helper functions and if they are not inlined, they cost an extra 20-40 gas because of the extra 2 jump instructions and additional stack operations needed for function calls.
  • Optimizer improvements in packed structs: Before 0.8.3, storing packed structs, in some cases, used an additional storage read operation. After EIP-2929, if the slot was already cold, this means unnecessary stacking operations and extra deployment time costs. However, if the slot was already warm, this means additional cost of 100 gas along with the same unnecessary stack operations and extra deploy time costs.
  • Custom errors from 0.8.4, leads to cheaper deploy time cost and run time cost. Note: the run time cost is only relevant when the revert condition is met. In short, replace revert strings with custom errors.

Code Location

TokenSaleDistributor.sol

pragma solidity 0.6.12;
Score
Impact: 1
Likelihood: 1
Recommendation

SOLVED: The Moonwell Team solved this issue by updating pragma to 0.8.10.

Commit ID: Commit ID

8.14 OPEN TODOS

// Informational

Description

Open TODOs can point to architecture or programming issues that still need to be resolved.

Code Location

TokenSaleDistributor.sol

    /// @notice EIP-20 token name for this token
    // TODO(lunar): validate this name.
    string public constant name = "vWELL";
Score
Impact: 1
Likelihood: 1
Recommendation

SOLVED: The Moonwell Team solved this issue by deleting TODOs check.

Commit ID: Commit ID

8.15 CHANGING FUNCTION VISIBILITY FROM PUBLIC TO EXTERNAL CAN SAVE GAS

// Informational

Description

There is a function declared as public that is never called internally within the contract. It is best practice to mark such functions as external instead, as this saves gas (especially in the case where the function takes arguments, as external functions can read arguments directly from calldata instead of having to allocate memory).

Code Location

TokenSaleDistributor.sol

    function delegate(address delegatee) public {
        return _delegate(msg.sender, delegatee);
    }
Score
Impact: 1
Likelihood: 1
Recommendation

SOLVED: The Moonwell Team solved this issue by marking function as an external.

Commit ID: Commit ID

8.16 DIRECT USAGE OF ECRECOVER ALLOWS SIGNATURE MALLEABILITY

// Informational

Description

delegateBySig calls the Solidity ecrecover function directly to verify the given signatures. However, the ecrecover EVM opcode allows malleable (non-unique) signatures and thus is susceptible to replay attacks.

Although a replay attack seems not possible here since the nonce is increased each time, ensuring the signatures are not malleable is considered a best practice.

Code Location

TokenSaleDistributor.sol

    function delegateBySig(address delegatee, uint nonce, uint expiry, uint8 v, bytes32 r, bytes32 s) public {
        bytes32 domainSeparator = keccak256(abi.encode(DOMAIN_TYPEHASH, keccak256(bytes(name)), getChainId(), address(this)));
        bytes32 structHash = keccak256(abi.encode(DELEGATION_TYPEHASH, delegatee, nonce, expiry));
        bytes32 digest = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash));
        address signatory = ecrecover(digest, v, r, s);
        require(signatory != address(0), "VestingWell::delegateBySig: invalid signature");
        require(nonce == nonces[signatory]++, "VestingWell::delegateBySig: invalid nonce");
        require(block.timestamp <= expiry, "VestingWell::delegateBySig: signature expired");
        return _delegate(signatory, delegatee);
    }

Score
Impact: 1
Likelihood: 1
Recommendation

SOLVED: The Moonwell Team solved this issue by changing implementation with ECDSA library.

Commit ID: Commit ID

8.17 MISSING EVENTS

// Informational

Description

Owner/governor only functions that change critical parameters should emit events. Events allow capturing the changed parameters so that off-chain tools/interfaces can register such changes with timelocks that allow users to evaluate them and consider if they would like to engage/exit based on how they perceive the changes as affecting the trustworthiness of the protocol or profitability of the implemented financial services. The alternative of directly querying on-chain contract state for such changes is not considered practical for most users/usages.

Code Location

TokenSaleDistributor.sol

     function setVotingEnabled(bool enabled) external adminOnly {
         votingEnabled = enabled;
     }
Score
Impact: 1
Likelihood: 1
Recommendation

SOLVED: The Moonwell Team solved this issue by adding event on the related function.

Commit ID: Commit ID

8.18 OPTIMIZE UNSIGNED INTEGER COMPARISON

// Informational

Description
Finding description placeholder
Score
Impact:
Likelihood:

9. Automated Testing

STATIC ANALYSIS REPORT

Description

Halborn used automated testing techniques to enhance coverage of certain areas of the scoped contract. Among the tools used was Slither, a Solidity static analysis framework. After Halborn verified all the contracts in the repository and was able to compile them correctly into their ABI and binary formats. This tool can statically verify mathematical relationships between Solidity variables to detect invalid or inconsistent usage of the contracts' APIs across the entire code-base.

Results

659e8db3a1aa3698c0ea068b659e8db5a1aa3698c0ea068e659e8db7a1aa3698c0ea0691

As a result of the tests carried out with the Slither tool, some results were obtained and these results were reviewed by Halborn. Based on the results reviewed, some vulnerabilities were determined to be false positives and these results were not included in the report. The actual vulnerabilities found by Slither are already included in the report findings.

Halborn strongly recommends conducting a follow-up assessment of the project either within six months or immediately following any material changes to the codebase, whichever comes first. This approach is crucial for maintaining the project’s integrity and addressing potential vulnerabilities introduced by code modifications.