From fa0665477aec05f7373ad6ab38dc18962ce11d93 Mon Sep 17 00:00:00 2001 From: Joseph Delong Date: Sat, 29 Aug 2026 18:17:41 -0500 Subject: [PATCH 01/25] feat: add governance-controlled rewarder v2 --- src/DeepstateRewarderV2.sol | 60 ++++++++++++++++++++ test/DeepstateRewarderV2.t.sol | 101 +++++++++++++++++++++++++++++++++ 2 files changed, 161 insertions(+) create mode 100644 src/DeepstateRewarderV2.sol create mode 100644 test/DeepstateRewarderV2.t.sol diff --git a/src/DeepstateRewarderV2.sol b/src/DeepstateRewarderV2.sol new file mode 100644 index 0000000..71c330a --- /dev/null +++ b/src/DeepstateRewarderV2.sol @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.28; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +import {DeepstateRewarder} from "./DeepstateRewarder.sol"; + +/// @title Deepstate Rewarder V2 +/// @notice Extends the original rewarder with governance-controlled recovery of remaining rewards. +contract DeepstateRewarderV2 is DeepstateRewarder { + using SafeERC20 for IERC20; + + event RewardBalanceWithdrawn(address indexed receiver, uint256 amount); + + error InvalidReceiver(); + + 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 Withdraw the rewarder's entire remaining reward-token balance. + /// @dev The owner is governance after deployment. Outstanding claims remain accounted for and + /// will revert during distribution until governance restores sufficient funding. + function withdrawRewardBalance(address receiver) external onlyOwner returns (uint256 amount) { + if (receiver == address(0)) revert InvalidReceiver(); + + IERC20 token = IERC20(rewardToken); + amount = token.balanceOf(address(this)); + if (amount != 0) token.safeTransfer(receiver, amount); + + emit RewardBalanceWithdrawn(receiver, amount); + } +} diff --git a/test/DeepstateRewarderV2.t.sol b/test/DeepstateRewarderV2.t.sol new file mode 100644 index 0000000..1e23bca --- /dev/null +++ b/test/DeepstateRewarderV2.t.sol @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {Test} from "forge-std/Test.sol"; +import {Ownable} from "solady/auth/Ownable.sol"; +import {ERC20} from "solady/tokens/ERC20.sol"; + +import {DeepstateRewarderV2} from "../src/DeepstateRewarderV2.sol"; + +contract RewarderV2TestToken is ERC20 { + function name() public pure override returns (string memory) { + return "Reward"; + } + + function symbol() public pure override returns (string memory) { + return "RWD"; + } + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } +} + +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); + + RewarderV2TestToken internal rewardToken; + DeepstateRewarderV2 internal rewarder; + address internal alice = makeAddr("alice"); + address internal bob = makeAddr("bob"); + + function setUp() public { + rewardToken = new RewarderV2TestToken(); + 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_OwnerCanWithdrawEntireRewardBalance() public { + uint256 expected = rewardToken.balanceOf(address(rewarder)); + + vm.expectEmit(true, false, false, true, address(rewarder)); + emit DeepstateRewarderV2.RewardBalanceWithdrawn(alice, expected); + uint256 withdrawn = rewarder.withdrawRewardBalance(alice); + + assertEq(withdrawn, expected); + assertEq(rewardToken.balanceOf(address(rewarder)), 0); + assertEq(rewardToken.balanceOf(alice), expected); + } + + function test_NonOwnerCannotWithdrawRewardBalance() public { + uint256 fundingBefore = rewardToken.balanceOf(address(rewarder)); + + vm.expectRevert(Ownable.Unauthorized.selector); + vm.prank(alice); + rewarder.withdrawRewardBalance(alice); + + assertEq(rewardToken.balanceOf(address(rewarder)), fundingBefore); + assertEq(rewardToken.balanceOf(alice), 0); + } + + function test_WithdrawRewardBalanceRejectsZeroReceiver() public { + vm.expectRevert(DeepstateRewarderV2.InvalidReceiver.selector); + rewarder.withdrawRewardBalance(address(0)); + } + + function test_WithdrawEmptyRewardBalanceReturnsZero() public { + rewarder.withdrawRewardBalance(alice); + + vm.expectEmit(true, false, false, true, address(rewarder)); + emit DeepstateRewarderV2.RewardBalanceWithdrawn(bob, 0); + uint256 withdrawn = rewarder.withdrawRewardBalance(bob); + + assertEq(withdrawn, 0); + assertEq(rewardToken.balanceOf(bob), 0); + } +} From 3284eefd9bde44bda944f5d1a80f8590711548f0 Mon Sep 17 00:00:00 2001 From: Joseph Delong Date: Sat, 29 Aug 2026 21:28:47 -0500 Subject: [PATCH 02/25] feat: add CREATE2 rewarder market factory --- src/DeepstateRewarderFactory.sol | 245 ++++++++++++++++ src/DeepstateRewarderV2.sol | 14 +- test/DeepstateRewarderFactory.t.sol | 429 ++++++++++++++++++++++++++++ test/DeepstateRewarderV2.t.sol | 1 + 4 files changed, 685 insertions(+), 4 deletions(-) create mode 100644 src/DeepstateRewarderFactory.sol create mode 100644 test/DeepstateRewarderFactory.t.sol diff --git a/src/DeepstateRewarderFactory.sol b/src/DeepstateRewarderFactory.sol new file mode 100644 index 0000000..0635069 --- /dev/null +++ b/src/DeepstateRewarderFactory.sol @@ -0,0 +1,245 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.28; + +import {Ownable} from "solady/auth/Ownable.sol"; + +import {DeepstateRewarderV2} from "./DeepstateRewarderV2.sol"; +import {DeepstateToken} from "./DeepstateToken.sol"; + +interface IDeepstateRewarderRouter { + 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; +} + +/// @title Deepstate Rewarder Factory +/// @notice Governance-owned CREATE2 factory for controller-launched market reward programs. +/// @dev The factory must hold DEEP's MINTER_ROLE and own the Deepstate router before deployment. +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 controller 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; + + IDeepstateRewarderRouter public immutable deepstate; + DeepstateToken public immutable rewardToken; + + /// @notice Revocable address permitted to launch and retire factory markets. + address public controller; + /// @notice Earliest timestamp at which another market may be deployed. + uint256 public nextDeploymentAt; + /// @notice Nonce assigned to the next successful CREATE2 deployment. + uint256 public deploymentCount; + + mapping(bytes32 poolId => address rewarder) public activeRewarder; + mapping(address rewarder => bytes32 poolId) public rewarderPool; + + event ControllerSet(address indexed previousController, address indexed newController); + event MarketDeployed( + bytes32 indexed poolId, + uint256 indexed deploymentNonce, + address indexed rewarder, + bytes32 salt, + address token0, + address token1, + bool token0Active, + bool token1Active + ); + event MarketRemoved(bytes32 indexed poolId, address indexed rewarder, uint256 burnedAmount); + event DeepstateFeeConfigured(address indexed recipient, uint16 bps); + event DeepstateOwnershipTransferred(address indexed newOwner); + + error InvalidOwner(); + error InvalidDeepstate(); + error InvalidRewardToken(); + error InvalidPool(); + error InvalidHookFlags(); + error DeploymentCooldown(uint256 nextDeploymentAt); + error ActiveMarketExists(bytes32 poolId, address rewarder); + error ExistingPoolHook(bytes32 poolId, address hook); + error MarketNotActive(bytes32 poolId); + + constructor(address owner_, address deepstate_, address rewardToken_) { + if (owner_ == address(0)) revert InvalidOwner(); + if (deepstate_ == address(0)) revert InvalidDeepstate(); + if (rewardToken_ == address(0)) revert InvalidRewardToken(); + + _initializeOwner(owner_); + deepstate = IDeepstateRewarderRouter(deepstate_); + rewardToken = DeepstateToken(rewardToken_); + } + + modifier onlyControllerOrOwner() { + _checkControllerOrOwner(); + _; + } + + /// @notice Appoint or revoke the operational controller. Set zero to revoke without replacement. + function setController(address newController) external onlyOwner { + address previousController = controller; + controller = newController; + emit ControllerSet(previousController, newController); + } + + /// @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 + onlyControllerOrOwner + 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); + + uint256 nonce = deploymentCount; + bytes32 salt = marketSalt(poolId_, nonce); + + deploymentCount = nonce + 1; + nextDeploymentAt = block.timestamp + DEPLOYMENT_COOLDOWN; + + rewarder = new DeepstateRewarderV2{salt: salt}( + owner(), + 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_; + + rewardToken.mint(address(rewarder), INITIAL_FUNDING); + deepstate.setPoolHookConfig( + config.token0, config.token1, address(rewarder), config.token0Active, config.token1Active + ); + + emit MarketDeployed( + poolId_, + nonce, + address(rewarder), + salt, + config.token0, + config.token1, + config.token0Active, + config.token1Active + ); + } + + /// @notice Remove a factory market, recover all remaining DEEP, and burn it. + /// @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 + onlyControllerOrOwner + returns (uint256 burnedAmount) + { + bytes32 poolId_ = _poolId(token0, token1); + address rewarder = activeRewarder[poolId_]; + if (rewarder == address(0)) revert MarketNotActive(poolId_); + + deepstate.setPoolHookConfig(token0, token1, address(0), false, false); + delete activeRewarder[poolId_]; + delete rewarderPool[rewarder]; + + burnedAmount = DeepstateRewarderV2(rewarder).withdrawRewardBalance(address(this)); + if (burnedAmount != 0) rewardToken.burn(burnedAmount); + + emit MarketRemoved(poolId_, rewarder, burnedAmount); + } + + /// @notice Configure router fees while the factory owns the router. + function setDeepstateFeeConfig(address recipient, uint16 bps) external onlyOwner { + deepstate.setFeeConfig(recipient, bps); + emit DeepstateFeeConfigured(recipient, bps); + } + + /// @notice Return router ownership to governance or transfer it to a governance-approved owner. + function transferDeepstateOwnership(address newOwner) external onlyOwner { + deepstate.transferOwnership(newOwner); + emit DeepstateOwnershipTransferred(newOwner); + } + + /// @notice CREATE2 salt for a pool and global deployment nonce. + function marketSalt(bytes32 poolId_, uint256 deploymentNonce) public pure returns (bytes32) { + return keccak256(abi.encode(poolId_, deploymentNonce)); + } + + /// @notice Predict a rewarder address using the factory's current owner and a deployment nonce. + function predictRewarderAddress(MarketConfig calldata config, uint256 deploymentNonce) + external + view + returns (address predicted) + { + bytes32 poolId_ = _validateMarket(config); + bytes32 salt = marketSalt(poolId_, deploymentNonce); + bytes32 initCodeHash = keccak256( + abi.encodePacked( + type(DeepstateRewarderV2).creationCode, + abi.encode( + owner(), + address(deepstate), + address(rewardToken), + poolId_, + config.token0, + config.token1, + SIDE_EMISSION_CAP, + EMISSION_DURATION, + config.token0StartQuantity, + config.token0MaxQuantity, + config.token1StartQuantity, + config.token1MaxQuantity + ) + ) + ); + + predicted = + address(uint160(uint256(keccak256(abi.encodePacked(bytes1(0xff), address(this), salt, initCodeHash))))); + } + + 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)); + } + + function _checkControllerOrOwner() private view { + if (msg.sender != controller) _checkOwner(); + } +} diff --git a/src/DeepstateRewarderV2.sol b/src/DeepstateRewarderV2.sol index 71c330a..4cf1486 100644 --- a/src/DeepstateRewarderV2.sol +++ b/src/DeepstateRewarderV2.sol @@ -11,6 +11,9 @@ import {DeepstateRewarder} from "./DeepstateRewarder.sol"; contract DeepstateRewarderV2 is DeepstateRewarder { using SafeERC20 for IERC20; + /// @notice Factory that deployed and may retire this rewarder. + address public immutable factory; + event RewardBalanceWithdrawn(address indexed receiver, uint256 amount); error InvalidReceiver(); @@ -43,12 +46,15 @@ contract DeepstateRewarderV2 is DeepstateRewarder { token1StartQuantity_, token1MaxQuantity_ ) - {} + { + factory = msg.sender; + } /// @notice Withdraw the rewarder's entire remaining reward-token balance. - /// @dev The owner is governance after deployment. Outstanding claims remain accounted for and - /// will revert during distribution until governance restores sufficient funding. - function withdrawRewardBalance(address receiver) external onlyOwner returns (uint256 amount) { + /// @dev Governance owns the rewarder. Its deploying factory may also withdraw when retiring a + /// market. Outstanding claims remain accounted for and will revert until funding is restored. + function withdrawRewardBalance(address receiver) external returns (uint256 amount) { + if (msg.sender != factory) _checkOwner(); if (receiver == address(0)) revert InvalidReceiver(); IERC20 token = IERC20(rewardToken); diff --git a/test/DeepstateRewarderFactory.t.sol b/test/DeepstateRewarderFactory.t.sol new file mode 100644 index 0000000..39275b5 --- /dev/null +++ b/test/DeepstateRewarderFactory.t.sol @@ -0,0 +1,429 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.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 {DeepstateRewarderFactory} from "../src/DeepstateRewarderFactory.sol"; +import {DeepstateRewarderV2} from "../src/DeepstateRewarderV2.sol"; +import {DeepstateToken} from "../src/DeepstateToken.sol"; + +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; + DeepstateRewarderFactory internal factory; + + address internal controller = makeAddr("controller"); + address internal alice = makeAddr("alice"); + address internal feeRecipient = makeAddr("feeRecipient"); + + function setUp() public { + vm.warp(1_000_000); + + deep = new DeepstateToken(address(this), "Deepstate", "DEEP"); + deepstate = new DeepstateV1(); + factory = new DeepstateRewarderFactory(address(this), address(deepstate), address(deep)); + + deep.grantRole(deep.MINTER_ROLE(), address(factory)); + deepstate.transferOwnership(address(factory)); + factory.setController(controller); + } + + function test_ImmutableConfigurationAndInitialAuthority() public view { + assertEq(factory.owner(), address(this)); + assertEq(factory.controller(), controller); + assertEq(address(factory.deepstate()), address(deepstate)); + 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(factory))); + assertEq(deepstate.owner(), address(factory)); + assertEq(factory.nextDeploymentAt(), 0); + assertEq(factory.deploymentCount(), 0); + } + + function test_ConstructorValidation() public { + vm.expectRevert(DeepstateRewarderFactory.InvalidOwner.selector); + new DeepstateRewarderFactory(address(0), address(deepstate), address(deep)); + + vm.expectRevert(DeepstateRewarderFactory.InvalidDeepstate.selector); + new DeepstateRewarderFactory(address(this), address(0), address(deep)); + + vm.expectRevert(DeepstateRewarderFactory.InvalidRewardToken.selector); + new DeepstateRewarderFactory(address(this), address(deepstate), address(0)); + } + + function test_GovernanceOwnerIsIndependentFromFactoryDeployer() public { + address governance = makeAddr("governance"); + DeepstateToken secondToken = new DeepstateToken(address(this), "Second", "SECOND"); + DeepstateV1 secondRouter = new DeepstateV1(); + DeepstateRewarderFactory secondFactory = + new DeepstateRewarderFactory(governance, address(secondRouter), address(secondToken)); + secondToken.grantRole(secondToken.MINTER_ROLE(), address(secondFactory)); + secondRouter.transferOwnership(address(secondFactory)); + + vm.expectRevert(Ownable.Unauthorized.selector); + secondFactory.setController(controller); + + vm.prank(governance); + secondFactory.setController(controller); + vm.prank(controller); + DeepstateRewarderV2 rewarder = secondFactory.deployMarket(_market(TOKEN_A, TOKEN_B)); + + assertEq(secondFactory.owner(), governance); + assertEq(rewarder.owner(), governance); + assertEq(rewarder.factory(), address(secondFactory)); + assertEq(secondToken.balanceOf(address(rewarder)), 100_000_000e18); + } + + function test_ControllerDeploysPredictedCreate2MarketWithFixedScheduleAndFunding() public { + DeepstateRewarderFactory.MarketConfig memory config = _market(TOKEN_A, TOKEN_B); + address predicted = factory.predictRewarderAddress(config, 0); + bytes32 poolId = _poolId(TOKEN_A, TOKEN_B); + bytes32 expectedSalt = keccak256(abi.encode(poolId, uint256(0))); + + vm.expectEmit(true, true, true, true, address(factory)); + emit DeepstateRewarderFactory.MarketDeployed(poolId, 0, predicted, expectedSalt, TOKEN_A, TOKEN_B, true, true); + vm.prank(controller); + DeepstateRewarderV2 rewarder = factory.deployMarket(config); + + assertEq(address(rewarder), predicted); + assertGt(predicted.code.length, 0); + assertEq(rewarder.owner(), address(this)); + assertEq(rewarder.factory(), 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.totalSupply(), 100_000_000e18); + assertEq(deepstate.poolHook(poolId), address(rewarder)); + assertEq(factory.activeRewarder(poolId), address(rewarder)); + assertEq(factory.rewarderPool(address(rewarder)), poolId); + assertEq(factory.deploymentCount(), 1); + assertEq(factory.nextDeploymentAt(), block.timestamp + 3 days); + assertEq(factory.marketSalt(poolId, 0), expectedSalt); + } + + function test_DeploymentCooldownIsGlobalAndAllowsExactBoundary() public { + vm.prank(controller); + factory.deployMarket(_market(TOKEN_A, TOKEN_B)); + + uint256 next = factory.nextDeploymentAt(); + vm.expectRevert(abi.encodeWithSelector(DeepstateRewarderFactory.DeploymentCooldown.selector, next)); + vm.prank(controller); + factory.deployMarket(_market(TOKEN_C, TOKEN_D)); + + vm.warp(next - 1); + vm.expectRevert(abi.encodeWithSelector(DeepstateRewarderFactory.DeploymentCooldown.selector, next)); + vm.prank(controller); + factory.deployMarket(_market(TOKEN_C, TOKEN_D)); + + vm.warp(next); + vm.prank(controller); + DeepstateRewarderV2 second = factory.deployMarket(_market(TOKEN_C, TOKEN_D)); + + assertEq(factory.deploymentCount(), 2); + assertEq(deep.balanceOf(address(second)), 100_000_000e18); + assertEq(deep.totalSupply(), 200_000_000e18); + } + + function test_GovernanceCanDeployWithoutControllerButStillObeysCooldown() public { + factory.setController(address(0)); + DeepstateRewarderV2 rewarder = factory.deployMarket(_market(TOKEN_A, TOKEN_B)); + assertEq(rewarder.owner(), address(this)); + + uint256 next = factory.nextDeploymentAt(); + vm.expectRevert(abi.encodeWithSelector(DeepstateRewarderFactory.DeploymentCooldown.selector, next)); + factory.deployMarket(_market(TOKEN_C, TOKEN_D)); + } + + function test_GovernanceCanRevokeControllerImmediately() public { + vm.expectEmit(true, true, false, false, address(factory)); + emit DeepstateRewarderFactory.ControllerSet(controller, address(0)); + factory.setController(address(0)); + + vm.expectRevert(Ownable.Unauthorized.selector); + vm.prank(controller); + factory.deployMarket(_market(TOKEN_A, TOKEN_B)); + + assertEq(factory.controller(), address(0)); + assertEq(factory.deploymentCount(), 0); + assertEq(deep.totalSupply(), 0); + } + + function test_OnlyGovernanceCanSetController() public { + vm.expectRevert(Ownable.Unauthorized.selector); + vm.prank(controller); + factory.setController(alice); + + vm.expectRevert(Ownable.Unauthorized.selector); + vm.prank(alice); + factory.setController(alice); + + assertEq(factory.controller(), controller); + } + + function test_ControllerCanRemoveMarketAndBurnAllRemainingFunding() public { + DeepstateRewarderFactory.MarketConfig memory config = _market(TOKEN_A, TOKEN_B); + vm.prank(controller); + DeepstateRewarderV2 rewarder = factory.deployMarket(config); + bytes32 poolId = _poolId(TOKEN_A, TOKEN_B); + + vm.expectEmit(true, true, false, true, address(factory)); + emit DeepstateRewarderFactory.MarketRemoved(poolId, address(rewarder), 100_000_000e18); + vm.prank(controller); + uint256 burned = factory.removeMarket(TOKEN_A, TOKEN_B); + + assertEq(burned, 100_000_000e18); + 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.totalSupply(), 0); + } + + function test_ControllerCannotWithdrawFromRewarderDirectly() public { + vm.prank(controller); + DeepstateRewarderV2 rewarder = factory.deployMarket(_market(TOKEN_A, TOKEN_B)); + + vm.expectRevert(Ownable.Unauthorized.selector); + vm.prank(controller); + rewarder.withdrawRewardBalance(controller); + + assertEq(deep.balanceOf(address(rewarder)), 100_000_000e18); + assertEq(deep.balanceOf(controller), 0); + } + + function test_RemovalBurnsGovernanceTopUpAlongWithInitialFunding() public { + vm.prank(controller); + DeepstateRewarderV2 rewarder = factory.deployMarket(_market(TOKEN_A, TOKEN_B)); + + deep.grantRole(deep.MINTER_ROLE(), address(this)); + deep.mint(address(rewarder), 900_000_000e18); + deep.revokeRole(deep.MINTER_ROLE(), address(this)); + assertEq(deep.balanceOf(address(rewarder)), 1_000_000_000e18); + + vm.prank(controller); + uint256 burned = factory.removeMarket(TOKEN_A, TOKEN_B); + + assertEq(burned, 1_000_000_000e18); + assertEq(deep.totalSupply(), 0); + } + + function test_GovernanceCanRemoveMarketAfterRevokingController() public { + vm.prank(controller); + factory.deployMarket(_market(TOKEN_A, TOKEN_B)); + factory.setController(address(0)); + + uint256 burned = factory.removeMarket(TOKEN_A, TOKEN_B); + + assertEq(burned, 100_000_000e18); + assertEq(deep.totalSupply(), 0); + } + + function test_RemovedPoolCanBeResetWithFreshCreate2AddressAfterCooldown() public { + DeepstateRewarderFactory.MarketConfig memory config = _market(TOKEN_A, TOKEN_B); + vm.startPrank(controller); + 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); + address predicted = factory.predictRewarderAddress(config, 1); + DeepstateRewarderV2 second = factory.deployMarket(config); + vm.stopPrank(); + + assertNotEq(address(first), address(second)); + assertEq(address(second), predicted); + assertEq(factory.activeRewarder(_poolId(TOKEN_A, TOKEN_B)), address(second)); + assertEq(deep.totalSupply(), 100_000_000e18); + } + + function test_CannotDeployOverActiveFactoryMarketOrExistingRouterHook() public { + DeepstateRewarderFactory.MarketConfig memory config = _market(TOKEN_A, TOKEN_B); + vm.prank(controller); + 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(controller); + factory.deployMarket(config); + + DeepstateV1 secondRouter = new DeepstateV1(); + DeepstateRewarderFactory secondFactory = + new DeepstateRewarderFactory(address(this), address(secondRouter), address(deep)); + secondRouter.setPoolHookConfig(TOKEN_C, TOKEN_D, alice, true, false); + secondRouter.transferOwnership(address(secondFactory)); + deep.grantRole(deep.MINTER_ROLE(), address(secondFactory)); + secondFactory.setController(controller); + + bytes32 secondPoolId = _poolId(TOKEN_C, TOKEN_D); + vm.expectRevert(abi.encodeWithSelector(DeepstateRewarderFactory.ExistingPoolHook.selector, secondPoolId, alice)); + vm.prank(controller); + secondFactory.deployMarket(_market(TOKEN_C, TOKEN_D)); + + assertEq(secondFactory.deploymentCount(), 0); + assertEq(deep.balanceOf(address(secondFactory)), 0); + } + + function test_DeploymentWithoutMinterRoleRevertsAtomically() public { + DeepstateToken secondToken = new DeepstateToken(address(this), "Second", "SECOND"); + DeepstateV1 secondRouter = new DeepstateV1(); + DeepstateRewarderFactory secondFactory = + new DeepstateRewarderFactory(address(this), address(secondRouter), address(secondToken)); + secondRouter.transferOwnership(address(secondFactory)); + secondFactory.setController(controller); + + DeepstateRewarderFactory.MarketConfig memory config = _market(TOKEN_A, TOKEN_B); + address predicted = secondFactory.predictRewarderAddress(config, 0); + + vm.expectRevert( + abi.encodeWithSelector( + IAccessControl.AccessControlUnauthorizedAccount.selector, + address(secondFactory), + secondToken.MINTER_ROLE() + ) + ); + vm.prank(controller); + secondFactory.deployMarket(config); + + assertEq(predicted.code.length, 0); + assertEq(secondFactory.deploymentCount(), 0); + assertEq(secondFactory.nextDeploymentAt(), 0); + assertEq(secondToken.totalSupply(), 0); + assertEq(secondRouter.poolHook(_poolId(TOKEN_A, TOKEN_B)), address(0)); + } + + function test_DeploymentWithoutRouterOwnershipRevertsAtomically() public { + DeepstateToken secondToken = new DeepstateToken(address(this), "Second", "SECOND"); + DeepstateV1 secondRouter = new DeepstateV1(); + DeepstateRewarderFactory secondFactory = + new DeepstateRewarderFactory(address(this), address(secondRouter), address(secondToken)); + secondToken.grantRole(secondToken.MINTER_ROLE(), address(secondFactory)); + secondFactory.setController(controller); + + DeepstateRewarderFactory.MarketConfig memory config = _market(TOKEN_A, TOKEN_B); + address predicted = secondFactory.predictRewarderAddress(config, 0); + + vm.expectRevert(Ownable.Unauthorized.selector); + vm.prank(controller); + secondFactory.deployMarket(config); + + assertEq(predicted.code.length, 0); + assertEq(secondFactory.deploymentCount(), 0); + assertEq(secondToken.totalSupply(), 0); + } + + function test_InvalidMarketConfigurationRevertsBeforeDeployment() public { + DeepstateRewarderFactory.MarketConfig memory config = _market(TOKEN_B, TOKEN_A); + vm.expectRevert(DeepstateRewarderFactory.InvalidPool.selector); + vm.prank(controller); + factory.deployMarket(config); + + config = _market(TOKEN_A, TOKEN_B); + config.token0Active = false; + config.token1Active = false; + vm.expectRevert(DeepstateRewarderFactory.InvalidHookFlags.selector); + vm.prank(controller); + factory.deployMarket(config); + + config = _market(TOKEN_A, TOKEN_B); + config.token0StartQuantity = 0; + vm.expectRevert(DeepstateRewarder.InvalidQuantitySchedule.selector); + vm.prank(controller); + factory.deployMarket(config); + + assertEq(factory.deploymentCount(), 0); + assertEq(factory.nextDeploymentAt(), 0); + assertEq(deep.totalSupply(), 0); + } + + function test_OnlyGovernanceCanConfigureFeesAndTransferRouterOwnership() public { + vm.expectRevert(Ownable.Unauthorized.selector); + vm.prank(controller); + factory.setDeepstateFeeConfig(feeRecipient, 10); + + factory.setDeepstateFeeConfig(feeRecipient, 10); + (address recipient, uint16 bps) = deepstate.feeConfig(); + assertEq(recipient, feeRecipient); + assertEq(bps, 10); + + vm.expectRevert(Ownable.Unauthorized.selector); + vm.prank(controller); + factory.transferDeepstateOwnership(alice); + + factory.transferDeepstateOwnership(alice); + assertEq(deepstate.owner(), alice); + } + + function test_UnauthorizedAccountCannotDeployOrRemoveMarket() public { + vm.expectRevert(Ownable.Unauthorized.selector); + vm.prank(alice); + factory.deployMarket(_market(TOKEN_A, TOKEN_B)); + + vm.prank(controller); + 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(controller); + factory.removeMarket(TOKEN_A, TOKEN_B); + + vm.expectRevert(DeepstateRewarderFactory.InvalidPool.selector); + vm.prank(controller); + 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)); + } +} diff --git a/test/DeepstateRewarderV2.t.sol b/test/DeepstateRewarderV2.t.sol index 1e23bca..49a464a 100644 --- a/test/DeepstateRewarderV2.t.sol +++ b/test/DeepstateRewarderV2.t.sol @@ -53,6 +53,7 @@ contract DeepstateRewarderV2Test is Test { function test_InheritsRewarderConfiguration() public view { assertEq(rewarder.owner(), address(this)); + assertEq(rewarder.factory(), address(this)); assertEq(rewarder.deepstate(), DEEPSTATE); assertEq(rewarder.rewardToken(), address(rewardToken)); assertEq(rewarder.token0(), TOKEN0); From 82065c631754c84633e06042dd22e47b1949f92e Mon Sep 17 00:00:00 2001 From: Joseph Delong Date: Sat, 29 Aug 2026 21:43:02 -0500 Subject: [PATCH 03/25] refactor: delegate router hooks through governance controller --- src/DeepstateRewarderFactory.sol | 57 +++++----- src/DeepstateRouterController.sol | 73 +++++++++++++ test/DeepstateRewarderFactory.t.sol | 144 +++++++++++++++++++------ test/DeepstateRouterController.t.sol | 153 +++++++++++++++++++++++++++ 4 files changed, 362 insertions(+), 65 deletions(-) create mode 100644 src/DeepstateRouterController.sol create mode 100644 test/DeepstateRouterController.t.sol diff --git a/src/DeepstateRewarderFactory.sol b/src/DeepstateRewarderFactory.sol index 0635069..7cb1074 100644 --- a/src/DeepstateRewarderFactory.sol +++ b/src/DeepstateRewarderFactory.sol @@ -4,20 +4,12 @@ pragma solidity 0.8.28; import {Ownable} from "solady/auth/Ownable.sol"; import {DeepstateRewarderV2} from "./DeepstateRewarderV2.sol"; +import {DeepstateRouterController, IDeepstateRouterAdmin} from "./DeepstateRouterController.sol"; import {DeepstateToken} from "./DeepstateToken.sol"; -interface IDeepstateRewarderRouter { - 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; -} - /// @title Deepstate Rewarder Factory /// @notice Governance-owned CREATE2 factory for controller-launched market reward programs. -/// @dev The factory must hold DEEP's MINTER_ROLE and own the Deepstate router before deployment. +/// @dev The factory must hold DEEP's MINTER_ROLE and be the router controller's hook manager. contract DeepstateRewarderFactory is Ownable { struct MarketConfig { address token0; @@ -39,7 +31,8 @@ contract DeepstateRewarderFactory is Ownable { /// @notice Initial DEEP minted to each rewarder. Further funding requires governance. uint256 public constant INITIAL_FUNDING = 100_000_000e18; - IDeepstateRewarderRouter public immutable deepstate; + DeepstateRouterController public immutable routerController; + IDeepstateRouterAdmin public immutable deepstate; DeepstateToken public immutable rewardToken; /// @notice Revocable address permitted to launch and retire factory markets. @@ -64,26 +57,33 @@ contract DeepstateRewarderFactory is Ownable { bool token1Active ); event MarketRemoved(bytes32 indexed poolId, address indexed rewarder, uint256 burnedAmount); - event DeepstateFeeConfigured(address indexed recipient, uint16 bps); - event DeepstateOwnershipTransferred(address indexed newOwner); error InvalidOwner(); - error InvalidDeepstate(); + error InvalidRouterController(); + error RouterControllerOwnerMismatch(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 deepstate_, address rewardToken_) { + constructor(address owner_, address routerController_, address rewardToken_) { if (owner_ == address(0)) revert InvalidOwner(); - if (deepstate_ == address(0)) revert InvalidDeepstate(); - if (rewardToken_ == address(0)) revert InvalidRewardToken(); + if (routerController_ == address(0) || routerController_.code.length == 0) { + revert InvalidRouterController(); + } + if (rewardToken_ == address(0) || rewardToken_.code.length == 0) revert InvalidRewardToken(); _initializeOwner(owner_); - deepstate = IDeepstateRewarderRouter(deepstate_); + routerController = DeepstateRouterController(routerController_); + address routerControllerOwner = routerController.owner(); + if (routerControllerOwner != owner_) { + revert RouterControllerOwnerMismatch(owner_, routerControllerOwner); + } + deepstate = routerController.deepstate(); rewardToken = DeepstateToken(rewardToken_); } @@ -141,7 +141,7 @@ contract DeepstateRewarderFactory is Ownable { rewarderPool[address(rewarder)] = poolId_; rewardToken.mint(address(rewarder), INITIAL_FUNDING); - deepstate.setPoolHookConfig( + routerController.setPoolHookConfig( config.token0, config.token1, address(rewarder), config.token0Active, config.token1Active ); @@ -169,7 +169,12 @@ contract DeepstateRewarderFactory is Ownable { address rewarder = activeRewarder[poolId_]; if (rewarder == address(0)) revert MarketNotActive(poolId_); - deepstate.setPoolHookConfig(token0, token1, address(0), false, false); + address currentHook = deepstate.poolHook(poolId_); + if (currentHook == rewarder) { + routerController.setPoolHookConfig(token0, token1, address(0), false, false); + } else if (currentHook != address(0)) { + revert UnexpectedPoolHook(poolId_, rewarder, currentHook); + } delete activeRewarder[poolId_]; delete rewarderPool[rewarder]; @@ -179,18 +184,6 @@ contract DeepstateRewarderFactory is Ownable { emit MarketRemoved(poolId_, rewarder, burnedAmount); } - /// @notice Configure router fees while the factory owns the router. - function setDeepstateFeeConfig(address recipient, uint16 bps) external onlyOwner { - deepstate.setFeeConfig(recipient, bps); - emit DeepstateFeeConfigured(recipient, bps); - } - - /// @notice Return router ownership to governance or transfer it to a governance-approved owner. - function transferDeepstateOwnership(address newOwner) external onlyOwner { - deepstate.transferOwnership(newOwner); - emit DeepstateOwnershipTransferred(newOwner); - } - /// @notice CREATE2 salt for a pool and global deployment nonce. function marketSalt(bytes32 poolId_, uint256 deploymentNonce) public pure returns (bytes32) { return keccak256(abi.encode(poolId_, deploymentNonce)); diff --git a/src/DeepstateRouterController.sol b/src/DeepstateRouterController.sol new file mode 100644 index 0000000..caa532c --- /dev/null +++ b/src/DeepstateRouterController.sol @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.28; + +import {Ownable} from "solady/auth/Ownable.sol"; + +interface IDeepstateRouterAdmin { + 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; +} + +/// @title Deepstate Router Controller +/// @notice Governance-owned capability boundary around the single-owner Deepstate router. +contract DeepstateRouterController is Ownable { + IDeepstateRouterAdmin public immutable deepstate; + + /// @notice Revocable contract permitted only to configure pool hooks. + address public hookManager; + + event HookManagerSet(address indexed previousHookManager, address indexed newHookManager); + event DeepstateFeeConfigured(address indexed recipient, uint16 bps); + event DeepstateOwnershipTransferred(address indexed newOwner); + + error InvalidOwner(); + error InvalidDeepstate(); + + constructor(address owner_, address deepstate_) { + if (owner_ == address(0)) revert InvalidOwner(); + if (deepstate_ == address(0) || deepstate_.code.length == 0) revert InvalidDeepstate(); + + _initializeOwner(owner_); + deepstate = IDeepstateRouterAdmin(deepstate_); + } + + modifier onlyHookManagerOrOwner() { + _checkHookManagerOrOwner(); + _; + } + + /// @notice Appoint or revoke the sole delegated pool-hook manager. + function setHookManager(address newHookManager) external onlyOwner { + address previousHookManager = hookManager; + hookManager = newHookManager; + emit HookManagerSet(previousHookManager, newHookManager); + } + + /// @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 + onlyHookManagerOrOwner + { + 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); + } + + function _checkHookManagerOrOwner() private view { + if (msg.sender != hookManager) _checkOwner(); + } +} diff --git a/test/DeepstateRewarderFactory.t.sol b/test/DeepstateRewarderFactory.t.sol index 39275b5..15660f3 100644 --- a/test/DeepstateRewarderFactory.t.sol +++ b/test/DeepstateRewarderFactory.t.sol @@ -9,6 +9,7 @@ import {DeepstateV1} from "deepstate-contracts/DeepstateV1.sol"; import {DeepstateRewarder} from "../src/DeepstateRewarder.sol"; import {DeepstateRewarderFactory} from "../src/DeepstateRewarderFactory.sol"; import {DeepstateRewarderV2} from "../src/DeepstateRewarderV2.sol"; +import {DeepstateRouterController} from "../src/DeepstateRouterController.sol"; import {DeepstateToken} from "../src/DeepstateToken.sol"; contract DeepstateRewarderFactoryTest is Test { @@ -19,27 +20,30 @@ contract DeepstateRewarderFactoryTest is Test { DeepstateToken internal deep; DeepstateV1 internal deepstate; + DeepstateRouterController internal routerController; DeepstateRewarderFactory internal factory; address internal controller = makeAddr("controller"); address internal alice = makeAddr("alice"); - address internal feeRecipient = makeAddr("feeRecipient"); function setUp() public { vm.warp(1_000_000); deep = new DeepstateToken(address(this), "Deepstate", "DEEP"); deepstate = new DeepstateV1(); - factory = new DeepstateRewarderFactory(address(this), address(deepstate), address(deep)); + routerController = new DeepstateRouterController(address(this), address(deepstate)); + factory = new DeepstateRewarderFactory(address(this), address(routerController), address(deep)); deep.grantRole(deep.MINTER_ROLE(), address(factory)); - deepstate.transferOwnership(address(factory)); + deepstate.transferOwnership(address(routerController)); + routerController.setHookManager(address(factory)); factory.setController(controller); } function test_ImmutableConfigurationAndInitialAuthority() public view { assertEq(factory.owner(), address(this)); assertEq(factory.controller(), controller); + assertEq(address(factory.routerController()), address(routerController)); assertEq(address(factory.deepstate()), address(deepstate)); assertEq(address(factory.rewardToken()), address(deep)); assertEq(factory.DEPLOYMENT_COOLDOWN(), 3 days); @@ -47,36 +51,55 @@ contract DeepstateRewarderFactoryTest is Test { assertEq(factory.SIDE_EMISSION_CAP(), 500_000_000e18); assertEq(factory.INITIAL_FUNDING(), 100_000_000e18); assertTrue(deep.hasRole(deep.MINTER_ROLE(), address(factory))); - assertEq(deepstate.owner(), address(factory)); + assertEq(deepstate.owner(), address(routerController)); + assertEq(routerController.hookManager(), address(factory)); assertEq(factory.nextDeploymentAt(), 0); assertEq(factory.deploymentCount(), 0); } function test_ConstructorValidation() public { vm.expectRevert(DeepstateRewarderFactory.InvalidOwner.selector); - new DeepstateRewarderFactory(address(0), address(deepstate), address(deep)); + new DeepstateRewarderFactory(address(0), address(routerController), address(deep)); - vm.expectRevert(DeepstateRewarderFactory.InvalidDeepstate.selector); + vm.expectRevert(DeepstateRewarderFactory.InvalidRouterController.selector); new DeepstateRewarderFactory(address(this), address(0), address(deep)); + vm.expectRevert(DeepstateRewarderFactory.InvalidRouterController.selector); + new DeepstateRewarderFactory(address(this), alice, address(deep)); + + DeepstateRouterController mismatchedController = new DeepstateRouterController(alice, address(deepstate)); + vm.expectRevert( + abi.encodeWithSelector( + DeepstateRewarderFactory.RouterControllerOwnerMismatch.selector, address(this), alice + ) + ); + new DeepstateRewarderFactory(address(this), address(mismatchedController), address(deep)); + + vm.expectRevert(DeepstateRewarderFactory.InvalidRewardToken.selector); + new DeepstateRewarderFactory(address(this), address(routerController), address(0)); + vm.expectRevert(DeepstateRewarderFactory.InvalidRewardToken.selector); - new DeepstateRewarderFactory(address(this), address(deepstate), address(0)); + new DeepstateRewarderFactory(address(this), address(routerController), alice); } function test_GovernanceOwnerIsIndependentFromFactoryDeployer() public { address governance = makeAddr("governance"); DeepstateToken secondToken = new DeepstateToken(address(this), "Second", "SECOND"); DeepstateV1 secondRouter = new DeepstateV1(); + DeepstateRouterController secondRouterController = + new DeepstateRouterController(governance, address(secondRouter)); DeepstateRewarderFactory secondFactory = - new DeepstateRewarderFactory(governance, address(secondRouter), address(secondToken)); + new DeepstateRewarderFactory(governance, address(secondRouterController), address(secondToken)); secondToken.grantRole(secondToken.MINTER_ROLE(), address(secondFactory)); - secondRouter.transferOwnership(address(secondFactory)); + secondRouter.transferOwnership(address(secondRouterController)); vm.expectRevert(Ownable.Unauthorized.selector); secondFactory.setController(controller); - vm.prank(governance); + vm.startPrank(governance); + secondRouterController.setHookManager(address(secondFactory)); secondFactory.setController(controller); + vm.stopPrank(); vm.prank(controller); DeepstateRewarderV2 rewarder = secondFactory.deployMarket(_market(TOKEN_A, TOKEN_B)); @@ -275,11 +298,14 @@ contract DeepstateRewarderFactoryTest is Test { factory.deployMarket(config); DeepstateV1 secondRouter = new DeepstateV1(); + DeepstateRouterController secondRouterController = + new DeepstateRouterController(address(this), address(secondRouter)); DeepstateRewarderFactory secondFactory = - new DeepstateRewarderFactory(address(this), address(secondRouter), address(deep)); + new DeepstateRewarderFactory(address(this), address(secondRouterController), address(deep)); secondRouter.setPoolHookConfig(TOKEN_C, TOKEN_D, alice, true, false); - secondRouter.transferOwnership(address(secondFactory)); + secondRouter.transferOwnership(address(secondRouterController)); deep.grantRole(deep.MINTER_ROLE(), address(secondFactory)); + secondRouterController.setHookManager(address(secondFactory)); secondFactory.setController(controller); bytes32 secondPoolId = _poolId(TOKEN_C, TOKEN_D); @@ -294,9 +320,12 @@ contract DeepstateRewarderFactoryTest is Test { function test_DeploymentWithoutMinterRoleRevertsAtomically() public { DeepstateToken secondToken = new DeepstateToken(address(this), "Second", "SECOND"); DeepstateV1 secondRouter = new DeepstateV1(); + DeepstateRouterController secondRouterController = + new DeepstateRouterController(address(this), address(secondRouter)); DeepstateRewarderFactory secondFactory = - new DeepstateRewarderFactory(address(this), address(secondRouter), address(secondToken)); - secondRouter.transferOwnership(address(secondFactory)); + new DeepstateRewarderFactory(address(this), address(secondRouterController), address(secondToken)); + secondRouter.transferOwnership(address(secondRouterController)); + secondRouterController.setHookManager(address(secondFactory)); secondFactory.setController(controller); DeepstateRewarderFactory.MarketConfig memory config = _market(TOKEN_A, TOKEN_B); @@ -322,9 +351,12 @@ contract DeepstateRewarderFactoryTest is Test { function test_DeploymentWithoutRouterOwnershipRevertsAtomically() public { DeepstateToken secondToken = new DeepstateToken(address(this), "Second", "SECOND"); DeepstateV1 secondRouter = new DeepstateV1(); + DeepstateRouterController secondRouterController = + new DeepstateRouterController(address(this), address(secondRouter)); DeepstateRewarderFactory secondFactory = - new DeepstateRewarderFactory(address(this), address(secondRouter), address(secondToken)); + new DeepstateRewarderFactory(address(this), address(secondRouterController), address(secondToken)); secondToken.grantRole(secondToken.MINTER_ROLE(), address(secondFactory)); + secondRouterController.setHookManager(address(secondFactory)); secondFactory.setController(controller); DeepstateRewarderFactory.MarketConfig memory config = _market(TOKEN_A, TOKEN_B); @@ -339,6 +371,70 @@ contract DeepstateRewarderFactoryTest is Test { assertEq(secondToken.totalSupply(), 0); } + function test_DeploymentWithoutDelegatedHookPermissionRevertsAtomically() public { + DeepstateToken secondToken = new DeepstateToken(address(this), "Second", "SECOND"); + DeepstateV1 secondRouter = new DeepstateV1(); + DeepstateRouterController secondRouterController = + new DeepstateRouterController(address(this), address(secondRouter)); + DeepstateRewarderFactory secondFactory = + new DeepstateRewarderFactory(address(this), address(secondRouterController), address(secondToken)); + secondToken.grantRole(secondToken.MINTER_ROLE(), address(secondFactory)); + secondRouter.transferOwnership(address(secondRouterController)); + secondFactory.setController(controller); + + DeepstateRewarderFactory.MarketConfig memory config = _market(TOKEN_A, TOKEN_B); + address predicted = secondFactory.predictRewarderAddress(config, 0); + + vm.expectRevert(Ownable.Unauthorized.selector); + vm.prank(controller); + secondFactory.deployMarket(config); + + assertEq(predicted.code.length, 0); + assertEq(secondFactory.deploymentCount(), 0); + assertEq(secondToken.totalSupply(), 0); + assertEq(secondRouter.poolHook(_poolId(TOKEN_A, TOKEN_B)), address(0)); + } + + function test_GovernanceCanCleanUpAfterRevokingFactoryHookPermission() public { + vm.prank(controller); + DeepstateRewarderV2 rewarder = factory.deployMarket(_market(TOKEN_A, TOKEN_B)); + bytes32 poolId = _poolId(TOKEN_A, TOKEN_B); + routerController.setHookManager(address(0)); + + vm.expectRevert(Ownable.Unauthorized.selector); + vm.prank(controller); + factory.removeMarket(TOKEN_A, TOKEN_B); + assertEq(factory.activeRewarder(poolId), address(rewarder)); + assertEq(deep.balanceOf(address(rewarder)), 100_000_000e18); + + routerController.setPoolHookConfig(TOKEN_A, TOKEN_B, address(0), false, false); + uint256 burned = factory.removeMarket(TOKEN_A, TOKEN_B); + + assertEq(burned, 100_000_000e18); + assertEq(factory.activeRewarder(poolId), address(0)); + assertEq(deep.balanceOf(address(rewarder)), 0); + assertEq(deep.totalSupply(), 0); + } + + function test_RemovalRejectsUnexpectedReplacementHook() public { + vm.prank(controller); + DeepstateRewarderV2 rewarder = factory.deployMarket(_market(TOKEN_A, TOKEN_B)); + bytes32 poolId = _poolId(TOKEN_A, TOKEN_B); + routerController.setPoolHookConfig(TOKEN_A, TOKEN_B, alice, true, true); + + vm.expectRevert( + abi.encodeWithSelector( + DeepstateRewarderFactory.UnexpectedPoolHook.selector, poolId, address(rewarder), alice + ) + ); + vm.prank(controller); + 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); @@ -363,24 +459,6 @@ contract DeepstateRewarderFactoryTest is Test { assertEq(deep.totalSupply(), 0); } - function test_OnlyGovernanceCanConfigureFeesAndTransferRouterOwnership() public { - vm.expectRevert(Ownable.Unauthorized.selector); - vm.prank(controller); - factory.setDeepstateFeeConfig(feeRecipient, 10); - - factory.setDeepstateFeeConfig(feeRecipient, 10); - (address recipient, uint16 bps) = deepstate.feeConfig(); - assertEq(recipient, feeRecipient); - assertEq(bps, 10); - - vm.expectRevert(Ownable.Unauthorized.selector); - vm.prank(controller); - factory.transferDeepstateOwnership(alice); - - factory.transferDeepstateOwnership(alice); - assertEq(deepstate.owner(), alice); - } - function test_UnauthorizedAccountCannotDeployOrRemoveMarket() public { vm.expectRevert(Ownable.Unauthorized.selector); vm.prank(alice); diff --git a/test/DeepstateRouterController.t.sol b/test/DeepstateRouterController.t.sol new file mode 100644 index 0000000..23fa236 --- /dev/null +++ b/test/DeepstateRouterController.t.sol @@ -0,0 +1,153 @@ +// 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 {DeepstateRouterController} from "../src/DeepstateRouterController.sol"; + +contract DeepstateRouterControllerTest is Test { + address internal constant TOKEN0 = address(0x1000); + address internal constant TOKEN1 = address(0x2000); + + DeepstateV1 internal deepstate; + DeepstateRouterController 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 DeepstateRouterController(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.hookManager(), address(0)); + assertEq(deepstate.owner(), address(controller)); + } + + function test_ConstructorValidation() public { + vm.expectRevert(DeepstateRouterController.InvalidOwner.selector); + new DeepstateRouterController(address(0), address(deepstate)); + + vm.expectRevert(DeepstateRouterController.InvalidDeepstate.selector); + new DeepstateRouterController(address(this), address(0)); + + vm.expectRevert(DeepstateRouterController.InvalidDeepstate.selector); + new DeepstateRouterController(address(this), alice); + } + + function test_GovernanceCanSetAndRevokeHookManager() public { + vm.expectEmit(true, true, false, false, address(controller)); + emit DeepstateRouterController.HookManagerSet(address(0), hookManager); + controller.setHookManager(hookManager); + assertEq(controller.hookManager(), hookManager); + + vm.expectEmit(true, true, false, false, address(controller)); + emit DeepstateRouterController.HookManagerSet(hookManager, address(0)); + controller.setHookManager(address(0)); + assertEq(controller.hookManager(), address(0)); + } + + function test_OnlyGovernanceCanSetHookManager() public { + vm.expectRevert(Ownable.Unauthorized.selector); + vm.prank(hookManager); + controller.setHookManager(hookManager); + + vm.expectRevert(Ownable.Unauthorized.selector); + vm.prank(alice); + controller.setHookManager(alice); + } + + function test_HookManagerCanConfigurePoolHook() public { + controller.setHookManager(hookManager); + + vm.prank(hookManager); + controller.setPoolHookConfig(TOKEN0, TOKEN1, hook, true, false); + + 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.setHookManager(hookManager); + controller.setHookManager(address(0)); + + 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.setHookManager(hookManager); + + 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 DeepstateRouterController.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 DeepstateRouterController.DeepstateOwnershipTransferred(alice); + controller.transferDeepstateOwnership(alice); + assertEq(deepstate.owner(), alice); + } + + function test_ControllerCallsFailUntilItOwnsRouter() public { + DeepstateV1 secondRouter = new DeepstateV1(); + DeepstateRouterController secondController = new DeepstateRouterController(address(this), address(secondRouter)); + secondController.setHookManager(hookManager); + + 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 _poolId() private pure returns (bytes32) { + return keccak256(abi.encode(TOKEN0, TOKEN1)); + } +} From e30142c7eec75894b1415f517e23d2efc9e51cb8 Mon Sep 17 00:00:00 2001 From: Joseph Delong Date: Sat, 29 Aug 2026 22:29:45 -0500 Subject: [PATCH 04/25] feat: enforce vested recipient allocation on DEEP mints --- .gitmodules | 9 + README.md | 21 ++ lib/chainlink-contracts | 1 + lib/prb-math | 1 + lib/sablier | 1 + remappings.txt | 4 + src/DeepstateMinterController.sol | 100 +++++++ src/DeepstateRewarderFactory.sol | 20 +- src/interfaces/IDeepstateMinterController.sol | 9 + src/interfaces/ISablierLockupLinearV4.sol | 15 ++ test/DeepstateMinterController.t.sol | 245 ++++++++++++++++++ ...teMinterControllerSablierIntegration.t.sol | 88 +++++++ test/DeepstateRewarderFactory.t.sol | 131 +++++++--- test/mocks/MockSablierLockupLinearV4.sol | 93 +++++++ 14 files changed, 702 insertions(+), 36 deletions(-) create mode 160000 lib/chainlink-contracts create mode 160000 lib/prb-math create mode 160000 lib/sablier create mode 100644 src/DeepstateMinterController.sol create mode 100644 src/interfaces/IDeepstateMinterController.sol create mode 100644 src/interfaces/ISablierLockupLinearV4.sol create mode 100644 test/DeepstateMinterController.t.sol create mode 100644 test/DeepstateMinterControllerSablierIntegration.t.sol create mode 100644 test/mocks/MockSablierLockupLinearV4.sol 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..7c59178 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,27 @@ 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. Governance grants +its `MINTER_ROLE` only to approved issuance contracts, such as +`DeepstateRewarderFactory`. For every requested mint `M`, the controller mints +`M` to the requested address and an additional `floor(30% * M)` to Sablier +Lockup v4.0.1. Each recipient allocation gets its own linear 365-day stream. +The vesting recipient and Sablier contract are immutable constructor settings; +streams are non-cancelable and their NFTs are non-transferable. + +The 30% is additional issuance, not a split of `M`. A factory market therefore +receives its complete 100,000,000 DEEP initial funding while a separate +30,000,000 DEEP stream is created, for 130,000,000 DEEP total issuance. 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 administers both +role systems and must not grant the token-level role directly to the factory or +another minter that can bypass the controller. + ## 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/DeepstateMinterController.sol b/src/DeepstateMinterController.sol new file mode 100644 index 0000000..1fa7779 --- /dev/null +++ b/src/DeepstateMinterController.sol @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity 0.8.28; + +import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; +import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol"; +import {Lockup} from "@sablier/lockup/src/types/Lockup.sol"; +import {LockupLinear} from "@sablier/lockup/src/types/LockupLinear.sol"; + +import {DeepstateToken} from "./DeepstateToken.sol"; +import {ISablierLockupLinearV4} from "./interfaces/ISablierLockupLinearV4.sol"; + +/// @title Deepstate Minter Controller +/// @notice Enforces an additional 30% recipient allocation on every authorized DEEP mint. +/// @dev The recipient allocation is placed in a new non-cancelable, non-transferable Sablier +/// Lockup v4 linear stream. This contract must be DEEP's only operational MINTER_ROLE holder. +contract DeepstateMinterController is AccessControl, ReentrancyGuard { + using SafeERC20 for IERC20; + + bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE"); + uint256 public constant RECIPIENT_ALLOCATION_BPS = 3_000; + uint256 public constant BPS_DENOMINATOR = 10_000; + uint40 public constant VESTING_DURATION = 365 days; + + DeepstateToken public immutable rewardToken; + ISablierLockupLinearV4 public immutable sablierLockup; + address public immutable recipient; + + event MintedWithVesting( + address indexed caller, + address indexed mintRecipient, + uint256 mintAmount, + address indexed vestingRecipient, + uint256 vestingAmount, + uint256 streamId + ); + + error InvalidAdmin(); + error InvalidRewardToken(); + error InvalidSablierLockup(); + error InvalidRecipient(); + error InvalidMintRecipient(); + error MintAmountTooSmall(); + error VestingAmountTooLarge(uint256 amount); + error StreamFundingMismatch(uint256 expectedBalance, uint256 actualBalance); + + constructor(address admin_, address rewardToken_, address sablierLockup_, address recipient_) { + if (admin_ == address(0)) revert InvalidAdmin(); + if (rewardToken_ == address(0) || rewardToken_.code.length == 0) revert InvalidRewardToken(); + if (sablierLockup_ == address(0) || sablierLockup_.code.length == 0) revert InvalidSablierLockup(); + if (recipient_ == address(0)) revert InvalidRecipient(); + + rewardToken = DeepstateToken(rewardToken_); + sablierLockup = ISablierLockupLinearV4(sablierLockup_); + recipient = recipient_; + _grantRole(DEFAULT_ADMIN_ROLE, admin_); + } + + /// @notice Mint `amount` DEEP to `to` and an additional 30% into a one-year recipient stream. + /// @dev The 30% calculation rounds down. Amounts that round the stream allocation to zero revert. + function mint(address to, uint256 amount) external onlyRole(MINTER_ROLE) nonReentrant returns (uint256 streamId) { + if (to == address(0)) revert InvalidMintRecipient(); + + uint256 vestingAmount = Math.mulDiv(amount, RECIPIENT_ALLOCATION_BPS, BPS_DENOMINATOR); + if (vestingAmount == 0) revert MintAmountTooSmall(); + if (vestingAmount > type(uint128).max) revert VestingAmountTooLarge(vestingAmount); + uint128 streamAmount = SafeCast.toUint128(vestingAmount); + + IERC20 token = IERC20(address(rewardToken)); + uint256 balanceBefore = token.balanceOf(address(this)); + + rewardToken.mint(to, amount); + rewardToken.mint(address(this), vestingAmount); + + token.forceApprove(address(sablierLockup), vestingAmount); + streamId = sablierLockup.createWithDurationsLL( + Lockup.CreateWithDurations({ + sender: address(this), + recipient: recipient, + depositAmount: streamAmount, + token: token, + cancelable: false, + transferable: false, + shape: "Deepstate allocation" + }), + LockupLinear.UnlockAmounts({start: 0, cliff: 0}), + 0, + LockupLinear.Durations({cliff: 0, total: VESTING_DURATION}) + ); + token.forceApprove(address(sablierLockup), 0); + + uint256 balanceAfter = token.balanceOf(address(this)); + if (balanceAfter != balanceBefore) revert StreamFundingMismatch(balanceBefore, balanceAfter); + + emit MintedWithVesting(msg.sender, to, amount, recipient, vestingAmount, streamId); + } +} diff --git a/src/DeepstateRewarderFactory.sol b/src/DeepstateRewarderFactory.sol index 7cb1074..ec99799 100644 --- a/src/DeepstateRewarderFactory.sol +++ b/src/DeepstateRewarderFactory.sol @@ -6,10 +6,11 @@ import {Ownable} from "solady/auth/Ownable.sol"; import {DeepstateRewarderV2} from "./DeepstateRewarderV2.sol"; import {DeepstateRouterController, IDeepstateRouterAdmin} from "./DeepstateRouterController.sol"; import {DeepstateToken} from "./DeepstateToken.sol"; +import {IDeepstateMinterController} from "./interfaces/IDeepstateMinterController.sol"; /// @title Deepstate Rewarder Factory /// @notice Governance-owned CREATE2 factory for controller-launched market reward programs. -/// @dev The factory must hold DEEP's MINTER_ROLE and be the router controller's hook manager. +/// @dev The factory must hold the minter controller's MINTER_ROLE and be the router controller's hook manager. contract DeepstateRewarderFactory is Ownable { struct MarketConfig { address token0; @@ -33,6 +34,7 @@ contract DeepstateRewarderFactory is Ownable { DeepstateRouterController public immutable routerController; IDeepstateRouterAdmin public immutable deepstate; + IDeepstateMinterController public immutable minterController; DeepstateToken public immutable rewardToken; /// @notice Revocable address permitted to launch and retire factory markets. @@ -61,6 +63,8 @@ contract DeepstateRewarderFactory is Ownable { error InvalidOwner(); error InvalidRouterController(); error RouterControllerOwnerMismatch(address expected, address actual); + error InvalidMinterController(); + error MinterControllerAdminMismatch(address expected); error InvalidRewardToken(); error InvalidPool(); error InvalidHookFlags(); @@ -70,12 +74,14 @@ contract DeepstateRewarderFactory is Ownable { error UnexpectedPoolHook(bytes32 poolId, address expected, address actual); error MarketNotActive(bytes32 poolId); - constructor(address owner_, address routerController_, address rewardToken_) { + constructor(address owner_, address routerController_, address minterController_) { if (owner_ == address(0)) revert InvalidOwner(); if (routerController_ == address(0) || routerController_.code.length == 0) { revert InvalidRouterController(); } - if (rewardToken_ == address(0) || rewardToken_.code.length == 0) revert InvalidRewardToken(); + if (minterController_ == address(0) || minterController_.code.length == 0) { + revert InvalidMinterController(); + } _initializeOwner(owner_); routerController = DeepstateRouterController(routerController_); @@ -84,6 +90,12 @@ contract DeepstateRewarderFactory is Ownable { revert RouterControllerOwnerMismatch(owner_, routerControllerOwner); } deepstate = routerController.deepstate(); + minterController = IDeepstateMinterController(minterController_); + if (!minterController.hasRole(bytes32(0), owner_)) { + revert MinterControllerAdminMismatch(owner_); + } + address rewardToken_ = minterController.rewardToken(); + if (rewardToken_ == address(0) || rewardToken_.code.length == 0) revert InvalidRewardToken(); rewardToken = DeepstateToken(rewardToken_); } @@ -140,7 +152,7 @@ contract DeepstateRewarderFactory is Ownable { activeRewarder[poolId_] = address(rewarder); rewarderPool[address(rewarder)] = poolId_; - rewardToken.mint(address(rewarder), INITIAL_FUNDING); + minterController.mint(address(rewarder), INITIAL_FUNDING); routerController.setPoolHookConfig( config.token0, config.token1, address(rewarder), config.token0Active, config.token1Active ); diff --git a/src/interfaces/IDeepstateMinterController.sol b/src/interfaces/IDeepstateMinterController.sol new file mode 100644 index 0000000..31b34b1 --- /dev/null +++ b/src/interfaces/IDeepstateMinterController.sol @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.28; + +/// @notice Factory-facing interface for policy-controlled DEEP minting. +interface IDeepstateMinterController { + function hasRole(bytes32 role, address account) external view returns (bool); + function rewardToken() external view returns (address); + function mint(address to, uint256 amount) external returns (uint256 streamId); +} 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..d516294 --- /dev/null +++ b/test/DeepstateMinterController.t.sol @@ -0,0 +1,245 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity 0.8.28; + +import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; +import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; +import {Test} from "forge-std/Test.sol"; + +import {DeepstateMinterController} from "../src/DeepstateMinterController.sol"; +import {DeepstateToken} from "../src/DeepstateToken.sol"; +import {MockSablierLockupLinearV4} from "./mocks/MockSablierLockupLinearV4.sol"; + +contract DeepstateMinterControllerTest is Test { + DeepstateToken internal deep; + DeepstateMinterController internal minterController; + MockSablierLockupLinearV4 internal sablier; + + address internal recipient = makeAddr("recipient"); + address internal mintRecipient = makeAddr("mintRecipient"); + address internal unauthorized = makeAddr("unauthorized"); + + function setUp() public { + deep = new DeepstateToken(address(this), "Deepstate", "DEEP"); + sablier = new MockSablierLockupLinearV4(); + minterController = new DeepstateMinterController(address(this), address(deep), address(sablier), recipient); + + deep.grantRole(deep.MINTER_ROLE(), address(minterController)); + minterController.grantRole(minterController.MINTER_ROLE(), address(this)); + } + + function test_ImmutableConfigurationAndInitialAuthority() public view { + assertEq(address(minterController.rewardToken()), address(deep)); + assertEq(address(minterController.sablierLockup()), address(sablier)); + assertEq(minterController.recipient(), recipient); + assertEq(minterController.RECIPIENT_ALLOCATION_BPS(), 3_000); + assertEq(minterController.BPS_DENOMINATOR(), 10_000); + assertEq(minterController.VESTING_DURATION(), 365 days); + assertTrue(minterController.hasRole(minterController.DEFAULT_ADMIN_ROLE(), address(this))); + assertTrue(minterController.hasRole(minterController.MINTER_ROLE(), address(this))); + assertTrue(deep.hasRole(deep.MINTER_ROLE(), address(minterController))); + } + + function test_ConstructorValidation() public { + vm.expectRevert(DeepstateMinterController.InvalidAdmin.selector); + new DeepstateMinterController(address(0), address(deep), address(sablier), recipient); + + vm.expectRevert(DeepstateMinterController.InvalidRewardToken.selector); + new DeepstateMinterController(address(this), address(0), address(sablier), recipient); + + vm.expectRevert(DeepstateMinterController.InvalidRewardToken.selector); + new DeepstateMinterController(address(this), unauthorized, address(sablier), recipient); + + vm.expectRevert(DeepstateMinterController.InvalidSablierLockup.selector); + new DeepstateMinterController(address(this), address(deep), address(0), recipient); + + vm.expectRevert(DeepstateMinterController.InvalidSablierLockup.selector); + new DeepstateMinterController(address(this), address(deep), unauthorized, recipient); + + vm.expectRevert(DeepstateMinterController.InvalidRecipient.selector); + new DeepstateMinterController(address(this), address(deep), address(sablier), address(0)); + } + + function test_MintCreatesExactNonCancelableOneYearStream() public { + uint256 amount = 100_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_EachMintCreatesAnIndependentStream() public { + uint256 firstStreamId = minterController.mint(mintRecipient, 100e18); + vm.warp(block.timestamp + 30 days); + uint256 secondStreamId = minterController.mint(mintRecipient, 200e18); + + assertEq(firstStreamId, 1); + assertEq(secondStreamId, 2); + assertEq(sablier.stream(firstStreamId).depositAmount, 30e18); + assertEq(sablier.stream(secondStreamId).depositAmount, 60e18); + assertEq(deep.balanceOf(mintRecipient), 300e18); + assertEq(deep.balanceOf(address(sablier)), 90e18); + } + + function test_MintRoundsRecipientAllocationDown() public { + minterController.mint(mintRecipient, 4); + + assertEq(deep.balanceOf(mintRecipient), 4); + assertEq(deep.balanceOf(address(sablier)), 1); + assertEq(deep.totalSupply(), 5); + } + + 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( + abi.encodeWithSelector( + IAccessControl.AccessControlUnauthorizedAccount.selector, unauthorized, minterController.MINTER_ROLE() + ) + ); + vm.prank(unauthorized); + minterController.mint(mintRecipient, 100e18); + } + + function test_AdminCanGrantAndRevokeControllerMinterRole() public { + minterController.grantRole(minterController.MINTER_ROLE(), unauthorized); + vm.prank(unauthorized); + minterController.mint(mintRecipient, 100e18); + + minterController.revokeRole(minterController.MINTER_ROLE(), unauthorized); + vm.expectRevert( + abi.encodeWithSelector( + IAccessControl.AccessControlUnauthorizedAccount.selector, unauthorized, minterController.MINTER_ROLE() + ) + ); + vm.prank(unauthorized); + minterController.mint(mintRecipient, 100e18); + } + + function test_RevertWhenNonAdminChangesMinterRole() public { + bytes32 minterRole = minterController.MINTER_ROLE(); + bytes32 adminRole = minterController.DEFAULT_ADMIN_ROLE(); + vm.expectRevert( + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, unauthorized, adminRole) + ); + vm.prank(unauthorized); + minterController.grantRole(minterRole, unauthorized); + } + + function test_RevertForZeroMintRecipientOrDustAmount() public { + vm.expectRevert(DeepstateMinterController.InvalidMintRecipient.selector); + minterController.mint(address(0), 100e18); + + vm.expectRevert(DeepstateMinterController.MintAmountTooSmall.selector); + minterController.mint(mintRecipient, 3); + + assertEq(deep.totalSupply(), 0); + assertEq(sablier.nextStreamId(), 1); + } + + function test_RevertWhenVestingAmountExceedsSablierUint128Limit() public { + uint256 amount = Math.mulDiv(uint256(type(uint128).max) + 1, 10_000, 3_000, Math.Rounding.Ceil); + uint256 vestingAmount = Math.mulDiv(amount, 3_000, 10_000); + + 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.grantRole(minterController.MINTER_ROLE(), address(sablier)); + sablier.setReentry( + address(minterController), abi.encodeCall(DeepstateMinterController.mint, (mintRecipient, 100e18)) + ); + + vm.expectRevert(ReentrancyGuard.ReentrancyGuardReentrantCall.selector); + minterController.mint(mintRecipient, 100e18); + + assertEq(deep.totalSupply(), 0); + assertEq(sablier.nextStreamId(), 1); + } + + function test_MissingSablierTokenPullRollsBackBothMintsAndStream() public { + sablier.setSkipTokenPull(true); + + vm.expectRevert(abi.encodeWithSelector(DeepstateMinterController.StreamFundingMismatch.selector, 0, 30e18)); + 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 testFuzz_MintAlwaysCreatesExactAdditionalAllocation(uint128 rawAmount) public { + uint256 amount = bound(uint256(rawAmount), 4, type(uint96).max); + uint256 expectedVesting = Math.mulDiv(amount, 3_000, 10_000); + + uint256 streamId = minterController.mint(mintRecipient, amount); + + assertEq(deep.balanceOf(mintRecipient), amount); + assertEq(deep.balanceOf(address(sablier)), expectedVesting); + assertEq(deep.totalSupply(), amount + expectedVesting); + assertEq(sablier.stream(streamId).depositAmount, expectedVesting); + } +} diff --git a/test/DeepstateMinterControllerSablierIntegration.t.sol b/test/DeepstateMinterControllerSablierIntegration.t.sol new file mode 100644 index 0000000..436c77d --- /dev/null +++ b/test/DeepstateMinterControllerSablierIntegration.t.sol @@ -0,0 +1,88 @@ +// 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 { + 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); + + deep.grantRole(deep.MINTER_ROLE(), address(minterController)); + minterController.grantRole(minterController.MINTER_ROLE(), address(this)); + } + + function test_RealSablierV4StreamVestsLinearlyForOneYear() public { + uint40 startTime = uint40(block.timestamp); + uint128 vestingAmount = 30e18; + uint256 streamId = minterController.mint(mintRecipient, 100e18); + + 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(address(sablier)), vestingAmount); + + vm.warp(startTime + 182.5 days); + 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, 100e18); + + 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); + } +} diff --git a/test/DeepstateRewarderFactory.t.sol b/test/DeepstateRewarderFactory.t.sol index 15660f3..30b8b6a 100644 --- a/test/DeepstateRewarderFactory.t.sol +++ b/test/DeepstateRewarderFactory.t.sol @@ -7,10 +7,33 @@ 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 {DeepstateRouterController} from "../src/DeepstateRouterController.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 _rewardToken; + + constructor(address rewardToken_) { + _rewardToken = rewardToken_; + } + + function hasRole(bytes32, address) external pure returns (bool) { + return true; + } + + function rewardToken() external view returns (address) { + return _rewardToken; + } + + function mint(address, uint256) external pure returns (uint256) { + return 0; + } +} contract DeepstateRewarderFactoryTest is Test { address internal constant TOKEN_A = address(0x1000); @@ -21,20 +44,25 @@ contract DeepstateRewarderFactoryTest is Test { DeepstateToken internal deep; DeepstateV1 internal deepstate; DeepstateRouterController internal routerController; + DeepstateMinterController internal minterController; DeepstateRewarderFactory internal factory; + MockSablierLockupLinearV4 internal sablier; address internal controller = makeAddr("controller"); 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(); routerController = new DeepstateRouterController(address(this), address(deepstate)); - factory = new DeepstateRewarderFactory(address(this), address(routerController), address(deep)); + minterController = _newMinterController(address(this), deep); + factory = new DeepstateRewarderFactory(address(this), address(routerController), address(minterController)); - deep.grantRole(deep.MINTER_ROLE(), address(factory)); + minterController.grantRole(minterController.MINTER_ROLE(), address(factory)); deepstate.transferOwnership(address(routerController)); routerController.setHookManager(address(factory)); factory.setController(controller); @@ -45,12 +73,15 @@ contract DeepstateRewarderFactoryTest is Test { assertEq(factory.controller(), controller); assertEq(address(factory.routerController()), address(routerController)); 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(factory))); + assertTrue(deep.hasRole(deep.MINTER_ROLE(), address(minterController))); + assertFalse(deep.hasRole(deep.MINTER_ROLE(), address(factory))); + assertTrue(minterController.hasRole(minterController.MINTER_ROLE(), address(factory))); assertEq(deepstate.owner(), address(routerController)); assertEq(routerController.hookManager(), address(factory)); assertEq(factory.nextDeploymentAt(), 0); @@ -59,13 +90,13 @@ contract DeepstateRewarderFactoryTest is Test { function test_ConstructorValidation() public { vm.expectRevert(DeepstateRewarderFactory.InvalidOwner.selector); - new DeepstateRewarderFactory(address(0), address(routerController), address(deep)); + new DeepstateRewarderFactory(address(0), address(routerController), address(minterController)); vm.expectRevert(DeepstateRewarderFactory.InvalidRouterController.selector); - new DeepstateRewarderFactory(address(this), address(0), address(deep)); + new DeepstateRewarderFactory(address(this), address(0), address(minterController)); vm.expectRevert(DeepstateRewarderFactory.InvalidRouterController.selector); - new DeepstateRewarderFactory(address(this), alice, address(deep)); + new DeepstateRewarderFactory(address(this), alice, address(minterController)); DeepstateRouterController mismatchedController = new DeepstateRouterController(alice, address(deepstate)); vm.expectRevert( @@ -73,13 +104,27 @@ contract DeepstateRewarderFactoryTest is Test { DeepstateRewarderFactory.RouterControllerOwnerMismatch.selector, address(this), alice ) ); - new DeepstateRewarderFactory(address(this), address(mismatchedController), address(deep)); + new DeepstateRewarderFactory(address(this), address(mismatchedController), address(minterController)); - vm.expectRevert(DeepstateRewarderFactory.InvalidRewardToken.selector); + vm.expectRevert(DeepstateRewarderFactory.InvalidMinterController.selector); new DeepstateRewarderFactory(address(this), address(routerController), address(0)); - vm.expectRevert(DeepstateRewarderFactory.InvalidRewardToken.selector); + vm.expectRevert(DeepstateRewarderFactory.InvalidMinterController.selector); new DeepstateRewarderFactory(address(this), address(routerController), alice); + + DeepstateMinterController mismatchedMinter = _newMinterController(alice, deep); + vm.expectRevert( + abi.encodeWithSelector(DeepstateRewarderFactory.MinterControllerAdminMismatch.selector, address(this)) + ); + new DeepstateRewarderFactory(address(this), address(routerController), address(mismatchedMinter)); + + InvalidRewardTokenMinterController zeroTokenController = new InvalidRewardTokenMinterController(address(0)); + vm.expectRevert(DeepstateRewarderFactory.InvalidRewardToken.selector); + new DeepstateRewarderFactory(address(this), address(routerController), address(zeroTokenController)); + + InvalidRewardTokenMinterController eoaTokenController = new InvalidRewardTokenMinterController(alice); + vm.expectRevert(DeepstateRewarderFactory.InvalidRewardToken.selector); + new DeepstateRewarderFactory(address(this), address(routerController), address(eoaTokenController)); } function test_GovernanceOwnerIsIndependentFromFactoryDeployer() public { @@ -88,15 +133,16 @@ contract DeepstateRewarderFactoryTest is Test { DeepstateV1 secondRouter = new DeepstateV1(); DeepstateRouterController secondRouterController = new DeepstateRouterController(governance, address(secondRouter)); + DeepstateMinterController secondMinterController = _newMinterController(governance, secondToken); DeepstateRewarderFactory secondFactory = - new DeepstateRewarderFactory(governance, address(secondRouterController), address(secondToken)); - secondToken.grantRole(secondToken.MINTER_ROLE(), address(secondFactory)); + new DeepstateRewarderFactory(governance, address(secondRouterController), address(secondMinterController)); secondRouter.transferOwnership(address(secondRouterController)); vm.expectRevert(Ownable.Unauthorized.selector); secondFactory.setController(controller); vm.startPrank(governance); + secondMinterController.grantRole(secondMinterController.MINTER_ROLE(), address(secondFactory)); secondRouterController.setHookManager(address(secondFactory)); secondFactory.setController(controller); vm.stopPrank(); @@ -107,6 +153,7 @@ contract DeepstateRewarderFactoryTest is Test { assertEq(rewarder.owner(), governance); assertEq(rewarder.factory(), address(secondFactory)); assertEq(secondToken.balanceOf(address(rewarder)), 100_000_000e18); + assertEq(secondToken.balanceOf(address(sablier)), 30_000_000e18); } function test_ControllerDeploysPredictedCreate2MarketWithFixedScheduleAndFunding() public { @@ -136,7 +183,8 @@ contract DeepstateRewarderFactoryTest is Test { assertEq(rewarder.token1StartQuantity(), 1e6); assertEq(rewarder.token1MaxQuantity(), 1_000_000e6); assertEq(deep.balanceOf(address(rewarder)), 100_000_000e18); - assertEq(deep.totalSupply(), 100_000_000e18); + assertEq(deep.balanceOf(address(sablier)), 30_000_000e18); + assertEq(deep.totalSupply(), 130_000_000e18); assertEq(deepstate.poolHook(poolId), address(rewarder)); assertEq(factory.activeRewarder(poolId), address(rewarder)); assertEq(factory.rewarderPool(address(rewarder)), poolId); @@ -165,7 +213,8 @@ contract DeepstateRewarderFactoryTest is Test { assertEq(factory.deploymentCount(), 2); assertEq(deep.balanceOf(address(second)), 100_000_000e18); - assertEq(deep.totalSupply(), 200_000_000e18); + assertEq(deep.balanceOf(address(sablier)), 60_000_000e18); + assertEq(deep.totalSupply(), 260_000_000e18); } function test_GovernanceCanDeployWithoutControllerButStillObeysCooldown() public { @@ -221,7 +270,8 @@ contract DeepstateRewarderFactoryTest is Test { assertEq(factory.rewarderPool(address(rewarder)), bytes32(0)); assertEq(deep.balanceOf(address(rewarder)), 0); assertEq(deep.balanceOf(address(factory)), 0); - assertEq(deep.totalSupply(), 0); + assertEq(deep.balanceOf(address(sablier)), 30_000_000e18); + assertEq(deep.totalSupply(), 30_000_000e18); } function test_ControllerCannotWithdrawFromRewarderDirectly() public { @@ -240,16 +290,17 @@ contract DeepstateRewarderFactoryTest is Test { vm.prank(controller); DeepstateRewarderV2 rewarder = factory.deployMarket(_market(TOKEN_A, TOKEN_B)); - deep.grantRole(deep.MINTER_ROLE(), address(this)); - deep.mint(address(rewarder), 900_000_000e18); - deep.revokeRole(deep.MINTER_ROLE(), address(this)); + minterController.grantRole(minterController.MINTER_ROLE(), address(this)); + minterController.mint(address(rewarder), 900_000_000e18); + minterController.revokeRole(minterController.MINTER_ROLE(), address(this)); assertEq(deep.balanceOf(address(rewarder)), 1_000_000_000e18); + assertEq(deep.balanceOf(address(sablier)), 300_000_000e18); vm.prank(controller); uint256 burned = factory.removeMarket(TOKEN_A, TOKEN_B); assertEq(burned, 1_000_000_000e18); - assertEq(deep.totalSupply(), 0); + assertEq(deep.totalSupply(), 300_000_000e18); } function test_GovernanceCanRemoveMarketAfterRevokingController() public { @@ -260,7 +311,7 @@ contract DeepstateRewarderFactoryTest is Test { uint256 burned = factory.removeMarket(TOKEN_A, TOKEN_B); assertEq(burned, 100_000_000e18); - assertEq(deep.totalSupply(), 0); + assertEq(deep.totalSupply(), 30_000_000e18); } function test_RemovedPoolCanBeResetWithFreshCreate2AddressAfterCooldown() public { @@ -281,7 +332,9 @@ contract DeepstateRewarderFactoryTest is Test { assertNotEq(address(first), address(second)); assertEq(address(second), predicted); assertEq(factory.activeRewarder(_poolId(TOKEN_A, TOKEN_B)), address(second)); - assertEq(deep.totalSupply(), 100_000_000e18); + assertEq(deep.balanceOf(address(second)), 100_000_000e18); + assertEq(deep.balanceOf(address(sablier)), 60_000_000e18); + assertEq(deep.totalSupply(), 160_000_000e18); } function test_CannotDeployOverActiveFactoryMarketOrExistingRouterHook() public { @@ -301,10 +354,10 @@ contract DeepstateRewarderFactoryTest is Test { DeepstateRouterController secondRouterController = new DeepstateRouterController(address(this), address(secondRouter)); DeepstateRewarderFactory secondFactory = - new DeepstateRewarderFactory(address(this), address(secondRouterController), address(deep)); + new DeepstateRewarderFactory(address(this), address(secondRouterController), address(minterController)); secondRouter.setPoolHookConfig(TOKEN_C, TOKEN_D, alice, true, false); secondRouter.transferOwnership(address(secondRouterController)); - deep.grantRole(deep.MINTER_ROLE(), address(secondFactory)); + minterController.grantRole(minterController.MINTER_ROLE(), address(secondFactory)); secondRouterController.setHookManager(address(secondFactory)); secondFactory.setController(controller); @@ -322,8 +375,10 @@ contract DeepstateRewarderFactoryTest is Test { DeepstateV1 secondRouter = new DeepstateV1(); DeepstateRouterController secondRouterController = new DeepstateRouterController(address(this), address(secondRouter)); - DeepstateRewarderFactory secondFactory = - new DeepstateRewarderFactory(address(this), address(secondRouterController), address(secondToken)); + DeepstateMinterController secondMinterController = _newMinterController(address(this), secondToken); + DeepstateRewarderFactory secondFactory = new DeepstateRewarderFactory( + address(this), address(secondRouterController), address(secondMinterController) + ); secondRouter.transferOwnership(address(secondRouterController)); secondRouterController.setHookManager(address(secondFactory)); secondFactory.setController(controller); @@ -335,7 +390,7 @@ contract DeepstateRewarderFactoryTest is Test { abi.encodeWithSelector( IAccessControl.AccessControlUnauthorizedAccount.selector, address(secondFactory), - secondToken.MINTER_ROLE() + secondMinterController.MINTER_ROLE() ) ); vm.prank(controller); @@ -353,9 +408,11 @@ contract DeepstateRewarderFactoryTest is Test { DeepstateV1 secondRouter = new DeepstateV1(); DeepstateRouterController secondRouterController = new DeepstateRouterController(address(this), address(secondRouter)); - DeepstateRewarderFactory secondFactory = - new DeepstateRewarderFactory(address(this), address(secondRouterController), address(secondToken)); - secondToken.grantRole(secondToken.MINTER_ROLE(), address(secondFactory)); + DeepstateMinterController secondMinterController = _newMinterController(address(this), secondToken); + DeepstateRewarderFactory secondFactory = new DeepstateRewarderFactory( + address(this), address(secondRouterController), address(secondMinterController) + ); + secondMinterController.grantRole(secondMinterController.MINTER_ROLE(), address(secondFactory)); secondRouterController.setHookManager(address(secondFactory)); secondFactory.setController(controller); @@ -376,9 +433,11 @@ contract DeepstateRewarderFactoryTest is Test { DeepstateV1 secondRouter = new DeepstateV1(); DeepstateRouterController secondRouterController = new DeepstateRouterController(address(this), address(secondRouter)); - DeepstateRewarderFactory secondFactory = - new DeepstateRewarderFactory(address(this), address(secondRouterController), address(secondToken)); - secondToken.grantRole(secondToken.MINTER_ROLE(), address(secondFactory)); + DeepstateMinterController secondMinterController = _newMinterController(address(this), secondToken); + DeepstateRewarderFactory secondFactory = new DeepstateRewarderFactory( + address(this), address(secondRouterController), address(secondMinterController) + ); + secondMinterController.grantRole(secondMinterController.MINTER_ROLE(), address(secondFactory)); secondRouter.transferOwnership(address(secondRouterController)); secondFactory.setController(controller); @@ -413,7 +472,7 @@ contract DeepstateRewarderFactoryTest is Test { assertEq(burned, 100_000_000e18); assertEq(factory.activeRewarder(poolId), address(0)); assertEq(deep.balanceOf(address(rewarder)), 0); - assertEq(deep.totalSupply(), 0); + assertEq(deep.totalSupply(), 30_000_000e18); } function test_RemovalRejectsUnexpectedReplacementHook() public { @@ -504,4 +563,12 @@ contract DeepstateRewarderFactoryTest is Test { function _poolId(address token0, address token1) internal pure returns (bytes32) { return keccak256(abi.encode(token0, token1)); } + + function _newMinterController(address admin, DeepstateToken token) + internal + returns (DeepstateMinterController controller_) + { + controller_ = new DeepstateMinterController(admin, address(token), address(sablier), vestingRecipient); + token.grantRole(token.MINTER_ROLE(), address(controller_)); + } } diff --git a/test/mocks/MockSablierLockupLinearV4.sol b/test/mocks/MockSablierLockupLinearV4.sol new file mode 100644 index 0000000..1a99173 --- /dev/null +++ b/test/mocks/MockSablierLockupLinearV4.sol @@ -0,0 +1,93 @@ +// 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; + bool public skipTokenPull; + address public reentryTarget; + bytes public reentryData; + mapping(uint256 streamId => Stream stream) private _streams; + + error CreateReverted(); + + function setRevertCreate(bool value) external { + revertCreate = value; + } + + function setSkipTokenPull(bool value) external { + skipTokenPull = 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 + }); + + if (!skipTokenPull) params.token.safeTransferFrom(msg.sender, address(this), params.depositAmount); + } +} From 61e1f2b28a54da0b9e4f6a6159060c0814b1d316 Mon Sep 17 00:00:00 2001 From: Joseph Delong Date: Sat, 29 Aug 2026 22:33:33 -0500 Subject: [PATCH 05/25] ci: size-check production contracts only --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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) From 90cf4bdc3785f536f39119546ada995ffa27d08b Mon Sep 17 00:00:00 2001 From: Joseph Delong Date: Sat, 29 Aug 2026 22:55:30 -0500 Subject: [PATCH 06/25] feat: return DEEP administration after issuance term --- README.md | 27 ++- src/DeepstateMinterController.sol | 106 ++++++++- test/DeepstateMinterController.t.sol | 220 +++++++++++++++++- ...teMinterControllerSablierIntegration.t.sol | 13 +- test/DeepstateRewarderFactory.t.sol | 4 +- 5 files changed, 339 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 7c59178..b9b1d47 100644 --- a/README.md +++ b/README.md @@ -64,10 +64,18 @@ proposals can therefore execute immediately after voting ends. its `MINTER_ROLE` only to approved issuance contracts, such as `DeepstateRewarderFactory`. For every requested mint `M`, the controller mints `M` to the requested address and an additional `floor(30% * M)` to Sablier -Lockup v4.0.1. Each recipient allocation gets its own linear 365-day stream. +Lockup v4.0.1. Each recipient allocation gets its own linear two-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 additional 30% allocation. 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 30% is additional issuance, not a split of `M`. A factory market therefore receives its complete 100,000,000 DEEP initial funding while a separate 30,000,000 DEEP stream is created, for 130,000,000 DEEP total issuance. If a @@ -75,9 +83,20 @@ 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 administers both -role systems and must not grant the token-level role directly to the factory or -another minter that can bypass the controller. +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 activates the term only after granting the token admin role +to the controller. Activation also ensures the controller has the token minter +role. Governance may return token administration early; at or after the exact +two-year deadline, anyone may trigger the return. The return 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 retains its ordinary token minter role until governance revokes +it after regaining token administration. ## Reward Schedule diff --git a/src/DeepstateMinterController.sol b/src/DeepstateMinterController.sol index 1fa7779..a11b79e 100644 --- a/src/DeepstateMinterController.sol +++ b/src/DeepstateMinterController.sol @@ -9,6 +9,7 @@ import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol"; import {Lockup} from "@sablier/lockup/src/types/Lockup.sol"; import {LockupLinear} from "@sablier/lockup/src/types/LockupLinear.sol"; +import {Ownable} from "solady/auth/Ownable.sol"; import {DeepstateToken} from "./DeepstateToken.sol"; import {ISablierLockupLinearV4} from "./interfaces/ISablierLockupLinearV4.sol"; @@ -16,18 +17,26 @@ import {ISablierLockupLinearV4} from "./interfaces/ISablierLockupLinearV4.sol"; /// @title Deepstate Minter Controller /// @notice Enforces an additional 30% recipient allocation on every authorized DEEP mint. /// @dev The recipient allocation is placed in a new non-cancelable, non-transferable Sablier -/// Lockup v4 linear stream. This contract must be DEEP's only operational MINTER_ROLE holder. -contract DeepstateMinterController is AccessControl, ReentrancyGuard { +/// Lockup v4 linear stream. This contract temporarily administers DEEP while remaining owned by governance. +contract DeepstateMinterController is AccessControl, Ownable, ReentrancyGuard { using SafeERC20 for IERC20; bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE"); uint256 public constant RECIPIENT_ALLOCATION_BPS = 3_000; uint256 public constant BPS_DENOMINATOR = 10_000; - uint40 public constant VESTING_DURATION = 365 days; + uint40 public constant VESTING_DURATION = 2 * 365 days; + uint40 public constant TOKEN_ADMINISTRATION_DURATION = 2 * 365 days; DeepstateToken public immutable rewardToken; 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 Timestamp after which anyone may return DEEP administration to this contract's owner. + uint40 public tokenAdministrationEndsAt; + /// @notice Whether DEEP administration has already been returned. + bool public tokenAdministrationReturned; event MintedWithVesting( address indexed caller, @@ -37,29 +46,79 @@ contract DeepstateMinterController is AccessControl, ReentrancyGuard { uint256 vestingAmount, uint256 streamId ); + event TokenAdministrationActivated(uint40 indexed endsAt); + event TokenAdministrationReturned(address indexed owner, address indexed caller); - error InvalidAdmin(); + error InvalidOwner(); error InvalidRewardToken(); error InvalidSablierLockup(); error InvalidRecipient(); + error InvalidMintCap(); error InvalidMintRecipient(); error MintAmountTooSmall(); error VestingAmountTooLarge(uint256 amount); error StreamFundingMismatch(uint256 expectedBalance, uint256 actualBalance); - - constructor(address admin_, address rewardToken_, address sablierLockup_, address recipient_) { - if (admin_ == address(0)) revert InvalidAdmin(); + error ControllerNotTokenAdmin(); + error TokenAdministrationAlreadyActivated(); + error TokenAdministrationAlreadyReturned(); + error TokenAdministrationNotActive(); + error TokenAdministrationActive(uint40 endsAt); + error OwnerMustRetainDefaultAdmin(); + error MintCapExceeded(uint256 cap, uint256 attemptedSupply); + + constructor(address owner_, address rewardToken_, address sablierLockup_, address recipient_, uint256 mintCap_) { + if (owner_ == address(0)) revert InvalidOwner(); if (rewardToken_ == address(0) || rewardToken_.code.length == 0) revert InvalidRewardToken(); if (sablierLockup_ == address(0) || sablierLockup_.code.length == 0) revert InvalidSablierLockup(); if (recipient_ == address(0)) revert InvalidRecipient(); + if (mintCap_ == 0) revert InvalidMintCap(); rewardToken = DeepstateToken(rewardToken_); sablierLockup = ISablierLockupLinearV4(sablierLockup_); recipient = recipient_; - _grantRole(DEFAULT_ADMIN_ROLE, admin_); + mintCap = mintCap_; + _initializeOwner(owner_); + _grantRole(DEFAULT_ADMIN_ROLE, owner_); + } + + /// @notice Start the two-year DEEP administration term after this contract receives token admin authority. + /// @dev Also ensures this controller holds DEEP's operational minter role. + function activateTokenAdministration() external onlyOwner { + if (tokenAdministrationEndsAt != 0) revert TokenAdministrationAlreadyActivated(); + + bytes32 tokenAdminRole = rewardToken.DEFAULT_ADMIN_ROLE(); + if (!rewardToken.hasRole(tokenAdminRole, address(this))) revert ControllerNotTokenAdmin(); + if (!rewardToken.hasRole(rewardToken.MINTER_ROLE(), address(this))) { + rewardToken.grantRole(rewardToken.MINTER_ROLE(), address(this)); + } + + uint40 endsAt = SafeCast.toUint40(block.timestamp + TOKEN_ADMINISTRATION_DURATION); + tokenAdministrationEndsAt = endsAt; + emit TokenAdministrationActivated(endsAt); + } + + /// @notice Return DEEP administration to this contract's current governance owner. + /// @dev The owner may trigger an emergency return at any time. Anyone may trigger it after the term expires. + function returnTokenAdministration() external { + uint40 endsAt = tokenAdministrationEndsAt; + if (endsAt == 0) revert TokenAdministrationNotActive(); + if (tokenAdministrationReturned) revert TokenAdministrationAlreadyReturned(); + + address owner_ = owner(); + if (msg.sender != owner_ && block.timestamp < endsAt) revert TokenAdministrationActive(endsAt); + + bytes32 tokenAdminRole = rewardToken.DEFAULT_ADMIN_ROLE(); + if (!rewardToken.hasRole(tokenAdminRole, address(this))) revert ControllerNotTokenAdmin(); + + tokenAdministrationReturned = true; + // Grant first so DeepstateToken's final-admin invariant cannot strand the token. + rewardToken.grantRole(tokenAdminRole, owner_); + rewardToken.renounceRole(tokenAdminRole, address(this)); + + emit TokenAdministrationReturned(owner_, msg.sender); } - /// @notice Mint `amount` DEEP to `to` and an additional 30% into a one-year recipient stream. + /// @notice Mint `amount` DEEP to `to` and an additional 30% into a two-year recipient stream. /// @dev The 30% calculation rounds down. Amounts that round the stream allocation to zero revert. function mint(address to, uint256 amount) external onlyRole(MINTER_ROLE) nonReentrant returns (uint256 streamId) { if (to == address(0)) revert InvalidMintRecipient(); @@ -69,6 +128,10 @@ contract DeepstateMinterController is AccessControl, ReentrancyGuard { if (vestingAmount > type(uint128).max) revert VestingAmountTooLarge(vestingAmount); uint128 streamAmount = SafeCast.toUint128(vestingAmount); + uint256 mintSupply = amount + vestingAmount; + uint256 attemptedSupply = rewardToken.totalSupply() + mintSupply; + if (attemptedSupply > mintCap) revert MintCapExceeded(mintCap, attemptedSupply); + IERC20 token = IERC20(address(rewardToken)); uint256 balanceBefore = token.balanceOf(address(this)); @@ -97,4 +160,29 @@ contract DeepstateMinterController is AccessControl, ReentrancyGuard { emit MintedWithVesting(msg.sender, to, amount, recipient, vestingAmount, streamId); } + + /// @dev Keep EIP-173 ownership and AccessControl administration assigned to the same account. + function _setOwner(address newOwner) internal override { + if (newOwner == address(0)) revert NewOwnerIsZeroAddress(); + + address previousOwner = owner(); + super._setOwner(newOwner); + _grantRole(DEFAULT_ADMIN_ROLE, newOwner); + if (previousOwner != address(0)) _revokeRole(DEFAULT_ADMIN_ROLE, previousOwner); + } + + function grantRole(bytes32 role, address account) public override { + if (role == DEFAULT_ADMIN_ROLE && account != owner()) revert OwnerMustRetainDefaultAdmin(); + super.grantRole(role, account); + } + + function revokeRole(bytes32 role, address account) public override { + if (role == DEFAULT_ADMIN_ROLE && account == owner()) revert OwnerMustRetainDefaultAdmin(); + super.revokeRole(role, account); + } + + function renounceRole(bytes32 role, address callerConfirmation) public override { + if (role == DEFAULT_ADMIN_ROLE && callerConfirmation == owner()) revert OwnerMustRetainDefaultAdmin(); + super.renounceRole(role, callerConfirmation); + } } diff --git a/test/DeepstateMinterController.t.sol b/test/DeepstateMinterController.t.sol index d516294..68c57c9 100644 --- a/test/DeepstateMinterController.t.sol +++ b/test/DeepstateMinterController.t.sol @@ -5,12 +5,15 @@ import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol" import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.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 {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; @@ -18,11 +21,13 @@ contract DeepstateMinterControllerTest is Test { 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); + minterController = + new DeepstateMinterController(address(this), address(deep), address(sablier), recipient, MINT_CAP); deep.grantRole(deep.MINTER_ROLE(), address(minterController)); minterController.grantRole(minterController.MINTER_ROLE(), address(this)); @@ -32,35 +37,43 @@ contract DeepstateMinterControllerTest is Test { assertEq(address(minterController.rewardToken()), address(deep)); assertEq(address(minterController.sablierLockup()), address(sablier)); assertEq(minterController.recipient(), recipient); + assertEq(minterController.mintCap(), MINT_CAP); assertEq(minterController.RECIPIENT_ALLOCATION_BPS(), 3_000); assertEq(minterController.BPS_DENOMINATOR(), 10_000); - assertEq(minterController.VESTING_DURATION(), 365 days); + assertEq(minterController.VESTING_DURATION(), 2 * 365 days); + assertEq(minterController.TOKEN_ADMINISTRATION_DURATION(), 2 * 365 days); + assertEq(minterController.owner(), address(this)); + assertEq(minterController.tokenAdministrationEndsAt(), 0); + assertFalse(minterController.tokenAdministrationReturned()); assertTrue(minterController.hasRole(minterController.DEFAULT_ADMIN_ROLE(), address(this))); assertTrue(minterController.hasRole(minterController.MINTER_ROLE(), address(this))); assertTrue(deep.hasRole(deep.MINTER_ROLE(), address(minterController))); } function test_ConstructorValidation() public { - vm.expectRevert(DeepstateMinterController.InvalidAdmin.selector); - new DeepstateMinterController(address(0), address(deep), address(sablier), recipient); + vm.expectRevert(DeepstateMinterController.InvalidOwner.selector); + new DeepstateMinterController(address(0), address(deep), address(sablier), recipient, MINT_CAP); vm.expectRevert(DeepstateMinterController.InvalidRewardToken.selector); - new DeepstateMinterController(address(this), address(0), address(sablier), recipient); + new DeepstateMinterController(address(this), address(0), address(sablier), recipient, MINT_CAP); vm.expectRevert(DeepstateMinterController.InvalidRewardToken.selector); - new DeepstateMinterController(address(this), unauthorized, address(sablier), recipient); + new DeepstateMinterController(address(this), unauthorized, address(sablier), recipient, MINT_CAP); vm.expectRevert(DeepstateMinterController.InvalidSablierLockup.selector); - new DeepstateMinterController(address(this), address(deep), address(0), recipient); + new DeepstateMinterController(address(this), address(deep), address(0), recipient, MINT_CAP); vm.expectRevert(DeepstateMinterController.InvalidSablierLockup.selector); - new DeepstateMinterController(address(this), address(deep), unauthorized, recipient); + 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)); + 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 { + function test_MintCreatesExactNonCancelableTwoYearStream() public { uint256 amount = 100_000_000e18; uint256 vestingAmount = 30_000_000e18; @@ -90,7 +103,132 @@ contract DeepstateMinterControllerTest is Test { assertEq(created.cliffUnlockAmount, 0); assertEq(created.granularity, 0); assertEq(created.cliffDuration, 0); - assertEq(created.totalDuration, 365 days); + assertEq(created.totalDuration, 2 * 365 days); + } + + function test_ActivateTokenAdministrationStartsTwoYearTermAndEnsuresMinterRole() 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.activateTokenAdministration(); + + assertEq(minterController.tokenAdministrationEndsAt(), expectedEndsAt); + assertFalse(minterController.tokenAdministrationReturned()); + assertTrue(deep.hasRole(deep.DEFAULT_ADMIN_ROLE(), address(minterController))); + assertTrue(deep.hasRole(deep.MINTER_ROLE(), address(minterController))); + } + + function test_RevertActivationWithoutTokenAdminOrByNonOwnerOrTwice() public { + vm.expectRevert(Ownable.Unauthorized.selector); + vm.prank(unauthorized); + minterController.activateTokenAdministration(); + + vm.expectRevert(DeepstateMinterController.ControllerNotTokenAdmin.selector); + minterController.activateTokenAdministration(); + + deep.grantRole(deep.DEFAULT_ADMIN_ROLE(), address(minterController)); + minterController.activateTokenAdministration(); + + vm.expectRevert(DeepstateMinterController.TokenAdministrationAlreadyActivated.selector); + minterController.activateTokenAdministration(); + } + + function test_OwnerCanReturnTokenAdministrationEarlyWithoutAdminGap() public { + _activateSoleTokenAdministration(); + + vm.expectEmit(true, true, false, true, address(minterController)); + emit DeepstateMinterController.TokenAdministrationReturned(address(this), address(this)); + minterController.returnTokenAdministration(); + + assertTrue(minterController.tokenAdministrationReturned()); + assertTrue(deep.hasRole(deep.DEFAULT_ADMIN_ROLE(), address(this))); + assertFalse(deep.hasRole(deep.DEFAULT_ADMIN_ROLE(), address(minterController))); + assertEq(deep.defaultAdminCount(), 1); + assertTrue(deep.hasRole(deep.MINTER_ROLE(), address(minterController))); + } + + function test_AnyoneCanReturnTokenAdministrationAtExactDeadline() public { + _activateSoleTokenAdministration(); + uint40 endsAt = minterController.tokenAdministrationEndsAt(); + + vm.warp(endsAt - 1); + vm.expectRevert(abi.encodeWithSelector(DeepstateMinterController.TokenAdministrationActive.selector, endsAt)); + vm.prank(unauthorized); + minterController.returnTokenAdministration(); + + vm.warp(endsAt); + vm.prank(unauthorized); + minterController.returnTokenAdministration(); + + assertTrue(deep.hasRole(deep.DEFAULT_ADMIN_ROLE(), address(this))); + assertFalse(deep.hasRole(deep.DEFAULT_ADMIN_ROLE(), address(minterController))); + assertEq(deep.defaultAdminCount(), 1); + } + + function test_ReturnUsesCurrentOwnerAfterOwnershipTransfer() public { + _activateSoleTokenAdministration(); + + minterController.transferOwnership(newGovernance); + assertEq(minterController.owner(), newGovernance); + assertFalse(minterController.hasRole(minterController.DEFAULT_ADMIN_ROLE(), address(this))); + assertTrue(minterController.hasRole(minterController.DEFAULT_ADMIN_ROLE(), newGovernance)); + + vm.prank(newGovernance); + minterController.returnTokenAdministration(); + + 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_TwoStepOwnershipHandoverSynchronizesControllerAdmin() public { + vm.prank(newGovernance); + minterController.requestOwnershipHandover(); + minterController.completeOwnershipHandover(newGovernance); + + assertEq(minterController.owner(), newGovernance); + assertFalse(minterController.hasRole(minterController.DEFAULT_ADMIN_ROLE(), address(this))); + assertTrue(minterController.hasRole(minterController.DEFAULT_ADMIN_ROLE(), newGovernance)); + } + + function test_ControllerOwnerCannotRenounceOrLoseDefaultAdmin() public { + bytes32 controllerAdminRole = minterController.DEFAULT_ADMIN_ROLE(); + + vm.expectRevert(Ownable.NewOwnerIsZeroAddress.selector); + minterController.renounceOwnership(); + + vm.expectRevert(DeepstateMinterController.OwnerMustRetainDefaultAdmin.selector); + minterController.revokeRole(controllerAdminRole, address(this)); + + vm.expectRevert(DeepstateMinterController.OwnerMustRetainDefaultAdmin.selector); + minterController.renounceRole(controllerAdminRole, address(this)); + + vm.expectRevert(DeepstateMinterController.OwnerMustRetainDefaultAdmin.selector); + minterController.grantRole(controllerAdminRole, unauthorized); + } + + function test_RevertReturnBeforeActivationAfterReturnOrWithoutTokenAdmin() public { + vm.expectRevert(DeepstateMinterController.TokenAdministrationNotActive.selector); + minterController.returnTokenAdministration(); + + _activateSoleTokenAdministration(); + bytes32 tokenAdminRole = deep.DEFAULT_ADMIN_ROLE(); + vm.prank(address(minterController)); + deep.grantRole(tokenAdminRole, address(this)); + deep.revokeRole(tokenAdminRole, address(minterController)); + + vm.expectRevert(DeepstateMinterController.ControllerNotTokenAdmin.selector); + minterController.returnTokenAdministration(); + + deep.grantRole(tokenAdminRole, address(minterController)); + minterController.returnTokenAdministration(); + + vm.expectRevert(DeepstateMinterController.TokenAdministrationAlreadyReturned.selector); + minterController.returnTokenAdministration(); } function test_EachMintCreatesAnIndependentStream() public { @@ -114,6 +252,37 @@ contract DeepstateMinterControllerTest is Test { assertEq(deep.totalSupply(), 5); } + function test_MintCapIncludesExistingRequestedAndVestedSupply() public { + DeepstateMinterController cappedController = _newControllerWithCap(130e18); + + deep.grantRole(deep.MINTER_ROLE(), address(this)); + deep.mint(unauthorized, 5); + + vm.expectRevert(abi.encodeWithSelector(DeepstateMinterController.MintCapExceeded.selector, 130e18, 130e18 + 5)); + cappedController.mint(mintRecipient, 100e18); + + assertEq(deep.totalSupply(), 5); + assertEq(sablier.nextStreamId(), 1); + } + + function test_BurnReopensMintCapacity() public { + DeepstateMinterController cappedController = _newControllerWithCap(130e18); + + cappedController.mint(mintRecipient, 100e18); + assertEq(deep.totalSupply(), 130e18); + + vm.expectRevert(abi.encodeWithSelector(DeepstateMinterController.MintCapExceeded.selector, 130e18, 130e18 + 5)); + cappedController.mint(mintRecipient, 4); + + vm.prank(mintRecipient); + deep.burn(5); + cappedController.mint(mintRecipient, 4); + + assertEq(deep.totalSupply(), 130e18); + assertEq(deep.balanceOf(mintRecipient), 100e18 - 1); + assertEq(deep.balanceOf(address(sablier)), 30e18 + 1); + } + function test_MintPreservesPreexistingControllerBalance() public { deep.grantRole(deep.MINTER_ROLE(), address(this)); deep.mint(address(minterController), 11); @@ -149,6 +318,16 @@ contract DeepstateMinterControllerTest is Test { minterController.mint(mintRecipient, 100e18); } + function test_ControllerMinterCanRenounceRole() public { + bytes32 minterRole = minterController.MINTER_ROLE(); + minterController.grantRole(minterRole, unauthorized); + + vm.prank(unauthorized); + minterController.renounceRole(minterRole, unauthorized); + + assertFalse(minterController.hasRole(minterRole, unauthorized)); + } + function test_RevertWhenNonAdminChangesMinterRole() public { bytes32 minterRole = minterController.MINTER_ROLE(); bytes32 adminRole = minterController.DEFAULT_ADMIN_ROLE(); @@ -232,7 +411,8 @@ contract DeepstateMinterControllerTest is Test { } function testFuzz_MintAlwaysCreatesExactAdditionalAllocation(uint128 rawAmount) public { - uint256 amount = bound(uint256(rawAmount), 4, type(uint96).max); + uint256 maximumAmount = Math.mulDiv(MINT_CAP, 10_000, 13_000); + uint256 amount = bound(uint256(rawAmount), 4, maximumAmount); uint256 expectedVesting = Math.mulDiv(amount, 3_000, 10_000); uint256 streamId = minterController.mint(mintRecipient, amount); @@ -242,4 +422,20 @@ contract DeepstateMinterControllerTest is Test { assertEq(deep.totalSupply(), amount + expectedVesting); assertEq(sablier.stream(streamId).depositAmount, expectedVesting); } + + function _activateSoleTokenAdministration() internal { + deep.grantRole(deep.DEFAULT_ADMIN_ROLE(), address(minterController)); + minterController.activateTokenAdministration(); + 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)); + controller.grantRole(controller.MINTER_ROLE(), address(this)); + } } diff --git a/test/DeepstateMinterControllerSablierIntegration.t.sol b/test/DeepstateMinterControllerSablierIntegration.t.sol index 436c77d..0dfdc37 100644 --- a/test/DeepstateMinterControllerSablierIntegration.t.sol +++ b/test/DeepstateMinterControllerSablierIntegration.t.sol @@ -18,6 +18,8 @@ contract SablierComptrollerStub { } contract DeepstateMinterControllerSablierIntegrationTest is Test { + uint256 internal constant MINT_CAP = 20_000_000_000e18; + DeepstateToken internal deep; DeepstateMinterController internal minterController; SablierLockup internal sablier; @@ -29,13 +31,14 @@ contract DeepstateMinterControllerSablierIntegrationTest is Test { 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); + minterController = + new DeepstateMinterController(address(this), address(deep), address(sablier), recipient, MINT_CAP); deep.grantRole(deep.MINTER_ROLE(), address(minterController)); minterController.grantRole(minterController.MINTER_ROLE(), address(this)); } - function test_RealSablierV4StreamVestsLinearlyForOneYear() public { + function test_RealSablierV4StreamVestsLinearlyForTwoYears() public { uint40 startTime = uint40(block.timestamp); uint128 vestingAmount = 30e18; uint256 streamId = minterController.mint(mintRecipient, 100e18); @@ -47,7 +50,7 @@ contract DeepstateMinterControllerSablierIntegrationTest is Test { 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.getEndTime(streamId), startTime + 2 * 365 days); assertEq(sablier.getCliffTime(streamId), 0); assertEq(sablier.getGranularity(streamId), 1); assertFalse(sablier.isCancelable(streamId)); @@ -55,14 +58,14 @@ contract DeepstateMinterControllerSablierIntegrationTest is Test { assertEq(sablier.streamedAmountOf(streamId), 0); assertEq(deep.balanceOf(address(sablier)), vestingAmount); - vm.warp(startTime + 182.5 days); + vm.warp(startTime + 365 days); 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); + vm.warp(startTime + 2 * 365 days); assertEq(sablier.streamedAmountOf(streamId), vestingAmount); vm.prank(recipient); uint128 finalWithdrawal = sablier.withdrawMax(streamId, recipient); diff --git a/test/DeepstateRewarderFactory.t.sol b/test/DeepstateRewarderFactory.t.sol index 30b8b6a..6029c74 100644 --- a/test/DeepstateRewarderFactory.t.sol +++ b/test/DeepstateRewarderFactory.t.sol @@ -568,7 +568,9 @@ contract DeepstateRewarderFactoryTest is Test { internal returns (DeepstateMinterController controller_) { - controller_ = new DeepstateMinterController(admin, address(token), address(sablier), vestingRecipient); + controller_ = new DeepstateMinterController( + admin, address(token), address(sablier), vestingRecipient, 20_000_000_000e18 + ); token.grantRole(token.MINTER_ROLE(), address(controller_)); } } From d81bfc78c0128e84b97ad4b1000a30947ad8e5ec Mon Sep 17 00:00:00 2001 From: Joseph Delong Date: Sat, 29 Aug 2026 23:03:11 -0500 Subject: [PATCH 07/25] fix: lock token administration for full term --- README.md | 10 ++++--- src/DeepstateMinterController.sol | 13 +++++++--- test/DeepstateMinterController.t.sol | 39 ++++++++++++++++++++++------ 3 files changed, 46 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index b9b1d47..5ab5b7c 100644 --- a/README.md +++ b/README.md @@ -91,10 +91,12 @@ For the initial two-year issuance term, the controller temporarily holds `DeepstateToken.DEFAULT_ADMIN_ROLE` while governance remains the controller's owner. Governance activates the term only after granting the token admin role to the controller. Activation also ensures the controller has the token minter -role. Governance may return token administration early; at or after the exact -two-year deadline, anyone may trigger the return. The return 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. +role. The controller owner may rotate during the term but cannot return token +administration early. At or after the exact two-year deadline, anyone may +trigger the return. The return 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 current controller owner +may mint through the controller without a separately granted controller role. The controller retains its ordinary token minter role until governance revokes it after regaining token administration. diff --git a/src/DeepstateMinterController.sol b/src/DeepstateMinterController.sol index a11b79e..50739ac 100644 --- a/src/DeepstateMinterController.sol +++ b/src/DeepstateMinterController.sol @@ -97,15 +97,15 @@ contract DeepstateMinterController is AccessControl, Ownable, ReentrancyGuard { emit TokenAdministrationActivated(endsAt); } - /// @notice Return DEEP administration to this contract's current governance owner. - /// @dev The owner may trigger an emergency return at any time. Anyone may trigger it after the term expires. + /// @notice Return DEEP administration to this contract's current governance owner after the term expires. + /// @dev Anyone may trigger the return at or after the exact deadline. function returnTokenAdministration() external { uint40 endsAt = tokenAdministrationEndsAt; if (endsAt == 0) revert TokenAdministrationNotActive(); if (tokenAdministrationReturned) revert TokenAdministrationAlreadyReturned(); address owner_ = owner(); - if (msg.sender != owner_ && block.timestamp < endsAt) revert TokenAdministrationActive(endsAt); + if (block.timestamp < endsAt) revert TokenAdministrationActive(endsAt); bytes32 tokenAdminRole = rewardToken.DEFAULT_ADMIN_ROLE(); if (!rewardToken.hasRole(tokenAdminRole, address(this))) revert ControllerNotTokenAdmin(); @@ -120,7 +120,7 @@ contract DeepstateMinterController is AccessControl, Ownable, ReentrancyGuard { /// @notice Mint `amount` DEEP to `to` and an additional 30% into a two-year recipient stream. /// @dev The 30% calculation rounds down. Amounts that round the stream allocation to zero revert. - function mint(address to, uint256 amount) external onlyRole(MINTER_ROLE) nonReentrant returns (uint256 streamId) { + function mint(address to, uint256 amount) external onlyMinterOrOwner nonReentrant returns (uint256 streamId) { if (to == address(0)) revert InvalidMintRecipient(); uint256 vestingAmount = Math.mulDiv(amount, RECIPIENT_ALLOCATION_BPS, BPS_DENOMINATOR); @@ -161,6 +161,11 @@ contract DeepstateMinterController is AccessControl, Ownable, ReentrancyGuard { emit MintedWithVesting(msg.sender, to, amount, recipient, vestingAmount, streamId); } + modifier onlyMinterOrOwner() { + if (msg.sender != owner()) _checkRole(MINTER_ROLE); + _; + } + /// @dev Keep EIP-173 ownership and AccessControl administration assigned to the same account. function _setOwner(address newOwner) internal override { if (newOwner == address(0)) revert NewOwnerIsZeroAddress(); diff --git a/test/DeepstateMinterController.t.sol b/test/DeepstateMinterController.t.sol index 68c57c9..29f8375 100644 --- a/test/DeepstateMinterController.t.sol +++ b/test/DeepstateMinterController.t.sol @@ -136,18 +136,17 @@ contract DeepstateMinterControllerTest is Test { minterController.activateTokenAdministration(); } - function test_OwnerCanReturnTokenAdministrationEarlyWithoutAdminGap() public { + function test_OwnerCannotReturnTokenAdministrationBeforeDeadline() public { _activateSoleTokenAdministration(); + uint40 endsAt = minterController.tokenAdministrationEndsAt(); - vm.expectEmit(true, true, false, true, address(minterController)); - emit DeepstateMinterController.TokenAdministrationReturned(address(this), address(this)); + vm.expectRevert(abi.encodeWithSelector(DeepstateMinterController.TokenAdministrationActive.selector, endsAt)); minterController.returnTokenAdministration(); - assertTrue(minterController.tokenAdministrationReturned()); - assertTrue(deep.hasRole(deep.DEFAULT_ADMIN_ROLE(), address(this))); - assertFalse(deep.hasRole(deep.DEFAULT_ADMIN_ROLE(), address(minterController))); + assertFalse(minterController.tokenAdministrationReturned()); + assertFalse(deep.hasRole(deep.DEFAULT_ADMIN_ROLE(), address(this))); + assertTrue(deep.hasRole(deep.DEFAULT_ADMIN_ROLE(), address(minterController))); assertEq(deep.defaultAdminCount(), 1); - assertTrue(deep.hasRole(deep.MINTER_ROLE(), address(minterController))); } function test_AnyoneCanReturnTokenAdministrationAtExactDeadline() public { @@ -176,7 +175,8 @@ contract DeepstateMinterControllerTest is Test { assertFalse(minterController.hasRole(minterController.DEFAULT_ADMIN_ROLE(), address(this))); assertTrue(minterController.hasRole(minterController.DEFAULT_ADMIN_ROLE(), newGovernance)); - vm.prank(newGovernance); + vm.warp(minterController.tokenAdministrationEndsAt()); + vm.prank(unauthorized); minterController.returnTokenAdministration(); assertTrue(deep.hasRole(deep.DEFAULT_ADMIN_ROLE(), newGovernance)); @@ -185,6 +185,28 @@ contract DeepstateMinterControllerTest is Test { 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)); + + ownerController.mint(mintRecipient, 100e18); + ownerController.transferOwnership(newGovernance); + + vm.expectRevert( + abi.encodeWithSelector( + IAccessControl.AccessControlUnauthorizedAccount.selector, address(this), ownerController.MINTER_ROLE() + ) + ); + ownerController.mint(mintRecipient, 100e18); + + vm.prank(newGovernance); + ownerController.mint(mintRecipient, 100e18); + + assertEq(deep.balanceOf(mintRecipient), 200e18); + assertEq(deep.balanceOf(address(sablier)), 60e18); + } + function test_TwoStepOwnershipHandoverSynchronizesControllerAdmin() public { vm.prank(newGovernance); minterController.requestOwnershipHandover(); @@ -220,6 +242,7 @@ contract DeepstateMinterControllerTest is Test { vm.prank(address(minterController)); deep.grantRole(tokenAdminRole, address(this)); deep.revokeRole(tokenAdminRole, address(minterController)); + vm.warp(minterController.tokenAdministrationEndsAt()); vm.expectRevert(DeepstateMinterController.ControllerNotTokenAdmin.selector); minterController.returnTokenAdministration(); From 539f5ebe8a3807519d6dba452a78ca572b3e7d9b Mon Sep 17 00:00:00 2001 From: Joseph Delong Date: Sat, 29 Aug 2026 23:53:37 -0500 Subject: [PATCH 08/25] fix: require minter role for all controller mints --- README.md | 6 ++++-- src/DeepstateMinterController.sol | 20 ++++++++++--------- test/DeepstateMinterController.t.sol | 20 ++++++++++++++++++- ...teMinterControllerSablierIntegration.t.sol | 1 - 4 files changed, 34 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 5ab5b7c..dfa67fc 100644 --- a/README.md +++ b/README.md @@ -95,8 +95,10 @@ role. The controller owner may rotate during the term but cannot return token administration early. At or after the exact two-year deadline, anyone may trigger the return. The return 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 current controller owner -may mint through the controller without a separately granted controller role. +final-admin invariant throughout the transition. The controller owner receives +`DeepstateMinterController.MINTER_ROLE`; ownership transfers grant that role to +the new owner and revoke it from the previous owner. Every mint is therefore +authorized through the same role check, including owner mints. The controller retains its ordinary token minter role until governance revokes it after regaining token administration. diff --git a/src/DeepstateMinterController.sol b/src/DeepstateMinterController.sol index 50739ac..dfb4056 100644 --- a/src/DeepstateMinterController.sol +++ b/src/DeepstateMinterController.sol @@ -79,6 +79,7 @@ contract DeepstateMinterController is AccessControl, Ownable, ReentrancyGuard { mintCap = mintCap_; _initializeOwner(owner_); _grantRole(DEFAULT_ADMIN_ROLE, owner_); + _grantRole(MINTER_ROLE, owner_); } /// @notice Start the two-year DEEP administration term after this contract receives token admin authority. @@ -120,7 +121,7 @@ contract DeepstateMinterController is AccessControl, Ownable, ReentrancyGuard { /// @notice Mint `amount` DEEP to `to` and an additional 30% into a two-year recipient stream. /// @dev The 30% calculation rounds down. Amounts that round the stream allocation to zero revert. - function mint(address to, uint256 amount) external onlyMinterOrOwner nonReentrant returns (uint256 streamId) { + function mint(address to, uint256 amount) external onlyRole(MINTER_ROLE) nonReentrant returns (uint256 streamId) { if (to == address(0)) revert InvalidMintRecipient(); uint256 vestingAmount = Math.mulDiv(amount, RECIPIENT_ALLOCATION_BPS, BPS_DENOMINATOR); @@ -161,19 +162,20 @@ contract DeepstateMinterController is AccessControl, Ownable, ReentrancyGuard { emit MintedWithVesting(msg.sender, to, amount, recipient, vestingAmount, streamId); } - modifier onlyMinterOrOwner() { - if (msg.sender != owner()) _checkRole(MINTER_ROLE); - _; - } - - /// @dev Keep EIP-173 ownership and AccessControl administration assigned to the same account. + /// @dev Keep EIP-173 ownership, AccessControl administration, and owner mint authority synchronized. function _setOwner(address newOwner) internal override { if (newOwner == address(0)) revert NewOwnerIsZeroAddress(); address previousOwner = owner(); super._setOwner(newOwner); - _grantRole(DEFAULT_ADMIN_ROLE, newOwner); - if (previousOwner != address(0)) _revokeRole(DEFAULT_ADMIN_ROLE, previousOwner); + if (newOwner != previousOwner) { + _grantRole(DEFAULT_ADMIN_ROLE, newOwner); + _grantRole(MINTER_ROLE, newOwner); + if (previousOwner != address(0)) { + _revokeRole(DEFAULT_ADMIN_ROLE, previousOwner); + _revokeRole(MINTER_ROLE, previousOwner); + } + } } function grantRole(bytes32 role, address account) public override { diff --git a/test/DeepstateMinterController.t.sol b/test/DeepstateMinterController.t.sol index 29f8375..955e419 100644 --- a/test/DeepstateMinterController.t.sol +++ b/test/DeepstateMinterController.t.sol @@ -30,7 +30,6 @@ contract DeepstateMinterControllerTest is Test { new DeepstateMinterController(address(this), address(deep), address(sablier), recipient, MINT_CAP); deep.grantRole(deep.MINTER_ROLE(), address(minterController)); - minterController.grantRole(minterController.MINTER_ROLE(), address(this)); } function test_ImmutableConfigurationAndInitialAuthority() public view { @@ -174,6 +173,8 @@ contract DeepstateMinterControllerTest is Test { assertEq(minterController.owner(), newGovernance); assertFalse(minterController.hasRole(minterController.DEFAULT_ADMIN_ROLE(), address(this))); assertTrue(minterController.hasRole(minterController.DEFAULT_ADMIN_ROLE(), newGovernance)); + assertFalse(minterController.hasRole(minterController.MINTER_ROLE(), address(this))); + assertTrue(minterController.hasRole(minterController.MINTER_ROLE(), newGovernance)); vm.warp(minterController.tokenAdministrationEndsAt()); vm.prank(unauthorized); @@ -190,9 +191,13 @@ contract DeepstateMinterControllerTest is Test { new DeepstateMinterController(address(this), address(deep), address(sablier), recipient, MINT_CAP); deep.grantRole(deep.MINTER_ROLE(), address(ownerController)); + assertTrue(ownerController.hasRole(ownerController.MINTER_ROLE(), address(this))); ownerController.mint(mintRecipient, 100e18); ownerController.transferOwnership(newGovernance); + assertFalse(ownerController.hasRole(ownerController.MINTER_ROLE(), address(this))); + assertTrue(ownerController.hasRole(ownerController.MINTER_ROLE(), newGovernance)); + vm.expectRevert( abi.encodeWithSelector( IAccessControl.AccessControlUnauthorizedAccount.selector, address(this), ownerController.MINTER_ROLE() @@ -215,6 +220,19 @@ contract DeepstateMinterControllerTest is Test { assertEq(minterController.owner(), newGovernance); assertFalse(minterController.hasRole(minterController.DEFAULT_ADMIN_ROLE(), address(this))); assertTrue(minterController.hasRole(minterController.DEFAULT_ADMIN_ROLE(), newGovernance)); + assertFalse(minterController.hasRole(minterController.MINTER_ROLE(), address(this))); + assertTrue(minterController.hasRole(minterController.MINTER_ROLE(), newGovernance)); + } + + function test_TransferOwnershipToCurrentOwnerPreservesRoles() public { + minterController.transferOwnership(address(this)); + + assertEq(minterController.owner(), address(this)); + assertTrue(minterController.hasRole(minterController.DEFAULT_ADMIN_ROLE(), address(this))); + assertTrue(minterController.hasRole(minterController.MINTER_ROLE(), address(this))); + + minterController.mint(mintRecipient, 100e18); + assertEq(deep.balanceOf(mintRecipient), 100e18); } function test_ControllerOwnerCannotRenounceOrLoseDefaultAdmin() public { diff --git a/test/DeepstateMinterControllerSablierIntegration.t.sol b/test/DeepstateMinterControllerSablierIntegration.t.sol index 0dfdc37..64b1a7f 100644 --- a/test/DeepstateMinterControllerSablierIntegration.t.sol +++ b/test/DeepstateMinterControllerSablierIntegration.t.sol @@ -35,7 +35,6 @@ contract DeepstateMinterControllerSablierIntegrationTest is Test { new DeepstateMinterController(address(this), address(deep), address(sablier), recipient, MINT_CAP); deep.grantRole(deep.MINTER_ROLE(), address(minterController)); - minterController.grantRole(minterController.MINTER_ROLE(), address(this)); } function test_RealSablierV4StreamVestsLinearlyForTwoYears() public { From 45df2f376777150d0de4387ccd50cf0ddebec68f Mon Sep 17 00:00:00 2001 From: Joseph Delong Date: Sat, 29 Aug 2026 23:58:39 -0500 Subject: [PATCH 09/25] refactor: name Deepstate V1 controller accurately --- src/DeepstateRewarderFactory.sol | 9 +++--- ...ntroller.sol => DeepstateV1Controller.sol} | 17 ++++------- src/interfaces/IDeepstateV1.sol | 12 ++++++++ test/DeepstateRewarderFactory.t.sol | 23 ++++++-------- ...ller.t.sol => DeepstateV1Controller.t.sol} | 30 +++++++++---------- 5 files changed, 46 insertions(+), 45 deletions(-) rename src/{DeepstateRouterController.sol => DeepstateV1Controller.sol} (78%) create mode 100644 src/interfaces/IDeepstateV1.sol rename test/{DeepstateRouterController.t.sol => DeepstateV1Controller.t.sol} (81%) diff --git a/src/DeepstateRewarderFactory.sol b/src/DeepstateRewarderFactory.sol index ec99799..307d2e5 100644 --- a/src/DeepstateRewarderFactory.sol +++ b/src/DeepstateRewarderFactory.sol @@ -4,9 +4,10 @@ pragma solidity 0.8.28; import {Ownable} from "solady/auth/Ownable.sol"; import {DeepstateRewarderV2} from "./DeepstateRewarderV2.sol"; -import {DeepstateRouterController, IDeepstateRouterAdmin} from "./DeepstateRouterController.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 CREATE2 factory for controller-launched market reward programs. @@ -32,8 +33,8 @@ contract DeepstateRewarderFactory is Ownable { /// @notice Initial DEEP minted to each rewarder. Further funding requires governance. uint256 public constant INITIAL_FUNDING = 100_000_000e18; - DeepstateRouterController public immutable routerController; - IDeepstateRouterAdmin public immutable deepstate; + DeepstateV1Controller public immutable routerController; + IDeepstateV1 public immutable deepstate; IDeepstateMinterController public immutable minterController; DeepstateToken public immutable rewardToken; @@ -84,7 +85,7 @@ contract DeepstateRewarderFactory is Ownable { } _initializeOwner(owner_); - routerController = DeepstateRouterController(routerController_); + routerController = DeepstateV1Controller(routerController_); address routerControllerOwner = routerController.owner(); if (routerControllerOwner != owner_) { revert RouterControllerOwnerMismatch(owner_, routerControllerOwner); diff --git a/src/DeepstateRouterController.sol b/src/DeepstateV1Controller.sol similarity index 78% rename from src/DeepstateRouterController.sol rename to src/DeepstateV1Controller.sol index caa532c..1413a05 100644 --- a/src/DeepstateRouterController.sol +++ b/src/DeepstateV1Controller.sol @@ -3,19 +3,12 @@ pragma solidity 0.8.28; import {Ownable} from "solady/auth/Ownable.sol"; -interface IDeepstateRouterAdmin { - 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; -} +import {IDeepstateV1} from "./interfaces/IDeepstateV1.sol"; -/// @title Deepstate Router Controller +/// @title Deepstate V1 Controller /// @notice Governance-owned capability boundary around the single-owner Deepstate router. -contract DeepstateRouterController is Ownable { - IDeepstateRouterAdmin public immutable deepstate; +contract DeepstateV1Controller is Ownable { + IDeepstateV1 public immutable deepstate; /// @notice Revocable contract permitted only to configure pool hooks. address public hookManager; @@ -32,7 +25,7 @@ contract DeepstateRouterController is Ownable { if (deepstate_ == address(0) || deepstate_.code.length == 0) revert InvalidDeepstate(); _initializeOwner(owner_); - deepstate = IDeepstateRouterAdmin(deepstate_); + deepstate = IDeepstateV1(deepstate_); } modifier onlyHookManagerOrOwner() { 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/test/DeepstateRewarderFactory.t.sol b/test/DeepstateRewarderFactory.t.sol index 6029c74..548b6b4 100644 --- a/test/DeepstateRewarderFactory.t.sol +++ b/test/DeepstateRewarderFactory.t.sol @@ -10,7 +10,7 @@ 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 {DeepstateRouterController} from "../src/DeepstateRouterController.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"; @@ -43,7 +43,7 @@ contract DeepstateRewarderFactoryTest is Test { DeepstateToken internal deep; DeepstateV1 internal deepstate; - DeepstateRouterController internal routerController; + DeepstateV1Controller internal routerController; DeepstateMinterController internal minterController; DeepstateRewarderFactory internal factory; MockSablierLockupLinearV4 internal sablier; @@ -58,7 +58,7 @@ contract DeepstateRewarderFactoryTest is Test { deep = new DeepstateToken(address(this), "Deepstate", "DEEP"); sablier = new MockSablierLockupLinearV4(); deepstate = new DeepstateV1(); - routerController = new DeepstateRouterController(address(this), address(deepstate)); + routerController = new DeepstateV1Controller(address(this), address(deepstate)); minterController = _newMinterController(address(this), deep); factory = new DeepstateRewarderFactory(address(this), address(routerController), address(minterController)); @@ -98,7 +98,7 @@ contract DeepstateRewarderFactoryTest is Test { vm.expectRevert(DeepstateRewarderFactory.InvalidRouterController.selector); new DeepstateRewarderFactory(address(this), alice, address(minterController)); - DeepstateRouterController mismatchedController = new DeepstateRouterController(alice, address(deepstate)); + DeepstateV1Controller mismatchedController = new DeepstateV1Controller(alice, address(deepstate)); vm.expectRevert( abi.encodeWithSelector( DeepstateRewarderFactory.RouterControllerOwnerMismatch.selector, address(this), alice @@ -131,8 +131,7 @@ contract DeepstateRewarderFactoryTest is Test { address governance = makeAddr("governance"); DeepstateToken secondToken = new DeepstateToken(address(this), "Second", "SECOND"); DeepstateV1 secondRouter = new DeepstateV1(); - DeepstateRouterController secondRouterController = - new DeepstateRouterController(governance, address(secondRouter)); + DeepstateV1Controller secondRouterController = new DeepstateV1Controller(governance, address(secondRouter)); DeepstateMinterController secondMinterController = _newMinterController(governance, secondToken); DeepstateRewarderFactory secondFactory = new DeepstateRewarderFactory(governance, address(secondRouterController), address(secondMinterController)); @@ -351,8 +350,7 @@ contract DeepstateRewarderFactoryTest is Test { factory.deployMarket(config); DeepstateV1 secondRouter = new DeepstateV1(); - DeepstateRouterController secondRouterController = - new DeepstateRouterController(address(this), address(secondRouter)); + DeepstateV1Controller secondRouterController = new DeepstateV1Controller(address(this), address(secondRouter)); DeepstateRewarderFactory secondFactory = new DeepstateRewarderFactory(address(this), address(secondRouterController), address(minterController)); secondRouter.setPoolHookConfig(TOKEN_C, TOKEN_D, alice, true, false); @@ -373,8 +371,7 @@ contract DeepstateRewarderFactoryTest is Test { function test_DeploymentWithoutMinterRoleRevertsAtomically() public { DeepstateToken secondToken = new DeepstateToken(address(this), "Second", "SECOND"); DeepstateV1 secondRouter = new DeepstateV1(); - DeepstateRouterController secondRouterController = - new DeepstateRouterController(address(this), address(secondRouter)); + DeepstateV1Controller secondRouterController = new DeepstateV1Controller(address(this), address(secondRouter)); DeepstateMinterController secondMinterController = _newMinterController(address(this), secondToken); DeepstateRewarderFactory secondFactory = new DeepstateRewarderFactory( address(this), address(secondRouterController), address(secondMinterController) @@ -406,8 +403,7 @@ contract DeepstateRewarderFactoryTest is Test { function test_DeploymentWithoutRouterOwnershipRevertsAtomically() public { DeepstateToken secondToken = new DeepstateToken(address(this), "Second", "SECOND"); DeepstateV1 secondRouter = new DeepstateV1(); - DeepstateRouterController secondRouterController = - new DeepstateRouterController(address(this), address(secondRouter)); + DeepstateV1Controller secondRouterController = new DeepstateV1Controller(address(this), address(secondRouter)); DeepstateMinterController secondMinterController = _newMinterController(address(this), secondToken); DeepstateRewarderFactory secondFactory = new DeepstateRewarderFactory( address(this), address(secondRouterController), address(secondMinterController) @@ -431,8 +427,7 @@ contract DeepstateRewarderFactoryTest is Test { function test_DeploymentWithoutDelegatedHookPermissionRevertsAtomically() public { DeepstateToken secondToken = new DeepstateToken(address(this), "Second", "SECOND"); DeepstateV1 secondRouter = new DeepstateV1(); - DeepstateRouterController secondRouterController = - new DeepstateRouterController(address(this), address(secondRouter)); + DeepstateV1Controller secondRouterController = new DeepstateV1Controller(address(this), address(secondRouter)); DeepstateMinterController secondMinterController = _newMinterController(address(this), secondToken); DeepstateRewarderFactory secondFactory = new DeepstateRewarderFactory( address(this), address(secondRouterController), address(secondMinterController) diff --git a/test/DeepstateRouterController.t.sol b/test/DeepstateV1Controller.t.sol similarity index 81% rename from test/DeepstateRouterController.t.sol rename to test/DeepstateV1Controller.t.sol index 23fa236..e41bf1f 100644 --- a/test/DeepstateRouterController.t.sol +++ b/test/DeepstateV1Controller.t.sol @@ -5,14 +5,14 @@ import {Test} from "forge-std/Test.sol"; import {Ownable} from "solady/auth/Ownable.sol"; import {DeepstateV1} from "deepstate-contracts/DeepstateV1.sol"; -import {DeepstateRouterController} from "../src/DeepstateRouterController.sol"; +import {DeepstateV1Controller} from "../src/DeepstateV1Controller.sol"; -contract DeepstateRouterControllerTest is Test { +contract DeepstateV1ControllerTest is Test { address internal constant TOKEN0 = address(0x1000); address internal constant TOKEN1 = address(0x2000); DeepstateV1 internal deepstate; - DeepstateRouterController internal controller; + DeepstateV1Controller internal controller; address internal hookManager = makeAddr("hookManager"); address internal hook = makeAddr("hook"); @@ -21,7 +21,7 @@ contract DeepstateRouterControllerTest is Test { function setUp() public { deepstate = new DeepstateV1(); - controller = new DeepstateRouterController(address(this), address(deepstate)); + controller = new DeepstateV1Controller(address(this), address(deepstate)); deepstate.transferOwnership(address(controller)); } @@ -33,24 +33,24 @@ contract DeepstateRouterControllerTest is Test { } function test_ConstructorValidation() public { - vm.expectRevert(DeepstateRouterController.InvalidOwner.selector); - new DeepstateRouterController(address(0), address(deepstate)); + vm.expectRevert(DeepstateV1Controller.InvalidOwner.selector); + new DeepstateV1Controller(address(0), address(deepstate)); - vm.expectRevert(DeepstateRouterController.InvalidDeepstate.selector); - new DeepstateRouterController(address(this), address(0)); + vm.expectRevert(DeepstateV1Controller.InvalidDeepstate.selector); + new DeepstateV1Controller(address(this), address(0)); - vm.expectRevert(DeepstateRouterController.InvalidDeepstate.selector); - new DeepstateRouterController(address(this), alice); + vm.expectRevert(DeepstateV1Controller.InvalidDeepstate.selector); + new DeepstateV1Controller(address(this), alice); } function test_GovernanceCanSetAndRevokeHookManager() public { vm.expectEmit(true, true, false, false, address(controller)); - emit DeepstateRouterController.HookManagerSet(address(0), hookManager); + emit DeepstateV1Controller.HookManagerSet(address(0), hookManager); controller.setHookManager(hookManager); assertEq(controller.hookManager(), hookManager); vm.expectEmit(true, true, false, false, address(controller)); - emit DeepstateRouterController.HookManagerSet(hookManager, address(0)); + emit DeepstateV1Controller.HookManagerSet(hookManager, address(0)); controller.setHookManager(address(0)); assertEq(controller.hookManager(), address(0)); } @@ -118,7 +118,7 @@ contract DeepstateRouterControllerTest is Test { function test_GovernanceCanConfigureFeesAndRecoverRouterOwnership() public { vm.expectEmit(true, false, false, true, address(controller)); - emit DeepstateRouterController.DeepstateFeeConfigured(feeRecipient, 10); + emit DeepstateV1Controller.DeepstateFeeConfigured(feeRecipient, 10); controller.setDeepstateFeeConfig(feeRecipient, 10); (address recipient, uint16 bps) = deepstate.feeConfig(); @@ -126,14 +126,14 @@ contract DeepstateRouterControllerTest is Test { assertEq(bps, 10); vm.expectEmit(true, false, false, false, address(controller)); - emit DeepstateRouterController.DeepstateOwnershipTransferred(alice); + emit DeepstateV1Controller.DeepstateOwnershipTransferred(alice); controller.transferDeepstateOwnership(alice); assertEq(deepstate.owner(), alice); } function test_ControllerCallsFailUntilItOwnsRouter() public { DeepstateV1 secondRouter = new DeepstateV1(); - DeepstateRouterController secondController = new DeepstateRouterController(address(this), address(secondRouter)); + DeepstateV1Controller secondController = new DeepstateV1Controller(address(this), address(secondRouter)); secondController.setHookManager(hookManager); vm.expectRevert(Ownable.Unauthorized.selector); From 6c77db6ff6ea5c260debec6d8bc8d1ca54dfe7cf Mon Sep 17 00:00:00 2001 From: Joseph Delong Date: Sun, 30 Aug 2026 00:04:28 -0500 Subject: [PATCH 10/25] fix: burn retired rewarder balances in place --- src/DeepstateRewarderFactory.sol | 12 ++--- src/DeepstateRewarderV2.sol | 37 ++++----------- test/DeepstateRewarderFactory.t.sol | 12 ++--- test/DeepstateRewarderV2.t.sol | 71 +++++++++++++---------------- 4 files changed, 52 insertions(+), 80 deletions(-) diff --git a/src/DeepstateRewarderFactory.sol b/src/DeepstateRewarderFactory.sol index 307d2e5..532f92a 100644 --- a/src/DeepstateRewarderFactory.sol +++ b/src/DeepstateRewarderFactory.sol @@ -136,7 +136,7 @@ contract DeepstateRewarderFactory is Ownable { nextDeploymentAt = block.timestamp + DEPLOYMENT_COOLDOWN; rewarder = new DeepstateRewarderV2{salt: salt}( - owner(), + address(this), address(deepstate), address(rewardToken), poolId_, @@ -170,7 +170,7 @@ contract DeepstateRewarderFactory is Ownable { ); } - /// @notice Remove a factory market, recover all remaining DEEP, and burn it. + /// @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) @@ -191,8 +191,8 @@ contract DeepstateRewarderFactory is Ownable { delete activeRewarder[poolId_]; delete rewarderPool[rewarder]; - burnedAmount = DeepstateRewarderV2(rewarder).withdrawRewardBalance(address(this)); - if (burnedAmount != 0) rewardToken.burn(burnedAmount); + burnedAmount = rewardToken.balanceOf(rewarder); + DeepstateRewarderV2(rewarder).burnBalance(burnedAmount); emit MarketRemoved(poolId_, rewarder, burnedAmount); } @@ -202,7 +202,7 @@ contract DeepstateRewarderFactory is Ownable { return keccak256(abi.encode(poolId_, deploymentNonce)); } - /// @notice Predict a rewarder address using the factory's current owner and a deployment nonce. + /// @notice Predict a rewarder address using the factory and a deployment nonce. function predictRewarderAddress(MarketConfig calldata config, uint256 deploymentNonce) external view @@ -214,7 +214,7 @@ contract DeepstateRewarderFactory is Ownable { abi.encodePacked( type(DeepstateRewarderV2).creationCode, abi.encode( - owner(), + address(this), address(deepstate), address(rewardToken), poolId_, diff --git a/src/DeepstateRewarderV2.sol b/src/DeepstateRewarderV2.sol index 4cf1486..7faf6cf 100644 --- a/src/DeepstateRewarderV2.sol +++ b/src/DeepstateRewarderV2.sol @@ -1,22 +1,14 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; -import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; - import {DeepstateRewarder} from "./DeepstateRewarder.sol"; +import {IBurnableERC20} from "./interfaces/IBurnableERC20.sol"; /// @title Deepstate Rewarder V2 -/// @notice Extends the original rewarder with governance-controlled recovery of remaining rewards. +/// @notice Extends the original rewarder with owner-controlled burning of remaining rewards. +/// @dev Ownable is inherited through DeepstateRewarder. contract DeepstateRewarderV2 is DeepstateRewarder { - using SafeERC20 for IERC20; - - /// @notice Factory that deployed and may retire this rewarder. - address public immutable factory; - - event RewardBalanceWithdrawn(address indexed receiver, uint256 amount); - - error InvalidReceiver(); + event RewardBalanceBurned(uint256 amount); constructor( address owner_, @@ -46,21 +38,12 @@ contract DeepstateRewarderV2 is DeepstateRewarder { token1StartQuantity_, token1MaxQuantity_ ) - { - factory = msg.sender; - } - - /// @notice Withdraw the rewarder's entire remaining reward-token balance. - /// @dev Governance owns the rewarder. Its deploying factory may also withdraw when retiring a - /// market. Outstanding claims remain accounted for and will revert until funding is restored. - function withdrawRewardBalance(address receiver) external returns (uint256 amount) { - if (msg.sender != factory) _checkOwner(); - if (receiver == address(0)) revert InvalidReceiver(); - - IERC20 token = IERC20(rewardToken); - amount = token.balanceOf(address(this)); - if (amount != 0) token.safeTransfer(receiver, amount); + {} - emit RewardBalanceWithdrawn(receiver, amount); + /// @notice Burn an amount of the reward token held by this rewarder. + /// @dev Outstanding claims remain accounted for and will revert until funding is restored. + function burnBalance(uint256 amount) external onlyOwner { + IBurnableERC20(rewardToken).burn(amount); + emit RewardBalanceBurned(amount); } } diff --git a/test/DeepstateRewarderFactory.t.sol b/test/DeepstateRewarderFactory.t.sol index 548b6b4..8a309bf 100644 --- a/test/DeepstateRewarderFactory.t.sol +++ b/test/DeepstateRewarderFactory.t.sol @@ -149,8 +149,7 @@ contract DeepstateRewarderFactoryTest is Test { DeepstateRewarderV2 rewarder = secondFactory.deployMarket(_market(TOKEN_A, TOKEN_B)); assertEq(secondFactory.owner(), governance); - assertEq(rewarder.owner(), governance); - assertEq(rewarder.factory(), address(secondFactory)); + assertEq(rewarder.owner(), address(secondFactory)); assertEq(secondToken.balanceOf(address(rewarder)), 100_000_000e18); assertEq(secondToken.balanceOf(address(sablier)), 30_000_000e18); } @@ -168,8 +167,7 @@ contract DeepstateRewarderFactoryTest is Test { assertEq(address(rewarder), predicted); assertGt(predicted.code.length, 0); - assertEq(rewarder.owner(), address(this)); - assertEq(rewarder.factory(), address(factory)); + assertEq(rewarder.owner(), address(factory)); assertEq(rewarder.deepstate(), address(deepstate)); assertEq(rewarder.rewardToken(), address(deep)); assertEq(rewarder.poolId(), poolId); @@ -219,7 +217,7 @@ contract DeepstateRewarderFactoryTest is Test { function test_GovernanceCanDeployWithoutControllerButStillObeysCooldown() public { factory.setController(address(0)); DeepstateRewarderV2 rewarder = factory.deployMarket(_market(TOKEN_A, TOKEN_B)); - assertEq(rewarder.owner(), address(this)); + assertEq(rewarder.owner(), address(factory)); uint256 next = factory.nextDeploymentAt(); vm.expectRevert(abi.encodeWithSelector(DeepstateRewarderFactory.DeploymentCooldown.selector, next)); @@ -273,13 +271,13 @@ contract DeepstateRewarderFactoryTest is Test { assertEq(deep.totalSupply(), 30_000_000e18); } - function test_ControllerCannotWithdrawFromRewarderDirectly() public { + function test_ControllerCannotBurnRewarderDirectly() public { vm.prank(controller); DeepstateRewarderV2 rewarder = factory.deployMarket(_market(TOKEN_A, TOKEN_B)); vm.expectRevert(Ownable.Unauthorized.selector); vm.prank(controller); - rewarder.withdrawRewardBalance(controller); + rewarder.burnBalance(1); assertEq(deep.balanceOf(address(rewarder)), 100_000_000e18); assertEq(deep.balanceOf(controller), 0); diff --git a/test/DeepstateRewarderV2.t.sol b/test/DeepstateRewarderV2.t.sol index 49a464a..bc9fb0b 100644 --- a/test/DeepstateRewarderV2.t.sol +++ b/test/DeepstateRewarderV2.t.sol @@ -1,25 +1,12 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.24; +import {IERC20Errors} from "@openzeppelin/contracts/interfaces/draft-IERC6093.sol"; import {Test} from "forge-std/Test.sol"; import {Ownable} from "solady/auth/Ownable.sol"; -import {ERC20} from "solady/tokens/ERC20.sol"; import {DeepstateRewarderV2} from "../src/DeepstateRewarderV2.sol"; - -contract RewarderV2TestToken is ERC20 { - function name() public pure override returns (string memory) { - return "Reward"; - } - - function symbol() public pure override returns (string memory) { - return "RWD"; - } - - function mint(address to, uint256 amount) external { - _mint(to, amount); - } -} +import {DeepstateToken} from "../src/DeepstateToken.sol"; contract DeepstateRewarderV2Test is Test { uint96 internal constant SIDE_CAP = 500_000_000e18; @@ -27,13 +14,13 @@ contract DeepstateRewarderV2Test is Test { address internal constant TOKEN0 = address(0x2000); address internal constant TOKEN1 = address(0x3000); - RewarderV2TestToken internal rewardToken; + DeepstateToken internal rewardToken; DeepstateRewarderV2 internal rewarder; address internal alice = makeAddr("alice"); - address internal bob = makeAddr("bob"); function setUp() public { - rewardToken = new RewarderV2TestToken(); + rewardToken = new DeepstateToken(address(this), "Reward", "RWD"); + rewardToken.grantRole(rewardToken.MINTER_ROLE(), address(this)); rewarder = new DeepstateRewarderV2( address(this), DEEPSTATE, @@ -53,7 +40,6 @@ contract DeepstateRewarderV2Test is Test { function test_InheritsRewarderConfiguration() public view { assertEq(rewarder.owner(), address(this)); - assertEq(rewarder.factory(), address(this)); assertEq(rewarder.deepstate(), DEEPSTATE); assertEq(rewarder.rewardToken(), address(rewardToken)); assertEq(rewarder.token0(), TOKEN0); @@ -61,42 +47,47 @@ contract DeepstateRewarderV2Test is Test { assertEq(rewarder.sideEmissionCap(), SIDE_CAP); } - function test_OwnerCanWithdrawEntireRewardBalance() public { - uint256 expected = rewardToken.balanceOf(address(rewarder)); + function test_OwnerCanBurnRequestedRewardBalance() public { + uint256 funding = rewardToken.balanceOf(address(rewarder)); + uint256 amount = funding / 4; - vm.expectEmit(true, false, false, true, address(rewarder)); - emit DeepstateRewarderV2.RewardBalanceWithdrawn(alice, expected); - uint256 withdrawn = rewarder.withdrawRewardBalance(alice); + vm.expectEmit(false, false, false, true, address(rewarder)); + emit DeepstateRewarderV2.RewardBalanceBurned(amount); + rewarder.burnBalance(amount); - assertEq(withdrawn, expected); - assertEq(rewardToken.balanceOf(address(rewarder)), 0); - assertEq(rewardToken.balanceOf(alice), expected); + assertEq(rewardToken.balanceOf(address(rewarder)), funding - amount); + assertEq(rewardToken.totalSupply(), funding - amount); } - function test_NonOwnerCannotWithdrawRewardBalance() public { + function test_NonOwnerCannotBurnRewardBalance() public { uint256 fundingBefore = rewardToken.balanceOf(address(rewarder)); vm.expectRevert(Ownable.Unauthorized.selector); vm.prank(alice); - rewarder.withdrawRewardBalance(alice); + rewarder.burnBalance(1); assertEq(rewardToken.balanceOf(address(rewarder)), fundingBefore); - assertEq(rewardToken.balanceOf(alice), 0); + assertEq(rewardToken.totalSupply(), fundingBefore); } - function test_WithdrawRewardBalanceRejectsZeroReceiver() public { - vm.expectRevert(DeepstateRewarderV2.InvalidReceiver.selector); - rewarder.withdrawRewardBalance(address(0)); + function test_BurnBalanceRejectsAmountAboveBalance() public { + uint256 funding = rewardToken.balanceOf(address(rewarder)); + vm.expectRevert( + abi.encodeWithSelector( + IERC20Errors.ERC20InsufficientBalance.selector, address(rewarder), funding, funding + 1 + ) + ); + rewarder.burnBalance(funding + 1); } - function test_WithdrawEmptyRewardBalanceReturnsZero() public { - rewarder.withdrawRewardBalance(alice); + function test_BurnBalanceAcceptsZeroAmount() public { + uint256 funding = rewardToken.balanceOf(address(rewarder)); - vm.expectEmit(true, false, false, true, address(rewarder)); - emit DeepstateRewarderV2.RewardBalanceWithdrawn(bob, 0); - uint256 withdrawn = rewarder.withdrawRewardBalance(bob); + vm.expectEmit(false, false, false, true, address(rewarder)); + emit DeepstateRewarderV2.RewardBalanceBurned(0); + rewarder.burnBalance(0); - assertEq(withdrawn, 0); - assertEq(rewardToken.balanceOf(bob), 0); + assertEq(rewardToken.balanceOf(address(rewarder)), funding); + assertEq(rewardToken.totalSupply(), funding); } } From 929f19694584f9b1daf5c6b33998274eabf70718 Mon Sep 17 00:00:00 2001 From: Joseph Delong Date: Sun, 30 Aug 2026 00:14:14 -0500 Subject: [PATCH 11/25] fix: vest recipient allocations for one year --- README.md | 2 +- src/DeepstateMinterController.sol | 4 ++-- test/DeepstateMinterController.t.sol | 6 +++--- test/DeepstateMinterControllerSablierIntegration.t.sol | 8 ++++---- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index dfa67fc..f8a1e5c 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ proposals can therefore execute immediately after voting ends. its `MINTER_ROLE` only to approved issuance contracts, such as `DeepstateRewarderFactory`. For every requested mint `M`, the controller mints `M` to the requested address and an additional `floor(30% * M)` to Sablier -Lockup v4.0.1. Each recipient allocation gets its own linear two-year stream. +Lockup v4.0.1. 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. diff --git a/src/DeepstateMinterController.sol b/src/DeepstateMinterController.sol index dfb4056..ea4f093 100644 --- a/src/DeepstateMinterController.sol +++ b/src/DeepstateMinterController.sol @@ -24,7 +24,7 @@ contract DeepstateMinterController is AccessControl, Ownable, ReentrancyGuard { bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE"); uint256 public constant RECIPIENT_ALLOCATION_BPS = 3_000; uint256 public constant BPS_DENOMINATOR = 10_000; - uint40 public constant VESTING_DURATION = 2 * 365 days; + uint40 public constant VESTING_DURATION = 365 days; uint40 public constant TOKEN_ADMINISTRATION_DURATION = 2 * 365 days; DeepstateToken public immutable rewardToken; @@ -119,7 +119,7 @@ contract DeepstateMinterController is AccessControl, Ownable, ReentrancyGuard { emit TokenAdministrationReturned(owner_, msg.sender); } - /// @notice Mint `amount` DEEP to `to` and an additional 30% into a two-year recipient stream. + /// @notice Mint `amount` DEEP to `to` and an additional 30% into a one-year recipient stream. /// @dev The 30% calculation rounds down. Amounts that round the stream allocation to zero revert. function mint(address to, uint256 amount) external onlyRole(MINTER_ROLE) nonReentrant returns (uint256 streamId) { if (to == address(0)) revert InvalidMintRecipient(); diff --git a/test/DeepstateMinterController.t.sol b/test/DeepstateMinterController.t.sol index 955e419..92d54b2 100644 --- a/test/DeepstateMinterController.t.sol +++ b/test/DeepstateMinterController.t.sol @@ -39,7 +39,7 @@ contract DeepstateMinterControllerTest is Test { assertEq(minterController.mintCap(), MINT_CAP); assertEq(minterController.RECIPIENT_ALLOCATION_BPS(), 3_000); assertEq(minterController.BPS_DENOMINATOR(), 10_000); - assertEq(minterController.VESTING_DURATION(), 2 * 365 days); + assertEq(minterController.VESTING_DURATION(), 365 days); assertEq(minterController.TOKEN_ADMINISTRATION_DURATION(), 2 * 365 days); assertEq(minterController.owner(), address(this)); assertEq(minterController.tokenAdministrationEndsAt(), 0); @@ -72,7 +72,7 @@ contract DeepstateMinterControllerTest is Test { new DeepstateMinterController(address(this), address(deep), address(sablier), recipient, 0); } - function test_MintCreatesExactNonCancelableTwoYearStream() public { + function test_MintCreatesExactNonCancelableOneYearStream() public { uint256 amount = 100_000_000e18; uint256 vestingAmount = 30_000_000e18; @@ -102,7 +102,7 @@ contract DeepstateMinterControllerTest is Test { assertEq(created.cliffUnlockAmount, 0); assertEq(created.granularity, 0); assertEq(created.cliffDuration, 0); - assertEq(created.totalDuration, 2 * 365 days); + assertEq(created.totalDuration, 365 days); } function test_ActivateTokenAdministrationStartsTwoYearTermAndEnsuresMinterRole() public { diff --git a/test/DeepstateMinterControllerSablierIntegration.t.sol b/test/DeepstateMinterControllerSablierIntegration.t.sol index 64b1a7f..bea96a9 100644 --- a/test/DeepstateMinterControllerSablierIntegration.t.sol +++ b/test/DeepstateMinterControllerSablierIntegration.t.sol @@ -37,7 +37,7 @@ contract DeepstateMinterControllerSablierIntegrationTest is Test { deep.grantRole(deep.MINTER_ROLE(), address(minterController)); } - function test_RealSablierV4StreamVestsLinearlyForTwoYears() public { + function test_RealSablierV4StreamVestsLinearlyForOneYear() public { uint40 startTime = uint40(block.timestamp); uint128 vestingAmount = 30e18; uint256 streamId = minterController.mint(mintRecipient, 100e18); @@ -49,7 +49,7 @@ contract DeepstateMinterControllerSablierIntegrationTest is Test { assertEq(address(sablier.getUnderlyingToken(streamId)), address(deep)); assertEq(sablier.getDepositedAmount(streamId), vestingAmount); assertEq(sablier.getStartTime(streamId), startTime); - assertEq(sablier.getEndTime(streamId), startTime + 2 * 365 days); + assertEq(sablier.getEndTime(streamId), startTime + 365 days); assertEq(sablier.getCliffTime(streamId), 0); assertEq(sablier.getGranularity(streamId), 1); assertFalse(sablier.isCancelable(streamId)); @@ -57,14 +57,14 @@ contract DeepstateMinterControllerSablierIntegrationTest is Test { assertEq(sablier.streamedAmountOf(streamId), 0); assertEq(deep.balanceOf(address(sablier)), vestingAmount); - vm.warp(startTime + 365 days); + 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 + 2 * 365 days); + vm.warp(startTime + 365 days); assertEq(sablier.streamedAmountOf(streamId), vestingAmount); vm.prank(recipient); uint128 finalWithdrawal = sablier.withdrawMax(streamId, recipient); From c26d8851d90677a29f49c3f21a82711941b23052 Mon Sep 17 00:00:00 2001 From: Joseph Delong Date: Sun, 30 Aug 2026 00:19:07 -0500 Subject: [PATCH 12/25] style: format basis points as percentages --- src/DeepstateMinterController.sol | 4 ++-- test/DeepstateMinterController.t.sol | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/DeepstateMinterController.sol b/src/DeepstateMinterController.sol index ea4f093..aaa5a6f 100644 --- a/src/DeepstateMinterController.sol +++ b/src/DeepstateMinterController.sol @@ -22,8 +22,8 @@ contract DeepstateMinterController is AccessControl, Ownable, ReentrancyGuard { using SafeERC20 for IERC20; bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE"); - uint256 public constant RECIPIENT_ALLOCATION_BPS = 3_000; - uint256 public constant BPS_DENOMINATOR = 10_000; + uint256 public constant RECIPIENT_ALLOCATION_BPS = 30_00; + uint256 public constant BPS_DENOMINATOR = 100_00; uint40 public constant VESTING_DURATION = 365 days; uint40 public constant TOKEN_ADMINISTRATION_DURATION = 2 * 365 days; diff --git a/test/DeepstateMinterController.t.sol b/test/DeepstateMinterController.t.sol index 92d54b2..ad49e51 100644 --- a/test/DeepstateMinterController.t.sol +++ b/test/DeepstateMinterController.t.sol @@ -37,8 +37,8 @@ contract DeepstateMinterControllerTest is Test { assertEq(address(minterController.sablierLockup()), address(sablier)); assertEq(minterController.recipient(), recipient); assertEq(minterController.mintCap(), MINT_CAP); - assertEq(minterController.RECIPIENT_ALLOCATION_BPS(), 3_000); - assertEq(minterController.BPS_DENOMINATOR(), 10_000); + assertEq(minterController.RECIPIENT_ALLOCATION_BPS(), 30_00); + assertEq(minterController.BPS_DENOMINATOR(), 100_00); assertEq(minterController.VESTING_DURATION(), 365 days); assertEq(minterController.TOKEN_ADMINISTRATION_DURATION(), 2 * 365 days); assertEq(minterController.owner(), address(this)); @@ -391,8 +391,8 @@ contract DeepstateMinterControllerTest is Test { } function test_RevertWhenVestingAmountExceedsSablierUint128Limit() public { - uint256 amount = Math.mulDiv(uint256(type(uint128).max) + 1, 10_000, 3_000, Math.Rounding.Ceil); - uint256 vestingAmount = Math.mulDiv(amount, 3_000, 10_000); + uint256 amount = Math.mulDiv(uint256(type(uint128).max) + 1, 100_00, 30_00, Math.Rounding.Ceil); + uint256 vestingAmount = Math.mulDiv(amount, 30_00, 100_00); vm.expectRevert(abi.encodeWithSelector(DeepstateMinterController.VestingAmountTooLarge.selector, vestingAmount)); minterController.mint(mintRecipient, amount); @@ -452,9 +452,9 @@ contract DeepstateMinterControllerTest is Test { } function testFuzz_MintAlwaysCreatesExactAdditionalAllocation(uint128 rawAmount) public { - uint256 maximumAmount = Math.mulDiv(MINT_CAP, 10_000, 13_000); + uint256 maximumAmount = Math.mulDiv(MINT_CAP, 100_00, 130_00); uint256 amount = bound(uint256(rawAmount), 4, maximumAmount); - uint256 expectedVesting = Math.mulDiv(amount, 3_000, 10_000); + uint256 expectedVesting = Math.mulDiv(amount, 30_00, 100_00); uint256 streamId = minterController.mint(mintRecipient, amount); From e1303f9f43d87389d6c8d1fcc364a71bd96146d9 Mon Sep 17 00:00:00 2001 From: Joseph Delong Date: Sun, 30 Aug 2026 00:24:45 -0500 Subject: [PATCH 13/25] fix: burn each rewarder's live balance --- src/DeepstateRewarderFactory.sol | 3 +-- src/DeepstateRewarderV2.sol | 7 ++++-- test/DeepstateRewarderFactory.t.sol | 20 +++++++++++++++- test/DeepstateRewarderV2.t.sol | 36 +++++++++++------------------ 4 files changed, 39 insertions(+), 27 deletions(-) diff --git a/src/DeepstateRewarderFactory.sol b/src/DeepstateRewarderFactory.sol index 532f92a..73bba06 100644 --- a/src/DeepstateRewarderFactory.sol +++ b/src/DeepstateRewarderFactory.sol @@ -191,8 +191,7 @@ contract DeepstateRewarderFactory is Ownable { delete activeRewarder[poolId_]; delete rewarderPool[rewarder]; - burnedAmount = rewardToken.balanceOf(rewarder); - DeepstateRewarderV2(rewarder).burnBalance(burnedAmount); + burnedAmount = DeepstateRewarderV2(rewarder).burnBalance(); emit MarketRemoved(poolId_, rewarder, burnedAmount); } diff --git a/src/DeepstateRewarderV2.sol b/src/DeepstateRewarderV2.sol index 7faf6cf..848a427 100644 --- a/src/DeepstateRewarderV2.sol +++ b/src/DeepstateRewarderV2.sol @@ -1,6 +1,8 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + import {DeepstateRewarder} from "./DeepstateRewarder.sol"; import {IBurnableERC20} from "./interfaces/IBurnableERC20.sol"; @@ -40,9 +42,10 @@ contract DeepstateRewarderV2 is DeepstateRewarder { ) {} - /// @notice Burn an amount of the reward token held by this rewarder. + /// @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(uint256 amount) external onlyOwner { + function burnBalance() external onlyOwner returns (uint256 amount) { + amount = IERC20(rewardToken).balanceOf(address(this)); IBurnableERC20(rewardToken).burn(amount); emit RewardBalanceBurned(amount); } diff --git a/test/DeepstateRewarderFactory.t.sol b/test/DeepstateRewarderFactory.t.sol index 8a309bf..6d07d80 100644 --- a/test/DeepstateRewarderFactory.t.sol +++ b/test/DeepstateRewarderFactory.t.sol @@ -271,13 +271,31 @@ contract DeepstateRewarderFactoryTest is Test { assertEq(deep.totalSupply(), 30_000_000e18); } + function test_RemovalBurnsLiveBalanceAfterPriorClaim() public { + vm.prank(controller); + DeepstateRewarderV2 rewarder = factory.deployMarket(_market(TOKEN_A, TOKEN_B)); + uint256 claimed = 25_000_000e18; + + vm.prank(address(rewarder)); + deep.transfer(alice, claimed); + + vm.prank(controller); + uint256 burned = factory.removeMarket(TOKEN_A, TOKEN_B); + + assertEq(burned, 75_000_000e18); + assertEq(deep.balanceOf(address(rewarder)), 0); + assertEq(deep.balanceOf(alice), claimed); + assertEq(deep.balanceOf(address(sablier)), 30_000_000e18); + assertEq(deep.totalSupply(), 55_000_000e18); + } + function test_ControllerCannotBurnRewarderDirectly() public { vm.prank(controller); DeepstateRewarderV2 rewarder = factory.deployMarket(_market(TOKEN_A, TOKEN_B)); vm.expectRevert(Ownable.Unauthorized.selector); vm.prank(controller); - rewarder.burnBalance(1); + rewarder.burnBalance(); assertEq(deep.balanceOf(address(rewarder)), 100_000_000e18); assertEq(deep.balanceOf(controller), 0); diff --git a/test/DeepstateRewarderV2.t.sol b/test/DeepstateRewarderV2.t.sol index bc9fb0b..e387817 100644 --- a/test/DeepstateRewarderV2.t.sol +++ b/test/DeepstateRewarderV2.t.sol @@ -1,7 +1,6 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.24; -import {IERC20Errors} from "@openzeppelin/contracts/interfaces/draft-IERC6093.sol"; import {Test} from "forge-std/Test.sol"; import {Ownable} from "solady/auth/Ownable.sol"; @@ -47,16 +46,16 @@ contract DeepstateRewarderV2Test is Test { assertEq(rewarder.sideEmissionCap(), SIDE_CAP); } - function test_OwnerCanBurnRequestedRewardBalance() public { + function test_OwnerCanBurnEntireLiveRewardBalance() public { uint256 funding = rewardToken.balanceOf(address(rewarder)); - uint256 amount = funding / 4; vm.expectEmit(false, false, false, true, address(rewarder)); - emit DeepstateRewarderV2.RewardBalanceBurned(amount); - rewarder.burnBalance(amount); + emit DeepstateRewarderV2.RewardBalanceBurned(funding); + uint256 burned = rewarder.burnBalance(); - assertEq(rewardToken.balanceOf(address(rewarder)), funding - amount); - assertEq(rewardToken.totalSupply(), funding - amount); + assertEq(burned, funding); + assertEq(rewardToken.balanceOf(address(rewarder)), 0); + assertEq(rewardToken.totalSupply(), 0); } function test_NonOwnerCannotBurnRewardBalance() public { @@ -64,30 +63,23 @@ contract DeepstateRewarderV2Test is Test { vm.expectRevert(Ownable.Unauthorized.selector); vm.prank(alice); - rewarder.burnBalance(1); + rewarder.burnBalance(); assertEq(rewardToken.balanceOf(address(rewarder)), fundingBefore); assertEq(rewardToken.totalSupply(), fundingBefore); } - function test_BurnBalanceRejectsAmountAboveBalance() public { - uint256 funding = rewardToken.balanceOf(address(rewarder)); - vm.expectRevert( - abi.encodeWithSelector( - IERC20Errors.ERC20InsufficientBalance.selector, address(rewarder), funding, funding + 1 - ) - ); - rewarder.burnBalance(funding + 1); - } - - function test_BurnBalanceAcceptsZeroAmount() public { + function test_BurnBalanceReturnsZeroForEmptyRewarder() public { uint256 funding = rewardToken.balanceOf(address(rewarder)); + rewarder.burnBalance(); vm.expectEmit(false, false, false, true, address(rewarder)); emit DeepstateRewarderV2.RewardBalanceBurned(0); - rewarder.burnBalance(0); + uint256 burned = rewarder.burnBalance(); - assertEq(rewardToken.balanceOf(address(rewarder)), funding); - assertEq(rewardToken.totalSupply(), funding); + assertEq(burned, 0); + assertEq(funding, uint256(SIDE_CAP) * 2); + assertEq(rewardToken.balanceOf(address(rewarder)), 0); + assertEq(rewardToken.totalSupply(), 0); } } From 0f7c263a259c58441b9c5e420de1fe4ec396b04a Mon Sep 17 00:00:00 2001 From: Joseph Delong Date: Sun, 30 Aug 2026 00:37:08 -0500 Subject: [PATCH 14/25] refactor: rename token administration lifecycle --- README.md | 15 ++++---- src/DeepstateMinterController.sol | 10 +++--- test/DeepstateMinterController.t.sol | 53 +++++++++++++++------------- 3 files changed, 41 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index f8a1e5c..a2b3d65 100644 --- a/README.md +++ b/README.md @@ -89,13 +89,14 @@ 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 activates the term only after granting the token admin role -to the controller. Activation also ensures the controller has the token minter -role. The controller owner may rotate during the term but cannot return token -administration early. At or after the exact two-year deadline, anyone may -trigger the return. The return 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 +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 `DeepstateMinterController.MINTER_ROLE`; ownership transfers grant that role to the new owner and revoke it from the previous owner. Every mint is therefore authorized through the same role check, including owner mints. diff --git a/src/DeepstateMinterController.sol b/src/DeepstateMinterController.sol index aaa5a6f..a2f7a29 100644 --- a/src/DeepstateMinterController.sol +++ b/src/DeepstateMinterController.sol @@ -82,9 +82,9 @@ contract DeepstateMinterController is AccessControl, Ownable, ReentrancyGuard { _grantRole(MINTER_ROLE, owner_); } - /// @notice Start the two-year DEEP administration term after this contract receives token admin authority. + /// @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 activateTokenAdministration() external onlyOwner { + function lockTokenAdministration() external onlyOwner { if (tokenAdministrationEndsAt != 0) revert TokenAdministrationAlreadyActivated(); bytes32 tokenAdminRole = rewardToken.DEFAULT_ADMIN_ROLE(); @@ -98,9 +98,9 @@ contract DeepstateMinterController is AccessControl, Ownable, ReentrancyGuard { emit TokenAdministrationActivated(endsAt); } - /// @notice Return DEEP administration to this contract's current governance owner after the term expires. - /// @dev Anyone may trigger the return at or after the exact deadline. - function returnTokenAdministration() external { + /// @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 (tokenAdministrationReturned) revert TokenAdministrationAlreadyReturned(); diff --git a/test/DeepstateMinterController.t.sol b/test/DeepstateMinterController.t.sol index ad49e51..4865c1c 100644 --- a/test/DeepstateMinterController.t.sol +++ b/test/DeepstateMinterController.t.sol @@ -105,14 +105,14 @@ contract DeepstateMinterControllerTest is Test { assertEq(created.totalDuration, 365 days); } - function test_ActivateTokenAdministrationStartsTwoYearTermAndEnsuresMinterRole() public { + 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.activateTokenAdministration(); + minterController.lockTokenAdministration(); assertEq(minterController.tokenAdministrationEndsAt(), expectedEndsAt); assertFalse(minterController.tokenAdministrationReturned()); @@ -120,27 +120,27 @@ contract DeepstateMinterControllerTest is Test { assertTrue(deep.hasRole(deep.MINTER_ROLE(), address(minterController))); } - function test_RevertActivationWithoutTokenAdminOrByNonOwnerOrTwice() public { + function test_RevertLockWithoutTokenAdminOrByNonOwnerOrTwice() public { vm.expectRevert(Ownable.Unauthorized.selector); vm.prank(unauthorized); - minterController.activateTokenAdministration(); + minterController.lockTokenAdministration(); vm.expectRevert(DeepstateMinterController.ControllerNotTokenAdmin.selector); - minterController.activateTokenAdministration(); + minterController.lockTokenAdministration(); deep.grantRole(deep.DEFAULT_ADMIN_ROLE(), address(minterController)); - minterController.activateTokenAdministration(); + minterController.lockTokenAdministration(); vm.expectRevert(DeepstateMinterController.TokenAdministrationAlreadyActivated.selector); - minterController.activateTokenAdministration(); + minterController.lockTokenAdministration(); } - function test_OwnerCannotReturnTokenAdministrationBeforeDeadline() public { - _activateSoleTokenAdministration(); + function test_OwnerCannotUnlockTokenAdministrationBeforeDeadline() public { + _lockSoleTokenAdministration(); uint40 endsAt = minterController.tokenAdministrationEndsAt(); vm.expectRevert(abi.encodeWithSelector(DeepstateMinterController.TokenAdministrationActive.selector, endsAt)); - minterController.returnTokenAdministration(); + minterController.unlockTokenAdministration(); assertFalse(minterController.tokenAdministrationReturned()); assertFalse(deep.hasRole(deep.DEFAULT_ADMIN_ROLE(), address(this))); @@ -148,26 +148,28 @@ contract DeepstateMinterControllerTest is Test { assertEq(deep.defaultAdminCount(), 1); } - function test_AnyoneCanReturnTokenAdministrationAtExactDeadline() public { - _activateSoleTokenAdministration(); + 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.returnTokenAdministration(); + minterController.unlockTokenAdministration(); vm.warp(endsAt); vm.prank(unauthorized); - minterController.returnTokenAdministration(); + minterController.unlockTokenAdministration(); + assertEq(minterController.tokenAdministrationEndsAt(), endsAt); + assertTrue(minterController.tokenAdministrationReturned()); assertTrue(deep.hasRole(deep.DEFAULT_ADMIN_ROLE(), address(this))); assertFalse(deep.hasRole(deep.DEFAULT_ADMIN_ROLE(), address(minterController))); assertEq(deep.defaultAdminCount(), 1); } - function test_ReturnUsesCurrentOwnerAfterOwnershipTransfer() public { - _activateSoleTokenAdministration(); + function test_UnlockUsesCurrentOwnerAfterOwnershipTransfer() public { + _lockSoleTokenAdministration(); minterController.transferOwnership(newGovernance); assertEq(minterController.owner(), newGovernance); @@ -178,8 +180,9 @@ contract DeepstateMinterControllerTest is Test { vm.warp(minterController.tokenAdministrationEndsAt()); vm.prank(unauthorized); - minterController.returnTokenAdministration(); + minterController.unlockTokenAdministration(); + assertTrue(minterController.tokenAdministrationReturned()); 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))); @@ -251,11 +254,11 @@ contract DeepstateMinterControllerTest is Test { minterController.grantRole(controllerAdminRole, unauthorized); } - function test_RevertReturnBeforeActivationAfterReturnOrWithoutTokenAdmin() public { + function test_RevertUnlockBeforeLockAfterUnlockOrWithoutTokenAdmin() public { vm.expectRevert(DeepstateMinterController.TokenAdministrationNotActive.selector); - minterController.returnTokenAdministration(); + minterController.unlockTokenAdministration(); - _activateSoleTokenAdministration(); + _lockSoleTokenAdministration(); bytes32 tokenAdminRole = deep.DEFAULT_ADMIN_ROLE(); vm.prank(address(minterController)); deep.grantRole(tokenAdminRole, address(this)); @@ -263,13 +266,13 @@ contract DeepstateMinterControllerTest is Test { vm.warp(minterController.tokenAdministrationEndsAt()); vm.expectRevert(DeepstateMinterController.ControllerNotTokenAdmin.selector); - minterController.returnTokenAdministration(); + minterController.unlockTokenAdministration(); deep.grantRole(tokenAdminRole, address(minterController)); - minterController.returnTokenAdministration(); + minterController.unlockTokenAdministration(); vm.expectRevert(DeepstateMinterController.TokenAdministrationAlreadyReturned.selector); - minterController.returnTokenAdministration(); + minterController.unlockTokenAdministration(); } function test_EachMintCreatesAnIndependentStream() public { @@ -464,9 +467,9 @@ contract DeepstateMinterControllerTest is Test { assertEq(sablier.stream(streamId).depositAmount, expectedVesting); } - function _activateSoleTokenAdministration() internal { + function _lockSoleTokenAdministration() internal { deep.grantRole(deep.DEFAULT_ADMIN_ROLE(), address(minterController)); - minterController.activateTokenAdministration(); + minterController.lockTokenAdministration(); deep.renounceRole(deep.DEFAULT_ADMIN_ROLE(), address(this)); assertTrue(deep.hasRole(deep.DEFAULT_ADMIN_ROLE(), address(minterController))); From c3960a35801f91a368733e5c1d984309d2438f5d Mon Sep 17 00:00:00 2001 From: Joseph Delong Date: Sun, 30 Aug 2026 00:54:59 -0500 Subject: [PATCH 15/25] fix: make recipient carry thirty percent --- README.md | 14 ++-- src/DeepstateMinterController.sol | 10 +-- src/interfaces/IDeepstateMinterController.sol | 1 + test/DeepstateMinterController.t.sol | 64 ++++++++++--------- ...teMinterControllerSablierIntegration.t.sol | 4 +- test/DeepstateRewarderFactory.t.sol | 36 ++++++----- 6 files changed, 71 insertions(+), 58 deletions(-) diff --git a/README.md b/README.md index a2b3d65..79dec37 100644 --- a/README.md +++ b/README.md @@ -63,22 +63,24 @@ proposals can therefore execute immediately after voting ends. `DeepstateMinterController` is the operational DEEP minter. Governance grants its `MINTER_ROLE` only to approved issuance contracts, such as `DeepstateRewarderFactory`. For every requested mint `M`, the controller mints -`M` to the requested address and an additional `floor(30% * M)` to Sablier -Lockup v4.0.1. Each recipient allocation gets its own linear one-year stream. +`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 additional 30% allocation. Burns reduce total supply and reopen +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 30% is additional issuance, not a split of `M`. A factory market therefore -receives its complete 100,000,000 DEEP initial funding while a separate -30,000,000 DEEP stream is created, for 130,000,000 DEEP total issuance. If a +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. diff --git a/src/DeepstateMinterController.sol b/src/DeepstateMinterController.sol index a2f7a29..63efa93 100644 --- a/src/DeepstateMinterController.sol +++ b/src/DeepstateMinterController.sol @@ -15,7 +15,7 @@ import {DeepstateToken} from "./DeepstateToken.sol"; import {ISablierLockupLinearV4} from "./interfaces/ISablierLockupLinearV4.sol"; /// @title Deepstate Minter Controller -/// @notice Enforces an additional 30% recipient allocation on every authorized DEEP mint. +/// @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 AccessControl, Ownable, ReentrancyGuard { @@ -23,7 +23,7 @@ contract DeepstateMinterController is AccessControl, Ownable, ReentrancyGuard { bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE"); uint256 public constant RECIPIENT_ALLOCATION_BPS = 30_00; - uint256 public constant BPS_DENOMINATOR = 100_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; @@ -119,12 +119,12 @@ contract DeepstateMinterController is AccessControl, Ownable, ReentrancyGuard { emit TokenAdministrationReturned(owner_, msg.sender); } - /// @notice Mint `amount` DEEP to `to` and an additional 30% into a one-year recipient stream. - /// @dev The 30% calculation rounds down. Amounts that round the stream allocation to zero revert. + /// @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 onlyRole(MINTER_ROLE) nonReentrant returns (uint256 streamId) { if (to == address(0)) revert InvalidMintRecipient(); - uint256 vestingAmount = Math.mulDiv(amount, RECIPIENT_ALLOCATION_BPS, BPS_DENOMINATOR); + uint256 vestingAmount = Math.mulDiv(amount, RECIPIENT_ALLOCATION_BPS, PRIMARY_ALLOCATION_BPS); if (vestingAmount == 0) revert MintAmountTooSmall(); if (vestingAmount > type(uint128).max) revert VestingAmountTooLarge(vestingAmount); uint128 streamAmount = SafeCast.toUint128(vestingAmount); diff --git a/src/interfaces/IDeepstateMinterController.sol b/src/interfaces/IDeepstateMinterController.sol index 31b34b1..18f04ca 100644 --- a/src/interfaces/IDeepstateMinterController.sol +++ b/src/interfaces/IDeepstateMinterController.sol @@ -5,5 +5,6 @@ pragma solidity 0.8.28; interface IDeepstateMinterController { function hasRole(bytes32 role, address account) external view returns (bool); function rewardToken() 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/test/DeepstateMinterController.t.sol b/test/DeepstateMinterController.t.sol index 4865c1c..be1629f 100644 --- a/test/DeepstateMinterController.t.sol +++ b/test/DeepstateMinterController.t.sol @@ -38,7 +38,7 @@ contract DeepstateMinterControllerTest is Test { assertEq(minterController.recipient(), recipient); assertEq(minterController.mintCap(), MINT_CAP); assertEq(minterController.RECIPIENT_ALLOCATION_BPS(), 30_00); - assertEq(minterController.BPS_DENOMINATOR(), 100_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)); @@ -73,7 +73,7 @@ contract DeepstateMinterControllerTest is Test { } function test_MintCreatesExactNonCancelableOneYearStream() public { - uint256 amount = 100_000_000e18; + uint256 amount = 70_000_000e18; uint256 vestingAmount = 30_000_000e18; vm.expectEmit(true, true, true, true, address(minterController)); @@ -212,7 +212,7 @@ contract DeepstateMinterControllerTest is Test { ownerController.mint(mintRecipient, 100e18); assertEq(deep.balanceOf(mintRecipient), 200e18); - assertEq(deep.balanceOf(address(sablier)), 60e18); + assertEq(deep.balanceOf(address(sablier)), 2 * Math.mulDiv(100e18, 30_00, 70_00)); } function test_TwoStepOwnershipHandoverSynchronizesControllerAdmin() public { @@ -276,54 +276,54 @@ contract DeepstateMinterControllerTest is Test { } function test_EachMintCreatesAnIndependentStream() public { - uint256 firstStreamId = minterController.mint(mintRecipient, 100e18); + uint256 firstStreamId = minterController.mint(mintRecipient, 70e18); vm.warp(block.timestamp + 30 days); - uint256 secondStreamId = minterController.mint(mintRecipient, 200e18); + 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), 300e18); + assertEq(deep.balanceOf(mintRecipient), 210e18); assertEq(deep.balanceOf(address(sablier)), 90e18); } function test_MintRoundsRecipientAllocationDown() public { - minterController.mint(mintRecipient, 4); + minterController.mint(mintRecipient, 5); - assertEq(deep.balanceOf(mintRecipient), 4); - assertEq(deep.balanceOf(address(sablier)), 1); - assertEq(deep.totalSupply(), 5); + assertEq(deep.balanceOf(mintRecipient), 5); + assertEq(deep.balanceOf(address(sablier)), 2); + assertEq(deep.totalSupply(), 7); } function test_MintCapIncludesExistingRequestedAndVestedSupply() public { - DeepstateMinterController cappedController = _newControllerWithCap(130e18); + DeepstateMinterController cappedController = _newControllerWithCap(100e18); deep.grantRole(deep.MINTER_ROLE(), address(this)); deep.mint(unauthorized, 5); - vm.expectRevert(abi.encodeWithSelector(DeepstateMinterController.MintCapExceeded.selector, 130e18, 130e18 + 5)); - cappedController.mint(mintRecipient, 100e18); + 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(130e18); + DeepstateMinterController cappedController = _newControllerWithCap(100e18); - cappedController.mint(mintRecipient, 100e18); - assertEq(deep.totalSupply(), 130e18); + cappedController.mint(mintRecipient, 70e18); + assertEq(deep.totalSupply(), 100e18); - vm.expectRevert(abi.encodeWithSelector(DeepstateMinterController.MintCapExceeded.selector, 130e18, 130e18 + 5)); - cappedController.mint(mintRecipient, 4); + vm.expectRevert(abi.encodeWithSelector(DeepstateMinterController.MintCapExceeded.selector, 100e18, 100e18 + 4)); + cappedController.mint(mintRecipient, 3); vm.prank(mintRecipient); - deep.burn(5); - cappedController.mint(mintRecipient, 4); + deep.burn(4); + cappedController.mint(mintRecipient, 3); - assertEq(deep.totalSupply(), 130e18); - assertEq(deep.balanceOf(mintRecipient), 100e18 - 1); + assertEq(deep.totalSupply(), 100e18); + assertEq(deep.balanceOf(mintRecipient), 70e18 - 1); assertEq(deep.balanceOf(address(sablier)), 30e18 + 1); } @@ -387,15 +387,15 @@ contract DeepstateMinterControllerTest is Test { minterController.mint(address(0), 100e18); vm.expectRevert(DeepstateMinterController.MintAmountTooSmall.selector); - minterController.mint(mintRecipient, 3); + 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, 100_00, 30_00, Math.Rounding.Ceil); - uint256 vestingAmount = Math.mulDiv(amount, 30_00, 100_00); + 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); @@ -443,8 +443,11 @@ contract DeepstateMinterControllerTest is Test { function test_MissingSablierTokenPullRollsBackBothMintsAndStream() public { sablier.setSkipTokenPull(true); + uint256 vestingAmount = Math.mulDiv(100e18, 30_00, 70_00); - vm.expectRevert(abi.encodeWithSelector(DeepstateMinterController.StreamFundingMismatch.selector, 0, 30e18)); + vm.expectRevert( + abi.encodeWithSelector(DeepstateMinterController.StreamFundingMismatch.selector, 0, vestingAmount) + ); minterController.mint(mintRecipient, 100e18); assertEq(deep.totalSupply(), 0); @@ -454,16 +457,17 @@ contract DeepstateMinterControllerTest is Test { assertEq(sablier.nextStreamId(), 1); } - function testFuzz_MintAlwaysCreatesExactAdditionalAllocation(uint128 rawAmount) public { - uint256 maximumAmount = Math.mulDiv(MINT_CAP, 100_00, 130_00); - uint256 amount = bound(uint256(rawAmount), 4, maximumAmount); - uint256 expectedVesting = Math.mulDiv(amount, 30_00, 100_00); + 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); } diff --git a/test/DeepstateMinterControllerSablierIntegration.t.sol b/test/DeepstateMinterControllerSablierIntegration.t.sol index bea96a9..fb890d0 100644 --- a/test/DeepstateMinterControllerSablierIntegration.t.sol +++ b/test/DeepstateMinterControllerSablierIntegration.t.sol @@ -40,7 +40,7 @@ contract DeepstateMinterControllerSablierIntegrationTest is Test { function test_RealSablierV4StreamVestsLinearlyForOneYear() public { uint40 startTime = uint40(block.timestamp); uint128 vestingAmount = 30e18; - uint256 streamId = minterController.mint(mintRecipient, 100e18); + uint256 streamId = minterController.mint(mintRecipient, 70e18); assertEq(streamId, 1); assertEq(sablier.ownerOf(streamId), recipient); @@ -75,7 +75,7 @@ contract DeepstateMinterControllerSablierIntegrationTest is Test { } function test_RealSablierV4StreamCannotBeCanceledOrTransferred() public { - uint256 streamId = minterController.mint(mintRecipient, 100e18); + uint256 streamId = minterController.mint(mintRecipient, 70e18); vm.expectRevert(); sablier.cancel(streamId); diff --git a/test/DeepstateRewarderFactory.t.sol b/test/DeepstateRewarderFactory.t.sol index 6d07d80..8e48a19 100644 --- a/test/DeepstateRewarderFactory.t.sol +++ b/test/DeepstateRewarderFactory.t.sol @@ -2,6 +2,7 @@ pragma solidity ^0.8.24; 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 {DeepstateV1} from "deepstate-contracts/DeepstateV1.sol"; @@ -151,7 +152,7 @@ contract DeepstateRewarderFactoryTest is Test { assertEq(secondFactory.owner(), governance); assertEq(rewarder.owner(), address(secondFactory)); assertEq(secondToken.balanceOf(address(rewarder)), 100_000_000e18); - assertEq(secondToken.balanceOf(address(sablier)), 30_000_000e18); + assertEq(secondToken.balanceOf(address(sablier)), _vestingAllocation(100_000_000e18)); } function test_ControllerDeploysPredictedCreate2MarketWithFixedScheduleAndFunding() public { @@ -180,8 +181,8 @@ contract DeepstateRewarderFactoryTest is Test { assertEq(rewarder.token1StartQuantity(), 1e6); assertEq(rewarder.token1MaxQuantity(), 1_000_000e6); assertEq(deep.balanceOf(address(rewarder)), 100_000_000e18); - assertEq(deep.balanceOf(address(sablier)), 30_000_000e18); - assertEq(deep.totalSupply(), 130_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); @@ -210,8 +211,8 @@ contract DeepstateRewarderFactoryTest is Test { assertEq(factory.deploymentCount(), 2); assertEq(deep.balanceOf(address(second)), 100_000_000e18); - assertEq(deep.balanceOf(address(sablier)), 60_000_000e18); - assertEq(deep.totalSupply(), 260_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_GovernanceCanDeployWithoutControllerButStillObeysCooldown() public { @@ -267,8 +268,8 @@ contract DeepstateRewarderFactoryTest is Test { assertEq(factory.rewarderPool(address(rewarder)), bytes32(0)); assertEq(deep.balanceOf(address(rewarder)), 0); assertEq(deep.balanceOf(address(factory)), 0); - assertEq(deep.balanceOf(address(sablier)), 30_000_000e18); - assertEq(deep.totalSupply(), 30_000_000e18); + assertEq(deep.balanceOf(address(sablier)), _vestingAllocation(100_000_000e18)); + assertEq(deep.totalSupply(), _vestingAllocation(100_000_000e18)); } function test_RemovalBurnsLiveBalanceAfterPriorClaim() public { @@ -285,8 +286,8 @@ contract DeepstateRewarderFactoryTest is Test { assertEq(burned, 75_000_000e18); assertEq(deep.balanceOf(address(rewarder)), 0); assertEq(deep.balanceOf(alice), claimed); - assertEq(deep.balanceOf(address(sablier)), 30_000_000e18); - assertEq(deep.totalSupply(), 55_000_000e18); + assertEq(deep.balanceOf(address(sablier)), _vestingAllocation(100_000_000e18)); + assertEq(deep.totalSupply(), claimed + _vestingAllocation(100_000_000e18)); } function test_ControllerCannotBurnRewarderDirectly() public { @@ -309,13 +310,14 @@ contract DeepstateRewarderFactoryTest is Test { minterController.mint(address(rewarder), 900_000_000e18); minterController.revokeRole(minterController.MINTER_ROLE(), address(this)); assertEq(deep.balanceOf(address(rewarder)), 1_000_000_000e18); - assertEq(deep.balanceOf(address(sablier)), 300_000_000e18); + uint256 vested = _vestingAllocation(100_000_000e18) + _vestingAllocation(900_000_000e18); + assertEq(deep.balanceOf(address(sablier)), vested); vm.prank(controller); uint256 burned = factory.removeMarket(TOKEN_A, TOKEN_B); assertEq(burned, 1_000_000_000e18); - assertEq(deep.totalSupply(), 300_000_000e18); + assertEq(deep.totalSupply(), vested); } function test_GovernanceCanRemoveMarketAfterRevokingController() public { @@ -326,7 +328,7 @@ contract DeepstateRewarderFactoryTest is Test { uint256 burned = factory.removeMarket(TOKEN_A, TOKEN_B); assertEq(burned, 100_000_000e18); - assertEq(deep.totalSupply(), 30_000_000e18); + assertEq(deep.totalSupply(), _vestingAllocation(100_000_000e18)); } function test_RemovedPoolCanBeResetWithFreshCreate2AddressAfterCooldown() public { @@ -348,8 +350,8 @@ contract DeepstateRewarderFactoryTest is Test { assertEq(address(second), predicted); assertEq(factory.activeRewarder(_poolId(TOKEN_A, TOKEN_B)), address(second)); assertEq(deep.balanceOf(address(second)), 100_000_000e18); - assertEq(deep.balanceOf(address(sablier)), 60_000_000e18); - assertEq(deep.totalSupply(), 160_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_CannotDeployOverActiveFactoryMarketOrExistingRouterHook() public { @@ -483,7 +485,7 @@ contract DeepstateRewarderFactoryTest is Test { assertEq(burned, 100_000_000e18); assertEq(factory.activeRewarder(poolId), address(0)); assertEq(deep.balanceOf(address(rewarder)), 0); - assertEq(deep.totalSupply(), 30_000_000e18); + assertEq(deep.totalSupply(), _vestingAllocation(100_000_000e18)); } function test_RemovalRejectsUnexpectedReplacementHook() public { @@ -575,6 +577,10 @@ contract DeepstateRewarderFactoryTest is Test { 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_) From 4c6b0bfb5049bdd7395c878185716c74b8cfe268 Mon Sep 17 00:00:00 2001 From: Joseph Delong Date: Sun, 30 Aug 2026 01:09:03 -0500 Subject: [PATCH 16/25] refactor: name controller token explicitly --- src/DeepstateMinterController.sol | 34 +++++++++---------- src/DeepstateRewarderFactory.sol | 6 ++-- src/interfaces/IDeepstateMinterController.sol | 2 +- test/DeepstateMinterController.t.sol | 6 ++-- test/DeepstateRewarderFactory.t.sol | 10 +++--- 5 files changed, 29 insertions(+), 29 deletions(-) diff --git a/src/DeepstateMinterController.sol b/src/DeepstateMinterController.sol index 63efa93..048981e 100644 --- a/src/DeepstateMinterController.sol +++ b/src/DeepstateMinterController.sol @@ -27,7 +27,7 @@ contract DeepstateMinterController is AccessControl, Ownable, ReentrancyGuard { uint40 public constant VESTING_DURATION = 365 days; uint40 public constant TOKEN_ADMINISTRATION_DURATION = 2 * 365 days; - DeepstateToken public immutable rewardToken; + DeepstateToken public immutable deepstateToken; ISablierLockupLinearV4 public immutable sablierLockup; address public immutable recipient; /// @notice Maximum live DEEP supply this controller will permit after a mint. @@ -50,7 +50,7 @@ contract DeepstateMinterController is AccessControl, Ownable, ReentrancyGuard { event TokenAdministrationReturned(address indexed owner, address indexed caller); error InvalidOwner(); - error InvalidRewardToken(); + error InvalidDeepstateToken(); error InvalidSablierLockup(); error InvalidRecipient(); error InvalidMintCap(); @@ -66,14 +66,14 @@ contract DeepstateMinterController is AccessControl, Ownable, ReentrancyGuard { error OwnerMustRetainDefaultAdmin(); error MintCapExceeded(uint256 cap, uint256 attemptedSupply); - constructor(address owner_, address rewardToken_, address sablierLockup_, address recipient_, uint256 mintCap_) { + constructor(address owner_, address deepstateToken_, address sablierLockup_, address recipient_, uint256 mintCap_) { if (owner_ == address(0)) revert InvalidOwner(); - if (rewardToken_ == address(0) || rewardToken_.code.length == 0) revert InvalidRewardToken(); + 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(); - rewardToken = DeepstateToken(rewardToken_); + deepstateToken = DeepstateToken(deepstateToken_); sablierLockup = ISablierLockupLinearV4(sablierLockup_); recipient = recipient_; mintCap = mintCap_; @@ -87,10 +87,10 @@ contract DeepstateMinterController is AccessControl, Ownable, ReentrancyGuard { function lockTokenAdministration() external onlyOwner { if (tokenAdministrationEndsAt != 0) revert TokenAdministrationAlreadyActivated(); - bytes32 tokenAdminRole = rewardToken.DEFAULT_ADMIN_ROLE(); - if (!rewardToken.hasRole(tokenAdminRole, address(this))) revert ControllerNotTokenAdmin(); - if (!rewardToken.hasRole(rewardToken.MINTER_ROLE(), address(this))) { - rewardToken.grantRole(rewardToken.MINTER_ROLE(), address(this)); + 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 = SafeCast.toUint40(block.timestamp + TOKEN_ADMINISTRATION_DURATION); @@ -108,13 +108,13 @@ contract DeepstateMinterController is AccessControl, Ownable, ReentrancyGuard { address owner_ = owner(); if (block.timestamp < endsAt) revert TokenAdministrationActive(endsAt); - bytes32 tokenAdminRole = rewardToken.DEFAULT_ADMIN_ROLE(); - if (!rewardToken.hasRole(tokenAdminRole, address(this))) revert ControllerNotTokenAdmin(); + bytes32 tokenAdminRole = deepstateToken.DEFAULT_ADMIN_ROLE(); + if (!deepstateToken.hasRole(tokenAdminRole, address(this))) revert ControllerNotTokenAdmin(); tokenAdministrationReturned = true; // Grant first so DeepstateToken's final-admin invariant cannot strand the token. - rewardToken.grantRole(tokenAdminRole, owner_); - rewardToken.renounceRole(tokenAdminRole, address(this)); + deepstateToken.grantRole(tokenAdminRole, owner_); + deepstateToken.renounceRole(tokenAdminRole, address(this)); emit TokenAdministrationReturned(owner_, msg.sender); } @@ -130,14 +130,14 @@ contract DeepstateMinterController is AccessControl, Ownable, ReentrancyGuard { uint128 streamAmount = SafeCast.toUint128(vestingAmount); uint256 mintSupply = amount + vestingAmount; - uint256 attemptedSupply = rewardToken.totalSupply() + mintSupply; + uint256 attemptedSupply = deepstateToken.totalSupply() + mintSupply; if (attemptedSupply > mintCap) revert MintCapExceeded(mintCap, attemptedSupply); - IERC20 token = IERC20(address(rewardToken)); + IERC20 token = IERC20(address(deepstateToken)); uint256 balanceBefore = token.balanceOf(address(this)); - rewardToken.mint(to, amount); - rewardToken.mint(address(this), vestingAmount); + deepstateToken.mint(to, amount); + deepstateToken.mint(address(this), vestingAmount); token.forceApprove(address(sablierLockup), vestingAmount); streamId = sablierLockup.createWithDurationsLL( diff --git a/src/DeepstateRewarderFactory.sol b/src/DeepstateRewarderFactory.sol index 73bba06..5e5b08e 100644 --- a/src/DeepstateRewarderFactory.sol +++ b/src/DeepstateRewarderFactory.sol @@ -95,9 +95,9 @@ contract DeepstateRewarderFactory is Ownable { if (!minterController.hasRole(bytes32(0), owner_)) { revert MinterControllerAdminMismatch(owner_); } - address rewardToken_ = minterController.rewardToken(); - if (rewardToken_ == address(0) || rewardToken_.code.length == 0) revert InvalidRewardToken(); - rewardToken = DeepstateToken(rewardToken_); + address deepstateToken_ = minterController.deepstateToken(); + if (deepstateToken_ == address(0) || deepstateToken_.code.length == 0) revert InvalidRewardToken(); + rewardToken = DeepstateToken(deepstateToken_); } modifier onlyControllerOrOwner() { diff --git a/src/interfaces/IDeepstateMinterController.sol b/src/interfaces/IDeepstateMinterController.sol index 18f04ca..582c1ef 100644 --- a/src/interfaces/IDeepstateMinterController.sol +++ b/src/interfaces/IDeepstateMinterController.sol @@ -4,7 +4,7 @@ pragma solidity 0.8.28; /// @notice Factory-facing interface for policy-controlled DEEP minting. interface IDeepstateMinterController { function hasRole(bytes32 role, address account) external view returns (bool); - function rewardToken() 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/test/DeepstateMinterController.t.sol b/test/DeepstateMinterController.t.sol index be1629f..672caad 100644 --- a/test/DeepstateMinterController.t.sol +++ b/test/DeepstateMinterController.t.sol @@ -33,7 +33,7 @@ contract DeepstateMinterControllerTest is Test { } function test_ImmutableConfigurationAndInitialAuthority() public view { - assertEq(address(minterController.rewardToken()), address(deep)); + assertEq(address(minterController.deepstateToken()), address(deep)); assertEq(address(minterController.sablierLockup()), address(sablier)); assertEq(minterController.recipient(), recipient); assertEq(minterController.mintCap(), MINT_CAP); @@ -53,10 +53,10 @@ contract DeepstateMinterControllerTest is Test { vm.expectRevert(DeepstateMinterController.InvalidOwner.selector); new DeepstateMinterController(address(0), address(deep), address(sablier), recipient, MINT_CAP); - vm.expectRevert(DeepstateMinterController.InvalidRewardToken.selector); + vm.expectRevert(DeepstateMinterController.InvalidDeepstateToken.selector); new DeepstateMinterController(address(this), address(0), address(sablier), recipient, MINT_CAP); - vm.expectRevert(DeepstateMinterController.InvalidRewardToken.selector); + vm.expectRevert(DeepstateMinterController.InvalidDeepstateToken.selector); new DeepstateMinterController(address(this), unauthorized, address(sablier), recipient, MINT_CAP); vm.expectRevert(DeepstateMinterController.InvalidSablierLockup.selector); diff --git a/test/DeepstateRewarderFactory.t.sol b/test/DeepstateRewarderFactory.t.sol index 8e48a19..529e5ef 100644 --- a/test/DeepstateRewarderFactory.t.sol +++ b/test/DeepstateRewarderFactory.t.sol @@ -17,18 +17,18 @@ import {IDeepstateMinterController} from "../src/interfaces/IDeepstateMinterCont import {MockSablierLockupLinearV4} from "./mocks/MockSablierLockupLinearV4.sol"; contract InvalidRewardTokenMinterController is IDeepstateMinterController { - address internal immutable _rewardToken; + address internal immutable _deepstateToken; - constructor(address rewardToken_) { - _rewardToken = rewardToken_; + constructor(address deepstateToken_) { + _deepstateToken = deepstateToken_; } function hasRole(bytes32, address) external pure returns (bool) { return true; } - function rewardToken() external view returns (address) { - return _rewardToken; + function deepstateToken() external view returns (address) { + return _deepstateToken; } function mint(address, uint256) external pure returns (uint256) { From 1cd282f67433d7d6836af518967b560ce4813a69 Mon Sep 17 00:00:00 2001 From: Joseph Delong Date: Sun, 30 Aug 2026 01:14:59 -0500 Subject: [PATCH 17/25] fix: authorize controller owner to mint --- README.md | 6 +++--- src/DeepstateMinterController.sol | 16 +++++++++++----- test/DeepstateMinterController.t.sol | 23 +++++++++++++++++------ 3 files changed, 31 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 79dec37..b6d8225 100644 --- a/README.md +++ b/README.md @@ -99,9 +99,9 @@ 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 -`DeepstateMinterController.MINTER_ROLE`; ownership transfers grant that role to -the new owner and revoke it from the previous owner. Every mint is therefore -authorized through the same role check, including owner mints. +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. diff --git a/src/DeepstateMinterController.sol b/src/DeepstateMinterController.sol index 048981e..f6d14e8 100644 --- a/src/DeepstateMinterController.sol +++ b/src/DeepstateMinterController.sol @@ -79,7 +79,11 @@ contract DeepstateMinterController is AccessControl, Ownable, ReentrancyGuard { mintCap = mintCap_; _initializeOwner(owner_); _grantRole(DEFAULT_ADMIN_ROLE, owner_); - _grantRole(MINTER_ROLE, owner_); + } + + modifier onlyMinterOrOwner() { + _checkMinterOrOwner(); + _; } /// @notice Lock DEEP administration in this contract for the initial two-year term. @@ -121,7 +125,7 @@ contract DeepstateMinterController is AccessControl, Ownable, ReentrancyGuard { /// @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 onlyRole(MINTER_ROLE) nonReentrant returns (uint256 streamId) { + function mint(address to, uint256 amount) external onlyMinterOrOwner nonReentrant returns (uint256 streamId) { if (to == address(0)) revert InvalidMintRecipient(); uint256 vestingAmount = Math.mulDiv(amount, RECIPIENT_ALLOCATION_BPS, PRIMARY_ALLOCATION_BPS); @@ -162,7 +166,7 @@ contract DeepstateMinterController is AccessControl, Ownable, ReentrancyGuard { emit MintedWithVesting(msg.sender, to, amount, recipient, vestingAmount, streamId); } - /// @dev Keep EIP-173 ownership, AccessControl administration, and owner mint authority synchronized. + /// @dev Keep EIP-173 ownership and AccessControl administration synchronized. function _setOwner(address newOwner) internal override { if (newOwner == address(0)) revert NewOwnerIsZeroAddress(); @@ -170,10 +174,8 @@ contract DeepstateMinterController is AccessControl, Ownable, ReentrancyGuard { super._setOwner(newOwner); if (newOwner != previousOwner) { _grantRole(DEFAULT_ADMIN_ROLE, newOwner); - _grantRole(MINTER_ROLE, newOwner); if (previousOwner != address(0)) { _revokeRole(DEFAULT_ADMIN_ROLE, previousOwner); - _revokeRole(MINTER_ROLE, previousOwner); } } } @@ -192,4 +194,8 @@ contract DeepstateMinterController is AccessControl, Ownable, ReentrancyGuard { if (role == DEFAULT_ADMIN_ROLE && callerConfirmation == owner()) revert OwnerMustRetainDefaultAdmin(); super.renounceRole(role, callerConfirmation); } + + function _checkMinterOrOwner() internal view { + if (msg.sender != owner()) _checkRole(MINTER_ROLE); + } } diff --git a/test/DeepstateMinterController.t.sol b/test/DeepstateMinterController.t.sol index 672caad..36a4380 100644 --- a/test/DeepstateMinterController.t.sol +++ b/test/DeepstateMinterController.t.sol @@ -45,7 +45,7 @@ contract DeepstateMinterControllerTest is Test { assertEq(minterController.tokenAdministrationEndsAt(), 0); assertFalse(minterController.tokenAdministrationReturned()); assertTrue(minterController.hasRole(minterController.DEFAULT_ADMIN_ROLE(), address(this))); - assertTrue(minterController.hasRole(minterController.MINTER_ROLE(), address(this))); + assertFalse(minterController.hasRole(minterController.MINTER_ROLE(), address(this))); assertTrue(deep.hasRole(deep.MINTER_ROLE(), address(minterController))); } @@ -176,7 +176,7 @@ contract DeepstateMinterControllerTest is Test { assertFalse(minterController.hasRole(minterController.DEFAULT_ADMIN_ROLE(), address(this))); assertTrue(minterController.hasRole(minterController.DEFAULT_ADMIN_ROLE(), newGovernance)); assertFalse(minterController.hasRole(minterController.MINTER_ROLE(), address(this))); - assertTrue(minterController.hasRole(minterController.MINTER_ROLE(), newGovernance)); + assertFalse(minterController.hasRole(minterController.MINTER_ROLE(), newGovernance)); vm.warp(minterController.tokenAdministrationEndsAt()); vm.prank(unauthorized); @@ -194,12 +194,12 @@ contract DeepstateMinterControllerTest is Test { new DeepstateMinterController(address(this), address(deep), address(sablier), recipient, MINT_CAP); deep.grantRole(deep.MINTER_ROLE(), address(ownerController)); - assertTrue(ownerController.hasRole(ownerController.MINTER_ROLE(), address(this))); + assertFalse(ownerController.hasRole(ownerController.MINTER_ROLE(), address(this))); ownerController.mint(mintRecipient, 100e18); ownerController.transferOwnership(newGovernance); assertFalse(ownerController.hasRole(ownerController.MINTER_ROLE(), address(this))); - assertTrue(ownerController.hasRole(ownerController.MINTER_ROLE(), newGovernance)); + assertFalse(ownerController.hasRole(ownerController.MINTER_ROLE(), newGovernance)); vm.expectRevert( abi.encodeWithSelector( @@ -215,6 +215,17 @@ contract DeepstateMinterControllerTest is Test { assertEq(deep.balanceOf(address(sablier)), 2 * Math.mulDiv(100e18, 30_00, 70_00)); } + function test_OwnerCanMintAfterItsMinterRoleIsRevoked() public { + bytes32 minterRole = minterController.MINTER_ROLE(); + minterController.grantRole(minterRole, address(this)); + minterController.revokeRole(minterRole, address(this)); + + assertFalse(minterController.hasRole(minterRole, address(this))); + minterController.mint(mintRecipient, 70e18); + assertEq(deep.balanceOf(mintRecipient), 70e18); + assertEq(deep.balanceOf(address(sablier)), 30e18); + } + function test_TwoStepOwnershipHandoverSynchronizesControllerAdmin() public { vm.prank(newGovernance); minterController.requestOwnershipHandover(); @@ -224,7 +235,7 @@ contract DeepstateMinterControllerTest is Test { assertFalse(minterController.hasRole(minterController.DEFAULT_ADMIN_ROLE(), address(this))); assertTrue(minterController.hasRole(minterController.DEFAULT_ADMIN_ROLE(), newGovernance)); assertFalse(minterController.hasRole(minterController.MINTER_ROLE(), address(this))); - assertTrue(minterController.hasRole(minterController.MINTER_ROLE(), newGovernance)); + assertFalse(minterController.hasRole(minterController.MINTER_ROLE(), newGovernance)); } function test_TransferOwnershipToCurrentOwnerPreservesRoles() public { @@ -232,7 +243,7 @@ contract DeepstateMinterControllerTest is Test { assertEq(minterController.owner(), address(this)); assertTrue(minterController.hasRole(minterController.DEFAULT_ADMIN_ROLE(), address(this))); - assertTrue(minterController.hasRole(minterController.MINTER_ROLE(), address(this))); + assertFalse(minterController.hasRole(minterController.MINTER_ROLE(), address(this))); minterController.mint(mintRecipient, 100e18); assertEq(deep.balanceOf(mintRecipient), 100e18); From 1d8e5b1e6ef09fc045fe8e54278431ad13d67cc8 Mon Sep 17 00:00:00 2001 From: Joseph Delong Date: Sun, 30 Aug 2026 01:33:21 -0500 Subject: [PATCH 18/25] refactor: use Solady roles for mint controller --- README.md | 5 +- src/DeepstateMinterController.sol | 88 ++++---------- src/DeepstateRewarderFactory.sol | 7 +- src/DeepstateRewarderV2.sol | 4 +- src/interfaces/IDeepstateMinterController.sol | 2 +- test/DeepstateMinterController.t.sol | 115 ++++++------------ ...teMinterControllerSablierIntegration.t.sol | 18 +++ test/DeepstateRewarderFactory.t.sol | 33 +++-- test/mocks/MockSablierLockupLinearV4.sol | 7 +- 9 files changed, 102 insertions(+), 177 deletions(-) diff --git a/README.md b/README.md index b6d8225..d64ffa2 100644 --- a/README.md +++ b/README.md @@ -60,8 +60,9 @@ proposals can therefore execute immediately after voting ends. ## Controlled Minting -`DeepstateMinterController` is the operational DEEP minter. Governance grants -its `MINTER_ROLE` only to approved issuance contracts, such as +`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 diff --git a/src/DeepstateMinterController.sol b/src/DeepstateMinterController.sol index f6d14e8..068d899 100644 --- a/src/DeepstateMinterController.sol +++ b/src/DeepstateMinterController.sol @@ -1,15 +1,14 @@ // SPDX-License-Identifier: GPL-3.0-or-later pragma solidity 0.8.28; -import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; -import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; -import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; -import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol"; import {Lockup} from "@sablier/lockup/src/types/Lockup.sol"; import {LockupLinear} from "@sablier/lockup/src/types/LockupLinear.sol"; -import {Ownable} from "solady/auth/Ownable.sol"; +import {OwnableRoles} from "solady/auth/OwnableRoles.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 {ISablierLockupLinearV4} from "./interfaces/ISablierLockupLinearV4.sol"; @@ -18,10 +17,10 @@ import {ISablierLockupLinearV4} from "./interfaces/ISablierLockupLinearV4.sol"; /// @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 AccessControl, Ownable, ReentrancyGuard { - using SafeERC20 for IERC20; +contract DeepstateMinterController is OwnableRoles, ReentrancyGuard { + using SafeTransferLib for address; - bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE"); + 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; @@ -57,13 +56,11 @@ contract DeepstateMinterController is AccessControl, Ownable, ReentrancyGuard { error InvalidMintRecipient(); error MintAmountTooSmall(); error VestingAmountTooLarge(uint256 amount); - error StreamFundingMismatch(uint256 expectedBalance, uint256 actualBalance); error ControllerNotTokenAdmin(); error TokenAdministrationAlreadyActivated(); error TokenAdministrationAlreadyReturned(); error TokenAdministrationNotActive(); error TokenAdministrationActive(uint40 endsAt); - error OwnerMustRetainDefaultAdmin(); error MintCapExceeded(uint256 cap, uint256 attemptedSupply); constructor(address owner_, address deepstateToken_, address sablierLockup_, address recipient_, uint256 mintCap_) { @@ -78,12 +75,6 @@ contract DeepstateMinterController is AccessControl, Ownable, ReentrancyGuard { recipient = recipient_; mintCap = mintCap_; _initializeOwner(owner_); - _grantRole(DEFAULT_ADMIN_ROLE, owner_); - } - - modifier onlyMinterOrOwner() { - _checkMinterOrOwner(); - _; } /// @notice Lock DEEP administration in this contract for the initial two-year term. @@ -97,7 +88,7 @@ contract DeepstateMinterController is AccessControl, Ownable, ReentrancyGuard { deepstateToken.grantRole(deepstateToken.MINTER_ROLE(), address(this)); } - uint40 endsAt = SafeCast.toUint40(block.timestamp + TOKEN_ADMINISTRATION_DURATION); + uint40 endsAt = SafeCastLib.toUint40(block.timestamp + TOKEN_ADMINISTRATION_DURATION); tokenAdministrationEndsAt = endsAt; emit TokenAdministrationActivated(endsAt); } @@ -123,33 +114,40 @@ contract DeepstateMinterController is AccessControl, Ownable, ReentrancyGuard { emit TokenAdministrationReturned(owner_, msg.sender); } + /// @notice Ownership cannot be renounced while this contract may administer DEEP. + function renounceOwnership() public payable override onlyOwner { + revert NewOwnerIsZeroAddress(); + } + /// @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 onlyMinterOrOwner nonReentrant returns (uint256 streamId) { + function mint(address to, uint256 amount) + external + onlyOwnerOrRoles(MINTER_ROLE) + nonReentrant + returns (uint256 streamId) + { if (to == address(0)) revert InvalidMintRecipient(); - uint256 vestingAmount = Math.mulDiv(amount, RECIPIENT_ALLOCATION_BPS, PRIMARY_ALLOCATION_BPS); + 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 = SafeCast.toUint128(vestingAmount); + uint128 streamAmount = SafeCastLib.toUint128(vestingAmount); uint256 mintSupply = amount + vestingAmount; uint256 attemptedSupply = deepstateToken.totalSupply() + mintSupply; if (attemptedSupply > mintCap) revert MintCapExceeded(mintCap, attemptedSupply); - IERC20 token = IERC20(address(deepstateToken)); - uint256 balanceBefore = token.balanceOf(address(this)); - deepstateToken.mint(to, amount); deepstateToken.mint(address(this), vestingAmount); - token.forceApprove(address(sablierLockup), vestingAmount); + address(deepstateToken).safeApproveWithRetry(address(sablierLockup), vestingAmount); streamId = sablierLockup.createWithDurationsLL( Lockup.CreateWithDurations({ sender: address(this), recipient: recipient, depositAmount: streamAmount, - token: token, + token: IERC20(address(deepstateToken)), cancelable: false, transferable: false, shape: "Deepstate allocation" @@ -158,44 +156,8 @@ contract DeepstateMinterController is AccessControl, Ownable, ReentrancyGuard { 0, LockupLinear.Durations({cliff: 0, total: VESTING_DURATION}) ); - token.forceApprove(address(sablierLockup), 0); - - uint256 balanceAfter = token.balanceOf(address(this)); - if (balanceAfter != balanceBefore) revert StreamFundingMismatch(balanceBefore, balanceAfter); + address(deepstateToken).safeApproveWithRetry(address(sablierLockup), 0); emit MintedWithVesting(msg.sender, to, amount, recipient, vestingAmount, streamId); } - - /// @dev Keep EIP-173 ownership and AccessControl administration synchronized. - function _setOwner(address newOwner) internal override { - if (newOwner == address(0)) revert NewOwnerIsZeroAddress(); - - address previousOwner = owner(); - super._setOwner(newOwner); - if (newOwner != previousOwner) { - _grantRole(DEFAULT_ADMIN_ROLE, newOwner); - if (previousOwner != address(0)) { - _revokeRole(DEFAULT_ADMIN_ROLE, previousOwner); - } - } - } - - function grantRole(bytes32 role, address account) public override { - if (role == DEFAULT_ADMIN_ROLE && account != owner()) revert OwnerMustRetainDefaultAdmin(); - super.grantRole(role, account); - } - - function revokeRole(bytes32 role, address account) public override { - if (role == DEFAULT_ADMIN_ROLE && account == owner()) revert OwnerMustRetainDefaultAdmin(); - super.revokeRole(role, account); - } - - function renounceRole(bytes32 role, address callerConfirmation) public override { - if (role == DEFAULT_ADMIN_ROLE && callerConfirmation == owner()) revert OwnerMustRetainDefaultAdmin(); - super.renounceRole(role, callerConfirmation); - } - - function _checkMinterOrOwner() internal view { - if (msg.sender != owner()) _checkRole(MINTER_ROLE); - } } diff --git a/src/DeepstateRewarderFactory.sol b/src/DeepstateRewarderFactory.sol index 5e5b08e..0384e58 100644 --- a/src/DeepstateRewarderFactory.sol +++ b/src/DeepstateRewarderFactory.sol @@ -65,7 +65,7 @@ contract DeepstateRewarderFactory is Ownable { error InvalidRouterController(); error RouterControllerOwnerMismatch(address expected, address actual); error InvalidMinterController(); - error MinterControllerAdminMismatch(address expected); + error MinterControllerOwnerMismatch(address expected, address actual); error InvalidRewardToken(); error InvalidPool(); error InvalidHookFlags(); @@ -92,8 +92,9 @@ contract DeepstateRewarderFactory is Ownable { } deepstate = routerController.deepstate(); minterController = IDeepstateMinterController(minterController_); - if (!minterController.hasRole(bytes32(0), owner_)) { - revert MinterControllerAdminMismatch(owner_); + 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(); diff --git a/src/DeepstateRewarderV2.sol b/src/DeepstateRewarderV2.sol index 848a427..85ffd84 100644 --- a/src/DeepstateRewarderV2.sol +++ b/src/DeepstateRewarderV2.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; -import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeTransferLib} from "solady/utils/SafeTransferLib.sol"; import {DeepstateRewarder} from "./DeepstateRewarder.sol"; import {IBurnableERC20} from "./interfaces/IBurnableERC20.sol"; @@ -45,7 +45,7 @@ contract DeepstateRewarderV2 is DeepstateRewarder { /// @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 returns (uint256 amount) { - amount = IERC20(rewardToken).balanceOf(address(this)); + amount = SafeTransferLib.balanceOf(rewardToken, address(this)); IBurnableERC20(rewardToken).burn(amount); emit RewardBalanceBurned(amount); } diff --git a/src/interfaces/IDeepstateMinterController.sol b/src/interfaces/IDeepstateMinterController.sol index 582c1ef..0de369f 100644 --- a/src/interfaces/IDeepstateMinterController.sol +++ b/src/interfaces/IDeepstateMinterController.sol @@ -3,7 +3,7 @@ pragma solidity 0.8.28; /// @notice Factory-facing interface for policy-controlled DEEP minting. interface IDeepstateMinterController { - function hasRole(bytes32 role, address account) external view returns (bool); + 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/test/DeepstateMinterController.t.sol b/test/DeepstateMinterController.t.sol index 36a4380..c3d4f7b 100644 --- a/test/DeepstateMinterController.t.sol +++ b/test/DeepstateMinterController.t.sol @@ -2,10 +2,10 @@ pragma solidity 0.8.28; import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; -import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.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 {DeepstateMinterController} from "../src/DeepstateMinterController.sol"; import {DeepstateToken} from "../src/DeepstateToken.sol"; @@ -44,8 +44,9 @@ contract DeepstateMinterControllerTest is Test { assertEq(minterController.owner(), address(this)); assertEq(minterController.tokenAdministrationEndsAt(), 0); assertFalse(minterController.tokenAdministrationReturned()); - assertTrue(minterController.hasRole(minterController.DEFAULT_ADMIN_ROLE(), address(this))); - assertFalse(minterController.hasRole(minterController.MINTER_ROLE(), address(this))); + 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))); } @@ -173,10 +174,8 @@ contract DeepstateMinterControllerTest is Test { minterController.transferOwnership(newGovernance); assertEq(minterController.owner(), newGovernance); - assertFalse(minterController.hasRole(minterController.DEFAULT_ADMIN_ROLE(), address(this))); - assertTrue(minterController.hasRole(minterController.DEFAULT_ADMIN_ROLE(), newGovernance)); - assertFalse(minterController.hasRole(minterController.MINTER_ROLE(), address(this))); - assertFalse(minterController.hasRole(minterController.MINTER_ROLE(), newGovernance)); + assertFalse(minterController.hasAnyRole(address(this), minterController.MINTER_ROLE())); + assertFalse(minterController.hasAnyRole(newGovernance, minterController.MINTER_ROLE())); vm.warp(minterController.tokenAdministrationEndsAt()); vm.prank(unauthorized); @@ -194,18 +193,14 @@ contract DeepstateMinterControllerTest is Test { new DeepstateMinterController(address(this), address(deep), address(sablier), recipient, MINT_CAP); deep.grantRole(deep.MINTER_ROLE(), address(ownerController)); - assertFalse(ownerController.hasRole(ownerController.MINTER_ROLE(), address(this))); + assertFalse(ownerController.hasAnyRole(address(this), ownerController.MINTER_ROLE())); ownerController.mint(mintRecipient, 100e18); ownerController.transferOwnership(newGovernance); - assertFalse(ownerController.hasRole(ownerController.MINTER_ROLE(), address(this))); - assertFalse(ownerController.hasRole(ownerController.MINTER_ROLE(), newGovernance)); + assertFalse(ownerController.hasAnyRole(address(this), ownerController.MINTER_ROLE())); + assertFalse(ownerController.hasAnyRole(newGovernance, ownerController.MINTER_ROLE())); - vm.expectRevert( - abi.encodeWithSelector( - IAccessControl.AccessControlUnauthorizedAccount.selector, address(this), ownerController.MINTER_ROLE() - ) - ); + vm.expectRevert(Ownable.Unauthorized.selector); ownerController.mint(mintRecipient, 100e18); vm.prank(newGovernance); @@ -216,53 +211,39 @@ contract DeepstateMinterControllerTest is Test { } function test_OwnerCanMintAfterItsMinterRoleIsRevoked() public { - bytes32 minterRole = minterController.MINTER_ROLE(); - minterController.grantRole(minterRole, address(this)); - minterController.revokeRole(minterRole, address(this)); + uint256 minterRole = minterController.MINTER_ROLE(); + minterController.grantRoles(address(this), minterRole); + minterController.revokeRoles(address(this), minterRole); - assertFalse(minterController.hasRole(minterRole, address(this))); + assertFalse(minterController.hasAnyRole(address(this), minterRole)); minterController.mint(mintRecipient, 70e18); assertEq(deep.balanceOf(mintRecipient), 70e18); assertEq(deep.balanceOf(address(sablier)), 30e18); } - function test_TwoStepOwnershipHandoverSynchronizesControllerAdmin() public { + function test_TwoStepOwnershipHandoverDoesNotMutateRoles() public { vm.prank(newGovernance); minterController.requestOwnershipHandover(); minterController.completeOwnershipHandover(newGovernance); assertEq(minterController.owner(), newGovernance); - assertFalse(minterController.hasRole(minterController.DEFAULT_ADMIN_ROLE(), address(this))); - assertTrue(minterController.hasRole(minterController.DEFAULT_ADMIN_ROLE(), newGovernance)); - assertFalse(minterController.hasRole(minterController.MINTER_ROLE(), address(this))); - assertFalse(minterController.hasRole(minterController.MINTER_ROLE(), 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)); - assertTrue(minterController.hasRole(minterController.DEFAULT_ADMIN_ROLE(), address(this))); - assertFalse(minterController.hasRole(minterController.MINTER_ROLE(), address(this))); + assertEq(minterController.rolesOf(address(this)), 0); minterController.mint(mintRecipient, 100e18); assertEq(deep.balanceOf(mintRecipient), 100e18); } - function test_ControllerOwnerCannotRenounceOrLoseDefaultAdmin() public { - bytes32 controllerAdminRole = minterController.DEFAULT_ADMIN_ROLE(); - + function test_ControllerOwnerCannotRenounceOwnership() public { vm.expectRevert(Ownable.NewOwnerIsZeroAddress.selector); minterController.renounceOwnership(); - - vm.expectRevert(DeepstateMinterController.OwnerMustRetainDefaultAdmin.selector); - minterController.revokeRole(controllerAdminRole, address(this)); - - vm.expectRevert(DeepstateMinterController.OwnerMustRetainDefaultAdmin.selector); - minterController.renounceRole(controllerAdminRole, address(this)); - - vm.expectRevert(DeepstateMinterController.OwnerMustRetainDefaultAdmin.selector); - minterController.grantRole(controllerAdminRole, unauthorized); } function test_RevertUnlockBeforeLockAfterUnlockOrWithoutTokenAdmin() public { @@ -349,48 +330,37 @@ contract DeepstateMinterControllerTest is Test { } function test_RevertWhenCallerLacksControllerMinterRole() public { - vm.expectRevert( - abi.encodeWithSelector( - IAccessControl.AccessControlUnauthorizedAccount.selector, unauthorized, minterController.MINTER_ROLE() - ) - ); + vm.expectRevert(Ownable.Unauthorized.selector); vm.prank(unauthorized); minterController.mint(mintRecipient, 100e18); } - function test_AdminCanGrantAndRevokeControllerMinterRole() public { - minterController.grantRole(minterController.MINTER_ROLE(), unauthorized); + function test_OwnerCanGrantAndRevokeControllerMinterRole() public { + minterController.grantRoles(unauthorized, minterController.MINTER_ROLE()); vm.prank(unauthorized); minterController.mint(mintRecipient, 100e18); - minterController.revokeRole(minterController.MINTER_ROLE(), unauthorized); - vm.expectRevert( - abi.encodeWithSelector( - IAccessControl.AccessControlUnauthorizedAccount.selector, unauthorized, minterController.MINTER_ROLE() - ) - ); + minterController.revokeRoles(unauthorized, minterController.MINTER_ROLE()); + vm.expectRevert(Ownable.Unauthorized.selector); vm.prank(unauthorized); minterController.mint(mintRecipient, 100e18); } function test_ControllerMinterCanRenounceRole() public { - bytes32 minterRole = minterController.MINTER_ROLE(); - minterController.grantRole(minterRole, unauthorized); + uint256 minterRole = minterController.MINTER_ROLE(); + minterController.grantRoles(unauthorized, minterRole); vm.prank(unauthorized); - minterController.renounceRole(minterRole, unauthorized); + minterController.renounceRoles(minterRole); - assertFalse(minterController.hasRole(minterRole, unauthorized)); + assertFalse(minterController.hasAnyRole(unauthorized, minterRole)); } - function test_RevertWhenNonAdminChangesMinterRole() public { - bytes32 minterRole = minterController.MINTER_ROLE(); - bytes32 adminRole = minterController.DEFAULT_ADMIN_ROLE(); - vm.expectRevert( - abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, unauthorized, adminRole) - ); + function test_RevertWhenNonOwnerChangesMinterRole() public { + uint256 minterRole = minterController.MINTER_ROLE(); + vm.expectRevert(Ownable.Unauthorized.selector); vm.prank(unauthorized); - minterController.grantRole(minterRole, unauthorized); + minterController.grantRoles(unauthorized, minterRole); } function test_RevertForZeroMintRecipientOrDustAmount() public { @@ -440,34 +410,18 @@ contract DeepstateMinterControllerTest is Test { } function test_SablierCannotReenterEvenWhenIncorrectlyGrantedMinterRole() public { - minterController.grantRole(minterController.MINTER_ROLE(), address(sablier)); + minterController.grantRoles(address(sablier), minterController.MINTER_ROLE()); sablier.setReentry( address(minterController), abi.encodeCall(DeepstateMinterController.mint, (mintRecipient, 100e18)) ); - vm.expectRevert(ReentrancyGuard.ReentrancyGuardReentrantCall.selector); + vm.expectRevert(ReentrancyGuard.Reentrancy.selector); minterController.mint(mintRecipient, 100e18); assertEq(deep.totalSupply(), 0); assertEq(sablier.nextStreamId(), 1); } - function test_MissingSablierTokenPullRollsBackBothMintsAndStream() public { - sablier.setSkipTokenPull(true); - uint256 vestingAmount = Math.mulDiv(100e18, 30_00, 70_00); - - vm.expectRevert( - abi.encodeWithSelector(DeepstateMinterController.StreamFundingMismatch.selector, 0, vestingAmount) - ); - 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 testFuzz_MintMaintainsThirtyPercentOfCombinedIssuance(uint128 rawAmount) public { uint256 maximumAmount = Math.mulDiv(MINT_CAP, 70_00, 100_00); uint256 amount = bound(uint256(rawAmount), 3, maximumAmount); @@ -495,6 +449,5 @@ contract DeepstateMinterControllerTest is Test { 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)); - controller.grantRole(controller.MINTER_ROLE(), address(this)); } } diff --git a/test/DeepstateMinterControllerSablierIntegration.t.sol b/test/DeepstateMinterControllerSablierIntegration.t.sol index fb890d0..947691a 100644 --- a/test/DeepstateMinterControllerSablierIntegration.t.sol +++ b/test/DeepstateMinterControllerSablierIntegration.t.sol @@ -55,7 +55,11 @@ contract DeepstateMinterControllerSablierIntegrationTest is Test { 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); @@ -87,4 +91,18 @@ contract DeepstateMinterControllerSablierIntegrationTest is Test { 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 index 529e5ef..7279848 100644 --- a/test/DeepstateRewarderFactory.t.sol +++ b/test/DeepstateRewarderFactory.t.sol @@ -1,7 +1,6 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.24; -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"; @@ -18,13 +17,15 @@ 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 hasRole(bytes32, address) external pure returns (bool) { - return true; + function owner() external view returns (address) { + return _owner; } function deepstateToken() external view returns (address) { @@ -63,7 +64,7 @@ contract DeepstateRewarderFactoryTest is Test { minterController = _newMinterController(address(this), deep); factory = new DeepstateRewarderFactory(address(this), address(routerController), address(minterController)); - minterController.grantRole(minterController.MINTER_ROLE(), address(factory)); + minterController.grantRoles(address(factory), minterController.MINTER_ROLE()); deepstate.transferOwnership(address(routerController)); routerController.setHookManager(address(factory)); factory.setController(controller); @@ -82,7 +83,7 @@ contract DeepstateRewarderFactoryTest is Test { 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.hasRole(minterController.MINTER_ROLE(), address(factory))); + assertTrue(minterController.hasAnyRole(address(factory), minterController.MINTER_ROLE())); assertEq(deepstate.owner(), address(routerController)); assertEq(routerController.hookManager(), address(factory)); assertEq(factory.nextDeploymentAt(), 0); @@ -115,7 +116,9 @@ contract DeepstateRewarderFactoryTest is Test { DeepstateMinterController mismatchedMinter = _newMinterController(alice, deep); vm.expectRevert( - abi.encodeWithSelector(DeepstateRewarderFactory.MinterControllerAdminMismatch.selector, address(this)) + abi.encodeWithSelector( + DeepstateRewarderFactory.MinterControllerOwnerMismatch.selector, address(this), alice + ) ); new DeepstateRewarderFactory(address(this), address(routerController), address(mismatchedMinter)); @@ -142,7 +145,7 @@ contract DeepstateRewarderFactoryTest is Test { secondFactory.setController(controller); vm.startPrank(governance); - secondMinterController.grantRole(secondMinterController.MINTER_ROLE(), address(secondFactory)); + secondMinterController.grantRoles(address(secondFactory), secondMinterController.MINTER_ROLE()); secondRouterController.setHookManager(address(secondFactory)); secondFactory.setController(controller); vm.stopPrank(); @@ -306,9 +309,7 @@ contract DeepstateRewarderFactoryTest is Test { vm.prank(controller); DeepstateRewarderV2 rewarder = factory.deployMarket(_market(TOKEN_A, TOKEN_B)); - minterController.grantRole(minterController.MINTER_ROLE(), address(this)); minterController.mint(address(rewarder), 900_000_000e18); - minterController.revokeRole(minterController.MINTER_ROLE(), address(this)); 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); @@ -373,7 +374,7 @@ contract DeepstateRewarderFactoryTest is Test { new DeepstateRewarderFactory(address(this), address(secondRouterController), address(minterController)); secondRouter.setPoolHookConfig(TOKEN_C, TOKEN_D, alice, true, false); secondRouter.transferOwnership(address(secondRouterController)); - minterController.grantRole(minterController.MINTER_ROLE(), address(secondFactory)); + minterController.grantRoles(address(secondFactory), minterController.MINTER_ROLE()); secondRouterController.setHookManager(address(secondFactory)); secondFactory.setController(controller); @@ -401,13 +402,7 @@ contract DeepstateRewarderFactoryTest is Test { DeepstateRewarderFactory.MarketConfig memory config = _market(TOKEN_A, TOKEN_B); address predicted = secondFactory.predictRewarderAddress(config, 0); - vm.expectRevert( - abi.encodeWithSelector( - IAccessControl.AccessControlUnauthorizedAccount.selector, - address(secondFactory), - secondMinterController.MINTER_ROLE() - ) - ); + vm.expectRevert(Ownable.Unauthorized.selector); vm.prank(controller); secondFactory.deployMarket(config); @@ -426,7 +421,7 @@ contract DeepstateRewarderFactoryTest is Test { DeepstateRewarderFactory secondFactory = new DeepstateRewarderFactory( address(this), address(secondRouterController), address(secondMinterController) ); - secondMinterController.grantRole(secondMinterController.MINTER_ROLE(), address(secondFactory)); + secondMinterController.grantRoles(address(secondFactory), secondMinterController.MINTER_ROLE()); secondRouterController.setHookManager(address(secondFactory)); secondFactory.setController(controller); @@ -450,7 +445,7 @@ contract DeepstateRewarderFactoryTest is Test { DeepstateRewarderFactory secondFactory = new DeepstateRewarderFactory( address(this), address(secondRouterController), address(secondMinterController) ); - secondMinterController.grantRole(secondMinterController.MINTER_ROLE(), address(secondFactory)); + secondMinterController.grantRoles(address(secondFactory), secondMinterController.MINTER_ROLE()); secondRouter.transferOwnership(address(secondRouterController)); secondFactory.setController(controller); diff --git a/test/mocks/MockSablierLockupLinearV4.sol b/test/mocks/MockSablierLockupLinearV4.sol index 1a99173..b82ab54 100644 --- a/test/mocks/MockSablierLockupLinearV4.sol +++ b/test/mocks/MockSablierLockupLinearV4.sol @@ -29,7 +29,6 @@ contract MockSablierLockupLinearV4 is ISablierLockupLinearV4 { uint256 public nextStreamId = 1; bool public revertCreate; - bool public skipTokenPull; address public reentryTarget; bytes public reentryData; mapping(uint256 streamId => Stream stream) private _streams; @@ -40,10 +39,6 @@ contract MockSablierLockupLinearV4 is ISablierLockupLinearV4 { revertCreate = value; } - function setSkipTokenPull(bool value) external { - skipTokenPull = value; - } - function setReentry(address target, bytes calldata data) external { reentryTarget = target; reentryData = data; @@ -88,6 +83,6 @@ contract MockSablierLockupLinearV4 is ISablierLockupLinearV4 { totalDuration: durations.total }); - if (!skipTokenPull) params.token.safeTransferFrom(msg.sender, address(this), params.depositAmount); + params.token.safeTransferFrom(msg.sender, address(this), params.depositAmount); } } From 8278d182a50cf7352139d7920bd33252b836d284 Mon Sep 17 00:00:00 2001 From: Joseph Delong Date: Sun, 30 Aug 2026 01:38:47 -0500 Subject: [PATCH 19/25] refactor: clarify factory authority names --- src/DeepstateRewarderFactory.sol | 64 ++++---- test/DeepstateRewarderFactory.t.sol | 225 ++++++++++++++-------------- 2 files changed, 146 insertions(+), 143 deletions(-) diff --git a/src/DeepstateRewarderFactory.sol b/src/DeepstateRewarderFactory.sol index 0384e58..c850daa 100644 --- a/src/DeepstateRewarderFactory.sol +++ b/src/DeepstateRewarderFactory.sol @@ -10,8 +10,8 @@ import {IDeepstateMinterController} from "./interfaces/IDeepstateMinterControlle import {IDeepstateV1} from "./interfaces/IDeepstateV1.sol"; /// @title Deepstate Rewarder Factory -/// @notice Governance-owned CREATE2 factory for controller-launched market reward programs. -/// @dev The factory must hold the minter controller's MINTER_ROLE and be the router controller's hook manager. +/// @notice Governance-owned CREATE2 factory for operator-launched market reward programs. +/// @dev The factory must hold the minter controller's MINTER_ROLE and be the V1 controller's hook manager. contract DeepstateRewarderFactory is Ownable { struct MarketConfig { address token0; @@ -24,7 +24,7 @@ contract DeepstateRewarderFactory is Ownable { bool token1Active; } - /// @notice Minimum interval between successful controller or governance market deployments. + /// @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; @@ -33,13 +33,13 @@ contract DeepstateRewarderFactory is Ownable { /// @notice Initial DEEP minted to each rewarder. Further funding requires governance. uint256 public constant INITIAL_FUNDING = 100_000_000e18; - DeepstateV1Controller public immutable routerController; + DeepstateV1Controller public immutable deepstateV1Controller; IDeepstateV1 public immutable deepstate; IDeepstateMinterController public immutable minterController; DeepstateToken public immutable rewardToken; - /// @notice Revocable address permitted to launch and retire factory markets. - address public controller; + /// @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; /// @notice Nonce assigned to the next successful CREATE2 deployment. @@ -48,7 +48,7 @@ contract DeepstateRewarderFactory is Ownable { mapping(bytes32 poolId => address rewarder) public activeRewarder; mapping(address rewarder => bytes32 poolId) public rewarderPool; - event ControllerSet(address indexed previousController, address indexed newController); + event OperatorSet(address indexed previousOperator, address indexed newOperator); event MarketDeployed( bytes32 indexed poolId, uint256 indexed deploymentNonce, @@ -62,8 +62,8 @@ contract DeepstateRewarderFactory is Ownable { event MarketRemoved(bytes32 indexed poolId, address indexed rewarder, uint256 burnedAmount); error InvalidOwner(); - error InvalidRouterController(); - error RouterControllerOwnerMismatch(address expected, address actual); + error InvalidDeepstateV1Controller(); + error DeepstateV1ControllerOwnerMismatch(address expected, address actual); error InvalidMinterController(); error MinterControllerOwnerMismatch(address expected, address actual); error InvalidRewardToken(); @@ -75,22 +75,22 @@ contract DeepstateRewarderFactory is Ownable { error UnexpectedPoolHook(bytes32 poolId, address expected, address actual); error MarketNotActive(bytes32 poolId); - constructor(address owner_, address routerController_, address minterController_) { + constructor(address owner_, address deepstateV1Controller_, address minterController_) { if (owner_ == address(0)) revert InvalidOwner(); - if (routerController_ == address(0) || routerController_.code.length == 0) { - revert InvalidRouterController(); + if (deepstateV1Controller_ == address(0) || deepstateV1Controller_.code.length == 0) { + revert InvalidDeepstateV1Controller(); } if (minterController_ == address(0) || minterController_.code.length == 0) { revert InvalidMinterController(); } _initializeOwner(owner_); - routerController = DeepstateV1Controller(routerController_); - address routerControllerOwner = routerController.owner(); - if (routerControllerOwner != owner_) { - revert RouterControllerOwnerMismatch(owner_, routerControllerOwner); + deepstateV1Controller = DeepstateV1Controller(deepstateV1Controller_); + address deepstateV1ControllerOwner = deepstateV1Controller.owner(); + if (deepstateV1ControllerOwner != owner_) { + revert DeepstateV1ControllerOwnerMismatch(owner_, deepstateV1ControllerOwner); } - deepstate = routerController.deepstate(); + deepstate = deepstateV1Controller.deepstate(); minterController = IDeepstateMinterController(minterController_); address minterControllerOwner = minterController.owner(); if (minterControllerOwner != owner_) { @@ -101,23 +101,23 @@ contract DeepstateRewarderFactory is Ownable { rewardToken = DeepstateToken(deepstateToken_); } - modifier onlyControllerOrOwner() { - _checkControllerOrOwner(); + modifier onlyOperatorOrOwner() { + _checkOperatorOrOwner(); _; } - /// @notice Appoint or revoke the operational controller. Set zero to revoke without replacement. - function setController(address newController) external onlyOwner { - address previousController = controller; - controller = newController; - emit ControllerSet(previousController, newController); + /// @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 - onlyControllerOrOwner + onlyOperatorOrOwner returns (DeepstateRewarderV2 rewarder) { bytes32 poolId_ = _validateMarket(config); @@ -155,7 +155,7 @@ contract DeepstateRewarderFactory is Ownable { rewarderPool[address(rewarder)] = poolId_; minterController.mint(address(rewarder), INITIAL_FUNDING); - routerController.setPoolHookConfig( + deepstateV1Controller.setPoolHookConfig( config.token0, config.token1, address(rewarder), config.token0Active, config.token1Active ); @@ -174,18 +174,14 @@ contract DeepstateRewarderFactory is Ownable { /// @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 - onlyControllerOrOwner - returns (uint256 burnedAmount) - { + function removeMarket(address token0, address token1) external onlyOperatorOrOwner returns (uint256 burnedAmount) { bytes32 poolId_ = _poolId(token0, token1); address rewarder = activeRewarder[poolId_]; if (rewarder == address(0)) revert MarketNotActive(poolId_); address currentHook = deepstate.poolHook(poolId_); if (currentHook == rewarder) { - routerController.setPoolHookConfig(token0, token1, address(0), false, false); + deepstateV1Controller.setPoolHookConfig(token0, token1, address(0), false, false); } else if (currentHook != address(0)) { revert UnexpectedPoolHook(poolId_, rewarder, currentHook); } @@ -245,7 +241,7 @@ contract DeepstateRewarderFactory is Ownable { poolId_ = keccak256(abi.encode(token0, token1)); } - function _checkControllerOrOwner() private view { - if (msg.sender != controller) _checkOwner(); + function _checkOperatorOrOwner() private view { + if (msg.sender != operator) _checkOwner(); } } diff --git a/test/DeepstateRewarderFactory.t.sol b/test/DeepstateRewarderFactory.t.sol index 7279848..2a9915b 100644 --- a/test/DeepstateRewarderFactory.t.sol +++ b/test/DeepstateRewarderFactory.t.sol @@ -45,12 +45,12 @@ contract DeepstateRewarderFactoryTest is Test { DeepstateToken internal deep; DeepstateV1 internal deepstate; - DeepstateV1Controller internal routerController; + DeepstateV1Controller internal deepstateV1Controller; DeepstateMinterController internal minterController; DeepstateRewarderFactory internal factory; MockSablierLockupLinearV4 internal sablier; - address internal controller = makeAddr("controller"); + address internal operator = makeAddr("operator"); address internal alice = makeAddr("alice"); address internal vestingRecipient = makeAddr("vestingRecipient"); @@ -60,20 +60,20 @@ contract DeepstateRewarderFactoryTest is Test { deep = new DeepstateToken(address(this), "Deepstate", "DEEP"); sablier = new MockSablierLockupLinearV4(); deepstate = new DeepstateV1(); - routerController = new DeepstateV1Controller(address(this), address(deepstate)); + deepstateV1Controller = new DeepstateV1Controller(address(this), address(deepstate)); minterController = _newMinterController(address(this), deep); - factory = new DeepstateRewarderFactory(address(this), address(routerController), address(minterController)); + factory = new DeepstateRewarderFactory(address(this), address(deepstateV1Controller), address(minterController)); minterController.grantRoles(address(factory), minterController.MINTER_ROLE()); - deepstate.transferOwnership(address(routerController)); - routerController.setHookManager(address(factory)); - factory.setController(controller); + deepstate.transferOwnership(address(deepstateV1Controller)); + deepstateV1Controller.setHookManager(address(factory)); + factory.setOperator(operator); } function test_ImmutableConfigurationAndInitialAuthority() public view { assertEq(factory.owner(), address(this)); - assertEq(factory.controller(), controller); - assertEq(address(factory.routerController()), address(routerController)); + 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)); @@ -84,35 +84,35 @@ contract DeepstateRewarderFactoryTest is Test { 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(routerController)); - assertEq(routerController.hookManager(), address(factory)); + assertEq(deepstate.owner(), address(deepstateV1Controller)); + assertEq(deepstateV1Controller.hookManager(), address(factory)); assertEq(factory.nextDeploymentAt(), 0); assertEq(factory.deploymentCount(), 0); } function test_ConstructorValidation() public { vm.expectRevert(DeepstateRewarderFactory.InvalidOwner.selector); - new DeepstateRewarderFactory(address(0), address(routerController), address(minterController)); + new DeepstateRewarderFactory(address(0), address(deepstateV1Controller), address(minterController)); - vm.expectRevert(DeepstateRewarderFactory.InvalidRouterController.selector); + vm.expectRevert(DeepstateRewarderFactory.InvalidDeepstateV1Controller.selector); new DeepstateRewarderFactory(address(this), address(0), address(minterController)); - vm.expectRevert(DeepstateRewarderFactory.InvalidRouterController.selector); + vm.expectRevert(DeepstateRewarderFactory.InvalidDeepstateV1Controller.selector); new DeepstateRewarderFactory(address(this), alice, address(minterController)); DeepstateV1Controller mismatchedController = new DeepstateV1Controller(alice, address(deepstate)); vm.expectRevert( abi.encodeWithSelector( - DeepstateRewarderFactory.RouterControllerOwnerMismatch.selector, address(this), alice + DeepstateRewarderFactory.DeepstateV1ControllerOwnerMismatch.selector, address(this), alice ) ); new DeepstateRewarderFactory(address(this), address(mismatchedController), address(minterController)); vm.expectRevert(DeepstateRewarderFactory.InvalidMinterController.selector); - new DeepstateRewarderFactory(address(this), address(routerController), address(0)); + new DeepstateRewarderFactory(address(this), address(deepstateV1Controller), address(0)); vm.expectRevert(DeepstateRewarderFactory.InvalidMinterController.selector); - new DeepstateRewarderFactory(address(this), address(routerController), alice); + new DeepstateRewarderFactory(address(this), address(deepstateV1Controller), alice); DeepstateMinterController mismatchedMinter = _newMinterController(alice, deep); vm.expectRevert( @@ -120,36 +120,38 @@ contract DeepstateRewarderFactoryTest is Test { DeepstateRewarderFactory.MinterControllerOwnerMismatch.selector, address(this), alice ) ); - new DeepstateRewarderFactory(address(this), address(routerController), address(mismatchedMinter)); + new DeepstateRewarderFactory(address(this), address(deepstateV1Controller), address(mismatchedMinter)); InvalidRewardTokenMinterController zeroTokenController = new InvalidRewardTokenMinterController(address(0)); vm.expectRevert(DeepstateRewarderFactory.InvalidRewardToken.selector); - new DeepstateRewarderFactory(address(this), address(routerController), address(zeroTokenController)); + new DeepstateRewarderFactory(address(this), address(deepstateV1Controller), address(zeroTokenController)); InvalidRewardTokenMinterController eoaTokenController = new InvalidRewardTokenMinterController(alice); vm.expectRevert(DeepstateRewarderFactory.InvalidRewardToken.selector); - new DeepstateRewarderFactory(address(this), address(routerController), address(eoaTokenController)); + 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 secondRouter = new DeepstateV1(); - DeepstateV1Controller secondRouterController = new DeepstateV1Controller(governance, address(secondRouter)); + DeepstateV1 secondDeepstate = new DeepstateV1(); + DeepstateV1Controller secondDeepstateV1Controller = + new DeepstateV1Controller(governance, address(secondDeepstate)); DeepstateMinterController secondMinterController = _newMinterController(governance, secondToken); - DeepstateRewarderFactory secondFactory = - new DeepstateRewarderFactory(governance, address(secondRouterController), address(secondMinterController)); - secondRouter.transferOwnership(address(secondRouterController)); + DeepstateRewarderFactory secondFactory = new DeepstateRewarderFactory( + governance, address(secondDeepstateV1Controller), address(secondMinterController) + ); + secondDeepstate.transferOwnership(address(secondDeepstateV1Controller)); vm.expectRevert(Ownable.Unauthorized.selector); - secondFactory.setController(controller); + secondFactory.setOperator(operator); vm.startPrank(governance); secondMinterController.grantRoles(address(secondFactory), secondMinterController.MINTER_ROLE()); - secondRouterController.setHookManager(address(secondFactory)); - secondFactory.setController(controller); + secondDeepstateV1Controller.setHookManager(address(secondFactory)); + secondFactory.setOperator(operator); vm.stopPrank(); - vm.prank(controller); + vm.prank(operator); DeepstateRewarderV2 rewarder = secondFactory.deployMarket(_market(TOKEN_A, TOKEN_B)); assertEq(secondFactory.owner(), governance); @@ -158,7 +160,7 @@ contract DeepstateRewarderFactoryTest is Test { assertEq(secondToken.balanceOf(address(sablier)), _vestingAllocation(100_000_000e18)); } - function test_ControllerDeploysPredictedCreate2MarketWithFixedScheduleAndFunding() public { + function test_OperatorDeploysPredictedCreate2MarketWithFixedScheduleAndFunding() public { DeepstateRewarderFactory.MarketConfig memory config = _market(TOKEN_A, TOKEN_B); address predicted = factory.predictRewarderAddress(config, 0); bytes32 poolId = _poolId(TOKEN_A, TOKEN_B); @@ -166,7 +168,7 @@ contract DeepstateRewarderFactoryTest is Test { vm.expectEmit(true, true, true, true, address(factory)); emit DeepstateRewarderFactory.MarketDeployed(poolId, 0, predicted, expectedSalt, TOKEN_A, TOKEN_B, true, true); - vm.prank(controller); + vm.prank(operator); DeepstateRewarderV2 rewarder = factory.deployMarket(config); assertEq(address(rewarder), predicted); @@ -195,21 +197,21 @@ contract DeepstateRewarderFactoryTest is Test { } function test_DeploymentCooldownIsGlobalAndAllowsExactBoundary() public { - vm.prank(controller); + vm.prank(operator); factory.deployMarket(_market(TOKEN_A, TOKEN_B)); uint256 next = factory.nextDeploymentAt(); vm.expectRevert(abi.encodeWithSelector(DeepstateRewarderFactory.DeploymentCooldown.selector, next)); - vm.prank(controller); + vm.prank(operator); factory.deployMarket(_market(TOKEN_C, TOKEN_D)); vm.warp(next - 1); vm.expectRevert(abi.encodeWithSelector(DeepstateRewarderFactory.DeploymentCooldown.selector, next)); - vm.prank(controller); + vm.prank(operator); factory.deployMarket(_market(TOKEN_C, TOKEN_D)); vm.warp(next); - vm.prank(controller); + vm.prank(operator); DeepstateRewarderV2 second = factory.deployMarket(_market(TOKEN_C, TOKEN_D)); assertEq(factory.deploymentCount(), 2); @@ -218,8 +220,8 @@ contract DeepstateRewarderFactoryTest is Test { assertEq(deep.totalSupply(), 200_000_000e18 + 2 * _vestingAllocation(100_000_000e18)); } - function test_GovernanceCanDeployWithoutControllerButStillObeysCooldown() public { - factory.setController(address(0)); + function test_GovernanceCanDeployWithoutOperatorButStillObeysCooldown() public { + factory.setOperator(address(0)); DeepstateRewarderV2 rewarder = factory.deployMarket(_market(TOKEN_A, TOKEN_B)); assertEq(rewarder.owner(), address(factory)); @@ -228,41 +230,41 @@ contract DeepstateRewarderFactoryTest is Test { factory.deployMarket(_market(TOKEN_C, TOKEN_D)); } - function test_GovernanceCanRevokeControllerImmediately() public { + function test_GovernanceCanRevokeOperatorImmediately() public { vm.expectEmit(true, true, false, false, address(factory)); - emit DeepstateRewarderFactory.ControllerSet(controller, address(0)); - factory.setController(address(0)); + emit DeepstateRewarderFactory.OperatorSet(operator, address(0)); + factory.setOperator(address(0)); vm.expectRevert(Ownable.Unauthorized.selector); - vm.prank(controller); + vm.prank(operator); factory.deployMarket(_market(TOKEN_A, TOKEN_B)); - assertEq(factory.controller(), address(0)); + assertEq(factory.operator(), address(0)); assertEq(factory.deploymentCount(), 0); assertEq(deep.totalSupply(), 0); } - function test_OnlyGovernanceCanSetController() public { + function test_OnlyGovernanceCanSetOperator() public { vm.expectRevert(Ownable.Unauthorized.selector); - vm.prank(controller); - factory.setController(alice); + vm.prank(operator); + factory.setOperator(alice); vm.expectRevert(Ownable.Unauthorized.selector); vm.prank(alice); - factory.setController(alice); + factory.setOperator(alice); - assertEq(factory.controller(), controller); + assertEq(factory.operator(), operator); } - function test_ControllerCanRemoveMarketAndBurnAllRemainingFunding() public { + function test_OperatorCanRemoveMarketAndBurnAllRemainingFunding() public { DeepstateRewarderFactory.MarketConfig memory config = _market(TOKEN_A, TOKEN_B); - vm.prank(controller); + vm.prank(operator); DeepstateRewarderV2 rewarder = factory.deployMarket(config); bytes32 poolId = _poolId(TOKEN_A, TOKEN_B); vm.expectEmit(true, true, false, true, address(factory)); emit DeepstateRewarderFactory.MarketRemoved(poolId, address(rewarder), 100_000_000e18); - vm.prank(controller); + vm.prank(operator); uint256 burned = factory.removeMarket(TOKEN_A, TOKEN_B); assertEq(burned, 100_000_000e18); @@ -276,14 +278,14 @@ contract DeepstateRewarderFactoryTest is Test { } function test_RemovalBurnsLiveBalanceAfterPriorClaim() public { - vm.prank(controller); + 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(controller); + vm.prank(operator); uint256 burned = factory.removeMarket(TOKEN_A, TOKEN_B); assertEq(burned, 75_000_000e18); @@ -293,20 +295,20 @@ contract DeepstateRewarderFactoryTest is Test { assertEq(deep.totalSupply(), claimed + _vestingAllocation(100_000_000e18)); } - function test_ControllerCannotBurnRewarderDirectly() public { - vm.prank(controller); + function test_OperatorCannotBurnRewarderDirectly() public { + vm.prank(operator); DeepstateRewarderV2 rewarder = factory.deployMarket(_market(TOKEN_A, TOKEN_B)); vm.expectRevert(Ownable.Unauthorized.selector); - vm.prank(controller); + vm.prank(operator); rewarder.burnBalance(); assertEq(deep.balanceOf(address(rewarder)), 100_000_000e18); - assertEq(deep.balanceOf(controller), 0); + assertEq(deep.balanceOf(operator), 0); } function test_RemovalBurnsGovernanceTopUpAlongWithInitialFunding() public { - vm.prank(controller); + vm.prank(operator); DeepstateRewarderV2 rewarder = factory.deployMarket(_market(TOKEN_A, TOKEN_B)); minterController.mint(address(rewarder), 900_000_000e18); @@ -314,17 +316,17 @@ contract DeepstateRewarderFactoryTest is Test { uint256 vested = _vestingAllocation(100_000_000e18) + _vestingAllocation(900_000_000e18); assertEq(deep.balanceOf(address(sablier)), vested); - vm.prank(controller); + vm.prank(operator); uint256 burned = factory.removeMarket(TOKEN_A, TOKEN_B); assertEq(burned, 1_000_000_000e18); assertEq(deep.totalSupply(), vested); } - function test_GovernanceCanRemoveMarketAfterRevokingController() public { - vm.prank(controller); + function test_GovernanceCanRemoveMarketAfterRevokingOperator() public { + vm.prank(operator); factory.deployMarket(_market(TOKEN_A, TOKEN_B)); - factory.setController(address(0)); + factory.setOperator(address(0)); uint256 burned = factory.removeMarket(TOKEN_A, TOKEN_B); @@ -334,7 +336,7 @@ contract DeepstateRewarderFactoryTest is Test { function test_RemovedPoolCanBeResetWithFreshCreate2AddressAfterCooldown() public { DeepstateRewarderFactory.MarketConfig memory config = _market(TOKEN_A, TOKEN_B); - vm.startPrank(controller); + vm.startPrank(operator); DeepstateRewarderV2 first = factory.deployMarket(config); factory.removeMarket(TOKEN_A, TOKEN_B); @@ -355,9 +357,9 @@ contract DeepstateRewarderFactoryTest is Test { assertEq(deep.totalSupply(), 100_000_000e18 + 2 * _vestingAllocation(100_000_000e18)); } - function test_CannotDeployOverActiveFactoryMarketOrExistingRouterHook() public { + function test_CannotDeployOverActiveFactoryMarketOrExistingDeepstateV1Hook() public { DeepstateRewarderFactory.MarketConfig memory config = _market(TOKEN_A, TOKEN_B); - vm.prank(controller); + vm.prank(operator); DeepstateRewarderV2 rewarder = factory.deployMarket(config); vm.warp(factory.nextDeploymentAt()); @@ -365,22 +367,24 @@ contract DeepstateRewarderFactoryTest is Test { vm.expectRevert( abi.encodeWithSelector(DeepstateRewarderFactory.ActiveMarketExists.selector, poolId, address(rewarder)) ); - vm.prank(controller); + vm.prank(operator); factory.deployMarket(config); - DeepstateV1 secondRouter = new DeepstateV1(); - DeepstateV1Controller secondRouterController = new DeepstateV1Controller(address(this), address(secondRouter)); - DeepstateRewarderFactory secondFactory = - new DeepstateRewarderFactory(address(this), address(secondRouterController), address(minterController)); - secondRouter.setPoolHookConfig(TOKEN_C, TOKEN_D, alice, true, false); - secondRouter.transferOwnership(address(secondRouterController)); + 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()); - secondRouterController.setHookManager(address(secondFactory)); - secondFactory.setController(controller); + secondDeepstateV1Controller.setHookManager(address(secondFactory)); + secondFactory.setOperator(operator); bytes32 secondPoolId = _poolId(TOKEN_C, TOKEN_D); vm.expectRevert(abi.encodeWithSelector(DeepstateRewarderFactory.ExistingPoolHook.selector, secondPoolId, alice)); - vm.prank(controller); + vm.prank(operator); secondFactory.deployMarket(_market(TOKEN_C, TOKEN_D)); assertEq(secondFactory.deploymentCount(), 0); @@ -389,47 +393,49 @@ contract DeepstateRewarderFactoryTest is Test { function test_DeploymentWithoutMinterRoleRevertsAtomically() public { DeepstateToken secondToken = new DeepstateToken(address(this), "Second", "SECOND"); - DeepstateV1 secondRouter = new DeepstateV1(); - DeepstateV1Controller secondRouterController = new DeepstateV1Controller(address(this), address(secondRouter)); + 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(secondRouterController), address(secondMinterController) + address(this), address(secondDeepstateV1Controller), address(secondMinterController) ); - secondRouter.transferOwnership(address(secondRouterController)); - secondRouterController.setHookManager(address(secondFactory)); - secondFactory.setController(controller); + secondDeepstate.transferOwnership(address(secondDeepstateV1Controller)); + secondDeepstateV1Controller.setHookManager(address(secondFactory)); + secondFactory.setOperator(operator); DeepstateRewarderFactory.MarketConfig memory config = _market(TOKEN_A, TOKEN_B); address predicted = secondFactory.predictRewarderAddress(config, 0); vm.expectRevert(Ownable.Unauthorized.selector); - vm.prank(controller); + vm.prank(operator); secondFactory.deployMarket(config); assertEq(predicted.code.length, 0); assertEq(secondFactory.deploymentCount(), 0); assertEq(secondFactory.nextDeploymentAt(), 0); assertEq(secondToken.totalSupply(), 0); - assertEq(secondRouter.poolHook(_poolId(TOKEN_A, TOKEN_B)), address(0)); + assertEq(secondDeepstate.poolHook(_poolId(TOKEN_A, TOKEN_B)), address(0)); } - function test_DeploymentWithoutRouterOwnershipRevertsAtomically() public { + function test_DeploymentWithoutDeepstateV1OwnershipRevertsAtomically() public { DeepstateToken secondToken = new DeepstateToken(address(this), "Second", "SECOND"); - DeepstateV1 secondRouter = new DeepstateV1(); - DeepstateV1Controller secondRouterController = new DeepstateV1Controller(address(this), address(secondRouter)); + 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(secondRouterController), address(secondMinterController) + address(this), address(secondDeepstateV1Controller), address(secondMinterController) ); secondMinterController.grantRoles(address(secondFactory), secondMinterController.MINTER_ROLE()); - secondRouterController.setHookManager(address(secondFactory)); - secondFactory.setController(controller); + secondDeepstateV1Controller.setHookManager(address(secondFactory)); + secondFactory.setOperator(operator); DeepstateRewarderFactory.MarketConfig memory config = _market(TOKEN_A, TOKEN_B); address predicted = secondFactory.predictRewarderAddress(config, 0); vm.expectRevert(Ownable.Unauthorized.selector); - vm.prank(controller); + vm.prank(operator); secondFactory.deployMarket(config); assertEq(predicted.code.length, 0); @@ -439,42 +445,43 @@ contract DeepstateRewarderFactoryTest is Test { function test_DeploymentWithoutDelegatedHookPermissionRevertsAtomically() public { DeepstateToken secondToken = new DeepstateToken(address(this), "Second", "SECOND"); - DeepstateV1 secondRouter = new DeepstateV1(); - DeepstateV1Controller secondRouterController = new DeepstateV1Controller(address(this), address(secondRouter)); + 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(secondRouterController), address(secondMinterController) + address(this), address(secondDeepstateV1Controller), address(secondMinterController) ); secondMinterController.grantRoles(address(secondFactory), secondMinterController.MINTER_ROLE()); - secondRouter.transferOwnership(address(secondRouterController)); - secondFactory.setController(controller); + secondDeepstate.transferOwnership(address(secondDeepstateV1Controller)); + secondFactory.setOperator(operator); DeepstateRewarderFactory.MarketConfig memory config = _market(TOKEN_A, TOKEN_B); address predicted = secondFactory.predictRewarderAddress(config, 0); vm.expectRevert(Ownable.Unauthorized.selector); - vm.prank(controller); + vm.prank(operator); secondFactory.deployMarket(config); assertEq(predicted.code.length, 0); assertEq(secondFactory.deploymentCount(), 0); assertEq(secondToken.totalSupply(), 0); - assertEq(secondRouter.poolHook(_poolId(TOKEN_A, TOKEN_B)), address(0)); + assertEq(secondDeepstate.poolHook(_poolId(TOKEN_A, TOKEN_B)), address(0)); } function test_GovernanceCanCleanUpAfterRevokingFactoryHookPermission() public { - vm.prank(controller); + vm.prank(operator); DeepstateRewarderV2 rewarder = factory.deployMarket(_market(TOKEN_A, TOKEN_B)); bytes32 poolId = _poolId(TOKEN_A, TOKEN_B); - routerController.setHookManager(address(0)); + deepstateV1Controller.setHookManager(address(0)); vm.expectRevert(Ownable.Unauthorized.selector); - vm.prank(controller); + vm.prank(operator); factory.removeMarket(TOKEN_A, TOKEN_B); assertEq(factory.activeRewarder(poolId), address(rewarder)); assertEq(deep.balanceOf(address(rewarder)), 100_000_000e18); - routerController.setPoolHookConfig(TOKEN_A, TOKEN_B, address(0), false, false); + deepstateV1Controller.setPoolHookConfig(TOKEN_A, TOKEN_B, address(0), false, false); uint256 burned = factory.removeMarket(TOKEN_A, TOKEN_B); assertEq(burned, 100_000_000e18); @@ -484,17 +491,17 @@ contract DeepstateRewarderFactoryTest is Test { } function test_RemovalRejectsUnexpectedReplacementHook() public { - vm.prank(controller); + vm.prank(operator); DeepstateRewarderV2 rewarder = factory.deployMarket(_market(TOKEN_A, TOKEN_B)); bytes32 poolId = _poolId(TOKEN_A, TOKEN_B); - routerController.setPoolHookConfig(TOKEN_A, TOKEN_B, alice, true, true); + deepstateV1Controller.setPoolHookConfig(TOKEN_A, TOKEN_B, alice, true, true); vm.expectRevert( abi.encodeWithSelector( DeepstateRewarderFactory.UnexpectedPoolHook.selector, poolId, address(rewarder), alice ) ); - vm.prank(controller); + vm.prank(operator); factory.removeMarket(TOKEN_A, TOKEN_B); assertEq(deepstate.poolHook(poolId), alice); @@ -505,20 +512,20 @@ contract DeepstateRewarderFactoryTest is Test { function test_InvalidMarketConfigurationRevertsBeforeDeployment() public { DeepstateRewarderFactory.MarketConfig memory config = _market(TOKEN_B, TOKEN_A); vm.expectRevert(DeepstateRewarderFactory.InvalidPool.selector); - vm.prank(controller); + 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(controller); + vm.prank(operator); factory.deployMarket(config); config = _market(TOKEN_A, TOKEN_B); config.token0StartQuantity = 0; vm.expectRevert(DeepstateRewarder.InvalidQuantitySchedule.selector); - vm.prank(controller); + vm.prank(operator); factory.deployMarket(config); assertEq(factory.deploymentCount(), 0); @@ -531,7 +538,7 @@ contract DeepstateRewarderFactoryTest is Test { vm.prank(alice); factory.deployMarket(_market(TOKEN_A, TOKEN_B)); - vm.prank(controller); + vm.prank(operator); factory.deployMarket(_market(TOKEN_A, TOKEN_B)); vm.expectRevert(Ownable.Unauthorized.selector); @@ -543,11 +550,11 @@ contract DeepstateRewarderFactoryTest is Test { vm.expectRevert( abi.encodeWithSelector(DeepstateRewarderFactory.MarketNotActive.selector, _poolId(TOKEN_A, TOKEN_B)) ); - vm.prank(controller); + vm.prank(operator); factory.removeMarket(TOKEN_A, TOKEN_B); vm.expectRevert(DeepstateRewarderFactory.InvalidPool.selector); - vm.prank(controller); + vm.prank(operator); factory.removeMarket(TOKEN_B, TOKEN_A); } From 7f8dfcde92dba2ff0fd6021eee019f79deb37a5f Mon Sep 17 00:00:00 2001 From: Joseph Delong Date: Sun, 30 Aug 2026 01:41:53 -0500 Subject: [PATCH 20/25] fix: allow independent V1 controller ownership --- src/DeepstateRewarderFactory.sol | 5 ----- test/DeepstateRewarderFactory.t.sol | 11 +++++------ 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/src/DeepstateRewarderFactory.sol b/src/DeepstateRewarderFactory.sol index c850daa..3f8af26 100644 --- a/src/DeepstateRewarderFactory.sol +++ b/src/DeepstateRewarderFactory.sol @@ -63,7 +63,6 @@ contract DeepstateRewarderFactory is Ownable { error InvalidOwner(); error InvalidDeepstateV1Controller(); - error DeepstateV1ControllerOwnerMismatch(address expected, address actual); error InvalidMinterController(); error MinterControllerOwnerMismatch(address expected, address actual); error InvalidRewardToken(); @@ -86,10 +85,6 @@ contract DeepstateRewarderFactory is Ownable { _initializeOwner(owner_); deepstateV1Controller = DeepstateV1Controller(deepstateV1Controller_); - address deepstateV1ControllerOwner = deepstateV1Controller.owner(); - if (deepstateV1ControllerOwner != owner_) { - revert DeepstateV1ControllerOwnerMismatch(owner_, deepstateV1ControllerOwner); - } deepstate = deepstateV1Controller.deepstate(); minterController = IDeepstateMinterController(minterController_); address minterControllerOwner = minterController.owner(); diff --git a/test/DeepstateRewarderFactory.t.sol b/test/DeepstateRewarderFactory.t.sol index 2a9915b..dbb88fa 100644 --- a/test/DeepstateRewarderFactory.t.sol +++ b/test/DeepstateRewarderFactory.t.sol @@ -100,13 +100,12 @@ contract DeepstateRewarderFactoryTest is Test { vm.expectRevert(DeepstateRewarderFactory.InvalidDeepstateV1Controller.selector); new DeepstateRewarderFactory(address(this), alice, address(minterController)); - DeepstateV1Controller mismatchedController = new DeepstateV1Controller(alice, address(deepstate)); - vm.expectRevert( - abi.encodeWithSelector( - DeepstateRewarderFactory.DeepstateV1ControllerOwnerMismatch.selector, address(this), alice - ) + DeepstateV1Controller independentlyOwnedController = new DeepstateV1Controller(alice, address(deepstate)); + DeepstateRewarderFactory independentlyOwnedControllerFactory = new DeepstateRewarderFactory( + address(this), address(independentlyOwnedController), address(minterController) ); - new DeepstateRewarderFactory(address(this), address(mismatchedController), address(minterController)); + assertEq(independentlyOwnedControllerFactory.owner(), address(this)); + assertEq(independentlyOwnedController.owner(), alice); vm.expectRevert(DeepstateRewarderFactory.InvalidMinterController.selector); new DeepstateRewarderFactory(address(this), address(deepstateV1Controller), address(0)); From 8b091ab194c0ddf11b7d623554fe55313b19d793 Mon Sep 17 00:00:00 2001 From: Joseph Delong Date: Sun, 30 Aug 2026 01:47:43 -0500 Subject: [PATCH 21/25] refactor: encode returned token administration state --- src/DeepstateMinterController.sol | 8 +++----- test/DeepstateMinterController.t.sol | 12 ++++++------ 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/src/DeepstateMinterController.sol b/src/DeepstateMinterController.sol index 068d899..4fc60c8 100644 --- a/src/DeepstateMinterController.sol +++ b/src/DeepstateMinterController.sol @@ -32,10 +32,8 @@ contract DeepstateMinterController is OwnableRoles, ReentrancyGuard { /// @notice Maximum live DEEP supply this controller will permit after a mint. uint256 public immutable mintCap; - /// @notice Timestamp after which anyone may return DEEP administration to this contract's owner. + /// @notice Administration deadline, zero before locking, and uint40 max after permanent return. uint40 public tokenAdministrationEndsAt; - /// @notice Whether DEEP administration has already been returned. - bool public tokenAdministrationReturned; event MintedWithVesting( address indexed caller, @@ -98,7 +96,7 @@ contract DeepstateMinterController is OwnableRoles, ReentrancyGuard { function unlockTokenAdministration() external { uint40 endsAt = tokenAdministrationEndsAt; if (endsAt == 0) revert TokenAdministrationNotActive(); - if (tokenAdministrationReturned) revert TokenAdministrationAlreadyReturned(); + if (endsAt == type(uint40).max) revert TokenAdministrationAlreadyReturned(); address owner_ = owner(); if (block.timestamp < endsAt) revert TokenAdministrationActive(endsAt); @@ -106,7 +104,7 @@ contract DeepstateMinterController is OwnableRoles, ReentrancyGuard { bytes32 tokenAdminRole = deepstateToken.DEFAULT_ADMIN_ROLE(); if (!deepstateToken.hasRole(tokenAdminRole, address(this))) revert ControllerNotTokenAdmin(); - tokenAdministrationReturned = true; + 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)); diff --git a/test/DeepstateMinterController.t.sol b/test/DeepstateMinterController.t.sol index c3d4f7b..ef5292e 100644 --- a/test/DeepstateMinterController.t.sol +++ b/test/DeepstateMinterController.t.sol @@ -43,7 +43,6 @@ contract DeepstateMinterControllerTest is Test { assertEq(minterController.TOKEN_ADMINISTRATION_DURATION(), 2 * 365 days); assertEq(minterController.owner(), address(this)); assertEq(minterController.tokenAdministrationEndsAt(), 0); - assertFalse(minterController.tokenAdministrationReturned()); assertEq(minterController.MINTER_ROLE(), 1); assertEq(minterController.rolesOf(address(this)), 0); assertFalse(minterController.hasAnyRole(address(this), minterController.MINTER_ROLE())); @@ -116,7 +115,6 @@ contract DeepstateMinterControllerTest is Test { minterController.lockTokenAdministration(); assertEq(minterController.tokenAdministrationEndsAt(), expectedEndsAt); - assertFalse(minterController.tokenAdministrationReturned()); assertTrue(deep.hasRole(deep.DEFAULT_ADMIN_ROLE(), address(minterController))); assertTrue(deep.hasRole(deep.MINTER_ROLE(), address(minterController))); } @@ -143,7 +141,7 @@ contract DeepstateMinterControllerTest is Test { vm.expectRevert(abi.encodeWithSelector(DeepstateMinterController.TokenAdministrationActive.selector, endsAt)); minterController.unlockTokenAdministration(); - assertFalse(minterController.tokenAdministrationReturned()); + 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); @@ -162,8 +160,7 @@ contract DeepstateMinterControllerTest is Test { vm.prank(unauthorized); minterController.unlockTokenAdministration(); - assertEq(minterController.tokenAdministrationEndsAt(), endsAt); - assertTrue(minterController.tokenAdministrationReturned()); + 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); @@ -181,7 +178,7 @@ contract DeepstateMinterControllerTest is Test { vm.prank(unauthorized); minterController.unlockTokenAdministration(); - assertTrue(minterController.tokenAdministrationReturned()); + 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))); @@ -265,6 +262,9 @@ contract DeepstateMinterControllerTest is Test { vm.expectRevert(DeepstateMinterController.TokenAdministrationAlreadyReturned.selector); minterController.unlockTokenAdministration(); + + vm.expectRevert(DeepstateMinterController.TokenAdministrationAlreadyActivated.selector); + minterController.lockTokenAdministration(); } function test_EachMintCreatesAnIndependentStream() public { From ff13cdcb2c4ba4feb66e32d2a3769ef74e72beda Mon Sep 17 00:00:00 2001 From: Joseph Delong Date: Sun, 30 Aug 2026 01:52:34 -0500 Subject: [PATCH 22/25] perf: rely on exact Sablier allowance consumption --- src/DeepstateMinterController.sol | 1 - 1 file changed, 1 deletion(-) diff --git a/src/DeepstateMinterController.sol b/src/DeepstateMinterController.sol index 4fc60c8..245d743 100644 --- a/src/DeepstateMinterController.sol +++ b/src/DeepstateMinterController.sol @@ -154,7 +154,6 @@ contract DeepstateMinterController is OwnableRoles, ReentrancyGuard { 0, LockupLinear.Durations({cliff: 0, total: VESTING_DURATION}) ); - address(deepstateToken).safeApproveWithRetry(address(sablierLockup), 0); emit MintedWithVesting(msg.sender, to, amount, recipient, vestingAmount, streamId); } From a11186abe5b00a5645c51fe9bd3b046ceb34cfd8 Mon Sep 17 00:00:00 2001 From: Joseph Delong Date: Sun, 30 Aug 2026 02:01:41 -0500 Subject: [PATCH 23/25] refactor: deploy rewarders with CREATE --- src/DeepstateRewarderFactory.sol | 58 ++--------------------------- test/DeepstateRewarderFactory.t.sol | 33 ++++------------ 2 files changed, 10 insertions(+), 81 deletions(-) diff --git a/src/DeepstateRewarderFactory.sol b/src/DeepstateRewarderFactory.sol index 3f8af26..82ceb79 100644 --- a/src/DeepstateRewarderFactory.sol +++ b/src/DeepstateRewarderFactory.sol @@ -10,7 +10,7 @@ import {IDeepstateMinterController} from "./interfaces/IDeepstateMinterControlle import {IDeepstateV1} from "./interfaces/IDeepstateV1.sol"; /// @title Deepstate Rewarder Factory -/// @notice Governance-owned CREATE2 factory for operator-launched market reward programs. +/// @notice Governance-owned factory for operator-launched market reward programs. /// @dev The factory must hold the minter controller's MINTER_ROLE and be the V1 controller's hook manager. contract DeepstateRewarderFactory is Ownable { struct MarketConfig { @@ -42,8 +42,6 @@ contract DeepstateRewarderFactory is Ownable { address public operator; /// @notice Earliest timestamp at which another market may be deployed. uint256 public nextDeploymentAt; - /// @notice Nonce assigned to the next successful CREATE2 deployment. - uint256 public deploymentCount; mapping(bytes32 poolId => address rewarder) public activeRewarder; mapping(address rewarder => bytes32 poolId) public rewarderPool; @@ -51,9 +49,7 @@ contract DeepstateRewarderFactory is Ownable { event OperatorSet(address indexed previousOperator, address indexed newOperator); event MarketDeployed( bytes32 indexed poolId, - uint256 indexed deploymentNonce, address indexed rewarder, - bytes32 salt, address token0, address token1, bool token0Active, @@ -125,13 +121,9 @@ contract DeepstateRewarderFactory is Ownable { address existingHook = deepstate.poolHook(poolId_); if (existingHook != address(0)) revert ExistingPoolHook(poolId_, existingHook); - uint256 nonce = deploymentCount; - bytes32 salt = marketSalt(poolId_, nonce); - - deploymentCount = nonce + 1; nextDeploymentAt = block.timestamp + DEPLOYMENT_COOLDOWN; - rewarder = new DeepstateRewarderV2{salt: salt}( + rewarder = new DeepstateRewarderV2( address(this), address(deepstate), address(rewardToken), @@ -155,14 +147,7 @@ contract DeepstateRewarderFactory is Ownable { ); emit MarketDeployed( - poolId_, - nonce, - address(rewarder), - salt, - config.token0, - config.token1, - config.token0Active, - config.token1Active + poolId_, address(rewarder), config.token0, config.token1, config.token0Active, config.token1Active ); } @@ -188,43 +173,6 @@ contract DeepstateRewarderFactory is Ownable { emit MarketRemoved(poolId_, rewarder, burnedAmount); } - /// @notice CREATE2 salt for a pool and global deployment nonce. - function marketSalt(bytes32 poolId_, uint256 deploymentNonce) public pure returns (bytes32) { - return keccak256(abi.encode(poolId_, deploymentNonce)); - } - - /// @notice Predict a rewarder address using the factory and a deployment nonce. - function predictRewarderAddress(MarketConfig calldata config, uint256 deploymentNonce) - external - view - returns (address predicted) - { - bytes32 poolId_ = _validateMarket(config); - bytes32 salt = marketSalt(poolId_, deploymentNonce); - bytes32 initCodeHash = keccak256( - abi.encodePacked( - type(DeepstateRewarderV2).creationCode, - abi.encode( - address(this), - address(deepstate), - address(rewardToken), - poolId_, - config.token0, - config.token1, - SIDE_EMISSION_CAP, - EMISSION_DURATION, - config.token0StartQuantity, - config.token0MaxQuantity, - config.token1StartQuantity, - config.token1MaxQuantity - ) - ) - ); - - predicted = - address(uint160(uint256(keccak256(abi.encodePacked(bytes1(0xff), address(this), salt, initCodeHash))))); - } - function _validateMarket(MarketConfig calldata config) private pure returns (bytes32 poolId_) { if (config.token0 >= config.token1) revert InvalidPool(); if (!config.token0Active && !config.token1Active) revert InvalidHookFlags(); diff --git a/test/DeepstateRewarderFactory.t.sol b/test/DeepstateRewarderFactory.t.sol index dbb88fa..d814779 100644 --- a/test/DeepstateRewarderFactory.t.sol +++ b/test/DeepstateRewarderFactory.t.sol @@ -87,7 +87,6 @@ contract DeepstateRewarderFactoryTest is Test { assertEq(deepstate.owner(), address(deepstateV1Controller)); assertEq(deepstateV1Controller.hookManager(), address(factory)); assertEq(factory.nextDeploymentAt(), 0); - assertEq(factory.deploymentCount(), 0); } function test_ConstructorValidation() public { @@ -159,19 +158,16 @@ contract DeepstateRewarderFactoryTest is Test { assertEq(secondToken.balanceOf(address(sablier)), _vestingAllocation(100_000_000e18)); } - function test_OperatorDeploysPredictedCreate2MarketWithFixedScheduleAndFunding() public { + function test_OperatorDeploysMarketWithFixedScheduleAndFunding() public { DeepstateRewarderFactory.MarketConfig memory config = _market(TOKEN_A, TOKEN_B); - address predicted = factory.predictRewarderAddress(config, 0); bytes32 poolId = _poolId(TOKEN_A, TOKEN_B); - bytes32 expectedSalt = keccak256(abi.encode(poolId, uint256(0))); - vm.expectEmit(true, true, true, true, address(factory)); - emit DeepstateRewarderFactory.MarketDeployed(poolId, 0, predicted, expectedSalt, TOKEN_A, TOKEN_B, true, true); + 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); - assertEq(address(rewarder), predicted); - assertGt(predicted.code.length, 0); + assertGt(address(rewarder).code.length, 0); assertEq(rewarder.owner(), address(factory)); assertEq(rewarder.deepstate(), address(deepstate)); assertEq(rewarder.rewardToken(), address(deep)); @@ -190,9 +186,7 @@ contract DeepstateRewarderFactoryTest is Test { assertEq(deepstate.poolHook(poolId), address(rewarder)); assertEq(factory.activeRewarder(poolId), address(rewarder)); assertEq(factory.rewarderPool(address(rewarder)), poolId); - assertEq(factory.deploymentCount(), 1); assertEq(factory.nextDeploymentAt(), block.timestamp + 3 days); - assertEq(factory.marketSalt(poolId, 0), expectedSalt); } function test_DeploymentCooldownIsGlobalAndAllowsExactBoundary() public { @@ -213,7 +207,6 @@ contract DeepstateRewarderFactoryTest is Test { vm.prank(operator); DeepstateRewarderV2 second = factory.deployMarket(_market(TOKEN_C, TOKEN_D)); - assertEq(factory.deploymentCount(), 2); 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)); @@ -239,7 +232,6 @@ contract DeepstateRewarderFactoryTest is Test { factory.deployMarket(_market(TOKEN_A, TOKEN_B)); assertEq(factory.operator(), address(0)); - assertEq(factory.deploymentCount(), 0); assertEq(deep.totalSupply(), 0); } @@ -333,7 +325,7 @@ contract DeepstateRewarderFactoryTest is Test { assertEq(deep.totalSupply(), _vestingAllocation(100_000_000e18)); } - function test_RemovedPoolCanBeResetWithFreshCreate2AddressAfterCooldown() public { + function test_RemovedPoolCanBeResetWithFreshAddressAfterCooldown() public { DeepstateRewarderFactory.MarketConfig memory config = _market(TOKEN_A, TOKEN_B); vm.startPrank(operator); DeepstateRewarderV2 first = factory.deployMarket(config); @@ -344,12 +336,10 @@ contract DeepstateRewarderFactoryTest is Test { factory.deployMarket(config); vm.warp(next); - address predicted = factory.predictRewarderAddress(config, 1); DeepstateRewarderV2 second = factory.deployMarket(config); vm.stopPrank(); assertNotEq(address(first), address(second)); - assertEq(address(second), predicted); 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)); @@ -386,7 +376,6 @@ contract DeepstateRewarderFactoryTest is Test { vm.prank(operator); secondFactory.deployMarket(_market(TOKEN_C, TOKEN_D)); - assertEq(secondFactory.deploymentCount(), 0); assertEq(deep.balanceOf(address(secondFactory)), 0); } @@ -404,14 +393,11 @@ contract DeepstateRewarderFactoryTest is Test { secondFactory.setOperator(operator); DeepstateRewarderFactory.MarketConfig memory config = _market(TOKEN_A, TOKEN_B); - address predicted = secondFactory.predictRewarderAddress(config, 0); vm.expectRevert(Ownable.Unauthorized.selector); vm.prank(operator); secondFactory.deployMarket(config); - assertEq(predicted.code.length, 0); - assertEq(secondFactory.deploymentCount(), 0); assertEq(secondFactory.nextDeploymentAt(), 0); assertEq(secondToken.totalSupply(), 0); assertEq(secondDeepstate.poolHook(_poolId(TOKEN_A, TOKEN_B)), address(0)); @@ -431,14 +417,12 @@ contract DeepstateRewarderFactoryTest is Test { secondFactory.setOperator(operator); DeepstateRewarderFactory.MarketConfig memory config = _market(TOKEN_A, TOKEN_B); - address predicted = secondFactory.predictRewarderAddress(config, 0); vm.expectRevert(Ownable.Unauthorized.selector); vm.prank(operator); secondFactory.deployMarket(config); - assertEq(predicted.code.length, 0); - assertEq(secondFactory.deploymentCount(), 0); + assertEq(secondFactory.nextDeploymentAt(), 0); assertEq(secondToken.totalSupply(), 0); } @@ -456,14 +440,12 @@ contract DeepstateRewarderFactoryTest is Test { secondFactory.setOperator(operator); DeepstateRewarderFactory.MarketConfig memory config = _market(TOKEN_A, TOKEN_B); - address predicted = secondFactory.predictRewarderAddress(config, 0); vm.expectRevert(Ownable.Unauthorized.selector); vm.prank(operator); secondFactory.deployMarket(config); - assertEq(predicted.code.length, 0); - assertEq(secondFactory.deploymentCount(), 0); + assertEq(secondFactory.nextDeploymentAt(), 0); assertEq(secondToken.totalSupply(), 0); assertEq(secondDeepstate.poolHook(_poolId(TOKEN_A, TOKEN_B)), address(0)); } @@ -527,7 +509,6 @@ contract DeepstateRewarderFactoryTest is Test { vm.prank(operator); factory.deployMarket(config); - assertEq(factory.deploymentCount(), 0); assertEq(factory.nextDeploymentAt(), 0); assertEq(deep.totalSupply(), 0); } From cae5b2555ab9141532e7614c60fdb25068ebe94b Mon Sep 17 00:00:00 2001 From: Joseph Delong Date: Sun, 30 Aug 2026 02:08:45 -0500 Subject: [PATCH 24/25] refactor: unify controller role authority --- src/DeepstateController.sol | 20 ++++++++++ src/DeepstateMinterController.sol | 16 +++----- src/DeepstateRewarderFactory.sol | 10 ++--- src/DeepstateRewarderV2.sol | 4 +- src/DeepstateV1Controller.sol | 34 +++------------- test/DeepstateMinterController.t.sol | 3 +- test/DeepstateRewarderFactory.t.sol | 35 ++++++++--------- test/DeepstateRewarderV2.t.sol | 8 ++-- test/DeepstateV1Controller.t.sol | 59 +++++++++++++++++++--------- 9 files changed, 99 insertions(+), 90 deletions(-) create mode 100644 src/DeepstateController.sol 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 index 245d743..eabb552 100644 --- a/src/DeepstateMinterController.sol +++ b/src/DeepstateMinterController.sol @@ -4,20 +4,20 @@ 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 {OwnableRoles} from "solady/auth/OwnableRoles.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 OwnableRoles, ReentrancyGuard { +contract DeepstateMinterController is DeepstateController, ReentrancyGuard { using SafeTransferLib for address; uint256 public constant MINTER_ROLE = 1 << 0; @@ -46,7 +46,6 @@ contract DeepstateMinterController is OwnableRoles, ReentrancyGuard { event TokenAdministrationActivated(uint40 indexed endsAt); event TokenAdministrationReturned(address indexed owner, address indexed caller); - error InvalidOwner(); error InvalidDeepstateToken(); error InvalidSablierLockup(); error InvalidRecipient(); @@ -61,8 +60,9 @@ contract DeepstateMinterController is OwnableRoles, ReentrancyGuard { error TokenAdministrationActive(uint40 endsAt); error MintCapExceeded(uint256 cap, uint256 attemptedSupply); - constructor(address owner_, address deepstateToken_, address sablierLockup_, address recipient_, uint256 mintCap_) { - if (owner_ == address(0)) revert InvalidOwner(); + 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(); @@ -72,7 +72,6 @@ contract DeepstateMinterController is OwnableRoles, ReentrancyGuard { sablierLockup = ISablierLockupLinearV4(sablierLockup_); recipient = recipient_; mintCap = mintCap_; - _initializeOwner(owner_); } /// @notice Lock DEEP administration in this contract for the initial two-year term. @@ -112,11 +111,6 @@ contract DeepstateMinterController is OwnableRoles, ReentrancyGuard { emit TokenAdministrationReturned(owner_, msg.sender); } - /// @notice Ownership cannot be renounced while this contract may administer DEEP. - function renounceOwnership() public payable override onlyOwner { - revert NewOwnerIsZeroAddress(); - } - /// @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) diff --git a/src/DeepstateRewarderFactory.sol b/src/DeepstateRewarderFactory.sol index 82ceb79..a3cb808 100644 --- a/src/DeepstateRewarderFactory.sol +++ b/src/DeepstateRewarderFactory.sol @@ -11,7 +11,7 @@ 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 be the V1 controller's hook manager. +/// @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; @@ -55,7 +55,7 @@ contract DeepstateRewarderFactory is Ownable { bool token0Active, bool token1Active ); - event MarketRemoved(bytes32 indexed poolId, address indexed rewarder, uint256 burnedAmount); + event MarketRemoved(bytes32 indexed poolId, address indexed rewarder); error InvalidOwner(); error InvalidDeepstateV1Controller(); @@ -154,7 +154,7 @@ contract DeepstateRewarderFactory is Ownable { /// @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 returns (uint256 burnedAmount) { + function removeMarket(address token0, address token1) external onlyOperatorOrOwner { bytes32 poolId_ = _poolId(token0, token1); address rewarder = activeRewarder[poolId_]; if (rewarder == address(0)) revert MarketNotActive(poolId_); @@ -168,9 +168,9 @@ contract DeepstateRewarderFactory is Ownable { delete activeRewarder[poolId_]; delete rewarderPool[rewarder]; - burnedAmount = DeepstateRewarderV2(rewarder).burnBalance(); + DeepstateRewarderV2(rewarder).burnBalance(); - emit MarketRemoved(poolId_, rewarder, burnedAmount); + emit MarketRemoved(poolId_, rewarder); } function _validateMarket(MarketConfig calldata config) private pure returns (bytes32 poolId_) { diff --git a/src/DeepstateRewarderV2.sol b/src/DeepstateRewarderV2.sol index 85ffd84..65d718a 100644 --- a/src/DeepstateRewarderV2.sol +++ b/src/DeepstateRewarderV2.sol @@ -44,8 +44,8 @@ contract DeepstateRewarderV2 is DeepstateRewarder { /// @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 returns (uint256 amount) { - amount = SafeTransferLib.balanceOf(rewardToken, address(this)); + 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 index 1413a05..3d2eda0 100644 --- a/src/DeepstateV1Controller.sol +++ b/src/DeepstateV1Controller.sol @@ -1,49 +1,31 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; -import {Ownable} from "solady/auth/Ownable.sol"; - +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 Ownable { - IDeepstateV1 public immutable deepstate; +contract DeepstateV1Controller is DeepstateController { + uint256 public constant HOOK_MANAGER_ROLE = 1 << 0; - /// @notice Revocable contract permitted only to configure pool hooks. - address public hookManager; + IDeepstateV1 public immutable deepstate; - event HookManagerSet(address indexed previousHookManager, address indexed newHookManager); event DeepstateFeeConfigured(address indexed recipient, uint16 bps); event DeepstateOwnershipTransferred(address indexed newOwner); - error InvalidOwner(); error InvalidDeepstate(); - constructor(address owner_, address deepstate_) { - if (owner_ == address(0)) revert InvalidOwner(); + constructor(address owner_, address deepstate_) DeepstateController(owner_) { if (deepstate_ == address(0) || deepstate_.code.length == 0) revert InvalidDeepstate(); - _initializeOwner(owner_); deepstate = IDeepstateV1(deepstate_); } - modifier onlyHookManagerOrOwner() { - _checkHookManagerOrOwner(); - _; - } - - /// @notice Appoint or revoke the sole delegated pool-hook manager. - function setHookManager(address newHookManager) external onlyOwner { - address previousHookManager = hookManager; - hookManager = newHookManager; - emit HookManagerSet(previousHookManager, newHookManager); - } - /// @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 - onlyHookManagerOrOwner + onlyOwnerOrRoles(HOOK_MANAGER_ROLE) { deepstate.setPoolHookConfig(token0, token1, hook, token0Active, token1Active); } @@ -59,8 +41,4 @@ contract DeepstateV1Controller is Ownable { deepstate.transferOwnership(newOwner); emit DeepstateOwnershipTransferred(newOwner); } - - function _checkHookManagerOrOwner() private view { - if (msg.sender != hookManager) _checkOwner(); - } } diff --git a/test/DeepstateMinterController.t.sol b/test/DeepstateMinterController.t.sol index ef5292e..67be6cc 100644 --- a/test/DeepstateMinterController.t.sol +++ b/test/DeepstateMinterController.t.sol @@ -7,6 +7,7 @@ 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"; @@ -50,7 +51,7 @@ contract DeepstateMinterControllerTest is Test { } function test_ConstructorValidation() public { - vm.expectRevert(DeepstateMinterController.InvalidOwner.selector); + vm.expectRevert(DeepstateController.InvalidOwner.selector); new DeepstateMinterController(address(0), address(deep), address(sablier), recipient, MINT_CAP); vm.expectRevert(DeepstateMinterController.InvalidDeepstateToken.selector); diff --git a/test/DeepstateRewarderFactory.t.sol b/test/DeepstateRewarderFactory.t.sol index d814779..53987d8 100644 --- a/test/DeepstateRewarderFactory.t.sol +++ b/test/DeepstateRewarderFactory.t.sol @@ -66,7 +66,7 @@ contract DeepstateRewarderFactoryTest is Test { minterController.grantRoles(address(factory), minterController.MINTER_ROLE()); deepstate.transferOwnership(address(deepstateV1Controller)); - deepstateV1Controller.setHookManager(address(factory)); + deepstateV1Controller.grantRoles(address(factory), deepstateV1Controller.HOOK_MANAGER_ROLE()); factory.setOperator(operator); } @@ -85,7 +85,7 @@ contract DeepstateRewarderFactoryTest is Test { assertFalse(deep.hasRole(deep.MINTER_ROLE(), address(factory))); assertTrue(minterController.hasAnyRole(address(factory), minterController.MINTER_ROLE())); assertEq(deepstate.owner(), address(deepstateV1Controller)); - assertEq(deepstateV1Controller.hookManager(), address(factory)); + assertTrue(deepstateV1Controller.hasAnyRole(address(factory), deepstateV1Controller.HOOK_MANAGER_ROLE())); assertEq(factory.nextDeploymentAt(), 0); } @@ -146,7 +146,7 @@ contract DeepstateRewarderFactoryTest is Test { vm.startPrank(governance); secondMinterController.grantRoles(address(secondFactory), secondMinterController.MINTER_ROLE()); - secondDeepstateV1Controller.setHookManager(address(secondFactory)); + secondDeepstateV1Controller.grantRoles(address(secondFactory), secondDeepstateV1Controller.HOOK_MANAGER_ROLE()); secondFactory.setOperator(operator); vm.stopPrank(); vm.prank(operator); @@ -253,12 +253,13 @@ contract DeepstateRewarderFactoryTest is Test { DeepstateRewarderV2 rewarder = factory.deployMarket(config); bytes32 poolId = _poolId(TOKEN_A, TOKEN_B); - vm.expectEmit(true, true, false, true, address(factory)); - emit DeepstateRewarderFactory.MarketRemoved(poolId, address(rewarder), 100_000_000e18); + 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); - uint256 burned = factory.removeMarket(TOKEN_A, TOKEN_B); + factory.removeMarket(TOKEN_A, TOKEN_B); - assertEq(burned, 100_000_000e18); assertEq(deepstate.poolHook(poolId), address(0)); assertEq(factory.activeRewarder(poolId), address(0)); assertEq(factory.rewarderPool(address(rewarder)), bytes32(0)); @@ -277,9 +278,8 @@ contract DeepstateRewarderFactoryTest is Test { deep.transfer(alice, claimed); vm.prank(operator); - uint256 burned = factory.removeMarket(TOKEN_A, TOKEN_B); + factory.removeMarket(TOKEN_A, TOKEN_B); - assertEq(burned, 75_000_000e18); assertEq(deep.balanceOf(address(rewarder)), 0); assertEq(deep.balanceOf(alice), claimed); assertEq(deep.balanceOf(address(sablier)), _vestingAllocation(100_000_000e18)); @@ -308,9 +308,8 @@ contract DeepstateRewarderFactoryTest is Test { assertEq(deep.balanceOf(address(sablier)), vested); vm.prank(operator); - uint256 burned = factory.removeMarket(TOKEN_A, TOKEN_B); + factory.removeMarket(TOKEN_A, TOKEN_B); - assertEq(burned, 1_000_000_000e18); assertEq(deep.totalSupply(), vested); } @@ -319,9 +318,8 @@ contract DeepstateRewarderFactoryTest is Test { factory.deployMarket(_market(TOKEN_A, TOKEN_B)); factory.setOperator(address(0)); - uint256 burned = factory.removeMarket(TOKEN_A, TOKEN_B); + factory.removeMarket(TOKEN_A, TOKEN_B); - assertEq(burned, 100_000_000e18); assertEq(deep.totalSupply(), _vestingAllocation(100_000_000e18)); } @@ -368,7 +366,7 @@ contract DeepstateRewarderFactoryTest is Test { secondDeepstate.setPoolHookConfig(TOKEN_C, TOKEN_D, alice, true, false); secondDeepstate.transferOwnership(address(secondDeepstateV1Controller)); minterController.grantRoles(address(secondFactory), minterController.MINTER_ROLE()); - secondDeepstateV1Controller.setHookManager(address(secondFactory)); + secondDeepstateV1Controller.grantRoles(address(secondFactory), secondDeepstateV1Controller.HOOK_MANAGER_ROLE()); secondFactory.setOperator(operator); bytes32 secondPoolId = _poolId(TOKEN_C, TOKEN_D); @@ -389,7 +387,7 @@ contract DeepstateRewarderFactoryTest is Test { address(this), address(secondDeepstateV1Controller), address(secondMinterController) ); secondDeepstate.transferOwnership(address(secondDeepstateV1Controller)); - secondDeepstateV1Controller.setHookManager(address(secondFactory)); + secondDeepstateV1Controller.grantRoles(address(secondFactory), secondDeepstateV1Controller.HOOK_MANAGER_ROLE()); secondFactory.setOperator(operator); DeepstateRewarderFactory.MarketConfig memory config = _market(TOKEN_A, TOKEN_B); @@ -413,7 +411,7 @@ contract DeepstateRewarderFactoryTest is Test { address(this), address(secondDeepstateV1Controller), address(secondMinterController) ); secondMinterController.grantRoles(address(secondFactory), secondMinterController.MINTER_ROLE()); - secondDeepstateV1Controller.setHookManager(address(secondFactory)); + secondDeepstateV1Controller.grantRoles(address(secondFactory), secondDeepstateV1Controller.HOOK_MANAGER_ROLE()); secondFactory.setOperator(operator); DeepstateRewarderFactory.MarketConfig memory config = _market(TOKEN_A, TOKEN_B); @@ -454,7 +452,7 @@ contract DeepstateRewarderFactoryTest is Test { vm.prank(operator); DeepstateRewarderV2 rewarder = factory.deployMarket(_market(TOKEN_A, TOKEN_B)); bytes32 poolId = _poolId(TOKEN_A, TOKEN_B); - deepstateV1Controller.setHookManager(address(0)); + deepstateV1Controller.revokeRoles(address(factory), deepstateV1Controller.HOOK_MANAGER_ROLE()); vm.expectRevert(Ownable.Unauthorized.selector); vm.prank(operator); @@ -463,9 +461,8 @@ contract DeepstateRewarderFactoryTest is Test { assertEq(deep.balanceOf(address(rewarder)), 100_000_000e18); deepstateV1Controller.setPoolHookConfig(TOKEN_A, TOKEN_B, address(0), false, false); - uint256 burned = factory.removeMarket(TOKEN_A, TOKEN_B); + factory.removeMarket(TOKEN_A, TOKEN_B); - assertEq(burned, 100_000_000e18); assertEq(factory.activeRewarder(poolId), address(0)); assertEq(deep.balanceOf(address(rewarder)), 0); assertEq(deep.totalSupply(), _vestingAllocation(100_000_000e18)); diff --git a/test/DeepstateRewarderV2.t.sol b/test/DeepstateRewarderV2.t.sol index e387817..ca0c38e 100644 --- a/test/DeepstateRewarderV2.t.sol +++ b/test/DeepstateRewarderV2.t.sol @@ -51,9 +51,8 @@ contract DeepstateRewarderV2Test is Test { vm.expectEmit(false, false, false, true, address(rewarder)); emit DeepstateRewarderV2.RewardBalanceBurned(funding); - uint256 burned = rewarder.burnBalance(); + rewarder.burnBalance(); - assertEq(burned, funding); assertEq(rewardToken.balanceOf(address(rewarder)), 0); assertEq(rewardToken.totalSupply(), 0); } @@ -69,15 +68,14 @@ contract DeepstateRewarderV2Test is Test { assertEq(rewardToken.totalSupply(), fundingBefore); } - function test_BurnBalanceReturnsZeroForEmptyRewarder() public { + function test_BurnBalanceEmitsZeroForEmptyRewarder() public { uint256 funding = rewardToken.balanceOf(address(rewarder)); rewarder.burnBalance(); vm.expectEmit(false, false, false, true, address(rewarder)); emit DeepstateRewarderV2.RewardBalanceBurned(0); - uint256 burned = rewarder.burnBalance(); + rewarder.burnBalance(); - assertEq(burned, 0); 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 index e41bf1f..8c08086 100644 --- a/test/DeepstateV1Controller.t.sol +++ b/test/DeepstateV1Controller.t.sol @@ -5,6 +5,7 @@ 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 { @@ -28,12 +29,14 @@ contract DeepstateV1ControllerTest is Test { function test_ImmutableConfiguration() public view { assertEq(controller.owner(), address(this)); assertEq(address(controller.deepstate()), address(deepstate)); - assertEq(controller.hookManager(), address(0)); + 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(DeepstateV1Controller.InvalidOwner.selector); + vm.expectRevert(DeepstateController.InvalidOwner.selector); new DeepstateV1Controller(address(0), address(deepstate)); vm.expectRevert(DeepstateV1Controller.InvalidDeepstate.selector); @@ -43,30 +46,28 @@ contract DeepstateV1ControllerTest is Test { new DeepstateV1Controller(address(this), alice); } - function test_GovernanceCanSetAndRevokeHookManager() public { - vm.expectEmit(true, true, false, false, address(controller)); - emit DeepstateV1Controller.HookManagerSet(address(0), hookManager); - controller.setHookManager(hookManager); - assertEq(controller.hookManager(), hookManager); + function test_GovernanceCanGrantAndRevokeHookManagerRole() public { + controller.grantRoles(hookManager, controller.HOOK_MANAGER_ROLE()); + assertTrue(controller.hasAnyRole(hookManager, controller.HOOK_MANAGER_ROLE())); - vm.expectEmit(true, true, false, false, address(controller)); - emit DeepstateV1Controller.HookManagerSet(hookManager, address(0)); - controller.setHookManager(address(0)); - assertEq(controller.hookManager(), address(0)); + controller.revokeRoles(hookManager, controller.HOOK_MANAGER_ROLE()); + assertFalse(controller.hasAnyRole(hookManager, controller.HOOK_MANAGER_ROLE())); } - function test_OnlyGovernanceCanSetHookManager() public { + function test_OnlyGovernanceCanGrantOrRevokeHookManagerRole() public { + uint256 hookManagerRole = controller.HOOK_MANAGER_ROLE(); + vm.expectRevert(Ownable.Unauthorized.selector); vm.prank(hookManager); - controller.setHookManager(hookManager); + controller.grantRoles(hookManager, hookManagerRole); vm.expectRevert(Ownable.Unauthorized.selector); vm.prank(alice); - controller.setHookManager(alice); + controller.revokeRoles(hookManager, hookManagerRole); } function test_HookManagerCanConfigurePoolHook() public { - controller.setHookManager(hookManager); + controller.grantRoles(hookManager, controller.HOOK_MANAGER_ROLE()); vm.prank(hookManager); controller.setPoolHookConfig(TOKEN0, TOKEN1, hook, true, false); @@ -74,6 +75,21 @@ contract DeepstateV1ControllerTest is Test { 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); @@ -83,8 +99,8 @@ contract DeepstateV1ControllerTest is Test { } function test_RevokedHookManagerImmediatelyLosesHookAccess() public { - controller.setHookManager(hookManager); - controller.setHookManager(address(0)); + controller.grantRoles(hookManager, controller.HOOK_MANAGER_ROLE()); + controller.revokeRoles(hookManager, controller.HOOK_MANAGER_ROLE()); vm.expectRevert(Ownable.Unauthorized.selector); vm.prank(hookManager); @@ -100,7 +116,7 @@ contract DeepstateV1ControllerTest is Test { } function test_HookManagerCannotConfigureFeesOrTransferRouterOwnership() public { - controller.setHookManager(hookManager); + controller.grantRoles(hookManager, controller.HOOK_MANAGER_ROLE()); vm.expectRevert(Ownable.Unauthorized.selector); vm.prank(hookManager); @@ -134,7 +150,7 @@ contract DeepstateV1ControllerTest is Test { function test_ControllerCallsFailUntilItOwnsRouter() public { DeepstateV1 secondRouter = new DeepstateV1(); DeepstateV1Controller secondController = new DeepstateV1Controller(address(this), address(secondRouter)); - secondController.setHookManager(hookManager); + secondController.grantRoles(hookManager, secondController.HOOK_MANAGER_ROLE()); vm.expectRevert(Ownable.Unauthorized.selector); vm.prank(hookManager); @@ -147,6 +163,11 @@ contract DeepstateV1ControllerTest is Test { 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)); } From 39a336f0015d9a5c3f1029cde1191c1789e85587 Mon Sep 17 00:00:00 2001 From: Joseph Delong Date: Sun, 30 Aug 2026 02:14:59 -0500 Subject: [PATCH 25/25] refactor: inline factory authority check --- src/DeepstateRewarderFactory.sol | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/DeepstateRewarderFactory.sol b/src/DeepstateRewarderFactory.sol index a3cb808..c921c82 100644 --- a/src/DeepstateRewarderFactory.sol +++ b/src/DeepstateRewarderFactory.sol @@ -93,7 +93,7 @@ contract DeepstateRewarderFactory is Ownable { } modifier onlyOperatorOrOwner() { - _checkOperatorOrOwner(); + if (msg.sender != operator) _checkOwner(); _; } @@ -183,8 +183,4 @@ contract DeepstateRewarderFactory is Ownable { if (token0 >= token1) revert InvalidPool(); poolId_ = keccak256(abi.encode(token0, token1)); } - - function _checkOperatorOrOwner() private view { - if (msg.sender != operator) _checkOwner(); - } }