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: 15/55
Findings: 1
Award: $1,139.25
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: Aymen0909
Also found by: 0xStalin, DanielArmstrong, FastChecker
1139.2469 USDC - $1,139.25
https://github.com/code-423n4/2024-04-panoptic/blob/main/contracts/PanopticPool.sol#L1017-L1171 https://github.com/code-423n4/2024-04-panoptic/blob/main/contracts/libraries/PanopticMath.sol#L768-L908
Short users of the chunk corresponding to the long position leg
do not benefit, and short users of the chunk corresponding to the 0th leg benefit.
An attacker can use this to obtain premiums from other short users.
The PanopticPool.sol#liquidate
function is as follows.
1017: function liquidate( 1018: TokenId[] calldata positionIdListLiquidator, 1019: address liquidatee, 1020: LeftRightUnsigned delegations, 1021: TokenId[] calldata positionIdList 1022: ) external { _validatePositionList(liquidatee, positionIdList, 0); // Assert the account we are liquidating is actually insolvent int24 twapTick = getUniV3TWAP(); LeftRightUnsigned tokenData0; LeftRightUnsigned tokenData1; LeftRightSigned premia; { (, int24 currentTick, , , , , ) = s_univ3pool.slot0(); // Enforce maximum delta between TWAP and currentTick to prevent extreme price manipulation if (Math.abs(currentTick - twapTick) > MAX_TWAP_DELTA_LIQUIDATION) revert Errors.StaleTWAP(); uint256[2][] memory positionBalanceArray = new uint256[2][](positionIdList.length); (premia, positionBalanceArray) = _calculateAccumulatedPremia( liquidatee, positionIdList, COMPUTE_ALL_PREMIA, ONLY_AVAILABLE_PREMIUM, currentTick ); tokenData0 = s_collateralToken0.getAccountMarginDetails( liquidatee, twapTick, positionBalanceArray, premia.rightSlot() ); tokenData1 = s_collateralToken1.getAccountMarginDetails( liquidatee, twapTick, positionBalanceArray, premia.leftSlot() ); (uint256 balanceCross, uint256 thresholdCross) = _getSolvencyBalances( tokenData0, tokenData1, Math.getSqrtRatioAtTick(twapTick) ); if (balanceCross >= thresholdCross) revert Errors.NotMarginCalled(); } // Perform the specified delegation from `msg.sender` to `liquidatee` // Works like a transfer, so the liquidator must possess all the tokens they are delegating, resulting in no net supply change // If not enough tokens are delegated for the positions of `liquidatee` to be closed, the liquidation will fail s_collateralToken0.delegate(msg.sender, liquidatee, delegations.rightSlot()); s_collateralToken1.delegate(msg.sender, liquidatee, delegations.leftSlot()); int256 liquidationBonus0; int256 liquidationBonus1; int24 finalTick; { LeftRightSigned netExchanged; LeftRightSigned[4][] memory premiasByLeg; // burn all options from the liquidatee // Do not commit any settled long premium to storage - we will do this after we determine if any long premium must be revoked // This is to prevent any short positions the liquidatee has being settled with tokens that will later be revoked // Note: tick limits are not applied here since it is not the liquidator's position being liquidated (netExchanged, premiasByLeg) = _burnAllOptionsFrom( liquidatee, Constants.MIN_V3POOL_TICK, Constants.MAX_V3POOL_TICK, DONOT_COMMIT_LONG_SETTLED, positionIdList ); (, finalTick, , , , , ) = s_univ3pool.slot0(); LeftRightSigned collateralRemaining; // compute bonus amounts using latest tick data (liquidationBonus0, liquidationBonus1, collateralRemaining) = PanopticMath .getLiquidationBonus( tokenData0, tokenData1, Math.getSqrtRatioAtTick(twapTick), Math.getSqrtRatioAtTick(finalTick), netExchanged, premia ); // premia cannot be paid if there is protocol loss associated with the liquidatee // otherwise, an economic exploit could occur if the liquidator and liquidatee collude to // manipulate the fees in a liquidity area they control past the protocol loss threshold // such that the PLPs are forced to pay out premia to the liquidator // thus, we haircut any premium paid by the liquidatee (converting tokens as necessary) until the protocol loss is covered or the premium is exhausted // note that the haircutPremia function also commits the settled amounts (adjusted for the haircut) to storage, so it will be called even if there is no haircut // if premium is haircut from a token that is not in protocol loss, some of the liquidation bonus will be converted into that token // reusing variables to save stack space; netExchanged = deltaBonus0, premia = deltaBonus1 address _liquidatee = liquidatee; TokenId[] memory _positionIdList = positionIdList; int24 _finalTick = finalTick; int256 deltaBonus0; int256 deltaBonus1; 1122: (deltaBonus0, deltaBonus1) = PanopticMath.haircutPremia( 1123: _liquidatee, 1124: _positionIdList, 1125: premiasByLeg, 1126: collateralRemaining, 1127: s_collateralToken0, 1128: s_collateralToken1, 1129: Math.getSqrtRatioAtTick(_finalTick), 1130: s_settledTokens 1131: ); unchecked { liquidationBonus0 += deltaBonus0; liquidationBonus1 += deltaBonus1; } } LeftRightUnsigned _delegations = delegations; // revoke the delegated amount plus the bonus amount. s_collateralToken0.revoke( msg.sender, liquidatee, uint256(int256(uint256(_delegations.rightSlot())) + liquidationBonus0) ); s_collateralToken1.revoke( msg.sender, liquidatee, uint256(int256(uint256(_delegations.leftSlot())) + liquidationBonus1) ); // check that the provided positionIdList matches the positions in memory _validatePositionList(msg.sender, positionIdListLiquidator, 0); if ( !_checkSolvencyAtTick( msg.sender, positionIdListLiquidator, finalTick, finalTick, BP_DECREASE_BUFFER ) ) revert Errors.NotEnoughCollateral(); LeftRightSigned bonusAmounts = LeftRightSigned .wrap(0) .toRightSlot(int128(liquidationBonus0)) .toLeftSlot(int128(liquidationBonus1)); emit AccountLiquidated(msg.sender, liquidatee, bonusAmounts); }
The PanopticMath.sol#haircutPremia
function was called in L1122, and the function is as follows.
768: function haircutPremia( 769: address liquidatee, 770: TokenId[] memory positionIdList, 771: LeftRightSigned[4][] memory premiasByLeg, 772: LeftRightSigned collateralRemaining, 773: CollateralTracker collateral0, 774: CollateralTracker collateral1, 775: uint160 sqrtPriceX96Final, 776: mapping(bytes32 chunkKey => LeftRightUnsigned settledTokens) storage settledTokens 777: ) 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; } { 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)); } 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()) ); 878: bytes32 chunkKey = keccak256( 879: abi.encodePacked( 880: tokenId.strike(0), 881: tokenId.width(0), 882: tokenId.tokenType(0) 883: ) 884: ); 885: 886: // The long premium is not commited to storage during the liquidation, so we add the entire adjusted amount 887: // for the haircut directly to the accumulator 888: settled0 = Math.max( 889: 0, 890: uint128(-_premiasByLeg[i][leg].rightSlot()) - settled0 891: ); 892: settled1 = Math.max( 893: 0, 894: uint128(-_premiasByLeg[i][leg].leftSlot()) - settled1 895: ); 896: 897: _settledTokens[chunkKey] = _settledTokens[chunkKey].add( 898: LeftRightUnsigned.wrap(0).toRightSlot(uint128(settled0)).toLeftSlot( 899: uint128(settled1) 900: ) 901: ); } } } return (collateralDelta0, collateralDelta1); } }
L878 always calculates the chunkKey
of the 0th leg.
The chunkKey
calculated here is used in L897. By L897, the premium of liquidee's long position leg
is always set to the chunk corresponding to the 0th leg.
In other words, short users of the chunk corresponding to the long position leg
will not benefit, and short users of the chunk corresponding to the 0th log will benefit.
Manual Review
Modify PanopticMath.sol#haircutPremia
as follows.
768: function haircutPremia( 769: address liquidatee, 770: TokenId[] memory positionIdList, 771: LeftRightSigned[4][] memory premiasByLeg, 772: LeftRightSigned collateralRemaining, 773: CollateralTracker collateral0, 774: CollateralTracker collateral1, 775: uint160 sqrtPriceX96Final, 776: mapping(bytes32 chunkKey => LeftRightUnsigned settledTokens) storage settledTokens 777: ) 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; } { 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)); } 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()) ); 878: bytes32 chunkKey = keccak256( 879: abi.encodePacked( 880: -- tokenId.strike(0), 881: -- tokenId.width(0), 882: -- tokenId.tokenType(0) ++ tokenId.strike(leg), ++ tokenId.width(leg), ++ tokenId.tokenType(leg) 883: ) 884: ); 885: 886: // The long premium is not commited to storage during the liquidation, so we add the entire adjusted amount 887: // for the haircut directly to the accumulator 888: settled0 = Math.max( 889: 0, 890: uint128(-_premiasByLeg[i][leg].rightSlot()) - settled0 891: ); 892: settled1 = Math.max( 893: 0, 894: uint128(-_premiasByLeg[i][leg].leftSlot()) - settled1 895: ); 896: 897: _settledTokens[chunkKey] = _settledTokens[chunkKey].add( 898: LeftRightUnsigned.wrap(0).toRightSlot(uint128(settled0)).toLeftSlot( 899: uint128(settled1) 900: ) 901: ); } } } return (collateralDelta0, collateralDelta1); } }
Other
#0 - c4-judge
2024-04-26T18:36:41Z
Picodes marked the issue as primary issue
#1 - dyedm1
2024-04-26T22:06:49Z
The impact described is wrong, this seems to be a dup of #374 which i will confirm but dispute sev
#2 - c4-judge
2024-05-01T11:04:42Z
Picodes marked the issue as duplicate of #374
#3 - c4-judge
2024-05-06T15:39:37Z
Picodes changed the severity to 2 (Med Risk)
#4 - c4-judge
2024-05-06T15:40:25Z
Picodes marked the issue as satisfactory