Panoptic - FastChecker'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: 15/55

Findings: 1

Award: $1,139.25

🌟 Selected for report: 0

🚀 Solo Findings: 0

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/PanopticPool.sol#L1017-L1171 https://github.com/code-423n4/2024-04-panoptic/blob/main/contracts/libraries/PanopticMath.sol#L768-L908

Vulnerability details

Impact

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.

Proof of Concept

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.

Tools Used

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);
            }
        }

Assessed type

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

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