diff --git a/src/OrgRegistry.sol b/src/OrgRegistry.sol index 58390b6..96c9c9f 100644 --- a/src/OrgRegistry.sol +++ b/src/OrgRegistry.sol @@ -1,9 +1,10 @@ -// SPDX‑License‑Identifier: MIT +// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; import "@openzeppelin-contracts-upgradeable/contracts/access/OwnableUpgradeable.sol"; import "@openzeppelin-contracts-upgradeable/contracts/proxy/utils/Initializable.sol"; import {ValidationLib} from "./libs/ValidationLib.sol"; +import {IHats} from "@hats-protocol/src/Interfaces/IHats.sol"; /* ─────────── Custom errors ─────────── */ error InvalidParam(); @@ -12,6 +13,7 @@ error OrgUnknown(); error TypeTaken(); error ContractUnknown(); error NotOrgExecutor(); +error NotOrgMetadataAdmin(); error OwnerOnlyDuringBootstrap(); // deployer tried after bootstrap error AutoUpgradeRequired(); // deployer must set autoUpgrade=true @@ -59,6 +61,10 @@ contract OrgRegistry is Initializable, OwnableUpgradeable { mapping(bytes32 => mapping(uint256 => uint256)) roleHatOf; // orgId => roleIndex => hatId bytes32[] orgIds; uint256 totalContracts; + // Optional per-org metadata admin hat (if 0, falls back to topHat) + mapping(bytes32 => uint256) metadataAdminHatOf; + // Hats Protocol address for permission checks + IHats hats; } // keccak256("poa.orgregistry.storage") to get a unique, collision-free slot @@ -84,6 +90,7 @@ contract OrgRegistry is Initializable, OwnableUpgradeable { ); event AutoUpgradeSet(bytes32 indexed contractId, bool enabled); event HatsTreeRegistered(bytes32 indexed orgId, uint256 topHatId, uint256[] roleHatIds); + event OrgMetadataAdminHatSet(bytes32 indexed orgId, uint256 hatId); /// @custom:oz-upgrades-unsafe-allow constructor constructor() initializer {} @@ -91,10 +98,19 @@ contract OrgRegistry is Initializable, OwnableUpgradeable { /** * @dev Initializes the contract, replacing the constructor for upgradeable pattern * @param initialOwner The address that will own this registry + * @param _hats The Hats Protocol contract address */ - function initialize(address initialOwner) external initializer { - if (initialOwner == address(0)) revert InvalidParam(); + function initialize(address initialOwner, address _hats) external initializer { + if (initialOwner == address(0) || _hats == address(0)) revert InvalidParam(); __Ownable_init(initialOwner); + _layout().hats = IHats(_hats); + } + + /** + * @dev Returns the Hats Protocol contract address + */ + function getHats() external view returns (address) { + return address(_layout().hats); } /* ═════════════════ ORG LOGIC ═════════════════ */ @@ -157,6 +173,12 @@ contract OrgRegistry is Initializable, OwnableUpgradeable { o.executor = executorAddr; } + /** + * @dev Updates org metadata (governance path - only executor) + * @param orgId The organization ID + * @param newName New organization name (bytes, validated by ValidationLib) + * @param newMetadataHash New IPFS metadata hash (bytes32) + */ function updateOrgMeta(bytes32 orgId, bytes calldata newName, bytes32 newMetadataHash) external { ValidationLib.requireValidTitle(newName); Layout storage l = _layout(); @@ -167,6 +189,57 @@ contract OrgRegistry is Initializable, OwnableUpgradeable { emit MetaUpdated(orgId, newName, newMetadataHash); } + /** + * @dev Allows a metadata admin hat wearer to update org metadata directly (no governance) + * @param orgId The organization ID + * @param newName New organization name (bytes, validated by ValidationLib) + * @param newMetadataHash New IPFS metadata hash (bytes32) + */ + function updateOrgMetaAsAdmin(bytes32 orgId, bytes calldata newName, bytes32 newMetadataHash) external { + ValidationLib.requireValidTitle(newName); + + Layout storage l = _layout(); + OrgInfo storage o = l.orgOf[orgId]; + if (!o.exists) revert OrgUnknown(); + + // Ensure hats protocol is configured + IHats hats = l.hats; + if (address(hats) == address(0)) revert InvalidParam(); + + // Check if caller wears the org's metadata admin hat (optional, falls back to topHat) + uint256 metadataAdminHat = l.metadataAdminHatOf[orgId]; + if (metadataAdminHat == 0) { + metadataAdminHat = l.topHatOf[orgId]; + } + if (metadataAdminHat == 0) revert NotOrgMetadataAdmin(); + + if (!hats.isWearerOfHat(msg.sender, metadataAdminHat)) revert NotOrgMetadataAdmin(); + + emit MetaUpdated(orgId, newName, newMetadataHash); + } + + /** + * @dev Set the metadata admin hat for an org (only executor can do this) + * @param orgId The organization ID + * @param hatId The hat ID that can edit metadata directly (0 to use topHat as fallback) + */ + function setOrgMetadataAdminHat(bytes32 orgId, uint256 hatId) external { + Layout storage l = _layout(); + OrgInfo storage o = l.orgOf[orgId]; + if (!o.exists) revert OrgUnknown(); + if (msg.sender != o.executor) revert NotOrgExecutor(); + + l.metadataAdminHatOf[orgId] = hatId; + emit OrgMetadataAdminHatSet(orgId, hatId); + } + + /** + * @dev Get the metadata admin hat for an org (returns 0 if not set, meaning topHat is used) + */ + function getOrgMetadataAdminHat(bytes32 orgId) external view returns (uint256) { + return _layout().metadataAdminHatOf[orgId]; + } + /* ══════════ CONTRACT REGISTRATION ══════════ */ /** * ‑ During **bootstrap** (`o.bootstrap == true`) the registry owner _may_ diff --git a/test/DeployerTest.t.sol b/test/DeployerTest.t.sol index d7aefae..7d110b6 100644 --- a/test/DeployerTest.t.sol +++ b/test/DeployerTest.t.sol @@ -680,8 +680,8 @@ contract DeployerTest is Test, IEligibilityModuleEvents { address orgRegBeacon = poaManager.getBeaconById(keccak256("OrgRegistry")); address deployerBeacon = poaManager.getBeaconById(keccak256("OrgDeployer")); - // Create OrgRegistry proxy - initialize with poaAdmin as owner - bytes memory orgRegistryInit = abi.encodeWithSignature("initialize(address)", poaAdmin); + // Create OrgRegistry proxy - initialize with poaAdmin as owner and hats address + bytes memory orgRegistryInit = abi.encodeWithSignature("initialize(address,address)", poaAdmin, SEPOLIA_HATS); orgRegistry = OrgRegistry(address(new BeaconProxy(orgRegBeacon, orgRegistryInit))); // Debug to verify OrgRegistry owner diff --git a/test/OrgRegistry.t.sol b/test/OrgRegistry.t.sol index 957b60d..20c32d8 100644 --- a/test/OrgRegistry.t.sol +++ b/test/OrgRegistry.t.sol @@ -5,13 +5,35 @@ import "forge-std/Test.sol"; import "../src/OrgRegistry.sol"; import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +/// @dev Mock Hats contract for testing +contract MockHats { + mapping(address => mapping(uint256 => bool)) public wearers; + + function setWearer(address account, uint256 hatId, bool isWearer) external { + wearers[account][hatId] = isWearer; + } + + function isWearerOfHat(address account, uint256 hatId) external view returns (bool) { + return wearers[account][hatId]; + } +} + contract OrgRegistryTest is Test { OrgRegistry reg; + MockHats mockHats; bytes32 ORG_ID = keccak256("ORG"); + uint256 constant TOP_HAT_ID = 1; + uint256 constant ADMIN_HAT_ID = 2; + address constant ADMIN_USER = address(0xAD); + address constant NON_ADMIN_USER = address(0xBA); function setUp() public { + // Deploy mock hats + mockHats = new MockHats(); + + // Deploy OrgRegistry with mock hats OrgRegistry impl = new OrgRegistry(); - bytes memory data = abi.encodeCall(OrgRegistry.initialize, (address(this))); + bytes memory data = abi.encodeCall(OrgRegistry.initialize, (address(this), address(mockHats))); ERC1967Proxy proxy = new ERC1967Proxy(address(impl), data); reg = OrgRegistry(address(proxy)); } @@ -25,4 +47,132 @@ contract OrgRegistryTest is Test { address proxy = reg.proxyOf(ORG_ID, typeId); assertEq(proxy, address(0x1)); } + + function testGetHats() public view { + assertEq(reg.getHats(), address(mockHats)); + } + + /* ══════════ Metadata Admin Tests ══════════ */ + + function testUpdateOrgMetaAsAdmin_WithTopHat() public { + // Setup: register org and hats tree + reg.registerOrg(ORG_ID, address(this), bytes("Test Org"), bytes32(0)); + uint256[] memory roleHats = new uint256[](0); + reg.registerHatsTree(ORG_ID, TOP_HAT_ID, roleHats); + + // Make ADMIN_USER wear the top hat + mockHats.setWearer(ADMIN_USER, TOP_HAT_ID, true); + + // Should succeed when caller wears top hat + vm.prank(ADMIN_USER); + reg.updateOrgMetaAsAdmin(ORG_ID, bytes("New Name"), bytes32(uint256(1))); + + // Verify event was emitted (implicitly tested by no revert) + } + + function testUpdateOrgMetaAsAdmin_WithCustomAdminHat() public { + // Setup: register org and hats tree + reg.registerOrg(ORG_ID, address(this), bytes("Test Org"), bytes32(0)); + uint256[] memory roleHats = new uint256[](0); + reg.registerHatsTree(ORG_ID, TOP_HAT_ID, roleHats); + + // Set custom metadata admin hat (as executor) + reg.setOrgMetadataAdminHat(ORG_ID, ADMIN_HAT_ID); + + // Make ADMIN_USER wear the custom admin hat (not the top hat) + mockHats.setWearer(ADMIN_USER, ADMIN_HAT_ID, true); + + // Should succeed when caller wears custom admin hat + vm.prank(ADMIN_USER); + reg.updateOrgMetaAsAdmin(ORG_ID, bytes("New Name"), bytes32(uint256(1))); + } + + function testUpdateOrgMetaAsAdmin_RevertWhenNotWearingHat() public { + // Setup: register org and hats tree + reg.registerOrg(ORG_ID, address(this), bytes("Test Org"), bytes32(0)); + uint256[] memory roleHats = new uint256[](0); + reg.registerHatsTree(ORG_ID, TOP_HAT_ID, roleHats); + + // NON_ADMIN_USER doesn't wear any hat + vm.prank(NON_ADMIN_USER); + vm.expectRevert(NotOrgMetadataAdmin.selector); + reg.updateOrgMetaAsAdmin(ORG_ID, bytes("New Name"), bytes32(uint256(1))); + } + + function testUpdateOrgMetaAsAdmin_RevertWhenNoHatsConfigured() public { + // Setup: register org but NO hats tree + reg.registerOrg(ORG_ID, address(this), bytes("Test Org"), bytes32(0)); + + // Should revert because no top hat or admin hat is set + vm.prank(ADMIN_USER); + vm.expectRevert(NotOrgMetadataAdmin.selector); + reg.updateOrgMetaAsAdmin(ORG_ID, bytes("New Name"), bytes32(uint256(1))); + } + + function testUpdateOrgMetaAsAdmin_RevertWhenOrgUnknown() public { + bytes32 unknownOrgId = keccak256("UNKNOWN"); + + vm.prank(ADMIN_USER); + vm.expectRevert(OrgUnknown.selector); + reg.updateOrgMetaAsAdmin(unknownOrgId, bytes("New Name"), bytes32(uint256(1))); + } + + function testSetOrgMetadataAdminHat_Success() public { + // Setup: register org + reg.registerOrg(ORG_ID, address(this), bytes("Test Org"), bytes32(0)); + + // Set metadata admin hat (as executor - which is address(this)) + reg.setOrgMetadataAdminHat(ORG_ID, ADMIN_HAT_ID); + + // Verify it was set + assertEq(reg.getOrgMetadataAdminHat(ORG_ID), ADMIN_HAT_ID); + } + + function testSetOrgMetadataAdminHat_RevertWhenNotExecutor() public { + // Setup: register org with address(this) as executor + reg.registerOrg(ORG_ID, address(this), bytes("Test Org"), bytes32(0)); + + // Try to set from non-executor + vm.prank(NON_ADMIN_USER); + vm.expectRevert(NotOrgExecutor.selector); + reg.setOrgMetadataAdminHat(ORG_ID, ADMIN_HAT_ID); + } + + function testSetOrgMetadataAdminHat_CanResetToZero() public { + // Setup: register org and set admin hat + reg.registerOrg(ORG_ID, address(this), bytes("Test Org"), bytes32(0)); + reg.setOrgMetadataAdminHat(ORG_ID, ADMIN_HAT_ID); + + // Reset to zero (falls back to topHat) + reg.setOrgMetadataAdminHat(ORG_ID, 0); + + assertEq(reg.getOrgMetadataAdminHat(ORG_ID), 0); + } + + function testGetOrgMetadataAdminHat_ReturnsZeroByDefault() public { + // Setup: register org + reg.registerOrg(ORG_ID, address(this), bytes("Test Org"), bytes32(0)); + + // Should return 0 if not set + assertEq(reg.getOrgMetadataAdminHat(ORG_ID), 0); + } + + function testUpdateOrgMetaAsAdmin_CustomHatTakesPrecedenceOverTopHat() public { + // Setup: register org and hats tree + reg.registerOrg(ORG_ID, address(this), bytes("Test Org"), bytes32(0)); + uint256[] memory roleHats = new uint256[](0); + reg.registerHatsTree(ORG_ID, TOP_HAT_ID, roleHats); + + // Set custom metadata admin hat + reg.setOrgMetadataAdminHat(ORG_ID, ADMIN_HAT_ID); + + // ADMIN_USER wears top hat but NOT custom admin hat + mockHats.setWearer(ADMIN_USER, TOP_HAT_ID, true); + mockHats.setWearer(ADMIN_USER, ADMIN_HAT_ID, false); + + // Should FAIL because custom admin hat takes precedence + vm.prank(ADMIN_USER); + vm.expectRevert(NotOrgMetadataAdmin.selector); + reg.updateOrgMetaAsAdmin(ORG_ID, bytes("New Name"), bytes32(uint256(1))); + } }