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: 9/55
Findings: 3
Award: $3,564.62
🌟 Selected for report: 1
🚀 Solo Findings: 0
2050.6445 USDC - $2,050.64
https://github.com/code-423n4/2024-04-panoptic/blob/main/contracts/PanopticPool.sol#L1600-L1640 https://github.com/code-423n4/2024-04-panoptic/blob/main/contracts/CollateralTracker.sol#L1043-L1079
Within PanopticPool
, options sellers are allowed to call PanopticPool::settleLongPremium
to settle all unpaid premium for long legs of a given buyer position.
The PanopticPool::settleLongPremium
function is supposed to calculate the current long premium owed to the buyer using the s_options
leg accumulator, then it must collect that the owed premium from the buyer collaterals by calling CollateralTracker::exercise
function, and finally, the function will update the storage mapping settledTokens
which represents the per-chunk accumulator for tokens owed to options sellers (which will make the premium available for sellers of that chunk).
The issue that it's present in the PanopticPool::settleLongPremium
function is that it will mint collateral shares to the buyer instead of burning them from his balance and this is due to the fact that the calculated realizedPremia
passed to CollateralTracker::exercise
is a positive value. To understand this, let's first look at the function code snippet below:
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))); //@audit realizedPremia is position so exercise will mint collateral shares to buyer instead of burning them // 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); }
As it can be seen, the realizedPremia
which represents the long premium owed by the buyer is calculated using the following formula (for both token 0 & 1):
realizedPremia = (accumulatedPremium * liquidity) / 2**64
If we look at the code above, we can notice that accumulatedPremium
is of type LeftRightUnsigned
, meaning that it's a positive number (>= 0), and the chunk liquidity is also a positive number (type uint256
). This means that the resultant realizedPremia
value will also be a positive number as it's the multiplication of two positive numbers.
As we explained above, to collect the owed premium from the buyer collaterals, the CollateralTracker::exercise
function is called. This function's logic implies that if the provided realizedPremia
value is positive, then we must mint new collateral shares to the user, and if realizedPremia
is negative, then we should burn from his balance instead, as shown below:
function exercise( address optionOwner, int128 longAmount, int128 shortAmount, int128 swappedAmount, int128 realizedPremium ) external onlyPanopticPool returns (int128) { unchecked { // current available assets belonging to PLPs (updated after settlement) excluding any premium paid int256 updatedAssets = int256(uint256(s_poolAssets)) - swappedAmount; //@audit will inverse the realizedPremium sign here // add premium to be paid/collected on position close int256 tokenToPay = -realizedPremium; // if burning ITM and swap occurred, compute tokens to be paid through exercise and add swap fees int256 intrinsicValue = swappedAmount - (longAmount - shortAmount); if ((intrinsicValue != 0) && ((shortAmount != 0) || (longAmount != 0))) { // intrinsic value is the amount that need to be exchanged due to burning in-the-money // add the intrinsic value to the tokenToPay tokenToPay += intrinsicValue; } 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) { //@audit settleLongPremium will always run this because provided realizedPremium > 0 // if user must receive tokens, mint them uint256 sharesToMint = convertToShares(uint256(-tokenToPay)); _mint(optionOwner, sharesToMint); } ... } }
So because the realizedPremia
value calculated in settleLongPremium
is positive and is given to CollateralTracker::exercise
function as a positive value, this later will mint new collateral shares to the buyer instead of burning them from his balance (realizedPremium > 0 ==> tokenToPay=-realizedPremium < 0
).
This issue means that whenever the PanopticPool::settleLongPremium
function is called, the options sellers of that leg chunks being settled will suffer a fund loss as even though the settledTokens
accumulator will be updated correctly with the long premium, the actual long premium funds will not be collected from the buyer and made available for the sellers; instead, they will be minted to the buyer as additional collaterals.
The PanopticPool::settleLongPremium
function will not collect long premiums from buyer collateral shares and will instead mint new shares for the buyer, resulting in a loss of funds for all the options sellers.
Manual review, VS Code
When calling CollateralTracker::exercise
in PanopticPool::settleLongPremium
, the provided realizedPremia
value shoud be made negative 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))); ++ .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); }
Context
#0 - c4-judge
2024-04-23T11:41:40Z
Picodes marked the issue as primary issue
#1 - c4-judge
2024-04-30T21:48:49Z
Picodes marked the issue as satisfactory
#2 - c4-judge
2024-04-30T21:49:29Z
Picodes marked issue #497 as primary and marked this issue as a duplicate of 497
🌟 Selected for report: Aymen0909
Also found by: 0xStalin, DanielArmstrong, FastChecker
1481.021 USDC - $1,481.02
https://github.com/code-423n4/2024-04-panoptic/blob/main/contracts/libraries/PanopticMath.sol#L878-L884 https://github.com/code-423n4/2024-04-panoptic/blob/main/contracts/PanopticPool.sol#L1122-L1131
When the user positions are getting liquidated, the PanopticPool::liquidate
function will invoke under the hood the PanopticMath::haircutPremia
function which will use the user's long premium to cover the protocol losses.
The PanopticMath::haircutPremia
function will also update the storage mapping settledTokens
which represent the per-chunk accumulator for tokens owed to options sellers.
The issue that it's present in the PanopticMath::haircutPremia
function is when it run the for-loop to update settledTokens
for each position leg chunk, inside the loop the function uses always the index 0
when calculating the leg chunkKey
instead of using the actual leg index leg
(which can be 0,1,2,3), this shown in the code snippet below:
function haircutPremia( address liquidatee, TokenId[] memory positionIdList, LeftRightSigned[4][] memory premiasByLeg, LeftRightSigned collateralRemaining, CollateralTracker collateral0, CollateralTracker collateral1, uint160 sqrtPriceX96Final, mapping(bytes32 chunkKey => LeftRightUnsigned settledTokens) storage settledTokens ) external returns (int256, int256) { ... for (uint256 i = 0; i < positionIdList.length; i++) { TokenId tokenId = positionIdList[i]; LeftRightSigned[4][] memory _premiasByLeg = premiasByLeg; for (uint256 leg = 0; leg < tokenId.countLegs(); ++leg) { if (tokenId.isLong(leg) == 1) { mapping(bytes32 chunkKey => LeftRightUnsigned settledTokens) storage _settledTokens = settledTokens; // calculate amounts to revoke from settled and subtract from haircut req uint256 settled0 = Math.unsafeDivRoundingUp( uint128(-_premiasByLeg[i][leg].rightSlot()) * uint256(haircut0), uint128(longPremium.rightSlot()) ); uint256 settled1 = Math.unsafeDivRoundingUp( uint128(-_premiasByLeg[i][leg].leftSlot()) * uint256(haircut1), uint128(longPremium.leftSlot()) ); //@audit always calculating the chunkKey of leg 0 bytes32 chunkKey = keccak256( abi.encodePacked( tokenId.strike(0), tokenId.width(0), tokenId.tokenType(0) ) ); // The long premium is not commited to storage during the liquidation, so we add the entire adjusted amount // for the haircut directly to the accumulator settled0 = Math.max( 0, uint128(-_premiasByLeg[i][leg].rightSlot()) - settled0 ); settled1 = Math.max( 0, uint128(-_premiasByLeg[i][leg].leftSlot()) - settled1 ); _settledTokens[chunkKey] = _settledTokens[chunkKey].add( LeftRightUnsigned.wrap(0).toRightSlot(uint128(settled0)).toLeftSlot( uint128(settled1) ) ); } } } return (collateralDelta0, collateralDelta1); }
This issue means that the settledTokens
accumulator will be updated incorrectly and will not include all the legs premium, as for each position only the first leg (index=0) is considered, this will result in funds losses for the options sellers and for the protocol.
Wrong leg chunkKey
calculation in haircutPremia
will cause incorrect update of settledTokens
accumulator and will result in funds losses for the options sellers and for the protocol.
Manual review, VS Code
Use the correct leg index when calculating the leg chunkKey
in haircutPremia
function, the correct code should be:
function haircutPremia( address liquidatee, TokenId[] memory positionIdList, LeftRightSigned[4][] memory premiasByLeg, LeftRightSigned collateralRemaining, CollateralTracker collateral0, CollateralTracker collateral1, uint160 sqrtPriceX96Final, mapping(bytes32 chunkKey => LeftRightUnsigned settledTokens) storage settledTokens ) external returns (int256, int256) { ... for (uint256 i = 0; i < positionIdList.length; i++) { TokenId tokenId = positionIdList[i]; LeftRightSigned[4][] memory _premiasByLeg = premiasByLeg; for (uint256 leg = 0; leg < tokenId.countLegs(); ++leg) { if (tokenId.isLong(leg) == 1) { mapping(bytes32 chunkKey => LeftRightUnsigned settledTokens) storage _settledTokens = settledTokens; // calculate amounts to revoke from settled and subtract from haircut req uint256 settled0 = Math.unsafeDivRoundingUp( uint128(-_premiasByLeg[i][leg].rightSlot()) * uint256(haircut0), uint128(longPremium.rightSlot()) ); uint256 settled1 = Math.unsafeDivRoundingUp( uint128(-_premiasByLeg[i][leg].leftSlot()) * uint256(haircut1), uint128(longPremium.leftSlot()) ); bytes32 chunkKey = keccak256( abi.encodePacked( -- tokenId.strike(0), -- tokenId.width(0), -- tokenId.tokenType(0) ++ tokenId.strike(leg), ++ tokenId.width(leg), ++ tokenId.tokenType(leg) ) ); // The long premium is not commited to storage during the liquidation, so we add the entire adjusted amount // for the haircut directly to the accumulator settled0 = Math.max( 0, uint128(-_premiasByLeg[i][leg].rightSlot()) - settled0 ); settled1 = Math.max( 0, uint128(-_premiasByLeg[i][leg].leftSlot()) - settled1 ); _settledTokens[chunkKey] = _settledTokens[chunkKey].add( LeftRightUnsigned.wrap(0).toRightSlot(uint128(settled0)).toLeftSlot( uint128(settled1) ) ); } } } return (collateralDelta0, collateralDelta1); }
Context
#0 - c4-judge
2024-04-23T11:40:55Z
Picodes marked the issue as primary issue
#1 - dyedm1
2024-04-26T22:15:52Z
I will confirm this because we are fixing it, but I don't think high sev is justified here. The only impact is that during liquidations, premium paid by the liquidatee for legs other than 0 is effectively haircut/refunded to the liquidatee, which could result in an uneven distribution of the haircut and some premium that could have been safely paid being refunded to the liquidatee. Besides resulting in unfair distribution at liquidation time for sellers in the 2-3-4 chunks, no actor in the protocol actually loses funds (yield for sellers is not realized until it is settled anyway, so if they closed before the liquidation ocurred they would get the exact same payment).
#2 - Picodes
2024-05-06T15:39:30Z
Giving Medium severity as this only concerns unrealized yield and happens during liquidations only
#3 - c4-judge
2024-05-06T15:39:38Z
Picodes changed the severity to 2 (Med Risk)
#4 - c4-judge
2024-05-06T15:39:49Z
Picodes marked the issue as selected for report
#5 - c4-judge
2024-05-06T15:39:55Z
Picodes marked the issue as satisfactory
🌟 Selected for report: DadeKuma
Also found by: 0xStalin, 0xhacksmithh, 99Crits, Aymen0909, Bauchibred, CodeWasp, Dup1337, IllIllI, John_Femi, K42, KupiaSec, Naresh, Rhaydden, Rolezn, Sathish9098, Topmark, ZanyBonzy, albahaca, bareli, blockchainbuttonmasher, cheatc0d3, codeslide, crc32, d3e4, favelanky, grearlake, hihen, jasonxiale, jesjupyter, lanrebayode77, lirezArAzAvi, lsaudit, mining_mario, oualidpro, pfapostol, radin100, rbserver, sammy, satoshispeedrunner, slvDev, twcctop, zabihullahazadzoi
32.9585 USDC - $32.96
https://github.com/code-423n4/2024-04-panoptic/blob/main/contracts/libraries/PanopticMath.sol#L768-L858 https://github.com/code-423n4/2024-04-panoptic/blob/main/contracts/CollateralTracker.sol#L1043-L1080 https://github.com/code-423n4/2024-04-panoptic/blob/main/contracts/PanopticPool.sol#L1122-L1131
When the user positions are getting liquidated the PanopticPool::liquidate
function will invoke under the hood the PanopticMath::haircutPremia
function which will use the liquidatee's owed long premium to cover any eventual protocol losses.
The issue that it's present in the PanopticPool::haircutPremia
function is that when trying to exercice the haircut amounts it will mint new collaterals shares to the liquidatee instead of burning them from his existing balance and this is due to the fact that the calculated haircut amounts haircut0
and haircut1
passed to CollateralTracker::exercise
are both positive values.
To understand this, let's first look at the function code snippet below:
function haircutPremia( address liquidatee, TokenId[] memory positionIdList, LeftRightSigned[4][] memory premiasByLeg, LeftRightSigned collateralRemaining, CollateralTracker collateral0, CollateralTracker collateral1, uint160 sqrtPriceX96Final, mapping(bytes32 chunkKey => LeftRightUnsigned settledTokens) storage settledTokens ) external returns (int256, int256) { unchecked { // get the amount of premium paid by the liquidatee LeftRightSigned longPremium; for (uint256 i = 0; i < positionIdList.length; ++i) { TokenId tokenId = positionIdList[i]; uint256 numLegs = tokenId.countLegs(); for (uint256 leg = 0; leg < numLegs; ++leg) { if (tokenId.isLong(leg) == 1) { longPremium = longPremium.sub(premiasByLeg[i][leg]); } } } // Ignore any surplus collateral - the liquidatee is either solvent or it converts to <1 unit of the other token int256 collateralDelta0 = -Math.min(collateralRemaining.rightSlot(), 0); int256 collateralDelta1 = -Math.min(collateralRemaining.leftSlot(), 0); int256 haircut0; int256 haircut1; // if the premium in the same token is not enough to cover the loss and there is a surplus of the other token, // the liquidator will provide the tokens (reflected in the bonus amount) & receive compensation in the other token if ( longPremium.rightSlot() < collateralDelta0 && longPremium.leftSlot() > collateralDelta1 ) { int256 protocolLoss1 = collateralDelta1; (collateralDelta0, collateralDelta1) = ( -Math.min( collateralDelta0 - longPremium.rightSlot(), PanopticMath.convert1to0( longPremium.leftSlot() - collateralDelta1, sqrtPriceX96Final ) ), Math.min( longPremium.leftSlot() - collateralDelta1, PanopticMath.convert0to1( collateralDelta0 - longPremium.rightSlot(), sqrtPriceX96Final ) ) ); haircut0 = longPremium.rightSlot(); haircut1 = protocolLoss1 + collateralDelta1; } else if ( longPremium.leftSlot() < collateralDelta1 && longPremium.rightSlot() > collateralDelta0 ) { int256 protocolLoss0 = collateralDelta0; (collateralDelta0, collateralDelta1) = ( Math.min( longPremium.rightSlot() - collateralDelta0, PanopticMath.convert1to0( collateralDelta1 - longPremium.leftSlot(), sqrtPriceX96Final ) ), -Math.min( collateralDelta1 - longPremium.leftSlot(), PanopticMath.convert0to1( longPremium.rightSlot() - collateralDelta0, sqrtPriceX96Final ) ) ); haircut0 = collateralDelta0 + protocolLoss0; haircut1 = longPremium.leftSlot(); } else { // for each token, haircut until the protocol loss is mitigated or the premium paid is exhausted haircut0 = Math.min(collateralDelta0, longPremium.rightSlot()); haircut1 = Math.min(collateralDelta1, longPremium.leftSlot()); collateralDelta0 = 0; collateralDelta1 = 0; } { //@audit haircut0 & haircut1 will be positive so we it will mint new shares to liquidatee instead of removing them address _liquidatee = liquidatee; if (haircut0 != 0) collateral0.exercise(_liquidatee, 0, 0, 0, int128(haircut0)); if (haircut1 != 0) collateral1.exercise(_liquidatee, 0, 0, 0, int128(haircut1)); } ... } }
As it can be seen, the function will calculates the values of both haircut0
and haircut1
which represents the portion of long premium owed by the liquidatee that must be haircut to cover the protocol losses (this means that this portion of owed long premium will not be given to the seller but instead returned to the collateral pool for PLP).
If we look at the code above, we can notice the following:
longPremium
is a positive number as is calculated using this formula:longPremium = longPremium.sub(premiasByLeg[i][leg]);
We know that for long options premiasByLeg
is negative and so by using sub
(applying -) longPremium
we'll be the sum of - premiasByLeg
which we'll be positive.
collateralDelta0
& collateralDelta1
are both positive numbers (either equal to 0 or above it) which can be deducted from their definition:int256 collateralDelta0 = -Math.min(collateralRemaining.rightSlot(), 0); int256 collateralDelta1 = -Math.min(collateralRemaining.leftSlot(), 0);
If collateralRemaining
is negative then we'll get a positive number after applying (-) and if its above 0 then the min will return 0.
Knowing this two facts we can concluded that no matter which if statement block is run in the haircutPremia
function to calculate haircut0
and haircut1
, both their values will be positive as they are the combination (equal or sum) of positive values: (collateralDelta0
or collateralDelta1
), (protocolLoss0
or protocolLoss1
) and longPremium
(and this is quiet evident in the last else block).
As we explained above, to get the owed premium from the liquidatee collaterals, the CollateralTracker::exercise
function is called. This function's logic implies that if the provided realizedPremia
(represented by haircut0
and haircut1
here) value is positive, then we must mint new collateral shares to the user, and if realizedPremia
is negative, then we should burn from his balance instead, as shown below:
function exercise( address optionOwner, int128 longAmount, int128 shortAmount, int128 swappedAmount, int128 realizedPremium ) external onlyPanopticPool returns (int128) { unchecked { // current available assets belonging to PLPs (updated after settlement) excluding any premium paid int256 updatedAssets = int256(uint256(s_poolAssets)) - swappedAmount; //@audit will inverse the realizedPremium sign here // add premium to be paid/collected on position close int256 tokenToPay = -realizedPremium; // if burning ITM and swap occurred, compute tokens to be paid through exercise and add swap fees int256 intrinsicValue = swappedAmount - (longAmount - shortAmount); if ((intrinsicValue != 0) && ((shortAmount != 0) || (longAmount != 0))) { // intrinsic value is the amount that need to be exchanged due to burning in-the-money // add the intrinsic value to the tokenToPay tokenToPay += intrinsicValue; } 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) { //@audit haircutPremia will always run this because provided realizedPremium > 0 // if user must receive tokens, mint them uint256 sharesToMint = convertToShares(uint256(-tokenToPay)); _mint(optionOwner, sharesToMint); } ... } }
So because both haircut0
and haircut1
are positive and they represent the realized premia realizedPremia
provided to CollateralTracker::exercise
function, this later will mint new collateral shares to the liquidatee instead of burning them from his balance (realizedPremium > 0 ==> tokenToPay=-realizedPremium < 0
).
This issue means that whenever the haircutPremia
function is called in a liquidation call, if there are some protocol losses the function will not haircut from the liquidatee collateral shares balance and will mint him new collateral shares instead, this will result in a protocol loss and will impact all the PLP and other users that deposited collaterals into the collateral tracker which will experience a direct fund loss due to this issue.
During liquidations, the PanopticMath::haircutPremia
function will not remove the long premiums from liquidatee collateral shares to cover protocol losses and will instead mint him new collateral shares, resulting in a loss of funds for the protocol and the PLP.
Manual review, VS Code
When calling CollateralTracker::exercise
in PanopticMath::haircutPremia
, the provided realizedPremia
parameter value should be negative to remove collateral shares from the liquidatee balance as follows:
function haircutPremia( address liquidatee, TokenId[] memory positionIdList, LeftRightSigned[4][] memory premiasByLeg, LeftRightSigned collateralRemaining, CollateralTracker collateral0, CollateralTracker collateral1, uint160 sqrtPriceX96Final, mapping(bytes32 chunkKey => LeftRightUnsigned settledTokens) storage settledTokens ) external returns (int256, int256) { unchecked { // get the amount of premium paid by the liquidatee LeftRightSigned longPremium; for (uint256 i = 0; i < positionIdList.length; ++i) { TokenId tokenId = positionIdList[i]; uint256 numLegs = tokenId.countLegs(); for (uint256 leg = 0; leg < numLegs; ++leg) { if (tokenId.isLong(leg) == 1) { longPremium = longPremium.sub(premiasByLeg[i][leg]); } } } // Ignore any surplus collateral - the liquidatee is either solvent or it converts to <1 unit of the other token int256 collateralDelta0 = -Math.min(collateralRemaining.rightSlot(), 0); int256 collateralDelta1 = -Math.min(collateralRemaining.leftSlot(), 0); int256 haircut0; int256 haircut1; // if the premium in the same token is not enough to cover the loss and there is a surplus of the other token, // the liquidator will provide the tokens (reflected in the bonus amount) & receive compensation in the other token if ( longPremium.rightSlot() < collateralDelta0 && longPremium.leftSlot() > collateralDelta1 ) { int256 protocolLoss1 = collateralDelta1; (collateralDelta0, collateralDelta1) = ( -Math.min( collateralDelta0 - longPremium.rightSlot(), PanopticMath.convert1to0( longPremium.leftSlot() - collateralDelta1, sqrtPriceX96Final ) ), Math.min( longPremium.leftSlot() - collateralDelta1, PanopticMath.convert0to1( collateralDelta0 - longPremium.rightSlot(), sqrtPriceX96Final ) ) ); haircut0 = longPremium.rightSlot(); haircut1 = protocolLoss1 + collateralDelta1; } else if ( longPremium.leftSlot() < collateralDelta1 && longPremium.rightSlot() > collateralDelta0 ) { int256 protocolLoss0 = collateralDelta0; (collateralDelta0, collateralDelta1) = ( Math.min( longPremium.rightSlot() - collateralDelta0, PanopticMath.convert1to0( collateralDelta1 - longPremium.leftSlot(), sqrtPriceX96Final ) ), -Math.min( collateralDelta1 - longPremium.leftSlot(), PanopticMath.convert0to1( longPremium.rightSlot() - collateralDelta0, sqrtPriceX96Final ) ) ); haircut0 = collateralDelta0 + protocolLoss0; haircut1 = longPremium.leftSlot(); } else { // for each token, haircut until the protocol loss is mitigated or the premium paid is exhausted haircut0 = Math.min(collateralDelta0, longPremium.rightSlot()); haircut1 = Math.min(collateralDelta1, longPremium.leftSlot()); collateralDelta0 = 0; collateralDelta1 = 0; } { //@audit haircut0 & haircut1 must be transformed into negative values address _liquidatee = liquidatee; -- if (haircut0 != 0) collateral0.exercise(_liquidatee, 0, 0, 0, int128(haircut0)); -- if (haircut1 != 0) collateral1.exercise(_liquidatee, 0, 0, 0, int128(haircut1)); ++ if (haircut0 != 0) collateral0.exercise(_liquidatee, 0, 0, 0, int128(-haircut0)); ++ if (haircut1 != 0) collateral1.exercise(_liquidatee, 0, 0, 0, int128(-haircut1)); } ... } }
Context
#0 - c4-judge
2024-04-23T11:40:58Z
Picodes marked the issue as primary issue
#1 - dyedm1
2024-04-26T19:53:01Z
Actually, the mechanism for protocol loss is executed when revoking delegated shares from the liquidatee to the liquidator. If there is protocol loss, the liquidatee will not have enough tokens to pay back the liquidator's original value + bonus, so shares are minted to the liquidator instead (getting them their payment, but lowering the share price). So, if we wanted to lower the liquidatee's protocol loss by revoking premium they paid, we would simply mint shares to their account (which would cover more of the revoked tokens to the liquidator, requiring less excess shares to be minted = lower protocol loss)
#2 - Picodes
2024-05-06T12:01:38Z
Downgrading to QA following the sponsor's remark.
#3 - c4-judge
2024-05-06T12:01:42Z
Picodes changed the severity to QA (Quality Assurance)
#4 - c4-judge
2024-05-06T16:07:17Z
Picodes marked the issue as grade-b