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: 10/55
Findings: 3
Award: $3,402.78
🌟 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#L1632-L1640
Loss of funds for PLPs because Option Buyers receive shares for the premia they owe instead of having their shares burnt to pay their debt. This causes each share can claim fewer amounts of underlyingToken, thus, all the share's holders receive a haircut equivalent to the amount of premia that the Option Buyers should have paid.
The PanopticPool.settleLongPremium() function
is meant to be used by OptionSellers to settle premium owed by OptionSellers as long as they are solvent.
The process to determine the amount of premium to settle is by determining the amount of realizedPremia
that should be paid by the OptionBuyer. The problem with the existing implementation is that the computed value of the realizedPremia
is not inverted when is passed to the CollateralTracker.exercise() function
.
realizedPremia
is received as a positive value on the CollateralTracker.exercise() function
, the OptionBuyer will actually get shares minted instead of having his shares burnt to pay for the owedPremia.This bug on the settleLongPremium()
function can be abused by the same OptionBuyers, they can continuously call this function to receive free shares for the amount of premia they owe, apart from receiving free shares, they will also settle their owedPremia, meaning, they can hold long options and get paid for it, instead of they paying for the long options.
> PanopticPool.sol function settleLongPremium( ... ) external { ... LeftRightUnsigned accumulatedPremium; { ... //@audit-info => If position is `long`, return the accumulated value as the `premiumOwed` for the whole chunk => Longs pays premia! (uint128 premiumAccumulator0, uint128 premiumAccumulator1) = SFPM.getAccountPremium( ... ); //@audit-info => accumulatedPremia up to the current block. accumulated `premiumOwed` for the whole chunk! accumulatedPremium = LeftRightUnsigned .wrap(0) .toRightSlot(premiumAccumulator0) .toLeftSlot(premiumAccumulator1); ... //@audit-info => premia owed is the difference between the currentAccumulator on the SFPM and the last snapshot saved on the PP! accumulatedPremium = accumulatedPremium.sub(premiumAccumulatorsLast); } ... unchecked { //@audit-info => Calculate the exact owedPremia by the owner! // 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 a positive value. //@audit => Calls the exercise() and passes a positive value... Let's see what this does! // 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()); ... } ... }
</br> </details>> CollateralTracker.sol function exercise( ... //@audit-info => Value of the `realizedPremia` when settling long premium int128 realizedPremium ) external onlyPanopticPool returns (int128) { unchecked { ... //@audit => The value of the `realizedPremium` parameter is inverted. //@audit-info => If received as positive, it is converted to negative //@audit-info => If received as negative, it is converted to positive //@audit-info => Because `realizedPremium` was received as a positive value, `tokenToPay` will be a negative value! // add premium to be paid/collected on position close int256 tokenToPay = -realizedPremium; ... //@audit => Shares are burnt only if tokenToPay is a positive value! if (tokenToPay > 0) { // if user must pay tokens, burn them from user balance (revert if balance too small) ... _burn(optionOwner, sharesToBurn); //@audit-issue => Because `tokenToPay` is negative, the OptionBuyer will receive shares instead of getting some of his shares burnt! } else if (tokenToPay < 0) { // if user must receive tokens, mint them uint256 sharesToMint = convertToShares(uint256(-tokenToPay)); _mint(optionOwner, sharesToMint); } ... } }
Manual Audit
Before calling the CollateralTracker.exercise() function
, make sure the value of the realizedPremia
is inverted, in this way, the exercise()
will receive a negative value, this will ensure that the OptionBuyer get his shares burnt instead of receiving free shares.
realizedPremia
of long options is a technique already done in the _getPremia()
, this is why any other place on the codebase that determines the realizedPremia by calling the _getPremia()
works fine!function settleLongPremium( TokenId[] calldata positionIdList, address owner, uint256 legIndex ) external { ... ... ... 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-info => invert the value of the `realizedPremia` and send this value to the exercise()! + LeftRightSigned owedPremia = LeftRightSigned.wrap(0).sub(realizedPremia); // 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()); //@audit-info => Send the inverted value of the `realizedPremia`, in this way, the `owner` will get his shares burnt, instead of getting shares minted for free! + s_collateralToken0.exercise(owner, 0, 0, 0, owedPremia.rightSlot()); + s_collateralToken1.exercise(owner, 0, 0, 0, owedPremia.leftSlot()); ... //@audit-ok => This is fine, when buyers pay long premium, settledTokens increases, thus, is required to use the `realizedPremia` //@audit-info => Remember, the `realizedPremia` was required to be inverted only to ensure that the `exercise()` would burn shares from the OptionBuyer. // 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); } ... }
Context
#0 - c4-judge
2024-04-23T11:44:35Z
Picodes marked the issue as duplicate of #376
#1 - c4-judge
2024-04-30T21:48:04Z
Picodes marked the issue as satisfactory
🌟 Selected for report: Aymen0909
Also found by: 0xStalin, DanielArmstrong, FastChecker
1139.2469 USDC - $1,139.25
The _settledTokens
will be updated incorrectly when executing the PanopticMath.haircutPremia() function
, leading to OptionSellers that sold shorts on that chunk to earn less premium because there will be less settledTokens than what should really be.
settledTokens
will be lower, thus, the availablePremium
will be lower than what it really should be, causing the OptionSellers to earn fewer premia because the ratio of total owed premium to total settled premium in that chunk will be lower.When a liquidation is executed, the premium paid by the liquidatee is haircut, this process is done using the PanopticMath.haircutPremia() function
.
In this function, the long premium of all the long options of the liquidatee is extracted from the premiasByLeg
parameter, then, depending on the longPremium and the liquidatee's collateralRemaining is determined the haircut per each token which then is exercised from the liquidatee on the CollateralTrackers. After this, the haircutPremia()
iterates one more time all the options across all the positions, and on each long leg it proceeds to compute the number of tokens to be settled on the _settledTokens
variable
The problem is how the chunkKey
is computed, it is always computing the chunkKey for the legIndex 0, no matter what is the current legIndex, the chunkKey is computed using the index 0.
As a result of updating the settledTokens on the incorrect chunkKey, the real accounting of settledTokens for the computed chunkKey will be messed up.
function haircutPremia( ... ) external returns (int256, int256) { unchecked { ... for (uint256 i = 0; i < positionIdList.length; i++) { ... for (uint256 leg = 0; leg < tokenId.countLegs(); ++leg) { if (tokenId.isLong(leg) == 1) { mapping(bytes32 chunkKey => LeftRightUnsigned settledTokens) storage _settledTokens = settledTokens; //@audit-info => Computes the amount to settle // 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-issue => The computed `chunkKey` is for the values of the legIndex 0, regardless of the current legIndex! bytes32 chunkKey = keccak256( abi.encodePacked( tokenId.strike(0), tokenId.width(0), tokenId.tokenType(0) ) ); ... //@audit-issue => Updates the settled tokens on the chunkKey that was computed using the legIndex 0, regardless of the current legIndex! _settledTokens[chunkKey] = _settledTokens[chunkKey].add( LeftRightUnsigned.wrap(0).toRightSlot(uint128(settled0)).toLeftSlot( uint128(settled1) ) ); } } } ... } }
Manual Audit
The recommendation to mitigate this issue is to compute the chunkKey
using the current legIndex instead of always computing the chunkKey for the legIndex 0
function haircutPremia( ... ) external returns (int256, int256) { unchecked { ... for (uint256 i = 0; i < positionIdList.length; i++) { ... for (uint256 leg = 0; leg < tokenId.countLegs(); ++leg) { if (tokenId.isLong(leg) == 1) { ... bytes32 chunkKey = keccak256( abi.encodePacked( - tokenId.strike(0), - tokenId.width(0), - tokenId.tokenType(0) //@audit => Use the current legIndex! + tokenId.strike(leg), + tokenId.width(leg), + tokenId.tokenType(leg) ) ); ... } } } ... } }
Context
#0 - c4-judge
2024-04-23T11:41:23Z
Picodes marked the issue as duplicate of #374
#1 - c4-judge
2024-05-06T15:39:36Z
Picodes changed the severity to 2 (Med Risk)
#2 - c4-judge
2024-05-06T15:40:36Z
Picodes marked the issue as satisfactory
🌟 Selected for report: DadeKuma
Also found by: 0xStalin, 0xhacksmithh, 99Crits, Aymen0909, Bauchibred, CodeWasp, Dup1337, IllIllI, John_Femi, K42, KupiaSec, Naresh, Rhaydden, Rolezn, Sathish9098, Topmark, ZanyBonzy, albahaca, bareli, blockchainbuttonmasher, cheatc0d3, codeslide, crc32, d3e4, favelanky, grearlake, hihen, jasonxiale, jesjupyter, lanrebayode77, lirezArAzAvi, lsaudit, mining_mario, oualidpro, pfapostol, radin100, rbserver, sammy, satoshispeedrunner, slvDev, twcctop, zabihullahazadzoi
212.8863 USDC - $212.89
Users can't transfer their positions on the SFPM even though they are transfering all the liquidity of the position.
When an account opens opens different positions with options deploying liquidity to the same chunk, the s_accountLiquidity
will be udpated to reflect all the account's liquidity.
This causes that when the SemiFungiblePositionManager.registerTokenTransfer() function
checks if the liquidity of the position to be transferred matches the s_accountLiquidity
to not be the same, which will cause the tx to revert, even though the user is attempting to transfer the full liquidity of the position.
The problem is that the s_accountLiquidity
is updated with all the account's liquidity that has been deployed to the same chunk, not only the liquidity of the position that is beent transferred.
Add the below PoC to the SemiFungiblePositionManager.t.sol
test file, and run it with the next command:
forge test --match-test testStalin_tokenTransfer_PoC -vvvv
</details>function testStalin_tokenTransfer_PoC( uint256 x, uint256 widthSeed, int256 strikeSeed, uint256 positionSizeSeed ) public { _initPool(x); (int24 width, int24 strike) = PositionUtils.getOutOfRangeSW( widthSeed, strikeSeed, uint24(tickSpacing), currentTick ); populatePositionData(width, strike, positionSizeSeed); vm.startPrank(Alice); //@audit-info => Short Option 1 (Position 1) TokenId tokenId1 = TokenId.wrap(0).addPoolId(poolId).addLeg(0, 1, 1, 0, 1, 0, strike, width); //@audit-info => mint tokenId1 sfpm.mintTokenizedPosition( tokenId1, uint128(positionSize), TickMath.MIN_TICK, TickMath.MAX_TICK ); //@audit-info => Short Option 2 (Position 2) deploying liquidity on the same chunk as the first position! TokenId tokenId2 = TokenId.wrap(0).addPoolId(poolId).addLeg(0, 1, 2, 0, 1, 0, strike, width); //@audit-info => mint tokenId2 sfpm.mintTokenizedPosition( tokenId2, uint128(positionSize) * 2, TickMath.MIN_TICK, TickMath.MAX_TICK ); //@audit-info => Alice attempts transfers her tokenId1 to Bob - Will revert even though is transferring all the liquidity of the tokenId1/position1 sfpm.safeTransferFrom(Alice, Bob, TokenId.unwrap(tokenId1), positionSize, ""); }
Fix:
The CollateralTracker.maxMint() function
has a precision loss that computed incorrectly the amount of shares that would be received for the maximum allowed deposit.
If the returned value from the maxMint() function
is compared with the returned value of the CollateralTracker.previewDeposit() function
, the values are not equals.
The discrepancy on these values could cause integration problems with contracts that wanted to deposit the maximum amount, and, validate the received shares for the deposit with the expected output for depositing the maximum allowed deposit.
<details> <summary><b>Expand to see a Coded PoC</b></summary> <br> Add the below PoC to the [`CollateralTracker.t.sol`](https://github.com/code-423n4/2024-04-panoptic/blob/main/test/foundry/core/CollateralTracker.t.sol) test file, and run it with the next command:forge test --match-test test_Success_maxMint_PoC -vvvv
</details> </br>function test_Success_maxMint_PoC(uint256 x) public { _initWorld(x); uint256 expectedValue = collateralToken0.previewDeposit(type(uint104).max); // real value uint256 actualValue = collateralToken0.maxMint(Bob); //@audit => Will revert! assertEq(expectedValue, actualValue); }
Fix:
CollateralTracker.maxMint() function
, instead of using the current formula, call directly the CollateralTracker.previewDeposit() function
and pass as a parameter the value of type(uint104).max
function maxMint(address) external view returns (uint256 maxShares) { unchecked { - return (convertToShares(type(uint104).max) * DECIMALS) / (DECIMALS + COMMISSION_FEE); + return (previewDeposit(type(uint104).max)); } }
The PanopticFactory.initialize() function
can be frontrun and a malicious user can set an address of his own as the onwer of the PanopticFactory, this would force the protocol to redeploy the PanopticFactory until they can set the owner as an account controlled by them.
//@audit-issue => Function can be called by anyone to set the owner of the contract as any address they want function initialize(address _owner) public { if (!s_initialized) { s_owner = _owner; s_initialized = true; } }
Fix:
owner
is initialized on the PanopticFactory.initialize() function
, better set the owner in the constructor. Since the PanopticFactory is not an upgradeable contract, it is not really necessary to initialize the owner using a separate function outside of the constructor.When a new PanopticPool is deployed, the PanopticFactory calls the PanopticPool.startPool() function
to initialize some core variables, as well as to grant approvals to the SFPM and the CollateralTrackers associated with the PanopticPool.
To grant allowance, the InteractionHelper.doApprovals() function
is called, which then proceeds to call the underlyingToken.approve() function to grant infinite allowance to the SFPM and the two CollateralTrackers. The current interface doesn't check for the returned value from the underlyingToken.approve(), if the call fails and returns false instead of reverting, either the SFPM or one of the two CollateralTrackers won't have allowance to spend the underlyingToken from the PanopticPool.
--- PanopticFactory.sol --- function deployNewPool( address token0, address token1, uint24 fee, bytes32 salt ) external returns (PanopticPool newPoolContract) { ... //@audit-info => One UniV3 Pool can have assigned only 1 PanopticPool! if (address(s_getPanopticPool[v3Pool]) != address(0)) revert Errors.PoolAlreadyInitialized(); ... //@audit-ok => Initializes the PanopticPool when is deployed from the PanopticFactory newPoolContract.startPool(v3Pool, token0, token1, collateralTracker0, collateralTracker1); //@audit-info => Links the newly deployed PanopticPool to its assigned UniV3 Pool //@audit-info => One UniV3 Pool can have assigned only 1 PanopticPool! s_getPanopticPool[v3Pool] = newPoolContract; ... } --- PanopticPool.sol --- function startPool( ... ) external { ... //@audit-info => Approves SFPM, and the two CollateralTrackers InteractionHelper.doApprovals(SFPM, collateralTracker0, collateralTracker1, token0, token1); } --- InteractionHelper.sol --- function doApprovals( ... ) external { //@audit-issue => If the approve fails and returns false instead of reverting, it is not possible to catch the problem and revert the tx //@audit-issue => Not reverting the tx would allow the deployment of the new PP to finalize. //@audit => This means, the associated UniV3Pool to this PP won't be able to be assigned to a different PP. //@audit => If this PP becomes unnusable because the allowance failure, the underlying UniV3Pool won't be able to be used on any other PP. // Approve transfers of Panoptic Pool funds by SFPM IERC20Partial(token0).approve(address(sfpm), type(uint256).max); IERC20Partial(token1).approve(address(sfpm), type(uint256).max); // Approve transfers of Panoptic Pool funds by Collateral token IERC20Partial(token0).approve(address(ct0), type(uint256).max); IERC20Partial(token1).approve(address(ct1), type(uint256).max); }
Fix:
The best way to mitigate this potential thread it is by using the forceApprove()
of the SafeErc20.sol
contract from OZ.
An Option Seller could deploy all the liquidity (if enough collateral) sitting on a PanopticPool onto a range far away from the current price, with the objective to open an unreasonable short position that any OptionSeller would take because of the cost-opportunity to pay premia for an option with a target price very unreasonable that it most likely never hit.
The OptionSeller would need to only pay the COMISSION_FEE to open the short in exchange for deploying liquidity on a range where it won't generate any fees nor OptionBuyers would open long positions for that range.
Even though the OptionSeller will also have its collateral locked during the time the short option remains open, the impact on the PLPs is bigger because of the leverage that OptionSellers have when opening shorts.
Since positions with only shorts can't be force exercised, this short option would remain open for as long as the OptionSeller wants.
Fix: I'd suggest allowing OptionSellers to open shorts without extra commission within a reasonable range from the current price. But if the target range exceeds a defined threshold, charge an extra commission, in this way, this would disincentivize this type of situation because the fees & commissions to pay would be higher.
#0 - c4-judge
2024-04-26T17:18:34Z
Picodes marked the issue as grade-b
#1 - c4-judge
2024-05-01T14:55:03Z
Picodes marked the issue as grade-a