diff --git a/src/JBController4_0_1.sol b/src/JBController4_0_1.sol new file mode 100644 index 000000000..8dfb3c4f3 --- /dev/null +++ b/src/JBController4_0_1.sol @@ -0,0 +1,1170 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +import {JBPermissionIds} from "@bananapus/permission-ids/src/JBPermissionIds.sol"; +import {ERC2771Context} from "@openzeppelin/contracts/metatx/ERC2771Context.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {Context} from "@openzeppelin/contracts/utils/Context.sol"; +import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; +import {mulDiv} from "@prb/math/src/Common.sol"; + +import {JBPermissioned} from "./abstract/JBPermissioned.sol"; +import {JBApprovalStatus} from "./enums/JBApprovalStatus.sol"; +import {IJBController} from "./interfaces/IJBController.sol"; +import {IJBDirectory} from "./interfaces/IJBDirectory.sol"; +import {IJBDirectoryAccessControl} from "./interfaces/IJBDirectoryAccessControl.sol"; +import {IJBFundAccessLimits} from "./interfaces/IJBFundAccessLimits.sol"; +import {IJBMigratable} from "./interfaces/IJBMigratable.sol"; +import {IJBPermissioned} from "./interfaces/IJBPermissioned.sol"; +import {IJBPermissions} from "./interfaces/IJBPermissions.sol"; +import {IJBPriceFeed} from "./interfaces/IJBPriceFeed.sol"; +import {IJBPrices} from "./interfaces/IJBPrices.sol"; +import {IJBProjects} from "./interfaces/IJBProjects.sol"; +import {IJBProjectUriRegistry} from "./interfaces/IJBProjectUriRegistry.sol"; +import {IJBRulesetDataHook4_0_1} from "./interfaces/IJBRulesetDataHook4_0_1.sol"; +import {IJBRulesets} from "./interfaces/IJBRulesets.sol"; +import {IJBSplitHook} from "./interfaces/IJBSplitHook.sol"; +import {IJBSplits} from "./interfaces/IJBSplits.sol"; +import {IJBTerminal} from "./interfaces/IJBTerminal.sol"; +import {IJBToken} from "./interfaces/IJBToken.sol"; +import {IJBTokens} from "./interfaces/IJBTokens.sol"; +import {JBConstants} from "./libraries/JBConstants.sol"; +import {JBRulesetMetadataResolver} from "./libraries/JBRulesetMetadataResolver.sol"; +import {JBSplitGroupIds} from "./libraries/JBSplitGroupIds.sol"; +import {JBRuleset} from "./structs/JBRuleset.sol"; +import {JBRulesetConfig} from "./structs/JBRulesetConfig.sol"; +import {JBRulesetMetadata} from "./structs/JBRulesetMetadata.sol"; +import {JBRulesetWithMetadata} from "./structs/JBRulesetWithMetadata.sol"; +import {JBSplit} from "./structs/JBSplit.sol"; +import {JBSplitGroup} from "./structs/JBSplitGroup.sol"; +import {JBSplitHookContext} from "./structs/JBSplitHookContext.sol"; +import {JBTerminalConfig} from "./structs/JBTerminalConfig.sol"; + +/// @notice `JBController` coordinates rulesets and project tokens, and is the entry point for most operations related +/// to rulesets and project tokens. +contract JBController is JBPermissioned, ERC2771Context, IJBController, IJBMigratable { + // A library that parses packed ruleset metadata into a friendlier format. + using JBRulesetMetadataResolver for JBRuleset; + + // A library that adds default safety checks to ERC20 functionality. + using SafeERC20 for IERC20; + + //*********************************************************************// + // --------------------------- custom errors ------------------------- // + //*********************************************************************// + + error JBController_AddingPriceFeedNotAllowed(); + error JBController_CreditTransfersPaused(); + error JBController_InvalidCashOutTaxRate(uint256 rate, uint256 limit); + error JBController_InvalidReservedPercent(uint256 percent, uint256 limit); + error JBController_MintNotAllowedAndNotTerminalOrHook(); + error JBController_NoReservedTokens(); + error JBController_OnlyDirectory(address sender, IJBDirectory directory); + error JBController_RulesetsAlreadyLaunched(); + error JBController_RulesetsArrayEmpty(); + error JBController_RulesetSetTokenNotAllowed(); + error JBController_ZeroTokensToBurn(); + error JBController_ZeroTokensToMint(); + + //*********************************************************************// + // --------------- public immutable stored properties ---------------- // + //*********************************************************************// + + /// @notice The directory of terminals and controllers for projects. + IJBDirectory public immutable override DIRECTORY; + + /// @notice A contract that stores fund access limits for each project. + IJBFundAccessLimits public immutable override FUND_ACCESS_LIMITS; + + /// @notice A contract that stores prices for each project. + IJBPrices public immutable override PRICES; + + /// @notice Mints ERC-721s that represent project ownership and transfers. + IJBProjects public immutable override PROJECTS; + + /// @notice The contract storing and managing project rulesets. + IJBRulesets public immutable override RULESETS; + + /// @notice The contract that stores splits for each project. + IJBSplits public immutable override SPLITS; + + /// @notice The contract that manages token minting and burning. + IJBTokens public immutable override TOKENS; + + //*********************************************************************// + // --------------------- public stored properties -------------------- // + //*********************************************************************// + + /// @notice A project's unrealized reserved token balance (i.e. reserved tokens which haven't been sent out to the + /// reserved token split group yet). + /// @custom:param projectId The ID of the project to get the pending reserved token balance of. + mapping(uint256 projectId => uint256) public override pendingReservedTokenBalanceOf; + + /// @notice The metadata URI for each project. This is typically an IPFS hash, optionally with an `ipfs://` prefix. + /// @custom:param projectId The ID of the project to get the metadata URI of. + mapping(uint256 projectId => string) public override uriOf; + + //*********************************************************************// + // ---------------------------- constructor -------------------------- // + //*********************************************************************// + + /// @param directory A contract storing directories of terminals and controllers for each project. + /// @param fundAccessLimits A contract that stores fund access limits for each project. + /// @param permissions A contract storing permissions. + /// @param prices A contract that stores prices for each project. + /// @param projects A contract which mints ERC-721s that represent project ownership and transfers. + /// @param rulesets A contract storing and managing project rulesets. + /// @param splits A contract that stores splits for each project. + /// @param tokens A contract that manages token minting and burning. + /// @param trustedForwarder The trusted forwarder for the ERC2771Context. + constructor( + IJBDirectory directory, + IJBFundAccessLimits fundAccessLimits, + IJBPermissions permissions, + IJBPrices prices, + IJBProjects projects, + IJBRulesets rulesets, + IJBSplits splits, + IJBTokens tokens, + address trustedForwarder + ) + JBPermissioned(permissions) + ERC2771Context(trustedForwarder) + { + DIRECTORY = directory; + FUND_ACCESS_LIMITS = fundAccessLimits; + PRICES = prices; + PROJECTS = projects; + RULESETS = rulesets; + SPLITS = splits; + TOKENS = tokens; + } + + //*********************************************************************// + // ------------------------- external views -------------------------- // + //*********************************************************************// + + /// @notice Get an array of a project's rulesets (with metadata) up to a maximum array size, sorted from latest to + /// earliest. + /// @param projectId The ID of the project to get the rulesets of. + /// @param startingId The ID of the ruleset to begin with. This will be the latest ruleset in the result. If the + /// `startingId` is 0, passed, the project's latest ruleset will be used. + /// @param size The maximum number of rulesets to return. + /// @return rulesets The array of rulesets with their metadata. + function allRulesetsOf( + uint256 projectId, + uint256 startingId, + uint256 size + ) + external + view + override + returns (JBRulesetWithMetadata[] memory rulesets) + { + // Get the rulesets (without metadata). + JBRuleset[] memory baseRulesets = RULESETS.allOf(projectId, startingId, size); + + // Keep a reference to the number of rulesets. + uint256 numberOfRulesets = baseRulesets.length; + + // Initialize the array being returned. + rulesets = new JBRulesetWithMetadata[](numberOfRulesets); + + // Populate the array with rulesets AND their metadata. + for (uint256 i; i < numberOfRulesets; i++) { + // Set the ruleset being iterated on. + JBRuleset memory baseRuleset = baseRulesets[i]; + + // Set the returned value. + rulesets[i] = JBRulesetWithMetadata({ruleset: baseRuleset, metadata: baseRuleset.expandMetadata()}); + } + } + + /// @notice A project's currently active ruleset and its metadata. + /// @param projectId The ID of the project to get the current ruleset of. + /// @return ruleset The current ruleset's struct. + /// @return metadata The current ruleset's metadata. + function currentRulesetOf(uint256 projectId) + external + view + override + returns (JBRuleset memory ruleset, JBRulesetMetadata memory metadata) + { + ruleset = _currentRulesetOf(projectId); + metadata = ruleset.expandMetadata(); + } + + /// @notice Get the `JBRuleset` and `JBRulesetMetadata` corresponding to the specified `rulesetId`. + /// @param projectId The ID of the project the ruleset belongs to. + /// @return ruleset The ruleset's struct. + /// @return metadata The ruleset's metadata. + function getRulesetOf( + uint256 projectId, + uint256 rulesetId + ) + external + view + override + returns (JBRuleset memory ruleset, JBRulesetMetadata memory metadata) + { + ruleset = RULESETS.getRulesetOf(projectId, rulesetId); + metadata = ruleset.expandMetadata(); + } + + /// @notice Gets the latest ruleset queued for a project, its approval status, and its metadata. + /// @dev The 'latest queued ruleset' is the ruleset initialized furthest in the future (at the end of the ruleset + /// queue). + /// @param projectId The ID of the project to get the latest ruleset of. + /// @return ruleset The struct for the project's latest queued ruleset. + /// @return metadata The ruleset's metadata. + /// @return approvalStatus The ruleset's approval status. + function latestQueuedRulesetOf(uint256 projectId) + external + view + override + returns (JBRuleset memory ruleset, JBRulesetMetadata memory metadata, JBApprovalStatus approvalStatus) + { + (ruleset, approvalStatus) = RULESETS.latestQueuedOf(projectId); + metadata = ruleset.expandMetadata(); + } + + /// @notice Check whether the project's terminals can currently be set. + /// @param projectId The ID of the project to check. + /// @return A `bool` which is true if the project allows terminals to be set. + function setTerminalsAllowed(uint256 projectId) external view returns (bool) { + return _currentRulesetOf(projectId).expandMetadata().allowSetTerminals; + } + + /// @notice Check whether the project's controller can currently be set. + /// @param projectId The ID of the project to check. + /// @return A `bool` which is true if the project allows controllers to be set. + function setControllerAllowed(uint256 projectId) external view returns (bool) { + return _currentRulesetOf(projectId).expandMetadata().allowSetController; + } + + /// @notice Gets the a project token's total supply, including pending reserved tokens. + /// @param projectId The ID of the project to get the total token supply of. + /// @return The total supply of the project's token, including pending reserved tokens. + function totalTokenSupplyWithReservedTokensOf(uint256 projectId) external view override returns (uint256) { + // Add the reserved tokens to the total supply. + return TOKENS.totalSupplyOf(projectId) + pendingReservedTokenBalanceOf[projectId]; + } + + /// @notice A project's next ruleset along with its metadata. + /// @dev If an upcoming ruleset isn't found, returns an empty ruleset with all properties set to 0. + /// @param projectId The ID of the project to get the next ruleset of. + /// @return ruleset The upcoming ruleset's struct. + /// @return metadata The upcoming ruleset's metadata. + function upcomingRulesetOf(uint256 projectId) + external + view + override + returns (JBRuleset memory ruleset, JBRulesetMetadata memory metadata) + { + ruleset = _upcomingRulesetOf(projectId); + metadata = ruleset.expandMetadata(); + } + + //*********************************************************************// + // -------------------------- public views --------------------------- // + //*********************************************************************// + + /// @notice Indicates whether this contract adheres to the specified interface. + /// @dev See {IERC165-supportsInterface}. + /// @param interfaceId The ID of the interface to check for adherence to. + /// @return A flag indicating if the provided interface ID is supported. + function supportsInterface(bytes4 interfaceId) public pure override returns (bool) { + return interfaceId == type(IJBController).interfaceId || interfaceId == type(IJBProjectUriRegistry).interfaceId + || interfaceId == type(IJBDirectoryAccessControl).interfaceId || interfaceId == type(IJBMigratable).interfaceId + || interfaceId == type(IJBPermissioned).interfaceId || interfaceId == type(IERC165).interfaceId; + } + + //*********************************************************************// + // -------------------------- internal views ------------------------- // + //*********************************************************************// + + /// @dev `ERC-2771` specifies the context as being a single address (20 bytes). + function _contextSuffixLength() internal view override(ERC2771Context, Context) returns (uint256) { + return super._contextSuffixLength(); + } + + /// @notice The project's current ruleset. + /// @param projectId The ID of the project to check. + /// @return The project's current ruleset. + function _currentRulesetOf(uint256 projectId) internal view returns (JBRuleset memory) { + return RULESETS.currentOf(projectId); + } + + /// @notice Indicates whether the provided address is a terminal for the project. + /// @param projectId The ID of the project to check. + /// @param terminal The address to check. + /// @return A flag indicating if the provided address is a terminal for the project. + function _isTerminalOf(uint256 projectId, address terminal) internal view returns (bool) { + return DIRECTORY.isTerminalOf(projectId, IJBTerminal(terminal)); + } + + /// @notice Indicates whether the provided address has mint permission for the project byway of the data hook. + /// @param projectId The ID of the project to check. + /// @param ruleset The ruleset to check. + /// @param addr The address to check. + /// @return A flag indicating if the provided address has mint permission for the project. + function _hasDataHookMintPermissionFor( + uint256 projectId, + JBRuleset memory ruleset, + address addr + ) + internal + view + returns (bool) + { + return ruleset.dataHook() != address(0) + && IJBRulesetDataHook(ruleset.dataHook()).hasMintPermissionFor({ + projectId: projectId, + ruleset: ruleset, + addr: addr + }); + } + + /// @notice The calldata. Preferred to use over `msg.data`. + /// @return calldata The `msg.data` of this call. + function _msgData() internal view override(ERC2771Context, Context) returns (bytes calldata) { + return ERC2771Context._msgData(); + } + + /// @notice The message's sender. Preferred to use over `msg.sender`. + /// @return sender The address which sent this call. + function _msgSender() internal view override(ERC2771Context, Context) returns (address sender) { + return ERC2771Context._msgSender(); + } + + /// @notice The project's upcoming ruleset. + /// @param projectId The ID of the project to check. + /// @return The project's upcoming ruleset. + function _upcomingRulesetOf(uint256 projectId) internal view returns (JBRuleset memory) { + return RULESETS.upcomingOf(projectId); + } + + //*********************************************************************// + // --------------------- external transactions ----------------------- // + //*********************************************************************// + + /// @notice Add a price feed for a project. + /// @dev Can only be called by the project's owner or an address with the owner's permission to `ADD_PRICE_FEED`. + /// @param projectId The ID of the project to add the feed for. + /// @param pricingCurrency The currency the feed's output price is in terms of. + /// @param unitCurrency The currency being priced by the feed. + /// @param feed The address of the price feed to add. + function addPriceFeed( + uint256 projectId, + uint256 pricingCurrency, + uint256 unitCurrency, + IJBPriceFeed feed + ) + external + override + { + // Enforce permissions. + _requirePermissionFrom({ + account: PROJECTS.ownerOf(projectId), + projectId: projectId, + permissionId: JBPermissionIds.ADD_PRICE_FEED + }); + + JBRuleset memory ruleset = _currentRulesetOf(projectId); + + // Make sure the project's ruleset allows adding price feeds. + if (!ruleset.allowAddPriceFeed()) revert JBController_AddingPriceFeedNotAllowed(); + + PRICES.addPriceFeedFor({ + projectId: projectId, + pricingCurrency: pricingCurrency, + unitCurrency: unitCurrency, + feed: feed + }); + } + + /// @notice Burns a project's tokens or credits from the specific holder's balance. + /// @dev Can only be called by the holder, an address with the holder's permission to `BURN_TOKENS`, or a project's + /// terminal. + /// @param holder The address whose tokens are being burned. + /// @param projectId The ID of the project whose tokens are being burned. + /// @param tokenCount The number of tokens to burn. + /// @param memo A memo to pass along to the emitted event. + function burnTokensOf( + address holder, + uint256 projectId, + uint256 tokenCount, + string calldata memo + ) + external + override + { + // Enforce permissions. + _requirePermissionAllowingOverrideFrom({ + account: holder, + projectId: projectId, + permissionId: JBPermissionIds.BURN_TOKENS, + alsoGrantAccessIf: _isTerminalOf(projectId, _msgSender()) + }); + + // There must be tokens to burn. + if (tokenCount == 0) revert JBController_ZeroTokensToBurn(); + + emit BurnTokens({holder: holder, projectId: projectId, tokenCount: tokenCount, memo: memo, caller: _msgSender()}); + + // Burn the tokens. + TOKENS.burnFrom({holder: holder, projectId: projectId, count: tokenCount}); + } + + /// @notice Redeem credits to claim tokens into a `beneficiary`'s account. + /// @dev Can only be called by the credit holder or an address with the holder's permission to `CLAIM_TOKENS`. + /// @param holder The address to redeem credits from. + /// @param projectId The ID of the project whose tokens are being claimed. + /// @param tokenCount The number of tokens to claim. + /// @param beneficiary The account the claimed tokens will go to. + function claimTokensFor( + address holder, + uint256 projectId, + uint256 tokenCount, + address beneficiary + ) + external + override + { + // Enforce permissions. + _requirePermissionFrom({account: holder, projectId: projectId, permissionId: JBPermissionIds.CLAIM_TOKENS}); + + TOKENS.claimTokensFor({holder: holder, projectId: projectId, count: tokenCount, beneficiary: beneficiary}); + } + + /// @notice Deploys an ERC-20 token for a project. It will be used when claiming tokens (with credits). + /// @dev Deploys the project's ERC-20 contract. + /// @dev Can only be called by the project's owner or an address with the owner's permission to `DEPLOY_ERC20`. + /// @param projectId The ID of the project to deploy the ERC-20 for. + /// @param name The ERC-20's name. + /// @param symbol The ERC-20's symbol. + /// @param salt The salt used for ERC-1167 clone deployment. Pass a non-zero salt for deterministic deployment based + /// on `msg.sender` and the `TOKEN` implementation address. + /// @return token The address of the token that was deployed. + function deployERC20For( + uint256 projectId, + string calldata name, + string calldata symbol, + bytes32 salt + ) + external + override + returns (IJBToken token) + { + // Enforce permissions. + _requirePermissionFrom({ + account: PROJECTS.ownerOf(projectId), + projectId: projectId, + permissionId: JBPermissionIds.DEPLOY_ERC20 + }); + + if (salt != bytes32(0)) salt = keccak256(abi.encodePacked(_msgSender(), salt)); + + return TOKENS.deployERC20For({projectId: projectId, name: name, symbol: symbol, salt: salt}); + } + + /// @notice When a project receives reserved tokens, if it has a terminal for the token, this is used to pay the + /// terminal. + /// @dev Can only be called by this controller. + /// @param terminal The terminal to pay. + /// @param projectId The ID of the project being paid. + /// @param token The token being paid with. + /// @param splitTokenCount The number of tokens being paid. + /// @param beneficiary The payment's beneficiary. + /// @param metadata The pay metadata sent to the terminal. + function executePayReservedTokenToTerminal( + IJBTerminal terminal, + uint256 projectId, + IJBToken token, + uint256 splitTokenCount, + address beneficiary, + bytes calldata metadata + ) + external + { + // Can only be called by this contract. + require(msg.sender == address(this)); + + // Approve the tokens being paid. + IERC20(address(token)).forceApprove(address(terminal), splitTokenCount); + + // slither-disable-next-line unused-return + terminal.pay({ + projectId: projectId, + token: address(token), + amount: splitTokenCount, + beneficiary: beneficiary, + minReturnedTokens: 0, + memo: "", + metadata: metadata + }); + + // Make sure that the terminal received the tokens. + assert(IERC20(address(token)).allowance(address(this), address(terminal)) == 0); + } + + /// @notice Creates a project. + /// @dev This will mint the project's ERC-721 to the `owner`'s address, queue the specified rulesets, and set up the + /// specified splits and terminals. Each operation within this transaction can be done in sequence separately. + /// @dev Anyone can deploy a project to any `owner`'s address. + /// @param owner The project's owner. The project ERC-721 will be minted to this address. + /// @param projectUri The project's metadata URI. This is typically an IPFS hash, optionally with the `ipfs://` + /// prefix. This can be updated by the project's owner. + /// @param rulesetConfigurations The rulesets to queue. + /// @param terminalConfigurations The terminals to set up for the project. + /// @param memo A memo to pass along to the emitted event. + /// @return projectId The project's ID. + function launchProjectFor( + address owner, + string calldata projectUri, + JBRulesetConfig[] calldata rulesetConfigurations, + JBTerminalConfig[] calldata terminalConfigurations, + string calldata memo + ) + external + override + returns (uint256 projectId) + { + // Mint the project ERC-721 into the owner's wallet. + // slither-disable-next-line reentrancy-benign + projectId = PROJECTS.createFor(owner); + + // If provided, set the project's metadata URI. + if (bytes(projectUri).length > 0) { + uriOf[projectId] = projectUri; + } + + // Set this contract as the project's controller in the directory. + DIRECTORY.setControllerOf(projectId, IERC165(this)); + + // Configure the terminals. + _configureTerminals(projectId, terminalConfigurations); + + // Queue the rulesets. + // slither-disable-next-line reentrancy-events + uint256 rulesetId = _queueRulesets(projectId, rulesetConfigurations); + + emit LaunchProject({ + rulesetId: rulesetId, + projectId: projectId, + projectUri: projectUri, + memo: memo, + caller: _msgSender() + }); + } + + /// @notice Queue a project's initial rulesets and set up terminals for it. Projects which already have rulesets + /// should use `queueRulesetsOf(...)`. + /// @dev Each operation within this transaction can be done in sequence separately. + /// @dev Can only be called by the project's owner or an address with the owner's permission to `QUEUE_RULESETS`. + /// @param projectId The ID of the project to launch rulesets for. + /// @param rulesetConfigurations The rulesets to queue. + /// @param terminalConfigurations The terminals to set up. + /// @param memo A memo to pass along to the emitted event. + /// @return rulesetId The ID of the last successfully queued ruleset. + function launchRulesetsFor( + uint256 projectId, + JBRulesetConfig[] calldata rulesetConfigurations, + JBTerminalConfig[] calldata terminalConfigurations, + string calldata memo + ) + external + override + returns (uint256 rulesetId) + { + // Make sure there are rulesets being queued. + if (rulesetConfigurations.length == 0) revert JBController_RulesetsArrayEmpty(); + + // Enforce permissions. + _requirePermissionFrom({ + account: PROJECTS.ownerOf(projectId), + projectId: projectId, + permissionId: JBPermissionIds.QUEUE_RULESETS + }); + + // Enforce permissions. + _requirePermissionFrom({ + account: PROJECTS.ownerOf(projectId), + projectId: projectId, + permissionId: JBPermissionIds.SET_TERMINALS + }); + + // If the project has already had rulesets, use `queueRulesetsOf(...)` instead. + if (RULESETS.latestRulesetIdOf(projectId) > 0) { + revert JBController_RulesetsAlreadyLaunched(); + } + + // Set this contract as the project's controller in the directory. + DIRECTORY.setControllerOf(projectId, IERC165(this)); + + // Configure the terminals. + _configureTerminals(projectId, terminalConfigurations); + + // Queue the first ruleset. + // slither-disable-next-line reentrancy-events + rulesetId = _queueRulesets(projectId, rulesetConfigurations); + + emit LaunchRulesets({rulesetId: rulesetId, projectId: projectId, memo: memo, caller: _msgSender()}); + } + + /// @notice Migrate a project from this controller to another one. + /// @dev Can only be called by the directory. + /// @param projectId The ID of the project to migrate. + /// @param to The controller to migrate the project to. + function migrate(uint256 projectId, IERC165 to) external override { + // Make sure this is being called by the directory. + if (msg.sender != address(DIRECTORY)) revert JBController_OnlyDirectory(msg.sender, DIRECTORY); + + emit Migrate({projectId: projectId, to: to, caller: msg.sender}); + + // Mint any pending reserved tokens before migrating. + if (pendingReservedTokenBalanceOf[projectId] != 0) { + _sendReservedTokensToSplitsOf(projectId); + } + } + + /// @notice Add new project tokens or credits to the specified beneficiary's balance. Optionally, reserve a portion + /// according to the ruleset's reserved percent. + /// @dev Can only be called by the project's owner, an address with the owner's permission to `MINT_TOKENS`, one of + /// the project's terminals, or the project's data hook. + /// @dev If the ruleset's metadata has `allowOwnerMinting` set to `false`, this function can only be called by the + /// project's terminals or data hook. + /// @param projectId The ID of the project whose tokens are being minted. + /// @param tokenCount The number of tokens to mint, including any reserved tokens. + /// @param beneficiary The address which will receive the (non-reserved) tokens. + /// @param memo A memo to pass along to the emitted event. + /// @param useReservedPercent Whether to apply the ruleset's reserved percent. + /// @return beneficiaryTokenCount The number of tokens minted for the `beneficiary`. + function mintTokensOf( + uint256 projectId, + uint256 tokenCount, + address beneficiary, + string calldata memo, + bool useReservedPercent + ) + external + override + returns (uint256 beneficiaryTokenCount) + { + // There should be tokens to mint. + if (tokenCount == 0) revert JBController_ZeroTokensToMint(); + + // Keep a reference to the reserved percent. + uint256 reservedPercent; + + // Get a reference to the project's ruleset. + JBRuleset memory ruleset = _currentRulesetOf(projectId); + + // Minting is restricted to: the project's owner, addresses with permission to `MINT_TOKENS`, the project's + // terminals, and the project's data hook. + _requirePermissionAllowingOverrideFrom({ + account: PROJECTS.ownerOf(projectId), + projectId: projectId, + permissionId: JBPermissionIds.MINT_TOKENS, + alsoGrantAccessIf: _isTerminalOf(projectId, _msgSender()) || _msgSender() == ruleset.dataHook() + || _hasDataHookMintPermissionFor(projectId, ruleset, _msgSender()) + }); + + // If the message sender is not the project's terminal or data hook, the ruleset must have `allowOwnerMinting` + // set to `true`. + if ( + ruleset.id != 0 && !ruleset.allowOwnerMinting() && !_isTerminalOf(projectId, _msgSender()) + && _msgSender() != address(ruleset.dataHook()) + && !_hasDataHookMintPermissionFor(projectId, ruleset, _msgSender()) + ) revert JBController_MintNotAllowedAndNotTerminalOrHook(); + + // Determine the reserved percent to use. + reservedPercent = useReservedPercent ? ruleset.reservedPercent() : 0; + + if (reservedPercent != JBConstants.MAX_RESERVED_PERCENT) { + // Calculate the number of (non-reserved) tokens that will be minted to the beneficiary. + beneficiaryTokenCount = + mulDiv(tokenCount, JBConstants.MAX_RESERVED_PERCENT - reservedPercent, JBConstants.MAX_RESERVED_PERCENT); + + // Mint the tokens. + // slither-disable-next-line reentrancy-benign,reentrancy-events,unused-return + TOKENS.mintFor({holder: beneficiary, projectId: projectId, count: beneficiaryTokenCount}); + } + + emit MintTokens({ + beneficiary: beneficiary, + projectId: projectId, + tokenCount: tokenCount, + beneficiaryTokenCount: beneficiaryTokenCount, + memo: memo, + reservedPercent: reservedPercent, + caller: _msgSender() + }); + + // Add any reserved tokens to the pending reserved token balance. + if (reservedPercent > 0) { + pendingReservedTokenBalanceOf[projectId] += tokenCount - beneficiaryTokenCount; + } + } + + /// @notice Add one or more rulesets to the end of a project's ruleset queue. Rulesets take effect after the + /// previous ruleset in the queue ends, and only if they are approved by the previous ruleset's approval hook. + /// @dev Can only be called by the project's owner or an address with the owner's permission to `QUEUE_RULESETS`. + /// @param projectId The ID of the project to queue rulesets for. + /// @param rulesetConfigurations The rulesets to queue. + /// @param memo A memo to pass along to the emitted event. + /// @return rulesetId The ID of the last ruleset which was successfully queued. + function queueRulesetsOf( + uint256 projectId, + JBRulesetConfig[] calldata rulesetConfigurations, + string calldata memo + ) + external + override + returns (uint256 rulesetId) + { + // Make sure there are rulesets being queued. + if (rulesetConfigurations.length == 0) revert JBController_RulesetsArrayEmpty(); + + // Enforce permissions. + _requirePermissionFrom({ + account: PROJECTS.ownerOf(projectId), + projectId: projectId, + permissionId: JBPermissionIds.QUEUE_RULESETS + }); + + // Queue the rulesets. + // slither-disable-next-line reentrancy-events + rulesetId = _queueRulesets(projectId, rulesetConfigurations); + + emit QueueRulesets({rulesetId: rulesetId, projectId: projectId, memo: memo, caller: _msgSender()}); + } + + /// @notice Prepares this controller to receive a project being migrated from another controller. + /// @dev This controller should not be the project's controller yet. + /// @param from The controller being migrated from. + /// @param projectId The ID of the project that will migrate to this controller. + function beforeReceiveMigrationFrom(IERC165 from, uint256 projectId) external override { + // Keep a reference to the sender. + address sender = _msgSender(); + + // Make sure the sender is the expected source controller. + if (sender != address(DIRECTORY)) revert JBController_OnlyDirectory(sender, DIRECTORY); + + // If the sending controller is an `IJBProjectUriRegistry`, copy the project's metadata URI. + if (from.supportsInterface(type(IJBProjectUriRegistry).interfaceId)) { + uriOf[projectId] = IJBProjectUriRegistry(address(from)).uriOf(projectId); + } + } + + /// @notice Sends a project's pending reserved tokens to its reserved token splits. + /// @dev If the project has no reserved token splits, or if they don't add up to 100%, leftover tokens are sent to + /// the project's owner. + /// @param projectId The ID of the project to send reserved tokens for. + /// @return The amount of reserved tokens minted and sent. + function sendReservedTokensToSplitsOf(uint256 projectId) external override returns (uint256) { + return _sendReservedTokensToSplitsOf(projectId); + } + + /// @notice Sets a project's split groups. The new split groups must include any current splits which are locked. + /// @dev Can only be called by the project's owner or an address with the owner's permission to `SET_SPLIT_GROUPS`. + /// @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. Use a `rulesetId` of 0 to set the + /// default split groups, which are used when a ruleset has no splits set. If there are no default splits and no + /// splits are set, all splits are sent to the project's owner. + /// @param splitGroups An array of split groups to set. + function setSplitGroupsOf( + uint256 projectId, + uint256 rulesetId, + JBSplitGroup[] calldata splitGroups + ) + external + override + { + // Enforce permissions. + _requirePermissionFrom({ + account: PROJECTS.ownerOf(projectId), + projectId: projectId, + permissionId: JBPermissionIds.SET_SPLIT_GROUPS + }); + + // Set the split groups. + SPLITS.setSplitGroupsOf({projectId: projectId, rulesetId: rulesetId, splitGroups: splitGroups}); + } + + /// @notice Set a project's token. If the project's token is already set, this will revert. + /// @dev Can only be called by the project's owner or an address with the owner's permission to `SET_TOKEN`. + /// @param projectId The ID of the project to set the token of. + /// @param token The new token's address. + function setTokenFor(uint256 projectId, IJBToken token) external override { + // Enforce permissions. + _requirePermissionFrom({ + account: PROJECTS.ownerOf(projectId), + projectId: projectId, + permissionId: JBPermissionIds.SET_TOKEN + }); + + // Get a reference to the current ruleset. + JBRuleset memory ruleset = _currentRulesetOf(projectId); + + // If there's no current ruleset, get a reference to the upcoming one. + if (ruleset.id == 0) ruleset = _upcomingRulesetOf(projectId); + + // If owner minting is disabled for the ruleset, the owner cannot change the token. + if (!ruleset.allowSetCustomToken()) revert JBController_RulesetSetTokenNotAllowed(); + + TOKENS.setTokenFor({projectId: projectId, token: token}); + } + + /// @notice Set a project's metadata URI. + /// @dev This is typically an IPFS hash, optionally with an `ipfs://` prefix. + /// @dev Can only be called by the project's owner or an address with the owner's permission to + /// `SET_PROJECT_URI`. + /// @param projectId The ID of the project to set the metadata URI of. + /// @param uri The metadata URI to set. + function setUriOf(uint256 projectId, string calldata uri) external override { + // Enforce permissions. + _requirePermissionFrom({ + account: PROJECTS.ownerOf(projectId), + projectId: projectId, + permissionId: JBPermissionIds.SET_PROJECT_URI + }); + + // Set the project's metadata URI. + uriOf[projectId] = uri; + + emit SetUri({projectId: projectId, uri: uri, caller: _msgSender()}); + } + + /// @notice Allows a credit holder to transfer credits to another address. + /// @dev Can only be called by the credit holder or an address with the holder's permission to `TRANSFER_CREDITS`. + /// @param holder The address to transfer credits from. + /// @param projectId The ID of the project whose credits are being transferred. + /// @param recipient The address to transfer credits to. + /// @param creditCount The number of credits to transfer. + function transferCreditsFrom( + address holder, + uint256 projectId, + address recipient, + uint256 creditCount + ) + external + override + { + // Enforce permissions. + _requirePermissionFrom({account: holder, projectId: projectId, permissionId: JBPermissionIds.TRANSFER_CREDITS}); + + // Get a reference to the project's ruleset. + JBRuleset memory ruleset = _currentRulesetOf(projectId); + + // Credit transfers must not be paused. + if (ruleset.pauseCreditTransfers()) revert JBController_CreditTransfersPaused(); + + TOKENS.transferCreditsFrom({holder: holder, projectId: projectId, recipient: recipient, count: creditCount}); + } + + //*********************************************************************// + // ------------------------ internal functions ----------------------- // + //*********************************************************************// + + /// @notice Set up a project's terminals. + /// @param projectId The ID of the project to set up terminals for. + /// @param terminalConfigs The terminals to set up. + function _configureTerminals(uint256 projectId, JBTerminalConfig[] calldata terminalConfigs) internal { + // Initialize an array of terminals to populate. + IJBTerminal[] memory terminals = new IJBTerminal[](terminalConfigs.length); + + for (uint256 i; i < terminalConfigs.length; i++) { + // Set the terminal configuration being iterated on. + JBTerminalConfig memory terminalConfig = terminalConfigs[i]; + + // Add the accounting contexts for the specified tokens. + terminalConfig.terminal.addAccountingContextsFor({ + projectId: projectId, + accountingContexts: terminalConfig.accountingContextsToAccept + }); + + // Add the terminal. + terminals[i] = terminalConfig.terminal; + } + + // Set the terminals in the directory. + if (terminalConfigs.length > 0) { + DIRECTORY.setTerminalsOf({projectId: projectId, terminals: terminals}); + } + } + + /// @notice Queues one or more rulesets and stores information pertinent to the configuration. + /// @param projectId The ID of the project to queue rulesets for. + /// @param rulesetConfigurations The rulesets being queued. + /// @return rulesetId The ID of the last ruleset that was successfully queued. + function _queueRulesets( + uint256 projectId, + JBRulesetConfig[] calldata rulesetConfigurations + ) + internal + returns (uint256 rulesetId) + { + for (uint256 i; i < rulesetConfigurations.length; i++) { + // Get a reference to the ruleset config being iterated on. + JBRulesetConfig memory rulesetConfig = rulesetConfigurations[i]; + + // Make sure its reserved percent is valid. + if (rulesetConfig.metadata.reservedPercent > JBConstants.MAX_RESERVED_PERCENT) { + revert JBController_InvalidReservedPercent( + rulesetConfig.metadata.reservedPercent, JBConstants.MAX_RESERVED_PERCENT + ); + } + + // Make sure its cash out tax rate is valid. + if (rulesetConfig.metadata.cashOutTaxRate > JBConstants.MAX_CASH_OUT_TAX_RATE) { + revert JBController_InvalidCashOutTaxRate( + rulesetConfig.metadata.cashOutTaxRate, JBConstants.MAX_CASH_OUT_TAX_RATE + ); + } + + // Queue its ruleset. + JBRuleset memory ruleset = RULESETS.queueFor({ + projectId: projectId, + duration: rulesetConfig.duration, + weight: rulesetConfig.weight, + weightCutPercent: rulesetConfig.weightCutPercent, + approvalHook: rulesetConfig.approvalHook, + metadata: JBRulesetMetadataResolver.packRulesetMetadata(rulesetConfig.metadata), + mustStartAtOrAfter: rulesetConfig.mustStartAtOrAfter + }); + + // Set its split groups. + SPLITS.setSplitGroupsOf({ + projectId: projectId, + rulesetId: ruleset.id, + splitGroups: rulesetConfig.splitGroups + }); + + // Set its fund access limits. + FUND_ACCESS_LIMITS.setFundAccessLimitsFor({ + projectId: projectId, + rulesetId: ruleset.id, + fundAccessLimitGroups: rulesetConfig.fundAccessLimitGroups + }); + + // If this is the last configuration being queued, return the ruleset's ID. + if (i == rulesetConfigurations.length - 1) { + rulesetId = ruleset.id; + } + } + } + + /// @notice Sends pending reserved tokens to the project's reserved token splits. + /// @dev If the project has no reserved token splits, or if they don't add up to 100%, leftover tokens are sent to + /// the project's owner. + /// @param projectId The ID of the project to send reserved tokens for. + /// @return tokenCount The amount of reserved tokens minted and sent. + function _sendReservedTokensToSplitsOf(uint256 projectId) internal returns (uint256 tokenCount) { + // Get a reference to the number of tokens that need to be minted. + tokenCount = pendingReservedTokenBalanceOf[projectId]; + + // Revert if there are no pending reserved tokens + if (tokenCount == 0) revert JBController_NoReservedTokens(); + + // Get the ruleset to read the reserved percent from. + JBRuleset memory ruleset = _currentRulesetOf(projectId); + + // Get a reference to the project's owner. + address owner = PROJECTS.ownerOf(projectId); + + // Reset the pending reserved token balance. + pendingReservedTokenBalanceOf[projectId] = 0; + + // Mint the tokens to this contract. + IJBToken token = TOKENS.mintFor({holder: address(this), projectId: projectId, count: tokenCount}); + + // Send reserved tokens to splits and get a reference to the amount left after the splits have all been paid. + uint256 leftoverTokenCount = tokenCount == 0 + ? 0 + : _sendReservedTokensToSplitGroupOf({ + projectId: projectId, + rulesetId: ruleset.id, + groupId: JBSplitGroupIds.RESERVED_TOKENS, + tokenCount: tokenCount, + token: token + }); + + // Mint any leftover tokens to the project owner. + if (leftoverTokenCount > 0) { + _sendTokens({projectId: projectId, tokenCount: leftoverTokenCount, recipient: owner, token: token}); + } + + emit SendReservedTokensToSplits({ + rulesetId: ruleset.id, + rulesetCycleNumber: ruleset.cycleNumber, + projectId: projectId, + owner: owner, + tokenCount: tokenCount, + leftoverAmount: leftoverTokenCount, + caller: _msgSender() + }); + } + + /// @notice Send project tokens to a split group. + /// @dev This is used to send reserved tokens to the reserved token split group. + /// @param projectId The ID of the project the splits belong to. + /// @param rulesetId The ID of the split group's ruleset. + /// @param groupId The ID of the split group. + /// @param tokenCount The number of tokens to send. + /// @param token The token to send. + /// @return leftoverTokenCount If the split percents don't add up to 100%, the leftover amount is returned. + function _sendReservedTokensToSplitGroupOf( + uint256 projectId, + uint256 rulesetId, + uint256 groupId, + uint256 tokenCount, + IJBToken token + ) + internal + returns (uint256 leftoverTokenCount) + { + // Set the leftover amount to the initial amount. + leftoverTokenCount = tokenCount; + + // Get a reference to the split group. + JBSplit[] memory splits = SPLITS.splitsOf({projectId: projectId, rulesetId: rulesetId, groupId: groupId}); + + // Keep a reference to the number of splits being iterated on. + uint256 numberOfSplits = splits.length; + + // Send the tokens to the splits. + for (uint256 i; i < numberOfSplits; i++) { + // Get a reference to the split being iterated on. + JBSplit memory split = splits[i]; + + // Calculate the amount to send to the split. + uint256 splitTokenCount = mulDiv(tokenCount, split.percent, JBConstants.SPLITS_TOTAL_PERCENT); + + // Mints tokens for the split if needed. + if (splitTokenCount > 0) { + // 1. If the split has a `hook`, call the hook's `processSplitWith` function. + // 2. Otherwise, if the split has a `projectId`, try to pay the project using the split's `beneficiary`, + // or the `_msgSender()` if the split has no beneficiary. + // 3. Otherwise, if the split has a beneficiary, send the tokens to the split's beneficiary. + // 4. Otherwise, send the tokens to the `_msgSender()`. + + // If the split has a hook, call its `processSplitWith` function. + if (split.hook != IJBSplitHook(address(0))) { + // Send the tokens to the split hook. + // slither-disable-next-line reentrancy-events + _sendTokens({ + projectId: projectId, + tokenCount: splitTokenCount, + recipient: address(split.hook), + token: token + }); + + // slither-disable-next-line reentrancy-events + split.hook.processSplitWith( + JBSplitHookContext({ + token: address(token), + amount: splitTokenCount, + decimals: 18, // Hard-coded in `JBTokens`. + projectId: projectId, + groupId: groupId, + split: split + }) + ); + // If the split has a project ID, try to pay the project. If that fails, pay the beneficiary. + } else { + // Pay the project using the split's beneficiary if one was provided. Otherwise, use the message + // sender. + address beneficiary = split.beneficiary != address(0) ? split.beneficiary : _msgSender(); + + if (split.projectId != 0) { + // Get a reference to the receiving project's primary payment terminal for the token. + IJBTerminal terminal = token == IJBToken(address(0)) + ? IJBTerminal(address(0)) + : DIRECTORY.primaryTerminalOf({projectId: split.projectId, token: address(token)}); + + // If the project doesn't have a token, or if the receiving project doesn't have a terminal + // which accepts the token, send the tokens to the beneficiary. + if (address(token) == address(0) || address(terminal) == address(0)) { + // Mint the tokens to the beneficiary. + // slither-disable-next-line reentrancy-events + _sendTokens({ + projectId: projectId, + tokenCount: splitTokenCount, + recipient: beneficiary, + token: token + }); + } else { + // Use the `projectId` in the pay metadata. + // slither-disable-next-line reentrancy-events + bytes memory metadata = bytes(abi.encodePacked(projectId)); + + // Try to fulfill the payment. + try this.executePayReservedTokenToTerminal({ + projectId: split.projectId, + terminal: terminal, + token: token, + splitTokenCount: splitTokenCount, + beneficiary: beneficiary, + metadata: metadata + }) {} catch (bytes memory reason) { + emit ReservedDistributionReverted({ + projectId: projectId, + split: split, + tokenCount: splitTokenCount, + reason: reason, + caller: _msgSender() + }); + + // If it fails, transfer the tokens from this contract to the beneficiary. + IERC20(address(token)).safeTransfer(beneficiary, splitTokenCount); + } + } + } else if (beneficiary == address(0xdead)) { + // If the split has no project ID, and the beneficiary is 0xdead, burn. + TOKENS.burnFrom({holder: address(this), projectId: projectId, count: splitTokenCount}); + } else { + // If the split has no project Id, send to beneficiary. + _sendTokens({ + projectId: projectId, + tokenCount: splitTokenCount, + recipient: beneficiary, + token: token + }); + } + } + + // Subtract the amount sent from the leftover. + leftoverTokenCount -= splitTokenCount; + } + + emit SendReservedTokensToSplit({ + projectId: projectId, + rulesetId: rulesetId, + groupId: groupId, + split: split, + tokenCount: splitTokenCount, + caller: _msgSender() + }); + } + } + + /// @notice Send tokens from this contract to a recipient. + /// @param projectId The ID of the project the tokens belong to. + /// @param tokenCount The number of tokens to send. + /// @param recipient The address to send the tokens to. + /// @param token The token to send, if one exists + function _sendTokens(uint256 projectId, uint256 tokenCount, address recipient, IJBToken token) internal { + if (token != IJBToken(address(0))) { + IERC20(address(token)).safeTransfer({to: recipient, value: tokenCount}); + } else { + TOKENS.transferCreditsFrom({ + holder: address(this), + projectId: projectId, + recipient: recipient, + count: tokenCount + }); + } + } +} diff --git a/src/JBDeadline4_0_1.sol b/src/JBDeadline4_0_1.sol new file mode 100644 index 000000000..f05df4fa8 --- /dev/null +++ b/src/JBDeadline4_0_1.sol @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; + +import {JBApprovalStatus} from "./enums/JBApprovalStatus.sol"; +import {IJBRulesetApprovalHook4_0_1} from "./interfaces/IJBRulesetApprovalHook4_0_1.sol"; + +/// @notice `JBDeadline` is a ruleset approval hook which rejects rulesets if they are not queued at least `duration` +/// seconds before the current ruleset ends. In other words, rulesets must be queued before the deadline to take effect. +/// @dev Project rulesets are stored in a queue. Rulesets take effect after the previous ruleset in the queue ends, and +/// only if they are approved by the previous ruleset's approval hook. +contract JBDeadline4_0_1 is IJBRulesetApprovalHook4_0_1 { + //*********************************************************************// + // ---------------- public immutable stored properties --------------- // + //*********************************************************************// + + /// @notice The minimum number of seconds between the time a ruleset is queued and the time it starts. If the + /// difference is greater than this number, the ruleset is `Approved`. + uint256 public immutable override DURATION; + + //*********************************************************************// + // -------------------------- constructor ---------------------------- // + //*********************************************************************// + + /// @param duration The minimum number of seconds between the time a ruleset is queued and the time it starts for it + /// to be `Approved`. + constructor(uint256 duration) { + DURATION = duration; + } + + //*********************************************************************// + // -------------------------- public views --------------------------- // + //*********************************************************************// + + /// @notice The approval status of a given ruleset. + /// @param rulesetId The ID of the ruleset to check the status of. + /// @param start The start timestamp of the ruleset to check the status of. + /// @return The ruleset's approval status. + function approvalStatusOf(uint256, JBRuleset memory ruleset) public view override returns (JBApprovalStatus) { + // The ruleset ID is the timestamp at which the ruleset was queued. + // If the provided `rulesetId` timestamp is after the start timestamp, the ruleset has `Failed`. + if (ruleset.id > ruleset.start) return JBApprovalStatus.Failed; + + unchecked { + // If there aren't enough seconds between the time the ruleset was queued and the time it starts, it has + // `Failed`. + // Otherwise, if there is still time before the deadline, the ruleset's status is `ApprovalExpected`. + // If we've already passed the deadline, the ruleset is `Approved`. + return (ruleset.start - rulesetId < DURATION) + ? JBApprovalStatus.Failed + : (block.timestamp + DURATION < ruleset.start) + ? JBApprovalStatus.ApprovalExpected + : JBApprovalStatus.Approved; + } + } + + /// @notice Indicates whether this contract adheres to the specified interface. + /// @dev See {IERC165-supportsInterface}. + /// @param interfaceId The ID of the interface to check for adherence to. + /// @return A flag indicating if this contract adheres to the specified interface. + function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) { + return interfaceId == type(IJBRulesetApprovalHook).interfaceId || interfaceId == type(IERC165).interfaceId; + } +} diff --git a/src/JBRulesets4_0_1.sol b/src/JBRulesets4_0_1.sol new file mode 100644 index 000000000..2f4c3c1e5 --- /dev/null +++ b/src/JBRulesets4_0_1.sol @@ -0,0 +1,1070 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +import {mulDiv} from "@prb/math/src/Common.sol"; + +import {JBControlled} from "./abstract/JBControlled.sol"; +import {JBApprovalStatus} from "./enums/JBApprovalStatus.sol"; +import {IJBDirectory} from "./interfaces/IJBDirectory.sol"; +import {IJBRulesetApprovalHook4_0_1} from "./interfaces/IJBRulesetApprovalHook4_0_1.sol"; +import {IJBRulesets} from "./interfaces/IJBRulesets.sol"; +import {JBConstants} from "./libraries/JBConstants.sol"; +import {JBRuleset} from "./structs/JBRuleset.sol"; +import {JBRulesetWeightCache} from "./structs/JBRulesetWeightCache.sol"; + +/// @notice Manages rulesets and queuing. +/// @dev Rulesets dictate how a project behaves for a period of time. To learn more about their functionality, see the +/// `JBRuleset` data structure. +/// @dev Throughout this contract, `rulesetId` is an identifier for each ruleset. The `rulesetId` is the unix timestamp +/// when the ruleset was initialized. +/// @dev `approvable` means a ruleset which may or may not be approved. +contract JBRulesets4_0_1 is JBControlled, IJBRulesets { + //*********************************************************************// + // --------------------------- custom errors ------------------------- // + //*********************************************************************// + + error JBRulesets_InvalidWeightCutPercent(uint256 percent); + error JBRulesets_InvalidRulesetApprovalHook(IJBRulesetApprovalHook hook); + error JBRulesets_InvalidRulesetDuration(uint256 duration, uint256 limit); + error JBRulesets_InvalidRulesetEndTime(uint256 timestamp, uint256 limit); + error JBRulesets_InvalidWeight(uint256 weight, uint256 limit); + + //*********************************************************************// + // ------------------------- internal constants ----------------------- // + //*********************************************************************// + + /// @notice The number of weight cut percent multiples before a cached value is sought. + uint256 internal constant _WEIGHT_CUT_MULTIPLE_CACHE_LOOKUP_THRESHOLD = 1000; + + /// @notice The maximum number of weight cut percent multiples that can be cached at a time. + uint256 internal constant _MAX_WEIGHT_CUT_MULTIPLE_CACHE_THRESHOLD = 50_000; + + //*********************************************************************// + // --------------------- public stored properties -------------------- // + //*********************************************************************// + + /// @notice The ID of the ruleset with the latest start time for a specific project, whether the ruleset has been + /// approved or not. + /// @dev If a project has multiple rulesets queued, the `latestRulesetIdOf` will be the last one. This is the + /// "changeable" cycle. + /// @custom:param projectId The ID of the project to get the latest ruleset ID of. + /// @return latestRulesetIdOf The `rulesetId` of the project's latest ruleset. + mapping(uint256 projectId => uint256) public override latestRulesetIdOf; + + //*********************************************************************// + // --------------------- internal stored properties ------------------- // + //*********************************************************************// + + /// @notice The metadata for each ruleset, packed into one storage slot. + /// @custom:param projectId The ID of the project to get metadata of. + /// @custom:param rulesetId The ID of the ruleset to get metadata of. + mapping(uint256 projectId => mapping(uint256 rulesetId => uint256)) internal _metadataOf; + + /// @notice The mechanism-added properties to manage and schedule each ruleset, packed into one storage slot. + /// @custom:param projectId The ID of the project to get the intrinsic properties of. + /// @custom:param rulesetId The ID of the ruleset to get the intrinsic properties of. + mapping(uint256 projectId => mapping(uint256 rulesetId => uint256)) internal _packedIntrinsicPropertiesOf; + + /// @notice The user-defined properties of each ruleset, packed into one storage slot. + /// @custom:param projectId The ID of the project to get the user-defined properties of. + /// @custom:param rulesetId The ID of the ruleset to get the user-defined properties of. + mapping(uint256 projectId => mapping(uint256 rulesetId => uint256)) internal _packedUserPropertiesOf; + + /// @notice Cached weight values to derive rulesets from. + /// @custom:param projectId The ID of the project to which the cache applies. + /// @custom:param rulesetId The ID of the ruleset to which the cache applies. + mapping(uint256 projectId => mapping(uint256 rulesetId => JBRulesetWeightCache)) internal _weightCacheOf; + + //*********************************************************************// + // -------------------------- constructor ---------------------------- // + //*********************************************************************// + + /// @param directory A contract storing directories of terminals and controllers for each project. + // solhint-disable-next-line no-empty-blocks + constructor(IJBDirectory directory) JBControlled(directory) {} + + //*********************************************************************// + // ------------------------- external views -------------------------- // + //*********************************************************************// + + /// @notice Get an array of a project's rulesets up to a maximum array size, sorted from latest to earliest. + /// @param projectId The ID of the project to get the rulesets of. + /// @param startingId The ID of the ruleset to begin with. This will be the latest ruleset in the result. If 0 is + /// passed, the project's latest ruleset will be used. + /// @param size The maximum number of rulesets to return. + /// @return rulesets The rulesets as an array of `JBRuleset` structs. + function allOf( + uint256 projectId, + uint256 startingId, + uint256 size + ) + external + view + override + returns (JBRuleset[] memory rulesets) + { + // If no starting ID was provided, set it to the latest ruleset's ID. + if (startingId == 0) startingId = latestRulesetIdOf[projectId]; + + // Keep a reference to the number of rulesets being returned. + uint256 count = 0; + + // Keep a reference to the starting ruleset. + JBRuleset memory ruleset = _getStructFor(projectId, startingId); + + // First, count the number of rulesets to include in the result by iterating backwards from the starting + // ruleset. + while (ruleset.id != 0 && count < size) { + // Increment the counter. + count++; + + // Iterate to the ruleset it was based on. + ruleset = _getStructFor(projectId, ruleset.basedOnId); + } + + // Keep a reference to the array of rulesets that'll be populated. + rulesets = new JBRuleset[](count); + + // Return an empty array if there are no rulesets to return. + if (count == 0) { + return rulesets; + } + + // Reset the ruleset being iterated on to the starting ruleset. + ruleset = _getStructFor(projectId, startingId); + + // Set the counter. + uint256 i; + + // Populate the array of rulesets to return. + while (i < count) { + // Add the ruleset to the array. + rulesets[i++] = ruleset; + + // Get the ruleset it was based on if needed. + if (i != count) ruleset = _getStructFor(projectId, ruleset.basedOnId); + } + } + + /// @notice The current approval status of a given project's latest ruleset. + /// @param projectId The ID of the project to check the approval status of. + /// @return The project's current approval status. + function currentApprovalStatusForLatestRulesetOf(uint256 projectId) + external + view + override + returns (JBApprovalStatus) + { + // Get a reference to the latest ruleset ID. + uint256 rulesetId = latestRulesetIdOf[projectId]; + + // Resolve the struct for the latest ruleset. + JBRuleset memory ruleset = _getStructFor(projectId, rulesetId); + + return _approvalStatusOf({projectId: projectId, ruleset: ruleset}); + } + + /// @notice The ruleset that is currently active for the specified project. + /// @dev If a current ruleset of the project is not found, returns an empty ruleset with all properties set to 0. + /// @param projectId The ID of the project to get the current ruleset of. + /// @return ruleset The project's current ruleset. + function currentOf(uint256 projectId) external view override returns (JBRuleset memory ruleset) { + // If the project does not have a ruleset, return an empty struct. + // slither-disable-next-line incorrect-equality + if (latestRulesetIdOf[projectId] == 0) return _getStructFor(0, 0); + + // Get a reference to the currently approvable ruleset's ID. + uint256 rulesetId = _currentlyApprovableRulesetIdOf(projectId); + + // If a currently approvable ruleset exists... + if (rulesetId != 0) { + // Resolve the struct for the currently approvable ruleset. + ruleset = _getStructFor(projectId, rulesetId); + + // Get a reference to the approval status. + JBApprovalStatus approvalStatus = _approvalStatusOf(projectId, ruleset); + + // Check to see if this ruleset's approval hook is approved if it exists. + // If so, return it. + // slither-disable-next-line incorrect-equality + if (approvalStatus == JBApprovalStatus.Approved || approvalStatus == JBApprovalStatus.Empty) { + return ruleset; + } + + // If it hasn't been approved, set the ruleset configuration to be the configuration of the ruleset that + // it's based on, + // which carries the last approved configuration. + rulesetId = ruleset.basedOnId; + + // Keep a reference to its ruleset. + ruleset = _getStructFor(projectId, rulesetId); + } else { + // No upcoming ruleset found that is currently approvable, + // so use the latest ruleset ID. + rulesetId = latestRulesetIdOf[projectId]; + + // Get the struct for the latest ID. + ruleset = _getStructFor(projectId, rulesetId); + + // Get a reference to the approval status. + JBApprovalStatus approvalStatus = _approvalStatusOf(projectId, ruleset); + + // While the ruleset has a approval hook that isn't approved or if it hasn't yet started, get a reference to + // the ruleset that the latest is based on, which has the latest approved configuration. + while ( + (approvalStatus != JBApprovalStatus.Approved && approvalStatus != JBApprovalStatus.Empty) + || block.timestamp < ruleset.start + ) { + rulesetId = ruleset.basedOnId; + ruleset = _getStructFor(projectId, rulesetId); + approvalStatus = _approvalStatusOf(projectId, ruleset); + } + } + + // If the base has no duration, it's still the current one. + // slither-disable-next-line incorrect-equality + if (ruleset.duration == 0) return ruleset; + + // Return a simulation of the current ruleset. + return _simulateCycledRulesetBasedOn({projectId: projectId, baseRuleset: ruleset, allowMidRuleset: true}); + } + + /// @notice Get the ruleset struct for a given `rulesetId` and `projectId`. + /// @param projectId The ID of the project to which the ruleset belongs. + /// @param rulesetId The ID of the ruleset to get the struct of. + /// @return ruleset The ruleset struct. + function getRulesetOf( + uint256 projectId, + uint256 rulesetId + ) + external + view + override + returns (JBRuleset memory ruleset) + { + return _getStructFor(projectId, rulesetId); + } + + /// @notice The latest ruleset queued for a project. Returns the ruleset's struct and its current approval status. + /// @dev Returns struct and status for the ruleset initialized furthest in the future (at the end of the ruleset + /// queue). + /// @param projectId The ID of the project to get the latest queued ruleset of. + /// @return ruleset The project's latest queued ruleset's struct. + /// @return approvalStatus The approval hook's status for the ruleset. + function latestQueuedOf(uint256 projectId) + external + view + override + returns (JBRuleset memory ruleset, JBApprovalStatus approvalStatus) + { + // Get a reference to the latest ruleset's ID. + uint256 rulesetId = latestRulesetIdOf[projectId]; + + // Resolve the struct for the latest ruleset. + ruleset = _getStructFor(projectId, rulesetId); + + // Resolve the approval status. + approvalStatus = _approvalStatusOf({projectId: projectId, ruleset: ruleset}); + } + + /// @notice The ruleset that's up next for a project. + /// @dev If an upcoming ruleset is not found for the project, returns an empty ruleset with all properties set to 0. + /// @param projectId The ID of the project to get the upcoming ruleset of. + /// @return ruleset The struct for the project's upcoming ruleset. + function upcomingOf(uint256 projectId) external view override returns (JBRuleset memory ruleset) { + // If the project does not have a latest ruleset, return an empty struct. + // slither-disable-next-line incorrect-equality + if (latestRulesetIdOf[projectId] == 0) return _getStructFor(0, 0); + + // Get a reference to the upcoming approvable ruleset's ID. + uint256 upcomingApprovableRulesetId = _upcomingApprovableRulesetIdOf(projectId); + + // Keep a reference to its approval status. + JBApprovalStatus approvalStatus; + + // If an upcoming approvable ruleset has been queued, and it's approval status is Approved or ApprovalExpected, + // return its ruleset struct + if (upcomingApprovableRulesetId != 0) { + ruleset = _getStructFor(projectId, upcomingApprovableRulesetId); + + // Get a reference to the approval status. + approvalStatus = _approvalStatusOf(projectId, ruleset); + + // If the approval hook is empty, expects approval, or has approved the ruleset, return it. + if ( + // slither-disable-next-line incorrect-equality + approvalStatus == JBApprovalStatus.Approved || approvalStatus == JBApprovalStatus.ApprovalExpected + || approvalStatus == JBApprovalStatus.Empty + ) return ruleset; + + // Resolve the ruleset for the ruleset the upcoming approvable ruleset was based on. + ruleset = _getStructFor(projectId, ruleset.basedOnId); + } else { + // Resolve the ruleset for the latest queued ruleset. + ruleset = _getStructFor(projectId, latestRulesetIdOf[projectId]); + + // If the latest ruleset starts in the future, it must start in the distant future + // Since its not the upcoming approvable ruleset. In this case, base the upcoming ruleset on the base + // ruleset. + while (ruleset.start > block.timestamp) { + ruleset = _getStructFor(projectId, ruleset.basedOnId); + } + } + + // There's no queued if the current has a duration of 0. + // slither-disable-next-line incorrect-equality + if (ruleset.duration == 0) return _getStructFor(0, 0); + + // Get a reference to the approval status. + approvalStatus = _approvalStatusOf(projectId, ruleset); + + // Check to see if this ruleset's approval hook hasn't failed. + // If so, return a ruleset based on it. + // slither-disable-next-line incorrect-equality + if (approvalStatus == JBApprovalStatus.Approved || approvalStatus == JBApprovalStatus.Empty) { + return _simulateCycledRulesetBasedOn({projectId: projectId, baseRuleset: ruleset, allowMidRuleset: false}); + } + + // Get the ruleset of its base ruleset, which carries the last approved configuration. + ruleset = _getStructFor(projectId, ruleset.basedOnId); + + // There's no queued if the base, which must still be the current, has a duration of 0. + // slither-disable-next-line incorrect-equality + if (ruleset.duration == 0) return _getStructFor(0, 0); + + // Return a simulated cycled ruleset. + return _simulateCycledRulesetBasedOn({projectId: projectId, baseRuleset: ruleset, allowMidRuleset: false}); + } + + //*********************************************************************// + // --------------------------- public views -------------------------- // + //*********************************************************************// + + /// @notice The cycle number of the next ruleset given the specified ruleset. + /// @dev Each time a ruleset starts, whether it was queued or cycled over, the cycle number is incremented by 1. + /// @param baseRulesetCycleNumber The cycle number of the base ruleset. + /// @param baseRulesetStart The start time of the base ruleset. + /// @param baseRulesetDuration The duration of the base ruleset. + /// @param start The start time of the ruleset to derive a cycle number for. + /// @return The ruleset's cycle number. + function deriveCycleNumberFrom( + uint256 baseRulesetCycleNumber, + uint256 baseRulesetStart, + uint256 baseRulesetDuration, + uint256 start + ) + public + pure + returns (uint256) + { + // A subsequent ruleset to one with a duration of 0 should be the next number. + // slither-disable-next-line incorrect-equality + if (baseRulesetDuration == 0) { + return baseRulesetCycleNumber + 1; + } + + // The difference between the start of the base ruleset and the proposed start. + uint256 startDistance = start - baseRulesetStart; + + // Find the number of base rulesets that fit in the start distance. + return baseRulesetCycleNumber + (startDistance / baseRulesetDuration); + } + + /// @notice The date that is the nearest multiple of the base ruleset's duration from the start of the next cycle. + /// @param baseRulesetStart The start time of the base ruleset. + /// @param baseRulesetDuration The duration of the base ruleset. + /// @param mustStartAtOrAfter The earliest time the next ruleset can start. The ruleset cannot start before this + /// timestamp. + /// @return start The next start time. + function deriveStartFrom( + uint256 baseRulesetStart, + uint256 baseRulesetDuration, + uint256 mustStartAtOrAfter + ) + public + pure + returns (uint256 start) + { + // A subsequent ruleset to one with a duration of 0 should start as soon as possible. + // slither-disable-next-line incorrect-equality + if (baseRulesetDuration == 0) return mustStartAtOrAfter; + + // The time when the ruleset immediately after the specified ruleset starts. + uint256 nextImmediateStart = baseRulesetStart + baseRulesetDuration; + + // If the next immediate start is now or in the future, return it. + if (nextImmediateStart >= mustStartAtOrAfter) { + return nextImmediateStart; + } + + // The amount of seconds since the `mustStartAtOrAfter` time which results in a start time that might satisfy + // the specified limits. + // slither-disable-next-line weak-prng + uint256 timeFromImmediateStartMultiple = (mustStartAtOrAfter - nextImmediateStart) % baseRulesetDuration; + + // A reference to the first possible start timestamp. + start = mustStartAtOrAfter - timeFromImmediateStartMultiple; + + // Add increments of duration as necessary to satisfy the threshold. + while (mustStartAtOrAfter > start) { + start += baseRulesetDuration; + } + } + + /// @notice The accumulated weight change since the specified ruleset. + /// @param projectId The ID of the project to which the ruleset weights apply. + /// @param baseRulesetStart The start time of the base ruleset. + /// @param baseRulesetDuration The duration of the base ruleset. + /// @param baseRulesetWeight The weight of the base ruleset. + /// @param baseRulesetWeightCutPercent The weight cut percent of the base ruleset. + /// @param baseRulesetCacheId The ID of the ruleset to base the calculation on (the previous ruleset). + /// @param start The start time of the ruleset to derive a weight for. + /// @return weight The derived weight, as a fixed point number with 18 decimals. + function deriveWeightFrom( + uint256 projectId, + uint256 baseRulesetStart, + uint256 baseRulesetDuration, + uint256 baseRulesetWeight, + uint256 baseRulesetWeightCutPercent, + uint256 baseRulesetCacheId, + uint256 start + ) + public + view + returns (uint256 weight) + { + // A subsequent ruleset to one with a duration of 0 should have the next possible weight. + // slither-disable-next-line incorrect-equality + if (baseRulesetDuration == 0) { + return mulDiv( + baseRulesetWeight, + JBConstants.MAX_WEIGHT_CUT_PERCENT - baseRulesetWeightCutPercent, + JBConstants.MAX_WEIGHT_CUT_PERCENT + ); + } + + // The weight should be based off the base ruleset's weight. + weight = baseRulesetWeight; + + // If the weight cut percent is 0, the weight doesn't change. + // slither-disable-next-line incorrect-equality + if (baseRulesetWeightCutPercent == 0) return weight; + + // The difference between the start of the base ruleset and the proposed start. + uint256 startDistance = start - baseRulesetStart; + + // Apply the base ruleset's weight cut percent for each ruleset that has passed. + uint256 weightCutMultiple; + unchecked { + weightCutMultiple = startDistance / baseRulesetDuration; // Non-null duration is excluded above + } + + // Check the cache if needed. + if (baseRulesetCacheId > 0 && weightCutMultiple > _WEIGHT_CUT_MULTIPLE_CACHE_LOOKUP_THRESHOLD) { + // Get a cached weight for the rulesetId. + JBRulesetWeightCache memory cache = _weightCacheOf[projectId][baseRulesetCacheId]; + + // If a cached value is available, use it. + if (cache.weightCutMultiple > 0) { + // Set the starting weight to be the cached value. + weight = cache.weight; + + // Set the weight cut multiple to be the difference between the cached value and the total weight cut + // multiple that should be applied. + weightCutMultiple -= cache.weightCutMultiple; + } + } + + for (uint256 i; i < weightCutMultiple; i++) { + // The number of times to apply the weight cut percent. + // Base the new weight on the specified ruleset's weight. + weight = mulDiv( + weight, + JBConstants.MAX_WEIGHT_CUT_PERCENT - baseRulesetWeightCutPercent, + JBConstants.MAX_WEIGHT_CUT_PERCENT + ); + + // The calculation doesn't need to continue if the weight is 0. + if (weight == 0) break; + } + } + + //*********************************************************************// + // -------------------------- internal views ------------------------- // + //*********************************************************************// + + /// @notice The approval status of a given ruleset for a given project ID. + /// @param projectId The ID of the project the ruleset belongs to. + /// @param ruleset The ruleset to get the approval status of. + /// @return The approval status of the project. + function _approvalStatusOf(uint256 projectId, JBRuleset memory ruleset) internal view returns (JBApprovalStatus) { + // If there is no ruleset ID to check the approval hook of, the approval hook is empty. + // slither-disable-next-line incorrect-equality + if (ruleset.basedOnId == 0) return JBApprovalStatus.Empty; + + // Get the struct of the ruleset with the approval hook. + JBRuleset memory approvalHookRuleset = _getStructFor(projectId, ruleset.basedOnId); + + // If there is no approval hook, it's considered empty. + if (approvalHookRuleset.approvalHook == IJBRulesetApprovalHook(address(0))) { + return JBApprovalStatus.Empty; + } + + // Return the approval hook's approval status. + // slither-disable-next-line calls-loop + return approvalHookRuleset.approvalHook.approvalStatusOf(projectId, ruleset); + } + + /// @notice The ID of the ruleset which has started and hasn't expired yet, whether or not it has been approved, for + /// a given project. If approved, this is the active ruleset. + /// @dev A value of 0 is returned if no ruleset was found. + /// @dev Assumes the project has a latest ruleset. + /// @param projectId The ID of the project to check for a currently approvable ruleset. + /// @return The ID of a currently approvable ruleset if one exists, or 0 if one doesn't exist. + function _currentlyApprovableRulesetIdOf(uint256 projectId) internal view returns (uint256) { + // Get a reference to the project's latest ruleset. + uint256 rulesetId = latestRulesetIdOf[projectId]; + + // Get the struct for the latest ruleset. + JBRuleset memory ruleset = _getStructFor(projectId, rulesetId); + + // Loop through all most recently queued rulesets until an approvable one is found, or we've proven one can't + // exist. + do { + // If the latest ruleset is expired, return an empty ruleset. + // A ruleset with a duration of 0 cannot expire. + if (ruleset.duration != 0 && block.timestamp >= ruleset.start + ruleset.duration) { + return 0; + } + + // Return the ruleset's `rulesetId` if it has started. + if (block.timestamp >= ruleset.start) { + return ruleset.id; + } + + ruleset = _getStructFor(projectId, ruleset.basedOnId); + } while (ruleset.cycleNumber != 0); + + return 0; + } + + /// @notice Unpack a ruleset's packed stored values into an easy-to-work-with ruleset struct. + /// @param projectId The ID of the project the ruleset belongs to. + /// @param rulesetId The ID of the ruleset to get the full struct for. + /// @return ruleset A ruleset struct. + function _getStructFor(uint256 projectId, uint256 rulesetId) internal view returns (JBRuleset memory ruleset) { + // Return an empty ruleset if the specified `rulesetId` is 0. + // slither-disable-next-line incorrect-equality + if (rulesetId == 0) return ruleset; + + ruleset.id = uint48(rulesetId); + + uint256 packedIntrinsicProperties = _packedIntrinsicPropertiesOf[projectId][rulesetId]; + + // `weight` in bits 0-111 bits. + ruleset.weight = uint112(packedIntrinsicProperties); + // `basedOnId` in bits 112-159 bits. + ruleset.basedOnId = uint48(packedIntrinsicProperties >> 112); + // `start` in bits 160-207 bits. + ruleset.start = uint48(packedIntrinsicProperties >> 160); + // `cycleNumber` in bits 208-255 bits. + ruleset.cycleNumber = uint48(packedIntrinsicProperties >> 208); + + uint256 packedUserProperties = _packedUserPropertiesOf[projectId][rulesetId]; + + // approval hook in bits 0-159 bits. + ruleset.approvalHook = IJBRulesetApprovalHook(address(uint160(packedUserProperties))); + // `duration` in bits 160-191 bits. + ruleset.duration = uint32(packedUserProperties >> 160); + // weight cut percent in bits 192-223 bits. + ruleset.weightCutPercent = uint32(packedUserProperties >> 192); + + ruleset.metadata = _metadataOf[projectId][rulesetId]; + } + + /// @notice A simulated view of the ruleset that would be created if the provided one cycled over (if the project + /// doesn't queue a new ruleset). + /// @dev Returns an empty ruleset if a ruleset can't be simulated based on the provided one. + /// @dev Assumes a simulated ruleset will never be based on a ruleset with a duration of 0. + /// @param projectId The ID of the project of the ruleset. + /// @param baseRuleset The ruleset that the simulated ruleset should be based on. + /// @param allowMidRuleset A flag indicating if the simulated ruleset is allowed to already be mid ruleset. + /// @return A simulated ruleset struct: the next ruleset by default. This will be overwritten if a new ruleset is + /// queued for the project. + function _simulateCycledRulesetBasedOn( + uint256 projectId, + JBRuleset memory baseRuleset, + bool allowMidRuleset + ) + internal + view + returns (JBRuleset memory) + { + // Get the distance from the current time to the start of the next possible ruleset. + // If the simulated ruleset must not yet have started, the start time of the simulated ruleset must be in the + // future. + uint256 mustStartAtOrAfter = !allowMidRuleset ? block.timestamp + 1 : block.timestamp - baseRuleset.duration + 1; + + // Calculate what the start time should be. + uint256 start = deriveStartFrom({ + baseRulesetStart: baseRuleset.start, + baseRulesetDuration: baseRuleset.duration, + mustStartAtOrAfter: mustStartAtOrAfter + }); + + // Calculate what the cycle number should be. + uint256 rulesetCycleNumber = deriveCycleNumberFrom({ + baseRulesetCycleNumber: baseRuleset.cycleNumber, + baseRulesetStart: baseRuleset.start, + baseRulesetDuration: baseRuleset.duration, + start: start + }); + + return JBRuleset({ + cycleNumber: uint48(rulesetCycleNumber), + id: baseRuleset.id, + basedOnId: baseRuleset.basedOnId, + start: uint48(start), + duration: baseRuleset.duration, + weight: uint112( + deriveWeightFrom({ + projectId: projectId, + baseRulesetStart: baseRuleset.start, + baseRulesetDuration: baseRuleset.duration, + baseRulesetWeight: baseRuleset.weight, + baseRulesetWeightCutPercent: baseRuleset.weightCutPercent, + baseRulesetCacheId: baseRuleset.id, + start: start + }) + ), + weightCutPercent: baseRuleset.weightCutPercent, + approvalHook: baseRuleset.approvalHook, + metadata: baseRuleset.metadata + }); + } + + /// @notice The ruleset up next for a project, if one exists, whether or not that ruleset has been approved. + /// @dev A value of 0 is returned if no ruleset was found. + /// @dev Assumes the project has a `latestRulesetIdOf` value. + /// @param projectId The ID of the project to check for an upcoming approvable ruleset. + /// @return rulesetId The `rulesetId` of the upcoming approvable ruleset if one exists, or 0 if one doesn't exist. + function _upcomingApprovableRulesetIdOf(uint256 projectId) internal view returns (uint256 rulesetId) { + // Get a reference to the ID of the project's latest ruleset. + rulesetId = latestRulesetIdOf[projectId]; + + // Get the struct for the latest ruleset. + JBRuleset memory ruleset = _getStructFor(projectId, rulesetId); + + // There is no upcoming ruleset if the latest ruleset has already started. + // slither-disable-next-line incorrect-equality + if (block.timestamp >= ruleset.start) return 0; + + // If this is the first ruleset, it is queued. + // slither-disable-next-line incorrect-equality + if (ruleset.cycleNumber == 1) return rulesetId; + + // Get a reference to the ID of the ruleset the latest ruleset was based on. + uint256 basedOnId = ruleset.basedOnId; + + // Get the necessary properties for the base ruleset. + JBRuleset memory baseRuleset; + + // Find the base ruleset that is not still queued. + while (true) { + baseRuleset = _getStructFor(projectId, basedOnId); + + // If the base ruleset starts in the future, + if (block.timestamp < baseRuleset.start) { + // Set the `rulesetId` to the one found. + rulesetId = baseRuleset.id; + // Check the ruleset it was based on in the next iteration. + basedOnId = baseRuleset.basedOnId; + } else { + // Break out of the loop when a base ruleset which has already started is found. + break; + } + } + + // Get the ruleset struct for the ID found. + ruleset = _getStructFor(projectId, rulesetId); + + // If the latest ruleset doesn't start until after another base ruleset return 0. + if (baseRuleset.duration != 0 && block.timestamp < ruleset.start - baseRuleset.duration) { + return 0; + } + } + + //*********************************************************************// + // ---------------------- external transactions ---------------------- // + //*********************************************************************// + + /// @notice Queues the upcoming approvable ruleset for the specified project. + /// @dev Only a project's current controller can queue its rulesets. + /// @param projectId The ID of the project to queue the ruleset for. + /// @param duration The number of seconds the ruleset lasts for, after which a new ruleset starts. + /// - A `duration` of 0 means this ruleset will remain active until the project owner queues a new ruleset. That new + /// ruleset will start immediately. + /// - A ruleset with a non-zero `duration` applies until the duration ends – any newly queued rulesets will be + /// *queued* to take effect afterwards. + /// - If a duration ends and no new rulesets are queued, the ruleset rolls over to a new ruleset with the same rules + /// (except for a new `start` timestamp and a cut `weight`). + /// @param weight A fixed point number with 18 decimals that contracts can use to base arbitrary calculations on. + /// Payment terminals generally use this to determine how many tokens should be minted when the project is paid. + /// @param weightCutPercent A fraction (out of `JBConstants.MAX_WEIGHT_CUT_PERCENT`) to reduce the next ruleset's + /// `weight` + /// by. + /// - If a ruleset specifies a non-zero `weight`, the `weightCutPercent` does not apply. + /// - If the `weightCutPercent` is 0, the `weight` stays the same. + /// - If the `weightCutPercent` is 10% of `JBConstants.MAX_WEIGHT_CUT_PERCENT`, next ruleset's `weight` will be 90% + /// of the + /// current + /// one. + /// @param approvalHook A contract which dictates whether a proposed ruleset should be accepted or rejected. It can + /// be used to constrain a project owner's ability to change ruleset parameters over time. + /// @param metadata Arbitrary extra data to associate with this ruleset. This metadata is not used by `JBRulesets`. + /// @param mustStartAtOrAfter The earliest time the ruleset can start. The ruleset cannot start before this + /// timestamp. + /// @return The struct of the new ruleset. + function queueFor( + uint256 projectId, + uint256 duration, + uint256 weight, + uint256 weightCutPercent, + IJBRulesetApprovalHook approvalHook, + uint256 metadata, + uint256 mustStartAtOrAfter + ) + external + override + onlyControllerOf(projectId) + returns (JBRuleset memory) + { + // Duration must fit in a uint32. + if (duration > type(uint32).max) revert JBRulesets_InvalidRulesetDuration(duration, type(uint32).max); + + // Weight cut percent must be less than or equal to 100%. + if (weightCutPercent > JBConstants.MAX_WEIGHT_CUT_PERCENT) { + revert JBRulesets_InvalidWeightCutPercent(weightCutPercent); + } + + // Weight must fit into a uint112. + if (weight > type(uint112).max) revert JBRulesets_InvalidWeight(weight, type(uint112).max); + + // If the start date is not set, set it to be the current timestamp. + if (mustStartAtOrAfter == 0) { + mustStartAtOrAfter = block.timestamp; + } + + // Make sure the min start date fits in a uint48, and that the start date of the following ruleset will also fit + // within the max. + if (mustStartAtOrAfter + duration > type(uint48).max) { + revert JBRulesets_InvalidRulesetEndTime(mustStartAtOrAfter + duration, type(uint48).max); + } + + // Approval hook should be a valid contract, supporting the correct interface + if (approvalHook != IJBRulesetApprovalHook(address(0))) { + // Revert if there isn't a contract at the address + if (address(approvalHook).code.length == 0) revert JBRulesets_InvalidRulesetApprovalHook(approvalHook); + + // Make sure the approval hook supports the expected interface. + try approvalHook.supportsInterface(type(IJBRulesetApprovalHook).interfaceId) returns (bool doesSupport) { + if (!doesSupport) revert JBRulesets_InvalidRulesetApprovalHook(approvalHook); // Contract exists at the + // address but + // with the + // wrong interface + } catch { + revert JBRulesets_InvalidRulesetApprovalHook(approvalHook); // No ERC165 support + } + } + + // Get a reference to the latest ruleset's ID. + uint256 latestId = latestRulesetIdOf[projectId]; + + // The new rulesetId timestamp is now, or an increment from now if the current timestamp is taken. + uint256 rulesetId = latestId >= block.timestamp ? latestId + 1 : block.timestamp; + + // Set up the ruleset by configuring intrinsic properties. + _configureIntrinsicPropertiesFor(projectId, rulesetId, weight, mustStartAtOrAfter); + + // Efficiently stores the ruleset's user-defined properties. + // If all user config properties are zero, no need to store anything as the default value will have the same + // outcome. + if (approvalHook != IJBRulesetApprovalHook(address(0)) || duration > 0 || weightCutPercent > 0) { + // approval hook in bits 0-159 bytes. + uint256 packed = uint160(address(approvalHook)); + + // duration in bits 160-191 bytes. + packed |= duration << 160; + + // weightCutPercent in bits 192-223 bytes. + packed |= weightCutPercent << 192; + + // Set in storage. + _packedUserPropertiesOf[projectId][rulesetId] = packed; + } + + // Set the metadata if needed. + if (metadata > 0) _metadataOf[projectId][rulesetId] = metadata; + + emit RulesetQueued({ + rulesetId: rulesetId, + projectId: projectId, + duration: duration, + weight: weight, + weightCutPercent: weightCutPercent, + approvalHook: approvalHook, + metadata: metadata, + mustStartAtOrAfter: mustStartAtOrAfter, + caller: msg.sender + }); + + // Return the struct for the new ruleset's ID. + return _getStructFor(projectId, rulesetId); + } + + /// @notice Cache the value of the ruleset weight. + /// @param projectId The ID of the project having its ruleset weight cached. + function updateRulesetWeightCache(uint256 projectId) external override { + // Keep a reference to the struct for the latest queued ruleset. + // The cached value will be based on this struct. + JBRuleset memory latestQueuedRuleset = _getStructFor(projectId, latestRulesetIdOf[projectId]); + + // Nothing to cache if the latest ruleset doesn't have a duration or a weight cut percent. + // slither-disable-next-line incorrect-equality + if (latestQueuedRuleset.duration == 0 || latestQueuedRuleset.weightCutPercent == 0) return; + + // Get a reference to the current cache. + JBRulesetWeightCache storage cache = _weightCacheOf[projectId][latestQueuedRuleset.id]; + + // Determine the largest start timestamp the cache can be filled to. + uint256 maxStart = latestQueuedRuleset.start + + (cache.weightCutMultiple + _MAX_WEIGHT_CUT_MULTIPLE_CACHE_THRESHOLD) * latestQueuedRuleset.duration; + + // Determine the start timestamp to derive a weight from for the cache. + uint256 start = block.timestamp < maxStart ? block.timestamp : maxStart; + + // The difference between the start of the latest queued ruleset and the start of the ruleset we're caching the + // weight of. + uint256 startDistance = start - latestQueuedRuleset.start; + + // Calculate the weight cut multiple. + uint168 weightCutMultiple; + unchecked { + weightCutMultiple = uint168(startDistance / latestQueuedRuleset.duration); + } + + // Store the new values. + cache.weight = uint112( + deriveWeightFrom({ + projectId: projectId, + baseRulesetStart: latestQueuedRuleset.start, + baseRulesetDuration: latestQueuedRuleset.duration, + baseRulesetWeight: latestQueuedRuleset.weight, + baseRulesetWeightCutPercent: latestQueuedRuleset.weightCutPercent, + baseRulesetCacheId: latestQueuedRuleset.id, + start: start + }) + ); + cache.weightCutMultiple = weightCutMultiple; + + emit WeightCacheUpdated({ + projectId: projectId, + weight: cache.weight, + weightCutMultiple: weightCutMultiple, + caller: msg.sender + }); + } + + //*********************************************************************// + // ------------------------ internal functions ----------------------- // + //*********************************************************************// + + /// @notice Updates the latest ruleset for this project if it exists. If there is no ruleset, initializes one. + /// @param projectId The ID of the project to update the latest ruleset for. + /// @param rulesetId The timestamp of when the ruleset was queued. + /// @param weight The weight to store in the queued ruleset. + /// @param mustStartAtOrAfter The earliest time the ruleset can start. The ruleset cannot start before this + /// timestamp. + function _configureIntrinsicPropertiesFor( + uint256 projectId, + uint256 rulesetId, + uint256 weight, + uint256 mustStartAtOrAfter + ) + internal + { + // Keep a reference to the project's latest ruleset's ID. + uint256 latestId = latestRulesetIdOf[projectId]; + + // If the project doesn't have a ruleset yet, initialize one. + // slither-disable-next-line incorrect-equality + if (latestId == 0) { + // Use an empty ruleset as the base. + return _initializeRulesetFor({ + projectId: projectId, + baseRuleset: _getStructFor(0, 0), + rulesetId: rulesetId, + mustStartAtOrAfter: mustStartAtOrAfter, + weight: weight + }); + } + + // Get a reference to the latest ruleset's struct. + JBRuleset memory baseRuleset = _getStructFor(projectId, latestId); + + // Get a reference to the approval status. + JBApprovalStatus approvalStatus = _approvalStatusOf(projectId, baseRuleset); + + // If the base ruleset has started but wasn't approved if a approval hook exists + // OR it hasn't started but is currently approved + // OR it hasn't started but it is likely to be approved and takes place before the proposed one, + // set the struct to be the ruleset it's based on, which carries the latest approved ruleset. + if ( + ( + block.timestamp >= baseRuleset.start && approvalStatus != JBApprovalStatus.Approved + && approvalStatus != JBApprovalStatus.Empty + ) + || ( + block.timestamp < baseRuleset.start && mustStartAtOrAfter < baseRuleset.start + baseRuleset.duration + && approvalStatus != JBApprovalStatus.Approved + ) + || ( + block.timestamp < baseRuleset.start && mustStartAtOrAfter >= baseRuleset.start + baseRuleset.duration + && approvalStatus != JBApprovalStatus.Approved && approvalStatus != JBApprovalStatus.ApprovalExpected + && approvalStatus != JBApprovalStatus.Empty + ) + ) { + baseRuleset = _getStructFor(projectId, baseRuleset.basedOnId); + } + + // The time when the duration of the base ruleset's approval hook has finished. + // If the provided ruleset has no approval hook, return 0 (no constraint on start time). + uint256 timestampAfterApprovalHook = baseRuleset.approvalHook == IJBRulesetApprovalHook(address(0)) + ? 0 + : rulesetId + baseRuleset.approvalHook.DURATION(); + + _initializeRulesetFor({ + projectId: projectId, + baseRuleset: baseRuleset, + rulesetId: rulesetId, + // Can only start after the approval hook. + mustStartAtOrAfter: timestampAfterApprovalHook > mustStartAtOrAfter + ? timestampAfterApprovalHook + : mustStartAtOrAfter, + weight: weight + }); + } + + /// @notice Initializes a ruleset with the specified properties. + /// @param projectId The ID of the project to initialize the ruleset for. + /// @param baseRuleset The ruleset struct to base the newly initialized one on. + /// @param rulesetId The `rulesetId` for the ruleset being initialized. + /// @param mustStartAtOrAfter The earliest time the ruleset can start. The ruleset cannot start before this + /// timestamp. + /// @param weight The weight to give the newly initialized ruleset. + function _initializeRulesetFor( + uint256 projectId, + JBRuleset memory baseRuleset, + uint256 rulesetId, + uint256 mustStartAtOrAfter, + uint256 weight + ) + internal + { + // If there is no base, initialize a first ruleset. + // slither-disable-next-line incorrect-equality + if (baseRuleset.cycleNumber == 0) { + // Set fresh intrinsic properties. + _packAndStoreIntrinsicPropertiesOf({ + rulesetId: rulesetId, + projectId: projectId, + rulesetCycleNumber: 1, + weight: weight, + basedOnId: baseRuleset.id, + start: mustStartAtOrAfter + }); + } else { + // Derive the correct next start time from the base. + uint256 start = deriveStartFrom({ + baseRulesetStart: baseRuleset.start, + baseRulesetDuration: baseRuleset.duration, + mustStartAtOrAfter: mustStartAtOrAfter + }); + + // A weight of 1 is a special case that represents inheriting the cut weight of the previous + // ruleset. + weight = weight == 1 + ? deriveWeightFrom({ + projectId: projectId, + baseRulesetStart: baseRuleset.start, + baseRulesetDuration: baseRuleset.duration, + baseRulesetWeight: baseRuleset.weight, + baseRulesetWeightCutPercent: baseRuleset.weightCutPercent, + baseRulesetCacheId: baseRuleset.id, + start: start + }) + : weight; + + // Derive the correct ruleset cycle number. + uint256 rulesetCycleNumber = deriveCycleNumberFrom({ + baseRulesetCycleNumber: baseRuleset.cycleNumber, + baseRulesetStart: baseRuleset.start, + baseRulesetDuration: baseRuleset.duration, + start: start + }); + + // Update the intrinsic properties. + _packAndStoreIntrinsicPropertiesOf({ + rulesetId: rulesetId, + projectId: projectId, + rulesetCycleNumber: rulesetCycleNumber, + weight: weight, + basedOnId: baseRuleset.id, + start: start + }); + } + + // Set the project's latest ruleset configuration. + latestRulesetIdOf[projectId] = rulesetId; + + emit RulesetInitialized({ + rulesetId: rulesetId, + projectId: projectId, + basedOnId: baseRuleset.id, + caller: msg.sender + }); + } + + /// @notice Efficiently stores the provided intrinsic properties of a ruleset. + /// @param rulesetId The `rulesetId` of the ruleset to pack and store for. + /// @param projectId The ID of the project the ruleset belongs to. + /// @param rulesetCycleNumber The cycle number of the ruleset. + /// @param weight The weight of the ruleset. + /// @param basedOnId The `rulesetId` of the ruleset this ruleset was based on. + /// @param start The start time of this ruleset. + function _packAndStoreIntrinsicPropertiesOf( + uint256 rulesetId, + uint256 projectId, + uint256 rulesetCycleNumber, + uint256 weight, + uint256 basedOnId, + uint256 start + ) + internal + { + // `weight` in bits 0-111. + uint256 packed = weight; + + // `basedOnId` in bits 112-159. + packed |= basedOnId << 112; + + // `start` in bits 160-207. + packed |= start << 160; + + // cycle number in bits 208-255. + packed |= rulesetCycleNumber << 208; + + // Store the packed value. + _packedIntrinsicPropertiesOf[projectId][rulesetId] = packed; + } +} diff --git a/src/interfaces/IJBRulesetApprovalHook4_0_1.sol b/src/interfaces/IJBRulesetApprovalHook4_0_1.sol new file mode 100644 index 000000000..626926f2c --- /dev/null +++ b/src/interfaces/IJBRulesetApprovalHook4_0_1.sol @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; + +import {JBApprovalStatus} from "./../enums/JBApprovalStatus.sol"; + +/// @notice `IJBRulesetApprovalHook`s are used to determine whether the next ruleset in the ruleset queue is approved or +/// rejected. +/// @dev Project rulesets are stored in a queue. Rulesets take effect after the previous ruleset in the queue ends, and +/// only if they are approved by the previous ruleset's approval hook. +interface IJBRulesetApprovalHook4_0_1 is IERC165 { + function DURATION() external view returns (uint256); + + function approvalStatusOf(uint256 projectId, JBRuleset memory ruleset) external view returns (JBApprovalStatus); +} diff --git a/src/interfaces/IJBRulesetDataHook4_0_1.sol b/src/interfaces/IJBRulesetDataHook4_0_1.sol new file mode 100644 index 000000000..a4755304f --- /dev/null +++ b/src/interfaces/IJBRulesetDataHook4_0_1.sol @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; + +import {JBBeforePayRecordedContext} from "./../structs/JBBeforePayRecordedContext.sol"; +import {JBBeforeCashOutRecordedContext} from "./../structs/JBBeforeCashOutRecordedContext.sol"; +import {JBCashOutHookSpecification} from "./../structs/JBCashOutHookSpecification.sol"; +import {JBPayHookSpecification} from "./../structs/JBPayHookSpecification.sol"; +import {JBRuleset} from "./../structs/JBRuleset.sol"; + +/// @notice Data hooks can extend a terminal's core pay/cashout functionality by overriding the weight or memo. They can +/// also specify pay/cashout hooks for the terminal to fulfill, or allow addresses to mint a project's tokens on-demand. +/// @dev If a project's ruleset has `useDataHookForPay` or `useDataHookForCashOut` enabled, its `dataHook` is called by +/// the terminal upon payments/cashouts (respectively). +interface IJBRulesetDataHook4_0_1 is IERC165 { + /// @notice A flag indicating whether an address has permission to mint a project's tokens on-demand. + /// @dev A project's data hook can allow any address to mint its tokens. + /// @param projectId The ID of the project whose token can be minted. + /// @param addr The address to check the token minting permission of. + /// @return flag A flag indicating whether the address has permission to mint the project's tokens on-demand. + function hasMintPermissionFor( + uint256 projectId, + JBRuleset memory ruleset, + address addr + ) + external + view + returns (bool flag); + + /// @notice The data calculated before a payment is recorded in the terminal store. This data is provided to the + /// terminal's `pay(...)` transaction. + /// @param context The context passed to this data hook by the `pay(...)` function as a `JBBeforePayRecordedContext` + /// struct. + /// @return weight The new `weight` to use, overriding the ruleset's `weight`. + /// @return hookSpecifications The amount and data to send to pay hooks instead of adding to the terminal's balance. + function beforePayRecordedWith(JBBeforePayRecordedContext calldata context) + external + view + returns (uint256 weight, JBPayHookSpecification[] memory hookSpecifications); + + /// @notice The data calculated before a cash out is recorded in the terminal store. This data is provided to the + /// terminal's `cashOutTokensOf(...)` transaction. + /// @param context The context passed to this data hook by the `cashOutTokensOf(...)` function as a + /// `JBBeforeCashOutRecordedContext` struct. + /// @return cashOutTaxRate The rate determining the amount that should be reclaimable for a given surplus and token + /// supply. + /// @return cashOutCount The amount of tokens that should be considered cashed out. + /// @return totalSupply The total amount of tokens that are considered to be existing. + /// @return hookSpecifications The amount and data to send to cash out hooks instead of returning to the + /// beneficiary. + function beforeCashOutRecordedWith(JBBeforeCashOutRecordedContext calldata context) + external + view + returns ( + uint256 cashOutTaxRate, + uint256 cashOutCount, + uint256 totalSupply, + JBCashOutHookSpecification[] memory hookSpecifications + ); +}