Platform: Code4rena
Start Date: 27/11/2023
Pot Size: $60,500 USDC
Total HM: 7
Participants: 72
Period: 7 days
Judge: Picodes
Total Solo HM: 2
Id: 309
League: ETH
Rank: 19/72
Findings: 2
Award: $559.39
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: hash
Also found by: 0xCiphky, Neon2835, Topmark, Udsen, critical-or-high, lanrebayode77, ptsanev
416.6648 USDC - $416.66
https://github.com/code-423n4/2023-11-panoptic/blob/f75d07c345fd795f907385868c39bafcd6a56624/contracts/SemiFungiblePositionManager.sol#L936 https://github.com/code-423n4/2023-11-panoptic/blob/f75d07c345fd795f907385868c39bafcd6a56624/contracts/SemiFungiblePositionManager.sol#L1169
The _createLegInAMM
function in the protocol handles the minting and burning of token positions. When closing a long position (setting _isBurn
to true), the function reduces the removedLiquidity
by the chunkLiquidity amount in an unchecked block. If a user slightly overpays the long position debt, it can lead to an underflow.
The function performs a subtraction to adjust removedLiquidity
for a closed long position:
function _createLegInAMM( ... ) internal returns (int256 _moved, int256 _itmAmounts, int256 _totalCollected) { ... unchecked { ... if (isLong == 0) { ... /// @dev If the isLong flag is 0=short but the position was burnt, then this is closing a long position /// @dev so the amount of short liquidity should decrease. if (_isBurn) { removedLiquidity -= chunkLiquidity; } } else { ... } // update the starting liquidity for this position for next time around s_accountLiquidity[positionKey] = uint256(0).toLeftSlot(removedLiquidity).toRightSlot(updatedLiquidity); } ... }
In specific scenarios, such as when a user has a concurrent open short position within the same range, an overpayment on the long position could result in a successful burn operation, despite attempting to burn more liquidity than available:
function _burnLiquidity(uint256 liquidityChunk, IUniswapV3Pool univ3pool) internal returns (int256 movedAmounts) { // burn that option's liquidity in the Uniswap Pool. // This will send the underlying tokens back to the Panoptic Pool (msg.sender) (uint256 amount0, uint256 amount1) = univ3pool.burn(liquidityChunk.tickLower(), liquidityChunk.tickUpper(), liquidityChunk.liquidity()); // amount0 The amount of token0 that was sent back to the Panoptic Pool // amount1 The amount of token1 that was sent back to the Panoptic Pool // no need to safecast to int from uint here as the max position size is int128 // decrement the amountsOut with burnt amounts. amountsOut = notional value of tokens moved unchecked { movedAmounts = int256(0).toRightSlot(-int128(int256(amount0))).toLeftSlot(-int128(int256(amount1))); } }
The unchecked subtraction in the _createLegInAMM
function can lead to an underflow in removedLiquidity
if the burn amount exceeds the removed liquidity. This underflow can cause inaccurate liquidity records, potentially leading to a loss of funds.
Introduce a check in the _createLegInAMM
function, ensuring that the removedLiquidity
does not underflow, as demonstrated in the following snippet:
function _createLegInAMM( ... ) internal returns (int256 _moved, int256 _itmAmounts, int256 _totalCollected) { ... unchecked { ... if (isLong == 0) { ... /// @dev If the isLong flag is 0=short but the position was burnt, then this is closing a long position /// @dev so the amount of short liquidity should decrease. if (_isBurn) { if (removedLiquidity - chunkLiquidity > removedLiquidity {revert Errors.InvalidAmount();} // Add here removedLiquidity -= chunkLiquidity; } } else { ... } // update the starting liquidity for this position for next time around s_accountLiquidity[positionKey] = uint256(0).toLeftSlot(removedLiquidity).toRightSlot(updatedLiquidity); } ... }
Under/Overflow
#0 - c4-sponsor
2023-12-17T22:32:51Z
dyedm1 (sponsor) disputed
#1 - dyedm1
2023-12-17T22:34:03Z
the impact is dup #256, but it doesn't look like they actually figured out how an "overpayment" would happen in their PoC.
#2 - c4-judge
2023-12-24T11:22:00Z
Picodes marked the issue as duplicate of #256
#3 - c4-judge
2023-12-24T11:22:05Z
Picodes marked the issue as partial-50
#4 - c4-judge
2023-12-26T22:42:33Z
Picodes changed the severity to 3 (High Risk)
#5 - c4-judge
2023-12-26T22:47:04Z
Picodes marked the issue as not a duplicate
#6 - c4-judge
2023-12-26T22:53:07Z
Picodes marked the issue as duplicate of #516
#7 - Picodes
2023-12-26T22:53:36Z
This is to me a dup of #516 although no clear impact is described
#8 - c4-judge
2023-12-26T23:04:06Z
Picodes changed the severity to 2 (Med Risk)
#9 - c4-judge
2023-12-26T23:06:45Z
Picodes marked the issue as satisfactory
🌟 Selected for report: osmanozdemir1
Also found by: 0xCiphky, Audinarey, Banditx0x, CRYP70, Cryptor, D1r3Wolf, KupiaSec, LokiThe5th, Sathish9098, Skylice, ThenPuli, Topmark, Udsen, ZanyBonzy, baice, ether_sky, fatherOfBlocks, foxb868, grearlake, hihen, hubble, hunter_w3b, lanrebayode77, leegh, lsaudit, minhtrng, nocoder, onchain-guardians, ptsanev, ro1sharkm, seaton0x1, sivanesh_808, t4sk, tapir, tpiliposian, ustas
142.7295 USDC - $142.73
The ERC155Minimal contract does not fully comply with the EIP1155 standard, specifically it misses the rules listed below.
safeTransferFrom rules:
_to
is the zero address.function safeTransferFrom(address from, address to, uint256 id, uint256 amount, bytes calldata data) public { if (!(msg.sender == from || isApprovedForAll[from][msg.sender])) revert NotAuthorized(); // @audit-info doesn't check if to is the zero address balanceOf[from][id] -= amount; // balance will never overflow unchecked { balanceOf[to][id] += amount; } ... }
safeBatchTransferFrom rules:
_to
is the zero address._ids
is not the same as length of _values
.function safeBatchTransferFrom( address from, address to, uint256[] calldata ids, uint256[] calldata amounts, bytes calldata data ) public virtual { if (!(msg.sender == from || isApprovedForAll[from][msg.sender])) revert NotAuthorized(); // Storing these outside the loop saves ~15 gas per iteration. uint256 id; uint256 amount; // @audit-info doesn't check if to is the zero address // @audit-info does not revert if ids and amounts are of unequal length, per eip specifications for (uint256 i = 0; i < ids.length;) { id = ids[i]; amount = amounts[i]; ... } ... }
Add the following checks:
function safeTransferFrom(address from, address to, uint256 id, uint256 amount, bytes calldata data) public { if (!(msg.sender == from || isApprovedForAll[from][msg.sender])) revert NotAuthorized(); // add here if (to == address(0)) revert ("..."); balanceOf[from][id] -= amount; // balance will never overflow unchecked { balanceOf[to][id] += amount; } ... }
function safeBatchTransferFrom( address from, address to, uint256[] calldata ids, uint256[] calldata amounts, bytes calldata data ) public virtual { if (!(msg.sender == from || isApprovedForAll[from][msg.sender])) revert NotAuthorized(); // Storing these outside the loop saves ~15 gas per iteration. uint256 id; uint256 amount; // add here if (to == address(0)) revert ("..."); // add here if (ids.length != amounts.length) revert ("..."); for (uint256 i = 0; i < ids.length;) { id = ids[i]; amount = amounts[i]; ... unchecked { ++i; } } ... }
The protocol allows users to mint ERC1155 tokens representing positions in Uniswap V3 pools, allowing up to four legs per token. These tokens are transferable under specific conditions, primarily requiring that the entire balance of a given tokenId is transferred, and the recipient does not already hold a position within that token.
A constraint arises during the transfer process. The registerTokenTransfer
function, which manages the transfer logistics, checks if the total liquidity of the current leg equals the user’s total liquidity for that position. However, the position key, used to track liquidity, is not unique to each leg. Consequently, users who have multiple legs associated with the same position key cannot successfully transfer their positions.
function registerTokenTransfer(address from, address to, uint256 id, uint256 amount) internal { // Extract univ3pool from the poolId map to Uniswap Pool IUniswapV3Pool univ3pool = s_poolContext[id.validate()].pool; uint256 numLegs = id.countLegs(); for (uint256 leg = 0; leg < numLegs;) { // for this leg index: extract the liquidity chunk: a 256bit word containing the liquidity amount and upper/lower tick // @dev see `contracts/types/LiquidityChunk.sol` uint256 liquidityChunk = PanopticMath.getLiquidityChunk(id, leg, uint128(amount), univ3pool.tickSpacing()); //construct the positionKey for the from and to addresses bytes32 positionKey_from = keccak256( abi.encodePacked( address(univ3pool), from, id.tokenType(leg), liquidityChunk.tickLower(), liquidityChunk.tickUpper() ) ); ... // Revert if not all balance is transferred uint256 fromLiq = s_accountLiquidity[positionKey_from]; if (fromLiq.rightSlot() != liquidityChunk.liquidity()) revert Errors.TransferFailed(); ... } } }
This issue, while not leading to fund loss or severe security risks, can inconvenience users and restrict the fungibility of these ERC1155 tokens. It particularly affects users who wish to transfer positions but have created multiple legs with the same position key.
Clearly document to users about the specific conditions under which token transfers are possible, especially concerning the creation of multiple legs for the same position.
The protocol has specific guidelines regarding the pools that are permissible for use. However, the initializeAMMPool
function, responsible for setting up Uniswap V3 pools within the protocol, does not enforce these guidelines. Specifically, it allows the initialization of any valid Uniswap pool, even those explicitly disallowed by the protocol's documentation.
“Very large quantities of tokens are not supported. It should be assumed that for any given pool, the cumulative amount of tokens that enter the system (associated with that pool, through adding liquidity, collection, etc.) will not exceed 2^127 - 1.”
“Pools with a tick spacing of 1 are not currently supported. For the purposes of this audit, the only tick spacings that are supported are 10, 60, and 200”
According to the protocol's documentation, pools with an very large token quantity (exceeding 2^127 - 1) and those with a tick spacing of 1 are not supported. Despite this, the initializeAMMPool
function lacks mechanisms to filter out these disallowed pools, which could potentially cause underflow/overflow issues or precision loss errors in the protocol. Moreover, these pools have not undergone thorough testing, potentially exposing users to unanticipated risks.
function initializeAMMPool(address token0, address token1, uint24 fee) external { // compute the address of the Uniswap v3 pool for the given token0, token1, and fee tier address univ3pool = FACTORY.getPool(token0, token1, fee); // reverts if the Uni v3 pool has not been initialized if (address(univ3pool) == address(0)) revert Errors.UniswapPoolNotInitialized(); ... if (s_AddrToPoolIdData[univ3pool] != 0) return; ... uint64 poolId = PanopticMath.getPoolId(univ3pool); while (address(s_poolContext[poolId].pool) != address(0)) { poolId = PanopticMath.getFinalPoolId(poolId, token0, token1, fee); } }
The absence of these restrictions in the initialization function presents a risk, as users might inadvertently interact with unsupported pools maliciously initialized, assuming them to be valid within the protocol.
Consider implementing a whitelist mechanism for pools, allowing only those pools that have been vetted and approved by the protocol administrators.
The protocol's method for determining the strike price of an option position requires that the strike price be a multiple of the corresponding pool's tick spacing (10, 60, 200 in this context). This constraint is due to the way the protocol calculates the upper and lower ticks of an option leg, based on the strike price and the pool's tick spacing.
In the asTicks
function, the lower and upper ticks (legLowerTick
and legUpperTick
) are derived from the leg's width and strike price. These ticks are then validated to ensure they align with the pool's tick spacing. The function enforces this alignment through a modulo operation, ensuring that both the lower and upper ticks are multiples of the tick spacing. This constraint inherently limits the strike price to also be a multiple of the tick spacing.
function asTicks(uint256 self, uint256 legIndex, int24 tickSpacing) ... { unchecked { int24 selfWidth = self.width(legIndex); //width; defined as (tickUpper - tickLower) / tickSpacing int24 selfStrike = self.strike(legIndex); //strike price; defined as (tickUpper + tickLower) / 2 ... // The width is from lower to upper tick, the one-sided range is from strike to upper/lower int24 oneSidedRange = (selfWidth * tickSpacing) / 2; (legLowerTick, legUpperTick) = (selfStrike - oneSidedRange, selfStrike + oneSidedRange); // Revert if the upper/lower ticks are not multiples of tickSpacing // Revert if the tick range extends from the strike outside of the valid tick range // These are invalid states, and would revert silently later in `univ3Pool.mint` if ( legLowerTick % tickSpacing != 0 || legUpperTick % tickSpacing != 0 || legLowerTick < minTick || legUpperTick > maxTick ) revert Errors.TicksNotInitializable(); } }
As a result, users are restricted in their choice of strike prices for their positions, only able to select values that conform to the multiples of the pool's tick spacing.
This limitation narrows the range of available strike prices, users seeking specific strike prices that don't align with the tick spacing multiples may find the protocol less accommodating to their trading strategies.
Ensure that users are clearly informed about the tick spacing constraints and how they impact strike price selection.
#0 - c4-judge
2023-12-14T16:52:31Z
Picodes marked the issue as grade-a
#1 - c4-sponsor
2023-12-17T21:51:22Z
dyedm1 (sponsor) confirmed