title | sponsor | slug | date | findings | contest |
---|---|---|---|---|---|
JPEG'd contest |
JPEG'd |
2022-04-jpegd |
2022-06-10 |
107 |
Code4rena (C4) is an open organization consisting of security researchers, auditors, developers, and individuals with domain expertise in smart contracts.
A C4 audit contest is an event in which community participants, referred to as Wardens, review, audit, or analyze smart contract logic in exchange for a bounty provided by sponsoring projects.
During the audit contest outlined in this document, C4 conducted an analysis of the JPEG'd smart contract system written in Solidity. The audit contest took place between April 7—April 13 2022.
66 Wardens contributed reports to the JPEG'd contest:
- cmichel
- hickuphh3
- rayn
- WatchPug
- Dravee
- TrungOre
- berndartmueller
- Kenshin
- minhquanym
- AuditsAreUS
- 0xDjango
- IllIllI
- cccz
- Jujic
- hyh
- pedroais
- Kthere
- Foundation (HardlyDifficult and batu)
- 0x1f8b
- PPrieditis
- robee
- Meta0xNull
- rfa
- 0xkatana
- TerrierLover
- Cr4ckM3
- ilan
- Funen
- JMukesh
- catchup
- kenta
- kebabsec (okkothejawa and FlameHorizon)
- ellahi
- Hawkeye (0xwags and 0xmint)
- Cityscape
- horsefacts
- delfin454000
- Picodes
- smiling_heretic
- Wayne
- Ruhum
- reassor
- hubble (ksk2345 and shri4net)
- saian
- JC
- dy
- pauliax
- samruna
- jayjonah8
- Tomio
- 0xNazgul
- 0v3rf10w
- securerodd
- FSchmoede
- slywaters
- rokinot
- nahnah
- sorrynotsorry
- dirk_y
- defsec
- joshie
- peritoflores
This contest was judged by LSDan.
Final report assembled by liveactionllama.
The C4 analysis yielded an aggregated total of 20 unique vulnerabilities. Of these vulnerabilities, 9 received a risk rating in the category of HIGH severity and 11 received a risk rating in the category of MEDIUM severity.
Additionally, C4 analysis included 42 reports detailing issues with a risk rating of LOW severity or non-critical. There were also 38 reports recommending gas optimizations.
All of the issues presented here are linked back to their original finding.
The code under review can be found within the C4 JPEG'd contest repository, and is composed of 14 smart contracts written in the Solidity programming language and includes 3,118 lines of Solidity code.
C4 assesses the severity of disclosed vulnerabilities according to a methodology based on OWASP standards.
Vulnerabilities are divided into three primary risk categories: high, medium, and low/non-critical.
High-level considerations for vulnerabilities span the following key areas when conducting assessments:
- Malicious Input Handling
- Escalation of privileges
- Arithmetic
- Gas use
Further information regarding the severity criteria referenced throughout the submission review process, please refer to the documentation provided on the C4 website.
Submitted by hickuphh3, also found by 0xDjango, berndartmueller, cmichel, hyh, and WatchPug
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”.
- 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 itsdeposit()
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));
});
- Uniswap V2 solved this problem by sending the first 1000 LP tokens to the zero address. The same can be done in this case i.e. when
totalSupply() == 0
, send the first min liquidity LP tokens to the zero address to enable share dilution. - Ensure the number of shares to be minted is non-zero:
require(_shares != 0, "zero shares minted");
spaghettieth (JPEG'd) confirmed, but disagreed with High severity
spaghettieth (JPEG'd) resolved and commented:
Fixed in jpegd/core#16.
[H-02] Existing user’s locked JPEG could be overwritten by new user, causing permanent loss of JPEG funds
Submitted by hickuphh3, also found by 0x1f8b, AuditsAreUS, Foundation, Kthere, Meta0xNull, rayn, and WatchPug
NFTVault.sol#L375
JPEGLock.sol#L54-L62
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.
user
successfully proposes and finalizes a proposal to change his NFT’s collateral value- Another user (
owner
) does the same for the same NFT index 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");
});
- 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);
}
- 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.
spaghettieth (JPEG'd) confirmed
spaghettieth (JPEG'd) resolved and commented:
Fixed in jpegd/core#3.
Submitted by Dravee
The solution uses:
"@openzeppelin/contracts": "^4.0.0",
"@openzeppelin/contracts-upgradeable": "^4.3.2",
These dependencies have a known high severity vulnerability:
- https://security.snyk.io/vuln/SNYK-JS-OPENZEPPELINCONTRACTSUPGRADEABLE-2320177
- https://snyk.io/test/npm/@openzeppelin/contracts-upgradeable/4.3.2#SNYK-JS-OPENZEPPELINCONTRACTSUPGRADEABLE-2320177
- https://snyk.io/test/npm/@openzeppelin/contracts/4.0.0#SNYK-JS-OPENZEPPELINCONTRACTS-2320176
Which makes these contracts vulnerable:
contracts/helpers/CryptoPunksHelper.sol:
19: function initialize(address punksAddress) external initializer {
contracts/helpers/EtherRocksHelper.sol:
19: function initialize(address rocksAddress) external initializer {
contracts/staking/JPEGStaking.sol:
21: function initialize(IERC20Upgradeable _jpeg) external initializer {
contracts/vaults/FungibleAssetVaultForDAO.sol:
71: ) external initializer {
contracts/vaults/NFTVault.sol:
149: ) external initializer {
Upgrade @openzeppelin/contracts
and @openzeppelin/contracts-upgradeable
to version 4.4.1 or higher.
spaghettieth (JPEG'd) confirmed, but disagreed with High severity
spaghettieth (JPEG'd) resolved and commented:
Fixed in jpegd/core#11.
Submitted by cmichel
In deposit
, the balance is cached and then a token.transferFrom
is triggered which can lead to exploits if the token
is a token that gives control to the sender, like ERC777 tokens.
Initial state: balance() = 1000
, shares supply = 1000
.
Depositing 1000 amount should mint 1000 supply, but one can split the 1000 amounts into two 500 deposits and use re-entrancy to profit.
- Outer
deposit(500)
:balanceBefore = 1000
. Control is given to attacker ... - Inner
deposit(500)
:balanceBefore = 1000
.shares = (_amount * supply) / balanceBefore = 500 * 1000 / 1000 = 500
shares are minted ... - Outer
deposit(500)
continues with the mint:shares = (_amount * supply) / balanceBefore = 500 * 1500 / 1000 = 750
are minted. - Withdrawing the
500 + 750 = 1250
shares viawithdraw(1250)
, the attacker receivesbackingTokens = (balance() * _shares) / supply = 2000 * 1250 / 2250 = 1111.111111111
. The attacker makes a profit of1111 - 1000 = 111
tokens. - They repeat the attack until the vault is drained.
The safeTransferFrom
should be the last call in deposit
.
spaghettieth (JPEG'd) confirmed
spaghettieth (JPEG'd) resolved and commented:
Fixed in jpegd/core#19.
Submitted by hickuphh3
yVault users participating in the farm have to trust that:
vault.balanceOfJPEG()
returns the correct claimable JPEG amount by its strategy / strategies- the strategy / strategies will send all claimable JPEG to the farm
Should either of these assumptions break, then it could possibly be the case that currentBalance
is less than previousBalance
, causing deposits and crucially, withdrawals to fail due to subtraction overflow.
For instance,
- Farm migration occurs. A new farm is set in
yVault
, thenwithdrawJPEG()
is called, which sends funds to the new farm. Users of the old farm would be unable to withdraw their deposits.
it.only("will revert old farms' deposits and withdrawals if yVault migrates farm", async () => {
// 0. setup
await token.mint(owner.address, units(1000));
await token.approve(yVault.address, units(1000));
await yVault.depositAll();
await yVault.approve(lpFarming.address, units(1000));
// send some JPEG to strategy prior to deposit
await jpeg.mint(strategy.address, units(100));
// deposit twice, so that the second deposit will invoke _update()
await lpFarming.deposit(units(250));
await lpFarming.deposit(units(250));
// 1. change farm and call withdrawJPEG()
await yVault.setFarmingPool(user1.address);
await yVault.withdrawJPEG();
// deposit and withdrawal will fail
await expect(lpFarming.deposit(units(500))).to.be.revertedWith('reverted with panic code 0x11 (Arithmetic operation underflowed or overflowed outside of an unchecked block)');
await expect(lpFarming.withdraw(units(500))).to.be.revertedWith('reverted with panic code 0x11 (Arithmetic operation underflowed or overflowed outside of an unchecked block)');
});
- Strategy migration occurs, but JPEG funds held by the old strategy were not claimed, causing
vault.balanceOfJPEG()
to report a smaller amount than previously recorded jpeg
could be accidentally included in the StrategyConfig, resulting in JPEG being converted to other assets- A future implementation takes a fee on the
jpeg
to be claimed
A simple fix would be to return
if currentBalance ≤ previousBalance
. A full fix would properly handle potential shortfall.
if (currentBalance <= previousBalance) return;
spaghettieth (JPEG'd) confirmed, but disagreed with High severity and commented:
The issue can be reproduced, but due to the extremely specific cases in which this happens the severity should be lowered to 2.
spaghettieth (JPEG'd) resolved and commented:
Fixed in jpegd/core#7.
I disagree with the sponsor. This is high risk.
Submitted by cmichel
yVaultLPFarming.sol#L170
yVault.sol#L108
The accruals in yVaultLPFarming
will fail if currentBalance < previousBalance
in _computeUpdate
.
currentBalance = vault.balanceOfJPEG() + jpeg.balanceOf(address(this));
uint256 newRewards = currentBalance - previousBalance;
No funds can be withdrawn anymore as the withdraw
functions first trigger an _update
.
The currentBalance < previousBalance
case can, for example, be triggerd by decreasing the vault.balanceOfJPEG()
due to calling yVault.setController
:
function setController(address _controller) public onlyOwner {
// @audit can reduce balanceofJpeg which breaks other masterchef contract
require(_controller != address(0), "INVALID_CONTROLLER");
controller = IController(_controller);
}
function balanceOfJPEG() external view returns (uint256) {
// @audit new controller could return a smaller balance
return controller.balanceOfJPEG(address(token));
}
Setting a new controller on a vault must be done very carefully and requires a migration.
This is not a duplicate of H-05. Though both of them deal with issues related to balanceOfJPEG, they describe different causes.
spaghettieth (JPEG'd) acknowledged
Submitted by hickuphh3, also found by rayn
Controller.sol#L95
StrategyPUSDConvex.sol#L266
The controller calls the withdraw()
method to withdraw JPEGs from the contract, but the strategy might blacklist the JPEG asset, which is what the PUSDConvex strategy has done.
The migration would therefore revert.
Insert this test into StrategyPUSDConvex.ts
.
it.only("will revert when attempting to migrate strategy", async () => {
await controller.setVault(want.address, yVault.address);
await expect(controller.setStrategy(want.address, strategy.address)).to.be.revertedWith("jpeg");
});
Replace _current.withdraw(address(jpeg));
with _current.withdrawJPEG(vaults[_token])
.
spaghettieth (JPEG'd) confirmed and commented:
The proposed migration steps would modify the intended behaviour, which is to withdraw JPEG to the controller and not the vault. A correct solution would be replacing
_current.withdraw(address(jpeg))
with_current.withdrawJPEG(address(this))
.
spaghettieth (JPEG'd) resolved and commented:
Fixed in jpegd/core#6.
[H-08] StrategyPUSDConvex.balanceOfJPEG
uses incorrect function signature while calling extraReward.earned
, causing the function to unexpectedly revert everytime
Submitted by rayn
As specified in Convex BaseRewardPool.sol and VirtualRewardPool.sol, the function signature of earned
is earned(address)
. However, balanceOfJPEG
did not pass any arguments to earned
, which would cause balanceOfJPEG
to always revert.
This bug will propagate through Controller
and YVault
until finally reaching the source of the call in YVaultLPFarming ._computeUpdate
, and render the entire farming contract unuseable.
Both BaseRewardPool.earned
and VirtualBalanceRewardPool.earned
takes an address as argument
function earned(address account) public view returns (uint256) {
return
balanceOf(account)
.mul(rewardPerToken().sub(userRewardPerTokenPaid[account]))
.div(1e18)
.add(rewards[account]);
}
function earned(address account) public view returns (uint256) {
return
balanceOf(account)
.mul(rewardPerToken().sub(userRewardPerTokenPaid[account]))
.div(1e18)
.add(rewards[account]);
}
But balanceOfJPEG
does not pass any address to extraReward.earned
, causing the entire function to revert when called
function balanceOfJPEG() external view returns (uint256) {
uint256 availableBalance = jpeg.balanceOf(address(this));
IBaseRewardPool baseRewardPool = convexConfig.baseRewardPool;
uint256 length = baseRewardPool.extraRewardsLength();
for (uint256 i = 0; i < length; i++) {
IBaseRewardPool extraReward = IBaseRewardPool(baseRewardPool.extraRewards(i));
if (address(jpeg) == extraReward.rewardToken()) {
availableBalance += extraReward.earned();
//we found jpeg, no need to continue the loop
break;
}
}
return availableBalance;
}
vim, ganache-cli
Pass address(this)
as argument of earned
.
Notice how we modify the fetching of reward. This is reported in a separate bug report, but for completeness, the entire fix is shown in both report entries.
function balanceOfJPEG() external view returns (uint256) {
uint256 availableBalance = jpeg.balanceOf(address(this));
IBaseRewardPool baseRewardPool = convexConfig.baseRewardPool;
availableBalance += baseRewardPool.earned(address(this));
uint256 length = baseRewardPool.extraRewardsLength();
for (uint256 i = 0; i < length; i++) {
IBaseRewardPool extraReward = IBaseRewardPool(baseRewardPool.extraRewards(i));
if (address(jpeg) == extraReward.rewardToken()) {
availableBalance += extraReward.earned(address(this));
}
}
return availableBalance;
}
spaghettieth (JPEG'd) confirmed, but disagreed with High severity
spaghettieth (JPEG'd) resolved and commented:
Fixed in jpegd/core#15.
Leaving this as high risk. The issue would cause a loss of funds.
Submitted by WatchPug
uint256 debtAmount = _getDebtAmount(_nftIndex);
require(
debtAmount >= _getLiquidationLimit(_nftIndex),
"position_not_liquidatable"
);
// burn all payment
stablecoin.burnFrom(msg.sender, debtAmount);
In the current design/implementation, the liquidator must fully repay the user's outstanding debt in order to get the NFT.
When the market value of the NFT fell rapidly, the liquidators may not be able to successfully liquidate as they can not sell the NFT for more than the debt amount.
In that case, the protocol will have positions that are considered bad debts.
However, these loans, which may never be repaid, are still accruing interest. And every time the DAO collects interest, new stablecoin
will be minted.
When the proportion of bad debts is large enough since the interest generated by these bad debts is not backed. It will damage the authenticity of the stablecoin.
Given:
NFT 1
worth 30,000 USDcreditLimitRate
= 60%liquidationLimitRate
= 50%debtInterestApr
= 10%
- Alice borrowed
10,000 USD
withNFT #1
; - After 1 year,
NFT 1
's market value in USD has suddenly dropped to10,000
USD, no liquidator is willing to repay 11,000 USD forNFT #1
; - The DAO
collect()
and minted1,000
stablecoin; - After 1 year, the DAO call
collect()
will mint1,100
stablecoin. and so on...
Consider adding a stored value to record the amount of bad debt, and add a public function that allows anyone to mark a bad debt to get some reward. and change accrue
to:
uint256 internal badDebtPortion;
function accrue() public {
uint256 additionalInterest = _calculateAdditionalInterest();
totalDebtAccruedAt = block.timestamp;
totalDebtAmount += additionalInterest;
uint256 collectibleInterest = additionalInterest * (totalDebtPortion - badDebtPortion) / totalDebtPortion;
totalFeeCollected += collectibleInterest;
}
spaghettieth (JPEG'd) acknowledged, but disagreed with High severity
I agree with the warden. Left unchecked, this issue is almost certain to occur and will cause substantial negative impacts on the protocol. The only way this would not occur is if the NFT market never crashes.
Submitted by cccz, also found by minhquanym
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;
}
LPFarming.sol#L141-L154
LPFarming.sol#L288-L311
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.
spaghettieth (JPEG'd) confirmed
spaghettieth (JPEG'd) resolved and commented:
Fixed in jpegd/core#2.
Submitted by Kenshin
CryptoPunksHelper.sol#L38
CryptoPunksHelper.sol#L52
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.
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
.
// 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);
});
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"
- Foundry
- Hardhat
Even the functions are restricted for only the owner, the zero address should not be allowed as the receiver address.
spaghettieth (JPEG'd) acknowledged and commented:
The sole purpose of
CryptoPunksHelper.sol
andEtherRocksHelper.sol
contracts is to allow compatibility of non ERC721 NFTs to be compatible withNFTVault.sol
without having to modify the vault's code. They don't have to provide any additional security check outside of compatibility related ones, everything else is out of scope and should be handled by the underlying NFT.
Submitted by TrungOre
LPFarming.sol#L214
LPFarming.sol#L107
A part of reward tokens will be locked in the farming pool if no user deposits lpToken at the epoch.startBlock.
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 !!!
typescript
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);
}
spaghettieth (JPEG'd) acknowledged, but disagreed with Medium severity and commented:
This is a very minor issue, severity should be 0.
I disagree with the sponsor. Funds are lost in this scenario and it is very easy to mitigate.
Submitted by cmichel, also found by pedroais
The setDebtInterestApr
changes the debt interest rate without first accruing the debt.
This means that the new debt interest rate is applied retroactively to the unaccrued period on next accrue()
call.
It should never be applied retroactively to a previous time window as this is unfair & wrong.
Borrowers can incur more debt than they should.
Call accrue()
first in setDebtInterestApr
before setting the new settings.debtInterestApr
.
spaghettieth (JPEG'd) confirmed
spaghettieth (JPEG'd) resolved and commented:
Fixed in jpegd/core#4.
Submitted by TrungOre
Reward will be locked in the farming, when user execute a direct transfer with lpToken to farm without using deposit.
"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.
typescript
Declare a new variable totalLPSupply
to the struct PoolInfo
, and use it instead of pool.lpToken.balanceOf(address(this))
.
spaghettieth (JPEG'd) confirmed, but disagreed with High severity and commented:
Very minor issue, severity should be 0.
spaghettieth (JPEG'd) resolved and commented:
Fixed in jpegd/core#2.
LSDan (judge) decreased severity to Medium and commented:
I'm going to downgrade this to a medium. There is a possibility for lost funds given real world external factors (user stupidity).
Submitted by Jujic, also found by hickuphh3
Price can be stale and can lead to wrong answer
return value.
Oracle data feed is insufficiently validated. There is no check for stale price and round completeness. Price can be stale and can lead to wrong answer
return value.
function _collateralPriceUsd() internal view returns (uint256) {
int256 answer = oracle.latestAnswer();
uint8 decimals = oracle.decimals();
require(answer > 0, "invalid_oracle_answer");
...
FungibleAssetVaultForDAO.sol#L105
Validate data feed
function _collateralPriceUsd() internal view returns (uint256) {
(uint80 roundID, int256 answer, , uint256 timestamp, uint80 answeredInRound) = oracle.latestRoundData();
require(answer > 0, "invalid_oracle_answer");
require(answeredInRound >= roundID, "ChainLink: Stale price");
require(timestamp > 0, "ChainLink: Round not complete");
...
0xJPEG (JPEG'd) confirmed, but disagreed with High severity and commented:
Can add validation for round not being complete yet and potentially for stale pricing.
This should be medium risk, as shown in past contests [1] [2] [3]
spaghettieth (JPEG'd) resolved and commented:
Fixed in jpegd/core#9.
LSDan (judge) decreased severity to Medium and commented:
Agree with sponsor on the medium risk rating. An oracle with a bad value is by definition an external requirement.
Submitted by berndartmueller
The yVault.getPricePerFullShare() function calculates the price per share by multiplying with 1e18
token decimals with the assumption that the underlying token always has 18 decimals. yVault
has the same amount of decimals as it's underlying token see (yVault.decimals())
But tokens don't always have 1e18
decimals (e.g. USDC).
The price per share calculation does not return the correct price for underlying tokens that do not have 18 decimals. This could lead to paying out too little or too much and therefore to a loss for either the protocol or the user.
Following test will fail with the current implementation when the underlying vault token has 6 decimals:
NOTE: units()
helper function was adapted to accept the desired decimals.
it.only("should mint the correct amount of tokens for tokens with 6 decimals", async () => {
const DECIMALS = 6;
await token.setDecimals(DECIMALS);
expect(await yVault.decimals()).to.equal(DECIMALS);
expect(await yVault.getPricePerFullShare()).to.equal(0);
await token.mint(user1.address, units(1000, DECIMALS));
await token.connect(user1).approve(yVault.address, units(1000, DECIMALS));
await yVault.connect(user1).deposit(units(500, DECIMALS));
expect(await yVault.balanceOf(user1.address)).to.equal(units(500, DECIMALS));
await token.mint(strategy.address, units(500, DECIMALS));
expect(await yVault.getPricePerFullShare()).to.equal(units(2, DECIMALS));
});
Fails with following error: AssertionError: Expected "2000000000000000000" to be equal 2000000
Use vault decimals()
instead of hardcoded 1e18
decimals.
function getPricePerFullShare() external view returns (uint256) {
uint256 supply = totalSupply();
if (supply == 0) return 0;
return (balance() * (10**decimals())) / supply; // @audit-info use `decimals()` instead of hardcoded `1e18`
}
spaghettieth (JPEG'd) disputed and commented:
The
yVault
contract has been designed to work with Curve LPs, which have 18 decimals
LSDan (judge) decreased severity to Medium and commented:
I'm downgrading this to a medium risk but leaving it as valid. Any number of external factors could conspire to result in a non-18 decimal token being used in the future, at which point this code may have been forgotten. A better choice would be to do a decimal check.
[M-08] _swapUniswapV2
may use an improper path
which can cause a loss of the majority of the rewardTokens
Submitted by WatchPug
StrategyPUSDConvex.sol#L311-L334
function harvest(uint256 minOutCurve) external onlyRole(STRATEGIST_ROLE) {
convexConfig.baseRewardPool.getReward(address(this), true);
//Prevent `Stack too deep` errors
{
DexConfig memory dex = dexConfig;
IERC20[] memory rewardTokens = strategyConfig.rewardTokens;
IERC20 _weth = weth;
for (uint256 i = 0; i < rewardTokens.length; i++) {
uint256 balance = rewardTokens[i].balanceOf(address(this));
if (balance > 0)
//minOut is not needed here, we already have it on the Curve deposit
_swapUniswapV2(
dex.uniswapV2,
rewardTokens[i],
_weth,
balance,
0
);
}
uint256 wethBalance = _weth.balanceOf(address(this));
require(wethBalance > 0, "NOOP");
...
StrategyPUSDConvex.sol#L410-L430
function _swapUniswapV2(
IUniswapV2Router router,
IERC20 tokenIn,
IERC20 tokenOut,
uint256 amountIn,
uint256 minOut
) internal {
tokenIn.safeIncreaseAllowance(address(router), amountIn);
address[] memory path = new address[](2);
path[0] = address(tokenIn);
path[1] = address(tokenOut);
router.swapExactTokensForTokens(
amountIn,
minOut,
path,
address(this),
block.timestamp
);
}
In the current implementation, rewardTokens
from the underlying strategy will be swapped to weth
first then weth
-> usdc
.
However, the path
used for swapping from rewardToken
-> weth
is hardcoded as [rewardToken, weth]
, which may not be the optimal route.
For example, the majority liquidity for a particular rewardToken
may actually be in the rewardToken/USDC
pool. Swapping through the rewardToken/WETH
with low liquidity may end up getting only a dust amount of WETH.
Consider allowing the admin to set a path for the rewardTokens.
spaghettieth (JPEG'd) disputed and commented:
As of now, the one in the contract is the optimal routing path.
LSDan (judge) decreased severity to Medium and commented:
I think the warden has made a reasonable find and recommendation. The sponsor used the phrase 'as of now' in disputing the report, but the idea that it may not always be the optimal path is actually specifically what the report and its mitigation addresses. That said, external factors are required so moving it to medium severity.
Submitted by Wayne, also found by hyh, PPrieditis, rayn, smiling_heretic, and Cr4ckM3
yVault.sol#L61
yVaultLPFarming.sol#L54
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.
Modify the code to require(msg.sender == tx.origin);
spaghettieth (JPEG'd) confirmed, but disagreed with Medium severity and commented:
The observations made in the issue are correct, but given how impractical it would be to make an autocompounder that bypasses the
noContract
modifier this issue should probably be severity 0. It's worth mentioning that this behaviour was already known as outlined in the modifier's documentation.
spaghettieth (JPEG'd) resolved and commented:
Fixed in jpegd/core#17.
I'm going to let this stand. Impractical or not, this is very easily exploited.
Submitted by cccz, also found by 0xDjango, 0xkatana, berndartmueller, Cr4ckM3, defsec, horsefacts, hyh, JMukesh, joshie, Jujic, pedroais, peritoflores, rayn, reassor, Ruhum, and WatchPug
According to Chainlink's documentation, the latestAnswer function is deprecated. This function might suddenly stop working if Chainlink stops supporting deprecated APIs. And the old API can return stale data.
FungibleAssetVaultForDAO.sol#L105
NFTVault.sol#L459
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/
spaghettieth (JPEG'd) confirmed
spaghettieth (JPEG'd) resolved and commented:
Fixed in jpegd/core#9.
Submitted by AuditsAreUS, also found by minhquanym
There is a division before multiplication bug in NFTVault._calculateAdditionalInterest()
which may result in no interesting being accrued and will have significant rounding issues for tokens with small decimal places.
This issue occurs since an intermediate calculation of interestPerSec
may round to zero and therefore the multiplication by elapsedTime
may remain zero.
Furthermore, even if interestPerSec > 0
there will still be rounding errors as a result of doing division before multiplication and _calculatedInterest()
will be understated.
This issue is significant as one divisor is 365 days = 30,758,400 (excluding the rate). Since many ERC20 tokens such as USDC and USDT only have 6 decimal places a numerator of less 30 * 10^6 will round to zero.
The rate also multiplies into the denominator. e.g. If the rate is 1% then the denominator will be equivalent to 1 / rate * 30 * 10^6 = 3,000 * 10^6
.
The order of operations for the interest calculations
totalDebtAmount
- MUL
settings.debtInterestApr.numerator
- DIV
settings.debtInterestApr.denominator
- DIV
365 days
- MUL
elapsedTime
If the intermediate value of interestPerSec = 0
then the multiplication by elapsedTime
will still be zero and no interested will be accrued.
Excerpt from NFTVault._calculateAdditionalInterest()
.
uint256 interestPerYear = (totalDebtAmount *
settings.debtInterestApr.numerator) /
settings.debtInterestApr.denominator;
uint256 interestPerSec = interestPerYear / 365 days;
return elapsedTime * interestPerSec;
This issue may be resolved by performing the multiplication by elapsedTime
before the division by the denominator or 365 days
.
uint256 interestAccrued = (elapsedTime *
totalDebtAmount *
settings.debtInterestApr.numerator) /
settings.debtInterestApr.denominator /
365 days;
return interestAccrued;
spaghettieth (JPEG'd) confirmed
spaghettieth (JPEG'd) resolved and commented:
Fixed jpegd/core#8.
This report makes sense as a medium to me because it involves a calculation error that can lead to the protocol functioning incorrectly.
For this contest, 42 reports were submitted by wardens detailing low risk and non-critical issues. The report highlighted below by Dravee received the top score from the judge.
The following wardens also submitted reports: IllIllI, 0xDjango, robee, PPrieditis, Kthere, hickuphh3, 0xkatana, WatchPug, horsefacts, ilan, Ruhum, berndartmueller, hubble, hyh, kebabsec, kenta, Hawkeye, TerrierLover, rfa, catchup, rayn, Foundation, Funen, Picodes, TrungOre, delfin454000, ellahi, JMukesh, 0x1f8b, Cityscape, Jujic, JC, dy, AuditsAreUS, pauliax, cccz, cmichel, reassor, minhquanym, samruna, and jayjonah8.
Overall, the code quality is high.
The findings here revolve around some commonly suggested practices.
As per OpenZeppelin’s (OZ) recommendation, “The guidelines are now to make it impossible for anyone to run initialize
on an implementation contract, by adding an empty constructor with the initializer
modifier. So the implementation contract gets initialized automatically upon deployment.”
Note that this behaviour is also incorporated the OZ Wizard since the UUPS vulnerability discovery: “Additionally, we modified the code generated by the Wizard 19 to include a constructor that automatically initializes the implementation when deployed.”
Furthermore, this thwarts any attempts to frontrun the initialization tx of these contracts:
contracts/helpers/CryptoPunksHelper.sol:
19: function initialize(address punksAddress) external initializer {
contracts/helpers/EtherRocksHelper.sol:
19: function initialize(address rocksAddress) external initializer {
contracts/staking/JPEGStaking.sol:
21: function initialize(IERC20Upgradeable _jpeg) external initializer {
contracts/vaults/FungibleAssetVaultForDAO.sol:
66: function initialize(
contracts/vaults/NFTVault.sol:
139: function initialize(
Consider adding an address(0)
check here (see @audit
):
contracts/farming/LPFarming.sol:
77: jpeg = IERC20(_jpeg); //@audit low: should be address(0) checked just like in yVaultLPFarming.sol and StrategyPUSDConvex.sol
contracts/vaults/yVault/Controller.sol:
28: jpeg = IERC20(_jpeg); //@audit low: should be address(0) checked just like in yVaultLPFarming.sol and StrategyPUSDConvex.sol
contracts/vaults/yVault/yVault.sol:
53: token = ERC20(_token); //@audit low: should be address(0)
As this array can grow quite large, the transaction's gas cost could exceed the block gas limit and make it impossible to call this function at all (see @audit
):
File: LPFarming.sol
141: function add(uint256 _allocPoint, IERC20 _lpToken) external onlyOwner {
...
146: poolInfo.push( //@audit low: a push exist but there's no pop in the solution.
...
154: }
...
347: function claimAll() external nonReentrant noContract(msg.sender) {
348: for (uint256 i = 0; i < poolInfo.length; i++) { //@audit low: poolInfo is unbounded
349: _updatePool(i);
350: _withdrawReward(i);
351: }
...
360: }
Consider introducing a reasonable upper limit based on block gas limits and/or adding a remove
method to remove elements in the array.
It is a good practice to give time for users to react and adjust to critical changes. A timelock provides more guarantees and reduces the level of trust required, thus decreasing risk for users. It also indicates that the project is legitimate.
Consider adding a timelock and event to:
vaults/yVault/strategies/StrategyPUSDConvex.sol:177: function setPerformanceFee(Rate memory _performanceFee)
vaults/NFTVault.sol:290: function setOrganizationFeeRate(Rate memory _organizationFeeRate)
See @audit
:
File: StrategyPUSDConvex.sol
177: function setPerformanceFee(Rate memory _performanceFee)
178: public
179: onlyRole(DEFAULT_ADMIN_ROLE)
180: {
181: require(
182: _performanceFee.denominator > 0 &&
183: _performanceFee.denominator >= _performanceFee.numerator,
184: "INVALID_RATE"
185: );
186: performanceFee = _performanceFee; //@audit low: fee can be 100% (_performanceFee.denominator == _performanceFee.numerator)
187: }
See @audit
:
File: NFTVault.sol
290: function setOrganizationFeeRate(Rate memory _organizationFeeRate)
291: external
292: onlyRole(DAO_ROLE)
293: {
294: _validateRate(_organizationFeeRate);
295: settings.organizationFeeRate = _organizationFeeRate; //@audit low: fee can be 100%
296: }
...
400: function _validateRate(Rate memory rate) internal pure {
401: require(
402: rate.denominator > 0 && rate.denominator >= rate.numerator, //@audit low: fee can be 100% (rate.denominator == rate.numerator)
403: "invalid_rate"
404: );
405: }
farming/LPFarming.sol:196: 1e36 *
farming/LPFarming.sol:207: 1e36;
farming/LPFarming.sol:307: 1e36 *
farming/LPFarming.sol:319: 1e36;
farming/yVaultLPFarming.sol:94: 1e36;
farming/yVaultLPFarming.sol:172: newAccRewardsPerShare = accRewardPerShare + newRewards * 1e36 / totalStaked;
farming/yVaultLPFarming.sol:179: (accRewardPerShare - userLastAccRewardPerShare[account])) / 1e36;
I suggest using constant
variables as this would make the code more maintainable and readable while costing nothing gas-wise.
The pragma declared across the solution is ^0.8.0
. As the compiler introduces a several interesting upgrades in Solidity 0.8.4
, consider locking at this version or a more recent one.
The following maps
should be grouped in structs.
From:
contracts/farming/yVaultLPFarming.sol:
31: mapping(address => uint256) public balanceOf; //@audit NC: related data 1
32: mapping(address => uint256) private userLastAccRewardPerShare;//@audit NC: related data 2
33: mapping(address => uint256) private userPendingRewards;//@audit NC: related data 3
contracts/vaults/yVault/Controller.sol:
20: mapping(IERC20 => address) public vaults; //@audit NC: related data 1
21: mapping(IERC20 => IStrategy) public strategies; //@audit NC: related data 2
22: mapping(IERC20 => mapping(IStrategy => bool)) public approvedStrategies; //@audit NC: related data 3
To
struct UserInfo {
uint256 balance;
uint256 lastAccRewardPerShare;
uint256 pendingReward;
}
mapping(address => UserInfo) public userInfo;
And
struct TokenInfo {
address vaults;
IStrategy approvedStrategies;
mapping(IStrategy => bool) pendingReward;
}
mapping(IERC20 => TokenInfo) public tokenInfo;
It would be less error-prone, more readable, and it would be possible to delete all related fields with a simple delete userInfo[address]
.
However, the sponsor should notice that pendingReward
won't be as easily deleted in tokenInfo
, as it's a mapping
field. It would still improve code quality nonetheless.
Using both named returns and a return statement isn't necessary. Removing one of those can improve code clarity (see @audit
):
File: NFTEscrow.sol
081: function precompute(address _owner, uint256 _idx)
082: public
083: view
084: returns (bytes32 salt, address predictedAddress) //@audit NC: unused named returns
085: {
...
091: salt = sha256(abi.encodePacked(_owner));
...
105: predictedAddress = address(uint160(uint256(hash)));
106: return (salt, predictedAddress); //@audit NC: unused named returns
107: }
spaghettieth (JPEG'd) acknowledged
I agree with the warden's security ratings for the issues described.
For this contest, 38 reports were submitted by wardens detailing gas optimizations. The report highlighted below by Dravee received the top score from the judge.
The following wardens also submitted reports: IllIllI, rfa, 0xkatana, TerrierLover, saian, Tomio, 0xNazgul, Funen, robee, Foundation, WatchPug, catchup, 0v3rf10w, berndartmueller, ellahi, securerodd, 0x1f8b, Cityscape, FSchmoede, hickuphh3, ilan, kenta, slywaters, rokinot, 0xDjango, JMukesh, nahnah, PPrieditis, sorrynotsorry, delfin454000, Hawkeye, Meta0xNull, dirk_y, rayn, Cr4ckM3, Picodes, and kebabsec.
See original submission.
See @audit
tag:
67: function _executeTransfer(address _owner, uint256 _idx) internal {
68: (bytes32 salt, ) = precompute(_owner, _idx);
69: new FlashEscrow{salt: salt}( //@audit gas: deployment can cost less through clones
70: nftAddress,
71: _encodeFlashEscrowPayload(_idx)
72: );
73: }
There's a way to save a significant amount of gas on deployment using Clones: https://www.youtube.com/watch?v=3Mw-pMmJ7TA .
This is a solution that was adopted, as an example, by Porter Finance. They realized that deploying using clones was 10x cheaper:
I suggest applying a similar pattern.
Solidity version 0.8+ comes with implicit overflow and underflow checks on unsigned integers. When an overflow or an underflow isn't possible (as an example, when a comparison is made before the arithmetic operation), some gas can be saved by using an unchecked
block: https://docs.soliditylang.org/en/v0.8.10/control-structures.html#checked-or-unchecked-arithmetic
I suggest wrapping with an unchecked
block here (see @audit
tag):
107: function newEpoch(
...
111: ) external onlyOwner {
127: if (remainingRewards > newRewards) {
128: jpeg.safeTransfer(msg.sender, remainingRewards - newRewards); //@audit gas: should be unchecked (can't underflow due to L127)
129: } else if (remainingRewards < newRewards) {
130: jpeg.safeTransferFrom(
131: msg.sender,
132: address(this),
133: newRewards - remainingRewards //@audit gas: should be unchecked (can't underflow due to L129)
134: );
135: }
136: }
See @audit
tag:
235: function withdraw(uint256 _pid, uint256 _amount)
236: external
237: noContract(msg.sender)
238: {
239: require(_amount > 0, "invalid_amount");
240:
241: PoolInfo storage pool = poolInfo[_pid];
242: UserInfo storage user = userInfo[_pid][msg.sender];
243: require(user.amount >= _amount, "insufficient_amount");
244:
245: _updatePool(_pid);
246: _withdrawReward(_pid);
247:
248: user.amount -= _amount; //@audit gas: should be unchecked (can't underflow due to L243)
The code can be optimized by minimising the number of SLOADs. SLOADs are expensive (100 gas) compared to MLOADs/MSTOREs (3 gas). Here, the storage value should get cached in memory (see the @audit
tags for further details):
315: function _withdrawReward(uint256 _pid) internal returns (uint256) {
316: UserInfo storage user = userInfo[_pid][msg.sender];
317: uint256 pending = (user.amount *
318: (poolInfo[_pid].accRewardPerShare - user.lastAccRewardPerShare)) / //@audit gas: SLOAD 1 poolInfo[_pid].accRewardPerShare
319: 1e36;
320: if (pending > 0) {
321: userRewards[msg.sender] += pending;
322: }
323:
324: user.lastAccRewardPerShare = poolInfo[_pid].accRewardPerShare; //@audit gas: SLOAD 2 poolInfo[_pid].accRewardPerShare
325:
326: return pending;
327: }
See @audit
tag:
117: function withdraw(uint256 _amount) external noContract(msg.sender) {
118: require(_amount > 0, "invalid_amount");
119: require(balanceOf[msg.sender] >= _amount, "insufficient_amount");
120:
121: _update();
122: _withdrawReward(msg.sender);
123:
124: balanceOf[msg.sender] -= _amount; //@audit gas: should be unchecked (can't underflow due to L119)
See @audit
tags:
177: function _withdrawReward(address account) internal returns (uint256) {
178: uint256 pending = (balanceOf[account] *
179: (accRewardPerShare - userLastAccRewardPerShare[account])) / 1e36; //@audit gas: SLOAD 1 accRewardPerShare
180:
181: if (pending > 0) userPendingRewards[account] += pending;
182:
183: userLastAccRewardPerShare[account] = accRewardPerShare; //@audit gas: SLOAD 2 accRewardPerShare
184:
185: return pending;
186: }
See @audit
tag:
68: function unlock(uint256 _nftIndex) external nonReentrant {
69: LockPosition memory position = positions[_nftIndex]; //@audit gas: costing 3 SLOADs while only lockAmount is needed twice. Replace "memory" with "storage" and cache only position.lockAmount
70: require(position.owner == msg.sender, "unauthorized");
71: require(position.unlockAt <= block.timestamp, "locked");
72:
73: delete positions[_nftIndex];
74:
75: jpeg.safeTransfer(msg.sender, position.lockAmount);
76:
77: emit Unlock(msg.sender, _nftIndex, position.lockAmount);
78: }
Here, a copy in memory is costing 3 SLOADs and 3 MSTORES. The, 2 variables are only read once through MLOAD (position.owner
and position.unlockAt
) and one is read twice (position.lockAmount
).
I suggest replacing the memory
keyword with storage
at L69 and only copying position.lockAmount
in memory.
See @audit
tags:
104: function _collateralPriceUsd() internal view returns (uint256) {
105: int256 answer = oracle.latestAnswer(); //@audit gas: SLOAD 1 oracle
106: uint8 decimals = oracle.decimals(); //@audit gas: SLOAD 2 oracle
107:
See @audit
tag:
104: function _collateralPriceUsd() internal view returns (uint256) {
...
111: return //@audit gas: whole return statement should be unchecked (obviously can't underflow/overflow here)
112: decimals > 18
113: ? uint256(answer) / 10**(decimals - 18)
114: : uint256(answer) * 10**(18 - decimals);
115: }
Due to the ternary condition and the fact that int256 answer = oracle.latestAnswer();
, the return statement can't underflow and should be unchecked.
See @audit
tags:
141: function deposit(uint256 amount) external payable onlyRole(WHITELISTED_ROLE) {
142: require(amount > 0, "invalid_amount");
143:
144: if (collateralAsset == ETH) { //@audit gas: SLOAD 1 collateralAsset
145: require(msg.value == amount, "invalid_msg_value");
146: } else {
147: require(msg.value == 0, "non_zero_eth_value");
148: IERC20Upgradeable(collateralAsset).safeTransferFrom( //@audit gas: SLOAD 2 collateralAsset
149: msg.sender,
150: address(this),
151: amount
152: );
153: }
See @audit
tag:
179: function repay(uint256 amount) external onlyRole(WHITELISTED_ROLE) nonReentrant {
180: require(amount > 0, "invalid_amount");
181:
182: amount = amount > debtAmount ? debtAmount : amount;
183:
184: debtAmount -= amount; //@audit gas: should be unchecked (can't underflow due to L182)
See @audit
tag:
193: function withdraw(uint256 amount) external onlyRole(WHITELISTED_ROLE) nonReentrant {
194: require(amount > 0 && amount <= collateralAmount, "invalid_amount");
195:
196: uint256 creditLimit = getCreditLimit(collateralAmount - amount); //@audit gas: should be unchecked (can't underflow due to L194)
See @audit
tags:
193: function withdraw(uint256 amount) external onlyRole(WHITELISTED_ROLE) nonReentrant {
194: require(amount > 0 && amount <= collateralAmount, "invalid_amount"); //@audit gas: SLOAD 1 collateralAmount
195:
196: uint256 creditLimit = getCreditLimit(collateralAmount - amount); //@audit gas: SLOAD 2 collateralAmount
197: require(creditLimit >= debtAmount, "insufficient_credit");
198:
199: collateralAmount -= amount; //@audit gas: SLOAD 3 collateralAmount (could've used the a cached value for calculation)
See @audit
tag:
454: function _normalizeAggregatorAnswer(IAggregatorV3Interface aggregator)
455: internal
456: view
457: returns (uint256)
458: {
...
464: return //@audit gas: whole return statement should be unchecked (obviously can't underflow/overflow)
465: decimals > 18
466: ? uint256(answer) / 10**(decimals - 18)
467: : uint256(answer) * 10**(18 - decimals);
468: }
See @audit
tags:
578: function _calculateAdditionalInterest() internal view returns (uint256) {
...
585: if (totalDebtAmount == 0) { //@audit gas: SLOAD 1 totalDebtAmount
586: return 0;
587: }
588:
589: // Accrue interest
590: uint256 interestPerYear = (totalDebtAmount * //@audit gas: SLOAD 2 totalDebtAmount
From (see @audit
tags):
610: struct PositionPreview { // @audit gas: can be tightly packed by moving borrowType and liquidatable at the end
611: address owner;
612: uint256 nftIndex;
613: bytes32 nftType;
614: uint256 nftValueUSD;
615: VaultSettings vaultSettings;
616: uint256 creditLimit;
617: uint256 debtPrincipal;
618: uint256 debtInterest; // @audit gas: 32 bytes
619: BorrowType borrowType; // @audit gas: 1 byte (this enum is equivalent to uint8 as it has less than 256 options)
620: bool liquidatable; // @audit gas: 1 byte
621: uint256 liquidatedAt; // @audit gas: 32 bytes
622: address liquidator; // @audit gas: 20 bytes
623: }
To:
struct PositionPreview {
address owner;
uint256 nftIndex;
bytes32 nftType;
uint256 nftValueUSD;
VaultSettings vaultSettings;
uint256 creditLimit;
uint256 debtPrincipal;
uint256 debtInterest; // @audit gas: 32 bytes
uint256 liquidatedAt; // @audit gas: 32 bytes
BorrowType borrowType; // @audit gas: 1 byte (this enum is equivalent to uint8 as it has less than 256 options)
bool liquidatable; // @audit gas: 1 byte
address liquidator; // @audit gas: 20 bytes
}
See @audit
tag:
File: NFTVault.sol
628: function showPosition(uint256 _nftIndex)
...
649: if (debtPrincipal > debtAmount) debtAmount = debtPrincipal;
...
659: debtInterest: debtAmount - debtPrincipal, //@audit gas: should be unchecked (can't underflow due to L649)
See @audit
tags:
File: NFTVault.sol
628: function showPosition(uint256 _nftIndex)
...
661: liquidatable: positions[_nftIndex].liquidatedAt == 0 && //@audit gas: SLOAD 1 positions[_nftIndex].liquidatedAt
662: debtAmount >= _getLiquidationLimit(_nftIndex),
663: liquidatedAt: positions[_nftIndex].liquidatedAt, //@audit gas: SLOAD 2 positions[_nftIndex].liquidatedAt
[G-19] NFTVault.showPosition()
: Help the optimizer by saving a storage variable's reference instead of repeatedly fetching it (positions[_nftIndex]
)
To help the optimizer, declare a storage
type variable and use it instead of repeatedly fetching the reference in a map or an array.
The effect can be quite significant.
Here, instead of repeatedly calling positions[_nftIndex]
, save its reference like this: Position storage _position = positions[_nftIndex]
and use it.
Impacted lines (see @audit
tags):
636: uint256 debtPrincipal = positions[_nftIndex].debtPrincipal; //@audit gas: use the suggested storage variable "Position storage _position"
637: uint256 debtAmount = positions[_nftIndex].liquidatedAt > 0 //@audit gas: use the suggested storage variable "Position storage _position"
638: ? positions[_nftIndex].debtAmountForRepurchase //@audit gas: use the suggested storage variable "Position storage _position"
641: positions[_nftIndex].debtPortion, //@audit gas: use the suggested storage variable "Position storage _position"
660: borrowType: positions[_nftIndex].borrowType, //@audit gas: use the suggested storage variable "Position storage _position"
661: liquidatable: positions[_nftIndex].liquidatedAt == 0 && //@audit gas: use the suggested storage variable "Position storage _position"
663: liquidatedAt: positions[_nftIndex].liquidatedAt, //@audit gas: use the suggested storage variable "Position storage _position"
664: liquidator: positions[_nftIndex].liquidator //@audit gas: use the suggested storage variable "Position storage _position"
This practice already exists in the solution, as seen in NFTVault.borrow()
:
675: function borrow(
...
697: Position storage position = positions[_nftIndex];
See @audit
tags:
675: function borrow(
...
735: if (totalDebtPortion == 0) { //@audit gas: SLOAD 1 totalDebtPortion
...
738: } else {
739: uint256 plusPortion = (totalDebtPortion * _amount) / //@audit gas: SLOAD 2 totalDebtPortion
740: totalDebtAmount;
741: totalDebtPortion += plusPortion; //@audit gas: SLOAD 3 totalDebtPortion (could've used cached value for calculation)
See @audit
tag:
756: function repay(uint256 _nftIndex, uint256 _amount)
...
780: uint256 paidPrincipal = _amount > debtInterest
781: ? _amount - debtInterest //@audit gas: should be unchecked (obviously)
782: : 0;
See @audit
tags:
756: function repay(uint256 _nftIndex, uint256 _amount)
...
784: uint256 minusPortion = paidPrincipal == debtPrincipal
785: ? position.debtPortion
786: : (totalDebtPortion * _amount) / totalDebtAmount; //@audit gas: SLOADs 1 totalDebtPortion & totalDebtAmount
787:
788: totalDebtPortion -= minusPortion; //@audit gas: SLOAD 2 totalDebtPortion (could've used cached value for calculation)
...
791: totalDebtAmount -= _amount; //@audit gas: SLOAD 2 totalDebtAmount (could've used cached value for calculation)
Comparing to a constant (true
or false
) is a bit more expensive than directly checking the returned boolean value.
I suggest using if(directValue)
instead of if(directValue == true)
and if(!directValue)
instead of if(directValue == false)
here (see @audit
tag):
82: function setStrategy(IERC20 _token, IStrategy _strategy)
83: external
84: onlyRole(STRATEGIST_ROLE)
85: {
86: require(
87: approvedStrategies[_token][_strategy] == true, //@audit gas: instead of comparing to a constant, just use "approvedStrategies[_token][_strategy]"
See @audit
tags:
226: function balanceOfJPEG() external view returns (uint256) {
227: uint256 availableBalance = jpeg.balanceOf(address(this)); //@audit gas: SLOAD 1 jpeg
...
231: for (uint256 i = 0; i < length; i++) {
...
233: if (address(jpeg) == extraReward.rewardToken()) { //@audit gas: SLOADs in Loop for jpeg. Cache it at L227
See @audit
tag:
226: function balanceOfJPEG() external view returns (uint256) {
...
231: for (uint256 i = 0; i < length; i++) {
...
233: if (address(jpeg) == extraReward.rewardToken()) {
234: availableBalance += extraReward.earned();
235: //we found jpeg, no need to continue the loop
236: break; //@audit gas: instead of adding to availableBalance & breaking, just return here "availableBalance + extraReward.earned()"
237: }
238: }
239:
240: return availableBalance;
241: }
Here, instead of making a memory operation with availableBalance += extraReward.earned();
and then using break;
before returning the memory variable availableBalance
, it would've been more optimized to just return availableBalance + extraReward.earned()
:
function balanceOfJPEG() external view returns (uint256) {
...
for (uint256 i = 0; i < length; i++) {
...
if (address(jpeg) == extraReward.rewardToken()) {
return availableBalance + extraReward.earned();
}
}
}
See @audit
tag:
273: function withdraw(uint256 _amount) external onlyController {
...
279: if (balance < _amount)
280: convexConfig.baseRewardPool.withdrawAndUnwrap(
281: _amount - balance, //@audit gas: should be unchecked (can't underflow due to L279)
282: false
283: );
[G-27] StrategyPUSDConvex.harvest()
: L362 should be unchecked due to L359-L360 and how performanceFee is set L183
See @audit
tags:
177: function setPerformanceFee(Rate memory _performanceFee)
...
181: require(
182: _performanceFee.denominator > 0 &&
183: _performanceFee.denominator >= _performanceFee.numerator, //@audit gas: can uncheck L362 thanks to this
184: "INVALID_RATE"
185: );
...
311: function harvest(uint256 minOutCurve) external onlyRole(STRATEGIST_ROLE) {
...
359: uint256 fee = (usdcBalance * performanceFee.numerator) /
360: performanceFee.denominator;
361: usdc.safeTransfer(strategy.controller.feeAddress(), fee);
362: usdcBalance -= fee; //@audit gas: should be unchecked (can't underflow due to L359-L360 & how performanceFee is set L183)
See @audit
tags:
129: function earn() external {
130: uint256 _bal = available();
131: token.safeTransfer(address(controller), _bal); //@audit gas: SLOADs 1 token & controller
132: controller.earn(address(token), _bal); //@audit gas: SLOADs 2 token & controller
133: }
See @audit
tag:
166: function withdraw(uint256 _shares) public noContract(msg.sender) {
...
177: if (vaultBalance < backingTokens) {
178: uint256 toWithdraw = backingTokens - vaultBalance; //@audit gas: should be unchecked (can't underflow due to L177)
See @audit
tags:
166: function withdraw(uint256 _shares) public noContract(msg.sender) {
...
176: uint256 vaultBalance = token.balanceOf(address(this)); //@audit gas: SLOAD 1 token
177: if (vaultBalance < backingTokens) {
178: uint256 toWithdraw = backingTokens - vaultBalance;
179: controller.withdraw(address(token), toWithdraw); //@audit gas: SLOAD 2 token
180: }
181:
182: token.safeTransfer(msg.sender, backingTokens); //@audit gas: SLOAD 3 token
See @audit
tags:
187: function withdrawJPEG() external {
188: require(farm != address(0), "NO_FARM"); //@audit gas: SLOAD 1 farm
189: controller.withdrawJPEG(address(token), farm); //@audit gas: SLOAD 2 farm
190: }
Across the whole solution, the declared pragma is ^0.8.0
.
Using newer compiler versions and the optimizer give gas optimizations. Also, additional safety checks are available for free.
The advantages here are:
- Low level inliner (>= 0.8.2): Cheaper runtime gas (especially relevant when the contract has small functions).
- Optimizer improvements in packed structs (>= 0.8.3)
- Custom errors (>= 0.8.4): cheaper deployment cost and runtime cost. Note: the runtime cost is only relevant when the revert condition is met. In short, replace revert strings by custom errors.
Consider upgrading pragma to at least 0.8.4:
If a variable is not set/initialized, it is assumed to have the default value (0
for uint
, false
for bool
, address(0)
for address...). Explicitly initializing it with its default value is an anti-pattern and wastes gas.
As an example: for (uint256 i = 0; i < numIterations; ++i) {
should be replaced with for (uint256 i; i < numIterations; ++i) {
Instances include:
farming/LPFarming.sol:281: for (uint256 pid = 0; pid < length; ++pid) {
farming/LPFarming.sol:348: for (uint256 i = 0; i < poolInfo.length; i++) {
vaults/yVault/strategies/StrategyPUSDConvex.sol:145: for (uint256 i = 0; i < _strategyConfig.rewardTokens.length; i++) {
vaults/yVault/strategies/StrategyPUSDConvex.sol:231: for (uint256 i = 0; i < length; i++) {
vaults/yVault/strategies/StrategyPUSDConvex.sol:319: for (uint256 i = 0; i < rewardTokens.length; i++) {
vaults/FungibleAssetVaultForDAO.sol:45: address internal constant ETH = address(0);
vaults/NFTVault.sol:181: for (uint256 i = 0; i < _typeInitializers.length; i++) {
vaults/NFTVault.sol:184: for (uint256 j = 0; j < initializer.nfts.length; j++) {
I suggest removing explicit initializations for default values.
!= 0
costs less gas compared to > 0
for unsigned integers in require
statements with the optimizer enabled (6 gas)
Proof: While it may seem that > 0
is cheaper than !=
, this is only true without the optimizer enabled and outside a require statement. If you enable the optimizer at 10k AND you're in a require
statement, this will save gas. You can see this tweet for more proofs: https://twitter.com/gzeon/status/1485428085885640706
I suggest changing > 0
with != 0
here:
farming/LPFarming.sol:114: require(_rewardPerBlock > 0, "Invalid reward per block");
farming/LPFarming.sol:218: require(_amount > 0, "invalid_amount");
farming/LPFarming.sol:239: require(_amount > 0, "invalid_amount");
farming/LPFarming.sol:337: require(rewards > 0, "no_reward");
farming/LPFarming.sol:354: require(rewards > 0, "no_reward");
farming/yVaultLPFarming.sol:101: require(_amount > 0, "invalid_amount");
farming/yVaultLPFarming.sol:118: require(_amount > 0, "invalid_amount");
farming/yVaultLPFarming.sol:139: require(rewards > 0, "no_reward");
lock/JPEGLock.sol:40: require(_newTime > 0, "Invalid lock time");
staking/JPEGStaking.sol:32: require(_amount > 0, "invalid_amount");
vaults/yVault/strategies/StrategyPUSDConvex.sol:182: _performanceFee.denominator > 0 &&
vaults/yVault/strategies/StrategyPUSDConvex.sol:334: require(wethBalance > 0, "NOOP");
vaults/yVault/yVault.sol:100: _rate.numerator > 0 && _rate.denominator >= _rate.numerator,
vaults/yVault/yVault.sol:143: require(_amount > 0, "INVALID_AMOUNT");
vaults/yVault/yVault.sol:167: require(_shares > 0, "INVALID_AMOUNT");
vaults/yVault/yVault.sol:170: require(supply > 0, "NO_TOKENS_DEPOSITED");
vaults/FungibleAssetVaultForDAO.sol:94: _creditLimitRate.denominator > 0 &&
vaults/FungibleAssetVaultForDAO.sol:108: require(answer > 0, "invalid_oracle_answer");
vaults/FungibleAssetVaultForDAO.sol:142: require(amount > 0, "invalid_amount");
vaults/FungibleAssetVaultForDAO.sol:164: require(amount > 0, "invalid_amount");
vaults/FungibleAssetVaultForDAO.sol:180: require(amount > 0, "invalid_amount");
vaults/FungibleAssetVaultForDAO.sol:194: require(amount > 0 && amount <= collateralAmount, "invalid_amount");
vaults/NFTVault.sol:278: require(_newFloor > 0, "Invalid floor");
vaults/NFTVault.sol:327: _type == bytes32(0) || nftTypeValueETH[_type] > 0,
vaults/NFTVault.sol:365: require(pendingValue > 0, "no_pending_value");
vaults/NFTVault.sol:402: rate.denominator > 0 && rate.denominator >= rate.numerator,
vaults/NFTVault.sol:462: require(answer > 0, "invalid_oracle_answer");
vaults/NFTVault.sol:687: require(_amount > 0, "invalid_amount");
vaults/NFTVault.sol:764: require(_amount > 0, "invalid_amount");
vaults/NFTVault.sol:770: require(debtAmount > 0, "position_not_borrowed");
vaults/NFTVault.sol:882: require(position.liquidatedAt > 0, "not_liquidated");
vaults/NFTVault.sol:926: require(position.liquidatedAt > 0, "not_liquidated");
Also, please enable the Optimizer.
Strict inequalities (>
) are more expensive than non-strict ones (>=
). This is due to some supplementary checks (ISZERO, 3 gas)
I suggest using >=
instead of >
to avoid some opcodes here:
vaults/NFTVault.sol:539: return principal > calculatedDebt ? principal : calculatedDebt;
vaults/NFTVault.sol:775: _amount = _amount > debtAmount ? debtAmount : _amount;
Reading array length at each iteration of the loop takes 6 gas (3 for mload and 3 to place memory_offset) in the stack.
Caching the array length in the stack saves around 3 gas per iteration.
Here, I suggest storing the array's length in a variable before the for-loop, and use it instead:
farming/LPFarming.sol:348: for (uint256 i = 0; i < poolInfo.length; i++) {
vaults/yVault/strategies/StrategyPUSDConvex.sol:145: for (uint256 i = 0; i < _strategyConfig.rewardTokens.length; i++) {
vaults/yVault/strategies/StrategyPUSDConvex.sol:319: for (uint256 i = 0; i < rewardTokens.length; i++) {
vaults/NFTVault.sol:181: for (uint256 i = 0; i < _typeInitializers.length; i++) {
vaults/NFTVault.sol:184: for (uint256 j = 0; j < initializer.nfts.length; j++) {
This is already done here:
farming/LPFarming.sol:281: for (uint256 pid = 0; pid < length; ++pid) {
vaults/yVault/strategies/StrategyPUSDConvex.sol:231: for (uint256 i = 0; i < length; i++) {
++i
costs less gas compared to i++
or i += 1
for unsigned integer, as pre-increment is cheaper (about 5 gas per iteration). This statement is true even with the optimizer enabled.
i++
increments i
and returns the initial value of i
. Which means:
uint i = 1;
i++; // == 1 but i == 2
But ++i
returns the actual incremented value:
uint i = 1;
++i; // == 2 and i == 2 too, so no need for a temporary variable
In the first case, the compiler has to create a temporary variable (when used) for returning 1
instead of 2
Instances include:
farming/LPFarming.sol:348: for (uint256 i = 0; i < poolInfo.length; i++) {
vaults/yVault/strategies/StrategyPUSDConvex.sol:145: for (uint256 i = 0; i < _strategyConfig.rewardTokens.length; i++) {
vaults/yVault/strategies/StrategyPUSDConvex.sol:231: for (uint256 i = 0; i < length; i++) {
vaults/yVault/strategies/StrategyPUSDConvex.sol:319: for (uint256 i = 0; i < rewardTokens.length; i++) {
vaults/NFTVault.sol:181: for (uint256 i = 0; i < _typeInitializers.length; i++) {
vaults/NFTVault.sol:184: for (uint256 j = 0; j < initializer.nfts.length; j++) {
I suggest using ++i
instead of i++
to increment the value of an uint variable.
This is already done here:
farming/LPFarming.sol:281: for (uint256 pid = 0; pid < length; ++pid) {
In Solidity 0.8+, there's a default overflow check on unsigned integers. It's possible to uncheck this in for-loops and save some gas at each iteration, but at the cost of some code readability, as this uncheck cannot be made inline.
Instances include:
farming/LPFarming.sol:281: for (uint256 pid = 0; pid < length; ++pid) {
farming/LPFarming.sol:348: for (uint256 i = 0; i < poolInfo.length; i++) {
vaults/yVault/strategies/StrategyPUSDConvex.sol:145: for (uint256 i = 0; i < _strategyConfig.rewardTokens.length; i++) {
vaults/yVault/strategies/StrategyPUSDConvex.sol:231: for (uint256 i = 0; i < length; i++) {
vaults/yVault/strategies/StrategyPUSDConvex.sol:319: for (uint256 i = 0; i < rewardTokens.length; i++) {
vaults/NFTVault.sol:181: for (uint256 i = 0; i < _typeInitializers.length; i++) {
vaults/NFTVault.sol:184: for (uint256 j = 0; j < initializer.nfts.length; j++) {
The code would go from:
for (uint256 i; i < numIterations; i++) {
// ...
}
to:
for (uint256 i; i < numIterations;) {
// ...
unchecked { ++i; }
}
The risk of overflow is inexistant for a uint256
here.
When arguments are read-only on external functions, the data location should be calldata
:
contracts/vaults/NFTVault.sol:
212: function setDebtInterestApr(Rate memory _debtInterestApr) //@audit gas: should be calldata
222: function setValueIncreaseLockRate(Rate memory _valueIncreaseLockRate) //@audit gas: should be calldata
232: function setCreditLimitRate(Rate memory _creditLimitRate) //@audit gas: should be calldata
247: function setLiquidationLimitRate(Rate memory _liquidationLimitRate) //@audit gas: should be calldata
290: function setOrganizationFeeRate(Rate memory _organizationFeeRate) //@audit gas: should be calldata
300: function setInsurancePurchaseRate(Rate memory _insurancePurchaseRate) //@audit gas: should be calldata
311: Rate memory _insuranceLiquidationPenaltyRate //@audit gas: should be calldata
Reducing from public
to private
or internal
can save gas when a constant isn't used outside of its contract.
I suggest changing the visibility from public
to internal
or private
here:
tokens/JPEG.sol:10: bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
tokens/StableCoin.sol:22: bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
tokens/StableCoin.sol:23: bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE");
vaults/yVault/strategies/StrategyPUSDConvex.sol:66: bytes32 public constant STRATEGIST_ROLE = keccak256("STRATEGIST_ROLE");
vaults/yVault/Controller.sol:15: bytes32 public constant STRATEGIST_ROLE = keccak256("STRATEGIST_ROLE");
vaults/FungibleAssetVaultForDAO.sol:41: bytes32 public constant WHITELISTED_ROLE = keccak256("WHITELISTED_ROLE");
vaults/NFTVault.sol:71: bytes32 public constant DAO_ROLE = keccak256("DAO_ROLE");
vaults/NFTVault.sol:72: bytes32 public constant LIQUIDATOR_ROLE = keccak256("LIQUIDATOR_ROLE");
vaults/NFTVault.sol:74: bytes32 public constant CUSTOM_NFT_HASH = keccak256("CUSTOM");
The following functions could be set external to save gas and improve code quality. External call cost is less expensive than of public functions.
withdraw(IERC20,uint256) should be declared external:
- Controller.withdraw(IERC20,uint256) (contracts/vaults/yVault/Controller.sol#151-154)
setFarmingPool(address) should be declared external:
- YVault.setFarmingPool(address) (contracts/vaults/yVault/yVault.sol#115-118)
Shortening revert strings to fit in 32 bytes will decrease deployment time gas and will decrease runtime gas when the revert condition is met.
Revert strings that are longer than 32 bytes require at least one additional mstore, along with additional overhead for computing memory offset, etc.
Revert strings > 32 bytes:
tokens/JPEG.sol:23: "JPEG: must have minter role to mint"
tokens/StableCoin.sol:41: "StableCoin: must have minter role to mint"
tokens/StableCoin.sol:55: "StableCoin: must have pauser role to pause"
tokens/StableCoin.sol:69: "StableCoin: must have pauser role to unpause"
vaults/NFTVault.sol:394: "credit_rate_exceeds_or_equals_liquidation_rate"
I suggest shortening the revert strings to fit in 32 bytes, or using custom errors as described next.
Custom errors from Solidity 0.8.4 are cheaper than revert strings (cheaper deployment cost and runtime cost when the revert condition is met)
Source: https://blog.soliditylang.org/2021/04/21/custom-errors/:
Starting from Solidity v0.8.4, there is a convenient and gas-efficient way to explain to users why an operation failed through the use of custom errors. Until now, you could already use strings to give more information about failures (e.g.,
revert("Insufficient funds.");
), but they are rather expensive, especially when it comes to deploy cost, and it is difficult to use dynamic information in them.
Custom errors are defined using the error
statement, which can be used inside and outside of contracts (including interfaces and libraries).
See original submission for instances.
spaghettieth (JPEG'd) confirmed and commented:
Very high quality report, may implement some of your suggestions. Thank you!
spaghettieth (JPEG'd) resolved and commented:
Implemented most of your suggestions in jpegd/core#21. Custom errors have not been implemented as it would be too big of a change at this point and doing it properly may cause unexpected behaviour/bugs due to the codebase changing too much.
C4 is an open organization governed by participants in the community.
C4 Contests incentivize the discovery of exploits, vulnerabilities, and bugs in smart contracts. Security researchers are rewarded at an increasing rate for finding higher-risk issues. Contest submissions are judged by a knowledgeable security researcher and solidity developer and disclosed to sponsoring developers. C4 does not conduct formal verification regarding the provided code but instead provides final verification.
C4 does not provide any guarantee or warranty regarding the security of this project. All smart contract software should be used at the sole risk and responsibility of users.