Platform: Code4rena
Start Date: 07/04/2022
Pot Size: $100,000 USDC
Total HM: 20
Participants: 62
Period: 7 days
Judge: LSDan
Total Solo HM: 11
Id: 107
League: ETH
Rank: 2/62
Findings: 7
Award: $14,085.37
🌟 Selected for report: 4
🚀 Solo Findings: 1
775.8898 USDC - $775.89
https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/vaults/yVault/yVault.sol#L148-L153
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”.
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.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)); });
totalSupply() == 0
, send the first min liquidity LP tokens to the zero address to enable share dilution.require(_shares != 0, "zero shares minted");
#0 - spaghettieth
2022-04-14T14:32:23Z
🌟 Selected for report: hickuphh3
Also found by: 0x1f8b, AuditsAreUS, Foundation, Kthere, Meta0xNull, WatchPug, rayn
471.3531 USDC - $471.35
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
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 valueowner
) does the same for the same NFT indexuser
will be unable to withdraw his locked JPEG because schedule has been overwrittenInsert 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"); });
// in JPEGLock#lockFor() LockPosition memory existingPosition = positions[_nftIndex]; if (existingPosition.owner != address(0)) { // release jpegs to existing owner jpeg.safeTransfer(existingPosition.owner, existingPosition.lockAmount); }
finalizePendingNFTValueETH()
there is an existing lock schedule. This is less desirable IMO, as there is a use-case for increasing / decreasing the NFT value.#0 - spaghettieth
2022-04-11T17:19:28Z
Closed by mistake
#1 - spaghettieth
2022-04-14T14:15:09Z
Fixed in https://github.com/jpegd/core/pull/3
🌟 Selected for report: hickuphh3
7883.8572 USDC - $7,883.86
yVault users participating in the farm have to trust that:
vault.balanceOfJPEG()
returns the correct claimable JPEG amount by its strategy / strategiesShould either of these assumptions break, then it could be possibly be the case that currentBalance
is less than previousBalance
, causing deposits and crucially, withdrawals to fail due to subtraction overflow.
For instance,
yVault
, then withdrawJPEG()
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)'); });
vault.balanceOfJPEG()
to report a smaller amount than previously recordedjpeg
could be accidentally included in the StrategyConfig, resulting in JPEG being converted to other assetsjpeg
to be claimedA simple fix would be to return
if currentBalance ≤ previousBalance
. A full fix would properly handle potential shortfall.
if (currentBalance <= previousBalance) return;
#0 - spaghettieth
2022-04-12T15:18:44Z
The issue can be reproduced, but due to the extremely specific cases in which this happens the severity should be lowered to 2
#1 - spaghettieth
2022-04-14T14:26:24Z
Fixed in https://github.com/jpegd/core/pull/7
#2 - dmvt
2022-04-26T14:36:44Z
I disagree with the sponsor. This is high risk.
3547.7358 USDC - $3,547.74
https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/vaults/yVault/Controller.sol#L95 https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/vaults/yVault/strategies/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])
.
#0 - spaghettieth
2022-04-12T12:00:10Z
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))
.
#1 - spaghettieth
2022-04-14T14:25:52Z
Fixed in https://github.com/jpegd/core/pull/6
The deprecated latestAnswer()
API is being used, which may at any time fail to work if Chainlink ends support for it.
In addition, the data freshness should be checked. The oracle could, for example, not have been updated in a while, causing outdated prices to be used. This would result in improper collateral valuations and cause systemic risk.
Switch to latestRoundData()
checks on the return data with proper revert messages if the price is stale or the round is incomplete.
(uint80 roundID, int256 price, , uint256 timeStamp, uint80 answeredInRound) = oracle.latestRoundData(); require(price > 0, "invalid_oracle_answer"); require(answeredInRound >= roundID, "..."); // eg. price must have been last updated within past hour require(timeStamp >= block.timestamp - 1 hour, "outdated_price");
#0 - spaghettieth
2022-04-12T16:03:24Z
Duplicate of #54
🌟 Selected for report: Dravee
Also found by: 0x1f8b, 0xDjango, 0xkatana, AuditsAreUS, Cityscape, Foundation, Funen, Hawkeye, IllIllI, JC, JMukesh, Jujic, Kthere, PPrieditis, Picodes, Ruhum, TerrierLover, TrungOre, WatchPug, berndartmueller, catchup, cccz, cmichel, delfin454000, dy, ellahi, hickuphh3, horsefacts, hubble, hyh, ilan, jayjonah8, kebabsec, kenta, minhquanym, pauliax, rayn, reassor, rfa, robee, samruna
259.7584 USDC - $259.76
The JPEG’d contracts are in general of high quality. The core feature is the issuance of a stablecoin PUSD that is backed by NFTs, whose collateral pricing is determined by the DAO / submission of governance proposals. Supporting features are staking and farming JPEG tokens, and a convex autocompounding strategy for the PUSD/USDC/USDT/MIM curve pool.
A noteworthy mechanism is the support for non-ERC721 NFTs like CryptoPunks and EtherRocks. By combining the deterministic generation of contract addresses with the selfdestruct
opcode, the same address can be used for multiple deployments of new code. One concern for this is that EIP-4758 may see the deprecation of the selfdestruct
opcode, causing this mechanism to break.
Supporting documentation and inline comments were adequate in helping understand the contracts’ functionalities.
An area of improvement is to have more integration tests. For example, testing the yVault
contract with the non-ERC721 NFTs and flash escrow mechanism would help to ensure things work as expected. Also, a high severity bug on the failure of strategy migration would have been caught if it was tested with the actual PUSDConvex strategy being used instead of a mock strategy.
Nevertheless, test coverage yielded close to 100% coverage for most contracts, which is commendable.
https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/tokens/StableCoin.sol#L68
For better security, the resumption of functionality (unpausing) should be performed only by the admin (DAO).
Change PAUSER_ROLE
to DEFAULT_ADMIN_ROLE
.
hasRole(DEFAULT_ADMIN_ROLE, _msgSender()), "StableCoin: only admin can unpause"
call()
instead of transfer()
for native asset withdrawalIt is recommended to use call()
instead of transfer()
because the former fowards all remaining gas with the call, while the latter has a gas limit of 2300. This would cause withdrawals to fail if the caller is a smart contract that:
receive()
/ payable fallback()
functions(bool success, ) = payable(msg.sender).call{ value: amount }(""); require(success, "ETH_transfer_failed");
I recommend checking that the curve config indexes correspond to the token addresses because it verifies the correctness of the following parameters:
_curveConfig.curve
_curveConfig.pusdIndex
and _curveConfig.usdcIndex
USDC
and PUSD
token addressesAdd these checks in the constructor.
require(_curveConfig.curve.coins(_curveConfig.pusdIndex) == pusd, "invalid_pusd_config"); require(_curveConfig.curve.coins(_curveConfig.usdcIndex) == usdc, "invalid_pusd_config");
These existing checks can be removed because they are implicitly checked.
// zero address checks require(_pusd != address(0), "INVALID_PUSD"); require(_usdc != address(0), "INVALID_USDC"); require(address(_curveConfig.curve) != address(0), "INVALID_CURVE"); // config checks require( _curveConfig.pusdIndex != _curveConfig.usdcIndex, "INVALID_CURVE_INDEXES" ); require(_curveConfig.pusdIndex < 4, "INVALID_PUSD_CURVE_INDEX"); require(_curveConfig.usdcIndex < 4, "INVALID_USDC_CURVE_INDEX");
want
from curve poolIn a similar vein to L03, to reduce human error, it would be better to derive the want
address (PUSD/USDC/USDT/MIM Curve LP token) directly from the curve pool.
want = IERC20(_curveConfig.curve.lpToken());
JPEG
It is possible for JPEG
to be accidentally added as a reward token. This means that JPEG
would have to be converted to USDC / PUSD as well, potentially bricking yVaultLPFarming
's functionality.
Add the following check.
require( address(_strategyConfig.rewardTokens[i]) != _jpeg, "INVALID_REWARD_TOKEN" );
The comment seems to be outdated and should be removed.
selfdestruct
opcode may be deprecated in the futurehttps://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/escrow/NFTEscrow.sol#L20
https://eips.ethereum.org/EIPS/eip-4758
https://ethereum.stackexchange.com/questions/76116/can-you-selfdestruct-a-contract-more-than-once
The FlashEscrow
self-destructs once the call is executed. In tandem with a deterministic way of precomputing the contract address, it would allow new / the same code to be deployed at the same address. Unfortunately, as outlined in EIP-4758, the selfdestruct
opcode may be deprecated in the future, causing this mechanism to be incompatible with the change.
Avoid the use of selfdestruct
. This however means that you would have to introduce more entropy to the salt used for pre-computation of the flash escrow contract.
contrat
→ contract
The README notes that the natspec is incorrect for the LPFarming’s newEpoch()
function. Do remember to update it.
yVault
contract ERC4626 CompliantThe EIP4626 standard has just been finalised. I recommend making the yVault
contract compliant to the standard. As our favourite optimisoooor @t11s succinctly puts it, “there are a terrifying amount of pitfalls that you can run into when writing a vault from scratch, i learned this first hand working on them at rari. If you want to sleep at night, skip that bs and use 4626”.
The other reason why you would want to be ERC4626 compliant is for composability. To quote yearn, “build an app on top of one ERC-4626 vault, and it will work for all other ERC-4626 tokens. Contributors are already working hard implementing the standard for Yearn’s V3 vaults. So are devs at @AlchemixFi, @balancer, @RariCapital, @feiprotocol, @OpenZeppelin and elsewhere.”
Twitter thread by @t11s shilling EIP4626
🌟 Selected for report: Dravee
Also found by: 0v3rf10w, 0x1f8b, 0xDjango, 0xNazgul, 0xkatana, Cityscape, Cr4ckM3, FSchmoede, Foundation, Funen, Hawkeye, IllIllI, JMukesh, Meta0xNull, PPrieditis, Picodes, TerrierLover, Tomio, WatchPug, berndartmueller, catchup, delfin454000, dirk_y, ellahi, hickuphh3, ilan, kebabsec, kenta, nahnah, rayn, rfa, robee, rokinot, saian, securerodd, slywaters, sorrynotsorry
82.4497 USDC - $82.45
NOT_CONFIRMED
casehttps://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/vaults/NFTVault.sol#L728-L732
https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/vaults/NFTVault.sol#L700
https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/vaults/NFTVault.sol#L719
A couple of conditions can be removed by setting the position’s borrowType earlier for a new position.
"invalid_insurance_mode"
can remove the position.borrowType == BorrowType.NOT_CONFIRMED
caseposition.borrowType == BorrowType.USE_INSURANCE
since _useInsurance
will definitely be equal to the USE_INSURANCE
borrow type.In fact, consider passing the _useInsurance
parameter in the _openPosition()
function to directly set the borrow type.
// _openPosition() positions[_nftIndex] = Position({ borrowType: _useInsurance ? _BorrowType.USE_INSURANCE : BorrowType.NON_INSURANCE, ... }); // borrow() if (positionOwner[_nftIndex] == address(0)) { _openPosition(msg.sender, _nftIndex, _useInsurance); } ... // removed position.borrowType == BorrowType.NOT_CONFIRMED require( (position.borrowType == BorrowType.USE_INSURANCE && _useInsurance) || (position.borrowType == BorrowType.NON_INSURANCE && !_useInsurance), "invalid_insurance_mode" ); ... // if the position is insured, calculate the insurance fee if (_useInsurance) { // removed position.borrowType == BorrowType.USE_INSURANCE
organizationFee
in feeAmount
https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/vaults/NFTVault.sol#L713-L717
organizationFee
can be directly incorporated into feeAmount
, just like how the insurance fee is.
// calculate the borrow (organization) fee uint256 feeAmount = (_amount * settings.organizationFeeRate.numerator) / settings.organizationFeeRate.denominator;
uint256
castingliquidityAmounts
is already a uint256
array; the uint256
casting of 0 is redundant;
uint256[4] memory liquidityAmounts = [0, 0, 0, 0];
== true
comparison in setStrategy()
https://github.com/code-423n4/2022-04-jpegd/blob/main/contracts/vaults/yVault/Controller.sol#L87
Since the approvedStrategies[_token][_strategy]
mapping evaluates to a boolean, its comparison against true
is redundant.
require( approvedStrategies[_token][_strategy] == true, "STRATEGY_NOT_APPROVED" );