// SPDX-License-Identifier: MIT pragma solidity 0.8.28; import {JBControlled} from "./abstract/JBControlled.sol"; import {IJBDirectory} from "./interfaces/IJBDirectory.sol"; import {IJBSplitHook} from "./interfaces/IJBSplitHook.sol"; import {IJBSplits} from "./interfaces/IJBSplits.sol"; import {JBConstants} from "./libraries/JBConstants.sol"; import {JBSplit} from "./structs/JBSplit.sol"; import {JBSplitGroup} from "./structs/JBSplitGroup.sol"; /// @notice Manages how a project distributes its payouts and reserved tokens. Each "split" specifies a recipient and /// their share (as a fraction of 1,000,000,000). Splits can be locked until a timestamp — locked splits cannot be /// removed or reduced until the lock expires, providing recipients with guaranteed revenue streams. /// @dev Splits are organized by project, ruleset, and group. Ruleset 0 is the fallback used when no splits are set for /// the active ruleset. The payout group ID is derived from the token address to distribute. contract JBSplits is JBControlled, IJBSplits { //*********************************************************************// // --------------------------- custom errors ------------------------- // //*********************************************************************// /// @notice Thrown when a new set of splits omits a still-locked split from the previous set. error JBSplits_PreviousLockedSplitsNotIncluded(uint256 projectId, uint256 rulesetId); /// @notice Thrown when the splits in a group sum to more than 100%. error JBSplits_TotalPercentExceeds100(uint256 projectId, uint256 rulesetId, uint256 groupId, uint256 percentTotal); /// @notice Thrown when a split is configured with a percent of zero. error JBSplits_ZeroSplitPercent(uint256 projectId, uint256 rulesetId, uint256 groupId, uint256 splitIndex); //*********************************************************************// // ------------------------- public constants ------------------------ // //*********************************************************************// /// @notice The ID of the ruleset that will be checked if nothing was found in the provided rulesetId. uint256 public constant override FALLBACK_RULESET_ID = 0; //*********************************************************************// // --------------------- internal stored properties ------------------ // //*********************************************************************// /// @notice Packed split data given the split's project, ruleset, and group IDs, as well as the split's index within /// that group. /// @dev `preferAddToBalance` in bit 0, `percent` in bits 1-32, `projectId` in bits 33-88, and `beneficiary` in bits /// 89-248 /// @custom:param projectId The ID of the project that the split applies to. /// @custom:param rulesetId The ID of the ruleset that the group is in. /// @custom:param groupId The ID of the group the split is in. /// @custom:param index The split's index within the group (in the order that the split were set). /// @custom:return The split's `preferAddToBalance`, `percent`, `projectId`, and `beneficiary` packed into one /// `uint256`. mapping( uint256 projectId => mapping(uint256 rulesetId => mapping(uint256 groupId => mapping(uint256 index => uint256))) ) internal _packedSplitParts1Of; /// @notice More packed split data given the split's project, ruleset, and group IDs, as well as the split's index /// within that group. /// @dev `lockedUntil` in bits 0-47, `hook` address in bits 48-207. /// @dev This packed data is often 0. /// @custom:param projectId The ID of the project that the ruleset applies to. /// @custom:param rulesetId The ID of the ruleset that the group is in. /// @custom:param groupId The ID of the group the split is in. /// @custom:param index The split's index within the group (in the order that the split were set). /// @custom:return The split's `lockedUntil` and `hook` packed into one `uint256`. mapping( uint256 projectId => mapping(uint256 rulesetId => mapping(uint256 groupId => mapping(uint256 index => uint256))) ) internal _packedSplitParts2Of; /// @notice The number of splits currently stored in a group given a project ID, ruleset ID, and group ID. /// @custom:param projectId The ID of the project the split applies to. /// @custom:param rulesetId The ID of the ruleset that the group is specified within. /// @custom:param groupId The ID of the group to count this splits of. mapping(uint256 projectId => mapping(uint256 rulesetId => mapping(uint256 groupId => uint256))) internal _splitCountOf; //*********************************************************************// // -------------------------- constructor ---------------------------- // //*********************************************************************// /// @param directory A contract storing directories of terminals and controllers for each project. constructor(IJBDirectory directory) JBControlled(directory) {} //*********************************************************************// // ---------------------- external transactions ---------------------- // //*********************************************************************// /// @notice Configure how a project distributes funds to its split recipients. Each split group defines a list of /// recipients (with percentage shares) for a specific purpose (e.g. payouts in ETH, reserved token distribution). /// @dev Only the project's controller can set splits — unless the group ID encodes `msg.sender` in its lower 160 /// bits with non-zero upper 96 bits, which enables self-managed splits (e.g. hooks managing their own groups). /// @dev Locked splits cannot be removed or reduced until their lock expires. The new groups must include all /// currently-locked splits. /// @param projectId The ID of the project to set the split groups of. /// @param rulesetId The ID of the ruleset the split groups should be active in. Pass 0 to set the default splits /// (used when no ruleset-specific splits are configured). The default's default is the project's owner. /// @param splitGroups An array of split groups to set. function setSplitGroupsOf( uint256 projectId, uint256 rulesetId, JBSplitGroup[] calldata splitGroups ) external override { // Cache whether the controller check has already passed to avoid repeated external calls. bool controllerChecked; // Set each grouped splits. Access calldata directly to avoid copying each split group to memory. for (uint256 i; i < splitGroups.length;) { // Self-auth: lower 160 bits must match msg.sender AND upper 96 bits must be non-zero. // GroupIds with zero upper bits are reserved for protocol use (e.g. terminal payout groups) // and always require controller authorization to prevent token contracts from hijacking payouts. bool isSelfManaged = splitGroups[i].groupId >> 160 != 0 && address(uint160(splitGroups[i].groupId)) == msg.sender; if (!isSelfManaged && !controllerChecked) { _onlyControllerOf(projectId); controllerChecked = true; } // Set the splits for the group. _setSplitsOf({ projectId: projectId, rulesetId: rulesetId, groupId: splitGroups[i].groupId, splits: splitGroups[i].splits }); unchecked { ++i; } } } //*********************************************************************// // ------------------------- external views -------------------------- // //*********************************************************************// /// @notice Get the list of split recipients for a project's group within a given ruleset. Falls back to the /// default splits (ruleset 0) if none are set for the specific ruleset. /// @param projectId The ID of the project to get splits for. /// @param rulesetId The ruleset to look up splits in. Falls back to ruleset 0 if none are found. /// @param groupId The split group ID (e.g. token address for payout groups, or a custom ID for reserved tokens). /// @return splits An array of all splits for the project. function splitsOf( uint256 projectId, uint256 rulesetId, uint256 groupId ) external view override returns (JBSplit[] memory splits) { splits = _getStructsFor({projectId: projectId, rulesetId: rulesetId, groupId: groupId}); // Use the default splits if there aren't any for the ruleset. if (splits.length == 0) { splits = _getStructsFor({projectId: projectId, rulesetId: FALLBACK_RULESET_ID, groupId: groupId}); } } //*********************************************************************// // ---------------------- internal transactions ---------------------- // //*********************************************************************// /// @notice Sets the splits for a group given a project, ruleset, and group ID. /// @dev The new splits must include any currently set splits that are locked. /// @dev The sum of the split `percent`s within one group must be less than 100%. /// @param projectId The ID of the project to set splits for. /// @param rulesetId The ID of the ruleset the splits should be considered active within. /// @param groupId The ID of the group to set the splits within. /// @param splits An array of splits to set. function _setSplitsOf(uint256 projectId, uint256 rulesetId, uint256 groupId, JBSplit[] memory splits) internal { // Get a reference to the current split structs within the project, ruleset, and group. JBSplit[] memory currentSplits = _getStructsFor({projectId: projectId, rulesetId: rulesetId, groupId: groupId}); // Keep a reference to the current number of splits within the group. uint256 numberOfCurrentSplits = currentSplits.length; // Check to see if all locked splits are included in the array of splits which is being set. // Duplicate locked splits must be preserved with the same multiplicity. Otherwise a table with two identical // locked splits could be collapsed into one matching split and one unrelated split while each old split sees // the same new match. for (uint256 i; i < numberOfCurrentSplits;) { // If not locked, continue. // forge-lint: disable-next-line(block-timestamp) if (block.timestamp < currentSplits[i].lockedUntil) { uint256 requiredCount; for (uint256 j; j < numberOfCurrentSplits;) { if ( // forge-lint: disable-next-line(block-timestamp) block.timestamp < currentSplits[j].lockedUntil && _isLockedSplitIncluded({split: currentSplits[j], lockedSplit: currentSplits[i]}) ) { ++requiredCount; } unchecked { ++j; } } if (_includedLockedSplitCount({splits: splits, lockedSplit: currentSplits[i]}) < requiredCount) { revert JBSplits_PreviousLockedSplitsNotIncluded({projectId: projectId, rulesetId: rulesetId}); } } unchecked { ++i; } } // Add up all the `percent`s to make sure their total is under 100%. uint256 percentTotal; // Keep a reference to the number of splits to set. uint256 numberOfSplits = splits.length; for (uint256 i; i < numberOfSplits;) { // Set the split being iterated on. JBSplit memory split = splits[i]; // The percent should be greater than 0. if (split.percent == 0) { revert JBSplits_ZeroSplitPercent({ projectId: projectId, rulesetId: rulesetId, groupId: groupId, splitIndex: i }); } // Add to the `percent` total. percentTotal += split.percent; // Ensure the total does not exceed 100%. if (percentTotal > JBConstants.SPLITS_TOTAL_PERCENT) { revert JBSplits_TotalPercentExceeds100({ projectId: projectId, rulesetId: rulesetId, groupId: groupId, percentTotal: percentTotal }); } uint256 packedSplitParts1; // Pack `percent` in bits 0-31. packedSplitParts1 = split.percent; // Pack `projectId` in bits 32-95. packedSplitParts1 |= uint256(split.projectId) << 32; // Pack `beneficiary` in bits 96-255. packedSplitParts1 |= uint256(uint160(address(split.beneficiary))) << 96; // Store the first split part. _packedSplitParts1Of[projectId][rulesetId][groupId][i] = packedSplitParts1; // If there's data to store in the second packed split part, pack and store. if (split.preferAddToBalance || split.lockedUntil > 0 || split.hook != IJBSplitHook(address(0))) { // Pack `preferAddToBalance` in bit 0. uint256 packedSplitParts2 = split.preferAddToBalance ? 1 : 0; // Pack `lockedUntil` in bits 1-48. packedSplitParts2 |= uint256(split.lockedUntil) << 1; // Pack `hook` in bits 49-208. packedSplitParts2 |= uint256(uint160(address(split.hook))) << 49; // Store the second split part. _packedSplitParts2Of[projectId][rulesetId][groupId][i] = packedSplitParts2; } else if (_packedSplitParts2Of[projectId][rulesetId][groupId][i] > 0) { // If there's a value stored in the indexed position, delete it. delete _packedSplitParts2Of[projectId][rulesetId][groupId][i]; } emit SetSplit({ projectId: projectId, rulesetId: rulesetId, groupId: groupId, split: split, caller: msg.sender }); unchecked { ++i; } } // Store the number of splits for the project, ruleset, and group. _splitCountOf[projectId][rulesetId][groupId] = numberOfSplits; // Clean up stale storage slots if the new split count is less than the previous count. // This zeroes out leftover packed data to reclaim gas via storage refunds. for (uint256 i = numberOfSplits; i < numberOfCurrentSplits;) { delete _packedSplitParts1Of[projectId][rulesetId][groupId][i]; delete _packedSplitParts2Of[projectId][rulesetId][groupId][i]; unchecked { ++i; } } } //*********************************************************************// // -------------------------- internal views ------------------------- // //*********************************************************************// /// @notice Unpack an array of `JBSplit` structs for all of the splits in a group, given project, ruleset, and group /// IDs. /// @param projectId The ID of the project the splits belong to. /// @param rulesetId The ID of the ruleset the group of splits should be considered active within. /// @param groupId The ID of the group to get the splits structs of. /// @return splits The split structs, as an array of `JBSplit`s. function _getStructsFor( uint256 projectId, uint256 rulesetId, uint256 groupId ) internal view returns (JBSplit[] memory) { // Get a reference to the number of splits that need to be added to the returned array. uint256 splitCount = _splitCountOf[projectId][rulesetId][groupId]; // Initialize an array to be returned that has the appropriate length. JBSplit[] memory splits = new JBSplit[](splitCount); // Loop through each split and unpack the values into structs. for (uint256 i; i < splitCount;) { // Get a reference to the first part of the split's packed data. uint256 packedSplitPart1 = _packedSplitParts1Of[projectId][rulesetId][groupId][i]; // Populate the split struct. JBSplit memory split; // `percent` in bits 0-31. // forge-lint: disable-next-line(unsafe-typecast) split.percent = uint32(packedSplitPart1); // `projectId` in bits 32-95. // forge-lint: disable-next-line(unsafe-typecast) split.projectId = uint64(packedSplitPart1 >> 32); // `beneficiary` in bits 96-255. // forge-lint: disable-next-line(unsafe-typecast) split.beneficiary = payable(address(uint160(packedSplitPart1 >> 96))); // Get a reference to the second part of the split's packed data. uint256 packedSplitPart2 = _packedSplitParts2Of[projectId][rulesetId][groupId][i]; // If there's anything in it, unpack. if (packedSplitPart2 > 0) { // `preferAddToBalance` in bit 0. split.preferAddToBalance = packedSplitPart2 & 1 == 1; // `lockedUntil` in bits 1-48. // forge-lint: disable-next-line(unsafe-typecast) split.lockedUntil = uint48(packedSplitPart2 >> 1); // `hook` in bits 49-208. // forge-lint: disable-next-line(unsafe-typecast) split.hook = IJBSplitHook(address(uint160(packedSplitPart2 >> 49))); } // Add the split to the value being returned. splits[i] = split; unchecked { ++i; } } return splits; } /// @notice Count the splits in the provided array that include the locked split. /// @param splits The array of splits to check within. /// @param lockedSplit The locked split. /// @return count The number of matching splits. function _includedLockedSplitCount( JBSplit[] memory splits, JBSplit memory lockedSplit ) internal pure returns (uint256 count) { // Keep a reference to the number of splits. uint256 numberOfSplits = splits.length; for (uint256 i; i < numberOfSplits;) { if (_isLockedSplitIncluded({split: splits[i], lockedSplit: lockedSplit})) ++count; unchecked { ++i; } } } /// @notice Determine if a split satisfies the locked split's immutable fields and lock length. /// @param split The split to check. /// @param lockedSplit The locked split. /// @return A flag indicating whether the split includes the locked split. function _isLockedSplitIncluded(JBSplit memory split, JBSplit memory lockedSplit) internal pure returns (bool) { // Allow the lock to be extended. return split.percent == lockedSplit.percent && split.beneficiary == lockedSplit.beneficiary && split.hook == lockedSplit.hook && split.projectId == lockedSplit.projectId && split.preferAddToBalance == lockedSplit.preferAddToBalance && split.lockedUntil >= lockedSplit.lockedUntil; } }