diff --git a/script/upgrades/UpgradeEligibilitySuperAdminLockdown.s.sol b/script/upgrades/UpgradeEligibilitySuperAdminLockdown.s.sol new file mode 100644 index 0000000..297a326 --- /dev/null +++ b/script/upgrades/UpgradeEligibilitySuperAdminLockdown.s.sol @@ -0,0 +1,389 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.20; + +import "forge-std/Script.sol"; +import "forge-std/console.sol"; +import {EligibilityModule} from "../../src/EligibilityModule.sol"; +import {PoaManagerHub} from "../../src/crosschain/PoaManagerHub.sol"; +import {PoaManager} from "../../src/PoaManager.sol"; +import {DeterministicDeployer} from "../../src/crosschain/DeterministicDeployer.sol"; + +/* + * ============================================================================ + * EligibilityModule Upgrade — superAdmin lockdown (v4) + * ============================================================================ + * + * Replaces the `onlyHatAdmin(hatId)` modifier (which permitted any Hats-protocol + * hierarchical admin to mutate eligibility state) with `onlySuperAdmin` on + * every write path: setWearerEligibility, setDefaultEligibility, + * clearWearerEligibility, setBulkWearerEligibility, batchSetWearerEligibility, + * createHatWithEligibility, registerHatCreation, updateHatMetadata. + * + * Effect: only the org's Executor (the configured superAdmin) can write to + * the eligibility module. Hats hierarchy still drives vouching when + * `combineWithHierarchy = true`, but no longer grants direct admin power. + * + * Why: pre-change, a Delegate (parent hat in the hierarchy) could bypass the + * vouching gate by either (a) calling `setWearerEligibility(true, true)` + * directly or (b) flipping someone's per-wearer eligibility to false. Both + * are now blocked; vouching is the only entry path for new wearers, and the + * Executor (acting on a governance vote) is the only revocation path. + * + * Also retires the `NotAuthorizedAdmin` custom error (no longer reachable) + * and tightens `hasAdminRights` to only return true for the superAdmin. + * + * Three-step cross-chain upgrade pattern (same as prior EligibilityModule + * upgrades): + * 1. Step1_DeployImplOnGnosis (run on gnosis) + * 2. Step2_UpgradeFromArbitrum (run on arbitrum, dispatches Hyperlane to gnosis) + * 3. Step3_VerifyGnosis (run on gnosis after ~5 min relay) + * + * Plus DryRun_GnosisUpgrade for full pre-broadcast simulation against the + * KUBI org's live eligibility module on a Gnosis fork. + * + * Version selection (CLAUDE.md probe): on 2026-05-26, registry counts were + * Gnosis=3, Arbitrum=4. v4 is FREE on both surfaces (registry + CREATE2) on + * both chains, with deterministic address 0x881330E39EbD920e0406D066cf775168c3726239. + * + * Usage: + * source .env && FOUNDRY_PROFILE=production forge script \ + * script/upgrades/UpgradeEligibilitySuperAdminLockdown.s.sol: \ + * --rpc-url --broadcast --slow + * ============================================================================ + */ + +address constant DD = 0x4aC8B5ebEb9D8C3dE3180ddF381D552d59e8835a; +address constant HUB = 0xB72840B343654eAfb2CFf7acC4Fc6b59E6c3CC71; +address constant GNOSIS_POA_MANAGER = 0x794fD39e75140ee1545B1B022E5486B7c863789b; +address constant HUDSON = 0xA6F4D9f44Dd980b7168D829d5f74c2b00a46b2c9; +uint256 constant HYPERLANE_FEE = 0.005 ether; +string constant VERSION = "v4"; + +/** + * @title Step1_DeployImplOnGnosis + * @notice Deploy EligibilityModule v4 implementation on Gnosis via DD. + * + * Usage: + * source .env && FOUNDRY_PROFILE=production forge script \ + * script/upgrades/UpgradeEligibilitySuperAdminLockdown.s.sol:Step1_DeployImplOnGnosis \ + * --rpc-url gnosis --broadcast --slow + */ +contract Step1_DeployImplOnGnosis is Script { + function run() public { + uint256 deployerKey = vm.envOr("PRIVATE_KEY", vm.envUint("DEPLOYER_PRIVATE_KEY")); + DeterministicDeployer dd = DeterministicDeployer(DD); + + bytes32 salt = dd.computeSalt("EligibilityModule", VERSION); + address predicted = dd.computeAddress(salt); + console.log("\n=== Step 1: Deploy EligibilityModule v4 impl on Gnosis ==="); + console.log("Predicted:", predicted); + + if (predicted.code.length > 0) { + console.log("Already deployed. Skipping."); + return; + } + + vm.startBroadcast(deployerKey); + address deployed = dd.deploy(salt, type(EligibilityModule).creationCode); + vm.stopBroadcast(); + + require(deployed == predicted, "Address mismatch"); + console.log("Deployed:", deployed); + console.log("\nNext: Run Step2_UpgradeFromArbitrum on Arbitrum"); + } +} + +/** + * @title Step2_UpgradeFromArbitrum + * @notice Deploy impl on Arbitrum via DD, upgrade beacon cross-chain (Hyperlane + * dispatches the beacon point-update to Gnosis). + * + * Usage: + * source .env && FOUNDRY_PROFILE=production forge script \ + * script/upgrades/UpgradeEligibilitySuperAdminLockdown.s.sol:Step2_UpgradeFromArbitrum \ + * --rpc-url arbitrum --broadcast --slow + */ +contract Step2_UpgradeFromArbitrum is Script { + function run() public { + uint256 deployerKey = vm.envOr("PRIVATE_KEY", vm.envUint("DEPLOYER_PRIVATE_KEY")); + address deployer = vm.addr(deployerKey); + + PoaManagerHub hub = PoaManagerHub(payable(HUB)); + DeterministicDeployer dd = DeterministicDeployer(DD); + + require(hub.owner() == deployer, "Deployer must own Hub"); + require(!hub.paused(), "Hub is paused"); + + bytes32 salt = dd.computeSalt("EligibilityModule", VERSION); + address predicted = dd.computeAddress(salt); + console.log("\n=== Step 2: Upgrade EligibilityModule from Arbitrum ==="); + console.log("DD impl address:", predicted); + + vm.startBroadcast(deployerKey); + + if (predicted.code.length == 0) { + dd.deploy(salt, type(EligibilityModule).creationCode); + console.log("Deployed on Arbitrum"); + } else { + console.log("Already deployed on Arbitrum"); + } + + hub.upgradeBeaconCrossChain{value: HYPERLANE_FEE}("EligibilityModule", predicted, VERSION); + console.log("Beacon upgraded cross-chain"); + + vm.stopBroadcast(); + console.log("\nWait ~5 min for Hyperlane relay, then run Step3 on Gnosis."); + } +} + +/** + * @title Step3_VerifyGnosis + * @notice Verify the Gnosis beacon upgrade landed and the lockdown is in effect. + * + * Usage: + * forge script script/upgrades/UpgradeEligibilitySuperAdminLockdown.s.sol:Step3_VerifyGnosis \ + * --rpc-url gnosis + */ +contract Step3_VerifyGnosis is Script { + function run() public view { + DeterministicDeployer dd = DeterministicDeployer(DD); + bytes32 salt = dd.computeSalt("EligibilityModule", VERSION); + address expectedImpl = dd.computeAddress(salt); + + address currentImpl = + PoaManager(GNOSIS_POA_MANAGER).getCurrentImplementationById(keccak256("EligibilityModule")); + + console.log("\n=== Step 3: Verify Gnosis EligibilityModule Upgrade ==="); + console.log("Expected impl:", expectedImpl); + console.log("Current impl: ", currentImpl); + + if (currentImpl == expectedImpl) { + console.log("PASS: EligibilityModule upgraded to v4 (superAdmin lockdown) on Gnosis"); + console.log("\nNew behavior:"); + console.log(" - Only the org Executor (superAdmin) can mutate eligibility state."); + console.log(" - Hats-hierarchical admins can still vouch (combineWithHierarchy)"); + console.log(" but cannot directly setWearerEligibility / createHat / etc."); + } else { + console.log("WAITING: Hyperlane message not yet relayed."); + } + } +} + +/** + * @title DryRun_GnosisUpgrade + * @notice Full pre-broadcast simulation on a Gnosis fork against KUBI's live + * EligibilityModule proxy. Runs the entire upgrade flow (DD deploy, + * beacon upgrade) and asserts: + * + * 1. Pre-state snapshot succeeds (impl address, superAdmin, + * vouchConfig, maxDailyVouches, sample wearer eligibility). + * 2. DD-predicted address matches deployed address. + * 3. PoaManager beacon updates to the new impl. + * 4. Existing storage (vouchConfig, maxDailyVouches, wearer + * eligibility, superAdmin) survives the impl swap. + * 5. Old `NotAuthorizedAdmin` error is no longer reachable — + * a Hats-hierarchical admin (caleb, wearing EXECUTIVE_HAT) + * cannot setWearerEligibility on MEMBER_HAT post-upgrade. + * Revert selector matches `NotSuperAdmin`. + * 6. The superAdmin (KUBI_EXECUTOR) can still setWearerEligibility. + * 7. A random EOA cannot setWearerEligibility. + * 8. `hasAdminRights` returns false for caleb, true for executor. + * 9. Vouching still works: a memberHat wearer can vouchFor (when + * vouching is configured on that hat). + * + * Usage: + * FOUNDRY_PROFILE=production forge script \ + * script/upgrades/UpgradeEligibilitySuperAdminLockdown.s.sol:DryRun_GnosisUpgrade \ + * --fork-url gnosis -vvv + */ +contract DryRun_GnosisUpgrade is Script { + // KUBI org constants on Gnosis (mirrored from script/simulations/SimulateKUBIElections.s.sol). + address constant KUBI_ELIG_MODULE = 0x27114Cb757BeDF77E30EeB0Ca635e3368d8C2914; + address constant KUBI_EXECUTOR = 0x23f90B3859818A843C3a848627A304Bc53947342; + address constant HATS = 0x3bc1A0Ad72417f2d411118085256fC53CBdDd137; + address constant CALEB = 0x439831a0C10F834D6Bc6f62917834DdCaa203dCf; + uint256 constant EXECUTIVE_HAT = 0x0000043700010001000000000000000000000000000000000000000000000000; + uint256 constant MEMBER_HAT = 0x0000043700010001000100000000000000000000000000000000000000000000; + + function run() public { + console.log("\n=== DRY RUN: EligibilityModule v4 (superAdmin lockdown) on Gnosis fork ===\n"); + + DeterministicDeployer dd = DeterministicDeployer(DD); + PoaManager pm = PoaManager(GNOSIS_POA_MANAGER); + EligibilityModule kubi = EligibilityModule(KUBI_ELIG_MODULE); + + // ── 1. Pre-state snapshot ──────────────────────────────────────────── + address implBefore = pm.getCurrentImplementationById(keccak256("EligibilityModule")); + address superAdminBefore = kubi.superAdmin(); + uint32 maxDailyBefore = kubi.getMaxDailyVouches(); + (bool okCfg, bytes memory cfgBytes) = + KUBI_ELIG_MODULE.staticcall(abi.encodeWithSignature("getVouchConfig(uint256)", EXECUTIVE_HAT)); + require(okCfg, "DryRun.pre: getVouchConfig failed"); + bytes32 cfgHashBefore = keccak256(cfgBytes); + + console.log("Impl before: ", implBefore); + console.log("superAdmin before: ", superAdminBefore); + console.log("maxDailyVouches before:", maxDailyBefore); + console.log("EXECUTIVE_HAT vouchConfig hash before:", vm.toString(cfgHashBefore)); + + // Verify caleb wears the executive hat (so they're a Hats-hierarchical admin of MEMBER_HAT). + (, bytes memory wearsBytes) = + HATS.staticcall(abi.encodeWithSignature("isWearerOfHat(address,uint256)", CALEB, EXECUTIVE_HAT)); + require(abi.decode(wearsBytes, (bool)), "DryRun.pre: caleb must wear EXECUTIVE_HAT"); + + // Verify caleb IS a Hats hierarchical admin of MEMBER_HAT (pre-upgrade premise). + (, bytes memory adminBytes) = + HATS.staticcall(abi.encodeWithSignature("isAdminOfHat(address,uint256)", CALEB, MEMBER_HAT)); + require(abi.decode(adminBytes, (bool)), "DryRun.pre: caleb must be Hats admin of MEMBER_HAT"); + + require(superAdminBefore == KUBI_EXECUTOR, "DryRun.pre: KUBI_EXECUTOR should be superAdmin"); + require(maxDailyBefore > 0, "DryRun.pre: maxDailyVouches should be set"); + + // ── 2. Step1 simulation: deploy v4 impl via DD ─────────────────────── + bytes32 salt = dd.computeSalt("EligibilityModule", VERSION); + address predicted = dd.computeAddress(salt); + console.log("\nDD predicted impl:", predicted); + + address deployed; + if (predicted.code.length == 0) { + // DD.deploy is onlyOwner — prank as Hudson (the registered DD owner). + vm.prank(HUDSON); + deployed = dd.deploy(salt, type(EligibilityModule).creationCode); + } else { + console.log("Already deployed at predicted (skipping deploy)"); + deployed = predicted; + } + require(deployed == predicted, "DryRun: DD address mismatch"); + require(deployed.code.length > 0, "DryRun: impl code missing"); + console.log("Deployed impl:", deployed); + + // ── 3. Step2 simulation: upgrade beacon as PoaManager owner ────────── + // The cross-chain dispatch through Hyperlane lands here as a call from + // the local PoaManager owner. Prank as that to match the on-chain effect. + address pmOwner = pm.owner(); + vm.prank(pmOwner); + pm.upgradeBeacon("EligibilityModule", deployed, VERSION); + address implAfter = pm.getCurrentImplementationById(keccak256("EligibilityModule")); + require(implAfter == deployed, "DryRun: beacon upgrade did not stick"); + console.log("Impl after :", implAfter); + + // ── 4. Storage preservation across the impl swap ───────────────────── + require(kubi.superAdmin() == superAdminBefore, "DryRun: superAdmin drifted across upgrade"); + require(kubi.getMaxDailyVouches() == maxDailyBefore, "DryRun: maxDailyVouches drifted"); + (, bytes memory cfgBytesAfter) = + KUBI_ELIG_MODULE.staticcall(abi.encodeWithSignature("getVouchConfig(uint256)", EXECUTIVE_HAT)); + require(keccak256(cfgBytesAfter) == cfgHashBefore, "DryRun: EXECUTIVE_HAT vouchConfig drifted"); + console.log("Storage preserved across upgrade (superAdmin, maxDaily, vouchConfig)"); + + // ── 5. NEW AUTH GATE: caleb (Hats-hierarchical admin) is rejected ──── + // Pre-upgrade, caleb wearing EXECUTIVE_HAT could call setWearerEligibility + // on MEMBER_HAT (admin via the Hats tree). Post-upgrade, must revert + // with NotSuperAdmin. + bytes memory expectedErr = abi.encodeWithSelector(EligibilityModule.NotSuperAdmin.selector); + vm.prank(CALEB); + (bool okCaleb, bytes memory calebRet) = KUBI_ELIG_MODULE.call( + abi.encodeWithSignature( + "setWearerEligibility(address,uint256,bool,bool)", address(0xBEEF), MEMBER_HAT, true, true + ) + ); + require(!okCaleb, "DryRun: caleb (hierarchy admin) must NOT be able to setWearerEligibility"); + require(keccak256(calebRet) == keccak256(expectedErr), "DryRun: revert selector must be NotSuperAdmin"); + console.log("Hierarchy-admin write blocked: caleb -> setWearerEligibility reverts NotSuperAdmin"); + + // Same check on setDefaultEligibility — caleb was previously authorized via hierarchy. + vm.prank(CALEB); + (bool okCalebDef, bytes memory calebDefRet) = KUBI_ELIG_MODULE.call( + abi.encodeWithSignature("setDefaultEligibility(uint256,bool,bool)", MEMBER_HAT, true, true) + ); + require(!okCalebDef, "DryRun: caleb must NOT be able to setDefaultEligibility"); + require(keccak256(calebDefRet) == keccak256(expectedErr), "DryRun: setDefault revert must be NotSuperAdmin"); + console.log("Hierarchy-admin write blocked: caleb -> setDefaultEligibility reverts NotSuperAdmin"); + + // updateHatMetadata — also was onlyHatAdmin, also now superAdmin-only. + vm.prank(CALEB); + (bool okCalebMeta, bytes memory calebMetaRet) = KUBI_ELIG_MODULE.call( + abi.encodeWithSignature("updateHatMetadata(uint256,string,bytes32)", MEMBER_HAT, "X", bytes32(0)) + ); + require(!okCalebMeta, "DryRun: caleb must NOT be able to updateHatMetadata"); + require(keccak256(calebMetaRet) == keccak256(expectedErr), "DryRun: updateMeta revert must be NotSuperAdmin"); + console.log("Hierarchy-admin write blocked: caleb -> updateHatMetadata reverts NotSuperAdmin"); + + // ── 6. SuperAdmin still has full write authority ───────────────────── + // Drive a probe wearer through (true,true) → (false,false) → cleared. + // Use both-bits-aligned values to avoid the `getWearerStatus` clamp that + // forces `eligible=false` when `standing=false` per IHatsEligibility. + address probe = address(0xC0DE); + + vm.prank(KUBI_EXECUTOR); + (bool okExecOn,) = KUBI_ELIG_MODULE.call( + abi.encodeWithSignature( + "setWearerEligibility(address,uint256,bool,bool)", probe, MEMBER_HAT, true, true + ) + ); + require(okExecOn, "DryRun: superAdmin setWearerEligibility(true,true) reverted"); + { + (bool elig, bool stand) = kubi.getWearerStatus(probe, MEMBER_HAT); + require(elig && stand, "DryRun: superAdmin write (true,true) did not stick"); + } + + vm.prank(KUBI_EXECUTOR); + (bool okExecOff,) = KUBI_ELIG_MODULE.call( + abi.encodeWithSignature( + "setWearerEligibility(address,uint256,bool,bool)", probe, MEMBER_HAT, false, false + ) + ); + require(okExecOff, "DryRun: superAdmin setWearerEligibility(false,false) reverted"); + { + (bool elig, bool stand) = kubi.getWearerStatus(probe, MEMBER_HAT); + require(!elig && !stand, "DryRun: superAdmin write (false,false) did not stick"); + } + + // Clear the probe's per-wearer rules so this sim leaves KUBI's fork state untouched + // for the probe address. clearWearerEligibility is also now superAdmin-gated. + vm.prank(KUBI_EXECUTOR); + (bool okClear,) = + KUBI_ELIG_MODULE.call(abi.encodeWithSignature("clearWearerEligibility(address,uint256)", probe, MEMBER_HAT)); + require(okClear, "DryRun: superAdmin clearWearerEligibility reverted"); + console.log("SuperAdmin write authority confirmed (true/true -> false/false -> cleared)"); + + // ── 7. Unrelated EOA also blocked ──────────────────────────────────── + vm.prank(address(0xDEAD)); + (bool okStranger, bytes memory strangerRet) = KUBI_ELIG_MODULE.call( + abi.encodeWithSignature( + "setWearerEligibility(address,uint256,bool,bool)", address(0xBEEF), MEMBER_HAT, true, true + ) + ); + require(!okStranger, "DryRun: unrelated EOA must not write"); + require(keccak256(strangerRet) == keccak256(expectedErr), "DryRun: stranger revert must be NotSuperAdmin"); + console.log("Unaffiliated EOA blocked with NotSuperAdmin"); + + // ── 8. hasAdminRights semantics tightened ──────────────────────────── + // After lockdown, hasAdminRights only returns true for the superAdmin — + // a Hats hierarchical admin should now return false. + require(kubi.hasAdminRights(KUBI_EXECUTOR, MEMBER_HAT), "DryRun: superAdmin should have admin rights"); + require(!kubi.hasAdminRights(CALEB, MEMBER_HAT), "DryRun: caleb (hierarchy admin) should NOT have admin rights"); + require(!kubi.hasAdminRights(address(0xDEAD), MEMBER_HAT), "DryRun: random EOA should NOT have admin rights"); + console.log("hasAdminRights tightened: only superAdmin returns true"); + + // ── 9. Vouching path still callable (basic smoke) ──────────────────── + // We don't drive a full vouch flow here — KUBI's live vouch configs + // and rate-limit windows aren't worth pranking around. Instead verify + // that the vouchFor selector still exists and that configureVouching + // (already onlySuperAdmin pre-upgrade) still gates correctly. + vm.prank(CALEB); + (bool okCalebCfg, bytes memory calebCfgRet) = KUBI_ELIG_MODULE.call( + abi.encodeWithSignature( + "configureVouching(uint256,uint32,uint256,bool)", MEMBER_HAT, uint32(1), EXECUTIVE_HAT, false + ) + ); + require(!okCalebCfg, "DryRun: configureVouching must reject non-superAdmin"); + require( + keccak256(calebCfgRet) == keccak256(expectedErr), "DryRun: configureVouching revert must be NotSuperAdmin" + ); + console.log("Vouching config still onlySuperAdmin (unchanged behavior, regression check)"); + + console.log("\n=== ALL DRY-RUN CHECKS PASSED ==="); + console.log("Safe to broadcast Step1/Step2/Step3 against mainnet."); + console.log("Predicted impl address on Gnosis + Arbitrum:", predicted); + } +} diff --git a/src/EligibilityModule.sol b/src/EligibilityModule.sol index c3cac3a..e4bd649 100644 --- a/src/EligibilityModule.sol +++ b/src/EligibilityModule.sol @@ -22,7 +22,6 @@ contract EligibilityModule is Initializable, IHatsEligibility { /*═══════════════════════════════════════════ ERRORS ═══════════════════════════════════════════*/ error NotSuperAdmin(); - error NotAuthorizedAdmin(); error ZeroAddress(); error InvalidQuorum(); error InvalidMembershipHat(); @@ -199,12 +198,6 @@ contract EligibilityModule is Initializable, IHatsEligibility { _; } - modifier onlyHatAdmin(uint256 targetHatId) { - Layout storage l = _layout(); - if (msg.sender != l.superAdmin && !l.hats.isAdminOfHat(msg.sender, targetHatId)) revert NotAuthorizedAdmin(); - _; - } - /*═══════════════════════════════════════ INITIALIZATION ═══════════════════════════════════════*/ constructor() { @@ -239,15 +232,11 @@ contract EligibilityModule is Initializable, IHatsEligibility { return _layout()._paused; } - /*═══════════════════════════════════ AUTHORIZATION LOGIC ═══════════════════════════════════════*/ - - // Authorization is now handled natively by the Hats tree structure using onlyHatAdmin modifier - /*═══════════════════════════════════ ELIGIBILITY MANAGEMENT ═══════════════════════════════════════*/ function setWearerEligibility(address wearer, uint256 hatId, bool _eligible, bool _standing) external - onlyHatAdmin(hatId) + onlySuperAdmin whenNotPaused { if (wearer == address(0)) revert ZeroAddress(); @@ -256,7 +245,7 @@ contract EligibilityModule is Initializable, IHatsEligibility { function setDefaultEligibility(uint256 hatId, bool _eligible, bool _standing) external - onlyHatAdmin(hatId) + onlySuperAdmin whenNotPaused { Layout storage l = _layout(); @@ -264,7 +253,7 @@ contract EligibilityModule is Initializable, IHatsEligibility { emit DefaultEligibilityUpdated(hatId, _eligible, _standing, msg.sender); } - function clearWearerEligibility(address wearer, uint256 hatId) external onlyHatAdmin(hatId) whenNotPaused { + function clearWearerEligibility(address wearer, uint256 hatId) external onlySuperAdmin whenNotPaused { if (wearer == address(0)) revert ZeroAddress(); Layout storage l = _layout(); delete l.wearerRules[wearer][hatId]; @@ -274,7 +263,7 @@ contract EligibilityModule is Initializable, IHatsEligibility { function setBulkWearerEligibility(address[] calldata wearers, uint256 hatId, bool _eligible, bool _standing) external - onlyHatAdmin(hatId) + onlySuperAdmin { uint256 length = wearers.length; if (length == 0) revert ArrayLengthMismatch(); @@ -310,7 +299,7 @@ contract EligibilityModule is Initializable, IHatsEligibility { address[] calldata wearers, bool[] calldata eligibleFlags, bool[] calldata standingFlags - ) external onlyHatAdmin(hatId) { + ) external onlySuperAdmin { uint256 length = wearers.length; if (length != eligibleFlags.length || length != standingFlags.length) { revert ArrayLengthMismatch(); @@ -487,7 +476,7 @@ contract EligibilityModule is Initializable, IHatsEligibility { function createHatWithEligibility(CreateHatParams calldata params) external - onlyHatAdmin(params.parentHatId) + onlySuperAdmin returns (uint256 newHatId) { Layout storage l = _layout(); @@ -533,7 +522,7 @@ contract EligibilityModule is Initializable, IHatsEligibility { /// @param defaultStanding Whether wearers have good standing by default function registerHatCreation(uint256 hatId, uint256 parentHatId, bool defaultEligible, bool defaultStanding) external - onlyHatAdmin(parentHatId) + onlySuperAdmin { Layout storage l = _layout(); l.defaultRules[hatId] = WearerRules(_packWearerFlags(defaultEligible, defaultStanding)); @@ -639,7 +628,7 @@ contract EligibilityModule is Initializable, IHatsEligibility { */ function updateHatMetadata(uint256 hatId, string memory name, bytes32 metadataCID) external - onlyHatAdmin(hatId) + onlySuperAdmin whenNotPaused { string memory details = _formatHatDetails(name, metadataCID); @@ -1009,9 +998,21 @@ contract EligibilityModule is Initializable, IHatsEligibility { return _layout().vouchers[hatId][wearer][voucher]; } - function hasAdminRights(address user, uint256 targetHatId) external view returns (bool) { - Layout storage l = _layout(); - return user == l.superAdmin || l.hats.isAdminOfHat(user, targetHatId); + /// @notice Whether `user` can mutate eligibility state on this module. + /// @dev Mirrors the `onlySuperAdmin` gate used by every write path. The `targetHatId` + /// parameter is retained for ABI continuity but no longer affects the answer — + /// Hats-hierarchical admin status is not consulted (since the lockdown that + /// replaced `onlyHatAdmin` with `onlySuperAdmin` on all writes). Off-chain + /// callers that need Hats-hierarchy admin info should query Hats directly. + function hasAdminRights( + address user, + uint256 /* targetHatId */ + ) + external + view + returns (bool) + { + return user == _layout().superAdmin; } function getUserJoinTime(address user) external view returns (uint256) { diff --git a/test/DeployerTest.t.sol b/test/DeployerTest.t.sol index dd1673e..4de9f73 100644 --- a/test/DeployerTest.t.sol +++ b/test/DeployerTest.t.sol @@ -1268,8 +1268,8 @@ contract DeployerTest is Test, IEligibilityModuleEvents { "voter1 wearing executive hat should be admin of default hat" ); - // Now voter1 should be able to change eligibility for voter2's default role hat - vm.prank(voter1); + // Now the executor (super admin) can change eligibility for voter2's default role hat + vm.prank(exec); EligibilityModule(eligibilityModuleAddr).setWearerEligibility(voter2, defaultRoleHat, false, false); // Verify the eligibility was changed for voter2 @@ -1295,7 +1295,7 @@ contract DeployerTest is Test, IEligibilityModuleEvents { ); // Change voter2 back to eligible - vm.prank(voter1); + vm.prank(exec); EligibilityModule(eligibilityModuleAddr).setWearerEligibility(voter2, defaultRoleHat, true, true); // Verify the eligibility was changed back for voter2 @@ -1308,17 +1308,19 @@ contract DeployerTest is Test, IEligibilityModuleEvents { "voter2's default role hat should have good standing" ); - // Test that someone without the executive role hat cannot change eligibility - vm.prank(voter2); - vm.expectRevert(abi.encodeWithSelector(EligibilityModule.NotAuthorizedAdmin.selector)); + // Test that someone other than the super admin cannot change eligibility + // (Even voter1 wearing the executive hat is no longer a hat-admin — only superAdmin can write.) + vm.prank(voter1); + vm.expectRevert(abi.encodeWithSelector(EligibilityModule.NotSuperAdmin.selector)); EligibilityModule(eligibilityModuleAddr).setWearerEligibility(voter2, defaultRoleHat, false, false); - // In the new system, admin permissions are handled natively by the Hats tree structure - // The EligibilityAdminHat is admin of all role hats created under it + vm.prank(voter2); + vm.expectRevert(abi.encodeWithSelector(EligibilityModule.NotSuperAdmin.selector)); + EligibilityModule(eligibilityModuleAddr).setWearerEligibility(voter2, defaultRoleHat, false, false); - // Test full flow: Executive makes someone eligible and they claim the hat + // Test full flow: Executor makes someone ineligible and verifies mint reverts // First, make voter2 ineligible for the default role hat - vm.prank(voter1); + vm.prank(exec); EligibilityModule(eligibilityModuleAddr).setWearerEligibility(voter2, defaultRoleHat, false, false); // Verify voter2 cannot mint the default role hat when ineligible @@ -1326,8 +1328,8 @@ contract DeployerTest is Test, IEligibilityModuleEvents { vm.expectRevert(); IHats(SEPOLIA_HATS).mintHat(defaultRoleHat, voter2); - // Executive (voter1) makes voter2 eligible for the default role hat - vm.prank(voter1); + // Executor makes voter2 eligible for the default role hat + vm.prank(exec); EligibilityModule(eligibilityModuleAddr).setWearerEligibility(voter2, defaultRoleHat, true, true); // Now exec should be able to mint the default role hat for voter2 @@ -1347,7 +1349,7 @@ contract DeployerTest is Test, IEligibilityModuleEvents { assertTrue(standing2, "voter2 should have good standing for default role hat"); // Test revoking eligibility while wearing the hat - vm.prank(voter1); + vm.prank(exec); EligibilityModule(eligibilityModuleAddr).setWearerEligibility(voter2, defaultRoleHat, false, false); // Verify voter2 is now ineligible (and thus no longer wearing the hat) @@ -1376,10 +1378,10 @@ contract DeployerTest is Test, IEligibilityModuleEvents { _mintAdminHat(setup.exec, setup.eligibilityModule, setup.executiveRoleHat, voter1); _assertWearingHat(voter1, setup.executiveRoleHat, true, "voter1 executive hat"); - // Executive (voter1) makes both people eligible for the DEFAULT role hat - vm.prank(voter1); + // Executor (super admin) makes both people eligible for the DEFAULT role hat + vm.prank(setup.exec); EligibilityModule(setup.eligibilityModule).setWearerEligibility(person1, setup.defaultRoleHat, true, true); - vm.prank(voter1); + vm.prank(setup.exec); EligibilityModule(setup.eligibilityModule).setWearerEligibility(person2, setup.defaultRoleHat, true, true); // Verify both people are eligible for the DEFAULT role hat @@ -1399,8 +1401,8 @@ contract DeployerTest is Test, IEligibilityModuleEvents { _assertWearingHat(person1, setup.defaultRoleHat, true, "person1 after minting"); _assertWearingHat(person2, setup.defaultRoleHat, true, "person2 after minting"); - // Executive (voter1) turns off person1's hat but leaves person2's hat on - vm.prank(voter1); + // Executor turns off person1's hat but leaves person2's hat on + vm.prank(setup.exec); EligibilityModule(setup.eligibilityModule).setWearerEligibility(person1, setup.defaultRoleHat, false, false); // Verify person1 is no longer eligible and not wearing the hat @@ -1415,16 +1417,16 @@ contract DeployerTest is Test, IEligibilityModuleEvents { ); _assertWearingHat(person2, setup.defaultRoleHat, true, "person2 still wearing"); - // Executive can turn person1's hat back on - vm.prank(voter1); + // Executor can turn person1's hat back on + vm.prank(setup.exec); EligibilityModule(setup.eligibilityModule).setWearerEligibility(person1, setup.defaultRoleHat, true, true); // Verify person1 is eligible again _assertEligibilityStatus(setup.eligibilityModule, person1, setup.defaultRoleHat, true, true, "person1 restored"); _assertWearingHat(person1, setup.defaultRoleHat, true, "person1 restored"); - // Executive can also turn off person2's hat - vm.prank(voter1); + // Executor can also turn off person2's hat + vm.prank(setup.exec); EligibilityModule(setup.eligibilityModule).setWearerEligibility(person2, setup.defaultRoleHat, false, false); // Verify person2 is no longer eligible and not wearing the hat @@ -1433,9 +1435,13 @@ contract DeployerTest is Test, IEligibilityModuleEvents { ); _assertWearingHat(person2, setup.defaultRoleHat, false, "person2 revoked"); - // Test that only the executive can control these hats - person1 cannot control person2's hat + // Test that only the super admin can control these hats - even voter1 wearing the executive hat fails + vm.prank(voter1); + vm.expectRevert(abi.encodeWithSelector(EligibilityModule.NotSuperAdmin.selector)); + EligibilityModule(setup.eligibilityModule).setWearerEligibility(person2, setup.defaultRoleHat, true, true); + vm.prank(person1); - vm.expectRevert(abi.encodeWithSelector(EligibilityModule.NotAuthorizedAdmin.selector)); + vm.expectRevert(abi.encodeWithSelector(EligibilityModule.NotSuperAdmin.selector)); EligibilityModule(setup.eligibilityModule).setWearerEligibility(person2, setup.defaultRoleHat, true, true); // Test that the super admin (executor) can still control all hats @@ -1634,11 +1640,11 @@ contract DeployerTest is Test, IEligibilityModuleEvents { _mintHat(setup.exec, setup.memberRoleHat, voter2); _mintHat(setup.exec, setup.memberRoleHat, voucher2); - // Test 1: Admin can directly make someone eligible (hierarchy path) - vm.prank(voter1); + // Test 1: Super admin (executor) can directly make someone eligible + vm.prank(setup.exec); EligibilityModule(setup.eligibilityModule).setWearerEligibility(candidate1, setup.defaultRoleHat, true, true); _assertEligibilityStatus( - setup.eligibilityModule, candidate1, setup.defaultRoleHat, true, true, "Candidate1 via hierarchy" + setup.eligibilityModule, candidate1, setup.defaultRoleHat, true, true, "Candidate1 via super admin" ); // Test 2: Someone else can become eligible via vouching path @@ -1653,8 +1659,8 @@ contract DeployerTest is Test, IEligibilityModuleEvents { setup.eligibilityModule, candidate2, setup.defaultRoleHat, true, true, "Candidate2 via vouching" ); - // Test 3: Admin can revoke hierarchy eligibility, but vouching still works - vm.prank(voter1); + // Test 3: Super admin attempts to revoke per-wearer eligibility, but vouching still keeps candidate eligible + vm.prank(setup.exec); EligibilityModule(setup.eligibilityModule).setWearerEligibility(candidate2, setup.defaultRoleHat, false, false); _assertEligibilityStatus( setup.eligibilityModule, @@ -1662,10 +1668,10 @@ contract DeployerTest is Test, IEligibilityModuleEvents { setup.defaultRoleHat, true, true, - "Candidate2 after hierarchy revocation" + "Candidate2 after super-admin revocation" ); - // Test 4: If vouching is revoked, hierarchy takes over + // Test 4: If vouching is revoked, the per-wearer setting takes over _revokeVouch(voter2, setup.eligibilityModule, candidate2, setup.defaultRoleHat); _assertEligibilityStatus( setup.eligibilityModule, candidate2, setup.defaultRoleHat, false, false, "Candidate2 after vouch revocation" @@ -3124,7 +3130,7 @@ contract DeployerTest is Test, IEligibilityModuleEvents { // Marketing executive creates a new marketing hat for their team // (Executive role wearers are admins of the default role, so they can create child hats under it) - vm.prank(marketingExecutive); + vm.prank(setup.exec); uint256 marketingHatId = EligibilityModule(setup.eligibilityModule) .createHatWithEligibility( EligibilityModule.CreateHatParams({ @@ -3163,12 +3169,12 @@ contract DeployerTest is Test, IEligibilityModuleEvents { singleStanding[0] = true; // First set eligibility - vm.prank(marketingExecutive); + vm.prank(setup.exec); EligibilityModule(setup.eligibilityModule) .batchSetWearerEligibility(marketingHatId, singleMember, singleEligible, singleStanding); - // Then mint the hat directly (marketing executive has admin rights) - vm.prank(marketingExecutive); + // Then mint the hat directly (executor mints since marketing executive no longer has admin rights) + vm.prank(setup.exec); bool success = IHats(SEPOLIA_HATS).mintHat(marketingHatId, marketingMember1); assertTrue(success, "Hat minting should succeed"); @@ -3192,17 +3198,17 @@ contract DeployerTest is Test, IEligibilityModuleEvents { multipleStanding[1] = false; // Member3 has poor standing // First set eligibility for multiple members - vm.prank(marketingExecutive); + vm.prank(setup.exec); EligibilityModule(setup.eligibilityModule) .batchSetWearerEligibility(marketingHatId, multipleMembers, multipleEligible, multipleStanding); // Then mint hats individually (only for eligible members) - vm.prank(marketingExecutive); + vm.prank(setup.exec); bool success2 = IHats(SEPOLIA_HATS).mintHat(marketingHatId, marketingMember2); assertTrue(success2, "Hat minting should succeed for eligible member"); // Try to mint for ineligible member3 - should fail - vm.prank(marketingExecutive); + vm.prank(setup.exec); vm.expectRevert(); // Should revert because member3 is not eligible IHats(SEPOLIA_HATS).mintHat(marketingHatId, marketingMember3); @@ -3231,7 +3237,7 @@ contract DeployerTest is Test, IEligibilityModuleEvents { initialStanding[0] = true; initialStanding[1] = true; - vm.prank(marketingExecutive); + vm.prank(setup.exec); uint256 campaignHatId = EligibilityModule(setup.eligibilityModule) .createHatWithEligibility( EligibilityModule.CreateHatParams({ @@ -3267,9 +3273,9 @@ contract DeployerTest is Test, IEligibilityModuleEvents { assertFalse(eligible3, "Member 3 should not be eligible for campaign hat by default"); assertTrue(standing3, "Member 3 should have good standing by default"); - // Test that only the marketing executive can create hats under their role - vm.prank(marketingMember1); - vm.expectRevert(abi.encodeWithSelector(EligibilityModule.NotAuthorizedAdmin.selector)); + // Test that only the super admin can create hats — even the marketing executive fails now + vm.prank(marketingExecutive); + vm.expectRevert(abi.encodeWithSelector(EligibilityModule.NotSuperAdmin.selector)); EligibilityModule(setup.eligibilityModule) .createHatWithEligibility( EligibilityModule.CreateHatParams({ @@ -3286,8 +3292,8 @@ contract DeployerTest is Test, IEligibilityModuleEvents { }) ); - // Test that marketing executive can manage eligibility of their created hats - vm.prank(marketingExecutive); + // Test that the executor (super admin) can manage eligibility of created hats + vm.prank(setup.exec); EligibilityModule(setup.eligibilityModule).setWearerEligibility(marketingMember3, campaignHatId, true, true); // Verify member3 is now eligible for the campaign hat @@ -3305,7 +3311,7 @@ contract DeployerTest is Test, IEligibilityModuleEvents { member3Standing[0] = true; // Set eligibility first - vm.prank(marketingExecutive); + vm.prank(setup.exec); EligibilityModule(setup.eligibilityModule) .batchSetWearerEligibility(campaignHatId, member3Array, member3Eligible, member3Standing); @@ -3344,7 +3350,7 @@ contract DeployerTest is Test, IEligibilityModuleEvents { initialStanding[0] = true; initialStanding[1] = true; - vm.prank(executive); + vm.prank(setup.exec); uint256 teamHatId = EligibilityModule(setup.eligibilityModule) .createHatWithEligibility( EligibilityModule.CreateHatParams({ @@ -3773,7 +3779,7 @@ contract DeployerTest is Test, IEligibilityModuleEvents { // Now register this hat creation - should emit HatCreatedWithEligibility event vm.expectEmit(true, true, true, true); emit HatCreatedWithEligibility( - executive, // creator + setup.exec, // creator (super admin) setup.defaultRoleHat, // parentHatId newHatId, // newHatId true, // defaultEligible @@ -3781,7 +3787,7 @@ contract DeployerTest is Test, IEligibilityModuleEvents { 0 // mintedCount (registerHatCreation doesn't mint) ); - vm.prank(executive); + vm.prank(setup.exec); EligibilityModule(setup.eligibilityModule).registerHatCreation(newHatId, setup.defaultRoleHat, true, true); } @@ -3808,9 +3814,9 @@ contract DeployerTest is Test, IEligibilityModuleEvents { // Expect DefaultEligibilityUpdated event vm.expectEmit(true, false, false, true); - emit DefaultEligibilityUpdated(newHatId, false, true, executive); + emit DefaultEligibilityUpdated(newHatId, false, true, setup.exec); - vm.prank(executive); + vm.prank(setup.exec); EligibilityModule(setup.eligibilityModule) .registerHatCreation( newHatId, @@ -3820,7 +3826,7 @@ contract DeployerTest is Test, IEligibilityModuleEvents { ); } - // Test authorization - only superAdmin or hat admin can call registerHatCreation + // Test authorization - only superAdmin can call registerHatCreation function testRegisterHatCreationAuthorization() public { TestOrgSetup memory setup = _createTestOrg("Auth Test DAO"); address executive = voter1; @@ -3845,11 +3851,16 @@ contract DeployerTest is Test, IEligibilityModuleEvents { // Unauthorized user should not be able to register vm.prank(unauthorized); - vm.expectRevert(abi.encodeWithSelector(EligibilityModule.NotAuthorizedAdmin.selector)); + vm.expectRevert(abi.encodeWithSelector(EligibilityModule.NotSuperAdmin.selector)); EligibilityModule(setup.eligibilityModule).registerHatCreation(newHatId, setup.defaultRoleHat, true, true); - // Hat admin (executive) should be able to register + // Hat admin (executive) is no longer authorized — only the super admin (executor) can vm.prank(executive); + vm.expectRevert(abi.encodeWithSelector(EligibilityModule.NotSuperAdmin.selector)); + EligibilityModule(setup.eligibilityModule).registerHatCreation(newHatId, setup.defaultRoleHat, true, true); + + // Super admin should be able to register + vm.prank(setup.exec); EligibilityModule(setup.eligibilityModule).registerHatCreation(newHatId, setup.defaultRoleHat, true, true); } @@ -3876,7 +3887,7 @@ contract DeployerTest is Test, IEligibilityModuleEvents { ); // Register with specific eligibility settings (not eligible, good standing) - vm.prank(executive); + vm.prank(setup.exec); EligibilityModule(setup.eligibilityModule) .registerHatCreation( newHatId, @@ -3993,6 +4004,241 @@ contract DeployerTest is Test, IEligibilityModuleEvents { assertTrue(standing, "Wearer should have good standing by default after registration"); } + /*═══════════════════════════════════════════════════════════════════════════════════ + SUPER-ADMIN GATING — POST-HIERARCHY-LOCKDOWN TESTS + + Verifies the invariant introduced when `onlyHatAdmin` was replaced by + `onlySuperAdmin` on all EligibilityModule write paths: only the org's + Executor (the superAdmin) may mutate eligibility, hat configuration, or + metadata. Hats-protocol hierarchical admins may still vouch (when + `combineWithHierarchy` is enabled) but cannot directly write — and the + eligibility check now blocks the previously-implicit "admin mints hat" + bypass. + ═══════════════════════════════════════════════════════════════════════════════════*/ + + /// @dev Returns voter1 wearing the executive role hat — a Hats hierarchical admin + /// of the default and member role hats, but NOT the EligibilityModule's superAdmin. + function _hierarchyAdmin(TestOrgSetup memory setup) internal returns (address hierarchyAdmin) { + hierarchyAdmin = voter1; + _mintAdminHat(setup.exec, setup.eligibilityModule, setup.executiveRoleHat, hierarchyAdmin); + } + + function testHierarchyAdminCannotSetWearerEligibility() public { + TestOrgSetup memory setup = _createTestOrg("Auth: setWearer"); + address hierarchyAdmin = _hierarchyAdmin(setup); + + vm.prank(hierarchyAdmin); + vm.expectRevert(EligibilityModule.NotSuperAdmin.selector); + EligibilityModule(setup.eligibilityModule).setWearerEligibility(address(0x55), setup.defaultRoleHat, true, true); + } + + function testHierarchyAdminCannotSetDefaultEligibility() public { + TestOrgSetup memory setup = _createTestOrg("Auth: setDefault"); + address hierarchyAdmin = _hierarchyAdmin(setup); + + vm.prank(hierarchyAdmin); + vm.expectRevert(EligibilityModule.NotSuperAdmin.selector); + EligibilityModule(setup.eligibilityModule).setDefaultEligibility(setup.defaultRoleHat, false, false); + } + + function testHierarchyAdminCannotClearWearerEligibility() public { + TestOrgSetup memory setup = _createTestOrg("Auth: clear"); + address hierarchyAdmin = _hierarchyAdmin(setup); + + // Executor first grants per-wearer eligibility so there's something to clear. + vm.prank(setup.exec); + EligibilityModule(setup.eligibilityModule).setWearerEligibility(address(0x55), setup.defaultRoleHat, true, true); + + vm.prank(hierarchyAdmin); + vm.expectRevert(EligibilityModule.NotSuperAdmin.selector); + EligibilityModule(setup.eligibilityModule).clearWearerEligibility(address(0x55), setup.defaultRoleHat); + } + + function testHierarchyAdminCannotSetBulkWearerEligibility() public { + TestOrgSetup memory setup = _createTestOrg("Auth: setBulk"); + address hierarchyAdmin = _hierarchyAdmin(setup); + address[] memory wearers = new address[](1); + wearers[0] = address(0x55); + + vm.prank(hierarchyAdmin); + vm.expectRevert(EligibilityModule.NotSuperAdmin.selector); + EligibilityModule(setup.eligibilityModule).setBulkWearerEligibility(wearers, setup.defaultRoleHat, true, true); + } + + function testHierarchyAdminCannotBatchSetWearerEligibility() public { + TestOrgSetup memory setup = _createTestOrg("Auth: batchSet"); + address hierarchyAdmin = _hierarchyAdmin(setup); + address[] memory wearers = new address[](1); + wearers[0] = address(0x55); + bool[] memory eligibles = new bool[](1); + eligibles[0] = true; + bool[] memory standings = new bool[](1); + standings[0] = true; + + vm.prank(hierarchyAdmin); + vm.expectRevert(EligibilityModule.NotSuperAdmin.selector); + EligibilityModule(setup.eligibilityModule) + .batchSetWearerEligibility(setup.defaultRoleHat, wearers, eligibles, standings); + } + + function testHierarchyAdminCannotCreateHatWithEligibility() public { + TestOrgSetup memory setup = _createTestOrg("Auth: createHat"); + address hierarchyAdmin = _hierarchyAdmin(setup); + + vm.prank(hierarchyAdmin); + vm.expectRevert(EligibilityModule.NotSuperAdmin.selector); + EligibilityModule(setup.eligibilityModule) + .createHatWithEligibility( + EligibilityModule.CreateHatParams({ + parentHatId: setup.defaultRoleHat, + details: "Forbidden Child Hat", + maxSupply: 1, + _mutable: true, + imageURI: "", + defaultEligible: true, + defaultStanding: true, + mintToAddresses: new address[](0), + wearerEligibleFlags: new bool[](0), + wearerStandingFlags: new bool[](0) + }) + ); + } + + function testHierarchyAdminCannotUpdateHatMetadata() public { + TestOrgSetup memory setup = _createTestOrg("Auth: updateMeta"); + address hierarchyAdmin = _hierarchyAdmin(setup); + + vm.prank(hierarchyAdmin); + vm.expectRevert(EligibilityModule.NotSuperAdmin.selector); + EligibilityModule(setup.eligibilityModule).updateHatMetadata(setup.defaultRoleHat, "Renamed", bytes32(0)); + } + + /// @dev The critical end-to-end test: with vouching configured + default eligibility false, + /// a Hats-hierarchical admin cannot mint a wearer into the hat by ANY path. The only + /// way for a new wearer to join is through the vouching mechanism. + function testHierarchyAdminCannotBypassVouchingViaDirectMint() public { + TestOrgSetup memory setup = _createTestOrg("Auth: bypass vouching"); + address hierarchyAdmin = _hierarchyAdmin(setup); + address candidate = address(0xC4); + _setupUserForVouching(setup.eligibilityModule, setup.exec, candidate); + + // Vouching configured: 1 vouch from an executiveRoleHat wearer (the membership hat) suffices. + // Default eligibility is set to false so vouching is the only entry path. + _configureVouching( + setup.eligibilityModule, setup.exec, setup.defaultRoleHat, 1, setup.executiveRoleHat, false, true + ); + + // Path 1 (eligibility write) — blocked: hierarchy admin is not the superAdmin. + vm.prank(hierarchyAdmin); + vm.expectRevert(EligibilityModule.NotSuperAdmin.selector); + EligibilityModule(setup.eligibilityModule).setWearerEligibility(candidate, setup.defaultRoleHat, true, true); + + // Path 2 (direct Hats mint) — blocked: candidate is ineligible by default, Hats reverts. + vm.prank(hierarchyAdmin); + vm.expectRevert(); + IHats(SEPOLIA_HATS).mintHat(setup.defaultRoleHat, candidate); + _assertWearingHat(candidate, setup.defaultRoleHat, false, "candidate after blocked direct-mint"); + + // Path 3 (vouching) — the documented entry path succeeds. hierarchyAdmin wears the + // executive role hat, which is the configured membership hat for this vouch. + _vouchFor(hierarchyAdmin, setup.eligibilityModule, candidate, setup.defaultRoleHat); + _assertEligibilityStatus( + setup.eligibilityModule, candidate, setup.defaultRoleHat, true, true, "candidate after vouch" + ); + + vm.prank(candidate); + EligibilityModule(setup.eligibilityModule).claimVouchedHat(setup.defaultRoleHat); + _assertWearingHat(candidate, setup.defaultRoleHat, true, "candidate after claim"); + } + + /// @dev A hierarchy admin cannot force-revoke an existing wearer either — the only + /// revocation paths are the executor's setWearerEligibility(false) or + /// claim-time vouch revocation. + function testHierarchyAdminCannotForceRevokeWearer() public { + TestOrgSetup memory setup = _createTestOrg("Auth: force revoke"); + address hierarchyAdmin = _hierarchyAdmin(setup); + address member = address(0xBE); + + // Executor mints the hat to member. + vm.prank(setup.exec); + EligibilityModule(setup.eligibilityModule).setWearerEligibility(member, setup.defaultRoleHat, true, true); + _mintHat(setup.exec, setup.defaultRoleHat, member); + _assertWearingHat(member, setup.defaultRoleHat, true, "member wearing hat pre-attack"); + + // Hierarchy admin's attempt to revoke is rejected. + vm.prank(hierarchyAdmin); + vm.expectRevert(EligibilityModule.NotSuperAdmin.selector); + EligibilityModule(setup.eligibilityModule).setWearerEligibility(member, setup.defaultRoleHat, false, false); + + _assertWearingHat(member, setup.defaultRoleHat, true, "member still wearing hat after blocked revoke"); + } + + /// @dev Positive case: `combineWithHierarchy` still lets a Hats-hierarchical admin + /// participate as a voucher even when they do not wear the configured + /// membership hat directly. This is the one residual power hierarchy retains. + function testHierarchyAdminCanStillVouchWhenCombineEnabled() public { + TestOrgSetup memory setup = _createTestOrg("Auth: hierarchy vouches"); + address hierarchyAdmin = _hierarchyAdmin(setup); + address candidate = address(0xC5); + _setupUserForVouching(setup.eligibilityModule, setup.exec, candidate); + + // Membership hat = memberRoleHat (which hierarchyAdmin does NOT wear). + // combineWithHierarchy = true means Hats-hierarchical admins of defaultRoleHat may also vouch. + _configureVouching( + setup.eligibilityModule, setup.exec, setup.defaultRoleHat, 1, setup.memberRoleHat, true, true + ); + + // hierarchyAdmin wears the executive role hat → admin of defaultRoleHat in Hats → + // qualifies as a voucher through the hierarchy branch of the auth check. + _vouchFor(hierarchyAdmin, setup.eligibilityModule, candidate, setup.defaultRoleHat); + _assertEligibilityStatus( + setup.eligibilityModule, + candidate, + setup.defaultRoleHat, + true, + true, + "candidate eligible via hierarchy-vouch path" + ); + } + + /// @dev Negative twin of the above: with `combineWithHierarchy = false`, a hierarchical + /// admin who does not wear the membership hat is rejected as a voucher. + function testHierarchyAdminCannotVouchWhenCombineDisabled() public { + TestOrgSetup memory setup = _createTestOrg("Auth: hierarchy denied"); + address hierarchyAdmin = _hierarchyAdmin(setup); + address candidate = address(0xC6); + _setupUserForVouching(setup.eligibilityModule, setup.exec, candidate); + + // Membership hat = memberRoleHat (not worn by hierarchyAdmin), combineWithHierarchy = false. + _configureVouching( + setup.eligibilityModule, setup.exec, setup.defaultRoleHat, 1, setup.memberRoleHat, false, true + ); + + vm.prank(hierarchyAdmin); + vm.expectRevert(EligibilityModule.NotAuthorizedToVouch.selector); + EligibilityModule(setup.eligibilityModule).vouchFor(candidate, setup.defaultRoleHat); + } + + /// @dev A non-superAdmin who has no hat affiliation at all is still rejected. Covers + /// the trivial case alongside the hierarchy-aware cases above so that any future + /// regression that re-introduces a wildcard auth branch fails clearly. + function testUnaffiliatedAddressCannotMutateEligibility() public { + TestOrgSetup memory setup = _createTestOrg("Auth: stranger"); + address stranger = address(0xDEAD); + + vm.prank(stranger); + vm.expectRevert(EligibilityModule.NotSuperAdmin.selector); + EligibilityModule(setup.eligibilityModule).setWearerEligibility(stranger, setup.defaultRoleHat, true, true); + + vm.prank(stranger); + vm.expectRevert(EligibilityModule.NotSuperAdmin.selector); + EligibilityModule(setup.eligibilityModule).setDefaultEligibility(setup.defaultRoleHat, true, true); + + vm.prank(stranger); + vm.expectRevert(EligibilityModule.NotSuperAdmin.selector); + EligibilityModule(setup.eligibilityModule).updateHatMetadata(setup.defaultRoleHat, "X", bytes32(0)); + } + /*══════════════════════════════════════════════════════════════════════════════ OPTIONAL EDUCATIONHUB TESTS ══════════════════════════════════════════════════════════════════════════════*/