// 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 uses nontransferable ERC1155 NFTs with encrypted metadata to enable private asset deposits, transfers, and withdrawals. Encryption provided by Metadata Validator Oracles.

// 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";

/*///////////////////////////////////////////////////////////////
                            INTERFACES
//////////////////////////////////////////////////////////////*/

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

    // DAI permit
    function permit(
        address holder,
        address spender,
        uint256 nonce,
        uint256 expiry,
        bool allowed,
        uint8 v,
        bytes32 r,
        bytes32 s
    ) external;

	function balanceOf(address owner) external view returns (uint256);
}

interface IWETH {
    function deposit() external payable;

    function withdraw(uint256) external;
}

interface IMVOStaking_EnshroudProtocol {
    function awardPoints(string[] calldata mvoIDs) external;

    function checkMVO(string calldata mvoID) external;

    function checkMVOSig(
        string calldata mvoID,
        bytes calldata mvoSig,
        bytes32 obHash
    ) external view;
}

/// @notice Modern, minimalist, and gas-optimized **non-transferable** ERC1155 adaptation.
/// @author Modified from Solbase's ERC1155 (https://github.com/Sol-DAO/solbase/blob/main/src/tokens/ERC1155/ERC1155.sol), removing transferability
/** @dev the following were removed from the Sol-DAO implementation for non-transferability or because they are unused: ApprovalForAll() event, Unauthorized() custom error,
 *** 'isApprovedForAll[]' mapping, 'setApprovalForAll()', 'safeTransferFrom()', 'safeBatchTransferFrom()', 'balanceOfBatch()'; '_batchMint()'; 'batchBurn()'; 'TransferBatch' event
 *** therefore this is intentionally not fully compliant with the (usually transferable) ERC1155 standard */
abstract contract ERC1155 {
    /// -----------------------------------------------------------------------
    /// Events
    /// -----------------------------------------------------------------------

    event TransferSingle(
        address indexed operator,
        address indexed from,
        address indexed to,
        uint256 id,
        uint256 amount
    );

    event URI(string value, uint256 indexed id);

    /// -----------------------------------------------------------------------
    /// Custom Errors
    /// -----------------------------------------------------------------------

    error UnsafeRecipient();

    error InvalidRecipient();

    error LengthMismatch();

    /// -----------------------------------------------------------------------
    /// ERC1155 Storage
    /// -----------------------------------------------------------------------

    mapping(address => mapping(uint256 => uint256)) public balanceOf;

    /// -----------------------------------------------------------------------
    /// Metadata Logic
    /// -----------------------------------------------------------------------

    function uri(
        string calldata metadata
    ) public view virtual returns (string memory);

    /// -----------------------------------------------------------------------
    /// ERC165 Logic
    /// -----------------------------------------------------------------------

    function supportsInterface(
        bytes4 interfaceId
    ) public view virtual returns (bool) {
        return
            interfaceId == 0x01ffc9a7 || // ERC165 Interface ID for ERC165
            interfaceId == 0xd9b67a26 || // ERC165 Interface ID for ERC1155
            interfaceId == 0x0e89341c; // ERC165 Interface ID for ERC1155MetadataURI
    }

    /// -----------------------------------------------------------------------
    /// Internal Mint/Burn Logic
    /// -----------------------------------------------------------------------

    /// @param to: owning address
    /// @param id: unique ID of eNFT
	/// @param amount: always 1 (quantity is specified within the eNFT)
	/// @param data: encrypted eNFT metadata
    function _mint(
        address to,
        uint256 id,
        uint256 amount,
        bytes memory data
    ) internal virtual {
        balanceOf[to][id] += amount;

        // mint event for MVO layer to detect and act upon
        emit TransferSingle(msg.sender, address(0), to, id, amount);

        if (to.code.length != 0) {
            if (
                ERC1155TokenReceiver(to).onERC1155Received(
                    msg.sender,
                    address(0),
                    id,
                    amount,
                    data
                ) != ERC1155TokenReceiver.onERC1155Received.selector
            ) revert UnsafeRecipient();
        } else if (to == address(0)) revert InvalidRecipient();
    }

    /// @param from: owning address burning the eNFT
    /// @param id: unique ID of eNFT
    /// @param amount: always 1 (results in 0 balance)
    function _burn(address from, uint256 id, uint256 amount) internal virtual {
        balanceOf[from][id] -= amount;

        // burn event for MVO layer to detect and act upon
        emit TransferSingle(msg.sender, from, address(0), id, amount);
    }
}

/// @author Solbase (https://github.com/Sol-DAO/solbase/blob/main/src/tokens/ERC1155/ERC1155.sol)
/// @author Modified from Solmate (https://github.com/transmissions11/solmate/blob/main/src/tokens/ERC1155/ERC1155.sol)
/// @dev while the ERC1155-like tokens in this protocol are non-transferable, the below are included for minted eNFT receivers
abstract contract ERC1155TokenReceiver {
    function onERC1155Received(
        address,
        address,
        uint256,
        uint256,
        bytes calldata
    ) external virtual returns (bytes4) {
        return ERC1155TokenReceiver.onERC1155Received.selector;
    }

    function onERC1155BatchReceived(
        address,
        address,
        uint256[] calldata,
        uint256[] calldata,
        bytes calldata
    ) external virtual returns (bytes4) {
        return ERC1155TokenReceiver.onERC1155BatchReceived.selector;
    }
}

/// @notice Enshroud protocol contract
contract EnshroudProtocol is ERC1155, ERC1155TokenReceiver, ReentrancyGuard {
    using SafeTransferLib for address;

	/// types of eNFT operations
    enum Flag {
        MintFromDeposit,
        Spend,
        Withdraw
    }

    /// @notice struct of necessary Operations Block information to properly cause eNFT mint(s)
    /// @param recipients: array of addresses to receive minted eNFTs
    /// @param detailsHashes: supplied by MVO layer, hash of address owner, uint256 id, address asset, uint256 amount, and (optional) bytes rand for each eNFT
    /// @param inputIds: array of token IDs of eNFTs to be redeemed and burned
    /// @param outputIds: array of token IDs of eNFTs to be minted -- unique and assigned randomly by the lead MVO
    /// @param metadata: array of metadata strings for each corresponding eNFT ID, passed to uri function
    /// @param amount: amount of tokens to which the Operations Block corresponds
    /// @param obHash: keccak256 hash of the Operations Block
    /// @dev each array must be in the same corresponding order
    struct OpsInfo {
        address[] recipients;
        bytes32[] detailsHashes;
        uint256[] inputIds;
        uint256[] outputIds;
        string[] metadata;
        uint256 amount;
        bytes32 obHash;
    }

    /// @notice 'mvoSigs' is an array of MVO signatures provided by the MVO server (first is lead MVO); 'mvoIDs' are the IDs of the MVOs, the first of which is the lead MVO which receives the deposit message
    struct MVOinfo {
        bytes[] mvoSigs;
        string[] mvoIDs;
    }

	/// types of approvals handled by _isRequestApproved()
	enum ApprovalTypes { MVOStaking, GreyList, Auditor, Treasury, DaoPool }

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

    /*///////////////////////////////////////////////////////////////
                            VARIABLES
    //////////////////////////////////////////////////////////////*/

    /// @dev to enable dai-pattern permit (ETH mainnet, possibly elsewhere)
	address internal immutable daiToken;

    // max limit for dynamic arrays
    uint256 internal constant ARRAY_LIMIT = 20;
    uint256 internal constant FEE_LIMIT = 5e17;
    uint256 internal constant WAD = 1e18;

    address internal immutable weth;
    uint256 public immutable confirmationBlocks;

    address payable public daoPool;
    address payable public treasury;

    string public baseURI;

    uint256 public requiredSigs;

    /// @notice initial treasury fee is 5%. To remove, DAO voting app (once added as admin) and two other admins can vote to change treasury address to DAO pool
    uint256 public treasuryFee = 5e16;

    IMVOStaking_EnshroudProtocol internal iMVOStaking;
    IWETH internal immutable iWeth;

    mapping(address => Requests) internal updateRequested;
    mapping(address => bool) public adminStatus;
    /// @notice maps a user to a mapping of tokenContract to amount deposited in this contract
    mapping(address => mapping(address => uint256)) public assetBalances;
    mapping(address => uint256) public assetDepositFee;
    mapping(address => uint256) public assetWithdrawFee;
    /// @notice maps auditor Id to their address. Zero address means no such auditor exists.
    mapping(string => address) public auditors;
    /// @notice maps an id number to a bool of whether such id has been used for an eNFT
    mapping(uint256 => bool) internal nftIdUsed;
    /// @notice maps an eNFT's id number to its detailsHash
    mapping(uint256 => bytes32) internal detailsHash;
    /// @notice maps an eNFT's id number to error string; contains the AudId of the Auditor node who so marked it, and may contain a reason code.
    mapping(uint256 => string) public idToAuditorGreylist;
    /// @notice sets dwell time for an eNFT before redemption by mapping eNFT id to (mint block.number + 'confirmationBlocks')
    mapping(uint256 => uint256) public enftUnlockTime;

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

    /// certain events excluded for contract size considerations, such as unlikely/rare variable updates (iMVOStaking, treasury, etc.)

    event AdminUpdated(address admin, bool status);

    event AuditorUpdated(address auditor, string audId);

    event DepositERC20(
        address indexed sender,
        address tokenContract,
        uint256 amount,
        uint256 chainId
    );

    event DepositETH(
        address indexed sender,
        uint256 weiAmount,
        uint256 chainId
    );

    event GreyListAdd(string audId, uint256[] ids);

    event GreyListDeletion(uint256 id);

    event WithdrawERC20(
        address indexed withdrawer,
        address tokenContract,
        uint256 amount,
        uint256 chainId
    );

    event WithdrawETH(
        address indexed withdrawer,
        uint256 weiAmount,
        uint256 chainId
    );

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

    /// @notice requires "_addr" to have a true 'adminStatus' mapping
    /// @dev implemented in this manner to allow external contracts to pass an address (i.e. MVOStaking) rather than assuming msg.sender
    modifier onlyAdmin(address _addr) {
        if (!adminStatus[_addr]) revert Enshroud_NotAdmin();
        _;
    }

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

    error Enshroud_AmountMismatch();
    error Enshroud_ArrayTooLong();
    error Enshroud_ENFTStillLocked();
    error Enshroud_FeeTooLarge();
    error Enshroud_Greylisted(uint256 id); // ID number of greylisted eNFT
    error Enshroud_HashDoesNotMatch();
    error Enshroud_InvalidID();
    error Enshroud_MVOSignaturesMissing();
    error Enshroud_NoENFT();
    error Enshroud_NotAuditor();
    error Enshroud_NotAdmin();
    error Enshroud_OnlyWETH();
    error Enshroud_ZeroMsgValue();
	error Enshroud_OnlyDAOPool();
    error MulWadFailed();

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

    /// @param _treasury: treasury address to receive 5% of deposit and withdraw fees for project operations.
    /// @param _weth: token contract address for WETH. Immutable. 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2 for WETH9 on ETH mainnet.
    /// @param _dai: token contract address for DAI. Immutable. 0x6B175474E89094C44Da98b954EedeAC495271d0F for DAI on ETH mainnet.
    /// @param _initialAdmins: array of initial addresses with admin status. Should be at least 3 total initial admins, in order to initialise the MVOStaking address
    /// @param _depositFee: initial per-asset percentage fee from user upon successful shielding, in 18 decimals (which also corresponds to wei). Must be < 5e17.
    /// @param _withdrawFee: initial per-asset percentage fee from eNFT burner upon successful eNFT burning/withdrawal, in 18 decimals (which also corresponds to wei). Must be < 5e17.
    /// @param _requiredSigs: immutable required number of MVO signatures for a valid Operations Block
    /// @param _confBlocks: set immutable variable 'confirmationBlocks', which provides the minimum "dwell time" for an eNFT before redemption
    /// @param _baseUri: baseURI for eNFTs, which does not operate as a normal "baseURI" but as a notification to check the event log of this protocol contract for the eNFT's id. This constructor-passed baseURI also removes the need for a uriFetcher
    /// @dev note math of fees. Currently a 5e16 fee corresponds to 5%, as all fees are calculated with a denominator of 1e18.
    constructor(
        address payable _treasury,
        address _weth,
        address _dai,
        address[] memory _initialAdmins,
        uint256 _depositFee,
        uint256 _withdrawFee,
        uint256 _requiredSigs,
        uint256 _confBlocks,
        string memory _baseUri
    ) payable {
        if (_depositFee >= FEE_LIMIT || _withdrawFee >= FEE_LIMIT)
            revert Enshroud_FeeTooLarge(); // cannot have 50% or greater for either fee
        treasury = _treasury;
        weth = _weth;
		daiToken = _dai;
        confirmationBlocks = _confBlocks;
        baseURI = _baseUri;
        assetDepositFee[address(0)] = _depositFee;
        assetWithdrawFee[address(0)] = _withdrawFee;
        requiredSigs = _requiredSigs;
        adminStatus[address(this)] = true;
        adminStatus[_treasury] = true;
        iWeth = IWETH(_weth);
        for (uint256 i = 0; i < _initialAdmins.length; ) {
            adminStatus[_initialAdmins[i]] = true;
            unchecked {
                ++i;
            }
        }
    }

    /// @dev address(this) accepts ETH only from the WETH contract; 'depositEth()' must be used for ETH deposits, as ETH sent directly to address(this) will not cause an eNFT mint (and thus its ability to be eventually withdrawn)
    receive() external payable {
        if (msg.sender != weth) revert Enshroud_OnlyWETH();
    }

    /// @notice initialise (or replace) the MVOStaking contract address, by three different admins
    /// @dev must be set after mvoStaking contract is deployed, and before the protocol is used
    /// @param _mvoStaking: contract address for MVO storage and logic
    function setMVOStakingAddress(
        address _mvoStaking
    ) external onlyAdmin(msg.sender) {
        bytes8 _caller = bytes8(uint64(uint160(msg.sender)));
        if (_isRequestApproved(_mvoStaking, _caller, ApprovalTypes.MVOStaking, "")) {
            // only set or update MVOStaking address and interface upon third different admin's call, to thwart a single malicious admin
            adminStatus[_mvoStaking] = true;
            iMVOStaking = IMVOStaking_EnshroudProtocol(_mvoStaking);

            // reset after variable update
            delete updateRequested[_mvoStaking];
        }
    }

    /// @notice deposit ETH/applicable native token. Msg.value is amount of deposit, which is in wei (18 decimals).
    /// @dev deposited ETH is immediately converted to WETH to address(this) (except for deposit fee sent to treasury and daoPool, which remains in ETH)
    /// @param _depositor: address of depositor (msg.sender if calling this directly, or can be a specified user if an external contract is calling this function, such as daoPool)
    function depositEth(address _depositor) external payable nonReentrant {
        if (msg.value == 0) revert Enshroud_ZeroMsgValue();

        uint256 _depositFeeAmount = _mulWadDown(
            msg.value,
            assetDepositFee[address(0)]
        );
        uint256 _treasuryFeeAmount = _mulWadDown(
            _depositFeeAmount,
            treasuryFee
        );
        uint256 _netDeposit;

        unchecked {
            // will not underflow due to preceding _depositFeeAmount division operation (no fee can be >= 5e17)
            _netDeposit = msg.value - _depositFeeAmount;
            // add to deposit's 'assetBalances'
            assetBalances[_depositor][weth] += _netDeposit;
        }

        // deposit msg.value - deposit fees to the WETH contract, corresponding WETH sent to address(this) -- number of WETH tokens mirrors amount of wei
        iWeth.deposit{value: _netDeposit}();

        // send respective deposit fees to treasury and daoPool
        unchecked {
            SafeTransferLib.safeTransferETH(treasury, _treasuryFeeAmount);
            // will not underflow due to preceding _treasuryFeeAmount division operation (no fee can be >= 5e17)
            SafeTransferLib.safeTransferETH(
                daoPool,
                _depositFeeAmount - _treasuryFeeAmount
            );
        }
        emit DepositETH(_depositor, _netDeposit, block.chainid);
    }

    /// @notice deposit ERC20-compatible tokens after '_depositor' approved address(this) for '_amount' of tokens in the '_tokenContract'
    /// @dev _amount of tokens must first be approved for transferFrom by address(this). While there is no token validity check here, the MVO layer will check when later generating operations block
    /// @param _depositor: address of depositor (msg.sender if calling this directly, or can be a specified user if an external contract is calling this function, such as daoPool)
    /// @param _tokenContract: ERC20-compatible token contract address
    /// @param _amount: amount of tokens deposited, 18 decimals (hardcoded into mulWadDown in _depositTokens())
    function depositTokens(
        address _depositor,
        address _tokenContract,
        uint256 _amount
    ) external nonReentrant {
        // deposit fee is either the global amount (if no specific assetDepositFee set) or specific amount for the _tokenContract
        if (assetDepositFee[_tokenContract] == 0) {
            _depositTokens(
                _depositor,
                _tokenContract,
                _amount,
                assetDepositFee[address(0)]
            );
        } else {
            _depositTokens(
                _depositor,
                _tokenContract,
                _amount,
                assetDepositFee[_tokenContract]
            );
        }
    }

    /// @notice deposit an ERC20-compatible asset which supports gasless ERC20Permit via EIP2612 to approve this address for transferFrom msg.sender
    /// @param _tokenContract: ERC20-compatible token contract address
    /// @param _depositor: address of depositor, to whom the EIP712 signature corresponds (often, msg.sender)
    /// @param _amount: amount of tokens deposited, 18 decimals (hardcoded into mulWadDown in _depositTokens())
    /// @param _nonce: user's nonce on the erc20 contract, for replay protection
    /// @param _deadline: deadline for permit approval usage
    /// @param v: ECDSA sig param
    /// @param r: ECDSA sig param
    /// @param s: ECDSA sig param
    function depositTokensWithPermit(
        address _tokenContract,
        address _depositor,
        uint256 _amount,
        uint256 _nonce,
        uint256 _deadline,
        uint8 v,
        bytes32 r,
        bytes32 s
    ) external nonReentrant {
        // will revert if the permit is unsuccessful or _tokenContract doesn't support it
        if (_tokenContract == daiToken) {
            IERC20Permit_EnshroudProtocol(_tokenContract).permit(
                _depositor,
                address(this),
                _nonce,
                _deadline,
                true,
                v,
                r,
                s
            );
        } else {
            IERC20Permit_EnshroudProtocol(_tokenContract).permit(
                _depositor,
                address(this),
                _amount,
                _deadline,
                v,
                r,
                s
            );
        }

        // deposit fee is either the global amount (if no specific assetDepositFee set) or specific amount for the _tokenContract
        uint256 _depositFee = assetDepositFee[_tokenContract];
        if (_depositFee == 0) {
            _depositTokens(
                msg.sender,
                _tokenContract,
                _amount,
                assetDepositFee[address(0)]
            );
        } else {
            _depositTokens(msg.sender, _tokenContract, _amount, _depositFee);
        }
    }

    /// @notice called by an address which has already deposited assets into this contract to mintENFTs based on some amount of those assets up to msg.sender's 'assetBalances'
    /// @param _opsInfo: OpsInfo struct containing all of the necessary Operations Block information
    /// @param _mvoInfo: MVOinfo struct containing array of signatures and IDs by MVOs that supplied encrypted metadata
    /// @param _tokenContract: ERC20-compatible token contract address; pass the 'weth' address to mint an eNFT for ETH deposits
    /// @dev because msg.sender is used to check the 'assetBalances' mapping (to prevent spoofing on a _depositor variable), other contracts using this function will have to use delegatecall
    function mintENFTsFromDeposit(
        OpsInfo calldata _opsInfo,
        MVOinfo calldata _mvoInfo,
        address _tokenContract
    ) external {
        if (assetBalances[msg.sender][_tokenContract] < _opsInfo.amount)
            revert Enshroud_AmountMismatch();
        unchecked {
            assetBalances[msg.sender][_tokenContract] -= _opsInfo.amount;
        }
        // validate inputs
        _validateInputs(_opsInfo, _mvoInfo, Flag.MintFromDeposit);
        // mint eNFTs
        _mintENFTs(_opsInfo, _opsInfo.outputIds.length, Flag.MintFromDeposit);

        // on success, award points to relevant MVOs
        iMVOStaking.awardPoints(_mvoInfo.mvoIDs);
    }

    /// @notice for a holder of eNFT(s) to spend their eNFT(s) (after receiving new OperationsBlock) for new eNFT(s) to be subsequently minted, without publicly compromising the corresponding asset or amount in the subsequently minted eNFTs
    /// @dev only necessary to check msg.sender's balanceOf to burn the spent eNFT -- the new eNFTs will be produced by calling _mintNewENFTs(). Call only after new OperationsBlock received for subsequent eNFT mints.
    /// @param _enftInfo: OpsInfo struct containing all of the necessary OperationsBlock information to mint new eNFTs
    /// @param _mvoInfo: MVOinfo struct containing array of signatures and IDs by MVOs that supplied encrypted metadata to mint new eNFTs
    function spendENFTs(
        OpsInfo calldata _enftInfo,
        MVOinfo calldata _mvoInfo
    ) external nonReentrant {
        // check inputs
        _validateInputs(_enftInfo, _mvoInfo, Flag.Spend);

        // the length of each array in '_enftInfo' is checked in '_mintENFTs()' rather than here for efficiency
        for (uint256 x = 0; x < _enftInfo.inputIds.length; ) {
            uint256 _spendID = _enftInfo.inputIds[x];
            if (enftUnlockTime[_spendID] > block.number)
                revert Enshroud_ENFTStillLocked();
            if (bytes(idToAuditorGreylist[_spendID]).length != 0)
                revert Enshroud_Greylisted(_spendID); // ensure each _spendID isn't greylisted by checking if the mapped string has a length of 0 when cast to bytes

            /// @dev entire transaction will revert if msg.sender does not have corresponding eNFT for a _spendID
            if (balanceOf[msg.sender][_spendID] == 0) revert Enshroud_NoENFT();

            unchecked {
                ++x;
            }

            // delete mapping and burn eNFT
            delete detailsHash[_spendID];
            delete enftUnlockTime[_spendID];
            _burn(msg.sender, _spendID, 1);
        }

        // mint new eNFTs
        _mintENFTs(_enftInfo, _enftInfo.recipients.length, Flag.Spend);

        // on success, award points to relevant MVOs
        iMVOStaking.awardPoints(_mvoInfo.mvoIDs);
    }

    /// @notice for eNFT holder to redeem their eNFT(s) for asset withdrawal via their decrypted eNFT metadata
    /// @param _opsInfo: OpsInfo struct containing all of the necessary Operations Block information
    /// @param _mvoInfo: MVOinfo struct containing array of signatures and IDs by MVOs that supplied encrypted metadata to mint new eNFTs
    /// @param _amounts: array of amounts of tokens/asset requested for withdrawal, in order by _id
    /// @param _rand: array of optional randomly generated bytes, represented in base64, generated client-side.
    /// @param _tokenContract: token contract address requested for withdraw. Provide zero address for ETH/native token withdrawal.
    /// @dev caller will be able to access decrypted eNFT metadata off chain in order to supply info to this function
    function redeemENFTsAndWithdraw(
        OpsInfo calldata _opsInfo,
        MVOinfo calldata _mvoInfo,
        uint256[] calldata _amounts,
        bytes16[] calldata _rand,
        address _tokenContract
    ) external nonReentrant {
        // counter for amount actually being redeemed from eNFTs
        uint256 _amt;

        uint256 _redeems = _opsInfo.inputIds.length;

        if (_redeems != _rand.length || _redeems != _amounts.length)
            revert LengthMismatch();

        // check inputs
        _validateInputs(_opsInfo, _mvoInfo, Flag.Withdraw);

        for (uint256 x = 0; x < _redeems; ) {
            uint256 _xId = _opsInfo.inputIds[x];
            if (balanceOf[msg.sender][_xId] == 0) revert Enshroud_NoENFT();
            if (enftUnlockTime[_xId] > block.number)
                revert Enshroud_ENFTStillLocked();
            if (bytes(idToAuditorGreylist[_xId]).length != 0)
                revert Enshroud_Greylisted(_xId); // ensure _id[x] isn't greylisted by checking if the mapped string has a length of 0 when cast to bytes

            // compare hash of each redemption information against detailsHash mapping created when eNFT was minted
            if (
                detailsHash[_xId] !=
                keccak256(
                    abi.encode(
                        msg.sender,
                        _xId,
                        _tokenContract,
                        _amounts[x],
                        _rand[x]
                    )
                )
            ) revert Enshroud_HashDoesNotMatch();

            // burn eNFT and delete mappings
            delete detailsHash[_xId];
            delete enftUnlockTime[_xId];
            _burn(msg.sender, _xId, 1);

            unchecked {
                _amt += _amounts[x]; // detailsHash condition check prevents invalidly high _amount value and thus overflow
                ++x;
            }
        }

        // revert if the total amount of tokens/asset (across all eNFTs being redeemed) requesting for withdraw is higher than the actual total amount to which the eNFTs correspond
        uint256 _amountTotal = _opsInfo.amount;
        if (_amt < _amountTotal) revert Enshroud_AmountMismatch();

        // if the user wants to withdraw only part of the total redemption amount, mint an eNFT to msg.sender (per the _opsInfo struct) for the difference by calling '_mintENFTs()'
        /// @dev all relevant members (recipients, detailsHashes, outputIds, metadata) of the _opsInfo struct will have a length of 1, the number of mints is 1, and the Flag value is Withdraw
        if (_amt > _amountTotal) _mintENFTs(_opsInfo, 1, Flag.Withdraw);

        _withdraw(_amountTotal, msg.sender, _tokenContract);

        // on success, award points to relevant MVOs
        iMVOStaking.awardPoints(_mvoInfo.mvoIDs);
    }

    /// @notice for an auditor to change eNFT(s) greylist status
    /// @param _audId: ID of the auditor calling this function
    /// @param _ids: array of eNFT IDs for which the auditor is altering greylist status
    /// @param _reasons: array of strings corresponding to the reason for each respective '_ids' greylisting
    function auditorGreyList(
        string calldata _audId,
        uint256[] calldata _ids,
        string[] calldata _reasons
    ) external {
        if (msg.sender != auditors[_audId]) revert Enshroud_NotAuditor();
        if (_ids.length > ARRAY_LIMIT) revert Enshroud_ArrayTooLong();
        if (_ids.length != _reasons.length) revert LengthMismatch();

        for (uint256 i = 0; i < _ids.length; ) {
            uint256 _id = _ids[i];
            // store reason only if no value is set
            if (bytes(idToAuditorGreylist[_id]).length == 0)
            	idToAuditorGreylist[_id] = _reasons[i];

            // limited to ARRAY_LIMIT so will not overflow
            unchecked {
                ++i;
            }
        }

        // emit event with all of the greylisted IDs
        emit GreyListAdd(_audId, _ids);
    }

    /// @notice upon call by three admins, remove an eNFT's greylist status
    /// @param _id: ID of greylisted eNFT, to be removed from greylist
    function removeGreylistStatus(uint256 _id) external onlyAdmin(msg.sender) {
        // truncate and cast '_id' to type address for updateRequests mapping; unlikely to collide with other addresses
        address _addr = address(uint160(_id));
        bytes8 _caller = bytes8(uint64(uint160(msg.sender)));
        if (_isRequestApproved(_addr, _caller, ApprovalTypes.GreyList, "")) {
            // only remove greylist status upon third different admin's call, to thwart a single malicious admin
            delete idToAuditorGreylist[_id];

            // reset after greylist update
            delete updateRequested[_addr];
            emit GreyListDeletion(_id);
        }
    }

    /// @notice for three admins to update an auditor's status
    /// @param _addr: address of auditor, or zero address to remove an auditor
    /// @param _audId: ID of the auditor being updated by admin
    function updateAuditor(
        address _addr,
        string calldata _audId
    ) external onlyAdmin(msg.sender) {
		require(bytes(_audId).length > 0);
        bytes8 _caller = bytes8(uint64(uint160(msg.sender)));
        if (_isRequestApproved(_addr, _caller, ApprovalTypes.Auditor, _audId)) {
            // only grant auditor update upon third different admin's call, to thwart a single malicious admin
            auditors[_audId] = _addr;

            // reset after auditor update
            delete updateRequested[_addr];
            emit AuditorUpdated(_addr, _audId);
        }
    }

    /// @notice admin may designate new fee award amounts globally (pass address(0) as _tokenAddr), or token-specifically. All fees should correspond to 18 decimals regardless of underlying token contact decimal amount
    /** @dev if a 0 fee for a certain asset is desired, set that fee to 1 without decimals, as checks for an asset-specific fee in other functions
     *** will use the global fees if the mapping returns a 0 for that asset. If one of the two fees should remain unchanged, pass the existing value */
    /// @param _tokenAddr: address of token whose fees are being updated. Leave as zero address for global changes (native token always has global fees)
    /// @param _newDepositFee: new per-asset percentage fee from user upon successful shielding, in 18 decimals. Must be < 5e17.
    /// @param _newWithdrawFee: new per-asset percentage fee from eNFT burner upon successful eNFT burning/withdrawal, in 18 decimals. Must be < 5e17.
    function updateFees(
        address _tokenAddr,
        uint256 _newDepositFee,
        uint256 _newWithdrawFee
    ) external onlyAdmin(msg.sender) {
        if (_newDepositFee >= FEE_LIMIT || _newWithdrawFee >= FEE_LIMIT)
            revert Enshroud_FeeTooLarge();
        assetDepositFee[_tokenAddr] = _newDepositFee;
        assetWithdrawFee[_tokenAddr] = _newWithdrawFee;
    }

    /// @notice allows an admin to designate another admin or revoke an active admin; can be used in event of malicious admin/exploit, to remove initial admins, add DAO voting addresses, etc.
    /// @param _addr: address whose admin status is being updated
    /// @param _status: true for admin status grant, false for revocation
    function updateAdmin(
        address _addr,
        bool _status
    ) external onlyAdmin(msg.sender) {
        adminStatus[_addr] = _status;
        emit AdminUpdated(_addr, _status);
    }

    /// @notice upon agreement of >= 3 admins, designate a new treasury contract address, including to DAO governance. adminStatus mapping also updated accordingly.
    /** @dev in case of treasury exploit or subsequently deployed treasury contract. Note this also uses the updateRequests mapping,
     ** as a new treasury will also receive admin status, and no issue (though unnecessary) if the new treasury address is passed as a new admin first */
    /// @param _newTreasury: new treasury address
    function updateTreasury(
        address payable _newTreasury
    ) external onlyAdmin(msg.sender) {
        bytes8 _caller = bytes8(uint64(uint160(msg.sender)));
        if (_isRequestApproved(_newTreasury, _caller, ApprovalTypes.Treasury, "")) {
            // only grant treasury update upon third different admin's call, to thwart a single malicious admin
            adminStatus[_newTreasury] = true;
            // remove admin status of prior treasury address and update treasury variable
            delete adminStatus[treasury];
            treasury = _newTreasury;

            // reset after treasury variable update
            delete updateRequested[_newTreasury];
        }
    }

    /// @notice upon agreement of >= 3 admins, designate a new Dao Pool contract address, including to DAO governance. adminStatus mapping also updated accordingly.
    /** @dev in case of exploit or subsequently deployed DAO contract. Note this also uses the updateRequests mapping,
     ** as a new daoPool will also receive admin status, and no issue (though unnecessary) if the new daoPool address is passed as a new admin first */
    /// @param _newDaoPool: new DAO Pool address
    function updateDaoPool(
        address payable _newDaoPool
    ) external onlyAdmin(msg.sender) {
        bytes8 _caller = bytes8(uint64(uint160(msg.sender)));
        if (_isRequestApproved(_newDaoPool, _caller, ApprovalTypes.DaoPool, ""))
		{
            // only grant daoPool update upon third different admin's call, to thwart a single malicious admin
            adminStatus[_newDaoPool] = true;

            // remove admin status of prior daoPool address and update daoPool variable
            delete adminStatus[daoPool];
            daoPool = _newDaoPool;

            // reset after daoPool variable update
            delete updateRequested[_newDaoPool];
        }
    }

    /// @param _metadata: eNFT token metadata
    /// @return uri for the supplied '_metadata'; see notes on baseURI in the constructor natSpec
    function uri(
        string calldata _metadata
    ) public view override returns (string memory) {
        return string.concat(baseURI, _metadata);
    }

    /// @notice internal function which causes all necessary token and fee transfers upon valid 'depositTokens()' or 'depositTokensWithPermit()' call
    /// @param _depositor: depositing address (msg.sender unless called by daoPool)
    /// @param _tokenContract: token contract address, ERC20 compliance (including permit, if applicable) checks completed in preceding function call
    /// @param _amount: amount of tokens deposited
    /// @param _fee: deposit fee for _tokenContract
    function _depositTokens(
        address _depositor,
        address _tokenContract,
        uint256 _amount,
        uint256 _fee
    ) internal {
		uint256 _netAmount;

		if (msg.sender == _depositor) {
			// account for possibility this ERC20 is fee-on-transfer or deflationary
			uint256 _ourBalanceBefore = IERC20Permit_EnshroudProtocol(_tokenContract).balanceOf(address(this));
        	/// @dev safeTransferFrom will revert on failure (for example if lacking proper permit or approve() from '_depositor')
        	_tokenContract.safeTransferFrom(_depositor, address(this), _amount);
			uint256 _ourBalanceAfter = IERC20Permit_EnshroudProtocol(_tokenContract).balanceOf(address(this));
			require(_ourBalanceAfter >= _ourBalanceBefore);
			_netAmount = _ourBalanceAfter - _ourBalanceBefore;
		} else if (msg.sender == daoPool) {
			/// @dev occurs when called via DAOPool.claimTokenYieldAsENFTs()
			/// in this case DAOPool has already safeTransfer'd the tokens to this contract and accounted for any fee-on-transfer issues
			_netAmount = _amount;
		} else {
			/// @dev third-party deposits are not otherwise permitted
			revert Enshroud_OnlyDAOPool();
		}

		// calculate net deposit after fees
        uint256 _tokenDepositFeeAmount = _mulWadDown(_netAmount, _fee);
        uint256 _netDeposit = _netAmount - _tokenDepositFeeAmount;
        if (_netDeposit == 0) revert Enshroud_ZeroMsgValue();

        // transfer tokenDepositFeeAmount for token to treasury and daoPool and retain net deposit for eventual withdrawal
        uint256 _treasuryFeeAmount = _mulWadDown(
            _tokenDepositFeeAmount,
            treasuryFee
        );

        unchecked {
            // add to _depositor's 'assetBalances' for '_tokenContract'
            assetBalances[_depositor][_tokenContract] += _netDeposit;

			// will not underflow due to preceding _treasuryFeeAmount division operation (no fee can be >= 5e17)
			_tokenContract.safeTransfer(treasury, _treasuryFeeAmount);

            // will not underflow due to _tokenDepositFeeAmount division operation above
            _tokenContract.safeTransfer(
                daoPool,
                _tokenDepositFeeAmount - _treasuryFeeAmount
            );

            emit DepositERC20(
                _depositor,
                _tokenContract,
                _netDeposit,
                block.chainid
            );
        }
    }

    /// @notice mints eNFTs to designated recipients
    /** @dev only callable by functions in this contract which already have reentrancy protection ('mintENFTsFromDeposit() and 'spendENFTs()');
     ** internal mint logic of the ERC1155 abstract contract checks whether the recipient is a contract address (and thus calls onERC1155Received())
     ** see https://github.com/Sol-DAO/solbase/blob/2572d3a1bfb45d4082501ba1a550a2d093a4cca5/src/tokens/ERC1155/ERC1155.sol#L168 */
    /// @param _enftInfo: OpsInfo struct containing all of the necessary Operations Block information
    /// @param _mints: number of eNFTs to be minted for use in for loop (saves a storage read, and array lengths are checked)
    /// @param _flag: Flag enum corresponding to MintFromDeposit, Spend, or Withdraw context (0, 1, or 2)
    function _mintENFTs(
        OpsInfo calldata _enftInfo,
        uint256 _mints,
        Flag _flag
    ) internal {
        if (
            _mints != _enftInfo.outputIds.length ||
            _mints != _enftInfo.detailsHashes.length ||
            _mints != _enftInfo.recipients.length ||
            _mints != _enftInfo.metadata.length
        ) revert LengthMismatch();

        // mint each eNFT with its corresponding info
        for (uint256 i = 0; i < _mints; ) {
            uint256 _id = _enftInfo.outputIds[i];
            if (bytes(idToAuditorGreylist[_id]).length != 0)
                revert Enshroud_Greylisted(_id); // ensure each _id isn't greylisted by checking if the mapped string has a length of 0 when cast to bytes
            if (nftIdUsed[_id]) revert Enshroud_InvalidID(); // ensure each _id isn't already in use
            nftIdUsed[_id] = true;
            detailsHash[_id] = _enftInfo.detailsHashes[i];
            unchecked {
                enftUnlockTime[_id] = block.number + confirmationBlocks;
            }

            address _recipient = _enftInfo.recipients[i];
            if (_flag == Flag.Withdraw) {
                // on a Withdraw operation, the mintee must be msg.sender
                _recipient = msg.sender;
            }
            // mint eNFT, amount is always 1
            _mint(_recipient, _id, 1, bytes(_enftInfo.metadata[i]));

            emit URI(uri(_enftInfo.metadata[i]), _id);
            unchecked {
                ++i; // will not overflow, limited by '_mints'
            }
        }
    }

    /// @notice withdrawal functionality for redeemed eNFTs
    /// @dev reentrancy-protected by 'redeemENFTsAndWithdraw()'
    /// @param _amountTotal: total amount of tokens/asset (across all eNFTs being redeemed) requesting for withdraw in proper decimals
    /// @param _recipient: address which receives the withdrawn assets
    /// @param _tokenContract: token contract address, ERC20 compliance (including permit, if applicable) checks completed in preceding function call
    function _withdraw(
        uint256 _amountTotal,
        address _recipient,
        address _tokenContract
    ) internal {
        address _daoPool = daoPool;
        uint256 _tokenWithdrawFeeAmount;
        // fee is either the global amount (if no specific assetWithdrawFee set) or specific amount for the _tokenContract
        if (assetWithdrawFee[_tokenContract] == 0) {
            _tokenWithdrawFeeAmount = _mulWadDown(
                _amountTotal,
                assetWithdrawFee[address(0)]
            );
        } else {
            _tokenWithdrawFeeAmount = _mulWadDown(
                _amountTotal,
                assetWithdrawFee[_tokenContract]
            );
        }

        uint256 _treasuryFeeAmount = _mulWadDown(
            _tokenWithdrawFeeAmount,
            treasuryFee
        );

        uint256 _netWithdrawal;
        unchecked {
            // will not underflow due to preceding _tokenWithdrawFeeAmount division operation (no fee can be >= 5e17)
            _netWithdrawal = _amountTotal - _tokenWithdrawFeeAmount;
        }

        // pay withdrawal fees and transfer remainder to _recipient
        if (_tokenContract == address(0) || _tokenContract == weth) {
            // withdraw requests for either ETH/native token or WETH will receive ETH/native token, _amountTotal is properly 18 decimals whether wei or WETH tokens
            // all ETH deposits are converted to WETH, so WETH must be withdrawn whether ETH or WETH is requested to withdraw
            iWeth.withdraw(_amountTotal); // call withdraw with amount of WETH to send ETH to this address

            // safeTransfer ETH fee to treasury and daoPool, and rest to _recipient
            SafeTransferLib.safeTransferETH(treasury, _treasuryFeeAmount);
            // will not underflow due to preceding _treasuryFeeAmount division operation (no fee can be >= 5e17)
            unchecked {
                SafeTransferLib.safeTransferETH(
                    _daoPool,
                    _tokenWithdrawFeeAmount - _treasuryFeeAmount
                );
            }
            SafeTransferLib.safeTransferETH(_recipient, _netWithdrawal);

            emit WithdrawETH(_recipient, _netWithdrawal, block.chainid);
        } else {
            // safeTransfer fees for token to treasury and daoPool, and rest to _recipient
            // ERC20 compliance (including permit, if applicable) checks completed in detailsHash condition check
            _tokenContract.safeTransfer(treasury, _treasuryFeeAmount);
            // will not underflow due to preceding _treasuryFeeAmount division operation (no fee can be >= 5e17)
            unchecked {
                _tokenContract.safeTransfer(
                    _daoPool,
                    _tokenWithdrawFeeAmount - _treasuryFeeAmount
                );
            }
            _tokenContract.safeTransfer(_recipient, _netWithdrawal);
            emit WithdrawERC20(
                _recipient,
                _tokenContract,
                _netWithdrawal,
                block.chainid
            );
        }
    }

    /// @notice reverts on any violation of input constraints
    /// @param _enftInfo: OpsInfo struct containing all of the necessary Operations Block information
    /// @param _mvoInfo: MVOinfo struct containing array of signatures and IDs of MVOs that supplied encrypted metadata. One struct for entire operation.
    /// @param _flag: Flag enum corresponding to MintFromDeposit, Spend, or Withdraw context (0, 1, or 2)
    function _validateInputs(
        OpsInfo calldata _enftInfo,
        MVOinfo calldata _mvoInfo,
        Flag _flag
    ) internal {
        // check required number of MVO signatures were submitted
        uint256 _sigLen = _mvoInfo.mvoSigs.length;
        uint256 _outputIdsLength = _enftInfo.outputIds.length;
        if (_sigLen < requiredSigs) revert Enshroud_MVOSignaturesMissing();
        if (
            _enftInfo.inputIds.length > ARRAY_LIMIT ||
            _outputIdsLength > ARRAY_LIMIT
        ) revert Enshroud_ArrayTooLong();

        bytes32 _argsHash;

        // check size limits and build hash from passed arguments
        if (_flag == Flag.MintFromDeposit) {
            _argsHash = keccak256(
                abi.encode(
                    _enftInfo.amount,
                    _enftInfo.outputIds,
                    _enftInfo.detailsHashes
                )
            );
        } else if (_flag == Flag.Spend) {
            _argsHash = keccak256(
                abi.encode(
                    _enftInfo.inputIds,
                    _enftInfo.outputIds,
                    _enftInfo.detailsHashes
                )
            );
        } else if (_flag == Flag.Withdraw) {
            if (_outputIdsLength > 1) revert Enshroud_ArrayTooLong();
            if (_outputIdsLength == 1) {
                // must include "change" eNFT minted for overage
                _argsHash = keccak256(
                    abi.encode(
                        _enftInfo.inputIds,
                        _enftInfo.amount,
                        _enftInfo.outputIds,
                        _enftInfo.detailsHashes
                    )
                );
            } else {
                _argsHash = keccak256(
                    abi.encode(_enftInfo.inputIds, _enftInfo.amount)
                );
            }
        }

        // this computed hash must match the value passed from the MVO layer
        if (_argsHash != _enftInfo.obHash) revert Enshroud_HashDoesNotMatch();

        // Now validate all MVO signatures on the passed obHash value.
        // Ensure all signatures in '_mvoInfo' are valid, or revert - no need to check for a length mismatch, thanks to the earlier check that there are
        // at least 'requiredSigs' number of mvoSigs and if there are too few mvoIDs, the 'checkMVOSig' call will revert
        for (uint256 x = 0; x < _sigLen; ) {
            string memory _mvoID = _mvoInfo.mvoIDs[x];
            // ensure the MVO is still active and has a high enough tokenQuantum, will revert otherwise
            iMVOStaking.checkMVO(_mvoID);

            // now check the MVO's sig
            iMVOStaking.checkMVOSig(
                _mvoID,
                _mvoInfo.mvoSigs[x],
                _enftInfo.obHash
            );
            unchecked {
                ++x; // limited by number of sigs, will not overflow
            }
        }
    }

    /// @notice for variable updates that require update requests from three unique admins
	/// @param _reqAddr: address value looked up in updateRequested mapping
    /// @param _caller: admin invoking one of the update*() methods (truncated)
	/// @param _reqType: which type of approval (identifies update*() caller)
	/// @param _audId: optional auditors[] index for ApprovalTypes.Auditor case
    function _isRequestApproved(
        address _reqAddr,
        bytes8 _caller,
		ApprovalTypes _reqType,
		string memory _audId
    ) internal returns (bool) {
        bytes8 _default;
		bytes memory _aId = bytes(_audId);

        if (updateRequested[_reqAddr].requester1 == _default) {
            updateRequested[_reqAddr].requester1 = _caller;
			updateRequested[_reqAddr].requestType = _reqType;
			if (_reqType == ApprovalTypes.Auditor) {
				// must also record associated auditor ID
				updateRequested[_reqAddr].audID = _aId;
			}
		} else if (
            updateRequested[_reqAddr].requester1 != _default &&
            updateRequested[_reqAddr].requester2 == _default &&
            _caller != updateRequested[_reqAddr].requester1 &&
			_reqType == updateRequested[_reqAddr].requestType &&
			(_reqType != ApprovalTypes.Auditor
			 || keccak256(updateRequested[_reqAddr].audID) == keccak256(_aId))
        ) 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 &&
			(_reqType != ApprovalTypes.Auditor
			 || keccak256(updateRequested[_reqAddr].audID) == keccak256(_aId))
        ) {
            return true;
        }
        return false;
    }

    /// @dev Solbase mulDiv operation with WAD as denominator. Equivalent to `(x * y) / WAD` rounded down. source: https://github.com/Sol-DAO/solbase/blob/main/src/utils/FixedPointMathLib.sol
    function _mulWadDown(
        uint256 x,
        uint256 y
    ) internal pure returns (uint256 z) {
        assembly {
            // Equivalent to `require(y == 0 || x <= type(uint256).max / y)`.
            if mul(y, gt(x, div(not(0), y))) {
                // Store the function selector of `MulWadFailed()`.
                mstore(0x00, 0xbac65e5b)
                // Revert with (offset, size).
                revert(0x1c, 0x04)
            }
            z := div(mul(x, y), WAD)
        }
    }
}
