From 25f43ea892c2a21c918f9c743dc716d80f7ae9a6 Mon Sep 17 00:00:00 2001 From: hudsonhrh Date: Sun, 1 Feb 2026 15:02:31 -0500 Subject: [PATCH 1/8] feat: Add direct metadata editing for org admins - Add updateOrgMetaAsAdmin function for admin hat wearers to edit metadata directly - Add setOrgAdminHat/getOrgAdminHat to configure per-org admin hats - Add setHatsProtocol/getHatsProtocol for Hats Protocol integration - Falls back to topHat if no admin hat is configured - Add IHats interface for checking hat wearers - Add NotOrgAdmin custom error - Add OrgAdminHatSet and HatsProtocolSet events This enables org admins (topHat or configured admin hat wearers) to update organization metadata (name, description, logo, links) without going through the governance proposal flow. Co-Authored-By: Claude Opus 4.5 --- src/OrgRegistry.sol | 83 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/src/OrgRegistry.sol b/src/OrgRegistry.sol index 58390b6..f787c2c 100644 --- a/src/OrgRegistry.sol +++ b/src/OrgRegistry.sol @@ -5,6 +5,11 @@ import "@openzeppelin-contracts-upgradeable/contracts/access/OwnableUpgradeable. import "@openzeppelin-contracts-upgradeable/contracts/proxy/utils/Initializable.sol"; import {ValidationLib} from "./libs/ValidationLib.sol"; +/* ─────────── Hats Protocol Interface ─────────── */ +interface IHats { + function isWearerOfHat(address account, uint256 hatId) external view returns (bool); +} + /* ─────────── Custom errors ─────────── */ error InvalidParam(); error OrgExists(); @@ -12,6 +17,7 @@ error OrgUnknown(); error TypeTaken(); error ContractUnknown(); error NotOrgExecutor(); +error NotOrgAdmin(); error OwnerOnlyDuringBootstrap(); // deployer tried after bootstrap error AutoUpgradeRequired(); // deployer must set autoUpgrade=true @@ -59,6 +65,9 @@ contract OrgRegistry is Initializable, OwnableUpgradeable { mapping(bytes32 => mapping(uint256 => uint256)) roleHatOf; // orgId => roleIndex => hatId bytes32[] orgIds; uint256 totalContracts; + // New storage for admin hat feature + mapping(bytes32 => uint256) adminHatOf; // orgId => admin hatId for direct metadata editing + address hatsProtocol; // Hats Protocol contract address } // keccak256("poa.orgregistry.storage") to get a unique, collision-free slot @@ -84,6 +93,8 @@ contract OrgRegistry is Initializable, OwnableUpgradeable { ); event AutoUpgradeSet(bytes32 indexed contractId, bool enabled); event HatsTreeRegistered(bytes32 indexed orgId, uint256 topHatId, uint256[] roleHatIds); + event OrgAdminHatSet(bytes32 indexed orgId, uint256 hatId); + event HatsProtocolSet(address hatsProtocol); /// @custom:oz-upgrades-unsafe-allow constructor constructor() initializer {} @@ -97,6 +108,23 @@ contract OrgRegistry is Initializable, OwnableUpgradeable { __Ownable_init(initialOwner); } + /** + * @dev Sets the Hats Protocol contract address + * @param _hats The Hats Protocol contract address + */ + function setHatsProtocol(address _hats) external onlyOwner { + if (_hats == address(0)) revert InvalidParam(); + _layout().hatsProtocol = _hats; + emit HatsProtocolSet(_hats); + } + + /** + * @dev Gets the Hats Protocol contract address + */ + function getHatsProtocol() external view returns (address) { + return _layout().hatsProtocol; + } + /* ═════════════════ ORG LOGIC ═════════════════ */ function registerOrg(bytes32 orgId, address executorAddr, bytes calldata name, bytes32 metadataHash) external @@ -157,6 +185,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 +201,55 @@ contract OrgRegistry is Initializable, OwnableUpgradeable { emit MetaUpdated(orgId, newName, newMetadataHash); } + /** + * @dev Allows an 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(); + + // Check if caller wears the org's admin hat + uint256 adminHat = l.adminHatOf[orgId]; + if (adminHat == 0) { + // No admin hat configured, fall back to topHat + adminHat = l.topHatOf[orgId]; + } + if (adminHat == 0) revert NotOrgAdmin(); + + address hats = l.hatsProtocol; + if (hats == address(0)) revert InvalidParam(); + if (!IHats(hats).isWearerOfHat(msg.sender, adminHat)) revert NotOrgAdmin(); + + emit MetaUpdated(orgId, newName, newMetadataHash); + } + + /** + * @dev Set the 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) + */ + function setOrgAdminHat(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.adminHatOf[orgId] = hatId; + emit OrgAdminHatSet(orgId, hatId); + } + + /** + * @dev Get the admin hat for an org + */ + function getOrgAdminHat(bytes32 orgId) external view returns (uint256) { + return _layout().adminHatOf[orgId]; + } + /* ══════════ CONTRACT REGISTRATION ══════════ */ /** * ‑ During **bootstrap** (`o.bootstrap == true`) the registry owner _may_ From a963de4380dba8d51e0c418e0825ba772e4c14ad Mon Sep 17 00:00:00 2001 From: hudsonhrh Date: Sun, 1 Feb 2026 21:09:15 -0500 Subject: [PATCH 2/8] fix: Rename IHats to IHatsMinimal to avoid collision The hats-protocol library already defines an IHats interface in lib/hats-protocol/src/Interfaces/IHats.sol. When OrgRegistry.sol is imported alongside files that import the full IHats, the compiler throws an "Identifier already declared" error. Renamed our minimal interface to IHatsMinimal to avoid the collision. Co-Authored-By: Claude Opus 4.5 --- src/OrgRegistry.sol | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/OrgRegistry.sol b/src/OrgRegistry.sol index f787c2c..a4cda8a 100644 --- a/src/OrgRegistry.sol +++ b/src/OrgRegistry.sol @@ -5,8 +5,10 @@ import "@openzeppelin-contracts-upgradeable/contracts/access/OwnableUpgradeable. import "@openzeppelin-contracts-upgradeable/contracts/proxy/utils/Initializable.sol"; import {ValidationLib} from "./libs/ValidationLib.sol"; -/* ─────────── Hats Protocol Interface ─────────── */ -interface IHats { +/* ─────────── Minimal Hats Protocol Interface ─────────── */ +/// @dev Minimal interface for Hats Protocol - only what we need for admin checks +/// Using a distinct name to avoid collision with the full IHats in lib/hats-protocol +interface IHatsMinimal { function isWearerOfHat(address account, uint256 hatId) external view returns (bool); } @@ -223,7 +225,7 @@ contract OrgRegistry is Initializable, OwnableUpgradeable { address hats = l.hatsProtocol; if (hats == address(0)) revert InvalidParam(); - if (!IHats(hats).isWearerOfHat(msg.sender, adminHat)) revert NotOrgAdmin(); + if (!IHatsMinimal(hats).isWearerOfHat(msg.sender, adminHat)) revert NotOrgAdmin(); emit MetaUpdated(orgId, newName, newMetadataHash); } From 810d57c62b9f1eb459e0ce2724885963ee491d0e Mon Sep 17 00:00:00 2001 From: hudsonhrh Date: Sun, 1 Feb 2026 21:20:09 -0500 Subject: [PATCH 3/8] refactor: Import IHats from hats-protocol instead of defining minimal interface Removes the custom IHatsMinimal interface and imports the actual IHats interface from the hats-protocol library. This is the proper approach: - Reuses the existing, well-tested interface - Follows the pattern used elsewhere in the codebase - Avoids interface duplication and potential inconsistencies Co-Authored-By: Claude Opus 4.5 --- src/OrgRegistry.sol | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/OrgRegistry.sol b/src/OrgRegistry.sol index a4cda8a..c1afba4 100644 --- a/src/OrgRegistry.sol +++ b/src/OrgRegistry.sol @@ -4,13 +4,7 @@ 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"; - -/* ─────────── Minimal Hats Protocol Interface ─────────── */ -/// @dev Minimal interface for Hats Protocol - only what we need for admin checks -/// Using a distinct name to avoid collision with the full IHats in lib/hats-protocol -interface IHatsMinimal { - function isWearerOfHat(address account, uint256 hatId) external view returns (bool); -} +import {IHats} from "@hats-protocol/src/Interfaces/IHats.sol"; /* ─────────── Custom errors ─────────── */ error InvalidParam(); @@ -225,7 +219,7 @@ contract OrgRegistry is Initializable, OwnableUpgradeable { address hats = l.hatsProtocol; if (hats == address(0)) revert InvalidParam(); - if (!IHatsMinimal(hats).isWearerOfHat(msg.sender, adminHat)) revert NotOrgAdmin(); + if (!IHats(hats).isWearerOfHat(msg.sender, adminHat)) revert NotOrgAdmin(); emit MetaUpdated(orgId, newName, newMetadataHash); } From a3990cfcca7a67acc8d60e18e5a689fedf01cae0 Mon Sep 17 00:00:00 2001 From: hudsonhrh Date: Sun, 1 Feb 2026 21:34:32 -0500 Subject: [PATCH 4/8] refactor: Simplify metadata admin feature - Remove setHatsProtocol/getHatsProtocol - hats address passed as param instead - Rename to OrgMetadataAdmin to clarify scope (metadata editing only, not full admin) - metadataAdminHatOf is optional - if 0, falls back to topHat - Cleaner interface: no global state, explicit hats address per call Co-Authored-By: Claude Opus 4.5 --- src/OrgRegistry.sol | 67 +++++++++++++++++---------------------------- 1 file changed, 25 insertions(+), 42 deletions(-) diff --git a/src/OrgRegistry.sol b/src/OrgRegistry.sol index c1afba4..1565919 100644 --- a/src/OrgRegistry.sol +++ b/src/OrgRegistry.sol @@ -13,7 +13,7 @@ error OrgUnknown(); error TypeTaken(); error ContractUnknown(); error NotOrgExecutor(); -error NotOrgAdmin(); +error NotOrgMetadataAdmin(); error OwnerOnlyDuringBootstrap(); // deployer tried after bootstrap error AutoUpgradeRequired(); // deployer must set autoUpgrade=true @@ -61,9 +61,8 @@ contract OrgRegistry is Initializable, OwnableUpgradeable { mapping(bytes32 => mapping(uint256 => uint256)) roleHatOf; // orgId => roleIndex => hatId bytes32[] orgIds; uint256 totalContracts; - // New storage for admin hat feature - mapping(bytes32 => uint256) adminHatOf; // orgId => admin hatId for direct metadata editing - address hatsProtocol; // Hats Protocol contract address + // Optional per-org metadata admin hat (if 0, falls back to topHat) + mapping(bytes32 => uint256) metadataAdminHatOf; } // keccak256("poa.orgregistry.storage") to get a unique, collision-free slot @@ -89,8 +88,7 @@ contract OrgRegistry is Initializable, OwnableUpgradeable { ); event AutoUpgradeSet(bytes32 indexed contractId, bool enabled); event HatsTreeRegistered(bytes32 indexed orgId, uint256 topHatId, uint256[] roleHatIds); - event OrgAdminHatSet(bytes32 indexed orgId, uint256 hatId); - event HatsProtocolSet(address hatsProtocol); + event OrgMetadataAdminHatSet(bytes32 indexed orgId, uint256 hatId); /// @custom:oz-upgrades-unsafe-allow constructor constructor() initializer {} @@ -104,23 +102,6 @@ contract OrgRegistry is Initializable, OwnableUpgradeable { __Ownable_init(initialOwner); } - /** - * @dev Sets the Hats Protocol contract address - * @param _hats The Hats Protocol contract address - */ - function setHatsProtocol(address _hats) external onlyOwner { - if (_hats == address(0)) revert InvalidParam(); - _layout().hatsProtocol = _hats; - emit HatsProtocolSet(_hats); - } - - /** - * @dev Gets the Hats Protocol contract address - */ - function getHatsProtocol() external view returns (address) { - return _layout().hatsProtocol; - } - /* ═════════════════ ORG LOGIC ═════════════════ */ function registerOrg(bytes32 orgId, address executorAddr, bytes calldata name, bytes32 metadataHash) external @@ -198,52 +179,54 @@ contract OrgRegistry is Initializable, OwnableUpgradeable { } /** - * @dev Allows an admin hat wearer to update org metadata directly (no governance) + * @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) + * @param hats The Hats Protocol contract address */ - function updateOrgMetaAsAdmin(bytes32 orgId, bytes calldata newName, bytes32 newMetadataHash) external { + function updateOrgMetaAsAdmin(bytes32 orgId, bytes calldata newName, bytes32 newMetadataHash, address hats) + external + { ValidationLib.requireValidTitle(newName); + if (hats == address(0)) revert InvalidParam(); + Layout storage l = _layout(); OrgInfo storage o = l.orgOf[orgId]; if (!o.exists) revert OrgUnknown(); - // Check if caller wears the org's admin hat - uint256 adminHat = l.adminHatOf[orgId]; - if (adminHat == 0) { - // No admin hat configured, fall back to topHat - adminHat = l.topHatOf[orgId]; + // 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 (adminHat == 0) revert NotOrgAdmin(); + if (metadataAdminHat == 0) revert NotOrgMetadataAdmin(); - address hats = l.hatsProtocol; - if (hats == address(0)) revert InvalidParam(); - if (!IHats(hats).isWearerOfHat(msg.sender, adminHat)) revert NotOrgAdmin(); + if (!IHats(hats).isWearerOfHat(msg.sender, metadataAdminHat)) revert NotOrgMetadataAdmin(); emit MetaUpdated(orgId, newName, newMetadataHash); } /** - * @dev Set the admin hat for an org (only executor can do this) + * @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) + * @param hatId The hat ID that can edit metadata directly (0 to use topHat as fallback) */ - function setOrgAdminHat(bytes32 orgId, uint256 hatId) external { + 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.adminHatOf[orgId] = hatId; - emit OrgAdminHatSet(orgId, hatId); + l.metadataAdminHatOf[orgId] = hatId; + emit OrgMetadataAdminHatSet(orgId, hatId); } /** - * @dev Get the admin hat for an org + * @dev Get the metadata admin hat for an org (returns 0 if not set, meaning topHat is used) */ - function getOrgAdminHat(bytes32 orgId) external view returns (uint256) { - return _layout().adminHatOf[orgId]; + function getOrgMetadataAdminHat(bytes32 orgId) external view returns (uint256) { + return _layout().metadataAdminHatOf[orgId]; } /* ══════════ CONTRACT REGISTRATION ══════════ */ From 48e54ffeab791145427237334a3bfb26924fe86f Mon Sep 17 00:00:00 2001 From: hudsonhrh Date: Sun, 1 Feb 2026 21:56:12 -0500 Subject: [PATCH 5/8] refactor: Store hats address in OrgRegistry instead of passing as parameter Security fix: Previously updateOrgMetaAsAdmin took hats address as a parameter which could allow malicious callers to pass a fake contract. Now the hats address is stored in contract storage and set via initialize. Changes: - Add IHats hats to Layout storage struct - Add hats parameter to initialize() function - Add getHats() view function - Remove hats parameter from updateOrgMetaAsAdmin (now uses stored address) Co-Authored-By: Claude Opus 4.5 --- src/OrgRegistry.sol | 27 +++++++++++++++++++-------- test/OrgRegistry.t.sol | 3 ++- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/src/OrgRegistry.sol b/src/OrgRegistry.sol index 1565919..3c899fc 100644 --- a/src/OrgRegistry.sol +++ b/src/OrgRegistry.sol @@ -63,6 +63,8 @@ contract OrgRegistry is Initializable, OwnableUpgradeable { 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 @@ -96,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 ═════════════════ */ @@ -183,18 +194,18 @@ contract OrgRegistry is Initializable, OwnableUpgradeable { * @param orgId The organization ID * @param newName New organization name (bytes, validated by ValidationLib) * @param newMetadataHash New IPFS metadata hash (bytes32) - * @param hats The Hats Protocol contract address */ - function updateOrgMetaAsAdmin(bytes32 orgId, bytes calldata newName, bytes32 newMetadataHash, address hats) - external - { + function updateOrgMetaAsAdmin(bytes32 orgId, bytes calldata newName, bytes32 newMetadataHash) external { ValidationLib.requireValidTitle(newName); - if (hats == address(0)) revert InvalidParam(); 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) { @@ -202,7 +213,7 @@ contract OrgRegistry is Initializable, OwnableUpgradeable { } if (metadataAdminHat == 0) revert NotOrgMetadataAdmin(); - if (!IHats(hats).isWearerOfHat(msg.sender, metadataAdminHat)) revert NotOrgMetadataAdmin(); + if (!hats.isWearerOfHat(msg.sender, metadataAdminHat)) revert NotOrgMetadataAdmin(); emit MetaUpdated(orgId, newName, newMetadataHash); } diff --git a/test/OrgRegistry.t.sol b/test/OrgRegistry.t.sol index 957b60d..8e643b9 100644 --- a/test/OrgRegistry.t.sol +++ b/test/OrgRegistry.t.sol @@ -8,10 +8,11 @@ import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; contract OrgRegistryTest is Test { OrgRegistry reg; bytes32 ORG_ID = keccak256("ORG"); + address constant MOCK_HATS = address(0x1234); // Mock hats address function setUp() public { OrgRegistry impl = new OrgRegistry(); - bytes memory data = abi.encodeCall(OrgRegistry.initialize, (address(this))); + bytes memory data = abi.encodeCall(OrgRegistry.initialize, (address(this), MOCK_HATS)); ERC1967Proxy proxy = new ERC1967Proxy(address(impl), data); reg = OrgRegistry(address(proxy)); } From 310f4d700263335d5b8798e3821a03d596cd0b1b Mon Sep 17 00:00:00 2001 From: hudsonhrh Date: Sun, 1 Feb 2026 22:02:45 -0500 Subject: [PATCH 6/8] test: Add comprehensive tests for metadata admin functionality Tests cover: - updateOrgMetaAsAdmin with topHat (fallback) - updateOrgMetaAsAdmin with custom admin hat - updateOrgMetaAsAdmin reverts for non-hat wearers - updateOrgMetaAsAdmin reverts when no hats configured - updateOrgMetaAsAdmin reverts for unknown org - setOrgMetadataAdminHat by executor - setOrgMetadataAdminHat reverts for non-executor - setOrgMetadataAdminHat can reset to zero - getOrgMetadataAdminHat returns zero by default - Custom admin hat takes precedence over topHat - getHats returns correct address Co-Authored-By: Claude Opus 4.5 --- test/OrgRegistry.t.sol | 153 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 151 insertions(+), 2 deletions(-) diff --git a/test/OrgRegistry.t.sol b/test/OrgRegistry.t.sol index 8e643b9..20c32d8 100644 --- a/test/OrgRegistry.t.sol +++ b/test/OrgRegistry.t.sol @@ -5,14 +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"); - address constant MOCK_HATS = address(0x1234); // Mock hats address + 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), MOCK_HATS)); + bytes memory data = abi.encodeCall(OrgRegistry.initialize, (address(this), address(mockHats))); ERC1967Proxy proxy = new ERC1967Proxy(address(impl), data); reg = OrgRegistry(address(proxy)); } @@ -26,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))); + } } From e23d0d89b12f909524bb6d692e88be5372e9901c Mon Sep 17 00:00:00 2001 From: hudsonhrh Date: Sun, 1 Feb 2026 22:05:06 -0500 Subject: [PATCH 7/8] fix: Use standard hyphen in SPDX license identifier Co-Authored-By: Claude Opus 4.5 --- src/OrgRegistry.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/OrgRegistry.sol b/src/OrgRegistry.sol index 3c899fc..96c9c9f 100644 --- a/src/OrgRegistry.sol +++ b/src/OrgRegistry.sol @@ -1,4 +1,4 @@ -// SPDX‑License‑Identifier: MIT +// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; import "@openzeppelin-contracts-upgradeable/contracts/access/OwnableUpgradeable.sol"; From 25d5130dea287594c0bfdd4fe52680731da6bce3 Mon Sep 17 00:00:00 2001 From: hudsonhrh Date: Sun, 1 Feb 2026 22:22:35 -0500 Subject: [PATCH 8/8] fix: Update DeployerTest to use new initialize signature with hats address Co-Authored-By: Claude Opus 4.5 --- test/DeployerTest.t.sol | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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