Panoptic - Aymen0909'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: 9/55

Findings: 3

Award: $3,564.62

🌟 Selected for report: 1

🚀 Solo Findings: 0

Findings Information

🌟 Selected for report: pkqs90

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

Labels

bug
3 (High Risk)
satisfactory
sponsor confirmed
: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#L1600-L1640 https://github.com/code-423n4/2024-04-panoptic/blob/main/contracts/CollateralTracker.sol#L1043-L1079

Vulnerability details

Issue Description

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.

Impact

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.

Tools Used

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

Assessed type

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

Findings Information

🌟 Selected for report: Aymen0909

Also found by: 0xStalin, DanielArmstrong, FastChecker

Labels

bug
2 (Med Risk)
downgraded by judge
primary issue
satisfactory
selected for report
sponsor confirmed
edited-by-warden
:robot:_140_group
M-08

Awards

1481.021 USDC - $1,481.02

External Links

Lines of code

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

Vulnerability details

Issue Description

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.

Impact

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.

Tools Used

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

Assessed type

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

Awards

32.9585 USDC - $32.96

Labels

bug
downgraded by judge
grade-b
primary issue
QA (Quality Assurance)
sponsor disputed
:robot:_242_group
Q-09

External Links

Lines of code

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

Vulnerability details

Issue Description

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.

Impact

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.

Tools Used

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

        ...
    }
}

Assessed type

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

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