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: 15/72
Findings: 2
Award: $1,122.43
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: osmanozdemir1
Also found by: 0xDING99YA, 0xloscar01, KupiaSec, SpicyMeatball, ether_sky, fnanni, hash, minhtrng
1111.1061 USDC - $1,111.11
The protocol has an invariant that if someone wants to transfer SFPM-tokens of a certain ID to someone else, they have to send all tokens of that ID that they possess. The checks for this invariant are insufficient.
The registerTokenTransfer
function performs the following check:
/// @notice update user position data following a token transfer /// @dev token transfers are only allowed if you transfer your entire balance of a given tokenId and the recipient has none function registerTokenTransfer(address from, address to, uint256 id, uint256 amount) internal { ... 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();
The check to validate that all balance is transferred is insufficient. An example:
s_accountLiq[from]
will be 0-200 (0 left, 200 right). He gets minted 200 tokens (say tokenId 1 for simplicity)s_accountLiq[from]
will be 100-100. He gets minted 100 tokens with (with tokenId 2)fromLiq.rightSlot() == liquidityChunk.liquidity()
passes, even though he owns 200 tokens with tokenId 1Manual review
One approach would be to disallow sending tokens where any of its legs has removed liquidity (where leftSlot() != 0
). Though this may be more restrictive than the intended design.
Other
#0 - dyedm1
2023-12-17T21:56:00Z
dup #256
#1 - c4-sponsor
2023-12-17T21:56:06Z
dyedm1 (sponsor) confirmed
#2 - c4-judge
2023-12-23T10:05:47Z
Picodes marked the issue as duplicate of #256
#3 - c4-judge
2023-12-26T22:41:50Z
Picodes marked the issue as partial-50
#4 - Picodes
2023-12-26T22:42:20Z
Giving partial credit here because the found impact is different from the main one (messing up premia)
#5 - c4-judge
2023-12-26T22:42:33Z
Picodes changed the severity to 3 (High Risk)
🌟 Selected for report: osmanozdemir1
Also found by: 0xDING99YA, 0xloscar01, KupiaSec, SpicyMeatball, ether_sky, fnanni, hash, minhtrng
1111.1061 USDC - $1,111.11
Its possible to transfer liquidity that only has the left slot occupied to manipulate the premia calculation of a target that does not have liquidity for that range yet, without any additional cost.
Providing liquidity and removing it through minting a long position allows any user to create a LeftRight-liquidity of x-0 (x being amount in left slot and 0 in right slot). Doing a zero-transfer of any of the tokens minted will assign this liquidity to any target address that does not yet have a position in that tickRange:
//SFPM function registerTokenTransfer(address from, address to, uint256 id, uint256 amount) internal { ... // Revert if recipient already has that position if ( (s_accountLiquidity[positionKey_to] != 0) || (s_accountFeesBase[positionKey_to] != 0) ) revert Errors.TransferFailed(); // Revert if not all balance is transferred uint256 fromLiq = s_accountLiquidity[positionKey_from]; if (fromLiq.rightSlot() != liquidityChunk.liquidity()) revert Errors.TransferFailed(); int256 fromBase = s_accountFeesBase[positionKey_from]; //update+store liquidity and fee values between accounts s_accountLiquidity[positionKey_to] = fromLiq; s_accountLiquidity[positionKey_from] = 0; s_accountFeesBase[positionKey_to] = fromBase; s_accountFeesBase[positionKey_from] = 0; unchecked { ++leg; }
Since the left slot, which represents the removed liquidity is important for the calculation of premia deltas, this can impact any upstream contracts that use them (e.g. the PanopticPool). This could for example take the form of frontrunning the initial mint on a tickRange of that pool to bypass the s_accountLiquidity[positionKey_to] != 0
check. That position would then start out with an amount x of supposedly removed liquidity.
Manual Review
disallow transfers of positions with x-y liquidity where x != 0
Other
#0 - dyedm1
2023-12-17T21:57:55Z
dup #256
#1 - c4-sponsor
2023-12-17T21:58:02Z
dyedm1 (sponsor) confirmed
#2 - c4-judge
2023-12-21T15:26:06Z
Picodes marked the issue as duplicate of #256
#3 - c4-judge
2023-12-26T22:41:23Z
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
11.3163 USDC - $11.32
The SFPM has the following code:
unchecked { // Reverse the order of the legs if this call is burning a position (LIFO) // We loop in reverse order if burning a position so that any dependent long liquidity is returned // to the pool first, // allowing the corresponding short liquidity to be removed _leg = _isBurn ? numLegs - leg - 1 : leg; }
However when validating a position it is not enforced that longs or shorts are in any particular order, see TokenId.validate
registerTransfer reverts if the liquidity of positionKey_to
is not 0:
if ( (s_accountLiquidity[positionKey_to] != 0) || (s_accountFeesBase[positionKey_to] != 0) ) revert Errors.TransferFailed();
Since the position key is determined by target address, tokenType and tickRange. This will disallow a token transfer of tokenIds that contain legs with same tokenType and TickRange. As the transfer of 1 leg in the loop will set that value, causing a revert of the next iteration
Related to issue [2], a transfer of tokens can be denied by frontrunning, since registerTransfer revert if the positionKey_to
already has liquidy.
_collectAndWritePositionData
casts the left and right slot of amountToCollect
to uint128:
(uint128 receivedAmount0, uint128 receivedAmount1) = univ3pool.collect( msg.sender, liquidityChunk.tickLower(), liquidityChunk.tickUpper(), uint128(amountToCollect.rightSlot()), uint128(amountToCollect.leftSlot()) );
As explained in _getFeesBase
. The values can be negative:
// (feegrowth * liquidity) / 2 ** 128 /// @dev here we're converting the value to an int128 even though all values (feeGrowth, liquidity, Q128) // are strictly positive. /// That's because of the way feeGrowthInside works in Uniswap v3, where it can underflow when stored for the // first time. /// This is not a problem in Uniswap v3 because the fees are always calculated by taking the difference of th // feeGrowths, /// so that the net different is always positive. /// So by using int128 instead of uint128, we remove the need to handle extremely large underflows and simply //allow it to be negative feesBase = int256(0) .toRightSlot(int128(int256(Math.mulDiv128(feeGrowthInside0LastX128, liquidity)))) .toLeftSlot(int128(int256(Math.mulDiv128(feeGrowthInside1LastX128, liquidity))));
In practice there shouldnt be a negative impact, because collecting if the collected fees are negative will just yield 0. To be safe, only casting if the slot values are > 0 would probably be a good idea.
#0 - c4-judge
2023-12-14T16:43:33Z
Picodes marked the issue as grade-b