Security Audit
August 17th, 2023
Version 1.0.0
Presented by 0xMacro
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 July 17, 2023 to August 2, 2023.
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.
The following is an aggregation of issues found by the Macro Audit team:
Severity | Count | Acknowledged | Won't Do | Addressed |
---|---|---|---|---|
High | 1 | - | - | 1 |
Medium | 3 | 1 | - | 2 |
Low | 3 | - | - | 3 |
Code Quality | 7 | - | - | 7 |
Mento was quick to respond to these issues.
Our understanding of the specification was based on the following sources:
The following source code was reviewed during the audit:
9879bdb4d5e22da2eacb0c3629fd8a032d2d7f9a
5cad9ee941b1e27efcfca7fe44a283f3b180dc82
Specifically, we audited the following contracts within this repository:
Source Code | SHA256 |
---|---|
contracts/interfaces/IBiPoolManager.sol |
|
contracts/interfaces/IBreaker.sol |
|
contracts/interfaces/IBreakerBox.sol |
|
contracts/interfaces/IPricingModule.sol |
|
contracts/interfaces/IStableTokenV2.sol |
|
contracts/oracles/BreakerBox.sol |
|
contracts/oracles/breakers/MedianDeltaBreaker.sol |
|
contracts/swap/BiPoolManager.sol |
|
contracts/swap/ConstantSumPricingModule.sol |
|
contracts/tokens/StableTokenV2.sol |
|
contracts/tokens/patched/ERC20PermitUpgradeable.sol |
|
contracts/tokens/patched/ERC20Upgradeable.sol |
|
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.
Click on an issue to jump to it, or scroll down to see them all.
MedianDeltaBreaker.shouldTrigger()
allows any external actor to incorrectly update the medianRatesEMA
setTokenPrecisionMultipliers
allows arbitrary decimal multipliers
MedianDeltaBreaker
will store the first median reference without restrictions
MedianDeltaBreaker
previous rates can not be reset if needed
removeRateFeed()
doesn’t delete the rate feed dependencies
struct state
and struct config
in the TradingLimits
library
createExchange
performs the same external call twice
getOracleExchangeRate()
StableTokenV2
storage __gap
uses existent storage slots
We quantify issues in three parts:
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. |
MedianDeltaBreaker.shouldTrigger()
allows any external actor to incorrectly update the medianRatesEMA
The MedianDeltaBreaker
contract implements an Exponential Moving Average (EMA) to calculate the percentage difference between the current reported median and the previous median rate EMA value, calculated from the following formula:
currentMedian * smoothingFactor + previousMedianEMA * (1 - smoothingFactor)
Where
rateFeedID
with setSmoothingFactor()
and can’t be higher than 1
.SortedOracles.medianRate()
.This allows the MedianDeltaBreaker
to weigh the latest median provided by oracles (reported through SortedOracles.report()
and read through SortedOracles.medianRate()
) and consider the previously stored value.
function shouldTrigger(address rateFeedID) public returns (bool triggerBreaker) {
(uint256 currentMedian, ) = sortedOracles.medianRate(rateFeedID);
uint256 previousRatesEMA = medianRatesEMA[rateFeedID];
...
FixidityLib.Fraction memory smoothingFactor = FixidityLib.wrap(getSmoothingFactor(rateFeedID));
medianRatesEMA[rateFeedID] = FixidityLib
.wrap(currentMedian)
.multiply(smoothingFactor)
.add(FixidityLib.wrap(previousRatesEMA).multiply(FixidityLib.fixed1().subtract(smoothingFactor)))
.unwrap();
return exceedsThreshold(previousRatesEMA, currentMedian, rateFeedID);
}
However, this implementation will only remain true if shouldTrigger()
function is called once per value update in the SortedOracle
median rate; and since the shouldTrigger()
function is public (as per the IBreaker.sol
interface) and doesn’t have any access control, any external actor can perform any arbitrary shouldTrigger()
calls incorrectly updating the medianRatesEMA
values with the same reported medianRate
.
Remediations to Consider
medianRatesEMA
only if a new report was provided for that specific rateFeedID
orSortedOracles
or BreakerBox
contracts.Note: For the second suggestion, it's worth adding an additional view function to allow users or external points to check this condition.
setTokenPrecisionMultipliers
allows arbitrary decimal multipliers
When the BiPoolManager
creates an exchange, after performing several validations on the inputs, the token precision multipliers are set in order to deal with assets with different token decimals while calculating swap amounts.
For example, USDC
has 6
decimal places, having a token precision multiplier of 1e12
(10 ** (18 - 6)
), as we can see in the function’s logic:
tokenPrecisionMultipliers[exchange.asset0] = 10**(18 - uint256(IERC20Metadata(exchange.asset0).decimals()));
tokenPrecisionMultipliers[exchange.asset1] = 10**(18 - uint256(IERC20Metadata(exchange.asset1).decimals()));
However, the function setTokenPrecisionMultipliers
allows to arbitrarily change the token precision multipliers, in this case, say for USDC
, from 1e12
to an arbitrarily large or small value. Which would then affect the calculations in the swap in/out and in the get amounts in/out functions.
Additionally, the function doesn’t check whether the tokens are valid and registered with the Reserve
contract.
Remediations to Consider
createExchange
function orsetTokenPrecisionMultipliers
from the contract.When creating an exchange in the BiPoolManager
contract, the pricing module is specified as an input parameter in the PoolExchange _exchange
struct. However, there are no validations that the address of the pricing module corresponds to a canonical version of this module. This can cause, for example, the previous version of the constant sum module to be used in the exchange creation and, therefore, to calculate swap amounts incorrectly. The only check performed during swaps is in the pricingModuleIdentifier
:
function pricingModuleIdentifier(PoolExchange memory exchange) internal view returns (bytes32) {
return keccak256(abi.encodePacked(exchange.pricingModule.name()));
}
This also opens up other scenarios where invalid pricing module addresses can be used.
Remediations to Consider
Keeping in the BiPoolManager
contract’s storage the address of the latest or allowed pricing modules and asserting it’s used in createExchange
.
MedianDeltaBreaker
will store the first median reference without restrictions
The MedianDeltaBreaker
contract implements an EMA (Exponential Moving Average) that weights the current median rate and adds as a consideration the previously stored median EMA rate; this mitigates immediate price spikes in the median but also allows constant trends in the price to move without triggering the breaker.
As we can see in the shouldTrigger()
logic, there is a special consideration when the previousRatesEMA == 0
. This condition will only be true in the initial call after enabling this breaker for a specific rateFeedID
.
function shouldTrigger(address rateFeedID) public returns (bool triggerBreaker) {
...
uint256 previousRatesEMA = medianRatesEMA[rateFeedID];
if (previousRatesEMA == 0) {
// Previous recorded EMA will be 0 the first time this rate feed is checked.
medianRatesEMA[rateFeedID] = currentMedian;
return false;
}
...
}
However, since this function will store the current median without any constraints, the reported median, at the execution time of shouldTrigger()
call, could be outside of the expected values or manipulated. This will render an invalid median that will be used as a reference to check every subsequent oracle report for that rateFeedID
.
Remediations to Consider
medianRatesEMA
value will be stored and used as reference, orValueDeltaBreaker
contract.We don’t believe the situation described is a credible threat, the assumption is that we have an attacker that can manipulate an Oracle price and set the initial EMA value when a circuit breaker gets added or activated via Governance proposal. In this situation, it wouldn’t make sense for the attacker to wait until that moment to manipulate the price.
And even so, there’s no clear way to seed the value that makes sense. If we provide an initial value, it will already be old until the governance proposal passes to the execution state so we would be seeding an old value. A range would add a lot of configuration complexity without providing many benefits and could result in problematic behavior if the range is too tight or too wide.
The trading limits act as a safeguard and are checked during the swap operation if the trading config was created for the token. A valid configuration should not permit setting these limits to 0
for enabled directional trading flags.
The function Broker.configureTradingLimit()
doesn’t validate the above criteria when setting the trading config for a token.
In addition, there are no checks to ensure that the trading limit for L0
is smaller than the trading limit for L1
, and they’re both smaller than the global trading limit LG
.
Remediations to Consider
Consider validating the trading config inputs to ensure that invalid trading limits cannot be set.
In BreakerBox.sol
, when turning on the breakers for certain rateFeedId
s, there will be an execution gap between the toggleBreaker()
call and the comparison of the corresponding breaker trigger conditions in checkAndSetBreakers()
.
Since the BiPoolManager
contract uses the view function getRateFeedTradingMode()
before executing swaps, which reads the current breaker status but doesn’t update each dependency:
function getRateFeedTradingMode(address rateFeedID) external view returns (uint8) {
require(rateFeedStatus[rateFeedID], "Rate feed ID has not been added");
uint8 tradingMode = rateFeedTradingMode[rateFeedID];
for (uint256 i = 0; i < rateFeedDependencies[rateFeedID].length; i++) {
tradingMode = tradingMode | rateFeedTradingMode[rateFeedDependencies[rateFeedID][i]];
}
return tradingMode;
}
It is possible that the conditions that trigger a breaker may already be present. However, until the next oracle feed SortedOracles::report()
, or external call to checkAndSetBreakers()
, the trading mode won’t consider this breaker. This is especially visible with the MedianDeltaBreaker
, where the first update will only store the median rate.
Remediations to Consider
checkAndSetBreakers
when adding breakers to a specific rateFeedID
in the toggleBreaker
logic or,checkAndSetBreakers
atomically call after enabling a breaker.MedianDeltaBreaker
previous rates can not be reset if needed
In MedianDeltaBreaker
, the initial value and start point, where medianRatesEMA == 0
, only exist the first time a specific rateFeedID
is set for this breaker. When a rateFeedID
needs to be removed and later added again into the MedianDeltaBreaker
, there is no way to reset the initial reference value, which means it cannot easily adjust to newly established rates. It will require a custom higher configuration of the breaker threshold to temporarily bypass the reported values.
Remediations to Consider
Implementing a reset function if the breaker is disabled from the BreakerBox
contract to allow a new initialization of the median rates.
The following require
statement is used in the internal functions_getAmountIn
and _getAmountOut
contained in the BiPoolManager.sol
. This is repeated code that could be refactored into a function modifier.
//The following require statement is used in multiple functions
require(
(tokenIn == exchange.asset0 && tokenOut == exchange.asset1) ||
(tokenIn == exchange.asset1 && tokenOut == exchange.asset0),
"tokenIn and tokenOut must match exchange"
);
Remediations to Consider
Consider converting this validation code to a function modifier and using it for the internal functions _getAmountOut
and _getAmountIn
.
removeRateFeed()
doesn’t delete the rate feed dependencies
In the BreakerBox
contract, a rateFeedId
could have a set of rate feed dependencies mapped to it via the setRateFeedDependencies
function.
However, when a rate feed is removed, its dependencies are not deleted.
Remediations to Consider
Consider adding the following line to the removeRateFeed
function
delete rateFeedDependencies[rateFeedID];
struct state
and struct config
in the TradingLimits
library
Consider adding the corresponding natspec comments to these structs.
In the BiPoolManager
docs page, the Executing Swaps section mentions the following:
This function executes, in that it prices the swap and updates the virtual bucket sizes of the PoolExchange.
However, it doesn’t consider the case where an exchange uses the ConstantSumPricingModule
, which doesn’t update the bucket sizes on swaps.
On the Pricing Modules page, both pricing module links to GitHub are broken. Also, the constant sum pricing module formula should be updated to reflect the newly implemented value constant sum formula.
Some of the links included in the documentation pages are currently broken:
BreakerBox
section: https://docs.mento.org/mento/developers/smart-contracts/breakerboxConsider adding documentation pages for the specific breaker contract implementations (MedianDeltaBreaker
and ValueDeltaBreaker
).
createExchange
performs the same external call twice
In BiPoolManager.sol
, when asserting the asset0
and asset1
decimals and setting the tokenPrecisionMultipliers
, createExchange()
function performs the same external call twice for each operation.
if (IERC20Metadata(exchange.asset0).decimals() > 18) {
revert("asset0 decimals must be <= 18");
}
if (IERC20Metadata(exchange.asset1).decimals() > 18) {
revert("asset1 decimals must be <= 18");
}
tokenPrecisionMultipliers[exchange.asset0] = 10**(18 - uint256(IERC20Metadata(exchange.asset0).decimals()));
tokenPrecisionMultipliers[exchange.asset1] = 10**(18 - uint256(IERC20Metadata(exchange.asset1).decimals()));
Consider saving these values to memory to save gas and avoid unnecessary external calls.
getOracleExchangeRate()
Consider using the return values in the function declaration to improve readability and consistency with other functions.
function getOracleExchangeRate(address target) internal view returns (uint256 rateNumerator, uint256 rateDenominator) {
(rateNumerator, rateDenominator) = sortedOracles.medianRate(target);
require(rateDenominator > 0, "exchange rate denominator must be greater than 0");
}
StableTokenV2
storage __gap
uses existent storage slots
In StableTokenV2
, the storage layout implemented deprecated variables for unused slots from the previous StableToken
implementation. However, for slots 8 and 12, inflationState
and exchangeRegistryId
respectively, there aren’t any deprecated variables declarations. These slots are included in the __gap
reserve variable.
Consider adding deprecated variables for these slots to keep track of these unused but non-empty variables in case of any future implementation migration.
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.