From 67e736f0efecd80460e38a51dd00b688302999af Mon Sep 17 00:00:00 2001 From: Joseph Delong Date: Sat, 5 Sep 2026 21:33:56 -0500 Subject: [PATCH 1/5] Add DGP-003 protocol contracts --- src/DeepstateGovernorV2.sol | 46 ++++++++++ src/DeepstateRewarder.sol | 19 +++++ src/DeepstateRewarderFactoryV3.sol | 115 +++++++++++++++++++++++++ src/DeepstateRewarderV3.sol | 39 +++++++++ src/DeepstateTokenV2.sol | 63 ++++++++++++++ src/interfaces/IDeepstateV1.sol | 13 +++ src/interfaces/IOrderBook.sol | 1 + test/DeepstateRewarderFactoryV3.t.sol | 116 +++++++++++++++++++++++++ test/DeepstateV2.t.sol | 118 ++++++++++++++++++++++++++ 9 files changed, 530 insertions(+) create mode 100644 src/DeepstateGovernorV2.sol create mode 100644 src/DeepstateRewarderFactoryV3.sol create mode 100644 src/DeepstateRewarderV3.sol create mode 100644 src/DeepstateTokenV2.sol create mode 100644 src/interfaces/IDeepstateV1.sol create mode 100644 test/DeepstateRewarderFactoryV3.t.sol create mode 100644 test/DeepstateV2.t.sol diff --git a/src/DeepstateGovernorV2.sol b/src/DeepstateGovernorV2.sol new file mode 100644 index 0000000..f0b8791 --- /dev/null +++ b/src/DeepstateGovernorV2.sol @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {IVotes} from "@openzeppelin/contracts/governance/utils/IVotes.sol"; + +import {DeepstateGovernor} from "./DeepstateGovernor.sol"; + +/// @notice Deepstate Governor extended with a governance-managed cancellation guardian set. +contract DeepstateGovernorV2 is DeepstateGovernor { + mapping(address guardian => bool enabled) public isGuardian; + + event GuardianSet(address indexed guardian, bool enabled); + + error InvalidGuardian(); + + constructor( + IVotes token_, + uint48 governanceStartDelay, + uint48 initialVotingDelay, + uint32 initialVotingPeriod, + uint256 initialProposalThresholdNumerator, + uint256 quorumNumeratorValue, + uint48 initialVoteExtension + ) + DeepstateGovernor( + token_, + governanceStartDelay, + initialVotingDelay, + initialVotingPeriod, + initialProposalThresholdNumerator, + quorumNumeratorValue, + initialVoteExtension + ) + {} + + /// @notice Add or remove an address that may cancel any unexecuted proposal. + function setGuardian(address guardian, bool enabled) external onlyGovernance { + if (guardian == address(0)) revert InvalidGuardian(); + isGuardian[guardian] = enabled; + emit GuardianSet(guardian, enabled); + } + + function _validateCancel(uint256 proposalId, address caller) internal view override returns (bool) { + return isGuardian[caller] || super._validateCancel(proposalId, caller); + } +} diff --git a/src/DeepstateRewarder.sol b/src/DeepstateRewarder.sol index 57eb4d3..d7d6474 100644 --- a/src/DeepstateRewarder.sol +++ b/src/DeepstateRewarder.sol @@ -135,6 +135,25 @@ contract DeepstateRewarder is Ownable, IHook { token1QuantityLogWad = uint128(token1Log); } + /// @dev Begin a fresh schedule for top orders already resting in the active book. + /// Derived rewarders opt into this behavior by calling the initializer from their constructor. + function _initializeLiveCursors() internal { + IOrderBook orderBook = IOrderBook(deepstate); + bytes32 bookId = orderBook.activeBookId(token0, token1); + (uint32 token0Nonce, uint160 token0Amount) = orderBook.topOrder(bookId, false); + (uint32 token1Nonce, uint160 token1Amount) = orderBook.topOrder(bookId, true); + uint64 startedAt = uint64(block.timestamp); + + if (token0Nonce != 0 && token0Amount != 0) { + _token0BookId = bookId; + _token0State = _packState(token0Nonce, startedAt, startedAt, 0); + } + if (token1Nonce != 0 && token1Amount != 0) { + _token1BookId = bookId; + _token1State = _packState(token1Nonce, startedAt, startedAt, 0); + } + } + /// @notice Current top-order cursor for one side. function rewardees(address token) external view returns (uint32 orderNonce, uint64 startedAt) { (orderNonce, startedAt,,) = _unpackState(_packedState(token)); diff --git a/src/DeepstateRewarderFactoryV3.sol b/src/DeepstateRewarderFactoryV3.sol new file mode 100644 index 0000000..8d0aaf9 --- /dev/null +++ b/src/DeepstateRewarderFactoryV3.sol @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; +import {SafeCastLib} from "solady/utils/SafeCastLib.sol"; +import {Ownable} from "solady/auth/Ownable.sol"; + +import {DeepstateTokenV2} from "./DeepstateTokenV2.sol"; +import {DeepstateRewarderV3} from "./DeepstateRewarderV3.sol"; +import {IDeepstateV1} from "./interfaces/IDeepstateV1.sol"; + +/// @notice Governance-owned factory for fully funded Rewarder V3 programs. +contract DeepstateRewarderFactoryV3 is Ownable { + struct MarketConfig { + address token0; + address token1; + uint256 token0MaxUnits; + uint256 token1MaxUnits; + bool token0Active; + bool token1Active; + } + + uint32 public constant EMISSION_DURATION = 365 days; + uint96 public constant SIDE_EMISSION_CAP = 50_000_000e18; + uint256 public constant MARKET_FUNDING = 100_000_000e18; + uint256 public constant MAX_QUANTITY_GROWTH = 1_000_000; + + IDeepstateV1 public immutable deepstate; + DeepstateTokenV2 public immutable rewardToken; + + event RewarderDeployed( + bytes32 indexed poolId, + address indexed rewarder, + address token0, + address token1, + bool token0Active, + bool token1Active + ); + + error InvalidOwner(); + error QuantityGrowthTooLarge(address token, uint256 maxUnits); + + constructor(address owner_, address deepstate_, address rewardToken_) { + if (owner_ == address(0)) revert InvalidOwner(); + + _initializeOwner(owner_); + deepstate = IDeepstateV1(deepstate_); + rewardToken = DeepstateTokenV2(rewardToken_); + } + + /// @notice Deploy, fund, and install a Rewarder V3 for one canonical pool. + function deployMarket(MarketConfig calldata config) external onlyOwner returns (DeepstateRewarderV3 rewarder) { + _validateQuantityGrowth(config.token0, config.token0MaxUnits); + _validateQuantityGrowth(config.token1, config.token1MaxUnits); + + (uint160 token0StartQuantity, uint160 token0MaxQuantity) = + _quantitiesForUnits(config.token0, config.token0MaxUnits); + (uint160 token1StartQuantity, uint160 token1MaxQuantity) = + _quantitiesForUnits(config.token1, config.token1MaxUnits); + bytes32 poolId = keccak256(abi.encode(config.token0, config.token1)); + + rewarder = new DeepstateRewarderV3( + address(this), + address(deepstate), + address(rewardToken), + poolId, + config.token0, + config.token1, + SIDE_EMISSION_CAP, + EMISSION_DURATION, + token0StartQuantity, + token0MaxQuantity, + token1StartQuantity, + token1MaxQuantity + ); + + rewardToken.mint(address(rewarder), MARKET_FUNDING); + deepstate.setPoolHookConfig( + config.token0, config.token1, address(rewarder), config.token0Active, config.token1Active + ); + + emit RewarderDeployed( + poolId, address(rewarder), config.token0, config.token1, config.token0Active, config.token1Active + ); + } + + /// @notice Return direct Router administration to this factory's owner. + /// @dev The factory cannot install another rewarder unless governance later transfers the Router back. + function returnDeepstateOwnership() external onlyOwner { + deepstate.transferOwnership(owner()); + } + + function renounceOwnership() public payable override onlyOwner { + revert NewOwnerIsZeroAddress(); + } + + function _quantitiesForUnits(address token, uint256 maxUnits) + private + view + returns (uint160 startQuantity, uint160 maxQuantity) + { + uint256 unit = token == address(0) ? 1e18 : 10 ** uint256(IERC20Metadata(token).decimals()); + startQuantity = SafeCastLib.toUint160(unit); + maxQuantity = SafeCastLib.toUint160(maxUnits * unit); + } + + function _validateQuantityGrowth(address token, uint256 maxUnits) private pure { + if (maxUnits > MAX_QUANTITY_GROWTH) revert QuantityGrowthTooLarge(token, maxUnits); + } + + function _setOwner(address newOwner) internal override { + if (newOwner == address(this)) revert InvalidOwner(); + super._setOwner(newOwner); + } +} diff --git a/src/DeepstateRewarderV3.sol b/src/DeepstateRewarderV3.sol new file mode 100644 index 0000000..948268e --- /dev/null +++ b/src/DeepstateRewarderV3.sol @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {DeepstateRewarder} from "./DeepstateRewarder.sol"; + +/// @notice Rewarder that starts a fresh schedule for orders already resting in the active market. +contract DeepstateRewarderV3 is DeepstateRewarder { + 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_ + ) + { + _initializeLiveCursors(); + } +} diff --git a/src/DeepstateTokenV2.sol b/src/DeepstateTokenV2.sol new file mode 100644 index 0000000..8bb25e5 --- /dev/null +++ b/src/DeepstateTokenV2.sol @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {ERC20Votes} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Votes.sol"; +import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; +import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol"; + +import {DeepstateToken} from "./DeepstateToken.sol"; + +/// @notice Capped, vote-enabled DEEP with automatic self-delegation on first receipt. +contract DeepstateTokenV2 is DeepstateToken, ERC20Votes { + uint256 public supplyCap; + + event SupplyCapRaised(uint256 previousCap, uint256 newCap); + + error InvalidSupplyCap(); + error SupplyCapNotRaised(uint256 currentCap, uint256 proposedCap); + error SupplyCapExceeded(uint256 cap, uint256 attemptedSupply); + + constructor(address admin_, uint256 initialSupplyCap) + DeepstateToken(admin_, "Deepstate", "DEEP") + EIP712("Deepstate", "1") + { + if (initialSupplyCap == 0 || initialSupplyCap > type(uint208).max) { + revert InvalidSupplyCap(); + } + supplyCap = initialSupplyCap; + } + + /// @notice Governance may expand issuance but cannot reduce its prior cap commitment. + function raiseSupplyCap(uint256 newCap) external onlyRole(DEFAULT_ADMIN_ROLE) { + uint256 currentCap = supplyCap; + if (newCap <= currentCap) revert SupplyCapNotRaised(currentCap, newCap); + if (newCap > type(uint208).max) revert InvalidSupplyCap(); + + supplyCap = newCap; + emit SupplyCapRaised(currentCap, newCap); + } + + function clock() public view override returns (uint48) { + return SafeCast.toUint48(block.timestamp); + } + + // solhint-disable-next-line func-name-mixedcase + function CLOCK_MODE() public pure override returns (string memory) { + return "mode=timestamp"; + } + + function _update(address from, address to, uint256 amount) internal override(ERC20, ERC20Votes) { + if (from == address(0)) { + uint256 attemptedSupply = totalSupply() + amount; + uint256 cap = supplyCap; + if (attemptedSupply > cap) revert SupplyCapExceeded(cap, attemptedSupply); + } + + super._update(from, to, amount); + + if (to != address(0) && delegates(to) == address(0)) { + _delegate(to, to); + } + } +} diff --git a/src/interfaces/IDeepstateV1.sol b/src/interfaces/IDeepstateV1.sol new file mode 100644 index 0000000..c91cf3e --- /dev/null +++ b/src/interfaces/IDeepstateV1.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +interface IDeepstateV1 { + 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; +} diff --git a/src/interfaces/IOrderBook.sol b/src/interfaces/IOrderBook.sol index dda5499..c1f8103 100644 --- a/src/interfaces/IOrderBook.sol +++ b/src/interfaces/IOrderBook.sol @@ -3,6 +3,7 @@ pragma solidity 0.8.28; /// @notice Minimal engine surface used by hooks to prove an order owner. interface IOrderBook { + function activeBookId(address token0, address token1) external view returns (bytes32); function orderId(bytes32 id, bytes32 order) external pure returns (bytes32); function ownerOfOrder(bytes32 orderId) external view returns (address); function topOrder(bytes32 bookId, bool isBid) external view returns (uint32 nonce, uint160 soldAmount); diff --git a/test/DeepstateRewarderFactoryV3.t.sol b/test/DeepstateRewarderFactoryV3.t.sol new file mode 100644 index 0000000..27f838b --- /dev/null +++ b/test/DeepstateRewarderFactoryV3.t.sol @@ -0,0 +1,116 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {Test} from "forge-std/Test.sol"; + +import {DeepstateTokenV2 as DeepstateToken} from "../src/DeepstateTokenV2.sol"; +import {DeepstateRewarderV3} from "../src/DeepstateRewarderV3.sol"; +import {DeepstateRewarderFactoryV3} from "../src/DeepstateRewarderFactoryV3.sol"; +import {MockERC20} from "./mocks/MockERC20.sol"; + +contract MockRouterV3 { + address public owner; + mapping(bytes32 poolId => address hook) public poolHook; + bytes32 public currentBookId = keccak256("book"); + + constructor() { + owner = msg.sender; + } + + modifier onlyOwner() { + require(msg.sender == owner, "not owner"); + _; + } + + function setPoolHookConfig(address token0, address token1, address hook, bool, bool) external onlyOwner { + poolHook[keccak256(abi.encode(token0, token1))] = hook; + } + + function setFeeConfig(address, uint16) external onlyOwner {} + + function activeBookId(address, address) external view returns (bytes32) { + return currentBookId; + } + + function topOrder(bytes32, bool isBid) external pure returns (uint32 nonce, uint160 soldAmount) { + return isBid ? (uint32(11), uint160(2e18)) : (uint32(22), uint160(3e6)); + } + + function transferOwnership(address newOwner) external onlyOwner { + owner = newOwner; + } +} + +contract DeepstateRewarderFactoryV3Test is Test { + address internal unauthorized = makeAddr("unauthorized"); + + DeepstateToken internal token; + MockRouterV3 internal router; + DeepstateRewarderFactoryV3 internal factory; + MockERC20 internal tokenA; + MockERC20 internal tokenB; + + function setUp() public { + token = new DeepstateToken(address(this), 3_000_000_000e18); + router = new MockRouterV3(); + factory = new DeepstateRewarderFactoryV3(address(this), address(router), address(token)); + tokenA = new MockERC20("Token A", "A", 6); + tokenB = new MockERC20("Token B", "B", 18); + + token.grantRole(token.MINTER_ROLE(), address(factory)); + router.transferOwnership(address(factory)); + } + + function testGovernanceDeploysFullyFundedRewarderAndCanReplaceIt() public { + DeepstateRewarderFactoryV3.MarketConfig memory config = _config(); + DeepstateRewarderV3 first = factory.deployMarket(config); + bytes32 poolId = keccak256(abi.encode(config.token0, config.token1)); + + assertEq(token.balanceOf(address(first)), 100_000_000e18); + assertEq(first.owner(), address(factory)); + assertEq(first.sideEmissionCap(), 50_000_000e18); + assertEq(first.emissionDuration(), 365 days); + assertEq(router.poolHook(poolId), address(first)); + (uint32 token0Nonce, uint64 token0StartedAt) = first.rewardees(config.token0); + (uint32 token1Nonce, uint64 token1StartedAt) = first.rewardees(config.token1); + assertEq(token0Nonce, 22); + assertEq(token1Nonce, 11); + assertEq(token0StartedAt, block.timestamp); + assertEq(token1StartedAt, block.timestamp); + + DeepstateRewarderV3 second = factory.deployMarket(config); + assertNotEq(address(first), address(second)); + assertEq(router.poolHook(poolId), address(second)); + assertEq(token.totalSupply(), 200_000_000e18); + } + + function testUnauthorizedAddressCannotDeployMarket() public { + vm.prank(unauthorized); + vm.expectRevert(); + factory.deployMarket(_config()); + } + + function testOnlyGovernorCanReturnRouterOwnership() public { + vm.prank(unauthorized); + vm.expectRevert(); + factory.returnDeepstateOwnership(); + + factory.returnDeepstateOwnership(); + assertEq(router.owner(), address(this)); + } + + function _config() private view returns (DeepstateRewarderFactoryV3.MarketConfig memory config) { + (address token0, address token1) = + address(tokenA) < address(tokenB) ? (address(tokenA), address(tokenB)) : (address(tokenB), address(tokenA)); + bool tokenAIsToken0 = token0 == address(tokenA); + + config = DeepstateRewarderFactoryV3.MarketConfig({ + token0: token0, + token1: token1, + token0MaxUnits: tokenAIsToken0 ? 1_000_000 : 5_000, + token1MaxUnits: tokenAIsToken0 ? 5_000 : 1_000_000, + token0Active: true, + token1Active: true + }); + } +} diff --git a/test/DeepstateV2.t.sol b/test/DeepstateV2.t.sol new file mode 100644 index 0000000..7d3f290 --- /dev/null +++ b/test/DeepstateV2.t.sol @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {Test} from "forge-std/Test.sol"; +import {IVotes} from "@openzeppelin/contracts/governance/utils/IVotes.sol"; + +import {DeepstateTokenV2 as DeepstateToken} from "../src/DeepstateTokenV2.sol"; +import {DeepstateGovernorV2 as DeepstateGovernor} from "../src/DeepstateGovernorV2.sol"; + +contract DeepstateTokenAndGovernorTest is Test { + address internal alice = makeAddr("alice"); + address internal bob = makeAddr("bob"); + address internal guardian = makeAddr("guardian"); + + DeepstateToken internal token; + DeepstateGovernor internal governor; + + function setUp() public { + token = new DeepstateToken(address(this), 3_000_000_000e18); + token.grantRole(token.MINTER_ROLE(), address(this)); + token.mint(alice, 100e18); + governor = new DeepstateGovernor(IVotes(address(token)), 0, 1 days, 1 days, 1, 10, 1 days); + } + + function testRecipientsSelfDelegateOnMintAndFirstTransfer() public { + assertEq(token.delegates(alice), alice); + assertEq(token.getVotes(alice), 100e18); + + vm.prank(alice); + token.transfer(bob, 25e18); + + assertEq(token.delegates(bob), bob); + assertEq(token.getVotes(alice), 75e18); + assertEq(token.getVotes(bob), 25e18); + } + + function testExplicitDelegationIsPreservedAcrossReceipts() public { + vm.prank(alice); + token.delegate(bob); + + token.mint(alice, 10e18); + + assertEq(token.delegates(alice), bob); + assertEq(token.getVotes(alice), 0); + assertEq(token.getVotes(bob), 110e18); + } + + function testOnlyAdminCanRaiseCapAndCapCannotFall() public { + vm.prank(alice); + vm.expectRevert(); + token.raiseSupplyCap(4_000_000_000e18); + + vm.expectRevert( + abi.encodeWithSelector(DeepstateToken.SupplyCapNotRaised.selector, 3_000_000_000e18, 3_000_000_000e18) + ); + token.raiseSupplyCap(3_000_000_000e18); + + token.raiseSupplyCap(4_000_000_000e18); + assertEq(token.supplyCap(), 4_000_000_000e18); + } + + function testMinterCannotExceedCap() public { + token.grantRole(token.MINTER_ROLE(), bob); + + vm.prank(bob); + vm.expectRevert( + abi.encodeWithSelector(DeepstateToken.SupplyCapExceeded.selector, 3_000_000_000e18, 3_000_000_001e18) + ); + token.mint(bob, 3_000_000_000e18 - 100e18 + 1e18); + } + + function testGovernanceCanAddAndRemoveGuardian() public { + vm.warp(block.timestamp + 1); + (address[] memory targets, uint256[] memory values, bytes[] memory calldatas) = _guardianProposal(true); + string memory description = "Add guardian"; + + vm.prank(alice); + uint256 proposalId = governor.propose(targets, values, calldatas, description); + vm.warp(governor.proposalSnapshot(proposalId) + 1); + vm.prank(alice); + governor.castVote(proposalId, 1); + vm.warp(governor.proposalDeadline(proposalId) + 1); + governor.execute(targets, values, calldatas, keccak256(bytes(description))); + + assertTrue(governor.isGuardian(guardian)); + + address[] memory secondTargets = new address[](1); + secondTargets[0] = bob; + uint256[] memory secondValues = new uint256[](1); + bytes[] memory secondCalldatas = new bytes[](1); + secondCalldatas[0] = ""; + string memory secondDescription = "Cancelable proposal"; + + vm.prank(alice); + uint256 secondId = governor.propose(secondTargets, secondValues, secondCalldatas, secondDescription); + vm.warp(governor.proposalSnapshot(secondId) + 1); + vm.prank(guardian); + governor.cancel(secondTargets, secondValues, secondCalldatas, keccak256(bytes(secondDescription))); + + assertEq(uint8(governor.state(secondId)), 2); + } + + function testNoGuardianIsConfiguredAtDeployment() public view { + assertFalse(governor.isGuardian(guardian)); + } + + function _guardianProposal(bool enabled) + private + view + returns (address[] memory targets, uint256[] memory values, bytes[] memory calldatas) + { + targets = new address[](1); + targets[0] = address(governor); + values = new uint256[](1); + calldatas = new bytes[](1); + calldatas[0] = abi.encodeCall(DeepstateGovernor.setGuardian, (guardian, enabled)); + } +} From 1bd0a2fba02f5fb57d8d138539112632dd28f453 Mon Sep 17 00:00:00 2001 From: Joseph Delong Date: Mon, 7 Sep 2026 14:52:12 -0500 Subject: [PATCH 2/5] Simplify Rewarder V3 initialization --- src/DeepstateRewarder.sol | 19 ------------------- src/DeepstateRewarderV3.sol | 4 +--- src/interfaces/IOrderBook.sol | 1 - test/DeepstateRewarderFactoryV3.t.sol | 13 ++++--------- 4 files changed, 5 insertions(+), 32 deletions(-) diff --git a/src/DeepstateRewarder.sol b/src/DeepstateRewarder.sol index d7d6474..57eb4d3 100644 --- a/src/DeepstateRewarder.sol +++ b/src/DeepstateRewarder.sol @@ -135,25 +135,6 @@ contract DeepstateRewarder is Ownable, IHook { token1QuantityLogWad = uint128(token1Log); } - /// @dev Begin a fresh schedule for top orders already resting in the active book. - /// Derived rewarders opt into this behavior by calling the initializer from their constructor. - function _initializeLiveCursors() internal { - IOrderBook orderBook = IOrderBook(deepstate); - bytes32 bookId = orderBook.activeBookId(token0, token1); - (uint32 token0Nonce, uint160 token0Amount) = orderBook.topOrder(bookId, false); - (uint32 token1Nonce, uint160 token1Amount) = orderBook.topOrder(bookId, true); - uint64 startedAt = uint64(block.timestamp); - - if (token0Nonce != 0 && token0Amount != 0) { - _token0BookId = bookId; - _token0State = _packState(token0Nonce, startedAt, startedAt, 0); - } - if (token1Nonce != 0 && token1Amount != 0) { - _token1BookId = bookId; - _token1State = _packState(token1Nonce, startedAt, startedAt, 0); - } - } - /// @notice Current top-order cursor for one side. function rewardees(address token) external view returns (uint32 orderNonce, uint64 startedAt) { (orderNonce, startedAt,,) = _unpackState(_packedState(token)); diff --git a/src/DeepstateRewarderV3.sol b/src/DeepstateRewarderV3.sol index 948268e..ed3920d 100644 --- a/src/DeepstateRewarderV3.sol +++ b/src/DeepstateRewarderV3.sol @@ -33,7 +33,5 @@ contract DeepstateRewarderV3 is DeepstateRewarder { token1StartQuantity_, token1MaxQuantity_ ) - { - _initializeLiveCursors(); - } + {} } diff --git a/src/interfaces/IOrderBook.sol b/src/interfaces/IOrderBook.sol index c1f8103..dda5499 100644 --- a/src/interfaces/IOrderBook.sol +++ b/src/interfaces/IOrderBook.sol @@ -3,7 +3,6 @@ pragma solidity 0.8.28; /// @notice Minimal engine surface used by hooks to prove an order owner. interface IOrderBook { - function activeBookId(address token0, address token1) external view returns (bytes32); function orderId(bytes32 id, bytes32 order) external pure returns (bytes32); function ownerOfOrder(bytes32 orderId) external view returns (address); function topOrder(bytes32 bookId, bool isBid) external view returns (uint32 nonce, uint160 soldAmount); diff --git a/test/DeepstateRewarderFactoryV3.t.sol b/test/DeepstateRewarderFactoryV3.t.sol index 27f838b..4000075 100644 --- a/test/DeepstateRewarderFactoryV3.t.sol +++ b/test/DeepstateRewarderFactoryV3.t.sol @@ -11,7 +11,6 @@ import {MockERC20} from "./mocks/MockERC20.sol"; contract MockRouterV3 { address public owner; mapping(bytes32 poolId => address hook) public poolHook; - bytes32 public currentBookId = keccak256("book"); constructor() { owner = msg.sender; @@ -28,10 +27,6 @@ contract MockRouterV3 { function setFeeConfig(address, uint16) external onlyOwner {} - function activeBookId(address, address) external view returns (bytes32) { - return currentBookId; - } - function topOrder(bytes32, bool isBid) external pure returns (uint32 nonce, uint160 soldAmount) { return isBid ? (uint32(11), uint160(2e18)) : (uint32(22), uint160(3e6)); } @@ -73,10 +68,10 @@ contract DeepstateRewarderFactoryV3Test is Test { assertEq(router.poolHook(poolId), address(first)); (uint32 token0Nonce, uint64 token0StartedAt) = first.rewardees(config.token0); (uint32 token1Nonce, uint64 token1StartedAt) = first.rewardees(config.token1); - assertEq(token0Nonce, 22); - assertEq(token1Nonce, 11); - assertEq(token0StartedAt, block.timestamp); - assertEq(token1StartedAt, block.timestamp); + assertEq(token0Nonce, 0); + assertEq(token1Nonce, 0); + assertEq(token0StartedAt, 0); + assertEq(token1StartedAt, 0); DeepstateRewarderV3 second = factory.deployMarket(config); assertNotEq(address(first), address(second)); From 592953594da00e8020e57a4c95e7c8b7e09c3c32 Mon Sep 17 00:00:00 2001 From: Joseph Delong Date: Mon, 7 Sep 2026 18:22:29 -0500 Subject: [PATCH 3/5] Complete DGP-003 governance and rewarder behavior --- src/DeepstateGovernor.sol | 10 +- src/DeepstateGovernorV2.sol | 16 +++ src/DeepstateRewarderFactoryV3.sol | 5 + src/DeepstateRewarderV3.sol | 10 ++ src/DeepstateToken.sol | 2 +- src/DeepstateTokenV2.sol | 85 +++++++++++++++- src/interfaces/ISablierLockupLinearV4.sol | 34 +++++++ test/DeepstateRewarderFactoryV3.t.sol | 33 +++++- test/DeepstateV2.t.sol | 116 +++++++++++++++++++++- test/mocks/MockSablierLockupLinearV4.sol | 52 ++++++++++ 10 files changed, 351 insertions(+), 12 deletions(-) create mode 100644 src/interfaces/ISablierLockupLinearV4.sol create mode 100644 test/mocks/MockSablierLockupLinearV4.sol diff --git a/src/DeepstateGovernor.sol b/src/DeepstateGovernor.sol index 45f0310..55b2dd4 100644 --- a/src/DeepstateGovernor.sol +++ b/src/DeepstateGovernor.sol @@ -113,7 +113,7 @@ contract DeepstateGovernor is _setVotingPeriod(newVotingPeriod); } - function proposalThreshold() public view override(Governor, GovernorSettings) returns (uint256) { + function proposalThreshold() public view virtual override(Governor, GovernorSettings) returns (uint256) { uint256 numerator = _proposalThresholdNumerator; uint48 currentTimepoint = clock(); if (currentTimepoint == 0) return numerator == 0 ? 0 : 1; @@ -148,7 +148,13 @@ contract DeepstateGovernor is return super.propose(targets, values, calldatas, description); } - function quorum(uint256 timepoint) public view override(Governor, GovernorVotesQuorumFraction) returns (uint256) { + function quorum(uint256 timepoint) + public + view + virtual + override(Governor, GovernorVotesQuorumFraction) + returns (uint256) + { return Math.max(super.quorum(timepoint), MINIMUM_QUORUM); } diff --git a/src/DeepstateGovernorV2.sol b/src/DeepstateGovernorV2.sol index f0b8791..6a647cc 100644 --- a/src/DeepstateGovernorV2.sol +++ b/src/DeepstateGovernorV2.sol @@ -1,7 +1,9 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.28; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {IVotes} from "@openzeppelin/contracts/governance/utils/IVotes.sol"; +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; import {DeepstateGovernor} from "./DeepstateGovernor.sol"; @@ -40,6 +42,20 @@ contract DeepstateGovernorV2 is DeepstateGovernor { emit GuardianSet(guardian, enabled); } + /// @notice The live proposal threshold is always a percentage of the current 2DEEP supply. + function proposalThreshold() public view override returns (uint256) { + uint256 supply = IERC20(address(token())).totalSupply(); + return Math.max( + Math.mulDiv(supply, proposalThresholdNumerator(), PROPOSAL_THRESHOLD_DENOMINATOR, Math.Rounding.Ceil), 1 + ); + } + + /// @notice Quorum is always a percentage of the current 2DEEP supply; `timepoint` is intentionally ignored. + function quorum(uint256) public view override returns (uint256) { + uint256 supply = IERC20(address(token())).totalSupply(); + return Math.max(Math.mulDiv(supply, quorumNumerator(), quorumDenominator()), MINIMUM_QUORUM); + } + function _validateCancel(uint256 proposalId, address caller) internal view override returns (bool) { return isGuardian[caller] || super._validateCancel(proposalId, caller); } diff --git a/src/DeepstateRewarderFactoryV3.sol b/src/DeepstateRewarderFactoryV3.sol index 8d0aaf9..20d2299 100644 --- a/src/DeepstateRewarderFactoryV3.sol +++ b/src/DeepstateRewarderFactoryV3.sol @@ -90,6 +90,11 @@ contract DeepstateRewarderFactoryV3 is Ownable { deepstate.transferOwnership(owner()); } + /// @notice Burn the full reward-token balance of a Rewarder V3 owned by this factory. + function burnBalance(address rewarder) external onlyOwner { + DeepstateRewarderV3(rewarder).burnBalance(); + } + function renounceOwnership() public payable override onlyOwner { revert NewOwnerIsZeroAddress(); } diff --git a/src/DeepstateRewarderV3.sol b/src/DeepstateRewarderV3.sol index ed3920d..64fb168 100644 --- a/src/DeepstateRewarderV3.sol +++ b/src/DeepstateRewarderV3.sol @@ -1,9 +1,13 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.28; +import {SafeTransferLib} from "solady/utils/SafeTransferLib.sol"; + import {DeepstateRewarder} from "./DeepstateRewarder.sol"; +import {IBurnableERC20} from "./interfaces/IBurnableERC20.sol"; /// @notice Rewarder that starts a fresh schedule for orders already resting in the active market. +/// @dev Retains Rewarder V2's owner-controlled balance-burning capability. contract DeepstateRewarderV3 is DeepstateRewarder { constructor( address owner_, @@ -34,4 +38,10 @@ contract DeepstateRewarderV3 is DeepstateRewarder { token1MaxQuantity_ ) {} + + /// @notice Burn this rewarder's entire reward-token balance. + function burnBalance() external onlyOwner { + uint256 amount = SafeTransferLib.balanceOf(rewardToken, address(this)); + IBurnableERC20(rewardToken).burn(amount); + } } diff --git a/src/DeepstateToken.sol b/src/DeepstateToken.sol index 76f94ea..341bef0 100644 --- a/src/DeepstateToken.sol +++ b/src/DeepstateToken.sol @@ -18,7 +18,7 @@ contract DeepstateToken is ERC20, AccessControl { _grantRole(DEFAULT_ADMIN_ROLE, admin_); } - function mint(address to, uint256 amount) external onlyRole(MINTER_ROLE) { + function mint(address to, uint256 amount) external virtual onlyRole(MINTER_ROLE) { if (to == address(0)) revert ZeroAddress(); _mint(to, amount); } diff --git a/src/DeepstateTokenV2.sol b/src/DeepstateTokenV2.sol index 8bb25e5..56563f7 100644 --- a/src/DeepstateTokenV2.sol +++ b/src/DeepstateTokenV2.sol @@ -2,30 +2,56 @@ pragma solidity ^0.8.28; import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {ERC20Votes} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Votes.sol"; import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol"; +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; +import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; import {DeepstateToken} from "./DeepstateToken.sol"; +import {ISablierLockupLinearV4} from "./interfaces/ISablierLockupLinearV4.sol"; + +/// @notice Capped, vote-enabled 2DEEP with automatic self-delegation and two-year endowment minting. +contract DeepstateTokenV2 is DeepstateToken, ERC20Votes, ReentrancyGuard { + bytes32 public constant ENDOWMENT_MINTER_ROLE = keccak256("ENDOWMENT_MINTER_ROLE"); + uint256 public constant ENDOWMENT_BPS = 30_00; + uint256 public constant PRIMARY_BPS = 70_00; + uint40 public constant ENDOWMENT_TERM = 2 * 365 days; + uint40 public constant VESTING_DURATION = 365 days; + + ISablierLockupLinearV4 public immutable sablierLockup; + address public immutable endowmentRecipient; -/// @notice Capped, vote-enabled DEEP with automatic self-delegation on first receipt. -contract DeepstateTokenV2 is DeepstateToken, ERC20Votes { uint256 public supplyCap; + uint40 public endowmentEndsAt; event SupplyCapRaised(uint256 previousCap, uint256 newCap); + event EndowmentTermStarted(uint40 endsAt); + event MintedWithEndowment( + address indexed caller, + address indexed mintRecipient, + uint256 mintAmount, + address indexed endowmentRecipient, + uint256 endowmentAmount, + uint256 streamId + ); error InvalidSupplyCap(); error SupplyCapNotRaised(uint256 currentCap, uint256 proposedCap); error SupplyCapExceeded(uint256 cap, uint256 attemptedSupply); + error EndowmentAmountTooSmall(); - constructor(address admin_, uint256 initialSupplyCap) - DeepstateToken(admin_, "Deepstate", "DEEP") - EIP712("Deepstate", "1") + constructor(address admin_, uint256 initialSupplyCap, address sablierLockup_, address endowmentRecipient_) + DeepstateToken(admin_, "Deepstate 2", "2DEEP") + EIP712("Deepstate 2", "1") { if (initialSupplyCap == 0 || initialSupplyCap > type(uint208).max) { revert InvalidSupplyCap(); } supplyCap = initialSupplyCap; + sablierLockup = ISablierLockupLinearV4(sablierLockup_); + endowmentRecipient = endowmentRecipient_; } /// @notice Governance may expand issuance but cannot reduce its prior cap commitment. @@ -38,6 +64,55 @@ contract DeepstateTokenV2 is DeepstateToken, ERC20Votes { emit SupplyCapRaised(currentCap, newCap); } + /// @notice Mint `amount` to `to`, automatically adding the Deepstate Inc endowment during its two-year term. + /// @dev ENDOWMENT_MINTER_ROLE callers start the term on their first mint and receive the automatic 30/70 treatment + /// until it expires; their later mints continue normally without an endowment. MINTER_ROLE is reserved for raw + /// migration issuance that must never create a second endowment. + function mint(address to, uint256 amount) external override nonReentrant { + if (!hasRole(ENDOWMENT_MINTER_ROLE, msg.sender)) { + _checkRole(MINTER_ROLE, msg.sender); + if (to == address(0)) revert ZeroAddress(); + _mint(to, amount); + return; + } + if (to == address(0)) revert ZeroAddress(); + + uint40 endsAt = endowmentEndsAt; + if (endsAt == 0) { + endsAt = SafeCast.toUint40(block.timestamp + ENDOWMENT_TERM); + endowmentEndsAt = endsAt; + emit EndowmentTermStarted(endsAt); + } else if (block.timestamp >= endsAt) { + _mint(to, amount); + return; + } + + uint256 endowmentAmount = Math.mulDiv(amount, ENDOWMENT_BPS, PRIMARY_BPS); + if (endowmentAmount == 0) revert EndowmentAmountTooSmall(); + uint128 streamAmount = SafeCast.toUint128(endowmentAmount); + + _mint(to, amount); + _mint(address(this), endowmentAmount); + _approve(address(this), address(sablierLockup), endowmentAmount); + + uint256 streamId = sablierLockup.createWithDurationsLL( + ISablierLockupLinearV4.CreateWithDurations({ + sender: address(this), + recipient: endowmentRecipient, + depositAmount: streamAmount, + token: IERC20(address(this)), + cancelable: false, + transferable: true, + shape: "Deepstate Inc endowment" + }), + ISablierLockupLinearV4.UnlockAmounts({start: 0, cliff: 0}), + 1 seconds, + ISablierLockupLinearV4.Durations({cliff: 0, total: VESTING_DURATION}) + ); + + emit MintedWithEndowment(msg.sender, to, amount, endowmentRecipient, endowmentAmount, streamId); + } + function clock() public view override returns (uint48) { return SafeCast.toUint48(block.timestamp); } diff --git a/src/interfaces/ISablierLockupLinearV4.sol b/src/interfaces/ISablierLockupLinearV4.sol new file mode 100644 index 0000000..48f71a3 --- /dev/null +++ b/src/interfaces/ISablierLockupLinearV4.sol @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +/// @notice Minimal ABI for creating a Sablier Lockup v4 linear stream. +interface ISablierLockupLinearV4 { + struct CreateWithDurations { + address sender; + address recipient; + uint128 depositAmount; + IERC20 token; + bool cancelable; + bool transferable; + string shape; + } + + struct UnlockAmounts { + uint128 start; + uint128 cliff; + } + + struct Durations { + uint40 cliff; + uint40 total; + } + + function createWithDurationsLL( + CreateWithDurations calldata params, + UnlockAmounts calldata unlockAmounts, + uint40 granularity, + Durations calldata durations + ) external payable returns (uint256 streamId); +} diff --git a/test/DeepstateRewarderFactoryV3.t.sol b/test/DeepstateRewarderFactoryV3.t.sol index 4000075..342146e 100644 --- a/test/DeepstateRewarderFactoryV3.t.sol +++ b/test/DeepstateRewarderFactoryV3.t.sol @@ -2,11 +2,13 @@ pragma solidity ^0.8.28; import {Test} from "forge-std/Test.sol"; +import {Ownable} from "solady/auth/Ownable.sol"; import {DeepstateTokenV2 as DeepstateToken} from "../src/DeepstateTokenV2.sol"; import {DeepstateRewarderV3} from "../src/DeepstateRewarderV3.sol"; import {DeepstateRewarderFactoryV3} from "../src/DeepstateRewarderFactoryV3.sol"; import {MockERC20} from "./mocks/MockERC20.sol"; +import {MockSablierLockupLinearV4} from "./mocks/MockSablierLockupLinearV4.sol"; contract MockRouterV3 { address public owner; @@ -44,15 +46,17 @@ contract DeepstateRewarderFactoryV3Test is Test { DeepstateRewarderFactoryV3 internal factory; MockERC20 internal tokenA; MockERC20 internal tokenB; + MockSablierLockupLinearV4 internal sablier; function setUp() public { - token = new DeepstateToken(address(this), 3_000_000_000e18); + sablier = new MockSablierLockupLinearV4(); + token = new DeepstateToken(address(this), 3_000_000_000e18, address(sablier), makeAddr("endowment")); router = new MockRouterV3(); factory = new DeepstateRewarderFactoryV3(address(this), address(router), address(token)); tokenA = new MockERC20("Token A", "A", 6); tokenB = new MockERC20("Token B", "B", 18); - token.grantRole(token.MINTER_ROLE(), address(factory)); + token.grantRole(token.ENDOWMENT_MINTER_ROLE(), address(factory)); router.transferOwnership(address(factory)); } @@ -62,6 +66,7 @@ contract DeepstateRewarderFactoryV3Test is Test { bytes32 poolId = keccak256(abi.encode(config.token0, config.token1)); assertEq(token.balanceOf(address(first)), 100_000_000e18); + assertEq(token.balanceOf(address(sablier)), 42_857_142_857_142_857_142_857_142); assertEq(first.owner(), address(factory)); assertEq(first.sideEmissionCap(), 50_000_000e18); assertEq(first.emissionDuration(), 365 days); @@ -76,7 +81,7 @@ contract DeepstateRewarderFactoryV3Test is Test { DeepstateRewarderV3 second = factory.deployMarket(config); assertNotEq(address(first), address(second)); assertEq(router.poolHook(poolId), address(second)); - assertEq(token.totalSupply(), 200_000_000e18); + assertEq(token.totalSupply(), 285_714_285_714_285_714_285_714_284); } function testUnauthorizedAddressCannotDeployMarket() public { @@ -85,6 +90,28 @@ contract DeepstateRewarderFactoryV3Test is Test { factory.deployMarket(_config()); } + function testFactoryOwnerCanBurnRewarderBalance() public { + DeepstateRewarderV3 rewarder = factory.deployMarket(_config()); + uint256 funding = token.balanceOf(address(rewarder)); + uint256 supplyBefore = token.totalSupply(); + + vm.prank(unauthorized); + vm.expectRevert(Ownable.Unauthorized.selector); + rewarder.burnBalance(); + + vm.prank(unauthorized); + vm.expectRevert(Ownable.Unauthorized.selector); + factory.burnBalance(address(rewarder)); + + factory.burnBalance(address(rewarder)); + factory.burnBalance(address(rewarder)); + + assertEq(token.balanceOf(address(rewarder)), 0); + assertEq(token.totalSupply(), supplyBefore - funding); + assertEq(token.balanceOf(address(sablier)), 42_857_142_857_142_857_142_857_142); + assertEq(rewarder.owner(), address(factory)); + } + function testOnlyGovernorCanReturnRouterOwnership() public { vm.prank(unauthorized); vm.expectRevert(); diff --git a/test/DeepstateV2.t.sol b/test/DeepstateV2.t.sol index 7d3f290..bde45f5 100644 --- a/test/DeepstateV2.t.sol +++ b/test/DeepstateV2.t.sol @@ -6,22 +6,35 @@ import {IVotes} from "@openzeppelin/contracts/governance/utils/IVotes.sol"; import {DeepstateTokenV2 as DeepstateToken} from "../src/DeepstateTokenV2.sol"; import {DeepstateGovernorV2 as DeepstateGovernor} from "../src/DeepstateGovernorV2.sol"; +import {MockSablierLockupLinearV4} from "./mocks/MockSablierLockupLinearV4.sol"; contract DeepstateTokenAndGovernorTest is Test { address internal alice = makeAddr("alice"); address internal bob = makeAddr("bob"); address internal guardian = makeAddr("guardian"); + address internal endowmentRecipient = makeAddr("endowmentRecipient"); DeepstateToken internal token; DeepstateGovernor internal governor; + MockSablierLockupLinearV4 internal sablier; function setUp() public { - token = new DeepstateToken(address(this), 3_000_000_000e18); + sablier = new MockSablierLockupLinearV4(); + token = new DeepstateToken(address(this), 3_000_000_000e18, address(sablier), endowmentRecipient); token.grantRole(token.MINTER_ROLE(), address(this)); token.mint(alice, 100e18); governor = new DeepstateGovernor(IVotes(address(token)), 0, 1 days, 1 days, 1, 10, 1 days); } + function testTokenIdentityAndEndowmentConfiguration() public view { + assertEq(token.name(), "Deepstate 2"); + assertEq(token.symbol(), "2DEEP"); + assertEq(address(token.sablierLockup()), address(sablier)); + assertEq(token.endowmentRecipient(), endowmentRecipient); + assertEq(token.ENDOWMENT_TERM(), 2 * 365 days); + assertEq(token.VESTING_DURATION(), 365 days); + } + function testRecipientsSelfDelegateOnMintAndFirstTransfer() public { assertEq(token.delegates(alice), alice); assertEq(token.getVotes(alice), 100e18); @@ -59,6 +72,20 @@ contract DeepstateTokenAndGovernorTest is Test { assertEq(token.supplyCap(), 4_000_000_000e18); } + function testGovernanceFractionsUseCurrent2DeepSupply() public { + assertEq(governor.proposalThreshold(), 1e18); + assertEq(governor.quorum(block.timestamp), 10e18); + + token.mint(bob, 100e18); + + assertEq(governor.proposalThreshold(), 2e18); + assertEq(governor.quorum(block.timestamp), 20e18); + } + + function testGovernanceIsOpenImmediately() public view { + assertEq(governor.governanceStart(), block.timestamp); + } + function testMinterCannotExceedCap() public { token.grantRole(token.MINTER_ROLE(), bob); @@ -69,6 +96,93 @@ contract DeepstateTokenAndGovernorTest is Test { token.mint(bob, 3_000_000_000e18 - 100e18 + 1e18); } + function testControlledMintAddsThirtyPercentOfCombinedIssuanceToOneYearStream() public { + token.grantRole(token.ENDOWMENT_MINTER_ROLE(), address(this)); + uint256 startedAt = block.timestamp; + + token.mint(alice, 70e18); + uint256 streamId = 1; + + assertEq(token.balanceOf(alice), 170e18); + assertEq(token.balanceOf(address(sablier)), 30e18); + assertEq(token.totalSupply(), 200e18); + assertEq(token.endowmentEndsAt(), startedAt + 2 * 365 days); + + MockSablierLockupLinearV4.Stream memory created = sablier.stream(streamId); + assertEq(created.sender, address(token)); + assertEq(created.recipient, endowmentRecipient); + assertEq(created.depositAmount, 30e18); + assertEq(address(created.token), address(token)); + assertFalse(created.cancelable); + assertTrue(created.transferable); + assertEq(created.granularity, 1 seconds); + assertEq(created.durations.cliff, 0); + assertEq(created.durations.total, 365 days); + } + + function testRawMigrationMintDoesNotCreateEndowmentOrStartTerm() public { + token.mint(bob, 10e18); + + assertEq(token.balanceOf(bob), 10e18); + assertEq(token.balanceOf(address(sablier)), 0); + assertEq(token.endowmentEndsAt(), 0); + } + + function testControlledMintContinuesWithoutEndowmentAfterTwoYears() public { + token.grantRole(token.ENDOWMENT_MINTER_ROLE(), address(this)); + token.mint(alice, 70e18); + uint40 endsAt = token.endowmentEndsAt(); + uint256 streamedBefore = token.balanceOf(address(sablier)); + + vm.warp(endsAt); + token.mint(alice, 70e18); + + assertEq(token.balanceOf(alice), 240e18); + assertEq(token.balanceOf(address(sablier)), streamedBefore); + assertEq(sablier.nextStreamId(), 2); + } + + function testFuzzControlledMintUsesThirtySeventiethsMath(uint128 rawAmount) public { + uint256 amount = bound(uint256(rawAmount), 3, 1_000_000_000e18); + token.grantRole(token.ENDOWMENT_MINTER_ROLE(), address(this)); + uint256 supplyBefore = token.totalSupply(); + + token.mint(bob, amount); + + uint256 expectedEndowment = amount * 30 / 70; + assertEq(token.balanceOf(bob), amount); + assertEq(token.balanceOf(address(sablier)), expectedEndowment); + assertEq(token.totalSupply(), supplyBefore + amount + expectedEndowment); + assertLe(expectedEndowment * 70, amount * 30); + assertLt(amount * 30 - expectedEndowment * 70, 70); + } + + function testControlledRoleAutomaticallyUsesEndowment() public { + token.grantRole(token.ENDOWMENT_MINTER_ROLE(), bob); + + vm.prank(bob); + token.mint(bob, 70e18); + + assertEq(token.balanceOf(bob), 70e18); + assertEq(token.balanceOf(address(sablier)), 30e18); + } + + function testControlledMintCapIncludesPrimaryAndEndowment() public { + token.grantRole(token.ENDOWMENT_MINTER_ROLE(), address(this)); + uint256 amount = token.supplyCap() - token.totalSupply(); + uint256 expectedEndowment = amount * 30 / 70; + + vm.expectRevert( + abi.encodeWithSelector( + DeepstateToken.SupplyCapExceeded.selector, token.supplyCap(), token.supplyCap() + expectedEndowment + ) + ); + token.mint(bob, amount); + + assertEq(token.totalSupply(), 100e18); + assertEq(token.endowmentEndsAt(), 0); + } + function testGovernanceCanAddAndRemoveGuardian() public { vm.warp(block.timestamp + 1); (address[] memory targets, uint256[] memory values, bytes[] memory calldatas) = _guardianProposal(true); diff --git a/test/mocks/MockSablierLockupLinearV4.sol b/test/mocks/MockSablierLockupLinearV4.sol new file mode 100644 index 0000000..0ea781e --- /dev/null +++ b/test/mocks/MockSablierLockupLinearV4.sol @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +import {ISablierLockupLinearV4} from "../../src/interfaces/ISablierLockupLinearV4.sol"; + +contract MockSablierLockupLinearV4 is ISablierLockupLinearV4 { + struct Stream { + address sender; + address recipient; + uint128 depositAmount; + IERC20 token; + bool cancelable; + bool transferable; + string shape; + UnlockAmounts unlockAmounts; + uint40 granularity; + Durations durations; + uint256 nativeFee; + } + + uint256 public nextStreamId = 1; + mapping(uint256 streamId => Stream stream) internal _streams; + + function createWithDurationsLL( + CreateWithDurations calldata params, + UnlockAmounts calldata unlockAmounts, + uint40 granularity, + Durations calldata durations + ) external payable returns (uint256 streamId) { + IERC20(params.token).transferFrom(msg.sender, address(this), params.depositAmount); + streamId = nextStreamId++; + _streams[streamId] = Stream({ + sender: params.sender, + recipient: params.recipient, + depositAmount: params.depositAmount, + token: params.token, + cancelable: params.cancelable, + transferable: params.transferable, + shape: params.shape, + unlockAmounts: unlockAmounts, + granularity: granularity, + durations: durations, + nativeFee: msg.value + }); + } + + function stream(uint256 streamId) external view returns (Stream memory) { + return _streams[streamId]; + } +} From e84276b0f7324177e9a77cf8fc04bdcf027ba4a4 Mon Sep 17 00:00:00 2001 From: Joseph Delong Date: Mon, 7 Sep 2026 19:04:10 -0500 Subject: [PATCH 4/5] Keep DGP-003 contracts in proposal repository --- src/DeepstateGovernorV2.sol | 62 ------ src/DeepstateRewarderFactoryV3.sol | 120 ----------- src/DeepstateRewarderV3.sol | 47 ----- src/DeepstateTokenV2.sol | 138 ------------- src/interfaces/IDeepstateV1.sol | 13 -- src/interfaces/ISablierLockupLinearV4.sol | 34 ---- test/DeepstateRewarderFactoryV3.t.sol | 138 ------------- test/DeepstateV2.t.sol | 232 ---------------------- test/mocks/MockSablierLockupLinearV4.sol | 52 ----- 9 files changed, 836 deletions(-) delete mode 100644 src/DeepstateGovernorV2.sol delete mode 100644 src/DeepstateRewarderFactoryV3.sol delete mode 100644 src/DeepstateRewarderV3.sol delete mode 100644 src/DeepstateTokenV2.sol delete mode 100644 src/interfaces/IDeepstateV1.sol delete mode 100644 src/interfaces/ISablierLockupLinearV4.sol delete mode 100644 test/DeepstateRewarderFactoryV3.t.sol delete mode 100644 test/DeepstateV2.t.sol delete mode 100644 test/mocks/MockSablierLockupLinearV4.sol diff --git a/src/DeepstateGovernorV2.sol b/src/DeepstateGovernorV2.sol deleted file mode 100644 index 6a647cc..0000000 --- a/src/DeepstateGovernorV2.sol +++ /dev/null @@ -1,62 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.28; - -import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import {IVotes} from "@openzeppelin/contracts/governance/utils/IVotes.sol"; -import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; - -import {DeepstateGovernor} from "./DeepstateGovernor.sol"; - -/// @notice Deepstate Governor extended with a governance-managed cancellation guardian set. -contract DeepstateGovernorV2 is DeepstateGovernor { - mapping(address guardian => bool enabled) public isGuardian; - - event GuardianSet(address indexed guardian, bool enabled); - - error InvalidGuardian(); - - constructor( - IVotes token_, - uint48 governanceStartDelay, - uint48 initialVotingDelay, - uint32 initialVotingPeriod, - uint256 initialProposalThresholdNumerator, - uint256 quorumNumeratorValue, - uint48 initialVoteExtension - ) - DeepstateGovernor( - token_, - governanceStartDelay, - initialVotingDelay, - initialVotingPeriod, - initialProposalThresholdNumerator, - quorumNumeratorValue, - initialVoteExtension - ) - {} - - /// @notice Add or remove an address that may cancel any unexecuted proposal. - function setGuardian(address guardian, bool enabled) external onlyGovernance { - if (guardian == address(0)) revert InvalidGuardian(); - isGuardian[guardian] = enabled; - emit GuardianSet(guardian, enabled); - } - - /// @notice The live proposal threshold is always a percentage of the current 2DEEP supply. - function proposalThreshold() public view override returns (uint256) { - uint256 supply = IERC20(address(token())).totalSupply(); - return Math.max( - Math.mulDiv(supply, proposalThresholdNumerator(), PROPOSAL_THRESHOLD_DENOMINATOR, Math.Rounding.Ceil), 1 - ); - } - - /// @notice Quorum is always a percentage of the current 2DEEP supply; `timepoint` is intentionally ignored. - function quorum(uint256) public view override returns (uint256) { - uint256 supply = IERC20(address(token())).totalSupply(); - return Math.max(Math.mulDiv(supply, quorumNumerator(), quorumDenominator()), MINIMUM_QUORUM); - } - - function _validateCancel(uint256 proposalId, address caller) internal view override returns (bool) { - return isGuardian[caller] || super._validateCancel(proposalId, caller); - } -} diff --git a/src/DeepstateRewarderFactoryV3.sol b/src/DeepstateRewarderFactoryV3.sol deleted file mode 100644 index 20d2299..0000000 --- a/src/DeepstateRewarderFactoryV3.sol +++ /dev/null @@ -1,120 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.28; - -import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; -import {SafeCastLib} from "solady/utils/SafeCastLib.sol"; -import {Ownable} from "solady/auth/Ownable.sol"; - -import {DeepstateTokenV2} from "./DeepstateTokenV2.sol"; -import {DeepstateRewarderV3} from "./DeepstateRewarderV3.sol"; -import {IDeepstateV1} from "./interfaces/IDeepstateV1.sol"; - -/// @notice Governance-owned factory for fully funded Rewarder V3 programs. -contract DeepstateRewarderFactoryV3 is Ownable { - struct MarketConfig { - address token0; - address token1; - uint256 token0MaxUnits; - uint256 token1MaxUnits; - bool token0Active; - bool token1Active; - } - - uint32 public constant EMISSION_DURATION = 365 days; - uint96 public constant SIDE_EMISSION_CAP = 50_000_000e18; - uint256 public constant MARKET_FUNDING = 100_000_000e18; - uint256 public constant MAX_QUANTITY_GROWTH = 1_000_000; - - IDeepstateV1 public immutable deepstate; - DeepstateTokenV2 public immutable rewardToken; - - event RewarderDeployed( - bytes32 indexed poolId, - address indexed rewarder, - address token0, - address token1, - bool token0Active, - bool token1Active - ); - - error InvalidOwner(); - error QuantityGrowthTooLarge(address token, uint256 maxUnits); - - constructor(address owner_, address deepstate_, address rewardToken_) { - if (owner_ == address(0)) revert InvalidOwner(); - - _initializeOwner(owner_); - deepstate = IDeepstateV1(deepstate_); - rewardToken = DeepstateTokenV2(rewardToken_); - } - - /// @notice Deploy, fund, and install a Rewarder V3 for one canonical pool. - function deployMarket(MarketConfig calldata config) external onlyOwner returns (DeepstateRewarderV3 rewarder) { - _validateQuantityGrowth(config.token0, config.token0MaxUnits); - _validateQuantityGrowth(config.token1, config.token1MaxUnits); - - (uint160 token0StartQuantity, uint160 token0MaxQuantity) = - _quantitiesForUnits(config.token0, config.token0MaxUnits); - (uint160 token1StartQuantity, uint160 token1MaxQuantity) = - _quantitiesForUnits(config.token1, config.token1MaxUnits); - bytes32 poolId = keccak256(abi.encode(config.token0, config.token1)); - - rewarder = new DeepstateRewarderV3( - address(this), - address(deepstate), - address(rewardToken), - poolId, - config.token0, - config.token1, - SIDE_EMISSION_CAP, - EMISSION_DURATION, - token0StartQuantity, - token0MaxQuantity, - token1StartQuantity, - token1MaxQuantity - ); - - rewardToken.mint(address(rewarder), MARKET_FUNDING); - deepstate.setPoolHookConfig( - config.token0, config.token1, address(rewarder), config.token0Active, config.token1Active - ); - - emit RewarderDeployed( - poolId, address(rewarder), config.token0, config.token1, config.token0Active, config.token1Active - ); - } - - /// @notice Return direct Router administration to this factory's owner. - /// @dev The factory cannot install another rewarder unless governance later transfers the Router back. - function returnDeepstateOwnership() external onlyOwner { - deepstate.transferOwnership(owner()); - } - - /// @notice Burn the full reward-token balance of a Rewarder V3 owned by this factory. - function burnBalance(address rewarder) external onlyOwner { - DeepstateRewarderV3(rewarder).burnBalance(); - } - - function renounceOwnership() public payable override onlyOwner { - revert NewOwnerIsZeroAddress(); - } - - function _quantitiesForUnits(address token, uint256 maxUnits) - private - view - returns (uint160 startQuantity, uint160 maxQuantity) - { - uint256 unit = token == address(0) ? 1e18 : 10 ** uint256(IERC20Metadata(token).decimals()); - startQuantity = SafeCastLib.toUint160(unit); - maxQuantity = SafeCastLib.toUint160(maxUnits * unit); - } - - function _validateQuantityGrowth(address token, uint256 maxUnits) private pure { - if (maxUnits > MAX_QUANTITY_GROWTH) revert QuantityGrowthTooLarge(token, maxUnits); - } - - function _setOwner(address newOwner) internal override { - if (newOwner == address(this)) revert InvalidOwner(); - super._setOwner(newOwner); - } -} diff --git a/src/DeepstateRewarderV3.sol b/src/DeepstateRewarderV3.sol deleted file mode 100644 index 64fb168..0000000 --- a/src/DeepstateRewarderV3.sol +++ /dev/null @@ -1,47 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.28; - -import {SafeTransferLib} from "solady/utils/SafeTransferLib.sol"; - -import {DeepstateRewarder} from "./DeepstateRewarder.sol"; -import {IBurnableERC20} from "./interfaces/IBurnableERC20.sol"; - -/// @notice Rewarder that starts a fresh schedule for orders already resting in the active market. -/// @dev Retains Rewarder V2's owner-controlled balance-burning capability. -contract DeepstateRewarderV3 is DeepstateRewarder { - constructor( - address owner_, - address deepstate_, - address rewardToken_, - bytes32 poolId_, - address token0_, - address token1_, - uint96 sideEmissionCap_, - uint32 emissionDuration_, - uint160 token0StartQuantity_, - uint160 token0MaxQuantity_, - uint160 token1StartQuantity_, - uint160 token1MaxQuantity_ - ) - DeepstateRewarder( - owner_, - deepstate_, - rewardToken_, - poolId_, - token0_, - token1_, - sideEmissionCap_, - emissionDuration_, - token0StartQuantity_, - token0MaxQuantity_, - token1StartQuantity_, - token1MaxQuantity_ - ) - {} - - /// @notice Burn this rewarder's entire reward-token balance. - function burnBalance() external onlyOwner { - uint256 amount = SafeTransferLib.balanceOf(rewardToken, address(this)); - IBurnableERC20(rewardToken).burn(amount); - } -} diff --git a/src/DeepstateTokenV2.sol b/src/DeepstateTokenV2.sol deleted file mode 100644 index 56563f7..0000000 --- a/src/DeepstateTokenV2.sol +++ /dev/null @@ -1,138 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.28; - -import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; -import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import {ERC20Votes} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Votes.sol"; -import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; -import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol"; -import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; -import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; - -import {DeepstateToken} from "./DeepstateToken.sol"; -import {ISablierLockupLinearV4} from "./interfaces/ISablierLockupLinearV4.sol"; - -/// @notice Capped, vote-enabled 2DEEP with automatic self-delegation and two-year endowment minting. -contract DeepstateTokenV2 is DeepstateToken, ERC20Votes, ReentrancyGuard { - bytes32 public constant ENDOWMENT_MINTER_ROLE = keccak256("ENDOWMENT_MINTER_ROLE"); - uint256 public constant ENDOWMENT_BPS = 30_00; - uint256 public constant PRIMARY_BPS = 70_00; - uint40 public constant ENDOWMENT_TERM = 2 * 365 days; - uint40 public constant VESTING_DURATION = 365 days; - - ISablierLockupLinearV4 public immutable sablierLockup; - address public immutable endowmentRecipient; - - uint256 public supplyCap; - uint40 public endowmentEndsAt; - - event SupplyCapRaised(uint256 previousCap, uint256 newCap); - event EndowmentTermStarted(uint40 endsAt); - event MintedWithEndowment( - address indexed caller, - address indexed mintRecipient, - uint256 mintAmount, - address indexed endowmentRecipient, - uint256 endowmentAmount, - uint256 streamId - ); - - error InvalidSupplyCap(); - error SupplyCapNotRaised(uint256 currentCap, uint256 proposedCap); - error SupplyCapExceeded(uint256 cap, uint256 attemptedSupply); - error EndowmentAmountTooSmall(); - - constructor(address admin_, uint256 initialSupplyCap, address sablierLockup_, address endowmentRecipient_) - DeepstateToken(admin_, "Deepstate 2", "2DEEP") - EIP712("Deepstate 2", "1") - { - if (initialSupplyCap == 0 || initialSupplyCap > type(uint208).max) { - revert InvalidSupplyCap(); - } - supplyCap = initialSupplyCap; - sablierLockup = ISablierLockupLinearV4(sablierLockup_); - endowmentRecipient = endowmentRecipient_; - } - - /// @notice Governance may expand issuance but cannot reduce its prior cap commitment. - function raiseSupplyCap(uint256 newCap) external onlyRole(DEFAULT_ADMIN_ROLE) { - uint256 currentCap = supplyCap; - if (newCap <= currentCap) revert SupplyCapNotRaised(currentCap, newCap); - if (newCap > type(uint208).max) revert InvalidSupplyCap(); - - supplyCap = newCap; - emit SupplyCapRaised(currentCap, newCap); - } - - /// @notice Mint `amount` to `to`, automatically adding the Deepstate Inc endowment during its two-year term. - /// @dev ENDOWMENT_MINTER_ROLE callers start the term on their first mint and receive the automatic 30/70 treatment - /// until it expires; their later mints continue normally without an endowment. MINTER_ROLE is reserved for raw - /// migration issuance that must never create a second endowment. - function mint(address to, uint256 amount) external override nonReentrant { - if (!hasRole(ENDOWMENT_MINTER_ROLE, msg.sender)) { - _checkRole(MINTER_ROLE, msg.sender); - if (to == address(0)) revert ZeroAddress(); - _mint(to, amount); - return; - } - if (to == address(0)) revert ZeroAddress(); - - uint40 endsAt = endowmentEndsAt; - if (endsAt == 0) { - endsAt = SafeCast.toUint40(block.timestamp + ENDOWMENT_TERM); - endowmentEndsAt = endsAt; - emit EndowmentTermStarted(endsAt); - } else if (block.timestamp >= endsAt) { - _mint(to, amount); - return; - } - - uint256 endowmentAmount = Math.mulDiv(amount, ENDOWMENT_BPS, PRIMARY_BPS); - if (endowmentAmount == 0) revert EndowmentAmountTooSmall(); - uint128 streamAmount = SafeCast.toUint128(endowmentAmount); - - _mint(to, amount); - _mint(address(this), endowmentAmount); - _approve(address(this), address(sablierLockup), endowmentAmount); - - uint256 streamId = sablierLockup.createWithDurationsLL( - ISablierLockupLinearV4.CreateWithDurations({ - sender: address(this), - recipient: endowmentRecipient, - depositAmount: streamAmount, - token: IERC20(address(this)), - cancelable: false, - transferable: true, - shape: "Deepstate Inc endowment" - }), - ISablierLockupLinearV4.UnlockAmounts({start: 0, cliff: 0}), - 1 seconds, - ISablierLockupLinearV4.Durations({cliff: 0, total: VESTING_DURATION}) - ); - - emit MintedWithEndowment(msg.sender, to, amount, endowmentRecipient, endowmentAmount, streamId); - } - - function clock() public view override returns (uint48) { - return SafeCast.toUint48(block.timestamp); - } - - // solhint-disable-next-line func-name-mixedcase - function CLOCK_MODE() public pure override returns (string memory) { - return "mode=timestamp"; - } - - function _update(address from, address to, uint256 amount) internal override(ERC20, ERC20Votes) { - if (from == address(0)) { - uint256 attemptedSupply = totalSupply() + amount; - uint256 cap = supplyCap; - if (attemptedSupply > cap) revert SupplyCapExceeded(cap, attemptedSupply); - } - - super._update(from, to, amount); - - if (to != address(0) && delegates(to) == address(0)) { - _delegate(to, to); - } - } -} diff --git a/src/interfaces/IDeepstateV1.sol b/src/interfaces/IDeepstateV1.sol deleted file mode 100644 index c91cf3e..0000000 --- a/src/interfaces/IDeepstateV1.sol +++ /dev/null @@ -1,13 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.28; - -interface IDeepstateV1 { - 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; -} diff --git a/src/interfaces/ISablierLockupLinearV4.sol b/src/interfaces/ISablierLockupLinearV4.sol deleted file mode 100644 index 48f71a3..0000000 --- a/src/interfaces/ISablierLockupLinearV4.sol +++ /dev/null @@ -1,34 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.28; - -import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; - -/// @notice Minimal ABI for creating a Sablier Lockup v4 linear stream. -interface ISablierLockupLinearV4 { - struct CreateWithDurations { - address sender; - address recipient; - uint128 depositAmount; - IERC20 token; - bool cancelable; - bool transferable; - string shape; - } - - struct UnlockAmounts { - uint128 start; - uint128 cliff; - } - - struct Durations { - uint40 cliff; - uint40 total; - } - - function createWithDurationsLL( - CreateWithDurations calldata params, - UnlockAmounts calldata unlockAmounts, - uint40 granularity, - Durations calldata durations - ) external payable returns (uint256 streamId); -} diff --git a/test/DeepstateRewarderFactoryV3.t.sol b/test/DeepstateRewarderFactoryV3.t.sol deleted file mode 100644 index 342146e..0000000 --- a/test/DeepstateRewarderFactoryV3.t.sol +++ /dev/null @@ -1,138 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.28; - -import {Test} from "forge-std/Test.sol"; -import {Ownable} from "solady/auth/Ownable.sol"; - -import {DeepstateTokenV2 as DeepstateToken} from "../src/DeepstateTokenV2.sol"; -import {DeepstateRewarderV3} from "../src/DeepstateRewarderV3.sol"; -import {DeepstateRewarderFactoryV3} from "../src/DeepstateRewarderFactoryV3.sol"; -import {MockERC20} from "./mocks/MockERC20.sol"; -import {MockSablierLockupLinearV4} from "./mocks/MockSablierLockupLinearV4.sol"; - -contract MockRouterV3 { - address public owner; - mapping(bytes32 poolId => address hook) public poolHook; - - constructor() { - owner = msg.sender; - } - - modifier onlyOwner() { - require(msg.sender == owner, "not owner"); - _; - } - - function setPoolHookConfig(address token0, address token1, address hook, bool, bool) external onlyOwner { - poolHook[keccak256(abi.encode(token0, token1))] = hook; - } - - function setFeeConfig(address, uint16) external onlyOwner {} - - function topOrder(bytes32, bool isBid) external pure returns (uint32 nonce, uint160 soldAmount) { - return isBid ? (uint32(11), uint160(2e18)) : (uint32(22), uint160(3e6)); - } - - function transferOwnership(address newOwner) external onlyOwner { - owner = newOwner; - } -} - -contract DeepstateRewarderFactoryV3Test is Test { - address internal unauthorized = makeAddr("unauthorized"); - - DeepstateToken internal token; - MockRouterV3 internal router; - DeepstateRewarderFactoryV3 internal factory; - MockERC20 internal tokenA; - MockERC20 internal tokenB; - MockSablierLockupLinearV4 internal sablier; - - function setUp() public { - sablier = new MockSablierLockupLinearV4(); - token = new DeepstateToken(address(this), 3_000_000_000e18, address(sablier), makeAddr("endowment")); - router = new MockRouterV3(); - factory = new DeepstateRewarderFactoryV3(address(this), address(router), address(token)); - tokenA = new MockERC20("Token A", "A", 6); - tokenB = new MockERC20("Token B", "B", 18); - - token.grantRole(token.ENDOWMENT_MINTER_ROLE(), address(factory)); - router.transferOwnership(address(factory)); - } - - function testGovernanceDeploysFullyFundedRewarderAndCanReplaceIt() public { - DeepstateRewarderFactoryV3.MarketConfig memory config = _config(); - DeepstateRewarderV3 first = factory.deployMarket(config); - bytes32 poolId = keccak256(abi.encode(config.token0, config.token1)); - - assertEq(token.balanceOf(address(first)), 100_000_000e18); - assertEq(token.balanceOf(address(sablier)), 42_857_142_857_142_857_142_857_142); - assertEq(first.owner(), address(factory)); - assertEq(first.sideEmissionCap(), 50_000_000e18); - assertEq(first.emissionDuration(), 365 days); - assertEq(router.poolHook(poolId), address(first)); - (uint32 token0Nonce, uint64 token0StartedAt) = first.rewardees(config.token0); - (uint32 token1Nonce, uint64 token1StartedAt) = first.rewardees(config.token1); - assertEq(token0Nonce, 0); - assertEq(token1Nonce, 0); - assertEq(token0StartedAt, 0); - assertEq(token1StartedAt, 0); - - DeepstateRewarderV3 second = factory.deployMarket(config); - assertNotEq(address(first), address(second)); - assertEq(router.poolHook(poolId), address(second)); - assertEq(token.totalSupply(), 285_714_285_714_285_714_285_714_284); - } - - function testUnauthorizedAddressCannotDeployMarket() public { - vm.prank(unauthorized); - vm.expectRevert(); - factory.deployMarket(_config()); - } - - function testFactoryOwnerCanBurnRewarderBalance() public { - DeepstateRewarderV3 rewarder = factory.deployMarket(_config()); - uint256 funding = token.balanceOf(address(rewarder)); - uint256 supplyBefore = token.totalSupply(); - - vm.prank(unauthorized); - vm.expectRevert(Ownable.Unauthorized.selector); - rewarder.burnBalance(); - - vm.prank(unauthorized); - vm.expectRevert(Ownable.Unauthorized.selector); - factory.burnBalance(address(rewarder)); - - factory.burnBalance(address(rewarder)); - factory.burnBalance(address(rewarder)); - - assertEq(token.balanceOf(address(rewarder)), 0); - assertEq(token.totalSupply(), supplyBefore - funding); - assertEq(token.balanceOf(address(sablier)), 42_857_142_857_142_857_142_857_142); - assertEq(rewarder.owner(), address(factory)); - } - - function testOnlyGovernorCanReturnRouterOwnership() public { - vm.prank(unauthorized); - vm.expectRevert(); - factory.returnDeepstateOwnership(); - - factory.returnDeepstateOwnership(); - assertEq(router.owner(), address(this)); - } - - function _config() private view returns (DeepstateRewarderFactoryV3.MarketConfig memory config) { - (address token0, address token1) = - address(tokenA) < address(tokenB) ? (address(tokenA), address(tokenB)) : (address(tokenB), address(tokenA)); - bool tokenAIsToken0 = token0 == address(tokenA); - - config = DeepstateRewarderFactoryV3.MarketConfig({ - token0: token0, - token1: token1, - token0MaxUnits: tokenAIsToken0 ? 1_000_000 : 5_000, - token1MaxUnits: tokenAIsToken0 ? 5_000 : 1_000_000, - token0Active: true, - token1Active: true - }); - } -} diff --git a/test/DeepstateV2.t.sol b/test/DeepstateV2.t.sol deleted file mode 100644 index bde45f5..0000000 --- a/test/DeepstateV2.t.sol +++ /dev/null @@ -1,232 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.28; - -import {Test} from "forge-std/Test.sol"; -import {IVotes} from "@openzeppelin/contracts/governance/utils/IVotes.sol"; - -import {DeepstateTokenV2 as DeepstateToken} from "../src/DeepstateTokenV2.sol"; -import {DeepstateGovernorV2 as DeepstateGovernor} from "../src/DeepstateGovernorV2.sol"; -import {MockSablierLockupLinearV4} from "./mocks/MockSablierLockupLinearV4.sol"; - -contract DeepstateTokenAndGovernorTest is Test { - address internal alice = makeAddr("alice"); - address internal bob = makeAddr("bob"); - address internal guardian = makeAddr("guardian"); - address internal endowmentRecipient = makeAddr("endowmentRecipient"); - - DeepstateToken internal token; - DeepstateGovernor internal governor; - MockSablierLockupLinearV4 internal sablier; - - function setUp() public { - sablier = new MockSablierLockupLinearV4(); - token = new DeepstateToken(address(this), 3_000_000_000e18, address(sablier), endowmentRecipient); - token.grantRole(token.MINTER_ROLE(), address(this)); - token.mint(alice, 100e18); - governor = new DeepstateGovernor(IVotes(address(token)), 0, 1 days, 1 days, 1, 10, 1 days); - } - - function testTokenIdentityAndEndowmentConfiguration() public view { - assertEq(token.name(), "Deepstate 2"); - assertEq(token.symbol(), "2DEEP"); - assertEq(address(token.sablierLockup()), address(sablier)); - assertEq(token.endowmentRecipient(), endowmentRecipient); - assertEq(token.ENDOWMENT_TERM(), 2 * 365 days); - assertEq(token.VESTING_DURATION(), 365 days); - } - - function testRecipientsSelfDelegateOnMintAndFirstTransfer() public { - assertEq(token.delegates(alice), alice); - assertEq(token.getVotes(alice), 100e18); - - vm.prank(alice); - token.transfer(bob, 25e18); - - assertEq(token.delegates(bob), bob); - assertEq(token.getVotes(alice), 75e18); - assertEq(token.getVotes(bob), 25e18); - } - - function testExplicitDelegationIsPreservedAcrossReceipts() public { - vm.prank(alice); - token.delegate(bob); - - token.mint(alice, 10e18); - - assertEq(token.delegates(alice), bob); - assertEq(token.getVotes(alice), 0); - assertEq(token.getVotes(bob), 110e18); - } - - function testOnlyAdminCanRaiseCapAndCapCannotFall() public { - vm.prank(alice); - vm.expectRevert(); - token.raiseSupplyCap(4_000_000_000e18); - - vm.expectRevert( - abi.encodeWithSelector(DeepstateToken.SupplyCapNotRaised.selector, 3_000_000_000e18, 3_000_000_000e18) - ); - token.raiseSupplyCap(3_000_000_000e18); - - token.raiseSupplyCap(4_000_000_000e18); - assertEq(token.supplyCap(), 4_000_000_000e18); - } - - function testGovernanceFractionsUseCurrent2DeepSupply() public { - assertEq(governor.proposalThreshold(), 1e18); - assertEq(governor.quorum(block.timestamp), 10e18); - - token.mint(bob, 100e18); - - assertEq(governor.proposalThreshold(), 2e18); - assertEq(governor.quorum(block.timestamp), 20e18); - } - - function testGovernanceIsOpenImmediately() public view { - assertEq(governor.governanceStart(), block.timestamp); - } - - function testMinterCannotExceedCap() public { - token.grantRole(token.MINTER_ROLE(), bob); - - vm.prank(bob); - vm.expectRevert( - abi.encodeWithSelector(DeepstateToken.SupplyCapExceeded.selector, 3_000_000_000e18, 3_000_000_001e18) - ); - token.mint(bob, 3_000_000_000e18 - 100e18 + 1e18); - } - - function testControlledMintAddsThirtyPercentOfCombinedIssuanceToOneYearStream() public { - token.grantRole(token.ENDOWMENT_MINTER_ROLE(), address(this)); - uint256 startedAt = block.timestamp; - - token.mint(alice, 70e18); - uint256 streamId = 1; - - assertEq(token.balanceOf(alice), 170e18); - assertEq(token.balanceOf(address(sablier)), 30e18); - assertEq(token.totalSupply(), 200e18); - assertEq(token.endowmentEndsAt(), startedAt + 2 * 365 days); - - MockSablierLockupLinearV4.Stream memory created = sablier.stream(streamId); - assertEq(created.sender, address(token)); - assertEq(created.recipient, endowmentRecipient); - assertEq(created.depositAmount, 30e18); - assertEq(address(created.token), address(token)); - assertFalse(created.cancelable); - assertTrue(created.transferable); - assertEq(created.granularity, 1 seconds); - assertEq(created.durations.cliff, 0); - assertEq(created.durations.total, 365 days); - } - - function testRawMigrationMintDoesNotCreateEndowmentOrStartTerm() public { - token.mint(bob, 10e18); - - assertEq(token.balanceOf(bob), 10e18); - assertEq(token.balanceOf(address(sablier)), 0); - assertEq(token.endowmentEndsAt(), 0); - } - - function testControlledMintContinuesWithoutEndowmentAfterTwoYears() public { - token.grantRole(token.ENDOWMENT_MINTER_ROLE(), address(this)); - token.mint(alice, 70e18); - uint40 endsAt = token.endowmentEndsAt(); - uint256 streamedBefore = token.balanceOf(address(sablier)); - - vm.warp(endsAt); - token.mint(alice, 70e18); - - assertEq(token.balanceOf(alice), 240e18); - assertEq(token.balanceOf(address(sablier)), streamedBefore); - assertEq(sablier.nextStreamId(), 2); - } - - function testFuzzControlledMintUsesThirtySeventiethsMath(uint128 rawAmount) public { - uint256 amount = bound(uint256(rawAmount), 3, 1_000_000_000e18); - token.grantRole(token.ENDOWMENT_MINTER_ROLE(), address(this)); - uint256 supplyBefore = token.totalSupply(); - - token.mint(bob, amount); - - uint256 expectedEndowment = amount * 30 / 70; - assertEq(token.balanceOf(bob), amount); - assertEq(token.balanceOf(address(sablier)), expectedEndowment); - assertEq(token.totalSupply(), supplyBefore + amount + expectedEndowment); - assertLe(expectedEndowment * 70, amount * 30); - assertLt(amount * 30 - expectedEndowment * 70, 70); - } - - function testControlledRoleAutomaticallyUsesEndowment() public { - token.grantRole(token.ENDOWMENT_MINTER_ROLE(), bob); - - vm.prank(bob); - token.mint(bob, 70e18); - - assertEq(token.balanceOf(bob), 70e18); - assertEq(token.balanceOf(address(sablier)), 30e18); - } - - function testControlledMintCapIncludesPrimaryAndEndowment() public { - token.grantRole(token.ENDOWMENT_MINTER_ROLE(), address(this)); - uint256 amount = token.supplyCap() - token.totalSupply(); - uint256 expectedEndowment = amount * 30 / 70; - - vm.expectRevert( - abi.encodeWithSelector( - DeepstateToken.SupplyCapExceeded.selector, token.supplyCap(), token.supplyCap() + expectedEndowment - ) - ); - token.mint(bob, amount); - - assertEq(token.totalSupply(), 100e18); - assertEq(token.endowmentEndsAt(), 0); - } - - function testGovernanceCanAddAndRemoveGuardian() public { - vm.warp(block.timestamp + 1); - (address[] memory targets, uint256[] memory values, bytes[] memory calldatas) = _guardianProposal(true); - string memory description = "Add guardian"; - - vm.prank(alice); - uint256 proposalId = governor.propose(targets, values, calldatas, description); - vm.warp(governor.proposalSnapshot(proposalId) + 1); - vm.prank(alice); - governor.castVote(proposalId, 1); - vm.warp(governor.proposalDeadline(proposalId) + 1); - governor.execute(targets, values, calldatas, keccak256(bytes(description))); - - assertTrue(governor.isGuardian(guardian)); - - address[] memory secondTargets = new address[](1); - secondTargets[0] = bob; - uint256[] memory secondValues = new uint256[](1); - bytes[] memory secondCalldatas = new bytes[](1); - secondCalldatas[0] = ""; - string memory secondDescription = "Cancelable proposal"; - - vm.prank(alice); - uint256 secondId = governor.propose(secondTargets, secondValues, secondCalldatas, secondDescription); - vm.warp(governor.proposalSnapshot(secondId) + 1); - vm.prank(guardian); - governor.cancel(secondTargets, secondValues, secondCalldatas, keccak256(bytes(secondDescription))); - - assertEq(uint8(governor.state(secondId)), 2); - } - - function testNoGuardianIsConfiguredAtDeployment() public view { - assertFalse(governor.isGuardian(guardian)); - } - - function _guardianProposal(bool enabled) - private - view - returns (address[] memory targets, uint256[] memory values, bytes[] memory calldatas) - { - targets = new address[](1); - targets[0] = address(governor); - values = new uint256[](1); - calldatas = new bytes[](1); - calldatas[0] = abi.encodeCall(DeepstateGovernor.setGuardian, (guardian, enabled)); - } -} diff --git a/test/mocks/MockSablierLockupLinearV4.sol b/test/mocks/MockSablierLockupLinearV4.sol deleted file mode 100644 index 0ea781e..0000000 --- a/test/mocks/MockSablierLockupLinearV4.sol +++ /dev/null @@ -1,52 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.28; - -import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; - -import {ISablierLockupLinearV4} from "../../src/interfaces/ISablierLockupLinearV4.sol"; - -contract MockSablierLockupLinearV4 is ISablierLockupLinearV4 { - struct Stream { - address sender; - address recipient; - uint128 depositAmount; - IERC20 token; - bool cancelable; - bool transferable; - string shape; - UnlockAmounts unlockAmounts; - uint40 granularity; - Durations durations; - uint256 nativeFee; - } - - uint256 public nextStreamId = 1; - mapping(uint256 streamId => Stream stream) internal _streams; - - function createWithDurationsLL( - CreateWithDurations calldata params, - UnlockAmounts calldata unlockAmounts, - uint40 granularity, - Durations calldata durations - ) external payable returns (uint256 streamId) { - IERC20(params.token).transferFrom(msg.sender, address(this), params.depositAmount); - streamId = nextStreamId++; - _streams[streamId] = Stream({ - sender: params.sender, - recipient: params.recipient, - depositAmount: params.depositAmount, - token: params.token, - cancelable: params.cancelable, - transferable: params.transferable, - shape: params.shape, - unlockAmounts: unlockAmounts, - granularity: granularity, - durations: durations, - nativeFee: msg.value - }); - } - - function stream(uint256 streamId) external view returns (Stream memory) { - return _streams[streamId]; - } -} From 9a29857f1a335cb365810d5506593e0cb4d39c10 Mon Sep 17 00:00:00 2001 From: Joseph Delong Date: Wed, 9 Sep 2026 10:07:42 -0500 Subject: [PATCH 5/5] Add rewarder accrual freeze primitive --- src/DeepstateRewarder.sol | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/DeepstateRewarder.sol b/src/DeepstateRewarder.sol index 57eb4d3..0032352 100644 --- a/src/DeepstateRewarder.sol +++ b/src/DeepstateRewarder.sol @@ -387,6 +387,44 @@ contract DeepstateRewarder is Ownable, IHook { balances[bookId][token][nonce] = 0; } + /// @dev Checkpoint one live rewardee through the current timestamp, then permanently forget its cursor unless the + /// engine later installs another one. Derived rewarders call this only after the Router has stopped using them. + function _freezeRewardee(address token) + internal + returns (bytes32 bookId, uint32 orderNonce, uint256 checkpointedReward) + { + bool isToken0 = token == token0; + if (!isToken0 && token != token1) revert InvalidHookToken(); + + uint256 packed = isToken0 ? _token0State : _token1State; + uint64 topStartedAt; + uint64 activatedAt; + uint96 accrued; + (orderNonce, topStartedAt, activatedAt, accrued) = _unpackState(packed); + bookId = isToken0 ? _token0BookId : _token1BookId; + + if (orderNonce != 0 && topStartedAt != 0 && activatedAt != 0 && block.timestamp > topStartedAt) { + (uint32 liveNonce, uint160 liveAmount) = IOrderBook(deepstate).topOrder(bookId, !isToken0); + if (liveNonce == orderNonce && liveAmount != 0) { + checkpointedReward = previewReward(token, topStartedAt, block.timestamp, liveAmount); + checkpointedReward = _remainingReward(accrued, checkpointedReward); + if (checkpointedReward != 0) { + balances[bookId][token][orderNonce] += checkpointedReward; + accrued += uint96(checkpointedReward); + } + } + } + + uint256 frozenState = _packState(0, 0, activatedAt, accrued); + if (isToken0) { + _token0State = frozenState; + _token0BookId = bytes32(0); + } else { + _token1State = frozenState; + _token1BookId = bytes32(0); + } + } + function _resolveClaimant(bytes32 bookId, bytes32 order) private returns (address claimant) { IOrderBook orderBook = IOrderBook(deepstate); bytes32 id = orderBook.orderId(bookId, order);