// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;

// This file is strictly provided as-is
// without any express or implied guarantees, representations, or warranties
// as to this file or any deployments hereof.

// Any users of these files are doing so at their own risk.

/// @notice contains all of the MVO staking storage and logic
/// @dev address(this) must be passed to updateMinterStatus() in the $ENSHROUD token contract by a minter

// Solbase ReentrancyGuard (https://github.com/Sol-DAO/solbase/blob/main/src/utils/ReentrancyGuard.sol)
import {ReentrancyGuard} from "./ReentrancyGuard.sol";

// Solbase SafeTransferLib (https://github.com/Sol-DAO/solbase/blob/main/src/utils/SafeTransferLib.sol)
import {SafeTransferLib} from "./SafeTransferLib.sol";

// used for Enshroud Token
interface IERC20Permit_MVOStaking {
    function mint(address _to, uint256 _value) external;

    function permit(
        address owner,
        address spender,
        uint256 value,
        uint256 deadline,
        uint8 v,
        bytes32 r,
        bytes32 s
    ) external;
}

interface IEnshroudProtocol_MVOStaking {
    // getter for public 'adminStatus[]' mapping
    function adminStatus(address addr) external view returns (bool);
}

contract MVOStaking is ReentrancyGuard {
    using SafeTransferLib for address;

    /*///////////////////////////////////////////////////////////////
                        TYPES AND STATE VARIABLES
    //////////////////////////////////////////////////////////////*/

    /// @notice 'listenURI' represents the web2 URI at which dApp users can contact this MVO (note this is typically an http proxy endpoint)
    /// @notice 'stakingAddress' is the EOA from which the MVO's owner stakes tokens in this contract, and receives mints when claiming rewards
	/// @notice 'signingAddress' corresponds to the privkey with which this MVO signs OperationsBlocks, allowing sig verification by peers and dApps
	/// @notice 'encrPubkey' is the public key used to faciliate ECIES encryption between dApp users and the MVO
	/// @notice 'stakingQuantum' is the amount staked by stakingAddress in this contract
	/// @notice 'memberPoolId' is updated when the MVO joins or creates a pool
	/// @notice 'rating' is initially unused in this contract, but may be used for purposes such as slashing in conjunction with 'reducePoints()', offchain reputation trackers, etc.
	/// @notice 'active' is an administrative enable/shutoff switch
	/// @notice except for stakingQuantum and memberPoolId, all of these values can be configured only by admins
    struct MVO {
        string listenURI;
        address payable stakingAddress;
        address signingAddress;
        string encrPubkey;
        uint256 stakingQuantum;
        uint120 memberPoolId;
        uint120 rating;
        bool active;
    }

	/// @notice the 'poolAddress' will match the stakingAddress of the creator
    struct MVOPool {
        address payable poolAddress;
        uint96 dues;
    }

	/// types to distinguish Requests
	enum ApprovalTypes { MinStake, Protocol, PointsBot }

    /// @notice to track admin update requests; addresses truncated to bytes8 for size/gas considerations
    struct Requests {
        bytes8 requester1;
        bytes8 requester2;
		ApprovalTypes requestType;
    }

	/// @notice mirrors Timelock{} in TimelockManager and DAOPool contracts
    struct Timelock {
        uint256 totalAmount;
        uint256 remainingAmount;
        uint128 releaseStart;
        uint128 releaseEnd;
    }

    /// @notice constant variable for ETH mainnet: approximated via 7150 blocks/day, 365 days per year
    /// @dev this must be altered for deployments on other chains
    uint256 internal constant BLOCKS_PER_YEAR = 2609750;
    /// @dev 18 decimal token calculations
    uint256 internal constant DECIMALS = 1e18;

    /// @notice $ENSHROUD token contract address, immutable
    address public immutable enshroudToken;
    /// @notice block starting point for epoch calculations
    uint256 public immutable genesisBlock;
    /// @notice immutable interface for 'enshroudToken'
    IERC20Permit_MVOStaking internal immutable iEnshroudToken;
    /// @notice TimelockManager contract address
    address public immutable timelockManager;

    /// @notice Enshroud protocol addresses (to support V1, V2, etc.)
    address[] public enshroudProtocols;
	/// @notice counter for enshroudProtocols array, set to index of latest deployed Enshroud protocol
	uint256 public protoIndex;

    /// @notice hash of off-chain codebase (i.e. MVO/Auditor node) location, or codebase in its entirety, for user verification. Provided by admin.
    bytes32 public codeHash;

    /// @notice minimum amount of stake required for an active MVO
    uint256 public minStake;
    /// @notice counter for 'mvoIds' array index via 'mvoIdsIndex' mapping
    uint256 public mvoIndex;
    /// @notice total amount of staked tokens, used to calculate relative staking amount for a given MVO in selection mechanism
    uint256 public totalStake;
    /// @notice total amount of awarded points outstanding, used to calculate relative pointsBalance for a given MVO when claiming reward mint
    uint256 public totalPoints;
	/// @notice address used by bot that imports points awarded on other chains
	address internal pointsBot;
    /// @notice for the MVO selection algorithm
    string[] public mvoIds;
	/// @notice used for tracking state of update*() invocations
    mapping(address => Requests) internal updateRequested;
    /// @notice MVO staking address mapped to current points balance
    mapping(address => uint256) public pointsBalance;
    /// @notice maps an mvoId to its index number ('mvoIndex') in the 'mvoIds' array
    mapping(string => uint256) public mvoIdsIndex;
    /// @notice maps an MVO's mvoId to corresponding MVO struct
    mapping(string => MVO) public idToMVO;
    /// @notice maps year to the amount of MVO incentive tokens remaining for reward mint in such year
    mapping(uint256 => uint256) public yearToTokensRemaining;
    /// @notice maps an MVOPoolId to its corresponding MVOPool struct
    mapping(uint256 => MVOPool) public idToMVOPool;
    /// @notice maps a timelock beneficiary address to their Timelock struct
    mapping(address => Timelock) public timelocks;

    /*///////////////////////////////////////////////////////////////
                            EVENTS
    //////////////////////////////////////////////////////////////*/

    event CodeHashChanged(bytes32 newCodeHash);

    event MinimumStakeUpdated(uint256 newMinStake);

    event MVOPoolCreated(uint256 poolId, uint256 dues, string creatorMvoID);

    event MVOPoolDeleted(uint256 poolId, string creatorMvoID);

    event MVOStakeUpdated(string mvoID, uint256 stakingQuantum);

    event MVOStaking_ProtocolUpdated(address newProtocol);

    event MVOStaking_Staked(
        address indexed sender,
        uint256 amount,
        uint256 blockNumber
    );

    event MVOStaking_Unstaked(
        address indexed sender,
        uint256 amount,
        uint256 blockNumber
    );

    event MVOStatusChanged(string mvoID, MVO updatedMVO, uint256 chainId);

    event PointsAwarded(address indexed mvoAddress, uint256 points, uint256 chainId);

	event PointsBotUpdated(address botAddress);

    event PointsReduced(address indexed mvoAddress, uint256 points);

    event PoolMembershipUpdated(
        uint256 indexed newPoolId,
        uint256 oldPoolId,
        string memberMvoID
    );

    /*///////////////////////////////////////////////////////////////
                            MODIFIERS
    //////////////////////////////////////////////////////////////*/

	/// @notice guarantees caller is an admin in the last-deployed EnshroudProtocol
    modifier onlyAdmin() {
		if (
			!IEnshroudProtocol_MVOStaking(enshroudProtocols[protoIndex-1])
				.adminStatus(msg.sender)
		) revert NotAdmin();
        _;
    }

    /*///////////////////////////////////////////////////////////////
                            ERRORS
    //////////////////////////////////////////////////////////////*/

    error AmountLargerThanBalance();
    error AmountLargerThanStake();
    error AmountLargerThanWithdrawable();
    error DuesTooLarge();
    error IDAlreadyInUse();
    error IDAndMsgSenderMismatch();
    error IncorrectSigLength();
    error InvalidChainId();
    error InvalidSignature();
    error LengthMismatch();
    error MintPeriodExpired();
    error NotActiveMVO();
    error NotAdmin();
    error OnlyPointsRobot();
    error OnlyProtocolContract();
    error OnlyTimelockManager();
    error PoolDoesNotExist();
    error UnclaimedPoints();

    /*///////////////////////////////////////////////////////////////
                            FUNCTIONS
    //////////////////////////////////////////////////////////////*/

    /// @param _enshroudProtocol: Enshroud Protocol contract address
    /// @param _enshroudToken: $ENSHROUD token contract address. Immutable.
    /// @param _timelockManager: contract address for $ENSHROUD token timelock manager. Immutable.
    /// @param _minStake: initial minimum staked amount of enshroudToken required for a signing MVO
    constructor(
        address _enshroudProtocol,
        address _enshroudToken,
        address _timelockManager,
        uint256 _minStake
    ) payable {
        enshroudProtocols.push(_enshroudProtocol);
		protoIndex = 1;
        enshroudToken = _enshroudToken;
        timelockManager = _timelockManager;
        iEnshroudToken = IERC20Permit_MVOStaking(_enshroudToken);
        minStake = _minStake;

        // stored to calculate mint amounts according to incentive schedule
        genesisBlock = block.number;

        // 5 million token reward incentive for year 1, 4 million for year 2, 3 million for year 3, 2 million for year 4, 1 million for year 5
        yearToTokensRemaining[1] = 5000000 * DECIMALS;
        yearToTokensRemaining[2] = 4000000 * DECIMALS;
        yearToTokensRemaining[3] = 3000000 * DECIMALS;
        yearToTokensRemaining[4] = 2000000 * DECIMALS;
        yearToTokensRemaining[5] = 1000000 * DECIMALS;
    }

    /// @notice award a lead MVO (3 points) and committee MVOs (1 point each) for each processed transaction, only callable by the protocol contract
    /// @dev uses idToMVO mapping to determine the committee MVO staking addresses; sigs not currently checked since only enshroudProtocol can call this method, and every invocation is preceeded by a call to checkMVOSig(); Reentrant-protected by each function in EnshroudProtocol which is able to call this function
    /// @param _mvoIDs: IDs of MVOs for the transaction
    function awardPoints(
        string[] calldata _mvoIDs
    ) external {
		/// @dev guarantee caller is in the array of deployed enshroudProtocols
		bool notProtocol = true;
		// @dev loop runs backwards to advantage latest protocol re gas usage
		for (uint256 pIdx = enshroudProtocols.length; pIdx > 0; pIdx--) {
			if (msg.sender == enshroudProtocols[pIdx-1]) {
				notProtocol = false;
				break;
			}
		}
        if (notProtocol) revert OnlyProtocolContract();

        MVO storage _leadMVO = idToMVO[_mvoIDs[0]];
        unchecked {
            pointsBalance[_leadMVO.stakingAddress] += 3;
            totalPoints += 3;
        }

        /// @dev MVO will be able to claim tokens with their 'pointsBalance' via their 'stakingAddress', but note operations block sigs are made with 'signingAddress'.
        /// Start at [1] as [0] (lead MVO) was already handled above. Array length limited in protocol contract.
        for (uint256 i = 1; i < _mvoIDs.length; ) {
            address _stakeAddr = idToMVO[_mvoIDs[i]].stakingAddress;
            unchecked {
                ++pointsBalance[_stakeAddr];
                ++totalPoints;
                ++i;
            }
            emit PointsAwarded(_stakeAddr, 1, block.chainid);
        }
        emit PointsAwarded(_leadMVO.stakingAddress, 3, block.chainid);
    }

	/// @notice this method is only relevant on Eth mainnet, callable by bot
	/// @dev used to migrate points earned by MVOs on other chains to mainnet where staking and point claims actually take place
	/// @param _chainId: the chain the bot is importing the points from
	/// @param _mvoIDs: IDs of MVOs for which points are being imported
	/// @param _points: number of points for each listed MVO
	function importPoints(
		uint256 _chainId,
		string[] calldata _mvoIDs,
		uint256[] calldata _points
	) external nonReentrant {
		/// @dev guarantee caller is the duly authorized point import bot
		if (msg.sender != pointsBot) revert OnlyPointsRobot();
		if (_chainId == block.chainid || _chainId == 0) revert InvalidChainId();
		if (_mvoIDs.length == 0 || _mvoIDs.length != _points.length) {
			revert LengthMismatch();
		}
		// add points to all provided MVOs if they are valid
		for (uint256 i = 0; i < _mvoIDs.length; ) {
        	MVO storage _mvo = idToMVO[_mvoIDs[i]];
			address _stakeAddr = _mvo.stakingAddress;
			/// @dev we do not check _mvo.active to allow import of points accrued elsewhere prior to deactivation
			if (_stakeAddr == address(0)) revert NotActiveMVO();
			unchecked {
				// points are small values and are cleared periodically, therefore won't overflow on human timelines
				pointsBalance[_stakeAddr] += _points[i];
				totalPoints += _points[i];
				emit PointsAwarded(_stakeAddr, _points[i], _chainId);
				++i;
			}
		}
	}

    /// @notice an admin can slash points from an MVO for impropriety; also may be used for points redemption mechanisms for MVO reward schemes beyond the 5 year hardcoded rewards
    /* @dev this method does not affect the MVO's struct; for alterations to an MVO's struct information, 'updateMvoStatus()' should be used;
     * single admin used here (1) for ease and reversibility of any malicious or mistaken update, and (2) so a single contract address may affect points balances in future,
     * such as a custom points slashing or redemption contract implementation */
    /// @param _mvoID: ID of MVO whose points are being reduced
    /// @param _points: amount of points being subtracted from the relevant MVO's 'pointsBalance'
    function reducePoints(
        string calldata _mvoID,
        uint256 _points
    ) external onlyAdmin {
        address _stakingAddr = idToMVO[_mvoID].stakingAddress;
        if (pointsBalance[_stakingAddr] < _points)
            revert AmountLargerThanBalance();
        // will not revert due to preceding 'pointsBalance[]' condition check and the fact that no amount subtracted from 'totalPoints' can be larger than a single address's pointsBalance (see 'claim()')
        unchecked {
            pointsBalance[_stakingAddr] -= _points;
            totalPoints -= _points;
        }

        emit PointsReduced(_stakingAddr, _points);
    }

    /// @notice for an MVO to claim their mint reward. Amount of reward is based on the ratio of msg.sender's 'pointsBalance[]' compared to 'totalPoints' at the time of claim, minus any pool dues
    /// @dev entire 'pointsBalance' of msg.sender is redeemed - claiming a partial balance is not supported for efficiency as it is generally more advantageous to claim as soon as possible
    /// @dev claimable mint amount updates linearly by block.number within each applicable year
    /// @param _mvoID: MVO ID of msg.sender
    function claim(string calldata _mvoID) external nonReentrant {
        if (msg.sender != idToMVO[_mvoID].stakingAddress)
            revert IDAndMsgSenderMismatch();
        // '_poolId' == 0 if msg.sender is not in a pool
        uint256 _poolId = idToMVO[_mvoID].memberPoolId;
        uint256 _pointsAccrued = pointsBalance[msg.sender];
        uint256 _index;
        uint256 _blockDiff = block.number - genesisBlock;

        // when five year incentive period is over, no tokens to claim
        if (_blockDiff > BLOCKS_PER_YEAR * 5) revert MintPeriodExpired();
        else if (_blockDiff <= BLOCKS_PER_YEAR) _index = 1;
        else if (
            _blockDiff > BLOCKS_PER_YEAR && _blockDiff <= BLOCKS_PER_YEAR * 2
        ) _index = 2;
        else if (
            _blockDiff > BLOCKS_PER_YEAR * 2 &&
            _blockDiff <= BLOCKS_PER_YEAR * 3
        ) _index = 3;
        else if (
            _blockDiff > BLOCKS_PER_YEAR * 3 &&
            _blockDiff <= BLOCKS_PER_YEAR * 4
        ) _index = 4;
        else if (
            _blockDiff > BLOCKS_PER_YEAR * 4 &&
            _blockDiff <= BLOCKS_PER_YEAR * 5
        ) _index = 5;

        // available mint amount = (tokens remaining to mint in applicable incentive year) * (blocks since start of year) / (blocks per year); equation below accounts for the year '_index'
        // available mint pot therefore updates every block but incentivises early claiming (as per-block amount is constant, assuming number of MVOs grows and points are more broadly distributed as the year progresses)
        // denominator will never be zero, as BLOCKS_PER_YEAR is constant and '_index' will revert above if == 0
        uint256 _availableToMint = (yearToTokensRemaining[_index] *
            _blockDiff) / (BLOCKS_PER_YEAR * _index);

        // tokens earned by msg.sender is calculated by (at the time of 'claim()' call) the ratio of points accrued:total points times the total available mint
        uint256 _earnedTokens = (_pointsAccrued * _availableToMint) /
            totalPoints;

        // subtract redeemed points from 'totalPoints' and reset 'pointsBalance[]' mapping for this msg.sender as all '_pointsAccrued' are redeemed
        totalPoints -= _pointsAccrued;
        delete pointsBalance[msg.sender];

        // subtract minted tokens from amount reserved as MVO incentive for applicable year; will not underflow as the '_earnedTokens' calculation must result in <= 'yearToTokensRemaining[_index]'
        yearToTokensRemaining[_index] -= _earnedTokens;

        // '_dues' == 0 if msg.sender is not in a pool, as _poolId == 0 and idToMVOPool[0].dues is intentionally not initialized to keep default value of 0
        uint256 _dues = (_earnedTokens * uint256(idToMVOPool[_poolId].dues)) /
            DECIMALS;

        // mint rewards
        iEnshroudToken.mint(msg.sender, _earnedTokens - _dues);
        if (_dues != 0)
            iEnshroudToken.mint(idToMVOPool[_poolId].poolAddress, _dues);

        emit PointsReduced(msg.sender, _pointsAccrued);
    }

    /// @notice for an MVO to stake their tokens; must first approve address(this) for '_amount'
    /// @dev updates 'totalStake' and 'stakingQuantum' MVO struct member for msg.sender
    /// @param _mvoID: MVO ID of msg.sender
    /// @param _amount: amount to be staked
    function stake(
        string calldata _mvoID,
        uint256 _amount
    ) external nonReentrant {
        if (msg.sender != idToMVO[_mvoID].stakingAddress)
            revert IDAndMsgSenderMismatch();

        totalStake += _amount;
        idToMVO[_mvoID].stakingQuantum += _amount;

        enshroudToken.safeTransferFrom(msg.sender, address(this), _amount);

        emit MVOStaking_Staked(msg.sender, _amount, block.number);
        emit MVOStakeUpdated(_mvoID, idToMVO[_mvoID].stakingQuantum);
    }

    /// @notice for an MVO to stake their tokens in this address using permit()
    /// @dev updates 'totalStake' and 'stakingQuantum' MVO struct member for msg.sender
    /// @param _mvoID: MVO ID of msg.sender
    /// @param _amount: amount to be staked
	/// @param _deadline: block limit by which permit must be used
    /// @param v - ECDSA sig param
    /// @param r - ECDSA sig param
    /// @param s - ECDSA sig param
    function stakeWithPermit(
        string calldata _mvoID,
        uint256 _amount,
		uint256 _deadline,
        uint8 v,
        bytes32 r,
        bytes32 s
    ) external nonReentrant {
        if (msg.sender != idToMVO[_mvoID].stakingAddress)
            revert IDAndMsgSenderMismatch();

        totalStake += _amount;
        idToMVO[_mvoID].stakingQuantum += _amount;

        iEnshroudToken.permit(
            msg.sender,
            address(this),
            _amount,
            _deadline,
            v,
            r,
            s
        );

        enshroudToken.safeTransferFrom(msg.sender, address(this), _amount);

        emit MVOStaking_Staked(msg.sender, _amount, block.number);
        emit MVOStakeUpdated(_mvoID, idToMVO[_mvoID].stakingQuantum);
    }

    /// @notice for the 'timelockManager' (by user calling 'withdrawTimelockToMVOStaking()' in TimelockManager contract) to stake $ENSHROUD tokens in this address on behalf of the timelock beneficiary ('_staker'), who is an MVO operator.
    /// @dev updates 'totalStake' and 'stakingQuantum' MVO struct member for '_staker'
	/// @dev one timelock per '_staker' at a time, though a further amount may be added to 'timelocks[_staker]' if the MVO owner consolidates multiple timelocks.
    /// nonReentrant modifier unnecessary here as it is already present in 'withdrawTimelockToMVOStaking()' in TimelockManager contract, which is the only permitted caller of this function
    /// @param _mvoID: MVO ID of '_staker'
    /// @param _amount: amount to be staked
    /// @param _staker: address of timelock beneficiary corresponding to '_mvoID' that will call the initiating function in 'timelockManager'
    /// @param _releaseStart: starting block number of timelock
    /// @param _releaseEnd: ending block number of timelock
    function stakeTimelock(
        string calldata _mvoID,
        uint256 _amount,
        address _staker,
        uint128 _releaseStart,
        uint128 _releaseEnd
    ) external {
        if (msg.sender != timelockManager) revert OnlyTimelockManager();
        if (_staker != idToMVO[_mvoID].stakingAddress)
            revert IDAndMsgSenderMismatch();

        totalStake += _amount;
        idToMVO[_mvoID].stakingQuantum += _amount;

		// check for existing Timelock for MVO's stakingAddress
		if (timelocks[_staker].remainingAmount != 0) {
			// increment existing Timelock, leaving start/end blocks the same
			timelocks[_staker].totalAmount += _amount;
			timelocks[_staker].remainingAmount += _amount;
        } else {
			// create new Timelock
			timelocks[_staker] = Timelock({
				totalAmount: _amount,
				remainingAmount: _amount,
				releaseStart: _releaseStart,
				releaseEnd: _releaseEnd
			});
		}

        enshroudToken.safeTransferFrom(msg.sender, address(this), _amount);

        emit MVOStaking_Staked(_staker, _amount, block.number);
        emit MVOStakeUpdated(_mvoID, idToMVO[_mvoID].stakingQuantum);
    }

    /// @notice for an MVO to withdraw its staked $ENSHROUD tokens
    /// @dev updates 'totalStake' and 'stakingQuantum' MVO struct member for msg.sender
    /// @dev stake can only be withdrawn if msg.sender's '_mvoID' is correct; if msg.sender's MVO struct is maliciously altered by an admin, will need to be corrected
    /// @param _mvoID: MVO ID of msg.sender
    /// @param _amount: amount to be withdrawn
    function withdraw(
        string calldata _mvoID,
        uint256 _amount
    ) external nonReentrant {
        if (msg.sender != idToMVO[_mvoID].stakingAddress)
            revert IDAndMsgSenderMismatch();
        if (idToMVO[_mvoID].stakingQuantum < _amount)
            revert AmountLargerThanStake();

        uint256 _remaining = timelocks[msg.sender].remainingAmount;
		if (_remaining != 0) {
        	// _unlockedAndWithdrawable = unlocked - withdrawn, which cannot be negative; will return 0 if no timelock
			uint256 _unlockedAndWithdrawable = _getUnlocked(msg.sender) -
				(timelocks[msg.sender].totalAmount - _remaining);
			// in addition to withdrawable from timelock, user can withdraw any
			// tokens which were added via stake() or stakeWithPermit()
			uint256 _neverLocked = idToMVO[_mvoID].stakingQuantum - _remaining;

			// if msg.sender has active timelock, revert if _amount is greater than the unlocked & not yet withdrawn amount + any separately staked unlocked
			if (_amount > _unlockedAndWithdrawable + _neverLocked)
				revert AmountLargerThanWithdrawable();

			// subtract _amount first from the struct's remainingAmount
			if (_unlockedAndWithdrawable >= _amount)
				timelocks[msg.sender].remainingAmount -= _amount;
			else
				timelocks[msg.sender].remainingAmount -= _unlockedAndWithdrawable;
				// (the rest will come from separately-staked unlocked tokens)
		}

        unchecked {
            // cannot underflow due to earlier condition check
            idToMVO[_mvoID].stakingQuantum -= _amount;
            // cannot underflow as 'idToMVO[_mvoID].stakingQuantum' cannot be greater than 'totalStake', and this is the only decrement of 'totalStake'
            totalStake -= _amount;
        }

        enshroudToken.safeTransfer(msg.sender, _amount);

        emit MVOStaking_Unstaked(msg.sender, _amount, block.number);
        emit MVOStakeUpdated(_mvoID, idToMVO[_mvoID].stakingQuantum);
    }

    /// @notice for an MVO to create an MVO pool -- msg.sender will be 'poolAddress'. Cannot be altered after creation; only option is to delete pool and start another with a new 'miningPoolId'
    /// @param _miningPoolId: unique ID of prospective MVO pool. Cannot be 0 -- this is the default value and indicates an MVO is not in a pool in its MVO struct
    /// @param _dues: amount of dues required to be a pool member, in 18 decimals (which also corresponds to wei). Must be < 5e17 (< 50%). 5e16 corresponds to 5%.
    /// @param _mvoID: MVO ID of msg.sender
	/// @dev to prevent dues dodging, user must claim any points first
    function createMVOPool(
        uint256 _miningPoolId,
        uint256 _dues,
        string calldata _mvoID
    ) external {
        if (
            _miningPoolId == 0 ||
            idToMVOPool[_miningPoolId].poolAddress != address(0)
        ) revert IDAlreadyInUse();
        // cannot have 50% or greater dues, this also prevents '_dues' from being larger than the max uint96 value
        if (_dues >= 5e17) revert DuesTooLarge();
        if (msg.sender != idToMVO[_mvoID].stakingAddress)
            revert IDAndMsgSenderMismatch();

        // ensure msg.sender is an active MVO with at least 'minStake' staked
        checkMVO(_mvoID);
		// ensure owner doesn't have any unclaimed points
        if (pointsBalance[msg.sender] > 0) revert UnclaimedPoints();

        // write new pool struct to stored mapping
        idToMVOPool[_miningPoolId] = MVOPool({
            poolAddress: payable(msg.sender),
            dues: uint96(_dues)
        });

        // update pool ID struct member for the pool creator
        idToMVO[_mvoID].memberPoolId = uint120(_miningPoolId);

        emit MVOPoolCreated(_miningPoolId, _dues, _mvoID);
    }

    /// @notice for an MVO pool creator to delete their pool
    /// @param _miningPoolId: msg.sender's MVO pool ID
    /// @param _mvoID: MVO ID of msg.sender
    function deleteMVOPool(
        uint256 _miningPoolId,
        string calldata _mvoID
    ) external {
        // ensure correct '_mvoID' is supplied and msg.sender is the poolAddress for '_miningPoolId'
        if (
            msg.sender != idToMVO[_mvoID].stakingAddress ||
            idToMVOPool[_miningPoolId].poolAddress != payable(msg.sender)
        ) revert IDAndMsgSenderMismatch();
        delete idToMVOPool[_miningPoolId];
        emit MVOPoolDeleted(_miningPoolId, _mvoID);
    }

    /// @notice for an MVO to join an existing MVO mining pool, change pool membership, or leave a pool
    /// @param _newMiningPoolId: pool ID which msg.sender desires to join; pass 0 to leave a pool without a replacement
	/// @dev to prevent dues dodging, user must claim any points first
    /// @param _mvoID: MVO ID of msg.sender
    function updateMVOPoolMembership(
        uint256 _newMiningPoolId,
        string calldata _mvoID
    ) external {
        if (msg.sender != idToMVO[_mvoID].stakingAddress)
            revert IDAndMsgSenderMismatch();
		// ensure owner doesn't have any unclaimed points
        if (pointsBalance[msg.sender] > 0) revert UnclaimedPoints();

		// ensure pool exists
        if (
            _newMiningPoolId != 0 &&
            idToMVOPool[_newMiningPoolId].poolAddress == address(0)
        ) revert PoolDoesNotExist();

        // ensure msg.sender is an active MVO with at least 'minStake' staked
        checkMVO(_mvoID);

        // store current poolId for event emission
        uint256 _currentPoolId = idToMVO[_mvoID].memberPoolId;

        // update msg.sender's MVO struct (via mapping from their MVO Id) with new mining pool id
        idToMVO[_mvoID].memberPoolId = uint120(_newMiningPoolId);

        emit PoolMembershipUpdated(_newMiningPoolId, _currentPoolId, _mvoID);
    }

    /// @notice update 'codeHash', designating a new hash of the off-chain codebase storage location reference or entire codebase, for user verification
    /// @dev admin should apprise users of how to verify the 'codeHash' -- the 'codeHash' is publicly accessible via getter as a public storage variable
    /// @param _newCodeHash: new bytes32 hash of the off-chain codebase or location
    function updateCodeHash(bytes32 _newCodeHash) external onlyAdmin {
        codeHash = _newCodeHash;
        emit CodeHashChanged(_newCodeHash);
    }

    /// @notice for a single admin to designate, update, or revoke an MVO's config struct using its ID
    /// @dev single admin used here for ease and reversibility of any malicious or mistaken update
    /// @param _mvoID: string ID for a given MVO
    /// @param _mvo: new or replacement MVO struct for the given ID
    function updateMvoStatus(
        string calldata _mvoID,
        MVO calldata _mvo
    ) external onlyAdmin {
        // if the pre-updated MVO struct corresponding to '_mvoID' has the default zero address as its 'stakingAddress', assume '_mvoID' is not yet stored in 'mvoIds' array and add it
        if (idToMVO[_mvoID].stakingAddress == address(0)) {
			mvoIds.push(_mvoID);
			// update mapping to store the index number in the 'mvoIds' array corresponding to this '_mvoID', then increment the 'mvoIndex' counter
			mvoIdsIndex[_mvoID] = mvoIndex;
			unchecked {
				++mvoIndex; // will not overflow on human timelines
			}
		}
        idToMVO[_mvoID] = _mvo;
        emit MVOStatusChanged(_mvoID, _mvo, block.chainid);
    }

    /// @notice update 'minStake', designating a new minimum stake amount of $ENSHROUD tokens for MVOs, after three admins have called
    /// @param _newMinStake: new minimum amount of $ENSHROUD tokens required for a MVO to stake
    function updateMinStake(uint256 _newMinStake) external onlyAdmin {
        bytes8 _caller = bytes8(uint64(uint160(msg.sender)));
        /// @dev we use 'address(uint160(_newMinStake))' as the key for the 'updateRequested' mapping to force every admin to set the same value
		/// @notice for the possible values of _newMinStake, the discarded highest 12 bytes will be 0
		address _minStakeKey = address(uint160(_newMinStake));
        if (_isRequestApproved(_minStakeKey, _caller, ApprovalTypes.MinStake)) {
            // only update 'minStake' address if three admins have requested, to thwart a single malicious admin
            minStake = _newMinStake;

            // reset after variable update
            delete updateRequested[_minStakeKey];
            emit MinimumStakeUpdated(_newMinStake);
        }
    }

    /// @notice update 'enshroudProtocol' a new protocol address, such as for a v2 upgrade, after three admins have called
    /// @param _newProtocol: new Enshroud Protocol contract address, changing 'enshroudProtocol'; must correctly implement public 'adminStatus[]' mapping
    function updateProtocol(address _newProtocol) external onlyAdmin {
        bytes8 _caller = bytes8(uint64(uint160(msg.sender)));
        if (_isRequestApproved(_newProtocol, _caller, ApprovalTypes.Protocol)) {
            // only add 'enshroudProtocols' address if three admins have requested, to thwart a single malicious admin
        	enshroudProtocols.push(_newProtocol);
			unchecked {
				++protoIndex; // will not overflow on human timelines
			}

            // reset after variable update
            delete updateRequested[_newProtocol];
            emit MVOStaking_ProtocolUpdated(_newProtocol);
        }
    }

	/// @notice set or update 'pointsBot' a new bot address authorized to import MVO points awarded on other chains (relevant only on mainnet contract deployment)
	/// @param _newBot: new bot authorized to import points from other chains
	function updatePointsBot(address _newBot) external onlyAdmin {
		bytes8 _caller = bytes8(uint64(uint160(msg.sender)));
		if (_isRequestApproved(_newBot, _caller, ApprovalTypes.PointsBot)) {
			// only set 'pointsBot' address if three admins have requested, to thwart a single malicious admin
			pointsBot = _newBot;

			// reset after variable update
			delete updateRequested[_newBot];
			emit PointsBotUpdated(_newBot);
		}
	}

    /// @notice check an MVO's signature against its ID and verify signature - will revert if not
    /// @param _mvoID: ID of MVO to access relevant struct containing MVO information
    /// @param _mvoSig: signature of MVO to check against its ID using the idToMVO mapping
    /// @param _obHash: keccak256 hash of the Operations Block
    function checkMVOSig(
        string calldata _mvoID,
        bytes calldata _mvoSig,
        bytes32 _obHash
    ) external view {
        (uint8 v, bytes32 r, bytes32 s) = _splitSignature(_mvoSig);
        address _signAddr = ecrecover(_obHash, v, r, s);
        if (idToMVO[_mvoID].signingAddress != _signAddr)
            revert InvalidSignature();
    }

    /// @notice check an MVO's struct and ensure it has at least 'minStake' tokens and is active
    /// @param _mvoID: ID of MVO to access relevant struct containing MVO information
    function checkMVO(string calldata _mvoID) public view {
        if (
            !idToMVO[_mvoID].active || idToMVO[_mvoID].stakingQuantum < minStake
        ) {
            revert NotActiveMVO();
        }
    }

    /// @notice for variable updates that require update requests from three unique admins
	/// @param _reqAddr: index to data being updated
	/// @param _caller: truncation of the admin address making the call
	/// @param _reqType: disambiguation for type of update call
    function _isRequestApproved(
        address _reqAddr,
        bytes8 _caller,
		ApprovalTypes _reqType
    ) internal returns (bool) {
        bytes8 _default;

        if (updateRequested[_reqAddr].requester1 == _default) {
            updateRequested[_reqAddr].requester1 = _caller;
			updateRequested[_reqAddr].requestType = _reqType;
		} else if (
            updateRequested[_reqAddr].requester1 != _default &&
            updateRequested[_reqAddr].requester2 == _default &&
            _caller != updateRequested[_reqAddr].requester1 &&
			_reqType == updateRequested[_reqAddr].requestType
        ) updateRequested[_reqAddr].requester2 = _caller;
        else if (
            updateRequested[_reqAddr].requester1 != _default &&
            updateRequested[_reqAddr].requester2 != _default &&
            _caller != updateRequested[_reqAddr].requester1 &&
            _caller != updateRequested[_reqAddr].requester2 &&
			_reqType == updateRequested[_reqAddr].requestType
        ) {
            return true;
        }
        return false;
    }

    /// @notice split signature for ecrecover function, see https://docs.soliditylang.org/en/latest/solidity-by-example.html#recovering-the-message-signer-in-solidity
	/// @param _sig: the standard signature to be split
    function _splitSignature(
        bytes memory _sig
    ) internal pure returns (uint8 v, bytes32 r, bytes32 s) {
        if (_sig.length != 65) revert IncorrectSigLength();

        assembly {
            // first 32 bytes, after the length prefix.
            r := mload(add(_sig, 32))
            // second 32 bytes.
            s := mload(add(_sig, 64))
            // final byte (first byte of the next 32 bytes).
            v := byte(0, mload(add(_sig, 96)))
        }

        return (v, r, s);
    }

    /// @notice returns 'unlocked', the amount of tokens that was unlocked for the recipient to date. Includes both withdrawn and non-withdrawn tokens.
    /// @dev same function as in Timelock.sol
    /// @param _recipient: address of the timelock recipient
	/// @return unlocked the quantity of tokens in a Timelock which are now unlockable
    function _getUnlocked(
        address _recipient
    ) private view returns (uint256 unlocked) {
        Timelock storage timelock = timelocks[_recipient];
        uint256 _releaseStart = uint256(timelock.releaseStart);
        uint256 _releaseEnd = uint256(timelock.releaseEnd);
        if (block.number <= _releaseStart) {
            unlocked = 0;
        } else if (block.number >= _releaseEnd) {
            unlocked = timelock.totalAmount;
        } else {
            // unlocked = (total amount * passed time since start) / total time of timelock
            // denominator will not be 0 due to checks in TimelockManager contract
            unlocked = (timelock.totalAmount * (block.number - _releaseStart)) /
                (_releaseEnd - _releaseStart);
        }
    }
}
