Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion foundry.lock
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"lib/deepstate-contracts": {
"rev": "56c7434aceb437b17cf5814fe6e4e30d26028dfd"
"rev": "59e5bb25a023d96a50a78fb1bd8504b5aa8e14ad"
},
"lib/forge-std": {
"tag": {
Expand Down
76 changes: 67 additions & 9 deletions src/DeepstateGovernor.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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();

Expand All @@ -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);
Expand All @@ -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) {
Expand Down Expand Up @@ -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)
Expand All @@ -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);
}

Expand Down
27 changes: 27 additions & 0 deletions src/DeepstateToken.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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);
}
}
51 changes: 46 additions & 5 deletions src/DeepstateVault.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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_)
Expand All @@ -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_;
Expand Down Expand Up @@ -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();

Expand All @@ -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();
Expand All @@ -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;
}
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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) {
Expand Down
Loading