Panoptic - DanielArmstrong's results

Permissionless, perpetual options trading on any token, any strike, any size.

General Information

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

Panoptic

Findings Distribution

Researcher Performance

Rank: 11/55

Findings: 2

Award: $3,189.89

🌟 Selected for report: 0

🚀 Solo Findings: 0

Findings Information

🌟 Selected for report: pkqs90

Also found by: 0xStalin, Aymen0909, DanielArmstrong, JecikPo, bin2chen

Labels

bug
3 (High Risk)
satisfactory
:robot:_98_group
duplicate-497

Awards

2050.6445 USDC - $2,050.64

External Links

Lines of code

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

Vulnerability details

Impact

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.

  1. If a malicious user intentionally exploits this error, he or she may unfairly steal the collateral from the pool.
  2. Errors in the accounting of poolAssets can cause more serious problems to the entire system.

Proof of Concept

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

  1. According to L1078, a share equal to realizedPremium is paid to the owner.
  2. 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.

Tools Used

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:     }

Assessed type

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

Findings Information

🌟 Selected for report: Aymen0909

Also found by: 0xStalin, DanielArmstrong, FastChecker

Labels

bug
2 (Med Risk)
downgraded by judge
satisfactory
sponsor disputed
:robot:_140_group
duplicate-374

Awards

1139.2469 USDC - $1,139.25

External Links

Lines of code

https://github.com/code-423n4/2024-04-panoptic/blob/main/contracts/libraries/PanopticMath.sol#L880

Vulnerability details

Impact

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.

  1. If a malicious user intentionally exploits this error, he or she may unfairly steal premiums that should be paid to other short users.
  2. Errors in the accounting of PanopticPool.sol#s_settledTokens can cause more serious problems to the entire system.

Proof of Concept

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.

Tools Used

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:                         );
...

Assessed type

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

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax © 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter