Reach out for an audit or to learn more about Macro
or Message on Telegram

Mento A-3

Security Audit

March 11th, 2024

Version 1.0.0

Presented by 0xMacro

Table of Contents

Introduction

This document includes the results of the security audit for Mento's smart contract code as found in the section titled ‘Source Code’. The security audit was performed by the Macro security team from February 26, 2024 to February 28, 2024.

The purpose of this audit is to review the source code of certain Mento Solidity contracts, and provide feedback on the design, architecture, and quality of the source code with an emphasis on validating the correctness and security of the software in its entirety.

Disclaimer: While Macro’s review is comprehensive and has surfaced some changes that should be made to the source code, this audit should not solely be relied upon for security, as no single audit is guaranteed to catch all possible bugs.

Overall Assessment

The following is an aggregation of issues found by the Macro Audit team:

Severity Count Acknowledged Won't Do Addressed
High 1 - - 1
Medium 1 - - 1
Low 6 - 1 5
Code Quality 2 - - 2
Informational 2 - - -

Mento was quick to respond to these issues.

Specification

Our understanding of the specification was based on the following sources:

Trust Model, Assumptions, and Accepted Risks (TMAAR)

Roles

Assumptions

Source Code

The following source code was reviewed during the audit:

Specifically, we audited changes to the following contracts within this repository:

Source Code SHA256
contracts/governance/GovernanceFactory.sol

a55bbeecfab5fe9ffa3b80b2809d801cfaf1089a535e4c7384ba3af7a1d8e3b7

contracts/governance/MentoToken.sol

258a5937df4cd4fdce455791eb65b44b841b2f08abf76ef5401c3a56099c1911

contracts/governance/deployers/MentoTokenDeployerLib.sol

3f537f6ef8502063e0b29741038a84afaa1e9ea8ceb508003494d7c32637a570

contracts/governance/Emission.sol

7bfdcbcff52eb90cb4a9a63331a20f1d87a0ae7ac1dba5223d3b8827f832e52a

contracts/governance/deployers/EmissionDeployerLib.sol

49c9ef2eeda2e301624e9aab57c0fedffc6d3a02f35a6d0755b520eb7bcae936

contracts/governance/locking/LockingBase.sol

4005d0ea364880c99dd12f9d924bab75efbb4c06e3afda563a18911ddebead99

Note: This document contains an audit solely of the Solidity contracts listed above. Specifically, the audit pertains only to the contracts themselves, and does not pertain to any other programs or scripts, including deployment scripts.

Issue Descriptions and Recommendations

Click on an issue to jump to it, or scroll down to see them all.

Security Level Reference

We quantify issues in three parts:

  1. The high/medium/low/spec-breaking impact of the issue:
    • How bad things can get (for a vulnerability)
    • The significance of an improvement (for a code quality issue)
    • The amount of gas saved (for a gas optimization)
  2. The high/medium/low likelihood of the issue:
    • How likely is the issue to occur (for a vulnerability)
  3. The overall critical/high/medium/low severity of the issue.

This third part – the severity level – is a summary of how much consideration the client should give to fixing the issue. We assign severity according to the table of guidelines below:

Severity Description
(C-x)
Critical

We recommend the client must fix the issue, no matter what, because not fixing would mean significant funds/assets WILL be lost.

(H-x)
High

We recommend the client must address the issue, no matter what, because not fixing would be very bad, or some funds/assets will be lost, or the code’s behavior is against the provided spec.

(M-x)
Medium

We recommend the client to seriously consider fixing the issue, as the implications of not fixing the issue are severe enough to impact the project significantly, albiet not in an existential manner.

(L-x)
Low

The risk is small, unlikely, or may not relevant to the project in a meaningful way.

Whether or not the project wants to develop a fix is up to the goals and needs of the project.

(Q-x)
Code Quality

The issue identified does not pose any obvious risk, but fixing could improve overall code quality, on-chain composability, developer ergonomics, or even certain aspects of protocol design.

(I-x)
Informational

Warnings and things to keep in mind when operating the protocol. No immediate action required.

(G-x)
Gas Optimizations

The presented optimization suggestion would save an amount of gas significant enough, in our opinion, to be worth the development cost of implementing it.

Issue Details

H-1

Attacker can DoS the governance execution for all proposals

Topic
DOS
Status
Impact
High
Likelihood
Low

Note: This issue was found in Sherlock contest after this audit was performed, more details on this specific issue can be found here

An attacker can DoS the governance execution mechanism by making calls to LockingVotes.getPastTotalSupply gas expensive.

The total supply of veMento tokens is calculated in the Locking contract. The calculations take every liquidity lines stored in the

An attacker can create multiple small locks. For each lock, a line will be added to the total supply in the Locking contract.

Then, when a proposal is executed, the governor contract will call LockingVotes.getPastTotalSupply to get the veMENTO total supply at a specific block.

The issue is that this total supply is calculated from a set of lines. The more lines there are, the more expensive the call to Locking.getPastTotalSupply is.

As the attacker can register as much locks as he wants and that the amount of token for a lock can be low (104 in POC, nothing compared as token is 18 decimals), the attacker can make the calls to Locking.totalSupply and LockingVotes.getPastTotalSupply too gas-consuming that it doesn't fit in one block.

Impact HIGH Governance proposals can't be executed because of gas required.

M-1

Mento tokens can be lost, or delegated incorrectly

Topic
Input Validation
Status
Impact
High
Likelihood
Low

In Locking.sol’s lock() function, a specified amount of Mento tokens can be locked in return for veMento for the duration of the lock period.

function lock(
  address account,
  address _delegate,
  uint96 amount,
  uint32 slopePeriod,
  uint32 cliff
) external override notStopped notMigrating returns (uint256) {
  require(amount > 0, "zero amount");
  require(cliff <= MAX_CLIFF_PERIOD, "cliff too big");
  require(slopePeriod <= MAX_SLOPE_PERIOD, "period too big");

  counter++;

  uint32 currentBlock = getBlockNumber();
  uint32 time = roundTimestamp(currentBlock);
  addLines(account, _delegate, amount, slopePeriod, cliff, time, currentBlock);
  accounts[account].amount = accounts[account].amount + (amount);

  // slither-disable-next-line reentrancy-events
  require(token.transferFrom(msg.sender, address(this), amount), "transfer failed");

  emit LockCreate(counter, account, _delegate, time, amount, slopePeriod, cliff);
  return counter;
}

Reference: Locking.sol#L44-L67

However, the inputted account and _delegate addresses are not validated to ensure they are not the zero address.

In the case where the _delegate is set to address(0), the mento tokens are locked for the set duration, but the veMento gained will be useless. Users may incorrectly assume that they can set delegate to address(0) so that they do not delegate to anyone else, rather than use their own address as the delegate as expected for this case.

In the case where account is address(0), then ownership of the locked Mento tokens would be given to the zero address which cannot withdraw the tokens, effectively burning the Mento tokens.

Remediations to Consider

Validate the account and _delegate input parameters to ensure they are not set to address(0) to prevent locking Mento tokens in the contract indefinitely, and miss-delegate of veMento.

L-1

Locking migration breaks withdrawals for unpaused MentoToken

Topic
Use Case
Status
Impact
High
Likelihood
Low

The Locking contract supports migration of lock entries to another contract via the migrate function. In addition, MentoToken transfers are initially disabled and will be enabled at a later point when the contract gets unpaused. The logic to prevent transfers is implemented in the overridden _beforeTokenTransfer function:

  function _beforeTokenTransfer(
    address from,
    address to,
    uint256 amount
  ) internal virtual override {
    super._beforeTokenTransfer(from, to, amount);

    require(to != address(this), "MentoToken: cannot transfer tokens to token contract");
    // Token transfers are only possible if the contract is not paused
    // OR if triggered by the owner of the contract
    // OR if triggered by the locking contract
    // OR if triggered by the emission contract
    require(
      !paused() || owner() == _msgSender() || locking == _msgSender() || emission == _msgSender(),
      "MentoToken: token transfer while paused"
    );
  }

Reference: MentoToken.sol#L116

According to this logic, transfers are only possible when the contract is unpaused or when triggered by the owner, emission, or locking contract.

But, if lock entries are migrated to a new Locking contract, this new contract is not allowed to trigger transfers anymore, since this new address is not updated in the MentoToken implementation. As a consequence, withdrawing from the new Locking contract would revert when the MentoToken is paused, leading to locked tokens until the MentoToken is unpaused.

Another consequence of migrating locks to a new contract is that calling lock would revert for the same reason, including a revert on the Airgrab.claim function.

Note that - after clarification with the Mento team - the severity of this issue is considered low, as it is very unlikely to utilize the Locking’s migrate function. If the Locking contract needs to be changed for any reason, the preferred method is to upgrade the contract rather than performing a migrate.

Remediation to Consider

Entirely remove migration capability from the Locking contract or alternatively, to maintain support for migration, consider allowing the locking address referenced in the MentoToken to be updated on migration.

L-2

Minimum values for cliff and slope periods cannot be set to match max amounts after deployment

Topic
Input Validation
Status
Impact
Low
Likelihood
Low

The minimum allowed values for both the cliff and slope period can initially be set in the __LockingBase_init_unchained function during deployment. These values can be later adjusted via the owner-controlled functions setMinCliffPeriod and setMinSlopePeriod.

The require checks in the init function allow the min periods to be set to values equal to or less than the MAX_CLIFF_PERIOD **and **MAX_SLOPE_PERIOD:

//setting min cliff and slope
require(_minCliffPeriod <= MAX_CLIFF_PERIOD, "cliff too big");
require(_minSlopePeriod <= MAX_SLOPE_PERIOD, "period too big");

Reference: LockingBase.sol#L148-L149

However, in the respective setter functions, the require checks only allow values less than the maximum amounts, not equal to them.

function setMinCliffPeriod(uint32 newMinCliffPeriod) external notStopped notMigrating onlyOwner {
  require(newMinCliffPeriod < MAX_CLIFF_PERIOD, "new cliff period > 2 years");
  minCliffPeriod = newMinCliffPeriod;

  emit SetMinCliffPeriod(newMinCliffPeriod);
}

function setMinSlopePeriod(uint32 newMinSlopePeriod) external notStopped notMigrating onlyOwner {
  require(newMinSlopePeriod < MAX_SLOPE_PERIOD, "new slope period > 2 years");
  minSlopePeriod = newMinSlopePeriod;

  emit SetMinSlopePeriod(newMinSlopePeriod);
}

Reference: LockingBase.sol#L269-L281

Remediation to Consider

Change setMinCliffPeriod and setMinSlopePeriod to allow new values being equal to the configured MAX_CLIFF_PERIOD **and **MAX_SLOPE_PERIOD.

L-3

Locking with a cliff is more effective than using a slope

Topic
Incentive Design
Status
Wont Do
Impact
Low
Likelihood
Medium

Currently in Locking.sol the MAX_CLIFF_PERIOD is less than the MAX_SLOPE_PERIOD:

uint32 constant MAX_CLIFF_PERIOD = 103;
uint32 constant MAX_SLOPE_PERIOD = 104;

Reference: LockingBase.sol#L18-L19

The calculation to determine the percent multiplier of locked Mento token to veMento, uses the cliff or slope period, divided by it’s respective max period:

uint96 cliffSide = (uint96(cliff) * ST_FORMULA_BASIS) / MAX_CLIFF_PERIOD;
uint96 slopeSide = (uint96(slopePeriod) * ST_FORMULA_BASIS) / MAX_SLOPE_PERIOD;
uint96 multiplier = cliffSide + slopeSide;

//@audit: notice the multiplier is capped at effectively 100%
if (multiplier > ST_FORMULA_BASIS) {
  multiplier = ST_FORMULA_BASIS;
}

uint256 amountMultiplied = uint256(amount) * uint256(multiplier);
lockAmount = uint96(amountMultiplied / (ST_FORMULA_BASIS));

Reference: LockingBase.sol#L219-L228

However, since the MAX_CLIFF_PERIOD is smaller, the weight of each cliff period is just under 1% more effective than if a user were to lock with a slope.

Remediations to Consider

Consider setting the max periods to be the same.

Response by Mento

Making the MAX_CLIFF_PERIOD 104 would effectively mean max cliff period being 105 weeks, taking 1 week of min slope - which acts like a cliff - into account. We are fine with cliff lockers having <1% advantage.

L-4

KYC credentials are invalid

Topic
KYC
Status
Impact
Low
Likelihood
Medium

Note: This issue was found in Sherlock contest after this audit was performed, more details on this specific issue can be found here

We noticed the Airgrab contract was using an incorrect KYC credential and after the contest ended, we discussed this with the KYC provider and updated the contracts accordingly.

Old credential: level:plus;residency_not:ca,us

New credential: level:plus+liveness;citizenship_not:;residency_not:cd,cu,gb,ir,kp,ml,mm,ss,sy,us,ye

This issue is not about the countries that are put as placeholder, it is about the level of the credential which lacks liveness and citizenship_not:

L-5

User Can Vote Even When They Have 0 Locked Mento

Topic
Edge Case
Status
Impact
Medium
Likelihood
Low

Note: This issue was found in Sherlock contest after this audit was performed, more details on this specific issue can be found here

The flow to receiving voting power can be understood in simple terms as follows:

Users locks his MENTO and chooses a delegate-> received veMENTO which gives them(delegatee) voting power (there's cliff and slope at play too)

The veMENTO is not a standard ERC20 , it is depicted through "lines" , voting power declines ( ie. slope period) with time and with time you can withdraw more of your MENTO.

The edge case where the user will be withdrawing his entire locked MENTO amount and even then will be able to vote is as follows ->

  1. User has locked his MENTO balance in the Locking.sol

  2. The owner of the contract "stops" the contract for some emergency reason.

  3. In this stopped state the user calls withdraw() which calls getAvailableForWithdraw() here https://github.com/sherlock-audit/2024-02-mento/blob/main/mento-core/contracts/governance/locking/Locking.sol#L97

  4. Since the contract is stopped , the getAvailableForWithdraw will return the entire locked amount of the user as withdrawable

    function getAvailableForWithdraw(address account) public view returns (uint96) {
        uint96 value = accounts[account].amount;
        if (!stopped) {
          uint32 currentBlock = getBlockNumber();
          uint32 time = roundTimestamp(currentBlock);
          uint96 bias = accounts[account].locked.actualValue(time, currentBlock);
          value = value - (bias);
        }
        return value;
    
  5. The user receives his entire locked amount in L101.

  6. The owner "start()" the contract again

  7. Since the user's veMENTO power was not effected by the above flow , there still exists veMENTO a.k.a voting power to the delegate, and the user's delegate is still able to vote on proposals (even when the user has withdrew everything).

L-6

Discrepancy Between Initialization and Setter Function for "Internal Time" Resulting in Time Management Dysfunction

Topic
Initialization
Status
Impact
Medium
Likelihood
Low

Note: This issue was found in Sherlock contest after this audit was performed, more details on this specific issue can be found here

The startingPointWeek variable is responsible for maintaining internal timing consistency within the locking contract. This variable is initialized as follows in the GovernanceFactory:

uint32 startingPointWeek = uint32(Locking(lockingImpl).getWeek() - 1);
TransparentUpgradeableProxy lockingProxy = ProxyDeployerLib.deployProxy( // NONCE:7
  address(lockingImpl),
  address(proxyAdmin),
  abi.encodeWithSelector(
    lockingImpl.__Locking_init.selector,
    address(mentoToken), /// @param _token The token to be locked in exchange for voting power in form of veTokens.
    startingPointWeek, ///   @param _startingPointWeek The locking epoch start in weeks. We start the locking contract from week 1 with min slope duration of 1
    0, ///                   @param _minCliffPeriod minimum cliff period in weeks.
    1 ///                    @param _minSlopePeriod minimum slope period in weeks.
  )

Now, let's examine the function call trace during the initialization of this variable:

/**
* @notice Returns "current week" of the contract. The Locking contract works with a week-based time system
* for managing locks and voting power. The current week number is calculated based on the number of weeks passed
* since the starting point week. The starting point is set during the contract initialization.
*/
function getWeek() external view returns (uint256) {
  return roundTimestamp(getBlockNumber());
}
...
  /**
  * @notice Calculates the week number for a given blocknumber
  * @param ts block number
  * @return week number the block number belongs to
  */
  function roundTimestamp(uint32 ts) public view returns (uint32) { 
    if (ts < getEpochShift()) {
      return 0;
    }
    uint32 shifted = ts - (getEpochShift());
    return shifted / WEEK - uint32(startingPointWeek);
  }
...
function getBlockNumber() internal view virtual returns (uint32) {
  return uint32(block.number);
}

As we can observe, the getWeek function within LockingImpl returns the number of weeks elapsed since the gnosis block because currently startingPointWeek is 0 since it is not initialized yet. Let's assume it corresponds to "179" (Test setup that I will use from protocol tests is using this value so I will continue with it) and continue. The startingPointWeek within the locking contract is initialized with the value 179. Calculations involving the getWeek() function will operate correctly, as it deducts startingPointWeek when computing the system's internal week. Now lets check the setStartingPointWeek() function:

/**
* @notice Sets the starting point for the week-based time system
* @param newStartingPointWeek new starting point
*/
function setStartingPointWeek(uint32 newStartingPointWeek) public notStopped onlyOwner { 
  require(newStartingPointWeek < roundTimestamp(getBlockNumber()), "wrong newStartingPointWeek");
  startingPointWeek = newStartingPointWeek;

  emit SetStartingPointWeek(newStartingPointWeek);
}

As we can see from the require statement, setStartingPointWeek() will only permit newStartingPointWeek to be less than roundTimestamp(getBlockNumber()). Currently, when computing weeks, it subtracts startingPointWeek (initialized with 179). Therefore, if there have been 10 weeks since the initialization, setStartingPointWeek() can only accept numbers smaller than 10. This assertion is confirmed by the following Proof of Concept (POC):

function test_notAllowChangeWeek() public {
  vm.timeTravel(BLOCKS_WEEK * 10);
  vm.prank(address(governanceTimelock));
  locking.setStartingPointWeek(11);
}

Add the function provided above to LockIntegration.fuzz.t.sol and execute the test. You will notice that it reverts with "wrong newStartingPointWeek". This outcome aligns with the natspec comment observed during the deployment of Locking:

startingPointWeek, ///   @param _startingPointWeek The locking epoch start in weeks. We start the locking contract from week 1 with min slope duration of 1

So there is no problem so far, we can only change the startingPointWeek with values less than current internal week number. Let's change it and see what happens then (Again add it to LockIntegration.fuzz.t.sol):

function test_ChangeWeekBrokesSystem() public {
  vm.timeTravel(BLOCKS_WEEK * 10);

  uint32 currentWeek = locking.roundTimestamp(uint32(block.number));
  console.log("Internal week before update:", currentWeek);

  vm.prank(address(governanceTimelock));
  locking.setStartingPointWeek(9);

  currentWeek = locking.roundTimestamp(uint32(block.number));
  console.log("Internal week after update:", currentWeek);
}

Here is the console output:

[PASS] test_ChangeWeekBrokesSystem() (gas: 37271) Logs: Internal week before update: 11 Internal week after update: 181

As we can see internal week accounting is completely broken after a benign setStartingPointWeek() call. This issue arises because during the deployment of the proxy, the calculation setting startingPointWeek utilized the implementation contract's variable, which is (un)initialized to 0. Consequently, it computed the variable with reference to the gnosis block. However, upon calling setStartingPointWeek(), it utilizes the proxy's storage (now set to 179), causing it to calculate the variable with reference to the internal system time. As a result, instead of producing the expected impact, it essentially disrupts the entire week accounting in the contract, leading to the unlocking of all locks.

Q-1

Locking contracts missing Natspec

Topic
Validation
Status
Quality Impact
Medium

The contracts involved in vote escrowed Mento tokens, including Locking, LockingBase, LockingRelock, and LockingVotes and their interface contracts are missing Natspec comments, which is present in other contracts within the protocol. Natspec helps users and developers understand the intention of functions and the parameters.

Q-2

assert check is missing after MentoGovernor deployment

Topic
Validation
Status
Quality Impact
Medium

Reference: GovernanceFactory.sol#L265

In GovernanceFactory.createGovernance, there is an assert statement after each deployment to verify that the pre-calculated address matches the actual deployed address.

However, this assert check is missing for the MentoGovernor deployment.

Remediation to Consider

For consistency and clarity sake, add an appropriate check to verify correct deployment of MentoGovernor.

I-1

Celo network shutdown could increase Mento token lock period

Topic
Informational
Impact
Informational

Locking.sol allows users to lock their Mento tokens for a set period, and in return give them vote escrowed tokens that can be used to vote on proposals in Mento’s governance contract. The locking duration is based on block numbers rather than timestamps, which if the Celo network stops processing blocks, it would increase the expected duration that users Mento tokens would be locked for.

I-2

Locking tokens past a point does not grant additional veMento

Topic
Informational
Impact
Informational

When locking Mento tokens in the Locking contract in return for veMento tokens, the amount of veMento received is determined by a percent multiplier that is calculated based on the locking duration, which is a combination of the cliff period and slope period set.

uint96 cliffSide = (uint96(cliff) * ST_FORMULA_BASIS) / MAX_CLIFF_PERIOD;
uint96 slopeSide = (uint96(slopePeriod) * ST_FORMULA_BASIS) / MAX_SLOPE_PERIOD;
uint96 multiplier = cliffSide + slopeSide;

//@audit: notice the multiplier is capped at effectively 100%
if (multiplier > ST_FORMULA_BASIS) {
  multiplier = ST_FORMULA_BASIS;
}

uint256 amountMultiplied = uint256(amount) * uint256(multiplier);
lockAmount = uint96(amountMultiplied / (ST_FORMULA_BASIS));

Reference: LockingBase.sol#L219-L228

If a cliff and slopePeriod were used that would bring the multiplier above 100%, then the multiplier would be capped, and the additional lock duration would not have any additional benefit other than maintaining a balance of veMento tokens for a longer duration. This may lead to users locking their Mento tokens for longer durations than they would otherwise.

The Mento team stated will notify users of this on their front end application, however users that integrate with this contract, or use it directly should be aware of this.

Disclaimer

Macro makes no warranties, either express, implied, statutory, or otherwise, with respect to the services or deliverables provided in this report, and Macro specifically disclaims all implied warranties of merchantability, fitness for a particular purpose, noninfringement and those arising from a course of dealing, usage or trade with respect thereto, and all such warranties are hereby excluded to the fullest extent permitted by law.

Macro will not be liable for any lost profits, business, contracts, revenue, goodwill, production, anticipated savings, loss of data, or costs of procurement of substitute goods or services or for any claim or demand by any other party. In no event will Macro be liable for consequential, incidental, special, indirect, or exemplary damages arising out of this agreement or any work statement, however caused and (to the fullest extent permitted by law) under any theory of liability (including negligence), even if Macro has been advised of the possibility of such damages.

The scope of this report and review is limited to a review of only the code presented by the Mento team and only the source code Macro notes as being within the scope of Macro’s review within this report. This report does not include an audit of the deployment scripts used to deploy the Solidity contracts in the repository corresponding to this audit. 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. In this report you may through hypertext or other computer links, gain access to websites operated by persons other than Macro. Such hyperlinks are provided for your reference and convenience only, and are the exclusive responsibility of such websites’ owners. You agree that Macro is not responsible for the content or operation of such websites, and that Macro shall have no liability to your or any other person or entity for the use of third party websites. Macro assumes no responsibility for the use of third party software and shall have no liability whatsoever to any person or entity for the accuracy or completeness of any outcome generated by such software.