Panoptic

Panoptic Hypovault
Findings & Analysis Report

2025-08-13

Table of contents

Overview

About C4

Code4rena (C4) is a competitive audit platform where security researchers, referred to as Wardens, review, audit, and analyze codebases for security vulnerabilities in exchange for bounties provided by sponsoring projects.

A C4 audit is an event in which community participants, referred to as Wardens, review, audit, or analyze smart contract logic in exchange for a bounty provided by sponsoring projects.

During the audit outlined in this document, C4 conducted an analysis of the Panoptic Hypovault smart contract system. The audit took place from June 27 to July 07, 2025.

Final report assembled by Code4rena.

Summary

The C4 analysis yielded an aggregated total of 2 unique vulnerabilities. Of these vulnerabilities, 2 received a risk rating in the category of HIGH severity and 0 received a risk rating in the category of MEDIUM severity.

Additionally, C4 analysis included 27 reports detailing issues with a risk rating of LOW severity or non-critical.

All of the issues presented here are linked back to their original finding, which may include relevant context from the judge and Panoptic team.

Scope

The code under review can be found within the C4 Panoptic Hypovault repository, and is composed of 2 smart contracts written in the Solidity programming language and includes 444 lines of Solidity code.

Severity Criteria

C4 assesses the severity of disclosed vulnerabilities based on three primary risk categories: high, medium, and low/non-critical.

High-level considerations for vulnerabilities span the following key areas when conducting assessments:

  • Malicious Input Handling
  • Escalation of privileges
  • Arithmetic
  • Gas use

For more information regarding the severity criteria referenced throughout the submission review process, please refer to the documentation provided on the C4 website, specifically our section on Severity Categorization.

High Risk Findings (2)

[H-01] The poolExposure for token1 is erroneously calculated as shortPremium - longPremium;

Submitted by AlexCzm, also found by 0xAura, Coachmike, dan__vinci, dhank, Egbe, JuggerNaut63, Neeloy, prk0, richa, ro1sharkm, TheWeb3Mechanic, and udogodwin

https://github.com/code-423n4/2025-06-panoptic/blob/8ef6d867a5fb6ffd1a6cc479a2380a611d452b4a/src/accountants/PanopticVaultAccountant.sol#L134-L136

Finding description and impact

getAccumulatedFeesAndPositionsData returns the total amount of premium owed to the short legs and the total amount of premium owned by the long legs. This means that the shortPremium is an asset for the Vault and the longPremium is a liability. The assets must be added to the NAV while the liabilities must be subtracted from the NAV.

The poolExposure0 calculates correctly the exposure in token0 by subtracting the longPremium from the shortPremium. For the poolExposure1 these 2 premiums are reversed: the shortPremium is wrongly subtracted from the longPremium:

    function computeNAV(
        address vault,
        address underlyingToken,
        bytes calldata managerInput
    ) external view returns (uint256 nav) {
...
            int256 poolExposure0;
            int256 poolExposure1;
            {
                LeftRightUnsigned shortPremium;
                LeftRightUnsigned longPremium;

                (shortPremium, longPremium, positionBalanceArray) = pools[i]
                    .pool
                    .getAccumulatedFeesAndPositionsData(_vault, true, tokenIds[i]);

                poolExposure0 =
                    int256(uint256(shortPremium.rightSlot())) -
                    int256(uint256(longPremium.rightSlot()));
                poolExposure1 =
@>                    int256(uint256(longPremium.leftSlot())) -
@>                    int256(uint256(shortPremium.leftSlot()));
            }

These 2 exposures are converted to the underlying token and summed to calculate the vault’s NAV. The computeNAV() function will return a wrong value.

The sharesReceived, when deposits are fulfilled and the assetsReceived on fulfill withdrawals from the HypoVault, will have incorrect values. Users can receive more (or less) shares as well as getting back less (or more) assets.

Calculate the poolExposure1 the same way poolExposure0 is calculated:

            {
                LeftRightUnsigned shortPremium;
                LeftRightUnsigned longPremium;

                (shortPremium, longPremium, positionBalanceArray) = pools[i]
                    .pool
                    .getAccumulatedFeesAndPositionsData(_vault, true, tokenIds[i]);

                poolExposure0 =
                    int256(uint256(shortPremium.rightSlot())) -
                    int256(uint256(longPremium.rightSlot()));
                poolExposure1 =
-                    int256(uint256(longPremium.leftSlot())) -
-                    int256(uint256(shortPremium.leftSlot()));
+                    int256(uint256(shortPremium.leftSlot())) -
+                    int256(uint256(longPremium.leftSlot()));
            }

Proof of Concept

The test test_computeNAV_exactCalculation_withExactPremiums fails to detect this issue because the tolerance used in assert is too high - 50% of the NAV.

To better show the issue, the following test uses slightly different values for premium. Add the following code to PoC.t.sol file and execute it with forge test --mt test_submissionValidity -vvv.

The test fails with [FAIL: NAV should include underlying plus converted premiums: 1048995033790712343115 !~= 1250000000000000000000 (max delta: 10000000000000000000, real delta: 201004966209287656885)] error message:

    function test_submissionValidity() public {
        // Create pools where token0 is underlying - only token1 needs conversion
        PanopticVaultAccountant.PoolInfo[] memory pools = new PanopticVaultAccountant.PoolInfo[](1);
        pools[0] = PanopticVaultAccountant.PoolInfo({
            pool: PanopticPool(address(mockPool)),
            token0: underlyingToken, // Same as underlying
            token1: token1,
            poolOracle: poolOracle,
            oracle0: oracle0,
            isUnderlyingToken0InOracle0: true,
            oracle1: oracle1,
            isUnderlyingToken0InOracle1: false,
            maxPriceDeviation: MAX_PRICE_DEVIATION,
            twapWindow: TWAP_WINDOW
        });
        address vault = address(vault);
        accountant.updatePoolsHash(vault, keccak256(abi.encode(pools)));

        // Setup scenario with no token balances, only collateral and premiums
        underlyingToken.setBalance(vault, 1000e18);
        token1.setBalance(vault, 0); // No token1 balance
        mockPool.collateralToken0().setBalance(vault, 0); // No collateral
        mockPool.collateralToken0().setPreviewRedeemReturn(0);
        mockPool.collateralToken1().setBalance(vault, 0);
        mockPool.collateralToken1().setPreviewRedeemReturn(0);

        uint256 shortPremiumRight = 200e18;
        uint256 shortPremiumLeft = 150e18;
        uint256 longPremiumRight = 50e18;
        uint256 longPremiumLeft = 50e18;
        uint256 net = (shortPremiumRight - longPremiumRight) + (shortPremiumLeft - longPremiumLeft);
        assertEq(net, 250e18, "Net premium should be 250 ether");
        mockPool.setMockPremiums(
            LeftRightUnsigned.wrap((shortPremiumLeft << 128) | shortPremiumRight),
            LeftRightUnsigned.wrap((longPremiumLeft << 128) | longPremiumRight)
        );

        // No positions
        mockPool.setNumberOfLegs(vault, 0);
        mockPool.setMockPositionBalanceArray(new uint256[2][](0));

        bytes memory managerInput = createManagerInput(pools, new TokenId[][](1));
        uint256 nav = accountant.computeNAV(vault, address(underlyingToken), managerInput);

        uint256 expectedNavBase = 1000e18 + net; // Conservative estimate
        uint256 tolerance = 10e18; // relatively small tolerance for premium conversion calculations
        assertApproxEqAbs(
            nav,
            expectedNavBase,
            tolerance,
            "NAV should include underlying plus converted premiums"
        );
    }

    // ===== helper functions =====

// copy-paste from PanopticVaultAccountant.t.sol
    function createManagerInput(
        PanopticVaultAccountant.PoolInfo[] memory pools,
        TokenId[][] memory tokenIds
    ) internal pure returns (bytes memory) {
        PanopticVaultAccountant.ManagerPrices[]
            memory managerPrices = new PanopticVaultAccountant.ManagerPrices[](pools.length);

        for (uint i = 0; i < pools.length; i++) {
            managerPrices[i] = PanopticVaultAccountant.ManagerPrices({
                poolPrice: TWAP_TICK,
                token0Price: TWAP_TICK,
                token1Price: TWAP_TICK
            });
        }

        return abi.encode(managerPrices, pools, tokenIds);
    }

After the suggested fix is implemented, the test passes.


[H-02] NAV calculation inconsistency due to underlying token position in pool configuration

Submitted by dhank, also found by kvar

The computeNAV function in PanopticVaultAccountant.sol contains a logical flaw that causes identical vault positions to report different NAV values based on their pool configuration. The issue comes from inconsistent handling of the vault’s underlying token balance when calculating nav.

The problem:

  1. When pools contain the underlying token: The underlying token balance is included in poolExposure calculations code within the loop, and the per-pool Math.max(poolExposure0 + poolExposure1, 0) is applied to the combined exposure.

    PanopticVaultAccountant.sol#L250

  2. When pools don't contain the underlying token: The underlying token balance is added after all pool calculations are complete and after the Math.max(_, 0) protection has already been applied per pool.

    PanopticVaultAccountant.sol#L254-L258

This creates a scenario where the timing of when underlying token balances are included in the NAV calculation determines whether they can offset negative pool exposures or are lost entirely.

// Inside the pool processing loop:
nav += uint256(Math.max(poolExposure0 + poolExposure1, 0)); // Applied per pool

// After loop completion:
if (!skipUnderlying) {
    nav += IERC20Partial(underlyingToken).balanceOf(_vault); // Added to already-processed NAV
}

Hence, Vaults with underlying tokens in their pool configuration report lower NAV values than economically identical vaults without such configuration.

Managers while executing fulfillDeposit() or fullfillWithdrawals(), the txn will execute without any reverts. Otherwise, it would have been underflowed here and the calculated share price will be incorrect putting the protocol and users to risk.

Example:

Two vaults with identical economic positions:

  • Pool exposure: -150 USDC (net losses from options trading)
  • Underlying balance: +50 USDC (cash reserves)
  • Expected NAV: max (-150 + 50, 0) = 0 USDC

Vault A (underlying token USDC appears in ETH/USDC pool):

poolExposure = -150 + 50 = -100 USDC (underlying included)
nav += max(-100, 0) = 0
skipUnderlying = true
Final NAV = 0 USDC  (Correct by coincidence)

Vault B (underlying token USDC not in ETH/WBTC pool):

poolExposure = -150 USDC (underlying not included)
nav += max(-150, 0) = 0
skipUnderlying = false
nav += 50 USDC (underlying added after)
Final NAV = 50 USDC  (Incorrect - should be 0)

Put nav calculation outside of the for loop.

    {
        for loop pools
        ....
    }
    bool skipUnderlying = false;
    for (uint256 i = 0; i < underlyingTokens.length; i++) {
        if (underlyingTokens[i] == underlyingToken) skipUnderlying = true;
    }
    if (!skipUnderlying) {
        totalExposure += int256(IERC20Partial(underlyingToken).balanceOf(_vault));
    }
    
    // Apply Math.max to final aggregated exposure
    nav = uint256(Math.max(totalExposure, 0));
    ```
    where totalExposure is net poolExposure0 + poolExposure1 for all pools

Low Risk and Non-Critical Issues

For this audit, 27 reports were submitted by wardens detailing low risk and non-critical issues. The report highlighted below by YouCrossTheLineAlfie received the top score from the judge.

The following wardens also submitted reports: 0xhp9, 0xnija, 0xp1ranh4, AasifUsmani, AlexCzm, codexNature, dd0x7e8, dystopia, entropydrifter, joicygiore, K42, katz, KupiaSec, lostOpcode, marutint10, MohitAndRanjan, newspacexyz, pavankv, PolarizedLight, rayss, ro1sharkm, Sparrow, StaVykoV, udogodwin, unnamed, and v1nzen.

Note: QA report issues deemed invalid by the judge have been omitted from this report.

[01] Users can avoid performance fees by transferring entire share balances in parts

The transfer and transferFrom can be used by anyone to transfer the shares across.

However, these functions make an internal call _transferBasis which uses the the amount being passed in proportion to the basis in order to transfer:

    function _transferBasis(address from, address to, uint256 amount) internal {
        uint256 fromBalance = balanceOf[from];
        if (fromBalance == 0) return;

        uint256 fromBasis = userBasis[from];
        uint256 basisToTransfer = (fromBasis * amount) / fromBalance;           <<@ -- // Allowing it to round down to 0

        userBasis[from] = fromBasis - basisToTransfer;
        userBasis[to] += basisToTransfer;
    }

Hence, if the fromBasis * amount is smaller than fromBalance, it would return 0.

An attacker can avoid performance fee by ensuring that the basis is 0 of the recipient address (which will be his own wallet) by transferring amounts such that fromBasis * amount < fromBalance, avoiding the performance fee when calling executeWithdrawal as pending basis would be zero when executing withdrawal:

        // executeWithdrawal
        uint256 withdrawnBasis = (uint256(pendingWithdrawal.basis) *
            _withdrawalEpochState.sharesFulfilled) / _withdrawalEpochState.sharesWithdrawn;         <<@ 
        uint256 performanceFee = (uint256(                                                  
            Math.max(0, int256(assetsToWithdraw) - int256(withdrawnBasis))                          <<@
        ) * performanceFeeBps) / 10_000;

It is recommended to use consistent scaling.

[02] Repetitive clear queuedWithdrawal in executeDeposit

The HypoVault::executeDeposit clears the queuedDeposit twice:

    function executeDeposit(address user, uint256 epoch) external {
        if (epoch >= depositEpoch) revert EpochNotFulfilled();

        uint256 queuedDepositAmount = queuedDeposit[user][epoch];
        queuedDeposit[user][epoch] = 0;                                                     <<@

        DepositEpochState memory _depositEpochState = depositEpochState[epoch];

        uint256 userAssetsDeposited = Math.mulDiv(
            queuedDepositAmount,
            _depositEpochState.assetsFulfilled,
            _depositEpochState.assetsDeposited
        );

        uint256 sharesReceived = Math.mulDiv(
            userAssetsDeposited,
            _depositEpochState.sharesReceived,
            _depositEpochState.assetsFulfilled
        );

        // shares from pending deposits are already added to the supply at the start of every new epoch
        _mintVirtual(user, sharesReceived);

        userBasis[user] += userAssetsDeposited;

        queuedDeposit[user][epoch] = 0;                                                     <<@

        uint256 assetsRemaining = queuedDepositAmount - userAssetsDeposited;

        // move remainder of deposit to next epoch -- unfulfilled assets in this epoch will be handled in the next epoch
        if (assetsRemaining > 0) queuedDeposit[user][epoch + 1] += uint128(assetsRemaining);

        emit DepositExecuted(user, userAssetsDeposited, sharesReceived, epoch);
    }

It is recommended to clear the queuedDeposit only once.


Disclosures

C4 is an open organization governed by participants in the community.

C4 audits incentivize the discovery of exploits, vulnerabilities, and bugs in smart contracts. Security researchers are rewarded at an increasing rate for finding higher-risk issues. Audit submissions are judged by a knowledgeable security researcher and disclosed to sponsoring developers. C4 does not conduct formal verification regarding the provided code but instead provides final verification.

C4 does not provide any guarantee or warranty regarding the security of this project. All smart contract software should be used at the sole risk and responsibility of users.