Platform: Code4rena
Start Date: 01/04/2024
Pot Size: $120,000 USDC
Total HM: 11
Participants: 55
Period: 21 days
Judge: Picodes
Total Solo HM: 6
Id: 354
League: ETH
Rank: 2/55
Findings: 3
Award: $11,316.21
🌟 Selected for report: 1
🚀 Solo Findings: 1
2050.6445 USDC - $2,050.64
settleLongPremium()
is use for Settle all unpaid premium for long legs
The code implementation is as follows:
function settleLongPremium( TokenId[] calldata positionIdList, address owner, uint256 legIndex ) external { _validatePositionList(owner, positionIdList, 0); TokenId tokenId = positionIdList[positionIdList.length - 1]; if (tokenId.isLong(legIndex) == 0 || legIndex > 3) revert Errors.NotALongLeg(); (, int24 currentTick, , , , , ) = s_univ3pool.slot0(); LeftRightUnsigned accumulatedPremium; { (int24 tickLower, int24 tickUpper) = tokenId.asTicks(legIndex); uint256 tokenType = tokenId.tokenType(legIndex); (uint128 premiumAccumulator0, uint128 premiumAccumulator1) = SFPM.getAccountPremium( address(s_univ3pool), address(this), tokenType, tickLower, tickUpper, currentTick, 1 ); accumulatedPremium = LeftRightUnsigned .wrap(0) .toRightSlot(premiumAccumulator0) .toLeftSlot(premiumAccumulator1); // update the premium accumulator for the long position to the latest value // (the entire premia delta will be settled) LeftRightUnsigned premiumAccumulatorsLast = s_options[owner][tokenId][legIndex]; s_options[owner][tokenId][legIndex] = accumulatedPremium; accumulatedPremium = accumulatedPremium.sub(premiumAccumulatorsLast); } uint256 liquidity = PanopticMath .getLiquidityChunk(tokenId, legIndex, s_positionBalance[owner][tokenId].rightSlot()) .liquidity(); unchecked { // update the realized premia LeftRightSigned realizedPremia = LeftRightSigned .wrap(0) .toRightSlot(int128(int256((accumulatedPremium.rightSlot() * liquidity) / 2 ** 64))) .toLeftSlot(int128(int256((accumulatedPremium.leftSlot() * liquidity) / 2 ** 64))); // deduct the paid premium tokens from the owner's balance and add them to the cumulative settled token delta @> s_collateralToken0.exercise(owner, 0, 0, 0, realizedPremia.rightSlot()); @> s_collateralToken1.exercise(owner, 0, 0, 0, realizedPremia.leftSlot()); bytes32 chunkKey = keccak256( abi.encodePacked( tokenId.strike(legIndex), tokenId.width(legIndex), tokenId.tokenType(legIndex) ) ); // commit the delta in settled tokens (all of the premium paid by long chunks in the tokenIds list) to storage s_settledTokens[chunkKey] = s_settledTokens[chunkKey].add( LeftRightUnsigned.wrap(uint256(LeftRightSigned.unwrap(realizedPremia))) ); emit PremiumSettled(owner, tokenId, realizedPremia); } // ensure the owner is solvent (insolvent accounts are not permitted to pay premium unless they are being liquidated) _validateSolvency(owner, positionIdList, NO_BUFFER); }
In the above code, realizedPremia
is positive and CollateralTracker.exercise(,,,,realizedPremia)
positive means it is get the funds
function exercise( address optionOwner, int128 longAmount, int128 shortAmount, int128 swappedAmount, int128 realizedPremium ) external onlyPanopticPool returns (int128) { .. // add premium to be paid/collected on position close @> int256 tokenToPay = -realizedPremium; if (tokenToPay > 0) { // if user must pay tokens, burn them from user balance (revert if balance too small) uint256 sharesToBurn = Math.mulDivRoundingUp( uint256(tokenToPay), totalSupply, totalAssets() ); _burn(optionOwner, sharesToBurn); @> } else if (tokenToPay < 0) { // if user must receive tokens, mint them uint256 sharesToMint = convertToShares(uint256(-tokenToPay)); @> _mint(optionOwner, sharesToMint); }
But buyer
should be paying Premium
, not getting Premium
.
buyer
changes from paying to getting Premium
, the contract will lose the funds
function settleLongPremium( TokenId[] calldata positionIdList, address owner, uint256 legIndex ) external { ... - s_collateralToken0.exercise(owner, 0, 0, 0, realizedPremia.rightSlot()); - s_collateralToken1.exercise(owner, 0, 0, 0, realizedPremia.leftSlot()); + s_collateralToken0.exercise(owner, 0, 0, 0, -realizedPremia.rightSlot()); + s_collateralToken1.exercise(owner, 0, 0, 0, -realizedPremia.leftSlot()); bytes32 chunkKey = keccak256( abi.encodePacked( tokenId.strike(legIndex), tokenId.width(legIndex), tokenId.tokenType(legIndex) ) ); // commit the delta in settled tokens (all of the premium paid by long chunks in the tokenIds list) to storage s_settledTokens[chunkKey] = s_settledTokens[chunkKey].add( LeftRightUnsigned.wrap(uint256(LeftRightSigned.unwrap(realizedPremia))) ); emit PremiumSettled(owner, tokenId, realizedPremia); } // ensure the owner is solvent (insolvent accounts are not permitted to pay premium unless they are being liquidated) _validateSolvency(owner, positionIdList, NO_BUFFER); }
Context
#0 - c4-judge
2024-04-23T11:44:12Z
Picodes marked the issue as duplicate of #376
#1 - c4-judge
2024-04-30T21:48:17Z
Picodes marked the issue as satisfactory
1139.2469 USDC - $1,139.25
_validatePositionList(positionIdList)
is called from multiple places and is primarily used to validate the legality of positionIdList
.
The main logic involves iterating through tokenId
s and performing XOR operations, then comparing the result with s_positionsHash[account]
.
function _validatePositionList( address account, TokenId[] calldata positionIdList, uint256 offset ) internal view { uint256 pLength; uint256 currentHash = s_positionsHash[account]; unchecked { pLength = positionIdList.length - offset; } // note that if pLength == 0 even if a user has existing position(s) the below will fail b/c the fingerprints will mismatch // Check that position hash (the fingerprint of option positions) matches the one stored for the '_account' uint256 fingerprintIncomingList; for (uint256 i = 0; i < pLength; ) { fingerprintIncomingList = PanopticMath.updatePositionsHash( fingerprintIncomingList, positionIdList[i], ADD ); unchecked { ++i; } } // revert if fingerprint for provided '_positionIdList' does not match the one stored for the '_account' if (fingerprintIncomingList != currentHash) revert Errors.InputListFail(); }
function updatePositionsHash( uint256 existingHash, TokenId tokenId, bool addFlag ) internal pure returns (uint256) { // add the XOR`ed hash of the single option position `tokenId` to the `existingHash` // @dev 0 ^ x = x unchecked { // update hash by taking the XOR of the new tokenId uint248 updatedHash = uint248(existingHash) ^ (uint248(uint256(keccak256(abi.encode(tokenId))))); // increment the top 8 bit if addflag=true, decrement otherwise return addFlag @> ? uint256(updatedHash) + (((existingHash >> 248) + 1) << 248) : uint256(updatedHash) + (((existingHash >> 248) - 1) << 248); } }
The main issue in the code is with (((existingHash >> 248) + 1) << 248)
. The first uint8
represents the number of tokenId
s, and overflow is ignored.
Additionally,when XOR
calculation, the known formula is X^Y^Y=X
.
This means that calculating the hash for positionIdList[1,2,3]
and positionIdList[1,2,3,4,4,4,4......]
(256 4
) will result in the same hash.
The following code demonstrates that inserting any 256
tokenId
s will yield the same hash.
contract CounterTest is Test { function updatePositionsHash( uint256 existingHash, uint256 tokenId, bool addFlag ) internal pure returns (uint256) { unchecked { // update hash by taking the XOR of the new tokenId uint248 updatedHash = uint248(existingHash) ^ (uint248(uint256(keccak256(abi.encode(tokenId))))); // increment the top 8 bit if addflag=true, decrement otherwise return addFlag ? uint256(updatedHash) + (((existingHash >> 248) + 1) << 248) : uint256(updatedHash) + (((existingHash >> 248) - 1) << 248); } } function test() external { uint256 existingHash = 0; uint256 u1 = updatePositionsHash(0,1,true); uint256 u2 = updatePositionsHash(u1,2,true); uint256 u3 = updatePositionsHash(u2,3,true); uint256 newU3=u3; for(uint256 i;i< 256;i++){ newU3 = updatePositionsHash(newU3,789,true); } console.log(newU3 == u3); } }
$ forge test -vvv Running 1 test for test/Counter.t.sol:CounterTest [PASS] test() (gas: 105578) Logs: true
If a seller
has a tokenId
premium
> required collateral
, they can maliciously increase positionIdList
by adding any 256*N
number of tokenId
s, passing the validation in _validateSolvency()
, and thus illegally opening a position.
function updatePositionsHash( uint256 existingHash, TokenId tokenId, bool addFlag ) internal pure returns (uint256) { // add the XOR`ed hash of the single option position `tokenId` to the `existingHash` // @dev 0 ^ x = x unchecked { // update hash by taking the XOR of the new tokenId uint248 updatedHash = uint248(existingHash) ^ (uint248(uint256(keccak256(abi.encode(tokenId))))); // increment the top 8 bit if addflag=true, decrement otherwise + require((existingHash >> 248)<255,"invald"); return addFlag ? uint256(updatedHash) + (((existingHash >> 248) + 1) << 248) : uint256(updatedHash) + (((existingHash >> 248) - 1) << 248); } }
Context
#0 - c4-judge
2024-04-25T08:41:56Z
Picodes marked the issue as duplicate of #498
#1 - c4-judge
2024-04-30T21:42:40Z
Picodes changed the severity to 3 (High Risk)
#2 - c4-judge
2024-04-30T21:45:48Z
Picodes marked the issue as satisfactory
#3 - c4-judge
2024-05-09T19:28:13Z
Picodes changed the severity to 2 (Med Risk)
🌟 Selected for report: bin2chen
8126.3155 USDC - $8,126.32
s_grossPremiumLast[]
definitions are as follows:
/// @dev Per-chunk
last
value that gives the aggregate amount of premium owed to all sellers when multiplied by the total amount of liquiditytotalLiquidity
/// totalGrossPremium = totalLiquidity * (grossPremium(perLiquidityX64) - lastGrossPremium(perLiquidityX64)) / 2**64 /// Used to compute the denominator for the fraction of premium available to sellers to collect /// LeftRight - right slot is token0, left slot is token1 mapping(bytes32 chunkKey => LeftRightUnsigned lastGrossPremium) internal s_grossPremiumLast;
a critical rule: if there is a change in totalLiquidity
, we must recalculate this value.
when Pool.mintOptions()
, s_grossPremiumLast[chunkKey]
will increase.
step: mintOptions()
->_mintInSFPMAndUpdateCollateral()
->_updateSettlementPostMint()
function _updateSettlementPostMint( TokenId tokenId, LeftRightUnsigned[4] memory collectedByLeg, uint128 positionSize ) internal { ... if (tokenId.isLong(leg) == 0) { LiquidityChunk liquidityChunk = PanopticMath.getLiquidityChunk( tokenId, leg, positionSize ); ... uint256[2] memory grossCurrent; (grossCurrent[0], grossCurrent[1]) = SFPM.getAccountPremium( address(s_univ3pool), address(this), tokenId.tokenType(leg), liquidityChunk.tickLower(), liquidityChunk.tickUpper(), type(int24).max, 0 ); unchecked { // L LeftRightUnsigned grossPremiumLast = s_grossPremiumLast[chunkKey]; // R uint256 positionLiquidity = liquidityChunk.liquidity(); // T (totalLiquidity is (T + R) after minting) uint256 totalLiquidityBefore = totalLiquidity - positionLiquidity; @> s_grossPremiumLast[chunkKey] = LeftRightUnsigned .wrap(0) .toRightSlot( uint128( (grossCurrent[0] * positionLiquidity + grossPremiumLast.rightSlot() * totalLiquidityBefore) / (totalLiquidity) ) ) .toLeftSlot( uint128( (grossCurrent[1] * positionLiquidity + grossPremiumLast.leftSlot() * totalLiquidityBefore) / (totalLiquidity) ) ); } } } }
when Pool.burnOptions()
,s_grossPremiumLast[chunkKey]
will decrease
burnOptions()
->_burnAndHandleExercise()
->_updateSettlementPostBurn()
function _updateSettlementPostBurn( address owner, TokenId tokenId, LeftRightUnsigned[4] memory collectedByLeg, uint128 positionSize, bool commitLongSettled ) internal returns (LeftRightSigned realizedPremia, LeftRightSigned[4] memory premiaByLeg) { ... uint256 numLegs = tokenId.countLegs(); uint256[2][4] memory premiumAccumulatorsByLeg; // compute accumulated fees (premiaByLeg, premiumAccumulatorsByLeg) = _getPremia( tokenId, positionSize, owner, COMPUTE_ALL_PREMIA, type(int24).max ); for (uint256 leg = 0; leg < numLegs; ) { LeftRightSigned legPremia = premiaByLeg[leg]; bytes32 chunkKey = keccak256( abi.encodePacked(tokenId.strike(leg), tokenId.width(leg), tokenId.tokenType(leg)) ); // collected from Uniswap LeftRightUnsigned settledTokens = s_settledTokens[chunkKey].add(collectedByLeg[leg]); @> if (LeftRightSigned.unwrap(legPremia) != 0) { // (will be) paid by long legs if (tokenId.isLong(leg) == 1) { ... } else { uint256 positionLiquidity = PanopticMath .getLiquidityChunk(tokenId, leg, positionSize) .liquidity(); // new totalLiquidity (total sold) = removedLiquidity + netLiquidity (T - R) uint256 totalLiquidity = _getTotalLiquidity(tokenId, leg); // T (totalLiquidity is (T - R) after burning) uint256 totalLiquidityBefore = totalLiquidity + positionLiquidity; LeftRightUnsigned grossPremiumLast = s_grossPremiumLast[chunkKey]; LeftRightUnsigned availablePremium = _getAvailablePremium( totalLiquidity + positionLiquidity, settledTokens, grossPremiumLast, LeftRightUnsigned.wrap(uint256(LeftRightSigned.unwrap(legPremia))), premiumAccumulatorsByLeg[leg] ); // subtract settled tokens sent to seller settledTokens = settledTokens.sub(availablePremium); // add available premium to amount that should be settled realizedPremia = realizedPremia.add( LeftRightSigned.wrap(int256(LeftRightUnsigned.unwrap(availablePremium))) ); ... unchecked { uint256[2][4] memory _premiumAccumulatorsByLeg = premiumAccumulatorsByLeg; uint256 _leg = leg; // if there's still liquidity, compute the new grossPremiumLast // otherwise, we just reset grossPremiumLast to the current grossPremium @> s_grossPremiumLast[chunkKey] = totalLiquidity != 0 ? LeftRightUnsigned .wrap(0) .toRightSlot( uint128( uint256( Math.max( (int256( grossPremiumLast.rightSlot() * totalLiquidityBefore ) - int256( _premiumAccumulatorsByLeg[_leg][0] * positionLiquidity )) + int256(legPremia.rightSlot() * 2 ** 64), 0 ) ) / totalLiquidity ) ) .toLeftSlot( uint128( uint256( Math.max( (int256( grossPremiumLast.leftSlot() * totalLiquidityBefore ) - int256( _premiumAccumulatorsByLeg[_leg][1] * positionLiquidity )) + int256(legPremia.leftSlot()) * 2 ** 64, 0 ) ) / totalLiquidity ) ) : LeftRightUnsigned .wrap(0) .toRightSlot(uint128(premiumAccumulatorsByLeg[_leg][0])) .toLeftSlot(uint128(premiumAccumulatorsByLeg[_leg][1])); } } } // update settled tokens in storage with all local deltas s_settledTokens[chunkKey] = settledTokens; unchecked { ++leg; }
The issue lies within _updateSettlementPostBurn()
, where it adds a condition that if (LeftRightSigned.unwrap(legPremia) != 0)
must be met for s_grossPremiumLast[chunkKey]
to decrease.
This results in not recalculating s_grossPremiumLast[chunkKey]
even when totalLiquidity
changes.
For example, in the same block, if a user executes mintOptions()
, s_grossPremiumLast[chunkKey]
increases by 50. Immediately after, executing burnOptions()
doesn't decrease s_grossPremiumLast[chunkKey]
by 50 because legPremia == 0
.
The following code demonstrates this scenario, where s_grossPremiumLast[chunkKey]
keeps increasing,so last gross accumulated
keeps decreasing.
Misc.t.sol
function test_burn_not_reduce_last() public { swapperc = new SwapperC(); vm.startPrank(Swapper); token0.mint(Swapper, type(uint128).max); token1.mint(Swapper, type(uint128).max); token0.approve(address(swapperc), type(uint128).max); token1.approve(address(swapperc), type(uint128).max); // mint OTM position $posIdList.push( TokenId.wrap(0).addPoolId(PanopticMath.getPoolId(address(uniPool))).addLeg( 0, 1, 1, 0, 0, 0, 15, 1 ) ); //@info Bob same gross vm.startPrank(Bob); pp.mintOptions($posIdList, 1_000_000, 0, 0, 0); vm.startPrank(Swapper); swapperc.swapTo(uniPool, Math.getSqrtRatioAtTick(10) + 1); // 1998600539 accruePoolFeesInRange(address(uniPool), (uniPool.liquidity() * 2) / 3, 1, 1); swapperc.swapTo(uniPool, 2 ** 96); //@info end of Bob same gross //@info start alice , it should without change gross console.log("*****start alice mint/burn should not reduce last accumulated"); for(uint256 i=0;i<10;i++){ vm.startPrank(Alice); pp.mintOptions($posIdList, 250_000, type(uint64).max, 0, 0); vm.startPrank(Alice); pp.burnOptions($posIdList[0], new TokenId[](0), 0, 0); } }
PanopticPool.sol
function _updateSettlementPostMint( TokenId tokenId, LeftRightUnsigned[4] memory collectedByLeg, uint128 positionSize ) internal { ... unchecked { // L LeftRightUnsigned grossPremiumLast = s_grossPremiumLast[chunkKey]; // R uint256 positionLiquidity = liquidityChunk.liquidity(); // T (totalLiquidity is (T + R) after minting) uint256 totalLiquidityBefore = totalLiquidity - positionLiquidity; //console.log("s_grossPremiumLast[chunkKey].rightSlot() from:",s_grossPremiumLast[chunkKey].rightSlot()); s_grossPremiumLast[chunkKey] = LeftRightUnsigned .wrap(0) .toRightSlot( uint128( (grossCurrent[0] * positionLiquidity + grossPremiumLast.rightSlot() * totalLiquidityBefore) / (totalLiquidity) ) ) .toLeftSlot( uint128( (grossCurrent[1] * positionLiquidity + grossPremiumLast.leftSlot() * totalLiquidityBefore) / (totalLiquidity) ) ); + console.log("last accumulated : ",(grossCurrent[0] - s_grossPremiumLast[chunkKey].rightSlot()) * totalLiquidity); } } } }
$ forge test -vvv --match-test test_burn_not_reduce_last [PASS] test_burn_not_reduce_last() (gas: 5276055) Logs: last accumulated : 0 *****start alice mint/burn should not reduce last accumulated last accumulated : 36893488148466911062 last accumulated : 29514790528266881407 last accumulated : 23611832432106857683 last accumulated : 18889465953679888300 last accumulated : 15111572767940411986 last accumulated : 12089258220348131204 last accumulated : 9671406580275706040 last accumulated : 7737125266718815505 last accumulated : 6189700215873303077 last accumulated : 4951760174697243000
Due to the incorrect accounting of s_grossPremiumLast[chunkKey]
, the calculation in _getAvailablePremium()
is also incorrect. This results in inaccuracies in the amount of premium received by the seller when closing their position.
Regardless of the value of legPremia
, it should recalculate s_grossPremiumLast[chunkKey]
when long == 0
.
function _updateSettlementPostBurn( address owner, TokenId tokenId, LeftRightUnsigned[4] memory collectedByLeg, uint128 positionSize, bool commitLongSettled ) internal returns (LeftRightSigned realizedPremia, LeftRightSigned[4] memory premiaByLeg) { ... for (uint256 leg = 0; leg < numLegs; ) { LeftRightSigned legPremia = premiaByLeg[leg]; bytes32 chunkKey = keccak256( abi.encodePacked(tokenId.strike(leg), tokenId.width(leg), tokenId.tokenType(leg)) ); // collected from Uniswap LeftRightUnsigned settledTokens = s_settledTokens[chunkKey].add(collectedByLeg[leg]); if (LeftRightSigned.unwrap(legPremia) != 0) { // (will be) paid by long legs if (tokenId.isLong(leg) == 1) { ... } else { .... // subtract settled tokens sent to seller settledTokens = settledTokens.sub(availablePremium); // add available premium to amount that should be settled realizedPremia = realizedPremia.add( LeftRightSigned.wrap(int256(LeftRightUnsigned.unwrap(availablePremium))) ); - unchecked { - uint256[2][4] memory _premiumAccumulatorsByLeg = premiumAccumulatorsByLeg;- - uint256 _leg = leg; - - // if there's still liquidity, compute the new grossPremiumLast - // otherwise, we just reset grossPremiumLast to the current grossPremium - s_grossPremiumLast[chunkKey] = totalLiquidity != 0 - ? LeftRightUnsigned - .wrap(0) - .toRightSlot( - uint128( - uint256( - Math.max( - (int256( - grossPremiumLast.rightSlot() * - totalLiquidityBefore - ) - - int256( - _premiumAccumulatorsByLeg[_leg][0] * - positionLiquidity - )) + int256(legPremia.rightSlot() * 2 ** 64), - 0 - ) - ) / totalLiquidity - ) - ) - .toLeftSlot( - uint128( - uint256( - Math.max( - (int256( - grossPremiumLast.leftSlot() * - totalLiquidityBefore - ) - - int256( - _premiumAccumulatorsByLeg[_leg][1] * - positionLiquidity - )) + int256(legPremia.leftSlot()) * 2 ** 64, - 0 - ) - ) / totalLiquidity - ) - ) - : LeftRightUnsigned - .wrap(0) - .toRightSlot(uint128(premiumAccumulatorsByLeg[_leg][0])) - .toLeftSlot(uint128(premiumAccumulatorsByLeg[_leg][1])); - } } } + if (tokenId.isLong(leg) == 0){ + uint256 positionLiquidity = PanopticMath + .getLiquidityChunk(tokenId, leg, positionSize) + .liquidity(); + + // new totalLiquidity (total sold) = removedLiquidity + netLiquidity (T - R) + uint256 totalLiquidity = _getTotalLiquidity(tokenId, leg); + // T (totalLiquidity is (T - R) after burning) + uint256 totalLiquidityBefore = totalLiquidity + positionLiquidity; + + LeftRightUnsigned grossPremiumLast = s_grossPremiumLast[chunkKey]; + unchecked { + uint256[2][4] memory _premiumAccumulatorsByLeg = premiumAccumulatorsByLeg; + uint256 _leg = leg; + + // if there's still liquidity, compute the new grossPremiumLast + // otherwise, we just reset grossPremiumLast to the current grossPremium + s_grossPremiumLast[chunkKey] = totalLiquidity != 0 + ? LeftRightUnsigned + .wrap(0) + .toRightSlot( + uint128( + uint256( + Math.max( + (int256( + grossPremiumLast.rightSlot() * + totalLiquidityBefore + ) - + int256( + _premiumAccumulatorsByLeg[_leg][0] * + positionLiquidity + )) + int256(legPremia.rightSlot() * 2 ** 64), + 0 + ) + ) / totalLiquidity + ) + ) + .toLeftSlot( + uint128( + uint256( + Math.max( + (int256( + grossPremiumLast.leftSlot() * + totalLiquidityBefore + ) - + int256( + _premiumAccumulatorsByLeg[_leg][1] * + positionLiquidity + )) + int256(legPremia.leftSlot()) * 2 ** 64, + 0 + ) + ) / totalLiquidity + ) + ) + : LeftRightUnsigned + .wrap(0) + .toRightSlot(uint128(premiumAccumulatorsByLeg[_leg][0])) + .toLeftSlot(uint128(premiumAccumulatorsByLeg[_leg][1])); + } + } } // update settled tokens in storage with all local deltas s_settledTokens[chunkKey] = settledTokens; unchecked { ++leg; } } } }
Context
#0 - c4-judge
2024-05-06T15:52:56Z
Picodes marked the issue as satisfactory
#1 - c4-judge
2024-05-06T15:52:59Z
Picodes marked the issue as selected for report