diff --git a/foundry.toml b/foundry.toml index bed77f705..ac537950a 100644 --- a/foundry.toml +++ b/foundry.toml @@ -10,6 +10,11 @@ optimizer = true optimizer_runs = 750 via_ir = false +additional_compiler_profiles = [ { name = "large-contracts", optimizer = true, optimizer_runs = 100 } ] +compilation_restrictions = [ + { paths = "src/modules/logicModule/LM_PC_FundingPot_v1.sol", optimizer = true, optimizer_runs = 100 }, +] + [fuzz] runs = 256 diff --git a/src/modules/logicModule/LM_PC_FundingPot_v1.sol b/src/modules/logicModule/LM_PC_FundingPot_v1.sol new file mode 100644 index 000000000..862ffdfa8 --- /dev/null +++ b/src/modules/logicModule/LM_PC_FundingPot_v1.sol @@ -0,0 +1,1321 @@ +// SPDX-License-Identifier: LGPL-3.0-only +pragma solidity 0.8.23; + +// Internal +import {IOrchestrator_v1} from + "src/orchestrator/interfaces/IOrchestrator_v1.sol"; +import {ILM_PC_FundingPot_v1} from + "src/modules/logicModule/interfaces/ILM_PC_FundingPot_v1.sol"; +import { + IERC20PaymentClientBase_v2, + IPaymentProcessor_v2 +} from "@lm/abstracts/ERC20PaymentClientBase_v2.sol"; +import { + ERC20PaymentClientBase_v2, + Module_v1 +} from "@lm/abstracts/ERC20PaymentClientBase_v2.sol"; +import {IBondingCurveBase_v1} from + "@fm/bondingCurve/interfaces/IBondingCurveBase_v1.sol"; +import {IFundingManager_v1} from "@fm/IFundingManager_v1.sol"; + +// External +import {IERC20} from "@oz/token/ERC20/IERC20.sol"; +import {IERC721} from "@oz/token/ERC721/IERC721.sol"; +import {SafeERC20} from "@oz/token/ERC20/utils/SafeERC20.sol"; +import {ERC165Upgradeable} from + "@oz-up/utils/introspection/ERC165Upgradeable.sol"; + +import {MerkleProof} from "@oz/utils/cryptography/MerkleProof.sol"; +import {EnumerableSet} from "@oz/utils/structs/EnumerableSet.sol"; + +/** + * @title Inverter Funding Pot Logic Module. + * + * @notice A sophisticated funding management system that enables configurable fundraising rounds + * with multiple access criteria, contribution limits, and automated distribution. + * This module provides a flexible framework for managing token sales and fundraising + * campaigns with granular access control and contribution management. + * + * @dev Implements a comprehensive funding system with the following features: + * - Round Configuration. + * Supports configurable start/end times, caps, and post-round hooks. + * + * - Access Control. + * Multiple access criteria types: + * - Allowlist-based access. + * - NFT ownership verification. + * - Merkle proof validation. + * - Open access. + * + * - Contribution Management. + * - Personal contribution caps. + * - Round-level caps. + * - Global accumulative caps across rounds. + * - Configurable contribution time windows. + * + * - Automated Processing. + * - Automatic round closure based on time or cap. + * - Post-round hook execution. + * - Payment order creation for contributors. + * + * @custom:security-contact security@inverter.network + * In case of any concerns or findings, please refer to + * our Security Policy at security.inverter.network or + * email us directly! + * + * @custom:version v1.0.0. + * + * @custom:inverter-standard-version v0.1.0. + * + * @author 33Audits. + */ +contract LM_PC_FundingPot_v1 is + ILM_PC_FundingPot_v1, + ERC20PaymentClientBase_v2 +{ + // ------------------------------------------------------------------------- + // Libraries + + using SafeERC20 for IERC20; + + // ------------------------------------------------------------------------- + // ERC165 + + /// @inheritdoc ERC165Upgradeable + function supportsInterface(bytes4 interfaceId_) + public + view + virtual + override(ERC20PaymentClientBase_v2) + returns (bool) + { + return interfaceId_ == type(ILM_PC_FundingPot_v1).interfaceId + || super.supportsInterface(interfaceId_); + } + + // -------------------------------------------------------------------------- + // Constants + + /// @notice The role that allows creating funding rounds. + bytes32 public constant FUNDING_POT_ADMIN_ROLE = "FUNDING_POT_ADMIN"; + + /// @notice The payment processor flag for the start timestamp. + uint8 internal constant FLAG_START = 1; + + /// @notice The payment processor flag for the cliff timestamp. + uint8 internal constant FLAG_CLIFF = 2; + + /// @notice The payment processor flag for the end timestamp. + uint8 internal constant FLAG_END = 3; + + /// @notice The maximum valid access criteria ID. + uint8 internal constant MAX_ACCESS_CRITERIA_TYPE = 4; + + // ------------------------------------------------------------------------- + // State + + /// @notice The current round count. + uint32 public roundCount; + + /// @notice Stores all funding rounds by their unique ID. + mapping(uint32 => Round) private rounds; + + /// @notice Stores all access criteria privilages by their unique ID. + mapping( + uint32 roundId + => mapping(uint8 accessCriteriaId_ => AccessCriteriaPrivileges) + ) private roundIdToAccessCriteriaIdToPrivileges; + + /// @notice Maps round IDs to user addresses to contribution amounts. + mapping(uint32 => mapping(address => uint)) public + roundIdToUserToContribution; + + /// @notice Maps round IDs to total contributions. + mapping(uint32 => uint) public roundIdToTotalContributions; + + /// @notice Maps round IDs to closed status. + mapping(uint32 => bool) public roundIdToClosedStatus; + + /// @notice Maps round IDs to bonding curve tokens bought. + mapping(uint32 => uint) private roundTokensBought; + + /// @notice Maps round IDs to contributors recipients. + mapping(uint32 => EnumerableSet.AddressSet) private contributorsByRound; + + /// @notice Maps round IDs to user addresses to contribution amounts by access criteria. + mapping(uint32 => mapping(address => mapping(uint8 => uint))) private + roundIdTouserContributionsByAccessCriteria; + + /// @notice Add a mapping to track the next unprocessed index for each round. + mapping(uint32 => uint) private roundIdToNextUnprocessedIndex; + + /// @notice The next available access criteria ID for each round + mapping(uint32 => uint8) private roundIdToNextAccessCriteriaId; + + /// @notice The minimum round ID (inclusive, >= 1) to consider for accumulation calculations. + /// @dev Defaults to 1. If a target round's mode allows accumulation, + /// only previous rounds with roundId >= globalAccumulationStartRoundId will be included. + uint32 public globalAccumulationStartRoundId; + + /// @notice Maps user addresses to a mapping of round IDs to a mapping of access criteria IDs to whether their unspent cap has been used + mapping(address => mapping(uint32 => mapping(uint8 => bool))) public + usedUnspentCaps; + + /// @notice Storage gap for future upgrades. + uint[47] private __gap; + + // ------------------------------------------------------------------------- + // Modifiers + + // ------------------------------------------------------------------------- + // Initialization + + /// @notice The module's initializer function. + /// @dev CAN be overridden by downstream contract. + /// @dev MUST call `__Module_init()`. + /// @param orchestrator_ The orchestrator contract. + /// @param metadata_ The metadata of the module. + function init( + IOrchestrator_v1 orchestrator_, + Metadata memory metadata_, + bytes memory + ) external override(Module_v1) initializer { + __Module_init(orchestrator_, metadata_); + // Set the flags for the PaymentOrders (this module uses 3 flags). + bytes32 flags; + flags |= bytes32(1 << FLAG_START); + flags |= bytes32(1 << FLAG_CLIFF); + flags |= bytes32(1 << FLAG_END); + + __ERC20PaymentClientBase_v2_init(flags); + // Explicitly initialize the global start round ID + globalAccumulationStartRoundId = 1; + } + + // ------------------------------------------------------------------------- + // Public - Getters + + /// @inheritdoc ILM_PC_FundingPot_v1 + function getRoundGenericParameters(uint32 roundId_) + external + view + returns ( + uint roundStart, + uint roundEnd, + uint roundCap, + address hookContract, + bytes memory hookFunction, + bool autoClosure, + AccumulationMode accumulationMode + ) + { + Round storage round = rounds[roundId_]; + return ( + round.roundStart, + round.roundEnd, + round.roundCap, + round.hookContract, + round.hookFunction, + round.autoClosure, + round.accumulationMode + ); + } + + /// @inheritdoc ILM_PC_FundingPot_v1 + function getRoundAccessCriteria(uint32 roundId_, uint8 accessCriteriaId_) + external + view + returns ( + bool isRoundOpen_, + address nftContract_, + bytes32 merkleRoot_, + bool isList_ + ) + { + AccessCriteria storage accessCriteria = + rounds[roundId_].accessCriterias[accessCriteriaId_]; + ILM_PC_FundingPot_v1.AccessCriteriaType acType = + accessCriteria.accessCriteriaType; + + bool isRoundOpen = + (acType == ILM_PC_FundingPot_v1.AccessCriteriaType.OPEN); + bool isList = ( + acType == ILM_PC_FundingPot_v1.AccessCriteriaType.OPEN + || acType == ILM_PC_FundingPot_v1.AccessCriteriaType.LIST + ); + + return ( + isRoundOpen, + accessCriteria.nftContract, + accessCriteria.merkleRoot, + isList + ); + } + + /// @inheritdoc ILM_PC_FundingPot_v1 + function getRoundAccessCriteriaPrivileges( + uint32 roundId_, + uint8 accessCriteriaId_ + ) + external + view + returns ( + uint personalCap_, + bool overrideContributionSpan_, + uint start_, + uint cliff_, + uint end_ + ) + { + Round storage round = rounds[roundId_]; + AccessCriteria storage accessCriteria = + round.accessCriterias[accessCriteriaId_]; + + if (accessCriteria.accessCriteriaType == AccessCriteriaType.UNSET) { + return (0, false, 0, 0, 0); + } + + // Store the privileges in a local variable to reduce stack usage. + AccessCriteriaPrivileges storage privileges = + roundIdToAccessCriteriaIdToPrivileges[roundId_][accessCriteriaId_]; + + return ( + privileges.personalCap, + privileges.overrideContributionSpan, + privileges.start, + privileges.cliff, + privileges.end + ); + } + + // ------------------------------------------------------------------------- + // Public - Mutating + + /// @inheritdoc ILM_PC_FundingPot_v1 + function createRound( + uint roundStart_, + uint roundEnd_, + uint roundCap_, + address hookContract_, + bytes memory hookFunction_, + bool autoClosure_, + AccumulationMode accumulationMode_ + ) external onlyModuleRole(FUNDING_POT_ADMIN_ROLE) returns (uint32) { + unchecked { + roundCount++; + } + + uint32 roundId = roundCount; + + Round storage round = rounds[roundId]; + _setAndValidateRoundParameters( + round, + roundStart_, + roundEnd_, + roundCap_, + hookContract_, + hookFunction_, + autoClosure_, + accumulationMode_ + ); + + emit RoundCreated( + roundId, + roundStart_, + roundEnd_, + roundCap_, + hookContract_, + hookFunction_, + autoClosure_, + accumulationMode_ + ); + + return uint32(roundId); + } + + /// @inheritdoc ILM_PC_FundingPot_v1 + function editRound( + uint32 roundId_, + uint roundStart_, + uint roundEnd_, + uint roundCap_, + address hookContract_, + bytes memory hookFunction_, + bool autoClosure_, + AccumulationMode accumulationMode_ + ) external onlyModuleRole(FUNDING_POT_ADMIN_ROLE) { + Round storage round = rounds[roundId_]; + + _validateEditRoundParameters(round); + + _setAndValidateRoundParameters( + round, + roundStart_, + roundEnd_, + roundCap_, + hookContract_, + hookFunction_, + autoClosure_, + accumulationMode_ + ); + + emit RoundEdited( + roundId_, + roundStart_, + roundEnd_, + roundCap_, + hookContract_, + hookFunction_, + autoClosure_, + accumulationMode_ + ); + } + + /// @inheritdoc ILM_PC_FundingPot_v1 + function setAccessCriteria( + uint32 roundId_, + uint8 accessCriteriaType_, + uint8 accessCriteriaId_, // Optional: 0 for new, non-zero for edit + address nftContract_, + bytes32 merkleRoot_, + address[] calldata allowedAddresses_, + address[] calldata removedAddresses_ + ) external onlyModuleRole(FUNDING_POT_ADMIN_ROLE) { + Round storage round = rounds[roundId_]; + + if (accessCriteriaType_ > MAX_ACCESS_CRITERIA_TYPE) { + revert Module__LM_PC_FundingPot__InvalidAccessCriteriaType(); + } + + _validateEditRoundParameters(round); + + uint8 criteriaId; + bool isEdit = false; + + // If accessCriteriaId_ is 0, create a new access criteria + // Otherwise, edit the existing one + if (accessCriteriaId_ == 0) { + unchecked { + criteriaId = ++roundIdToNextAccessCriteriaId[roundId_]; + } + } else { + criteriaId = accessCriteriaId_; + isEdit = true; + + if ( + round.accessCriterias[criteriaId].accessCriteriaType + == AccessCriteriaType.UNSET + ) { + revert Module__LM_PC_FundingPot__InvalidAccessCriteriaType(); + } + } + + // Validate required data based on access criteria type + AccessCriteriaType accessCriteriaType = + AccessCriteriaType(accessCriteriaType_); + if (accessCriteriaType == AccessCriteriaType.NFT) { + if (nftContract_ == address(0)) { + revert + Module__LM_PC_FundingPot__MissingRequiredAccessCriteriaData(); + } + } else if (accessCriteriaType == AccessCriteriaType.MERKLE) { + if (merkleRoot_ == bytes32(0)) { + revert + Module__LM_PC_FundingPot__MissingRequiredAccessCriteriaData(); + } + } else if (accessCriteriaType == AccessCriteriaType.LIST) { + if (allowedAddresses_.length == 0) { + revert + Module__LM_PC_FundingPot__MissingRequiredAccessCriteriaData(); + } + } + + // Clear all existing data to prevent stale data + round.accessCriterias[criteriaId].nftContract = address(0); + round.accessCriterias[criteriaId].merkleRoot = bytes32(0); + // @note: When changing allowlists, call removeAllowlistedAddresses first to clear previous entries + // Set the access criteria type + round.accessCriterias[criteriaId].accessCriteriaType = + accessCriteriaType; + + // Set only the relevant data based on the access criteria type + if (accessCriteriaType == AccessCriteriaType.NFT) { + round.accessCriterias[criteriaId].nftContract = nftContract_; + } else if (accessCriteriaType == AccessCriteriaType.MERKLE) { + round.accessCriterias[criteriaId].merkleRoot = merkleRoot_; + } else if (accessCriteriaType == AccessCriteriaType.LIST) { + // Remove the addresses from the allowed list if any + if (removedAddresses_.length > 0) { + for (uint i = 0; i < removedAddresses_.length; i++) { + round.accessCriterias[criteriaId].allowedAddresses[removedAddresses_[i]] + = false; + } + } + // For LIST type, update the allowed addresses + for (uint i = 0; i < allowedAddresses_.length; i++) { + round.accessCriterias[criteriaId].allowedAddresses[allowedAddresses_[i]] + = true; + } + } + + emit AccessUpdated(isEdit, roundId_, criteriaId); + } + + // Update removeAllowlistedAddresses to match the new approach + /// @inheritdoc ILM_PC_FundingPot_v1 + function removeAllowlistedAddresses( + uint32 roundId_, + uint8 accessCriteriaId_, + address[] calldata addressesToRemove_ + ) external onlyModuleRole(FUNDING_POT_ADMIN_ROLE) { + Round storage round = rounds[roundId_]; + + // Verify the access criteria exists + if ( + round.accessCriterias[accessCriteriaId_].accessCriteriaType + == AccessCriteriaType.UNSET + ) { + revert Module__LM_PC_FundingPot__InvalidAccessCriteriaType(); + } + + _validateEditRoundParameters(round); + + for (uint i = 0; i < addressesToRemove_.length; i++) { + round.accessCriterias[accessCriteriaId_].allowedAddresses[addressesToRemove_[i]] + = false; + } + + emit AllowlistedAddressesRemoved(); + } + + /// @inheritdoc ILM_PC_FundingPot_v1 + function setAccessCriteriaPrivileges( + uint32 roundId_, + uint8 accessCriteriaId_, + uint personalCap_, + bool overrideContributionSpan_, + uint start_, + uint cliff_, + uint end_ + ) external onlyModuleRole(FUNDING_POT_ADMIN_ROLE) { + Round storage round = rounds[roundId_]; + + _validateEditRoundParameters(round); + + if (!_validTimes(start_, cliff_, end_)) { + revert Module__LM_PC_FundingPot__InvalidInput(); + } + + AccessCriteriaPrivileges storage accessCriteriaPrivileges = + roundIdToAccessCriteriaIdToPrivileges[roundId_][accessCriteriaId_]; + + accessCriteriaPrivileges.personalCap = personalCap_; + accessCriteriaPrivileges.overrideContributionSpan = + overrideContributionSpan_; + accessCriteriaPrivileges.start = start_; + accessCriteriaPrivileges.cliff = cliff_; + accessCriteriaPrivileges.end = end_; + + emit AccessCriteriaPrivilegesSet( + roundId_, + accessCriteriaId_, + personalCap_, + overrideContributionSpan_, + start_, + cliff_, + end_ + ); + } + + /// @inheritdoc ILM_PC_FundingPot_v1 + function contributeToRoundFor( + address user_, + uint32 roundId_, + uint amount_, + uint8 accessCriteriaId_, + bytes32[] memory merkleProof_, + UnspentPersonalRoundCap[] calldata unspentPersonalRoundCaps_ + ) external { + // If using unspent caps, only the owner can use them + if (unspentPersonalRoundCaps_.length > 0 && _msgSender() != user_) { + revert Module__LM_PC_FundingPot__OnlyOwnerCanUseUnspentCaps(); + } + + uint unspentPersonalCap = _calculateUnspentPersonalCap( + user_, roundId_, unspentPersonalRoundCaps_ + ); + + _contributeToRoundFor( + user_, + roundId_, + amount_, + accessCriteriaId_, + merkleProof_, + unspentPersonalCap + ); + } + + /// @inheritdoc ILM_PC_FundingPot_v1 + function closeRound(uint32 roundId_) + external + onlyModuleRole(FUNDING_POT_ADMIN_ROLE) + { + Round storage round = rounds[roundId_]; + + if (round.roundEnd == 0 && round.roundCap == 0) { + revert Module__LM_PC_FundingPot__RoundNotCreated(); + } + + if (roundIdToClosedStatus[roundId_]) { + revert Module__LM_PC_FundingPot__RoundHasEnded(); + } + + bool readyToClose = _checkRoundClosureConditions(roundId_); + if (readyToClose) { + _closeRound(roundId_); + + _buyBondingCurveToken(roundId_); + + // Payment orders will be created separately via createPaymentOrdersForContributorsBatch + } else { + revert Module__LM_PC_FundingPot__ClosureConditionsNotMet(); + } + } + + /// @inheritdoc ILM_PC_FundingPot_v1 + function createPaymentOrdersForContributorsBatch( + uint32 roundId_, + uint batchSize_ + ) external { + Round storage round = rounds[roundId_]; + + // Check if round exists + if (round.roundEnd == 0 && round.roundCap == 0) { + revert Module__LM_PC_FundingPot__RoundNotCreated(); + } + + // Check if round is closed + if (!roundIdToClosedStatus[roundId_]) { + revert Module__LM_PC_FundingPot__RoundNotClosed(); + } + + address[] memory contributors = + EnumerableSet.values(contributorsByRound[roundId_]); + uint contributorCount = contributors.length; + + // If autoClosure is false, only admin can process contributors + if (!round.autoClosure) { + _checkRoleModifier( + __Module_orchestrator.authorizer().generateRoleId( + address(this), FUNDING_POT_ADMIN_ROLE + ), + _msgSender() + ); + } + + uint startIndex = roundIdToNextUnprocessedIndex[roundId_]; + _createPaymentOrdersForContributors(roundId_, startIndex, batchSize_); + + // Update the next unprocessed index + uint endIndex = startIndex + batchSize_; + if (endIndex > contributorCount) { + endIndex = contributorCount; + } + + roundIdToNextUnprocessedIndex[roundId_] = endIndex; + } + + /// @inheritdoc ILM_PC_FundingPot_v1 + function setGlobalAccumulationStart(uint32 startRoundId_) + external + onlyModuleRole(FUNDING_POT_ADMIN_ROLE) + { + if (startRoundId_ == 0) { + revert Module__LM_PC_FundingPot__StartRoundCannotBeZero(); + } + if (startRoundId_ > roundCount) { + revert Module__LM_PC_FundingPot__StartRoundGreaterThanRoundCount(); + } + + globalAccumulationStartRoundId = startRoundId_; + + emit GlobalAccumulationStartSet(startRoundId_); + } + + // ------------------------------------------------------------------------- + // Internal + + /// @notice Calculates the unspent personal capacity from previous rounds. + /// @param user_ The user address to calculate unspent capacity for. + /// @param roundId_ The current round ID. + /// @param unspentPersonalRoundCaps_ Array of previous rounds and access criteria to calculate unused capacity from. + /// @return unspentPersonalCap The amount of unspent personal capacity that can be used. + function _calculateUnspentPersonalCap( + address user_, + uint32 roundId_, + UnspentPersonalRoundCap[] calldata unspentPersonalRoundCaps_ + ) internal returns (uint unspentPersonalCap) { + uint totalAggregatedPersonalCap = 0; + uint totalSpentInPastRounds = 0; + + for (uint i = 0; i < unspentPersonalRoundCaps_.length; i++) { + UnspentPersonalRoundCap memory roundCapInfo = + unspentPersonalRoundCaps_[i]; + uint32 currentProcessingRoundId = roundCapInfo.roundId; + + // Skip if this round is before the global accumulation start round + if (currentProcessingRoundId < globalAccumulationStartRoundId) { + continue; + } + + // Skip if round is current or future round + if (currentProcessingRoundId >= roundId_) { + revert + Module__LM_PC_FundingPot__UnspentCapsMustBeFromPreviousRounds(); + } + + // Skip if cap was already used + if ( + usedUnspentCaps[user_][currentProcessingRoundId][roundCapInfo + .accessCriteriaId] + ) { + continue; + } + + // For PERSONAL cap rollover, the PREVIOUS round must have allowed it (Personal or All) + if ( + rounds[currentProcessingRoundId].accumulationMode + != AccumulationMode.Personal + && rounds[currentProcessingRoundId].accumulationMode + != AccumulationMode.All + ) { + continue; + } + + // Only count spent amounts from rounds that meet the accumulation criteria + totalSpentInPastRounds += + roundIdToUserToContribution[currentProcessingRoundId][user_]; + + // Check eligibility for the past round + if ( + _checkAccessCriteriaEligibility( + currentProcessingRoundId, + roundCapInfo.accessCriteriaId, + roundCapInfo.merkleProof, + user_ + ) + ) { + AccessCriteriaPrivileges storage privileges = + roundIdToAccessCriteriaIdToPrivileges[currentProcessingRoundId][roundCapInfo + .accessCriteriaId]; + totalAggregatedPersonalCap += privileges.personalCap; + } + // Mark the specific caps that were used in this contribution + usedUnspentCaps[user_][currentProcessingRoundId][roundCapInfo + .accessCriteriaId] = true; + } + + if (totalAggregatedPersonalCap > totalSpentInPastRounds) { + unspentPersonalCap = + totalAggregatedPersonalCap - totalSpentInPastRounds; + } + + return unspentPersonalCap; + } + + /// @notice Validates the round parameters. + /// @param round_ The round to validate. + /// @dev Reverts if the round parameters are invalid. + function _validateRoundParameters(Round storage round_) internal view { + // Validate round start time is in the future + // @note: The below condition wont allow _roundStart == block.timestamp + if (round_.roundStart <= block.timestamp) { + revert Module__LM_PC_FundingPot__InvalidInput(); + } + + // Validate that either end time or cap is set + if (round_.roundEnd == 0 && round_.roundCap == 0) { + revert Module__LM_PC_FundingPot__InvalidInput(); + } + + // If end time is set, validate it's after start time + if (round_.roundEnd > 0 && round_.roundEnd < round_.roundStart) { + revert Module__LM_PC_FundingPot__InvalidInput(); + } + } + + /// @notice Validates the round parameters before editing. + /// @param round_ The round to validate. + /// @dev Reverts if the round parameters are invalid. + function _validateEditRoundParameters(Round storage round_) internal view { + if (round_.roundEnd == 0 && round_.roundCap == 0) { + revert Module__LM_PC_FundingPot__RoundNotCreated(); + } + + if (block.timestamp > round_.roundStart) { + revert Module__LM_PC_FundingPot__InvalidInput(); + } + } + + /// @dev Validate uint start input. + /// @param start_ uint to validate. + /// @param cliff_ uint to validate. + /// @param end_ uint to validate. + /// @return True if uint is valid. + function _validTimes(uint start_, uint cliff_, uint end_) + internal + pure + returns (bool) + { + return start_ + cliff_ <= end_; + } + + /// @notice Contributes to a round with unused capacity from previous rounds. + /// @param roundId_ The ID of the round to contribute to. + /// @param user_ The address of the user to contribute for. + /// @param amount_ The amount to contribute. + /// @param accessCriteriaId_ The ID of the access criteria to use for this contribution. + /// @param merkleProof_ The Merkle proof for validation if needed. + /// @param unspentPersonalCap_ The amount of unused capacity from previous rounds. + function _contributeToRoundFor( + address user_, + uint32 roundId_, + uint amount_, + uint8 accessCriteriaId_, + bytes32[] memory merkleProof_, + uint unspentPersonalCap_ + ) internal { + if (amount_ == 0) { + revert Module__LM_PC_FundingPot__InvalidInput(); + } + + Round storage round = rounds[roundId_]; + + if (round.roundEnd == 0 && round.roundCap == 0) { + revert Module__LM_PC_FundingPot__RoundNotCreated(); + } + + if (block.timestamp < round.roundStart) { + revert Module__LM_PC_FundingPot__RoundHasNotStarted(); + } + + if (accessCriteriaId_ > MAX_ACCESS_CRITERIA_TYPE) { + revert Module__LM_PC_FundingPot__InvalidAccessCriteriaType(); + } + + _validateAccessCriteria( + roundId_, accessCriteriaId_, merkleProof_, user_ + ); + + AccessCriteriaPrivileges storage privileges = + roundIdToAccessCriteriaIdToPrivileges[roundId_][accessCriteriaId_]; + bool canOverrideContributionSpan = privileges.overrideContributionSpan; + + if ( + round.roundEnd > 0 && block.timestamp > round.roundEnd + && !canOverrideContributionSpan + ) { + revert Module__LM_PC_FundingPot__RoundHasEnded(); + } + + // Calculate the adjusted amount considering caps + uint adjustedAmount = _validateAndAdjustCapsWithUnspentCap( + user_, + roundId_, + amount_, + accessCriteriaId_, + canOverrideContributionSpan, + unspentPersonalCap_ + ); + + roundIdToUserToContribution[roundId_][user_] += adjustedAmount; + roundIdToTotalContributions[roundId_] += adjustedAmount; + roundIdTouserContributionsByAccessCriteria[roundId_][user_][accessCriteriaId_] + += adjustedAmount; + + __Module_orchestrator.fundingManager().token().safeTransferFrom( + _msgSender(), address(this), adjustedAmount + ); + + EnumerableSet.add(contributorsByRound[roundId_], user_); + + emit ContributionMade(roundId_, user_, adjustedAmount); + + // contribution triggers automatic closure + if (!roundIdToClosedStatus[roundId_] && round.autoClosure) { + bool readyToClose = _checkRoundClosureConditions(roundId_); + if (readyToClose) { + _closeRound(roundId_); + + _buyBondingCurveToken(roundId_); + } + } + } + + /// @notice Validates access criteria for a specific round and access type. + /// @dev Checks if a user meets the access requirements based on the round's access criteria. + /// @param roundId_ The ID of the round being validated. + /// @param accessCriteriaId_ The ID of the specific access criteria. + /// @param merkleProof_ Merkle proof for Merkle tree-based access (optional). + /// @param user_ The address of the user to validate. + function _validateAccessCriteria( + uint32 roundId_, + uint8 accessCriteriaId_, + bytes32[] memory merkleProof_, + address user_ + ) internal view { + Round storage round = rounds[roundId_]; + AccessCriteria storage accessCriteria = + round.accessCriterias[accessCriteriaId_]; + + bool isEligible = _checkAccessCriteriaEligibility( + roundId_, accessCriteriaId_, merkleProof_, user_ + ); + + if ( + !isEligible + && ( + accessCriteria.accessCriteriaType == AccessCriteriaType.NFT + || accessCriteria.accessCriteriaType + == AccessCriteriaType.MERKLE + || accessCriteria.accessCriteriaType == AccessCriteriaType.LIST + ) + ) { + revert Module__LM_PC_FundingPot__AccessCriteriaFailed(); + } + } + + /// @notice Validates and adjusts the contribution amount considering caps and unspent capacity. + /// @param user_ The address of the user to contribute for. + /// @param roundId_ The ID of the round to contribute to. + /// @param amount_ The amount to contribute. + /// @param accessCriteriaId_ The ID of the access criteria to use for this contribution. + /// @param canOverrideContributionSpan_ Whether the contribution span can be overridden. + /// @param unspentPersonalCap_ The amount of unused capacity from previous rounds. + function _validateAndAdjustCapsWithUnspentCap( + address user_, + uint32 roundId_, + uint amount_, + uint8 accessCriteriaId_, + bool canOverrideContributionSpan_, + uint unspentPersonalCap_ + ) internal view returns (uint adjustedAmount) { + adjustedAmount = amount_; + + Round storage round = rounds[roundId_]; + + // --- Round Cap Check --- + if (!canOverrideContributionSpan_ && round.roundCap > 0) { + uint totalRoundContribution = roundIdToTotalContributions[roundId_]; + uint effectiveRoundCap = round.roundCap; + + // If total accumulative caps are enabled for this round, + // adjust the effective round cap to accommodate unused capacity from previous rounds + if ( + round.accumulationMode == AccumulationMode.Total + || round.accumulationMode == AccumulationMode.All + ) { + uint unusedCapacityFromPrevious = + _calculateUnusedCapacityFromPreviousRounds(roundId_); + effectiveRoundCap += unusedCapacityFromPrevious; + } + + if (totalRoundContribution >= effectiveRoundCap) { + // If round cap is reached, revert (we know amount_ > 0 from parent function) + revert Module__LM_PC_FundingPot__RoundCapReached(); + } else { + // Cap is not full, calculate remaining and clamp if necessary + uint remainingRoundCap = + effectiveRoundCap - totalRoundContribution; + if (adjustedAmount > remainingRoundCap) { + adjustedAmount = remainingRoundCap; + } + } + } + + // --- Personal Cap Check --- + { + uint userPreviousContribution = + roundIdToUserToContribution[roundId_][user_]; + + AccessCriteriaPrivileges storage privileges = + roundIdToAccessCriteriaIdToPrivileges[roundId_][accessCriteriaId_]; + uint userPersonalCap = privileges.personalCap; + + // Add unspent personal capacity if personal accumulation is enabled for this round + if ( + round.accumulationMode == AccumulationMode.Personal + || round.accumulationMode == AccumulationMode.All + ) { + userPersonalCap += unspentPersonalCap_; + } + + // If user already reached their cap, revert + if (userPreviousContribution >= userPersonalCap) { + revert Module__LM_PC_FundingPot__PersonalCapReached(); + } + + // Calculate remaining personal cap and take minimum + uint remainingPersonalCap = + userPersonalCap - userPreviousContribution; + if (remainingPersonalCap < adjustedAmount) { + adjustedAmount = remainingPersonalCap; + } + } + + return adjustedAmount; + } + + /// @notice Checks if a user meets the access criteria for a specific round and access type. + /// @dev Returns true if the user meets the access criteria, reverts otherwise. + /// @param roundId_ The ID of the round being validated. + /// @param accessCriteriaId_ The ID of the specific access criteria. + /// @param merkleProof_ Merkle proof for Merkle tree-based access (optional). + /// @param user_ The address of the user to validate. + /// @return isEligible True if the user meets the access criteria, false otherwise. + function _checkAccessCriteriaEligibility( + uint32 roundId_, + uint8 accessCriteriaId_, + bytes32[] memory merkleProof_, + address user_ + ) internal view returns (bool isEligible) { + Round storage round = rounds[roundId_]; + AccessCriteria storage accessCriteria = + round.accessCriterias[accessCriteriaId_]; + + if (accessCriteria.accessCriteriaType == AccessCriteriaType.OPEN) { + isEligible = true; + } + if (accessCriteria.accessCriteriaType == AccessCriteriaType.NFT) { + isEligible = _checkNftOwnership(accessCriteria.nftContract, user_); + } else if ( + accessCriteria.accessCriteriaType == AccessCriteriaType.MERKLE + ) { + isEligible = _validateMerkleProof( + accessCriteria.merkleRoot, merkleProof_, user_, roundId_ + ); + } else if (accessCriteria.accessCriteriaType == AccessCriteriaType.LIST) + { + isEligible = accessCriteria.allowedAddresses[user_]; + } + + return isEligible; + } + + /// @notice Calculates unused capacity from previous rounds. + /// @param roundId_ The ID of the current round. + /// @return unusedCapacityFromPrevious The total unused capacity from previous rounds. + function _calculateUnusedCapacityFromPreviousRounds(uint32 roundId_) + internal + view + returns (uint unusedCapacityFromPrevious) + { + uint32 startAccumulationFrom = globalAccumulationStartRoundId; + + if (startAccumulationFrom >= roundId_) { + return 0; // No rounds to consider for accumulation + } + + // Iterate through previous rounds starting from the globalAccumulationStartRoundId + for (uint32 i = startAccumulationFrom; i < roundId_; ++i) { + Round storage prevRound = rounds[i]; + // Only consider previous rounds that allowed total accumulation + if ( + prevRound.accumulationMode != AccumulationMode.Total + && prevRound.accumulationMode != AccumulationMode.All + ) { + continue; + } + + uint prevRoundTotal = roundIdToTotalContributions[i]; + if (prevRoundTotal < prevRound.roundCap) { + unusedCapacityFromPrevious += + (prevRound.roundCap - prevRoundTotal); + } + } + return unusedCapacityFromPrevious; + } + + /// @notice Verifies NFT ownership for access control. + /// @dev Safely checks the NFT balance of a user using a try-catch block. + /// @param nftContract_ Address of the NFT contract. + /// @param user_ Address of the user to check for NFT ownership. + /// @return Boolean indicating whether the user owns an NFT. + function _checkNftOwnership(address nftContract_, address user_) + internal + view + returns (bool) + { + try IERC721(nftContract_).balanceOf(user_) returns (uint balance) { + return balance > 0; + } catch { + return false; + } + } + + /// @notice Verifies a Merkle p roof for access control. + /// @dev Validates that the user's address is part of the Merkle tree. + /// @param root_ The Merkle root to validate against. + /// @param user_ The address of the user to check. + /// @param roundId_ The ID of the round to check. + /// @param merkleProof_ The Merkle proof to verify. + /// @return Boolean indicating whether the proof is valid. + function _validateMerkleProof( + bytes32 root_, + bytes32[] memory merkleProof_, + address user_, + uint32 roundId_ + ) internal pure returns (bool) { + bytes32 leaf = keccak256(abi.encodePacked(user_, roundId_)); + + return MerkleProof.verify(merkleProof_, root_, leaf); + } + + /// @notice Handles round closure logic. + /// @dev Updates round status and executes hook if needed. + /// @param roundId_ The ID of the round to close. + function _closeRound(uint32 roundId_) internal { + Round storage round = rounds[roundId_]; + + roundIdToClosedStatus[roundId_] = true; + // @note: we don't check if the hook contract is valid here, because we don't want to revert the round closure + // if the hook contract is invalid. + if (round.hookContract != address(0)) { + (bool success,) = round.hookContract.call(round.hookFunction); + } + + emit RoundClosed(roundId_, roundIdToTotalContributions[roundId_]); + } + + /// @notice Creates payment orders for contributors in a round based on their access criteria. + /// @dev Processes a batch of contributors to handle gas limit concerns. + /// @param roundId_ The ID of the round to create payment orders for. + /// @param startIndex_ The starting index in the contributors array. + /// @param batchSize_ The number of contributors to process in this batch. + function _createPaymentOrdersForContributors( + uint32 roundId_, + uint startIndex_, + uint batchSize_ + ) internal { + uint totalContributions = roundIdToTotalContributions[roundId_]; + uint tokensBought = roundTokensBought[roundId_]; + + if (totalContributions == 0 || tokensBought == 0) return; + + address[] memory contributors = + EnumerableSet.values(contributorsByRound[roundId_]); + uint contributorCount = contributors.length; + + if (startIndex_ >= contributorCount) { + revert Module__LM_PC_FundingPot__InvalidInput(); + } + + // Calculate the end index (don't exceed array bounds) + uint endIndex = startIndex_ + batchSize_; + if (endIndex > contributorCount) { + endIndex = contributorCount; + } + + address issuanceToken = address( + IBondingCurveBase_v1( + address(__Module_orchestrator.fundingManager()) + ).getIssuanceToken() + ); + + for (uint i = startIndex_; i < endIndex; i++) { + address contributor = contributors[i]; + uint contributorTotal = + roundIdToUserToContribution[roundId_][contributor]; + + if (contributorTotal == 0) continue; + + for ( + uint8 accessCriteriaId = 1; + accessCriteriaId <= MAX_ACCESS_CRITERIA_TYPE; + accessCriteriaId++ + ) { + uint contributionByAccessCriteria = + roundIdTouserContributionsByAccessCriteria[roundId_][contributor][accessCriteriaId]; + + if (contributionByAccessCriteria == 0) continue; + + uint tokensForThisAccessCriteria; + unchecked { + tokensForThisAccessCriteria = ( + contributionByAccessCriteria * tokensBought + ) / totalContributions; + } + + _createAndAddPaymentOrder( + roundId_, + contributor, + accessCriteriaId, + tokensForThisAccessCriteria, + issuanceToken + ); + } + } + + emit ContributorBatchProcessed(roundId_, startIndex_, endIndex); + } + + /// @notice Creates time parameter data for a payment order. + /// @dev Sets default values for start, cliff, and end if they are zero. + /// @param start_ The start time of the payment order. + /// @param cliff_ The cliff time of the payment order. + /// @param end_ The end time of the payment order. + /// @return flags The flags for the payment order. + /// @return finalData The final data for the payment order. + function _createTimeParameterData(uint start_, uint cliff_, uint end_) + internal + view + returns (bytes32 flags, bytes32[] memory finalData) + { + if (start_ == 0) start_ = block.timestamp; + if (end_ == 0) end_ = block.timestamp; // Note: cliff_ is not defaulted here. + + flags = 0; + uint8 flagCount = 0; + bytes32[3] memory tempData; // Fixed-size array on stack for intermediate values + + // Start time + flags |= bytes32(uint(1) << FLAG_START); + tempData[flagCount] = bytes32(start_); + unchecked { + flagCount++; + } + + if (cliff_ > 0) { + flags |= bytes32(uint(1) << FLAG_CLIFF); + tempData[flagCount] = bytes32(cliff_); + unchecked { + flagCount++; + } + } + + // End time + flags |= bytes32(uint(1) << FLAG_END); + tempData[flagCount] = bytes32(end_); + unchecked { + flagCount++; + } + + finalData = new bytes32[](flagCount); + for (uint8 j = 0; j < flagCount; ++j) { + unchecked { + finalData[j] = tempData[j]; + } + } + + return (flags, finalData); + } + + /// @notice Creates and adds a payment order for a contributor. + /// @dev Sets default values for start, cliff, and end if they are zero. + /// @param roundId_ The ID of the round to create the payment order for. + /// @param recipient_ The address of the recipient of the payment order. + /// @param accessCriteriaId_ The ID of the specific access criteria. + /// @param tokensAmount_ The amount of tokens for the payment order. + /// @param issuanceToken_ The issuance token for the payment order. + function _createAndAddPaymentOrder( + uint32 roundId_, + address recipient_, + uint8 accessCriteriaId_, + uint tokensAmount_, + address issuanceToken_ + ) internal { + AccessCriteriaPrivileges storage privileges = + roundIdToAccessCriteriaIdToPrivileges[roundId_][accessCriteriaId_]; + + uint start = privileges.start; + uint cliff = privileges.cliff; + uint end = privileges.end; + + (bytes32 flags, bytes32[] memory finalData) = + _createTimeParameterData(start, cliff, end); + + IERC20PaymentClientBase_v2.PaymentOrder memory paymentOrder = + IERC20PaymentClientBase_v2.PaymentOrder({ + recipient: recipient_, + paymentToken: issuanceToken_, + amount: tokensAmount_, + originChainId: block.chainid, + targetChainId: block.chainid, + flags: flags, + data: finalData + }); + + _addPaymentOrder(paymentOrder); + } + + function _buyBondingCurveToken(uint32 roundId_) internal { + uint totalContributions = roundIdToTotalContributions[roundId_]; + if (totalContributions == 0) { + revert Module__LM_PC_FundingPot__NoContributions(); + } + + // Cache the funding manager instance and its address + IFundingManager_v1 fundingManager = + __Module_orchestrator.fundingManager(); + + // Get the contribution token from the cached funding manager instance and approve it + IERC20 contributionToken = fundingManager.token(); + contributionToken.approve(address(fundingManager), totalContributions); + + // Cast the cached funding manager address to the bonding curve interface + IBondingCurveBase_v1 bondingCurve = + IBondingCurveBase_v1(address(fundingManager)); + + uint minAmountOut = + bondingCurve.calculatePurchaseReturn(totalContributions); + bondingCurve.buyFor(address(this), totalContributions, minAmountOut); + + roundTokensBought[roundId_] = minAmountOut; + } + + /// @notice Checks if a round has reached its cap or time limit. + /// @param roundId_ The ID of the round to check. + /// @return Boolean indicating if the round has reached its cap or time limit. + function _checkRoundClosureConditions(uint32 roundId_) + internal + view + returns (bool) + { + Round storage round = rounds[roundId_]; + uint totalContribution = roundIdToTotalContributions[roundId_]; + bool capReached = + round.roundCap > 0 && totalContribution >= round.roundCap; + bool timeEnded = round.roundEnd > 0 && block.timestamp >= round.roundEnd; + return capReached || timeEnded; + } + + /// @notice Sets and validates the round parameters. + /// @param roundToSet_ The round storage object to set parameters for. + /// @param roundStart_ Start timestamp for the round. + /// @param roundEnd_ End timestamp for the round. + /// @param roundCap_ Maximum contribution cap. + /// @param hookContract_ Address of contract to call after round closure. + /// @param hookFunction_ Encoded function call for the hook. + /// @param autoClosure_ Whether hook closure coincides with contribution span end. + /// @param accumulationMode_ Defines how caps accumulate. + function _setAndValidateRoundParameters( + Round storage roundToSet_, + uint roundStart_, + uint roundEnd_, + uint roundCap_, + address hookContract_, + bytes memory hookFunction_, + bool autoClosure_, + AccumulationMode accumulationMode_ + ) internal { + roundToSet_.roundStart = roundStart_; + roundToSet_.roundEnd = roundEnd_; + roundToSet_.roundCap = roundCap_; + roundToSet_.hookContract = hookContract_; + roundToSet_.hookFunction = hookFunction_; + roundToSet_.autoClosure = autoClosure_; + roundToSet_.accumulationMode = accumulationMode_; + + _validateRoundParameters(roundToSet_); + } +} diff --git a/src/modules/logicModule/interfaces/ILM_PC_FundingPot_v1.sol b/src/modules/logicModule/interfaces/ILM_PC_FundingPot_v1.sol new file mode 100644 index 000000000..16848ccc5 --- /dev/null +++ b/src/modules/logicModule/interfaces/ILM_PC_FundingPot_v1.sol @@ -0,0 +1,449 @@ +// SPDX-License-Identifier: LGPL-3.0-only +pragma solidity ^0.8.0; + +// Internal +import {IERC20PaymentClientBase_v2} from + "@lm/interfaces/IERC20PaymentClientBase_v2.sol"; + +interface ILM_PC_FundingPot_v1 is IERC20PaymentClientBase_v2 { + // -------------------------------------------------------------------------- + // Structs + + /// @notice Struct used to store information about a funding round. + /// @param roundStart Timestamp indicating when the round starts. + /// @param roundEnd Timestamp indicating when the round ends. If set to `0`, the round operates only based on `roundCap`. + /// @param roundCap Maximum contribution cap in collateral tokens. If set to `0`, the round operates only based on `roundEnd`. + /// @param hookContract Address of an optional hook contract to be called after round closure. + /// @param hookFunction Encoded function call to be executed on the `hookContract` after round closure. + /// @param autoClosure Indicates whether the hook closure coincides with the contribution span end. + /// @param accumulationMode Defines how caps accumulate across rounds (see AccumulationMode enum). + /// @param accessCriterias Mapping of access criteria IDs to their respective access criteria. + struct Round { + uint roundStart; + uint roundEnd; + uint roundCap; + address hookContract; + bytes hookFunction; + bool autoClosure; + AccumulationMode accumulationMode; + mapping(uint32 id => AccessCriteria) accessCriterias; + } + + /// @notice Struct used to store information about a funding round's access criteria. + /// @param accessCriteriaType Type of access criteria. + /// @param nftContract Address of the NFT contract. + /// @param merkleRoot Merkle root for the access criteria. + /// @param allowedAddresses Mapping of addresses to their access status. + struct AccessCriteria { + AccessCriteriaType accessCriteriaType; + address nftContract; // NFT contract address (0x0 if unused) + bytes32 merkleRoot; // Merkle root (0x0 if unused) + mapping(address user => bool isAllowed) allowedAddresses; // Mapping of allowed addresses + } + + /// @notice Struct used to store information about a funding round's access criteria privileges. + /// @param personalCap Personal cap for the access criteria. + /// @param overrideContributionSpan Whether to override the round contribution span. + /// @param start The start timestamp for for when the linear vesting starts. + /// @param cliff The time in seconds from start time at which the unlock starts. + /// @param end The end timestamp for when the linear vesting ends. + struct AccessCriteriaPrivileges { + uint personalCap; + bool overrideContributionSpan; + uint start; + uint cliff; + uint end; + } + + /// @notice Struct used to specify previous round's access criteria for carry-over capacity. + /// @param roundId The ID of the previous round. + /// @param accessCriteriaId The ID of the access criteria in that round. + /// @param merkleProof The Merkle proof needed to validate eligibility (if needed). + struct UnspentPersonalRoundCap { + uint32 roundId; + uint8 accessCriteriaId; + bytes32[] merkleProof; + } + + /// @notice Struct to represent a user's complete eligibility information for a round. + /// @param isEligible Whether the user is eligible for the round through any criteria. + /// @param isNftHolder Whether the user is eligible through NFT holding. + /// @param isInMerkleTree Whether the user is eligible through Merkle proof. + /// @param isInAllowlist Whether the user is eligible through allowlist. + /// @param highestPersonalCap The highest personal cap the user can access. + /// @param canOverrideContributionSpan Whether the user has any criteria that can override contribution span. + struct RoundUserEligibility { + bool isEligible; + bool isNftHolder; + bool isInMerkleTree; + bool isInAllowlist; + uint highestPersonalCap; + bool canOverrideContributionSpan; + } + + // ------------------------------------------------------------------------- + // Enums + + /// @notice Enum used to identify the type of access criteria. + enum AccessCriteriaType { + UNSET, // 0 + OPEN, // 1 + NFT, // 2 + MERKLE, // 3 + LIST // 4 + + } + + /// @notice Enum used to define how caps accumulate across rounds. + /// @dev Determines whether unused personal caps or round caps from previous + /// rounds can affect the limits of the current round. + enum AccumulationMode { + Disabled, // 0 - No accumulation. Personal and round caps are isolated to this round. + Personal, // 1 - Only personal caps roll over from previous compatible rounds. Round cap is isolated. + Total, // 2 - Only total round caps expand based on previous compatible rounds' undersubscription. Personal caps are isolated. + All // 3 - Both personal caps roll over and total round caps expand based on previous compatible rounds. + + } + + // ------------------------------------------------------------------------- + // Events + + /// @notice Emitted when a new round is created. + /// @dev This event signals the creation of a new round with specific parameters. + /// @param roundId_ The unique identifier for the round. + /// @param roundStart_ The timestamp when the round starts. + /// @param roundEnd_ The timestamp when the round ends. + /// @param roundCap_ The maximum allocation or cap for the round. + /// @param hookContract_ The address of an optional hook contract for custom logic. + /// @param hookFunction_ The encoded function call for the hook. + /// @param autoClosure_ A boolean indicating whether a specific closure mechanism is enabled. + /// @param accumulationMode_ Defines how caps accumulate across rounds (see AccumulationMode enum). + event RoundCreated( + uint indexed roundId_, + uint roundStart_, + uint roundEnd_, + uint roundCap_, + address hookContract_, + bytes hookFunction_, + bool autoClosure_, + AccumulationMode accumulationMode_ + ); + + /// @notice Emitted when an existing round is edited. + /// @dev This event signals modifications to an existing round's parameters. + /// @param roundId_ The unique identifier of the round being edited. + /// @param roundStart_ The updated timestamp for when the round starts. + /// @param roundEnd_ The updated timestamp for when the round ends. + /// @param roundCap_ The updated maximum allocation or cap for the round. + /// @param hookContract_ The address of an optional hook contract for custom logic. + /// @param hookFunction_ The updated encoded function call for the hook. + /// @param autoClosure_ A boolean indicating whether a specific closure mechanism is enabled. + /// @param accumulationMode_ The accumulation mode for this round (see AccumulationMode enum). + event RoundEdited( + uint indexed roundId_, + uint roundStart_, + uint roundEnd_, + uint roundCap_, + address hookContract_, + bytes hookFunction_, + bool autoClosure_, + AccumulationMode accumulationMode_ + ); + + /// @notice Emitted when access criteria is edited for a round. + /// @param isEdit_ represents new or edited setting + /// @param roundId_ The unique identifier of the round. + /// @param accessCriteriaId_ The identifier of the access criteria. + event AccessUpdated( + bool isEdit_, uint32 indexed roundId_, uint8 accessCriteriaId_ + ); + + /// @notice Emitted when access criteria privileges are set for a round. + /// @param roundId_ The unique identifier of the round. + /// @param accessCriteriaId_ The identifier of the access criteria. + /// @param personalCap_ The personal cap for the access criteria. + /// @param overrideContributionSpan_ Whether to override the round contribution span. + /// @param start_ The start timestamp for for when the linear vesting starts. + /// @param cliff_ The time in seconds from start time at which the unlock starts. + /// @param end_ The end timestamp for when the linear vesting ends. + event AccessCriteriaPrivilegesSet( + uint32 indexed roundId_, + uint8 accessCriteriaId_, + uint personalCap_, + bool overrideContributionSpan_, + uint start_, + uint cliff_, + uint end_ + ); + + /// @notice Emitted when a contribution is made to a round. + /// @param roundId_ The ID of the round. + /// @param contributor_ The address of the contributor. + /// @param amount_ The amount contributed. + event ContributionMade(uint32 roundId_, address contributor_, uint amount_); + + /// @notice Emitted when a round is closed. + /// @param roundId_ The ID of the round. + /// @param totalContributions_ The total contributions collected in the round. + event RoundClosed(uint32 roundId_, uint totalContributions_); + + /// @notice Emitted when addresses are removed from an access criteria's allowed list. + event AllowlistedAddressesRemoved(); + + /// @notice Emitted when a contributor batch is processed. + /// @param roundId_ The ID of the round. + /// @param startIndex_ The starting index in the contributors array. + /// @param endIndex_ The ending index in the contributors array. + event ContributorBatchProcessed( + uint32 indexed roundId_, uint startIndex_, uint endIndex_ + ); + + /// @notice Emitted when the global accumulation start round ID is updated. + /// @param startRoundId The new round ID from which accumulation calculations will begin (inclusive, must be >= 1). + event GlobalAccumulationStartSet(uint32 startRoundId); + + // ------------------------------------------------------------------------- + // Errors + + /// @notice Invalid input validation. + error Module__LM_PC_FundingPot__InvalidInput(); + + /// @notice Round does not exist. + error Module__LM_PC_FundingPot__RoundNotCreated(); + + /// @notice Incorrect access criteria. + error Module__LM_PC_FundingPot__MissingRequiredAccessCriteriaData(); + + /// @notice Invalid access criteria type. + error Module__LM_PC_FundingPot__InvalidAccessCriteriaType(); + + /// @notice Round has not started yet. + error Module__LM_PC_FundingPot__RoundHasNotStarted(); + + /// @notice Round has already ended. + error Module__LM_PC_FundingPot__RoundHasEnded(); + + /// @notice Access criteria failed. + error Module__LM_PC_FundingPot__AccessCriteriaFailed(); + + /// @notice User has reached their personal contribution cap. + error Module__LM_PC_FundingPot__PersonalCapReached(); + + /// @notice Round contribution cap has been reached. + error Module__LM_PC_FundingPot__RoundCapReached(); + + /// @notice Round Closure conditions are not met. + error Module__LM_PC_FundingPot__ClosureConditionsNotMet(); + + /// @notice No contributions were made to the round. + error Module__LM_PC_FundingPot__NoContributions(); + + /// @notice Round is not closed. + error Module__LM_PC_FundingPot__RoundNotClosed(); + + /// @notice Start round ID must be greater than zero. + error Module__LM_PC_FundingPot__StartRoundCannotBeZero(); + + /// @notice Start round ID cannot be greater than the current round count. + error Module__LM_PC_FundingPot__StartRoundGreaterThanRoundCount(); + + /// @notice Unspent caps must be from previous rounds. + error Module__LM_PC_FundingPot__UnspentCapsMustBeFromPreviousRounds(); + + /// @notice Thrown when someone tries to use another user's unspent caps + error Module__LM_PC_FundingPot__OnlyOwnerCanUseUnspentCaps(); + + /// @notice Hook execution failed. + error Module__LM_PC_FundingPot__HookExecutionFailed(); + + // ------------------------------------------------------------------------- + // Public - Getters + + /// @notice Retrieves the generic parameters of a specific funding round. + /// @param roundId_ The unique identifier of the round to retrieve. + /// @return roundStart_ The timestamp when the round starts. + /// @return roundEnd_ The timestamp when the round ends. + /// @return roundCap_ The maximum contribution cap for the round. + /// @return hookContract_ The address of the hook contract. + /// @return hookFunction_ The encoded function call for the hook. + /// @return autoClosure_ Whether hook closure coincides with contribution span end. + /// @return accumulationMode_ The accumulation mode for the round. + function getRoundGenericParameters(uint32 roundId_) + external + view + returns ( + uint roundStart_, + uint roundEnd_, + uint roundCap_, + address hookContract_, + bytes memory hookFunction_, + bool autoClosure_, + AccumulationMode accumulationMode_ + ); + + /// @notice Retrieves the access criteria for a specific funding round. + /// @param roundId_ The unique identifier of the round to retrieve. + /// @param accessCriteriaId_ The identifier of the access criteria to retrieve. + /// @return isRoundOpen_ Whether anyone can contribute as part of the access criteria. + /// @return nftContract_ The address of the NFT contract used for access control. + /// @return merkleRoot_ The merkle root used for access verification. + /// @return isList_ If the access criteria is a list, this will be true. + function getRoundAccessCriteria(uint32 roundId_, uint8 accessCriteriaId_) + external + view + returns ( + bool isRoundOpen_, + address nftContract_, + bytes32 merkleRoot_, + bool isList_ + ); + + /// @notice Retrieves the access criteria privileges for a specific funding round. + /// @param roundId_ The unique identifier of the round. + /// @param accessCriteriaId_ The identifier of the access criteria. + /// @return personalCap_ The personal cap for the access criteria. + /// @return overrideContributionSpan_ Whether to override the round contribution span. + /// @return start_ The start timestamp for the access criteria. + /// @return cliff_ The cliff timestamp for the access criteria. + /// @return end_ The end timestamp for the access criteria. + function getRoundAccessCriteriaPrivileges( + uint32 roundId_, + uint8 accessCriteriaId_ + ) + external + view + returns ( + uint personalCap_, + bool overrideContributionSpan_, + uint start_, + uint cliff_, + uint end_ + ); + + // ------------------------------------------------------------------------- + // Public - Mutating + + /// @notice Creates a new funding round. + /// @dev Only callable by funding pot admin. + /// @param roundStart_ Start timestamp for the round. + /// @param roundEnd_ End timestamp for the round (0 if using roundCap only). + /// @param roundCap_ Maximum contribution cap in collateral tokens (0 if using roundEnd only). + /// @param hookContract_ Address of contract to call after round closure. + /// @param hookFunction_ Encoded function call for the hook. + /// @param autoClosure_ Whether hook closure coincides with contribution span end. + /// @param accumulationMode_ Defines how caps accumulate across rounds (see AccumulationMode enum). + /// @return The ID of the newly created round. + function createRound( + uint roundStart_, + uint roundEnd_, + uint roundCap_, + address hookContract_, + bytes memory hookFunction_, + bool autoClosure_, + AccumulationMode accumulationMode_ + ) external returns (uint32); + + /// @notice Edits an existing funding round. + /// @dev Only callable by funding pot admin and only before the round has started. + /// @param roundId_ ID of the round to edit. + /// @param roundStart_ New start timestamp. + /// @param roundEnd_ New end timestamp. + /// @param roundCap_ New maximum contribution cap. + /// @param hookContract_ New hook contract address. + /// @param hookFunction_ New encoded function call. + /// @param autoClosure_ New closure mechanism setting. + /// @param accumulationMode_ New accumulation mode setting. + function editRound( + uint32 roundId_, + uint roundStart_, + uint roundEnd_, + uint roundCap_, + address hookContract_, + bytes memory hookFunction_, + bool autoClosure_, + AccumulationMode accumulationMode_ + ) external; + + /// @notice Set Access Control Check. + /// @dev Only callable by funding pot admin and only before the round has started. + /// @param roundId_ ID of the round. + /// @param accessCriteriaType_ access criteria type of the round. + /// @param accessCriteriaId_ ID of the access criteria. + /// @param nftContract_ Address of the NFT contract. + /// @param merkleRoot_ Merkle root for the access criteria. + /// @param allowedAddresses_ List of explicitly allowed addresses. + /// @param removedAddresses_ List of addresses to remove from the allowed list. + function setAccessCriteria( + uint32 roundId_, + uint8 accessCriteriaType_, + uint8 accessCriteriaId_, + address nftContract_, + bytes32 merkleRoot_, + address[] memory allowedAddresses_, + address[] memory removedAddresses_ + ) external; + + /// @notice Removes addresses from the allowed list for a specific access criteria. + /// @dev Only callable by funding pot admin and only before the round has started. + /// @param roundId_ ID of the round. + /// @param accessCriteriaId_ ID of the access criteria. + /// @param addressesToRemove_ List of addresses to remove from the allowed list. + function removeAllowlistedAddresses( + uint32 roundId_, + uint8 accessCriteriaId_, + address[] calldata addressesToRemove_ + ) external; + + /// @notice Set access criteria privileges. + /// @dev Only callable by funding pot admin and only before the round has started. + /// @param roundId_ ID of the round. + /// @param accessCriteriaId_ ID of the access criteria. + /// @param personalCap_ Personal cap for the access criteria. + /// @param overrideContributionSpan_ Whether to override the round contribution span. + /// @param start_ Start timestamp for the access criteria. + /// @param cliff_ Cliff timestamp for the access criteria. + /// @param end_ End timestamp for the access criteria. + function setAccessCriteriaPrivileges( + uint32 roundId_, + uint8 accessCriteriaId_, + uint personalCap_, + bool overrideContributionSpan_, + uint start_, + uint cliff_, + uint end_ + ) external; + + /// @notice Contributes to a round on behalf of a user. + /// @param user_ The address of the user to contribute for. + /// @param roundId_ The ID of the round to contribute to. + /// @param amount_ The amount to contribute. + /// @param accessCriteriaId_ The ID of the access criteria to use for this contribution. + /// @param merkleProof_ The Merkle proof for validation if needed. + /// @param unspentPersonalRoundCaps_ Array of previous rounds and access criteria to calculate unused capacity from. + function contributeToRoundFor( + address user_, + uint32 roundId_, + uint amount_, + uint8 accessCriteriaId_, + bytes32[] memory merkleProof_, + UnspentPersonalRoundCap[] calldata unspentPersonalRoundCaps_ + ) external; + + /// @notice Closes a round. + /// @param roundId_ The ID of the round to close. + function closeRound(uint32 roundId_) external; + + /// @notice Creates a batch of contributors for payment order creation. + /// @param roundId_ The ID of the round to process contributors for. + /// @param batchSize_ The number of contributors to process in this batch. + function createPaymentOrdersForContributorsBatch( + uint32 roundId_, + uint batchSize_ + ) external; + + /// @notice Sets the global minimum round ID from which accumulation calculations should begin. + /// @dev Only callable by `FUNDING_POT_ADMIN_ROLE`. This setting affects all future + /// accumulation calculations across the module. The start round must be >= 1 and cannot exceed the current round count. + /// @param startRoundId_ The first round ID (inclusive, >= 1) to consider for accumulation. + function setGlobalAccumulationStart(uint32 startRoundId_) external; +} diff --git a/test/e2e/E2EModuleRegistry.sol b/test/e2e/E2EModuleRegistry.sol index e961036b4..91f8c3e00 100644 --- a/test/e2e/E2EModuleRegistry.sol +++ b/test/e2e/E2EModuleRegistry.sol @@ -27,6 +27,7 @@ import {LM_PC_Bounties_v2} from "@lm/LM_PC_Bounties_v2.sol"; import {LM_PC_RecurringPayments_v2} from "@lm/LM_PC_RecurringPayments_v2.sol"; import {LM_PC_PaymentRouter_v2} from "@lm/LM_PC_PaymentRouter_v2.sol"; import {LM_PC_Staking_v2} from "@lm/LM_PC_Staking_v2.sol"; +import {LM_PC_FundingPot_v1} from "@lm/LM_PC_FundingPot_v1.sol"; import {LM_PC_KPIRewarder_v2} from "@lm/LM_PC_KPIRewarder_v2.sol"; import {AUT_Roles_v1} from "@aut/role/AUT_Roles_v1.sol"; import {AUT_TokenGated_Roles_v1} from "@aut/role/AUT_TokenGated_Roles_v1.sol"; @@ -884,6 +885,49 @@ contract E2EModuleRegistry is Test { ); } + // LM_PC_FundingPot_v1 + LM_PC_FundingPot_v1 LM_PC_FundingPot_v1Impl; + + InverterBeacon_v1 LM_PC_FundingPot_v1Beacon; + + IModule_v1.Metadata LM_PC_FundingPot_v1Metadata = IModule_v1.Metadata( + 1, + 0, + 0, + "https://github.com/InverterNetwork/contracts", + "LM_PC_FundingPot_v1" + ); + + /* + IOrchestratorFactory_v1.ModuleConfig LM_PC_FundingPot_v1FactoryConfig = + IOrchestratorFactory_v1.ModuleConfig( + LM_PC_FundingPot_v1Metadata, + bytes(address(contributionToken)) + ); + */ + + function setUpLM_PC_FundingPot_v1() internal { + // Deploy module implementations. + LM_PC_FundingPot_v1Impl = new LM_PC_FundingPot_v1(); + + // Deploy module beacons. + LM_PC_FundingPot_v1Beacon = new InverterBeacon_v1( + moduleFactory.reverter(), + DEFAULT_BEACON_OWNER, + LM_PC_FundingPot_v1Metadata.majorVersion, + address(LM_PC_FundingPot_v1Impl), + LM_PC_FundingPot_v1Metadata.minorVersion, + LM_PC_FundingPot_v1Metadata.patchVersion + ); + + // Register modules at moduleFactory. + vm.prank(teamMultisig); + gov.registerMetadataInModuleFactory( + LM_PC_FundingPot_v1Metadata, + IInverterBeacon_v1(LM_PC_FundingPot_v1Beacon) + ); + } + // LM_PC_KPIRewarder_v2 LM_PC_KPIRewarder_v2 LM_PC_KPIRewarder_v2Impl; diff --git a/test/e2e/logicModule/FundingPotE2E.t.sol b/test/e2e/logicModule/FundingPotE2E.t.sol new file mode 100644 index 000000000..0e14453cd --- /dev/null +++ b/test/e2e/logicModule/FundingPotE2E.t.sol @@ -0,0 +1,367 @@ +// SPDX-License-Identifier: LGPL-3.0-only +pragma solidity ^0.8.0; + +// Internal Dependencies + +import { + E2ETest, + IOrchestratorFactory_v1, + IOrchestrator_v1 +} from "test/e2e/E2ETest.sol"; + +// SuT +import { + LM_PC_FundingPot_v1, + ILM_PC_FundingPot_v1 +} from "@lm/LM_PC_FundingPot_v1.sol"; +import {IERC20PaymentClientBase_v2} from + "test/mocks/modules/paymentClient/ERC20PaymentClientBaseV2Mock.sol"; +import { + FM_BC_Bancor_Redeeming_VirtualSupply_v1, + IFM_BC_Bancor_Redeeming_VirtualSupply_v1 +} from "@fm/bondingCurve/FM_BC_Bancor_Redeeming_VirtualSupply_v1.sol"; +import {PP_Streaming_v2} from "src/modules/paymentProcessor/PP_Streaming_v2.sol"; +import { + LM_PC_Bounties_v2, ILM_PC_Bounties_v2 +} from "@lm/LM_PC_Bounties_v2.sol"; + +import {FM_DepositVault_v1} from "@fm/depositVault/FM_DepositVault_v1.sol"; +import {ERC165Upgradeable} from + "@oz-up/utils/introspection/ERC165Upgradeable.sol"; +import {ERC20Mock} from "test/mocks/external/token/ERC20Mock.sol"; +import {SafeERC20} from "@oz/token/ERC20/utils/SafeERC20.sol"; +import {ERC20Issuance_v1} from "@ex/token/ERC20Issuance_v1.sol"; + +contract FundingPotE2E is E2ETest { + // Module Configurations for the current E2E test. Should be filled during setUp() call. + IOrchestratorFactory_v1.ModuleConfig[] moduleConfigurations; + + // Let's create a list of contributors + address contributor1 = makeAddr("contributor 1"); + address contributor2 = makeAddr("contributor 2"); + address contributor3 = makeAddr("contributor 3"); + ERC20Issuance_v1 issuanceToken; + LM_PC_Bounties_v2 bountyManager; + IOrchestrator_v1 orchestrator; + IFM_BC_Bancor_Redeeming_VirtualSupply_v1 bondingCurveFundingManager; + PP_Streaming_v2 paymentProcessor; + LM_PC_FundingPot_v1 fundingPot; + + // Constants + uint constant _SENTINEL = type(uint).max; + //ERC20Mock contributionToken = new ERC20Mock("Contribution Mock", "C_MOCK"); + ERC20Mock contributionToken; + + function setUp() public override { + // Setup common E2E framework + super.setUp(); + + // Set Up individual Modules the E2E test is going to use and store their configurations: + // NOTE: It's important to store the module configurations in order, since _create_E2E_Orchestrator() will copy from the array. + // The order should be: + // moduleConfigurations[0] => FundingManager + // moduleConfigurations[1] => Authorizer + // moduleConfigurations[2] => PaymentProcessor + // moduleConfigurations[3:] => Additional Logic Modules + + issuanceToken = new ERC20Issuance_v1( + "Bonding Curve Token", "BCT", 18, type(uint).max - 1 + ); + + issuanceToken.setMinter(address(this), true); + + IFM_BC_Bancor_Redeeming_VirtualSupply_v1.BondingCurveProperties memory + bc_properties = IFM_BC_Bancor_Redeeming_VirtualSupply_v1 + .BondingCurveProperties({ + formula: address(formula), + reserveRatioForBuying: 333_333, + reserveRatioForSelling: 333_333, + buyFee: 0, + sellFee: 0, + buyIsOpen: true, + sellIsOpen: true, + initialIssuanceSupply: 10, + initialCollateralSupply: 30 + }); + + // FundingManager + moduleConfigurations.push( + IOrchestratorFactory_v1.ModuleConfig( + bancorVirtualSupplyBondingCurveFundingManagerMetadata, + abi.encode(address(issuanceToken), bc_properties, token) + ) + ); + + // Authorizer + setUpRoleAuthorizer(); + moduleConfigurations.push( + IOrchestratorFactory_v1.ModuleConfig( + roleAuthorizerMetadata, abi.encode(address(this)) + ) + ); + + // PaymentProcessor + setUpStreamingPaymentProcessor(); + moduleConfigurations.push( + IOrchestratorFactory_v1.ModuleConfig( + streamingPaymentProcessorMetadata, + abi.encode(10, 0, 30) // defaultStart, defaultCliff, defaultEnd + ) + ); + + // Additional Logic Modules + setUpLM_PC_FundingPot_v1(); + moduleConfigurations.push( + IOrchestratorFactory_v1.ModuleConfig( + LM_PC_FundingPot_v1Metadata, abi.encode(contributionToken) + ) + ); + setUpBancorVirtualSupplyBondingCurveFundingManager(); + + // BancorFormula 'formula' is instantiated in the E2EModuleRegistry + } + + function init() private { + //-------------------------------------------------------------------------- + // Orchestrator_v1 Initialization + //-------------------------------------------------------------------------- + IOrchestratorFactory_v1.WorkflowConfig memory workflowConfig = + IOrchestratorFactory_v1.WorkflowConfig({ + independentUpdates: false, + independentUpdateAdmin: address(0) + }); + + orchestrator = + _create_E2E_Orchestrator(workflowConfig, moduleConfigurations); + + contributionToken = + ERC20Mock(address(orchestrator.fundingManager().token())); + // Get the Bancor bonding curve funding manager + bondingCurveFundingManager = IFM_BC_Bancor_Redeeming_VirtualSupply_v1( + address(orchestrator.fundingManager()) + ); + + // Get the streaming payment processor + paymentProcessor = + PP_Streaming_v2(address(orchestrator.paymentProcessor())); + + // Get the funding pot + address[] memory modulesList = orchestrator.listModules(); + for (uint i; i < modulesList.length; ++i) { + if ( + ERC165Upgradeable(modulesList[i]).supportsInterface( + type(ILM_PC_FundingPot_v1).interfaceId + ) + ) { + fundingPot = LM_PC_FundingPot_v1(modulesList[i]); + break; + } + } + + // Set up the bonding curve + issuanceToken.setMinter(address(bondingCurveFundingManager), true); + } + + function test_e2e_FundingPotLifecycle() public { + init(); + + // 2. Grant FUNDING_POT_ADMIN_ROLE to this contract for configuring rounds + fundingPot.grantModuleRole( + fundingPot.FUNDING_POT_ADMIN_ROLE(), address(this) + ); + + // 3. Configure rounds + // Round 1 + uint32 round1Id = fundingPot.createRound( + block.timestamp + 1 days, // start + block.timestamp + 30 days, // end + 1000e18, // cap + address(0), // no hook + bytes(""), // no hook function + false, // auto closure + ILM_PC_FundingPot_v1.AccumulationMode.Disabled // no global caps + ); + + // Round 2 + uint32 round2Id = fundingPot.createRound( + block.timestamp + 1, // start + block.timestamp + 60 days, // end + 750e18, // cap + address(0), // no hook + bytes(""), // no hook function + true, // auto closure + ILM_PC_FundingPot_v1.AccumulationMode.Disabled // no global caps + ); + + // 4. Set access criteria for the rounds + // Add access criteria to round 1 + address[] memory allowedAddresses = new address[](2); + allowedAddresses[0] = contributor1; + allowedAddresses[1] = contributor2; + + address[] memory removedAddresses = new address[](0); + + fundingPot.setAccessCriteria( + round1Id, + uint8(ILM_PC_FundingPot_v1.AccessCriteriaType.LIST), + 0, + address(0), + bytes32(0), + allowedAddresses, + removedAddresses + ); + + // Add access criteria to round 2 + allowedAddresses = new address[](1); + allowedAddresses[0] = contributor3; + + fundingPot.setAccessCriteria( + round2Id, + uint8(ILM_PC_FundingPot_v1.AccessCriteriaType.LIST), + 0, + address(0), + bytes32(0), + allowedAddresses, + removedAddresses + ); + + // 5. Set access criteria privileges for the rounds + fundingPot.setAccessCriteriaPrivileges( + round1Id, + 1, // accessCriteriaId + 500e18, // personalCap + true, // overrideContributionSpan + block.timestamp, // start + 0, // cliff + block.timestamp + 60 days // end + ); + + fundingPot.setAccessCriteriaPrivileges( + round2Id, + 1, // accessCriteriaId + 750e18, // personalCap + true, // overrideContributionSpan + block.timestamp, // start + 0, // cliff + block.timestamp + 60 days // end + ); + + vm.warp(block.timestamp + 1 days); + + // 6. Fund contributors and contribute to rounds + contributionToken.mint(contributor1, 1000e18); + contributionToken.mint(contributor2, 1000e18); + contributionToken.mint(contributor3, 1000e18); + + uint contributor1Amount = 400e18; + uint contributor2Amount = 500e18; + uint contributor3Amount = 750e18; + uint totalContributionForRound1 = + contributor1Amount + contributor2Amount; + + vm.startPrank(contributor1); + contributionToken.approve(address(fundingPot), contributor1Amount); + ILM_PC_FundingPot_v1.UnspentPersonalRoundCap[] memory + unspentPersonalRoundCaps = + new ILM_PC_FundingPot_v1.UnspentPersonalRoundCap[](0); + fundingPot.contributeToRoundFor( + contributor1, + round1Id, + contributor1Amount, + 1, + new bytes32[](0), + unspentPersonalRoundCaps + ); + vm.stopPrank(); + + vm.startPrank(contributor2); + contributionToken.approve(address(fundingPot), contributor2Amount); + unspentPersonalRoundCaps = + new ILM_PC_FundingPot_v1.UnspentPersonalRoundCap[](0); + fundingPot.contributeToRoundFor( + contributor2, + round1Id, + contributor2Amount, + 1, + new bytes32[](0), + unspentPersonalRoundCaps + ); + vm.stopPrank(); + + vm.startPrank(contributor3); + contributionToken.approve(address(fundingPot), contributor3Amount); + + fundingPot.contributeToRoundFor( + contributor3, + round2Id, + contributor3Amount, + 1, + new bytes32[](0), + unspentPersonalRoundCaps + ); + vm.stopPrank(); + + // 7. Fast forward to after rounds end + vm.warp(block.timestamp + 50 days); + + // 8. Close rounds + fundingPot.closeRound(round1Id); + assertEq(fundingPot.roundIdToClosedStatus(round1Id), true); + assertEq(fundingPot.roundIdToClosedStatus(round2Id), true); // round2 is auto closed + assertEq(contributionToken.balanceOf(address(fundingPot)), 0); + assertGt(issuanceToken.balanceOf(address(fundingPot)), 0); + + // 9. Create payment orders for contributors + fundingPot.createPaymentOrdersForContributorsBatch(round1Id, 2); + fundingPot.createPaymentOrdersForContributorsBatch(round2Id, 1); + + // 10. Process payments + vm.prank(address(fundingPot)); + paymentProcessor.processPayments( + IERC20PaymentClientBase_v2(address(fundingPot)) + ); + + // 11. Claim payments + vm.prank(contributor1); + paymentProcessor.claimAll(address(fundingPot)); + + vm.prank(contributor2); + paymentProcessor.claimAll(address(fundingPot)); + + vm.prank(contributor3); + paymentProcessor.claimAll(address(fundingPot)); + + // 12. Verify proportional distribution for round 1 + uint contributor1Issuance = issuanceToken.balanceOf(contributor1); + uint contributor2Issuance = issuanceToken.balanceOf(contributor2); + uint totalIssuanceForRound1 = + contributor1Issuance + contributor2Issuance; + + // Calculate the expected proportions (scaled by 1e18 for precision) + uint contributor1ExpectedProportion = + (contributor1Amount * 1e18) / totalContributionForRound1; + uint contributor1ActualProportion = + (contributor1Issuance * 1e18) / totalIssuanceForRound1; + + uint contributor2ExpectedProportion = + (contributor2Amount * 1e18) / totalContributionForRound1; + uint contributor2ActualProportion = + (contributor2Issuance * 1e18) / totalIssuanceForRound1; + + // Using 0.001e18 (0.1%) as the maximum relative error + assertApproxEqRel( + contributor1ActualProportion, + contributor1ExpectedProportion, + 0.001e18 + ); + + assertApproxEqRel( + contributor2ActualProportion, + contributor2ExpectedProportion, + 0.001e18 + ); + + // verify round 2 contributor + assertGt(issuanceToken.balanceOf(contributor3), 0); + } +} diff --git a/test/mocks/modules/fundingManager/FundingManagerV1Mock.sol b/test/mocks/modules/fundingManager/FundingManagerV1Mock.sol index d2fc6bffc..36e69d90a 100644 --- a/test/mocks/modules/fundingManager/FundingManagerV1Mock.sol +++ b/test/mocks/modules/fundingManager/FundingManagerV1Mock.sol @@ -11,6 +11,7 @@ import { IOrchestrator_v1 } from "src/modules/base/Module_v1.sol"; import {IFundingManager_v1} from "@fm/IFundingManager_v1.sol"; +import {ERC20Issuance_v1} from "@ex/token/ERC20Issuance_v1.sol"; // External Libraries import {SafeERC20} from "@oz/token/ERC20/utils/SafeERC20.sol"; @@ -32,6 +33,7 @@ contract FundingManagerV1Mock is IFundingManager_v1, Module_v1 { // using SafeERC20 for IERC20; IERC20 private _token; + ERC20Issuance_v1 private _bondingToken; function init( IOrchestrator_v1 orchestrator_, @@ -39,6 +41,10 @@ contract FundingManagerV1Mock is IFundingManager_v1, Module_v1 { bytes memory ) public override(Module_v1) initializer { __Module_init(orchestrator_, metadata); + _bondingToken = new ERC20Issuance_v1( + "Bonding Token", "BOND", 18, type(uint).max - 1 + ); + _bondingToken.setMinter(address(this), true); } function setToken(IERC20 newToken) public { @@ -73,4 +79,17 @@ contract FundingManagerV1Mock is IFundingManager_v1, Module_v1 { // _token.safeTransfer(to, amount); _token.transfer(to, amount); } + + function getIssuanceToken() public view returns (address) { + return address(_bondingToken); + } + + function calculatePurchaseReturn(uint amount) public pure returns (uint) { + return amount; + } + + function buyFor(address to, uint amount, uint) public { + _token.transferFrom(_msgSender(), address(this), amount); + _bondingToken.mint(to, amount); + } } diff --git a/test/mocks/modules/logicModule/LM_PC_FundingPot_v1Mock.sol b/test/mocks/modules/logicModule/LM_PC_FundingPot_v1Mock.sol new file mode 100644 index 000000000..3d7ac2042 --- /dev/null +++ b/test/mocks/modules/logicModule/LM_PC_FundingPot_v1Mock.sol @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: LGPL-3.0-only +pragma solidity 0.8.23; + +// External Dependencies +import "@oz/token/ERC721/ERC721.sol"; +import "@oz/token/ERC721/extensions/ERC721URIStorage.sol"; +import "@oz/access/Ownable.sol"; + +contract ERC721Mock is ERC721URIStorage, Ownable { + uint private _nextTokenId; + string public baseURI; + + constructor(string memory name, string memory symbol) + ERC721(name, symbol) + Ownable(msg.sender) + {} + + function _baseURI() internal view override returns (string memory) { + return baseURI; + } + + function setBaseURI(string memory newBaseURI) public onlyOwner { + baseURI = newBaseURI; + } + + function mint(address to) public onlyOwner returns (uint) { + uint tokenId = _nextTokenId; + _safeMint(to, tokenId); + _nextTokenId++; + + return tokenId; + } + + function setTokenURI(uint tokenId, string memory tokenURI) + public + onlyOwner + { + _setTokenURI(tokenId, tokenURI); + } +} + +// Mock contracts for testing hooks +contract MockHookContract { + bool public hookExecuted; + + function executeHook() external { + hookExecuted = true; + } +} + +contract MockFailingHookContract { + function executeHook() external pure { + revert("Hook execution failed"); + } +} diff --git a/test/mocks/modules/logicModule/LM_PC_FundingPot_v2NFTMock.sol b/test/mocks/modules/logicModule/LM_PC_FundingPot_v2NFTMock.sol new file mode 100644 index 000000000..3d7ac2042 --- /dev/null +++ b/test/mocks/modules/logicModule/LM_PC_FundingPot_v2NFTMock.sol @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: LGPL-3.0-only +pragma solidity 0.8.23; + +// External Dependencies +import "@oz/token/ERC721/ERC721.sol"; +import "@oz/token/ERC721/extensions/ERC721URIStorage.sol"; +import "@oz/access/Ownable.sol"; + +contract ERC721Mock is ERC721URIStorage, Ownable { + uint private _nextTokenId; + string public baseURI; + + constructor(string memory name, string memory symbol) + ERC721(name, symbol) + Ownable(msg.sender) + {} + + function _baseURI() internal view override returns (string memory) { + return baseURI; + } + + function setBaseURI(string memory newBaseURI) public onlyOwner { + baseURI = newBaseURI; + } + + function mint(address to) public onlyOwner returns (uint) { + uint tokenId = _nextTokenId; + _safeMint(to, tokenId); + _nextTokenId++; + + return tokenId; + } + + function setTokenURI(uint tokenId, string memory tokenURI) + public + onlyOwner + { + _setTokenURI(tokenId, tokenURI); + } +} + +// Mock contracts for testing hooks +contract MockHookContract { + bool public hookExecuted; + + function executeHook() external { + hookExecuted = true; + } +} + +contract MockFailingHookContract { + function executeHook() external pure { + revert("Hook execution failed"); + } +} diff --git a/test/unit/modules/logicModule/LM_PC_FundingPot_v1.t.sol b/test/unit/modules/logicModule/LM_PC_FundingPot_v1.t.sol new file mode 100644 index 000000000..41f676f47 --- /dev/null +++ b/test/unit/modules/logicModule/LM_PC_FundingPot_v1.t.sol @@ -0,0 +1,6208 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.23; + +// Internal +import { + ModuleTest, + IModule_v1, + IOrchestrator_v1 +} from "test/unit/modules/ModuleTest.sol"; +import {OZErrors} from "test/testUtilities/OZErrors.sol"; + +// External +import {Clones} from "@oz/proxy/Clones.sol"; + +// Mocks +import { + IERC20PaymentClientBase_v2, + ERC20PaymentClientBaseV2Mock, + ERC20Mock +} from "test/mocks/modules/paymentClient/ERC20PaymentClientBaseV2Mock.sol"; + +import { + ERC721Mock, + MockHookContract, + MockFailingHookContract +} from "test/mocks/modules/logicModule/LM_PC_FundingPot_v1Mock.sol"; + +import {IBondingCurveBase_v1} from + "@fm/bondingCurve/interfaces/IBondingCurveBase_v1.sol"; + +// System under Test (SuT) +import {LM_PC_FundingPot_v1_Exposed} from + "test/unit/modules/logicModule/LM_PC_FundingPot_v1_Exposed.sol"; +import {ILM_PC_FundingPot_v1} from + "src/modules/logicModule/interfaces/ILM_PC_FundingPot_v1.sol"; + +/** + * @title Inverter Funding Pot Logic Module Tests + * + * @notice Tests for the funding pot logic module + * + * @dev This test contract follows the standard testing pattern showing: + * - Initialization tests + * - External function tests + * - Internal function tests through exposed functions + * - Use of Gherkin for test documentation + * + * @author Inverter Network + */ +contract LM_PC_FundingPot_v1_Test is ModuleTest { + // ------------------------------------------------------------------------- + // Constants + + bytes32 internal constant FUNDING_POT_ADMIN_ROLE = "FUNDING_POT_ADMIN"; + address contributor1_; + address contributor2_; + address contributor3_; + + // ------------------------------------------------------------------------- + // State + + // SuT + + LM_PC_FundingPot_v1_Exposed fundingPot; + + // Default round parameters for testing + RoundParams private _defaultRoundParams; + RoundParams private _editedRoundParams; + ILM_PC_FundingPot_v1.UnspentPersonalRoundCap[] private + _unspentPersonalRoundCaps; + + // Struct to hold round parameters + struct RoundParams { + uint roundStart; + uint roundEnd; + uint roundCap; + address hookContract; + bytes hookFunction; + bool autoClosure; + ILM_PC_FundingPot_v1.AccumulationMode accumulationMode; + } + + ERC721Mock mockNFTContract = new ERC721Mock("NFT Mock", "NFT"); + MockFailingHookContract failingHook = new MockFailingHookContract(); + + address[] public removedAddresses; + + // ------------------------------------------------------------------------- + // Setup + + function setUp() public { + // Deploy the SuT + address impl = address(new LM_PC_FundingPot_v1_Exposed()); + fundingPot = LM_PC_FundingPot_v1_Exposed(Clones.clone(impl)); + + // Mint tokens to the contributors + contributor1_ = address(0xBeef); + contributor2_ = address(0xDEAD); + contributor3_ = address(0xCAFE); + + _token.mint(contributor1_, 10_000); + _token.mint(contributor2_, 10_000); + _token.mint(contributor3_, 10_000); + + // Setup the module to test + _setUpOrchestrator(fundingPot); + + // Initiate the Logic Module with the metadata and config data + fundingPot.init(_orchestrator, _METADATA, abi.encode("")); + + _authorizer.setIsAuthorized(address(this), true); + + // Set the block timestamp + vm.warp(block.timestamp + _orchestrator.MODULE_UPDATE_TIMELOCK()); + + // Initialize default round parameters + _defaultRoundParams = RoundParams({ + roundStart: block.timestamp + 1 days, + roundEnd: block.timestamp + 2 days, + roundCap: 1000, + hookContract: address(0), + hookFunction: bytes(""), + autoClosure: false, + accumulationMode: ILM_PC_FundingPot_v1.AccumulationMode.All + }); + + // Initialize edited round parameters + _editedRoundParams = _helper_createEditRoundParams( + block.timestamp + 3 days, + block.timestamp + 4 days, + 2000, + address(0x1), + bytes("test"), + true, + ILM_PC_FundingPot_v1.AccumulationMode.All + ); + + removedAddresses = new address[](0); + } + + // ------------------------------------------------------------------------- + // Test: Initialization + + function testInit() public override(ModuleTest) { + assertEq(address(fundingPot.orchestrator()), address(_orchestrator)); + } + + function testSupportsInterface() public { + assertTrue( + fundingPot.supportsInterface(type(ILM_PC_FundingPot_v1).interfaceId) + ); + } + + function testReinitFails() public override(ModuleTest) { + vm.expectRevert(OZErrors.Initializable__InvalidInitialization); + fundingPot.init(_orchestrator, _METADATA, abi.encode("")); + } + + // ------------------------------------------------------------------------- + // Test External (public + external) + + /* Test createRound() + ├── Given user does not have FUNDING_POT_ADMIN_ROLE + │ └── When user attempts to create a round + │ └── Then it should revert + │ + └── Given user has FUNDING_POT_ADMIN_ROLE + ├── And round start < block.timestamp + │ └── When user attempts to create a round + │ └── Then it should revert + │ + ├── And round end time == 0 + ├── And round cap == 0 + │ └── When user attempts to create a round + │ └── Then it should revert + │ + ├── And round end time is set + ├── And round end != 0 + ├── And round end < round start + │ └── When user attempts to create a round + │ └── Then it should revert + │ + ├── And hook contract is set but hook function is not set + │ └── When user attempts to create a round + │ └── Then it should revert + │ + ├── And hook function is set but hook contract is not set + │ └── When user attempts to create a round + │ └── Then it should revert + │ + └── And all the valid parameters are provided + └── When user attempts to create a round + └── Then it should not be active and should return the round id + */ + + function testCreateRound_revertsGivenUserIsNotFundingPotAdmin(address user_) + public + { + vm.assume(user_ != address(0) && user_ != address(this)); + vm.startPrank(user_); + bytes32 roleId = _authorizer.generateRoleId( + address(fundingPot), fundingPot.FUNDING_POT_ADMIN_ROLE() + ); + vm.expectRevert( + abi.encodeWithSelector( + IModule_v1.Module__CallerNotAuthorized.selector, roleId, user_ + ) + ); + RoundParams memory params = _defaultRoundParams; + + fundingPot.createRound( + params.roundStart, + params.roundEnd, + params.roundCap, + params.hookContract, + params.hookFunction, + params.autoClosure, + params.accumulationMode + ); + vm.stopPrank(); + } + + function testCreateRound_revertsGivenRoundStartIsInThePast(uint roundStart_) + public + { + vm.assume(roundStart_ < block.timestamp); + RoundParams memory params = _defaultRoundParams; + + params.roundStart = roundStart_; + vm.expectRevert( + abi.encodeWithSelector( + ILM_PC_FundingPot_v1 + .Module__LM_PC_FundingPot__InvalidInput + .selector + ) + ); + fundingPot.createRound( + params.roundStart, + params.roundEnd, + params.roundCap, + params.hookContract, + params.hookFunction, + params.autoClosure, + params.accumulationMode + ); + } + + function testCreateRound_revertsGivenRoundEndTimeAndCapAreBothZero() + public + { + RoundParams memory params = _defaultRoundParams; + + params.roundEnd = 0; + params.roundCap = 0; + vm.expectRevert( + abi.encodeWithSelector( + ILM_PC_FundingPot_v1 + .Module__LM_PC_FundingPot__InvalidInput + .selector + ) + ); + fundingPot.createRound( + params.roundStart, + params.roundEnd, + params.roundCap, + params.hookContract, + params.hookFunction, + params.autoClosure, + params.accumulationMode + ); + } + + function testCreateRound_revertsGivenRoundEndTimeIsBeforeRoundStart( + uint roundEnd_ + ) public { + RoundParams memory params = _defaultRoundParams; + + vm.assume(roundEnd_ != 0 && roundEnd_ < params.roundStart); + params.roundEnd = roundEnd_; + vm.expectRevert( + abi.encodeWithSelector( + ILM_PC_FundingPot_v1 + .Module__LM_PC_FundingPot__InvalidInput + .selector + ) + ); + fundingPot.createRound( + params.roundStart, + params.roundEnd, + params.roundCap, + params.hookContract, + params.hookFunction, + params.autoClosure, + params.accumulationMode + ); + } + + /* Test Fuzz createRound() + ├── Given all the valid parameters are provided + │ └── When user attempts to create a round + │ └── Then it should not be active and should return the round id + */ + + function testCreateRound() public { + RoundParams memory params = _defaultRoundParams; + + fundingPot.createRound( + params.roundStart, + params.roundEnd, + params.roundCap, + params.hookContract, + params.hookFunction, + params.autoClosure, + params.accumulationMode + ); + + uint32 roundId = fundingPot.roundCount(); + + // Retrieve the stored parameters + ( + uint storedRoundStart, + uint storedRoundEnd, + uint storedRoundCap, + address storedHookContract, + bytes memory storedHookFunction, + bool storedAutoClosure, + ILM_PC_FundingPot_v1.AccumulationMode storedAccumulationMode + ) = fundingPot.getRoundGenericParameters(roundId); + + // Compare with expected values + assertEq(storedRoundStart, params.roundStart); + assertEq(storedRoundEnd, params.roundEnd); + assertEq(storedRoundCap, params.roundCap); + assertEq(storedHookContract, params.hookContract); + assertEq(storedHookFunction, params.hookFunction); + assertEq(storedAutoClosure, params.autoClosure); + assertEq(uint(storedAccumulationMode), uint(params.accumulationMode)); + } + + /* Test editRound() + ├── Given user does not have FUNDING_POT_ADMIN_ROLE + │ └── When user attempts to edit a round + │ └── Then it should revert + │ + └── Given user has FUNDING_POT_ADMIN_ROLE + ├── Given round does not exist + │ └── When user attempts to edit the round + │ └── Then it should revert + │ + ├── Given round is active + │ └── When user attempts to edit the round + │ └── Then it should revert + │ + ├── Given round start time is in the past + │ └── When user attempts to edit a round with this parameter + │ └── Then it should revert + │ + ├── Given round end time == 0 and round cap == 0 + │ └── When user attempts to edit a round with these parameters + │ └── Then it should revert + │ + ├── Given round end time is set and round end < round start + │ └── When user attempts to edit the round + │ └── Then it should revert + │ + ├── Given hook contract is set but hook function is empty + │ └── When user attempts to edit the round + │ └── Then it should revert + │ + ├── Given hook function is set but hook contract is empty + │ └── When user attempts to edit the round + │ └── Then it should revert + │ + └── Given all valid parameters are provided + └── When user attempts to edit the round + └── Then all round details should be successfully updated + ├── roundStart should be updated to the new value + ├── roundEnd should be updated to the new value + ├── roundCap should be updated to the new value + ├── hookContract should be updated to the new value + ├── hookFunction should be updated to the new value + ├── autoClosure should be updated to the new value + └── accumulationMode should be updated to the new value + */ + + function testEditRound_revertsGivenUserIsNotFundingPotAdmin(address user_) + public + { + vm.assume(user_ != address(0) && user_ != address(this)); + testCreateRound(); + uint32 roundId = fundingPot.roundCount(); + + RoundParams memory params = RoundParams({ + roundStart: block.timestamp + 3 days, + roundEnd: block.timestamp + 4 days, + roundCap: 2000, + hookContract: address(0x1), + hookFunction: bytes("test"), + autoClosure: true, + accumulationMode: ILM_PC_FundingPot_v1.AccumulationMode.All + }); + + vm.startPrank(user_); + bytes32 roleId = _authorizer.generateRoleId( + address(fundingPot), fundingPot.FUNDING_POT_ADMIN_ROLE() + ); + vm.expectRevert( + abi.encodeWithSelector( + IModule_v1.Module__CallerNotAuthorized.selector, roleId, user_ + ) + ); + fundingPot.editRound( + roundId, + params.roundStart, + params.roundEnd, + params.roundCap, + params.hookContract, + params.hookFunction, + params.autoClosure, + params.accumulationMode + ); + vm.stopPrank(); + } + + function testEditRound_revertsGivenRoundIsNotCreated() public { + testCreateRound(); + uint32 roundId = fundingPot.roundCount(); + + RoundParams memory params = RoundParams({ + roundStart: block.timestamp + 3 days, + roundEnd: block.timestamp + 4 days, + roundCap: 2000, + hookContract: address(0x1), + hookFunction: bytes("test"), + autoClosure: true, + accumulationMode: ILM_PC_FundingPot_v1.AccumulationMode.All + }); + + vm.expectRevert( + abi.encodeWithSelector( + ILM_PC_FundingPot_v1 + .Module__LM_PC_FundingPot__RoundNotCreated + .selector + ) + ); + fundingPot.editRound( + uint32(roundId + 1), + params.roundStart, + params.roundEnd, + params.roundCap, + params.hookContract, + params.hookFunction, + params.autoClosure, + params.accumulationMode + ); + } + + function testEditRound_revertsGivenRoundIsActive() public { + testCreateRound(); + uint32 roundId = fundingPot.roundCount(); + + RoundParams memory params; + ( + params.roundStart, + params.roundEnd, + params.roundCap, + params.hookContract, + params.hookFunction, + params.autoClosure, + params.accumulationMode + ) = fundingPot.getRoundGenericParameters(roundId); + vm.warp(params.roundStart + 1); + + RoundParams memory params_ = RoundParams({ + roundStart: block.timestamp + 3 days, + roundEnd: block.timestamp + 4 days, + roundCap: 2000, + hookContract: address(0x1), + hookFunction: bytes("test"), + autoClosure: true, + accumulationMode: ILM_PC_FundingPot_v1.AccumulationMode.All + }); + + vm.expectRevert( + abi.encodeWithSelector( + ILM_PC_FundingPot_v1 + .Module__LM_PC_FundingPot__InvalidInput + .selector + ) + ); + fundingPot.editRound( + roundId, + params_.roundStart, + params_.roundEnd, + params_.roundCap, + params_.hookContract, + params_.hookFunction, + params_.autoClosure, + params_.accumulationMode + ); + } + + function testEditRound_revertsGivenRoundStartIsInThePast(uint roundStart_) + public + { + testCreateRound(); + uint32 roundId = fundingPot.roundCount(); + _editedRoundParams; + vm.assume(roundStart_ < block.timestamp); + _editedRoundParams.roundStart = roundStart_; + + vm.expectRevert( + abi.encodeWithSelector( + ILM_PC_FundingPot_v1 + .Module__LM_PC_FundingPot__InvalidInput + .selector + ) + ); + + fundingPot.editRound( + roundId, + _editedRoundParams.roundStart, + _editedRoundParams.roundEnd, + _editedRoundParams.roundCap, + _editedRoundParams.hookContract, + _editedRoundParams.hookFunction, + _editedRoundParams.autoClosure, + _editedRoundParams.accumulationMode + ); + } + + function testEditRound_revertsGivenRoundEndTimeAndCapAreBothZero() public { + testCreateRound(); + uint32 roundId = fundingPot.roundCount(); + + RoundParams memory params = RoundParams({ + roundStart: block.timestamp + 3 days, + roundEnd: 0, + roundCap: 0, + hookContract: address(0x1), + hookFunction: bytes("test"), + autoClosure: true, + accumulationMode: ILM_PC_FundingPot_v1.AccumulationMode.All + }); + + vm.expectRevert( + abi.encodeWithSelector( + ILM_PC_FundingPot_v1 + .Module__LM_PC_FundingPot__InvalidInput + .selector + ) + ); + + fundingPot.editRound( + roundId, + params.roundStart, + params.roundEnd, + params.roundCap, + params.hookContract, + params.hookFunction, + params.autoClosure, + params.accumulationMode + ); + } + + function testEditRound_revertsGivenRoundEndTimeIsBeforeRoundStart( + uint roundEnd_, + uint roundStart_ + ) public { + vm.assume( + roundEnd_ != 0 && roundStart_ > block.timestamp + && roundEnd_ < roundStart_ + ); + testCreateRound(); + uint32 roundId = fundingPot.roundCount(); + + // Get the current round start time + (uint currentRoundStart,,,,,,) = + fundingPot.getRoundGenericParameters(roundId); + + // Ensure roundEnd_ is less than current round start + vm.assume(roundEnd_ < currentRoundStart); + vm.assume(roundEnd_ != 0); + + RoundParams memory params = _helper_createEditRoundParams( + currentRoundStart, + roundEnd_, + 2000, + address(0x1), + bytes("test"), + true, + ILM_PC_FundingPot_v1.AccumulationMode.All + ); + + vm.expectRevert( + abi.encodeWithSelector( + ILM_PC_FundingPot_v1 + .Module__LM_PC_FundingPot__InvalidInput + .selector + ) + ); + + fundingPot.editRound( + roundId, + params.roundStart, + params.roundEnd, + params.roundCap, + params.hookContract, + params.hookFunction, + params.autoClosure, + params.accumulationMode + ); + } + + /* Test editRound() + └── Given a round has been created + ├── And the round is not active + └── When an admin provides valid parameters to edit the round + └── Then all round details should be successfully updated + ├── roundStart should be updated to the new value + ├── roundEnd should be updated to the new value + ├── roundCap should be updated to the new value + ├── hookContract should be updated to the new value + ├── hookFunction should be updated to the new value + ├── autoClosure should be updated to the new value + └── accumulationMode should be updated to the new value + */ + + function testEditRound() public { + testCreateRound(); + uint32 lastRoundId = fundingPot.roundCount(); + + RoundParams memory params = _editedRoundParams; + + fundingPot.editRound( + lastRoundId, + params.roundStart, + params.roundEnd, + params.roundCap, + params.hookContract, + params.hookFunction, + params.autoClosure, + params.accumulationMode + ); + + // Retrieve the stored parameters + ( + uint storedRoundStart, + uint storedRoundEnd, + uint storedRoundCap, + address storedHookContract, + bytes memory storedHookFunction, + bool storedAutoClosure, + ILM_PC_FundingPot_v1.AccumulationMode storedAccumulationMode + ) = fundingPot.getRoundGenericParameters(uint32(lastRoundId)); + + // Compare with expected values + assertEq(storedRoundStart, params.roundStart); + assertEq(storedRoundEnd, params.roundEnd); + assertEq(storedRoundCap, params.roundCap); + assertEq(storedHookContract, params.hookContract); + assertEq(storedHookFunction, params.hookFunction); + assertEq(storedAutoClosure, params.autoClosure); + assertEq(uint(storedAccumulationMode), uint(params.accumulationMode)); + } + + /* Test setAccessCriteria() + ├── Given user does not have FUNDING_POT_ADMIN_ROLE + │ └── When user attempts to set access criteria + │ └── Then it should revert + │ + └── Given user has FUNDING_POT_ADMIN_ROLE + ├── Given round does not exist + │ └── When user attempts to set access criteria + │ └── Then it should revert + │ + ├── Given round is active + │ └── When user attempts to set access criteria + │ └── Then it should revert + │ + ├── Given AccessCriteriaId is greater than MAX_ACCESS_CRITERIA_TYPE + │ └── When user attempts to set access criteria + │ └── Then it should revert + │ + ├── Given AccessCriteriaId is NFT and nftContract is 0x0 + │ └── When user attempts to set access criteria + │ └── Then it should revert + │ + ├── Given AccessCriteriaId is MERKLE and merkleRoot is 0x0 + │ └── When user attempts to set access criteria + │ └── Then it should revert + │ + ├── Given AccessCriteriaId is LIST and allowedAddresses is empty + │ └── When user attempts to set access criteria + │ └── Then it should revert + │ + ├── Given all the valid parameters are provided + │ └── When user attempts to set access criteria + │ └── Then it should not revert + └── Given all the valid parameters and access criteria is set + └── When user attempts to edit access criteria + └── Then it should not revert + */ + + function testFuzzSetAccessCriteria_revertsGivenUserDoesNotHaveFundingPotAdminRole( + uint8 accessCriteriaEnum_, + address user_ + ) public { + vm.assume(accessCriteriaEnum_ >= 0 && accessCriteriaEnum_ <= 4); + vm.assume(user_ != address(0) && user_ != address(this)); + + testCreateRound(); + uint32 roundId = fundingPot.roundCount(); + + ( + address nftContract, + bytes32 merkleRoot, + address[] memory allowedAddresses + ) = _helper_createAccessCriteria(accessCriteriaEnum_, roundId); + + vm.startPrank(user_); + bytes32 roleId = _authorizer.generateRoleId( + address(fundingPot), fundingPot.FUNDING_POT_ADMIN_ROLE() + ); + vm.expectRevert( + abi.encodeWithSelector( + IModule_v1.Module__CallerNotAuthorized.selector, roleId, user_ + ) + ); + fundingPot.setAccessCriteria( + roundId, + accessCriteriaEnum_, + 0, + nftContract, + merkleRoot, + allowedAddresses, + removedAddresses + ); + vm.stopPrank(); + } + + function testFuzzSetAccessCriteria_revertsGivenRoundDoesNotExist( + uint8 accessCriteriaEnum + ) public { + vm.assume(accessCriteriaEnum >= 0 && accessCriteriaEnum <= 4); + + uint32 roundId = fundingPot.roundCount(); + + ( + address nftContract, + bytes32 merkleRoot, + address[] memory allowedAddresses + ) = _helper_createAccessCriteria(accessCriteriaEnum, roundId); + + vm.expectRevert( + abi.encodeWithSelector( + ILM_PC_FundingPot_v1 + .Module__LM_PC_FundingPot__RoundNotCreated + .selector + ) + ); + fundingPot.setAccessCriteria( + roundId, + accessCriteriaEnum, + 0, + nftContract, + merkleRoot, + allowedAddresses, + removedAddresses + ); + } + + function testFuzzSetAccessCriteria_revertsGivenRoundIsActive( + uint8 accessCriteriaEnum + ) public { + vm.assume(accessCriteriaEnum >= 0 && accessCriteriaEnum <= 4); + + testCreateRound(); + uint32 roundId = fundingPot.roundCount(); + + ( + address nftContract, + bytes32 merkleRoot, + address[] memory allowedAddresses + ) = _helper_createAccessCriteria(accessCriteriaEnum, roundId); + + (uint roundStart,,,,,,) = fundingPot.getRoundGenericParameters(roundId); + vm.warp(roundStart + 1); + + vm.expectRevert( + abi.encodeWithSelector( + ILM_PC_FundingPot_v1 + .Module__LM_PC_FundingPot__InvalidInput + .selector + ) + ); + fundingPot.setAccessCriteria( + roundId, + accessCriteriaEnum, + 0, + nftContract, + merkleRoot, + allowedAddresses, + removedAddresses + ); + } + + function testFuzzSetAccessCriteria_revertsGivenAccessCriteriaIdIsGreaterThanMaxAccessCriteriaId( + uint8 accessCriteriaEnum + ) public { + vm.assume(accessCriteriaEnum > 4); + + testCreateRound(); + uint32 roundId = fundingPot.roundCount(); + + ( + address nftContract, + bytes32 merkleRoot, + address[] memory allowedAddresses + ) = _helper_createAccessCriteria(accessCriteriaEnum, roundId); + + (uint roundStart,,,,,,) = fundingPot.getRoundGenericParameters(roundId); + vm.warp(roundStart + 1); + + vm.expectRevert( + abi.encodeWithSelector( + ILM_PC_FundingPot_v1 + .Module__LM_PC_FundingPot__InvalidAccessCriteriaType + .selector + ) + ); + fundingPot.setAccessCriteria( + roundId, + accessCriteriaEnum, + 0, + nftContract, + merkleRoot, + allowedAddresses, + removedAddresses + ); + } + + function testSetAccessCriteria_revertsGivenAccessCriteriaIdIsNFTAndNftContractIsZero( + ) public { + uint8 accessCriteriaEnum = + uint8(ILM_PC_FundingPot_v1.AccessCriteriaType.NFT); + + testCreateRound(); + uint32 roundId = fundingPot.roundCount(); + + ( + address nftContract, + bytes32 merkleRoot, + address[] memory allowedAddresses + ) = _helper_createAccessCriteria(accessCriteriaEnum, roundId); + nftContract = address(0); + + vm.expectRevert( + abi.encodeWithSelector( + ILM_PC_FundingPot_v1 + .Module__LM_PC_FundingPot__MissingRequiredAccessCriteriaData + .selector + ) + ); + fundingPot.setAccessCriteria( + roundId, + accessCriteriaEnum, + 0, + nftContract, + merkleRoot, + allowedAddresses, + removedAddresses + ); + } + + function testSetAccessCriteria_revertsGivenAccessCriteriaIdIsMerkleAndMerkleRootIsZero( + ) public { + uint8 accessCriteriaEnum = + uint8(ILM_PC_FundingPot_v1.AccessCriteriaType.MERKLE); + + testCreateRound(); + uint32 roundId = fundingPot.roundCount(); + + ( + address nftContract, + bytes32 merkleRoot, + address[] memory allowedAddresses + ) = _helper_createAccessCriteria(accessCriteriaEnum, roundId); + merkleRoot = bytes32(uint(0x0)); + + vm.expectRevert( + abi.encodeWithSelector( + ILM_PC_FundingPot_v1 + .Module__LM_PC_FundingPot__MissingRequiredAccessCriteriaData + .selector + ) + ); + fundingPot.setAccessCriteria( + roundId, + accessCriteriaEnum, + 0, + nftContract, + merkleRoot, + allowedAddresses, + removedAddresses + ); + } + + function testSetAccessCriteria_revertsGivenAccessCriteriaIdIsListAndAllowedAddressesIsEmpty( + ) public { + uint8 accessCriteriaEnum = + uint8(ILM_PC_FundingPot_v1.AccessCriteriaType.LIST); + + testCreateRound(); + uint32 roundId = fundingPot.roundCount(); + + ( + address nftContract, + bytes32 merkleRoot, + address[] memory allowedAddresses + ) = _helper_createAccessCriteria(accessCriteriaEnum, roundId); + allowedAddresses = new address[](0); + + vm.expectRevert( + abi.encodeWithSelector( + ILM_PC_FundingPot_v1 + .Module__LM_PC_FundingPot__MissingRequiredAccessCriteriaData + .selector + ) + ); + fundingPot.setAccessCriteria( + roundId, + accessCriteriaEnum, + 0, + nftContract, + merkleRoot, + allowedAddresses, + removedAddresses + ); + } + + function testFuzzSetAccessCriteria(uint8 accessCriteriaEnum) public { + vm.assume(accessCriteriaEnum >= 0 && accessCriteriaEnum <= 4); + + testCreateRound(); + uint32 roundId = fundingPot.roundCount(); + uint8 accessCriteriaId = 1; + + ( + address nftContract, + bytes32 merkleRoot, + address[] memory allowedAddresses + ) = _helper_createAccessCriteria(accessCriteriaEnum, roundId); + + fundingPot.setAccessCriteria( + roundId, + accessCriteriaEnum, + 0, + nftContract, + merkleRoot, + allowedAddresses, + removedAddresses + ); + + ( + bool isOpen, + address retrievedNftContract, + bytes32 retrievedMerkleRoot, + bool hasAccess + ) = fundingPot.getRoundAccessCriteria(uint32(roundId), accessCriteriaId); + + assertEq(isOpen, accessCriteriaEnum == 1); + assertEq(retrievedNftContract, nftContract); + assertEq(retrievedMerkleRoot, merkleRoot); + if (accessCriteriaEnum == 1 || accessCriteriaEnum == 4) { + assertTrue(hasAccess); + } else { + assertFalse(hasAccess); + } + } + + function testFuzzEditAccessCriteria( + uint8 oldAccessCriteriaEnum, + uint8 newAccessCriteriaEnum + ) public { + vm.assume(oldAccessCriteriaEnum >= 1 && oldAccessCriteriaEnum <= 4); + vm.assume( + newAccessCriteriaEnum != oldAccessCriteriaEnum + && newAccessCriteriaEnum >= 1 && newAccessCriteriaEnum <= 4 + ); + + testFuzzSetAccessCriteria(oldAccessCriteriaEnum); + + uint32 roundId = fundingPot.roundCount(); + ( + address nftContract, + bytes32 merkleRoot, + address[] memory allowedAddresses + ) = _helper_createAccessCriteria(newAccessCriteriaEnum, roundId); + + vm.expectEmit(true, true, true, false); + emit ILM_PC_FundingPot_v1.AccessUpdated( + true, roundId, uint8(newAccessCriteriaEnum) + ); + fundingPot.setAccessCriteria( + roundId, + newAccessCriteriaEnum, + 1, + nftContract, + merkleRoot, + allowedAddresses, + removedAddresses + ); + } + + /* Test removeAllowlistedAddresses + ├── Given user does not have FUNDING_POT_ADMIN_ROLE + │ └── When user attempts to remove allowlisted addresses + │ └── Then it should revert + │ + ├── Given the round does not exist + │ └── When user attempts to remove allowlisted addresses + │ └── Then it should revert + │ + ├── Given the round has already started + │ └── When user attempts to remove allowlisted addresses + │ └── Then it should revert + │ + └── Given a valid round with LIST access criteria + └── When admin removes allowlisted addresses + └── Then the addresses should be removed from the allowlist + */ + function testRemoveAllowlistedAddresses() public { + testCreateRound(); + uint32 roundId = fundingPot.roundCount(); + + uint8 accessCriteriaId = 1; + uint8 accessType = uint8(ILM_PC_FundingPot_v1.AccessCriteriaType.LIST); + ( + address nftContract, + bytes32 merkleRoot, + address[] memory allowedAddresses + ) = _helper_createAccessCriteria(accessType, roundId); + + fundingPot.setAccessCriteria( + roundId, + accessType, + 0, + nftContract, + merkleRoot, + allowedAddresses, + removedAddresses + ); + + address[] memory addressesToRemove = new address[](2); + addressesToRemove[0] = address(0x2); + addressesToRemove[1] = contributor2_; + + fundingPot.removeAllowlistedAddresses( + roundId, accessCriteriaId, addressesToRemove + ); + + bool hasAccess = fundingPot.exposed_checkAccessCriteriaEligibility( + roundId, accessCriteriaId, new bytes32[](0), contributor2_ + ); + + assertFalse(hasAccess); + + bool otherAddressesHaveAccess = fundingPot + .exposed_checkAccessCriteriaEligibility( + roundId, accessCriteriaId, new bytes32[](0), address(0x3) + ); + + assertTrue(otherAddressesHaveAccess); + } + + /* + ├── Given the round exists + | ├── Given an initial access criteria list with addresses [0x1, 0x2, 0x3] + │ │ └── When checking access for address 0x3 + │ │ └── Then access should be granted + │ │ + │ └── Given an update to the access criteria + │ ├── When adding new addresses [0x4, 0x5] + │ ├── And removing address [0x3] + │ │ └── Then access for address 0x3 should be revoked + │ └── And the final allowed list should contain [0x1, 0x2, 0x4, 0x5] + */ + function testRemoveAllowAddressesSetAccessCriteria() public { + testCreateRound(); + uint32 roundId = fundingPot.roundCount(); + + uint8 accessCriteriaId = 1; + uint8 accessType = uint8(ILM_PC_FundingPot_v1.AccessCriteriaType.LIST); + ( + address nftContract, + bytes32 merkleRoot, + address[] memory allowedAddresses + ) = _helper_createAccessCriteria(accessType, roundId); + + allowedAddresses = new address[](3); + allowedAddresses[0] = address(0x1); + allowedAddresses[1] = address(0x2); + allowedAddresses[2] = address(0x3); + + fundingPot.setAccessCriteria( + roundId, + accessType, + 0, + nftContract, + merkleRoot, + allowedAddresses, + removedAddresses + ); + + bool hasAccess = fundingPot.exposed_checkAccessCriteriaEligibility( + roundId, accessCriteriaId, new bytes32[](0), address(0x3) + ); + assertTrue(hasAccess); + //Admin wants to give access to two new users and removed one user + allowedAddresses = new address[](4); + allowedAddresses[0] = address(0x1); + allowedAddresses[1] = address(0x2); + allowedAddresses[2] = address(0x4); + allowedAddresses[3] = address(0x5); + + removedAddresses = new address[](1); + removedAddresses[0] = address(0x3); + + //Edit the AccessCriteria + fundingPot.setAccessCriteria( + roundId, + accessType, + 1, + nftContract, + merkleRoot, + allowedAddresses, + removedAddresses + ); + + hasAccess = fundingPot.exposed_checkAccessCriteriaEligibility( + roundId, accessCriteriaId, new bytes32[](0), address(0x3) + ); + assertFalse(hasAccess); + } + + /* Test: setAccessCriteriaPrivileges() + ├── Given user does not have FUNDING_POT_ADMIN_ROLE + │ └── When user attempts to set access criteria privileges + │ └── Then it should revert + │ + └── Given user has FUNDING_POT_ADMIN_ROLE + └── Given all valid parameters are provided + └── When user attempts to set access criteria privileges + ├── Then it should not revert + └── Then the access criteria privileges should be updated + */ + + function testFuzzSetAccessCriteriaPrivileges_revertsGivenUserDoesNotHaveFundingPotAdminRole( + uint8 accessCriteriaEnum, + address user_ + ) public { + vm.assume(accessCriteriaEnum >= 0 && accessCriteriaEnum <= 4); + vm.assume(user_ != address(0) && user_ != address(this)); + + uint32 roundId = fundingPot.createRound( + _defaultRoundParams.roundStart, + _defaultRoundParams.roundEnd, + _defaultRoundParams.roundCap, + _defaultRoundParams.hookContract, + _defaultRoundParams.hookFunction, + _defaultRoundParams.autoClosure, + _defaultRoundParams.accumulationMode + ); + + ( + address nftContract, + bytes32 merkleRoot, + address[] memory allowedAddresses + ) = _helper_createAccessCriteria(accessCriteriaEnum, roundId); + + fundingPot.setAccessCriteria( + roundId, + accessCriteriaEnum, + 0, + nftContract, + merkleRoot, + allowedAddresses, + removedAddresses + ); + + vm.startPrank(user_); + bytes32 roleId = _authorizer.generateRoleId( + address(fundingPot), fundingPot.FUNDING_POT_ADMIN_ROLE() + ); + vm.expectRevert( + abi.encodeWithSelector( + IModule_v1.Module__CallerNotAuthorized.selector, roleId, user_ + ) + ); + + fundingPot.setAccessCriteriaPrivileges(roundId, 1, 1000, false, 0, 0, 0); + vm.stopPrank(); + } + + function testFuzzSetAccessCriteriaPrivileges_worksGivenAllConditionsMet( + uint8 accessCriteriaEnum + ) public { + vm.assume(accessCriteriaEnum > 0 && accessCriteriaEnum <= 4); + uint32 roundId = fundingPot.createRound( + _defaultRoundParams.roundStart, + _defaultRoundParams.roundEnd, + _defaultRoundParams.roundCap, + _defaultRoundParams.hookContract, + _defaultRoundParams.hookFunction, + _defaultRoundParams.autoClosure, + _defaultRoundParams.accumulationMode + ); + + ( + address nftContract, + bytes32 merkleRoot, + address[] memory allowedAddresses + ) = _helper_createAccessCriteria(accessCriteriaEnum, roundId); + + uint8 accessCriteriaId = 1; + + fundingPot.setAccessCriteria( + roundId, + accessCriteriaEnum, + 0, + nftContract, + merkleRoot, + allowedAddresses, + removedAddresses + ); + + fundingPot.setAccessCriteriaPrivileges( + roundId, accessCriteriaId, 1000, false, 0, 0, 0 + ); + + ( + uint personalCap, + bool overrideContributionSpan, + uint start, + uint cliff, + uint end + ) = fundingPot.getRoundAccessCriteriaPrivileges( + roundId, accessCriteriaId + ); + + assertEq(personalCap, 1000); + assertEq(overrideContributionSpan, false); + assertEq(start, 0); + assertEq(cliff, 0); + assertEq(end, 0); + } + + /* Test: getRoundAccessCriteriaPrivileges() + ├── Given the access criteria does not exist + │ └── When user attempts to get access criteria privileges + │ └── Then it should return default values + + */ + function testFuzzGetRoundAccessCriteriaPrivileges_returnsDefaultValuesGivenInvalidAccessCriteriaId( + uint8 accessCriteriaEnum + ) public { + vm.assume(accessCriteriaEnum > 4); + + RoundParams memory params = _defaultRoundParams; + + uint32 roundId = fundingPot.createRound( + params.roundStart, + params.roundEnd, + params.roundCap, + params.hookContract, + params.hookFunction, + params.autoClosure, + params.accumulationMode + ); + + ( + uint personalCap, + bool overrideContributionSpan, + uint start, + uint cliff, + uint end + ) = fundingPot.getRoundAccessCriteriaPrivileges( + roundId, accessCriteriaEnum + ); + + assertEq(personalCap, 0); + assertFalse(overrideContributionSpan); + assertEq(start, 0); + assertEq(cliff, 0); + assertEq(end, 0); + } + + /* Test: contributeToRoundFor() unhappy paths + ├── Given the round has not started yet + │ └── When the user contributes to the round + │ └── Then the transaction should revert + │ + ├── Given the round has ended + │ └── When the user contributes to the round + │ └── Then the transaction should revert + │ + ├── Given a round has been configured with generic round configuration and access criteria + │ And the round has started + │ And the round has not ended + │ And the user has approved their contribution + │ And the total contribution cap is not yet reached + │ ├── Given the access criteria is an NFT + │ │ └── And the user does not fulfill the access criteria + │ │ └── When the user contributes to the round + │ │ └── Then the transaction should revert + │ │ + │ ├── Given the access criteria is a Merkle Root + │ │ └── And the user does not fulfill the access criteria + │ │ └── When the user contributes to the round + │ │ └── Then the transaction should revert + │ │ + │ ├── Given the access criteria is a List + │ │ └── And the user does not fulfill the access criteria + │ │ └── When the user contributes to the round + │ │ └── Then the transaction should revert + │ │ + │ ├── Given the user tries to contribute with a zero amount + │ │ └── When the user contributes to the round + │ │ └── Then the transaction should revert + │ │ + │ ├── Given a user has already contributed up to their personal cap + │ │ └── When the user attempts to contribute again + │ │ └── Then the transaction should revert + │ │ + │ ├── Given the user tries to use unspent caps not from a previous round(i.e. using the current or a future round's ID) + │ │ └── When the user attempts to contribute + │ │ └── Then the transaction should revert + │ │ + │ ├── Given the user tries to use unspent caps with round IDs that are not strictly increasing + │ │ └── When the user attempts to contribute + │ │ └── Then the transaction should revert + │ │ + │ ├── Given the user tries to use unspent caps with non-contiguous round IDs + │ │ └── When the user attempts to contribute + │ │ └── Then the transaction should revert + │ │ + └── Given the round contribution cap is reached + └── When the user attempts to contribute + └── Then the transaction should revert + + */ + + function testContributeToRoundFor_revertsGivenContributionIsBeforeRoundStart( + ) public { + testCreateRound(); + + uint32 roundId = fundingPot.roundCount(); + uint8 accessCriteriaId = 1; + uint8 accessType = uint8(ILM_PC_FundingPot_v1.AccessCriteriaType.OPEN); + uint amount = 250; + + ( + address nftContract, + bytes32 merkleRoot, + address[] memory allowedAddresses + ) = _helper_createAccessCriteria(accessType, roundId); + + fundingPot.setAccessCriteria( + roundId, + accessType, + 0, + nftContract, + merkleRoot, + allowedAddresses, + removedAddresses + ); + fundingPot.setAccessCriteriaPrivileges( + roundId, accessCriteriaId, 500, false, 0, 0, 0 + ); + + // Approve + vm.prank(contributor1_); + _token.approve(address(fundingPot), amount); + + vm.expectRevert( + abi.encodeWithSelector( + ILM_PC_FundingPot_v1 + .Module__LM_PC_FundingPot__RoundHasNotStarted + .selector + ) + ); + + vm.prank(contributor1_); + fundingPot.contributeToRoundFor( + contributor1_, + roundId, + amount, + accessCriteriaId, + new bytes32[](0), + _unspentPersonalRoundCaps + ); + } + + function testContributeToRoundFor_revertsGivenContributionIsAfterRoundEnd() + public + { + testCreateRound(); + + uint32 roundId = fundingPot.roundCount(); + uint8 accessCriteriaId = 1; + uint8 accessType = uint8(ILM_PC_FundingPot_v1.AccessCriteriaType.OPEN); + + uint amount = 250; + + ( + address nftContract, + bytes32 merkleRoot, + address[] memory allowedAddresses + ) = _helper_createAccessCriteria(accessType, roundId); + + fundingPot.setAccessCriteria( + roundId, + accessType, + 0, + nftContract, + merkleRoot, + allowedAddresses, + removedAddresses + ); + fundingPot.setAccessCriteriaPrivileges( + roundId, accessCriteriaId, 500, false, 0, 0, 0 + ); + + (uint roundStart,,,,,,) = fundingPot.getRoundGenericParameters(roundId); + vm.warp(roundStart + 10 days); + + // Approve + vm.prank(contributor1_); + _token.approve(address(fundingPot), 500); + + vm.expectRevert( + abi.encodeWithSelector( + ILM_PC_FundingPot_v1 + .Module__LM_PC_FundingPot__RoundHasEnded + .selector + ) + ); + + vm.prank(contributor1_); + fundingPot.contributeToRoundFor( + contributor1_, + roundId, + amount, + accessCriteriaId, + new bytes32[](0), + _unspentPersonalRoundCaps + ); + } + + function testContributeToRoundFor_revertsGivenNFTAccessCriteriaIsNotMet() + public + { + uint8 accessCriteriaId = 1; + uint8 accessType = uint8(ILM_PC_FundingPot_v1.AccessCriteriaType.NFT); + _helper_setupRoundWithAccessCriteria(accessType); + + uint32 roundId = fundingPot.roundCount(); + + uint amount = 250; + + (uint roundStart,,,,,,) = fundingPot.getRoundGenericParameters(roundId); + vm.warp(roundStart + 1); + + // Approve + vm.prank(contributor1_); + _token.approve(address(fundingPot), amount); + + mockNFTContract.balanceOf(contributor1_); + + vm.expectRevert( + abi.encodeWithSelector( + ILM_PC_FundingPot_v1 + .Module__LM_PC_FundingPot__AccessCriteriaFailed + .selector + ) + ); + + vm.prank(contributor1_); + fundingPot.contributeToRoundFor( + contributor1_, + roundId, + amount, + accessCriteriaId, + new bytes32[](0), + _unspentPersonalRoundCaps + ); + } + + function testContributeToRoundFor_revertsGivenMerkleRootAccessCriteriaIsNotMet( + ) public { + testCreateRound(); + + uint32 roundId = fundingPot.roundCount(); + uint8 accessCriteriaId = 1; + uint8 accessType = uint8(ILM_PC_FundingPot_v1.AccessCriteriaType.MERKLE); + uint amount = 250; + + (,,,, bytes32[] memory proofB) = _helper_generateMerkleTreeForTwoLeaves( + contributor1_, contributor2_, roundId + ); + + ( + address nftContract, + bytes32 merkleRoot, + address[] memory allowedAddresses + ) = _helper_createAccessCriteria(accessType, roundId); + + fundingPot.setAccessCriteria( + roundId, + accessType, + 0, + nftContract, + merkleRoot, + allowedAddresses, + removedAddresses + ); + fundingPot.setAccessCriteriaPrivileges( + roundId, accessCriteriaId, 500, false, 0, 0, 0 + ); + + (uint roundStart,,,,,,) = fundingPot.getRoundGenericParameters(roundId); + vm.warp(roundStart + 1); + + // Approve + vm.prank(contributor3_); + _token.approve(address(fundingPot), amount); + + vm.expectRevert( + abi.encodeWithSelector( + ILM_PC_FundingPot_v1 + .Module__LM_PC_FundingPot__AccessCriteriaFailed + .selector + ) + ); + + vm.prank(contributor1_); + fundingPot.contributeToRoundFor( + contributor1_, + roundId, + amount, + accessCriteriaId, + proofB, + _unspentPersonalRoundCaps + ); + } + + function testContributeToRoundFor_revertsGivenAllowedListAccessCriteriaIsNotMet( + ) public { + testCreateRound(); + + uint32 roundId = fundingPot.roundCount(); + uint8 accessCriteriaId = 1; + uint8 accessType = uint8(ILM_PC_FundingPot_v1.AccessCriteriaType.LIST); + uint amount = 250; + + ( + address nftContract, + bytes32 merkleRoot, + address[] memory allowedAddresses + ) = _helper_createAccessCriteria(accessType, roundId); + + fundingPot.setAccessCriteria( + roundId, + accessType, + 0, + nftContract, + merkleRoot, + allowedAddresses, + removedAddresses + ); + fundingPot.setAccessCriteriaPrivileges( + roundId, accessCriteriaId, 500, false, 0, 0, 0 + ); + + (uint roundStart,,,,,,) = fundingPot.getRoundGenericParameters(roundId); + vm.warp(roundStart + 1); + + // Approve + vm.prank(contributor1_); + _token.approve(address(fundingPot), amount); + + vm.expectRevert( + abi.encodeWithSelector( + ILM_PC_FundingPot_v1 + .Module__LM_PC_FundingPot__AccessCriteriaFailed + .selector + ) + ); + + vm.prank(contributor1_); + fundingPot.contributeToRoundFor( + contributor1_, + roundId, + amount, + accessCriteriaId, + new bytes32[](0), + _unspentPersonalRoundCaps + ); + } + + function testContributeToRoundFor_revertsGivenUnspentCapsIsNotFromPreviousRounds( + ) public { + RoundParams memory params1 = _defaultRoundParams; + params1.accumulationMode = ILM_PC_FundingPot_v1.AccumulationMode.All; + + fundingPot.createRound( + params1.roundStart, + params1.roundEnd, + params1.roundCap, + params1.hookContract, + params1.hookFunction, + params1.autoClosure, + params1.accumulationMode + ); + uint32 round1Id = fundingPot.roundCount(); + + uint8 accessCriteriaId = 1; + uint8 accessType = uint8(ILM_PC_FundingPot_v1.AccessCriteriaType.NFT); + + ( + address nftContract, + bytes32 merkleRoot, + address[] memory allowedAddresses + ) = _helper_createAccessCriteria(accessType, round1Id); + + fundingPot.setAccessCriteria( + round1Id, + accessType, + 0, + nftContract, + merkleRoot, + allowedAddresses, + removedAddresses + ); + fundingPot.setAccessCriteriaPrivileges( + round1Id, accessCriteriaId, 500, false, 0, 0, 0 + ); + + mockNFTContract.mint(contributor1_); + + RoundParams memory params2 = _defaultRoundParams; + params2.roundStart = _defaultRoundParams.roundStart + 3 days; + params2.roundEnd = _defaultRoundParams.roundEnd + 3 days; + params2.accumulationMode = ILM_PC_FundingPot_v1.AccumulationMode.All; + + fundingPot.createRound( + params2.roundStart, + params2.roundEnd, + params2.roundCap, + params2.hookContract, + params2.hookFunction, + params2.autoClosure, + params2.accumulationMode + ); + uint32 round2Id = fundingPot.roundCount(); + + fundingPot.setAccessCriteria( + round2Id, + accessType, + 0, + nftContract, + merkleRoot, + allowedAddresses, + removedAddresses + ); + fundingPot.setAccessCriteriaPrivileges( + round2Id, accessCriteriaId, 400, false, 0, 0, 0 + ); + + vm.warp(params2.roundStart + 1); + + //Attempt to use current round's ID + ILM_PC_FundingPot_v1.UnspentPersonalRoundCap[] memory + invalidUnspentCaps1 = + new ILM_PC_FundingPot_v1.UnspentPersonalRoundCap[](1); + invalidUnspentCaps1[0] = ILM_PC_FundingPot_v1.UnspentPersonalRoundCap({ + roundId: round2Id, + accessCriteriaId: accessCriteriaId, + merkleProof: new bytes32[](0) + }); + + vm.startPrank(contributor1_); + _token.approve(address(fundingPot), 700); + + vm.expectRevert( + abi.encodeWithSelector( + ILM_PC_FundingPot_v1 + .Module__LM_PC_FundingPot__UnspentCapsMustBeFromPreviousRounds + .selector + ) + ); + fundingPot.contributeToRoundFor( + contributor1_, + round2Id, + 700, + accessCriteriaId, + new bytes32[](0), + invalidUnspentCaps1 + ); + + //Attempt to use future round's ID + uint32 round3Id = round2Id + 1; + + ILM_PC_FundingPot_v1.UnspentPersonalRoundCap[] memory + invalidUnspentCaps2 = + new ILM_PC_FundingPot_v1.UnspentPersonalRoundCap[](1); + invalidUnspentCaps2[0] = ILM_PC_FundingPot_v1.UnspentPersonalRoundCap({ + roundId: round3Id, + accessCriteriaId: accessCriteriaId, + merkleProof: new bytes32[](0) + }); + + vm.expectRevert( + abi.encodeWithSelector( + ILM_PC_FundingPot_v1 + .Module__LM_PC_FundingPot__UnspentCapsMustBeFromPreviousRounds + .selector + ) + ); + fundingPot.contributeToRoundFor( + contributor1_, + round2Id, + 700, + accessCriteriaId, + new bytes32[](0), + invalidUnspentCaps2 + ); + + vm.stopPrank(); + } + + function testContributeToRoundFor_revertsGivenPreviousContributionExceedsPersonalCap( + ) public { + testCreateRound(); + + uint32 roundId = fundingPot.roundCount(); + uint8 accessCriteriaId = 1; + uint8 accessType = uint8(ILM_PC_FundingPot_v1.AccessCriteriaType.NFT); + uint amount = 500; + + ( + address nftContract, + bytes32 merkleRoot, + address[] memory allowedAddresses + ) = _helper_createAccessCriteria(accessType, roundId); + + fundingPot.setAccessCriteria( + roundId, + accessType, + 0, + nftContract, + merkleRoot, + allowedAddresses, + removedAddresses + ); + fundingPot.setAccessCriteriaPrivileges( + roundId, accessCriteriaId, 500, false, 0, 0, 0 + ); + + mockNFTContract.mint(contributor1_); + + (uint roundStart,,,,,,) = fundingPot.getRoundGenericParameters(roundId); + vm.warp(roundStart + 1); + + // Approve + vm.prank(contributor1_); + _token.approve(address(fundingPot), 1000); + + vm.prank(contributor1_); + fundingPot.contributeToRoundFor( + contributor1_, + roundId, + amount, + accessCriteriaId, + new bytes32[](0), + _unspentPersonalRoundCaps + ); + + // Attempt to contribute beyond personal cap + vm.expectRevert( + abi.encodeWithSelector( + ILM_PC_FundingPot_v1 + .Module__LM_PC_FundingPot__PersonalCapReached + .selector + ) + ); + vm.prank(contributor1_); + + fundingPot.contributeToRoundFor( + contributor1_, + roundId, + 251, + accessCriteriaId, + new bytes32[](0), + _unspentPersonalRoundCaps + ); + } + + /* Test: contributeToRoundFor() happy paths + ├── Given a round has been configured with generic round configuration and access criteria + │ And the round has started + │ And the user fulfills the access criteria + │ And the user doesn't violate any privileges + │ And the user doesn't violate generic round parameters + │ And the user has approved the collateral token + │ └── When the user contributes to the round + │ └── Then the funds are transferred to the funding pot + │ And the contribution is recorded + │ + ├── Given the access criteria is NFT + │ And the user fulfills the access criteria + │ └── When the user contributes to the round + │ └── Then the funds are transferred to the funding pot + │ And the contribution is recorded + │ + ├── Given the access criteria is MERKLE + │ And the user fulfills the access criteria + │ └── When the user contributes to the round + │ └── Then the funds are transferred to the funding pot + │ And the contribution is recorded + │ + ├── Given the access criteria is LIST + │ And the user fulfills the access criteria + │ └── When the user contributes to the round + │ └── Then the funds are transferred to the funding pot + │ And the contribution is recorded + │ + ├── Given the round contribution cap is not reached + │ └── When the user contributes to the round so that it exceeds the round contribution cap + │ └── Then only the valid contribution amount is transferred to the funding pot + │ And the contribution is recorded + │ And round closure is initiated + │ + ├── Given the user fulfills the access criteria + │ And the user has already contributed their personal cap partially + │ └── When the user attempts to contribute more than their personal cap + │ └── Then only the amount up to the cap is accepted as contribution + │ And the contribution is recorded + │ + ├── Given the user fulfills the access criteria + │ And their access criteria has the privilege to override the contribution span + │ └── When + │ └── And the user attempts to contribute + │ └── Then the contribution is still recorded + │ + ├── Given the user fulfills the access criteria + │ And the round is set to have global accumulative caps + │ And the user has not fully utilized their personal contribution potential in previous rounds + │ └── When the user wants to contribute to the current round + │ └── Then they can contribute up to their personal limit of the current round plus unfilled potential from previous rounds + │ + ├── Given the round has been configured with global accumulative caps + │ And in the previous round the round contribution cap was X + │ And in total Y had been contributed in the previous round + │ And the round contribution cap for the current round is Z + │ └── When users attempt to contribute + │ └── Then they can in total contribute Z + X - Y + │ And the funds are transferred into the funding pot + │ + ├── Given globalAccumulationStartRoundId is set to 2 (e.g., R2) + │ ├── And target round (e.g., R3) uses AccumulationMode.Personal + │ │ └── When contributing to R3 with unspent capacity from R1 and R2 + │ │ └── Then only unspent personal capacity from R2 should be considered + │ ├── And target round (e.g., R3) uses AccumulationMode.Total + │ │ └── When contributing to R3 + │ │ └── Then only unspent total capacity from R2 should expand R3's effective cap + │ └── And target round (e.g., R3) uses AccumulationMode.All + │ ├── When contributing to R3 with unspent personal capacity from R1 and R2 + │ │ └── Then only unspent personal capacity from R2 should be considered + │ └── When calculating R3's effective total cap + │ └── Then only unspent total capacity from R2 should expand R3's effective cap + │ + ├── Given globalAccumulationStartRoundId is 1 (default) + │ ├── And target round (e.g., R2 or R3) uses AccumulationMode.Personal + │ │ └── When contributing with unspent capacity from all previous valid rounds (e.g., R1 for R2; R1 & R2 for R3) + │ │ └── Then unspent personal capacity from all applicable previous rounds should be considered + │ ├── And target round (e.g., R2 or R3) uses AccumulationMode.Total + │ │ └── When calculating effective total cap + │ │ └── Then unspent total capacity from all applicable previous rounds should expand the effective cap + │ └── And target round (e.g., R2 or R3) uses AccumulationMode.All + │ ├── When contributing with unspent personal capacity from all previous valid rounds + │ │ └── Then unspent personal capacity from all applicable previous rounds should be considered + │ └── When calculating effective total cap + │ └── Then unspent total capacity from all applicable previous rounds should expand the effective cap + │ + └── Given the user has unspent caps from previous contiguous rounds + │ └── When the user attempts to contribute using valid unspent caps from previous rounds + │ └── Then the contribution should succeed + │ And the unspent caps should be applied to expand their effective personal cap + │ And the funds should be transferred to the funding pot + │ And the contribution should be recorded + │ + ├── Given target round's AccumulationMode is Disabled + │ └── When globalAccumulationStartRoundId is set to allow previous rounds + │ └── Then no accumulation (personal or total) should occur for the target round + │ + ├── Given globalAccumulationStartRoundId is set to the target round's ID + │ └── When target round's AccumulationMode would normally allow accumulation + │ └── Then no accumulation (personal or total) from any previous round should occur + │ + ├── Given globalAccumulationStartRoundId is set to R2 (or later) + │ ├── When target round (R3) uses AccumulationMode.Personal + │ │ And contributing to R3 with unspent capacity from R1 and R2 + │ │ └── Then only unspent personal capacity from R2 (and subsequent allowed rounds) should be considered + │ ├── When target round (R3) uses AccumulationMode.Total + │ │ And calculating R3's effective total cap + │ │ └── Then only unspent total capacity from R2 (and subsequent allowed rounds) should expand R3's cap + │ └── When target round (R3) uses AccumulationMode.All + │ ├── And contributing to R3 with unspent personal capacity from R1 and R2 + │ │ └── Then only unspent personal capacity from R2 (and subsequent) should be considered for personal limit + │ └── And calculating R3's effective total cap + │ └── Then only unspent total capacity from R2 (and subsequent) should expand R3's cap + │ + ├── Given globalAccumulationStartRoundId is 1 (default) + │ ├── When target round (e.g., R2 or R3) uses AccumulationMode.Personal + │ │ And contributing with unspent capacity from all previous valid rounds (e.g., R1 for R2; R1 & R2 for R3) + │ │ └── Then unspent personal capacity from all applicable previous rounds (>= global start) should be considered + │ ├── When target round (e.g., R2 or R3) uses AccumulationMode.Total + │ │ And calculating effective total cap + │ │ └── Then unspent total capacity from all applicable previous rounds (>= global start) should expand the cap + │ └── When target round (e.g., R2 or R3) uses AccumulationMode.All + │ ├── And contributing with unspent personal capacity from all previous valid rounds + │ │ └── Then unspent personal capacity from all applicable previous rounds (>= global start) should be considered + │ └── And calculating effective total cap + │ └── Then unspent total capacity from all applicable previous rounds (>= global start) should expand the cap + │ + ├── Given target round's AccumulationMode is Disabled + │ └── When globalAccumulationStartRoundId is set to allow previous rounds (e.g. 1) + │ └── Then no accumulation (personal or total) should occur for the target round from any previous round + │ + ├── Given globalAccumulationStartRoundId is set to the target round's ID (or a later round ID) + │ └── When target round's AccumulationMode would normally allow accumulation + │ └── Then no accumulation (personal or total) from any previous round should occur + │ + ├── Given rounds use AccumulationMode.Personal + │ └── When unspent capacity from previous rounds is available + │ └── Then only personal caps accumulate, while total caps do not accumulate + │ + └── Given rounds use AccumulationMode.Total + └── When unspent capacity from previous rounds is available + └── Then only total caps accumulate, while personal caps do not accumulate + */ + function testContributeToRoundFor_worksGivenGenericConfigAndAccessCriteria() + public + { + testCreateRound(); + + uint8 accessCriteriaId = 1; + uint8 accessType = uint8(ILM_PC_FundingPot_v1.AccessCriteriaType.NFT); + + uint32 roundId = fundingPot.roundCount(); + uint amount = 250; + + ( + address nftContract, + bytes32 merkleRoot, + address[] memory allowedAddresses + ) = _helper_createAccessCriteria(accessType, roundId); + + fundingPot.setAccessCriteria( + roundId, + accessType, + 0, + nftContract, + merkleRoot, + allowedAddresses, + removedAddresses + ); + fundingPot.setAccessCriteriaPrivileges( + roundId, accessCriteriaId, 1000, false, 0, 0, 0 + ); + mockNFTContract.mint(contributor1_); + + vm.warp(_defaultRoundParams.roundStart + 1); + + // Approve + vm.prank(contributor1_); + _token.approve(address(fundingPot), amount); + + vm.expectEmit(true, false, false, true); + emit ILM_PC_FundingPot_v1.ContributionMade( + roundId, contributor1_, amount + ); + + vm.prank(contributor1_); + fundingPot.contributeToRoundFor( + contributor1_, + roundId, + amount, + accessCriteriaId, + new bytes32[](0), + _unspentPersonalRoundCaps + ); + + uint totalContributions = + fundingPot.roundIdToTotalContributions(roundId); + + assertEq(totalContributions, amount); + + uint personalContributions = + fundingPot.roundIdToUserToContribution(roundId, contributor1_); + assertEq(personalContributions, amount); + } + + function testContributeToRoundFor_worksGivenAccessCriteriaNFT( + uint8 accessCriteriaEnumOld, + uint8 accessCriteriaEnumNew + ) public { + vm.assume(accessCriteriaEnumOld >= 0 && accessCriteriaEnumOld <= 4); + vm.assume( + accessCriteriaEnumNew != accessCriteriaEnumOld + && accessCriteriaEnumNew >= 0 && accessCriteriaEnumNew <= 4 + ); + uint8 accessCriteriaId = 1; + + _helper_setupRoundWithAccessCriteria(accessCriteriaId); + uint32 roundId = fundingPot.roundCount(); + + mockNFTContract.mint(contributor1_); + + fundingPot.setAccessCriteriaPrivileges( + roundId, accessCriteriaId, 500, false, 0, 0, 0 + ); + + vm.warp(_defaultRoundParams.roundStart + 1); + + vm.startPrank(contributor1_); + _token.approve(address(fundingPot), 250); + + vm.expectEmit(true, false, false, true); + emit ILM_PC_FundingPot_v1.ContributionMade(roundId, contributor1_, 250); + + fundingPot.contributeToRoundFor( + contributor1_, + roundId, + 250, + accessCriteriaId, + new bytes32[](0), + _unspentPersonalRoundCaps + ); + vm.stopPrank(); + + uint userContribution = + fundingPot.roundIdToUserToContribution(roundId, contributor1_); + assertEq(userContribution, 250); + + uint totalContributions = + fundingPot.roundIdToTotalContributions(roundId); + assertEq(totalContributions, 250); + } + + function testContributeToRoundFor_worksGivenMerkleAccessCriteriaMet() + public + { + uint8 accessCriteriaId = 1; + uint8 accessType = uint8(ILM_PC_FundingPot_v1.AccessCriteriaType.MERKLE); + + _helper_setupRoundWithAccessCriteria(accessType); + + uint32 roundId = fundingPot.roundCount(); + + (,,,, bytes32[] memory proofB) = _helper_generateMerkleTreeForTwoLeaves( + contributor1_, contributor2_, roundId + ); + + fundingPot.setAccessCriteriaPrivileges( + roundId, accessCriteriaId, 500, false, 0, 0, 0 + ); + + vm.warp(_defaultRoundParams.roundStart + 1); + + uint contributionAmount = 250; + vm.startPrank(contributor2_); + _token.approve(address(fundingPot), contributionAmount); + + vm.expectEmit(true, false, false, true); + emit ILM_PC_FundingPot_v1.ContributionMade( + roundId, contributor2_, contributionAmount + ); + fundingPot.contributeToRoundFor( + contributor2_, + roundId, + contributionAmount, + accessCriteriaId, + proofB, + _unspentPersonalRoundCaps + ); + + vm.stopPrank(); + + uint userContribution = + fundingPot.roundIdToUserToContribution(roundId, contributor2_); + assertEq(userContribution, contributionAmount); + + uint totalContributions = + fundingPot.roundIdToTotalContributions(roundId); + assertEq(totalContributions, contributionAmount); + } + + function testContributeToRoundFor_worksGivenUserCurrentContributionExceedsTheRoundCap( + ) public { + _defaultRoundParams.roundCap = 150; + _defaultRoundParams.autoClosure = true; + + uint8 accessCriteriaId = 1; + uint8 accessType = uint8(ILM_PC_FundingPot_v1.AccessCriteriaType.NFT); + + uint32 roundId = fundingPot.createRound( + _defaultRoundParams.roundStart, + _defaultRoundParams.roundEnd, + _defaultRoundParams.roundCap, + _defaultRoundParams.hookContract, + _defaultRoundParams.hookFunction, + _defaultRoundParams.autoClosure, + _defaultRoundParams.accumulationMode + ); + + ( + address nftContract, + bytes32 merkleRoot, + address[] memory allowedAddresses + ) = _helper_createAccessCriteria(accessType, roundId); + + fundingPot.setAccessCriteria( + roundId, + accessType, + 0, + nftContract, + merkleRoot, + allowedAddresses, + removedAddresses + ); + + uint personalCap = 200; + fundingPot.setAccessCriteriaPrivileges( + roundId, accessCriteriaId, personalCap, false, 0, 0, 0 + ); + + mockNFTContract.mint(contributor1_); + mockNFTContract.mint(contributor2_); + + vm.warp(_defaultRoundParams.roundStart); + + vm.startPrank(contributor1_); + _token.approve(address(fundingPot), 100); + fundingPot.contributeToRoundFor( + contributor1_, + roundId, + 100, + accessCriteriaId, + new bytes32[](0), + _unspentPersonalRoundCaps + ); + vm.stopPrank(); + + vm.startPrank(contributor2_); + _token.approve(address(fundingPot), 100); + fundingPot.contributeToRoundFor( + contributor2_, + roundId, + 100, + accessCriteriaId, + new bytes32[](0), + _unspentPersonalRoundCaps + ); + vm.stopPrank(); + + uint contribution = + fundingPot.roundIdToUserToContribution(roundId, contributor2_); + assertEq(contribution, 50); + + uint totalContribution = fundingPot.roundIdToTotalContributions(roundId); + assertEq(totalContribution, _defaultRoundParams.roundCap); + assertTrue(fundingPot.roundIdToClosedStatus(roundId)); + } + + function testContributeToRoundFor_worksGivenContributionPartiallyExceedingPersonalCap( + ) public { + testCreateRound(); + + uint32 roundId = fundingPot.roundCount(); + uint8 accessCriteriaId = 1; + uint8 accessType = uint8(ILM_PC_FundingPot_v1.AccessCriteriaType.NFT); + + uint firstAmount = 400; + uint personalCap = 500; + + ( + address nftContract, + bytes32 merkleRoot, + address[] memory allowedAddresses + ) = _helper_createAccessCriteria(accessType, roundId); + + fundingPot.setAccessCriteria( + roundId, + accessType, + 0, + nftContract, + merkleRoot, + allowedAddresses, + removedAddresses + ); + fundingPot.setAccessCriteriaPrivileges( + roundId, accessCriteriaId, personalCap, false, 0, 0, 0 + ); + + mockNFTContract.mint(contributor1_); + + (uint roundStart,,,,,,) = fundingPot.getRoundGenericParameters(roundId); + vm.warp(roundStart + 1); + + // Approve + vm.prank(contributor1_); + _token.approve(address(fundingPot), 1000 ether); + + vm.expectEmit(true, false, false, true); + emit ILM_PC_FundingPot_v1.ContributionMade( + roundId, contributor1_, firstAmount + ); + + // First contribution + vm.prank(contributor1_); + fundingPot.contributeToRoundFor( + contributor1_, + roundId, + firstAmount, + accessCriteriaId, + new bytes32[](0), + _unspentPersonalRoundCaps + ); + + uint secondAmount = 200; + uint expectedSecondAmount = personalCap - firstAmount; + + vm.expectEmit(true, false, false, true); + emit ILM_PC_FundingPot_v1.ContributionMade( + roundId, contributor1_, expectedSecondAmount + ); + + vm.prank(contributor1_); + fundingPot.contributeToRoundFor( + contributor1_, + roundId, + secondAmount, + accessCriteriaId, + new bytes32[](0), + _unspentPersonalRoundCaps + ); + + uint totalContribution = + fundingPot.roundIdToUserToContribution(roundId, contributor1_); + + assertEq(totalContribution, personalCap); + } + + function testContributeToRoundFor_worksGivenUserCanOverrideTimeConstraints() + public + { + testCreateRound(); + + uint32 roundId = fundingPot.roundCount(); + uint8 accessCriteriaId = 1; + uint8 accessType = uint8(ILM_PC_FundingPot_v1.AccessCriteriaType.NFT); + uint amount = 250; + + ( + address nftContract, + bytes32 merkleRoot, + address[] memory allowedAddresses + ) = _helper_createAccessCriteria(accessType, roundId); + + fundingPot.setAccessCriteria( + roundId, + accessType, + 0, + nftContract, + merkleRoot, + allowedAddresses, + removedAddresses + ); + + // Set privileges with override capability + fundingPot.setAccessCriteriaPrivileges( + roundId, accessCriteriaId, 500, true, 0, 0, 0 + ); + + mockNFTContract.mint(contributor1_); + + (uint roundStart,,,,,,) = fundingPot.getRoundGenericParameters(roundId); + + vm.warp(roundStart + 10 days); + + // Approve + vm.prank(contributor1_); + _token.approve(address(fundingPot), amount); + + // Expect the ContributionMade event to be emitted + vm.expectEmit(true, true, false, true); + emit ILM_PC_FundingPot_v1.ContributionMade( + roundId, contributor1_, amount + ); + + // This should succeed despite being after round end, due to override privilege + vm.prank(contributor1_); + fundingPot.contributeToRoundFor( + contributor1_, + roundId, + amount, + accessCriteriaId, + new bytes32[](0), + _unspentPersonalRoundCaps + ); + + // Verify the contribution was recorded + uint totalContribution = fundingPot.roundIdToTotalContributions(roundId); + assertEq(totalContribution, amount); + } + + function testContributeToRoundFor_worksGivenPersonalCapAccumulation() + public + { + _defaultRoundParams.accumulationMode = + ILM_PC_FundingPot_v1.AccumulationMode.Personal; + fundingPot.createRound( + _defaultRoundParams.roundStart, + _defaultRoundParams.roundEnd, + _defaultRoundParams.roundCap, + _defaultRoundParams.hookContract, + _defaultRoundParams.hookFunction, + _defaultRoundParams.autoClosure, + _defaultRoundParams.accumulationMode + ); + + uint32 round1Id = fundingPot.roundCount(); + + uint8 accessCriteriaId = 1; + uint8 accessCriteriaType = + uint8(ILM_PC_FundingPot_v1.AccessCriteriaType.NFT); + ( + address nftContract, + bytes32 merkleRoot, + address[] memory allowedAddresses + ) = _helper_createAccessCriteria(accessCriteriaType, round1Id); + fundingPot.setAccessCriteria( + round1Id, + accessCriteriaType, + 0, + nftContract, + merkleRoot, + allowedAddresses, + removedAddresses + ); + + fundingPot.setAccessCriteriaPrivileges( + round1Id, accessCriteriaId, 500, false, 0, 0, 0 + ); + + mockNFTContract.mint(contributor1_); + + fundingPot.createRound( + _defaultRoundParams.roundStart + 3 days, + _defaultRoundParams.roundEnd + 3 days, + _defaultRoundParams.roundCap, + _defaultRoundParams.hookContract, + _defaultRoundParams.hookFunction, + _defaultRoundParams.autoClosure, + _defaultRoundParams.accumulationMode + ); + uint32 round2Id = fundingPot.roundCount(); + + fundingPot.setAccessCriteria( + round2Id, + accessCriteriaType, + 0, + nftContract, + merkleRoot, + allowedAddresses, + removedAddresses + ); + + // Set personal cap of 400 for round 2 + fundingPot.setAccessCriteriaPrivileges( + round2Id, accessCriteriaId, 400, false, 0, 0, 0 + ); + + vm.warp(_defaultRoundParams.roundStart + 1); + vm.startPrank(contributor1_); + _token.approve(address(fundingPot), 1000); + fundingPot.contributeToRoundFor( + contributor1_, + round1Id, + 200, + accessCriteriaId, + new bytes32[](0), + _unspentPersonalRoundCaps + ); + + // Warp to round 2 + vm.warp(_defaultRoundParams.roundStart + 3 days + 1); + + // Create unspent capacity structure + ILM_PC_FundingPot_v1.UnspentPersonalRoundCap[] memory unspentCaps = + new ILM_PC_FundingPot_v1.UnspentPersonalRoundCap[](1); + unspentCaps[0] = ILM_PC_FundingPot_v1.UnspentPersonalRoundCap({ + roundId: round1Id, + accessCriteriaId: accessCriteriaId, + merkleProof: new bytes32[](0) + }); + + vm.expectEmit(true, false, false, true); + emit ILM_PC_FundingPot_v1.ContributionMade(round2Id, contributor1_, 700); + + // Contribute to round 2 with unspent capacity from round 1 + fundingPot.contributeToRoundFor( + contributor1_, + round2Id, + 700, + accessCriteriaId, + new bytes32[](0), + unspentCaps + ); + vm.stopPrank(); + + assertEq( + fundingPot.roundIdToUserToContribution(round1Id, contributor1_), 200 + ); + + assertEq( + fundingPot.roundIdToUserToContribution(round2Id, contributor1_), 700 + ); + } + + function testContributeToRoundFor_worksGivenTotalRoundCapAccumulation() + public + { + _defaultRoundParams.accumulationMode = + ILM_PC_FundingPot_v1.AccumulationMode.All; + + // Create Round 1 + fundingPot.createRound( + _defaultRoundParams.roundStart, + _defaultRoundParams.roundEnd, + _defaultRoundParams.roundCap, + _defaultRoundParams.hookContract, + _defaultRoundParams.hookFunction, + _defaultRoundParams.autoClosure, + _defaultRoundParams.accumulationMode + ); + uint32 round1Id = fundingPot.roundCount(); + + uint8 accessCriteriaId = 1; + uint8 accessType = uint8(ILM_PC_FundingPot_v1.AccessCriteriaType.OPEN); + + ( + address nftContract, + bytes32 merkleRoot, + address[] memory allowedAddresses + ) = _helper_createAccessCriteria(accessType, round1Id); + fundingPot.setAccessCriteria( + round1Id, + accessType, + 0, + nftContract, + merkleRoot, + allowedAddresses, + removedAddresses + ); + fundingPot.setAccessCriteriaPrivileges( + round1Id, accessCriteriaId, 500, false, 0, 0, 0 + ); + + // Round 2 with a different cap + uint round2Cap = 500; + fundingPot.createRound( + _defaultRoundParams.roundStart + 3 days, + _defaultRoundParams.roundEnd + 3 days, + round2Cap, + _defaultRoundParams.hookContract, + _defaultRoundParams.hookFunction, + _defaultRoundParams.autoClosure, + _defaultRoundParams.accumulationMode + ); + uint32 round2Id = fundingPot.roundCount(); + fundingPot.setAccessCriteria( + round2Id, + accessType, + 0, + nftContract, + merkleRoot, + allowedAddresses, + removedAddresses + ); + fundingPot.setAccessCriteriaPrivileges( + round2Id, accessCriteriaId, 500, false, 0, 0, 0 + ); + + // Round 1: Multiple users contribute, but don't reach the cap + vm.warp(_defaultRoundParams.roundStart + 1); + + vm.startPrank(contributor1_); + _token.approve(address(fundingPot), 300); + fundingPot.contributeToRoundFor( + contributor1_, + round1Id, + 300, + accessCriteriaId, + new bytes32[](0), + _unspentPersonalRoundCaps + ); + vm.stopPrank(); + + vm.startPrank(contributor2_); + _token.approve(address(fundingPot), 200); + fundingPot.contributeToRoundFor( + contributor2_, + round1Id, + 200, + accessCriteriaId, + new bytes32[](0), + _unspentPersonalRoundCaps + ); + vm.stopPrank(); + + // Move to Round 2 + vm.warp(_defaultRoundParams.roundStart + 3 days + 1); + + vm.startPrank(contributor2_); + _token.approve(address(fundingPot), 400); + fundingPot.contributeToRoundFor( + contributor2_, + round2Id, + 400, + accessCriteriaId, + new bytes32[](0), + _unspentPersonalRoundCaps + ); + vm.stopPrank(); + + vm.startPrank(contributor3_); + _token.approve(address(fundingPot), 300); + fundingPot.contributeToRoundFor( + contributor3_, + round2Id, + 300, + accessCriteriaId, + new bytes32[](0), + _unspentPersonalRoundCaps + ); + vm.stopPrank(); + + assertEq(fundingPot.roundIdToTotalContributions(round1Id), 500); + assertEq( + fundingPot.roundIdToUserToContribution(round1Id, contributor1_), 300 + ); + assertEq( + fundingPot.roundIdToUserToContribution(round1Id, contributor2_), 200 + ); + + assertEq(fundingPot.roundIdToTotalContributions(round2Id), 700); + assertEq( + fundingPot.roundIdToUserToContribution(round2Id, contributor2_), 400 + ); + assertEq( + fundingPot.roundIdToUserToContribution(round2Id, contributor3_), 300 + ); + + assertEq(fundingPot.roundIdToTotalContributions(round2Id), 700); + } + + function testContributeToRoundFor_globalStartRestrictsPersonalAccumulation() + public + { + // SCENARIO: globalAccumulationStartRoundId = 2 restricts accumulation from Round 1 for Personal mode + // 1. Setup: Round 1, Round 2, Round 3. Partial contributions in R1 & R2. + // 2. Action: setGlobalAccumulationStart(2) + // 3. Verification: For contributions to R3 (Personal mode), only unused personal from R2 rolls over. + + uint initialTimestamp = block.timestamp; + + // --- Setup Rounds --- + uint r1PersonalCap = 500; + uint r1Contribution = 100; + // uint r1UnusedPersonal = r1PersonalCap - r1Contribution; // Not used in this restricted scenario directly for R3 calc + + uint r2PersonalCap = 600; + uint r2Contribution = 200; + + uint r3BasePersonalCap = 300; + + // Round 1 + uint32 round1Id = fundingPot.createRound( + initialTimestamp + 1 days, + initialTimestamp + 2 days, + 10_000, // large round cap + address(0), + bytes(""), + false, + ILM_PC_FundingPot_v1.AccumulationMode.Personal + ); + fundingPot.setAccessCriteria( + round1Id, + 1, + 0, + address(0), + bytes32(0), + new address[](0), + removedAddresses + ); // Open access + fundingPot.setAccessCriteriaPrivileges( + round1Id, 1, r1PersonalCap, false, 0, 0, 0 + ); + + // Round 2 + uint32 round2Id = fundingPot.createRound( + initialTimestamp + 3 days, + initialTimestamp + 4 days, + 10_000, // large round cap + address(0), + bytes(""), + false, + ILM_PC_FundingPot_v1.AccumulationMode.Personal + ); + fundingPot.setAccessCriteria( + round2Id, + 1, + 0, + address(0), + bytes32(0), + new address[](0), + removedAddresses + ); + fundingPot.setAccessCriteriaPrivileges( + round2Id, 1, r2PersonalCap, false, 0, 0, 0 + ); + + // Round 3 + uint32 round3Id = fundingPot.createRound( + initialTimestamp + 5 days, + initialTimestamp + 6 days, + 10_000, // large round cap + address(0), + bytes(""), + false, + ILM_PC_FundingPot_v1.AccumulationMode.Personal + ); + fundingPot.setAccessCriteria( + round3Id, + 1, + 0, + address(0), + bytes32(0), + new address[](0), + removedAddresses + ); + fundingPot.setAccessCriteriaPrivileges( + round3Id, 1, r3BasePersonalCap, false, 0, 0, 0 + ); + + // --- Contributions --- + vm.startPrank(contributor1_); + _token.approve(address(fundingPot), type(uint).max); + + vm.warp(initialTimestamp + 1 days + 1 hours); // Enter Round 1 + fundingPot.contributeToRoundFor( + contributor1_, + round1Id, + r1Contribution, + 1, + new bytes32[](0), + _unspentPersonalRoundCaps + ); + + vm.warp(initialTimestamp + 3 days + 1 hours); // Enter Round 2 + fundingPot.contributeToRoundFor( + contributor1_, + round2Id, + r2Contribution, + 1, + new bytes32[](0), + _unspentPersonalRoundCaps + ); + vm.stopPrank(); + + // --- Set Global Start --- + fundingPot.setGlobalAccumulationStart(2); + assertEq(fundingPot.globalAccumulationStartRoundId(), 2); + + // --- Attempt Contribution in Round 3 --- + vm.warp(initialTimestamp + 5 days + 1 hours); // Enter Round 3 + + ILM_PC_FundingPot_v1.UnspentPersonalRoundCap[] memory unspentCaps = + new ILM_PC_FundingPot_v1.UnspentPersonalRoundCap[](2); + // User claims unspent from R1 (should be ignored due to global start) + unspentCaps[0] = ILM_PC_FundingPot_v1.UnspentPersonalRoundCap( + round1Id, 1, new bytes32[](0) + ); + // User claims unspent from R2 (should be counted) + unspentCaps[1] = ILM_PC_FundingPot_v1.UnspentPersonalRoundCap( + round2Id, 1, new bytes32[](0) + ); + + uint expectedR3PersonalCap = r3BasePersonalCap + r2Contribution; // Only R2's unused personal cap + + vm.startPrank(contributor1_); + // Attempt to contribute up to the expected new personal cap + fundingPot.contributeToRoundFor( + contributor1_, + round3Id, + expectedR3PersonalCap, + 1, + new bytes32[](0), + unspentCaps + ); + vm.stopPrank(); + + // --- Assertion --- + assertEq( + fundingPot.roundIdToUserToContribution(round3Id, contributor1_), + expectedR3PersonalCap, + "R3 personal contribution incorrect" + ); + } + + function testContributeToRoundFor_globalStartRestrictsTotalAccumulation() + public + { + // SCENARIO: globalAccumulationStartRoundId = 2 restricts accumulation from Round 1 for Total mode + // 1. Setup: Round 1, Round 2, Round 3. Partial contributions in R1 & R2. + // 2. Action: setGlobalAccumulationStart(2) + // 3. Verification: For contributions to R3 (Total mode), only unused total from R2 expands R3 cap. + + uint initialTimestamp = block.timestamp; + + // --- Setup Rounds --- + uint r1BaseCap = 1000; + uint r1Contribution = 400; + // uint r1UnusedTotal = r1BaseCap - r1Contribution; + + uint r2BaseCap = 1200; + uint r2Contribution = 500; + // uint r2UnusedTotal = r2BaseCap - r2Contribution; + + uint r3BaseCap = 300; + + // Round 1 + uint32 round1Id = fundingPot.createRound( + initialTimestamp + 1 days, + initialTimestamp + 2 days, + r1BaseCap, + address(0), + bytes(""), + false, + ILM_PC_FundingPot_v1.AccumulationMode.Total + ); + fundingPot.setAccessCriteria( + round1Id, + 1, + 0, + address(0), + bytes32(0), + new address[](0), + removedAddresses + ); // Open access + fundingPot.setAccessCriteriaPrivileges( + round1Id, 1, r1BaseCap, false, 0, 0, 0 + ); // Personal cap equals round cap + + // Round 2 + uint32 round2Id = fundingPot.createRound( + initialTimestamp + 3 days, + initialTimestamp + 4 days, + r2BaseCap, + address(0), + bytes(""), + false, + ILM_PC_FundingPot_v1.AccumulationMode.Total + ); + fundingPot.setAccessCriteria( + round2Id, + 1, + 0, + address(0), + bytes32(0), + new address[](0), + removedAddresses + ); + fundingPot.setAccessCriteriaPrivileges( + round2Id, 1, r2BaseCap, false, 0, 0, 0 + ); + + // Round 3 + uint32 round3Id = fundingPot.createRound( + initialTimestamp + 5 days, + initialTimestamp + 6 days, + r3BaseCap, + address(0), + bytes(""), + false, + ILM_PC_FundingPot_v1.AccumulationMode.Total + ); + fundingPot.setAccessCriteria( + round3Id, + 1, + 0, + address(0), + bytes32(0), + new address[](0), + removedAddresses + ); + fundingPot.setAccessCriteriaPrivileges( + round3Id, 1, r3BaseCap + r2Contribution, false, 0, 0, 0 + ); // Allow full contribution for testing effective cap + + // --- Contributions --- + vm.startPrank(contributor1_); + _token.approve(address(fundingPot), type(uint).max); + + vm.warp(initialTimestamp + 1 days + 1 hours); // Enter Round 1 + fundingPot.contributeToRoundFor( + contributor1_, + round1Id, + r1Contribution, + 1, + new bytes32[](0), + _unspentPersonalRoundCaps + ); + + vm.warp(initialTimestamp + 3 days + 1 hours); // Enter Round 2 + fundingPot.contributeToRoundFor( + contributor1_, + round2Id, + r2Contribution, + 1, + new bytes32[](0), + _unspentPersonalRoundCaps + ); + vm.stopPrank(); + + // --- Set Global Start --- + fundingPot.setGlobalAccumulationStart(2); + assertEq(fundingPot.globalAccumulationStartRoundId(), 2); + + // --- Attempt Contribution in Round 3 --- + vm.warp(initialTimestamp + 5 days + 1 hours); // Enter Round 3 + + uint expectedR3EffectiveCap = r3BaseCap + r2Contribution; // Only R2's unused total cap + + vm.startPrank(contributor1_); + // Attempt to contribute up to the expected new effective cap + + fundingPot.contributeToRoundFor( + contributor1_, + round3Id, + expectedR3EffectiveCap, + 1, + new bytes32[](0), + _unspentPersonalRoundCaps + ); + vm.stopPrank(); + + // --- Assertion --- + assertEq( + fundingPot.roundIdToTotalContributions(round3Id), + expectedR3EffectiveCap, + "R3 total contribution incorrect, effective cap not as expected" + ); + assertEq( + fundingPot.roundIdToUserToContribution(round3Id, contributor1_), + expectedR3EffectiveCap, + "R3 user contribution incorrect" + ); + } + + function testContributeToRoundFor_defaultGlobalStartAllowsPersonalAccumulation( + ) public { + // SCENARIO: Default globalAccumulationStartRoundId = 1 allows accumulation from R1 for R2 (Personal mode) + // 1. Setup: Round 1 (Personal), Round 2 (Personal). + // Partial contribution by C1 in R1. + // 2. Action: Verify globalAccumulationStartRoundId() == 1 (default). + // 3. Verification: For C1's contribution to R2, unused personal capacity from R1 rolls over. + + // --- Round Parameters & Contributions for C1 --- + uint r1PersonalCapC1 = 500; + uint r1ContributionC1 = 100; // C1 leaves 400 personal unused from R1 + + uint r2BasePersonalCapC1 = 300; // C1's base personal cap in R2 + ILM_PC_FundingPot_v1.UnspentPersonalRoundCap[] memory + unspentPersonalRoundCaps = + new ILM_PC_FundingPot_v1.UnspentPersonalRoundCap[](0); + + // --- Approvals --- + vm.startPrank(contributor1_); + _token.approve(address(fundingPot), type(uint).max); + vm.stopPrank(); + uint initialTimestamp = block.timestamp; + // --- Create Round 1 (Personal Mode) --- + uint32 round1Id = fundingPot.createRound( + initialTimestamp + 1 days, + initialTimestamp + 2 days, + 10_000, // Large round cap, not relevant for personal accumulation focus + address(0), + bytes(""), + false, + ILM_PC_FundingPot_v1.AccumulationMode.Personal + ); + fundingPot.setAccessCriteria( + round1Id, + 1, + 0, + address(0), + bytes32(0), + new address[](0), + removedAddresses + ); + fundingPot.setAccessCriteriaPrivileges( + round1Id, 1, r1PersonalCapC1, false, 0, 0, 0 + ); + + // --- Create Round 2 (Personal Mode) --- + uint32 round2Id = fundingPot.createRound( + initialTimestamp + 3 days, + initialTimestamp + 4 days, + 10_000, // Large round cap + address(0), + bytes(""), + false, + ILM_PC_FundingPot_v1.AccumulationMode.Personal + ); + fundingPot.setAccessCriteria( + round2Id, + 1, + 0, + address(0), + bytes32(0), + new address[](0), + removedAddresses + ); + fundingPot.setAccessCriteriaPrivileges( + round2Id, 1, r2BasePersonalCapC1, false, 0, 0, 0 + ); + + // --- Contribution by C1 to Round 1 --- + vm.startPrank(contributor1_); + vm.warp(initialTimestamp + 1 days + 1 hours); // Enter Round 1 + fundingPot.contributeToRoundFor( + contributor1_, + round1Id, + r1ContributionC1, + 1, + new bytes32[](0), + unspentPersonalRoundCaps + ); + vm.stopPrank(); + + // --- Verify Default Global Start Round ID --- + assertEq( + fundingPot.globalAccumulationStartRoundId(), + 1, + "Default global start round ID should be 1" + ); + + // --- Attempt Contribution in Round 2 by C1 --- + vm.warp(initialTimestamp + 3 days + 1 hours); // Enter Round 2 + + ILM_PC_FundingPot_v1.UnspentPersonalRoundCap[] memory unspentCapsC1 = + new ILM_PC_FundingPot_v1.UnspentPersonalRoundCap[](1); + unspentCapsC1[0] = ILM_PC_FundingPot_v1.UnspentPersonalRoundCap( + round1Id, + 1, + new bytes32[](0) // Should be counted + ); + + uint r1UnusedPersonalC1 = r1PersonalCapC1 - r1ContributionC1; // 400 + + // Expected C1 effective personal cap in R2 = R2_Base (300) + R1_Unused (400) = 700 + uint expectedC1EffectivePersonalCapR2 = + r2BasePersonalCapC1 + r1UnusedPersonalC1; + + uint expectedC1ContributionR2 = expectedC1EffectivePersonalCapR2; // Should be clamped + + // Ensure the attempt is not clamped by the round cap (which is large) + if (expectedC1ContributionR2 > 10_000) { + // 10_000 is round cap for R2 + expectedC1ContributionR2 = 10_000; + } + + vm.startPrank(contributor1_); + fundingPot.contributeToRoundFor( + contributor1_, + round2Id, + r2BasePersonalCapC1 + r1UnusedPersonalC1 + 50, + 1, + new bytes32[](0), + unspentCapsC1 + ); + vm.stopPrank(); + + // --- Assertion --- + assertEq( + fundingPot.roundIdToUserToContribution(round2Id, contributor1_), + expectedC1ContributionR2, + "R2 C1 personal contribution incorrect (should use R1 unused)" + ); + } + + function testContributeToRoundFor_defaultGlobalStartAllowsTotalAccumulation( + ) public { + // SCENARIO: Default globalAccumulationStartRoundId = 1 allows total cap accumulation from R1 to R2 (Total mode) + // Simplified to reduce stack depth. + + uint initialTimestamp = block.timestamp; + uint8 accessId = 1; // Open access + + // --- Round 1 Parameters & Contribution --- + uint r1BaseCap = 1000; + uint r1ContributionC1 = 600; // Leaves 400 unused total from R1 + + // --- Round 2 Parameters --- + uint r2BaseCap = 500; + + // --- Approvals --- + vm.startPrank(contributor1_); + _token.approve(address(fundingPot), type(uint).max); + vm.stopPrank(); + + // --- Create Round 1 (Total Mode) --- + uint32 round1Id = fundingPot.createRound( + initialTimestamp + 1 days, + initialTimestamp + 2 days, + r1BaseCap, + address(0), + bytes(""), + false, + ILM_PC_FundingPot_v1.AccumulationMode.Total + ); + fundingPot.setAccessCriteria( + round1Id, + accessId, + 0, + address(0), + bytes32(0), + new address[](0), + removedAddresses + ); + fundingPot.setAccessCriteriaPrivileges( + round1Id, accessId, r1BaseCap, false, 0, 0, 0 + ); + + // --- Contribution by C1 to Round 1 --- + vm.warp(initialTimestamp + 1 days + 1 hours); + vm.startPrank(contributor1_); + ILM_PC_FundingPot_v1.UnspentPersonalRoundCap[] memory + unspentPersonalRoundCaps = + new ILM_PC_FundingPot_v1.UnspentPersonalRoundCap[](0); + fundingPot.contributeToRoundFor( + contributor1_, + round1Id, + r1ContributionC1, + accessId, + new bytes32[](0), + _unspentPersonalRoundCaps + ); + vm.stopPrank(); + + // --- Create Round 2 (Total Mode) --- + uint32 round2Id = fundingPot.createRound( + initialTimestamp + 3 days, + initialTimestamp + 4 days, + r2BaseCap, + address(0), + bytes(""), + false, + ILM_PC_FundingPot_v1.AccumulationMode.Total + ); + fundingPot.setAccessCriteria( + round2Id, + accessId, + 0, + address(0), + bytes32(0), + new address[](0), + removedAddresses + ); + // Set personal cap for R2 to be at least the expected effective total cap + uint r2ExpectedEffectiveTotalCap = + r2BaseCap + r1BaseCap - r1ContributionC1; // 500 + 400 = 900 + fundingPot.setAccessCriteriaPrivileges( + round2Id, accessId, r2ExpectedEffectiveTotalCap, false, 0, 0, 0 + ); + + // --- Verify Default Global Start Round ID --- + assertEq( + fundingPot.globalAccumulationStartRoundId(), + 1, + "Default global start round ID should be 1" + ); + + // --- Attempt Contribution in Round 2 by C1 --- + vm.warp(initialTimestamp + 3 days + 1 hours); + + uint c1AttemptR2 = r2ExpectedEffectiveTotalCap - 100; // e.g., 900 - 100 = 800. Utilizes expanded cap. + assertTrue( + c1AttemptR2 > r2BaseCap, "C1 R2 attempt should be > R2 base cap" + ); + assertTrue( + c1AttemptR2 <= r2ExpectedEffectiveTotalCap, + "C1 R2 attempt should be <= R2 effective cap" + ); + + vm.startPrank(contributor1_); + fundingPot.contributeToRoundFor( + contributor1_, + round2Id, + c1AttemptR2, + accessId, + new bytes32[](0), + _unspentPersonalRoundCaps + ); + vm.stopPrank(); + + // --- Assertions --- + assertEq( + fundingPot.roundIdToUserToContribution(round2Id, contributor1_), + c1AttemptR2, + "R2 C1 contribution incorrect" + ); + assertEq( + fundingPot.roundIdToTotalContributions(round2Id), + c1AttemptR2, + "R2 Total contributions after C1 incorrect" + ); + + // Verify that the total contributions possible is indeed the effective cap + uint remainingToFill = r2ExpectedEffectiveTotalCap - c1AttemptR2; + if (remainingToFill > 0) { + vm.startPrank(contributor1_); + fundingPot.contributeToRoundFor( + contributor1_, + round2Id, + remainingToFill, + accessId, + new bytes32[](0), + unspentPersonalRoundCaps + ); + vm.stopPrank(); + } + + assertEq( + fundingPot.roundIdToTotalContributions(round2Id), + r2ExpectedEffectiveTotalCap, + "R2 final total contributions should match effective total cap" + ); + } + + function testContributeToRoundFor_disabledModeIgnoresAccumulation() + public + { + // SCENARIO: AccumulationMode.Disabled on a target round (R2) prevents any accumulation + // from a previous round (R1), even if globalAccumulationStartRoundId would allow it. + + uint initialTimestamp = block.timestamp; + uint8 accessId = 1; // Open access + + // --- Round 1 Parameters --- + uint r1PersonalCapC1 = 500; + uint r1ContributionC1 = 100; + uint r1BaseCap = 1000; + + // --- Round 2 Parameters (Disabled Mode) --- + uint r2BasePersonalCapC1 = 50; + uint r2BaseCap = 200; + + // --- Approvals --- + vm.startPrank(contributor1_); + _token.approve(address(fundingPot), type(uint).max); + vm.stopPrank(); + + // --- Create Round 1 (Personal Mode to generate unused personal capacity) --- + uint32 round1Id = fundingPot.createRound( + initialTimestamp + 1 days, + initialTimestamp + 2 days, + r1BaseCap, + address(0), + bytes(""), + false, + ILM_PC_FundingPot_v1.AccumulationMode.Personal + ); + fundingPot.setAccessCriteria( + round1Id, + accessId, + 0, + address(0), + bytes32(0), + new address[](0), + removedAddresses + ); + fundingPot.setAccessCriteriaPrivileges( + round1Id, accessId, r1PersonalCapC1, false, 0, 0, 0 + ); + + // --- Contribution by C1 to Round 1 --- + vm.warp(initialTimestamp + 1 days + 1 hours); + vm.startPrank(contributor1_); + fundingPot.contributeToRoundFor( + contributor1_, + round1Id, + r1ContributionC1, + accessId, + new bytes32[](0), + _unspentPersonalRoundCaps + ); + vm.stopPrank(); + + // --- Create Round 2 (Disabled Mode) --- + uint32 round2Id = fundingPot.createRound( + initialTimestamp + 3 days, + initialTimestamp + 4 days, + r2BaseCap, + address(0), + bytes(""), + false, + ILM_PC_FundingPot_v1.AccumulationMode.Disabled + ); + fundingPot.setAccessCriteria( + round2Id, + accessId, + 0, + address(0), + bytes32(0), + new address[](0), + removedAddresses + ); + fundingPot.setAccessCriteriaPrivileges( + round2Id, accessId, r2BasePersonalCapC1, false, 0, 0, 0 + ); + + // --- Set Global Start Round ID to allow R1 (to show it's ignored by R2's Disabled mode) --- + fundingPot.setGlobalAccumulationStart(1); + assertEq( + fundingPot.globalAccumulationStartRoundId(), + 1, + "Global start round ID should be 1" + ); + + // --- Attempt Contribution in Round 2 by C1 --- + vm.warp(initialTimestamp + 3 days + 1 hours); + + uint c1AttemptR2 = r2BasePersonalCapC1 + 100; + + ILM_PC_FundingPot_v1.UnspentPersonalRoundCap[] memory unspentCapsC1 = + new ILM_PC_FundingPot_v1.UnspentPersonalRoundCap[](1); + unspentCapsC1[0] = ILM_PC_FundingPot_v1.UnspentPersonalRoundCap( + round1Id, accessId, new bytes32[](0) + ); + + vm.startPrank(contributor1_); + fundingPot.contributeToRoundFor( + contributor1_, + round2Id, + c1AttemptR2, + accessId, + new bytes32[](0), + unspentCapsC1 + ); + vm.stopPrank(); + + // --- Assertions --- + assertEq( + fundingPot.roundIdToUserToContribution(round2Id, contributor1_), + r2BasePersonalCapC1, + "R2 C1 personal contribution should be clamped by R2's base personal cap (Disabled mode)" + ); + assertEq( + fundingPot.roundIdToTotalContributions(round2Id), + r2BasePersonalCapC1, + "R2 Total contributions should not be expanded by R1 (Disabled mode)" + ); + assertTrue( + fundingPot.roundIdToTotalContributions(round2Id) <= r2BaseCap, + "R2 Total contributions exceeded R2's original base cap (Disabled mode)" + ); + } + + function testContributeToRoundFor_worksGivenUnspentCapsWithContiguousRoundIds( + ) public { + uint8 accessCriteriaId = 1; + uint personalCap = 300; + + // Round 1 - All accumulation enabled + uint32 round1Id = fundingPot.createRound( + block.timestamp + 1 days, + block.timestamp + 2 days, + 1000, + address(0), + bytes(""), + false, + ILM_PC_FundingPot_v1.AccumulationMode.All + ); + _helper_setupAccessCriteriaForRound( + round1Id, + uint8(ILM_PC_FundingPot_v1.AccessCriteriaType.OPEN), + accessCriteriaId, + personalCap + ); + + // Round 2 - Personal accumulation enabled + uint32 round2Id = fundingPot.createRound( + block.timestamp + 3 days, + block.timestamp + 4 days, + 1000, + address(0), + bytes(""), + false, + ILM_PC_FundingPot_v1.AccumulationMode.Personal + ); + _helper_setupAccessCriteriaForRound( + round2Id, + uint8(ILM_PC_FundingPot_v1.AccessCriteriaType.OPEN), + accessCriteriaId, + personalCap + ); + + // Round 3 - Target round with personal accumulation + uint32 round3Id = fundingPot.createRound( + block.timestamp + 5 days, + block.timestamp + 6 days, + 1000, + address(0), + bytes(""), + false, + ILM_PC_FundingPot_v1.AccumulationMode.Personal + ); + _helper_setupAccessCriteriaForRound( + round3Id, + uint8(ILM_PC_FundingPot_v1.AccessCriteriaType.OPEN), + accessCriteriaId, + personalCap + ); + + // Contribute to previous rounds + vm.warp(block.timestamp + 1 days + 1 hours); // Enter Round 1 + vm.startPrank(contributor1_); + _token.approve(address(fundingPot), 1000); + + fundingPot.contributeToRoundFor( + contributor1_, + round1Id, + 100, // Contributed 100 out of 300 cap + accessCriteriaId, + new bytes32[](0), + _unspentPersonalRoundCaps + ); + vm.stopPrank(); + + vm.warp(block.timestamp + 2 days); // Enter Round 2 (3 days total from start) + vm.startPrank(contributor1_); + fundingPot.contributeToRoundFor( + contributor1_, + round2Id, + 150, // Contributed 150 out of 300 cap + accessCriteriaId, + new bytes32[](0), + _unspentPersonalRoundCaps + ); + vm.stopPrank(); + + // Now contribute to round 3 using unspent caps from previous rounds + vm.warp(block.timestamp + 2 days); // Enter Round 3 (5 days total from start) + + // Create unspent caps array for rounds 1 and 2 + ILM_PC_FundingPot_v1.UnspentPersonalRoundCap[] memory unspentCaps = + new ILM_PC_FundingPot_v1.UnspentPersonalRoundCap[](2); + unspentCaps[0] = ILM_PC_FundingPot_v1.UnspentPersonalRoundCap({ + roundId: round1Id, + accessCriteriaId: accessCriteriaId, + merkleProof: new bytes32[](0) + }); + unspentCaps[1] = ILM_PC_FundingPot_v1.UnspentPersonalRoundCap({ + roundId: round2Id, + accessCriteriaId: accessCriteriaId, + merkleProof: new bytes32[](0) + }); + + vm.startPrank(contributor1_); + + // Should be able to contribute more than the base personal cap + // Round 1: 300 cap - 100 spent = 200 unused + // Round 2: 300 cap - 150 spent = 150 unused + // Total unspent = 350 + // Round 3 base cap = 300 + // Total effective cap for round 3 = 300 + 350 = 650 + + uint initialBalance = _token.balanceOf(contributor1_); + + fundingPot.contributeToRoundFor( + contributor1_, + round3Id, + 500, // Should work because effective cap is 650 + accessCriteriaId, + new bytes32[](0), + unspentCaps + ); + + vm.stopPrank(); + + // Verify the contribution was recorded + assertEq( + fundingPot.roundIdToUserToContribution(round3Id, contributor1_), + 500, + "User contribution should be 500" + ); + + // Verify tokens were transferred + assertEq( + _token.balanceOf(contributor1_), + initialBalance - 500, + "Tokens should have been transferred from contributor" + ); + } + + function testContributeToRoundFor_noAccumulationWhenGlobalStartEqualsTargetRound( + ) public { + // SCENARIO: If globalAccumulationStartRoundId is set to the target round's ID (R2), + // no accumulation from any previous round (R1) occurs for R2, even if R2's mode would allow it. + + uint initialTimestamp = block.timestamp; + uint8 accessId = 1; // Open access + + // --- Round 1 Parameters --- + uint r1PersonalCapC1 = 500; + uint r1ContributionC1 = 100; + uint r1BaseCap = 1000; + + // --- Round 2 Parameters (Mode that would normally allow accumulation, e.g., Personal) --- + uint r2BasePersonalCapC1 = 50; + uint r2BaseCap = 200; + + // --- Approvals --- + vm.startPrank(contributor1_); + _token.approve(address(fundingPot), type(uint).max); + vm.stopPrank(); + + // --- Create Round 1 (Personal Mode to generate unused personal capacity) --- + uint32 round1Id = fundingPot.createRound( + initialTimestamp + 1 days, + initialTimestamp + 2 days, + r1BaseCap, + address(0), + bytes(""), + false, + ILM_PC_FundingPot_v1.AccumulationMode.Personal + ); + fundingPot.setAccessCriteria( + round1Id, + accessId, + 0, + address(0), + bytes32(0), + new address[](0), + removedAddresses + ); + fundingPot.setAccessCriteriaPrivileges( + round1Id, accessId, r1PersonalCapC1, false, 0, 0, 0 + ); + + // --- Contribution by C1 to Round 1 --- + vm.warp(initialTimestamp + 1 days + 1 hours); + vm.startPrank(contributor1_); + ILM_PC_FundingPot_v1.UnspentPersonalRoundCap[] memory + unspentPersonalRoundCaps = + new ILM_PC_FundingPot_v1.UnspentPersonalRoundCap[](0); + fundingPot.contributeToRoundFor( + contributor1_, + round1Id, + r1ContributionC1, + accessId, + new bytes32[](0), + unspentPersonalRoundCaps + ); + vm.stopPrank(); + + // --- Create Round 2 (Personal Mode - would normally allow accumulation from R1) --- + uint32 round2Id = fundingPot.createRound( + initialTimestamp + 3 days, + initialTimestamp + 4 days, + r2BaseCap, + address(0), + bytes(""), + false, + ILM_PC_FundingPot_v1.AccumulationMode.Personal + ); + fundingPot.setAccessCriteria( + round2Id, + accessId, + 0, + address(0), + bytes32(0), + new address[](0), + removedAddresses + ); + fundingPot.setAccessCriteriaPrivileges( + round2Id, accessId, r2BasePersonalCapC1, false, 0, 0, 0 + ); + + // --- Set Global Start Round ID to be Round 2's ID --- + fundingPot.setGlobalAccumulationStart(round2Id); + assertEq( + fundingPot.globalAccumulationStartRoundId(), + round2Id, + "Global start round ID not set to R2 ID" + ); + + // --- Attempt Contribution in Round 2 by C1 --- + vm.warp(initialTimestamp + 3 days + 1 hours); + + uint c1AttemptR2 = r2BasePersonalCapC1 + 100; + + ILM_PC_FundingPot_v1.UnspentPersonalRoundCap[] memory unspentCapsC1 = + new ILM_PC_FundingPot_v1.UnspentPersonalRoundCap[](1); + unspentCapsC1[0] = ILM_PC_FundingPot_v1.UnspentPersonalRoundCap( + round1Id, accessId, new bytes32[](0) + ); + + vm.startPrank(contributor1_); + fundingPot.contributeToRoundFor( + contributor1_, + round2Id, + c1AttemptR2, + accessId, + new bytes32[](0), + unspentCapsC1 + ); + vm.stopPrank(); + + // --- Assertions --- + assertEq( + fundingPot.roundIdToUserToContribution(round2Id, contributor1_), + r2BasePersonalCapC1, + "R2 C1 personal contribution should be clamped by R2's base personal cap (global start = R2)" + ); + assertEq( + fundingPot.roundIdToTotalContributions(round2Id), + r2BasePersonalCapC1, + "R2 Total contributions should not be expanded by R1 (global start = R2)" + ); + assertTrue( + fundingPot.roundIdToTotalContributions(round2Id) <= r2BaseCap, + "R2 Total contributions exceeded R2's original base cap (global start = R2)" + ); + } + + function testContributeToRoundFor_defaultGlobalStartAllowsPersonalAccumulationFromMultipleRounds( + ) public { + // SCENARIO: globalAccumulationStartRoundId = 1 allows personal cap accumulation from R1 AND R2 + // for contributions to R3, when all rounds are in Personal mode. + // 1. Setup: R1, R2, R3 in Personal mode. C1 makes partial contributions in R1 & R2. + // 2. Action: Verify globalAccumulationStartRoundId = 1. C1 contributes to R3. + // 3. Verification: C1's effective personal cap in R3 includes unused from R1 and R2. + + uint initialTimestamp = block.timestamp; + + // --- Round Parameters, Personal Caps, and Contributions for contributor1_ --- + uint r1PersonalCapC1 = 500; + uint r1ContributionC1 = 200; + + uint r2PersonalCapC1 = 600; + uint r2ContributionC1 = 250; + + uint r3BasePersonalCapC1 = 300; + + uint largeRoundCap = 1_000_000; + + // --- Approvals --- + vm.startPrank(contributor1_); + _token.approve(address(fundingPot), type(uint).max); + vm.stopPrank(); + + // --- Create Round 1 (Personal Mode) --- + uint32 round1Id = fundingPot.createRound( + initialTimestamp + 1 days, + initialTimestamp + 2 days, + largeRoundCap, + address(0), + bytes(""), + false, + ILM_PC_FundingPot_v1.AccumulationMode.Personal + ); + fundingPot.setAccessCriteria( + round1Id, + 1, + 0, + address(0), + bytes32(0), + new address[](0), + removedAddresses + ); + fundingPot.setAccessCriteriaPrivileges( + round1Id, 1, r1PersonalCapC1, false, 0, 0, 0 + ); + + // --- Contribution by C1 to Round 1 --- + vm.warp(initialTimestamp + 1 days + 1 hours); + vm.startPrank(contributor1_); + fundingPot.contributeToRoundFor( + contributor1_, + round1Id, + r1ContributionC1, + 1, + new bytes32[](0), + _unspentPersonalRoundCaps + ); + vm.stopPrank(); + assertEq( + fundingPot.roundIdToUserToContribution(round1Id, contributor1_), + r1ContributionC1 + ); + + // --- Create Round 2 (Personal Mode) --- + uint32 round2Id = fundingPot.createRound( + initialTimestamp + 3 days, + initialTimestamp + 4 days, + largeRoundCap, + address(0), + bytes(""), + false, + ILM_PC_FundingPot_v1.AccumulationMode.Personal + ); + fundingPot.setAccessCriteria( + round2Id, + 1, + 0, + address(0), + bytes32(0), + new address[](0), + removedAddresses + ); + fundingPot.setAccessCriteriaPrivileges( + round2Id, 1, r2PersonalCapC1, false, 0, 0, 0 + ); + + // --- Contribution by C1 to Round 2 --- + vm.warp(initialTimestamp + 3 days + 1 hours); + vm.startPrank(contributor1_); + fundingPot.contributeToRoundFor( + contributor1_, + round2Id, + r2ContributionC1, + 1, + new bytes32[](0), + _unspentPersonalRoundCaps + ); + vm.stopPrank(); + assertEq( + fundingPot.roundIdToUserToContribution(round2Id, contributor1_), + r2ContributionC1 + ); + + // --- Create Round 3 (Personal Mode) --- + uint32 round3Id = fundingPot.createRound( + initialTimestamp + 5 days, + initialTimestamp + 6 days, + largeRoundCap, + address(0), + bytes(""), + false, + ILM_PC_FundingPot_v1.AccumulationMode.Personal + ); + fundingPot.setAccessCriteria( + round3Id, + 1, + 0, + address(0), + bytes32(0), + new address[](0), + removedAddresses + ); + fundingPot.setAccessCriteriaPrivileges( + round3Id, 1, r3BasePersonalCapC1, false, 0, 0, 0 + ); + + // --- Verify Global Start Round ID --- + assertEq( + fundingPot.globalAccumulationStartRoundId(), + 1, + "Default global start round ID should be 1" + ); + + // --- Attempt Contribution in Round 3 by C1 --- + vm.warp(initialTimestamp + 5 days + 1 hours); + + ILM_PC_FundingPot_v1.UnspentPersonalRoundCap[] memory unspentCapsC1 = + new ILM_PC_FundingPot_v1.UnspentPersonalRoundCap[](2); + unspentCapsC1[0] = ILM_PC_FundingPot_v1.UnspentPersonalRoundCap( + round1Id, 1, new bytes32[](0) + ); + unspentCapsC1[1] = ILM_PC_FundingPot_v1.UnspentPersonalRoundCap( + round2Id, 1, new bytes32[](0) + ); + + uint expectedR3PersonalCapC1 = r3BasePersonalCapC1 + + (r1PersonalCapC1 - r1ContributionC1) + + (r2PersonalCapC1 - r2ContributionC1); + + uint c1AttemptR3 = expectedR3PersonalCapC1; + + vm.startPrank(contributor1_); + fundingPot.contributeToRoundFor( + contributor1_, + round3Id, + c1AttemptR3, + 1, + new bytes32[](0), + unspentCapsC1 + ); + vm.stopPrank(); + + // --- Assertions --- + assertEq( + fundingPot.roundIdToUserToContribution(round3Id, contributor1_), + expectedR3PersonalCapC1, + "R3 C1 personal contribution incorrect (should use R1 & R2 unused)" + ); + assertEq( + fundingPot.roundIdToTotalContributions(round3Id), + expectedR3PersonalCapC1, + "R3 total contributions incorrect after C1" + ); + + vm.startPrank(contributor1_); + _token.approve(address(fundingPot), 1); + vm.expectRevert( + abi.encodeWithSelector( + ILM_PC_FundingPot_v1 + .Module__LM_PC_FundingPot__PersonalCapReached + .selector + ) + ); + fundingPot.contributeToRoundFor( + contributor1_, round3Id, 1, 1, new bytes32[](0), unspentCapsC1 + ); + vm.stopPrank(); + } + + function testContributeToRoundFor_defaultGlobalStartAllowsTotalAccumulationFromMultipleRounds( + ) public { + // SCENARIO: globalAccumulationStartRoundId = 1 allows accumulation from Round 1 AND Round 2 for Total mode + // 1. Setup: Round 1, Round 2, Round 3. Partial total contributions in R1 & R2. All in Total mode. + // 2. Action: setGlobalAccumulationStart(1) (or verify default). + // 3. Verification: For contributions to R3 (Total mode), unused total from R1 AND R2 rolls over, expanding R3's effective cap. + + uint initialTimestamp = block.timestamp; + + // --- Round Parameters & Contributions --- + uint r1BaseCap = 1000; + uint r1ContributionC1 = 400; + + uint r2BaseCap = 1200; + uint r2ContributionC2 = 700; + + uint r3BaseCap = 300; + + // --- Approvals --- + vm.startPrank(contributor1_); + _token.approve(address(fundingPot), type(uint).max); + vm.stopPrank(); + + vm.startPrank(contributor2_); + _token.approve(address(fundingPot), type(uint).max); + vm.stopPrank(); + + vm.startPrank(contributor3_); + _token.approve(address(fundingPot), type(uint).max); + vm.stopPrank(); + + // --- Create Round 1 (Total Mode) --- + uint32 round1Id = fundingPot.createRound( + initialTimestamp + 1 days, + initialTimestamp + 2 days, + r1BaseCap, + address(0), + bytes(""), + false, + ILM_PC_FundingPot_v1.AccumulationMode.Total + ); + fundingPot.setAccessCriteria( + round1Id, + 1, + 0, + address(0), + bytes32(0), + new address[](0), + removedAddresses + ); + fundingPot.setAccessCriteriaPrivileges( + round1Id, 1, r1BaseCap, false, 0, 0, 0 + ); + + // --- Contribution by C1 to Round 1 --- + vm.warp(initialTimestamp + 1 days + 1 hours); + vm.startPrank(contributor1_); + ILM_PC_FundingPot_v1.UnspentPersonalRoundCap[] memory + unspentPersonalRoundCaps = + new ILM_PC_FundingPot_v1.UnspentPersonalRoundCap[](0); + fundingPot.contributeToRoundFor( + contributor1_, + round1Id, + r1ContributionC1, + 1, + new bytes32[](0), + unspentPersonalRoundCaps + ); + vm.stopPrank(); + assertEq( + fundingPot.roundIdToTotalContributions(round1Id), r1ContributionC1 + ); + + // --- Create Round 2 (Total Mode) --- + uint32 round2Id = fundingPot.createRound( + initialTimestamp + 3 days, + initialTimestamp + 4 days, + r2BaseCap, + address(0), + bytes(""), + false, + ILM_PC_FundingPot_v1.AccumulationMode.Total + ); + fundingPot.setAccessCriteria( + round2Id, + 1, + 0, + address(0), + bytes32(0), + new address[](0), + removedAddresses + ); + fundingPot.setAccessCriteriaPrivileges( + round2Id, 1, r2BaseCap, false, 0, 0, 0 + ); + + // --- Contribution by C2 to Round 2 --- + vm.warp(initialTimestamp + 3 days + 1 hours); + vm.startPrank(contributor2_); + + fundingPot.contributeToRoundFor( + contributor2_, + round2Id, + r2ContributionC2, + 1, + new bytes32[](0), + unspentPersonalRoundCaps + ); + vm.stopPrank(); + assertEq( + fundingPot.roundIdToTotalContributions(round2Id), r2ContributionC2 + ); + + // --- Create Round 3 (Total Mode) --- + uint r3ExpectedEffectiveCap = r3BaseCap + (r1BaseCap - r1ContributionC1) + + (r2BaseCap - r2ContributionC2); + uint32 round3Id = fundingPot.createRound( + initialTimestamp + 5 days, + initialTimestamp + 6 days, + r3BaseCap, + address(0), + bytes(""), + false, + ILM_PC_FundingPot_v1.AccumulationMode.Total + ); + (address nftR3, bytes32 merkleR3, address[] memory allowedR3) = + _helper_createAccessCriteria(1, round3Id); + + fundingPot.setAccessCriteria( + round3Id, 1, 0, nftR3, merkleR3, allowedR3, removedAddresses + ); + fundingPot.setAccessCriteriaPrivileges( + round3Id, 1, r3ExpectedEffectiveCap, false, 0, 0, 0 + ); + + assertEq( + fundingPot.globalAccumulationStartRoundId(), + 1, + "Default global start round ID should be 1" + ); + + // --- Attempt Contribution in Round 3 by C3 --- + vm.warp(initialTimestamp + 5 days + 1 hours); + + vm.startPrank(contributor3_); + fundingPot.contributeToRoundFor( + contributor3_, + round3Id, + r3ExpectedEffectiveCap, + 1, + new bytes32[](0), + unspentPersonalRoundCaps + ); + vm.stopPrank(); + + // --- Assertions --- + assertEq( + fundingPot.roundIdToTotalContributions(round3Id), + r3ExpectedEffectiveCap, + "R3 total contributions should match effective cap with rollover from R1 and R2" + ); + assertEq( + fundingPot.roundIdToUserToContribution(round3Id, contributor3_), + r3ExpectedEffectiveCap, + "R3 C3 contribution incorrect" + ); + + vm.startPrank(contributor1_); + _token.approve(address(fundingPot), 1); + vm.expectRevert( + abi.encodeWithSelector( + ILM_PC_FundingPot_v1 + .Module__LM_PC_FundingPot__RoundCapReached + .selector + ) + ); + fundingPot.contributeToRoundFor( + contributor1_, + round3Id, + 1, + 1, + new bytes32[](0), + unspentPersonalRoundCaps + ); + vm.stopPrank(); + } + + function testContributeToRoundFor_allModeAllowsPersonalAccumulation() + public + { + // SCENARIO: globalAccumulationStartRoundId = 1 allows personal cap + // accumulation from R1 to R2, when both are in All mode. (Simplified for stack) + + uint initialTimestamp = block.timestamp; + uint8 accessId = 1; + + // --- Round 1: Setup & C1 Contribution --- + uint r1PersonalCapC1 = 500; + uint r1ContributionC1 = 100; + uint32 round1Id = fundingPot.createRound( + initialTimestamp + 1 days, + initialTimestamp + 2 days, + 1000, + address(0), + bytes(""), + false, + ILM_PC_FundingPot_v1.AccumulationMode.All + ); + fundingPot.setAccessCriteria( + round1Id, + accessId, + 0, + address(0), + bytes32(0), + new address[](0), + removedAddresses + ); + fundingPot.setAccessCriteriaPrivileges( + round1Id, accessId, r1PersonalCapC1, false, 0, 0, 0 + ); + + vm.warp(initialTimestamp + 1 days + 1 hours); + vm.startPrank(contributor1_); + _token.approve(address(fundingPot), type(uint).max); + ILM_PC_FundingPot_v1.UnspentPersonalRoundCap[] memory + unspentPersonalRoundCaps = + new ILM_PC_FundingPot_v1.UnspentPersonalRoundCap[](0); + fundingPot.contributeToRoundFor( + contributor1_, + round1Id, + r1ContributionC1, + accessId, + new bytes32[](0), + unspentPersonalRoundCaps + ); + vm.stopPrank(); + uint r1UnusedPersonalForC1 = r1PersonalCapC1 - r1ContributionC1; + + // --- Round 2: Setup --- + uint r2BasePersonalCapC1 = 200; + uint32 round2Id = fundingPot.createRound( + initialTimestamp + 3 days, + initialTimestamp + 4 days, + 2000, + address(0), + bytes(""), + false, + ILM_PC_FundingPot_v1.AccumulationMode.All + ); + fundingPot.setAccessCriteria( + round2Id, + accessId, + 0, + address(0), + bytes32(0), + new address[](0), + removedAddresses + ); + fundingPot.setAccessCriteriaPrivileges( + round2Id, accessId, r2BasePersonalCapC1, false, 0, 0, 0 + ); + + // --- Global Start ID Check --- + assertEq( + fundingPot.globalAccumulationStartRoundId(), + 1, + "Default global start ID is 1" + ); + + // --- C1 Contribution to Round 2 (Testing Personal Cap Rollover) --- + vm.warp(initialTimestamp + 3 days + 1 hours); + uint expectedEffectivePersonalCapC1R2 = + r2BasePersonalCapC1 + r1UnusedPersonalForC1; + uint c1AttemptR2 = expectedEffectivePersonalCapC1R2 + 50; + + ILM_PC_FundingPot_v1.UnspentPersonalRoundCap[] memory unspentCapsC1 = + new ILM_PC_FundingPot_v1.UnspentPersonalRoundCap[](1); + unspentCapsC1[0] = ILM_PC_FundingPot_v1.UnspentPersonalRoundCap( + round1Id, accessId, new bytes32[](0) + ); + + vm.startPrank(contributor1_); + fundingPot.contributeToRoundFor( + contributor1_, + round2Id, + c1AttemptR2, + accessId, + new bytes32[](0), + unspentCapsC1 + ); + vm.stopPrank(); + + // --- Assertions --- + assertEq( + fundingPot.roundIdToUserToContribution(round2Id, contributor1_), + expectedEffectivePersonalCapC1R2, + "R2 C1 personal contribution incorrect" + ); + assertEq( + fundingPot.roundIdToTotalContributions(round2Id), + expectedEffectivePersonalCapC1R2, + "R2 Total contributions incorrect" + ); + } + + function testContributeToRoundFor_allModeAllowsTotalAccumulation() public { + // SCENARIO: globalAccumulationStartRoundId = 1 (default or set) allows total cap + // accumulation from R1 to R2, when both are in All mode. + + uint initialTimestamp = block.timestamp; + uint8 accessId = 1; // Open access + + // --- Round 1 Parameters (All Mode) --- + uint r1BaseTotalCap = 1000; + uint r1C1PersonalCap = 800; + uint r1C1Contribution = 600; + + // --- Round 2 Parameters (All Mode) --- + uint r2BaseTotalCap = 500; + + // --- Approvals --- + vm.startPrank(contributor1_); + _token.approve(address(fundingPot), type(uint).max); + vm.stopPrank(); + + // --- Create Round 1 (All Mode) --- + uint32 round1Id = fundingPot.createRound( + initialTimestamp + 1 days, + initialTimestamp + 2 days, + r1BaseTotalCap, + address(0), + bytes(""), + false, + ILM_PC_FundingPot_v1.AccumulationMode.All + ); + fundingPot.setAccessCriteria( + round1Id, + accessId, + 0, + address(0), + bytes32(0), + new address[](0), + removedAddresses + ); + fundingPot.setAccessCriteriaPrivileges( + round1Id, accessId, r1C1PersonalCap, false, 0, 0, 0 + ); + + // --- Contribution by C1 to Round 1 --- + vm.warp(initialTimestamp + 1 days + 1 hours); + vm.startPrank(contributor1_); + ILM_PC_FundingPot_v1.UnspentPersonalRoundCap[] memory + unspentPersonalRoundCaps = + new ILM_PC_FundingPot_v1.UnspentPersonalRoundCap[](0); + fundingPot.contributeToRoundFor( + contributor1_, + round1Id, + r1C1Contribution, + accessId, + new bytes32[](0), + unspentPersonalRoundCaps + ); + vm.stopPrank(); + uint r1UnusedTotal = r1BaseTotalCap - r1C1Contribution; + + // --- Create Round 2 (All Mode) --- + uint32 round2Id = fundingPot.createRound( + initialTimestamp + 3 days, + initialTimestamp + 4 days, + r2BaseTotalCap, + address(0), + bytes(""), + false, + ILM_PC_FundingPot_v1.AccumulationMode.All + ); + fundingPot.setAccessCriteria( + round2Id, + accessId, + 0, + address(0), + bytes32(0), + new address[](0), + removedAddresses + ); + uint r2ExpectedEffectiveTotalCap = r2BaseTotalCap + r1UnusedTotal; + fundingPot.setAccessCriteriaPrivileges( + round2Id, accessId, r2ExpectedEffectiveTotalCap, false, 0, 0, 0 + ); + + // --- Ensure Global Start Round ID is 1 --- + assertEq( + fundingPot.globalAccumulationStartRoundId(), + 1, + "Global start round ID should be 1 by default" + ); + + // --- Attempt Contribution in Round 2 by C1 to fill effective total cap --- + vm.warp(initialTimestamp + 3 days + 1 hours); + + uint c1AttemptR2 = r2ExpectedEffectiveTotalCap; + + vm.startPrank(contributor1_); + fundingPot.contributeToRoundFor( + contributor1_, + round2Id, + c1AttemptR2, + accessId, + new bytes32[](0), + unspentPersonalRoundCaps + ); + vm.stopPrank(); + + // --- Assertions --- + assertEq( + fundingPot.roundIdToUserToContribution(round2Id, contributor1_), + c1AttemptR2, + "R2 C1 contribution should match attempt (filling effective total cap)" + ); + assertEq( + fundingPot.roundIdToTotalContributions(round2Id), + r2ExpectedEffectiveTotalCap, + "R2 Total contributions should match effective total cap (All mode, global_start=1)" + ); + } + + function testContributeToRoundFor_allModeWithGlobalStartRestrictsPersonalAccumulation( + ) public { + // SCENARIO: globalAccumulationStartRoundId = 2 restricts personal cap accumulation + // from R1 for R2, when both are in All mode. + + uint initialTimestamp = block.timestamp; + uint8 accessId = 1; // Open access + + // --- Round 1 Parameters (All Mode) --- + uint r1PersonalCapC1 = 500; + uint r1ContributionC1 = 100; + uint r1BaseTotalCap = 1000; + + // --- Round 2 Parameters (All Mode) --- + uint r2BasePersonalCapC1 = 50; + uint r2BaseTotalCap = 1000; + + // --- Approvals --- + vm.startPrank(contributor1_); + _token.approve(address(fundingPot), type(uint).max); + vm.stopPrank(); + + // --- Create Round 1 (All Mode) --- + uint32 round1Id = fundingPot.createRound( + initialTimestamp + 1 days, + initialTimestamp + 2 days, + r1BaseTotalCap, + address(0), + bytes(""), + false, + ILM_PC_FundingPot_v1.AccumulationMode.All + ); + fundingPot.setAccessCriteria( + round1Id, + accessId, + 0, + address(0), + bytes32(0), + new address[](0), + removedAddresses + ); + fundingPot.setAccessCriteriaPrivileges( + round1Id, accessId, r1PersonalCapC1, false, 0, 0, 0 + ); + + // --- Contribution by C1 to Round 1 --- + vm.warp(initialTimestamp + 1 days + 1 hours); + vm.startPrank(contributor1_); + fundingPot.contributeToRoundFor( + contributor1_, + round1Id, + r1ContributionC1, + accessId, + new bytes32[](0), + _unspentPersonalRoundCaps + ); + vm.stopPrank(); + + // --- Create Round 2 (All Mode) --- + uint32 round2Id = fundingPot.createRound( + initialTimestamp + 3 days, + initialTimestamp + 4 days, + r2BaseTotalCap, + address(0), + bytes(""), + false, + ILM_PC_FundingPot_v1.AccumulationMode.All + ); + fundingPot.setAccessCriteria( + round2Id, + accessId, + 0, + address(0), + bytes32(0), + new address[](0), + removedAddresses + ); + fundingPot.setAccessCriteriaPrivileges( + round2Id, accessId, r2BasePersonalCapC1, false, 0, 0, 0 + ); + + // --- Set Global Start Round ID to Round 2's ID --- + fundingPot.setGlobalAccumulationStart(round2Id); + assertEq( + fundingPot.globalAccumulationStartRoundId(), + round2Id, + "Global start ID not set to R2 ID" + ); + + // --- Attempt Contribution in Round 2 by C1 --- + vm.warp(initialTimestamp + 3 days + 1 hours); + + uint c1AttemptR2 = r2BasePersonalCapC1 + 100; + + ILM_PC_FundingPot_v1.UnspentPersonalRoundCap[] memory unspentCapsC1 = + new ILM_PC_FundingPot_v1.UnspentPersonalRoundCap[](1); + unspentCapsC1[0] = ILM_PC_FundingPot_v1.UnspentPersonalRoundCap( + round1Id, accessId, new bytes32[](0) + ); + + vm.startPrank(contributor1_); + fundingPot.contributeToRoundFor( + contributor1_, + round2Id, + c1AttemptR2, + accessId, + new bytes32[](0), + unspentCapsC1 + ); + vm.stopPrank(); + + // --- Assertions --- + assertEq( + fundingPot.roundIdToUserToContribution(round2Id, contributor1_), + r2BasePersonalCapC1, + "R2 C1 personal contribution should be clamped by R2 base personal cap (All mode, global_start=R2)" + ); + assertEq( + fundingPot.roundIdToTotalContributions(round2Id), + r2BasePersonalCapC1, + "R2 Total contributions should be C1's clamped amount (All mode, global_start=R2)" + ); + } + + function testContributeToRoundFor_allModeWithGlobalStartRestrictsTotalAccumulation( + ) public { + // SCENARIO: globalAccumulationStartRoundId = 2 restricts total cap accumulation + // from R1 for R2, when both are in All mode. + + uint initialTimestamp = block.timestamp; + uint8 accessId = 1; // Open access + + // --- Round 1 Parameters (All Mode) --- + uint r1BaseTotalCap = 1000; + uint r1C1PersonalCap = 800; + uint r1C1Contribution = 400; + + // --- Round 2 Parameters (All Mode) --- + uint r2BaseTotalCap = 200; + + // --- Approvals --- + vm.startPrank(contributor1_); + _token.approve(address(fundingPot), type(uint).max); + vm.stopPrank(); + + // --- Create Round 1 (All Mode) --- + uint32 round1Id = fundingPot.createRound( + initialTimestamp + 1 days, + initialTimestamp + 2 days, + r1BaseTotalCap, + address(0), + bytes(""), + false, + ILM_PC_FundingPot_v1.AccumulationMode.All + ); + fundingPot.setAccessCriteria( + round1Id, + accessId, + 0, + address(0), + bytes32(0), + new address[](0), + removedAddresses + ); + fundingPot.setAccessCriteriaPrivileges( + round1Id, accessId, r1C1PersonalCap, false, 0, 0, 0 + ); + + // --- Contribution by C1 to Round 1 --- + vm.warp(initialTimestamp + 1 days + 1 hours); + vm.startPrank(contributor1_); + fundingPot.contributeToRoundFor( + contributor1_, + round1Id, + r1C1Contribution, + accessId, + new bytes32[](0), + _unspentPersonalRoundCaps + ); + vm.stopPrank(); + + // --- Create Round 2 (All Mode) --- + uint32 round2Id = fundingPot.createRound( + initialTimestamp + 3 days, + initialTimestamp + 4 days, + r2BaseTotalCap, + address(0), + bytes(""), + false, + ILM_PC_FundingPot_v1.AccumulationMode.All + ); + fundingPot.setAccessCriteria( + round2Id, + accessId, + 0, + address(0), + bytes32(0), + new address[](0), + removedAddresses + ); + fundingPot.setAccessCriteriaPrivileges( + round2Id, accessId, r2BaseTotalCap, false, 0, 0, 0 + ); + + // --- Set Global Start Round ID to Round 2's ID --- + fundingPot.setGlobalAccumulationStart(round2Id); + assertEq( + fundingPot.globalAccumulationStartRoundId(), + round2Id, + "Global start ID not set to R2 ID" + ); + + // --- Attempt Contribution in Round 2 by C1 --- + vm.warp(initialTimestamp + 3 days + 1 hours); + + uint c1AttemptR2 = r2BaseTotalCap + 100; + + vm.startPrank(contributor1_); + fundingPot.contributeToRoundFor( + contributor1_, + round2Id, + c1AttemptR2, + accessId, + new bytes32[](0), + _unspentPersonalRoundCaps + ); + vm.stopPrank(); + + // --- Assertions --- + assertEq( + fundingPot.roundIdToUserToContribution(round2Id, contributor1_), + r2BaseTotalCap, + "R2 C1 contribution should be clamped by R2 base total cap (All mode, global_start=R2)" + ); + assertEq( + fundingPot.roundIdToTotalContributions(round2Id), + r2BaseTotalCap, + "R2 Total contributions should be R2 base total cap (All mode, global_start=R2)" + ); + } + + function testContributeToRoundFor_totalModeOnlyAccumulatesTotalCaps() + public + { + // 1. Create the first round with AccumulationMode.Total + _defaultRoundParams.accumulationMode = + ILM_PC_FundingPot_v1.AccumulationMode.Total; + + fundingPot.createRound( + _defaultRoundParams.roundStart, + _defaultRoundParams.roundEnd, + 1000, // Round 1 cap of 1000 + _defaultRoundParams.hookContract, + _defaultRoundParams.hookFunction, + _defaultRoundParams.autoClosure, + _defaultRoundParams.accumulationMode + ); + uint32 round1Id = fundingPot.roundCount(); + + // Set up access criteria for round 1 (Open) + uint8 accessCriteriaId = 1; + ( + address nftContract, + bytes32 merkleRoot, + address[] memory allowedAddresses + ) = _helper_createAccessCriteria( + uint8(ILM_PC_FundingPot_v1.AccessCriteriaType.OPEN), round1Id + ); + + fundingPot.setAccessCriteria( + round1Id, + uint8(ILM_PC_FundingPot_v1.AccessCriteriaType.OPEN), // accessCriteriaType + 0, // accessCriteriaId (0 for new) + nftContract, + merkleRoot, + allowedAddresses, + removedAddresses + ); + + // Set a personal cap of 800 for round 1 + fundingPot.setAccessCriteriaPrivileges( + round1Id, accessCriteriaId, 800, false, 0, 0, 0 + ); + + // 2. Create the second round, also with AccumulationMode.Total + RoundParams memory params = _helper_createEditRoundParams( + _defaultRoundParams.roundStart + 3 days, + _defaultRoundParams.roundEnd + 3 days, + 500, // Round 2 base cap of 500 + _defaultRoundParams.hookContract, + _defaultRoundParams.hookFunction, + _defaultRoundParams.autoClosure, + ILM_PC_FundingPot_v1.AccumulationMode.Total + ); + + fundingPot.createRound( + params.roundStart, + params.roundEnd, + params.roundCap, + params.hookContract, + params.hookFunction, + params.autoClosure, + params.accumulationMode + ); + uint32 round2Id = fundingPot.roundCount(); + + // Set up access criteria for round 2 (Open) + fundingPot.setAccessCriteria( + round2Id, + uint8(ILM_PC_FundingPot_v1.AccessCriteriaType.OPEN), // accessCriteriaType + 0, // accessCriteriaId (0 for new) + nftContract, + merkleRoot, + allowedAddresses, + removedAddresses + ); + + // Set a personal cap of 300 for round 2 + fundingPot.setAccessCriteriaPrivileges( + round2Id, accessCriteriaId, 300, false, 0, 0, 0 + ); + + // Round 1 contribution: contributor1 contributes 600 (less than round cap 1000, less than personal 800) + // Undersubscription: 1000 - 600 = 400 + vm.warp(_defaultRoundParams.roundStart + 1); + vm.startPrank(contributor1_); + _token.approve(address(fundingPot), 1000); + ILM_PC_FundingPot_v1.UnspentPersonalRoundCap[] memory + unspentPersonalRoundCaps = + new ILM_PC_FundingPot_v1.UnspentPersonalRoundCap[](0); + fundingPot.contributeToRoundFor( + contributor1_, + round1Id, + 600, + accessCriteriaId, + new bytes32[](0), + unspentPersonalRoundCaps + ); + vm.stopPrank(); + + // Verify contribution to round 1 + assertEq( + fundingPot.roundIdToUserToContribution(round1Id, contributor1_), 600 + ); + assertEq(fundingPot.roundIdToTotalContributions(round1Id), 600); + + // Move to round 2 + vm.warp(_defaultRoundParams.roundStart + 3 days + 1); + + // ------------ PART 1: VERIFY TOTAL CAP ACCUMULATION ------------ + // Effective Round 2 Cap = Base Cap (500) + Unused from Round 1 (400) = 900 + vm.startPrank(contributor2_); + _token.approve(address(fundingPot), 1000); // Approve enough + + // Contributor 2 attempts to contribute 700. + // Personal Cap (R2) is 300. Gets clamped to 300. + fundingPot.contributeToRoundFor( + contributor2_, + round2Id, + 700, + accessCriteriaId, + new bytes32[](0), + unspentPersonalRoundCaps + ); + // Verify contributor 2's contribution was clamped by personal cap. + assertEq( + fundingPot.roundIdToUserToContribution(round2Id, contributor2_), + 300, + "C2 contribution should be clamped by personal cap" + ); + vm.stopPrank(); + + // Verify total contributions after C2 is 300 + assertEq( + fundingPot.roundIdToTotalContributions(round2Id), + 300, + "Total after C2 should be 300" + ); + + // ------------ PART 2: VERIFY PERSONAL CAP NON-ACCUMULATION ------------ + // Contributor 1 had 800 personal cap in R1, contributed 600, unused = 200. + // Contributor 1 has 300 personal cap in R2. + // In Total mode, personal cap does NOT roll over. Max contribution is 300. + + // Prepare unspent caps struct (even though it shouldn't work for personal in Total mode) + ILM_PC_FundingPot_v1.UnspentPersonalRoundCap[] memory unspentCaps = + new ILM_PC_FundingPot_v1.UnspentPersonalRoundCap[](1); + unspentCaps[0] = ILM_PC_FundingPot_v1.UnspentPersonalRoundCap({ + roundId: round1Id, + accessCriteriaId: accessCriteriaId, + merkleProof: new bytes32[](0) + }); + + vm.startPrank(contributor1_); + _token.approve(address(fundingPot), 500); + + // Attempt to contribute 400 ( > R2 personal cap 300) + // Total contributions = 300. Effective Round Cap = 900. Remaining Round Cap = 600. + // Personal Cap (R2) = 300. Unspent (R1) = 200, ignored in Total mode. + // Min(Remaining Round Cap, Remaining Personal Cap) = Min(600, 300) = 300. + // Should be clamped to 300. + fundingPot.contributeToRoundFor( + contributor1_, + round2Id, + 400, + accessCriteriaId, + new bytes32[](0), + unspentCaps // Provide unspent caps, although they should be ignored for personal limit + ); + // Verify contributor 1's contribution was clamped to their R2 personal cap. + assertEq( + fundingPot.roundIdToUserToContribution(round2Id, contributor1_), + 300, + "C1 contribution should be clamped by personal cap" + ); + vm.stopPrank(); + + // Verify total round contributions: 300 (C2) + 300 (C1) = 600 + assertEq( + fundingPot.roundIdToTotalContributions(round2Id), + 600, + "Total after C1 and C2 should be 600" + ); + // Effective cap 900, current total 600. Remaining = 300. + + // Contributor 3 contributes 300. Personal Cap = 300. Remaining Round Cap = 300. Should succeed. + vm.startPrank(contributor3_); + _token.approve(address(fundingPot), 300); + fundingPot.contributeToRoundFor( + contributor3_, + round2Id, + 300, + accessCriteriaId, + new bytes32[](0), + unspentPersonalRoundCaps + ); + // Verify C3 contributed 300 + assertEq( + fundingPot.roundIdToUserToContribution(round2Id, contributor3_), + 300, + "C3 contributes remaining 300" + ); + vm.stopPrank(); + + // Total contributions should now be 900 (300 + 300 + 300), matching the effective cap. + assertEq( + fundingPot.roundIdToTotalContributions(round2Id), + 900, + "Total should match effective cap after C3" + ); + + // Now the effective cap is full. Try contributing 1 again. + vm.startPrank(contributor3_); // Can use C3 or another contributor + _token.approve(address(fundingPot), 1); + + // Try contributing 1, expect revert as cap is full + vm.expectRevert( + abi.encodeWithSelector( + ILM_PC_FundingPot_v1 + .Module__LM_PC_FundingPot__RoundCapReached + .selector + ) + ); + fundingPot.contributeToRoundFor( + contributor3_, + round2Id, + 1, + accessCriteriaId, + new bytes32[](0), + unspentPersonalRoundCaps + ); + vm.stopPrank(); + + // Final total check should remain 900 + assertEq( + fundingPot.roundIdToTotalContributions(round2Id), + 900, + "Final total should be effective cap" + ); + } + + function testContributeToRoundFor_UsedUnspentCapsIsSet() public { + // Step 1: Create round 1 and round 2 + uint32 round1 = fundingPot.createRound({ + roundStart_: block.timestamp + 1, + roundEnd_: block.timestamp + 1 days, + roundCap_: 1000, + hookContract_: address(0), + hookFunction_: "", + autoClosure_: false, + accumulationMode_: ILM_PC_FundingPot_v1.AccumulationMode.Personal + }); + + uint8 accessType = uint8(ILM_PC_FundingPot_v1.AccessCriteriaType.OPEN); + + ( + address nftContract, + bytes32 merkleRoot, + address[] memory allowedAddresses + ) = _helper_createAccessCriteria(accessType, round1); + + fundingPot.setAccessCriteria( + round1, + uint8(ILM_PC_FundingPot_v1.AccessCriteriaType.OPEN), + 0, + address(0), + 0, + allowedAddresses, + removedAddresses + ); + fundingPot.setAccessCriteriaPrivileges( + round1, 1, 200, false, block.timestamp, 0, block.timestamp + 1 days + ); + + vm.warp(block.timestamp + 2); + + // Contribute in round 1 + vm.startPrank(contributor1_); + _token.approve(address(fundingPot), 100); + ILM_PC_FundingPot_v1.UnspentPersonalRoundCap[] memory + unspentPersonalRoundCaps = + new ILM_PC_FundingPot_v1.UnspentPersonalRoundCap[](0); + fundingPot.contributeToRoundFor( + contributor1_, + round1, + 100, + 1, + new bytes32[](0), + unspentPersonalRoundCaps + ); + vm.stopPrank(); + + // Step 2: Create round 2 with accumulationMode enabled + uint32 round2 = fundingPot.createRound({ + roundStart_: block.timestamp + 1, + roundEnd_: block.timestamp + 2 days, + roundCap_: 1000, + hookContract_: address(0), + hookFunction_: "", + autoClosure_: false, + accumulationMode_: ILM_PC_FundingPot_v1.AccumulationMode.Personal + }); + + fundingPot.setAccessCriteria( + round2, + uint8(ILM_PC_FundingPot_v1.AccessCriteriaType.OPEN), + 0, + address(0), + 0, + allowedAddresses, + removedAddresses + ); + fundingPot.setAccessCriteriaPrivileges( + round2, 1, 200, false, block.timestamp, 0, block.timestamp + 1 days + ); + + // Step 3: Contribute to round 2 using unspent cap from round 1 + ILM_PC_FundingPot_v1.UnspentPersonalRoundCap[] memory caps = + new ILM_PC_FundingPot_v1.UnspentPersonalRoundCap[](1); + + caps[0] = ILM_PC_FundingPot_v1.UnspentPersonalRoundCap({ + roundId: round1, + accessCriteriaId: 1, + merkleProof: new bytes32[](0) + }); + + vm.warp(block.timestamp + 2); + + vm.startPrank(contributor1_); + _token.approve(address(fundingPot), 100); + fundingPot.contributeToRoundFor( + contributor1_, round2, 100, 1, new bytes32[](0), caps + ); + vm.stopPrank(); + + // Step 4: Validate that usedUnspentCaps is set to true + bool isUsed = fundingPot.usedUnspentCaps(contributor1_, round1, 1); // expose via helper function if needed + assertTrue(isUsed, "usedUnspentCaps should be true after contribution"); + } + + function testContributeToRoundFor_UsedUnspentCapsSkippedIfAlreadyUsed() + public + { + // Step 1: Create round 1 and round 2 + uint32 round1 = fundingPot.createRound({ + roundStart_: block.timestamp + 1, + roundEnd_: block.timestamp + 1 days, + roundCap_: 1000, + hookContract_: address(0), + hookFunction_: "", + autoClosure_: false, + accumulationMode_: ILM_PC_FundingPot_v1.AccumulationMode.Personal + }); + + uint8 accessType = uint8(ILM_PC_FundingPot_v1.AccessCriteriaType.OPEN); + (address nftContract,, address[] memory allowedAddresses) = + _helper_createAccessCriteria(accessType, round1); + + fundingPot.setAccessCriteria( + round1, + accessType, + 0, + address(0), + 0, + allowedAddresses, + removedAddresses + ); + fundingPot.setAccessCriteriaPrivileges( + round1, 1, 300, false, block.timestamp, 0, block.timestamp + 1 days + ); + + vm.warp(block.timestamp + 2); + + // Step 1b: Contribute in round 1 + vm.startPrank(contributor1_); + _token.approve(address(fundingPot), 100); + ILM_PC_FundingPot_v1.UnspentPersonalRoundCap[] memory + unspentPersonalRoundCaps = + new ILM_PC_FundingPot_v1.UnspentPersonalRoundCap[](0); + fundingPot.contributeToRoundFor( + contributor1_, + round1, + 100, + 1, + new bytes32[](0), + unspentPersonalRoundCaps + ); + vm.stopPrank(); + + // Step 2: Create round 2 + uint32 round2 = fundingPot.createRound({ + roundStart_: block.timestamp + 1, + roundEnd_: block.timestamp + 2 days, + roundCap_: 1000, + hookContract_: address(0), + hookFunction_: "", + autoClosure_: false, + accumulationMode_: ILM_PC_FundingPot_v1.AccumulationMode.Personal + }); + + fundingPot.setAccessCriteria( + round2, + accessType, + 0, + address(0), + 0, + allowedAddresses, + removedAddresses + ); + fundingPot.setAccessCriteriaPrivileges( + round2, 1, 200, false, block.timestamp, 0, block.timestamp + 1 days + ); + + // Step 2b: Contribute using round1 cap → sets usedUnspentCaps + ILM_PC_FundingPot_v1.UnspentPersonalRoundCap[] memory caps1 = + new ILM_PC_FundingPot_v1.UnspentPersonalRoundCap[](1); + caps1[0] = ILM_PC_FundingPot_v1.UnspentPersonalRoundCap({ + roundId: round1, + accessCriteriaId: 1, + merkleProof: new bytes32[](0) + }); + + vm.warp(block.timestamp + 2); + vm.startPrank(contributor1_); + _token.approve(address(fundingPot), 200); + fundingPot.contributeToRoundFor( + contributor1_, round2, 200, 1, new bytes32[](0), caps1 + ); + vm.stopPrank(); + + // Step 3: Create round 3 + uint32 round3 = fundingPot.createRound({ + roundStart_: block.timestamp + 1, + roundEnd_: block.timestamp + 2 days, + roundCap_: 1000, + hookContract_: address(0), + hookFunction_: "", + autoClosure_: false, + accumulationMode_: ILM_PC_FundingPot_v1.AccumulationMode.Personal + }); + + fundingPot.setAccessCriteria( + round3, + accessType, + 0, + address(0), + 0, + allowedAddresses, + removedAddresses + ); + fundingPot.setAccessCriteriaPrivileges( + round3, 1, 300, false, block.timestamp, 0, block.timestamp + 1 days + ); + + // Step 4: Try reusing round1 cap again → should skip because it's already used + ILM_PC_FundingPot_v1.UnspentPersonalRoundCap[] memory caps2 = + new ILM_PC_FundingPot_v1.UnspentPersonalRoundCap[](1); + caps2[0] = ILM_PC_FundingPot_v1.UnspentPersonalRoundCap({ + roundId: round1, + accessCriteriaId: 1, + merkleProof: new bytes32[](0) + }); + + vm.warp(block.timestamp + 2); + vm.startPrank(contributor1_); + _token.approve(address(fundingPot), 100); + fundingPot.contributeToRoundFor( + contributor1_, round3, 100, 1, new bytes32[](0), caps2 + ); + vm.stopPrank(); + + // Step 5: Check that contribution in round3 is only based on round3 cap (not reused from round1) + uint contributed = + fundingPot.roundIdToUserToContribution(round3, contributor1_); + assertLe( + contributed, + 300, + "Should not include unspent cap from already-used round" + ); + + // Confirm usedUnspentCaps[round1] is still true, not overwritten or reused + bool isStillUsed = fundingPot.usedUnspentCaps(contributor1_, round1, 1); + assertTrue( + isStillUsed, "usedUnspentCaps should still be true from earlier use" + ); + } + + // ------------------------------------------------------------------------- + // Test: closeRound() + + /* + ├── Given user does not have FUNDING_POT_ADMIN_ROLE + │ └── When user attempts to close a round + │ └── Then it should revert with Module__CallerNotAuthorized + │ + ├── Given round does not exist + │ └── When user attempts to close the round + │ └── Then it should revert with Module__LM_PC_FundingPot__RoundNotCreated + │ + ├── Given hook execution fails + │ └── When user attempts to close the round + │ └── Then it should revert with Module__LM_PC_FundingPot__HookExecutionFailed + │ + ├── Given closure conditions are not met + │ └── When user attempts to close the round + │ └── Then it should revert with Module__LM_PC_FundingPot__ClosureConditionsNotMet + │ + ├── Given round has started but not ended + ├── Given round is already closed + │ └── When user attempts to close the round again + │ └── Then it should revert with Module__LM_PC_FundingPot__RoundHasEnded + │ + ├── Given round has started but not ended + │ └── And round cap has not been reached + │ └── And user has contributed successfully + │ └── When user attempts to close the round + │ └── Then it should not revert and round should be closed + │ └── And payment orders should be created correctly + │ + ├── Given round has ended (by time) + │ └── And user has contributed during active round + │ └── When user attempts to close the round + │ └── Then it should not revert and round should be closed + │ └── And payment orders should be created correctly + │ + ├── Given round cap has been reached + │ └── And user has contributed up to the cap + │ └── When user attempts to close the round + │ └── Then it should not revert and round should be closed + │ └── And payment orders should be created correctly + -── Given round cap has been reached + │ └── And the round is set up for autoclosure + │ └── And user has contributed up to the cap + │ └── Then it should not revert and round should be closed + │ └── And payment orders should be created correctly + └── Given multiple users contributed before round ended or cap reached + └── When round is closed + └── Then it should not revert and round should be closed + └── And payment orders should be created for all contributors + */ + function testCloseRound_revertsGivenUserIsNotFundingPotAdmin(address user_) + public + { + vm.assume(user_ != address(0) && user_ != address(this)); + + testCreateRound(); + uint32 roundId = fundingPot.roundCount(); + + vm.startPrank(user_); + bytes32 roleId = _authorizer.generateRoleId( + address(fundingPot), fundingPot.FUNDING_POT_ADMIN_ROLE() + ); + vm.expectRevert( + abi.encodeWithSelector( + IModule_v1.Module__CallerNotAuthorized.selector, roleId, user_ + ) + ); + fundingPot.closeRound(roundId); + vm.stopPrank(); + } + + function testFuzzCloseRound_revertsGivenRoundDoesNotExist( + uint8 accessCriteriaEnum + ) public { + vm.assume(accessCriteriaEnum >= 0 && accessCriteriaEnum <= 4); + + uint32 roundId = fundingPot.roundCount(); + + _helper_createAccessCriteria(accessCriteriaEnum, roundId); + + vm.expectRevert( + abi.encodeWithSelector( + ILM_PC_FundingPot_v1 + .Module__LM_PC_FundingPot__RoundNotCreated + .selector + ) + ); + fundingPot.closeRound(roundId); + } + + function testCloseRound_revertsGivenClosureConditionsNotMet() public { + uint8 accessCriteriaId = + uint8(ILM_PC_FundingPot_v1.AccessCriteriaType.OPEN); + + _helper_setupRoundWithAccessCriteria(accessCriteriaId); + uint32 roundId = fundingPot.roundCount(); + + fundingPot.setAccessCriteriaPrivileges(roundId, 0, 1000, false, 0, 0, 0); + + vm.expectRevert( + ILM_PC_FundingPot_v1 + .Module__LM_PC_FundingPot__ClosureConditionsNotMet + .selector + ); + fundingPot.closeRound(roundId); + } + + function testCloseRound_revertsGivenRoundHasAlreadyBeenClosed() public { + uint8 accessCriteriaId = + uint8(ILM_PC_FundingPot_v1.AccessCriteriaType.OPEN); + + _helper_setupRoundWithAccessCriteria(accessCriteriaId); + uint32 roundId = fundingPot.roundCount(); + fundingPot.setAccessCriteriaPrivileges( + roundId, accessCriteriaId, 1000, false, 0, 0, 0 + ); + + (uint roundStart,,,,,,) = fundingPot.getRoundGenericParameters(roundId); + + vm.warp(roundStart + 1); + + vm.startPrank(contributor1_); + _token.approve(address(fundingPot), 1000); + ILM_PC_FundingPot_v1.UnspentPersonalRoundCap[] memory + unspentPersonalRoundCaps = + new ILM_PC_FundingPot_v1.UnspentPersonalRoundCap[](0); + fundingPot.contributeToRoundFor( + contributor1_, + roundId, + 1000, + accessCriteriaId, + new bytes32[](0), + unspentPersonalRoundCaps + ); + vm.stopPrank(); + + fundingPot.closeRound(roundId); + vm.expectRevert( + ILM_PC_FundingPot_v1 + .Module__LM_PC_FundingPot__RoundHasEnded + .selector + ); + fundingPot.closeRound(roundId); + } + + function testCloseRound_worksGivenRoundHasStartedButNotEnded() public { + testCreateRound(); + uint32 roundId = fundingPot.roundCount(); + + uint8 accessCriteriaId = 1; + uint8 accessType = uint8(ILM_PC_FundingPot_v1.AccessCriteriaType.OPEN); + ( + address nftContract, + bytes32 merkleRoot, + address[] memory allowedAddresses + ) = _helper_createAccessCriteria(accessType, roundId); + + fundingPot.setAccessCriteria( + roundId, + accessType, + 0, + nftContract, + merkleRoot, + allowedAddresses, + removedAddresses + ); + fundingPot.setAccessCriteriaPrivileges( + roundId, accessCriteriaId, 1000, false, 0, 0, 0 + ); + + // Warp to round start + (uint roundStart,,,,,,) = fundingPot.getRoundGenericParameters(roundId); + vm.warp(roundStart + 1); + + // Make a contribution + vm.startPrank(contributor1_); + _token.approve(address(fundingPot), 1000); + ILM_PC_FundingPot_v1.UnspentPersonalRoundCap[] memory + unspentPersonalRoundCaps = + new ILM_PC_FundingPot_v1.UnspentPersonalRoundCap[](0); + fundingPot.contributeToRoundFor( + contributor1_, + roundId, + 1000, + accessCriteriaId, + new bytes32[](0), + unspentPersonalRoundCaps + ); + vm.stopPrank(); + + // Close the round + fundingPot.closeRound(roundId); + + // Verify round is closed + assertEq(fundingPot.roundIdToClosedStatus(roundId), true); + } + + function testCloseRound_worksGivenRoundHasEnded() public { + testCreateRound(); + uint32 roundId = fundingPot.roundCount(); + + uint8 accessCriteriaId = 1; + uint8 accessType = uint8(ILM_PC_FundingPot_v1.AccessCriteriaType.OPEN); + + ( + address nftContract, + bytes32 merkleRoot, + address[] memory allowedAddresses + ) = _helper_createAccessCriteria(accessType, roundId); + + fundingPot.setAccessCriteria( + roundId, + accessType, + 0, + nftContract, + merkleRoot, + allowedAddresses, + removedAddresses + ); + fundingPot.setAccessCriteriaPrivileges( + roundId, accessCriteriaId, 1000, false, 0, 0, 0 + ); + + // Make a contribution + (uint roundStart, uint roundEnd,,,,,) = + fundingPot.getRoundGenericParameters(roundId); + vm.warp(roundStart + 1); + + vm.startPrank(contributor1_); + _token.approve(address(fundingPot), 500); + ILM_PC_FundingPot_v1.UnspentPersonalRoundCap[] memory + unspentPersonalRoundCaps = + new ILM_PC_FundingPot_v1.UnspentPersonalRoundCap[](0); + fundingPot.contributeToRoundFor( + contributor1_, + roundId, + 500, + accessCriteriaId, + new bytes32[](0), + unspentPersonalRoundCaps + ); + vm.stopPrank(); + + // Warp to after round end + vm.warp(roundEnd + 1); + + // Close the round + fundingPot.closeRound(roundId); + + // Verify round is closed + assertEq(fundingPot.roundIdToClosedStatus(roundId), true); + } + + function testCloseRound_worksGivenRoundCapHasBeenReached() public { + testCreateRound(); + + uint32 roundId = fundingPot.roundCount(); + uint8 accessCriteriaId = 1; + uint8 accessType = uint8(ILM_PC_FundingPot_v1.AccessCriteriaType.NFT); + + uint amount = 1000; + + ( + address nftContract, + bytes32 merkleRoot, + address[] memory allowedAddresses + ) = _helper_createAccessCriteria(accessType, roundId); + + fundingPot.setAccessCriteria( + roundId, + accessType, + 0, + nftContract, + merkleRoot, + allowedAddresses, + removedAddresses + ); + fundingPot.setAccessCriteriaPrivileges( + roundId, accessCriteriaId, 1000, false, 0, 0, 0 + ); + + mockNFTContract.mint(contributor1_); + + (uint roundStart,,,,,,) = fundingPot.getRoundGenericParameters(roundId); + vm.warp(roundStart + 1); + + // Approve + vm.prank(contributor1_); + _token.approve(address(fundingPot), 1000); + + vm.prank(contributor1_); + ILM_PC_FundingPot_v1.UnspentPersonalRoundCap[] memory + unspentPersonalRoundCaps = + new ILM_PC_FundingPot_v1.UnspentPersonalRoundCap[](0); + fundingPot.contributeToRoundFor( + contributor1_, + roundId, + amount, + accessCriteriaId, + new bytes32[](0), + unspentPersonalRoundCaps + ); + + assertEq(fundingPot.roundIdToClosedStatus(roundId), false); + fundingPot.closeRound(roundId); + assertEq(fundingPot.roundIdToClosedStatus(roundId), true); + } + + function testCloseRound_worksGivenRoundisAutoClosure() public { + testEditRound(); + + uint32 roundId = fundingPot.roundCount(); + uint8 accessCriteriaId = 1; + uint8 accessType = uint8(ILM_PC_FundingPot_v1.AccessCriteriaType.OPEN); + + uint amount = 2000; + + ( + address nftContract, + bytes32 merkleRoot, + address[] memory allowedAddresses + ) = _helper_createAccessCriteria(accessType, roundId); + + fundingPot.setAccessCriteria( + roundId, + accessType, + 0, + nftContract, + merkleRoot, + allowedAddresses, + removedAddresses + ); + + fundingPot.setAccessCriteriaPrivileges( + roundId, accessCriteriaId, 2000, false, 0, 0, 0 + ); + mockNFTContract.mint(contributor1_); + + (uint roundStart,,,,,,) = fundingPot.getRoundGenericParameters(roundId); + vm.warp(roundStart + 1); + + // Approve + vm.prank(contributor1_); + _token.approve(address(fundingPot), 2000); + + vm.prank(contributor1_); + ILM_PC_FundingPot_v1.UnspentPersonalRoundCap[] memory + unspentPersonalRoundCaps = + new ILM_PC_FundingPot_v1.UnspentPersonalRoundCap[](0); + fundingPot.contributeToRoundFor( + contributor1_, + roundId, + amount, + accessCriteriaId, + new bytes32[](0), + unspentPersonalRoundCaps + ); + + assertEq(fundingPot.roundIdToClosedStatus(roundId), true); + } + + function testCloseRound_worksWithMultipleContributors() public { + testCreateRound(); + uint32 roundId = fundingPot.roundCount(); + + // Set up access criteria + uint8 accessCriteriaId = 1; + uint8 accessType = uint8(ILM_PC_FundingPot_v1.AccessCriteriaType.OPEN); + + ( + address nftContract, + bytes32 merkleRoot, + address[] memory allowedAddresses + ) = _helper_createAccessCriteria(accessType, roundId); + + fundingPot.setAccessCriteria( + roundId, + accessType, + 0, + nftContract, + merkleRoot, + allowedAddresses, + removedAddresses + ); + fundingPot.setAccessCriteriaPrivileges( + roundId, accessCriteriaId, 1000, false, 0, 0, 0 + ); + + ILM_PC_FundingPot_v1.UnspentPersonalRoundCap[] memory + unspentPersonalRoundCaps = + new ILM_PC_FundingPot_v1.UnspentPersonalRoundCap[](0); + + // Warp to round start + (uint roundStart,,,,,,) = fundingPot.getRoundGenericParameters(roundId); + vm.warp(roundStart + 1); + + // Multiple contributors + vm.startPrank(contributor1_); + _token.approve(address(fundingPot), 500); + fundingPot.contributeToRoundFor( + contributor1_, + roundId, + 500, + accessCriteriaId, + new bytes32[](0), + unspentPersonalRoundCaps + ); + vm.stopPrank(); + + vm.startPrank(contributor2_); + _token.approve(address(fundingPot), 200); + fundingPot.contributeToRoundFor( + contributor2_, + roundId, + 200, + accessCriteriaId, + new bytes32[](0), + unspentPersonalRoundCaps + ); + vm.stopPrank(); + + vm.startPrank(contributor3_); + _token.approve(address(fundingPot), 300); + + fundingPot.contributeToRoundFor( + contributor3_, + roundId, + 300, + accessCriteriaId, + new bytes32[](0), + unspentPersonalRoundCaps + ); + vm.stopPrank(); + + // Close the round + fundingPot.closeRound(roundId); + + // Verify round is closed + assertEq(fundingPot.roundIdToClosedStatus(roundId), true); + } + + //------------------------------------------------------------------------- + + /* Test createPaymentOrdersForContributorsBatch() + ├── Given round does not exist + │ └── When user attempts to create payment orders in batch + │ └── Then it should revert with Module__LM_PC_FundingPot__RoundNotCreated + │ + ├── Given round is not closed + │ └── When user attempts to create payment orders in batch + │ └── Then it should revert with Module__LM_PC_FundingPot__RoundNotClosed + │ + ├── Given batch size is zero + │ └── When user attempts to create payment orders in batch + │ └── Then it should revert with Module__LM_PC_FundingPot__InvalidBatchParameters + │ + ├── Given user does not have FUNDING_POT_ADMIN_ROLE + │ └── Given the round is configured with autoClosure + │ └── When user attempts to create payment orders in batch + │ └── Then it should revert with Module__CallerNotAuthorized + │ + ├── Given start index is greater than the number of contributors + │ └── When user attempts to create payment orders in batch + │ └── Then it should not revert and create payment orders + │ + ├── Given a closed round with autoClosure + │ └── When user attempts to create payment orders in batch + │ └── Then it should not revert and payment orders should be created + │ └── And the payment orders should have correct token amounts + │ + ├── Given a closed round with manualClosure + │ └── When funding pot admin attempts to create payment orders in batch + │ └── Then it should not revert and payment orders should be created + │ └── And the payment orders should have correct token amounts + */ + + function testCreatePaymentOrdersForContributorsBatch_revertsGivenRoundDoesNotExist( + ) public { + uint32 nonExistentRoundId = 999; + + vm.expectRevert( + abi.encodeWithSelector( + ILM_PC_FundingPot_v1 + .Module__LM_PC_FundingPot__RoundNotCreated + .selector + ) + ); + fundingPot.createPaymentOrdersForContributorsBatch( + nonExistentRoundId, 1 + ); + } + + function testCreatePaymentOrdersForContributorsBatch_revertsGivenRoundIsNotClosed( + ) public { + testContributeToRoundFor_worksGivenGenericConfigAndAccessCriteria(); + uint32 roundId = fundingPot.roundCount(); + + vm.expectRevert( + abi.encodeWithSelector( + ILM_PC_FundingPot_v1 + .Module__LM_PC_FundingPot__RoundNotClosed + .selector + ) + ); + fundingPot.createPaymentOrdersForContributorsBatch(roundId, 1); + } + + function testCreatePaymentOrdersForContributorsBatch_revertsGivenUserDoesNotHaveFundingPotAdminRole( + ) public { + testCloseRound_worksWithMultipleContributors(); + uint32 roundId = fundingPot.roundCount(); + + vm.startPrank(contributor1_); + bytes32 roleId = _authorizer.generateRoleId( + address(fundingPot), fundingPot.FUNDING_POT_ADMIN_ROLE() + ); + vm.expectRevert( + abi.encodeWithSelector( + IModule_v1.Module__CallerNotAuthorized.selector, + roleId, + contributor1_ + ) + ); + fundingPot.createPaymentOrdersForContributorsBatch(roundId, 1); + vm.stopPrank(); + } + + function testCreatePaymentOrdersForContributorsBatch_worksGivenBatchSizeIsGreaterThanContributorCount( + ) public { + testCloseRound_worksWithMultipleContributors(); + uint32 roundId = fundingPot.roundCount(); + + fundingPot.createPaymentOrdersForContributorsBatch(roundId, 999); + assertEq(fundingPot.paymentOrders().length, 3); + } + + function testCreatePaymentOrdersForContributorsBatch_worksGivenRoundIsAutoClosure( + ) public { + testCloseRound_worksGivenRoundisAutoClosure(); + uint32 roundId = fundingPot.roundCount(); + + fundingPot.createPaymentOrdersForContributorsBatch(roundId, 1); + assertEq(fundingPot.paymentOrders().length, 1); + } + + function testCreatePaymentOrdersForContributorsBatch_worksGivenRoundIsManualClosure( + ) public { + testCloseRound_worksWithMultipleContributors(); + uint32 roundId = fundingPot.roundCount(); + + fundingPot.createPaymentOrdersForContributorsBatch(roundId, 3); + assertEq(fundingPot.paymentOrders().length, 3); + } + // ------------------------------------------------------------------------- + + // Internal Functions + function testFuzz_validateAndAdjustCapsWithUnspentCap( + uint32 roundId_, + uint amount_, + uint8 accessCriteriaId_, + bool canOverrideContributionSpan_, + uint unspentPersonalCap_ + ) external { + vm.assume(roundId_ > 0 && roundId_ >= fundingPot.roundCount()); + vm.assume(amount_ <= 1000); + vm.assume(accessCriteriaId_ <= 4); + vm.assume(unspentPersonalCap_ >= 0); + + try fundingPot.exposed_validateAndAdjustCapsWithUnspentCap( + contributor1_, + roundId_, + amount_, + accessCriteriaId_, + canOverrideContributionSpan_, + unspentPersonalCap_ + ) returns (uint adjustedAmount) { + assertLe( + adjustedAmount, amount_, "Adjusted amount should be <= amount_" + ); + assertGe(adjustedAmount, 0, "Adjusted amount should be >= 0"); + } catch (bytes memory reason) { + bytes32 roundCapReachedSelector = keccak256( + abi.encodeWithSignature( + "Module__LM_PC_FundingPot__RoundCapReached()" + ) + ); + bytes32 personalCapReachedSelector = keccak256( + abi.encodeWithSignature( + "Module__LM_PC_FundingPot__PersonalCapReached()" + ) + ); + + if (keccak256(reason) == roundCapReachedSelector) { + assertTrue( + !canOverrideContributionSpan_, + "Should not revert RoundCapReached when canOverrideContributionSpan is true" + ); + } else if (keccak256(reason) == personalCapReachedSelector) { + assertTrue(true, "Personal cap reached as expected"); + } else { + assertTrue(false, "Unexpected revert reason"); + } + } + } + + // ------------------------------------------------------------------------- + // Test: _calculateUnusedCapacityFromPreviousRounds + + function test_calculateUnusedCapacityFromPreviousRounds() public { + // round 1 (no accumulation) + fundingPot.createRound( + _defaultRoundParams.roundStart, + _defaultRoundParams.roundEnd, + _defaultRoundParams.roundCap, + _defaultRoundParams.hookContract, + _defaultRoundParams.hookFunction, + _defaultRoundParams.autoClosure, + ILM_PC_FundingPot_v1.AccumulationMode.Disabled + ); + + // round 2 (with accumulation) + fundingPot.createRound( + _defaultRoundParams.roundStart + 300, + _defaultRoundParams.roundEnd + 400, + _defaultRoundParams.roundCap, + _defaultRoundParams.hookContract, + _defaultRoundParams.hookFunction, + _defaultRoundParams.autoClosure, + ILM_PC_FundingPot_v1.AccumulationMode.All // globalAccumulativeCaps on + ); + + // round 3 + fundingPot.createRound( + _defaultRoundParams.roundStart + 500, + _defaultRoundParams.roundEnd + 600, + _defaultRoundParams.roundCap, + _defaultRoundParams.hookContract, + _defaultRoundParams.hookFunction, + _defaultRoundParams.autoClosure, + ILM_PC_FundingPot_v1.AccumulationMode.All + ); + + // Calculate unused capacity + uint actualUnusedCapacity = + fundingPot.exposed_calculateUnusedCapacityFromPreviousRounds(3); + assertEq(actualUnusedCapacity, 1000); + } + + // ------------------------------------------------------------------------- + // Test: _contributeToRoundFor() + + function testFuzz_contributeToRoundFor_revertsGivenInvalidAccessCriteria( + uint8 accessCriteriaEnum + ) public { + vm.assume(accessCriteriaEnum > 4); + + testCreateRound(); + uint32 roundId = fundingPot.roundCount(); + (uint roundStart,,,,,,) = fundingPot.getRoundGenericParameters(roundId); + vm.warp(roundStart + 1); + + vm.prank(contributor1_); + _token.approve(address(fundingPot), 1000); + + vm.expectRevert( + abi.encodeWithSelector( + ILM_PC_FundingPot_v1 + .Module__LM_PC_FundingPot__InvalidAccessCriteriaType + .selector + ) + ); + + fundingPot.exposed_contributeToRoundFor( + contributor1_, + roundId, + 1000, + accessCriteriaEnum, + new bytes32[](0), + 0 + ); + } + // ------------------------------------------------------------------------- + // Test: _checkRoundClosureConditions + + function test_checkRoundClosureConditions_whenCapReached() public { + RoundParams memory params = _defaultRoundParams; + params.roundStart = block.timestamp + 1 days; + params.roundEnd = block.timestamp + 2 days; + params.roundCap = 1000; + + uint32 roundId = fundingPot.createRound( + params.roundStart, + params.roundEnd, + params.roundCap, + params.hookContract, + params.hookFunction, + params.autoClosure, + params.accumulationMode + ); + + // Set access criteria and privileges + uint8 accessCriteriaId = 1; + uint8 accessType = uint8(ILM_PC_FundingPot_v1.AccessCriteriaType.OPEN); + + ( + address nftContract, + bytes32 merkleRoot, + address[] memory allowedAddresses + ) = _helper_createAccessCriteria(accessType, roundId); + fundingPot.setAccessCriteria( + roundId, + accessType, + 0, + nftContract, + merkleRoot, + allowedAddresses, + removedAddresses + ); + fundingPot.setAccessCriteriaPrivileges( + roundId, accessCriteriaId, 1000, false, 0, 0, 0 + ); + + vm.warp(params.roundStart + 1); + vm.startPrank(contributor1_); + _token.approve(address(fundingPot), params.roundCap); + ILM_PC_FundingPot_v1.UnspentPersonalRoundCap[] memory + unspentPersonalRoundCaps = + new ILM_PC_FundingPot_v1.UnspentPersonalRoundCap[](0); + fundingPot.contributeToRoundFor( + contributor1_, + roundId, + params.roundCap, + accessCriteriaId, + new bytes32[](0), + unspentPersonalRoundCaps + ); + vm.stopPrank(); + + assertTrue(fundingPot.exposed_checkRoundClosureConditions(roundId)); + } + + function test_checkRoundClosureConditions_whenEndTimeReached() public { + RoundParams memory params = _defaultRoundParams; + params.roundStart = block.timestamp + 1 days; + params.roundEnd = block.timestamp + 2 days; + params.roundCap = 1000; + + uint32 roundId = fundingPot.createRound( + params.roundStart, + params.roundEnd, + params.roundCap, + params.hookContract, + params.hookFunction, + params.autoClosure, + params.accumulationMode + ); + + // Move time past end time + vm.warp(params.roundEnd + 1); + assertTrue(fundingPot.exposed_checkRoundClosureConditions(roundId)); + } + + function test_checkRoundClosureConditions_whenNeitherConditionMet() + public + { + RoundParams memory params = _defaultRoundParams; + params.roundStart = block.timestamp + 1 days; + params.roundEnd = block.timestamp + 2 days; + params.roundCap = 1000; + + uint32 roundId = fundingPot.createRound( + params.roundStart, + params.roundEnd, + params.roundCap, + params.hookContract, + params.hookFunction, + params.autoClosure, + params.accumulationMode + ); + + assertFalse(fundingPot.exposed_checkRoundClosureConditions(roundId)); + } + + function test_checkRoundClosureConditions_withNoEndTime() public { + RoundParams memory params = _defaultRoundParams; + params.roundStart = block.timestamp + 1 days; + params.roundEnd = 0; // No end time + params.roundCap = 1000; + + uint32 roundId = fundingPot.createRound( + params.roundStart, + params.roundEnd, + params.roundCap, + params.hookContract, + params.hookFunction, + params.autoClosure, + params.accumulationMode + ); + + // Set access criteria and privileges + uint8 accessCriteriaId = 1; + uint8 accessType = uint8(ILM_PC_FundingPot_v1.AccessCriteriaType.OPEN); + + ( + address nftContract, + bytes32 merkleRoot, + address[] memory allowedAddresses + ) = _helper_createAccessCriteria(accessType, roundId); + fundingPot.setAccessCriteria( + roundId, + accessType, + 0, + nftContract, + merkleRoot, + allowedAddresses, + removedAddresses + ); + fundingPot.setAccessCriteriaPrivileges( + roundId, + accessCriteriaId, + 1000, + false, + 0, // no start + 0, // no cliff + 0 // no end + ); + + // Should be false initially + assertFalse(fundingPot.exposed_checkRoundClosureConditions(roundId)); + + // Should be true when cap is reached + vm.warp(params.roundStart + 1); + vm.startPrank(contributor1_); + _token.approve(address(fundingPot), params.roundCap); + ILM_PC_FundingPot_v1.UnspentPersonalRoundCap[] memory + unspentPersonalRoundCaps = + new ILM_PC_FundingPot_v1.UnspentPersonalRoundCap[](0); + fundingPot.contributeToRoundFor( + contributor1_, + roundId, + params.roundCap, + accessCriteriaId, + new bytes32[](0), + unspentPersonalRoundCaps + ); + vm.stopPrank(); + + assertTrue(fundingPot.exposed_checkRoundClosureConditions(roundId)); + } + + function test_checkRoundClosureConditions_withNoCap() public { + RoundParams memory params = _defaultRoundParams; + params.roundStart = block.timestamp + 1 days; + params.roundEnd = block.timestamp + 2 days; + params.roundCap = 0; // No cap + + uint32 roundId = fundingPot.createRound( + params.roundStart, + params.roundEnd, + params.roundCap, + params.hookContract, + params.hookFunction, + params.autoClosure, + params.accumulationMode + ); + + // Should be false before end time + assertFalse( + fundingPot.exposed_checkRoundClosureConditions(uint32(roundId)) + ); + + // Should be true after end time + vm.warp(params.roundEnd + 1); + assertTrue( + fundingPot.exposed_checkRoundClosureConditions(uint32(roundId)) + ); + } + + function test_closeRound_worksGivenCapReached() public { + testCreateRound(); + + uint32 roundId = fundingPot.roundCount(); + uint8 accessCriteriaId = 1; + uint8 accessType = uint8(ILM_PC_FundingPot_v1.AccessCriteriaType.OPEN); + + uint amount = 1000; + + ( + address nftContract, + bytes32 merkleRoot, + address[] memory allowedAddresses + ) = _helper_createAccessCriteria(accessType, roundId); + + fundingPot.setAccessCriteria( + roundId, + accessType, + 0, + nftContract, + merkleRoot, + allowedAddresses, + removedAddresses + ); + fundingPot.setAccessCriteriaPrivileges( + roundId, accessCriteriaId, 1000, false, 0, 0, 0 + ); + + mockNFTContract.mint(contributor1_); + + (uint roundStart,,,,,,) = fundingPot.getRoundGenericParameters(roundId); + vm.warp(roundStart + 1); + + // Approve + vm.prank(contributor1_); + _token.approve(address(fundingPot), 1000); + + vm.prank(contributor1_); + ILM_PC_FundingPot_v1.UnspentPersonalRoundCap[] memory + unspentPersonalRoundCaps = + new ILM_PC_FundingPot_v1.UnspentPersonalRoundCap[](0); + fundingPot.contributeToRoundFor( + contributor1_, + roundId, + amount, + accessCriteriaId, + new bytes32[](0), + unspentPersonalRoundCaps + ); + + assertTrue( + fundingPot.exposed_checkRoundClosureConditions(uint32(roundId)) + ); + + uint startIndex = 0; + uint batchSize = 1; + fundingPot.exposed_closeRound(uint32(roundId)); + fundingPot.exposed_buyBondingCurveToken(uint32(roundId)); + fundingPot.exposed_createPaymentOrdersForContributors( + uint32(roundId), startIndex, batchSize + ); + + assertTrue(fundingPot.roundIdToClosedStatus(roundId)); + } + + // ------------------------------------------------------------------------- + // Test: _buyBondingCurveToken + function test_buyBondingCurveToken_revertsGivenNoContributions() public { + testCreateRound(); + uint32 roundId = fundingPot.roundCount(); + (uint roundStart,,,,,,) = fundingPot.getRoundGenericParameters(roundId); + vm.warp(roundStart + 1); + + vm.expectRevert( + ILM_PC_FundingPot_v1 + .Module__LM_PC_FundingPot__NoContributions + .selector + ); + fundingPot.exposed_buyBondingCurveToken(roundId); + } + // ------------------------------------------------------------------------- + // Helper Functions + + // @notice Creates edit round parameters with customizable values + function _helper_createEditRoundParams( + uint roundStart_, + uint roundEnd_, + uint roundCap_, + address hookContract_, + bytes memory hookFunction_, + bool autoClosure_, + ILM_PC_FundingPot_v1.AccumulationMode accumulationMode_ + ) internal pure returns (RoundParams memory) { + return RoundParams({ + roundStart: roundStart_, + roundEnd: roundEnd_, + roundCap: roundCap_, + hookContract: hookContract_, + hookFunction: hookFunction_, + autoClosure: autoClosure_, + accumulationMode: accumulationMode_ + }); + } + + function _helper_generateMerkleTreeForTwoLeaves( + address contributorA, + address contributorB, + uint32 roundId + ) + internal + pure + returns ( + bytes32 root, + bytes32 leafA, + bytes32 leafB, + bytes32[] memory proofA, + bytes32[] memory proofB + ) + { + leafA = keccak256(abi.encodePacked(contributorA, roundId)); + leafB = keccak256(abi.encodePacked(contributorB, roundId)); + + proofA = new bytes32[](1); + proofB = new bytes32[](1); + + // Ensure consistent ordering for root calculation + if (leafA < leafB) { + root = keccak256(abi.encodePacked(leafA, leafB)); + proofA[0] = leafB; // Proof for A is B + proofB[0] = leafA; // Proof for B is A + } else { + root = keccak256(abi.encodePacked(leafB, leafA)); + proofA[0] = leafB; // Proof for A is still B + proofB[0] = leafA; // Proof for B is still A + } + } + + function _helper_createAccessCriteria( + uint8 accessCriteriaEnum, + uint32 roundId + ) + internal + view + returns ( + address nftContract_, + bytes32 merkleRoot_, + address[] memory allowedAddresses_ + ) + { + { + if ( + accessCriteriaEnum + == uint8(ILM_PC_FundingPot_v1.AccessCriteriaType.OPEN) + ) { + nftContract_ = address(0x0); + merkleRoot_ = bytes32(uint(0x0)); + allowedAddresses_ = new address[](0); + } else if ( + accessCriteriaEnum + == uint8(ILM_PC_FundingPot_v1.AccessCriteriaType.NFT) + ) { + address nftContract = address(mockNFTContract); + + nftContract_ = nftContract; + merkleRoot_ = bytes32(uint(0x0)); + allowedAddresses_ = new address[](0); + } else if ( + accessCriteriaEnum + == uint8(ILM_PC_FundingPot_v1.AccessCriteriaType.MERKLE) + ) { + (bytes32 merkleRoot,,,,) = + _helper_generateMerkleTreeForTwoLeaves( + contributor1_, contributor2_, roundId + ); + + nftContract_ = address(0x0); + merkleRoot_ = merkleRoot; + allowedAddresses_ = new address[](0); + } else if ( + accessCriteriaEnum + == uint8(ILM_PC_FundingPot_v1.AccessCriteriaType.LIST) + ) { + address[] memory allowedAddresses = new address[](4); + allowedAddresses[0] = address(this); + allowedAddresses[1] = address(0x2); + allowedAddresses[2] = address(0x3); + allowedAddresses[3] = contributor2_; + nftContract_ = address(0x0); + merkleRoot_ = bytes32(uint(0x0)); + allowedAddresses_ = allowedAddresses; + } + } + } + + // Helper function to set up a round with access criteria + function _helper_setupRoundWithAccessCriteria(uint8 accessCriteriaEnum) + internal + { + uint32 roundId = fundingPot.createRound( + _defaultRoundParams.roundStart, + _defaultRoundParams.roundEnd, + _defaultRoundParams.roundCap, + _defaultRoundParams.hookContract, + _defaultRoundParams.hookFunction, + _defaultRoundParams.autoClosure, + _defaultRoundParams.accumulationMode + ); + + ( + address nftContract, + bytes32 merkleRoot, + address[] memory allowedAddresses + ) = _helper_createAccessCriteria(accessCriteriaEnum, roundId); + + fundingPot.setAccessCriteria( + roundId, + accessCriteriaEnum, + 0, + nftContract, + merkleRoot, + allowedAddresses, + removedAddresses + ); + } + + // Helper function to set up access criteria for an existing round + function _helper_setupAccessCriteriaForRound( + uint32 roundId_, + uint8 accessCriteriaEnum_, + uint8 accessCriteriaId_, + uint personalCap_ + ) internal { + ( + address nftContract, + bytes32 merkleRoot, + address[] memory allowedAddresses + ) = _helper_createAccessCriteria(accessCriteriaEnum_, roundId_); + + fundingPot.setAccessCriteria( + roundId_, + accessCriteriaEnum_, + 0, + nftContract, + merkleRoot, + allowedAddresses, + removedAddresses + ); + fundingPot.setAccessCriteriaPrivileges( + roundId_, accessCriteriaId_, personalCap_, false, 0, 0, 0 + ); + } + + // ========================================================================= + // Test: contributeToRoundFor Authorization + // ========================================================================= + + function testContributeToRoundFor_revertsWhenNonOwnerTriesToUseUnspentCaps() + public + { + // Setup: Create two users - Alice and Bob + address alice = address(0x1111); + address bob = address(0x2222); + + // Give both users some tokens + vm.deal(alice, 10 ether); + vm.deal(bob, 10 ether); + _token.mint(alice, 1000); + _token.mint(bob, 1000); + + // Create first round where Alice contributes + vm.startPrank(address(this)); + uint32 round1 = fundingPot.createRound( + block.timestamp + 1 days, // start + block.timestamp + 7 days, // end + 500, // cap + address(0), // hookContract + "", // hookFunction + false, // autoClosure + ILM_PC_FundingPot_v1.AccumulationMode.Personal // allow personal accumulation + ); + + // Set up access criteria for round 1 + address[] memory allowedAddresses = new address[](2); + allowedAddresses[0] = alice; + allowedAddresses[1] = bob; + address[] memory localRemovedAddresses; + + fundingPot.setAccessCriteria( + round1, + uint8(ILM_PC_FundingPot_v1.AccessCriteriaType.LIST), + 0, // new access criteria + address(0), + bytes32(0), + allowedAddresses, + localRemovedAddresses + ); + + // Set personal cap for access criteria + fundingPot.setAccessCriteriaPrivileges( + round1, + 1, // accessCriteriaId + 200, // personalCap + false, // overrideContributionSpan + 0, + 0, + 0 // time parameters + ); + vm.stopPrank(); + + // Alice contributes to round 1 (only partially using her cap) + vm.warp(block.timestamp + 1 days + 1); // move to round start + vm.startPrank(alice); + _token.approve(address(fundingPot), 100); + + ILM_PC_FundingPot_v1.UnspentPersonalRoundCap[] memory emptyUnspentCaps; + bytes32[] memory emptyProof; + + fundingPot.contributeToRoundFor( + alice, + round1, + 100, // only use 100 out of 200 cap + 1, // accessCriteriaId + emptyProof, + emptyUnspentCaps + ); + vm.stopPrank(); + + // Close round 1 + vm.warp(block.timestamp + 7 days); + vm.prank(address(this)); + fundingPot.closeRound(round1); + + // Create second round with personal accumulation + vm.startPrank(address(this)); + uint32 round2 = fundingPot.createRound( + block.timestamp + 1 days, + block.timestamp + 7 days, + 500, + address(0), + "", + false, + ILM_PC_FundingPot_v1.AccumulationMode.Personal + ); + + // Set up same access criteria for round 2 + fundingPot.setAccessCriteria( + round2, + uint8(ILM_PC_FundingPot_v1.AccessCriteriaType.LIST), + 0, + address(0), + bytes32(0), + allowedAddresses, + localRemovedAddresses + ); + + fundingPot.setAccessCriteriaPrivileges( + round2, + 1, + 200, // same personal cap + false, + 0, + 0, + 0 + ); + vm.stopPrank(); + + // Create Alice's unspent cap data from round 1 + ILM_PC_FundingPot_v1.UnspentPersonalRoundCap[] memory aliceUnspentCaps = + new ILM_PC_FundingPot_v1.UnspentPersonalRoundCap[](1); + aliceUnspentCaps[0] = ILM_PC_FundingPot_v1.UnspentPersonalRoundCap({ + roundId: round1, + accessCriteriaId: 1, + merkleProof: emptyProof + }); + + // Move to round 2 start + vm.warp(block.timestamp + 1 days + 1); + + // Test: Bob tries to use Alice's unspent caps for his own contribution + vm.startPrank(bob); + _token.approve(address(fundingPot), 50); + + // Should revert with OnlyOwnerCanUseUnspentCaps + vm.expectRevert( + ILM_PC_FundingPot_v1 + .Module__LM_PC_FundingPot__OnlyOwnerCanUseUnspentCaps + .selector + ); + + fundingPot.contributeToRoundFor( + alice, // Bob contributing FOR Alice + round2, + 50, + 1, + emptyProof, + aliceUnspentCaps // Using Alice's unspent caps but called by Bob + ); + vm.stopPrank(); + + // Verify: Alice can still use her own unspent caps + vm.startPrank(alice); + _token.approve(address(fundingPot), 150); + + // This should work - Alice using her own unspent caps + fundingPot.contributeToRoundFor( + alice, + round2, + 150, // Alice can contribute more than base cap due to unspent caps + 1, + emptyProof, + aliceUnspentCaps + ); + vm.stopPrank(); + + // Verify Alice's contribution succeeded + assertEq(fundingPot.roundIdToUserToContribution(round2, alice), 150); + + // Verify Bob has no contributions (since his attack failed) + assertEq(fundingPot.roundIdToUserToContribution(round2, bob), 0); + } +} diff --git a/test/unit/modules/logicModule/LM_PC_FundingPot_v1_Exposed.sol b/test/unit/modules/logicModule/LM_PC_FundingPot_v1_Exposed.sol new file mode 100644 index 000000000..350b75753 --- /dev/null +++ b/test/unit/modules/logicModule/LM_PC_FundingPot_v1_Exposed.sol @@ -0,0 +1,160 @@ +// SPDX-License-Identifier: LGPL-3.0-only +pragma solidity 0.8.23; + +// Internal +import {LM_PC_FundingPot_v1} from + "src/modules/logicModule/LM_PC_FundingPot_v1.sol"; + +// Access Mock of the LM_PC_FundingPot_v1 contract for Testing. +contract LM_PC_FundingPot_v1_Exposed is LM_PC_FundingPot_v1 { + // Use the `exposed_` prefix for functions to expose internal functions for + // testing. + + /** + * @notice Exposes the internal _validTimes function for testing + */ + function exposed_validTimes(uint start_, uint cliff_, uint end_) + external + pure + returns (bool) + { + return _validTimes(start_, cliff_, end_); + } + + /** + * @notice Exposes the internal _validateAndAdjustCapsWithUnspentCap function for testing + */ + function exposed_validateAndAdjustCapsWithUnspentCap( + address user_, + uint32 roundId_, + uint amount_, + uint8 accessCriteriaId__, + bool canOverrideContributionSpan_, + uint unspentPersonalCap_ + ) external view returns (uint) { + return _validateAndAdjustCapsWithUnspentCap( + user_, + roundId_, + amount_, + accessCriteriaId__, + canOverrideContributionSpan_, + unspentPersonalCap_ + ); + } + + /** + * @notice Exposes the internal _validateAccessCriteria function for testing + */ + function exposed_validateAccessCriteria( + uint32 roundId_, + uint8 accessId_, + bytes32[] calldata merkleProof_, + address user_ + ) external view { + _validateAccessCriteria(roundId_, accessId_, merkleProof_, user_); + } + + /** + * @notice Exposes the internal _checkAccessCriteriaEligibility function for testing + */ + function exposed_checkAccessCriteriaEligibility( + uint32 roundId_, + uint8 accessCriteriaId_, + bytes32[] memory merkleProof_, + address user_ + ) external view returns (bool) { + return _checkAccessCriteriaEligibility( + roundId_, accessCriteriaId_, merkleProof_, user_ + ); + } + + /** + * @notice Exposes the internal _checkNftOwnership function for testing + */ + function exposed_checkNftOwnership(address nftContract_, address user_) + external + view + returns (bool) + { + return _checkNftOwnership(nftContract_, user_); + } + + /** + * @notice Exposes the internal _validateMerkleProof function for testing + */ + function exposed_validateMerkleProof( + bytes32 root_, + bytes32[] memory merkleProof_, + address user_, + uint32 roundId_ + ) external pure returns (bool) { + return _validateMerkleProof(root_, merkleProof_, user_, roundId_); + } + + /** + * @notice Exposes the internal _calculateUnusedCapacityFromPreviousRounds function for testing + */ + function exposed_calculateUnusedCapacityFromPreviousRounds(uint32 roundId_) + external + view + returns (uint) + { + return _calculateUnusedCapacityFromPreviousRounds(roundId_); + } + + function exposed_contributeToRoundFor( + address user_, + uint32 roundId_, + uint amount_, + uint8 accessCriteriaId__, + bytes32[] memory merkleProof_, + uint unspentPersonalCap_ + ) external { + _contributeToRoundFor( + user_, + roundId_, + amount_, + accessCriteriaId__, + merkleProof_, + unspentPersonalCap_ + ); + } + + /** + * @notice Exposes the internal _closeRound function for testing + */ + function exposed_closeRound(uint32 roundId_) external { + _closeRound(roundId_); + } + + /** + * @notice Exposes the internal _checkRoundClosureConditions function for testing + */ + function exposed_checkRoundClosureConditions(uint32 roundId_) + external + view + returns (bool) + { + return _checkRoundClosureConditions(roundId_); + } + + /** + * @notice Exposes the internal _buyBondingCurveToken function for testing + */ + function exposed_buyBondingCurveToken(uint32 roundId_) external { + return _buyBondingCurveToken(roundId_); + } + + /** + * @notice Exposes the internal _createPaymentOrdersForContributors function for testing + */ + function exposed_createPaymentOrdersForContributors( + uint32 roundId_, + uint startIndex_, + uint batchSize_ + ) external { + return _createPaymentOrdersForContributors( + roundId_, startIndex_, batchSize_ + ); + } +}