xanoutas/solidity-security-findings huggingface dataset:
Voting power creation through rounding error exploit
Audit: 2024-09-fenix-finance-findings
Severity: HIGH

# Lines of code

https://github.com/code-423n4/2024-09-fenix-finance/blob/main/contracts/core/VotingEscrowUpgradeableV2.sol#L143


# Vulnerability details

## Where is the bug
So this bug is in the voting power calc mechanism of the [VotingEscrowUpgradeableV2.deposit_for()](https://github.com/code-423n4/2024-09-fenix-finance/blob/main/contracts/core/VotingEscrowUpgradeableV2.sol#L143), [VotingEscrowUpgradeableV2._deposit()](https://github.com/code-423n4/2024-09-fenix-finance/blob/main/contracts/core/VotingEscrowUpgradeableV2.sol#L390), and [VotingEscrowUpgradeableV2._proccessLockChange()](https://github.com/code-423n4/2024-09-fenix-finance/blob/main/contracts/core/VotingEscrowUpgradeableV2.sol#L437). [VotingEscrowUpgradeableV2.balanceOfNFT()](https://github.com/code-423n4/2024-09-fenix-finance/blob/main/contracts/core/VotingEscrowUpgradeableV2.sol#L370) calc is also affected by this bug:

```solidity
function deposit_for(uint256 tokenId_, uint256 amount_) external override nonReentrant {
    _deposit(tokenId_, amount_, true);
}

function _deposit(uint256 tokenId_, uint256 amount_, bool shouldBoosted_) internal {
    LibVotingEscrowValidation.checkNoValueZero(amount_);
    TokenState memory state = nftStates[tokenId_];
    if (!IManagedNFTManager(managedNFTManager).isManagedNFT(tokenId_)) {
        state.depositCheck();
    }
    _proccessLockChange(tokenId_, amount_, 0, state.locked, DepositType.DEPOSIT_FOR_TYPE, shouldBoosted_);
}

function balanceOfNFT(uint256 tokenId_) public view override returns (uint256) {
    if (nftStates[tokenId_].lastTranferBlock == block.number) return 0;
    return _balanceOfNFT(tokenId_, block.timestamp);
}
```

## Impact
- Users can gain significant voting power with extremely minimal token investment.
- The ability to create voting power "out of thin air" undermines the entire governance structure.
 - The value of honestly locked tokens is thereby diluted. The integrity of the voting system is corrupted, therefore loss of user confidence.
## Proof of Concept
There is a flawed implementation of the voting power calculation in relation to deposits:
1. Initial state: A user creates a lock with a small amount of tokens.
2. Exploitation: The user makes multiple tiny deposits (as small as 1 wei) to the same lock.
3. Voting power calculation: Each deposit, regardless of size, triggers a recalculation of voting power.
4. Compounding error: The `_proccessLockChange` function doesn't properly account for these small deposits, so then there is a disproportionate increase in voting power.
5. Amplification: The `balanceOfNFT` function, which calculates the current voting power, doesn't correctly decay this inflated power over time.

The root of this lies in the `_proccessLockChange` function, which updates the lock's point history. It fails to properly scale the voting power increase, relative to the deposit `amount`, especially for very small deposits. Therefore users can exploit the system by making numerous minimal deposits,
Voting power inflation via Merge
Audit: 2024-09-fenix-finance-findings
Severity: HIGH

# Lines of code

https://github.com/code-423n4/2024-09-fenix-finance/blob/main/contracts/core/VotingEscrowUpgradeableV2.sol#L180


# Vulnerability details

## Where is the bug
The bug is in [VotingEscrowUpgradeableV2.merge()](https://github.com/code-423n4/2024-09-fenix-finance/blob/main/contracts/core/VotingEscrowUpgradeableV2.sol#L180):

```solidity
    function merge(
        uint256 tokenFromId_,
        uint256 tokenToId_
    ) external override nonReentrant onlyNftApprovedOrOwner(tokenFromId_) onlyNftApprovedOrOwner(tokenToId_) {
        if (tokenFromId_ == tokenToId_) {
            revert MergeTokenIdsTheSame();
        }
        TokenState memory stateFrom = nftStates[tokenFromId_];
        stateFrom.mergeCheckFrom();
        TokenState memory stateTo = nftStates[tokenToId_];
        stateTo.mergeCheckTo();
        _withdrawClearNftInfo(tokenFromId_, stateFrom);
        _proccessLockChange(
            tokenToId_,
            LibVotingEscrowUtils.toUint256(stateFrom.locked.amount),
            stateFrom.locked.end >= stateTo.locked.end ? stateFrom.locked.end : stateTo.locked.end,
            stateTo.locked,
            DepositType.MERGE_TYPE,
            false
        );
    }
```

## Impact
Allows for users to artificially inflate, their voting power through the NFT merging process. The issue occurs because the `merge` function incorrectly calculates the resulting voting power when combining two NFTs.

By exploiting the bug, an attacker can:
1. Create multiple NFTs with small token locks and varying lock times
2. Merge these NFTs to generate voting power that significantly exceeds the sum of the individual NFTs' power
3. Gain disproportionate control over the governance system without locking an equivalent amount of tokens

Key consequences:
- Undermines the fairness of voting system
- Opens malicious actors to dominate governance decisions
- Devalues the voting power of honest participants
- Harmful protocol changes or fund misappropriation

## Proof of Concept

How it happens:
1. The `merge` function combines the locked token amounts from both NFTs
2. It uses the later of the two unlock times for the merged NFT
3. The voting power calculation doesn't properly account for this combination, resulting in inflated power

Here is poc to show bug, put in [VoterV2.test.ts](https://github.com/code-423n4/2024-09-fenix-finance/blob/main/test/core/VoterV2/VoterV2.test.ts) and run using 
``npx hardhat test test/core/VoterV2/VoterV2.test.ts``: 

```typescript
  it("Should exploit merge mechanism to create voting power inconsistency", async function() {
        const user = signers.otherUser1;
        await token.connect(user).approve(VotingEscrow.target, ethers.MaxUint256);
    
        // Create locks
        const tx1 = await VotingEscrow.connect(user).create_lock_for(ethers.parseEther("1"), 4 * 7 * 24 * 3600, user.address);
        const receipt1 = await tx1.wait();
        const tokenId1 = (receipt1!.logs[0] as EventLog).args![2];
    
        c
mVeNFT DOS - can't trigger the vote function
Audit: 2024-09-fenix-finance-findings
Severity: MEDIUM

# Lines of code

https://github.com/code-423n4/2024-09-fenix-finance/blob/main/contracts/core/VoterUpgradeableV2.sol#L485
https://github.com/code-423n4/2024-09-fenix-finance/blob/main/contracts/core/VoterUpgradeableV2.sol#L448


# Vulnerability details

## Description

The `VoterUpgradeableV2.sol` contract has the function `attachToManagedNFT()`, users use it to delegate their veFNX voting power to a mVeNFT.
One of the things this function does after receiving the new voting power is sub-call to `_poke()` and it will update the Last voted Timestamp of the mVeNFT 
```solidity
lastVotedTimestamps[tokenId_] = _epochTimestamp() + 1;
```
At this point, the mVeNFT can't trigger the vote function until the next epoch starts due to the `_checkVoteDelay()`.
even this check inside the `vote()` doesn't help in this case 
```solidity
if (!managedNFTManagerCache.isWhitelistedNFT(tokenId_)) {
            _checkEndVoteWindow();
        }
```
  
However, to make things worse this protocol is deployed on Blast transactions are too cheap 
malicious users can keep creating new locks every epoch with one wei in `amount`  to bypass the zero check
```solidity
File: VotingEscrowUpgradeableV2.sol#_createLock()

 LibVotingEscrowValidation.checkNoValueZero(amount_);
```
then at the start of every new epoch (after the start of the voting window) just call `attachToManagedNFT()` 
By doing this it keeps forcing the mVeNFT to vote to the same gauges.


## Impact
DOS attack. 
mVeNFT can't invoke the vote function to change the weight of gauges. 
mVeNFT can't reset its votes.

## Tools Used

Manual Review

## Recommended Mitigation Steps
One solution is to not check the vote delay, However, I believe this comes with some trade-off
```solidity
    function vote(
        uint256 tokenId_,
        address[] calldata poolsVotes_,
        uint256[] calldata weights_
    ) external nonReentrant onlyNftApprovedOrOwner(tokenId_) {
        if (poolsVotes_.length != weights_.length) {
            revert ArrayLengthMismatch();
        }
        bool x = managedNFTManagerCache.isWhitelistedNFT(tokenId_);
        if (!x) {
        _checkVoteDelay(tokenId_);
        }

        _checkStartVoteWindow();
        IManagedNFTManager managedNFTManagerCache = IManagedNFTManager(managedNFTManager);
        if (managedNFTManagerCache.isDisabledNFT(tokenId_)) {
            revert DisabledManagedNft();
        }
        if (!x) {
            _checkEndVoteWindow();
        }
        _vote(tokenId_, poolsVotes_, weights_);
        _updateLastVotedTimestamp(tokenId_);
    }
```


## Assessed type

DoS
Gauge creation should be allowed only after receiving rewards from the minter per epoch
Audit: 2024-09-fenix-finance-findings
Severity: MEDIUM

# Lines of code

https://github.com/code-423n4/2024-09-fenix-finance/blob/main/contracts/core/VotingEscrowUpgradeableV2.sol#L660-L675


# Vulnerability details

## Impact

Rewards cannot be distributed to some gauges.

## Proof of Concept

When a gauge is created, the `gaugesState.index` is set to the current `index` at L669.

```solidity
File: contracts\core\VoterUpgradeableV2.sol
662:         gaugesState[gauge_] = GaugeState({
663:             isGauge: true,
664:             isAlive: true,
665:             internalBribe: internalBribe_,
666:             externalBribe: externalBribe_,
667:             pool: pool_,
668:             claimable: 0,
669:@>           index: index,
670:             lastDistributionTimestamp: 0
671:         });
```

If the gauge is created before calling `notifyRewardAmount`, the `gaugesState.index` is set to the old index. As a result, the new gauge can receive more rewards than the actual rewards, while other gauges may not receive any rewards.

Let's consider the following scenario:

1. There is `poolA` and `gaugeA`, with `index` initially set to 0.
2. Users vote 100 for `poolA` in the first week: `weightsPerEpoch = 100`, `totalWeightsPerEpoch = 100`.
3. The governor creates `gaugeB` for `poolB` at the start of the second week: `gaugesState[gaugeB] = 0`.
4. After that, users call the `distribute` function for `poolA`, and the minter transfers 100 FNX. They do not call the function for `poolB` because they did not vote for it.
    - `index = 100 * 1e18 / 100 = 1e18`
    - `gaugesState[gaugeA].index = 1e18`
    - `gaugesState[gaugeB].index = 0`
5. Users vote 50 for both `poolA` and `poolB` individually in the second week:
    - `weightsPerEpoch[poolA] = 50`
    - `weightsPerEpoch[poolB] = 50`
    - `totalWeightsPerEpoch = 100`
6. In the third week, users call the `distribute` function for both pools, and the minter transfers another 100 FNX.
    - `index = index + 100 * 1e18 / 100 = 2e18`
    - rewards amount for `gaugeA`: 50 * (2e18 - 1e18) / 1e18 = 50
    - rewards amount for `gaugeB`: 50 * (2e18 - 0) / 1e18 = 100

Even though the `VoterUpgradeableV2` contract receives 100 FNX from the minter, it attempts to transfer 150 FNX to the gauges. As a result, the rewards distribution to the gauges is reverted.

## Tools Used

Manual Review

## Recommended Mitigation Steps

Gauge creation should only be permitted after receiving rewards from the minter for each epoch.


## Assessed type

Other
The `VoterUpgradeableV2.createV3Gauge` function incorrectly uses `v2GaugeFactory` instead of `v3GaugeFactory`
Audit: 2024-09-fenix-finance-findings
Severity: MEDIUM

# Lines of code

https://github.com/code-423n4/2024-09-fenix-finance/blob/main/contracts/core/VoterUpgradeableV2.sol#L324


# Vulnerability details

## Impact

The gauges for the V3 pool are managed incorrectly by `v2GaugeFactory` rather than `v3GaugeFactory`.

## Proof of Concept

In the `VoterUpgradeableV2.createV3Gauge` function, `v2GaugeFactory` is used instead of the appropriate `v3GaugeFactory`.

```solidity
File: contracts\core\VoterUpgradeableV2.sol
323:         externalBribe = IBribeFactory(bribeFactoryCache).createBribe(token0, token1, string.concat("Fenix Bribes: ", symbol));
324:         gauge = IGaugeFactory(v2GaugeFactory).createGauge(
325:             token,
326:             votingEscrow,
327:             pool_,
328:             address(this),
329:             internalBribe,
330:             externalBribe,
331:             true,
332:             feeVault
333:         );
```

As a result, `v2GaugeFactory` manages the gauges for the V3 pool instead of `v3GaugeFactory`. The `GaugeFactoryUpgradeable` contract includes the `defaultBlastGovernor` and `merklGaugeMiddleman` variables, and the `createGauge` function initializes the gauge using these variables.

```solidity
File: contracts\gauges\GaugeFactoryUpgradeable.sol
    function createGauge(
        address _rewardToken,
        address _ve,
        address _token,
        address _distribution,
        address _internal_bribe,
        address _external_bribe,
        bool _isDistributeEmissionToMerkle,
        address _feeVault
    ) external virtual override returns (address) {
        require(msg.sender == voter || msg.sender == owner(), "only voter or owner");

        address newLastGauge = address(new GaugeProxy());
        IGauge(newLastGauge).initialize(
            defaultBlastGovernor,
            _rewardToken,
            _ve,
            _token,
            _distribution,
            _internal_bribe,
            _external_bribe,
            _isDistributeEmissionToMerkle,
            merklGaugeMiddleman,
            _feeVault
        );

        last_gauge = newLastGauge;

        return newLastGauge;
    }
```

## Tools Used

Manual Review

## Recommended Mitigation Steps

It is recommended to change the code in the `createV3Gauge` function as following:

```diff
-       gauge = IGaugeFactory(v2GaugeFactory).createGauge(
+       gauge = IGaugeFactory(v3GaugeFactory).createGauge(
            token,
            votingEscrow,
            pool_,
            address(this),
            internalBribe,
            externalBribe,
            true,
            feeVault
        );
```


## Assessed type

Other
If rewards are not distributed to some gauges in an epoch, it can lead to incorrect rewards distribution in the next epoch
Audit: 2024-09-fenix-finance-findings
Severity: MEDIUM

# Lines of code

https://github.com/code-423n4/2024-09-fenix-finance/blob/main/contracts/core/VotingEscrowUpgradeableV2.sol#L386


# Vulnerability details

## Impact
 
Some gauges may receive more rewards, while others may not receive any rewards at all.

## Proof of Concept

In the `VoterUpgradeableV2.notifyRewardAmount` function, the `index` is accumulated for every reward distribution from the minter. When distributing rewards to gauges, the reward amount is calculated using the delta index and `weightsPerEpoch`.

```solidity
        uint256 totalVotesWeight = weightsPerEpoch[currentTimestamp - _WEEK][state.pool];
        uint256 delta = index - state.index;
        if (delta > 0) {
L634:       uint256 amount = (totalVotesWeight * delta) / 1e18;
            if (state.isAlive) {
                gaugesState[gauge_].claimable += amount;
            } else {
                IERC20Upgradeable(token).safeTransfer(minter, amount);
            }
        }
```

If rewards are not distributed to a gauge in the current epoch, they can be distributed in the next epoch. If `weightsPerEpoch` for that gauge changes in the next epoch, the reward amount may be calculated incorrectly, leading to unfair distribution among the gauges.

Let's consider the following scenario:

1. There are two pools, `poolA` and `poolB`, with corresponding gauges `gaugeA` and `gaugeB`.
2. Users vote 50 for each pool individually in the first week: `weightsPerEpoch = 50`, `totalWeightsPerEpoch = 100`.
3. In the second week, users call the `distribute` function only for `poolA`, and the minter transfers 100 FNX. Of this, 50 FNX is transferred to `gaugeA`, while the remaining 50 FNX stays in the contract.
At that time, users never call the `distribute` function for `poolB`. This situation can occur if the creator of `poolB` intentionally does not call the `distribute` function and other users lack the incentive to call it.
    - `index = 100 * 1e18 / 100 = 1e18`
    - rewards amount for `gaugeA`: 50 * 1e18 / 1e18 = 50
    - `gaugesState[gaugeA].index = 1e18`
    - `gaugesState[gaugeB].index = 0`
4. In this week, users vote 10 for `poolA` and 90 for `poolB` individually.
5. In the third week, users call the `distribute` function for both pools, and the minter transfers another 100 FNX.
    - `index = index + 100 * 1e18 / 100 = 2e18`
    - rewards amount for `gaugeA`: 10 * 1e18 / 1e18 = 10
    - rewards amount for `gaugeB`: 90 * 2e18 / 1e18 = 180

Even though the `VoterUpgradeableV2` contract has 150 FNX from the minter, it attempts to transfer 190 FNX to the gauges. As a result, the rewards distribution to the gauges is reverted.

As a result, if rewards are not distributed to some gauges in an epoch, it can lead to incorrect rewards distribution in the next epoch.

## Tools Used

Manual Review

## Recommended Mitigation Steps

Rewards should be distributed to all gauges per epoch, or the reward index mechanism should be improved.


## Assessed type

Other
`boostedValue` should be added to `permanentTotalSupply` for permanently locked tokens
Audit: 2024-09-fenix-finance-findings
Severity: MEDIUM

# Lines of code

https://github.com/code-423n4/2024-09-fenix-finance/blob/main/contracts/core/VotingEscrowUpgradeableV2.sol#L470


# Vulnerability details

## Impact

Unlocking tokens from the permanent locking can be DoSed.

## Proof of Concept

In the `VotingEscrowUpgradeableV2._processLockChange` function, `boostedValue` is added to the token's locked amount at L465. However, `boostedValue` is not added to `permanentTotalSupply` for permanently locked tokens at L470.

```solidity
File: contracts\core\VotingEscrowUpgradeableV2.sol
465:@>       newLocked.amount += LibVotingEscrowUtils.toInt128(boostedValue);
466:         uint256 diff = LibVotingEscrowUtils.toUint256(newLocked.amount - oldLocked_.amount);
467:         uint256 supplyBefore = supply;
468:         supply += diff;
469:         if (newLocked.isPermanentLocked) {
470:@>           permanentTotalSupply += amount_;
471:         }
```

In the `unlockPermanent` function, the token's locked amount is subtracted from `permanentTotalSupply` at L219.

```solidity
File: contracts\core\VotingEscrowUpgradeableV2.sol
219:         permanentTotalSupply -= LibVotingEscrowUtils.toUint256(state.locked.amount);
```

As a result, calling this function may be reverted by the underflow.

Let's consider the following scenario:

1. Alice and Bob each create locks with 100 FNX for a 1-week duration (`< veBoostCached.getMinLockedTimeForBoost`).
2. Alice and Bob lock their tokens permanently: `permanentTotalSupply` = 100 + 100 = 200.
3. Alice deposits 1000 FNX (`>= veBoostCached.getMinFNXAmountForBoost`), and `_boostFNXPercentage` is 1000 (10%):
   - `boostedValue`: 100
   - total locked amount: 100 + 1000 + 100 = 1200
   - `permanentTotalSupply`: 200 + 1000 = 1200
4. Alice unlocks her token from the permanent lock: `permanentTotalSupply` = 1200 - 1200 = 0.
5. Bob tries to unlock his token from the permanent lock, but it is reverted because `permanentTotalSupply` is 0.

## Tools Used

Manual Review

## Recommended Mitigation Steps

It is recommended to change the code in the `VotingEscrowUpgradeableV2._processLockChange` function as following:

Let me know if you need further assistance!

```diff
        if (newLocked.isPermanentLocked) {
-           permanentTotalSupply += amount_;
+           permanentTotalSupply = permanentTotalSupply + amount_ + boostedValue;
        }
```


## Assessed type

DoS
mVeNFT can't trigger the vote function for one epoch
Audit: 2024-09-fenix-finance-findings
Severity: MEDIUM

# Lines of code

https://github.com/code-423n4/2024-09-fenix-finance/blob/main/contracts/core/VoterUpgradeableV2.sol#L553-L565


# Vulnerability details

## Description
The protocol has the **Attach** feature that lets the users delegate their voting power to mVeNFT (Managed Voting NFT) which will manage voting and reward collection without requiring the user’s active participation. 
Note the mVeNFT is owned by a managed (strategy) contract e.g,`CompoundVeFNXManagedNFTStrategyUpgradeable`

Users can attach a veNFT to a mVeNFT using `VoterUpgradeableV2.sol#attachToManagedNFT()` and to detache calls `VoterUpgradeableV2.sol#dettachFromManagedNFT()`

In case the last owner veNFT in mVeNFT triggers `dettachFromManagedNFT()`.
It will enter this IF block
```solidity
File: VoterUpgradeableV2.sol#dettachFromManagedNFT()

589:         if (weight == 0) {
590:             _reset(managedTokenId);
591:             delete lastVotedTimestamps[managedTokenId];
592:         } else {

```
So, the reset function will delete all the voting weight values of the mVeNFT and its `lastVotedTimestamps[]`

Now, after time a new user calls `attachToManagedNFT()`
```solidity
    function attachToManagedNFT(uint256 tokenId_, uint256 managedTokenId_) external nonReentrant onlyNftApprovedOrOwner(tokenId_) {
        _checkVoteDelay(tokenId_);
        _checkVoteWindow();
        IManagedNFTManager(managedNFTManager).onAttachToManagedNFT(tokenId_, managedTokenId_);
        _poke(managedTokenId_);
    }
```
which sub-call to `_poke()`. even if the voting weight of mVeNFT is still empty.
the poke will sub-call to update the value of `lastVotedTimestamps[]` to the current time.

This will leave the mVeNFT with zero votes in the current epoch (it is not able to call the vote function due to `_checkVoteDelay()` )

## Impact

The mVeNFT can't trigger the vote function for one epoch, the user(s) end up with zero rewards in this epoch

## Tools Used

Manual Review

## Recommended Mitigation Steps
```diff
function attachToManagedNFT(uint256 tokenId_, uint256 managedTokenId_) external nonReentrant onlyNftApprovedOrOwner(tokenId_) {
        _checkVoteDelay(tokenId_);
        _checkVoteWindow();
        IManagedNFTManager(managedNFTManager).onAttachToManagedNFT(tokenId_, managedTokenId_);
+        if (poolVote[managedTokenId_] != 0) {
        _poke(managedTokenId_);
+         }
        emit AttachToManagedNFT(tokenId_, managedTokenId_);
    }
```





## Assessed type

Invalid Validation