Skip to content

Commit

Permalink
feat: add privilege levels
Browse files Browse the repository at this point in the history
  • Loading branch information
pscott committed Jan 16, 2025
1 parent 0d37a57 commit b1b55e7
Show file tree
Hide file tree
Showing 15 changed files with 432 additions and 72 deletions.
2 changes: 1 addition & 1 deletion .forge-snapshots/ProposeSigComp.snap
Original file line number Diff line number Diff line change
@@ -1 +1 @@
148525
149334
2 changes: 1 addition & 1 deletion .forge-snapshots/VoteSigComp.snap
Original file line number Diff line number Diff line change
@@ -1 +1 @@
49770
50321
2 changes: 1 addition & 1 deletion .forge-snapshots/VoteSigCompMetadata.snap
Original file line number Diff line number Diff line change
@@ -1 +1 @@
51320
51871
2 changes: 1 addition & 1 deletion .forge-snapshots/VoteTxComp.snap
Original file line number Diff line number Diff line change
@@ -1 +1 @@
43317
43433
2 changes: 1 addition & 1 deletion .forge-snapshots/VoteTxCompMetadata.snap
Original file line number Diff line number Diff line change
@@ -1 +1 @@
44908
45024
2 changes: 2 additions & 0 deletions foundry.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ gas_reports = ["*"]
libs = ["lib"]
optimizer = true
optimizer_runs = 10_000
incremental = true
cache = true
out = "out"
solc = "0.8.18"
src = "src"
Expand Down
214 changes: 155 additions & 59 deletions src/Space.sol
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@ import { Initializable } from "@openzeppelin/contracts-upgradeable/proxy/utils/I
import { UUPSUpgradeable } from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import { ReentrancyGuard } from "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import { IERC4824 } from "src/interfaces/IERC4824.sol";
import { ISpace, ISpaceActions, ISpaceState, ISpaceOwnerActions } from "src/interfaces/ISpace.sol";
import { ISpace, ISpaceActions, ISpaceState, ISpacePrivilegedActions } from "src/interfaces/ISpace.sol";
import {
Choice,
FinalizationStatus,
IndexedStrategy,
PrivilegeLevel,
Proposal,
ProposalStatus,
Strategy,
Expand Down Expand Up @@ -71,6 +72,8 @@ contract Space is ISpace, Initializable, IERC4824, UUPSUpgradeable, OwnableUpgra
mapping(uint256 proposalId => mapping(Choice choice => uint256 votePower)) public override votePower;
/// @inheritdoc ISpaceState
mapping(uint256 proposalId => mapping(address voter => uint256 hasVoted)) public override voteRegistry;
// @inheritdoc ISpaceState
mapping(address members => PrivilegeLevel privilegeLevel) public privileges;

/// @inheritdoc ISpaceActions
function initialize(InitializeCalldata calldata input) external override initializer {
Expand All @@ -79,6 +82,7 @@ contract Space is ISpace, Initializable, IERC4824, UUPSUpgradeable, OwnableUpgra
if (input.votingStrategies.length != input.votingStrategyMetadataURIs.length) revert ArrayLengthMismatch();

__Ownable_init();
// grantPrivilege(input.owner, PrivilegeLevel.Controller);
transferOwnership(input.owner);
_setDaoURI(input.daoURI);
_setMaxVotingDuration(input.maxVotingDuration);
Expand All @@ -99,74 +103,151 @@ contract Space is ISpace, Initializable, IERC4824, UUPSUpgradeable, OwnableUpgra
// | |
// ------------------------------------

/// @inheritdoc ISpaceOwnerActions
/// @inheritdoc ISpacePrivilegedActions
function transferOwnership(
address newOwner
) public override(ISpacePrivilegedActions, OwnableUpgradeable) onlyOwner {
if (newOwner == address(0)) revert ZeroAddress();

privileges[owner()] = PrivilegeLevel.None;
emit PrivilegeChanged(owner(), PrivilegeLevel.None);
privileges[newOwner] = PrivilegeLevel.Controller;
emit PrivilegeChanged(newOwner, PrivilegeLevel.Controller);
_transferOwnership(newOwner);
}

/// @inheritdoc ISpacePrivilegedActions
function renounceOwnership() public override(ISpacePrivilegedActions, OwnableUpgradeable) onlyOwner {
privileges[owner()] = PrivilegeLevel.None;
emit PrivilegeChanged(owner(), PrivilegeLevel.None);
_transferOwnership(address(0));
}

/// @inheritdoc ISpacePrivilegedActions
function grantPrivilege(address user, PrivilegeLevel level) external {
PrivilegeLevel userLevel = privileges[msg.sender];

if (userLevel < PrivilegeLevel.Admin) {
// Must be at least Admin to grant privileges.
revert InvalidPrivilegeLevel();
} else if (userLevel == PrivilegeLevel.Admin) {
// If user is Admin, he may only grant up to Moderator privileges.
if (level >= PrivilegeLevel.Admin) {
revert InvalidPrivilegeLevel();
}
} else if (userLevel == PrivilegeLevel.Controller) {
// The user should call `transferOwnership` to transfer the controller role.
if (level == PrivilegeLevel.Controller) {
revert InvalidPrivilegeLevel();
}
} else {
// Unreachable code, but might become reachable if the enum is updated.
revert InvalidPrivilegeLevel();
}

privileges[user] = level;
emit PrivilegeChanged(user, level);
}

/// @inheritdoc ISpacePrivilegedActions
// solhint-disable-next-line code-complexity
function updateSettings(UpdateSettingsCalldata calldata input) external override onlyOwner {
if ((input.minVotingDuration != NO_UPDATE_UINT32) && (input.maxVotingDuration != NO_UPDATE_UINT32)) {
// Check that min and max VotingDuration are valid
// We don't use the internal `_setMinVotingDuration` and `_setMaxVotingDuration` functions because
// it would revert when `_minVotingDuration > maxVotingDuration` (when the new `_min` is
// bigger than the current `max`).
if (input.minVotingDuration > input.maxVotingDuration) {
revert InvalidDuration(input.minVotingDuration, input.maxVotingDuration);
function updateSettings(UpdateSettingsCalldata calldata input) external override {
PrivilegeLevel level = privileges[msg.sender];
if (level < PrivilegeLevel.Moderator) {
// If not at least a moderator, revert.
revert InvalidPrivilegeLevel();
} else if (level == PrivilegeLevel.Moderator) {
// If moderator, the user may only edit the metadataURI.
// Ensure that every field except metadataURI is set to the NO_UPDATE value. If not, error with InvalidPrivilegeLevel.
if (
input.minVotingDuration != NO_UPDATE_UINT32 ||
input.maxVotingDuration != NO_UPDATE_UINT32 ||
input.votingDelay != NO_UPDATE_UINT32 ||
keccak256(abi.encodePacked(input.daoURI)) != NO_UPDATE_HASH ||
input.proposalValidationStrategy.addr != NO_UPDATE_ADDRESS ||
keccak256(abi.encodePacked(input.proposalValidationStrategyMetadataURI)) != NO_UPDATE_HASH ||
input.authenticatorsToAdd.length > 0 ||
input.authenticatorsToRemove.length > 0 ||
input.votingStrategiesToAdd.length > 0 ||
input.votingStrategyMetadataURIsToAdd.length > 0 ||
input.votingStrategiesToRemove.length > 0
) {
revert InvalidPrivilegeLevel();
}

minVotingDuration = input.minVotingDuration;
emit MinVotingDurationUpdated(input.minVotingDuration);

maxVotingDuration = input.maxVotingDuration;
emit MaxVotingDurationUpdated(input.maxVotingDuration);
} else if (input.minVotingDuration != NO_UPDATE_UINT32) {
_setMinVotingDuration(input.minVotingDuration);
emit MinVotingDurationUpdated(input.minVotingDuration);
} else if (input.maxVotingDuration != NO_UPDATE_UINT32) {
_setMaxVotingDuration(input.maxVotingDuration);
emit MaxVotingDurationUpdated(input.maxVotingDuration);
}
// Update metadataURI.
if (keccak256(abi.encodePacked(input.metadataURI)) != NO_UPDATE_HASH) {
emit MetadataURIUpdated(input.metadataURI);
}
} else {
// Else, the user is an admin or controller and can edit all settings.

if ((input.minVotingDuration != NO_UPDATE_UINT32) && (input.maxVotingDuration != NO_UPDATE_UINT32)) {
// Check that min and max VotingDuration are valid
// We don't use the internal `_setMinVotingDuration` and `_setMaxVotingDuration` functions because
// it would revert when `_minVotingDuration > maxVotingDuration` (when the new `_min` is
// bigger than the current `max`).
if (input.minVotingDuration > input.maxVotingDuration) {
revert InvalidDuration(input.minVotingDuration, input.maxVotingDuration);
}

minVotingDuration = input.minVotingDuration;
emit MinVotingDurationUpdated(input.minVotingDuration);

maxVotingDuration = input.maxVotingDuration;
emit MaxVotingDurationUpdated(input.maxVotingDuration);
} else if (input.minVotingDuration != NO_UPDATE_UINT32) {
_setMinVotingDuration(input.minVotingDuration);
emit MinVotingDurationUpdated(input.minVotingDuration);
} else if (input.maxVotingDuration != NO_UPDATE_UINT32) {
_setMaxVotingDuration(input.maxVotingDuration);
emit MaxVotingDurationUpdated(input.maxVotingDuration);
}

if (input.votingDelay != NO_UPDATE_UINT32) {
_setVotingDelay(input.votingDelay);
emit VotingDelayUpdated(input.votingDelay);
}
if (input.votingDelay != NO_UPDATE_UINT32) {
_setVotingDelay(input.votingDelay);
emit VotingDelayUpdated(input.votingDelay);
}

if (keccak256(abi.encodePacked(input.metadataURI)) != NO_UPDATE_HASH) {
emit MetadataURIUpdated(input.metadataURI);
}
if (keccak256(abi.encodePacked(input.metadataURI)) != NO_UPDATE_HASH) {
emit MetadataURIUpdated(input.metadataURI);
}

if (keccak256(abi.encodePacked(input.daoURI)) != NO_UPDATE_HASH) {
_setDaoURI(input.daoURI);
emit DaoURIUpdated(input.daoURI);
}
if (keccak256(abi.encodePacked(input.daoURI)) != NO_UPDATE_HASH) {
_setDaoURI(input.daoURI);
emit DaoURIUpdated(input.daoURI);
}

if (input.proposalValidationStrategy.addr != NO_UPDATE_ADDRESS) {
_setProposalValidationStrategy(input.proposalValidationStrategy);
emit ProposalValidationStrategyUpdated(
input.proposalValidationStrategy,
input.proposalValidationStrategyMetadataURI
);
}
if (input.proposalValidationStrategy.addr != NO_UPDATE_ADDRESS) {
_setProposalValidationStrategy(input.proposalValidationStrategy);
emit ProposalValidationStrategyUpdated(
input.proposalValidationStrategy,
input.proposalValidationStrategyMetadataURI
);
}

if (input.authenticatorsToAdd.length > 0) {
_addAuthenticators(input.authenticatorsToAdd);
emit AuthenticatorsAdded(input.authenticatorsToAdd);
}
if (input.authenticatorsToAdd.length > 0) {
_addAuthenticators(input.authenticatorsToAdd);
emit AuthenticatorsAdded(input.authenticatorsToAdd);
}

if (input.authenticatorsToRemove.length > 0) {
_removeAuthenticators(input.authenticatorsToRemove);
emit AuthenticatorsRemoved(input.authenticatorsToRemove);
}
if (input.authenticatorsToRemove.length > 0) {
_removeAuthenticators(input.authenticatorsToRemove);
emit AuthenticatorsRemoved(input.authenticatorsToRemove);
}

if (input.votingStrategiesToAdd.length > 0) {
if (input.votingStrategiesToAdd.length != input.votingStrategyMetadataURIsToAdd.length) {
revert ArrayLengthMismatch();
if (input.votingStrategiesToAdd.length > 0) {
if (input.votingStrategiesToAdd.length != input.votingStrategyMetadataURIsToAdd.length) {
revert ArrayLengthMismatch();
}
_addVotingStrategies(input.votingStrategiesToAdd);
emit VotingStrategiesAdded(input.votingStrategiesToAdd, input.votingStrategyMetadataURIsToAdd);
}
_addVotingStrategies(input.votingStrategiesToAdd);
emit VotingStrategiesAdded(input.votingStrategiesToAdd, input.votingStrategyMetadataURIsToAdd);
}

if (input.votingStrategiesToRemove.length > 0) {
_removeVotingStrategies(input.votingStrategiesToRemove);
emit VotingStrategiesRemoved(input.votingStrategiesToRemove);
if (input.votingStrategiesToRemove.length > 0) {
_removeVotingStrategies(input.votingStrategiesToRemove);
emit VotingStrategiesRemoved(input.votingStrategiesToRemove);
}
}
}

Expand All @@ -176,6 +257,12 @@ contract Space is ISpace, Initializable, IERC4824, UUPSUpgradeable, OwnableUpgra
_;
}

/// @dev Gates acces to users with a certain level of privilege.
modifier onlyLevel(PrivilegeLevel level) {
if (privileges[msg.sender] < level) revert InvalidPrivilegeLevel();
_;
}

// ------------------------------------
// | |
// | GETTERS |
Expand Down Expand Up @@ -208,7 +295,11 @@ contract Space is ISpace, Initializable, IERC4824, UUPSUpgradeable, OwnableUpgra
Strategy calldata executionStrategy,
bytes calldata userProposalValidationParams
) external override onlyAuthenticator {
// To submit a proposal, a user must either:
// - Have author privilege level.
// - Pass the proposal validation strategy.
if (
(privileges[author] < PrivilegeLevel.Author) &&
!IProposalValidationStrategy(proposalValidationStrategy.addr).validate(
author,
proposalValidationStrategy.params,
Expand Down Expand Up @@ -298,8 +389,8 @@ contract Space is ISpace, Initializable, IERC4824, UUPSUpgradeable, OwnableUpgra
emit ProposalExecuted(proposalId);
}

/// @inheritdoc ISpaceOwnerActions
function cancel(uint256 proposalId) external override onlyOwner {
/// @inheritdoc ISpacePrivilegedActions
function cancel(uint256 proposalId) external override onlyLevel(PrivilegeLevel.Admin) {
Proposal storage proposal = proposals[proposalId];
_assertProposalExists(proposal);
if (proposal.finalizationStatus != FinalizationStatus.Pending) revert ProposalFinalized();
Expand Down Expand Up @@ -438,4 +529,9 @@ contract Space is ISpace, Initializable, IERC4824, UUPSUpgradeable, OwnableUpgra
}
return totalVotingPower;
}

/// @inheritdoc ISpaceState
function getPrivilegeLevel(address user) external view override returns (PrivilegeLevel) {
return privileges[user];
}
}
4 changes: 2 additions & 2 deletions src/interfaces/ISpace.sol
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@ pragma solidity ^0.8.18;

import { ISpaceState } from "./space/ISpaceState.sol";
import { ISpaceActions } from "./space/ISpaceActions.sol";
import { ISpaceOwnerActions } from "./space/ISpaceOwnerActions.sol";
import { ISpacePrivilegedActions } from "./space/ISpacePrivilegedActions.sol";
import { ISpaceEvents } from "./space/ISpaceEvents.sol";
import { ISpaceErrors } from "./space/ISpaceErrors.sol";

/// @title Space Interface
// solhint-disable-next-line no-empty-blocks
interface ISpace is ISpaceState, ISpaceActions, ISpaceOwnerActions, ISpaceEvents, ISpaceErrors {
interface ISpace is ISpaceState, ISpaceActions, ISpacePrivilegedActions, ISpaceEvents, ISpaceErrors {

}
3 changes: 3 additions & 0 deletions src/interfaces/space/ISpaceErrors.sol
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,7 @@ interface ISpaceErrors {
/// @notice Thrown when the execution payload supplied to the execution strategy is not equal
/// to the payload supplied when the proposal was created.
error InvalidPayload();

// Happens if a user does not meet the required level of privilege to perform an action.
error InvalidPrivilegeLevel();
}
7 changes: 6 additions & 1 deletion src/interfaces/space/ISpaceEvents.sol
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;

import { IndexedStrategy, Proposal, Strategy, Choice, InitializeCalldata } from "src/types.sol";
import { IndexedStrategy, PrivilegeLevel, Proposal, Strategy, Choice, InitializeCalldata } from "src/types.sol";

/// @title Space Events
interface ISpaceEvents {
Expand Down Expand Up @@ -101,4 +101,9 @@ interface ISpaceEvents {
/// consisting of a strategy address and an execution payload array.
/// @param newMetadataURI The metadata URI for the proposal.
event ProposalUpdated(uint256 proposalId, Strategy newExecutionStrategy, string newMetadataURI);

/// @notice Emitted when a privilege is updated (granted, revoked, or changed).
/// @param user The address of the user.
/// @param level The new privilege level.
event PrivilegeChanged(address user, PrivilegeLevel level);
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@
pragma solidity ^0.8.18;

import { Strategy, UpdateSettingsCalldata } from "../../types.sol";
import { PrivilegeLevel } from "../../types.sol";

/// @title Space Owner Actions
/// @notice The actions that can be performed by the owner of a Space,
/// These are in addition to the methods exposed by the `OwnableUpgradeable` module and the
/// `upgradeTo()` method of `UUPSUpgradeable`.
interface ISpaceOwnerActions {
interface ISpacePrivilegedActions {
/// @notice Cancels a proposal that has not already been finalized.
/// @param proposalId The proposal to cancel.
function cancel(uint256 proposalId) external;
Expand All @@ -31,4 +32,20 @@ interface ISpaceOwnerActions {
/// an empty array to ignore.
/// votignStrategiesToRemove The indices of voting strategies to remove. Set to empty array to ignore.
function updateSettings(UpdateSettingsCalldata calldata input) external;

/// @notice Transfers ownership of the Space to a new address.
/// @param user The address of the user to grant the privilege to.
/// @param level The privilege level to grant.
/// @dev Only Admins and Controller can grant privileges.
/// @dev Cannot grant `Controller` privilege. Use `transferOwnership` instead.
function grantPrivilege(address user, PrivilegeLevel level) external;

/// @notice Transfers ownership of the Space to a new address.
/// @param newOwner The address to transfer ownership to.
/// @dev Can only be called by the current owner.
function transferOwnership(address newOwner) external;

/// @notice Renounces ownership of the Space.
/// @dev Can only be called by the current owner.
function renounceOwnership() external;
}
Loading

0 comments on commit b1b55e7

Please sign in to comment.