GithubHelp home page GithubHelp logo

2022-04-jpegd-findings's Introduction

JPEG'd Contest

Unless otherwise discussed, this repo will be made public after contest completion, sponsor review, judging, and two-week issue mitigation window.


Contest findings are submitted to this repo

Typically most findings come in on the last day of the contest, so don't be alarmed at all if there's nothing here but crickets until the end of the contest.

As a sponsor, you have four critical tasks in the contest process:

  1. Handle duplicate issues.
  2. Weigh in on severity.
  3. Respond to issues.
  4. Share your mitigation of findings.

Let's walk through each of these.

High and Medium Risk Issues

Handle duplicates

Because the wardens are submitting issues without seeing each others' submissions, there will always be findings that are clear duplicates. Other findings may use different language that ultimately describes the same issue, but from different angles. Use your best judgment in identifying duplicates, and don't hesitate to reach out (in your private contest channel) to ask C4 for advice.

  1. For all issues labeled 3 (High Risk) or 2 (Medium Risk), determine the best and most thorough description of the finding among the set of duplicates. (At least a portion of the content of the most useful description will be used in the audit report.)
  2. Close the other duplicate issues and label them with duplicate
  3. Mention the primary issue # when closing the issue (using the format Duplicate of #issueNumber), so that duplicate issues get linked.

Weigh in on severity

Judges have the ultimate discretion in determining severity of issues, as well as whether/how issues are considered duplicates. However, sponsor input is a significant criteria.

For a detailed breakdown of severity criteria and how to estimate risk, please refer to the judging criteria in our documentation.

If you disagree with a finding's severity, leave the original severity label set by the warden and add the label disagree with severity, along with a comment indicating your opinion for the judges to review. It is possible for issues to be considered 0 (Non-critical).

Feel free to use the question label for anything you would like additional C4 input on.

Please don't change the severity labels; that's up to the judge's discretion.

Respond to issues

Label each High or Medium risk finding as one of these:

  • sponsor confirmed, meaning: "Yes, this is a problem and we intend to fix it."
  • sponsor disputed, meaning either: "We cannot duplicate this issue" or "We disagree that this is an issue at all."
  • sponsor acknowledged, meaning: "Yes, technically the issue is correct, but we are not going to resolve it for xyz reasons."

(Note: please don't use sponsor disputed for a finding if you think it should be considered of lower or higher severity. Instead, use the label disagree with severity and add comments to recommend a different severity level -- and include your reasoning.)

Add any necessary comments explaining your rationale for your evaluation of the issue. Note that when the repo is public, after all issues are mitigated, wardens will read these comments.

QA and Gas Reports

For contests starting on or after February 3, 2022, C4 introduced a mechanism change for low and non-critical findings, as well as gas optimizations. All warden submissions in these three categories should now be submitted as bulk listings of issues and recommendations:

  • QA reports should include all low severity and non-critical findings, along with a summary statement.
  • Gas reports should include all gas optimization recommendations, along with a summary statement.

For QA and Gas reports, we ask that you:

  • Leave a comment for the judge on any reports you consider to be particularly high quality. (These reports will be awarded on a curve.)
  • Add the sponsor disputed label to any reports that you think should be completely disregarded by the judge, i.e. the report contains no valid findings at all.

Once de-duping and labelling is complete

When you have marked all duplicates and labelled all findings, drop the C4 team a note in your private Discord backroom channel and let us know you've completed the sponsor review process. At this point, we will pass the repo over to the judge, and they'll get to work while you work on mitigation.

Share your mitigation of findings

For each non-duplicate finding which you have confirmed, you will want to mitigate the issue before the contest report is made public.

As you undertake that process, we ask that you create a pull request in your original repo for each finding, and link to the PR in the issue the PR resolves. This will allow for complete transparency in showing the work of mitigating the issues found in the contest. Do not close the issue; simply label it as resolved. If the issue in question has duplicates, please link to your PR from the issue you selected as the best and most thoroughly articulated one.

2022-04-jpegd-findings's People

Contributors

code423n4 avatar c4-staff avatar ninek9 avatar liveactionllama avatar kartoonjoy avatar

Stargazers

Carlos Ortega avatar

Watchers

LSDan avatar Ashok avatar  avatar

2022-04-jpegd-findings's Issues

When _lpToken is duplicated, reward calculation is incorrect

Lines of code

https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/farming/LPFarming.sol#L141-L154

Vulnerability details

Impact

In the LPFarming contract, a new staking pool can be added using the add() function. The staking token for the new pool is defined using the _lpToken variable. However, there is no additional checking whether the _lpToken is already used in other pools or not.

    function add(uint256 _allocPoint, IERC20 _lpToken) external onlyOwner {
        _massUpdatePools();

        uint256 lastRewardBlock = _blockNumber();
        totalAllocPoint = totalAllocPoint + _allocPoint;
        poolInfo.push(
            PoolInfo({
                lpToken: _lpToken,
                allocPoint: _allocPoint,
                lastRewardBlock: lastRewardBlock,
                accRewardPerShare: 0
            })
        );
    }

When the _lpToken is duplicated, reward calculation for that pool in the updatePool() function can be incorrect. This is because the current balance of the _lpToken in the contract is used in the calculation of the reward. Since the _lpToken is duplicated, lpSupply is counted from all pools using the same _lpToken, resulting in a higher value of lpSupply, causing the reward of that pool to be less than what it should be.

    function _updatePool(uint256 _pid) internal {
        PoolInfo storage pool = poolInfo[_pid];
        if (pool.allocPoint == 0) {
            return;
        }

        uint256 blockNumber = _blockNumber();
        //normalizing the pool's `lastRewardBlock` ensures that no rewards are distributed by staking outside of an epoch
        uint256 lastRewardBlock = _normalizeBlockNumber(pool.lastRewardBlock);
        if (blockNumber <= lastRewardBlock) {
            return;
        }
        uint256 lpSupply = pool.lpToken.balanceOf(address(this));
        if (lpSupply == 0) {
            pool.lastRewardBlock = blockNumber;
            return;
        }
        uint256 reward = ((blockNumber - lastRewardBlock) *
            epoch.rewardPerBlock *
            1e36 *
            pool.allocPoint) / totalAllocPoint;
        pool.accRewardPerShare = pool.accRewardPerShare + reward / lpSupply;
        pool.lastRewardBlock = blockNumber;
    }

Proof of Concept

https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/farming/LPFarming.sol#L141-L154
https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/farming/LPFarming.sol#L288-L311

Tools Used

None

Recommended Mitigation Steps

Make sure _lpToken is not used in other pools in the add() function

reward will be locked in the farm because of round-off error

Lines of code

https://github.com/code-423n4/2022-04-jpegd/blob/e72861a9ccb707ced9015166fbded5c97c6991b6/contracts/farming/LPFarming.sol#L194
https://github.com/code-423n4/2022-04-jpegd/blob/e72861a9ccb707ced9015166fbded5c97c6991b6/contracts/farming/LPFarming.sol#L196

Vulnerability details

Impact

A small number of rewards may be stuck in the farming pool due to the round-off error when calculating the portion of rewards for each user

Proof of Concept

The contract uses divide operator to calculate the reward portion for users, eg:
https://github.com/code-423n4/2022-04-jpegd/blob/e72861a9ccb707ced9015166fbded5c97c6991b6/contracts/farming/LPFarming.sol#L194
Even the contract tries to multiply the total reward amount to 10^36 (https://github.com/code-423n4/2022-04-jpegd/blob/e72861a9ccb707ced9015166fbded5c97c6991b6/contracts/farming/LPFarming.sol#L196), it still can't prevent round-off error. So maybe a small number of rewards can remain in the farm after epoch.endBlock. Since there is no function for the admin (or users) to withdraw the remaining, that amount will be locked in the pool forever !!!

it("a part of rewards will be locked in farm when using approximation math", async() => {
      // manual mine new block  
      await network.provider.send("evm_setAutomine", [false]);
      
      // prepare 
      await lpTokens[0].transfer(alice.address, units(1000));
      await lpTokens[0].transfer(bob.address, units(1000));
      
      await lpTokens[0].connect(alice).approve(farming.address, units(1000));
      await lpTokens[0].connect(bob).approve(farming.address, units(1000));
      await mineBlocks(1);

      // create new pool
      await farming.add(10, lpTokens[0].address);
      await mineBlocks(1);
      expect(await farming.poolLength()).to.equal(1);

      let pool = await farming.poolInfo(0);
      expect(pool.lpToken).to.equal(lpTokens[0].address);
      expect(pool.allocPoint).to.equal(10);
 
      // create new epoch ==> balance of pool will be 1000 
      let blockNumber = await ethers.provider.getBlockNumber();
      await farming.newEpoch(blockNumber + 1, blockNumber + 11, 100);

      // each user deposit 100 
      await farming.connect(alice).deposit(0, units(200));
      await farming.connect(bob).deposit(0, units(100));
      await mineBlocks(1);

      expect(await jpeg.balanceOf(farming.address)).to.equal(1000);
      await mineBlocks(13);

      // user claim reward
      await farming.connect(alice).claimAll();
      await farming.connect(bob).claimAll();
      await mineBlocks(13);

      console.log("alice's reward:", (await jpeg.balanceOf(alice.address)).toString());
      console.log("bob's reward:", (await jpeg.balanceOf(bob.address)).toString());
      console.log("reward remain:", (await jpeg.balanceOf(farming.address)).toString());

      expect((await jpeg.balanceOf(farming.address))).to.equal(BigNumber.from('1'));
    });

Tools Used

typescript

Recommended Mitigation Steps

Add a new function for the admin (or user) to claim all rewards which remained in the pool when epoch.endTime has passed

function claimRemainRewardsForOwner() external onlyOwner {
      require(
          block.number > epoch.endBlock,
          'epoch has not ended'
      );
      uint256 remain = jpeg.balanceOf(address(this));
      jpeg.safeTransfer(msg.sender, remain);
  }

When _lpToken is jpeg, reward calculation is incorrect

Lines of code

https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/farming/LPFarming.sol#L141-L154

Vulnerability details

Impact

In the LPFarming contract, a new staking pool can be added using the add() function. The staking token for the new pool is defined using the _lpToken variable. However, there is no additional checking whether the _lpToken is the same as the reward token (jpeg) or not.

    function add(uint256 _allocPoint, IERC20 _lpToken) external onlyOwner {
        _massUpdatePools();

        uint256 lastRewardBlock = _blockNumber();
        totalAllocPoint = totalAllocPoint + _allocPoint;
        poolInfo.push(
            PoolInfo({
                lpToken: _lpToken,
                allocPoint: _allocPoint,
                lastRewardBlock: lastRewardBlock,
                accRewardPerShare: 0
            })
        );
    }

When the _lpToken is the same token as jpeg, reward calculation for that pool in the updatePool() function can be incorrect. This is because the current balance of the _lpToken in the contract is used in the calculation of the reward. Since the _lpToken is the same token as the reward, the reward minted to the contract will inflate the value of lpSupply, causing the reward of that pool to be less than what it should be.

    function _updatePool(uint256 _pid) internal {
        PoolInfo storage pool = poolInfo[_pid];
        if (pool.allocPoint == 0) {
            return;
        }

        uint256 blockNumber = _blockNumber();
        //normalizing the pool's `lastRewardBlock` ensures that no rewards are distributed by staking outside of an epoch
        uint256 lastRewardBlock = _normalizeBlockNumber(pool.lastRewardBlock);
        if (blockNumber <= lastRewardBlock) {
            return;
        }
        uint256 lpSupply = pool.lpToken.balanceOf(address(this));
        if (lpSupply == 0) {
            pool.lastRewardBlock = blockNumber;
            return;
        }
        uint256 reward = ((blockNumber - lastRewardBlock) *
            epoch.rewardPerBlock *
            1e36 *
            pool.allocPoint) / totalAllocPoint;
        pool.accRewardPerShare = pool.accRewardPerShare + reward / lpSupply;
        pool.lastRewardBlock = blockNumber;
    }

Proof of Concept

https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/farming/LPFarming.sol#L141-L154
https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/farming/LPFarming.sol#L288-L311

Tools Used

None

Recommended Mitigation Steps

Add a check that _lpToken is not jpeg in the add function or mint the reward token to another contract to prevent the amount of the staked token from being mixed up with the reward token.

DAO Can Mint Unlimited PUSD by Abusing overrideFloor()

Lines of code

https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/vaults/NFTVault.sol#L275-L280

Vulnerability details

Impact

/// @notice Allows the DAO to bypass the floor oracle and override the NFT floor value

What we can see in crypto world is DAO or investors act for their best interest rather than interest of Protocol. If they are allowed to Mint Unlimited PUSD by set up fake NFT Price, they have no reason not to do it.

The consequence is the collapse of the protocol.

Proof of Concept

https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/vaults/NFTVault.sol#L275-L280

Recommended Mitigation Steps

Should Not Let DAO set up fake NFT Price.

Checks described in comment not in place

Lines of code

https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/farming/LPFarming.sol#L107

Vulnerability details

In this comment, (https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/farming/LPFarming.sol#L103) it is said that newEpoch function can only be called when there is no ongoing epoch. But the require statements in this function only checks that if the new epoch's start block parameter is the current block or a future one, if the end block parameter is a later block than the start one, and if reward per block parameter is a positive number. No checks are included regarding if an ongoing epoch exists or not.

Proof of Concept

https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/farming/LPFarming.sol#L107-114

Tools Used

Manual code review

Recommended Mitigation Steps

Add a check that makes sure newEpoch function can only be called when there is no ongoing epoch.

Gas Optimizations

Gas Optimisations

Through my review of this repo I have found 1 instance where gas usage can be significantly reduced. In Controller.sol the setStrategy method here can be improved by reverting when setting the strategy for a token to the same strategy that is already active. This can be achieved by adding the following line to the start of that function:

require(strategies[_token] != _strategy, "Strategy already active")

By reverting early you are reducing unnecessary calls and gas usage in the case that a user with STRATEGIST_ROLE accidentally sets the strategy to the same strategy that is already active.

QA Report

Lines of code

https://github.com/code-423n4/2022-04-jpegd/blob/e72861a9ccb707ced9015166fbded5c97c6991b6/contracts/vaults/FungibleAssetVaultForDAO.sol#L201

Vulnerability details

Impact

Function FungibleAssetVaultForDAO.withdraw in line 201 uses native transfer function to send ETH to msg.sender.

This is unsafe as transfer has hard coded gas budget (2300 gas) and can fail when the user is a smart contract. Especially when this contract is for DAO and ecosystem contracts as documentation.

Proof-of-concept

https://github.com/code-423n4/2022-04-jpegd/blob/e72861a9ccb707ced9015166fbded5c97c6991b6/contracts/vaults/FungibleAssetVaultForDAO.sol#L201

Tools Used

Manual code review

Recommended Mitigation Steps

All functions have a nonReentrant modifier already, so reentrancy is not an issue and transfer() can be replaced.

Using low-level call.value(amount) with the corresponding result check or using the OpenZeppelin Address.sendValue is advised
https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/utils/Address.sol#L60

`NFTVault` wont be able to bypass `onlyOwner` modifier on `transferFrom()` function

Lines of code

https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/helpers/CryptoPunksHelper.sol#L19
https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/helpers/CryptoPunksHelper.sol#L38
https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/helpers/CryptoPunksHelper.sol#L34

Vulnerability details

Impact

In CryptoPunksHelper.sol the initialize() function calls __Ownable_init(); which sets the msg.sender as the owner of the contract. Only the owner of the contract can call transferFrom() because it has the onlyOwner modifier. The comments in this file clearly state that transferFrom() is called by the NFTVault but since the NFTVault never calls the initialize() function in CryptoPunksHelper.sol it cant be the contract owner which means calls to transferFrom() from NFTVault will fail due to not being able to bypass the onlyOwner modifier.

Proof of Concept

https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/helpers/CryptoPunksHelper.sol#L19

https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/helpers/CryptoPunksHelper.sol#L38

https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/helpers/CryptoPunksHelper.sol#L34

Tools Used

Manual code review

Recommended Mitigation Steps

In order for the NFTVault to be able to bypass the onlyOwner modifier it must be the one that calls the initialize() function in CryptoPunksHelper.sol setting it as the owner.

Use of `transferFrom()` instead of `safeTransferFrom()` in `repurchase()` function

Lines of code

https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/vaults/NFTVault.sol#L899

Vulnerability details

Impact

Tokens that don’t correctly implement the latest EIP20 spec will be unusable in the protocol as they revert the transaction because of the missing return value.

Proof of Concept

https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/vaults/NFTVault.sol#L899

Tools Used

Manual code review

Recommended Mitigation Steps

It's recommended to use OpenZeppelin’s SafeERC20 versions with the safeTransferFrom() function that handles the return value check as well as non-standard-compliant tokens.

Oracle might be providing stale prices

Lines of code

https://github.com/code-423n4/2022-04-jpegd/blob/59e288c27e1ff1b47505fea2e5434a7577d85576/contracts/vaults/FungibleAssetVaultForDAO.sol#L105
https://github.com/code-423n4/2022-04-jpegd/blob/59e288c27e1ff1b47505fea2e5434a7577d85576/contracts/vaults/NFTVault.sol#L459

Vulnerability details

Impact

latestAnswer() is deprecated with the added risk that prices might be stale

Recommended Mitigation Steps

use latestRoundData() instead and make use of the extra arguments to validate

Reward could be locked in `LPFarming` because of using `balanceOf` to calculate `lpSupply`.

Lines of code

https://github.com/code-423n4/2022-04-jpegd/blob/e72861a9ccb707ced9015166fbded5c97c6991b6/contracts/farming/LPFarming.sol#L190
https://github.com/code-423n4/2022-04-jpegd/blob/e72861a9ccb707ced9015166fbded5c97c6991b6/contracts/farming/LPFarming.sol#L300

Vulnerability details

Impact

In both function _updatePool and pendingReward, lpSupply of that pool is calculated as pool.lpToken.balanceOf(address(this)).

But balance of lpToken can increase not only by calling deposit in that pool but also by deposit in another pool which has the same lpToken or simply by transfering lpToken directly to LPFarming contract.

In that case, accRewardPerShare is calculated using wrong amount of lpToken supply and its value will be lower than expected and total reward all users able to claim will decrease.

Proof-of-concept

https://github.com/code-423n4/2022-04-jpegd/blob/e72861a9ccb707ced9015166fbded5c97c6991b6/contracts/farming/LPFarming.sol#L190
https://github.com/code-423n4/2022-04-jpegd/blob/e72861a9ccb707ced9015166fbded5c97c6991b6/contracts/farming/LPFarming.sol#L300

Consider the following scenario

  • LPFarming currently has 2 pools A and B with the same lpToken X. Both have the same allocPoint = 10. Total reward is 5000 JPEG. So each pool is allocated 2500 JPEG reward.
  • Alice deposit 500 token X into A, now lpSupply of both A and B is 500.
  • Bob deposit 500 token X into B, now lpSupply of both A and B is 1000.
  • Someone by accident or malicious user transfers 5000 token X to address of LPFarming, now lpSupply of both A and B is 6000.
  • When the epoch end, Alice claims her reward. Since she is the only user in pool A, she supposed to claim all 2500 JPEG reward. But actually she only received 2500 * 500 / 6000 = 208.33 JPEG.
  • The situation is the same for Bob.

Tools Used

Manual code review

Recommended Mitigation Steps

Add 1 more variable into PoolInfo struct to keep track of lpSupply.

struct PoolInfo {
  IERC20 lpToken;
  uint256 allocPoint;
  uint256 lastRewardBlock;
  uint256 accRewardPerShare;
  uint256 lpSupply;
}

And change line 190 and 300 to

uint256 lpSupply = pool.lpSupply;

User Who Lock Up JPEG More Than Once Will Permanantly Lock Their Previous Deposited JPEG into Contract

Lines of code

https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/lock/JPEGLock.sol#L44-L62

Vulnerability details

Impact

  1. Alice Request DAO to Call setNFTTypeValueETH() and DAO Approved it
  2. Alice Call finalizePendingNFTValueETH() and External Function jpegLocker.lockFor was Called.
  3. In JPEGLock.sol, Alice Transfer _lockAmount = 1000 of JPEGS into the Contract and info was saved in storage as lockAmount.
jpeg.safeTransferFrom(_account, address(this), _lockAmount);
positions[_nftIndex] = LockPosition({
            owner: _account,
            unlockAt: block.timestamp + lockTime,
            lockAmount: _lockAmount
        });
  1. Alice Repeat Step 1 to 3 again and Transfer _lockAmount = 500 second time. It overwrite previous lockAmount = 1000 and now lockAmount = 500.
  2. Once the lock period ended, Alice Call unlock() and Expecting 1500 JPEGS will Transfer to her wallet but Only Receive 500 JPEGS.

The first 1000 JPEGS Alice Transfer will be Permanently Lock into Contract.

Proof of Concept

https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/vaults/NFTVault.sol#L360-L375
https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/lock/JPEGLock.sol#L44-L62
https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/lock/JPEGLock.sol#L68-L77

Recommended Mitigation Steps

  1. Check if the _nftIndex got any LockPosition.
  2. If Yes, should add New _lockAmount into Existing lockAmount rather than overwrite it.

External call is made before user balance is updated

Lines of code

https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/farming/LPFarming.sol#L214

Vulnerability details

Impact

In LPFarming.sol the deposit() function currently does not follow the Check Effects Interactions pattern which states that all external calls should be done last. Moreover the user.amount which is the users amount of LP tokens is updated after the external call which is a classic vulnerability and highly dangerous. The noContract modifier can also be bypassed through a contract calling the function from its constructor function so it does not serve as a true form of protection.

Proof of Concept

https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/farming/LPFarming.sol#L214

https://fravoll.github.io/solidity-patterns/checks_effects_interactions.html

Tools Used

Manual code review

Recommended Mitigation Steps

users LP token amount balance should be updated first like so:

user.amount = user.amount + _amount; 
pool.lpToken.safeTransferFrom(msg.sender, address(this), _amount);

NFTHelper Contract Allows Owner to Burn NFTs

Lines of code

https://github.com/code-423n4/2022-04-jpegd/blob/e72861a9ccb707ced9015166fbded5c97c6991b6/contracts/helpers/CryptoPunksHelper.sol#L38
https://github.com/code-423n4/2022-04-jpegd/blob/e72861a9ccb707ced9015166fbded5c97c6991b6/contracts/helpers/CryptoPunksHelper.sol#L52

Vulnerability details

Impact

In the NFT helper contract, there is no validation on that the receiver address must not be address zero. Therefore, it allows owner or an attacker who gain access to the owner address to burn NFTs forever through the functions by transferring the NFTs to address zero.

Proof of Concept

The PoC is originally conducted using foundry. However, It isn't that complicated so I rewrote it in TypeScipt as well, the team can easily proof this by including in the CryptoPunksHelper.ts.

TypeScript

// add `.only` to run only this test, not all.
it.only("allows the owner to burn nfts", async () => {
    // safeTransferFrom
    await cryptoPunks.getPunk(1);
    await cryptoPunks.transferPunk(helper.address, 1);
    await helper.safeTransferFrom(owner.address, ZERO_ADDRESS, 1);
    expect(await cryptoPunks.punkIndexToAddress(1)).to.equal(ZERO_ADDRESS);
    expect(await helper.ownerOf(1)).to.equal(ZERO_ADDRESS);

    // transferFrom
    await cryptoPunks.getPunk(2);
    await cryptoPunks.transferPunk(helper.address, 2);
    await helper.transferFrom(owner.address, ZERO_ADDRESS, 2);
    expect(await cryptoPunks.punkIndexToAddress(2)).to.equal(ZERO_ADDRESS);
    expect(await helper.ownerOf(2)).to.equal(ZERO_ADDRESS);
  });

Foundry

pragma solidity ^0.8.0;

// for test
import "ds-test/test.sol";
import "forge-std/Vm.sol";

// contracts
import "../test/CryptoPunks.sol";
import "../helpers/CryptoPunksHelper.sol";

contract CryptoPunksHelperTest is DSTest {
    Vm constant vm = Vm(HEVM_ADDRESS);
    
    address owner = address(1);
    address user = address(2);
    
    CryptoPunks private cps;
    CryptoPunksHelper private helper;

    function setUp() public {
        vm.startPrank(owner);
        cps = new CryptoPunks();
        helper = new CryptoPunksHelper();
        helper.initialize(address(cps));
        vm.stopPrank();
    }

    function testOwnerTransferToZero() public {
        //make sure address zero hold no punks
        assertEq(cps.balanceOf(address(0)), 0);

        // safeTransferFrom PoC
        vm.startPrank(owner);
        cps.getPunk(1);
        cps.transferPunk(address(helper), 1);
        helper.safeTransferFrom(owner, address(0), 1);
        assertEq(cps.punkIndexToAddress(1), address(0));
        assertEq(helper.ownerOf(1), address(0));
        assertEq(cps.balanceOf(address(0)), 1);

        // transferFrom PoC
        cps.getPunk(2);
        cps.transferPunk(address(helper), 2);
        helper.transferFrom(owner, address(0), 2);
        assertEq(cps.punkIndexToAddress(2), address(0));
        assertEq(helper.ownerOf(2), address(0));
        assertEq(cps.balanceOf(address(0)), 2);
    }
}

foundry.toml

[default]
src = "contracts"
libs = ["lib/forge-std/lib", "lib/", "node_modules"]
solc_version = "0.8.0"
optimizer = false
fuzz_runs = 100000
test = "foundryTest"

Tools Used

  • Foundry
  • Hardhat

Recommended Mitigation Steps

Even the functions are restricted for only the owner, the zero address should not be allowed as the receiver address.

QA Report

https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/tokens/JPEG.sol#L20
Consider added valid check for the to address. The to address can be of a contract or another token or user. See below test code to replicate the behavior
it("should return the correct JPEG balance", async () => {
await controller.approveStrategy(token.address, strategy.address);
await controller.setStrategy(token.address, strategy.address);
await jpeg.mint(token.address, units(500)); --- the to address can be any address.
expect(await controller.balanceOfJPEG(token.address)).to.equal(units(500));
});

https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/tokens/yVault.sol#L115
setFarmingPool is declared public but not called from within the contract. Consider makin it external. Public visiility will persist the parameters which can incurr as fees.

https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/tokens/yVault.sol#L35
The whitelistedContractMap is not necessary. It can be an address array. If contract is whitelisted, then add to array, if not, remove it. Removing the extra flag will save some space in the contract.

https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/tokens/yVault.sol#L187
Function withdrawJPEG() seems duplicate of Controller.withdrawJPEG() and is not necessay. Remove the function or make it a utility.

https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/tokens/yVault.sol#L61
Modifier noContract() is duplicated, can be abstracted out

https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/tokens/yVault.sol#L89
setContractWhitelisted is duplicated, can be abstracted out

JPEG can be withdrawn even after strategy is revoked.

Lines of code

https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/tokens/Controller.sol#L69

Vulnerability details

Impact

JPEG can be withdrawn even after the strategy is revoked. This makes the strategy completely useless. Attacker is can get hold of contract, can withdraw tokens or a human error can lead to withdrawal of tokens

Proof of Concept

See test below,
it("should allow the vault to withdraw jpeg", async () => {
await controller.approveStrategy(token.address, strategy.address);
await controller.setStrategy(token.address, strategy.address);
await controller.revokeStrategy(token.address, strategy.address);---strategy is revoked but nothing fails further down.

await jpeg.mint(strategy.address, units(500));
await yVault.setFarmingPool(owner.address);

await expect(yVault.withdrawJPEG()).to.be.revertedWith("NOT_VAULT");

await controller.setVault(token.address, yVault.address);

await yVault.withdrawJPEG();
expect(await jpeg.balanceOf(owner.address)).to.equal(units(500));

});

Tools Used

VS code
hardhat

Recommended Mitigation Steps

Add modifier to check if actin strategy is still approved.

yVault: First depositor can break minting of shares

Lines of code

https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/vaults/yVault/yVault.sol#L148-L153

Vulnerability details

Details

The attack vector and impact is the same as TOB-YEARN-003, where users may not receive shares in exchange for their deposits if the total asset amount has been manipulated through a large “donation”.

Proof of Concept

  • Attacker deposits 1 wei to mint 1 share
  • Attacker transfers exorbitant amount to the StrategyPUSDConvex contract to greatly inflate the share’s price. Note that the strategy deposits its entire balance into Convex when its deposit() function is called.
  • Subsequent depositors instead have to deposit an equivalent sum to avoid minting 0 shares. Otherwise, their deposits accrue to the attacker who holds the only share.

Insert this test into yVault.ts.

it.only("will cause 0 share issuance", async () => {
  // mint 10k + 1 wei tokens to user1
  // mint 10k tokens to owner
  let depositAmount = units(10_000);
  await token.mint(user1.address, depositAmount.add(1));
  await token.mint(owner.address, depositAmount);
  // token approval to yVault
  await token.connect(user1).approve(yVault.address, 1);
  await token.connect(owner).approve(yVault.address, depositAmount);
  
  // 1. user1 mints 1 wei = 1 share
  await yVault.connect(user1).deposit(1);
  
  // 2. do huge transfer of 10k to strategy
  // to greatly inflate share price (1 share = 10k + 1 wei)
  await token.connect(user1).transfer(strategy.address, depositAmount);
  
  // 3. owner deposits 10k
  await yVault.connect(owner).deposit(depositAmount);
  // receives 0 shares in return
  expect(await yVault.balanceOf(owner.address)).to.equal(0);

  // user1 withdraws both his and owner's deposits
  // total amt: 20k + 1 wei
  await expect(() => yVault.connect(user1).withdrawAll())
    .to.changeTokenBalance(token, user1, depositAmount.mul(2).add(1));
});

Recommended Mitigation Steps

QA Report

1) newEpoch() Can Be Called When There's Is Ongoing Epoch

Risk Level: Low

Impact

/// @notice Allows the owner to start a new epoch. Can only be called when there's no ongoing epoch

There is No Checking for _startBlock (from New Epoch) Vs epoch.endBlock (Existing Epoch) and thus is possible to call newEpoch() when there is ongoing Epoch.

Proof of Concept

https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/farming/LPFarming.sol#L103-L136

Recommended Mitigation Steps

require(_startBlock > epoch.endBlock, "Got Ongoing Epoch");

2) Some Important Functions Does Not Use nonReentrant Modifier

Risk Level: Non Critical

Impact

claim() and claimAll() Does use nonReentrant Modifier. But deposit() and withdraw() does not use nonReentrant Modifier.

Proof of Concept

https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/farming/LPFarming.sol#L214-L216
https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/farming/LPFarming.sol#L235-L237

Recommended Mitigation Steps

Add nonReentrant Modifier into deposit() and withdraw().

Deposits to Fungible Asset Vault might not work as intended with fee-on transfer tokens

Lines of code

https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/vaults/FungibleAssetVaultForDAO.sol#L141

Vulnerability details

If fee-on transfer tokens were to be used as collateral, collateral might be overvalued when credit limit is calculated. Issue is similar to cmichel's report of a previous unrelated contest. (See: code-423n4/2021-08-realitycards-findings#58)

Proof of Concept

In deposit() function of FungibleAssetVaultForDAO.sol, amount parameter is used to calculate collateralAmount then creditLimit, so if a fee-on transfer token is used as collateral, the contract would have actually less collateral then what it calculates.
(https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/vaults/FungibleAssetVaultForDAO.sol#L155)

Tools Used

Manual code review

Recommended Mitigation Steps

Ensure tokens with such features are not used, or measure the actual change of the token before and after transfer.

Gas Optimizations

Gas1:

break && into two require statements to save gas
NFTEscrow.sol l#86
JPEGStaking.sol l#45
StrategyPUSDConvex.sol L#181
yVault.sol L#99
FungibleAssetVaultForDAO.sol L#93,194
NFTVault.sol #401

Gas2:

making public to external saves gas
NFTEscrow.sol l#81
LPFarming.sol l#114

Gas3:

prefer != instead of > to save gas
LPFarming.sol l#218,239,320,337,354
yVaultLPFarming.sol L#101,118,139
JPEGLock.sol L#40
JPEGStaking.sol L#32,46
StrategyPUSDConvex.sol L#182,322,334
yVault.sol L#143,167,170
FungibleAssetVaultForDAO.sol L#108,142,164,180,194
NFTVault.sol L#278,365,401,687,764,770

Gas4:

use prefix
LPFarming.sol l#226 use user.amount += _amount; instead of user.amount = user.amount + _amount;

Gas5:

use prefix ++i instead of ++i
LPFarming.sol L#348
StrategyPUSDConvex.sol L#145,231,319 (also no need to set i=0, it's default)
NFTVault.sol L#181,184 (also no need to set i=0, it's default)

Gas6:

floating pragma, prefer to set to atleast 0.8.4

QA Report

Should use the consistent increment (orefix or postfix) in the for loop in LPFarming.sol

Target codebase

There are two for loops used in LPFarming.sol. ++pid is used at one loop while i++ is used at the other loop.

2022-04-jpegd/contracts/farming/LPFarming.sol
281: for (uint256 pid = 0; pid < length; ++pid) {
348: for (uint256 i = 0; i < poolInfo.length; i++) {

Potential workaround

At the whole codebase, postfix increment is used. So it may be logical to use the postfix increment.


Error message in require function should be consistent among the codebase

Target codebase

Error message in the require check seems not to be consistent. The below code is an example: one uses invalid_amount while the other uses INVALID_AMOUNT.

https://github.com/code-423n4/2022-04-jpegd/blob/59e288c27e1ff1b47505fea2e5434a7577d85576/contracts/staking/JPEGStaking.sol#L32

require(_amount > 0, "invalid_amount");

https://github.com/code-423n4/2022-04-jpegd/blob/59e288c27e1ff1b47505fea2e5434a7577d85576/contracts/vaults/yVault/yVault.sol#L143

require(_amount > 0, "INVALID_AMOUNT");

Potential workaround

For the consistency, it should standardize the error message among the codebase for the better code quality.


_msgSender() should be used instead of msg.sender

Target codebase

https://github.com/code-423n4/2022-04-jpegd/blob/59e288c27e1ff1b47505fea2e5434a7577d85576/contracts/tokens/JPEG.sol#L16

_mint(msg.sender, totalSupply);
_setupRole(DEFAULT_ADMIN_ROLE, _msgSender());

To be consistent, _msgSender() should be used.

Potential workaround

_mint(_msgSender(), totalSupply);
_setupRole(DEFAULT_ADMIN_ROLE, _msgSender());

The naming of _collateralUnit variable is inconsistent at FungibleAssetVaultForDAO.sol

Target codebase

https://github.com/code-423n4/2022-04-jpegd/blob/59e288c27e1ff1b47505fea2e5434a7577d85576/contracts/vaults/FungibleAssetVaultForDAO.sol#L51

uint256 private _collateralUnit;

Other variables does not have _ at its prefix, but only _collateralUnit contains _.

Potential workaround

Simply remove _ from its prefix.

uint256 private collateralUnit;

Consider adopting nonReentrant at stake function in JPEGStaking.sol

Target codebase

https://github.com/code-423n4/2022-04-jpegd/blob/59e288c27e1ff1b47505fea2e5434a7577d85576/contracts/staking/JPEGStaking.sol#L31

function stake(uint256 _amount) external {

unstake function has nonReentrant modifier but stake function does not. For the consistency, it may not harm adding nonReentrant modifier at stake function as well.

Potential workaround

function stake(uint256 _amount) external nonReentrant {

onlyOwner Can Withdraw Nearly ALL Remaining LP Reward JPEGS When Call newEpoch()

Lines of code

https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/farming/LPFarming.sol#L107-L128

Vulnerability details

Impact

if (remainingRewards > newRewards) {
jpeg.safeTransfer(msg.sender, remainingRewards - newRewards);

If current Epoch have 1,000,000 JPEGS in remainingRewards, onlyOwner() can Call newEpoch() with Minimal Block Number and Block Reward such as:
uint256 _startBlock = 100
uint256 _endBlock = 101
uint256 _rewardPerBlock = 1

Then remainingRewards (1,000,000 JPEGS) - newRewards ( 1 JPEGS) = 999,999 JPEGS Will Withdraw to onlyOwner()

Please Check a Low Risk But Important Bug at QA Report: newEpoch() Can Be Called When There's Is Ongoing Epoch

Proof of Concept

https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/farming/LPFarming.sol#L107-L128

Recommended Mitigation Steps

From community members perspective, this is a hidden function and is very bad because it is not expected when onlyOwner call newEpoch() can Withdraw LP Reward that supposely belong to them.

Suggest Add New Function Call cancelEpoch() function and so community members will not be surprise when remainingRewards transfer back to onlyOwner.

QA Report

1. Multiply before divide in calculation for better accuracy

Impact

In function NFTVault._calculateAdditionalInterest we should multiply by elapsedTime first then divide by 365 days for better accuracy.

We are working with uint so the result of operator ‘/’ is the quotient.

For example:
(5 / 2) * 4 = 2 * 4 = 8
(5 * 4) / 2 = 20 / 2 = 10

Proof-of-concept

https://github.com/code-423n4/2022-04-jpegd/blob/e72861a9ccb707ced9015166fbded5c97c6991b6/contracts/vaults/NFTVault.sol#L593-L595

Tools Used

Manual code review

Recommended Mitigation Steps

Change line 593 - 595 to

return (interestPerYear * elapsedTime) / 365 days;

Using deprecated Chainlink function `latestAnswer`

Lines of code

https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/vaults/NFTVault.sol#L459

Vulnerability details

Impact

According to Chainlink's documentation, the latestAnswer function is deprecated. This function does not error if no answer has been reached but returns 0.

Proof of Concept

NFTVault.sol#L459

It's same as a medium risk issue in other C4 contest

Tools Used

N/A

Recommended Mitigation Steps

Use the new latestRoundData function to get the price instead. Add checks on the return data with proper revert messages if the price is stale or the round is uncompleted, for example:

(uint80 roundID, int256 price, , uint256 timeStamp, uint80 answeredInRound) = oracle.latestRoundData();
require(answeredInRound >= roundID, "...");
require(timeStamp != 0, "...");

A whitelisted sender can drain free collateral from FungibleAssetVaultForDAO

Lines of code

https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/vaults/FungibleAssetVaultForDAO.sol#L193-L206

Vulnerability details

Impact

A whitelisted contract can drain all free collateral from FungibleAssetVaultForDAO. Although it is not clear from the documentation, my assumption is that contracts are whitelisted by the DAO through a voting process. It may be possible for an exploitable contract to be whitelisted, and hence allow an attacker to withdraw all of the collateral from the contract, even if the whitelisted contract hasn't contributed to any of the collective collateral.

I deem this to be of medium severity given that this would require a hypothetical scenario to occur, but would place a potentially large amount of assets at severe risk if it did occur.

Proof of Concept

If a contract address is granted the WHITELISTED_ROLE role, then it can call withdraw here. Because collateralAmount is counted for the contract as a whole vs counted for each sender, this would allow a whitelisted contract to withdraw any free collateral without having contributed anything to the collateral pool.

This can be easily demonstrated by granting the above role to a new signer and adding to the existing test suite (assuming an initial free collateral balance of units(10) and replacing [whitelisted_sender]):

balanceBefore = await [whitelisted_sender].getBalance();
await ethVault.connect([whitelisted_sender]).withdraw(units(10));
checkAlmostSame(await dao.getBalance(), balanceBefore.add(units(10)));
expect(await ethVault.collateralAmount()).to.equal(0);

Tools Used

VSCode + Hardhat

Recommended Mitigation Steps

Borrows should continue to be counted against the total collateral held by the contract. However, in addition to this global counter there should also be a mapping like:

mapping(address => uint256) collateralContributed;

This mapping should keep track of the collateral contributed to the pool by each whitelisted sender. When calling withdraw a whitelisted sender should only be able to withdraw as much collateral that they have contributed to the pool; at time of withdrawal the mapping entry should be appropriately decremented.

Use safeTransfer/safeTransferFrom consistently instead of transfer/transferFrom

Lines of code

https://github.com/code-423n4/2022-04-jpegd/blob/e72861a9ccb707ced9015166fbded5c97c6991b6/contracts/staking/JPEGStaking.sol#L34
https://github.com/code-423n4/2022-04-jpegd/blob/e72861a9ccb707ced9015166fbded5c97c6991b6/contracts/staking/JPEGStaking.sol#L52
https://github.com/code-423n4/2022-04-jpegd/blob/e72861a9ccb707ced9015166fbded5c97c6991b6/contracts/vaults/NFTVault.sol#L899

Vulnerability details

Impact

It is good to add a require() statement that checks the return value of token transfers or to use something like OpenZeppelin’s safeTransfer/safeTransferFrom unless one is sure the given token reverts in case of a failure. Failure to do so will cause silent failures of transfers and affect token accounting in contract.

While most places use a require or safeTransfer/safeTransferFrom, there are 3 missing cases. Especially, JPEG token transfers inconsistently use transferFrom in line 34 JPEGStaking.sol while use safeTransferFrom in LPFarming.sol.

Proof-of-concept

https://github.com/code-423n4/2022-04-jpegd/blob/e72861a9ccb707ced9015166fbded5c97c6991b6/contracts/staking/JPEGStaking.sol#L34
https://github.com/code-423n4/2022-04-jpegd/blob/e72861a9ccb707ced9015166fbded5c97c6991b6/contracts/staking/JPEGStaking.sol#L52
https://github.com/code-423n4/2022-04-jpegd/blob/e72861a9ccb707ced9015166fbded5c97c6991b6/contracts/vaults/NFTVault.sol#L899

Tools Used

Manual code review

Recommended Mitigation Steps

Consider using safeTransfer/safeTransferFrom or require() consistently.

DAO Can Open New FungibleAssetVaultForDAO Contract for Same Asset Rather Than Deposit More Collateral When Undercollateralization

Lines of code

https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/vaults/FungibleAssetVaultForDAO.sol#L15-L19

Vulnerability details

Impact

In FungibleAssetVaultForDAO.sol:
/// @dev The contract only supports one asset, meaning that multiple instances
/// of this contract are going to be deployed if support for multiple assets is needed.
/// The credit limit rate of the supported asset is set at deploy time.
/// This contract doesn't support liquidations. In case of undercollateralization,
/// the DAO will promptly deposit more collateral.

In StableCoin.sol:
MINTER_ROLE can Call mint() without specific restriction.

If DAO Can Open New FungibleAssetVaultForDAO Contract even for the Same Asset eg. ETH, why should DAO Deposit More Collateral When Undercollateralization? DAO can open new contract and Mint more PUSD.

When Collateral of PUSD undercollateralized, it will lose its peg and will cause PUSD collapse.

Proof of Concept

https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/vaults/FungibleAssetVaultForDAO.sol#L15-L19
https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/tokens/StableCoin.sol#L38-43

Recommended Mitigation Steps

In StableCoin.sol, should Register the Address of Fungible Asset Vault and its Collateral Address aim to prevent DAO deploy FungibleAssetVaultForDAO again for same asset.

reward will be locked in the farm if no LP join the pool at epoch.startBlock

Lines of code

https://github.com/code-423n4/2022-04-jpegd/blob/e72861a9ccb707ced9015166fbded5c97c6991b6/contracts/farming/LPFarming.sol#L214
https://github.com/code-423n4/2022-04-jpegd/blob/e72861a9ccb707ced9015166fbded5c97c6991b6/contracts/farming/LPFarming.sol#L107

Vulnerability details

Impact

a part of reward tokens will be locked in the farming pool if no user deposits lpToken at the epoch.startBlock

Proof of Concept

it("a part of reward should be locked in farm if no LP join the pool at epoch.startBlock", async() => {
      // manual mine new block  
      await network.provider.send("evm_setAutomine", [false]);

      // prepare 
      await lpTokens[0].transfer(alice.address, units(1000));
      await lpTokens[0].connect(alice).approve(farming.address, units(1000));
      await mineBlocks(1);

      // create new pool
      await farming.add(10, lpTokens[0].address);
      await mineBlocks(1);
      expect(await farming.poolLength()).to.equal(1);

      let pool = await farming.poolInfo(0);
      expect(pool.lpToken).to.equal(lpTokens[0].address);
      expect(pool.allocPoint).to.equal(10);

      // create new epoch ==> balance of pool will be 1000 
      let blockNumber = await ethers.provider.getBlockNumber();
      await farming.newEpoch(blockNumber + 1, blockNumber + 11, 100);

      // skip the epoch.startBlock  
      // it mean no one deposit lpToken to farm at this block 
      await mineBlocks(1);
      expect(await jpeg.balanceOf(farming.address)).to.equal(1000);

      // alice deposit 
      await farming.connect(alice).deposit(0, units(100));
      await mineBlocks(1);

      // skip the blocks to the end of epoch 
      await mineBlocks(13);

      await farming.connect(alice).claim(0);
      await mineBlocks(1);

      console.log("reward of alice: ", (await jpeg.balanceOf(alice.address)).toString());
      console.log("reward remain: ", await jpeg.balanceOf(farming.address));

      // 100 jpeg will be locked in the pool forevers 
      expect(await jpeg.balanceOf(alice.address)).to.equal(900);
      expect(await jpeg.balanceOf(farming.address)).to.equal(100);
    }); 

In the example above, I create an epoch from blockNumber + 1 to blockNumber + 11 with the reward for each block being 100JPEG. So, the total reward for this farm will be 1000JPEG. When I skip the epoch.startBlock and let Alice deposit 100 lpToken at the block right after, at the end of the farm (epoch.endBlock), the total reward of Alice is just 900JPEG, and 100JPEG still remains in the farming pool. Since there is no function for the admin (or users) to withdraw the remaining, 100JPEG will be stucked in the pool forever !!!

Tools Used

typescript

Recommended Mitigation Steps

Add a new function for the admin (or user) to claim all rewards which remained in the pool when epoch.endTime has passed

function claimRemainRewardsForOwner() external onlyOwner {
        require(
            block.number > epoch.endBlock, 
            'epoch has not ended'
        );
        uint256 remain = jpeg.balanceOf(address(this));
        jpeg.safeTransfer(msg.sender, remain);
    }

rewards was locked in the farming pool due to the round-off error

Lines of code

https://github.com/code-423n4/2022-04-jpegd/blob/e72861a9ccb707ced9015166fbded5c97c6991b6/contracts/farming/LPFarming.sol#L194
https://github.com/code-423n4/2022-04-jpegd/blob/e72861a9ccb707ced9015166fbded5c97c6991b6/contracts/farming/LPFarming.sol#L196

Vulnerability details

Impact

A small amount of rewards maybe stucked in the farming pool due to the round-off error when calculate the portion of rewards for each user

Proof of Concept

Contract uses divide operator to calculate the reward portion for users, eg:
https://github.com/code-423n4/2022-04-jpegd/blob/e72861a9ccb707ced9015166fbded5c97c6991b6/contracts/farming/LPFarming.sol#L194
Even the contract try to multiply the total reward amount to 10^36 (https://github.com/code-423n4/2022-04-jpegd/blob/e72861a9ccb707ced9015166fbded5c97c6991b6/contracts/farming/LPFarming.sol#L196), it still can't prevent round-off error. So maybe small amount of rewards can be remained in the farm after epoch.endBlock. Since there is no function for the admin (or users) to withdraw the remaining, that amount will be locked in the pool forever !!!

it("a part of rewards will be locked in farm when using approximation math", async() => {
      // manual mine new block  
      await network.provider.send("evm_setAutomine", [false]);
      
      // prepare 
      await lpTokens[0].transfer(alice.address, units(1000));
      await lpTokens[0].transfer(bob.address, units(1000));
      
      await lpTokens[0].connect(alice).approve(farming.address, units(1000));
      await lpTokens[0].connect(bob).approve(farming.address, units(1000));
      await mineBlocks(1);

      // create new pool
      await farming.add(10, lpTokens[0].address);
      await mineBlocks(1);
      expect(await farming.poolLength()).to.equal(1);

      let pool = await farming.poolInfo(0);
      expect(pool.lpToken).to.equal(lpTokens[0].address);
      expect(pool.allocPoint).to.equal(10);
 
      // create new epoch ==> balance of pool will be 1000 
      let blockNumber = await ethers.provider.getBlockNumber();
      await farming.newEpoch(blockNumber + 1, blockNumber + 11, 100);

      // each user deposit 100 
      await farming.connect(alice).deposit(0, units(200));
      await farming.connect(bob).deposit(0, units(100));
      await mineBlocks(1);

      expect(await jpeg.balanceOf(farming.address)).to.equal(1000);
      await mineBlocks(13);

      // user claim reward
      await farming.connect(alice).claimAll();
      await farming.connect(bob).claimAll();
      await mineBlocks(13);

      console.log("alice's reward:", (await jpeg.balanceOf(alice.address)).toString());
      console.log("bob's reward:", (await jpeg.balanceOf(bob.address)).toString());
      console.log("reward remain:", (await jpeg.balanceOf(farming.address)).toString());

      expect((await jpeg.balanceOf(farming.address))).to.equal(BigNumber.from('1'));
    });

Tools Used

typescript

Recommended Mitigation Steps

Add a new function for the admin (or user) to claim all rewards which remained in the pool when epoch.endTime has passed

function claimRemainRewardsForOwner() external onlyOwner {
      require(
          block.number > epoch.endBlock,
          'epoch has not ended'
      );
      uint256 remain = jpeg.balanceOf(address(this));
      jpeg.safeTransfer(msg.sender, remain);
  }

User token balance is updated after external call

Lines of code

https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/farming/yVaultLPFarming.sol#L100

Vulnerability details

Impact

In yVaultLPFarming.sol the users token balance is updated after an external call which is a very dangerous pattern and goes against the Checks Effect Interactions safety guidelines. The noContract modifier can also be bypassed by a contract calling the deposit() function from its constructor function so it does not serve as a protection.

Proof of Concept

https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/farming/yVaultLPFarming.sol#L100

https://fravoll.github.io/solidity-patterns/checks_effects_interactions.html

Tools Used

Manual code review

Recommended Mitigation Steps

The users balanceOf mapping and totalStaked amount should be updated before any external calls.

Minter role can mint infinite/total supply of tokens,if get compromised, Similar cases included

Lines of code

https://github.com/code-423n4/2022-04-jpegd/blob/e72861a9ccb707ced9015166fbded5c97c6991b6/contracts/tokens/JPEG.sol#L20
https://github.com/code-423n4/2022-04-jpegd/blob/e72861a9ccb707ced9015166fbded5c97c6991b6/contracts/tokens/StableCoin.sol#L38
https://github.com/code-423n4/2022-04-jpegd/blob/e72861a9ccb707ced9015166fbded5c97c6991b6/contracts/vaults/yVault/strategies/StrategyPUSDConvex.sol#L290

Vulnerability details

Impact

Minter role can mint infinite/total supply of tokens,if get compromised, Similar cases included

Proof of Concept

Using the mint() function of JPEG token, an address with MINTER_ROLE can burn an arbitrary amount of tokens.
JPEG.sol L#20

    function mint(address to, uint256 amount) external {
        require(
            hasRole(MINTER_ROLE, _msgSender()),
            "JPEG: must have minter role to mint"
        );
        _mint(to, amount);
    }
}

If the private key of the deployer or an address with the MINTER_ROLE is compromised, the attacker will be able to mint an unlimited amount of LPT tokens.

We believe this is unnecessary and poses a serious centralization risk.

Consider removing the MINTER_ROLE, make the JPEG token only mintable by the owner, and make the contract to be the owner and therefore the only minter.

SIMILAR in StableCoin.sol L#39

    function mint(address to, uint256 amount) external {
        require(
            hasRole(MINTER_ROLE, _msgSender()),
            "StableCoin: must have minter role to mint"
        );
        _mint(to, amount);
    }

SIMILAR, full control of function withdrawAll() is given to controller role, which is not a good idea.
StrategyPUSDConvex.sol L#290

    function withdrawAll() external onlyController returns (uint256 balance) {
        address vault = strategyConfig.controller.vaults(address(want));
        require(vault != address(0), "ZERO_VAULT"); // additional protection so we don't burn the funds

        convexConfig.baseRewardPool.withdrawAllAndUnwrap(false);

        balance = want.balanceOf(address(this));
        want.safeTransfer(vault, balance);
    }

`MINTER_ROLE` is never set in `StableCoin.sol`

Lines of code

https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/tokens/StableCoin.sol#L25
https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/tokens/StableCoin.sol#L14
https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/tokens/StableCoin.sol#L40

Vulnerability details

Impact

In StableCoin.sol the MINTER_ROLE is supposed to be occupied by the vaults as the comments mention in the file. The problem is that the MINTER_ROLE is never set in the constructor function or any where else in the contract for that matter which means the vaults wont have the needed role in order to call the mint() function.

Proof of Concept

https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/tokens/StableCoin.sol#L25

https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/tokens/StableCoin.sol#L14

https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/tokens/StableCoin.sol#L40

Tools Used

Manual code review

Recommended Mitigation Steps

Consider setting the MINTER_ROLE in the constructor function to the appropriate vault addresses. Then they will have the ability to call the mint() function.

`NFTVault` may not be able to bypass `onlyOwner` modifier

Lines of code

https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/helpers/EtherRocksHelper.sol#L21
https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/helpers/EtherRocksHelper.sol#L42
https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/helpers/EtherRocksHelper.sol#L34

Vulnerability details

Impact

In EtherRockHelper.sol the initialize() function calls __Ownable_init(); which sets the msg.sender as the owner of the contract. At the same time the transferFrom() function in the same EtherRockHelper.sol contract is only supposed to be called by the NFTVault (as the function comments clearly state) which is only possible if the NFTVault was the one who called initialize() which currently is not the case. This means that every time the NFTVault tries to call transferFrom() it should fail because it was never set as the owner and cannot bypass the onlyOwner modifier.

Proof of Concept

https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/helpers/EtherRocksHelper.sol#L21

https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/helpers/EtherRocksHelper.sol#L42

https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/helpers/EtherRocksHelper.sol#L34

Tools Used

Manual code review

Recommended Mitigation Steps

If the NFTVault should only be allowed to call transferFrom() then the initialize() function inside EtherRockHelper.sol should be called by the NFTVault to properly set it as the owner. It will then be able to bypass the onlyOwner modifier.

Unintended additional rewards

Lines of code

https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/farming/LPFarming.sol#L294-L305
https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/farming/LPFarming.sol#L266-L276

Vulnerability details

Impact

In the LPFarming contract, if the current Epoch has ended and the new Epoch has not been set, calling the _normalizeBlockNumber function will return epoch.endBlock.

So blockNumber - lastRewardBlock will include blocks after epoch.endBlock when _updatePool is executed, and every time _updatePool is triggered, the blocks after epoch.endBlock will be re-included. In fact, blocks after epoch.endBlock should not participate in the reward calculation, which will cause pool.accRewardPerShare to increase unexpectedly.

uint256 lastRewardBlock = _normalizeBlockNumber(pool.lastRewardBlock);

uint256 reward = ((blockNumber - lastRewardBlock) *
            epoch.rewardPerBlock *
            1e36 *
            pool.allocPoint) / totalAllocPoint;

pool.accRewardPerShare = pool.accRewardPerShare + reward / lpSupply;

Proof of Concept

If the current Epoch has ended and the new Epoch has not been set, calling the _normalizeBlockNumber function will return epoch.endBlock.
https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/farming/LPFarming.sol#L296
https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/farming/LPFarming.sol#L266-L276

This will cause blockNumber - lastRewardBlock to contain blocks after epoch.endBlock
https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/farming/LPFarming.sol#L296

Tools Used

None.

Recommended Mitigation Steps

It is recommended to add the following code to the _updatePool function for mitigation.

        if (lastRewardBlock == epoch.endBlock) {
            return;
        }
    function _updatePool(uint256 _pid) internal {
        PoolInfo storage pool = poolInfo[_pid];
        if (pool.allocPoint == 0) {
            return;
        }

        uint256 blockNumber = _blockNumber();
        //normalizing the pool's `lastRewardBlock` ensures that no rewards are distributed by staking outside of an epoch
        uint256 lastRewardBlock = _normalizeBlockNumber(pool.lastRewardBlock);
        if (lastRewardBlock == epoch.endBlock) {
            return;
        }
        if (blockNumber <= lastRewardBlock) {
            return;
        }
        uint256 lpSupply = pool.lpToken.balanceOf(address(this));
        if (lpSupply == 0) {
            pool.lastRewardBlock = blockNumber;
            return;
        }
        uint256 reward = ((blockNumber - lastRewardBlock) *
            epoch.rewardPerBlock *
            1e36 *
            pool.allocPoint) / totalAllocPoint;
        pool.accRewardPerShare = pool.accRewardPerShare + reward / lpSupply;
        pool.lastRewardBlock = blockNumber;
    }

Gas Optimizations

1) Long Revert Strings are Waste of Gas

Impact

Shortening revert strings to fit in 32 bytes will decrease deployment time gas and will decrease runtime gas when the revert condition has been met.

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

Proof of Concept

https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/vaults/NFTVault.sol#L394
https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/tokens/StableCoin.sol#L41
https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/tokens/StableCoin.sol#L69

Recommended Mitigation Steps

Shorten the revert strings to fit in 32 bytes.

Or consider using Custom Errors (solc >=0.8.4).

Existing user’s locked JPEG could be overwritten by new user, causing permanent loss of JPEG funds

Lines of code

https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/vaults/NFTVault.sol#L375
https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/lock/JPEGLock.sol#L54-L62

Vulnerability details

Details & Impact

A user’s JPEG lock schedule can be overwritten by another user’s if he (the other user) submits and finalizes a proposal to change the same NFT index’s value.

The existing user will be unable to withdraw his locked JPEGs, resulting in permanent lock up of JPEG in the locker contract.

Proof of Concept

  1. user successfully proposes and finalizes a proposal to change his NFT’s collateral value
  2. Another user (owner) does the same for the same NFT index
  3. user will be unable to withdraw his locked JPEG because schedule has been overwritten

Insert this test case into NFTVault.ts.

it.only("will overwrite existing user's JPEG lock schedule", async () => {
  // 0. setup
  const index = 7000;
  await erc721.mint(user.address, index);
  await nftVault
    .connect(dao)
    .setPendingNFTValueETH(index, units(50));
  await jpeg.transfer(user.address, units(150000));
  await jpeg.connect(user).approve(locker.address, units(500000));
  await jpeg.connect(owner).approve(locker.address, units(500000));

  // 1. user has JPEG locked for finalization
  await nftVault.connect(user).finalizePendingNFTValueETH(index);

  // 2. owner submit proposal to further increase NFT value
  await nftVault
    .connect(dao)
    .setPendingNFTValueETH(index, units(100));
  
  // 3. owner finalizes, has JPEG locked
  await nftVault.connect(owner).finalizePendingNFTValueETH(index);

  // user schedule has been overwritten
  let schedule = await locker.positions(index);
  expect(schedule.owner).to.equal(owner.address);

  // user tries to unstake
  // wont be able to because schedule was overwritten
  await timeTravel(days(366));
  await expect(locker.connect(user).unlock(index)).to.be.revertedWith("unauthorized");
});

Recommended Mitigation Steps

  1. Release the tokens of the existing schedule. Simple and elegant.
// in JPEGLock#lockFor()
LockPosition memory existingPosition = positions[_nftIndex];
if (existingPosition.owner != address(0)) {
  // release jpegs to existing owner
  jpeg.safeTransfer(existingPosition.owner, existingPosition.lockAmount);
}
  1. Revert in finalizePendingNFTValueETH() there is an existing lock schedule. This is less desirable IMO, as there is a use-case for increasing / decreasing the NFT value.

Gas Optimizations

We'll need to see how it works in reality, but our current assumption is that (a) low severity findings attempted to get pushed into med/high would essentially get zero (just logically so since they wouldn't be high or med), and then (b) their QA report would be lower quality as a result, and so they wouldn't score as highly as they could have. Judges could also decide to mark off points in someone's QA report if they saw behavior that seemed like it might be trying to game for higher rewards by inflating severity, so it could have a negative consequence as well.

QA Report

Lines of code

https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/vaults/NFTVault.sol#L675
https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/vaults/NFTVault.sol#L560

Vulnerability details

Impact

In NFTVault.sol the borrow() function does not follow the Checks Effects Interactions pattern. There are important state updates that occur after an external call which happens in _openPosition(). Assuming that the nonReentrant modifier makes this ok is false due to the threat of cross function reentrancy. require checks should be done followed by state updates and then any external calls in accord with the Checks Effects Interactions pattern

Proof of Concept

https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/vaults/NFTVault.sol#L675

https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/vaults/NFTVault.sol#L560

https://fravoll.github.io/solidity-patterns/checks_effects_interactions.html

Tools Used

Manual code review

Recommended Mitigation Steps

The borrow() function should fully implement the Checks Effects Interactions pattern performing all external calls last and not making important state updates after external calls.

QA Report

Better noContract

noContract modifier is used in farming/LPFarming.sol#L85 to prevent non-whitelisted contracts to interact with the LP farm. But isContract check can be bypassed with a contract executing constructor code.

require(msg.sender == tx.origin) may be a better choice.

Intended checks regarding the prices DAO sets for an NFT not in place

Lines of code

https://github.com/code-423n4/2022-04-jpegd/blob/59e288c27e1ff1b47505fea2e5434a7577d85576/contracts/vaults/NFTVault.sol#L410
https://github.com/code-423n4/2022-04-jpegd/blob/59e288c27e1ff1b47505fea2e5434a7577d85576/contracts/vaults/NFTVault.sol#L321
https://github.com/code-423n4/2022-04-jpegd/blob/59e288c27e1ff1b47505fea2e5434a7577d85576/contracts/vaults/NFTVault.sol#L336
https://github.com/code-423n4/2022-04-jpegd/blob/59e288c27e1ff1b47505fea2e5434a7577d85576/contracts/vaults/NFTVault.sol#L277

Vulnerability details

Impact

This comment implies that if the DAO were to make changes to the price of a NFT, the DAO would set a higher price than the one oracle returns.
(https://github.com/code-423n4/2022-04-jpegd/blob/59e288c27e1ff1b47505fea2e5434a7577d85576/contracts/vaults/NFTVault.sol#L16)

As there are no checks about whether the price that DAO set is higher than the price returned by the oracle, DAO can set the value of a NFT to effectively 0 using two different ways.

Proof of Concept

  1. Attaching a category to a NFT
    The function setNFTType (https://github.com/code-423n4/2022-04-jpegd/blob/59e288c27e1ff1b47505fea2e5434a7577d85576/contracts/vaults/NFTVault.sol#L321) can attach a NFT to a price category, setNFTTypeValueETH(https://github.com/code-423n4/2022-04-jpegd/blob/59e288c27e1ff1b47505fea2e5434a7577d85576/contracts/vaults/NFTVault.sol#L336) function then can change the value of that category, without any checks.

  2. Overriding floor
    The function overrideFloor(https://github.com/code-423n4/2022-04-jpegd/blob/59e288c27e1ff1b47505fea2e5434a7577d85576/contracts/vaults/NFTVault.sol#L277) can assign a new floor value to a NFT, overriding the oracle’s price. But this function also has no checks regarding if the new price that overrides the oracle’s price is higher than the oracle’s price.

These two methods can alter the way _getNFTValueETH calculates the value of an NFT. (https://github.com/code-423n4/2022-04-jpegd/blob/59e288c27e1ff1b47505fea2e5434a7577d85576/contracts/vaults/NFTVault.sol#L410) Which in turn used to determine credit limit in the borrow function.

In the case if DAO gets compromised or by mistake, these actions can be taken and then certain NFTs can be liquidated for less what is their actual value.

Tools Used

Manual code review

Recommended Mitigation Steps

Add require statements to functions setNFTTypeValueETH and overrideFloor that checks if the price set by the DAO is higher than the previous price to conform to the description of the code in the comments.

To increase trust by costumers, place checks that only allow the DAO_ROLE to increase the value, because currently the contract allows the DAO to undercut the value of the NFTs people deposit for collateral.

It would also add a layer of security that would protect costumers in case the DAO role gets compromised, since there would be no room to alter these functions maliciously.

LPFarming: Unsupported fee-on-transfer tokens

Lines of code

https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/farming/LPFarming.sol#L214-L229

Vulnerability details

Impact

When lpToken is fee-on-transfer tokens, in the deposit function, the actual amount of tokens received by the contract will be less than the _amount, so that users can deplete the tokens deposited by other users by continuously depositing and withdrawing.

    function deposit(uint256 _pid, uint256 _amount)
        external
        noContract(msg.sender)
    {
        require(_amount > 0, "invalid_amount");

        PoolInfo storage pool = poolInfo[_pid];
        UserInfo storage user = userInfo[_pid][msg.sender];
        _updatePool(_pid);
        _withdrawReward(_pid);

        pool.lpToken.safeTransferFrom(msg.sender, address(this), _amount);
        user.amount = user.amount + _amount;

        emit Deposit(msg.sender, _pid, _amount);
    }

Proof of Concept

https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/farming/LPFarming.sol#L214-L229
https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/farming/LPFarming.sol#L235-L252

Tools Used

None

Recommended Mitigation Steps

Consider getting the received amount by calculating the difference of token balance (using balanceOf) before and after the transferFrom.

rewards will be locked if admin create 2 more pools with the same lpToken

Lines of code

https://github.com/code-423n4/2022-04-jpegd/blob/e72861a9ccb707ced9015166fbded5c97c6991b6/contracts/farming/LPFarming.sol#L190

Vulnerability details

Impact

LPFarming.sol

A part of the rewards will be locked in the farm forever if the admin creates 2 or more pools with the same lpToken

Proof of Concept

"please add this test to your LpFarming.ts to check"

it("a part of rewards should be locked in contract when admin create 2 pools with the same lpToken", async() => {
      // manual mine new block  
      await network.provider.send("evm_setAutomine", [false]);

      // prepare 
      await lpTokens[0].transfer(alice.address, units(1000));
      await lpTokens[0].transfer(bob.address, units(1000));
      await lpTokens[0].connect(alice).approve(farming.address, units(1000));
      await lpTokens[0].connect(bob).approve(farming.address, units(1000));
      await mineBlocks(1);

      // create new pool
      await farming.add(10, lpTokens[0].address);
      await farming.add(10, lpTokens[0].address);
      await mineBlocks(1);
      expect(await farming.poolLength()).to.equal(2);

      for (let i = 0; i < 2; ++i) {
        let pool = await farming.poolInfo(i);
        expect(pool.lpToken).to.equal(lpTokens[0].address);
        expect(pool.allocPoint).to.equal(10);
      }

      // create new epoch ==> balance of pool will be 1000 
      let blockNumber = await ethers.provider.getBlockNumber();
      await farming.newEpoch(blockNumber + 1, blockNumber + 11, 100);

      // alice deposit into the pool 0 
      await farming.connect(alice).deposit(0, units(100));
      // bob deposit into the pool 1
      await farming.connect(bob).deposit(1, units(100));
      await mineBlocks(1);

      expect(await jpeg.balanceOf(farming.address)).to.equal(1000);

      await mineBlocks(13);
      console.log("reward of alice: ", (await farming.pendingReward(0, alice.address)).toString());
      console.log("reward of bob: ", (await farming.pendingReward(1, bob.address)).toString());

      /*
        alice's reward = 250  
        bob's reward = 250 
        How about the remaining (500) ? 
      */
      
    });

As we can see in [https://github.com/code-423n4/2022-04-jpegd/blob/e72861a9ccb707ced9015166fbded5c97c6991b6/contracts/farming/LPFarming.sol#L190], contract uses pool.lpToken.balanceOf(address(this)) to get the totalSupply of each pool. In the case that admin created 2 or more pools with the same lpToken, pool.lpToken.balanceOf(address(this)) will get the supply of all the pools that have the same lpToken instead of that particular pool. So it makes the calculation not work in the right way, and maybe the reward tokens will be locked in the farming pool forever (because there is no function for admin or user to withdraw the remaining)

Tools Used

typescript

Recommended Mitigation Steps

Declare a new variable totalLPSupply to the struct PoolInfo, and use it instead of pool.lpToken.balanceOf(address(this))

The noContract modifier does not work as expected.

Lines of code

https://github.com/code-423n4/2022-04-jpegd/blob/59e288c27e1ff1b47505fea2e5434a7577d85576/contracts/vaults/yVault/yVault.sol#L61
https://github.com/code-423n4/2022-04-jpegd/blob/59e288c27e1ff1b47505fea2e5434a7577d85576/contracts/farming/yVaultLPFarming.sol#L54
https://github.com/code-423n4/2022-04-jpegd/blob/59e288c27e1ff1b47505fea2e5434a7577d85576/contracts/vaults/yVault/yVault.sol#L61

Vulnerability details

Impact

Detailed description of the impact of this finding.

The expectation of the noContract modifier is to allow access only to accounts inside EOA or Whitelist, if access is controlled using ! access control with _account.isContract(), then because isContract() gets the size of the code length of the account in question by relying on extcodesize/address.code.length, this means that the restriction can be bypassed when deploying a smart contract through the smart contract's constructor call.

Proof of Concept

Provide direct links to all referenced code in GitHub. Add screenshots, logs, or any other relevant proof that illustrates the concept.

Tools Used

Recommended Mitigation Steps

Modify the code to require(msg.sender == tx.origin);

Chainlink pricer is using a deprecated API

Lines of code

https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/vaults/FungibleAssetVaultForDAO.sol#L105

Vulnerability details

Impact

According to Chainlink's documentation, the latestAnswer function is deprecated. This function might suddenly stop working if Chainlink stop supporting deprecated APIs. And the old API can return stale data.

Proof of Concept

https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/vaults/FungibleAssetVaultForDAO.sol#L105
https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/vaults/NFTVault.sol#L459

Tools Used

None

Recommended Mitigation Steps

Use the latestRoundData function to get the price instead. Add checks on the return data with proper revert messages if the price is stale or the round is uncomplete
https://docs.chain.link/docs/price-feeds-api-reference/

Gas Optimizations

Using != 0 instead of > 0 can reduce the gas cost at various contracts

Target codebase

2022-04-jpegd/contracts/farming/LPFarming.sol
114: require(_rewardPerBlock > 0, "Invalid reward per block");
218: require(_amount > 0, "invalid_amount");
239: require(_amount > 0, "invalid_amount");
320: if (pending > 0) {
337: require(rewards > 0, "no_reward");
354: require(rewards > 0, "no_reward");

2022-04-jpegd/contracts/farming/yVaultLPFarming.sol
84: if (block.number > lastRewardBlock && staked > 0) {
101: require(_amount > 0, "invalid_amount");
118: require(_amount > 0, "invalid_amount");
139: require(rewards > 0, "no_reward");
181: if (pending > 0) userPendingRewards[account] += pending;

2022-04-jpegd/contracts/lock/JPEGLock.sol
40: require(_newTime > 0, "Invalid lock time");

2022-04-jpegd/contracts/staking/JPEGStaking.sol
32: require(_amount > 0, "invalid_amount");
46: _amount > 0 && _amount <= balanceOf(msg.sender),

2022-04-jpegd/contracts/vaults/FungibleAssetVaultForDAO.sol
94: _creditLimitRate.denominator > 0 &&
142: require(amount > 0, "invalid_amount");
164: require(amount > 0, "invalid_amount");
180: require(amount > 0, "invalid_amount");
194: require(amount > 0 && amount <= collateralAmount, "invalid_amount");

2022-04-jpegd/contracts/vaults/NFTVault.sol
278: require(_newFloor > 0, "Invalid floor");
327: _type == bytes32(0) || nftTypeValueETH[_type] > 0,
365: require(pendingValue > 0, "no_pending_value");
402: rate.denominator > 0 && rate.denominator >= rate.numerator,
637: uint256 debtAmount = positions[_nftIndex].liquidatedAt > 0
687: require(_amount > 0, "invalid_amount");
764: require(_amount > 0, "invalid_amount");
770: require(debtAmount > 0, "position_not_borrowed");
882: require(position.liquidatedAt > 0, "not_liquidated");
926: require(position.liquidatedAt > 0, "not_liquidated");

2022-04-jpegd/contracts/vaults/yVault/yVault.sol
100: _rate.numerator > 0 && _rate.denominator >= _rate.numerator,
143: require(_amount > 0, "INVALID_AMOUNT");
167: require(_shares > 0, "INVALID_AMOUNT");
170: require(supply > 0, "NO_TOKENS_DEPOSITED");

2022-04-jpegd/contracts/vaults/yVault/strategies/StrategyPUSDConvex.sol
182: _performanceFee.denominator > 0 &&
322: if (balance > 0)
334: require(wethBalance > 0, "NOOP");

Potential improvement

Use use != 0 instead of > 0 at the above codebase. It checks with uint256 variable, so != and > 0 has a same meaning gramatically. Using != 0 can reduce the gas deployment cost of these contracts.


bytecode variable does not need to be defined

Target codebase

https://github.com/code-423n4/2022-04-jpegd/blob/59e288c27e1ff1b47505fea2e5434a7577d85576/contracts/escrow/NFTEscrow.sol#L93-L101

bytes memory bytecode = _encodeFlashEscrow(_idx);

//hash from which the contract address can be derived
bytes32 hash = keccak256(
    abi.encodePacked(
        bytes1(0xff),
        address(this),
        salt,
        keccak256(bytecode)

Potential improvement

Simply do not define bytecode variable to reduce the gas cost.

//hash from which the contract address can be derived
bytes32 hash = keccak256(
    abi.encodePacked(
        bytes1(0xff),
        address(this),
        salt,
        keccak256(_encodeFlashEscrow(_idx))

Do not need to set 0 at the variable used in for loop

Target codebase

2022-04-jpegd/contracts/farming/LPFarming.sol
281: for (uint256 pid = 0; pid < length; ++pid) {
348: for (uint256 i = 0; i < poolInfo.length; i++) {

2022-04-jpegd/contracts/vaults/NFTVault.sol
181: for (uint256 i = 0; i < _typeInitializers.length; i++) {
184: for (uint256 j = 0; j < initializer.nfts.length; j++) {

2022-04-jpegd/contracts/vaults/yVault/strategies/StrategyPUSDConvex.sol
145: for (uint256 i = 0; i < _strategyConfig.rewardTokens.length; i++) {
231: for (uint256 i = 0; i < length; i++) {
319: for (uint256 i = 0; i < rewardTokens.length; i++) {

Potential improvement

Just do not initialize with 0 for the variable used in for loop. The example code is as follows:

for (uint256 pid; pid < length; ++pid) {

No need to define staked variable at yVaultLPFarming.sol

Target codebase

https://github.com/code-423n4/2022-04-jpegd/blob/59e288c27e1ff1b47505fea2e5434a7577d85576/contracts/farming/yVaultLPFarming.sol#L81-L84

uint256 staked = totalStaked;
//if blockNumber is greater than the pool's `lastRewardBlock` the pool's `accRewardPerShare` is outdated,
//we need to calculate the up to date amount to return an accurate reward value
if (block.number > lastRewardBlock && staked > 0) {
    (rewardShare, ) = _computeUpdate();
}

staked variable is not used anywhere, so there is no need to define staked variable.

Potential improvement

//if blockNumber is greater than the pool's `lastRewardBlock` the pool's `accRewardPerShare` is outdated,
//we need to calculate the up to date amount to return an accurate reward value
if (block.number > lastRewardBlock && totalStaked > 0) {
    (rewardShare, ) = _computeUpdate();
}

This change can reduce the gas cost since it does not define variable.


No need to define newRewards variable at yVaultLPFarming.sol

Target codebase

newRewards is only used once so no need to be defined.

https://github.com/code-423n4/2022-04-jpegd/blob/59e288c27e1ff1b47505fea2e5434a7577d85576/contracts/farming/yVaultLPFarming.sol#L170-L172

uint256 newRewards = currentBalance - previousBalance;

newAccRewardsPerShare = accRewardPerShare + newRewards * 1e36 / totalStaked;

Potential improvement

newAccRewardsPerShare = accRewardPerShare + (currentBalance - previousBalance) * 1e36 / totalStaked;

No need to define account variable at _transferFrom function in CryptoPunksHelper.sol

Target codebase

https://github.com/code-423n4/2022-04-jpegd/blob/59e288c27e1ff1b47505fea2e5434a7577d85576/contracts/helpers/CryptoPunksHelper.sol#L72-L77

address account = punks.punkIndexToAddress(_idx);

//if the owner is this address we don't need to go through {NFTEscrow}
if (account != address(this)) {
    _executeTransfer(_from, _idx);
}

Potential improvement

//if the owner is this address we don't need to go through {NFTEscrow}
if (punks.punkIndexToAddress(_idx) != address(this)) {
    _executeTransfer(_from, _idx);
}

unchecked can be used to reduce the gas cost at repay function in FungibleAssetVaultForDAO.sol

Target codebase

https://github.com/code-423n4/2022-04-jpegd/blob/59e288c27e1ff1b47505fea2e5434a7577d85576/contracts/vaults/FungibleAssetVaultForDAO.sol#L184

function repay(uint256 amount) external onlyRole(WHITELISTED_ROLE) nonReentrant {
    require(amount > 0, "invalid_amount");

    amount = amount > debtAmount ? debtAmount : amount;

    debtAmount -= amount;

At line 184, debtAmount - amount becomes more than or equals to 0. So debtAmount -= amount; can be wrapped by unchecked.

Potential improvement

function repay(uint256 amount) external onlyRole(WHITELISTED_ROLE) nonReentrant {
    require(amount > 0, "invalid_amount");

    amount = amount > debtAmount ? debtAmount : amount;

    unchecked {
      debtAmount -= amount;
    }

unchecked can be used to reduce the gas cost at withdraw function in FungibleAssetVaultForDAO.sol

Target codebase

https://github.com/code-423n4/2022-04-jpegd/blob/59e288c27e1ff1b47505fea2e5434a7577d85576/contracts/vaults/FungibleAssetVaultForDAO.sol#L199

function withdraw(uint256 amount) external onlyRole(WHITELISTED_ROLE) nonReentrant {
    require(amount > 0 && amount <= collateralAmount, "invalid_amount");

    uint256 creditLimit = getCreditLimit(collateralAmount - amount);
    require(creditLimit >= debtAmount, "insufficient_credit");

    collateralAmount -= amount;

At line 184, collateralAmount - amount becomes more than or equals to 0. So collateralAmount -= amount; can be wrapped by unchecked.

Potential improvement

function withdraw(uint256 amount) external onlyRole(WHITELISTED_ROLE) nonReentrant {
    require(amount > 0 && amount <= collateralAmount, "invalid_amount");

    uint256 creditLimit = getCreditLimit(collateralAmount - amount);
    require(creditLimit >= debtAmount, "insufficient_credit");

    unchecked {
      collateralAmount -= amount;
    }

No need to define collateralValue variable at getCreditLimit function in FungibleAssetVaultForDAO.sol

Target codebase

https://github.com/code-423n4/2022-04-jpegd/blob/59e288c27e1ff1b47505fea2e5434a7577d85576/contracts/vaults/FungibleAssetVaultForDAO.sol#L132

Potential improvement

Simply avoiding defining collateralValue.

function getCreditLimit(uint256 amount) public view returns (uint256) {
    return
        (_getCollateralValue(amount) * creditLimitRate.numerator) /
        creditLimitRate.denominator;
}

This would reduce the deployment gas cost.


No need to define creditLimit variable at borrow function in FungibleAssetVaultForDAO.sol

Target codebase

https://github.com/code-423n4/2022-04-jpegd/blob/59e288c27e1ff1b47505fea2e5434a7577d85576/contracts/vaults/FungibleAssetVaultForDAO.sol#L166-L168

uint256 creditLimit = getCreditLimit(collateralAmount);
uint256 newDebtAmount = debtAmount + amount;
require(newDebtAmount <= creditLimit, "insufficient_credit");

creditLimit variable is only used once, so it can avoid be defined.

Potential improvement

Simply avoid defining creditLimit. This would decrease the deployment gas cost.

uint256 newDebtAmount = debtAmount + amount;
require(newDebtAmount <= getCreditLimit(collateralAmount), "insufficient_credit");

No need to define creditLimit variable at withdraw function in FungibleAssetVaultForDAO.sol

Target codebase

https://github.com/code-423n4/2022-04-jpegd/blob/59e288c27e1ff1b47505fea2e5434a7577d85576/contracts/vaults/FungibleAssetVaultForDAO.sol#L196-L197

uint256 creditLimit = getCreditLimit(collateralAmount - amount);
require(creditLimit >= debtAmount, "insufficient_credit");

creditLimit variable is only used once, so it can avoid be defined.

Potential improvement

Simply avoid defining creditLimit. This would decrease the deployment gas cost.

require(getCreditLimit(collateralAmount - amount) >= debtAmount, "insufficient_credit");

rewards will be locked if user transfer directly to pool without using deposit function

Lines of code

https://github.com/code-423n4/2022-04-jpegd/blob/e72861a9ccb707ced9015166fbded5c97c6991b6/contracts/farming/LPFarming.sol#L190

Vulnerability details

Impact

LpFarming.sol

reward will be locked in the farming, when user execute a direct transfer with lpToken to farm without using deposit

Proof of Concept

"pls add this test to LpFarming.ts to check"

it("a part of rewards can't be distributed if user execute a direct transfer to farm", async() => {
      // manual mine new block  
      await network.provider.send("evm_setAutomine", [false]);

      // prepare 
      const attacker = bob;
      await lpTokens[0].transfer(alice.address, units(1000));
      await lpTokens[0].transfer(attacker.address, units(1000));
      await lpTokens[0].connect(alice).approve(farming.address, units(1000));
      await mineBlocks(1);

      // attacker direct deposit lp token to the pool 
      await lpTokens[0].connect(attacker).transfer(farming.address, units(100));

      // create new pool
      await farming.add(10, lpTokens[0].address);
      await mineBlocks(1);
      expect(await farming.poolLength()).to.equal(1);

      let pool = await farming.poolInfo(0);
      expect(pool.lpToken).to.equal(lpTokens[0].address);
      expect(pool.allocPoint).to.equal(10);

      // create new epoch ==> balance of pool will be 1000 
      let blockNumber = await ethers.provider.getBlockNumber();
      await farming.newEpoch(blockNumber + 1, blockNumber + 11, 100);

      // alice deposit 
      await farming.connect(alice).deposit(0, units(100));
      await mineBlocks(1);

      expect(await jpeg.balanceOf(farming.address)).to.equal(1000);

      // when pool end, alice can just take 500 jpeg, and 500 jpeg will be locked in the contract forever !!!
      await mineBlocks(13);
      console.log("reward of alice: ", (await   farming.pendingReward(0, alice.address)).toString());
      expect(await farming.pendingReward(0, alice.address)).to.equal(BigNumber.from('500'));
    });

In the test above, the attacker transfers 100 lpToken to the farm without using deposit function, and alice deposit 100 lpToken. Because the contract uses pool.lpToken.balanceOf(address(this)) to get the total supply of lpToken in the pool, it will sum up 100 lpToken of attacker and 100 lpToken of alice. This will lead to the situation where Alice will only be able to claim 500 token (at epoch.endBlock), the rest will be locked in the pool forever. Not only with this pool, it also affects the following, a part of the reward will be locked in the pool when the farm end.

Tools Used

typescript

Recommended Mitigation Steps

Declare a new variable totalLPSupply to the struct PoolInfo, and use it instead of pool.lpToken.balanceOf(address(this))

The JPEG's DAO have extreme high Privilege and incentive to defraud

Lines of code

https://github.com/code-423n4/2022-04-jpegd/blob/e72861a9ccb707ced9015166fbded5c97c6991b6/README.md?plain=1#L35

Vulnerability details

Severity: High to Critical

The JPEG's DAO GnosisSafe have no known public party involved in the project administration. With 1 original Deployer account, 2 accounts fund from tornado directly, the project docs, blogs show no plan for updating administration protocol or bring in new people. The JPEG "staking governance" token have no actual use in changing DAO decision or vote for DAO members.

Consider this, the DAO actor cannot be trusted as credible admin. This is the same as a centralized actor.

List of incentive for defrauding

  • The JPEG circulation, minting is not controlled by official JPEG's DAO but original team member from deployment half a year ago.
  • The TokenVesting.sol, which have been audited by previous auditors, left out of this competition, have been modified to give DAO power to withdraw all vesting rewards instantly.
  • Most contracts owner privilege have no timelock or safety value check to all important actions to prevent accidents or misuse that can hurt user funds.
  • The DAO can change every configuration of minting PUSd and NFTs loan instantly without delay.
  • NFT vault is controlled by DAO, which debt can increase to infinity and lock all borrower NFTs instantly if misused.
  • NFT loan leeched off Convex's staking user funds instead of the DAO Vault.
  • The Stable coin PUSd collateral was designed to hurt users and staker fund and not stable asset controlled by JPEG's DAO. (The DAO lose nothing in case of loan default)
  • PUSd fake pegged 1:1 to USDC or ETH in FungibleAssetVaultForDAO.sol because the majority of user cannot access this. The only PUSd collateral that normal user can access is JPEG/Convex funded by other users.

Impact

The current JPEG project design have no sunk cost for DAO members, nor risk or obligation to provide NFTs loan service.
If the DAO wish, they can defraud the entire project money flow and withdraw all funds from DAO vault, SushiSwap, Convex, borrower NFTs with little consequence.

Details

  1. JPEG owner have been transferred from deployer 0x7a271674b5fae043f42f183092f48fb06d6d551b to 0xa80c3BC69a0b69a62E777a8ADA1E8807fF878a59 tx. Different from the official Gnosis multisig in Docs 0x51c2cef9efa48e08557a361b52db34061c025a1b. You can see the JPEG contract owner actually original team members here while the official DAO's only have one extra person.
  2. Original audited vesting/PreJPEGD.sol have been changed and left out this competition. Quote from original audit Update: According to the dev team "Requirements for PreJPEG changed and the token isn’t transferrable anymore.". Current PreJPEG.sol and TokenVesting.sol missing from this competition can be found from deployment code. The function function revoke(address account) public allow DEFAULT_ADMIN_ROLE to withdraw all token vested by any accounts in file 3 TokenVesting.sol. The DEFAULT_ADMIN_ROLE owner is official DAO gnosis safe controlled by 3 original team members out of 2 vote execution required. This fund worth 30% of total token in circulation.
  3. FungibleAssetVaultForDAO.sol have no maximum limit setting for credit rate. If this setting is changed to number > 1 or close to infinity.This allows DAO or whitelist address to instantly withdraw or mint money from vaults, more than it should be allowed.
  4. NFTVault.sol have no max rate limit for all settings. If debt interest set to high number, every borrower debt can reach higher than total PUSd in circulation and NFT can be liquidated after 3 days.
  5. The PUSd stable coin is not pegged to USD. The collateral is controlled by JPEG's DAO. Only whitelisted address can swap PUSd to USDC or ETH (No detail was provided for these address). The only other market allow swap PUSd is Convex's staking funded by user to get JPEG, CVX reward. This means PUSd have no value in USD and the available pegged value depend on user funds staking.
  6. NFT PUSd debt can be higher than total circulation of PUSd. Borrower NFT can borrow PUSd for what value decided by chainlink or DAO. Debt in PUSd will keep increasing + withdraw fee + default penalty 25% of total value. Without DAO vault minting more PUSd, there will be not enough PUSd to cover the debt for everyone.
  7. PUSd currently only swapped through Convex stable coin or unofficial pool. If condition 6 above is true, the money flow from user staking in Convex to NFT borrowers, while DAO 30% JPEG token slowly flow to staking user. Hence, there are no sink cost for DAO, all money and NFTs flow from all users end up into DAO vault in the end.
  8. Since only certain whitelist address allowed to swap PUSd to USDC or ETH in DAO fungible vault, PUSd can not peg 1:1 to any stable coin. Because users do not have access to the majority pegged value inside FungibleAssetVaultForDAO.sol. This is no different from normal centralized bank framework.

Recommended Mitigation

  • All access control onlyDAO should be renounced and transfer admin power to timelock. To give user time to react and when change happen.
  • Renounce ownership of minting JPEG to prevent centralization.
  • Provide clear transparency info regards ownership, DAO and admin control to all users.
  • Add maximum value for setting in NFTVault.sol and FungibleAssetVaultForDAO.sol to prevent setting debt or credit limit more than it should be.
  • PUSd cannot be called stable coin with current configuration. It is more like a mortgage created by DAO.

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.