Platform: Code4rena
Start Date: 17/03/2022
Pot Size: $30,000 USDC
Total HM: 8
Participants: 43
Period: 3 days
Judge: gzeon
Total Solo HM: 5
Id: 100
League: ETH
Rank: 8/43
Findings: 4
Award: $1,384.91
🌟 Selected for report: 2
🚀 Solo Findings: 0
🌟 Selected for report: GreyArt
Also found by: 0xDjango, CertoraInc, TomFrenchBlockchain, WatchPug, cmichel, rayn
511.5587 USDC - $511.56
https://github.com/code-423n4/2022-03-prepo/blob/main/contracts/core/Collateral.sol#L82-L91
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”.
_strategyController
to greatly inflate the share’s price. Note that the _strategyController
deposits its entire balance to the strategy when its deposit()
function is called.it("will cause 0 share issuance", async () => { // 1. first user deposits 2 wei because 1 wei will be deducted for fee let firstDepositAmount = ethers.BigNumber.from(2) await transferAndApproveForDeposit( user, collateral.address, firstDepositAmount ) await collateral .connect(user) .deposit(firstDepositAmount) // 2. do huge transfer of 1M to strategy to controller // to greatly inflate share price await baseToken.transfer(strategyController.address, ethers.utils.parseEther("1000000")); // 3. deployer tries to deposit reasonable amount of 10_000 let subsequentDepositAmount = ethers.utils.parseEther("10000"); await transferAndApproveForDeposit( deployer, collateral.address, subsequentDepositAmount ) await collateral .connect(deployer) .deposit(subsequentDepositAmount) // receives 0 shares in return expect(await collateral.balanceOf(deployer.address)).to.be.eq(0) });
totalSupply() == 0
, send the first min liquidity LP tokens to the zero address to enable share dilution.require(_shares != 0, "zero shares minted");
initialize()
and deposit()
deposit()
once in initialize()
to achieve the same effect as the suggestion above.#0 - ramenforbreakfast
2022-03-22T22:52:20Z
Valid submission, good explanation of the problem and nice to see it being demonstrated via a test case block. Will use this as the source submission for this problem since there are many duplicate mentions.
#1 - gzeoneth
2022-04-03T13:30:19Z
Agree with sponsor
https://docs.prepo.io/concepts/markets#expiry https://github.com/code-423n4/2022-03-prepo/blob/main/contracts/core/PrePOMarket.sol#L145-L156
The docs say that “If a market has not settled by its expiry date, it will automatically settle at the lower bound of its Valuation Range.”
However, in the implementation, the expiry date is entirely ignored. The default settlement after expiry is a 1:1 ratio of long and short token for 1 collateral token.
Should users believe that the market will settle at the lower bound, they would swap and hold long for short tokens instead of at a 1:1 ratio upon expiry. Thereafter, they would incur swap fees from having to swap some short tokens back for long tokens for redemption. User funds are also affected should long tokens are repurchased at a higher price than when they were sold.
If the market is to settle at the lower valuation after expiry, then the following logic should be added:
// market has expired // settle at lower bound if (block.timestamp > _expiryTime) { uint256 _shortPrice = MAX_PRICE - _floorLongPrice; _collateralOwed = (_floorLongPrice * _longAmount + _shortPrice * _shortAmount) / MAX_PRICE; } else if (_finalLongPrice <= MAX_PRICE) { ... } else { ... }
Otherwise, the documentation should be updated to reflect the default behaviour of 1:1 redemption.
#0 - ramenforbreakfast
2022-03-22T22:55:24Z
This is a valid submission, no longer disagreeing with severity as we clearly stated that expiry should be enforceable.
This was a mistake on our part and I think we ended up not using expiryTime
since the only thing that really mattered was if the finalLongPrice
was set. We should decide whether to enforce it or remove it altogether.
#1 - gzeoneth
2022-04-03T13:27:56Z
Agree with sponsor
🌟 Selected for report: defsec
Also found by: 0x1f8b, 0xDjango, 0xNazgul, 0xkatana, 0xwags, CertoraInc, Funen, GeekyLumberjack, GreyArt, IllIllI, Kenshin, Ruhum, TerrierLover, WatchPug, berndartmueller, bugwriter001, cccz, cmichel, csanuragjain, hake, kenta, kirk-baird, leastwood, minhquanym, oyc_109, peritoflores, rayn, remora, rfa, robee, saian, samruna, sorrynotsorry, wuwe1
298.1989 USDC - $298.20
Overall, code quality for the PrePO contracts is very high. The contracts are well modularised and are clear enough to follow. The provided documentation was adequate in explaining key concepts like the pricing formulas for the long and short tokens, and valuation ranges. Special mention for providing a video walkthrough as well!
The contracts have 100% coverage, which unfortunately isn’t the norm. Kudos for having adequate tests to fully test the core contracts!
In total, there were 1 high, 2 medium and 5 low findings reported. The high severity issue pertains to a vault related edge case that describes a scenario where a malicious actor is able to DOS other users by artificially inflating the value of a single unit of collateral token. That aside, the quantity of other findings appropriately reflect the quality of the source code as it conforms closely to smart contract best practices.
_ceilingValuation
> _floorValuation
https://github.com/code-423n4/2022-03-prepo/blob/main/contracts/core/PrePOMarket.sol#L78-L79
No check was performed that the _ceilingValuation
exceeds _floorValuation
. While it bears no impact to functionality, it would be ideal to ensure that the valuations are correctly set.
require(_newCeilingValuation > _newFloorValuation, "Ceiling must exceed floor");
createMarket()
has different parameter order in interface and contracthttps://github.com/code-423n4/2022-03-prepo/blob/main/contracts/core/PrePOMarketFactory.sol#L45-L46
The _governance
and _collateral
parameters are swapped in the interface and implementation. There is thankfully a check to ensure that the collateral is whitelisted before the market can be created (so market creation would have reverted). Otherwise, the variables would have been set incorrectly.
Swap _governance
and _collateral
parameters in the interface.
shortToken
param natspec in IPrePOMarket
shortToken
parameter is duplicated.
Remove either instance.
_delayedWithdrawalExpiry
adds unnecessary complexity and potentially griefs usershttps://github.com/code-423n4/2022-03-prepo/blob/main/contracts/core/Collateral.sol#L124-L127
The main motivation for _delayedWithdrawalExpiry
is to mitigate flash loan attacks. In our opinion, having a validity period for withdrawals provides negligible protection.
The _delayedWithdrawalExpiry
variable potentially griefs a majority of users if it is set a low value (1 block for example), where it becomes difficult for the average user to perform withdrawals.
Remove _delayedWithdrawalExpiry
and its corresponding check. A better way to mitigate flash loans is to prevent users from depositing and withdrawing in the same block.
_governance
and _treasury
in factoryhttps://github.com/code-423n4/2022-03-prepo/blob/main/contracts/core/PrePOMarketFactory.sol#L45-L46
The governance and treasury addresses are passed in as parameters whenever a market is created. It would be safer to save and retrieve these addresses to / from storage to avoid input errors.
Note that we are aware that it is mentioned in the README that gas optimization will not be awarded for struct packing but this low issue pertains to having a canonical source to retrieve the _governance
and _treasury
address (within the factory) to make it impossible for the owner to accidentally use the wrong address.
To counter the dreaded stack too deep problem, we pack the parameters into a struct. This avoids the need to first initialize the treasury address as the governance address as well.
// PrePOMarketFactory.sol // TODO: create getters and mutators for governance and treasury variables address private _governance; address private _treasury; // notice the removal of _governance and _treasury // TODO: Modify overriden interface function function createMarket( string memory _tokenNameSuffix, string memory _tokenSymbolSuffix, address _collateral, uint256 _floorLongPrice, uint256 _ceilingLongPrice, uint256 _floorValuation, uint256 _ceilingValuation, uint256 _mintingFee, uint256 _redemptionFee, uint256 _expiryTime ) external override onlyOwner nonReentrant { ... PrePOMarket _newMarket = new PrePOMarket{salt: _salt}( PrePOMarket.MarketInitParams({ governance: _governance, treasury: _treasury, collateral: _collateral, longToken: ILongShortToken(address(_longToken)), shortToken: ILongShortToken(address(_shortToken)), floorLongPrice: _floorLongPrice, ceilingLongPrice: _ceilingLongPrice, floorValuation: _floorValuation, ceilingValuation: _ceilingValuation, mintingFee: _mintingFee, redemptionFee: _redemptionFee, expiryTime: _expiryTime }) ); ... } // PrePOMarket.sol struct MarketInitParams { address governance; address treasury; address collateral; ILongShortToken longToken; ILongShortToken shortToken; uint256 floorLongPrice; uint256 ceilingLongPrice; uint256 floorValuation; uint256 ceilingValuation; uint256 mintingFee; uint256 redemptionFee; uint256 expiryTime; } /** * Assumes `_newCollateral`, `_newLongToken`, and `_newShortToken` are * valid, since they will be handled by the PrePOMarketFactory. * * Assumes that ownership of `_longToken` and `_shortToken` has been * transferred to this contract via `createMarket()` in * `PrePOMarketFactory.sol`. */ constructor(MarketInitParams memory marketParams) { require( marketParams.ceilingLongPrice > marketParams.floorLongPrice, "Ceiling must exceed floor" ); require(marketParams.expiryTime > block.timestamp, "Invalid expiry"); require(marketParams.mintingFee <= FEE_LIMIT, "Exceeds fee limit"); require(marketParams.redemptionFee <= FEE_LIMIT, "Exceeds fee limit"); require(marketParams.ceilingLongPrice <= MAX_PRICE, "Ceiling cannot exceed 1"); transferOwnership(marketParams.governance); _treasury = marketParams.treasury; _collateral = IERC20(marketParams.collateral); _longToken = marketParams.longToken; _shortToken = marketParams.shortToken; _floorLongPrice = marketParams.floorLongPrice; _ceilingLongPrice = marketParams.ceilingLongPrice; _finalLongPrice = MAX_PRICE + 1; _floorValuation = marketParams.floorValuation; _ceilingValuation = marketParams.ceilingValuation; _mintingFee = marketParams.mintingFee; _redemptionFee = marketParams.redemptionFee; _expiryTime = marketParams.expiryTime; emit MarketCreated( address(marketParams.longToken), address(marketParams.shortToken), marketParams.floorLongPrice, marketParams.ceilingLongPrice, marketParams.floorValuation, marketParams.ceilingValuation, marketParams.mintingFee, marketParams.redemptionFee, marketParams.expiryTime ); }
Collateral
contract EIP4626 CompliantThe EIP4626 standard has just been finalised. We recommend making the Collateral
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”
#0 - ramenforbreakfast
2022-03-22T23:03:09Z
L01 i think is unnecessary and adds additional complexity for something that is not actually used within contract logic. L02-03 are valid submissions and good catches! L04 We added expiries as an intentional decision for protection, so will not be considering this as valid. L05 Governance and treasury were designed so that they can be different for different markets and thus this is not an issue.
Keeping this severity since L02-03 are valid suggestions. Nicely organized report!
29.3575 USDC - $29.36
transfer()
instead of transferFrom()
https://github.com/code-423n4/2022-03-prepo/blob/main/contracts/core/PrePOMarket.sol#L121-L123
The mintLongShortTokens()
calls 2 transferFrom()
functions to send funds to 2 recipients: the contract itself and the treasury. It would be more gas efficient to call 1 transferFrom + 1 transfer instead to avoid an additional allowance check.
_collateral.transferFrom(msg.sender, address(this), amount); _collateral.transfer(_treasury, _fee); _amount -= _fee;
transferFrom()
Collateral directly to strategyWhenever Collateral#deposit()
is called, funds move through the controller, then to the strategy. A more gas efficient method would be to move funds directly from Collateral
to the strategy.
Note that baseToken
compatibility is assumed between the vault, controller and strategy.
_baseToken.safeTransferFrom(_vault, _strategy, _amount); _strategy.deposit(_amount);
_allowed
parameter and instantiation of _publicMinting
in PrePOMarket’s constructorhttps://github.com/code-423n4/2022-03-prepo/blob/main/contracts/core/PrePOMarketFactory.sol#L75
https://github.com/code-423n4/2022-03-prepo/blob/main/contracts/core/PrePOMarket.sol#L56
Since _publicMinting
will always be initialized to the default value false
, the instantiation is redundant.
Remove the _allowed
parameter and instantiation of _publicMinting
.
#0 - ramenforbreakfast
2022-03-22T23:04:57Z
G01 and G03 are valid, but would help if savings could be demonstrated. G02 is not valid, Collateral is not meant to interact directly with the Strategy and is documented as such.