diff --git a/foundry.lock b/foundry.lock index 6ea663c..1bbb8ac 100644 --- a/foundry.lock +++ b/foundry.lock @@ -1,6 +1,6 @@ { "lib/deepstate-contracts": { - "rev": "56c7434aceb437b17cf5814fe6e4e30d26028dfd" + "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/src/DeepstateGovernor.sol b/src/DeepstateGovernor.sol index 5f57af1..54fd3a8 100644 --- a/src/DeepstateGovernor.sol +++ b/src/DeepstateGovernor.sol @@ -25,6 +25,11 @@ 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; + uint256 public constant MINIMUM_QUORUM = 1e18; uint48 public immutable governanceStart; @@ -34,6 +39,10 @@ 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); error InvalidProposalThresholdFraction(uint256 numerator, uint256 denominator); error AbsoluteProposalThresholdUnsupported(); @@ -52,9 +61,16 @@ contract DeepstateGovernor is GovernorVotesQuorumFraction(quorumNumeratorValue) GovernorPreventLateQuorum(initialVoteExtension) { + if (quorumNumeratorValue == 0) { + revert GovernorInvalidQuorumFraction(quorumNumeratorValue, quorumDenominator()); + } if (!_usesTimestampClock(IERC5805(address(stateToken)))) { revert TimestampClockRequired(); } + _validateVotingDelayMinimum(initialVotingDelay); + _validateVotingDelayMaximum(initialVotingDelay); + _validateLateQuorumVoteExtension(initialVoteExtension); + _validateVotingPeriod(initialVotingPeriod); governanceStart = SafeCast.toUint48(block.timestamp + governanceStartDelay); _updateProposalThresholdNumerator(initialProposalThresholdNumerator); @@ -78,20 +94,30 @@ contract DeepstateGovernor is return super.votingDelay(); } + function setVotingDelay(uint48 newVotingDelay) public override onlyGovernance { + _validateVotingDelayMinimum(newVotingDelay); + _validateVotingDelayMaximum(newVotingDelay); + _setVotingDelay(newVotingDelay); + } + function votingPeriod() public view override(Governor, GovernorSettings) returns (uint256) { 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(); - if (currentTimepoint == 0) return 0; + if (currentTimepoint == 0) return numerator == 0 ? 0 : 1; - return Math.mulDiv( - token().getPastTotalSupply(currentTimepoint - 1), - _proposalThresholdNumerator, - PROPOSAL_THRESHOLD_DENOMINATOR, - Math.Rounding.Ceil - ); + 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) { @@ -119,7 +145,14 @@ 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 _updateQuorumNumerator(uint256 newQuorumNumerator) internal override { + if (newQuorumNumerator == 0) { + revert GovernorInvalidQuorumFraction(newQuorumNumerator, quorumDenominator()); + } + super._updateQuorumNumerator(newQuorumNumerator); } function proposalDeadline(uint256 proposalId) @@ -131,13 +164,38 @@ 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); } + function _validateVotingDelayMaximum(uint48 votingDelay_) private pure { + uint48 maximum = MAX_VOTING_DELAY; + 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); + } + + 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) { + if (newNumerator == 0 || newNumerator > denominator) { revert InvalidProposalThresholdFraction(newNumerator, denominator); } 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/src/DeepstateVault.sol b/src/DeepstateVault.sol index 864b631..5ac82fe 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; @@ -58,8 +60,12 @@ 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); + 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_) @@ -70,6 +76,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_; @@ -128,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(); @@ -144,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(); @@ -155,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; } @@ -228,6 +247,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(); @@ -308,6 +347,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/DeepstateGovernance.t.sol b/test/DeepstateGovernance.t.sol index a34e82a..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"; @@ -148,9 +151,191 @@ contract DeepstateGovernanceTest is Test { assertEq(governor.quorum(snapshot), 10e18); } - function testProposalThresholdIsZeroAtClockOrigin() public { + 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 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( + 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 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(), 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 { @@ -200,6 +385,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)); @@ -232,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); 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); 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(); diff --git a/test/DeepstateVault.t.sol b/test/DeepstateVault.t.sol index 690921f..42522b2 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); } @@ -42,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 {} } @@ -51,6 +69,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 +164,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 +175,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); @@ -169,6 +200,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" @@ -271,6 +378,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); @@ -531,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); @@ -551,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); @@ -560,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); @@ -582,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); @@ -604,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); @@ -629,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); @@ -637,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); @@ -656,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); @@ -674,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); @@ -724,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); @@ -746,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()); @@ -1081,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); @@ -1147,4 +1350,8 @@ contract DeepstateVaultTest is Test { ) ); } + + function _zeroMinimums(address[] memory tokens) private pure returns (uint256[] memory) { + return new uint256[](tokens.length); + } }