diff --git a/src/EligibilityModule.sol b/src/EligibilityModule.sol index b554d05..04409f1 100644 --- a/src/EligibilityModule.sol +++ b/src/EligibilityModule.sol @@ -37,6 +37,9 @@ contract EligibilityModule is Initializable, IHatsEligibility { error HasNotVouched(); error VouchingRateLimitExceeded(); error NewUserVouchingRestricted(); + error ApplicationAlreadyExists(); + error NoActiveApplication(); + error InvalidApplicationHash(); /*═════════════════════════════════════════ STRUCTS ═════════════════════════════════════════*/ @@ -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 @@ -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 ═════════════════════════════════════════*/ @@ -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) { @@ -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) { diff --git a/src/Executor.sol b/src/Executor.sol index 4472b2b..c7da034 100644 --- a/src/Executor.sol +++ b/src/Executor.sol @@ -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 { @@ -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(); diff --git a/src/OrgDeployer.sol b/src/OrgDeployer.sol index ca6225d..2c11cf2 100644 --- a/src/OrgDeployer.sol +++ b/src/OrgDeployer.sol @@ -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, @@ -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); diff --git a/src/OrgRegistry.sol b/src/OrgRegistry.sol index 96c9c9f..2033de0 100644 --- a/src/OrgRegistry.sol +++ b/src/OrgRegistry.sol @@ -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); @@ -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(); diff --git a/src/SwitchableBeacon.sol b/src/SwitchableBeacon.sol index f4785e7..da7975a 100644 --- a/src/SwitchableBeacon.sol +++ b/src/SwitchableBeacon.sol @@ -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; @@ -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(); @@ -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); } /** diff --git a/src/TaskManager.sol b/src/TaskManager.sol index fafff48..f3e5a33 100644 --- a/src/TaskManager.sol +++ b/src/TaskManager.sol @@ -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); @@ -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(); diff --git a/src/factories/GovernanceFactory.sol b/src/factories/GovernanceFactory.sol index 3bf2823..6fe089f 100644 --- a/src/factories/GovernanceFactory.sol +++ b/src/factories/GovernanceFactory.sol @@ -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; } @@ -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 */ @@ -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; } @@ -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({ @@ -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({ diff --git a/test/DeployerTest.t.sol b/test/DeployerTest.t.sol index 7d110b6..7e1ed7c 100644 --- a/test/DeployerTest.t.sol +++ b/test/DeployerTest.t.sol @@ -36,6 +36,7 @@ import {ModuleTypes} from "../src/libs/ModuleTypes.sol"; import {EligibilityModule} from "../src/EligibilityModule.sol"; import {ToggleModule} from "../src/ToggleModule.sol"; import {IExecutor} from "../src/Executor.sol"; +import {SwitchableBeacon} from "../src/SwitchableBeacon.sol"; import {IHats} from "@hats-protocol/src/Interfaces/IHats.sol"; import {MockERC20} from "./mocks/MockERC20.sol"; import {PaymasterHub} from "../src/PaymasterHub.sol"; @@ -68,6 +69,10 @@ interface IEligibilityModuleEvents { bool defaultStanding, uint256 mintedCount ); + + event RoleApplicationSubmitted(uint256 indexed hatId, address indexed applicant, bytes32 applicationHash); + + event RoleApplicationWithdrawn(uint256 indexed hatId, address indexed applicant); } /*────────────── Test contract ───────────*/ @@ -3674,4 +3679,305 @@ contract DeployerTest is Test, IEligibilityModuleEvents { assertEq(token.educationHub(), address(0), "EducationHub should be cleared to address(0)"); } + + /*───────────────── ROLE APPLICATION TESTS ───────────────────*/ + + function testRoleApplicationBasic() public { + TestOrgSetup memory setup = _createTestOrg("App Basic DAO"); + address applicant = address(0x400); + + _setupUserForVouching(setup.eligibilityModule, setup.exec, applicant); + + // Configure vouching on DEFAULT hat + _configureVouching( + setup.eligibilityModule, setup.exec, setup.defaultRoleHat, 2, setup.memberRoleHat, false, true + ); + + bytes32 appHash = keccak256("my-application-ipfs-hash"); + + // Apply + vm.prank(applicant); + EligibilityModule(setup.eligibilityModule).applyForRole(setup.defaultRoleHat, appHash); + + // Verify storage + assertEq( + EligibilityModule(setup.eligibilityModule).getRoleApplication(setup.defaultRoleHat, applicant), + appHash, + "Application hash should be stored" + ); + assertTrue( + EligibilityModule(setup.eligibilityModule).hasActiveApplication(setup.defaultRoleHat, applicant), + "Should have active application" + ); + address[] memory applicants = EligibilityModule(setup.eligibilityModule).getRoleApplicants(setup.defaultRoleHat); + assertEq(applicants.length, 1, "Should have 1 applicant"); + assertEq(applicants[0], applicant, "Applicant address should match"); + } + + function testRoleApplicationEmitsEvent() public { + TestOrgSetup memory setup = _createTestOrg("App Event DAO"); + address applicant = address(0x401); + + _setupUserForVouching(setup.eligibilityModule, setup.exec, applicant); + + _configureVouching( + setup.eligibilityModule, setup.exec, setup.defaultRoleHat, 2, setup.memberRoleHat, false, true + ); + + bytes32 appHash = keccak256("app-hash"); + + vm.prank(applicant); + vm.expectEmit(true, true, false, true); + emit RoleApplicationSubmitted(setup.defaultRoleHat, applicant, appHash); + EligibilityModule(setup.eligibilityModule).applyForRole(setup.defaultRoleHat, appHash); + } + + function testRoleApplicationWithdraw() public { + TestOrgSetup memory setup = _createTestOrg("App Withdraw DAO"); + address applicant = address(0x402); + + _setupUserForVouching(setup.eligibilityModule, setup.exec, applicant); + + _configureVouching( + setup.eligibilityModule, setup.exec, setup.defaultRoleHat, 2, setup.memberRoleHat, false, true + ); + + bytes32 appHash = keccak256("app-hash"); + + // Apply + vm.prank(applicant); + EligibilityModule(setup.eligibilityModule).applyForRole(setup.defaultRoleHat, appHash); + + // Withdraw + vm.prank(applicant); + vm.expectEmit(true, true, false, false); + emit RoleApplicationWithdrawn(setup.defaultRoleHat, applicant); + EligibilityModule(setup.eligibilityModule).withdrawApplication(setup.defaultRoleHat); + + assertFalse( + EligibilityModule(setup.eligibilityModule).hasActiveApplication(setup.defaultRoleHat, applicant), + "Application should be cleared" + ); + + // Can reapply after withdrawal + bytes32 newHash = keccak256("updated-app"); + vm.prank(applicant); + EligibilityModule(setup.eligibilityModule).applyForRole(setup.defaultRoleHat, newHash); + + assertEq( + EligibilityModule(setup.eligibilityModule).getRoleApplication(setup.defaultRoleHat, applicant), + newHash, + "New application should be stored" + ); + } + + function testRoleApplicationInvalidHash() public { + TestOrgSetup memory setup = _createTestOrg("App Invalid DAO"); + address applicant = address(0x403); + + _configureVouching( + setup.eligibilityModule, setup.exec, setup.defaultRoleHat, 2, setup.memberRoleHat, false, true + ); + + vm.prank(applicant); + vm.expectRevert(EligibilityModule.InvalidApplicationHash.selector); + EligibilityModule(setup.eligibilityModule).applyForRole(setup.defaultRoleHat, bytes32(0)); + } + + function testRoleApplicationVouchingNotEnabled() public { + TestOrgSetup memory setup = _createTestOrg("App NoVouch DAO"); + address applicant = address(0x404); + + // Don't configure vouching — default hat has no vouching + vm.prank(applicant); + vm.expectRevert(EligibilityModule.VouchingNotEnabled.selector); + EligibilityModule(setup.eligibilityModule).applyForRole(setup.defaultRoleHat, keccak256("app")); + } + + function testRoleApplicationDuplicate() public { + TestOrgSetup memory setup = _createTestOrg("App Dup DAO"); + address applicant = address(0x405); + + _setupUserForVouching(setup.eligibilityModule, setup.exec, applicant); + + _configureVouching( + setup.eligibilityModule, setup.exec, setup.defaultRoleHat, 2, setup.memberRoleHat, false, true + ); + + vm.prank(applicant); + EligibilityModule(setup.eligibilityModule).applyForRole(setup.defaultRoleHat, keccak256("first")); + + vm.prank(applicant); + vm.expectRevert(EligibilityModule.ApplicationAlreadyExists.selector); + EligibilityModule(setup.eligibilityModule).applyForRole(setup.defaultRoleHat, keccak256("second")); + } + + function testRoleApplicationAlreadyWearing() public { + TestOrgSetup memory setup = _createTestOrg("App Wearing DAO"); + + // Mint MEMBER hat to voter1 (default eligibility is true) + _mintHat(setup.exec, setup.memberRoleHat, voter1); + + // Configure vouching with combineWithHierarchy=true so hierarchy eligibility preserves wearer status + _configureVouching( + setup.eligibilityModule, setup.exec, setup.memberRoleHat, 2, setup.defaultRoleHat, true, false + ); + + // voter1 already wears memberRoleHat, so applying should revert + vm.prank(voter1); + vm.expectRevert("Already wearing hat"); + EligibilityModule(setup.eligibilityModule).applyForRole(setup.memberRoleHat, keccak256("app")); + } + + function testRoleApplicationFullFlow() public { + TestOrgSetup memory setup = _createTestOrg("App Full DAO"); + address applicant = address(0x406); + + _setupUserForVouching(setup.eligibilityModule, setup.exec, voter1); + _setupUserForVouching(setup.eligibilityModule, setup.exec, voter2); + _setupUserForVouching(setup.eligibilityModule, setup.exec, applicant); + + // Configure vouching: 2 vouches from MEMBER hat + _configureVouching( + setup.eligibilityModule, setup.exec, setup.defaultRoleHat, 2, setup.memberRoleHat, false, true + ); + + // Mint MEMBER hats to vouchers + _mintHat(setup.exec, setup.memberRoleHat, voter1); + _mintHat(setup.exec, setup.memberRoleHat, voter2); + + // 1. Applicant applies + bytes32 appHash = keccak256("my-application"); + vm.prank(applicant); + EligibilityModule(setup.eligibilityModule).applyForRole(setup.defaultRoleHat, appHash); + + assertTrue( + EligibilityModule(setup.eligibilityModule).hasActiveApplication(setup.defaultRoleHat, applicant), + "Should have active application" + ); + + // 2. Vouchers vouch + _vouchFor(voter1, setup.eligibilityModule, applicant, setup.defaultRoleHat); + _vouchFor(voter2, setup.eligibilityModule, applicant, setup.defaultRoleHat); + + // 3. Applicant claims hat + vm.prank(applicant); + EligibilityModule(setup.eligibilityModule).claimVouchedHat(setup.defaultRoleHat); + + // Verify: wearing hat + _assertWearingHat(applicant, setup.defaultRoleHat, true, "After claim"); + + // Verify: application auto-cleaned + assertFalse( + EligibilityModule(setup.eligibilityModule).hasActiveApplication(setup.defaultRoleHat, applicant), + "Application should be cleared after claim" + ); + } + + function testRoleApplicationMultipleApplicants() public { + TestOrgSetup memory setup = _createTestOrg("App Multi DAO"); + address applicant1 = address(0x407); + address applicant2 = address(0x408); + + _setupUserForVouching(setup.eligibilityModule, setup.exec, applicant1); + _setupUserForVouching(setup.eligibilityModule, setup.exec, applicant2); + + _configureVouching( + setup.eligibilityModule, setup.exec, setup.defaultRoleHat, 2, setup.memberRoleHat, false, true + ); + + vm.prank(applicant1); + EligibilityModule(setup.eligibilityModule).applyForRole(setup.defaultRoleHat, keccak256("app1")); + + vm.prank(applicant2); + EligibilityModule(setup.eligibilityModule).applyForRole(setup.defaultRoleHat, keccak256("app2")); + + // Both should have active applications + assertTrue( + EligibilityModule(setup.eligibilityModule).hasActiveApplication(setup.defaultRoleHat, applicant1), + "Applicant1 should have application" + ); + assertTrue( + EligibilityModule(setup.eligibilityModule).hasActiveApplication(setup.defaultRoleHat, applicant2), + "Applicant2 should have application" + ); + + // Applications should be independent + assertEq( + EligibilityModule(setup.eligibilityModule).getRoleApplication(setup.defaultRoleHat, applicant1), + keccak256("app1"), + "Applicant1 hash should match" + ); + assertEq( + EligibilityModule(setup.eligibilityModule).getRoleApplication(setup.defaultRoleHat, applicant2), + keccak256("app2"), + "Applicant2 hash should match" + ); + + address[] memory applicants = EligibilityModule(setup.eligibilityModule).getRoleApplicants(setup.defaultRoleHat); + assertEq(applicants.length, 2, "Should have 2 applicants"); + } + + function testRoleApplicationWhilePaused() public { + TestOrgSetup memory setup = _createTestOrg("App Paused DAO"); + address applicant = address(0x409); + + _configureVouching( + setup.eligibilityModule, setup.exec, setup.defaultRoleHat, 2, setup.memberRoleHat, false, true + ); + + // Pause the module + vm.prank(setup.exec); + EligibilityModule(setup.eligibilityModule).pause(); + + // Apply should revert + vm.prank(applicant); + vm.expectRevert("Contract is paused"); + EligibilityModule(setup.eligibilityModule).applyForRole(setup.defaultRoleHat, keccak256("app")); + + // Withdraw should also revert (even though there's nothing to withdraw, pause check comes first) + vm.prank(applicant); + vm.expectRevert("Contract is paused"); + EligibilityModule(setup.eligibilityModule).withdrawApplication(setup.defaultRoleHat); + } + + function testWithdrawApplicationNoActiveReverts() public { + TestOrgSetup memory setup = _createTestOrg("App NoActive DAO"); + address applicant = address(0x410); + + _configureVouching( + setup.eligibilityModule, setup.exec, setup.defaultRoleHat, 2, setup.memberRoleHat, false, true + ); + + vm.prank(applicant); + vm.expectRevert(EligibilityModule.NoActiveApplication.selector); + EligibilityModule(setup.eligibilityModule).withdrawApplication(setup.defaultRoleHat); + } + + /* ═══════════════════════════════════════════════════════════════════ + Beacon Ownership Tests (Fix 1 — all module beacons owned by executor) + ═══════════════════════════════════════════════════════════════════ */ + + function _getBeaconForType(bytes32 typeId) internal view returns (address) { + bytes32 contractId = keccak256(abi.encodePacked(ORG_ID, typeId)); + return orgRegistry.getContractBeacon(contractId); + } + + function testExecutorBeaconOwnedByExecutor() public { + TestOrgSetup memory setup = _createTestOrg("Beacon Owner DAO"); + address beacon = _getBeaconForType(ModuleTypes.EXECUTOR_ID); + assertEq(SwitchableBeacon(beacon).owner(), setup.exec, "Executor beacon should be owned by executor"); + } + + function testEligibilityBeaconOwnedByExecutor() public { + TestOrgSetup memory setup = _createTestOrg("Beacon Owner DAO"); + address beacon = _getBeaconForType(ModuleTypes.ELIGIBILITY_MODULE_ID); + assertEq(SwitchableBeacon(beacon).owner(), setup.exec, "Eligibility beacon should be owned by executor"); + } + + function testToggleBeaconOwnedByExecutor() public { + TestOrgSetup memory setup = _createTestOrg("Beacon Owner DAO"); + address beacon = _getBeaconForType(ModuleTypes.TOGGLE_MODULE_ID); + assertEq(SwitchableBeacon(beacon).owner(), setup.exec, "Toggle beacon should be owned by executor"); + } } diff --git a/test/SwitchableBeacon.t.sol b/test/SwitchableBeacon.t.sol index fa2921c..c4e43f3 100644 --- a/test/SwitchableBeacon.t.sol +++ b/test/SwitchableBeacon.t.sol @@ -27,6 +27,7 @@ contract SwitchableBeaconTest is Test { // Events event OwnerTransferred(address indexed previousOwner, address indexed newOwner); + event OwnershipTransferStarted(address indexed previousOwner, address indexed newOwner); event ModeChanged(SwitchableBeacon.Mode mode); event MirrorSet(address indexed mirrorBeacon); event Pinned(address indexed implementation); @@ -48,6 +49,32 @@ contract SwitchableBeaconTest is Test { ); } + // ============ Constructor Event Tests ============ + + function testConstructorEmitsMirrorSetInMirrorMode() public { + vm.expectEmit(true, false, false, true); + emit MirrorSet(address(poaBeacon)); + vm.expectEmit(false, false, false, true); + emit ModeChanged(SwitchableBeacon.Mode.Mirror); + + new SwitchableBeacon(owner, address(poaBeacon), address(0), SwitchableBeacon.Mode.Mirror); + } + + function testConstructorDoesNotEmitMirrorSetInStaticMode() public { + // Should emit Pinned is NOT expected, MirrorSet is NOT expected + // Only OwnerTransferred and ModeChanged + vm.recordLogs(); + new SwitchableBeacon(owner, address(poaBeacon), address(implV1), SwitchableBeacon.Mode.Static); + + Vm.Log[] memory logs = vm.getRecordedLogs(); + for (uint256 i = 0; i < logs.length; i++) { + // MirrorSet event topic + assertTrue( + logs[i].topics[0] != keccak256("MirrorSet(address)"), "MirrorSet should not be emitted in Static mode" + ); + } + } + // ============ Mirror Mode Tests ============ function testMirrorModeTracksPoaBeacon() public { @@ -204,14 +231,29 @@ contract SwitchableBeaconTest is Test { } function testOwnershipTransfer() public { - // Transfer ownership + // Initiate transfer — emits OwnershipTransferStarted, NOT OwnerTransferred vm.expectEmit(true, true, false, false); - emit OwnerTransferred(owner, newOwner); + emit OwnershipTransferStarted(owner, newOwner); switchableBeacon.transferOwnership(newOwner); + // Owner is still the original owner until accepted + assertEq(switchableBeacon.owner(), owner); + assertEq(switchableBeacon.pendingOwner(), newOwner); + + // Old owner can still perform restricted operations + switchableBeacon.pin(address(implV1)); + + // New owner accepts + vm.expectEmit(true, true, false, false); + emit OwnerTransferred(owner, newOwner); + + vm.prank(newOwner); + switchableBeacon.acceptOwnership(); + // Verify new owner assertEq(switchableBeacon.owner(), newOwner); + assertEq(switchableBeacon.pendingOwner(), address(0)); // Old owner can't perform restricted operations vm.expectRevert(SwitchableBeacon.NotOwner.selector); @@ -223,6 +265,70 @@ contract SwitchableBeaconTest is Test { assertEq(switchableBeacon.implementation(), address(implV2)); } + // ============ Two-Step Ownership Tests ============ + + function testPendingOwnerState() public { + assertEq(switchableBeacon.pendingOwner(), address(0)); + + switchableBeacon.transferOwnership(newOwner); + assertEq(switchableBeacon.pendingOwner(), newOwner); + assertEq(switchableBeacon.owner(), owner); + } + + function testNonPendingCannotAccept() public { + switchableBeacon.transferOwnership(newOwner); + + vm.prank(unauthorized); + vm.expectRevert(SwitchableBeacon.NotPendingOwner.selector); + switchableBeacon.acceptOwnership(); + } + + function testAcceptWithoutTransferReverts() public { + vm.prank(newOwner); + vm.expectRevert(SwitchableBeacon.NotPendingOwner.selector); + switchableBeacon.acceptOwnership(); + } + + function testOldOwnerRetainsControlUntilAccept() public { + switchableBeacon.transferOwnership(newOwner); + + // Old owner can still pin, setMirror, etc. + switchableBeacon.pin(address(implV1)); + assertEq(switchableBeacon.implementation(), address(implV1)); + + switchableBeacon.setMirror(address(poaBeacon)); + assertTrue(switchableBeacon.isMirrorMode()); + } + + function testTransferOverwritesPending() public { + address secondCandidate = address(0xABCD); + + switchableBeacon.transferOwnership(newOwner); + assertEq(switchableBeacon.pendingOwner(), newOwner); + + switchableBeacon.transferOwnership(secondCandidate); + assertEq(switchableBeacon.pendingOwner(), secondCandidate); + + // First candidate can no longer accept + vm.prank(newOwner); + vm.expectRevert(SwitchableBeacon.NotPendingOwner.selector); + switchableBeacon.acceptOwnership(); + + // Second candidate can accept + vm.prank(secondCandidate); + switchableBeacon.acceptOwnership(); + assertEq(switchableBeacon.owner(), secondCandidate); + } + + function testPendingClearedAfterAccept() public { + switchableBeacon.transferOwnership(newOwner); + + vm.prank(newOwner); + switchableBeacon.acceptOwnership(); + + assertEq(switchableBeacon.pendingOwner(), address(0)); + } + // ============ Zero Address Guards ============ function testConstructorRevertsOnZeroOwner() public { @@ -398,7 +504,13 @@ contract SwitchableBeaconTest is Test { vm.assume(newAddr != address(0)); switchableBeacon.transferOwnership(newAddr); + assertEq(switchableBeacon.pendingOwner(), newAddr); + assertEq(switchableBeacon.owner(), address(this)); // still original owner + + vm.prank(newAddr); + switchableBeacon.acceptOwnership(); assertEq(switchableBeacon.owner(), newAddr); + assertEq(switchableBeacon.pendingOwner(), address(0)); } } diff --git a/test/TaskManager.t.sol b/test/TaskManager.t.sol index a7c903a..6b75fff 100644 --- a/test/TaskManager.t.sol +++ b/test/TaskManager.t.sol @@ -3214,6 +3214,201 @@ contract MockToken is Test, IERC20 { emit TaskManager.TaskApplicationApproved(0, member1, pm1); tm.approveApplication(0, member1); } + + /*───────────────── TASK REJECTION ───────────────────*/ + + function _prepareSubmittedTask() internal returns (uint256 id, bytes32 pid) { + pid = _createDefaultProject("REJECT", 5 ether); + vm.prank(executor); + tm.setConfig(TaskManager.ConfigKey.PROJECT_MANAGER, abi.encode(pid, pm1, true)); + + vm.prank(pm1); + tm.createTask(1 ether, bytes("reject_task"), bytes32(0), pid, address(0), 0, false); + id = 0; + + vm.prank(member1); + tm.claimTask(id); + + vm.prank(member1); + tm.submitTask(id, keccak256("submission")); + } + + function test_RejectTaskSendsBackToClaimed() public { + (uint256 id, bytes32 pid) = _prepareSubmittedTask(); + + vm.prank(pm1); + tm.rejectTask(id, keccak256("needs_fixes")); + + bytes memory result = lens.getStorage(address(tm), TaskManagerLens.StorageKey.TASK_INFO, abi.encode(id)); + (uint256 payout, TaskManager.Status st, address claimer,,) = + abi.decode(result, (uint256, TaskManager.Status, address, bytes32, bool)); + assertEq(uint8(st), uint8(TaskManager.Status.CLAIMED), "status should be CLAIMED"); + assertEq(claimer, member1, "claimer should be unchanged"); + } + + function test_RejectTaskEmitsEvent() public { + (uint256 id,) = _prepareSubmittedTask(); + bytes32 rejHash = keccak256("needs_fixes"); + + vm.prank(pm1); + vm.expectEmit(true, true, true, true); + emit TaskManager.TaskRejected(id, pm1, rejHash); + tm.rejectTask(id, rejHash); + } + + function test_RejectThenResubmitThenComplete() public { + (uint256 id,) = _prepareSubmittedTask(); + + // reject + vm.prank(pm1); + tm.rejectTask(id, keccak256("try_again")); + + // resubmit + vm.prank(member1); + tm.submitTask(id, keccak256("submission_v2")); + + // complete + uint256 balBefore = token.balanceOf(member1); + vm.prank(pm1); + tm.completeTask(id); + + assertEq(token.balanceOf(member1), balBefore + 1 ether, "minted payout after rejection cycle"); + bytes memory result = lens.getStorage(address(tm), TaskManagerLens.StorageKey.TASK_INFO, abi.encode(id)); + (, TaskManager.Status st,,,) = abi.decode(result, (uint256, TaskManager.Status, address, bytes32, bool)); + assertEq(uint8(st), uint8(TaskManager.Status.COMPLETED)); + } + + function test_MultipleRejectionsBeforeComplete() public { + (uint256 id,) = _prepareSubmittedTask(); + + // first rejection + vm.prank(pm1); + tm.rejectTask(id, keccak256("round_1")); + + vm.prank(member1); + tm.submitTask(id, keccak256("v2")); + + // second rejection + vm.prank(pm1); + tm.rejectTask(id, keccak256("round_2")); + + vm.prank(member1); + tm.submitTask(id, keccak256("v3")); + + // complete + vm.prank(pm1); + tm.completeTask(id); + + bytes memory result = lens.getStorage(address(tm), TaskManagerLens.StorageKey.TASK_INFO, abi.encode(id)); + (, TaskManager.Status st,,,) = abi.decode(result, (uint256, TaskManager.Status, address, bytes32, bool)); + assertEq(uint8(st), uint8(TaskManager.Status.COMPLETED)); + } + + function test_RejectTaskRequiresReviewPermission() public { + (uint256 id,) = _prepareSubmittedTask(); + + vm.prank(outsider); + vm.expectRevert(TaskManager.Unauthorized.selector); + tm.rejectTask(id, keccak256("nope")); + + vm.prank(member1); + vm.expectRevert(TaskManager.Unauthorized.selector); + tm.rejectTask(id, keccak256("nope")); + } + + function test_RejectTaskByProjectManager() public { + (uint256 id,) = _prepareSubmittedTask(); + + vm.prank(pm1); + tm.rejectTask(id, keccak256("pm_reject")); + + bytes memory result = lens.getStorage(address(tm), TaskManagerLens.StorageKey.TASK_INFO, abi.encode(id)); + (, TaskManager.Status st,,,) = abi.decode(result, (uint256, TaskManager.Status, address, bytes32, bool)); + assertEq(uint8(st), uint8(TaskManager.Status.CLAIMED)); + } + + function test_RejectUnclaimedTaskReverts() public { + bytes32 pid = _createDefaultProject("REJ_UNCL", 5 ether); + vm.prank(executor); + tm.setConfig(TaskManager.ConfigKey.PROJECT_MANAGER, abi.encode(pid, pm1, true)); + + vm.prank(pm1); + tm.createTask(1 ether, bytes("unclaimed"), bytes32(0), pid, address(0), 0, false); + + vm.prank(pm1); + vm.expectRevert(TaskManager.BadStatus.selector); + tm.rejectTask(0, keccak256("nope")); + } + + function test_RejectClaimedTaskReverts() public { + bytes32 pid = _createDefaultProject("REJ_CL", 5 ether); + vm.prank(executor); + tm.setConfig(TaskManager.ConfigKey.PROJECT_MANAGER, abi.encode(pid, pm1, true)); + + vm.prank(pm1); + tm.createTask(1 ether, bytes("claimed_only"), bytes32(0), pid, address(0), 0, false); + + vm.prank(member1); + tm.claimTask(0); + + vm.prank(pm1); + vm.expectRevert(TaskManager.BadStatus.selector); + tm.rejectTask(0, keccak256("nope")); + } + + function test_RejectCompletedTaskReverts() public { + (uint256 id,) = _prepareSubmittedTask(); + + vm.prank(pm1); + tm.completeTask(id); + + vm.prank(pm1); + vm.expectRevert(TaskManager.BadStatus.selector); + tm.rejectTask(id, keccak256("nope")); + } + + function test_RejectCancelledTaskReverts() public { + bytes32 pid = _createDefaultProject("REJ_CAN", 5 ether); + vm.prank(executor); + tm.setConfig(TaskManager.ConfigKey.PROJECT_MANAGER, abi.encode(pid, pm1, true)); + + vm.prank(pm1); + tm.createTask(1 ether, bytes("to_cancel"), bytes32(0), pid, address(0), 0, false); + + vm.prank(pm1); + tm.cancelTask(0); + + vm.prank(pm1); + vm.expectRevert(TaskManager.BadStatus.selector); + tm.rejectTask(0, keccak256("nope")); + } + + function test_RejectTaskWithEmptyHashReverts() public { + (uint256 id,) = _prepareSubmittedTask(); + + vm.prank(pm1); + vm.expectRevert(ValidationLib.InvalidString.selector); + tm.rejectTask(id, bytes32(0)); + } + + function test_RejectTaskDoesNotChangeBudget() public { + (uint256 id, bytes32 pid) = _prepareSubmittedTask(); + + bytes memory before_ = lens.getStorage( + address(tm), TaskManagerLens.StorageKey.PROJECT_INFO, abi.encode(pid) + ); + (, uint256 spentBefore,) = abi.decode(before_, (uint256, uint256, bool)); + + vm.prank(pm1); + tm.rejectTask(id, keccak256("reject")); + + bytes memory after_ = lens.getStorage( + address(tm), TaskManagerLens.StorageKey.PROJECT_INFO, abi.encode(pid) + ); + (, uint256 spentAfter,) = abi.decode(after_, (uint256, uint256, bool)); + + assertEq(spentBefore, spentAfter, "budget spent should not change on rejection"); + } } /*───────────────── BOUNTY FUNCTIONALITY TESTS ────────────────────*/ @@ -4843,6 +5038,379 @@ contract MockToken is Test, IERC20 { } } + /*────────────────── Task Application Test Suite ──────────────────*/ + + contract TaskManagerApplicationTest is TaskManagerTestBase { + bytes32 APP_PROJECT_ID; + + function setUp() public { + setUpBase(); + APP_PROJECT_ID = _createDefaultProject("APP_PROJECT", 10 ether); + } + + /// @dev Helper: creates an application-required task and returns its ID + function _createAppTask(uint256 payout) internal returns (uint256 id) { + vm.prank(creator1); + tm.createTask(payout, bytes("app_task"), bytes32(0), APP_PROJECT_ID, address(0), 0, true); + id = 0; // first task + } + + function _createAppTaskN(uint256 payout, uint256 expectedId) internal returns (uint256 id) { + vm.prank(creator1); + tm.createTask(payout, bytes("app_task"), bytes32(0), APP_PROJECT_ID, address(0), 0, true); + id = expectedId; + } + + /*──────── Basic Apply ────────*/ + + function test_ApplyForTask() public { + uint256 id = _createAppTask(1 ether); + bytes32 appHash = keccak256("my application"); + + vm.prank(member1); + tm.applyForTask(id, appHash); + + // Verify application stored via lens + bytes memory result = + lens.getStorage(address(tm), TaskManagerLens.StorageKey.TASK_APPLICATION, abi.encode(id, member1)); + bytes32 stored = abi.decode(result, (bytes32)); + assertEq(stored, appHash, "application hash should be stored"); + + // Verify applicant in list + bytes memory listResult = + lens.getStorage(address(tm), TaskManagerLens.StorageKey.TASK_APPLICANTS, abi.encode(id)); + address[] memory applicants = abi.decode(listResult, (address[])); + assertEq(applicants.length, 1, "should have 1 applicant"); + assertEq(applicants[0], member1, "applicant should be member1"); + } + + function test_ApplyForTaskEmitsEvent() public { + uint256 id = _createAppTask(1 ether); + bytes32 appHash = keccak256("my application"); + + vm.expectEmit(true, true, false, true); + emit TaskManager.TaskApplicationSubmitted(id, member1, appHash); + + vm.prank(member1); + tm.applyForTask(id, appHash); + } + + /*──────── Approve Application ────────*/ + + function test_ApproveApplicationClaimsTask() public { + uint256 id = _createAppTask(1 ether); + bytes32 appHash = keccak256("my application"); + + vm.prank(member1); + tm.applyForTask(id, appHash); + + vm.prank(pm1); + tm.approveApplication(id, member1); + + // Verify task is now CLAIMED with member1 as claimer + bytes memory result = lens.getStorage(address(tm), TaskManagerLens.StorageKey.TASK_INFO, abi.encode(id)); + (uint256 payout, TaskManagerLens.Status status, address claimer,,) = + abi.decode(result, (uint256, TaskManagerLens.Status, address, bytes32, bool)); + assertEq(uint8(status), uint8(TaskManagerLens.Status.CLAIMED), "status should be CLAIMED"); + assertEq(claimer, member1, "claimer should be member1"); + } + + function test_ApproveApplicationEmitsEvent() public { + uint256 id = _createAppTask(1 ether); + bytes32 appHash = keccak256("my application"); + + vm.prank(member1); + tm.applyForTask(id, appHash); + + vm.expectEmit(true, true, true, true); + emit TaskManager.TaskApplicationApproved(id, member1, pm1); + + vm.prank(pm1); + tm.approveApplication(id, member1); + } + + function test_ApproveApplicationClearsApplicantsList() public { + uint256 id = _createAppTask(1 ether); + + // Two applicants apply + address member2 = makeAddr("member2"); + setHat(member2, MEMBER_HAT); + + vm.prank(member1); + tm.applyForTask(id, keccak256("app1")); + vm.prank(member2); + tm.applyForTask(id, keccak256("app2")); + + // Approve member1 + vm.prank(pm1); + tm.approveApplication(id, member1); + + // Applicants list should be cleared + bytes memory result = lens.getStorage( + address(tm), TaskManagerLens.StorageKey.TASK_APPLICANTS, abi.encode(id) + ); + address[] memory applicants = abi.decode(result, (address[])); + assertEq(applicants.length, 0, "applicants list should be cleared after approval"); + } + + /*──────── Full Lifecycle ────────*/ + + function test_FullApplicationFlow() public { + uint256 id = _createAppTask(1 ether); + bytes32 appHash = keccak256("my application"); + + // Apply + vm.prank(member1); + tm.applyForTask(id, appHash); + + // Approve + vm.prank(pm1); + tm.approveApplication(id, member1); + + // Submit + vm.prank(member1); + tm.submitTask(id, keccak256("submission")); + + // Complete + vm.prank(pm1); + tm.completeTask(id); + + // Verify completed + bytes memory result = lens.getStorage(address(tm), TaskManagerLens.StorageKey.TASK_INFO, abi.encode(id)); + (, TaskManagerLens.Status status,,,) = + abi.decode(result, (uint256, TaskManagerLens.Status, address, bytes32, bool)); + assertEq(uint8(status), uint8(TaskManagerLens.Status.COMPLETED), "status should be COMPLETED"); + + // Verify tokens minted + assertEq(token.balanceOf(member1), 1 ether, "member1 should receive payout"); + } + + function test_ApplicationRejectReapplyFlow() public { + uint256 id = _createAppTask(1 ether); + + // Apply -> Approve -> Submit -> Reject -> Resubmit -> Complete + vm.prank(member1); + tm.applyForTask(id, keccak256("app")); + + vm.prank(pm1); + tm.approveApplication(id, member1); + + vm.prank(member1); + tm.submitTask(id, keccak256("bad submission")); + + vm.prank(pm1); + tm.rejectTask(id, keccak256("needs work")); + + vm.prank(member1); + tm.submitTask(id, keccak256("good submission")); + + vm.prank(pm1); + tm.completeTask(id); + + bytes memory result = lens.getStorage(address(tm), TaskManagerLens.StorageKey.TASK_INFO, abi.encode(id)); + (, TaskManagerLens.Status status,,,) = + abi.decode(result, (uint256, TaskManagerLens.Status, address, bytes32, bool)); + assertEq(uint8(status), uint8(TaskManagerLens.Status.COMPLETED)); + } + + /*──────── Multiple Applicants ────────*/ + + function test_MultipleApplicants() public { + uint256 id = _createAppTask(1 ether); + + address member2 = makeAddr("member2"); + address member3 = makeAddr("member3"); + setHat(member2, MEMBER_HAT); + setHat(member3, MEMBER_HAT); + + vm.prank(member1); + tm.applyForTask(id, keccak256("app1")); + vm.prank(member2); + tm.applyForTask(id, keccak256("app2")); + vm.prank(member3); + tm.applyForTask(id, keccak256("app3")); + + // Verify 3 applicants + bytes memory result = + lens.getStorage(address(tm), TaskManagerLens.StorageKey.TASK_APPLICANT_COUNT, abi.encode(id)); + uint256 count = abi.decode(result, (uint256)); + assertEq(count, 3, "should have 3 applicants"); + + // Approve member2 + vm.prank(pm1); + tm.approveApplication(id, member2); + + // Verify claimer is member2 + result = lens.getStorage(address(tm), TaskManagerLens.StorageKey.TASK_INFO, abi.encode(id)); + (,, address claimer,,) = abi.decode(result, (uint256, TaskManagerLens.Status, address, bytes32, bool)); + assertEq(claimer, member2, "claimer should be member2"); + } + + /*──────── Permission Checks ────────*/ + + function test_ApplyRequiresClaimPermission() public { + uint256 id = _createAppTask(1 ether); + + vm.prank(outsider); + vm.expectRevert(TaskManager.Unauthorized.selector); + tm.applyForTask(id, keccak256("app")); + } + + function test_ApproveRequiresAssignPermission() public { + uint256 id = _createAppTask(1 ether); + + vm.prank(member1); + tm.applyForTask(id, keccak256("app")); + + vm.prank(outsider); + vm.expectRevert(TaskManager.Unauthorized.selector); + tm.approveApplication(id, member1); + } + + function test_ApproveByProjectManager() public { + uint256 id = _createAppTask(1 ether); + + // Add pm1 as project manager + vm.prank(executor); + tm.setConfig(TaskManager.ConfigKey.PROJECT_MANAGER, abi.encode(APP_PROJECT_ID, pm1, true)); + + vm.prank(member1); + tm.applyForTask(id, keccak256("app")); + + // PM can approve (bypasses hat-based assign permission check) + vm.prank(pm1); + tm.approveApplication(id, member1); + + bytes memory result = lens.getStorage(address(tm), TaskManagerLens.StorageKey.TASK_INFO, abi.encode(id)); + (, TaskManagerLens.Status status,,,) = + abi.decode(result, (uint256, TaskManagerLens.Status, address, bytes32, bool)); + assertEq(uint8(status), uint8(TaskManagerLens.Status.CLAIMED)); + } + + /*──────── Error Cases ────────*/ + + function test_ApplyDuplicateReverts() public { + uint256 id = _createAppTask(1 ether); + + vm.prank(member1); + tm.applyForTask(id, keccak256("app")); + + vm.prank(member1); + vm.expectRevert(TaskManager.AlreadyApplied.selector); + tm.applyForTask(id, keccak256("app2")); + } + + function test_ApplyEmptyHashReverts() public { + uint256 id = _createAppTask(1 ether); + + vm.prank(member1); + vm.expectRevert(ValidationLib.InvalidString.selector); + tm.applyForTask(id, bytes32(0)); + } + + function test_ApplyForNonApplicationTaskReverts() public { + // Create a regular (non-application) task + vm.prank(creator1); + tm.createTask(1 ether, bytes("regular_task"), bytes32(0), APP_PROJECT_ID, address(0), 0, false); + uint256 id = 0; + + vm.prank(member1); + vm.expectRevert(TaskManager.NoApplicationRequired.selector); + tm.applyForTask(id, keccak256("app")); + } + + function test_ApplyForClaimedTaskReverts() public { + uint256 id = _createAppTask(1 ether); + + // Apply and approve to move to CLAIMED + vm.prank(member1); + tm.applyForTask(id, keccak256("app")); + vm.prank(pm1); + tm.approveApplication(id, member1); + + // New applicant tries to apply for already-claimed task + address member2 = makeAddr("member2"); + setHat(member2, MEMBER_HAT); + + vm.prank(member2); + vm.expectRevert(TaskManager.BadStatus.selector); + tm.applyForTask(id, keccak256("app2")); + } + + function test_ApproveNonApplicantReverts() public { + uint256 id = _createAppTask(1 ether); + + vm.prank(member1); + tm.applyForTask(id, keccak256("app")); + + // Try to approve someone who didn't apply + address member2 = makeAddr("member2"); + vm.prank(pm1); + vm.expectRevert(TaskManager.NotApplicant.selector); + tm.approveApplication(id, member2); + } + + function test_ApproveAlreadyClaimedReverts() public { + uint256 id = _createAppTask(1 ether); + + vm.prank(member1); + tm.applyForTask(id, keccak256("app")); + + vm.prank(pm1); + tm.approveApplication(id, member1); + + // Try to approve again (task is now CLAIMED) + vm.prank(pm1); + vm.expectRevert(TaskManager.BadStatus.selector); + tm.approveApplication(id, member1); + } + + function test_ClaimTaskWithApplicationRequiredReverts() public { + uint256 id = _createAppTask(1 ether); + + // Try to claim directly (bypass application) + vm.prank(member1); + vm.expectRevert(TaskManager.RequiresApplication.selector); + tm.claimTask(id); + } + + /*──────── Cancel Clears Applications ────────*/ + + function test_CancelTaskClearsApplications() public { + uint256 id = _createAppTask(1 ether); + + vm.prank(member1); + tm.applyForTask(id, keccak256("app")); + + // Cancel the task + vm.prank(creator1); + tm.cancelTask(id); + + // Verify applicants list is cleared + bytes memory result = lens.getStorage( + address(tm), TaskManagerLens.StorageKey.TASK_APPLICANTS, abi.encode(id) + ); + address[] memory applicants = abi.decode(result, (address[])); + assertEq(applicants.length, 0, "applicants should be cleared after cancel"); + } + + /*──────── Assign bypasses application requirement ────────*/ + + function test_AssignTaskBypassesApplicationRequirement() public { + uint256 id = _createAppTask(1 ether); + + // PM can directly assign even if requiresApplication is true + vm.prank(pm1); + tm.assignTask(id, member1); + + bytes memory result = lens.getStorage(address(tm), TaskManagerLens.StorageKey.TASK_INFO, abi.encode(id)); + (, TaskManagerLens.Status status, address claimer,,) = + abi.decode(result, (uint256, TaskManagerLens.Status, address, bytes32, bool)); + assertEq(uint8(status), uint8(TaskManagerLens.Status.CLAIMED)); + assertEq(claimer, member1); + } + } + /*────────────────── Bootstrap Test Suite ──────────────────*/ contract TaskManagerBootstrapTest is Test { /* test actors */