From dfead0f46fbde8bfb4d41a4aba064cf83478fe4f Mon Sep 17 00:00:00 2001 From: Joseph Delong Date: Fri, 14 Aug 2026 19:31:36 -0500 Subject: [PATCH 01/14] fix: align Deepstate dependency metadata (V12 #227534 #227535) --- foundry.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/foundry.lock b/foundry.lock index 6ea663c..aea0947 100644 --- a/foundry.lock +++ b/foundry.lock @@ -1,6 +1,6 @@ { "lib/deepstate-contracts": { - "rev": "56c7434aceb437b17cf5814fe6e4e30d26028dfd" + "rev": "da8a976fc805dd153303ced3037137d8fa03d9eb" }, "lib/forge-std": { "tag": { From d6df4bad0e44d7ec45021c5826a48ffe2c837da3 Mon Sep 17 00:00:00 2001 From: Joseph Delong Date: Fri, 14 Aug 2026 19:33:51 -0500 Subject: [PATCH 02/14] fix: preserve final DEEP admin (V12 #227408) --- src/DeepstateToken.sol | 27 ++++++++++++++++ test/DeepstateToken.t.sol | 65 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/src/DeepstateToken.sol b/src/DeepstateToken.sol index c669d28..76f94ea 100644 --- a/src/DeepstateToken.sol +++ b/src/DeepstateToken.sol @@ -8,7 +8,10 @@ import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; contract DeepstateToken is ERC20, AccessControl { bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE"); + uint256 private _defaultAdminCount; + error ZeroAddress(); + error LastDefaultAdmin(); constructor(address admin_, string memory name_, string memory symbol_) ERC20(name_, symbol_) { if (admin_ == address(0)) revert ZeroAddress(); @@ -23,4 +26,28 @@ contract DeepstateToken is ERC20, AccessControl { function burn(uint256 amount) external { _burn(msg.sender, amount); } + + function defaultAdminCount() external view returns (uint256) { + return _defaultAdminCount; + } + + function _grantRole(bytes32 role, address account) internal override returns (bool) { + if (role == DEFAULT_ADMIN_ROLE && account == address(0)) revert ZeroAddress(); + + bool granted = super._grantRole(role, account); + if (granted && role == DEFAULT_ADMIN_ROLE) ++_defaultAdminCount; + return granted; + } + + function _revokeRole(bytes32 role, address account) internal override returns (bool) { + if (role == DEFAULT_ADMIN_ROLE && hasRole(role, account)) { + if (_defaultAdminCount == 1) revert LastDefaultAdmin(); + + bool revoked = super._revokeRole(role, account); + if (revoked) --_defaultAdminCount; + return revoked; + } + + return super._revokeRole(role, account); + } } diff --git a/test/DeepstateToken.t.sol b/test/DeepstateToken.t.sol index 22f87b2..a804725 100644 --- a/test/DeepstateToken.t.sol +++ b/test/DeepstateToken.t.sol @@ -23,6 +23,7 @@ contract DeepstateTokenTest is Test { assertEq(deepstate.symbol(), "DEEP"); assertEq(deepstate.decimals(), 18); assertTrue(deepstate.hasRole(deepstate.DEFAULT_ADMIN_ROLE(), owner)); + assertEq(deepstate.defaultAdminCount(), 1); assertEq(deepstate.getRoleAdmin(deepstate.MINTER_ROLE()), deepstate.DEFAULT_ADMIN_ROLE()); assertEq(deepstate.totalSupply(), 0); } @@ -51,6 +52,70 @@ contract DeepstateTokenTest is Test { assertFalse(deepstate.hasRole(minterRole, minter)); } + function testCannotRevokeFinalDefaultAdmin() public { + bytes32 adminRole = deepstate.DEFAULT_ADMIN_ROLE(); + + vm.prank(owner); + vm.expectRevert(DeepstateToken.LastDefaultAdmin.selector); + deepstate.revokeRole(adminRole, owner); + + assertTrue(deepstate.hasRole(adminRole, owner)); + assertEq(deepstate.defaultAdminCount(), 1); + } + + function testCannotRenounceFinalDefaultAdmin() public { + bytes32 adminRole = deepstate.DEFAULT_ADMIN_ROLE(); + + vm.prank(owner); + vm.expectRevert(DeepstateToken.LastDefaultAdmin.selector); + deepstate.renounceRole(adminRole, owner); + + assertTrue(deepstate.hasRole(adminRole, owner)); + assertEq(deepstate.defaultAdminCount(), 1); + } + + function testDefaultAdminCanBeTransferredWithoutLosingRoleAdministration() public { + bytes32 adminRole = deepstate.DEFAULT_ADMIN_ROLE(); + bytes32 minterRole = deepstate.MINTER_ROLE(); + + vm.startPrank(owner); + deepstate.grantRole(adminRole, alice); + assertEq(deepstate.defaultAdminCount(), 2); + deepstate.renounceRole(adminRole, owner); + vm.stopPrank(); + + assertFalse(deepstate.hasRole(adminRole, owner)); + assertTrue(deepstate.hasRole(adminRole, alice)); + assertEq(deepstate.defaultAdminCount(), 1); + + vm.prank(alice); + deepstate.grantRole(minterRole, minter); + assertTrue(deepstate.hasRole(minterRole, minter)); + } + + function testAdminCanRevokeAnotherAdminWhileOneRemains() public { + bytes32 adminRole = deepstate.DEFAULT_ADMIN_ROLE(); + + vm.startPrank(owner); + deepstate.grantRole(adminRole, alice); + deepstate.revokeRole(adminRole, alice); + vm.stopPrank(); + + assertTrue(deepstate.hasRole(adminRole, owner)); + assertFalse(deepstate.hasRole(adminRole, alice)); + assertEq(deepstate.defaultAdminCount(), 1); + } + + function testCannotUseZeroAddressAsReplacementDefaultAdmin() public { + bytes32 adminRole = deepstate.DEFAULT_ADMIN_ROLE(); + + vm.prank(owner); + vm.expectRevert(DeepstateToken.ZeroAddress.selector); + deepstate.grantRole(adminRole, address(0)); + + assertEq(deepstate.defaultAdminCount(), 1); + } + function testMultipleMintersCanMintAndRevocationStopsMinting() public { bytes32 minterRole = deepstate.MINTER_ROLE(); From 34df4afa23763d9c68df5f8857fc310d3a393557 Mon Sep 17 00:00:00 2001 From: Joseph Delong Date: Fri, 14 Aug 2026 19:35:01 -0500 Subject: [PATCH 03/14] fix: guard zero-supply proposal admission (V12 #227513) --- src/DeepstateGovernor.sol | 15 +++++++-------- test/DeepstateGovernance.t.sol | 26 ++++++++++++++++++++++++-- 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/src/DeepstateGovernor.sol b/src/DeepstateGovernor.sol index 5f57af1..0e6eb03 100644 --- a/src/DeepstateGovernor.sol +++ b/src/DeepstateGovernor.sol @@ -83,15 +83,14 @@ contract DeepstateGovernor is } function proposalThreshold() public view override(Governor, GovernorSettings) returns (uint256) { + uint256 numerator = _proposalThresholdNumerator; uint48 currentTimepoint = clock(); - if (currentTimepoint == 0) return 0; - - return Math.mulDiv( - token().getPastTotalSupply(currentTimepoint - 1), - _proposalThresholdNumerator, - PROPOSAL_THRESHOLD_DENOMINATOR, - Math.Rounding.Ceil - ); + if (currentTimepoint == 0) return numerator == 0 ? 0 : 1; + + uint256 pastTotalSupply = token().getPastTotalSupply(currentTimepoint - 1); + if (pastTotalSupply == 0 && numerator != 0) return 1; + + return Math.mulDiv(pastTotalSupply, numerator, PROPOSAL_THRESHOLD_DENOMINATOR, Math.Rounding.Ceil); } function proposalThresholdNumerator() public view returns (uint256) { diff --git a/test/DeepstateGovernance.t.sol b/test/DeepstateGovernance.t.sol index a34e82a..97cebbf 100644 --- a/test/DeepstateGovernance.t.sol +++ b/test/DeepstateGovernance.t.sol @@ -148,9 +148,31 @@ contract DeepstateGovernanceTest is Test { assertEq(governor.quorum(snapshot), 10e18); } - function testProposalThresholdIsZeroAtClockOrigin() public { + function testProposalThresholdIsOneAtClockOrigin() public { vm.warp(0); - assertEq(governor.proposalThreshold(), 0); + assertEq(governor.proposalThreshold(), 1); + } + + function testZeroSupplyEpochStillRequiresOnePastVoteToPropose() public { + _startGovernance(); + + valueToken.mint(address(vault), 1e6); + uint256 shares = vault.balanceOf(alice); + vm.prank(alice); + vault.redeemValue(shares, alice, alice); + vm.warp(vm.getBlockTimestamp() + 1); + + assertEq(vault.totalSupply(), 0); + assertEq(governor.proposalThreshold(), 1); + + (address[] memory targets, uint256[] memory values, bytes[] memory calldatas, string memory description) = + _proposal(address(vault), abi.encodeCall(Ownable.transferOwnership, (newVaultOwner)), "zero supply"); + + vm.prank(bob); + vm.expectRevert( + abi.encodeWithSelector(IGovernor.GovernorInsufficientProposerVotes.selector, bob, uint256(0), uint256(1)) + ); + governor.propose(targets, values, calldatas, description); } function testProposalCreationIsBlockedUntilExactGovernanceStart() public { From 98ce36e07f9b9b8f3051a03bfefb52acf1773846 Mon Sep 17 00:00:00 2001 From: Joseph Delong Date: Fri, 14 Aug 2026 19:36:01 -0500 Subject: [PATCH 04/14] fix: cap governance voting delay (V12 #227514) --- src/DeepstateGovernor.sol | 13 ++++++++++++ test/DeepstateGovernance.t.sol | 39 ++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/src/DeepstateGovernor.sol b/src/DeepstateGovernor.sol index 0e6eb03..fd46a57 100644 --- a/src/DeepstateGovernor.sol +++ b/src/DeepstateGovernor.sol @@ -25,6 +25,7 @@ contract DeepstateGovernor is { bytes32 private constant _TIMESTAMP_MODE_HASH = keccak256("mode=timestamp"); uint256 public constant PROPOSAL_THRESHOLD_DENOMINATOR = 100; + uint48 public constant MAX_VOTING_DELAY = 30 days; uint48 public immutable governanceStart; @@ -34,6 +35,7 @@ contract DeepstateGovernor is error TimestampClockRequired(); error GovernanceNotStarted(uint48 currentTimepoint, uint48 governanceStart); + error VotingDelayAboveMaximum(uint48 votingDelay, uint48 maximum); error InvalidProposalThresholdFraction(uint256 numerator, uint256 denominator); error AbsoluteProposalThresholdUnsupported(); @@ -55,6 +57,7 @@ contract DeepstateGovernor is if (!_usesTimestampClock(IERC5805(address(stateToken)))) { revert TimestampClockRequired(); } + _validateVotingDelayMaximum(initialVotingDelay); governanceStart = SafeCast.toUint48(block.timestamp + governanceStartDelay); _updateProposalThresholdNumerator(initialProposalThresholdNumerator); @@ -78,6 +81,11 @@ contract DeepstateGovernor is return super.votingDelay(); } + function setVotingDelay(uint48 newVotingDelay) public override onlyGovernance { + _validateVotingDelayMaximum(newVotingDelay); + _setVotingDelay(newVotingDelay); + } + function votingPeriod() public view override(Governor, GovernorSettings) returns (uint256) { return super.votingPeriod(); } @@ -134,6 +142,11 @@ contract DeepstateGovernor is super._tallyUpdated(proposalId); } + function _validateVotingDelayMaximum(uint48 votingDelay_) private pure { + uint48 maximum = MAX_VOTING_DELAY; + if (votingDelay_ > maximum) revert VotingDelayAboveMaximum(votingDelay_, maximum); + } + function _updateProposalThresholdNumerator(uint256 newNumerator) internal { uint256 denominator = PROPOSAL_THRESHOLD_DENOMINATOR; if (newNumerator > denominator) { diff --git a/test/DeepstateGovernance.t.sol b/test/DeepstateGovernance.t.sol index 97cebbf..1873627 100644 --- a/test/DeepstateGovernance.t.sol +++ b/test/DeepstateGovernance.t.sol @@ -148,6 +148,45 @@ contract DeepstateGovernanceTest is Test { assertEq(governor.quorum(snapshot), 10e18); } + function testVotingDelayMaximumAppliesAtDeploymentAndGovernanceUpdates() public { + uint48 maximum = governor.MAX_VOTING_DELAY(); + DeepstateGovernor boundaryGovernor = new DeepstateGovernor( + IVotes(address(vault)), + GOVERNANCE_START_DELAY, + maximum, + VOTING_PERIOD, + PROPOSAL_THRESHOLD_NUMERATOR, + QUORUM_NUMERATOR, + VOTE_EXTENSION + ); + assertEq(boundaryGovernor.votingDelay(), maximum); + + uint48 invalidDelay = maximum + 1; + vm.expectRevert( + abi.encodeWithSelector(DeepstateGovernor.VotingDelayAboveMaximum.selector, invalidDelay, maximum) + ); + new DeepstateGovernor( + IVotes(address(vault)), + GOVERNANCE_START_DELAY, + invalidDelay, + VOTING_PERIOD, + PROPOSAL_THRESHOLD_NUMERATOR, + QUORUM_NUMERATOR, + VOTE_EXTENSION + ); + + vm.prank(address(governor)); + governor.setVotingDelay(maximum); + assertEq(governor.votingDelay(), maximum); + + vm.prank(address(governor)); + vm.expectRevert( + abi.encodeWithSelector(DeepstateGovernor.VotingDelayAboveMaximum.selector, invalidDelay, maximum) + ); + governor.setVotingDelay(invalidDelay); + assertEq(governor.votingDelay(), maximum); + } + function testProposalThresholdIsOneAtClockOrigin() public { vm.warp(0); assertEq(governor.proposalThreshold(), 1); From f0c72c3e2a91877f3ca7c153881502030464aba1 Mon Sep 17 00:00:00 2001 From: Joseph Delong Date: Fri, 14 Aug 2026 19:36:45 -0500 Subject: [PATCH 05/14] fix: cap late-quorum vote extension (V12 #227515) --- src/DeepstateGovernor.sol | 13 ++++++++++ test/DeepstateGovernance.t.sol | 43 ++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/src/DeepstateGovernor.sol b/src/DeepstateGovernor.sol index fd46a57..ee04aff 100644 --- a/src/DeepstateGovernor.sol +++ b/src/DeepstateGovernor.sol @@ -26,6 +26,7 @@ contract DeepstateGovernor is bytes32 private constant _TIMESTAMP_MODE_HASH = keccak256("mode=timestamp"); uint256 public constant PROPOSAL_THRESHOLD_DENOMINATOR = 100; uint48 public constant MAX_VOTING_DELAY = 30 days; + uint48 public constant MAX_LATE_QUORUM_VOTE_EXTENSION = 7 days; uint48 public immutable governanceStart; @@ -36,6 +37,7 @@ contract DeepstateGovernor is error TimestampClockRequired(); error GovernanceNotStarted(uint48 currentTimepoint, uint48 governanceStart); error VotingDelayAboveMaximum(uint48 votingDelay, uint48 maximum); + error LateQuorumVoteExtensionAboveMaximum(uint48 voteExtension, uint48 maximum); error InvalidProposalThresholdFraction(uint256 numerator, uint256 denominator); error AbsoluteProposalThresholdUnsupported(); @@ -58,6 +60,7 @@ contract DeepstateGovernor is revert TimestampClockRequired(); } _validateVotingDelayMaximum(initialVotingDelay); + _validateLateQuorumVoteExtension(initialVoteExtension); governanceStart = SafeCast.toUint48(block.timestamp + governanceStartDelay); _updateProposalThresholdNumerator(initialProposalThresholdNumerator); @@ -138,6 +141,11 @@ contract DeepstateGovernor is return super.proposalDeadline(proposalId); } + function setLateQuorumVoteExtension(uint48 newVoteExtension) public override onlyGovernance { + _validateLateQuorumVoteExtension(newVoteExtension); + _setLateQuorumVoteExtension(newVoteExtension); + } + function _tallyUpdated(uint256 proposalId) internal override(Governor, GovernorPreventLateQuorum) { super._tallyUpdated(proposalId); } @@ -147,6 +155,11 @@ contract DeepstateGovernor is if (votingDelay_ > maximum) revert VotingDelayAboveMaximum(votingDelay_, maximum); } + function _validateLateQuorumVoteExtension(uint48 voteExtension) private pure { + uint48 maximum = MAX_LATE_QUORUM_VOTE_EXTENSION; + if (voteExtension > maximum) revert LateQuorumVoteExtensionAboveMaximum(voteExtension, maximum); + } + function _updateProposalThresholdNumerator(uint256 newNumerator) internal { uint256 denominator = PROPOSAL_THRESHOLD_DENOMINATOR; if (newNumerator > denominator) { diff --git a/test/DeepstateGovernance.t.sol b/test/DeepstateGovernance.t.sol index 1873627..e1512da 100644 --- a/test/DeepstateGovernance.t.sol +++ b/test/DeepstateGovernance.t.sol @@ -187,6 +187,49 @@ contract DeepstateGovernanceTest is Test { assertEq(governor.votingDelay(), maximum); } + function testLateQuorumExtensionMaximumAppliesAtDeploymentAndGovernanceUpdates() public { + uint48 maximum = governor.MAX_LATE_QUORUM_VOTE_EXTENSION(); + DeepstateGovernor boundaryGovernor = new DeepstateGovernor( + IVotes(address(vault)), + GOVERNANCE_START_DELAY, + VOTING_DELAY, + VOTING_PERIOD, + PROPOSAL_THRESHOLD_NUMERATOR, + QUORUM_NUMERATOR, + maximum + ); + assertEq(boundaryGovernor.lateQuorumVoteExtension(), maximum); + + uint48 invalidExtension = maximum + 1; + vm.expectRevert( + abi.encodeWithSelector( + DeepstateGovernor.LateQuorumVoteExtensionAboveMaximum.selector, invalidExtension, maximum + ) + ); + new DeepstateGovernor( + IVotes(address(vault)), + GOVERNANCE_START_DELAY, + VOTING_DELAY, + VOTING_PERIOD, + PROPOSAL_THRESHOLD_NUMERATOR, + QUORUM_NUMERATOR, + invalidExtension + ); + + vm.prank(address(governor)); + governor.setLateQuorumVoteExtension(maximum); + assertEq(governor.lateQuorumVoteExtension(), maximum); + + vm.prank(address(governor)); + vm.expectRevert( + abi.encodeWithSelector( + DeepstateGovernor.LateQuorumVoteExtensionAboveMaximum.selector, invalidExtension, maximum + ) + ); + governor.setLateQuorumVoteExtension(invalidExtension); + assertEq(governor.lateQuorumVoteExtension(), maximum); + } + function testProposalThresholdIsOneAtClockOrigin() public { vm.warp(0); assertEq(governor.proposalThreshold(), 1); From 4828315e264e115b79849f290e94eadbb52d8c7d Mon Sep 17 00:00:00 2001 From: Joseph Delong Date: Fri, 14 Aug 2026 19:37:23 -0500 Subject: [PATCH 06/14] fix: cap governance voting period (V12 #227516) --- src/DeepstateGovernor.sol | 13 ++++++++++++ test/DeepstateGovernance.t.sol | 39 ++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/src/DeepstateGovernor.sol b/src/DeepstateGovernor.sol index ee04aff..114405e 100644 --- a/src/DeepstateGovernor.sol +++ b/src/DeepstateGovernor.sol @@ -27,6 +27,7 @@ contract DeepstateGovernor is uint256 public constant PROPOSAL_THRESHOLD_DENOMINATOR = 100; uint48 public constant MAX_VOTING_DELAY = 30 days; uint48 public constant MAX_LATE_QUORUM_VOTE_EXTENSION = 7 days; + uint32 public constant MAX_VOTING_PERIOD = 30 days; uint48 public immutable governanceStart; @@ -38,6 +39,7 @@ contract DeepstateGovernor is error GovernanceNotStarted(uint48 currentTimepoint, uint48 governanceStart); error VotingDelayAboveMaximum(uint48 votingDelay, uint48 maximum); error LateQuorumVoteExtensionAboveMaximum(uint48 voteExtension, uint48 maximum); + error VotingPeriodAboveMaximum(uint32 votingPeriod, uint32 maximum); error InvalidProposalThresholdFraction(uint256 numerator, uint256 denominator); error AbsoluteProposalThresholdUnsupported(); @@ -61,6 +63,7 @@ contract DeepstateGovernor is } _validateVotingDelayMaximum(initialVotingDelay); _validateLateQuorumVoteExtension(initialVoteExtension); + _validateVotingPeriod(initialVotingPeriod); governanceStart = SafeCast.toUint48(block.timestamp + governanceStartDelay); _updateProposalThresholdNumerator(initialProposalThresholdNumerator); @@ -93,6 +96,11 @@ contract DeepstateGovernor is return super.votingPeriod(); } + function setVotingPeriod(uint32 newVotingPeriod) public override onlyGovernance { + _validateVotingPeriod(newVotingPeriod); + _setVotingPeriod(newVotingPeriod); + } + function proposalThreshold() public view override(Governor, GovernorSettings) returns (uint256) { uint256 numerator = _proposalThresholdNumerator; uint48 currentTimepoint = clock(); @@ -160,6 +168,11 @@ contract DeepstateGovernor is if (voteExtension > maximum) revert LateQuorumVoteExtensionAboveMaximum(voteExtension, maximum); } + function _validateVotingPeriod(uint32 votingPeriod_) private pure { + uint32 maximum = MAX_VOTING_PERIOD; + if (votingPeriod_ > maximum) revert VotingPeriodAboveMaximum(votingPeriod_, maximum); + } + function _updateProposalThresholdNumerator(uint256 newNumerator) internal { uint256 denominator = PROPOSAL_THRESHOLD_DENOMINATOR; if (newNumerator > denominator) { diff --git a/test/DeepstateGovernance.t.sol b/test/DeepstateGovernance.t.sol index e1512da..a208362 100644 --- a/test/DeepstateGovernance.t.sol +++ b/test/DeepstateGovernance.t.sol @@ -230,6 +230,45 @@ contract DeepstateGovernanceTest is Test { assertEq(governor.lateQuorumVoteExtension(), maximum); } + function testVotingPeriodMaximumAppliesAtDeploymentAndGovernanceUpdates() public { + uint32 maximum = governor.MAX_VOTING_PERIOD(); + DeepstateGovernor boundaryGovernor = new DeepstateGovernor( + IVotes(address(vault)), + GOVERNANCE_START_DELAY, + VOTING_DELAY, + maximum, + PROPOSAL_THRESHOLD_NUMERATOR, + QUORUM_NUMERATOR, + VOTE_EXTENSION + ); + assertEq(boundaryGovernor.votingPeriod(), maximum); + + uint32 invalidPeriod = maximum + 1; + vm.expectRevert( + abi.encodeWithSelector(DeepstateGovernor.VotingPeriodAboveMaximum.selector, invalidPeriod, maximum) + ); + new DeepstateGovernor( + IVotes(address(vault)), + GOVERNANCE_START_DELAY, + VOTING_DELAY, + invalidPeriod, + PROPOSAL_THRESHOLD_NUMERATOR, + QUORUM_NUMERATOR, + VOTE_EXTENSION + ); + + vm.prank(address(governor)); + governor.setVotingPeriod(maximum); + assertEq(governor.votingPeriod(), maximum); + + vm.prank(address(governor)); + vm.expectRevert( + abi.encodeWithSelector(DeepstateGovernor.VotingPeriodAboveMaximum.selector, invalidPeriod, maximum) + ); + governor.setVotingPeriod(invalidPeriod); + assertEq(governor.votingPeriod(), maximum); + } + function testProposalThresholdIsOneAtClockOrigin() public { vm.warp(0); assertEq(governor.proposalThreshold(), 1); From 2a741b9710daed9bc23c482c1d30120c5a930c25 Mon Sep 17 00:00:00 2001 From: Joseph Delong Date: Fri, 14 Aug 2026 19:38:03 -0500 Subject: [PATCH 07/14] fix: enforce minimum governance quorum (V12 #227517) --- src/DeepstateGovernor.sol | 3 ++- test/DeepstateGovernance.t.sol | 22 ++++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/DeepstateGovernor.sol b/src/DeepstateGovernor.sol index 114405e..4172ebd 100644 --- a/src/DeepstateGovernor.sol +++ b/src/DeepstateGovernor.sol @@ -28,6 +28,7 @@ contract DeepstateGovernor is uint48 public constant MAX_VOTING_DELAY = 30 days; uint48 public constant MAX_LATE_QUORUM_VOTE_EXTENSION = 7 days; uint32 public constant MAX_VOTING_PERIOD = 30 days; + uint256 public constant MINIMUM_QUORUM = 1e18; uint48 public immutable governanceStart; @@ -137,7 +138,7 @@ contract DeepstateGovernor is } function quorum(uint256 timepoint) public view override(Governor, GovernorVotesQuorumFraction) returns (uint256) { - return super.quorum(timepoint); + return Math.max(super.quorum(timepoint), MINIMUM_QUORUM); } function proposalDeadline(uint256 proposalId) diff --git a/test/DeepstateGovernance.t.sol b/test/DeepstateGovernance.t.sol index a208362..4362e3b 100644 --- a/test/DeepstateGovernance.t.sol +++ b/test/DeepstateGovernance.t.sol @@ -343,6 +343,28 @@ contract DeepstateGovernanceTest is Test { assertEq(governor.proposalThreshold(), 2e18); } + function testQuorumHasOneStateFloorAcrossZeroAndDustSupplyEpochs() public { + _startGovernance(); + + valueToken.mint(address(vault), 1e6); + uint256 shares = vault.balanceOf(alice); + vm.prank(alice); + vault.redeemValue(shares, alice, alice); + vm.warp(vm.getBlockTimestamp() + 1); + + uint256 zeroSupplyTimepoint = governor.clock() - 1; + assertEq(vault.getPastTotalSupply(zeroSupplyTimepoint), 0); + assertEq(governor.quorum(zeroSupplyTimepoint), governor.MINIMUM_QUORUM()); + + vm.prank(bob); + vault.deposit(1, bob); + vm.warp(vm.getBlockTimestamp() + 1); + + uint256 dustSupplyTimepoint = governor.clock() - 1; + assertEq(vault.getPastTotalSupply(dustSupplyTimepoint), 1); + assertEq(governor.quorum(dustSupplyTimepoint), governor.MINIMUM_QUORUM()); + } + function testProposalThresholdFractionCanOnlyBeChangedThroughGovernance() public { vm.prank(alice); vm.expectRevert(abi.encodeWithSelector(IGovernor.GovernorOnlyExecutor.selector, alice)); From 1f56cc901b679a358dc693f09445b51b0e2f8593 Mon Sep 17 00:00:00 2001 From: Joseph Delong Date: Fri, 14 Aug 2026 19:38:41 -0500 Subject: [PATCH 08/14] fix: require minimum governance voting delay (V12 #227518) --- src/DeepstateGovernor.sol | 9 ++++++++ test/DeepstateGovernance.t.sol | 39 ++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/src/DeepstateGovernor.sol b/src/DeepstateGovernor.sol index 4172ebd..7d628cf 100644 --- a/src/DeepstateGovernor.sol +++ b/src/DeepstateGovernor.sol @@ -25,6 +25,7 @@ contract DeepstateGovernor is { bytes32 private constant _TIMESTAMP_MODE_HASH = keccak256("mode=timestamp"); uint256 public constant PROPOSAL_THRESHOLD_DENOMINATOR = 100; + uint48 public constant MIN_VOTING_DELAY = 1 days; uint48 public constant MAX_VOTING_DELAY = 30 days; uint48 public constant MAX_LATE_QUORUM_VOTE_EXTENSION = 7 days; uint32 public constant MAX_VOTING_PERIOD = 30 days; @@ -38,6 +39,7 @@ contract DeepstateGovernor is error TimestampClockRequired(); error GovernanceNotStarted(uint48 currentTimepoint, uint48 governanceStart); + error VotingDelayBelowMinimum(uint48 votingDelay, uint48 minimum); error VotingDelayAboveMaximum(uint48 votingDelay, uint48 maximum); error LateQuorumVoteExtensionAboveMaximum(uint48 voteExtension, uint48 maximum); error VotingPeriodAboveMaximum(uint32 votingPeriod, uint32 maximum); @@ -62,6 +64,7 @@ contract DeepstateGovernor is if (!_usesTimestampClock(IERC5805(address(stateToken)))) { revert TimestampClockRequired(); } + _validateVotingDelayMinimum(initialVotingDelay); _validateVotingDelayMaximum(initialVotingDelay); _validateLateQuorumVoteExtension(initialVoteExtension); _validateVotingPeriod(initialVotingPeriod); @@ -89,6 +92,7 @@ contract DeepstateGovernor is } function setVotingDelay(uint48 newVotingDelay) public override onlyGovernance { + _validateVotingDelayMinimum(newVotingDelay); _validateVotingDelayMaximum(newVotingDelay); _setVotingDelay(newVotingDelay); } @@ -164,6 +168,11 @@ contract DeepstateGovernor is if (votingDelay_ > maximum) revert VotingDelayAboveMaximum(votingDelay_, maximum); } + function _validateVotingDelayMinimum(uint48 votingDelay_) private pure { + uint48 minimum = MIN_VOTING_DELAY; + if (votingDelay_ < minimum) revert VotingDelayBelowMinimum(votingDelay_, minimum); + } + function _validateLateQuorumVoteExtension(uint48 voteExtension) private pure { uint48 maximum = MAX_LATE_QUORUM_VOTE_EXTENSION; if (voteExtension > maximum) revert LateQuorumVoteExtensionAboveMaximum(voteExtension, maximum); diff --git a/test/DeepstateGovernance.t.sol b/test/DeepstateGovernance.t.sol index 4362e3b..2023bf5 100644 --- a/test/DeepstateGovernance.t.sol +++ b/test/DeepstateGovernance.t.sol @@ -187,6 +187,45 @@ contract DeepstateGovernanceTest is Test { assertEq(governor.votingDelay(), maximum); } + function testVotingDelayMinimumAppliesAtDeploymentAndGovernanceUpdates() public { + uint48 minimum = governor.MIN_VOTING_DELAY(); + DeepstateGovernor boundaryGovernor = new DeepstateGovernor( + IVotes(address(vault)), + GOVERNANCE_START_DELAY, + minimum, + VOTING_PERIOD, + PROPOSAL_THRESHOLD_NUMERATOR, + QUORUM_NUMERATOR, + VOTE_EXTENSION + ); + assertEq(boundaryGovernor.votingDelay(), minimum); + + uint48 invalidDelay = minimum - 1; + vm.expectRevert( + abi.encodeWithSelector(DeepstateGovernor.VotingDelayBelowMinimum.selector, invalidDelay, minimum) + ); + new DeepstateGovernor( + IVotes(address(vault)), + GOVERNANCE_START_DELAY, + invalidDelay, + VOTING_PERIOD, + PROPOSAL_THRESHOLD_NUMERATOR, + QUORUM_NUMERATOR, + VOTE_EXTENSION + ); + + vm.prank(address(governor)); + governor.setVotingDelay(minimum); + assertEq(governor.votingDelay(), minimum); + + vm.prank(address(governor)); + vm.expectRevert( + abi.encodeWithSelector(DeepstateGovernor.VotingDelayBelowMinimum.selector, invalidDelay, minimum) + ); + governor.setVotingDelay(invalidDelay); + assertEq(governor.votingDelay(), minimum); + } + function testLateQuorumExtensionMaximumAppliesAtDeploymentAndGovernanceUpdates() public { uint48 maximum = governor.MAX_LATE_QUORUM_VOTE_EXTENSION(); DeepstateGovernor boundaryGovernor = new DeepstateGovernor( From d4c8287abef8a37fb39dfd0a1e95caf4aa0d4184 Mon Sep 17 00:00:00 2001 From: Joseph Delong Date: Fri, 14 Aug 2026 19:39:48 -0500 Subject: [PATCH 09/14] fix: require positive governance fractions (V12 #227519 #227520 #227522) --- src/DeepstateGovernor.sol | 12 ++++++- test/DeepstateGovernance.t.sol | 57 ++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/src/DeepstateGovernor.sol b/src/DeepstateGovernor.sol index 7d628cf..54fd3a8 100644 --- a/src/DeepstateGovernor.sol +++ b/src/DeepstateGovernor.sol @@ -61,6 +61,9 @@ contract DeepstateGovernor is GovernorVotesQuorumFraction(quorumNumeratorValue) GovernorPreventLateQuorum(initialVoteExtension) { + if (quorumNumeratorValue == 0) { + revert GovernorInvalidQuorumFraction(quorumNumeratorValue, quorumDenominator()); + } if (!_usesTimestampClock(IERC5805(address(stateToken)))) { revert TimestampClockRequired(); } @@ -145,6 +148,13 @@ contract DeepstateGovernor is return Math.max(super.quorum(timepoint), MINIMUM_QUORUM); } + function _updateQuorumNumerator(uint256 newQuorumNumerator) internal override { + if (newQuorumNumerator == 0) { + revert GovernorInvalidQuorumFraction(newQuorumNumerator, quorumDenominator()); + } + super._updateQuorumNumerator(newQuorumNumerator); + } + function proposalDeadline(uint256 proposalId) public view @@ -185,7 +195,7 @@ contract DeepstateGovernor is function _updateProposalThresholdNumerator(uint256 newNumerator) internal { uint256 denominator = PROPOSAL_THRESHOLD_DENOMINATOR; - if (newNumerator > denominator) { + if (newNumerator == 0 || newNumerator > denominator) { revert InvalidProposalThresholdFraction(newNumerator, denominator); } diff --git a/test/DeepstateGovernance.t.sol b/test/DeepstateGovernance.t.sol index 2023bf5..24e6504 100644 --- a/test/DeepstateGovernance.t.sol +++ b/test/DeepstateGovernance.t.sol @@ -5,6 +5,9 @@ import {Test} from "forge-std/Test.sol"; import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; import {IGovernor} from "@openzeppelin/contracts/governance/IGovernor.sol"; +import { + GovernorVotesQuorumFraction +} from "@openzeppelin/contracts/governance/extensions/GovernorVotesQuorumFraction.sol"; import {IVotes} from "@openzeppelin/contracts/governance/utils/IVotes.sol"; import {DeepstateGovernor} from "../src/DeepstateGovernor.sol"; @@ -436,6 +439,60 @@ contract DeepstateGovernanceTest is Test { governor.setProposalThreshold(1e18); } + function testGovernanceFractionsCannotBeZeroAtDeploymentOrUpdates() public { + uint256 proposalDenominator = governor.PROPOSAL_THRESHOLD_DENOMINATOR(); + uint256 quorumDenominator = governor.quorumDenominator(); + + vm.expectRevert( + abi.encodeWithSelector( + DeepstateGovernor.InvalidProposalThresholdFraction.selector, uint256(0), proposalDenominator + ) + ); + new DeepstateGovernor( + IVotes(address(vault)), + GOVERNANCE_START_DELAY, + VOTING_DELAY, + VOTING_PERIOD, + 0, + QUORUM_NUMERATOR, + VOTE_EXTENSION + ); + + vm.expectRevert( + abi.encodeWithSelector( + GovernorVotesQuorumFraction.GovernorInvalidQuorumFraction.selector, uint256(0), quorumDenominator + ) + ); + new DeepstateGovernor( + IVotes(address(vault)), + GOVERNANCE_START_DELAY, + VOTING_DELAY, + VOTING_PERIOD, + PROPOSAL_THRESHOLD_NUMERATOR, + 0, + VOTE_EXTENSION + ); + + vm.prank(address(governor)); + vm.expectRevert( + abi.encodeWithSelector( + DeepstateGovernor.InvalidProposalThresholdFraction.selector, uint256(0), proposalDenominator + ) + ); + governor.updateProposalThresholdNumerator(0); + + vm.prank(address(governor)); + vm.expectRevert( + abi.encodeWithSelector( + GovernorVotesQuorumFraction.GovernorInvalidQuorumFraction.selector, uint256(0), quorumDenominator + ) + ); + governor.updateQuorumNumerator(0); + + assertEq(governor.proposalThresholdNumerator(), PROPOSAL_THRESHOLD_NUMERATOR); + assertEq(governor.quorumNumerator(), QUORUM_NUMERATOR); + } + function testVaultSharesBackGovernorOwnershipOfVault() public { address[] memory targets = new address[](1); targets[0] = address(vault); From d643c55973f517dc557100a5d1ea560ea783fe9d Mon Sep 17 00:00:00 2001 From: Joseph Delong Date: Fri, 14 Aug 2026 19:41:10 -0500 Subject: [PATCH 10/14] fix: prevent trapped vault shares (V12 #227523) --- src/DeepstateVault.sol | 2 ++ test/DeepstateVault.t.sol | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/src/DeepstateVault.sol b/src/DeepstateVault.sol index 864b631..cf95f12 100644 --- a/src/DeepstateVault.sol +++ b/src/DeepstateVault.sol @@ -308,6 +308,8 @@ contract DeepstateVault is ERC4626, ERC20Votes, Ownable, ReentrancyGuard { } function _update(address from, address to, uint256 value) internal override(ERC20, ERC20Votes) { + if (to == address(this) && value != 0) revert ProtectedToken(); + super._update(from, to, value); if (to == address(0) && totalSupply() == 0) { diff --git a/test/DeepstateVault.t.sol b/test/DeepstateVault.t.sol index 690921f..7bf97d9 100644 --- a/test/DeepstateVault.t.sol +++ b/test/DeepstateVault.t.sol @@ -271,6 +271,44 @@ contract DeepstateVaultTest is Test { assertEq(vault.getVotes(bob), 40e18); } + function testNonzeroStateCannotBeTransferredOrMintedToVault() public { + vm.prank(alice); + vault.deposit(100e18, alice); + + vm.prank(alice); + vm.expectRevert(DeepstateVault.ProtectedToken.selector); + vault.transfer(address(vault), 1); + + vm.prank(alice); + vault.approve(carol, 1); + vm.prank(carol); + vm.expectRevert(DeepstateVault.ProtectedToken.selector); + vault.transferFrom(alice, address(vault), 1); + + uint256 bobAssetsBefore = depositToken.balanceOf(bob); + vm.prank(bob); + vm.expectRevert(DeepstateVault.ProtectedToken.selector); + vault.deposit(1e18, address(vault)); + + assertEq(vault.balanceOf(address(vault)), 0); + assertEq(vault.balanceOf(alice), 100e18); + assertEq(vault.totalSupply(), 100e18); + assertEq(vault.totalBurnedDepositAssets(), 100e18); + assertEq(vault.allowance(alice, carol), 1); + assertEq(depositToken.balanceOf(bob), bobAssetsBefore); + } + + function testZeroStateTransferToVaultRemainsPermitted() public { + vm.prank(alice); + vault.deposit(100e18, alice); + + vm.prank(alice); + assertTrue(vault.transfer(address(vault), 0)); + + assertEq(vault.balanceOf(address(vault)), 0); + assertEq(vault.balanceOf(alice), 100e18); + } + function testTransferUpdatesVotesBetweenDelegatedAccounts() public { vm.prank(alice); vault.deposit(100e18, alice); From bb4d2ebc170ad4a0b2f13bcce72d372bb5ae6607 Mon Sep 17 00:00:00 2001 From: Joseph Delong Date: Fri, 14 Aug 2026 19:42:16 -0500 Subject: [PATCH 11/14] fix: enforce six-decimal vault value token (V12 #227527) --- src/DeepstateVault.sol | 5 +++++ test/DeepstateVault.t.sol | 13 +++++++++++++ 2 files changed, 18 insertions(+) diff --git a/src/DeepstateVault.sol b/src/DeepstateVault.sol index cf95f12..0c6e144 100644 --- a/src/DeepstateVault.sol +++ b/src/DeepstateVault.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.24; import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import {ERC20Votes} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Votes.sol"; import {ERC4626} from "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol"; @@ -30,6 +31,7 @@ contract DeepstateVault is ERC4626, ERC20Votes, Ownable, ReentrancyGuard { /// @notice Fixed price for purchasing the vault's listed non-USDG fee balances. uint256 public constant FEE_PURCHASE_PRICE = 10_000e6; + uint8 public constant VALUE_TOKEN_DECIMALS = 6; address public immutable depositToken; address public immutable valueToken; @@ -60,6 +62,7 @@ contract DeepstateVault is ERC4626, ERC20Votes, Ownable, ReentrancyGuard { error ArrayLengthMismatch(); error MinimumAssetAmountNotMet(address token, uint256 amount, uint256 minimum); error InvalidFeePayment(); + error InvalidValueTokenDecimals(uint8 actualDecimals); constructor(address owner_, address depositToken_, address valueToken_, string memory name_, string memory symbol_) ERC20(name_, symbol_) @@ -70,6 +73,8 @@ contract DeepstateVault is ERC4626, ERC20Votes, Ownable, ReentrancyGuard { if (depositToken_ == address(0) || valueToken_ == address(0)) { revert ZeroAddress(); } + uint8 valueTokenDecimals = IERC20Metadata(valueToken_).decimals(); + if (valueTokenDecimals != VALUE_TOKEN_DECIMALS) revert InvalidValueTokenDecimals(valueTokenDecimals); depositToken = depositToken_; valueToken = valueToken_; diff --git a/test/DeepstateVault.t.sol b/test/DeepstateVault.t.sol index 7bf97d9..8d99d14 100644 --- a/test/DeepstateVault.t.sol +++ b/test/DeepstateVault.t.sol @@ -51,6 +51,10 @@ contract ReenteringNativeReceiver { contract FeeOnTransferERC20 is OZERC20 { constructor() OZERC20("Fee-on-transfer USDG", "fUSDG") {} + function decimals() public pure override returns (uint8) { + return 6; + } + function mint(address to, uint256 amount) external { _mint(to, amount); } @@ -142,6 +146,7 @@ contract DeepstateVaultTest is Test { assertEq(vault.depositToken(), address(depositToken)); assertEq(vault.valueToken(), address(valueToken)); assertEq(vault.FEE_PURCHASE_PRICE(), 10_000e6); + assertEq(vault.VALUE_TOKEN_DECIMALS(), 6); assertEq(vault.owner(), owner); assertEq(vault.CLOCK_MODE(), "mode=timestamp"); @@ -152,6 +157,14 @@ contract DeepstateVaultTest is Test { new DeepstateVault(owner, address(depositToken), address(0), "Deepstate Governance", "STATE"); } + function testFuzzConstructorRejectsNonSixDecimalValueToken(uint8 valueTokenDecimals) public { + vm.assume(valueTokenDecimals != 6); + MockERC20 invalidValueToken = new MockERC20("Invalid USDG", "iUSDG", valueTokenDecimals); + + vm.expectRevert(abi.encodeWithSelector(DeepstateVault.InvalidValueTokenDecimals.selector, valueTokenDecimals)); + new DeepstateVault(owner, address(depositToken), address(invalidValueToken), "Deepstate Governance", "STATE"); + } + function testPreviewRedeemValueReturnsZeroBeforeSupplyExists() public view { assertEq(vault.convertToValueAssets(1e18), 0); assertEq(vault.previewRedeemValue(1e18), 0); From 1e230f28869aae3cd7731c0a696afed80bd7e5fe Mon Sep 17 00:00:00 2001 From: Joseph Delong Date: Fri, 14 Aug 2026 19:43:15 -0500 Subject: [PATCH 12/14] feat: add bounded vault entry methods (V12 #227528) --- src/DeepstateVault.sol | 22 +++++++++ test/DeepstateVault.t.sol | 93 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+) diff --git a/src/DeepstateVault.sol b/src/DeepstateVault.sol index 0c6e144..ac78998 100644 --- a/src/DeepstateVault.sol +++ b/src/DeepstateVault.sol @@ -63,6 +63,8 @@ contract DeepstateVault is ERC4626, ERC20Votes, Ownable, ReentrancyGuard { error MinimumAssetAmountNotMet(address token, uint256 amount, uint256 minimum); error InvalidFeePayment(); error InvalidValueTokenDecimals(uint8 actualDecimals); + error MinimumSharesNotMet(uint256 shares, uint256 minimum); + error MaximumAssetsExceeded(uint256 assets, uint256 maximum); constructor(address owner_, address depositToken_, address valueToken_, string memory name_, string memory symbol_) ERC20(name_, symbol_) @@ -233,6 +235,26 @@ contract DeepstateVault is ERC4626, ERC20Votes, Ownable, ReentrancyGuard { emit FeesPurchased(msg.sender, receiver, FEE_PURCHASE_PRICE); } + /// @notice Deposits assets only if the live conversion mints at least `minShares`. + function deposit(uint256 assets, address receiver, uint256 minShares) public returns (uint256 shares) { + uint256 maxAssets = maxDeposit(receiver); + if (assets > maxAssets) revert ERC4626ExceededMaxDeposit(receiver, assets, maxAssets); + + shares = previewDeposit(assets); + if (shares < minShares) revert MinimumSharesNotMet(shares, minShares); + _deposit(_msgSender(), receiver, assets, shares); + } + + /// @notice Mints shares only if the live conversion consumes at most `maxAssets`. + function mint(uint256 shares, address receiver, uint256 maxAssets) public returns (uint256 assets) { + uint256 maxShares = maxMint(receiver); + if (shares > maxShares) revert ERC4626ExceededMaxMint(receiver, shares, maxShares); + + assets = previewMint(shares); + if (assets > maxAssets) revert MaximumAssetsExceeded(assets, maxAssets); + _deposit(_msgSender(), receiver, assets, shares); + } + /// @dev Strict ERC-4626 withdrawal would return the deposit asset, which is burned here. function withdraw(uint256, address, address) public pure override returns (uint256) { revert UseRedeemValue(); diff --git a/test/DeepstateVault.t.sol b/test/DeepstateVault.t.sol index 8d99d14..503926d 100644 --- a/test/DeepstateVault.t.sol +++ b/test/DeepstateVault.t.sol @@ -3,15 +3,32 @@ pragma solidity ^0.8.20; import {Test} from "forge-std/Test.sol"; import {ERC20 as OZERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {ERC4626} from "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol"; import {DeepstateVault} from "../src/DeepstateVault.sol"; import {MockERC20} from "./mocks/MockERC20.sol"; contract DeepstateVaultHarness is DeepstateVault { + uint256 private depositCap = type(uint256).max; + uint256 private mintCap = type(uint256).max; + constructor(address owner_, address depositToken_, address valueToken_, string memory name_, string memory symbol_) DeepstateVault(owner_, depositToken_, valueToken_, name_, symbol_) {} + function setEntryCaps(uint256 depositCap_, uint256 mintCap_) external { + depositCap = depositCap_; + mintCap = mintCap_; + } + + function maxDeposit(address) public view override returns (uint256) { + return depositCap; + } + + function maxMint(address) public view override returns (uint256) { + return mintCap; + } + function exposedDeposit(address caller, address receiver, uint256 assets, uint256 shares) external { _deposit(caller, receiver, assets, shares); } @@ -182,6 +199,82 @@ contract DeepstateVaultTest is Test { vm.stopPrank(); } + function testBoundedDepositAndMintAcceptExactLiveLimits() public { + vm.prank(alice); + uint256 aliceShares = vault.deposit(25e18, alice, 25e18); + + vm.prank(bob); + uint256 bobAssets = vault.mint(25e18, bob, 25e18); + + assertEq(aliceShares, 25e18); + assertEq(bobAssets, 25e18); + assertEq(vault.balanceOf(alice), 25e18); + assertEq(vault.balanceOf(bob), 25e18); + assertEq(vault.totalSupply(), 50e18); + assertEq(vault.totalBurnedDepositAssets(), 50e18); + } + + function testBoundedDepositAndMintEnforceVaultEntryCaps() public { + DeepstateVaultHarness harness = new DeepstateVaultHarness( + owner, address(depositToken), address(valueToken), "Deepstate Governance", "STATE" + ); + harness.setEntryCaps(10e18, 20e18); + + vm.expectRevert(abi.encodeWithSelector(ERC4626.ERC4626ExceededMaxDeposit.selector, alice, 10e18 + 1, 10e18)); + harness.deposit(10e18 + 1, alice, 0); + + vm.expectRevert(abi.encodeWithSelector(ERC4626.ERC4626ExceededMaxMint.selector, alice, 20e18 + 1, 20e18)); + harness.mint(20e18 + 1, alice, type(uint256).max); + } + + function testBoundedDepositRejectsWorsenedLiveShareQuoteWithoutBurningAssets() public { + vm.prank(alice); + vault.deposit(100e18, alice); + valueToken.mint(address(vault), 100e6); + + uint256 victimAssets = 50e18; + uint256 quotedShares = vault.previewDeposit(victimAssets); + vm.prank(alice); + vault.redeemValue(50e18, alice, alice); + + uint256 liveShares = vault.previewDeposit(victimAssets); + assertEq(quotedShares, 50e18); + assertEq(liveShares, 25e18); + + uint256 bobAssetsBefore = depositToken.balanceOf(bob); + vm.prank(bob); + vm.expectRevert(abi.encodeWithSelector(DeepstateVault.MinimumSharesNotMet.selector, liveShares, quotedShares)); + vault.deposit(victimAssets, bob, quotedShares); + + assertEq(depositToken.balanceOf(bob), bobAssetsBefore); + assertEq(vault.balanceOf(bob), 0); + assertEq(vault.totalBurnedDepositAssets(), 100e18); + } + + function testBoundedMintRejectsWorsenedLiveAssetQuoteWithoutBurningAssets() public { + vm.prank(alice); + vault.deposit(100e18, alice); + valueToken.mint(address(vault), 100e6); + + uint256 targetShares = 50e18; + uint256 quotedAssets = vault.previewMint(targetShares); + vm.prank(alice); + vault.redeemValue(50e18, alice, alice); + + uint256 liveAssets = vault.previewMint(targetShares); + assertEq(quotedAssets, 50e18); + assertEq(liveAssets, 100e18); + + uint256 bobAssetsBefore = depositToken.balanceOf(bob); + vm.prank(bob); + vm.expectRevert(abi.encodeWithSelector(DeepstateVault.MaximumAssetsExceeded.selector, liveAssets, quotedAssets)); + vault.mint(targetShares, bob, quotedAssets); + + assertEq(depositToken.balanceOf(bob), bobAssetsBefore); + assertEq(vault.balanceOf(bob), 0); + assertEq(vault.totalBurnedDepositAssets(), 100e18); + } + function testInternalDepositRejectsZeroShares() public { DeepstateVaultHarness harness = new DeepstateVaultHarness( owner, address(depositToken), address(valueToken), "Deepstate Governance", "STATE" From 49c6b0669f5aae2e5c64ec45b4f31cf15a695555 Mon Sep 17 00:00:00 2001 From: Joseph Delong Date: Fri, 14 Aug 2026 19:44:58 -0500 Subject: [PATCH 13/14] feat: bound listed redemption outputs (V12 #227529) --- src/DeepstateVault.sol | 22 +++++++-- test/DeepstateVault.t.sol | 101 +++++++++++++++++++++++++++++++------- 2 files changed, 99 insertions(+), 24 deletions(-) diff --git a/src/DeepstateVault.sol b/src/DeepstateVault.sol index ac78998..5ac82fe 100644 --- a/src/DeepstateVault.sol +++ b/src/DeepstateVault.sol @@ -60,6 +60,7 @@ contract DeepstateVault is ERC4626, ERC20Votes, Ownable, ReentrancyGuard { error InsufficientRedeemableAssets(); error InsufficientFeeAssets(); error ArrayLengthMismatch(); + error MinimumAmountsRequired(); error MinimumAssetAmountNotMet(address token, uint256 amount, uint256 minimum); error InvalidFeePayment(); error InvalidValueTokenDecimals(uint8 actualDecimals); @@ -135,14 +136,21 @@ contract DeepstateVault is ERC4626, ERC20Votes, Ownable, ReentrancyGuard { emit ValueRedeemed(msg.sender, receiver, owner, shares, valueAssets); } + /// @notice Deprecated unsafe redemption interface. Use the overload with per-asset minimum amounts. + function redeemAssets(uint256, address, address, address[] calldata) external pure returns (uint256[] memory) { + revert MinimumAmountsRequired(); + } + /// @notice Burns STATE and pays a pro-rata share of each explicitly listed vault asset. /// @dev Use address(0) for native ETH. DEEP and STATE cannot be redeemed through this path. /// All payouts use pre-burn balances and supply; omitted assets remain in the vault. - function redeemAssets(uint256 shares, address receiver, address owner, address[] calldata tokens) - external - nonReentrant - returns (uint256[] memory assets) - { + function redeemAssets( + uint256 shares, + address receiver, + address owner, + address[] calldata tokens, + uint256[] calldata minimumAmounts + ) external nonReentrant returns (uint256[] memory assets) { if (shares == 0) revert ZeroShares(); if (receiver == address(0)) revert ZeroAddress(); @@ -151,6 +159,7 @@ contract DeepstateVault is ERC4626, ERC20Votes, Ownable, ReentrancyGuard { uint256 length = tokens.length; if (length == 0) revert EmptyAssetList(); + if (minimumAmounts.length != length) revert ArrayLengthMismatch(); uint256 supply = totalSupply(); uint256 marker = _nextAssetListCallMarker(); @@ -162,6 +171,9 @@ contract DeepstateVault is ERC4626, ERC20Votes, Ownable, ReentrancyGuard { _validateListedAsset(token, marker); uint256 amount = shares.mulDiv(_vaultBalance(token), supply); + uint256 minimum = minimumAmounts[i]; + if (amount < minimum) revert MinimumAssetAmountNotMet(token, amount, minimum); + assets[i] = amount; if (amount != 0) hasAssets = true; } diff --git a/test/DeepstateVault.t.sol b/test/DeepstateVault.t.sol index 503926d..42522b2 100644 --- a/test/DeepstateVault.t.sol +++ b/test/DeepstateVault.t.sol @@ -59,7 +59,8 @@ contract ReenteringNativeReceiver { address[] memory tokens = new address[](1); tokens[0] = reentryToken; - try vault.redeemAssets(reentryShares, address(this), address(this), tokens) { + uint256[] memory minimumAmounts = new uint256[](1); + try vault.redeemAssets(reentryShares, address(this), address(this), tokens, minimumAmounts) { reentered = true; } catch {} } @@ -675,10 +676,14 @@ contract DeepstateVaultTest is Test { tokens[0] = address(valueToken); tokens[1] = address(feeToken); tokens[2] = address(0); + uint256[] memory minimumAmounts = new uint256[](3); + minimumAmounts[0] = 250e6; + minimumAmounts[1] = 2.5e18; + minimumAmounts[2] = 0.5 ether; uint256 aliceNativeBefore = alice.balance; vm.prank(alice); - uint256[] memory assets = vault.redeemAssets(50e18, alice, alice, tokens); + uint256[] memory assets = vault.redeemAssets(50e18, alice, alice, tokens, minimumAmounts); assertEq(assets.length, 3); assertEq(assets[0], 250e6); @@ -695,6 +700,55 @@ contract DeepstateVaultTest is Test { assertEq(vault.getVotes(alice), 50e18); } + function testLegacyRedeemAssetsRequiresMinimumAmounts() public { + address[] memory tokens = new address[](1); + tokens[0] = address(valueToken); + + vm.expectRevert(DeepstateVault.MinimumAmountsRequired.selector); + vault.redeemAssets(1e18, alice, alice, tokens); + } + + function testRedeemAssetsMinimumsRejectDegradedBasketBeforeAllowanceOrBurn() public { + vm.prank(alice); + vault.deposit(100e18, alice); + vm.prank(bob); + vault.deposit(100e18, bob); + + feeToken.mint(address(vault), 10e18); + otherFeeToken.mint(address(vault), 20e8); + + uint256 shares = 50e18; + address[] memory tokens = new address[](2); + tokens[0] = address(feeToken); + tokens[1] = address(otherFeeToken); + uint256[] memory minimumAmounts = new uint256[](2); + minimumAmounts[0] = 2.5e18; + minimumAmounts[1] = 5e8; + + address[] memory purchaseTokens = new address[](1); + purchaseTokens[0] = address(feeToken); + uint256[] memory purchaseMinimums = new uint256[](1); + purchaseMinimums[0] = 10e18; + vm.prank(buyer); + vault.buyFees(purchaseTokens, purchaseMinimums, buyer); + + vm.prank(alice); + vault.approve(carol, shares); + vm.prank(carol); + vm.expectRevert( + abi.encodeWithSelector( + DeepstateVault.MinimumAssetAmountNotMet.selector, address(feeToken), uint256(0), minimumAmounts[0] + ) + ); + vault.redeemAssets(shares, alice, alice, tokens, minimumAmounts); + + assertEq(vault.allowance(alice, carol), shares); + assertEq(vault.balanceOf(alice), 100e18); + assertEq(vault.totalSupply(), 200e18); + assertEq(otherFeeToken.balanceOf(address(vault)), 20e8); + assertEq(otherFeeToken.balanceOf(alice), 0); + } + function testFinalMultiAssetRedemptionResetsDepositAccounting() public { vm.prank(alice); vault.deposit(100e18, alice); @@ -704,7 +758,7 @@ contract DeepstateVaultTest is Test { tokens[0] = address(feeToken); vm.prank(alice); - uint256[] memory assets = vault.redeemAssets(100e18, alice, alice, tokens); + uint256[] memory assets = vault.redeemAssets(100e18, alice, alice, tokens, _zeroMinimums(tokens)); assertEq(assets[0], 10e18); assertEq(feeToken.balanceOf(alice), 10e18); @@ -726,7 +780,7 @@ contract DeepstateVaultTest is Test { tokens[1] = address(feeToken); vm.prank(alice); - uint256[] memory assets = vault.redeemAssets(50e18, alice, alice, tokens); + uint256[] memory assets = vault.redeemAssets(50e18, alice, alice, tokens, _zeroMinimums(tokens)); assertEq(assets[0], 250e6); assertEq(assets[1], 0); @@ -748,7 +802,7 @@ contract DeepstateVaultTest is Test { tokens[1] = address(feeToken); vm.prank(bob); - uint256[] memory assets = vault.redeemAssets(40e18, carol, alice, tokens); + uint256[] memory assets = vault.redeemAssets(40e18, carol, alice, tokens, _zeroMinimums(tokens)); assertEq(assets[0], 200e6); assertEq(assets[1], 10e18); @@ -773,7 +827,7 @@ contract DeepstateVaultTest is Test { vm.prank(alice); vm.expectRevert(DeepstateVault.DuplicateAsset.selector); - vault.redeemAssets(40e18, alice, alice, tokens); + vault.redeemAssets(40e18, alice, alice, tokens, _zeroMinimums(tokens)); tokens = new address[](2); tokens[0] = address(0); @@ -781,7 +835,7 @@ contract DeepstateVaultTest is Test { vm.prank(alice); vm.expectRevert(DeepstateVault.DuplicateAsset.selector); - vault.redeemAssets(40e18, alice, alice, tokens); + vault.redeemAssets(40e18, alice, alice, tokens, _zeroMinimums(tokens)); assertEq(vault.balanceOf(alice), 100e18); assertEq(vault.totalSupply(), 100e18); @@ -800,8 +854,8 @@ contract DeepstateVaultTest is Test { tokens[0] = address(valueToken); vm.startPrank(alice); - uint256[] memory first = vault.redeemAssets(25e18, alice, alice, tokens); - uint256[] memory second = vault.redeemAssets(25e18, alice, alice, tokens); + uint256[] memory first = vault.redeemAssets(25e18, alice, alice, tokens, _zeroMinimums(tokens)); + uint256[] memory second = vault.redeemAssets(25e18, alice, alice, tokens, _zeroMinimums(tokens)); vm.stopPrank(); assertEq(first[0], 125e6); @@ -818,38 +872,42 @@ contract DeepstateVaultTest is Test { address[] memory tokens = new address[](1); tokens[0] = address(valueToken); + vm.prank(alice); + vm.expectRevert(DeepstateVault.ArrayLengthMismatch.selector); + vault.redeemAssets(1e18, alice, alice, tokens, new uint256[](0)); + vm.prank(alice); vm.expectRevert(DeepstateVault.ZeroShares.selector); - vault.redeemAssets(0, alice, alice, tokens); + vault.redeemAssets(0, alice, alice, tokens, _zeroMinimums(tokens)); vm.prank(alice); vm.expectRevert(DeepstateVault.ZeroAddress.selector); - vault.redeemAssets(1e18, address(0), alice, tokens); + vault.redeemAssets(1e18, address(0), alice, tokens, _zeroMinimums(tokens)); vm.prank(alice); vm.expectRevert(); - vault.redeemAssets(101e18, alice, alice, tokens); + vault.redeemAssets(101e18, alice, alice, tokens, _zeroMinimums(tokens)); tokens = new address[](0); vm.prank(alice); vm.expectRevert(DeepstateVault.EmptyAssetList.selector); - vault.redeemAssets(1e18, alice, alice, tokens); + vault.redeemAssets(1e18, alice, alice, tokens, _zeroMinimums(tokens)); tokens = new address[](1); tokens[0] = address(feeToken); vm.prank(alice); vm.expectRevert(DeepstateVault.InsufficientRedeemableAssets.selector); - vault.redeemAssets(1e18, alice, alice, tokens); + vault.redeemAssets(1e18, alice, alice, tokens, _zeroMinimums(tokens)); tokens[0] = address(depositToken); vm.prank(alice); vm.expectRevert(DeepstateVault.ProtectedToken.selector); - vault.redeemAssets(1e18, alice, alice, tokens); + vault.redeemAssets(1e18, alice, alice, tokens, _zeroMinimums(tokens)); tokens[0] = address(vault); vm.prank(alice); vm.expectRevert(DeepstateVault.ProtectedToken.selector); - vault.redeemAssets(1e18, alice, alice, tokens); + vault.redeemAssets(1e18, alice, alice, tokens, _zeroMinimums(tokens)); assertEq(vault.balanceOf(alice), 100e18); assertEq(vault.totalSupply(), 100e18); @@ -868,7 +926,7 @@ contract DeepstateVaultTest is Test { vm.prank(alice); vm.expectRevert(); - vault.redeemAssets(40e18, address(receiver), alice, tokens); + vault.redeemAssets(40e18, address(receiver), alice, tokens, _zeroMinimums(tokens)); assertEq(vault.balanceOf(alice), 100e18); assertEq(vault.totalSupply(), 100e18); @@ -890,7 +948,8 @@ contract DeepstateVaultTest is Test { tokens[0] = address(0); vm.prank(address(receiver)); - uint256[] memory assets = vault.redeemAssets(10e18, address(receiver), address(receiver), tokens); + uint256[] memory assets = + vault.redeemAssets(10e18, address(receiver), address(receiver), tokens, _zeroMinimums(tokens)); assertEq(assets[0], 0.1 ether); assertTrue(receiver.attempted()); @@ -1225,7 +1284,7 @@ contract DeepstateVaultTest is Test { tokens[2] = address(0); vm.prank(alice); - uint256[] memory assets = vault.redeemAssets(sharesToRedeem, alice, alice, tokens); + uint256[] memory assets = vault.redeemAssets(sharesToRedeem, alice, alice, tokens, _zeroMinimums(tokens)); assertEq(assets[0], expectedValue); assertEq(assets[1], expectedFee); @@ -1291,4 +1350,8 @@ contract DeepstateVaultTest is Test { ) ); } + + function _zeroMinimums(address[] memory tokens) private pure returns (uint256[] memory) { + return new uint256[](tokens.length); + } } From c2cb7dcc498c66a54e8492f668fe166bebaf4c1b Mon Sep 17 00:00:00 2001 From: Joseph Delong Date: Fri, 14 Aug 2026 20:10:11 -0500 Subject: [PATCH 14/14] fix: settle rewards before book rotation (V12 #227435) --- foundry.lock | 2 +- lib/deepstate-contracts | 2 +- test/DeepstateRewarder.t.sol | 38 ++++++++++++++++++++++++++++++++++-- 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/foundry.lock b/foundry.lock index aea0947..1bbb8ac 100644 --- a/foundry.lock +++ b/foundry.lock @@ -1,6 +1,6 @@ { "lib/deepstate-contracts": { - "rev": "da8a976fc805dd153303ced3037137d8fa03d9eb" + "rev": "59e5bb25a023d96a50a78fb1bd8504b5aa8e14ad" }, "lib/forge-std": { "tag": { diff --git a/lib/deepstate-contracts b/lib/deepstate-contracts index da8a976..59e5bb2 160000 --- a/lib/deepstate-contracts +++ b/lib/deepstate-contracts @@ -1 +1 @@ -Subproject commit da8a976fc805dd153303ced3037137d8fa03d9eb +Subproject commit 59e5bb25a023d96a50a78fb1bd8504b5aa8e14ad diff --git a/test/DeepstateRewarder.t.sol b/test/DeepstateRewarder.t.sol index 8c66737..55132f3 100644 --- a/test/DeepstateRewarder.t.sol +++ b/test/DeepstateRewarder.t.sol @@ -53,6 +53,14 @@ contract CountingHook is IHook { } } +contract RewardDeepstateHarness is DeepstateV1 { + function forceNextNonce(address token0, address token1, uint256 epoch, uint32 nonce) external { + bytes32 id = bookId(token0, token1, epoch); + uint256 nonceAndFlags = books[id].nonceAndFlags; + books[id].nonceAndFlags = (nonceAndFlags & ~uint256(type(uint32).max)) | uint256(nonce); + } +} + contract DeepstateRewarderTest is Test { uint32 internal constant MAX_ORDER_NONCE = type(uint32).max; uint96 internal constant NVDA_SIDE_CAP = 500_000_000e18; @@ -62,7 +70,7 @@ contract DeepstateRewarderTest is Test { bytes32 internal constant INVALID_POOL_ID = keccak256("invalid-pool"); bytes32 internal constant EMPTY_BOOK_ID = keccak256("empty-book"); - DeepstateV1 internal deepstate; + RewardDeepstateHarness internal deepstate; DeepstateRewarder internal rewarder; bytes32 internal configuredPoolId; RewardTestERC20 internal token0; @@ -85,7 +93,7 @@ contract DeepstateRewarderTest is Test { token1 = a; } - deepstate = new DeepstateV1(); + deepstate = new RewardDeepstateHarness(); rewardToken = new RewardTestERC20("Reward", "RWD"); configuredPoolId = deepstate.poolId(address(token0), address(token1)); rewarder = _deployRewarder( @@ -431,6 +439,32 @@ contract DeepstateRewarderTest is Test { assertEq(rewarder.claimants(aliceOrderId), alice); } + function test_BookRotationAccruesAndPreservesHistoricalTopReward() public { + bytes32 id = deepstate.bookId(address(token0), address(token1), 0); + + vm.prank(alice); + bytes32 aliceBid = deepstate.fill(_fill(0, _order(0, 5e18, 0), true, false, false)); + (, uint64 startedAt) = rewarder.rewardees(address(token1)); + vm.warp(block.timestamp + 1 days); + + uint256 expected = rewarder.previewReward(address(token1), startedAt, block.timestamp, 5e18); + assertGt(expected, 0); + + deepstate.forceNextNonce(address(token0), address(token1), 0, 2); + vm.prank(bob); + deepstate.fill(_fill(0, _order(-1, 2e18, 0), true, false, false)); + + assertEq(deepstate.poolEpoch(configuredPoolId), 1); + assertEq(rewarder.balances(id, address(token1), MAX_ORDER_NONCE), expected); + (uint32 currentNonce, uint64 currentStartedAt) = rewarder.rewardees(address(token1)); + assertEq(currentNonce, 0); + assertEq(currentStartedAt, 0); + + rewarder.distributeRewards(id, aliceBid, address(token1)); + assertEq(rewarder.balances(id, address(token1), MAX_ORDER_NONCE), 0); + assertEq(rewardToken.balanceOf(alice), expected); + } + function test_InlineRegistrationPreservesFinalAccrualAfterCancel() public { bytes32 id = deepstate.bookId(address(token0), address(token1), 0);