Panoptic - KupiaSec's results

Effortless options trading on any token, any strike, any size.

General Information

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

Panoptic

Findings Distribution

Researcher Performance

Rank: 14/72

Findings: 2

Award: $1,122.43

🌟 Selected for report: 0

🚀 Solo Findings: 0

Findings Information

🌟 Selected for report: osmanozdemir1

Also found by: 0xDING99YA, 0xloscar01, KupiaSec, SpicyMeatball, ether_sky, fnanni, hash, minhtrng

Labels

bug
3 (High Risk)
satisfactory
sponsor confirmed
duplicate-256

Awards

1111.1061 USDC - $1,111.11

External Links

Lines of code

https://github.com/code-423n4/2023-11-panoptic/blob/f75d07c345fd795f907385868c39bafcd6a56624/contracts/SemiFungiblePositionManager.sol#L620-L621

Vulnerability details

Impact

When a token is transferred, it checks if the entire balance is being transferred by comparing liquidity chunk and net liquidity.

uint256 fromLiq = s_accountLiquidity[positionKey_from];
if (fromLiq.rightSlot() != liquidityChunk.liquidity()) revert Errors.TransferFailed();

This causes an issue where invalid token is allowed to be transferred and it makes other token can't be burnt forever. For example, Alice creates an option for selling T amount of call at K, also creates another option(not a leg but separate token) for buying T/2 amount of call at K. In this case, R = T/2, N = T - R = T/2, thus make it possible for long call option to be transferred to another address. Once long call is transferred, liquidity information is reset to zero, making it impossible for the short call being burnt or transferred and the short call's position is stuck on the smart contract forever.

Proof of Concept

Here's a test case written in Foundry that shows above example:

function test_Audit_token_transfer(
    uint256 x,
    uint256 widthSeed,
    int256 strikeSeed
) public {
    uint256 positionSizeSeed = 1e19;
    _initPool(x);

    (int24 width, int24 strike) = PositionUtils.getInRangeSW(
        widthSeed,
        strikeSeed,
        uint24(tickSpacing),
        currentTick
    );

    populatePositionData(width, strike, positionSizeSeed);

    // tokenId1 - short call
    uint256 tokenId1 = uint256(0).addUniv3pool(poolId).addLeg(0, 1, isWETH == 0 ? 1 : 0, 0, 0, 0, strike, width);

    // tokenId2 - long call
    uint256 tokenId2 = uint256(0).addUniv3pool(poolId).addLeg(0, 1, isWETH == 0 ? 1 : 0, 1, 0, 0, strike, width);

    int256 totalCollected;
    int256 totalSwapped;
    int24 newTickk;

    changePrank(Alice);

    positionSize = 1e20;

    // Write long call option - Option1
    (totalCollected, totalSwapped, newTickk) = sfpm.mintTokenizedPosition(
        tokenId1,
        positionSize,
        TickMath.MAX_TICK - 1,
        TickMath.MIN_TICK + 1
    );

    // Write short call option - Option2
    (totalCollected, totalSwapped, newTickk) = sfpm.mintTokenizedPosition(
        tokenId2,
        positionSize / 2,
        TickMath.MAX_TICK - 1,
        TickMath.MIN_TICK + 1
    );

    // int24 tmp = width * tickSpacing / 2;
    // uint256 liq = sfpm.getAccountLiquidity(address(pool), Alice, 0, strike - tmp, strike + tmp);
    // assertEq(liq, 0);
    // return ;

    // Option2 can be transferred because `liquidityChunk = T/2 and N = T- R = T/2 as well`
    sfpm.safeTransferFrom(
        Alice,
        Bob,
        tokenId2,
        positionSize / 2,
        ""
    );

    // Option1 fails to burn, forever fails
    vm.expectRevert(Errors.NotEnoughLiquidity.selector);
    sfpm.burnTokenizedPosition(
        tokenId1,
        positionSize,
        TickMath.MAX_TICK - 1,
        TickMath.MIN_TICK + 1
    );
}

Tools Used

Manual Review, Foundry

When a token is transferred, T and R need to be calculated for all legs and compare it with the account liquidity. Another possible mitigation would be tieng position key to tokenId.

Assessed type

Invalid Validation

#0 - dyedm1

2023-12-17T21:59:29Z

dup #256

#1 - c4-sponsor

2023-12-17T21:59:37Z

dyedm1 (sponsor) confirmed

#2 - c4-judge

2023-12-21T18:59:05Z

Picodes marked the issue as duplicate of #256

#3 - c4-judge

2023-12-26T22:47:12Z

Picodes marked the issue as satisfactory

L-01: Minting positions might revert when user only has one-side token.

For writing options, call writers only require asset(ETH) to deposit and put writers only require cash(USDC) to deposit, they don't have to hold both asset and cash to write an option. However, by the nature of the minting logic, when the positin is in the money, call writers need to hold both asset and cash even though one side is returned after swap.

Recommendation: Implementation could be upgraded by using flashloan, without requiring option writers to hold both asset and cash.

L-02: Invalid check for upper/lower bound strike price

https://github.com/code-423n4/2023-11-panoptic/blob/f75d07c345fd795f907385868c39bafcd6a56624/contracts/types/TokenId.sol#L482-L485

Recommendation: Use <= and >= for comparison.

#0 - c4-judge

2023-12-14T16:55:49Z

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