diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6309138..1285a44 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -70,7 +70,7 @@ jobs: case "$CHECK" in quality) forge fmt --check - forge build --sizes --threads 0 + forge build --sizes --threads 0 --skip test bash test/DeployProductionScript.sh ;; tests) diff --git a/.gitmodules b/.gitmodules index a99fe48..b3976c8 100644 --- a/.gitmodules +++ b/.gitmodules @@ -10,3 +10,12 @@ [submodule "lib/openzeppelin-contracts"] path = lib/openzeppelin-contracts url = https://github.com/OpenZeppelin/openzeppelin-contracts.git +[submodule "lib/sablier"] + path = lib/sablier + url = https://github.com/sablier-labs/evm-monorepo.git +[submodule "lib/prb-math"] + path = lib/prb-math + url = https://github.com/PaulRBerg/prb-math.git +[submodule "lib/chainlink-contracts"] + path = lib/chainlink-contracts + url = https://github.com/smartcontractkit/chainlink-brownie-contracts.git diff --git a/README.md b/README.md index d0e0dab..d64ffa2 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,54 @@ The deployment deliberately does not install a timelock. The Governor remains the direct owner and executor for the vault, rewarder, and router. Successful proposals can therefore execute immediately after voting ends. +## Controlled Minting + +`DeepstateMinterController` is the operational DEEP minter. It uses Solady +`OwnableRoles`: governance is the owner and may grant the controller's +`MINTER_ROLE` bit only to approved issuance contracts, such as +`DeepstateRewarderFactory`. For every requested mint `M`, the controller mints +`M` as the primary 70% tranche and mints `floor(M * 30 / 70)` to Sablier +Lockup v4.0.1 as the recipient's 30% tranche. Each recipient allocation gets +its own linear one-year stream. +The vesting recipient and Sablier contract are immutable constructor settings; +streams are non-cancelable and their NFTs are non-transferable. + +The controller also has an immutable deployment-time live-supply cap. The +intended production value is 20,000,000,000 DEEP. Before every mint, the +controller checks the existing DEEP `totalSupply()` plus both the requested +amount and its corresponding 30% tranche. Burns reduce total supply and reopen +capacity below the cap. This is a controller-level soft cap: governance can +bypass it only by authorizing a different token-level minter after token +administration returns. + +The requested address always receives the complete `M`; the recipient amount is +minted in addition so that it represents 30% of the combined issuance. A +factory market therefore receives its complete 100,000,000 DEEP initial funding +while a separate `floor(100,000,000 * 30 / 70)` DEEP stream is created. If a +market is retired, its unspent rewarder balance is burned, but the independent +recipient stream continues vesting. + +This policy is enforceable only while `DeepstateMinterController` is the sole +operational holder of `DeepstateToken.MINTER_ROLE`. Governance must not grant +the token-level role directly to the factory or another minter that can bypass +the controller. + +For the initial two-year issuance term, the controller temporarily holds +`DeepstateToken.DEFAULT_ADMIN_ROLE` while governance remains the controller's +owner. Governance calls `lockTokenAdministration()` only after granting the +token admin role to the controller. Locking also ensures the controller has the +token minter role. The controller owner may rotate during the term, but +administration cannot be unlocked early. At or after the exact two-year +deadline, anyone may call `unlockTokenAdministration()`. Unlocking grants the +token admin role to the controller's current owner before the controller +renounces it, preserving the token's final-admin invariant throughout the +transition. The controller owner receives +independent mint authority without needing `DeepstateMinterController.MINTER_ROLE`. +Ownership transfers move that owner authority without changing separately +delegated minter roles. Every non-owner mint requires `MINTER_ROLE`. +The controller retains its ordinary token minter role until governance revokes +it after regaining token administration. + ## Reward Schedule The deployment creates one immutable rewarder for NVDA/USDG. Each side starts diff --git a/lib/chainlink-contracts b/lib/chainlink-contracts new file mode 160000 index 0000000..5cb41fb --- /dev/null +++ b/lib/chainlink-contracts @@ -0,0 +1 @@ +Subproject commit 5cb41fbc9b525338b6098da5ea7dd0b7e92f89e4 diff --git a/lib/prb-math b/lib/prb-math new file mode 160000 index 0000000..280fc5f --- /dev/null +++ b/lib/prb-math @@ -0,0 +1 @@ +Subproject commit 280fc5f77e1b21b9c54013aac51966be33f4a410 diff --git a/lib/sablier b/lib/sablier new file mode 160000 index 0000000..fae38dc --- /dev/null +++ b/lib/sablier @@ -0,0 +1 @@ +Subproject commit fae38dc7e43c6cab6de8f97124c559f42ed5b77a diff --git a/remappings.txt b/remappings.txt index 0370b46..431e3ab 100644 --- a/remappings.txt +++ b/remappings.txt @@ -1,4 +1,8 @@ +@chainlink/contracts/=lib/chainlink-contracts/contracts/ forge-std/=lib/forge-std/src/ solady/=lib/solady/src/ deepstate-contracts/=lib/deepstate-contracts/src/ @openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/ +@prb/math/=lib/prb-math/ +@sablier/evm-utils/=lib/sablier/utils/ +@sablier/lockup/=lib/sablier/lockup/ diff --git a/src/DeepstateController.sol b/src/DeepstateController.sol new file mode 100644 index 0000000..75dc037 --- /dev/null +++ b/src/DeepstateController.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.28; + +import {OwnableRoles} from "solady/auth/OwnableRoles.sol"; + +/// @title Deepstate Controller +/// @notice Shared governance ownership and delegated-role authority for protocol controllers. +abstract contract DeepstateController is OwnableRoles { + error InvalidOwner(); + + constructor(address owner_) { + if (owner_ == address(0)) revert InvalidOwner(); + _initializeOwner(owner_); + } + + /// @notice Controller ownership cannot be renounced. + function renounceOwnership() public payable virtual override onlyOwner { + revert NewOwnerIsZeroAddress(); + } +} diff --git a/src/DeepstateMinterController.sol b/src/DeepstateMinterController.sol new file mode 100644 index 0000000..eabb552 --- /dev/null +++ b/src/DeepstateMinterController.sol @@ -0,0 +1,154 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity 0.8.28; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {Lockup} from "@sablier/lockup/src/types/Lockup.sol"; +import {LockupLinear} from "@sablier/lockup/src/types/LockupLinear.sol"; +import {FixedPointMathLib} from "solady/utils/FixedPointMathLib.sol"; +import {ReentrancyGuard} from "solady/utils/ReentrancyGuard.sol"; +import {SafeCastLib} from "solady/utils/SafeCastLib.sol"; +import {SafeTransferLib} from "solady/utils/SafeTransferLib.sol"; + +import {DeepstateToken} from "./DeepstateToken.sol"; +import {DeepstateController} from "./DeepstateController.sol"; +import {ISablierLockupLinearV4} from "./interfaces/ISablierLockupLinearV4.sol"; + +/// @title Deepstate Minter Controller +/// @notice Allocates 30% of every authorized DEEP issuance to a vesting recipient. +/// @dev The recipient allocation is placed in a new non-cancelable, non-transferable Sablier +/// Lockup v4 linear stream. This contract temporarily administers DEEP while remaining owned by governance. +contract DeepstateMinterController is DeepstateController, ReentrancyGuard { + using SafeTransferLib for address; + + uint256 public constant MINTER_ROLE = 1 << 0; + uint256 public constant RECIPIENT_ALLOCATION_BPS = 30_00; + uint256 public constant PRIMARY_ALLOCATION_BPS = 70_00; + uint40 public constant VESTING_DURATION = 365 days; + uint40 public constant TOKEN_ADMINISTRATION_DURATION = 2 * 365 days; + + DeepstateToken public immutable deepstateToken; + ISablierLockupLinearV4 public immutable sablierLockup; + address public immutable recipient; + /// @notice Maximum live DEEP supply this controller will permit after a mint. + uint256 public immutable mintCap; + + /// @notice Administration deadline, zero before locking, and uint40 max after permanent return. + uint40 public tokenAdministrationEndsAt; + + event MintedWithVesting( + address indexed caller, + address indexed mintRecipient, + uint256 mintAmount, + address indexed vestingRecipient, + uint256 vestingAmount, + uint256 streamId + ); + event TokenAdministrationActivated(uint40 indexed endsAt); + event TokenAdministrationReturned(address indexed owner, address indexed caller); + + error InvalidDeepstateToken(); + error InvalidSablierLockup(); + error InvalidRecipient(); + error InvalidMintCap(); + error InvalidMintRecipient(); + error MintAmountTooSmall(); + error VestingAmountTooLarge(uint256 amount); + error ControllerNotTokenAdmin(); + error TokenAdministrationAlreadyActivated(); + error TokenAdministrationAlreadyReturned(); + error TokenAdministrationNotActive(); + error TokenAdministrationActive(uint40 endsAt); + error MintCapExceeded(uint256 cap, uint256 attemptedSupply); + + constructor(address owner_, address deepstateToken_, address sablierLockup_, address recipient_, uint256 mintCap_) + DeepstateController(owner_) + { + if (deepstateToken_ == address(0) || deepstateToken_.code.length == 0) revert InvalidDeepstateToken(); + if (sablierLockup_ == address(0) || sablierLockup_.code.length == 0) revert InvalidSablierLockup(); + if (recipient_ == address(0)) revert InvalidRecipient(); + if (mintCap_ == 0) revert InvalidMintCap(); + + deepstateToken = DeepstateToken(deepstateToken_); + sablierLockup = ISablierLockupLinearV4(sablierLockup_); + recipient = recipient_; + mintCap = mintCap_; + } + + /// @notice Lock DEEP administration in this contract for the initial two-year term. + /// @dev Also ensures this controller holds DEEP's operational minter role. + function lockTokenAdministration() external onlyOwner { + if (tokenAdministrationEndsAt != 0) revert TokenAdministrationAlreadyActivated(); + + bytes32 tokenAdminRole = deepstateToken.DEFAULT_ADMIN_ROLE(); + if (!deepstateToken.hasRole(tokenAdminRole, address(this))) revert ControllerNotTokenAdmin(); + if (!deepstateToken.hasRole(deepstateToken.MINTER_ROLE(), address(this))) { + deepstateToken.grantRole(deepstateToken.MINTER_ROLE(), address(this)); + } + + uint40 endsAt = SafeCastLib.toUint40(block.timestamp + TOKEN_ADMINISTRATION_DURATION); + tokenAdministrationEndsAt = endsAt; + emit TokenAdministrationActivated(endsAt); + } + + /// @notice Unlock DEEP administration to this contract's current governance owner after the term expires. + /// @dev Anyone may trigger the unlock at or after the exact deadline. + function unlockTokenAdministration() external { + uint40 endsAt = tokenAdministrationEndsAt; + if (endsAt == 0) revert TokenAdministrationNotActive(); + if (endsAt == type(uint40).max) revert TokenAdministrationAlreadyReturned(); + + address owner_ = owner(); + if (block.timestamp < endsAt) revert TokenAdministrationActive(endsAt); + + bytes32 tokenAdminRole = deepstateToken.DEFAULT_ADMIN_ROLE(); + if (!deepstateToken.hasRole(tokenAdminRole, address(this))) revert ControllerNotTokenAdmin(); + + tokenAdministrationEndsAt = type(uint40).max; + // Grant first so DeepstateToken's final-admin invariant cannot strand the token. + deepstateToken.grantRole(tokenAdminRole, owner_); + deepstateToken.renounceRole(tokenAdminRole, address(this)); + + emit TokenAdministrationReturned(owner_, msg.sender); + } + + /// @notice Mint the 70% primary tranche `amount` to `to` and the 30% tranche into a one-year stream. + /// @dev The recipient amount is `floor(amount * 30 / 70)`. Amounts that round it to zero revert. + function mint(address to, uint256 amount) + external + onlyOwnerOrRoles(MINTER_ROLE) + nonReentrant + returns (uint256 streamId) + { + if (to == address(0)) revert InvalidMintRecipient(); + + uint256 vestingAmount = FixedPointMathLib.fullMulDiv(amount, RECIPIENT_ALLOCATION_BPS, PRIMARY_ALLOCATION_BPS); + if (vestingAmount == 0) revert MintAmountTooSmall(); + if (vestingAmount > type(uint128).max) revert VestingAmountTooLarge(vestingAmount); + uint128 streamAmount = SafeCastLib.toUint128(vestingAmount); + + uint256 mintSupply = amount + vestingAmount; + uint256 attemptedSupply = deepstateToken.totalSupply() + mintSupply; + if (attemptedSupply > mintCap) revert MintCapExceeded(mintCap, attemptedSupply); + + deepstateToken.mint(to, amount); + deepstateToken.mint(address(this), vestingAmount); + + address(deepstateToken).safeApproveWithRetry(address(sablierLockup), vestingAmount); + streamId = sablierLockup.createWithDurationsLL( + Lockup.CreateWithDurations({ + sender: address(this), + recipient: recipient, + depositAmount: streamAmount, + token: IERC20(address(deepstateToken)), + cancelable: false, + transferable: false, + shape: "Deepstate allocation" + }), + LockupLinear.UnlockAmounts({start: 0, cliff: 0}), + 0, + LockupLinear.Durations({cliff: 0, total: VESTING_DURATION}) + ); + + emit MintedWithVesting(msg.sender, to, amount, recipient, vestingAmount, streamId); + } +} diff --git a/src/DeepstateRewarderFactory.sol b/src/DeepstateRewarderFactory.sol new file mode 100644 index 0000000..c921c82 --- /dev/null +++ b/src/DeepstateRewarderFactory.sol @@ -0,0 +1,186 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.28; + +import {Ownable} from "solady/auth/Ownable.sol"; + +import {DeepstateRewarderV2} from "./DeepstateRewarderV2.sol"; +import {DeepstateV1Controller} from "./DeepstateV1Controller.sol"; +import {DeepstateToken} from "./DeepstateToken.sol"; +import {IDeepstateMinterController} from "./interfaces/IDeepstateMinterController.sol"; +import {IDeepstateV1} from "./interfaces/IDeepstateV1.sol"; + +/// @title Deepstate Rewarder Factory +/// @notice Governance-owned factory for operator-launched market reward programs. +/// @dev The factory must hold the minter controller's MINTER_ROLE and V1 controller's HOOK_MANAGER_ROLE. +contract DeepstateRewarderFactory is Ownable { + struct MarketConfig { + address token0; + address token1; + uint160 token0StartQuantity; + uint160 token0MaxQuantity; + uint160 token1StartQuantity; + uint160 token1MaxQuantity; + bool token0Active; + bool token1Active; + } + + /// @notice Minimum interval between successful operator or governance market deployments. + uint256 public constant DEPLOYMENT_COOLDOWN = 3 days; + /// @notice Duration encoded into every factory rewarder. + uint32 public constant EMISSION_DURATION = 395 days; + /// @notice Maximum scheduled emissions for each side, for one billion DEEP total per market. + uint96 public constant SIDE_EMISSION_CAP = 500_000_000e18; + /// @notice Initial DEEP minted to each rewarder. Further funding requires governance. + uint256 public constant INITIAL_FUNDING = 100_000_000e18; + + DeepstateV1Controller public immutable deepstateV1Controller; + IDeepstateV1 public immutable deepstate; + IDeepstateMinterController public immutable minterController; + DeepstateToken public immutable rewardToken; + + /// @notice Revocable operator permitted to launch and retire factory markets. + address public operator; + /// @notice Earliest timestamp at which another market may be deployed. + uint256 public nextDeploymentAt; + + mapping(bytes32 poolId => address rewarder) public activeRewarder; + mapping(address rewarder => bytes32 poolId) public rewarderPool; + + event OperatorSet(address indexed previousOperator, address indexed newOperator); + event MarketDeployed( + bytes32 indexed poolId, + address indexed rewarder, + address token0, + address token1, + bool token0Active, + bool token1Active + ); + event MarketRemoved(bytes32 indexed poolId, address indexed rewarder); + + error InvalidOwner(); + error InvalidDeepstateV1Controller(); + error InvalidMinterController(); + error MinterControllerOwnerMismatch(address expected, address actual); + error InvalidRewardToken(); + error InvalidPool(); + error InvalidHookFlags(); + error DeploymentCooldown(uint256 nextDeploymentAt); + error ActiveMarketExists(bytes32 poolId, address rewarder); + error ExistingPoolHook(bytes32 poolId, address hook); + error UnexpectedPoolHook(bytes32 poolId, address expected, address actual); + error MarketNotActive(bytes32 poolId); + + constructor(address owner_, address deepstateV1Controller_, address minterController_) { + if (owner_ == address(0)) revert InvalidOwner(); + if (deepstateV1Controller_ == address(0) || deepstateV1Controller_.code.length == 0) { + revert InvalidDeepstateV1Controller(); + } + if (minterController_ == address(0) || minterController_.code.length == 0) { + revert InvalidMinterController(); + } + + _initializeOwner(owner_); + deepstateV1Controller = DeepstateV1Controller(deepstateV1Controller_); + deepstate = deepstateV1Controller.deepstate(); + minterController = IDeepstateMinterController(minterController_); + address minterControllerOwner = minterController.owner(); + if (minterControllerOwner != owner_) { + revert MinterControllerOwnerMismatch(owner_, minterControllerOwner); + } + address deepstateToken_ = minterController.deepstateToken(); + if (deepstateToken_ == address(0) || deepstateToken_.code.length == 0) revert InvalidRewardToken(); + rewardToken = DeepstateToken(deepstateToken_); + } + + modifier onlyOperatorOrOwner() { + if (msg.sender != operator) _checkOwner(); + _; + } + + /// @notice Appoint or revoke the operator. Set zero to revoke without replacement. + function setOperator(address newOperator) external onlyOwner { + address previousOperator = operator; + operator = newOperator; + emit OperatorSet(previousOperator, newOperator); + } + + /// @notice Deploy, initially fund, and install a deterministic rewarder for one pool. + /// @dev Both sides share a one-billion-DEEP schedule but receive only 100 million DEEP initially. + function deployMarket(MarketConfig calldata config) + external + onlyOperatorOrOwner + returns (DeepstateRewarderV2 rewarder) + { + bytes32 poolId_ = _validateMarket(config); + uint256 next = nextDeploymentAt; + if (block.timestamp < next) revert DeploymentCooldown(next); + + address active = activeRewarder[poolId_]; + if (active != address(0)) revert ActiveMarketExists(poolId_, active); + + address existingHook = deepstate.poolHook(poolId_); + if (existingHook != address(0)) revert ExistingPoolHook(poolId_, existingHook); + + nextDeploymentAt = block.timestamp + DEPLOYMENT_COOLDOWN; + + rewarder = new DeepstateRewarderV2( + address(this), + address(deepstate), + address(rewardToken), + poolId_, + config.token0, + config.token1, + SIDE_EMISSION_CAP, + EMISSION_DURATION, + config.token0StartQuantity, + config.token0MaxQuantity, + config.token1StartQuantity, + config.token1MaxQuantity + ); + + activeRewarder[poolId_] = address(rewarder); + rewarderPool[address(rewarder)] = poolId_; + + minterController.mint(address(rewarder), INITIAL_FUNDING); + deepstateV1Controller.setPoolHookConfig( + config.token0, config.token1, address(rewarder), config.token0Active, config.token1Active + ); + + emit MarketDeployed( + poolId_, address(rewarder), config.token0, config.token1, config.token0Active, config.token1Active + ); + } + + /// @notice Remove a factory market and burn its remaining DEEP balance. + /// @dev Retiring a market deliberately makes its unpaid claims unclaimable unless governance + /// later funds the detached rewarder directly. + function removeMarket(address token0, address token1) external onlyOperatorOrOwner { + bytes32 poolId_ = _poolId(token0, token1); + address rewarder = activeRewarder[poolId_]; + if (rewarder == address(0)) revert MarketNotActive(poolId_); + + address currentHook = deepstate.poolHook(poolId_); + if (currentHook == rewarder) { + deepstateV1Controller.setPoolHookConfig(token0, token1, address(0), false, false); + } else if (currentHook != address(0)) { + revert UnexpectedPoolHook(poolId_, rewarder, currentHook); + } + delete activeRewarder[poolId_]; + delete rewarderPool[rewarder]; + + DeepstateRewarderV2(rewarder).burnBalance(); + + emit MarketRemoved(poolId_, rewarder); + } + + function _validateMarket(MarketConfig calldata config) private pure returns (bytes32 poolId_) { + if (config.token0 >= config.token1) revert InvalidPool(); + if (!config.token0Active && !config.token1Active) revert InvalidHookFlags(); + poolId_ = keccak256(abi.encode(config.token0, config.token1)); + } + + function _poolId(address token0, address token1) private pure returns (bytes32 poolId_) { + if (token0 >= token1) revert InvalidPool(); + poolId_ = keccak256(abi.encode(token0, token1)); + } +} diff --git a/src/DeepstateRewarderV2.sol b/src/DeepstateRewarderV2.sol new file mode 100644 index 0000000..65d718a --- /dev/null +++ b/src/DeepstateRewarderV2.sol @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.28; + +import {SafeTransferLib} from "solady/utils/SafeTransferLib.sol"; + +import {DeepstateRewarder} from "./DeepstateRewarder.sol"; +import {IBurnableERC20} from "./interfaces/IBurnableERC20.sol"; + +/// @title Deepstate Rewarder V2 +/// @notice Extends the original rewarder with owner-controlled burning of remaining rewards. +/// @dev Ownable is inherited through DeepstateRewarder. +contract DeepstateRewarderV2 is DeepstateRewarder { + event RewardBalanceBurned(uint256 amount); + + constructor( + address owner_, + address deepstate_, + address rewardToken_, + bytes32 poolId_, + address token0_, + address token1_, + uint96 sideEmissionCap_, + uint32 emissionDuration_, + uint160 token0StartQuantity_, + uint160 token0MaxQuantity_, + uint160 token1StartQuantity_, + uint160 token1MaxQuantity_ + ) + DeepstateRewarder( + owner_, + deepstate_, + rewardToken_, + poolId_, + token0_, + token1_, + sideEmissionCap_, + emissionDuration_, + token0StartQuantity_, + token0MaxQuantity_, + token1StartQuantity_, + token1MaxQuantity_ + ) + {} + + /// @notice Burn the rewarder's entire remaining reward-token balance. + /// @dev Outstanding claims remain accounted for and will revert until funding is restored. + function burnBalance() external onlyOwner { + uint256 amount = SafeTransferLib.balanceOf(rewardToken, address(this)); + IBurnableERC20(rewardToken).burn(amount); + emit RewardBalanceBurned(amount); + } +} diff --git a/src/DeepstateV1Controller.sol b/src/DeepstateV1Controller.sol new file mode 100644 index 0000000..3d2eda0 --- /dev/null +++ b/src/DeepstateV1Controller.sol @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.28; + +import {DeepstateController} from "./DeepstateController.sol"; +import {IDeepstateV1} from "./interfaces/IDeepstateV1.sol"; + +/// @title Deepstate V1 Controller +/// @notice Governance-owned capability boundary around the single-owner Deepstate router. +contract DeepstateV1Controller is DeepstateController { + uint256 public constant HOOK_MANAGER_ROLE = 1 << 0; + + IDeepstateV1 public immutable deepstate; + + event DeepstateFeeConfigured(address indexed recipient, uint16 bps); + event DeepstateOwnershipTransferred(address indexed newOwner); + + error InvalidDeepstate(); + + constructor(address owner_, address deepstate_) DeepstateController(owner_) { + if (deepstate_ == address(0) || deepstate_.code.length == 0) revert InvalidDeepstate(); + + deepstate = IDeepstateV1(deepstate_); + } + + /// @notice Configure one pool hook as governance or the delegated hook manager. + function setPoolHookConfig(address token0, address token1, address hook, bool token0Active, bool token1Active) + external + onlyOwnerOrRoles(HOOK_MANAGER_ROLE) + { + deepstate.setPoolHookConfig(token0, token1, hook, token0Active, token1Active); + } + + /// @notice Configure protocol fees. The hook manager has no access to this capability. + function setDeepstateFeeConfig(address recipient, uint16 bps) external onlyOwner { + deepstate.setFeeConfig(recipient, bps); + emit DeepstateFeeConfigured(recipient, bps); + } + + /// @notice Return router ownership to governance or another governance-approved owner. + function transferDeepstateOwnership(address newOwner) external onlyOwner { + deepstate.transferOwnership(newOwner); + emit DeepstateOwnershipTransferred(newOwner); + } +} diff --git a/src/interfaces/IDeepstateMinterController.sol b/src/interfaces/IDeepstateMinterController.sol new file mode 100644 index 0000000..0de369f --- /dev/null +++ b/src/interfaces/IDeepstateMinterController.sol @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.28; + +/// @notice Factory-facing interface for policy-controlled DEEP minting. +interface IDeepstateMinterController { + function owner() external view returns (address); + function deepstateToken() external view returns (address); + /// @notice Mint `amount` as the primary 70% tranche and vest the corresponding 30% tranche. + function mint(address to, uint256 amount) external returns (uint256 streamId); +} diff --git a/src/interfaces/IDeepstateV1.sol b/src/interfaces/IDeepstateV1.sol new file mode 100644 index 0000000..a43e7dd --- /dev/null +++ b/src/interfaces/IDeepstateV1.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.28; + +/// @notice Administrative surface of the Deepstate V1 router. +interface IDeepstateV1 { + function owner() external view returns (address); + function poolHook(bytes32 poolId) external view returns (address); + function setPoolHookConfig(address token0, address token1, address hook, bool token0Active, bool token1Active) + external; + function setFeeConfig(address recipient, uint16 bps) external; + function transferOwnership(address newOwner) external payable; +} diff --git a/src/interfaces/ISablierLockupLinearV4.sol b/src/interfaces/ISablierLockupLinearV4.sol new file mode 100644 index 0000000..403eef2 --- /dev/null +++ b/src/interfaces/ISablierLockupLinearV4.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity 0.8.28; + +import {Lockup} from "@sablier/lockup/src/types/Lockup.sol"; +import {LockupLinear} from "@sablier/lockup/src/types/LockupLinear.sol"; + +/// @notice Minimal Sablier Lockup v4 interface used to create linear streams. +interface ISablierLockupLinearV4 { + function createWithDurationsLL( + Lockup.CreateWithDurations calldata params, + LockupLinear.UnlockAmounts calldata unlockAmounts, + uint40 granularity, + LockupLinear.Durations calldata durations + ) external payable returns (uint256 streamId); +} diff --git a/test/DeepstateMinterController.t.sol b/test/DeepstateMinterController.t.sol new file mode 100644 index 0000000..67be6cc --- /dev/null +++ b/test/DeepstateMinterController.t.sol @@ -0,0 +1,454 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity 0.8.28; + +import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; +import {Test} from "forge-std/Test.sol"; +import {Ownable} from "solady/auth/Ownable.sol"; +import {ReentrancyGuard} from "solady/utils/ReentrancyGuard.sol"; + +import {DeepstateController} from "../src/DeepstateController.sol"; +import {DeepstateMinterController} from "../src/DeepstateMinterController.sol"; +import {DeepstateToken} from "../src/DeepstateToken.sol"; +import {MockSablierLockupLinearV4} from "./mocks/MockSablierLockupLinearV4.sol"; + +contract DeepstateMinterControllerTest is Test { + uint256 internal constant MINT_CAP = 20_000_000_000e18; + + DeepstateToken internal deep; + DeepstateMinterController internal minterController; + MockSablierLockupLinearV4 internal sablier; + + address internal recipient = makeAddr("recipient"); + address internal mintRecipient = makeAddr("mintRecipient"); + address internal unauthorized = makeAddr("unauthorized"); + address internal newGovernance = makeAddr("newGovernance"); + + function setUp() public { + deep = new DeepstateToken(address(this), "Deepstate", "DEEP"); + sablier = new MockSablierLockupLinearV4(); + minterController = + new DeepstateMinterController(address(this), address(deep), address(sablier), recipient, MINT_CAP); + + deep.grantRole(deep.MINTER_ROLE(), address(minterController)); + } + + function test_ImmutableConfigurationAndInitialAuthority() public view { + assertEq(address(minterController.deepstateToken()), address(deep)); + assertEq(address(minterController.sablierLockup()), address(sablier)); + assertEq(minterController.recipient(), recipient); + assertEq(minterController.mintCap(), MINT_CAP); + assertEq(minterController.RECIPIENT_ALLOCATION_BPS(), 30_00); + assertEq(minterController.PRIMARY_ALLOCATION_BPS(), 70_00); + assertEq(minterController.VESTING_DURATION(), 365 days); + assertEq(minterController.TOKEN_ADMINISTRATION_DURATION(), 2 * 365 days); + assertEq(minterController.owner(), address(this)); + assertEq(minterController.tokenAdministrationEndsAt(), 0); + assertEq(minterController.MINTER_ROLE(), 1); + assertEq(minterController.rolesOf(address(this)), 0); + assertFalse(minterController.hasAnyRole(address(this), minterController.MINTER_ROLE())); + assertTrue(deep.hasRole(deep.MINTER_ROLE(), address(minterController))); + } + + function test_ConstructorValidation() public { + vm.expectRevert(DeepstateController.InvalidOwner.selector); + new DeepstateMinterController(address(0), address(deep), address(sablier), recipient, MINT_CAP); + + vm.expectRevert(DeepstateMinterController.InvalidDeepstateToken.selector); + new DeepstateMinterController(address(this), address(0), address(sablier), recipient, MINT_CAP); + + vm.expectRevert(DeepstateMinterController.InvalidDeepstateToken.selector); + new DeepstateMinterController(address(this), unauthorized, address(sablier), recipient, MINT_CAP); + + vm.expectRevert(DeepstateMinterController.InvalidSablierLockup.selector); + new DeepstateMinterController(address(this), address(deep), address(0), recipient, MINT_CAP); + + vm.expectRevert(DeepstateMinterController.InvalidSablierLockup.selector); + new DeepstateMinterController(address(this), address(deep), unauthorized, recipient, MINT_CAP); + + vm.expectRevert(DeepstateMinterController.InvalidRecipient.selector); + new DeepstateMinterController(address(this), address(deep), address(sablier), address(0), MINT_CAP); + + vm.expectRevert(DeepstateMinterController.InvalidMintCap.selector); + new DeepstateMinterController(address(this), address(deep), address(sablier), recipient, 0); + } + + function test_MintCreatesExactNonCancelableOneYearStream() public { + uint256 amount = 70_000_000e18; + uint256 vestingAmount = 30_000_000e18; + + vm.expectEmit(true, true, true, true, address(minterController)); + emit DeepstateMinterController.MintedWithVesting( + address(this), mintRecipient, amount, recipient, vestingAmount, 1 + ); + uint256 streamId = minterController.mint(mintRecipient, amount); + + assertEq(streamId, 1); + assertEq(deep.balanceOf(mintRecipient), amount); + assertEq(deep.balanceOf(address(sablier)), vestingAmount); + assertEq(deep.balanceOf(address(minterController)), 0); + assertEq(deep.totalSupply(), amount + vestingAmount); + assertEq(deep.allowance(address(minterController), address(sablier)), 0); + + MockSablierLockupLinearV4.Stream memory created = sablier.stream(streamId); + assertEq(created.funder, address(minterController)); + assertEq(created.sender, address(minterController)); + assertEq(created.recipient, recipient); + assertEq(created.token, address(deep)); + assertEq(created.depositAmount, vestingAmount); + assertFalse(created.cancelable); + assertFalse(created.transferable); + assertEq(created.shape, "Deepstate allocation"); + assertEq(created.startUnlockAmount, 0); + assertEq(created.cliffUnlockAmount, 0); + assertEq(created.granularity, 0); + assertEq(created.cliffDuration, 0); + assertEq(created.totalDuration, 365 days); + } + + function test_LockTokenAdministrationStartsTwoYearTermAndEnsuresMinterRole() public { + deep.revokeRole(deep.MINTER_ROLE(), address(minterController)); + deep.grantRole(deep.DEFAULT_ADMIN_ROLE(), address(minterController)); + + uint40 expectedEndsAt = uint40(block.timestamp + 2 * 365 days); + vm.expectEmit(true, false, false, true, address(minterController)); + emit DeepstateMinterController.TokenAdministrationActivated(expectedEndsAt); + minterController.lockTokenAdministration(); + + assertEq(minterController.tokenAdministrationEndsAt(), expectedEndsAt); + assertTrue(deep.hasRole(deep.DEFAULT_ADMIN_ROLE(), address(minterController))); + assertTrue(deep.hasRole(deep.MINTER_ROLE(), address(minterController))); + } + + function test_RevertLockWithoutTokenAdminOrByNonOwnerOrTwice() public { + vm.expectRevert(Ownable.Unauthorized.selector); + vm.prank(unauthorized); + minterController.lockTokenAdministration(); + + vm.expectRevert(DeepstateMinterController.ControllerNotTokenAdmin.selector); + minterController.lockTokenAdministration(); + + deep.grantRole(deep.DEFAULT_ADMIN_ROLE(), address(minterController)); + minterController.lockTokenAdministration(); + + vm.expectRevert(DeepstateMinterController.TokenAdministrationAlreadyActivated.selector); + minterController.lockTokenAdministration(); + } + + function test_OwnerCannotUnlockTokenAdministrationBeforeDeadline() public { + _lockSoleTokenAdministration(); + uint40 endsAt = minterController.tokenAdministrationEndsAt(); + + vm.expectRevert(abi.encodeWithSelector(DeepstateMinterController.TokenAdministrationActive.selector, endsAt)); + minterController.unlockTokenAdministration(); + + assertEq(minterController.tokenAdministrationEndsAt(), endsAt); + assertFalse(deep.hasRole(deep.DEFAULT_ADMIN_ROLE(), address(this))); + assertTrue(deep.hasRole(deep.DEFAULT_ADMIN_ROLE(), address(minterController))); + assertEq(deep.defaultAdminCount(), 1); + } + + function test_AnyoneCanUnlockTokenAdministrationAtExactDeadline() public { + _lockSoleTokenAdministration(); + uint40 endsAt = minterController.tokenAdministrationEndsAt(); + + vm.warp(endsAt - 1); + vm.expectRevert(abi.encodeWithSelector(DeepstateMinterController.TokenAdministrationActive.selector, endsAt)); + vm.prank(unauthorized); + minterController.unlockTokenAdministration(); + + vm.warp(endsAt); + vm.prank(unauthorized); + minterController.unlockTokenAdministration(); + + assertEq(minterController.tokenAdministrationEndsAt(), type(uint40).max); + assertTrue(deep.hasRole(deep.DEFAULT_ADMIN_ROLE(), address(this))); + assertFalse(deep.hasRole(deep.DEFAULT_ADMIN_ROLE(), address(minterController))); + assertEq(deep.defaultAdminCount(), 1); + } + + function test_UnlockUsesCurrentOwnerAfterOwnershipTransfer() public { + _lockSoleTokenAdministration(); + + minterController.transferOwnership(newGovernance); + assertEq(minterController.owner(), newGovernance); + assertFalse(minterController.hasAnyRole(address(this), minterController.MINTER_ROLE())); + assertFalse(minterController.hasAnyRole(newGovernance, minterController.MINTER_ROLE())); + + vm.warp(minterController.tokenAdministrationEndsAt()); + vm.prank(unauthorized); + minterController.unlockTokenAdministration(); + + assertEq(minterController.tokenAdministrationEndsAt(), type(uint40).max); + assertTrue(deep.hasRole(deep.DEFAULT_ADMIN_ROLE(), newGovernance)); + assertFalse(deep.hasRole(deep.DEFAULT_ADMIN_ROLE(), address(this))); + assertFalse(deep.hasRole(deep.DEFAULT_ADMIN_ROLE(), address(minterController))); + assertEq(deep.defaultAdminCount(), 1); + } + + function test_OwnerMintAuthorityRotatesWithOwnership() public { + DeepstateMinterController ownerController = + new DeepstateMinterController(address(this), address(deep), address(sablier), recipient, MINT_CAP); + deep.grantRole(deep.MINTER_ROLE(), address(ownerController)); + + assertFalse(ownerController.hasAnyRole(address(this), ownerController.MINTER_ROLE())); + ownerController.mint(mintRecipient, 100e18); + ownerController.transferOwnership(newGovernance); + + assertFalse(ownerController.hasAnyRole(address(this), ownerController.MINTER_ROLE())); + assertFalse(ownerController.hasAnyRole(newGovernance, ownerController.MINTER_ROLE())); + + vm.expectRevert(Ownable.Unauthorized.selector); + ownerController.mint(mintRecipient, 100e18); + + vm.prank(newGovernance); + ownerController.mint(mintRecipient, 100e18); + + assertEq(deep.balanceOf(mintRecipient), 200e18); + assertEq(deep.balanceOf(address(sablier)), 2 * Math.mulDiv(100e18, 30_00, 70_00)); + } + + function test_OwnerCanMintAfterItsMinterRoleIsRevoked() public { + uint256 minterRole = minterController.MINTER_ROLE(); + minterController.grantRoles(address(this), minterRole); + minterController.revokeRoles(address(this), minterRole); + + assertFalse(minterController.hasAnyRole(address(this), minterRole)); + minterController.mint(mintRecipient, 70e18); + assertEq(deep.balanceOf(mintRecipient), 70e18); + assertEq(deep.balanceOf(address(sablier)), 30e18); + } + + function test_TwoStepOwnershipHandoverDoesNotMutateRoles() public { + vm.prank(newGovernance); + minterController.requestOwnershipHandover(); + minterController.completeOwnershipHandover(newGovernance); + + assertEq(minterController.owner(), newGovernance); + assertEq(minterController.rolesOf(address(this)), 0); + assertEq(minterController.rolesOf(newGovernance), 0); + } + + function test_TransferOwnershipToCurrentOwnerPreservesRoles() public { + minterController.transferOwnership(address(this)); + + assertEq(minterController.owner(), address(this)); + assertEq(minterController.rolesOf(address(this)), 0); + + minterController.mint(mintRecipient, 100e18); + assertEq(deep.balanceOf(mintRecipient), 100e18); + } + + function test_ControllerOwnerCannotRenounceOwnership() public { + vm.expectRevert(Ownable.NewOwnerIsZeroAddress.selector); + minterController.renounceOwnership(); + } + + function test_RevertUnlockBeforeLockAfterUnlockOrWithoutTokenAdmin() public { + vm.expectRevert(DeepstateMinterController.TokenAdministrationNotActive.selector); + minterController.unlockTokenAdministration(); + + _lockSoleTokenAdministration(); + bytes32 tokenAdminRole = deep.DEFAULT_ADMIN_ROLE(); + vm.prank(address(minterController)); + deep.grantRole(tokenAdminRole, address(this)); + deep.revokeRole(tokenAdminRole, address(minterController)); + vm.warp(minterController.tokenAdministrationEndsAt()); + + vm.expectRevert(DeepstateMinterController.ControllerNotTokenAdmin.selector); + minterController.unlockTokenAdministration(); + + deep.grantRole(tokenAdminRole, address(minterController)); + minterController.unlockTokenAdministration(); + + vm.expectRevert(DeepstateMinterController.TokenAdministrationAlreadyReturned.selector); + minterController.unlockTokenAdministration(); + + vm.expectRevert(DeepstateMinterController.TokenAdministrationAlreadyActivated.selector); + minterController.lockTokenAdministration(); + } + + function test_EachMintCreatesAnIndependentStream() public { + uint256 firstStreamId = minterController.mint(mintRecipient, 70e18); + vm.warp(block.timestamp + 30 days); + uint256 secondStreamId = minterController.mint(mintRecipient, 140e18); + + assertEq(firstStreamId, 1); + assertEq(secondStreamId, 2); + assertEq(sablier.stream(firstStreamId).depositAmount, 30e18); + assertEq(sablier.stream(secondStreamId).depositAmount, 60e18); + assertEq(deep.balanceOf(mintRecipient), 210e18); + assertEq(deep.balanceOf(address(sablier)), 90e18); + } + + function test_MintRoundsRecipientAllocationDown() public { + minterController.mint(mintRecipient, 5); + + assertEq(deep.balanceOf(mintRecipient), 5); + assertEq(deep.balanceOf(address(sablier)), 2); + assertEq(deep.totalSupply(), 7); + } + + function test_MintCapIncludesExistingRequestedAndVestedSupply() public { + DeepstateMinterController cappedController = _newControllerWithCap(100e18); + + deep.grantRole(deep.MINTER_ROLE(), address(this)); + deep.mint(unauthorized, 5); + + vm.expectRevert(abi.encodeWithSelector(DeepstateMinterController.MintCapExceeded.selector, 100e18, 100e18 + 5)); + cappedController.mint(mintRecipient, 70e18); + + assertEq(deep.totalSupply(), 5); + assertEq(sablier.nextStreamId(), 1); + } + + function test_BurnReopensMintCapacity() public { + DeepstateMinterController cappedController = _newControllerWithCap(100e18); + + cappedController.mint(mintRecipient, 70e18); + assertEq(deep.totalSupply(), 100e18); + + vm.expectRevert(abi.encodeWithSelector(DeepstateMinterController.MintCapExceeded.selector, 100e18, 100e18 + 4)); + cappedController.mint(mintRecipient, 3); + + vm.prank(mintRecipient); + deep.burn(4); + cappedController.mint(mintRecipient, 3); + + assertEq(deep.totalSupply(), 100e18); + assertEq(deep.balanceOf(mintRecipient), 70e18 - 1); + assertEq(deep.balanceOf(address(sablier)), 30e18 + 1); + } + + function test_MintPreservesPreexistingControllerBalance() public { + deep.grantRole(deep.MINTER_ROLE(), address(this)); + deep.mint(address(minterController), 11); + + minterController.mint(mintRecipient, 100e18); + + assertEq(deep.balanceOf(address(minterController)), 11); + assertEq(deep.allowance(address(minterController), address(sablier)), 0); + } + + function test_RevertWhenCallerLacksControllerMinterRole() public { + vm.expectRevert(Ownable.Unauthorized.selector); + vm.prank(unauthorized); + minterController.mint(mintRecipient, 100e18); + } + + function test_OwnerCanGrantAndRevokeControllerMinterRole() public { + minterController.grantRoles(unauthorized, minterController.MINTER_ROLE()); + vm.prank(unauthorized); + minterController.mint(mintRecipient, 100e18); + + minterController.revokeRoles(unauthorized, minterController.MINTER_ROLE()); + vm.expectRevert(Ownable.Unauthorized.selector); + vm.prank(unauthorized); + minterController.mint(mintRecipient, 100e18); + } + + function test_ControllerMinterCanRenounceRole() public { + uint256 minterRole = minterController.MINTER_ROLE(); + minterController.grantRoles(unauthorized, minterRole); + + vm.prank(unauthorized); + minterController.renounceRoles(minterRole); + + assertFalse(minterController.hasAnyRole(unauthorized, minterRole)); + } + + function test_RevertWhenNonOwnerChangesMinterRole() public { + uint256 minterRole = minterController.MINTER_ROLE(); + vm.expectRevert(Ownable.Unauthorized.selector); + vm.prank(unauthorized); + minterController.grantRoles(unauthorized, minterRole); + } + + function test_RevertForZeroMintRecipientOrDustAmount() public { + vm.expectRevert(DeepstateMinterController.InvalidMintRecipient.selector); + minterController.mint(address(0), 100e18); + + vm.expectRevert(DeepstateMinterController.MintAmountTooSmall.selector); + minterController.mint(mintRecipient, 2); + + assertEq(deep.totalSupply(), 0); + assertEq(sablier.nextStreamId(), 1); + } + + function test_RevertWhenVestingAmountExceedsSablierUint128Limit() public { + uint256 amount = Math.mulDiv(uint256(type(uint128).max) + 1, 70_00, 30_00, Math.Rounding.Ceil); + uint256 vestingAmount = Math.mulDiv(amount, 30_00, 70_00); + + vm.expectRevert(abi.encodeWithSelector(DeepstateMinterController.VestingAmountTooLarge.selector, vestingAmount)); + minterController.mint(mintRecipient, amount); + } + + function test_MissingTokenMinterRoleRevertsAtomically() public { + deep.revokeRole(deep.MINTER_ROLE(), address(minterController)); + + vm.expectRevert( + abi.encodeWithSelector( + IAccessControl.AccessControlUnauthorizedAccount.selector, address(minterController), deep.MINTER_ROLE() + ) + ); + minterController.mint(mintRecipient, 100e18); + + assertEq(deep.totalSupply(), 0); + assertEq(sablier.nextStreamId(), 1); + } + + function test_SablierRevertRollsBackBothMints() public { + sablier.setRevertCreate(true); + + vm.expectRevert(MockSablierLockupLinearV4.CreateReverted.selector); + minterController.mint(mintRecipient, 100e18); + + assertEq(deep.totalSupply(), 0); + assertEq(deep.balanceOf(mintRecipient), 0); + assertEq(deep.balanceOf(address(minterController)), 0); + assertEq(deep.allowance(address(minterController), address(sablier)), 0); + assertEq(sablier.nextStreamId(), 1); + } + + function test_SablierCannotReenterEvenWhenIncorrectlyGrantedMinterRole() public { + minterController.grantRoles(address(sablier), minterController.MINTER_ROLE()); + sablier.setReentry( + address(minterController), abi.encodeCall(DeepstateMinterController.mint, (mintRecipient, 100e18)) + ); + + vm.expectRevert(ReentrancyGuard.Reentrancy.selector); + minterController.mint(mintRecipient, 100e18); + + assertEq(deep.totalSupply(), 0); + assertEq(sablier.nextStreamId(), 1); + } + + function testFuzz_MintMaintainsThirtyPercentOfCombinedIssuance(uint128 rawAmount) public { + uint256 maximumAmount = Math.mulDiv(MINT_CAP, 70_00, 100_00); + uint256 amount = bound(uint256(rawAmount), 3, maximumAmount); + uint256 expectedVesting = Math.mulDiv(amount, 30_00, 70_00); + + uint256 streamId = minterController.mint(mintRecipient, amount); + + assertEq(deep.balanceOf(mintRecipient), amount); + assertEq(deep.balanceOf(address(sablier)), expectedVesting); + assertEq(deep.totalSupply(), amount + expectedVesting); + assertEq(expectedVesting, Math.mulDiv(deep.totalSupply(), 30_00, 100_00)); + assertEq(sablier.stream(streamId).depositAmount, expectedVesting); + } + + function _lockSoleTokenAdministration() internal { + deep.grantRole(deep.DEFAULT_ADMIN_ROLE(), address(minterController)); + minterController.lockTokenAdministration(); + deep.renounceRole(deep.DEFAULT_ADMIN_ROLE(), address(this)); + + assertTrue(deep.hasRole(deep.DEFAULT_ADMIN_ROLE(), address(minterController))); + assertFalse(deep.hasRole(deep.DEFAULT_ADMIN_ROLE(), address(this))); + assertEq(deep.defaultAdminCount(), 1); + } + + function _newControllerWithCap(uint256 cap) internal returns (DeepstateMinterController controller) { + controller = new DeepstateMinterController(address(this), address(deep), address(sablier), recipient, cap); + deep.grantRole(deep.MINTER_ROLE(), address(controller)); + } +} diff --git a/test/DeepstateMinterControllerSablierIntegration.t.sol b/test/DeepstateMinterControllerSablierIntegration.t.sol new file mode 100644 index 0000000..947691a --- /dev/null +++ b/test/DeepstateMinterControllerSablierIntegration.t.sol @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity 0.8.28; + +import {Test} from "forge-std/Test.sol"; +import {SablierLockup} from "@sablier/lockup/src/SablierLockup.sol"; + +import {DeepstateMinterController} from "../src/DeepstateMinterController.sol"; +import {DeepstateToken} from "../src/DeepstateToken.sol"; + +contract SablierComptrollerStub { + function supportsInterface(bytes4) external pure returns (bool) { + return true; + } + + function calculateMinFeeWeiFor(uint8, address) external pure returns (uint256) { + return 0; + } +} + +contract DeepstateMinterControllerSablierIntegrationTest is Test { + uint256 internal constant MINT_CAP = 20_000_000_000e18; + + DeepstateToken internal deep; + DeepstateMinterController internal minterController; + SablierLockup internal sablier; + + address internal recipient = makeAddr("recipient"); + address internal mintRecipient = makeAddr("mintRecipient"); + + function setUp() public { + deep = new DeepstateToken(address(this), "Deepstate", "DEEP"); + SablierComptrollerStub comptroller = new SablierComptrollerStub(); + sablier = new SablierLockup(address(comptroller), address(0)); + minterController = + new DeepstateMinterController(address(this), address(deep), address(sablier), recipient, MINT_CAP); + + deep.grantRole(deep.MINTER_ROLE(), address(minterController)); + } + + function test_RealSablierV4StreamVestsLinearlyForOneYear() public { + uint40 startTime = uint40(block.timestamp); + uint128 vestingAmount = 30e18; + uint256 streamId = minterController.mint(mintRecipient, 70e18); + + assertEq(streamId, 1); + assertEq(sablier.ownerOf(streamId), recipient); + assertEq(sablier.getRecipient(streamId), recipient); + assertEq(sablier.getSender(streamId), address(minterController)); + assertEq(address(sablier.getUnderlyingToken(streamId)), address(deep)); + assertEq(sablier.getDepositedAmount(streamId), vestingAmount); + assertEq(sablier.getStartTime(streamId), startTime); + assertEq(sablier.getEndTime(streamId), startTime + 365 days); + assertEq(sablier.getCliffTime(streamId), 0); + assertEq(sablier.getGranularity(streamId), 1); + assertFalse(sablier.isCancelable(streamId)); + assertFalse(sablier.isTransferable(streamId)); + assertEq(sablier.streamedAmountOf(streamId), 0); + assertEq(deep.balanceOf(mintRecipient), 70e18); + assertEq(deep.balanceOf(address(sablier)), vestingAmount); + assertEq(deep.balanceOf(address(minterController)), 0); + assertEq(deep.allowance(address(minterController), address(sablier)), 0); + assertEq(deep.totalSupply(), 70e18 + vestingAmount); + + vm.warp(startTime + 365 days / 2); + assertEq(sablier.streamedAmountOf(streamId), vestingAmount / 2); + vm.prank(recipient); + uint128 firstWithdrawal = sablier.withdrawMax(streamId, recipient); + assertEq(firstWithdrawal, vestingAmount / 2); + assertEq(deep.balanceOf(recipient), vestingAmount / 2); + + vm.warp(startTime + 365 days); + assertEq(sablier.streamedAmountOf(streamId), vestingAmount); + vm.prank(recipient); + uint128 finalWithdrawal = sablier.withdrawMax(streamId, recipient); + assertEq(finalWithdrawal, vestingAmount / 2); + assertEq(deep.balanceOf(recipient), vestingAmount); + assertEq(deep.balanceOf(address(sablier)), 0); + assertTrue(sablier.isDepleted(streamId)); + } + + function test_RealSablierV4StreamCannotBeCanceledOrTransferred() public { + uint256 streamId = minterController.mint(mintRecipient, 70e18); + + vm.expectRevert(); + sablier.cancel(streamId); + + vm.expectRevert(); + vm.prank(recipient); + sablier.transferFrom(recipient, mintRecipient, streamId); + + assertEq(sablier.ownerOf(streamId), recipient); + assertEq(deep.balanceOf(address(sablier)), 30e18); + } + + function test_RealSablierConsumesOnlyNewlyMintedVestingAmount() public { + uint256 preexistingBalance = 11e18; + deep.grantRole(deep.MINTER_ROLE(), address(this)); + deep.mint(address(minterController), preexistingBalance); + + uint256 streamId = minterController.mint(mintRecipient, 70e18); + + assertEq(sablier.getDepositedAmount(streamId), 30e18); + assertEq(deep.balanceOf(address(sablier)), 30e18); + assertEq(deep.balanceOf(address(minterController)), preexistingBalance); + assertEq(deep.allowance(address(minterController), address(sablier)), 0); + assertEq(deep.totalSupply(), preexistingBalance + 100e18); + } +} diff --git a/test/DeepstateRewarderFactory.t.sol b/test/DeepstateRewarderFactory.t.sol new file mode 100644 index 0000000..53987d8 --- /dev/null +++ b/test/DeepstateRewarderFactory.t.sol @@ -0,0 +1,572 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; +import {Test} from "forge-std/Test.sol"; +import {Ownable} from "solady/auth/Ownable.sol"; +import {DeepstateV1} from "deepstate-contracts/DeepstateV1.sol"; + +import {DeepstateRewarder} from "../src/DeepstateRewarder.sol"; +import {DeepstateMinterController} from "../src/DeepstateMinterController.sol"; +import {DeepstateRewarderFactory} from "../src/DeepstateRewarderFactory.sol"; +import {DeepstateRewarderV2} from "../src/DeepstateRewarderV2.sol"; +import {DeepstateV1Controller} from "../src/DeepstateV1Controller.sol"; +import {DeepstateToken} from "../src/DeepstateToken.sol"; +import {IDeepstateMinterController} from "../src/interfaces/IDeepstateMinterController.sol"; +import {MockSablierLockupLinearV4} from "./mocks/MockSablierLockupLinearV4.sol"; + +contract InvalidRewardTokenMinterController is IDeepstateMinterController { + address internal immutable _deepstateToken; + address internal immutable _owner; + + constructor(address deepstateToken_) { + _deepstateToken = deepstateToken_; + _owner = msg.sender; + } + + function owner() external view returns (address) { + return _owner; + } + + function deepstateToken() external view returns (address) { + return _deepstateToken; + } + + function mint(address, uint256) external pure returns (uint256) { + return 0; + } +} + +contract DeepstateRewarderFactoryTest is Test { + address internal constant TOKEN_A = address(0x1000); + address internal constant TOKEN_B = address(0x2000); + address internal constant TOKEN_C = address(0x3000); + address internal constant TOKEN_D = address(0x4000); + + DeepstateToken internal deep; + DeepstateV1 internal deepstate; + DeepstateV1Controller internal deepstateV1Controller; + DeepstateMinterController internal minterController; + DeepstateRewarderFactory internal factory; + MockSablierLockupLinearV4 internal sablier; + + address internal operator = makeAddr("operator"); + address internal alice = makeAddr("alice"); + address internal vestingRecipient = makeAddr("vestingRecipient"); + + function setUp() public { + vm.warp(1_000_000); + + deep = new DeepstateToken(address(this), "Deepstate", "DEEP"); + sablier = new MockSablierLockupLinearV4(); + deepstate = new DeepstateV1(); + deepstateV1Controller = new DeepstateV1Controller(address(this), address(deepstate)); + minterController = _newMinterController(address(this), deep); + factory = new DeepstateRewarderFactory(address(this), address(deepstateV1Controller), address(minterController)); + + minterController.grantRoles(address(factory), minterController.MINTER_ROLE()); + deepstate.transferOwnership(address(deepstateV1Controller)); + deepstateV1Controller.grantRoles(address(factory), deepstateV1Controller.HOOK_MANAGER_ROLE()); + factory.setOperator(operator); + } + + function test_ImmutableConfigurationAndInitialAuthority() public view { + assertEq(factory.owner(), address(this)); + assertEq(factory.operator(), operator); + assertEq(address(factory.deepstateV1Controller()), address(deepstateV1Controller)); + assertEq(address(factory.deepstate()), address(deepstate)); + assertEq(address(factory.minterController()), address(minterController)); + assertEq(address(factory.rewardToken()), address(deep)); + assertEq(factory.DEPLOYMENT_COOLDOWN(), 3 days); + assertEq(factory.EMISSION_DURATION(), 395 days); + assertEq(factory.SIDE_EMISSION_CAP(), 500_000_000e18); + assertEq(factory.INITIAL_FUNDING(), 100_000_000e18); + assertTrue(deep.hasRole(deep.MINTER_ROLE(), address(minterController))); + assertFalse(deep.hasRole(deep.MINTER_ROLE(), address(factory))); + assertTrue(minterController.hasAnyRole(address(factory), minterController.MINTER_ROLE())); + assertEq(deepstate.owner(), address(deepstateV1Controller)); + assertTrue(deepstateV1Controller.hasAnyRole(address(factory), deepstateV1Controller.HOOK_MANAGER_ROLE())); + assertEq(factory.nextDeploymentAt(), 0); + } + + function test_ConstructorValidation() public { + vm.expectRevert(DeepstateRewarderFactory.InvalidOwner.selector); + new DeepstateRewarderFactory(address(0), address(deepstateV1Controller), address(minterController)); + + vm.expectRevert(DeepstateRewarderFactory.InvalidDeepstateV1Controller.selector); + new DeepstateRewarderFactory(address(this), address(0), address(minterController)); + + vm.expectRevert(DeepstateRewarderFactory.InvalidDeepstateV1Controller.selector); + new DeepstateRewarderFactory(address(this), alice, address(minterController)); + + DeepstateV1Controller independentlyOwnedController = new DeepstateV1Controller(alice, address(deepstate)); + DeepstateRewarderFactory independentlyOwnedControllerFactory = new DeepstateRewarderFactory( + address(this), address(independentlyOwnedController), address(minterController) + ); + assertEq(independentlyOwnedControllerFactory.owner(), address(this)); + assertEq(independentlyOwnedController.owner(), alice); + + vm.expectRevert(DeepstateRewarderFactory.InvalidMinterController.selector); + new DeepstateRewarderFactory(address(this), address(deepstateV1Controller), address(0)); + + vm.expectRevert(DeepstateRewarderFactory.InvalidMinterController.selector); + new DeepstateRewarderFactory(address(this), address(deepstateV1Controller), alice); + + DeepstateMinterController mismatchedMinter = _newMinterController(alice, deep); + vm.expectRevert( + abi.encodeWithSelector( + DeepstateRewarderFactory.MinterControllerOwnerMismatch.selector, address(this), alice + ) + ); + new DeepstateRewarderFactory(address(this), address(deepstateV1Controller), address(mismatchedMinter)); + + InvalidRewardTokenMinterController zeroTokenController = new InvalidRewardTokenMinterController(address(0)); + vm.expectRevert(DeepstateRewarderFactory.InvalidRewardToken.selector); + new DeepstateRewarderFactory(address(this), address(deepstateV1Controller), address(zeroTokenController)); + + InvalidRewardTokenMinterController eoaTokenController = new InvalidRewardTokenMinterController(alice); + vm.expectRevert(DeepstateRewarderFactory.InvalidRewardToken.selector); + new DeepstateRewarderFactory(address(this), address(deepstateV1Controller), address(eoaTokenController)); + } + + function test_GovernanceOwnerIsIndependentFromFactoryDeployer() public { + address governance = makeAddr("governance"); + DeepstateToken secondToken = new DeepstateToken(address(this), "Second", "SECOND"); + DeepstateV1 secondDeepstate = new DeepstateV1(); + DeepstateV1Controller secondDeepstateV1Controller = + new DeepstateV1Controller(governance, address(secondDeepstate)); + DeepstateMinterController secondMinterController = _newMinterController(governance, secondToken); + DeepstateRewarderFactory secondFactory = new DeepstateRewarderFactory( + governance, address(secondDeepstateV1Controller), address(secondMinterController) + ); + secondDeepstate.transferOwnership(address(secondDeepstateV1Controller)); + + vm.expectRevert(Ownable.Unauthorized.selector); + secondFactory.setOperator(operator); + + vm.startPrank(governance); + secondMinterController.grantRoles(address(secondFactory), secondMinterController.MINTER_ROLE()); + secondDeepstateV1Controller.grantRoles(address(secondFactory), secondDeepstateV1Controller.HOOK_MANAGER_ROLE()); + secondFactory.setOperator(operator); + vm.stopPrank(); + vm.prank(operator); + DeepstateRewarderV2 rewarder = secondFactory.deployMarket(_market(TOKEN_A, TOKEN_B)); + + assertEq(secondFactory.owner(), governance); + assertEq(rewarder.owner(), address(secondFactory)); + assertEq(secondToken.balanceOf(address(rewarder)), 100_000_000e18); + assertEq(secondToken.balanceOf(address(sablier)), _vestingAllocation(100_000_000e18)); + } + + function test_OperatorDeploysMarketWithFixedScheduleAndFunding() public { + DeepstateRewarderFactory.MarketConfig memory config = _market(TOKEN_A, TOKEN_B); + bytes32 poolId = _poolId(TOKEN_A, TOKEN_B); + + vm.expectEmit(true, false, false, true, address(factory)); + emit DeepstateRewarderFactory.MarketDeployed(poolId, address(0), TOKEN_A, TOKEN_B, true, true); + vm.prank(operator); + DeepstateRewarderV2 rewarder = factory.deployMarket(config); + + assertGt(address(rewarder).code.length, 0); + assertEq(rewarder.owner(), address(factory)); + assertEq(rewarder.deepstate(), address(deepstate)); + assertEq(rewarder.rewardToken(), address(deep)); + assertEq(rewarder.poolId(), poolId); + assertEq(rewarder.token0(), TOKEN_A); + assertEq(rewarder.token1(), TOKEN_B); + assertEq(rewarder.sideEmissionCap(), 500_000_000e18); + assertEq(rewarder.emissionDuration(), 395 days); + assertEq(rewarder.token0StartQuantity(), 1e18); + assertEq(rewarder.token0MaxQuantity(), 5_000e18); + assertEq(rewarder.token1StartQuantity(), 1e6); + assertEq(rewarder.token1MaxQuantity(), 1_000_000e6); + assertEq(deep.balanceOf(address(rewarder)), 100_000_000e18); + assertEq(deep.balanceOf(address(sablier)), _vestingAllocation(100_000_000e18)); + assertEq(deep.totalSupply(), 100_000_000e18 + _vestingAllocation(100_000_000e18)); + assertEq(deepstate.poolHook(poolId), address(rewarder)); + assertEq(factory.activeRewarder(poolId), address(rewarder)); + assertEq(factory.rewarderPool(address(rewarder)), poolId); + assertEq(factory.nextDeploymentAt(), block.timestamp + 3 days); + } + + function test_DeploymentCooldownIsGlobalAndAllowsExactBoundary() public { + vm.prank(operator); + factory.deployMarket(_market(TOKEN_A, TOKEN_B)); + + uint256 next = factory.nextDeploymentAt(); + vm.expectRevert(abi.encodeWithSelector(DeepstateRewarderFactory.DeploymentCooldown.selector, next)); + vm.prank(operator); + factory.deployMarket(_market(TOKEN_C, TOKEN_D)); + + vm.warp(next - 1); + vm.expectRevert(abi.encodeWithSelector(DeepstateRewarderFactory.DeploymentCooldown.selector, next)); + vm.prank(operator); + factory.deployMarket(_market(TOKEN_C, TOKEN_D)); + + vm.warp(next); + vm.prank(operator); + DeepstateRewarderV2 second = factory.deployMarket(_market(TOKEN_C, TOKEN_D)); + + assertEq(deep.balanceOf(address(second)), 100_000_000e18); + assertEq(deep.balanceOf(address(sablier)), 2 * _vestingAllocation(100_000_000e18)); + assertEq(deep.totalSupply(), 200_000_000e18 + 2 * _vestingAllocation(100_000_000e18)); + } + + function test_GovernanceCanDeployWithoutOperatorButStillObeysCooldown() public { + factory.setOperator(address(0)); + DeepstateRewarderV2 rewarder = factory.deployMarket(_market(TOKEN_A, TOKEN_B)); + assertEq(rewarder.owner(), address(factory)); + + uint256 next = factory.nextDeploymentAt(); + vm.expectRevert(abi.encodeWithSelector(DeepstateRewarderFactory.DeploymentCooldown.selector, next)); + factory.deployMarket(_market(TOKEN_C, TOKEN_D)); + } + + function test_GovernanceCanRevokeOperatorImmediately() public { + vm.expectEmit(true, true, false, false, address(factory)); + emit DeepstateRewarderFactory.OperatorSet(operator, address(0)); + factory.setOperator(address(0)); + + vm.expectRevert(Ownable.Unauthorized.selector); + vm.prank(operator); + factory.deployMarket(_market(TOKEN_A, TOKEN_B)); + + assertEq(factory.operator(), address(0)); + assertEq(deep.totalSupply(), 0); + } + + function test_OnlyGovernanceCanSetOperator() public { + vm.expectRevert(Ownable.Unauthorized.selector); + vm.prank(operator); + factory.setOperator(alice); + + vm.expectRevert(Ownable.Unauthorized.selector); + vm.prank(alice); + factory.setOperator(alice); + + assertEq(factory.operator(), operator); + } + + function test_OperatorCanRemoveMarketAndBurnAllRemainingFunding() public { + DeepstateRewarderFactory.MarketConfig memory config = _market(TOKEN_A, TOKEN_B); + vm.prank(operator); + DeepstateRewarderV2 rewarder = factory.deployMarket(config); + bytes32 poolId = _poolId(TOKEN_A, TOKEN_B); + + vm.expectEmit(false, false, false, true, address(rewarder)); + emit DeepstateRewarderV2.RewardBalanceBurned(100_000_000e18); + vm.expectEmit(true, true, false, false, address(factory)); + emit DeepstateRewarderFactory.MarketRemoved(poolId, address(rewarder)); + vm.prank(operator); + factory.removeMarket(TOKEN_A, TOKEN_B); + + assertEq(deepstate.poolHook(poolId), address(0)); + assertEq(factory.activeRewarder(poolId), address(0)); + assertEq(factory.rewarderPool(address(rewarder)), bytes32(0)); + assertEq(deep.balanceOf(address(rewarder)), 0); + assertEq(deep.balanceOf(address(factory)), 0); + assertEq(deep.balanceOf(address(sablier)), _vestingAllocation(100_000_000e18)); + assertEq(deep.totalSupply(), _vestingAllocation(100_000_000e18)); + } + + function test_RemovalBurnsLiveBalanceAfterPriorClaim() public { + vm.prank(operator); + DeepstateRewarderV2 rewarder = factory.deployMarket(_market(TOKEN_A, TOKEN_B)); + uint256 claimed = 25_000_000e18; + + vm.prank(address(rewarder)); + deep.transfer(alice, claimed); + + vm.prank(operator); + factory.removeMarket(TOKEN_A, TOKEN_B); + + assertEq(deep.balanceOf(address(rewarder)), 0); + assertEq(deep.balanceOf(alice), claimed); + assertEq(deep.balanceOf(address(sablier)), _vestingAllocation(100_000_000e18)); + assertEq(deep.totalSupply(), claimed + _vestingAllocation(100_000_000e18)); + } + + function test_OperatorCannotBurnRewarderDirectly() public { + vm.prank(operator); + DeepstateRewarderV2 rewarder = factory.deployMarket(_market(TOKEN_A, TOKEN_B)); + + vm.expectRevert(Ownable.Unauthorized.selector); + vm.prank(operator); + rewarder.burnBalance(); + + assertEq(deep.balanceOf(address(rewarder)), 100_000_000e18); + assertEq(deep.balanceOf(operator), 0); + } + + function test_RemovalBurnsGovernanceTopUpAlongWithInitialFunding() public { + vm.prank(operator); + DeepstateRewarderV2 rewarder = factory.deployMarket(_market(TOKEN_A, TOKEN_B)); + + minterController.mint(address(rewarder), 900_000_000e18); + assertEq(deep.balanceOf(address(rewarder)), 1_000_000_000e18); + uint256 vested = _vestingAllocation(100_000_000e18) + _vestingAllocation(900_000_000e18); + assertEq(deep.balanceOf(address(sablier)), vested); + + vm.prank(operator); + factory.removeMarket(TOKEN_A, TOKEN_B); + + assertEq(deep.totalSupply(), vested); + } + + function test_GovernanceCanRemoveMarketAfterRevokingOperator() public { + vm.prank(operator); + factory.deployMarket(_market(TOKEN_A, TOKEN_B)); + factory.setOperator(address(0)); + + factory.removeMarket(TOKEN_A, TOKEN_B); + + assertEq(deep.totalSupply(), _vestingAllocation(100_000_000e18)); + } + + function test_RemovedPoolCanBeResetWithFreshAddressAfterCooldown() public { + DeepstateRewarderFactory.MarketConfig memory config = _market(TOKEN_A, TOKEN_B); + vm.startPrank(operator); + DeepstateRewarderV2 first = factory.deployMarket(config); + factory.removeMarket(TOKEN_A, TOKEN_B); + + uint256 next = factory.nextDeploymentAt(); + vm.expectRevert(abi.encodeWithSelector(DeepstateRewarderFactory.DeploymentCooldown.selector, next)); + factory.deployMarket(config); + + vm.warp(next); + DeepstateRewarderV2 second = factory.deployMarket(config); + vm.stopPrank(); + + assertNotEq(address(first), address(second)); + assertEq(factory.activeRewarder(_poolId(TOKEN_A, TOKEN_B)), address(second)); + assertEq(deep.balanceOf(address(second)), 100_000_000e18); + assertEq(deep.balanceOf(address(sablier)), 2 * _vestingAllocation(100_000_000e18)); + assertEq(deep.totalSupply(), 100_000_000e18 + 2 * _vestingAllocation(100_000_000e18)); + } + + function test_CannotDeployOverActiveFactoryMarketOrExistingDeepstateV1Hook() public { + DeepstateRewarderFactory.MarketConfig memory config = _market(TOKEN_A, TOKEN_B); + vm.prank(operator); + DeepstateRewarderV2 rewarder = factory.deployMarket(config); + vm.warp(factory.nextDeploymentAt()); + + bytes32 poolId = _poolId(TOKEN_A, TOKEN_B); + vm.expectRevert( + abi.encodeWithSelector(DeepstateRewarderFactory.ActiveMarketExists.selector, poolId, address(rewarder)) + ); + vm.prank(operator); + factory.deployMarket(config); + + DeepstateV1 secondDeepstate = new DeepstateV1(); + DeepstateV1Controller secondDeepstateV1Controller = + new DeepstateV1Controller(address(this), address(secondDeepstate)); + DeepstateRewarderFactory secondFactory = new DeepstateRewarderFactory( + address(this), address(secondDeepstateV1Controller), address(minterController) + ); + secondDeepstate.setPoolHookConfig(TOKEN_C, TOKEN_D, alice, true, false); + secondDeepstate.transferOwnership(address(secondDeepstateV1Controller)); + minterController.grantRoles(address(secondFactory), minterController.MINTER_ROLE()); + secondDeepstateV1Controller.grantRoles(address(secondFactory), secondDeepstateV1Controller.HOOK_MANAGER_ROLE()); + secondFactory.setOperator(operator); + + bytes32 secondPoolId = _poolId(TOKEN_C, TOKEN_D); + vm.expectRevert(abi.encodeWithSelector(DeepstateRewarderFactory.ExistingPoolHook.selector, secondPoolId, alice)); + vm.prank(operator); + secondFactory.deployMarket(_market(TOKEN_C, TOKEN_D)); + + assertEq(deep.balanceOf(address(secondFactory)), 0); + } + + function test_DeploymentWithoutMinterRoleRevertsAtomically() public { + DeepstateToken secondToken = new DeepstateToken(address(this), "Second", "SECOND"); + DeepstateV1 secondDeepstate = new DeepstateV1(); + DeepstateV1Controller secondDeepstateV1Controller = + new DeepstateV1Controller(address(this), address(secondDeepstate)); + DeepstateMinterController secondMinterController = _newMinterController(address(this), secondToken); + DeepstateRewarderFactory secondFactory = new DeepstateRewarderFactory( + address(this), address(secondDeepstateV1Controller), address(secondMinterController) + ); + secondDeepstate.transferOwnership(address(secondDeepstateV1Controller)); + secondDeepstateV1Controller.grantRoles(address(secondFactory), secondDeepstateV1Controller.HOOK_MANAGER_ROLE()); + secondFactory.setOperator(operator); + + DeepstateRewarderFactory.MarketConfig memory config = _market(TOKEN_A, TOKEN_B); + + vm.expectRevert(Ownable.Unauthorized.selector); + vm.prank(operator); + secondFactory.deployMarket(config); + + assertEq(secondFactory.nextDeploymentAt(), 0); + assertEq(secondToken.totalSupply(), 0); + assertEq(secondDeepstate.poolHook(_poolId(TOKEN_A, TOKEN_B)), address(0)); + } + + function test_DeploymentWithoutDeepstateV1OwnershipRevertsAtomically() public { + DeepstateToken secondToken = new DeepstateToken(address(this), "Second", "SECOND"); + DeepstateV1 secondDeepstate = new DeepstateV1(); + DeepstateV1Controller secondDeepstateV1Controller = + new DeepstateV1Controller(address(this), address(secondDeepstate)); + DeepstateMinterController secondMinterController = _newMinterController(address(this), secondToken); + DeepstateRewarderFactory secondFactory = new DeepstateRewarderFactory( + address(this), address(secondDeepstateV1Controller), address(secondMinterController) + ); + secondMinterController.grantRoles(address(secondFactory), secondMinterController.MINTER_ROLE()); + secondDeepstateV1Controller.grantRoles(address(secondFactory), secondDeepstateV1Controller.HOOK_MANAGER_ROLE()); + secondFactory.setOperator(operator); + + DeepstateRewarderFactory.MarketConfig memory config = _market(TOKEN_A, TOKEN_B); + + vm.expectRevert(Ownable.Unauthorized.selector); + vm.prank(operator); + secondFactory.deployMarket(config); + + assertEq(secondFactory.nextDeploymentAt(), 0); + assertEq(secondToken.totalSupply(), 0); + } + + function test_DeploymentWithoutDelegatedHookPermissionRevertsAtomically() public { + DeepstateToken secondToken = new DeepstateToken(address(this), "Second", "SECOND"); + DeepstateV1 secondDeepstate = new DeepstateV1(); + DeepstateV1Controller secondDeepstateV1Controller = + new DeepstateV1Controller(address(this), address(secondDeepstate)); + DeepstateMinterController secondMinterController = _newMinterController(address(this), secondToken); + DeepstateRewarderFactory secondFactory = new DeepstateRewarderFactory( + address(this), address(secondDeepstateV1Controller), address(secondMinterController) + ); + secondMinterController.grantRoles(address(secondFactory), secondMinterController.MINTER_ROLE()); + secondDeepstate.transferOwnership(address(secondDeepstateV1Controller)); + secondFactory.setOperator(operator); + + DeepstateRewarderFactory.MarketConfig memory config = _market(TOKEN_A, TOKEN_B); + + vm.expectRevert(Ownable.Unauthorized.selector); + vm.prank(operator); + secondFactory.deployMarket(config); + + assertEq(secondFactory.nextDeploymentAt(), 0); + assertEq(secondToken.totalSupply(), 0); + assertEq(secondDeepstate.poolHook(_poolId(TOKEN_A, TOKEN_B)), address(0)); + } + + function test_GovernanceCanCleanUpAfterRevokingFactoryHookPermission() public { + vm.prank(operator); + DeepstateRewarderV2 rewarder = factory.deployMarket(_market(TOKEN_A, TOKEN_B)); + bytes32 poolId = _poolId(TOKEN_A, TOKEN_B); + deepstateV1Controller.revokeRoles(address(factory), deepstateV1Controller.HOOK_MANAGER_ROLE()); + + vm.expectRevert(Ownable.Unauthorized.selector); + vm.prank(operator); + factory.removeMarket(TOKEN_A, TOKEN_B); + assertEq(factory.activeRewarder(poolId), address(rewarder)); + assertEq(deep.balanceOf(address(rewarder)), 100_000_000e18); + + deepstateV1Controller.setPoolHookConfig(TOKEN_A, TOKEN_B, address(0), false, false); + factory.removeMarket(TOKEN_A, TOKEN_B); + + assertEq(factory.activeRewarder(poolId), address(0)); + assertEq(deep.balanceOf(address(rewarder)), 0); + assertEq(deep.totalSupply(), _vestingAllocation(100_000_000e18)); + } + + function test_RemovalRejectsUnexpectedReplacementHook() public { + vm.prank(operator); + DeepstateRewarderV2 rewarder = factory.deployMarket(_market(TOKEN_A, TOKEN_B)); + bytes32 poolId = _poolId(TOKEN_A, TOKEN_B); + deepstateV1Controller.setPoolHookConfig(TOKEN_A, TOKEN_B, alice, true, true); + + vm.expectRevert( + abi.encodeWithSelector( + DeepstateRewarderFactory.UnexpectedPoolHook.selector, poolId, address(rewarder), alice + ) + ); + vm.prank(operator); + factory.removeMarket(TOKEN_A, TOKEN_B); + + assertEq(deepstate.poolHook(poolId), alice); + assertEq(factory.activeRewarder(poolId), address(rewarder)); + assertEq(deep.balanceOf(address(rewarder)), 100_000_000e18); + } + + function test_InvalidMarketConfigurationRevertsBeforeDeployment() public { + DeepstateRewarderFactory.MarketConfig memory config = _market(TOKEN_B, TOKEN_A); + vm.expectRevert(DeepstateRewarderFactory.InvalidPool.selector); + vm.prank(operator); + factory.deployMarket(config); + + config = _market(TOKEN_A, TOKEN_B); + config.token0Active = false; + config.token1Active = false; + vm.expectRevert(DeepstateRewarderFactory.InvalidHookFlags.selector); + vm.prank(operator); + factory.deployMarket(config); + + config = _market(TOKEN_A, TOKEN_B); + config.token0StartQuantity = 0; + vm.expectRevert(DeepstateRewarder.InvalidQuantitySchedule.selector); + vm.prank(operator); + factory.deployMarket(config); + + assertEq(factory.nextDeploymentAt(), 0); + assertEq(deep.totalSupply(), 0); + } + + function test_UnauthorizedAccountCannotDeployOrRemoveMarket() public { + vm.expectRevert(Ownable.Unauthorized.selector); + vm.prank(alice); + factory.deployMarket(_market(TOKEN_A, TOKEN_B)); + + vm.prank(operator); + factory.deployMarket(_market(TOKEN_A, TOKEN_B)); + + vm.expectRevert(Ownable.Unauthorized.selector); + vm.prank(alice); + factory.removeMarket(TOKEN_A, TOKEN_B); + } + + function test_RemoveUnknownOrUnsortedMarketReverts() public { + vm.expectRevert( + abi.encodeWithSelector(DeepstateRewarderFactory.MarketNotActive.selector, _poolId(TOKEN_A, TOKEN_B)) + ); + vm.prank(operator); + factory.removeMarket(TOKEN_A, TOKEN_B); + + vm.expectRevert(DeepstateRewarderFactory.InvalidPool.selector); + vm.prank(operator); + factory.removeMarket(TOKEN_B, TOKEN_A); + } + + function _market(address token0, address token1) + internal + pure + returns (DeepstateRewarderFactory.MarketConfig memory config) + { + config = DeepstateRewarderFactory.MarketConfig({ + token0: token0, + token1: token1, + token0StartQuantity: 1e18, + token0MaxQuantity: 5_000e18, + token1StartQuantity: 1e6, + token1MaxQuantity: 1_000_000e6, + token0Active: true, + token1Active: true + }); + } + + function _poolId(address token0, address token1) internal pure returns (bytes32) { + return keccak256(abi.encode(token0, token1)); + } + + function _vestingAllocation(uint256 primaryAmount) internal pure returns (uint256) { + return Math.mulDiv(primaryAmount, 30_00, 70_00); + } + + function _newMinterController(address admin, DeepstateToken token) + internal + returns (DeepstateMinterController controller_) + { + controller_ = new DeepstateMinterController( + admin, address(token), address(sablier), vestingRecipient, 20_000_000_000e18 + ); + token.grantRole(token.MINTER_ROLE(), address(controller_)); + } +} diff --git a/test/DeepstateRewarderV2.t.sol b/test/DeepstateRewarderV2.t.sol new file mode 100644 index 0000000..ca0c38e --- /dev/null +++ b/test/DeepstateRewarderV2.t.sol @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {Test} from "forge-std/Test.sol"; +import {Ownable} from "solady/auth/Ownable.sol"; + +import {DeepstateRewarderV2} from "../src/DeepstateRewarderV2.sol"; +import {DeepstateToken} from "../src/DeepstateToken.sol"; + +contract DeepstateRewarderV2Test is Test { + uint96 internal constant SIDE_CAP = 500_000_000e18; + address internal constant DEEPSTATE = address(0x1000); + address internal constant TOKEN0 = address(0x2000); + address internal constant TOKEN1 = address(0x3000); + + DeepstateToken internal rewardToken; + DeepstateRewarderV2 internal rewarder; + address internal alice = makeAddr("alice"); + + function setUp() public { + rewardToken = new DeepstateToken(address(this), "Reward", "RWD"); + rewardToken.grantRole(rewardToken.MINTER_ROLE(), address(this)); + rewarder = new DeepstateRewarderV2( + address(this), + DEEPSTATE, + address(rewardToken), + keccak256(abi.encode(TOKEN0, TOKEN1)), + TOKEN0, + TOKEN1, + SIDE_CAP, + 395 days, + 1e18, + 5_000e18, + 1e6, + 1_000_000e6 + ); + rewardToken.mint(address(rewarder), uint256(SIDE_CAP) * 2); + } + + function test_InheritsRewarderConfiguration() public view { + assertEq(rewarder.owner(), address(this)); + assertEq(rewarder.deepstate(), DEEPSTATE); + assertEq(rewarder.rewardToken(), address(rewardToken)); + assertEq(rewarder.token0(), TOKEN0); + assertEq(rewarder.token1(), TOKEN1); + assertEq(rewarder.sideEmissionCap(), SIDE_CAP); + } + + function test_OwnerCanBurnEntireLiveRewardBalance() public { + uint256 funding = rewardToken.balanceOf(address(rewarder)); + + vm.expectEmit(false, false, false, true, address(rewarder)); + emit DeepstateRewarderV2.RewardBalanceBurned(funding); + rewarder.burnBalance(); + + assertEq(rewardToken.balanceOf(address(rewarder)), 0); + assertEq(rewardToken.totalSupply(), 0); + } + + function test_NonOwnerCannotBurnRewardBalance() public { + uint256 fundingBefore = rewardToken.balanceOf(address(rewarder)); + + vm.expectRevert(Ownable.Unauthorized.selector); + vm.prank(alice); + rewarder.burnBalance(); + + assertEq(rewardToken.balanceOf(address(rewarder)), fundingBefore); + assertEq(rewardToken.totalSupply(), fundingBefore); + } + + function test_BurnBalanceEmitsZeroForEmptyRewarder() public { + uint256 funding = rewardToken.balanceOf(address(rewarder)); + rewarder.burnBalance(); + + vm.expectEmit(false, false, false, true, address(rewarder)); + emit DeepstateRewarderV2.RewardBalanceBurned(0); + rewarder.burnBalance(); + + assertEq(funding, uint256(SIDE_CAP) * 2); + assertEq(rewardToken.balanceOf(address(rewarder)), 0); + assertEq(rewardToken.totalSupply(), 0); + } +} diff --git a/test/DeepstateV1Controller.t.sol b/test/DeepstateV1Controller.t.sol new file mode 100644 index 0000000..8c08086 --- /dev/null +++ b/test/DeepstateV1Controller.t.sol @@ -0,0 +1,174 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {Test} from "forge-std/Test.sol"; +import {Ownable} from "solady/auth/Ownable.sol"; +import {DeepstateV1} from "deepstate-contracts/DeepstateV1.sol"; + +import {DeepstateController} from "../src/DeepstateController.sol"; +import {DeepstateV1Controller} from "../src/DeepstateV1Controller.sol"; + +contract DeepstateV1ControllerTest is Test { + address internal constant TOKEN0 = address(0x1000); + address internal constant TOKEN1 = address(0x2000); + + DeepstateV1 internal deepstate; + DeepstateV1Controller internal controller; + + address internal hookManager = makeAddr("hookManager"); + address internal hook = makeAddr("hook"); + address internal alice = makeAddr("alice"); + address internal feeRecipient = makeAddr("feeRecipient"); + + function setUp() public { + deepstate = new DeepstateV1(); + controller = new DeepstateV1Controller(address(this), address(deepstate)); + deepstate.transferOwnership(address(controller)); + } + + function test_ImmutableConfiguration() public view { + assertEq(controller.owner(), address(this)); + assertEq(address(controller.deepstate()), address(deepstate)); + assertEq(controller.HOOK_MANAGER_ROLE(), 1); + assertEq(controller.rolesOf(address(this)), 0); + assertFalse(controller.hasAnyRole(hookManager, controller.HOOK_MANAGER_ROLE())); + assertEq(deepstate.owner(), address(controller)); + } + + function test_ConstructorValidation() public { + vm.expectRevert(DeepstateController.InvalidOwner.selector); + new DeepstateV1Controller(address(0), address(deepstate)); + + vm.expectRevert(DeepstateV1Controller.InvalidDeepstate.selector); + new DeepstateV1Controller(address(this), address(0)); + + vm.expectRevert(DeepstateV1Controller.InvalidDeepstate.selector); + new DeepstateV1Controller(address(this), alice); + } + + function test_GovernanceCanGrantAndRevokeHookManagerRole() public { + controller.grantRoles(hookManager, controller.HOOK_MANAGER_ROLE()); + assertTrue(controller.hasAnyRole(hookManager, controller.HOOK_MANAGER_ROLE())); + + controller.revokeRoles(hookManager, controller.HOOK_MANAGER_ROLE()); + assertFalse(controller.hasAnyRole(hookManager, controller.HOOK_MANAGER_ROLE())); + } + + function test_OnlyGovernanceCanGrantOrRevokeHookManagerRole() public { + uint256 hookManagerRole = controller.HOOK_MANAGER_ROLE(); + + vm.expectRevert(Ownable.Unauthorized.selector); + vm.prank(hookManager); + controller.grantRoles(hookManager, hookManagerRole); + + vm.expectRevert(Ownable.Unauthorized.selector); + vm.prank(alice); + controller.revokeRoles(hookManager, hookManagerRole); + } + + function test_HookManagerCanConfigurePoolHook() public { + controller.grantRoles(hookManager, controller.HOOK_MANAGER_ROLE()); + + vm.prank(hookManager); + controller.setPoolHookConfig(TOKEN0, TOKEN1, hook, true, false); + + assertEq(deepstate.poolHook(_poolId()), hook); + } + + function test_HookManagerRolesAreIndependent() public { + uint256 hookManagerRole = controller.HOOK_MANAGER_ROLE(); + controller.grantRoles(hookManager, hookManagerRole); + controller.grantRoles(alice, hookManagerRole); + controller.revokeRoles(hookManager, hookManagerRole); + + vm.expectRevert(Ownable.Unauthorized.selector); + vm.prank(hookManager); + controller.setPoolHookConfig(TOKEN0, TOKEN1, hook, true, false); + + vm.prank(alice); + controller.setPoolHookConfig(TOKEN0, TOKEN1, hook, false, true); + assertEq(deepstate.poolHook(_poolId()), hook); + } + + function test_GovernanceCanConfigurePoolHookWithoutManager() public { + controller.setPoolHookConfig(TOKEN0, TOKEN1, hook, false, true); + assertEq(deepstate.poolHook(_poolId()), hook); + + controller.setPoolHookConfig(TOKEN0, TOKEN1, address(0), false, false); + assertEq(deepstate.poolHook(_poolId()), address(0)); + } + + function test_RevokedHookManagerImmediatelyLosesHookAccess() public { + controller.grantRoles(hookManager, controller.HOOK_MANAGER_ROLE()); + controller.revokeRoles(hookManager, controller.HOOK_MANAGER_ROLE()); + + vm.expectRevert(Ownable.Unauthorized.selector); + vm.prank(hookManager); + controller.setPoolHookConfig(TOKEN0, TOKEN1, hook, true, true); + + assertEq(deepstate.poolHook(_poolId()), address(0)); + } + + function test_UnauthorizedAccountCannotConfigurePoolHook() public { + vm.expectRevert(Ownable.Unauthorized.selector); + vm.prank(alice); + controller.setPoolHookConfig(TOKEN0, TOKEN1, hook, true, true); + } + + function test_HookManagerCannotConfigureFeesOrTransferRouterOwnership() public { + controller.grantRoles(hookManager, controller.HOOK_MANAGER_ROLE()); + + vm.expectRevert(Ownable.Unauthorized.selector); + vm.prank(hookManager); + controller.setDeepstateFeeConfig(feeRecipient, 10); + + vm.expectRevert(Ownable.Unauthorized.selector); + vm.prank(hookManager); + controller.transferDeepstateOwnership(alice); + + (address recipient, uint16 bps) = deepstate.feeConfig(); + assertEq(recipient, address(0)); + assertEq(bps, 0); + assertEq(deepstate.owner(), address(controller)); + } + + function test_GovernanceCanConfigureFeesAndRecoverRouterOwnership() public { + vm.expectEmit(true, false, false, true, address(controller)); + emit DeepstateV1Controller.DeepstateFeeConfigured(feeRecipient, 10); + controller.setDeepstateFeeConfig(feeRecipient, 10); + + (address recipient, uint16 bps) = deepstate.feeConfig(); + assertEq(recipient, feeRecipient); + assertEq(bps, 10); + + vm.expectEmit(true, false, false, false, address(controller)); + emit DeepstateV1Controller.DeepstateOwnershipTransferred(alice); + controller.transferDeepstateOwnership(alice); + assertEq(deepstate.owner(), alice); + } + + function test_ControllerCallsFailUntilItOwnsRouter() public { + DeepstateV1 secondRouter = new DeepstateV1(); + DeepstateV1Controller secondController = new DeepstateV1Controller(address(this), address(secondRouter)); + secondController.grantRoles(hookManager, secondController.HOOK_MANAGER_ROLE()); + + vm.expectRevert(Ownable.Unauthorized.selector); + vm.prank(hookManager); + secondController.setPoolHookConfig(TOKEN0, TOKEN1, hook, true, true); + + vm.expectRevert(Ownable.Unauthorized.selector); + secondController.setDeepstateFeeConfig(feeRecipient, 10); + + assertEq(secondRouter.poolHook(_poolId()), address(0)); + assertEq(secondRouter.owner(), address(this)); + } + + function test_ControllerOwnerCannotRenounceOwnership() public { + vm.expectRevert(Ownable.NewOwnerIsZeroAddress.selector); + controller.renounceOwnership(); + } + + function _poolId() private pure returns (bytes32) { + return keccak256(abi.encode(TOKEN0, TOKEN1)); + } +} diff --git a/test/mocks/MockSablierLockupLinearV4.sol b/test/mocks/MockSablierLockupLinearV4.sol new file mode 100644 index 0000000..b82ab54 --- /dev/null +++ b/test/mocks/MockSablierLockupLinearV4.sol @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity 0.8.28; + +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {Lockup} from "@sablier/lockup/src/types/Lockup.sol"; +import {LockupLinear} from "@sablier/lockup/src/types/LockupLinear.sol"; + +import {ISablierLockupLinearV4} from "../../src/interfaces/ISablierLockupLinearV4.sol"; + +contract MockSablierLockupLinearV4 is ISablierLockupLinearV4 { + using SafeERC20 for IERC20; + + struct Stream { + address funder; + address sender; + address recipient; + address token; + uint128 depositAmount; + bool cancelable; + bool transferable; + string shape; + uint128 startUnlockAmount; + uint128 cliffUnlockAmount; + uint40 granularity; + uint40 cliffDuration; + uint40 totalDuration; + } + + uint256 public nextStreamId = 1; + bool public revertCreate; + address public reentryTarget; + bytes public reentryData; + mapping(uint256 streamId => Stream stream) private _streams; + + error CreateReverted(); + + function setRevertCreate(bool value) external { + revertCreate = value; + } + + function setReentry(address target, bytes calldata data) external { + reentryTarget = target; + reentryData = data; + } + + function stream(uint256 streamId) external view returns (Stream memory) { + return _streams[streamId]; + } + + function createWithDurationsLL( + Lockup.CreateWithDurations calldata params, + LockupLinear.UnlockAmounts calldata unlockAmounts, + uint40 granularity, + LockupLinear.Durations calldata durations + ) external payable returns (uint256 streamId) { + if (revertCreate) revert CreateReverted(); + + address target = reentryTarget; + if (target != address(0)) { + (bool success, bytes memory result) = target.call(reentryData); + if (!success) { + assembly ("memory-safe") { + revert(add(result, 0x20), mload(result)) + } + } + } + + streamId = nextStreamId++; + _streams[streamId] = Stream({ + funder: msg.sender, + sender: params.sender, + recipient: params.recipient, + token: address(params.token), + depositAmount: params.depositAmount, + cancelable: params.cancelable, + transferable: params.transferable, + shape: params.shape, + startUnlockAmount: unlockAmounts.start, + cliffUnlockAmount: unlockAmounts.cliff, + granularity: granularity, + cliffDuration: durations.cliff, + totalDuration: durations.total + }); + + params.token.safeTransferFrom(msg.sender, address(this), params.depositAmount); + } +}