GithubHelp home page GithubHelp logo

2022-10-merit-circle-judging's People

Contributors

evert0x avatar rcstanciu avatar sherlock-admin avatar

Stargazers

 avatar  avatar

2022-10-merit-circle-judging's Issues

Chom - cumulativeRewardsOf can be minus. This is unexpected behavior

Chom

medium

cumulativeRewardsOf can be minus. This is unexpected behavior

Summary

cumulativeRewardsOf can be minus. This is unexpected behavior.

Vulnerability Detail

Transferring from A to a new wallet B deducts pointsCorrection to B. Since B is a new wallet -> pointsCorrection = 0, when deducted pointsCorrection will be negative.

 function _correctPointsForTransfer(address _from, address _to, uint256 _shares) internal { 
   int256 _magCorrection = (pointsPerShare * _shares).toInt256(); 
   pointsCorrection[_from] = pointsCorrection[_from] + _magCorrection; 
   pointsCorrection[_to] = pointsCorrection[_to] - _magCorrection; 
 } 

 function cumulativeRewardsOf(address _account) public view override returns (uint256) { 
   return ((pointsPerShare * getSharesOf(_account)).toInt256() + pointsCorrection[_account]).toUint256() / POINTS_MULTIPLIER; 
 } 

In case pointsPerShare == 0, (pointsPerShare * getSharesOf(_account)) = 0 but pointsCorrection[_account] is minus. As a result, cumulativeRewardsOf is minus. Finally, everything that uses cumulativeRewardsOf will revert.

Impact

cumulativeRewardsOf can be minus. This is unexpected behavior as it should return zero or positive uint256. In this case, anyone can force a random wallet to be in debt.

Code Snippet

https://github.com/sherlock-audit/2022-10-merit-circle/blob/main/merit-liquidity-mining/contracts/base/BasePool.sol#L90-L93

https://github.com/sherlock-audit/2022-10-merit-circle/blob/main/merit-liquidity-mining/contracts/base/AbstractRewards.sol#L115-L119

https://github.com/sherlock-audit/2022-10-merit-circle/blob/main/merit-liquidity-mining/contracts/base/AbstractRewards.sol#L73-L75

Tool used

Manual Review

Recommendation

Check the logic of point correction / cumulativeRewardsOf again

Duplicate of #26

csanuragjain - User might lose funds on extending lock

csanuragjain

high

User might lose funds on extending lock

Summary

While extending lock, if the resulting mintAmount becomes lesser than userDeposit.shareAmount (due to varying Multiplier curve) then user own deposit are burned which means user loses funds for extending duration which is wrong

Vulnerability Detail

  1. User had a deposit of amount 100 for duration X which gave him mint Amount of 50
  2. After some time, User wants to extend duration by y ie new duration should be X+y using extendLock
  3. Due to varying Multiplier curve, the extendLock function resulted in new mint Amount of 40 with the duration X+y
  4. Since the new mint Amount of 40 is lesser than userDeposit.shareAmount so user deposit are burned which does not make sense since user is penalized for keeping his funds locked for more duration (instead he should be rewarded for locking amount)
function extendLock(uint256 _depositId, uint256 _increaseDuration) external {
else if (mintAmount < userDeposit.shareAmount) {
            depositsOf[_msgSender()][_depositId].shareAmount =  mintAmount;
            _burn(_msgSender(), userDeposit.shareAmount - mintAmount);
        }
}

Impact

User will lose there funds

Code Snippet

https://github.com/sherlock-audit/2022-10-merit-circle/blob/main/merit-liquidity-mining/contracts/TimeLockPool.sol#L176

Tool used

Manual Review

Recommendation

Revert if mintAmount < userDeposit.shareAmount so that user is prevented from loss. In this case it is advisable for user to start a new deposit

Lambda - setCurvePoint: unit not updated when curve.length changes

Lambda

medium

setCurvePoint: unit not updated when curve.length changes

Summary

When curve.length changes in setCurvePoint, unit is not updated, leading to wrong calculations.

Vulnerability Detail

unit is per definition maxLockDuration / (curve.length - 1) and when curve.length changes, it should therefore be recalculated (which is done in setCurve). However, the length of the curve array can also change in setCurvePoint (increase or decrease) and unit is not recalculated in these cases.

Impact

getMultiplier will return wrong values (a multiplier that is too high or low, which causes a financial loss / unexpected financial gain for users) and even not work at all in certain scenarios. For instance, it can happen that uint n = _lockDuration / unit > curve.length - 1, which causes the function to revert (because an out-of-bounds array access).

Code Snippet

https://github.com/sherlock-audit/2022-10-merit-circle/blob/main/merit-liquidity-mining/contracts/TimeLockPool.sol#L329

Tool used

Manual Review

Recommendation

Recalculate unit when the length of curve changes.

Duplicate of #101

rvierdiiev - User can't transfer all amount of claimed rewards

rvierdiiev

medium

User can't transfer all amount of claimed rewards

Summary

User can't transfer all amount of claimed rewards.

Vulnerability Detail

There is developer error in BasePool.claimRewards function. It's not possible to transfer value that less then 2. See here. That means that when amount nonEscrowedRewardAmount == 1 it will not be transferred to receiver and receiver will lost it.

Impact

Receiver lose funds.

Code Snippet

function claimRewards(address _receiver) external {
        uint256 rewardAmount = _prepareCollect(_msgSender());
        uint256 escrowedRewardAmount = rewardAmount * escrowPortion / 1e18;
        uint256 nonEscrowedRewardAmount = rewardAmount - escrowedRewardAmount;

        if(escrowedRewardAmount != 0 && address(escrowPool) != address(0)) {
            escrowPool.deposit(escrowedRewardAmount, escrowDuration, _receiver);
        }

        // ignore dust
        if(nonEscrowedRewardAmount > 1) {
            rewardToken.safeTransfer(_receiver, nonEscrowedRewardAmount);
        }

        emit RewardsClaimed(_msgSender(), _receiver, escrowedRewardAmount, nonEscrowedRewardAmount);
    }

Tool used

Manual Review

Recommendation

Change check condition to if(nonEscrowedRewardAmount > 0).

carlitox477 - TimeLockPool#increaseLock allows burning shares

carlitox477

medium

TimeLockPool#increaseLock allows burning shares

Summary

This can be done by setting _receiver=address(0) leading to lost of users deposit token

Vulnerability Detail

Calling increaseLock with _receiver=address(0) will allow msg.sender to send their deposit token to address zero.

Impact

Allows users to burn their rewards

Code Snippet

Tool used

Manual Review

Recommendation

Add next require statement at the start of function increaseLock: require(_receiver != address(0))

CodingNameKiki - A mistake made by depositer will result into losing his funds, which will be stuck in the pool.

CodingNameKiki

high

A mistake made by depositer will result into losing his funds, which will be stuck in the pool.

Summary

A mistake made by the depositer will result into losing his funds, which will be stuck in the pool.

Vulnerability Detail

Example:
Kiki wants to lock his funds and calls the function deposit(). He locks his 5000 tokens for the duration of 2 weeks and provides his address as the "_receiver".

By calling the function deposit():
1.The tokens will be transferred from Kiki to the pool.
2.The mintAmount will be calculated
3.Kiki's deposit information will be stored into a new Deposit struct in the depositOf mapping.
4.After that the function will mint the corresponding shares for the given amount of tokens to Kiki.

Later Kiki decides that he wants to lock another 1000 of his tokens. Instead of calling the function increaseLock(), he mades the mistake and calls the function deposit() again. This time he provides the amount of 1000 tokens and the same duration of 2 weeks.

The function will push the new deposit information to the already existing Deposit struct from the first deposit Kiki made.
However the problem here is that the function will change the amount of 5000 tokens from the first deposit with the new deposit of 1000 tokens and won't actually sum both of the amounts.
Same happens for the shares as well, the old "shareAmount" corresponding the amount of 5000 tokens will be replaced with the new shares from the second deposited amount of 1000 tokens.

https://github.com/sherlock-audit/2022-10-merit-circle/blob/main/merit-liquidity-mining/contracts/TimeLockPool.sol#L98-L102

2 weeks later when Kiki calls the function withdraw() to withdraw his tokens. The function will delete Kiki's Deposit struct, will burn the shares and return the tokens. However duo to the fact that userDeposit.shareAmount and userDeposit.amount will be equal to the 1000 tokens from the second deposit Kiki made. The function will successfuly return only the 1000 tokens to Kiki.
But his other 5000 tokens from the first deposit he made, will be stuck in the pool.

https://github.com/sherlock-audit/2022-10-merit-circle/blob/main/merit-liquidity-mining/contracts/TimeLockPool.sol#L130

https://github.com/sherlock-audit/2022-10-merit-circle/blob/main/merit-liquidity-mining/contracts/TimeLockPool.sol#L133

Kiki won't be able to withdraw his first deposit of 5000 tokens.
Because the function withdraw() checks the deposit information from the Kiki's Deposit struct
and both userDeposit.amount and userDeposit.shareAmount will be equal to the second deposit Kiki made for 1000 tokens.

Duo to the fact that the deposit() function minted shares for both of the deposits Kiki made, and only the shares of the second deposit were burned corresponding the 1000 tokens. Kiki won't only lose his first deposit of 5000 tokens, but won't even get the chance to claim the full amount of rewards, he will be able to claim the rewards only for the burned amount of shares corresponding the 1000 tokens.

Simple mistake led to Kiki's losing 5000 tokens out of the 6000 tokens he deposited into the pool from the two deposits.
And by following this scenario his 5000 tokens will be stuck in the pool without a way for retrieving them back.

Impact

Duo to the exploit described in "Vulnerability Detail", a simple mistake made by the depositer will lead to losing his funds, which will be stuck in the pool.

Code Snippet

https://github.com/sherlock-audit/2022-10-merit-circle/blob/main/merit-liquidity-mining/contracts/TimeLockPool.sol#L85-L107

Tool used

Manual Review

Recommendation

Consider adding the following changes to avoid this kind of problem from happening:
https://gist.github.com/CodingNameKiki/e28ccf6a48df4fcf4f8231a6468e52e1

ctf_sec - User will lose their ETH if they call the batch function in BoringBatch.sol with ETH sent

ctf_sec

medium

User will lose their ETH if they call the batch function in BoringBatch.sol with ETH sent

Summary

User will lose their ETH if they call the batch function in BoringBatch.sol with ETH sent

Vulnerability Detail

The boring batch has a batch function to let user execute function in batch by constructing the calldata,

    function batch(bytes[] calldata calls, bool revertOnFail) external payable {
        for (uint256 i = 0; i < calls.length; i++) {
            (bool success, bytes memory result) = address(this).delegatecall(calls[i]);
            if (!success && revertOnFail) {
                revert(_getRevertMsg(result));
            }
        }
    }

However, this function is payable and accept ETH, but there is no function in the construct that let we withdraw ETH, then ETH sent from user will be forever lost and stuck in the contract.

Impact

User can accidentally lose their ETH when calling BoringBatch.sol#Batch

Code Snippet

https://github.com/sherlock-audit/2022-10-merit-circle/blob/main/merit-liquidity-mining/contracts/base/BoringBatchable.sol#L33-L39

Tool used

Manual Review

Recommendation

We recommend the project make this function not payable. Also I do not see why a delegatecall must be performed here,

we can change to

    function batch(bytes[] calldata calls) external  {
        for (uint256 i = 0; i < calls.length; i++) {
            (bool success, bytes memory result) = address(this).call(calls[i]);
            require(success, 'failed');
        }
    }

Chom - pointsPerShare is not scaled once shares have been minted (deposit) or burned (withdraw)

Chom

high

pointsPerShare is not scaled once shares have been minted (deposit) or burned (withdraw)

Summary

pointsPerShare is not scaled once shares have been burned

Vulnerability Detail

Burning reduces total supply. For example, in beginning, there is a total of 1000 shares, and 1000 rewards have been distributed (1 / share) but suddenly 900 shares have been burned. A total of 100 shares remaining each share get 1 reward, only maximum of 100 rewards can be claimed. The remaining 900 will be wasted.

On the other hand, for minting, For example, in beginning, there is a total of 100 shares, and 100 rewards have been distributed (1 / share) but suddenly 900 shares have been minted. A total of 1000 shares remaining each share gets 1 reward. You need to have 1000 rewards in the contract, but only 100 rewards is actually in the contract.

Impact

Some rewards will be wasted forever if too many burns. And it won't be a sufficient reward if new shares get minted.

Code Snippet

https://github.com/sherlock-audit/2022-10-merit-circle/blob/main/merit-liquidity-mining/contracts/base/AbstractRewards.sol#L89-L99

https://github.com/sherlock-audit/2022-10-merit-circle/blob/main/merit-liquidity-mining/contracts/base/BasePool.sol#L85-L88

Tool used

Manual Review

Recommendation

Please take a look at any Masterchef implementation such as https://github.com/pancakeswap/pancake-farm/blob/master/contracts/MasterChef.sol

  1. Add reward debt system
  2. Automatically claim the reward on each deposit and withdraw.
  3. Automatically update reward debt on each deposit and withdraw.

Reward debt is not like withdrawnRewards tracking, it uses current balance * pointsPerShare instead.

berndartmueller - Claiming rewards with a pool that has an escrow portion but no escrow pool set will render the escrowed rewards lost

berndartmueller

medium

Claiming rewards with a pool that has an escrow portion but no escrow pool set will render the escrowed rewards lost

Summary

When a user claims rewards from a pool that has an escrow portion (a value non-zero) but no escrow pool set (set to address(0)), the escrowed rewards will be lost.

Vulnerability Detail

A user can claim rewards with the BasePool.claimRewards function. Parts or all of the claimable rewards can be escrowed. The escrowed rewards are sent to the escrowPool address. The amount of escrowed rewards are calculated with escrowPortion / 1e18. However, if the escrowPool address is set to address(0) but escrowPortion is defined to a non-zero value, the escrowed rewards are lost (this state is possible as there is no appropriate validation in the initializer function).

Impact

Calculated escrowed rewards are not claimable and will be lost.

Code Snippet

base/BasePool.sol#L100-L115

function claimRewards(address _receiver) external {
    uint256 rewardAmount = _prepareCollect(_msgSender());
    uint256 escrowedRewardAmount = rewardAmount * escrowPortion / 1e18;
    uint256 nonEscrowedRewardAmount = rewardAmount - escrowedRewardAmount;

    if(escrowedRewardAmount != 0 && address(escrowPool) != address(0)) {
        escrowPool.deposit(escrowedRewardAmount, escrowDuration, _receiver);
    }

    // ignore dust
    if(nonEscrowedRewardAmount > 1) {
        rewardToken.safeTransfer(_receiver, nonEscrowedRewardAmount);
    }

    emit RewardsClaimed(_msgSender(), _receiver, escrowedRewardAmount, nonEscrowedRewardAmount);
}

Tool used

Manual review

Recommendation

Consider either one of the following:

  • prevent a state of having an escrow portion but no escrow pool set, or
  • check for the escrow pool address to be non-zero, otherwise set nonEscrowedRewardAmount to rewardAmount

Duplicate of #107

CodingNameKiki - Two malicious users can drain a big amount of rewards up to 48 weeks, for the little lock time of 10 mins.

CodingNameKiki

high

Two malicious users can drain a big amount of rewards up to 48 weeks, for the little lock time of 10 mins.

Summary

By following the scenario in "Vulnerability Detail", two malicious users can drain rewards from the pool.

Vulnerability Detail

Example:
Kiki calls the function deposit() and locks tokens for himself, for the duration of 48 weeks and provides his address as the _receiver. Then he calls the function deposit() again and locks funds for Bob as well, for the duration of 10 mins and provides Bob's address as the _receiver. After that Kiki calls the function increaseLock() and provides his _depositId and Bob's address as the _receiver.

Since Kiki is the msg.sender calling the function increaseLock(), and provides his _depositId.
The function will successfuly make a copy in memory from Kiki's Deposit lock with the duration of 48 weeks:

https://github.com/sherlock-audit/2022-10-merit-circle/blob/main/merit-liquidity-mining/contracts/TimeLockPool.sol#L203

The remaingDuration will be calculated based on the Kiki's lock information, which he made for the duration of 48 weeks.

https://github.com/sherlock-audit/2022-10-merit-circle/blob/main/merit-liquidity-mining/contracts/TimeLockPool.sol#L213

And the mintAmount will be calculated based on the increaseAmount provided by Kiki and the sum of the remaingDuration.

https://github.com/sherlock-audit/2022-10-merit-circle/blob/main/merit-liquidity-mining/contracts/TimeLockPool.sol#L215

However the problem here is that by providing Bob's address as the _receiver. The mapping depositsOf will take Bob's address and will lead to Bob's lock information. Duo to the fact that Kiki made the two deposits and the _depositId will be the same for the two deposits, but only the right address will lead to the right deposit information. This way the function increaseLock() can be tricked to add the calculated mintAmount based on the Kiki's 48 weeks lock to Bob's lock balance.

If l understood right from the sponsor, the _depositId is created, when the user's wallet is connected to the site.
And when Kiki did the two deposits - one for him and one for Bob. The _depositId will be the same for the two deposits.
Kiki's address will lead to Kiki's lock and Bob's address will lead to Bob's lock, even tho the _depositId is the same.

The function will successfuly add the _increaseAmount and mintAmount calculated based on the Kiki's 48 weeks lock duration to Bob's lock of 10 mins.

https://github.com/sherlock-audit/2022-10-merit-circle/blob/main/merit-liquidity-mining/contracts/TimeLockPool.sol#L217-L218

When Bob's 10 mins lock duration ends, he can successfuly claim the big amount of rewards calculated based on the Kiki's 48 week duration lock. After that Bob can withdraw the increaseAmount provided by Kiki as well and both of them can repeat this process and successfuly drain rewards from the BasePool.

Simple Example of what will happen duo to the following scenario described above:
1.Kiki locks funds for himself for the duration of 48 weeks, and locks funds for Bob for the duration of 10 mins.
2.Kiki calls the function increaseLock() providing his _depositId, Bob's address as the _receiver and _increaseAmount of 1000 tokens.
3.The calculated mintAmount from Kiki's 48 weeks lock and the _increaseAmount of 1000 tokens will be added to Bob's lock.
4.After Bob's lock ends, he can claim the big amount of rewards and withdraw the _increaseAmount of 1000 tokens provided by Kiki.

Both of them can repeat this process over and over:
1.When Bob withdraw the 1000 tokens back, the function withdraw() will delete his deposit information.
2.Kiki can lock funds for Bob again, and that's how they can repeat this over and over and drain rewards from the pool.

Impact

Duo to the exploit described in "Vulnerability Detail", two malicious users can drain rewards from the BasePool.

Code Snippet

https://github.com/sherlock-audit/2022-10-merit-circle/blob/main/merit-liquidity-mining/contracts/TimeLockPool.sol#L197-L222

Tool used

Manual Review

Recommendation

The fix of this is simple, consider changing to this: https://gist.github.com/CodingNameKiki/cec8dd78feeb562104e2bc0a0c0b6e87

Duplicate of #102

ctf_sec - Share can be minted to address(0) in TimeLockPool.sol#Deposit

ctf_sec

medium

Share can be minted to address(0) in TimeLockPool.sol#Deposit

Summary

Share can be minted to address(0) in TimeLockPool.sol#Deposit

Vulnerability Detail

When calling the the function deposit

   function deposit(uint256 _amount, uint256 _duration, address _receiver) external override {
        if (_amount == 0) {
            revert ZeroAmountError();
        }

The function does not verify if the _receiver address is address(0),

When the mint is called, the share that minted to address(0) is basically equal to burn the share.

    _mint(_receiver, mintAmount);

Impact

Burning the share while deposit the token basically waste storage space and erode other user's share because address(0) is not supposed to get any reward anyway.

Code Snippet

https://github.com/sherlock-audit/2022-10-merit-circle/blob/main/merit-liquidity-mining/contracts/TimeLockPool.sol#L85-L93

Tool used

Manual Review

Recommendation

We recommend the project check if the receiverAddress is address(0)

   function deposit(uint256 _amount, uint256 _duration, address _receiver) external override {
        if(_receiver == address(0)) {
             revert InvalidReceiver();
        }
        if (_amount == 0) {
            revert ZeroAmountError();
        }

rvierdiiev - Lock time can be avoided

rvierdiiev

high

Lock time can be avoided

Summary

User is able to avoid locking his tokens due to increaseLock function.

Vulnerability Detail

When user deposit some amount of tokens it is going to be locked for some duration.
The protocol suppose that user can't withdraw that tokens during that time.

However it's possible to deposit and withdraw tokens without lock.

This is the flow.

  1. Attacker from account1 makes deposit with value 1 token for a minimum time(10 minutes). Now deposit with index 0 is created for account1.
  2. After 10 minutes attacker from account2 makes deposit for 1 token but for the max amount of time(to get big tokens multiplier). Now deposit with index 0 is for account2.
  3. Attacker from account2 calls increaseLock(0, account1.address, bigAmount). It's important here to provide account1 address as _receiver for increaseLock function. The function will check that lock for account2 0's deposit is not expired yet. Then it will mint big amount of reward tokens(because of big lock amount of the deposit) and will add minted tokens and deposited tokens to the _receiver same deposit with index 0(this is important to have deposits with same id).
  4. Attcker somehow use minted tokens(to get rewards for example)
  5. Attacker from account1 can withdraw all deposited tokens.

Impact

This mechanism can be used to deposit money just before reward distribution to get the share and then witdhraw deposited tokens.

It's possible to create a bot that will front run transactions when rewards are toped up to deposit big amount of stake tokens. Then after that get rewards and withdraw tokens. All this can be done in 1 block. The only condition for attacker is to have mock deposit account already expired, but not withdrawn.

Code Snippet

This code will show that this is possible

it.only("Increasing lock for another receiver will broke system", async() => {
            await timeLockPool.deposit(DEPOSIT_AMOUNT, constants.MaxUint256, account1.address);
            const startUserDepostit = await timeLockPool.depositsOf(account1.address, 0);
            console.log("account 1 deposit amount: " + Number(startUserDepostit.amount._hex));
            const startBalance = await timeLockPool.balanceOf(account1.address);
            console.log("account 1 pool token balance: " + Number(startBalance._hex));

            let startBalanceOfAnotherAccount = await timeLockPool.balanceOf(account2.address);
            expect(startBalanceOfAnotherAccount).to.be.eq(0)

            //change lock for account2
            await timeLockPool.deposit(1, 0, account2.address);
            await timeTraveler.increaseTime(60 * 10 * 2);
            let anotherAccountDeposit = await timeLockPool.depositsOf(account2.address, 0);
            console.log("account 2 deposit amount after first deposit: " + Number(anotherAccountDeposit.amount._hex));
            let timelockTokenBalance = await timeLockPool.balanceOf(account2.address);
            console.log("account 2 pool token balance after first deposit: " + Number(timelockTokenBalance._hex));

            await timeLockPool.increaseLock(0, account2.address, INCREASE_AMOUNT);
            anotherAccountDeposit = await timeLockPool.depositsOf(account2.address, 0);
            console.log("account 2 deposit amount after increase lock: " + Number(anotherAccountDeposit.amount._hex));
            timelockTokenBalance = await timeLockPool.balanceOf(account2.address);
            console.log("account 2 pool token balance after increase lock: " + Number(timelockTokenBalance._hex));
            
            await timeLockPool.connect(account2).withdraw(0, account2.address);

            timelockTokenBalance = await timeLockPool.balanceOf(account2.address);
            console.log("account 2 pool token balance after withdraw: " + Number(timelockTokenBalance._hex));

            const depositTokenBalance = await depositToken.balanceOf(account2.address);
            console.log("deposit token balance: " + depositTokenBalance);
        });

Tool used

Manual Review

Recommendation

Check that lock of _receiver has not expired yet. And also calculate multiplier for the _receiver based on his deposit lock duration.

Duplicate of #102

8olidity - unit might be 0,The getMultiplier() function is not available

8olidity

medium

unit might be 0,The getMultiplier() function is not available

Summary

unit might be 0

Vulnerability Detail

Because of this

uint256 public constant MIN_LOCK_DURATION = 10 minutes;
if (_maxLockDuration < MIN_LOCK_DURATION) {
    revert SmallMaxLockDuration();
}

maxLockDuration minimum is 600. If curve.length is greater than 600 on that day, unit will be 0 and getMultiplier() will calculate incorrectly

poc

// merit-liquidity-mining/test/TimeLockPool.ts
const MAX_LOCK_DURATION = 3;
    it("Replacing with a same length curve should do it correctly", async() => {
        // Mapping a new curve for replacing the old one
        const NEW_CURVE = CURVE.map(function(x) {
            return (hre.ethers.BigNumber.from(x).mul(2).toString())
        })
        await timeLockPool.connect(deployer).setCurve(NEW_CURVE);
        console.log(await timeLockPool.maxLockDuration());  // console  3
        console.log(await timeLockPool.unit());    // console 0 

        for(let i=0; i< NEW_CURVE.length; i++){
            const curvePoint = await timeLockPool.curve(i);
            expect(curvePoint).to.be.eq(NEW_CURVE[i])
        }
        await expect(timeLockPool.curve(NEW_CURVE.length + 1)).to.be.reverted;
    })

Impact

The getMultiplier() function is not available

Code Snippet

https://github.com/sherlock-audit/2022-10-merit-circle/blob/main/merit-liquidity-mining/contracts/TimeLockPool.sol#L280-L311

    function setCurve(uint256[] calldata _curve) external onlyGov {
        if (_curve.length < 2) {
            revert ShortCurveError();
        }
        // same length curves
        if (curve.length == _curve.length) {
            for (uint i=0; i < curve.length; i++) {
                curve[i] = maxBonusError(_curve[i]);
            }
        // replacing with a shorter curve
        } else if (curve.length > _curve.length) {
            for (uint i=0; i < _curve.length; i++) {
                curve[i] = maxBonusError(_curve[i]);
            }
            uint initialLength = curve.length;
            for (uint j=0; j < initialLength - _curve.length; j++) {
                curve.pop();
            }
            unit = maxLockDuration / (curve.length - 1);
        // replacing with a longer curve
        } else {
            for (uint i=0; i < curve.length; i++) {
                curve[i] = maxBonusError(_curve[i]);
            }
            uint initialLength = curve.length;
            for (uint j=0; j < _curve.length - initialLength; j++) {
                curve.push(maxBonusError(_curve[initialLength + j]));
            }
            unit = maxLockDuration / (curve.length - 1);
        }
        emit CurveChanged(_msgSender());
    }

Tool used

vscode
Manual Review

Recommendation

Judge unit's value after you calculate it

carlitox477 - TimeLockPool#withdraw allows burning shares

carlitox477

medium

TimeLockPool#withdraw allows burning shares

Summary

This can be done by setting _receiver=address(0) leading to lost of users deposit token

Vulnerability Detail

Calling withdraw with _receiver=address(0) will allow msg.sender to send their deposit token to address zero.

Impact

Allows users to burn their rewards

Code Snippet

Tool used

Manual Review

Recommendation

Add next require statement at the start of function withdraw: require(_receiver != address(0))

ctf_sec - The maxLockDuration parameter in TimeLockPool.sol does not have upper bound limit and does not conform to the 48 month maximum staking period business requirement.

ctf_sec

medium

The maxLockDuration parameter in TimeLockPool.sol does not have upper bound limit and does not conform to the 48 month maximum staking period business requirement.

Summary

The maxLockDuration parameter in TimeLockPool.sol does not have upper bound limit and does not conform to the 48 month maximum staking period business requirement.

Vulnerability Detail

the parameter maxLockDuration determines the maximum length of period that user can stake.

uint256 public maxLockDuration;

and

maxLockDuration = _maxLockDuration;

Then in deposit and this parameter is used to enforce the max lock period

In the function deposit

// Don't allow locking > maxLockDuration
 uint256 duration = _duration.min(maxLockDuration);

and in the function extendLock

 // New duration is the time expiration plus the increase
 uint256 duration = maxLockDuration.min(uint256(userDeposit.end - block.timestamp) + increaseDuration);

according to the documentation

https://parallel-jacket-e61.notion.site/Staking-V2-Smart-Contract-Overview-8c282013776849b5928b4d2d0d0b7579,

Merit Circle Staking V2 is a module where holders of MC or MC/ETH LP tokens can lock their tokens up to 48 months to get rewards.

This business requirement is not implemented by adding a upper limit bound for the parameter maxLockDuration.

Impact

A Compromise admin can set the maxLockDuration to a very very very large number and stake a small amount token with a very large duration to collect reward.

The User is able to stake more than 48 month, which violates the business requirement.

Code Snippet

https://github.com/sherlock-audit/2022-10-merit-circle/blob/main/merit-liquidity-mining/contracts/TimeLockPool.sol#L60-L63

https://github.com/sherlock-audit/2022-10-merit-circle/blob/main/merit-liquidity-mining/contracts/TimeLockPool.sol#L89-L97

https://github.com/sherlock-audit/2022-10-merit-circle/blob/main/merit-liquidity-mining/contracts/TimeLockPool.sol#L161-L168

Tool used

Manual Review

Recommendation

We recommend the project add a upper limit bound for the parameter maxLockDuration

berndartmueller - Curve points are not validated to be continuously increasing

berndartmueller

medium

Curve points are not validated to be continuously increasing

Summary

Curve points are not validated to be continuously increasing and in case a curve point is less than its predecessor, the getMultiplier function will revert

Vulnerability Detail

Curve points are set by governance in multiple places. Each point on the curve is expected to be greater than the previous point. However, this is not validated in the code. If a point is set to a value less than the previous point, the linear interpolation in TimeLockPool.getMultiplier will revert due to curve[n + 1] < curve[n].

Impact

The core functionality of the TimeLockPool contract will be broken and will revert until the curve is fixed by governance.

Code Snippet

TimeLockPool.sol#L245

function getMultiplier(uint256 _lockDuration) public view returns(uint256) {
    // There is no need to check _lockDuration amount, it is always checked before
    // in the functions that call this function

    // n is the time unit where the lockDuration stands
    uint n = _lockDuration / unit;
    // if last point no need to interpolate
    // trim de curve if it exceedes the maxBonus // TODO check if this is needed
    if (n == curve.length - 1) {
        return 1e18 + curve[n];
    }
    // linear interpolation between points
    return 1e18 + curve[n] + (_lockDuration - n * unit) * (curve[n + 1] - curve[n]) / unit; // @audit-info Can revert due to `curve[n + 1] - curve[n]`
}

Tool used

Manual review

Recommendation

Consider adding appropriate checks to all places where the curve points are set or changed to ensure that a curve point is not less than the previous point.

Duplicate of #111

Ruhum - Pool doesn't support fee-on-transfer tokens

Ruhum

medium

Pool doesn't support fee-on-transfer tokens

Summary

Some tokens take or might take a fee on each transfer in the future. A good example is USDT. The TimeLockPool contract doesn't take that into account. Any pool that uses such a token won't work as expected because of the wrong internal bookkeeping.

Vulnerability Detail

When a user calls deposit() the contract expects to receive exactly amount tokens: https://github.com/sherlock-audit/2022-10-merit-circle/blob/main/merit-liquidity-mining/contracts/TimeLockPool.sol#L94

If depositToken is a fee-on-transfer token, the contract will instead receive x where x < amount. If the same user now calls withdraw(), the transaction will fail because the contract doesn't have enough tokens to cover the withdrawal: https://github.com/sherlock-audit/2022-10-merit-circle/blob/main/merit-liquidity-mining/contracts/TimeLockPool.sol#L133

Impact

The contract will have fewer tokens than it expects to have. There will be no issues for initial withdrawals because the other user's deposits should cover their transactions. Later withdrawals will fail and the remaining funds will be locked inside the contract.

Code Snippet

    function deposit(uint256 _amount, uint256 _duration, address _receiver) external override {
        if (_amount == 0) {
            revert ZeroAmountError();
        }
        // Don't allow locking > maxLockDuration
        uint256 duration = _duration.min(maxLockDuration);
        // Enforce min lockup duration to prevent flash loan or MEV transaction ordering
        duration = duration.max(MIN_LOCK_DURATION);

        depositToken.safeTransferFrom(_msgSender(), address(this), _amount);

        uint256 mintAmount = _amount * getMultiplier(duration) / 1e18;

        depositsOf[_receiver].push(Deposit({
            amount: _amount,
            shareAmount: mintAmount,
            start: uint64(block.timestamp),
            end: uint64(block.timestamp) + uint64(duration)
        }));

        _mint(_receiver, mintAmount);
        emit Deposited(_amount, duration, _receiver, _msgSender());
    }

Tool used

Manual Review

Recommendation

You can support fee-on-transfer tokens by verifying how many tokens you got from a transfer:

uint initialBalance = token.balanceOf(address(this));
token.safeTransferFrom(user, address(this), amount);
uint actualBalance = token.balanceOf(address(this)) - initialBalance; 

berndartmueller - Withdrawing deposits within a batch transaction can lead to issues with deposit ids

berndartmueller

medium

Withdrawing deposits within a batch transaction can lead to issues with deposit ids

Summary

The deposit ids change whenever a deposit is withdrawn. This can cause issues when using deposit ids in batch transactions.

Vulnerability Detail

Deposit ids are based on the index of a deposit within the depositsOf[msg.sender] array. On withdrawal, deposits are reordered - the last deposit from a user will be placed at the position of the withdrawn deposit with id _depositId and the last deposit is removed. Hence the deposit id of the last deposit has changed. This can lead to issues when withdrawing deposits and, for example, extending deposit locks within a batch transaction. Then the change of deposit ids has to be taken into account by the user calling the batch transaction. Otherwise, if the deposit id of the previously last deposit is referenced, the transaction will revert with an out-of-bounds error.

Consider the following example:

  1. Alice has 10 deposits with ids 0, 1, 2, 3, 4, 5, 6, 7, 8, 9.
  2. Alice creates and executes the following batch transaction:
    1. Withdraw the deposit with id 6
    2. Withdraw the deposit with id 9
  3. The batch transaction will revert due to no deposit exists with id 9 (the previous deposit with id 9 has its deposit id changed to id 6)

Impact

Wrong and non-existent deposits are referenced by deposit ids, leading batch transactions to revert.

Code Snippet

TimeLockPool.sol#L127

function withdraw(uint256 _depositId, address _receiver) external {
    if (_depositId >= depositsOf[_msgSender()].length) {
        revert NonExistingDepositError();
    }
    Deposit memory userDeposit = depositsOf[_msgSender()][_depositId];
    if (block.timestamp < userDeposit.end) {
        revert TooSoonError();
    }

    // remove Deposit
    depositsOf[_msgSender()][_depositId] = depositsOf[_msgSender()][depositsOf[_msgSender()].length - 1]; // @audit-info this will change deposit ids
    depositsOf[_msgSender()].pop();

    // burn pool shares
    _burn(_msgSender(), userDeposit.shareAmount);

    // return tokens
    depositToken.safeTransfer(_receiver, userDeposit.amount);
    emit Withdrawn(_depositId, _receiver, _msgSender(), userDeposit.amount);
}

Tool used

Manual review

Recommendation

Consider using immutable deposit ids instead of referencing the deposit as the index in the array.

Bnke0x0 - Ensure zero msg.value if transferring from user and input Token is not ETH

Bnke0x0

medium

Ensure zero msg.value if transferring from user and input Token is not ETH

Summary

Ensure zero msg.value if transferring from user and input Token is not ETH

Vulnerability Detail

Impact

A user that mistakenly calls either create() or addToken() with WETH (or another ERC20) as the input token, but includes native ETH with the function call will have his native ETH permanently locked in the contract.

Code Snippet

https://github.com/sherlock-audit/2022-10-merit-circle/blob/main/merit-liquidity-mining/contracts/TimeLockPool.sol#L94

   'depositToken.safeTransferFrom(_msgSender(), address(this), _amount);'

https://github.com/sherlock-audit/2022-10-merit-circle/blob/main/merit-liquidity-mining/contracts/TimeLockPool.sol#L210

   'depositToken.safeTransferFrom(_msgSender(), address(this), _increaseAmount);'

https://github.com/sherlock-audit/2022-10-merit-circle/blob/main/merit-liquidity-mining/contracts/base/BasePool.sol#L96

   'rewardToken.safeTransferFrom(_msgSender(), address(this), _amount);'

Tool used

Manual Review

Recommendation

It is best to ensure that msg.value = 0

cccz - Too few tokens minted(too many tokens burned) in extendLock function

cccz

medium

Too few tokens minted(too many tokens burned) in extendLock function

Summary

Too few tokens minted(too many tokens burned) in extendLock function

Vulnerability Detail

When the user increases the lock duration in the extendLock function, the number of tokens minted is the number of tokens that should be minted in the new lock duration minus the number of tokens previously minted.
Consider the following scenario.
If the user locks 1000 deposited tokens, he will get 100 tokens in 2 months, 300 tokens in 3 months, and 800 tokens in 6 months.
If the user locks 1000 deposited tokens for 3 months, the user gets 300 tokens at that time.
After two months, the user decides to add another 5 months to the lock duration, so the new lock duration is 6 months and the user gets 800-300 = 500 tokens.
In total, the user has been locked in for 8 months, but has only received 800 tokens.
I think a better practice would be for the user to get 100 + 800 = 900 tokens.

Impact

The user gets less tokens than expected when calling the extendLock function

Code Snippet

https://github.com/sherlock-audit/2022-10-merit-circle/blob/main/merit-liquidity-mining/contracts/TimeLockPool.sol#L148-L179

Tool used

Manual Review

Recommendation

Consider adding the following gottenAmount to the calculation

       gottenAmount = userDeposit.amount * getMultiplier((block.timestamp - userDeposit.start)) / 1e18;
        if (mintAmount + gottenAmount > userDeposit.shareAmount) {
            depositsOf[_msgSender()][_depositId].shareAmount =  mintAmount;
            _mint(_msgSender(), mintAmount + gottenAmount - userDeposit.shareAmount);
        // If the new amount is less then burn that difference
        } else if (mintAmount + gottenAmount < userDeposit.shareAmount) {
            depositsOf[_msgSender()][_depositId].shareAmount =  mintAmount;
            _burn(_msgSender(), userDeposit.shareAmount - mintAmount - gottenAmount);
        }

cccz - Consider adding minAmount to extendLock/increaseLock/deposit for slippage control

cccz

medium

Consider adding minAmount to extendLock/increaseLock/deposit for slippage control

Summary

Consider adding minAmount to extendLock/increaseLock/deposit for slippage control

Vulnerability Detail

There is no minimum limit to the number of tokens that are minted (or burned) in the extendLock/increaseLock/deposit functions, which may result in the user receiving less tokens than expected if the transactions calling these functions occur in the same block as the transactions calling the setCurvePoint/setCurve functions.

Impact

This may result in the user receiving less tokens than expected.

Code Snippet

https://github.com/sherlock-audit/2022-10-merit-circle/blob/main/merit-liquidity-mining/contracts/TimeLockPool.sol#L85-L107
https://github.com/sherlock-audit/2022-10-merit-circle/blob/main/merit-liquidity-mining/contracts/TimeLockPool.sol#L148-L179
https://github.com/sherlock-audit/2022-10-merit-circle/blob/main/merit-liquidity-mining/contracts/TimeLockPool.sol#L197-L220
https://github.com/sherlock-audit/2022-10-merit-circle/blob/main/merit-liquidity-mining/contracts/TimeLockPool.sol#L233-L246

Tool used

Manual Review

Recommendation

Consider adding minAmount to extendLock/increaseLock/deposit for slippage control

Duplicate of #113

0xmuxyz - There is possibility that leave a contract "uninitialized" and that contract will be used for malicious attack

0xmuxyz

high

There is possibility that leave a contract "uninitialized" and that contract will be used for malicious attack

Summary

  • The vulnerability is leaving the implementation contract uninitialized , which allows a malicious attacker to call initialize() function to upgrade the implementation contract. That allow malicious attacker to upgrade malicious contract as a new implementation contract and steal fund locked in contract by executing bad functions.

Vulnerability Detail

  • The vulnerability is leaving the implementation contract (ex. TimeLockNonTransferablePool.sol) uninitialized , which allows a malicious attacker to call initialize() function to upgrade the implementation contract. As a result, an attacker can insert any attack contract that includes bad functionalities into a parameter of new implementation contract. Then, once an attacker is successful to update the implementation contract, they can execute the bad functions.

Impact

  • A malicious attacker can steal fund locked in contract by executing bad functions such as a function that withdraw all locked fund, etc.

Code Snippet

contract TimeLockNonTransferablePool is TimeLockPool {
    function initialize(
        string memory _name,
        string memory _symbol,
        address _depositToken,
        address _rewardToken,
        address _escrowPool,
        uint256 _escrowPortion,
        uint256 _escrowDuration,
        uint256 _maxBonus,
        uint256 _maxLockDuration,
        uint256[] memory _curve
    ) public initializer {
        __TimeLockPool_init(_name, _symbol, _depositToken, _rewardToken, _escrowPool, _escrowPortion, _escrowDuration, _maxBonus, _maxLockDuration, _curve);
    }

Tool used

  • Manual Review

Recommendation

  • To avoid leaving a contract uninitialized, you should invoke the _disableInitializers() function in the constructor to automatically lock it when it is deployed:

contract TimeLockNonTransferablePool is TimeLockPool {

    //@dev - _disableInitializers() should be added to avoid leaving a contract uninitialized.
    constructor() {
        _disableInitializers();
    }

    function initialize(
        string memory _name,
        string memory _symbol,
        address _depositToken,
        address _rewardToken,
        address _escrowPool,
        uint256 _escrowPortion,
        uint256 _escrowDuration,
        uint256 _maxBonus,
        uint256 _maxLockDuration,
        uint256[] memory _curve
    ) public initializer {
        __TimeLockPool_init(_name, _symbol, _depositToken, _rewardToken, _escrowPool, _escrowPortion, _escrowDuration, _maxBonus, _maxLockDuration, _curve);
    }

rvierdiiev - BasePool.distributeRewards function should be restricted to not be called by anyone

rvierdiiev

low

BasePool.distributeRewards function should be restricted to not be called by anyone

Summary

BasePool.distributeRewards function can be misused by user, so he will lost his tokens.

Vulnerability Detail

Impact

User may lose funds.

Code Snippet

function distributeRewards(uint256 _amount) external override {
        rewardToken.safeTransferFrom(_msgSender(), address(this), _amount);
        _distributeRewards(_amount);
    }

Tool used

Manual Review

Recommendation

Restrict this function to be called by government(onlyGov) or smth.

ctf_sec - Incompatability with deflationary / fee-on-transfer tokens

ctf_sec

medium

Incompatability with deflationary / fee-on-transfer tokens

Summary

When depositing and withdrawing, if the token is a fee-on-transfer tokens, the internal accounting will have issue.

Vulnerability Detail

when depositing, we call

   depositToken.safeTransferFrom(_msgSender(), address(this), _amount);
   uint256 mintAmount = _amount * getMultiplier(duration) / 1e18;

We assume that the _amount will passed accurately transferred into the TimeLockPool.sol,

however, if the underlying token is fee-on-transfer token, for example, we transfer 100 token, but when transfering, the token charge a fee and 95 Token is what the contract receive,

the contract will still use the 100 balance to mint share, which is not accurate.

When withdraw, we call

        // burn pool shares
        _burn(_msgSender(), userDeposit.shareAmount);
        
        // return tokens
        depositToken.safeTransfer(_receiver, userDeposit.amount);

However, if the underlying token is rebasing, the TimeLockContract have more token balance then the user deposit, then the rebased token balance will be lost because we just withdraw userDeposit.amount

Impact

In Deposit, the amount of share minted is not accurate because the amount we intended to receive can be difficult from the amount we received.

In Withdraw, user may lose the rebasing amount of token.

Code Snippet

https://github.com/sherlock-audit/2022-10-merit-circle/blob/main/merit-liquidity-mining/contracts/TimeLockPool.sol#L129-L133

https://github.com/sherlock-audit/2022-10-merit-circle/blob/main/merit-liquidity-mining/contracts/TimeLockPool.sol#L93-L97

Tool used

Manual Review

Recommendation

Consider compare pre-/after token balances to compute the actual transferred amount.

berndartmueller - Unsafe cast of `pointsPerShare` can cause wrong reward calculation

berndartmueller

medium

Unsafe cast of pointsPerShare can cause wrong reward calculation

Summary

The unsafe cast of pointsPerShare from uint256 to int256 can cause less earned rewards than anticipated.

Vulnerability Detail

The unsafe cast of pointsPerShare to int256 can cause its value to be different from the correct value. If the unsigned value of pointsPerShare is above the maximum signed value type(int256).max, it will be interpreted as a negative value instead. Then pointsCorrection[account] will be incorrect and the amount of earned rewards for a user will be less than expected.

Impact

A user will earn fewer rewards than expected.

Code Snippet

base/AbstractRewards.sol#L126

/**
  * @dev Increases or decreases the points correction for `account` by
  * `shares*pointsPerShare`.
  */
function _correctPoints(address _account, int256 _shares) internal {
  pointsCorrection[_account] = pointsCorrection[_account] + (_shares * (int256(pointsPerShare)));
}

Tool used

Manual review

Recommendation

Consider using SafeCast to cast pointsPerShare to int256:

function _correctPoints(address _account, int256 _shares) internal {
  pointsCorrection[_account] = pointsCorrection[_account] + (_shares * pointsPerShare.toInt256());
}

Duplicate of #59

rvierdiiev - Curve mess up is posssible that leads to deposit function blocked

rvierdiiev

medium

Curve mess up is posssible that leads to deposit function blocked

Summary

It's possible that the multiplier for deposited tokens will be calculated incorrectly when curves values are not validated and will be not possible to deposit.

Vulnerability Detail

When user deposits token then some multiplier is calculated to know the amount of share tokens to be minted. This multiplier is calculated here and it uses state variable curve inside.

It's possible that curve values will be provided in wrong order, because there is no validation nor in the constructor nor in the setter that curve[i+1] > curve[i]. There is only check that curve[i] is not bigger than maxBonus.

In case if curve[i+1] > curve[i] then this line in multiplier calculation function will revert with underflow error.
So depositing is not possible anymore.

Impact

Calculation of share tokens can be broken.

Code Snippet

Provided above

Tool used

Manual Review

Recommendation

Do validation of curve array elements, so curve[i+1] > curve[i].

Duplicate of #111

Ch_301 - Missing updating of the `unit` value.

Ch_301

high

Missing updating of the unit value.

Summary

Missing updating of the unit value.

Vulnerability Detail

On TimeLockPool.sol == > setCurvePoint()
When the Gov decide to add a point to the curve or remove the last point of the curve

        } else if (_position == curve.length) {
            curve.push(_newPoint);
        } else {
            if (curve.length - 1 < 2) {
                revert ShortCurveError();
            }
            curve.pop();
        }

There is no updating to the unit value
We can see here how the unit is calculating

 unit = maxLockDuration / (curve.length - 1);

So any change in the curve.length will affect the unit value which means getMultiplier() will return the wrong value.

Impact

getMultiplier() will deliver a wrong multiplier value
And this will affect the shares calculation on deposit()

uint256 mintAmount = _amount * getMultiplier(duration) / 1e18;

Code Snippet

    function setCurvePoint(uint256 _newPoint, uint256 _position) external onlyGov {
        if (_newPoint > maxBonus) {
            revert MaxBonusError();
        }
        if (_position < curve.length) {
            curve[_position] = _newPoint;
        } else if (_position == curve.length) {
            curve.push(_newPoint);
        } else {
            if (curve.length - 1 < 2) {
                revert ShortCurveError();
            }
            curve.pop();
        }
        emit CurveChanged(_msgSender());
    }

https://github.com/sherlock-audit/2022-10-merit-circle/blob/main/merit-liquidity-mining/contracts/TimeLockPool.sol#L322-L337

Tool used

Manual Review

Recommendation

If you add or remove a point to/from the curve
Reculcale the unit

unit = maxLockDuration / (curve.length - 1);

Duplicate of #101

8olidity - Consider whether depositToken and rewardToken are deflationary tokens

8olidity

medium

Consider whether depositToken and rewardToken are deflationary tokens

Summary

Consider whether depositToken and rewardToken are deflationary tokens

Vulnerability Detail

If depositToken is a deflationary token, the timelockpool contract cannot get _amount of tokens after deposit()

function deposit(uint256 _amount, uint256 _duration, address _receiver) external override {
    if (_amount == 0) {
        revert ZeroAmountError();
    }
    // Don't allow locking > maxLockDuration
    uint256 duration = _duration.min(maxLockDuration);
    // Enforce min lockup duration to prevent flash loan or MEV transaction ordering
    duration = duration.max(MIN_LOCK_DURATION);

    depositToken.safeTransferFrom(_msgSender(), address(this), _amount);

    uint256 mintAmount = _amount * getMultiplier(duration) / 1e18;

    depositsOf[_receiver].push(Deposit({
        amount: _amount,
        shareAmount: mintAmount,
        start: uint64(block.timestamp),
        end: uint64(block.timestamp) + uint64(duration)
    }));

    _mint(_receiver, mintAmount);
    emit Deposited(_amount, duration, _receiver, _msgSender());
}

If rewardToken is a deflationary token, distributeRewards() Basepool will also not get _amount of tokens

    function distributeRewards(uint256 _amount) external override {
        rewardToken.safeTransferFrom(_msgSender(), address(this), _amount);
        _distributeRewards(_amount);
    }

Impact

The token number is different from the actual number

Code Snippet

https://github.com/sherlock-audit/2022-10-merit-circle/blob/main/merit-liquidity-mining/contracts/TimeLockPool.sol#L94

Tool used

vscode
Manual Review

Recommendation

Add the depositToken and rewardToken whitelists

Ch_301 - Missing checking `address(0)`

Ch_301

medium

Missing checking address(0)

Summary

Missing checking address(0)

Vulnerability Detail

On TimeLockPool.sol ==> deposit() and increaseLock()
In case the user set _receiver == address(0) by mistake
he will lose all their shares

And on withdraw() also but here he will lose their funds, the pool will send the funds to the address(0).

Impact

The user could lose their shares or funds

Code Snippet

  function deposit(uint256 _amount, uint256 _duration, address _receiver) external override {
        if (_amount == 0) {
            revert ZeroAmountError();
        }
        // Don't allow locking > maxLockDuration
        uint256 duration = _duration.min(maxLockDuration);
        // Enforce min lockup duration to prevent flash loan or MEV transaction ordering
        duration = duration.max(MIN_LOCK_DURATION);

        depositToken.safeTransferFrom(_msgSender(), address(this), _amount);

        uint256 mintAmount = _amount * getMultiplier(duration) / 1e18;

        depositsOf[_receiver].push(Deposit({
            amount: _amount,
            shareAmount: mintAmount,
            start: uint64(block.timestamp),
            end: uint64(block.timestamp) + uint64(duration)
        }));

        _mint(_receiver, mintAmount);
        emit Deposited(_amount, duration, _receiver, _msgSender());
    }

https://github.com/sherlock-audit/2022-10-merit-circle/blob/main/merit-liquidity-mining/contracts/TimeLockPool.sol#L85-L107

    function increaseLock(uint256 _depositId, address _receiver, uint256 _increaseAmount) external {
        // Check if actually increasing
        if (_increaseAmount == 0) {
            revert ZeroAmountError();
        }

        Deposit memory userDeposit = depositsOf[_msgSender()][_depositId];

        // Only can extend if it has not expired
        if (block.timestamp >= userDeposit.end) {
            revert DepositExpiredError();
        }

        depositToken.safeTransferFrom(_msgSender(), address(this), _increaseAmount);

        // Multiplier should be acording the remaining time to the deposit to end
        uint256 remainingDuration = uint256(userDeposit.end - block.timestamp);

        uint256 mintAmount = _increaseAmount * getMultiplier(remainingDuration) / 1e18;

        depositsOf[_receiver][_depositId].amount += _increaseAmount;
        depositsOf[_receiver][_depositId].shareAmount += mintAmount;

        _mint(_receiver, mintAmount);
        emit LockIncreased(_depositId, _receiver, _msgSender(), _increaseAmount);
    }

https://github.com/sherlock-audit/2022-10-merit-circle/blob/main/merit-liquidity-mining/contracts/TimeLockPool.sol#L197-L222

    function withdraw(uint256 _depositId, address _receiver) external {
        if (_depositId >= depositsOf[_msgSender()].length) {
            revert NonExistingDepositError();
        }
        Deposit memory userDeposit = depositsOf[_msgSender()][_depositId];
        if (block.timestamp < userDeposit.end) {
            revert TooSoonError();
        }

        // remove Deposit
        depositsOf[_msgSender()][_depositId] = depositsOf[_msgSender()][depositsOf[_msgSender()].length - 1];
        depositsOf[_msgSender()].pop();

        // burn pool shares
        _burn(_msgSender(), userDeposit.shareAmount);
        
        // return tokens
        depositToken.safeTransfer(_receiver, userDeposit.amount);
        emit Withdrawn(_depositId, _receiver, _msgSender(), userDeposit.amount);
    }

https://github.com/sherlock-audit/2022-10-merit-circle/blob/main/merit-liquidity-mining/contracts/TimeLockPool.sol#L116-L135

Tool used

Manual Review

Recommendation

Add check if _receiver == address(0)

carlitox477 - TimeLockPool#deposit allow burning shares

carlitox477

medium

TimeLockPool#deposit allow burning shares

Summary

This can be done by setting _receiver=address(0) leading to lost of users deposit token

Vulnerability Detail

Calling deposit with _receiver=address(0) will allow msg.sender to send their deposit token to address zero.

Impact

Allows users to burn their rewards

Code Snippet

Tool used

Manual Review

Recommendation

Add next require statement at the start of function deposit: require(_receiver != address(0))

apajaresaguilera - Prevent reentrancy

apajaresaguilera

low

Prevent reentrancy

Summary

A reentrancy attack can be performed if the reward token is updated to be an ERC777 token.

Impact

Low

Vulnerability Detail

A lack of reentrancy preventions allow users to reenter relevant protocol functions, such as claimRewards() in BasePool.sol, or withdraw() in TimeLockPool.sol .
If the reward token is updated to be an ERC777 token in the future (which has support for hooks), a malicious party could re-enter the function that performed the token transfer by calling the function transferring the tokens again in the ERC777 hook.
Especially in claimRewards(), where users obtain the rewards corresponding to their staking amount, the transfer could call a callback triggering an attacker's malicious contract function where claimRewards() could be called again.

Code Snippet

function claimRewards(address _receiver) external {
        uint256 rewardAmount = _prepareCollect(_msgSender());
        uint256 escrowedRewardAmount = rewardAmount * escrowPortion / 1e18;
        uint256 nonEscrowedRewardAmount = rewardAmount - escrowedRewardAmount;

        if(escrowedRewardAmount != 0 && address(escrowPool) != address(0)) {
            escrowPool.deposit(escrowedRewardAmount, escrowDuration, _receiver);
        }
        // ignore dust
        if(nonEscrowedRewardAmount > 1) {
            rewardToken.safeTransfer(_receiver, nonEscrowedRewardAmount);
        }
        emit RewardsClaimed(_msgSender(), _receiver, escrowedRewardAmount, nonEscrowedRewardAmount);
    }

Tool used

Manual Review

Recommendation

Add reentrancy guards to functions transferring reward tokens back from the protocol (claimRewards() in BasePool.sol, or withdraw() in TimeLockPool.sol) (see ReentrancyGuard.sol)

Lambda - increaseLock can be used to circumvent MIN_LOCK_DURATION

Lambda

medium

increaseLock can be used to circumvent MIN_LOCK_DURATION

Summary

The increaseLock function can be used to circumvent the minimum lock duration that is normally enforced.

Vulnerability Detail

Normally, all locks need to have a duration that is greater than MIN_LOCK_DURATION. However, a user can call increaseLock just before the end time to essentially have locks with arbitrary small times. On the first deposit (where the duration is enforced), he could only deposit 1 wei and then increase it by 10^20 wei just before the end.

Impact

This circumvents a security measure of the system and allows attackers to gain a significant share with very short-term loans, which should not be possible. For instance, with a loan of just 12 seconds (1 block), someone could mint the majority of the tokens for him.

Code Snippet

https://github.com/sherlock-audit/2022-10-merit-circle/blob/main/merit-liquidity-mining/contracts/TimeLockPool.sol#L203

Tool used

Manual Review

Recommendation

Also enforce MIN_LOCK_DURATION when increasing a lock.

Duplicate of #67

gatsbyjr - Unlimited minting and burning is possible through TestBasePool.sol

gatsbyjr

high

Unlimited minting and burning is possible through TestBasePool.sol

Summary

External Contracts can directly call BasePool.mint() through TestBasePool.sol.

Vulnerability Detail

TestBasePool.sol#L40-46

function mint(address _receiver, uint256 _amount) external { 
        _mint(_receiver, _amount);
    }

function burn(address _from, uint256 _amount) external { 
        _burn(_from, _amount);
    }

TestBasePool inherits BasePool.sol without an auth check, allowing an external contract to directly call _mint and _burn without having to deposit any tokens.

Impact

A malicious user can arbitrarily mint shares as well as burn users' shares causing economic loss

Code Snippet

https://github.com/Merit-Circle/merit-liquidity-mining/blob/ce5feaae19126079d309ac8dd9a81372648437f1/contracts/test/TestBasePool.sol#L40-L46

Tool used

Manual Review

Recommendation

Add an onlyOwner modifier.

Bnke0x0 - Low level call returns true if the address doesn't exist

Bnke0x0

medium

Low level call returns true if the address doesn't exist

Summary

As written in the solidity documentation, the low-level functions call, delegatecall and staticcall return true as their first return value if the account called is non-existent, as part of the design of the EVM. Account existence must be checked prior to calling if needed.

Vulnerability Detail

Impact

The low-level function delegatecall are used in some places in the code and it can be problematic.

Code Snippet

https://github.com/sherlock-audit/2022-10-merit-circle/blob/main/merit-liquidity-mining/contracts/base/BoringBatchable.sol#L33-L38

  '   function batch(bytes[] calldata calls, bool revertOnFail) external payable {
    for (uint256 i = 0; i < calls.length; i++) {
        (bool success, bytes memory result) = address(this).delegatecall(calls[i]);
        if (!success && revertOnFail) {
            revert(_getRevertMsg(result));
        }'

Tool used

Manual Review

Recommendation

Check before any low-level call that the address actually exists, for example before the low-level call in the callERC20 function you can check that the address is a contract by checking its code size.

ctf_sec - A large amount of reward will be stucked in the TimeLockPool.sol if _escrowPool is address(0) and not set up.

ctf_sec

high

A large amount of reward will be stucked in the TimeLockPool.sol if _escrowPool is address(0) and not set up.

Summary

A large amount of reward can be stucked in the TimeLockPool.sol if _escrowPool is not set up.

Vulnerability Detail

When claiming reward, the code deposit a portion of the reward to _escrowPool, which is another TimeLockPool

     uint256 rewardAmount = _prepareCollect(_msgSender());
     uint256 escrowedRewardAmount = rewardAmount * escrowPortion / 1e18;
     uint256 nonEscrowedRewardAmount = rewardAmount - escrowedRewardAmount;

    if(escrowedRewardAmount != 0 && address(escrowPool) != address(0)) {
        escrowPool.deposit(escrowedRewardAmount, escrowDuration, _receiver);
    }

    // ignore dust
    if(nonEscrowedRewardAmount > 1) {
        rewardToken.safeTransfer(_receiver, nonEscrowedRewardAmount);
    }

However, if the escrowPool is not set up, the escrowedRewardAmount will be stucked in the contract and not claimable.

Impact

if address(escrowPool) is address(0), the code below will never run

    if(escrowedRewardAmount != 0 && address(escrowPool) != address(0)) {
        escrowPool.deposit(escrowedRewardAmount, escrowDuration, _receiver);
    }

then the reward amount "escrowedRewardAmount" will be stucked

Code Snippet

Tool used

https://github.com/sherlock-audit/2022-10-merit-circle/blob/main/merit-liquidity-mining/contracts/base/BasePool.sol#L100-L116

Manual Review

Recommendation

We recommend refractor the code to make sure if the _escrowPool is not set, transfer all the reward to receiver.

    function claimRewards(address _receiver) external {
        uint256 rewardAmount = _prepareCollect(_msgSender());
        uint256 escrowedRewardAmount = rewardAmount * escrowPortion / 1e18;
        uint256 nonEscrowedRewardAmount = rewardAmount - escrowedRewardAmount;

        if(escrowedRewardAmount != 0 && address(escrowPool) != address(0)) {
            escrowPool.deposit(escrowedRewardAmount, escrowDuration, _receiver);
        } else { 
            nonEscrowedRewardAmount += escrowedRewardAmount; // here.
       }

        // ignore dust
        if(nonEscrowedRewardAmount > 1) {
            rewardToken.safeTransfer(_receiver, nonEscrowedRewardAmount);
        }

        emit RewardsClaimed(_msgSender(), _receiver, escrowedRewardAmount, nonEscrowedRewardAmount);
    }

Duplicate of #107

carlitox477 - BasePool#claimRewards allows users to burn rewards

carlitox477

medium

BasePool#claimRewards allows users to burn rewards

Summary

This can be done by setting _receiver=address(0) leading to lost of users rewards

Vulnerability Detail

Calling claimRewards(address(0)) will allow msg.sender to burn their rewards.

Impact

Allows users to burn their rewards

Code Snippet

Tool used

Manual Review

Recommendation

Add next require statement at the start of function claimRewards: require(_receiver != address(0))

Bnke0x0 - Collect modules can fail on zero amount transfers if depositToken and rewardToken fee is set to zero

Bnke0x0

medium

Collect modules can fail on zero amount transfers if depositToken and rewardToken fee is set to zero

Summary

Some ERC20 tokens revert on zero value transfers:

https://github.com/d-xo/weird-erc20#revert-on-zero-value-transfers

Vulnerability Detail

Impact

A user that mistakenly calls either create() or addToken() with WETH (or another ERC20) as the input token, but includes native ETH with the function call will have his native ETH permanently locked in the contract.

Code Snippet

https://github.com/sherlock-audit/2022-10-merit-circle/blob/main/merit-liquidity-mining/contracts/TimeLockPool.sol#L94

   'depositToken.safeTransferFrom(_msgSender(), address(this), _amount);'

https://github.com/sherlock-audit/2022-10-merit-circle/blob/main/merit-liquidity-mining/contracts/TimeLockPool.sol#L210

   'depositToken.safeTransferFrom(_msgSender(), address(this), _increaseAmount);'

https://github.com/sherlock-audit/2022-10-merit-circle/blob/main/merit-liquidity-mining/contracts/base/BasePool.sol#L96

   'rewardToken.safeTransferFrom(_msgSender(), address(this), _amount);'

Tool used

Manual Review

Recommendation

Consider checking the depositToken and rewardToken fee amount and do transfer only when it is positive.

Zarf - Ability to enjoy max benefits while only being locked for the minimum duration

Zarf

high

Ability to enjoy max benefits while only being locked for the minimum duration

Summary

The increaseLock() function of the TimeLockPool is using the wrong deposit to verify whether the deposit has been ended and to calculate the remaining duration.

Vulnerability Detail

The increaseLock() function of the TimeLockPool contract verifies the remaining duration and whether the deposit has been expired on the depositId linked to the msg.sender. While on the other hand, the amount of deposit tokens and share amount is increased on the depositId linked to the receiver.

Impact

An attacker could exploit the vulnerability as follows:

  1. Create a new lock A with a very small amount and maxLockDuration from account A
  2. Create a new lock B (or use an existing one) with an arbitrary amount and MIN_LOCK_DURATION (10 minutes) from account B.
  3. Ensure the depositId of both locks are the same. This could be done by creating/deleting arbitrary locks until the amount of deposit’s of account A and B are the same.
  4. Call the increaseLock function from the account linked to lock A with a large _increaseAmount and address of account B as _receiver
  5. Lock B (with the small lock duration) will be increased with the _increaseAmount and a bonus of shares multiplied by 5 (due to the maxLockDuration of lock A).
  6. The attacker benefits from the 500% multiplier benefits while only having to lock their assets for a minimum of 10 minutes.

Code Snippet

https://github.com/sherlock-audit/2022-10-merit-circle/blob/main/merit-liquidity-mining/contracts/TimeLockPool.sol#L203

Tool used

Manual Review

Recommendation

Assign the userDeposit to the _depositId linked to the _receiver instead of _msgSender() as follows:

Deposit memory userDeposit = depositsOf[_receiver][_depositId];

Duplicate of #102

Chom - if (nonEscrowedRewardAmount > 1) is not correct

Chom

medium

if (nonEscrowedRewardAmount > 1) is not correct

Summary

if (nonEscrowedRewardAmount > 1) is not correct

Vulnerability Detail

        // ignore dust
        if(nonEscrowedRewardAmount > 1) {
            rewardToken.safeTransfer(_receiver, nonEscrowedRewardAmount);
        }

rewardToken must be transferred if nonEscrowedRewardAmount > 0 but currently transferred if nonEscrowedRewardAmount > 1.

Impact

If nonEscrowedRewardAmount == 1, rewardToken won't transfer on reward claiming

Code Snippet

https://github.com/sherlock-audit/2022-10-merit-circle/blob/main/merit-liquidity-mining/contracts/base/BasePool.sol#L109-L112

Tool used

Manual Review

Recommendation

if (nonEscrowedRewardAmount > 0)

        // ignore dust
        if(nonEscrowedRewardAmount > 0) {
            rewardToken.safeTransfer(_receiver, nonEscrowedRewardAmount);
        }

defsec - Persisted msg.value in a loop of delegate calls can be used to drain ETH from your proxy

defsec

high

Persisted msg.value in a loop of delegate calls can be used to drain ETH from your proxy

Summary

msg.value in a loop can be used to drain proxy funds.

Vulnerability Detail

While BoringBatchable is out of the scope, this bug affects seriously BasePool.sol#L17 as it inherits.

This vulnerability comes from the fact that msg.value and msg.sender are persisted in delegatecall.

It is possible to call execute() (which is payable ) from batch() (which is also payable ) because both are public functions. (For now ignore the fact that execute() has access control).

The attacker would call batch() sending, for example, 1 ETH with an array of 100 equal items that call execute()

This execute() will call and external contract 100 times and in every time it will send 1ETH from proxy funds (not from the attacker).

If the receiving contract stores these value then the proxy wallet will be drained.

Impact

msg.value in a loop can be used to drain proxy funds.

Code Snippet

While this is already a high risk and there should be many attacking scenarios I would like to show you a pretty simple one.

Suppose the owner would like to grant access to a target with a normal function (maybe no even payable).

For example suppose that the owner grant access to the function

function goodFunction() public

This function has the selector 0x0d092393 . However, for some reason. the owner mistyped the selector and grant access to non existing function 0x0d09392.

Then if the target contract has the so common function.

fallback() external payable { }

Then the attacker can drain wallet funds using this selector as I explained above.

Tool used

Manual Review

Recommendation

Remove payable from batch() in BoringBatchable.

carlitox477 - AbstractRewards#_correctPoints can lead to wrong calculation due to unsafe casting

carlitox477

medium

AbstractRewards#_correctPoints can lead to wrong calculation due to unsafe casting

Summary

Unsafe casting of state variable pointsPerShare if pointsPerShare > 2^255

Vulnerability Detail

Key dependant functions affected:

Impact

if pointsPerShare > 2^255 then pointsCorrection[_account] will not be updated.

Code Snippet

Tool used

Manual Review

Recommendation

Mitigation steps:
Replace this line for pointsCorrection[_account] = pointsCorrection[_account] + (_shares * (pointsPerShare.toInt256())); using the already imported Open Zeppelin library SafeCast

Duplicate of #59

carlitox477 - BasePool#__BasePool_init does not check some parameters

carlitox477

medium

BasePool#__BasePool_init does not check some parameters

Summary

BasePool#__BasePool_init does not check _rewardToken and _escrowPool to be different from zero address.

Vulnerability Detail

The code allows setting _rewardToken and _escrowPool to address zero without reverting, which might conduct to a wrong deployment, without way of changing both state variables value.

Impact

Setting the value of _rewardToken and _escrowPool to zero address would need to redeploy the contract

Code Snippet

Tool used

Manual Review

Recommendation

Add next two require statements to __BasePool_init function:

require(_rewardToken!=address(0));
require(_escrowPool!=address(0));

rvierdiiev - Protocol doesn't work proper with fee-on-transfer tokens

rvierdiiev

medium

Protocol doesn't work proper with fee-on-transfer tokens

Summary

When fee-on-transfer tokens will be used for staking then protocol will lost some funds.

Vulnerability Detail

Some ERC20 tokens may take fee when transfer assets.
This is how deposit function works.

function deposit(uint256 _amount, uint256 _duration, address _receiver) external override {
        if (_amount == 0) {
            revert ZeroAmountError();
        }
        // Don't allow locking > maxLockDuration
        uint256 duration = _duration.min(maxLockDuration);
        // Enforce min lockup duration to prevent flash loan or MEV transaction ordering
        duration = duration.max(MIN_LOCK_DURATION);

        depositToken.safeTransferFrom(_msgSender(), address(this), _amount);

        uint256 mintAmount = _amount * getMultiplier(duration) / 1e18;

        depositsOf[_receiver].push(Deposit({
            amount: _amount,
            shareAmount: mintAmount,
            start: uint64(block.timestamp),
            end: uint64(block.timestamp) + uint64(duration)
        }));

        _mint(_receiver, mintAmount);
        emit Deposited(_amount, duration, _receiver, _msgSender());
    }

After token transfering it doesn't check how the balance increased. Provided by user _amount param is saved to the Deposit struct. Also share amount that is minted depends on _amount param.

The same is for increaseLock function.
When user withdraw then saved amount is transferred back.

Impact

Protocol calculates more deposited tokens for the depositor. When people will start withdrawing their staked funds some of them can be left without some part. Also protocol mints a bigger share for the depositor.

Code Snippet

Provided above.

Tool used

Manual Review

Recommendation

Check balance before and after sending tokens to the pool. Use the difference as provided amount.

ctf_sec - Give _escrowPool max allowance is risky in BasePool.sol#__BasePool_init

ctf_sec

medium

Give _escrowPool max allowance is risky in BasePool.sol#__BasePool_init

Summary

Give _escrowPool max allowance is risky in BasePool.sol#__BasePool_init

Vulnerability Detail

Give _escrowPool max allowance is risky in BasePool.sol#__BasePool_init

    if(_rewardToken != address(0) && _escrowPool != address(0)) {
        IERC20(_rewardToken).safeApprove(_escrowPool, type(uint256).max);
    }

Given that _escrowPool is another upgradeable TimeLockPool, if the underlying smart contract is compromised, the malicious contract can drain the fund from the original TimeLockPool.

Source: https://kalis.me/unlimited-erc20-allowances/

Why are unlimited ERC20 allowances harmful?
When depositing a specific amount (say 100 DAI) into a contract, you can choose to set an allowance of exactly that amount. But instead, many apps instead request an unlimited allowance from the user.

This offers a superior user experience because the user does not need to approve a new allowance every time they want to deposit tokens. By setting up an unlimited allowance, the user just needs to approve it once, and not repeat the process for subsequent deposits.

However, this setup comes with significant drawbacks. As we know, bugs can exist and exploits can happen even in established projects. And by giving these platforms an unlimited allowance, you do not only expose your deposited funds to these risks, but also the tokens that you're holding "safely" in your wallet.

Impact

if the underlying smart contract is compromised, the malicious contract can drain the fund from the original TimeLockPool.

Recently, a lot user suffers from TRANSIT SWAP hack because the TRANSIT SWAP contract is compromised and any user that does not revoke approval lose their fund

Source: https://rekt.news/transit-swap-rekt/

Code Snippet

https://github.com/sherlock-audit/2022-10-merit-circle/blob/main/merit-liquidity-mining/contracts/base/BasePool.sol#L74-L77

https://github.com/sherlock-audit/2022-10-merit-circle/blob/main/merit-liquidity-mining/contracts/base/BasePool.sol#L104-L108

Tool used

Manual Review

Recommendation

We recommend the project only give a least of allowance needed for the underlying contract to do the job

    function claimRewards(address _receiver) external {
        uint256 rewardAmount = _prepareCollect(_msgSender());
        uint256 escrowedRewardAmount = rewardAmount * escrowPortion / 1e18;
        uint256 nonEscrowedRewardAmount = rewardAmount - escrowedRewardAmount;

        if(escrowedRewardAmount != 0 && address(escrowPool) != address(0)) {
            IERC20(_rewardToken).safeApprove(_escrowPool, escrowedRewardAmount); // allowance is given here
            escrowPool.deposit(escrowedRewardAmount, escrowDuration, _receiver);
        }

Chom - cumulativeRewardsOf logic is incorrect

Chom

high

cumulativeRewardsOf logic is incorrect

Summary

cumulativeRewardsOf logic is incorrect

Vulnerability Detail

 function cumulativeRewardsOf(address _account) public view override returns (uint256) { 
   return ((pointsPerShare * getSharesOf(_account)).toInt256() + pointsCorrection[_account]).toUint256() / POINTS_MULTIPLIER; 
 } 

pointsPerShare is first multiplied with getSharesOf(_account) then add it with pointsCorrection[_account]

But pointsCorrection[_account] is a correction of getSharesOf(_account), it should be added first to get net share to calculate the reward.

Impact

cumulativeRewardsOf is completely wrong. Causing the reward system to be broken. Fund loss is happening anytime.

Code Snippet

https://github.com/sherlock-audit/2022-10-merit-circle/blob/main/merit-liquidity-mining/contracts/base/AbstractRewards.sol#L73-L75

Tool used

Manual Review

Recommendation

  1. getSharesOf(_account).toInt256() + pointsCorrection[_account] first
  2. Multiply with pointsPerShare
  3. Divide with POINTS_MULTIPLIER
 function cumulativeRewardsOf(address _account) public view override returns (uint256) { 
   return (pointsPerShare * (getSharesOf(_account).toInt256() + pointsCorrection[_account])).toUint256() / POINTS_MULTIPLIER; 
 } 

csanuragjain - User loss previous amount

csanuragjain

high

User loss previous amount

Summary

On extending the lock, user will lose all the amount which he has gathered before calling the extendLock function. This is happening due to an incorrect logic

Vulnerability Detail

  1. User has deposited amount 200 for duration 20 seconds for which mint amount was 100
  2. Post 10 seconds user decides to use extendLock function to extend by further 5 seconds
  3. This makes the duration as below
uint256 duration = maxLockDuration.min(uint256(userDeposit.end - block.timestamp) + increaseDuration);
// duration = maxLockDuration.min(20-10)+5)=15

uint256 mintAmount = userDeposit.amount * getMultiplier(duration) / 1e18;
// 200*getMultiplier(15)/1e18 = 60

depositsOf[_msgSender()][_depositId].shareAmount =  mintAmount;
//depositsOf[_msgSender()][_depositId].shareAmount =  60 
  1. So new mint Amount came become 60 and old was 100. But this mint Amount was calculated for last 15 seconds which does not care of mint Amount from initial 10 seconds. The mint Amount of 100-60=40 which user for for the past 10 seconds should be withdrawn to user account

Impact

User will lose funds

Code Snippet

https://github.com/sherlock-audit/2022-10-merit-circle/blob/main/merit-liquidity-mining/contracts/TimeLockPool.sol#L165
https://github.com/sherlock-audit/2022-10-merit-circle/blob/main/merit-liquidity-mining/contracts/TimeLockPool.sol#L148

Tool used

Manual Review

Recommendation

Revise the calculation as shown (or simply withdraw the difference mint amount):

uint256 duration = maxLockDuration.min(increaseDuration);
...
if (mintAmount > userDeposit.shareAmount) {
            depositsOf[_msgSender()][_depositId].shareAmount+ =  mintAmount;
            _mint(_msgSender(), mintAmount);
        }
...

defsec - Front-runnable Initializers

defsec

medium

Front-runnable Initializers

Summary

All contract initializers were missing access controls, allowing any user to initialize the contract. By front-running the contract deployers to initialize the contract, the incorrect parameters may be supplied, leaving the contract needing to be redeployed.

Vulnerability Detail

All contract initializers were missing access controls, allowing any user to initialize the contract. By front-running the contract deployers to initialize the contract, the incorrect parameters may be supplied, leaving the contract needing to be redeployed.

Impact

Front-running the contracts

Code Snippet

https://github.com/Merit-Circle/merit-liquidity-mining/blob/ce5feaae19126079d309ac8dd9a81372648437f1/contracts/TimeLockNonTransferablePool.sol#L18

Tool used

Code Review

Manual Review

Recommendation

While the code that can be run in contract constructors is limited, setting the owner in the contract's constructor to the msg.sender and adding the onlyOwner modifier to all initializers would be a sufficient level of access control.

ali_shehab - Current implementation does not support DOESN’T SUPPORT FEE ON TRANSFER TOKENS

ali_shehab

medium

Current implementation does not support DOESN’T SUPPORT FEE ON TRANSFER TOKENS

Summary

Some ERC20 tokens are implemented so a fee is taken when transferring them, for example STA and PAXG. The current implementation of the deposit and withdraw will mess up the accounting of the deposited amounts if the token will be a token like that, it will lead to a state where users won’t be able to receive their funds.

Vulnerability Detail

The amount is directly added to the struct deposit. However, if for example, the STA token burns 1% of the value provided to the transfer function, so this is what will happen.

  1. UserA will call deposit with the amount of 100.
  2. The contract will receive 99, but the amount added to the struct will be 100.
  3. When the user now calls the withdraw functions, it will go again to the struct Deposit and transfer the amount.
  4. This might succeed due to other users locking too, so the transferred tokens will be taken from “their tokens”, but in the end there will be users left without an option to withdraw their funds, because the balance of the contract will be less than the locked amount that the contract is trying to transfer.

Impact

Code Snippet

https://github.com/sherlock-audit/2022-10-merit-circle/blob/main/merit-liquidity-mining/contracts/TimeLockPool.sol#L99

function deposit(uint256 _amount, uint256 _duration, address _receiver) external override {
        if (_amount == 0) {
            revert ZeroAmountError();
        }
        // Don't allow locking > maxLockDuration
        uint256 duration = _duration.min(maxLockDuration);
        // Enforce min lockup duration to prevent flash loan or MEV transaction ordering
        duration = duration.max(MIN_LOCK_DURATION);

        depositToken.safeTransferFrom(_msgSender(), address(this), _amount);

        uint256 mintAmount = _amount * getMultiplier(duration) / 1e18;

        depositsOf[_receiver].push(Deposit({
            amount: _amount,
            shareAmount: mintAmount,
            start: uint64(block.timestamp),
            end: uint64(block.timestamp) + uint64(duration)
        }));

        _mint(_receiver, mintAmount);
        emit Deposited(_amount, duration, _receiver, _msgSender());
    }

https://github.com/sherlock-audit/2022-10-merit-circle/blob/main/merit-liquidity-mining/contracts/TimeLockPool.sol#L133

function withdraw(uint256 _depositId, address _receiver) external {
        if (_depositId >= depositsOf[_msgSender()].length) {
            revert NonExistingDepositError();
        }
        Deposit memory userDeposit = depositsOf[_msgSender()][_depositId];
        if (block.timestamp < userDeposit.end) {
            revert TooSoonError();
        }

        // remove Deposit
        depositsOf[_msgSender()][_depositId] = depositsOf[_msgSender()][depositsOf[_msgSender()].length - 1];
        depositsOf[_msgSender()].pop();

        // burn pool shares
        _burn(_msgSender(), userDeposit.shareAmount);
        
        // return tokens
        depositToken.safeTransfer(_receiver, userDeposit.amount);
        emit Withdrawn(_depositId, _receiver, _msgSender(), userDeposit.amount);
    }

Tool used

Manual Review

Recommendation

Calculate the amount to add to the locked amount by the difference between the balances before and after the transfer instead of using the supplied value.

defsec - Incompatibility With Rebasing/Deflationary/Inflationary tokens

defsec

medium

Incompatibility With Rebasing/Deflationary/Inflationary tokens

Summary

The Merit Circle protocol do not appear to support rebasing/deflationary/inflationary tokens whose balance changes during transfers or over time. The necessary checks include at least verifying the amount of tokens transferred to contracts before and after the actual transfer to infer any fees/interest.

Vulnerability Detail

The Merit Circle protocol do not appear to support rebasing/deflationary/inflationary tokens whose balance changes during transfers or over time. The necessary checks include at least verifying the amount of tokens transferred to contracts before and after the actual transfer to infer any fees/interest.

Suppose 100 USDT is transferred via safeTransferFrom() to the TimeLockPool contract.
And a fee is applied (currently 0, but might be changed in the future).
Then you might receive 99.99 USDT
Now you try to do _mint(_receiver, mintAmount); ( 100 USDT ), minting amount will be less than excepted.

Impact

The internal accounting system of the liquidity would be inaccurate or break, affecting the protocol operation.

Code Snippet

https://github.com/sherlock-audit/2022-10-merit-circle/blob/main/merit-liquidity-mining/contracts/TimeLockPool.sol#L94

    function deposit(uint256 _amount, uint256 _duration, address _receiver) external override {
        if (_amount == 0) {
            revert ZeroAmountError();
        }
        // Don't allow locking > maxLockDuration
        uint256 duration = _duration.min(maxLockDuration);
        // Enforce min lockup duration to prevent flash loan or MEV transaction ordering
        duration = duration.max(MIN_LOCK_DURATION);

        depositToken.safeTransferFrom(_msgSender(), address(this), _amount);

        uint256 mintAmount = _amount * getMultiplier(duration) / 1e18;

        depositsOf[_receiver].push(Deposit({
            amount: _amount,
            shareAmount: mintAmount,
            start: uint64(block.timestamp),
            end: uint64(block.timestamp) + uint64(duration)
        }));

        _mint(_receiver, mintAmount);
        emit Deposited(_amount, duration, _receiver, _msgSender());
    }

Tool used

Manual Review

Recommendation

Determine the transferred amount by subtracting the before & after balance. Have a procedure to don't allow the use of rebasing/inflation/deflation underlying tokens.

JohnSmith - Unsafe cast on point correction calculations

JohnSmith

medium

Unsafe cast on point correction calculations

Summary

Unsafe cast may lead to wrong results.

Vulnerability Detail

Every time an accaunt's shares are minted or burned, there is a point correction calculation for the account, which involves uint256 pointsPerShare. To do the needed multiplication shares * pointsPerShare, we have to cast all operands to same type.
In function _correctPoints() shares represented as int256 _shares.
So we need to cast uint256 pointsPerShare to int256.
The way it is done: int256(pointsPerShare) will lead to problems when pointsPerShare become > type(int256).max

Impact

Unsafe cast will lead pointsPerShare to have some negative value in scope of this calculation, which will lead to wrong value for pointsCorrection[_account] to be stored, and as result wrong amount of rewards distributed to the account.

Code Snippet

merit-liquidity-mining/contracts/base/AbstractRewards.sol
125:   function _correctPoints(address _account, int256 _shares) internal {
126:     pointsCorrection[_account] = pointsCorrection[_account] + (_shares * (int256(pointsPerShare)));
127:   }

Tool used

Manual Review

Recommendation

Use SafeCast library to safely cast the uint256 to int256

function _correctPoints(address _account, int256 _shares) internal {
-  pointsCorrection[_account] = pointsCorrection[_account] + (_shares * (int256(pointsPerShare)));
+  pointsCorrection[_account] = pointsCorrection[_account] + (_shares * pointsPerShare.toInt256());
  }

Duplicate of #59

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.