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: 11/55
Findings: 2
Award: $3,189.89
🌟 Selected for report: 0
🚀 Solo Findings: 0
2050.6445 USDC - $2,050.64
https://github.com/code-423n4/2024-04-panoptic/blob/main/contracts/PanopticPool.sol#L1639 https://github.com/code-423n4/2024-04-panoptic/blob/main/contracts/PanopticPool.sol#L1640
Every PanopticPool.settleLongPremium()
call causes the CollateralTracker
's share value to be lower than the actual value, and an error occurs in the accounting of poolAssets.
As a result, the long position owner receives unfair profits equal to two times the premium, and other users suffer losses equivalent to that amount.
The PanopticPool.settleLongPremium()
function is as follows.
File: PanopticPool.sol 1587: function settleLongPremium( 1588: TokenId[] calldata positionIdList, 1589: address owner, 1590: uint256 legIndex 1591: ) external { 1592: _validatePositionList(owner, positionIdList, 0); ... 1600: LeftRightUnsigned accumulatedPremium; ... 1627: uint256 liquidity = PanopticMath 1628: .getLiquidityChunk(tokenId, legIndex, s_positionBalance[owner][tokenId].rightSlot()) 1629: .liquidity(); 1630: 1631: unchecked { 1632: // update the realized premia 1633: LeftRightSigned realizedPremia = LeftRightSigned 1634: .wrap(0) 1635: .toRightSlot(int128(int256((accumulatedPremium.rightSlot() * liquidity) / 2 ** 64))) 1636: .toLeftSlot(int128(int256((accumulatedPremium.leftSlot() * liquidity) / 2 ** 64))); 1637: 1638: // deduct the paid premium tokens from the owner's balance and add them to the cumulative settled token delta 1639: s_collateralToken0.exercise(owner, 0, 0, 0, realizedPremia.rightSlot()); // @audit <-- realizedPremia is an unsigned value greater than 0. However, in order for exercise() to subtract premia, we must pass a value less than 0. 1640: s_collateralToken1.exercise(owner, 0, 0, 0, realizedPremia.leftSlot()); // @audit <-- realizedPremia is an unsigned value greater than 0. However, in order for exercise() to subtract premia, we must pass a value less than 0. ... 1649: // commit the delta in settled tokens (all of the premium paid by long chunks in the tokenIds list) to storage 1650: s_settledTokens[chunkKey] = s_settledTokens[chunkKey].add( 1651: LeftRightUnsigned.wrap(uint256(LeftRightSigned.unwrap(realizedPremia))) 1652: ); ... 1659: }
In L1639 and L1640, the premium value is attempted to be deducted from the long position owner through the s_collateralToken.exercise()
function.
In L1635 and L1636, the data type of accumulatedPremium
is LeftRightUnsigned
and the data type of liquidity
is uint256
, which are both > 0, and therefore, the rightSlot
and leftSlot
of realizedPremium
are both > 0.
Meanwhile, the CollateralTracker.sol#exercise()
function is as follows.
File: CollateralTracker.sol 1043: function exercise( 1044: address optionOwner, 1045: int128 longAmount, 1046: int128 shortAmount, 1047: int128 swappedAmount, 1048: int128 realizedPremium 1049: ) external onlyPanopticPool returns (int128) { ... 1054: // add premium to be paid/collected on position close 1055: int256 tokenToPay = -realizedPremium; ... 1067: if (tokenToPay > 0) { 1068: // if user must pay tokens, burn them from user balance (revert if balance too small) 1069: uint256 sharesToBurn = Math.mulDivRoundingUp( 1070: uint256(tokenToPay), 1071: totalSupply, 1072: totalAssets() 1073: ); 1074: _burn(optionOwner, sharesToBurn); 1075: } else if (tokenToPay < 0) { 1076: // if user must receive tokens, mint them 1077: uint256 sharesToMint = convertToShares(uint256(-tokenToPay)); 1078: _mint(optionOwner, sharesToMint); 1079: } ... 1084: s_poolAssets = uint128(uint256(updatedAssets + realizedPremium)); ... 1089: }
Since the delivered realizedPremium
parameter is > 0, tokenToPay
< 0 by L1055 and the result is
realizedPremium
is paid to the owner.s_poolAssets
is increased by realizedPremium
by L1084.This is the exact opposite of what was intended and is a very dangerous result.
To be accurate, the CollateralTracker.sol#exercise()
function must burn as much share as realizedPremium
from the owner and s_poolAssets
must be reduced by realizedPremium
.
To achieve this, when calling s_collateralToken.exercise()
in PanopticPool.sol#L1639, L1640, realizedPremia
multiplied by -1 must be passed.
Manual Review
When calling s_collateralToken.exercise()
in PanopticPool.sol#L1639, L1640, the realizedPreemia
parameter multiplied by -1 must be passed.
File: PanopticPool.sol 1587: function settleLongPremium( 1588: TokenId[] calldata positionIdList, 1589: address owner, 1590: uint256 legIndex 1591: ) external { ... 1637: 1638: // deduct the paid premium tokens from the owner's balance and add them to the cumulative settled token delta --- 1639: s_collateralToken0.exercise(owner, 0, 0, 0, realizedPremia.rightSlot()); --- 1640: s_collateralToken1.exercise(owner, 0, 0, 0, realizedPremia.leftSlot()); +++ 1639: s_collateralToken0.exercise(owner, 0, 0, 0, -1 * realizedPremia.rightSlot()); +++ 1640: s_collateralToken1.exercise(owner, 0, 0, 0, -1 * realizedPremia.leftSlot()); ... 1659: }
Math
#0 - c4-judge
2024-04-23T11:45:13Z
Picodes marked the issue as duplicate of #376
#1 - c4-judge
2024-04-30T21:47:52Z
Picodes marked the issue as satisfactory
🌟 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/libraries/PanopticMath.sol#L880
When PanopticPool.liquidate()
is called, the premium paid from liquidee's long position leg is always settled to the chunk corresponding to leg 0.
As a result, short users of the chunk corresponding to the long position leg do not receive any premium, and short users of the chunk corresponding to leg 0 gain unfairly.
PanopticPool.sol#s_settledTokens
can cause more serious problems to the entire system.The PanopticPool.liquidate()
function is as follows.
File: PanopticPool.sol 1017: function liquidate( 1018: TokenId[] calldata positionIdListLiquidator, 1019: address liquidatee, 1020: LeftRightUnsigned delegations, 1021: TokenId[] calldata positionIdList 1022: ) external { ... 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: ); ...
The PanopticMath.haircutPremia()
function of L1122 is as follows.
File: PanopticMath.sol 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) { ... 860: for (uint256 i = 0; i < positionIdList.length; i++) { 861: TokenId tokenId = positionIdList[i]; 862: LeftRightSigned[4][] memory _premiasByLeg = premiasByLeg; 863: for (uint256 leg = 0; leg < tokenId.countLegs(); ++leg) { 864: if (tokenId.isLong(leg) == 1) { 865: mapping(bytes32 chunkKey => LeftRightUnsigned settledTokens) 866: storage _settledTokens = settledTokens; 867: 868: // calculate amounts to revoke from settled and subtract from haircut req 869: uint256 settled0 = Math.unsafeDivRoundingUp( 870: uint128(-_premiasByLeg[i][leg].rightSlot()) * uint256(haircut0), 871: uint128(longPremium.rightSlot()) 872: ); 873: uint256 settled1 = Math.unsafeDivRoundingUp( 874: uint128(-_premiasByLeg[i][leg].leftSlot()) * uint256(haircut1), 875: uint128(longPremium.leftSlot()) 876: ); 877: 878: bytes32 chunkKey = keccak256( 879: abi.encodePacked( 880: tokenId.strike(0), // @audit <-- Must be leg, not 0 881: tokenId.width(0), // @audit <-- Must be leg, not 0 882: tokenId.tokenType(0) // @audit <-- Must be leg, not 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: ); ...
Code lines L897 to L901 perform the function of settling the predium paid from leg
, the long position of the liquidee, to the corresponding chunk.
The chunkKey
used here is obtained from L878 ~ L884, and regardless of leg
, the key for the chunk of leg 0 is always calculated.
This is an obvious error, and in L878 ~ L884, the key of the chunk corresponding to leg
must be calculated.
Manual Review
File: PanopticMath.sol 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) { ... 878: bytes32 chunkKey = keccak256( 879: abi.encodePacked( 880: --- tokenId.strike(0), 881: --- tokenId.width(0), 882: --- tokenId.tokenType(0) 880: +++ tokenId.strike(leg), 881: +++ tokenId.width(leg), 882: +++ tokenId.tokenType(leg) 883: ) 884: ); ...
Math
#0 - dyedm1
2024-04-27T03:02:10Z
dup #374
#1 - c4-judge
2024-05-06T09:41:16Z
Picodes marked the issue as duplicate of #374
#2 - c4-judge
2024-05-06T15:39:36Z
Picodes changed the severity to 2 (Med Risk)
#3 - c4-judge
2024-05-06T15:40:44Z
Picodes marked the issue as satisfactory