xanoutas/solidity-security-findings huggingface dataset:
Off by one error leads to incorrect value return
Audit: 2024-05-olas-findings
Severity: MEDIUM

# Lines of code

https://github.com/code-423n4/2024-05-olas/blob/3ce502ec8b475885b90668e617f3983cea3ae29f/tokenomics/contracts/TokenomicsConstants.sol#L33
https://github.com/code-423n4/2024-05-olas/blob/3ce502ec8b475885b90668e617f3983cea3ae29f/tokenomics/contracts/TokenomicsConstants.sol#L51


# Vulnerability details

## Impact
The provided code appears to have a logical flaw when calculating the supply cap for years beyond the first 10 years. Specifically, the loop used to calculate the compounded supply cap returns the total supply cap rather than the cap for just the current year.

## Proof of Concept
If numYears is 10 or more, the function calculates the supply cap using an inflation rate of 2% per year.

numYears is adjusted by subtracting 9 to account for the 0-based index of the array and to start the calculation from the 10th year onwards.

The base supply cap after 10 years is set to 1,000,000,000e18.

The function then iterates over the years beyond the first 10, increasing the supply cap by 2% each year.

```
  function getSupplyCapForYear(uint256 numYears) public pure returns (uint256 supplyCap) {
        // For the first 10 years the supply caps are pre-defined
        if (numYears < 10) {
            uint96[10] memory supplyCaps = [
                529_659_000e18,
                569_913_084e18,
                610_313_084e18,
                666_313_084e18,
                746_313_084e18,
                818_313_084e18,
                882_313_084e18,
                930_313_084e18,
                970_313_084e18,
                1_000_000_000e18
            ];
            supplyCap = supplyCaps[numYears];
        } else {
            // Number of years after ten years have passed (including ongoing ones)
            numYears -= 9;
            // Max cap for the first 10 years
            supplyCap = 1_000_000_000e18;
            // After that the inflation is 2% per year as defined by the OLAS contract
            uint256 maxMintCapFraction = 2;

            // Get the supply cap until the current year
            for (uint256 i = 0; i < numYears; ++i) {
                supplyCap += (supplyCap * maxMintCapFraction) / 100;
            }
            // Return the difference between last two caps (inflation for the current year)
            return supplyCap;
        }
    }
```
The [getSupplyCapForYear()](https://github.com/code-423n4/2024-05-olas/blob/3ce502ec8b475885b90668e617f3983cea3ae29f/tokenomics/contracts/TokenomicsConstants.sol#L33C3-L65C1) function incorrectly calculates the supply cap for years beyond the first 10. Instead of returning the supply cap for the specific year requested, it returns the cumulative supply cap up to that year.

Call the getSupplyCapForYear function with numYears set to 11.

Observe that the function returns the cumulative supply cap up to year 11 instead of the specific supply cap for the 11th year.


***Steps to Reproduce:***

Call the getSupplyCapForYear function with numYears set to 11.
Observe
Unstake function reverts because of use of outdated/stale serviceIds array
Audit: 2024-05-olas-findings
Severity: MEDIUM

# Lines of code

https://github.com/code-423n4/2024-05-olas/blob/3ce502ec8b475885b90668e617f3983cea3ae29f/registries/contracts/staking/StakingBase.sol#L823
https://github.com/code-423n4/2024-05-olas/blob/3ce502ec8b475885b90668e617f3983cea3ae29f/registries/contracts/staking/StakingBase.sol#L840


# Vulnerability details

## Impact
When unstake function is called for a specific service id checkpoint function is called and if the checkpoint is successful then the serviceIds array retrieved from the checkpoint function might be outdated/stale because of eviction of some service ids. Due to the use of outdated serviceIds array unstake function reverts. 
## Proof of Concept
Following is the unstake function
```solidity
function unstake(uint256 serviceId) external returns (uint256 reward) {
        ServiceInfo storage sInfo = mapServiceInfo[serviceId];
        // Check for the service ownership
        if (msg.sender != sInfo.owner) {
            revert OwnerOnly(msg.sender, sInfo.owner);
        }

        // Get the staking start time
        // Note that if the service info exists, the service is staked or evicted, and thus start time is always valid
        uint256 tsStart = sInfo.tsStart;

        // Check that the service has staked long enough, or if there are no rewards left
        uint256 ts = block.timestamp - tsStart;
        if (ts <= minStakingDuration && availableRewards > 0) {
            revert NotEnoughTimeStaked(serviceId, ts, minStakingDuration);
        }

        // Call the checkpoint
        (uint256[] memory serviceIds, , , , uint256[] memory evictServiceIds) = checkpoint();

        // If the checkpoint was not successful, the serviceIds set is not returned and needs to be allocated
        if (serviceIds.length == 0) {
            serviceIds = getServiceIds();
            evictServiceIds = new uint256[](serviceIds.length);
        }

        // Get the service index in the set of services
        // The index must always exist as the service is currently staked, otherwise it has no record in the map
        uint256 idx;
        bool inSet;
        for (; idx < serviceIds.length; ++idx) {
            // Service is still in a global staking set if it is found in the services set,
            // and is not present in the evicted set
            if (evictServiceIds[idx] == serviceId) {
                break;
            } else if (serviceIds[idx] == serviceId) {
                inSet = true;
                break;
            }
        }

        // Get the unstaked service data
        reward = sInfo.reward;
        uint256[] memory nonces = sInfo.nonces;
        address multisig = sInfo.multisig;

        // Clear all the data about the unstaked service
        // Delete the service info struct
        delete mapServiceInfo[serviceId];

        // Update the set of staked service Ids
        // If the index was not found, the service was evicted and is not part of staked services set
        if (inSet) {
            setServiceId
In retain function checkpoint nominee function is not called which can cause zero amount of tokens being retained.
Audit: 2024-05-olas-findings
Severity: MEDIUM

# Lines of code

https://github.com/code-423n4/2024-05-olas/blob/3ce502ec8b475885b90668e617f3983cea3ae29f/tokenomics/contracts/Dispenser.sol#L1129
https://github.com/code-423n4/2024-05-olas/blob/3ce502ec8b475885b90668e617f3983cea3ae29f/tokenomics/contracts/Dispenser.sol#L878


# Vulnerability details

## Impact
In retain function before calculating the staking incentives which belongs to retainer for different epoches no checkpoint nominee function in vote weighing is called for retainer. Due to this retained staking incentives maybe lost and they won't be recovered ever. Marking this as high because likelihood of happening is high and impact is medium and direct loss of retained incentives.

## Proof of Concept
Following is the retain function
```solidity
function retain() external {
        // Reentrancy guard
        if (_locked > 1) {
            revert ReentrancyGuard();
        }
        _locked = 2;

        // Get first and last claimed epochs
        (uint256 firstClaimedEpoch, uint256 lastClaimedEpoch) =
            _checkpointNomineeAndGetClaimedEpochCounters(retainerHash, maxNumClaimingEpochs);

        // Write last claimed epoch counter to start retaining from the next time
        mapLastClaimedStakingEpochs[retainerHash] = lastClaimedEpoch;

        uint256 totalReturnAmount;

        // Go over epochs and retain funds to return back to the tokenomics
        for (uint256 j = firstClaimedEpoch; j < lastClaimedEpoch; ++j) {
            // Get service staking info
            ITokenomics.StakingPoint memory stakingPoint = ITokenomics(tokenomics).mapEpochStakingPoints(j);

            // Get epoch end time
            uint256 endTime = ITokenomics(tokenomics).getEpochEndTime(j);

            // Get the staking weight for each epoch
            (uint256 stakingWeight, ) = IVoteWeighting(voteWeighting).nomineeRelativeWeight(retainer,
                block.chainid, endTime);

            totalReturnAmount += stakingPoint.stakingIncentive * stakingWeight;
        }
        totalReturnAmount /= 1e18;

        if (totalReturnAmount > 0) {
            ITokenomics(tokenomics).refundFromStaking(totalReturnAmount);
        }

        emit Retained(msg.sender, totalReturnAmount);

        _locked = 1;
    }
```
As can be seen from above if the weight of retainer is not updated then the following stakingPoint.stakingIncentive * stakingWeight will return zero thus no incentives will be retained for that epoch even when there is staking weight left for that epoch for retainer. As this function is permission less and external anyone can call this and thus can prevent the retention of staking incentives for some epoches before there is updation of weights of the retainer.
Once this function is executed for a particular epoch it can never be executed for that epoch because of the following set value
```solidity
// Write last claimed epoch counter to start retaining from the next time
        mapLastClaimedStakingEpochs[retainerHash] = lastClaimedEpoc
user might not be able to unstake even when there are no available rewards
Audit: 2024-05-olas-findings
Severity: MEDIUM

# Lines of code

https://github.com/code-423n4/2024-05-olas/blob/3ce502ec8b475885b90668e617f3983cea3ae29f/registries/contracts/staking/StakingBase.sol#L823


# Vulnerability details

## Impact
As checkpoint is called after checking if there are available rewards or not this unables a user to unstake even if there are no avaialble rewards because available rewards should be updated first and then its condition should be checked.

## Proof of Concept
Following is unstake function 
```solidity
function unstake(uint256 serviceId) external returns (uint256 reward) {
        ServiceInfo storage sInfo = mapServiceInfo[serviceId];
        // Check for the service ownership
        if (msg.sender != sInfo.owner) {
            revert OwnerOnly(msg.sender, sInfo.owner);
        }

        // Get the staking start time
        // Note that if the service info exists, the service is staked or evicted, and thus start time is always valid
        uint256 tsStart = sInfo.tsStart;

        // Check that the service has staked long enough, or if there are no rewards left
        uint256 ts = block.timestamp - tsStart;
        if (ts <= minStakingDuration && availableRewards > 0) {
            revert NotEnoughTimeStaked(serviceId, ts, minStakingDuration);
        }

        // Call the checkpoint
        (uint256[] memory serviceIds, , , , uint256[] memory evictServiceIds) = checkpoint();

        // If the checkpoint was not successful, the serviceIds set is not returned and needs to be allocated
        if (serviceIds.length == 0) {
            serviceIds = getServiceIds();
            evictServiceIds = new uint256[](serviceIds.length);
        }

        // Get the service index in the set of services
        // The index must always exist as the service is currently staked, otherwise it has no record in the map
        uint256 idx;
        bool inSet;
        for (; idx < serviceIds.length; ++idx) {
            // Service is still in a global staking set if it is found in the services set,
            // and is not present in the evicted set
            if (evictServiceIds[idx] == serviceId) {
                break;
            } else if (serviceIds[idx] == serviceId) {
                inSet = true;
                break;
            }
        }

        // Get the unstaked service data
        reward = sInfo.reward;
        uint256[] memory nonces = sInfo.nonces;
        address multisig = sInfo.multisig;

        // Clear all the data about the unstaked service
        // Delete the service info struct
        delete mapServiceInfo[serviceId];

        // Update the set of staked service Ids
        // If the index was not found, the service was evicted and is not part of staked services set
        if (inSet) {
            setServiceIds[idx] = setServiceIds[setServiceIds.length - 1];
            setServiceIds.pop();
        }

        // Transfer the service back to the owner
        // Note that the reentrancy is not possible due to the ServiceInfo struct b
OptimismTargetDispenserL2:syncWithheldTokens is callable with no sanity check on payloads and can lead to permanent loss of withheld token amounts
Audit: 2024-05-olas-findings
Severity: MEDIUM

# Lines of code

https://github.com/code-423n4/2024-05-olas/blob/3ce502ec8b475885b90668e617f3983cea3ae29f/tokenomics/contracts/staking/OptimismTargetDispenserL2.sol#L66
https://github.com/code-423n4/2024-05-olas/blob/3ce502ec8b475885b90668e617f3983cea3ae29f/tokenomics/contracts/staking/OptimismTargetDispenserL2.sol#L71
https://github.com/code-423n4/2024-05-olas/blob/3ce502ec8b475885b90668e617f3983cea3ae29f/tokenomics/contracts/staking/OptimismTargetDispenserL2.sol#L89


# Vulnerability details

## Description
The syncWithheldTokens function in the context of Dispenser contracts is used to sync withheld token amounts with the L1. 
At the DefaultTargetDispenserL2 contract, it requires no special role to be called and does no sanity checks on the user-inputted bridgePayload bytes memory argument. 
In the context of the OptimismTargetDispenserL2 this is catastrophic, as the bridgePayload is used to determine the cost and the gas limit of the message relayed. 

Now, if we take a look at the sendMessage implementation of the [sendMessage function at the Optimism CrossDomainMessenger contract](https://github.com/ethereum-optimism/optimism/blob/c76c5420c43d1d7d07175b65e89d74925f7a3899/packages/contracts-bedrock/src/universal/CrossDomainMessenger.sol#L176) and at the internal [sendMessage function at the Optimism L2CrossDomainMessenger contract]([optimism/packages/contracts-bedrock/src/L2/L2CrossDomainMessenger.sol at b92dc8ff026e20fabe1f3015718ce4e6e6bdaf97 · ethereum-optimism/optimism (github.com)](https://github.com/ethereum-optimism/optimism/blob/b92dc8ff026e20fabe1f3015718ce4e6e6bdaf97/packages/contracts-bedrock/src/L2/L2CrossDomainMessenger.sol#L43C4-L47C6)) we can notice the cost and msg.value are not sanity checked during a message call:
```solidity
    function sendMessage(address _target, bytes calldata _message, uint32 _minGasLimit) external payable {
        if (isCustomGasToken()) {
            require(msg.value == 0, "CrossDomainMessenger: cannot send value with custom gas token");
        }

        // Triggers a message to the other messenger. Note that the amount of gas provided to the
        // message is the amount of gas requested by the user PLUS the base gas value. We want to
        // guarantee the property that the call to the target contract will always have at least
        // the minimum gas limit specified by the user.
        _sendMessage({
            _to: address(otherMessenger),
            _gasLimit: baseGas(_message, _minGasLimit),
            _value: msg.value,
            _data: abi.encodeWithSelector(
                this.relayMessage.selector, messageNonce(), msg.sender, _target, msg.value, _minGasLimit, _message
            )
        });

        emit SentMessage(_target, msg.sender, _message, messageNonce(), _minGasLimit);
        emit SentMessageExtension1(msg.sender, msg.value);

        unchecked {
            ++msgNonce;
        }
    }



|||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||

 f
Incorrect Handling of Withheld Amounts in `claimStakingIncentives()` Function
Audit: 2024-05-olas-findings
Severity: MEDIUM

# Lines of code

https://github.com/code-423n4/2024-05-olas/blob/main/tokenomics/contracts/Dispenser.sol#L1006


# Vulnerability details

## Relevant Context

In the [claimStakingIncentives()](https://github.com/code-423n4/2024-05-olas/blob/main/tokenomics/contracts/Dispenser.sol#L953C3-L1048C1) function allows users to claim staking incentives for a specific staking target. It calculates the staking incentives based on the provided `numClaimedEpochs`, `chainId`, and `stakingTarget`. The function also handles the distribution of the staking incentives to the corresponding deposit processor.

## Finding Description

The bug lies in the [claimStakingIncentives()](https://github.com/code-423n4/2024-05-olas/blob/main/tokenomics/contracts/Dispenser.sol#L953C3-L1048C1) function. The function does not properly handle the case when the `stakingIncentive` is `> 0`, but the `transferAmount` is `0` due to the withheld amount being `>=` to the `stakingIncentive`.

Here's the relevant code:

```solidity
// Check if staking incentive is deposited
if (stakingIncentive > 0) {
    transferAmount = stakingIncentive;
    // Account for possible withheld OLAS amounts
    // Note: in case of normalized staking incentives with bridging decimals, this is correctly managed
    // as normalized amounts are returned from another side
    uint256 withheldAmount = mapChainIdWithheldAmounts[chainId];
    if (withheldAmount > 0) {
        // If withheld amount is enough to cover all the staking incentives, the transfer of OLAS is not needed
        if (withheldAmount >= transferAmount) {
            withheldAmount -= transferAmount;
            transferAmount = 0;
        } else {
            // Otherwise, reduce the transfer of tokens for the OLAS withheld amount
            transferAmount -= withheldAmount;
            withheldAmount = 0;
        }
        mapChainIdWithheldAmounts[chainId] = withheldAmount;
    }

    // Check if minting is needed as the actual OLAS transfer is required
    if (transferAmount > 0) {
        uint256 olasBalance = IToken(olas).balanceOf(address(this));

        // Mint tokens to self in order to distribute to the staking deposit processor
        ITreasury(treasury).withdrawToAccount(address(this), 0, transferAmount);

        // Check the balance after the mint
        olasBalance = IToken(olas).balanceOf(address(this)) - olasBalance;
        if (olasBalance != transferAmount) {
            revert WrongAmount(olasBalance, transferAmount);
        }
    }

    // Dispense to a service staking target
    _distributeStakingIncentives(chainId, stakingTarget, stakingIncentive, bridgePayload, transferAmount);
}
```

If the `withheldAmount` is `>=` the `transferAmount`, the code sets `transferAmount` to `0`. However, it still proceeds to call the `_distributeStakingIncentives` function with the original `stakingIncentive` value, even though no tokens were minted or transferred.

## Impact Explanation

This bug therefore leads to a situation where
StakingToken.sol doesn't properly handle FOT, rebasing tokens or those with variable which will lead to accounting issues downstream.
Audit: 2024-05-olas-findings
Severity: MEDIUM

# Lines of code

https://github.com/code-423n4/2024-05-olas/blob/3ce502ec8b475885b90668e617f3983cea3ae29f/registries/contracts/staking/StakingToken.sol#L125
https://github.com/code-423n4/2024-05-olas/blob/3ce502ec8b475885b90668e617f3983cea3ae29f/registries/contracts/staking/StakingToken.sol#L108


# Vulnerability details

## Impact

The protocol aims to work with all sorts of ERC20 tokens including tokens that charge a fee on transfer including PAXG. Deposits and withdrawals of these tokens when being used as staking tokens in StakingToken.sol are not properly handle which can cause a number of accounting issues most especially transfer of the losses due to fees to the last unstaker or reward claimer. The same effect, although to a less degree can be observed with tokens like stETH which has the [1 wei corner case issue](https://docs.lido.fi/guides/lido-tokens-integration-guide/#1-2-wei-corner-case) in which the token amount transferred can be a bit less than the amount entered in. This also includes other rebasing tokens, and tokens with variable balances or airdrops as the direction of rebasing can lead to the same effect as FOT tokens, which token amount being less than internal accounting suggests, same as extra tokens from positive rebasing and airdrops being lost due it also not matching internal accounting and no other way to claim these tokens.

## Proof of Concept
From the readme,

> ERC20 token behaviors in scope
> Fee on transfer	Yes
> Balance changes outside of transfers	Yes

And looking at StakingToken.sol, we can see that the `amount` of the `stakingToken` is directly transferred as is and is also recorded into the contract's balance and avaliableRewards. Now if `stakingToken` is a token like PAXG that charges a fee on transfer, the actual amount transferred from msg.sender upon deposit will be different from amount received by StakingToken.sol and recorded into the balance/availableRewards. Here, there becomes a mismatch between the contract's accounting and the contract's actual balance.

```solidity
    function deposit(uint256 amount) external {
        // Add to the contract and available rewards balances
        uint256 newBalance = balance + amount;
        uint256 newAvailableRewards = availableRewards + amount;

        // Record the new actual balance and available rewards
        balance = newBalance;
        availableRewards = newAvailableRewards;

        // Add to the overall balance
        SafeTransferLib.safeTransferFrom(stakingToken, msg.sender, address(this), amount);

        emit Deposit(msg.sender, amount, newBalance, newAvailableRewards);
    }
```

The same can be seen in `_withdraw` which is called when a user [claims](https://github.com/code-423n4/2024-05-olas/blob/3ce502ec8b475885b90668e617f3983cea3ae29f/registries/contracts/staking/StakingBase.sol#L508) or [unstakes](https://github.com/code-423n4/2024-05-olas/blob/3ce502ec8b475885b90668e617f3983cea3ae29f/registries/contracts/staking/StakingBase.sol#L868).