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
55 changes: 55 additions & 0 deletions src/EligibilityModule.sol
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ contract EligibilityModule is Initializable, IHatsEligibility {
error HasNotVouched();
error VouchingRateLimitExceeded();
error NewUserVouchingRestricted();
error ApplicationAlreadyExists();
error NoActiveApplication();
error InvalidApplicationHash();

/*═════════════════════════════════════════ STRUCTS ═════════════════════════════════════════*/

Expand Down Expand Up @@ -88,6 +91,9 @@ contract EligibilityModule is Initializable, IHatsEligibility {
// Rate limiting for vouching
mapping(address => uint256) userJoinTime;
mapping(address => mapping(uint256 => uint32)) dailyVouchCount; // user => day => count
// Role application system
mapping(uint256 => mapping(address => bytes32)) roleApplications; // hatId => applicant => applicationHash
mapping(uint256 => address[]) roleApplicants; // hatId => array of applicant addresses
}

// keccak256("poa.eligibilitymodule.storage") → unique, collision-free slot
Expand Down Expand Up @@ -166,6 +172,8 @@ contract EligibilityModule is Initializable, IHatsEligibility {
event Paused(address indexed account);
event Unpaused(address indexed account);
event HatMetadataUpdated(uint256 indexed hatId, string name, bytes32 metadataCID);
event RoleApplicationSubmitted(uint256 indexed hatId, address indexed applicant, bytes32 applicationHash);
event RoleApplicationWithdrawn(uint256 indexed hatId, address indexed applicant);

/*═════════════════════════════════════════ MODIFIERS ═════════════════════════════════════════*/

Expand Down Expand Up @@ -801,9 +809,44 @@ contract EligibilityModule is Initializable, IHatsEligibility {
bool success = l.hats.mintHat(hatId, msg.sender);
require(success, "Hat minting failed");

// Clean up any pending role application
delete l.roleApplications[hatId][msg.sender];

emit HatClaimed(msg.sender, hatId);
}

/*═══════════════════════════════════ ROLE APPLICATION SYSTEM ═══════════════════════════════════════*/

/// @notice Submit an application for a role (hat) that has vouching enabled.
/// This is a signaling mechanism — it does not grant eligibility.
/// @param hatId The hat ID to apply for
/// @param applicationHash IPFS CID sha256 digest of the application details
function applyForRole(uint256 hatId, bytes32 applicationHash) external whenNotPaused {
if (applicationHash == bytes32(0)) revert InvalidApplicationHash();

Layout storage l = _layout();
VouchConfig memory config = l.vouchConfigs[hatId];
if (!_isVouchingEnabled(config.flags)) revert VouchingNotEnabled();
if (l.roleApplications[hatId][msg.sender] != bytes32(0)) revert ApplicationAlreadyExists();
require(!l.hats.isWearerOfHat(msg.sender, hatId), "Already wearing hat");

l.roleApplicants[hatId].push(msg.sender);
l.roleApplications[hatId][msg.sender] = applicationHash;

emit RoleApplicationSubmitted(hatId, msg.sender, applicationHash);
}

/// @notice Withdraw a previously submitted role application.
/// @param hatId The hat ID to withdraw the application from
function withdrawApplication(uint256 hatId) external whenNotPaused {
Layout storage l = _layout();
if (l.roleApplications[hatId][msg.sender] == bytes32(0)) revert NoActiveApplication();

delete l.roleApplications[hatId][msg.sender];

emit RoleApplicationWithdrawn(hatId, msg.sender);
}

/*═══════════════════════════════════ ELIGIBILITY INTERFACE ═══════════════════════════════════════*/

function getWearerStatus(address wearer, uint256 hatId) external view returns (bool eligible, bool standing) {
Expand Down Expand Up @@ -918,6 +961,18 @@ contract EligibilityModule is Initializable, IHatsEligibility {
return _layout().hasSpecificWearerRules[wearer][hatId];
}

function getRoleApplication(uint256 hatId, address applicant) external view returns (bytes32) {
return _layout().roleApplications[hatId][applicant];
}

function getRoleApplicants(uint256 hatId) external view returns (address[] memory) {
return _layout().roleApplicants[hatId];
}

function hasActiveApplication(uint256 hatId, address applicant) external view returns (bool) {
return _layout().roleApplications[hatId][applicant] != bytes32(0);
}

/*═════════════════════════════════════ PUBLIC GETTERS ═════════════════════════════════════════*/

function hats() external view returns (IHats) {
Expand Down
6 changes: 6 additions & 0 deletions src/Executor.sol
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import "@openzeppelin-contracts-upgradeable/contracts/access/OwnableUpgradeable.
import "@openzeppelin-contracts-upgradeable/contracts/utils/PausableUpgradeable.sol";
import "@openzeppelin-contracts-upgradeable/contracts/utils/ReentrancyGuardUpgradeable.sol";
import {IHats} from "@hats-protocol/src/Interfaces/IHats.sol";
import {SwitchableBeacon} from "./SwitchableBeacon.sol";

interface IExecutor {
struct Call {
Expand Down Expand Up @@ -129,6 +130,11 @@ contract Executor is Initializable, OwnableUpgradeable, PausableUpgradeable, Ree
emit BatchExecuted(proposalId, len);
}

/* ─────────── Beacon ownership ─────────── */
function acceptBeaconOwnership(address beacon) external onlyOwner {
SwitchableBeacon(beacon).acceptOwnership();
}

/* ─────────── Guardian helpers ─────────── */
function pause() external onlyOwner {
_pause();
Expand Down
4 changes: 4 additions & 0 deletions src/OrgDeployer.sol
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ interface IParticipationToken {
interface IExecutorAdmin {
function setCaller(address) external;
function setHatMinterAuthorization(address minter, bool authorized) external;
function acceptBeaconOwnership(address beacon) external;
function configureVouching(
address eligibilityModule,
uint256 hatId,
Expand Down Expand Up @@ -325,6 +326,9 @@ contract OrgDeployer is Initializable {
GovernanceFactory.GovernanceResult memory gov = _deployGovernanceInfrastructure(params);
result.executor = gov.executor;

/* 2b. Accept executor beacon ownership (two-step transfer initiated by GovernanceFactory) */
IExecutorAdmin(result.executor).acceptBeaconOwnership(gov.execBeacon);

/* 3. Set the executor for the org */
l.orgRegistry.setOrgExecutor(params.orgId, result.executor);

Expand Down
16 changes: 0 additions & 16 deletions src/OrgRegistry.sol
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,6 @@ contract OrgRegistry is Initializable, OwnableUpgradeable {
bool autoUpgrade,
address owner
);
event AutoUpgradeSet(bytes32 indexed contractId, bool enabled);
event HatsTreeRegistered(bytes32 indexed orgId, uint256 topHatId, uint256[] roleHatIds);
event OrgMetadataAdminHatSet(bytes32 indexed orgId, uint256 hatId);

Expand Down Expand Up @@ -369,21 +368,6 @@ contract OrgRegistry is Initializable, OwnableUpgradeable {
}
}

function setAutoUpgrade(bytes32 orgId, bytes32 typeId, bool enabled) external {
Layout storage l = _layout();
OrgInfo storage o = l.orgOf[orgId];
if (!o.exists) revert OrgUnknown();
if (msg.sender != o.executor) revert NotOrgExecutor();

address proxy = l.proxyOf[orgId][typeId];
if (proxy == address(0)) revert ContractUnknown();

bytes32 contractId = keccak256(abi.encodePacked(orgId, typeId));
l.contractOf[contractId].autoUpgrade = enabled;

emit AutoUpgradeSet(contractId, enabled);
}

/* ═════════════════ VIEW HELPERS ═════════════════ */
function getOrgContract(bytes32 orgId, bytes32 typeId) external view returns (address proxy) {
Layout storage l = _layout();
Expand Down
34 changes: 29 additions & 5 deletions src/SwitchableBeacon.sol
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ contract SwitchableBeacon is IBeacon {
/// @notice Current owner of this beacon (typically the Executor or UpgradeAdmin)
address public owner;

/// @notice Address that has been proposed as the new owner (two-step transfer)
address public pendingOwner;

/// @notice The global POA beacon to mirror when in Mirror mode
address public mirrorBeacon;

Expand All @@ -47,9 +50,15 @@ contract SwitchableBeacon is IBeacon {
/// @param implementation The address of the pinned implementation
event Pinned(address indexed implementation);

/// @notice Emitted when a new ownership transfer is initiated
event OwnershipTransferStarted(address indexed previousOwner, address indexed newOwner);

/// @notice Thrown when a non-owner attempts a restricted operation
error NotOwner();

/// @notice Thrown when caller is not the pending owner
error NotPendingOwner();

/// @notice Thrown when a zero address is provided where not allowed
error ZeroAddress();

Expand Down Expand Up @@ -95,21 +104,36 @@ contract SwitchableBeacon is IBeacon {
mode = _mode;

emit OwnerTransferred(address(0), _owner);
if (_mode == Mode.Mirror) {
emit MirrorSet(_mirrorBeacon);
}
emit ModeChanged(_mode);
}

/**
* @notice Transfers ownership of the beacon to a new address
* @param newOwner The address of the new owner
* @dev Only callable by the current owner
* @notice Initiates a two-step ownership transfer
* @param newOwner The address of the proposed new owner
* @dev Only callable by the current owner. The new owner must call acceptOwnership() to complete.
*/
function transferOwnership(address newOwner) external onlyOwner {
if (newOwner == address(0)) revert ZeroAddress();

pendingOwner = newOwner;
emit OwnershipTransferStarted(owner, newOwner);
}

/**
* @notice Completes the two-step ownership transfer
* @dev Only callable by the pending owner set via transferOwnership()
*/
function acceptOwnership() external {
if (msg.sender != pendingOwner) revert NotPendingOwner();

address previousOwner = owner;
owner = newOwner;
owner = msg.sender;
pendingOwner = address(0);

emit OwnerTransferred(previousOwner, newOwner);
emit OwnerTransferred(previousOwner, msg.sender);
}

/**
Expand Down
12 changes: 12 additions & 0 deletions src/TaskManager.sol
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ contract TaskManager is Initializable, ContextUpgradeable {
event TaskAssigned(uint256 indexed id, address indexed assignee, address indexed assigner);
event TaskCompleted(uint256 indexed id, address indexed completer);
event TaskCancelled(uint256 indexed id, address indexed canceller);
event TaskRejected(uint256 indexed id, address indexed rejector, bytes32 rejectionHash);
event TaskApplicationSubmitted(uint256 indexed id, address indexed applicant, bytes32 applicationHash);
event TaskApplicationApproved(uint256 indexed id, address indexed applicant, address indexed approver);
event ExecutorUpdated(address newExecutor);
Expand Down Expand Up @@ -513,6 +514,17 @@ contract TaskManager is Initializable, ContextUpgradeable {
emit TaskCompleted(id, _msgSender());
}

function rejectTask(uint256 id, bytes32 rejectionHash) external {
Layout storage l = _layout();
_checkPerm(l._tasks[id].projectId, TaskPerm.REVIEW);
Task storage t = _task(l, id);
if (t.status != Status.SUBMITTED) revert BadStatus();
if (rejectionHash == bytes32(0)) revert ValidationLib.InvalidString();

t.status = Status.CLAIMED;
emit TaskRejected(id, _msgSender(), rejectionHash);
}

function cancelTask(uint256 id) external {
_requireCanCreate(_layout()._tasks[id].projectId);
Layout storage l = _layout();
Expand Down
16 changes: 9 additions & 7 deletions src/factories/GovernanceFactory.sol
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ contract GovernanceFactory {
address toggleModule;
address hybridVoting; // Governance mechanism
address directDemocracyVoting; // Polling mechanism
address execBeacon; // Executor's SwitchableBeacon (for two-step ownership acceptance)
uint256 topHatId;
uint256[] roleHatIds;
}
Expand Down Expand Up @@ -138,11 +139,11 @@ contract GovernanceFactory {

/* 2. Deploy and configure modules for Hats tree (without registration) */
(result.eligibilityModule, eligibilityBeacon) = _deployEligibilityModule(
params.orgId, params.poaManager, params.orgRegistry, params.hats, params.autoUpgrade, params.deployer
params.orgId, params.poaManager, params.orgRegistry, params.hats, params.autoUpgrade, result.executor
);

(result.toggleModule, toggleBeacon) = _deployToggleModule(
params.orgId, params.poaManager, params.orgRegistry, params.hats, params.autoUpgrade, params.deployer
params.orgId, params.poaManager, params.orgRegistry, params.hats, params.autoUpgrade, result.executor
);

/* 3. Setup Hats Tree */
Expand Down Expand Up @@ -200,8 +201,9 @@ contract GovernanceFactory {
IOrgDeployer(params.deployer).batchRegisterContracts(params.orgId, registrations, params.autoUpgrade, false);
}

/* 5. Transfer executor beacon ownership back to executor itself */
/* 5. Initiate two-step ownership transfer for executor beacon */
SwitchableBeacon(execBeacon).transferOwnership(result.executor);
result.execBeacon = execBeacon;

return result;
}
Expand Down Expand Up @@ -350,10 +352,10 @@ contract GovernanceFactory {
address orgRegistry,
address hats,
bool autoUpgrade,
address deployer
address beaconOwner
) internal returns (address emProxy, address beacon) {
beacon = BeaconDeploymentLib.createBeacon(
ModuleTypes.ELIGIBILITY_MODULE_ID, poaManager, address(this), autoUpgrade, address(0)
ModuleTypes.ELIGIBILITY_MODULE_ID, poaManager, beaconOwner, autoUpgrade, address(0)
);

ModuleDeploymentLib.DeployConfig memory config = ModuleDeploymentLib.DeployConfig({
Expand Down Expand Up @@ -381,10 +383,10 @@ contract GovernanceFactory {
address orgRegistry,
address hats,
bool autoUpgrade,
address deployer
address beaconOwner
) internal returns (address tmProxy, address beacon) {
beacon = BeaconDeploymentLib.createBeacon(
ModuleTypes.TOGGLE_MODULE_ID, poaManager, address(this), autoUpgrade, address(0)
ModuleTypes.TOGGLE_MODULE_ID, poaManager, beaconOwner, autoUpgrade, address(0)
);

ModuleDeploymentLib.DeployConfig memory config = ModuleDeploymentLib.DeployConfig({
Expand Down
Loading