HIGH severity: Incorrect/repeated withdrawal from rocketSmoothingPool in executeRewardSnapshot() will lead to loss of ETH rewards. The vulnerability involves a redundant and incorrect withdrawal call in the _executeRewardSnapshot() function of RocketRewardsPool.sol, where ETH is withdrawn twice from the rocketSmoothingPool for the same reward distribution.

The root cause is a logic error where, after already withdrawing smooth...

Vulnerable code:
// SPDX-License-Identifier: GPL-3.0-only
pragma solidity 0.8.30;
pragma abicoder v2;

import {RocketStorageInterface} from "../../interface/RocketStorageInterface.sol";
import {RocketVaultInterface} from "../../interface/RocketVaultInterface.sol";
import {RocketDAONodeTrustedInterface} from "../../interface/dao/node/RocketDAONodeTrustedInterface.sol";
import {RocketDAOProtocolSettingsNetworkInterface} from "../../interface/dao/protocol/settings/RocketDAOProtocolSettingsNetworkInterface.sol";
import {RocketDAOProtocolSettingsRewardsInterface} from "../../interface/dao/protocol/settings/RocketDAOProtocolSettingsRewardsInterface.sol";
import {RocketRewardsPoolInterface} from "../../interface/rewards/RocketRewardsPoolInterface.sol";
import {RocketRewardsRelayInterface} from "../../interface/rewards/RocketRewardsRelayInterface.sol";
import {RocketSmoothingPoolInterface} from "../../interface/rewards/RocketSmoothingPoolInterface.sol";
import {RocketTokenRPLInterface} from "../../interface/token/RocketTokenRPLInterface.sol";
import {IERC20} from "../../interface/util/IERC20.sol";
import {RewardSubmission} from "../../types/RewardSubmission.sol";
import {RocketBase} from "../RocketBase.sol";
import {RocketVaultWithdrawerInterface} from "../../interface/RocketVaultWithdrawerInterface.sol";

/// @notice Holds RPL and ETH generated by the network for distribution each reward cycle
contract RocketRewardsPool is RocketBase, RocketRewardsPoolInterface, RocketVaultWithdrawerInterface {
    // Events
    event RewardSnapshotSubmitted(address indexed from, uint256 indexed rewardIndex, RewardSubmission submission, uint256 time);
    event RewardSnapshot(uint256 indexed rewardIndex, RewardSubmission submission, uint256 intervalStartTime, uint256 intervalEndTime, uint256 time);

    // Construct
    constructor(RocketStorageInterface _rocketStorageAddress) RocketBase(_rocketStorageAddress) {
        version = 5;
    }

    /// @dev Needs to freely accept ETH withdrawn from the smoothing pool
    receive() payable external {}

    /// @dev Needs to freely accept ETH withdrawn from the vault
    function receiveVaultWithdrawalETH() external payable {}

    /// @notice Accepts incoming ETH from megapool distributions for voter share into vault
    function depositVoterShare() override payable external {
        // Transfer incoming ETH directly to the vault
        RocketVaultInterface rocketVault = RocketVaultInterface(getContractAddress("rocketVault"));
        rocketVault.depositEther{value: msg.value}();
    }

    /// @notice Returns the amount of ETH rewards waiting to be distributed
    function getEthBalance() override external view returns (uint256) {
        RocketVaultInterface rocketVault = RocketVaultInterface(getContractAddress("rocketVault"));
        return rocketVault.balanceOf("rocketRewardsPool");
    }

    /// @notice Get the reward index
    function getRewardIndex() override public view returns (uint256) {
        return getUint(keccak256("rewards.snapshot.index"));
    }

    /// @notice Increment the reward index
    function incrementRewardIndex() private {
        addUint(keccak256("rewards.snapshot.index"), 1);
    }

    /// @notice Get how much RPL the Rewards Pool contract currently has assigned to it as a whole
    /// @return uint256 Returns rpl balance of rocket rewards contract
    function getRPLBalance() override public view returns (uint256) {
        // Get the vault contract instance
        RocketVaultInterface rocketVault = RocketVaultInterface(getContractAddress("rocketVault"));
        // Check contract RPL balance
        return rocketVault.balanceOfToken("rocketRewardsPool", IERC20(getContractAddress("rocketTokenRPL")));
    }

    /// @notice Returns the total amount of RPL that needs to be distributed to claimers at the current block
    function getPendingRPLRewards() override public view returns (uint256) {
        RocketTokenRPLInterface rplContract = RocketTokenRPLInterface(getContractAddress("rocketTokenRPL"));
        uint256 pendingInflation = rplContract.inflationCalculate();
        // Any inflation that has accrued so far plus any amount that would be minted if we called it now
        return getRPLBalance() + pendingInflation;
    }

    /// @notice Returns the total amount of ETH in the smoothing pool ready to be distributed
    function getPendingETHRewards() override public view returns (uint256) {
        address rocketSmoothingPoolAddress = getContractAddress("rocketSmoothingPool");
        return rocketSmoothingPoolAddress.balance;
    }

    /// @notice Returns the amount of pending voter share ETH ready to be distributed
    function getPendingVoterShare() override public view returns (uint256) {
        RocketVaultInterface rocketVault = RocketVaultInterface(getContractAddress("rocketVault"));
        return rocketVault.balanceOf("rocketRewardsPool");
    }

    /// @notice Get the last set interval start time
    /// @return uint256 Last set start timestamp for a claim interval
    function getClaimIntervalTimeStart() override public view returns (uint256) {
        return getUint(keccak256("rewards.pool.claim.interval.time.start"));
    }

    /// @notice Get how many seconds in a claim interval
    /// @return uint256 Number of seconds in a claim interval
    function getClaimIntervalTime() override public view returns (uint256) {
        // Get from the DAO settings
        RocketDAOProtocolSettingsRewardsInterface daoSettingsRewards = RocketDAOProtocolSettingsRewardsInterface(getContractAddress("rocketDAOProtocolSettingsRewards"));
        return daoSettingsRewards.getRewardsClaimIntervalTime();
    }

    /// @notice Compute intervals since last claim period
    /// @return uint256 Time intervals since last update
    function getClaimIntervalsPassed() override public view returns (uint256) {
        return (block.timestamp - getClaimIntervalTimeStart()) / getClaimIntervalTime();
    }

    /// @notice Returns the block number that the given claim interval was executed at
    /// @param _interval The interval for which to grab the execution block of
    function getClaimIntervalExecutionBlock(uint256 _interval) override external view returns (uint256) {
        return getUint(keccak256(abi.encodePacked("rewards.pool.interval.execution.block", _interval)));
    }

    /// @notice Returns the address of the contract which was used to execute this reward interval
    /// @param _interval The interval for which to grab the address of
    function getClaimIntervalExecutionAddress(uint256 _interval) override external view returns (address) {
        return getAddress(keccak256(abi.encodePacked("rewards.pool.interval.execution.address", _interval)));
    }

    /// @notice Get the percentage this contract can claim in this interval
    /// @return uint256 Rewards percentage this contract can claim in this interval
    function getClaimingContractPerc(string memory _claimingContract) override public view returns (uint256) {
        // Load contract
        RocketDAOProtocolSettingsRewardsInterface daoSettingsRewards = RocketDAOProtocolSettingsRewardsInterface(getContractAddress("rocketDAOProtocolSettingsRewards"));
        // Get the % amount allocated to this claim contract
        return daoSettingsRewards.getRewardsClaimerPerc(_claimingContract);
    }

    /// @notice Get an array of percentages that the given contracts can claim in this interval
    /// @return uint256[] Array of percentages in the order of the supplied contract names
    function getClaimingContractsPerc(string[] memory _claimingContracts) override external view returns (uint256[] memory) {
        // Load contract
        RocketDAOProtocolSettingsRewardsInterface daoSettingsRewards = RocketDAOProtocolSettingsRewardsInterface(getContractAddress("rocketDAOProtocolSettingsRewards"));
        // Get the % amount allocated to this claim contract
        uint256[] memory percentages = new uint256[](_claimingContracts.length);
        for (uint256 i = 0; i < _claimingContracts.length; ++i) {
            percentages[i] = daoSettingsRewards.getRewardsClaimerPerc(_claimingContracts[i]);
        }
        return percentages;
    }

    /// @notice Returns whether a trusted node has submitted for a given reward index
    function getTrustedNodeSubmitted(address _trustedNodeAddress, uint256 _rewardIndex) override external view returns (bool) {
        return getBool(keccak256(abi.encode("rewards.snapshot.submitted.node", _trustedNodeAddress, _rewardIndex)));
    }

    /// @notice Returns whether a trusted node has submitted a specific RewardSubmission
    function getSubmissionFromNodeExists(address _trustedNodeAddress, RewardSubmission calldata _submission) override external view returns (bool) {
        return getBool(keccak256(abi.encode("rewards.snapshot.submitted.node.key", _trustedNodeAddress, _submission)));
    }

    /// @notice Returns the number of trusted nodes who have agreed to the given submission
    function getSubmissionCount(RewardSubmission calldata _submission) override external view returns (uint256) {
        return getUint(keccak256(abi.encode("rewards.snapshot.submitted.count", _submission)));
    }

    /// @notice Submit a reward snapshot. Only accepts calls from trusted (oracle) nodes
    function submitRewardSnapshot(RewardSubmission calldata _submission) override external onlyLatestContract("rocketRewardsPool", address(this)) onlyTrustedNode(msg.sender) {
        // Get contracts
        RocketDAOProtocolSettingsNetworkInterface rocketDAOProtocolSettingsNetwork = RocketDAOProtocolSettingsNetworkInterface(getContractAddress("rocketDAOProtocolSettingsNetwork"));
        // Check submission is currently enabled
        require(rocketDAOProtocolSettingsNetwork.getSubmitRewardsEnabled(), "Submitting rewards is currently disabled");
        // Validate inputs
        uint256 rewardIndex = getRewardIndex();
        require(_submission.rewardIndex <= rewardIndex, "Can only submit snapshot for periods up to next");
        require(_submission.intervalsPassed > 0, "Invalid number of intervals passed");
        require(_submission.nodeRPL.length == _submission.trustedNodeRPL.length && _submission.trustedNodeRPL.length == _submission.nodeETH.length, "Invalid array length");
        // Calculate RPL reward total and validate
        { // Scope to prevent stack too deep
            uint256 totalRewardsRPL = _submission.treasuryRPL;
            for (uint256 i = 0; i < _submission.nodeRPL.length; ++i) {
                totalRewardsRPL = totalRewardsRPL + _submission.nodeRPL[i];
            }
            for (uint256 i = 0; i < _submission.trustedNodeRPL.length; ++i) {
                totalRewardsRPL = totalRewardsRPL + _submission.trustedNodeRPL[i];
            }
            require(totalRewardsRPL <= getPendingRPLRewards(), "Invalid RPL rewards");
        }
        // Calculate ETH reward total and validate
        { // Scope to prevent stack too deep
            uint256 totalRewardsETH = _submission.treasuryETH + _submission.userETH;
            for (uint256 i = 0; i < _submission.nodeETH.length; ++i) {
                totalRewardsETH = totalRewardsETH + _submission.nodeETH[i];
            }
            uint256 smoothingPoolBalance = getPendingETHRewards();
            require(totalRewardsETH <= smoothingPoolBalance + getPendingVoterShare(), "Invalid ETH rewards");
            require(_submission.smoothingPoolETH <= smoothingPoolBalance, "Invalid smoothing pool balance");
        }
        // Store and increment vote
        uint256 submissionCount;
        { // Scope to prevent stack too deep
            // Check & update node submission status
            bytes32 nodeSubmissionKey = keccak256(abi.encode("rewards.snapshot.submitted.node.key", msg.sender, _submission));
            require(!getBool(nodeSubmissionKey), "Duplicate submission from node");
            setBool(nodeSubmissionKey, true);
            setBool(keccak256(abi.encode("rewards.snapshot.submitted.node", msg.sender, _submission.rewardIndex)), true);
        }
        { // Scope to prevent stack too deep
            // Increment submission count
            bytes32 submissionCountKey = keccak256(abi.encode("rewards.snapshot.submitted.count", _submission));
            submissionCount = getUint(submissionCountKey) + 1;
            setUint(submissionCountKey, submissionCount);
        }
        // Emit snapshot submitted event
        emit RewardSnapshotSubmitted(msg.sender, _submission.rewardIndex, _submission, block.timestamp);
        // Return if already executed
        if (_submission.rewardIndex != rewardIndex) {
            return;
        }
        // If consensus is reached, execute the snapshot
        RocketDAONodeTrustedInterface rocketDAONodeTrusted = RocketDAONodeTrustedInterface(getContractAddress("rocketDAONodeTrusted"));
        if (calcBase * submissionCount / rocketDAONodeTrusted.getMemberCount() >= rocketDAOProtocolSettingsNetwork.getNodeConsensusThreshold()) {
            _executeRewardSnapshot(_submission);
        }
    }

    /// @notice Executes reward snapshot if consensus threshold is reached
    function executeRewardSnapshot(RewardSubmission calldata _submission) override external onlyLatestContract("rocketRewardsPool", address(this)) {
        // Validate reward index of submission
        require(_submission.rewardIndex == getRewardIndex(), "Can only execute snapshot for next period");
        // Get submission count
        bytes32 submissionCountKey = keccak256(abi.encode("rewards.snapshot.submitted.count", _submission));
        uint256 submissionCount = getUint(submissionCountKey);
        // Confirm consensus and execute
        RocketDAONodeTrustedInterface rocketDAONodeTrusted = RocketDAONodeTrustedInterface(getContractAddress("rocketDAONodeTrusted"));
        RocketDAOProtocolSettingsNetworkInterface rocketDAOProtocolSettingsNetwork = RocketDAOProtocolSettingsNetworkInterface(getContractAddress("rocketDAOProtocolSettingsNetwork"));
        require(calcBase * submissionCount / rocketDAONodeTrusted.getMemberCount() >= rocketDAOProtocolSettingsNetwork.getNodeConsensusThreshold(), "Consensus has not been reached");
        _executeRewardSnapshot(_submission);
    }

    /// @notice Executes reward snapshot and sends assets to the relays for distribution to reward recipients
    function _executeRewardSnapshot(RewardSubmission calldata _submission) private {
        // Get contract
        RocketTokenRPLInterface rplContract = RocketTokenRPLInterface(getContractAddress("rocketTokenRPL"));
        RocketVaultInterface rocketVault = RocketVaultInterface(getContractAddress("rocketVault"));
        // Execute inflation if required
        rplContract.inflationMintTokens();
        // Increment the reward index and update the claim interval timestamp
        incrementRewardIndex();
        uint256 claimIntervalTimeStart = getClaimIntervalTimeStart();
        uint256 claimIntervalTimeEnd = claimIntervalTimeStart + (getClaimIntervalTime() * _submission.intervalsPassed);
        // Emit reward snapshot event
        emit RewardSnapshot(_submission.rewardIndex, _submission, claimIntervalTimeStart, claimIntervalTimeEnd, block.timestamp);
        setUint(keccak256(abi.encodePacked("rewards.pool.interval.execution.block", _submission.rewardIndex)), block.number);
        setAddress(keccak256(abi.encodePacked("rewards.pool.interval.execution.address", _submission.rewardIndex)), address(this));
        setUint(keccak256("rewards.pool.claim.interval.time.start"), claimIntervalTimeEnd);
        // Send out the treasury rewards
        if (_submission.treasuryRPL > 0) {
            rocketVault.transferToken("rocketClaimDAO", rplContract, _submission.treasuryRPL);
        }
        // Get the smoothing pool instance
        RocketSmoothingPoolInterface rocketSmoothingPool = RocketSmoothingPoolInterface(getContractAddress("rocketSmoothingPool"));
        // Send deposit pool user's ETH
        if (_submission.smoothingPoolETH > 0) {
            rocketSmoothingPool.withdrawEther(address(this), _submission.smoothingPoolETH);
        }
        // Calculate total amount of ETH required for this reward interval
        uint256 totalETH = _submission.userETH + _submission.treasuryETH;
        for (uint i = 0; i < _submission.nodeETH.length; ++i) {
            totalETH += _submission.nodeETH[i];
        }
        // Withdraw required amount from the vault
        uint256 vaultBalance = totalETH - _submission.smoothingPoolETH;
        if (vaultBalance > 0) {
            rocketVault.withdrawEther(vaultBalance);
        }
        // Send user share to rETH contract
        if (_submission.userETH > 0) {
            address rocketTokenRETHAddress = getContractAddress("rocketTokenRETH");
            (bool result,) = rocketTokenRETHAddress.call{value: _submission.userETH}("");
            require(result, "Failed to send voter rewards");
        }
        // Send pDAO share to treasury
        if (_submission.treasuryETH > 0) {
            address payable rocketClaimDAO = payable(getContractAddress("rocketClaimDAO"));
            (bool result,) = rocketClaimDAO.call{value: _submission.treasuryETH}("");
            require(result, "Failed to send pDAO rewards");
        }
        // Loop over each network and distribute rewards
        for (uint i = 0; i < _submission.nodeRPL.length; ++i) {
            // Quick out if no rewards for this network
            uint256 rewardsRPL = _submission.nodeRPL[i] + _submission.trustedNodeRPL[i];
            uint256 rewardsETH = _submission.nodeETH[i];
            if (rewardsRPL == 0 && rewardsETH == 0) {
                continue;
            }
            // Grab the relay address
            RocketRewardsRelayInterface relay;
            { // Scope to prevent stack too deep
                address networkRelayAddress;
                bytes32 networkRelayKey = keccak256(abi.encodePacked("rewards.relay.address", i));
                networkRelayAddress = getAddress(networkRelayKey);
                // Validate network is valid
                require(networkRelayAddress != address(0), "Snapshot contains rewards for invalid network");
                relay = RocketRewardsRelayInterface(networkRelayAddress);
            }
            // Transfer rewards
            if (rewardsRPL > 0) {
                // RPL rewards are withdrawn from the vault
                rocketVault.withdrawToken(address(relay), rplContract, rewardsRPL);
            }
            if (rewardsETH > 0) {
                // ETH rewards are withdrawn from the smoothing pool
                rocketSmoothingPool.withdrawEther(address(relay), rewardsETH);
            }
            // Call into relay contract to handle distribution of rewards
            relay.relayRewards(_submission.rewardIndex, _submission.merkleRoot, rewardsRPL, rewardsETH);
        }
    }
}


Vulnerability type: CWE-691|CWE-670