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: 33/72
Findings: 2
Award: $66.20
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 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
11.3163 USDC - $11.32
The usage of call
in inline assembly here carries the risk of calling an arbitrary contract address controlled by the token
input parameter. This opens up possibilities for reentrancy or hijacking execution flow if the return value is not validated properly.
If a malicious contract is passed as the token
address, it can execute custom fallback code during the call
that could call back into the calling contract and modify state while the assembly block still continues execution. This reentrancy risk can lead to loss of funds, incorrect state updates, etc.
The return data from the call
sits in scratch space pointed to by p
. If the malicious contract writes custom raw bytes here, subsequent logic that assumes validated return data could be tricked and progress with an invalid state.
The hardcoded expectation of a 1 return value checks if transfer succeeded. But a malicious contract at token
address could ignore this convention and return any data or encoding it wants.
If output data from the call
is longer than expected, it can potentially overwrite parts of the input data sitting below scratch space. This could corrupt variables or calldata parameters for subsequent operations.
call(gas(), token, 0, p, 100, 0, 32)
Follow checks-effects-interactions pattern by doing all validation before the call
and revalidation after.
When expecting a specific return value like 1, mask and extract only the expected bytes. Avoid assumptions.
Disable oppcodes that intensify risks like delegatecall
. Favor staticcall
which protects state.
Lack of descriptive error messages in require
statements reduces debuggability. It takes two parameters and the message part is optional. This is shown to the user when and if the require
statement evaluates to false
. Users see opaque reverts instead of insights into root causes. Adding customized strings aids tracing and resolves issues faster by clearly signaling to users what conditions or assumptions failed.
require(denominator > 0);
Add a descriptive message, no longer than 32 bytes
, inside the require
statement to give more detail to the user about why the condition failed.
It uses low-level EVM instructions to encode the calldata
for the token transfer function, including the "from", "to", and "amount" arguments. This bypasses some safety checks.
It makes a call to the token contract at address "token". The call transfers the tokens.
It checks if the call succeeded by checking if it reverted or if it returned exactly 1 (success).
The bypass increases the risk of vulnerabilities if assembly is not properly validated.
If there is a mistake in the assembly, it could inadvertently drain tokens from the contract or user accounts.
Transactions using inline assembly may be more susceptible to front-running attacks compared to a Solidity high-level call.
Requires manual gas stipulation, risking out-of-gas exceptions.
assembly ("memory-safe") { // Get free memory pointer - we will store our calldata in scratch space starting at the offset specified here. let p := mload(0x40) // Write the abi-encoded calldata into memory, beginning with the function selector. mstore(p, 0x23b872dd00000000000000000000000000000000000000000000000000000000) mstore(add(4, p), from) // Append the "from" argument. mstore(add(36, p), to) // Append the "to" argument. mstore(add(68, p), amount) // Append the "amount" argument. success := and( // Set success to whether the call reverted, if not we check it either // returned exactly 1 (can't just be non-zero data), or had no return data. or(and(eq(mload(0), 1), gt(returndatasize(), 31)), iszero(returndatasize())), // We use 100 because that's the total length of our calldata (4 + 32 * 3) // Counterintuitively, this call() must be positioned after the or() in the // surrounding and() because and() evaluates its arguments from right to left. call(gas(), token, 0, p, 100, 0, 32) ) }
IN-LINE ASSEMBLY ARE ALSO IN THE FOLLOWING LINES
SemiFungiblePositionManager.sol#L393-L395
Avoid using inline assembly instructions if possible because it might introduce certain issues in the code if not dealt with properly because it bypasses several safety features that are already implemented.
Library FeesCalc.sol
which has defined its function as public. This can be optimized by changing the function visibility. Changing the visibility from public will remove the compiler-introduced checks for msg.value
and decrease the contract’s method ID table size
function calculateAMMSwapFeesLiquidityChunk( IUniswapV3Pool univ3pool, int24 currentTick, uint128 startingLiquidity, uint256 liquidityChunk ) public view returns (int256 feesEachToken) { // extract the amount of AMM fees collected within the liquidity chunk` // note: the fee variables are *per unit of liquidity*; so more "rate" variables ( uint256 ammFeesPerLiqToken0X128, uint256 ammFeesPerLiqToken1X128 ) = _getAMMSwapFeesPerLiquidityCollected( univ3pool, currentTick, liquidityChunk.tickLower(), liquidityChunk.tickUpper() ); // Use the fee growth (rate) variable to compute the absolute fees accumulated within the chunk: // ammFeesToken0X128 * liquidity / (2**128) // to store the (absolute) fees as int128: feesEachToken = feesEachToken .toRightSlot(int128(int256(Math.mulDiv128(ammFeesPerLiqToken0X128, startingLiquidity)))) .toLeftSlot(int128(int256(Math.mulDiv128(ammFeesPerLiqToken1X128, startingLiquidity)))); }
The public
functions can be changed to private
/internal
to save some gas.
Using both named returns and a return statement isn't necessary. Removing unused named return variables can reduce gas usage and improve code clarity.
function mulDiv( uint256 a, uint256 b, uint256 denominator ) internal pure returns (uint256 result) { unchecked { // 512-bit multiply [prod1 prod0] = a * b // Compute the product mod 2**256 and mod 2**256 - 1 // then use the Chinese Remainder Theorem to reconstruct // the 512 bit result. The result is stored in two 256 // variables such that product = prod1 * 2**256 + prod0 uint256 prod0; // Least significant 256 bits of the product uint256 prod1; // Most significant 256 bits of the product assembly ("memory-safe") { let mm := mulmod(a, b, not(0)) prod0 := mul(a, b) prod1 := sub(sub(mm, prod0), lt(mm, prod0)) } // Handle non-overflow cases, 256 by 256 division if (prod1 == 0) { require(denominator > 0); assembly ("memory-safe") { result := div(prod0, denominator) } return result; } // Make sure the result is less than 2**256. // Also prevents denominator == 0 require(denominator > prod1); /////////////////////////////////////////////// // 512 by 256 division. /////////////////////////////////////////////// // Make division exact by subtracting the remainder from [prod1 prod0] // Compute remainder using mulmod uint256 remainder; assembly ("memory-safe") { remainder := mulmod(a, b, denominator) } // Subtract 256 bit number from 512 bit number assembly ("memory-safe") { prod1 := sub(prod1, gt(remainder, prod0)) prod0 := sub(prod0, remainder) } // Factor powers of two out of denominator // Compute largest power of two divisor of denominator. // Always >= 1. uint256 twos = (0 - denominator) & denominator; // Divide denominator by power of two assembly ("memory-safe") { denominator := div(denominator, twos) } // Divide [prod1 prod0] by the factors of two assembly ("memory-safe") { prod0 := div(prod0, twos) } // Shift in bits from prod1 into prod0. For this we need // to flip `twos` such that it is 2**256 / twos. // If twos is zero, then it becomes one assembly ("memory-safe") { twos := add(div(sub(0, twos), twos), 1) } prod0 |= prod1 * twos; // Invert denominator mod 2**256 // Now that denominator is an odd number, it has an inverse // modulo 2**256 such that denominator * inv = 1 mod 2**256. // Compute the inverse by starting with a seed that is correct // correct for four bits. That is, denominator * inv = 1 mod 2**4 uint256 inv = (3 * denominator) ^ 2; // Now use Newton-Raphson iteration to improve the precision. // Thanks to Hensel's lifting lemma, this also works in modular // arithmetic, doubling the correct bits in each step. inv *= 2 - denominator * inv; // inverse mod 2**8 inv *= 2 - denominator * inv; // inverse mod 2**16 inv *= 2 - denominator * inv; // inverse mod 2**32 inv *= 2 - denominator * inv; // inverse mod 2**64 inv *= 2 - denominator * inv; // inverse mod 2**128 inv *= 2 - denominator * inv; // inverse mod 2**256 // Because the division is now exact we can divide by multiplying // with the modular inverse of denominator. This will give us the // correct result modulo 2**256. Since the precoditions guarantee // that the outcome is less than 2**256, this is the final result. // We don't need to compute the high bits of the result and prod1 // is no longer required. result = prod0 * inv; return result; } }
https://github.com//code-423n4/2023-11-panoptic/blob/main/contracts/libraries/Math.sol#L186-L280
function mulDiv64(uint256 a, uint256 b) internal pure returns (uint256 result) { unchecked { // 512-bit multiply [prod1 prod0] = a * b // Compute the product mod 2**256 and mod 2**256 - 1 // then use the Chinese Remainder Theorem to reconstruct // the 512 bit result. The result is stored in two 256 // variables such that product = prod1 * 2**256 + prod0 uint256 prod0; // Least significant 256 bits of the product uint256 prod1; // Most significant 256 bits of the product assembly ("memory-safe") { let mm := mulmod(a, b, not(0)) prod0 := mul(a, b) prod1 := sub(sub(mm, prod0), lt(mm, prod0)) } // Handle non-overflow cases, 256 by 256 division if (prod1 == 0) { assembly ("memory-safe") { // Right shift by n is equivalent and 2 gas cheaper than division by 2^n result := shr(64, prod0) } return result; } // Make sure the result is less than 2**256. require(2 ** 64 > prod1); /////////////////////////////////////////////// // 512 by 256 division. /////////////////////////////////////////////// // Make division exact by subtracting the remainder from [prod1 prod0] // Compute remainder using mulmod uint256 remainder; assembly ("memory-safe") { remainder := mulmod(a, b, 0x10000000000000000) } // Subtract 256 bit number from 512 bit number assembly ("memory-safe") { prod1 := sub(prod1, gt(remainder, prod0)) prod0 := sub(prod0, remainder) } // Divide [prod1 prod0] by the factors of two (note that this is just 2**96 since the denominator is a power of 2 itself) assembly ("memory-safe") { // Right shift by n is equivalent and 2 gas cheaper than division by 2^n prod0 := shr(64, prod0) } // Shift in bits from prod1 into prod0. For this we need // to flip `twos` such that it is 2**256 / twos. // If twos is zero, then it becomes one // Note that this is just 2**192 since 2**256 over the fixed denominator (2**64) equals 2**192 prod0 |= prod1 * 2 ** 192; return prod0; } }
https://github.com//code-423n4/2023-11-panoptic/blob/main/contracts/libraries/Math.sol#L286-L342
function mulDiv96(uint256 a, uint256 b) internal pure returns (uint256 result) { unchecked { // 512-bit multiply [prod1 prod0] = a * b // Compute the product mod 2**256 and mod 2**256 - 1 // then use the Chinese Remainder Theorem to reconstruct // the 512 bit result. The result is stored in two 256 // variables such that product = prod1 * 2**256 + prod0 uint256 prod0; // Least significant 256 bits of the product uint256 prod1; // Most significant 256 bits of the product assembly ("memory-safe") { let mm := mulmod(a, b, not(0)) prod0 := mul(a, b) prod1 := sub(sub(mm, prod0), lt(mm, prod0)) } // Handle non-overflow cases, 256 by 256 division if (prod1 == 0) { assembly ("memory-safe") { // Right shift by n is equivalent and 2 gas cheaper than division by 2^n result := shr(96, prod0) } return result; } // Make sure the result is less than 2**256. require(2 ** 96 > prod1); /////////////////////////////////////////////// // 512 by 256 division. /////////////////////////////////////////////// // Make division exact by subtracting the remainder from [prod1 prod0] // Compute remainder using mulmod uint256 remainder; assembly ("memory-safe") { remainder := mulmod(a, b, 0x1000000000000000000000000) } // Subtract 256 bit number from 512 bit number assembly ("memory-safe") { prod1 := sub(prod1, gt(remainder, prod0)) prod0 := sub(prod0, remainder) } // Divide [prod1 prod0] by the factors of two (note that this is just 2**96 since the denominator is a power of 2 itself) assembly ("memory-safe") { // Right shift by n is equivalent and 2 gas cheaper than division by 2^n prod0 := shr(96, prod0) } // Shift in bits from prod1 into prod0. For this we need // to flip `twos` such that it is 2**256 / twos. // If twos is zero, then it becomes one // Note that this is just 2**160 since 2**256 over the fixed denominator (2**96) equals 2**160 prod0 |= prod1 * 2 ** 160; return prod0; } }
https://github.com//code-423n4/2023-11-panoptic/blob/main/contracts/libraries/Math.sol#L348-L404
function mulDiv128(uint256 a, uint256 b) internal pure returns (uint256 result) { unchecked { // 512-bit multiply [prod1 prod0] = a * b // Compute the product mod 2**256 and mod 2**256 - 1 // then use the Chinese Remainder Theorem to reconstruct // the 512 bit result. The result is stored in two 256 // variables such that product = prod1 * 2**256 + prod0 uint256 prod0; // Least significant 256 bits of the product uint256 prod1; // Most significant 256 bits of the product assembly ("memory-safe") { let mm := mulmod(a, b, not(0)) prod0 := mul(a, b) prod1 := sub(sub(mm, prod0), lt(mm, prod0)) } // Handle non-overflow cases, 256 by 256 division if (prod1 == 0) { assembly ("memory-safe") { // Right shift by n is equivalent and 2 gas cheaper than division by 2^n result := shr(128, prod0) } return result; } // Make sure the result is less than 2**256. require(2 ** 128 > prod1); /////////////////////////////////////////////// // 512 by 256 division. /////////////////////////////////////////////// // Make division exact by subtracting the remainder from [prod1 prod0] // Compute remainder using mulmod uint256 remainder; assembly ("memory-safe") { remainder := mulmod(a, b, 0x100000000000000000000000000000000) } // Subtract 256 bit number from 512 bit number assembly ("memory-safe") { prod1 := sub(prod1, gt(remainder, prod0)) prod0 := sub(prod0, remainder) } // Divide [prod1 prod0] by the factors of two (note that this is just 2**128 since the denominator is a power of 2 itself) assembly ("memory-safe") { // Right shift by n is equivalent and 2 gas cheaper than division by 2^n prod0 := shr(128, prod0) } // Shift in bits from prod1 into prod0. For this we need // to flip `twos` such that it is 2**256 / twos. // If twos is zero, then it becomes one // Note that this is just 2**160 since 2**256 over the fixed denominator (2**128) equals 2**128 prod0 |= prod1 * 2 ** 128; return prod0; } }
https://github.com//code-423n4/2023-11-panoptic/blob/main/contracts/libraries/Math.sol#L410-L466
function mulDiv192(uint256 a, uint256 b) internal pure returns (uint256 result) { unchecked { // 512-bit multiply [prod1 prod0] = a * b // Compute the product mod 2**256 and mod 2**256 - 1 // then use the Chinese Remainder Theorem to reconstruct // the 512 bit result. The result is stored in two 256 // variables such that product = prod1 * 2**256 + prod0 uint256 prod0; // Least significant 256 bits of the product uint256 prod1; // Most significant 256 bits of the product assembly ("memory-safe") { let mm := mulmod(a, b, not(0)) prod0 := mul(a, b) prod1 := sub(sub(mm, prod0), lt(mm, prod0)) } // Handle non-overflow cases, 256 by 256 division if (prod1 == 0) { assembly ("memory-safe") { // Right shift by n is equivalent and 2 gas cheaper than division by 2^n result := shr(192, prod0) } return result; } // Make sure the result is less than 2**256. require(2 ** 192 > prod1); /////////////////////////////////////////////// // 512 by 256 division. /////////////////////////////////////////////// // Make division exact by subtracting the remainder from [prod1 prod0] // Compute remainder using mulmod uint256 remainder; assembly ("memory-safe") { remainder := mulmod(a, b, 0x1000000000000000000000000000000000000000000000000) } // Subtract 256 bit number from 512 bit number assembly ("memory-safe") { prod1 := sub(prod1, gt(remainder, prod0)) prod0 := sub(prod0, remainder) } // Divide [prod1 prod0] by the factors of two (note that this is just 2**96 since the denominator is a power of 2 itself) assembly ("memory-safe") { // Right shift by n is equivalent and 2 gas cheaper than division by 2^n prod0 := shr(192, prod0) } // Shift in bits from prod1 into prod0. For this we need // to flip `twos` such that it is 2**256 / twos. // If twos is zero, then it becomes one // Note that this is just 2**64 since 2**256 over the fixed denominator (2**192) equals 2**64 prod0 |= prod1 * 2 ** 64; return prod0; } }
https://github.com//code-423n4/2023-11-panoptic/blob/main/contracts/libraries/Math.sol#L472-L528
To save gas and improve code quality, consider using either the named returns or a return statement.
can save around 10 opcodes and some gas if the constructors are defined as payable.
constructor(IUniswapV3Factory _factory) { FACTORY = _factory; }
I suggest marking the constructors as payable to save some gas to make sure it does not lead to any adverse effects in case an upgrade pattern is involved.
The contract is using abi.encode()
in the validateCallback
function. In abi.encode()
, all elementary types are padded to 32 bytes and dynamic arrays include their length, whereas abi.encodePacked()
will only use the minimal required memory to encode the data.
keccak256(abi.encode(features)),
data = abi.encode(
bytes memory mintdata = abi.encode(
Unless explicitly needed , it is recommended to use abi.encodePacked()
instead of abi.encode()
.
#0 - c4-judge
2023-12-14T15:58:37Z
Picodes marked the issue as grade-b
🌟 Selected for report: Sathish9098
Also found by: 0xAadi, 0xHelium, 0xSmartContract, Bulletprime, K42, Raihan, ZanyBonzy, catellatech, fouzantanveer, foxb868, tala7985
54.8805 USDC - $54.88
Introduction
Panoptic is a decentralized protocol that enables gas-efficient trading of options positions on any ERC20 token in Uniswap V3. It manages complex multi-leg option positions encoded into ERC1155 tokens and allows creating both typical LP positions and advanced "long" positions.
Architecture
The core component is the SemiFungiblePositionManager
contract which acts as a drop-in replacement for Uniswap's NonfungiblePositionManager
. It leverages the composability of the ERC1155 standard to encode the full details of positions across four legs into the token IDs. This allows gas savings and advanced logic while still ensuring compatibility with Uniswap V3.
The system architecture is shown below:
+-----------------------------------------+ | User | +-----------------------------------------+ | | | mint() | burn() │ │ │ │ +-----------------------------------------+ | SemiFungiblePositionManager (SFPM) | | ERC1155 Positions | +-----------------------------------------+ │ │ unpack() unpack() │ │ │ │ +--------------------------------+ | TokenId | | - Pool ID | | - Leg 1 (Asset, Ratio, ...) | | - Leg 2 (Asset, Ratio, ...) | | - Leg 3 (Asset, Ratio, ...) | | - Leg 4 (Asset, Ratio, ...) | +--------------------------------+ │ +-----+------+ | | Interact with Interact with Uniswap V3 Pool Uniswap V3 Pool
TokenId
contains full position detailsKey Benefits
The SemiFungiblePositionManager
(SFPM) is designed to be a drop-in replacement for Uniswap's NonFungiblePositionManager
(NFPM) contract.
Key Functions
The SFPM aims to replicate all the critical functionality of managing positions in Uniswap, but in a more gas efficient way by using ERC1155 tokens instead of ERC721 NFTs.
Some of its key functions are:
initializeAMMPool()
: Registers a UniswapV3 pool to be usable by the SFPMmintTokenizedPosition()
: Mints a new ERC1155 tokenized position from a provided tokenIdburnTokenizedPosition()
: Burns an existing ERC1155 tokenized positioncollect()
: Collects any owed fees for the msg.sender from their Uniswap positionsUnder the Hood
Instead of each position being an NFT like in regular Uniswap, the SFPM encodes full position details (up to 4 legs) directly into the tokenId
using a bit-packing scheme.
This allows representing complex DeFi positions with a single ERC1155 token. When mint
/burn
are called, the contract unpacks the tokenId
, interacts with the AMM on the user's behalf to mint/burn legs, performs any swaps to handle ITM legs, and sets up callbacks.
The end result is gas savings and easier composability thanks to using ERC1155 while maintaining complete compatibility with regular Uniswap.
Analysis
The protocol has a well-designed architecture and modular structure. Here are some benefits:
+-----------------------------------------+ | User/External Contract | +-----------------------------------------+ ^ ^ | | mint() burn() | | V V +------------------------------------+ | SemiFungiblePositionManager | +------------------------------------+ ^ ^ | | unpack unpack | Legion | +-----+------------+------+ | | mintPosition() burnPosition() | | +-----------------+-----+ | collect() | V +----------+ | Uniswap | | V3 | +----------+
Mint Flow
mint() | V unpack() | V mintPosition() | +-> Decode tokenId | +-> Interact with Uniswap | to mint position | +-> Update internal state
Burn Flow
burn() | V unpack() | V burnPosition() | +-> Decode tokenId | +-> Interact with Uniswap | to burn position | +-> Update internal state
Supporting
collect() | V Collect owed fees | V Update user balances
However, a few areas that need deeper analysis:
Security Concerns
No major issues found after audit:
Some minor areas to improve:
Key Function
The key functions in the SemiFungiblePositionManager
contract:
initializeAMMPool()
+---------------+ +-----------------+ | | | | | External | call() | SemiFungible | | Contract | ---------> | PositionManager | | | | _ | +---------------+ | / \ | | / \ | +----------------+ | / \ | call() | | | / \ | --------> | UniswapV3Pool | | / \ | | | +-----------------+ / \ +----------------+ | |/ \| | Returns |\_____________/| | +-----------------+
Registers UniswapV3 pool to make positions on it available.
mintTokenizedPosition()
+---------------+ +------------------------+ | | | | | External | call() | SemiFungiblePosition | | Contract | ----------> | Manager | | | tokenId | _ | | | amount | / \ | +---------------+ | / \ | | / \ | | / \ | +--------------+-------+---------+----+| | | | | || | | | | || +----v------+ +--v-------+---------+---v-++ | | | | | | +----------| Uniswap | | Update | Mint | Burn | | | | | State | | | | +----------+ +---------+ +--------+ | ^ | | +-----------------+
Mints a new position by decoding tokenId, interacting with Uniswap V3, and updating state.
Burn Flow
+---------------+ +------------------------+ | | | | | External | call() | SemiFungiblePosition | | Contract | ---------> | Manager | | | tokenId | _ | | | amount | / \ | +---------------+ | / \ | | / \ | | / \ | +-------------+--------+---------+----+| | | | | || | | | | || +---v------+ +--v-------+---------+---v-++ | | | | | | +----------| Uniswap | | Update | Mint | Burn | | | | | State | | | | +----------+ +--------+ +--------+ | ^ | | +------------------+
Burns a position by decoding tokenId, interacting with Uniswap V3, and updating state.
Let me know if you need any clarification or have additional questions!
Centralization Risks
Minimal centralization risks:
Conclusion
Panoptic has a well-designed architecture and implementation. With minor tweaks, it can provide a robust and efficient protocol for decentralized options trading.
42 hours
#0 - c4-judge
2023-12-14T15:59:07Z
Picodes marked the issue as grade-b