Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add Privilege levels #255

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
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 .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
226 changes: 167 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 Down Expand Up @@ -99,74 +102,164 @@ contract Space is ISpace, Initializable, IERC4824, UUPSUpgradeable, OwnableUpgra
// | |
// ------------------------------------

/// @inheritdoc ISpaceOwnerActions
// 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);
/// @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 target, PrivilegeLevel level) external {
PrivilegeLevel senderLevel = privileges[msg.sender];
PrivilegeLevel targetLevel = privileges[target];

if (senderLevel < PrivilegeLevel.Admin) {
// Sender must be at least Admin to grant privileges.
revert InvalidPrivilegeLevel();
} else if (senderLevel == PrivilegeLevel.Admin) {
// An admin cannot modify other admins or controllers.
if (targetLevel >= PrivilegeLevel.Admin) {
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);
}
// If the sender is Admin, he may only grant up to Moderator privileges.
if (level >= PrivilegeLevel.Admin) {
revert InvalidPrivilegeLevel();
}
} else if (senderLevel == PrivilegeLevel.Controller) {
// A controller cannot modify other controllers.
// (n.b: there should only be a single controller at any point in time)
if (targetLevel == PrivilegeLevel.Controller) {
revert InvalidPrivilegeLevel();
}

if (input.votingDelay != NO_UPDATE_UINT32) {
_setVotingDelay(input.votingDelay);
emit VotingDelayUpdated(input.votingDelay);
// The sender 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();
}

if (keccak256(abi.encodePacked(input.metadataURI)) != NO_UPDATE_HASH) {
emit MetadataURIUpdated(input.metadataURI);
}
// Proceed to grant the privilege.
privileges[target] = level;
emit PrivilegeChanged(target, level);
}

if (keccak256(abi.encodePacked(input.daoURI)) != NO_UPDATE_HASH) {
_setDaoURI(input.daoURI);
emit DaoURIUpdated(input.daoURI);
}
/// @inheritdoc ISpacePrivilegedActions
// solhint-disable-next-line code-complexity
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();
}

if (input.proposalValidationStrategy.addr != NO_UPDATE_ADDRESS) {
_setProposalValidationStrategy(input.proposalValidationStrategy);
emit ProposalValidationStrategyUpdated(
input.proposalValidationStrategy,
input.proposalValidationStrategyMetadataURI
);
}
// 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.authenticatorsToAdd.length > 0) {
_addAuthenticators(input.authenticatorsToAdd);
emit AuthenticatorsAdded(input.authenticatorsToAdd);
}
if (input.votingDelay != NO_UPDATE_UINT32) {
_setVotingDelay(input.votingDelay);
emit VotingDelayUpdated(input.votingDelay);
}

if (input.authenticatorsToRemove.length > 0) {
_removeAuthenticators(input.authenticatorsToRemove);
emit AuthenticatorsRemoved(input.authenticatorsToRemove);
}
if (keccak256(abi.encodePacked(input.metadataURI)) != NO_UPDATE_HASH) {
emit MetadataURIUpdated(input.metadataURI);
}

if (input.votingStrategiesToAdd.length > 0) {
if (input.votingStrategiesToAdd.length != input.votingStrategyMetadataURIsToAdd.length) {
revert ArrayLengthMismatch();
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.authenticatorsToAdd.length > 0) {
_addAuthenticators(input.authenticatorsToAdd);
emit AuthenticatorsAdded(input.authenticatorsToAdd);
}

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();
}
_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 +269,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 +307,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 +401,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 +541,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);
}
Loading
Loading