Skip to content
79 changes: 76 additions & 3 deletions src/OrgRegistry.sol
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
// SPDXLicenseIdentifier: 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();
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -84,17 +90,27 @@ 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 {}

/**
* @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 ═════════════════ */
Expand Down Expand Up @@ -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();
Expand All @@ -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_
Expand Down
4 changes: 2 additions & 2 deletions test/DeployerTest.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
152 changes: 151 additions & 1 deletion test/OrgRegistry.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
Expand All @@ -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)));
}
}