Prepared by:
HALBORN
Last Updated 04/25/2024
Date of Engagement by: May 3rd, 2021 - May 15th, 2021
100% of all REPORTED Findings have been addressed
All findings
11
Critical
0
High
3
Medium
1
Low
5
Informational
2
MonoX
is a new DeFi protocol using a single token design for liquidity pools (instead of using pool pairs). This is made possible by grouping deposited tokens into a virtual pair with the vUSD stablecoin.
MonoX
engaged Halborn to conduct a security assessment on their smart contracts beginning on May 3rd, 2021 and ending May 15th, 2021. The security assessment was scoped to smart contracts implementing the core protocol and the staking mechanism, and an audit of the security risk and implications regarding the changes introduced by the development team at MonoX
prior to its production release shortly following the assessments deadline.
Though this security audit's outcome is satisfactory, only the most essential aspects were tested and verified to achieve objectives and deliverables set in the scope due to time and resource constraints. It is essential to note the use of the best practices for secure smart-contract development.
The team at Halborn was provided two weeks for the engagement and assigned one full time security engineer to audit the security of the assets in scope. The 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 to achieve the following:
Ensure that smart contract functions are intended.
Identify potential security issues with the smart contracts.
In summary, Halborn identified few security risks, and recommends performing further testing to validate extended safety and correctness in context to the whole set of contracts. External threats, such as economic attacks, oracle attacks, and inter-contract functions and calls should be validated for expected logic and state.
Halborn performed a combination of manual and automated security testing to balance efficiency, timeliness, practicality, and accuracy in regard to 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 the smart contract code 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 (Hardhat
and manual deployments on Ganache
)
Manual testing with custom Javascript.
Static Analysis of security for scoped contract, and imported functions.(Slither
)
Scanning of solidity files for vulnerabilities, security hotspots or bugs. (MythX
)
Testnet deployment (Remix IDE
)
IN-SCOPE: The security assessment was scoped to the smart contracts:
Monoswap Core
:
Monoswap.sol
MonoXPool.sol
VUSD.sol
commit #c1e16f0b588aeb129d8e13abbc9d39ab3a3392c3
Monoswap Staking
:
MonoswapStaking.sol
MonoToken.sol
commit #89115cd39237c496b60e8a71b07f46968bd854f2
OUT-OF-SCOPE: Dependencies and external libraries.
Critical
0
High
3
Medium
1
Low
5
Informational
2
Impact x Likelihood
HAL-08
HAL-01
HAL-02
HAL-03
HAL-05
HAL-06
HAL-04
HAL-07
HAL-09
HAL-10
HAL-11
Security analysis | Risk level | Remediation Date |
---|---|---|
UNRESTRICTED POOL TOKEN MINTING | High | Solved - 05/24/2021 |
POOL BLOCKING | High | Solved - 05/24/2021 |
ROLE-BASED ACCESS CONTROL MISSING | High | Solved - 05/24/2021 |
INTEGER OVERFLOW | Medium | Solved - 06/02/2021 |
EXTERNAL FUNCTION CALLS WITHIN LOOP | Low | Solved - 06/01/2021 |
DIVIDE BEFORE MULTIPLY | Low | Solved - 07/15/2021 |
ADDRESS VALIDATION MISSING | Low | Partially Solved - 05/24/2021 |
USE OF BLOCK.TIMESTAMP | Low | Solved - 06/02/2021 |
TAUTOLOGY EXPRESSIONS | Low | Solved - 05/24/2021 |
POSSIBLE MISUSE OF PUBLIC FUNCTIONS | Informational | Partially Solved - 05/24/2021 |
IMPRECISION OF A CONSTANT | Informational | Acknowledged - 07/15/2021 |
// High
One of MonoX's main objectives is to allow users for listing ERC20 tokens without the need for providing liquidity. In order to keep track of users' shares in pools, a corresponding amount of liquidity pool tokens is minted to providers. The exact amount to be minted depends on e.g. the declared
amount of ERC20 tokens added to the pool and the token price, intially set by the provider.
In the addLiquidityPair
function MonoX use OpenZeppelin's safeTransferFrom
to handle the token transfer. This function calls transferFrom
in the token contract to actually execute the transfer. However, since the actual amount transferred ie. the delta of previous (before transfer) and current (after transfer) balance is not verified, a malicious user may list a custom ERC20 token with the transferFrom
function modified in such a way that it e.g. does not transfer any tokens at all and the attacker is still going to have their liquidity pool tokens minted anyway.
Attacker-controlled example ERC20 token contract
function transferFrom(
address from,
address to,
uint256 value
)
public
override
returns (bool)
{
value = 1;
require(value <= _balances[from]);
require(value <= _allowed[from][msg.sender]);
require(to != address(0));
_balances[from] = _balances[from].sub(value);
_balances[to] = _balances[to].add(value);
_allowed[from][msg.sender] = _allowed[from][msg.sender].sub(value);
emit Transfer(from, to, value);
return true;
}
MonoX
function listNewToken (address _token, uint112 _price,
uint256 vusdAmount,
uint256 tokenAmount,
address to) public returns(uint _pid, uint256 liquidity) {
_pid = _createPool(_token, _price, PoolStatus.LISTED);
liquidity = addLiquidityPair(_token, vusdAmount, tokenAmount, to);
}
_mintFee(pool.pid, pool.lastPoolValue, poolValue);
uint256 _totalSupply = monoXPool.totalSupplyOf(pool.pid);
IERC20(_token).safeTransferFrom(msg.sender, address(monoXPool), tokenAmount);
if(vusdAmount>0){
vUSD.safeTransferFrom(msg.sender, address(monoXPool), vusdAmount);
}
uint256 liquidityVusdValue = vusdAmount.add(tokenAmount.mul(pool.price)/1e18);
if(_totalSupply==0){
liquidity = liquidityVusdValue.sub(MINIMUM_LIQUIDITY);
mint(owner(), pool.pid, MINIMUM_LIQUIDITY); // sorry, oz doesn't allow minting to address(0)
}else{
liquidity = _totalSupply.mul(liquidityVusdValue).div(poolValue);
}
mint(to, pool.pid, liquidity);
}
OpenZeppelin
library SafeERC20 {
using Address for address;
function safeTransfer(IERC20 token, address to, uint256 value) internal {
_callOptionalReturn(token, abi.encodeWithSelector(token.transfer.selector, to, value));
}
function safeTransferFrom(IERC20 token, address from, address to, uint256 value) internal {
_callOptionalReturn(token, abi.encodeWithSelector(token.transferFrom.selector, from, to, value));
}
SOLVED: Fixed in commit #635a4cee2f2e50d854e06cac47c48aa0fafde2b0. The amount to be minted is calculated now based on the delta of account balance before and after transfer.
// High
One of MonoX's main objectives is to allow users for listing ERC20 tokens without the need for providing liquidity. Users can set arbitrary prices for tokens they list because the Monoswap.sol
contract does not verify them against third-party data sources. The price of a given token can be updated only if it has not been swapped for at least 6000
blocks since the last exchange. In consequence, since the contract does not enforce minimum or maximum transaction amount
, a malicious user can list tokens, price them way above market rate and keep the price on that level by doing microexchanges once every 6000 blocks thus effectively DoSing the pool.
function listNewToken (address _token, uint112 _price,
uint256 vusdAmount,
uint256 tokenAmount,
address to) public returns(uint _pid, uint256 liquidity) {
_pid = _createPool(_token, _price, PoolStatus.LISTED);
liquidity = addLiquidityPair(_token, vusdAmount, tokenAmount, to);
}
function _createPool (address _token, uint112 _price, PoolStatus _status) lock internal returns(uint256 _pid) {
require(tokenPoolStatus[_token]==0, "Monoswap: Token Exists");
require (_token != address(vUSD), "Monoswap: vUSD pool not allowed");
_pid = poolSize;
pools[_token] = PoolInfo({
token: _token,
pid: _pid,
vusdCredit: 0,
vusdDebt: 0,
tokenBalance: 0,
lastPoolValue: 0,
status: _status,
price: _price
});
poolSize = _pid.add(1);
tokenPoolStatus[_token]=1;
// initialze pool's lasttradingblocknumber as the block number on which the pool is created
lastTradedBlock[_token] = block.number;
}
function swapExactTokenForToken(
address tokenIn,
address tokenOut,
uint amountIn,
uint amountOutMin,
address to,
uint deadline
) external virtual ensure(deadline) returns (uint amountOut) {
amountOut = swapIn(tokenIn, tokenOut, msg.sender, to, amountIn);
require(amountOut >= amountOutMin, 'Monoswap: INSUFFICIENT_OUTPUT_AMOUNT');
}
// record last trade's block number in mapping: lastTradedBlock
lastTradedBlock[_token] = block.number;
function updatePoolPrice(address _token, uint112 _newPrice) public onlyOwner {
require(_newPrice > 0, 'Monoswap: zeroPriceNotAccept');
require(tokenPoolStatus[_token] != 0, "Monoswap: PoolNotExist");
PoolInfo storage pool = pools[_token];
require(pool.price != _newPrice, "Monoswap: SamePriceNotAccept");
require(block.number > lastTradedBlock[_token].add(6000), "Monoswap: PoolPriceUpdateLocked");
pool.price = _newPrice;
lastTradedBlock[_token] = block.number;
}
SOLVED: Fixed in commit #635a4cee2f2e50d854e06cac47c48aa0fafde2b0. Contract owner can now pause pools and temporarily disable swapping so that users with the PriceAdjuster
role (assigned by the contract owner) can update prices.
// High
In smart contracts, implementing a correct Access Control policy is an essential step to maintain security and decentralization for permissions on a token. All the features of the smart contract , such as mint/burn tokens and pause contracts are given by Access Control. For instance, Ownership is the most common form of Access Control. In other words, the owner of a contract (the account that deployed it by default) can do some administrative tasks on it. Nevertheless, other authorization levels are required to follow the principle of least privilege, also known as least authority. Briefly, any process, user or program only can access to the necessary resources or information. Otherwise, the ownership role is useful in a simple system, but more complex projects require the use of more roles by using Role-based access control.
function setFeeTo (address _feeTo) onlyOwner external {
feeTo = _feeTo;
}
function setFees (uint16 _fees) onlyOwner external {
require(_fees<1e3, "fees too large");
fees = _fees;
}
function setDevFee (uint16 _devFee) onlyOwner external {
require(_devFee<1e3, "devFee too large");
devFee = _devFee;
}
// update status of a pool. onlyOwner.
function updatePoolStatus(address _token, PoolStatus _status) public onlyOwner {
PoolInfo storage pool = pools[_token];
pool.status = _status;
}
/**
@dev update pools price if there were no active trading for the last 6000 blocks
@notice Only owner callable, new price can neither be 0 nor be equal to old one
@param _token pool identifider (token address)
@param _newPrice new price in wei (uint112)
*/
function updatePoolPrice(address _token, uint112 _newPrice) public onlyOwner {
require(_newPrice > 0, 'Monoswap: zeroPriceNotAccept');
SOLVED: Fixed in commit #635a4cee2f2e50d854e06cac47c48aa0fafde2b0. Several new roles were introduced.
// Medium
An overflow happens when an arithmetic operation reaches the maximum size of a type. For instance, in Monoswap.sol
, the getAmountOut
method is subtracting fees
from a fixed number and may end up overflowing the integer since the resulting value is not checked to be greater or equal 0. In computer programming, an integer overflow occurs when an arithmetic operation attempts to create a numeric value that is outside of the range that can be represented with a given number of bits – either larger than the maximum or lower than the minimum representable value.
function getAmountOut(address tokenIn, address tokenOut,
uint256 amountIn) public view returns (uint256 tokenInPrice, uint256 tokenOutPrice,
uint256 amountOut, uint256 tradeVusdValue) {
require(amountIn > 0, 'Monoswap: INSUFFICIENT_INPUT_AMOUNT');
uint256 amountInWithFee = amountIn.mul(1e5-fees)/1e5;
address vusdAddress = address(vUSD);
function getAmountIn(address tokenIn, address tokenOut,
uint256 amountOut) public view returns (uint256 tokenInPrice, uint256 tokenOutPrice,
uint256 amountIn, uint256 tradeVusdValue) {
require(amountOut > 0, 'Monoswap: INSUFFICIENT_INPUT_AMOUNT');
uint256 amountOutWithFee = amountOut.mul(1e5+fees)/1e5;
address vusdAddress = address(vUSD);
SOLVED: MonoX is certain the integers reported will not overflow since the fees
variable cannot be assigned value greater than 1e3
.
// Low
Calls inside a loop might lead to a denial-of-service attack. In on of the functions discovered there is a for loop on variable pid
that iterates up to the poolInfo
array length. If this integer is evaluated at extremely large numbers this can cause a DoS.
function massUpdatePools() public {
uint256 length = poolInfo.length;
for (uint256 pid = 0; pid < length; ++pid) {
PoolInfo storage pool = poolInfo[pid];
if (pool.bActive)
updatePool(pid);
}
}
SOLVED: MonoX is certain the DoS scenario is highly unlikely here since all external calls in this loop are made to MonoX-controlled contracts.
// Low
Solidity integer division might truncate. As a result, performing multiplication before division can sometimes avoid loss of precision. In this audit, there are multiple instances found where division is being performed before multiplication operation in contract file.
if (user.oldReward > 0) {
monoReward = monoReward.add(user.oldReward.mul(stakedAmount).div(user.amount).mul(1e12));
}
SOLVED: fixed in commit #ac21bee3f7f1d7df3529907b0afb0470b0236d07
// Low
Address validation is missing in multiple functions in contracts Monoswap.sol
and MonoXPool.sol
. This may result with users irreversibly locking their tokens when incorrect address is provided.
function mint (address account, uint256 id, uint256 amount) internal {
monoXPool.mint(account, id, amount);
}
function burn (address account, uint256 id, uint256 amount) internal {
monoXPool.burn(account, id, amount);
}
constructor (address _WETH) {
WETH = _WETH;
}
function mint (address account, uint256 id, uint256 amount) public onlyOwner {
totalSupply[id]=totalSupply[id].add(amount);
_mint(account, id, amount, "");
}
function burn (address account, uint256 id, uint256 amount) public onlyOwner {
totalSupply[id]=totalSupply[id].sub(amount);
_burn(account, id, amount);
}
PARTIALLY SOLVED: Vulnerable function calls in Monoswap.sol
have been removed but address validation is missing in MonoXPool.sol
.
// Low
block.timestamp
can be influenced by miners to a certain degree, so the testers should be warned that this may have some risk if miners collude on time manipulation to influence the price oracles.
modifier ensure(uint deadline) {
require(deadline >= block.timestamp, 'Monoswap: EXPIRED');
_;
}
SOLVED: MonoX does not require timestamps to be extremely precise here (timescales are greater than 900 seconds)
// Low
In contract Monoswap.sol
, tautology expressions have been detected. Such expressions are of no use since they always evaluate true/false regardless of the context they are used in.
if(_poolStatus == PoolStatus.LISTED){
require (_vusdCredit>=0 && _vusdDebt==0, "Monoswap: unofficial pool cannot bear debt");
}
SOLVED: Tautology Expression was removed in commit #635a4cee2f2e50d854e06cac47c48aa0fafde2b0.
// Informational
In public functions, array arguments are immediately copied to memory, while external functions can read directly from calldata
. Reading calldata
is cheaper than memory allocation. Public functions need to write the arguments to memory because public functions may be called internally. Internal calls are passed internally by pointers to memory. Thus, the function expects its arguments being located in memory when the compiler generates the code for an internal function.
Also, methods do not necessarily have to be public if they are only called within the contract-in such case they should be marked internal
.
function updatePoolStatus(address _token, PoolStatus _status) public onlyOwner {
PoolInfo storage pool = pools[_token];
pool.status = _status;
}
function updatePoolPrice(address _token, uint112 _newPrice) public onlyOwner {
require(_newPrice > 0, 'Monoswap: zeroPriceNotAccept');
require(tokenPoolStatus[_token] != 0, "Monoswap: PoolNotExist");
PoolInfo storage pool = pools[_token];
function listNewToken (address _token, uint112 _price,
uint256 vusdAmount,
uint256 tokenAmount,
address to) public returns(uint _pid, uint256 liquidity) {
_pid = _createPool(_token, _price, PoolStatus.LISTED);
liquidity = addLiquidityPair(_token, vusdAmount, tokenAmount, to);
}
}
function set(
uint256 _pid,
uint256 _allocPoint,
bool _withUpdate
) public onlyOwner {
if (_withUpdate) {
massUpdatePools();
}
function stopPool(uint256 _pid) public onlyOwner {
updatePool(_pid);
function migratePool(uint256 _oldPid, uint256 _newPid) public {
PoolInfo storage oldPool = poolInfo[_oldPid];
PoolInfo storage newPool = poolInfo[_newPid];
function deposit(uint256 _pid, uint256 _amount) public {
PoolInfo storage pool = poolInfo[_pid];
UserInfo storage user = userInfo[_pid][msg.sender];
function withdraw(uint256 _pid, uint256 _amount) public {
PoolInfo storage pool = poolInfo[_pid];
UserInfo storage user = userInfo[_pid][msg.sender];
function emergencyWithdraw(uint256 _pid) public {
PoolInfo storage pool = poolInfo[_pid];
UserInfo storage user = userInfo[_pid][msg.sender];
// Informational
During the audit, it has been observed that integers with scientific notations are directly compared with function arguments.
function setFees (uint16 _fees) onlyOwner external {
require(_fees<1e3, "fees too large");
fees = _fees;
}
function setDevFee (uint16 _devFee) onlyOwner external {
require(_devFee<1e3, "devFee too large");
devFee = _devFee;
}
function _mintFee (uint256 pid, uint256 lastPoolValue, uint256 newPoolValue) internal {
uint256 _totalSupply = monoXPool.totalSupplyOf(pid);
if(newPoolValue>lastPoolValue && lastPoolValue>0) {
// safe ops, since newPoolValue>lastPoolValue
uint256 deltaPoolValue = newPoolValue - lastPoolValue;
// safe ops, since newPoolValue = deltaPoolValue + lastPoolValue > deltaPoolValue
uint256 devLiquidity = _totalSupply.mul(deltaPoolValue).mul(devFee).div(newPoolValue-deltaPoolValue)/1e5;
monoXPool.mint(feeTo, pid, devLiquidity);
}
}
Also lines #584
and #638
in Monoswap.sol
.
function getPool (address _token) view public returns (uint256 poolValue,
uint256 tokenBalanceVusdValue, uint256 vusdCredit, uint256 vusdDebt) {
PoolInfo memory pool = pools[_token];
vusdCredit = pool.vusdCredit;
vusdDebt = pool.vusdDebt;
tokenBalanceVusdValue = uint(pool.price).mul(pool.tokenBalance)/1e18;
poolValue = tokenBalanceVusdValue.add(vusdCredit).sub(vusdDebt);
}
Also lines #258
, #297
, #322
, #326
, #569
, #570
, #590
, #600
, #605
, #614
, #628
, #645
, #656
, #661
, #671
, #684
, #715
and #767
in Monoswap.sol
.
function initialize(
MonoToken _mono,
uint256 _monoPerPeriod,
uint256 _blockPerPeriod,
uint256 _decay
) public initializer {
OwnableUpgradeable.__Ownable_init();
__ERC1155Holder_init();
mono = _mono;
monoPerPeriod = _monoPerPeriod;
blockPerPeriod = _blockPerPeriod;
decay = _decay;
startBlock = block.number;
currentPeriod = 0;
ratios[currentPeriod] = 1e12;
totalAllocPoint = 0;
}
Also lines #176
, #185
, #211
, #221
, #232
, #265
, #297
, #299
, #316
, #317
, #336
, #337
, #359
, #377
, #378
and #389
in MonoswapStaking.sol
.
ACKNOWLEDGED: MonoX refrain from introducing extra variables as it increases the contract size quite a bit and increase gas usage as well. Therefore they are trying not to have a variable unless it's necessary.
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.
// Download the full report
* Use Google Chrome for best results
** Check "Background Graphics" in the print settings if needed