// SPDX-License-Identifier: MIT pragma solidity ^0.8.25; /** * @title VaultfireForumRegistry * @notice On-chain registry mapping forum category names to XMTP MLS groupIds. * * @dev * Lightweight, permissionless registry for the Vaultfire Discussion Board. * * MLS groups in XMTP are addressed by an opaque hex `groupId`. To allow new * users to discover and join a category's group without a centralized server, * the FIRST member to create a category's MLS group registers the resulting * groupId here. Subsequent users read the registry, request to join, and * existing members can add them. * * Design properties: * - First-write-wins: prevents drive-by hijacks. Once a category is set, * it cannot be overwritten (except by the original publisher, see below). * - Publisher can rotate: the address that originally registered a category * can publish a new groupId for it (e.g. if the group was rotated). This * keeps the registry usable without admin keys. * - Stateless: no fees, no access control, no upgradeability. Just a map. * - No personal data: only the publisher's address, the category string, * and an opaque groupId hex. * * @custom:security-model Zero-trust. Anyone can register an unset category; * only the original publisher can rotate. No owner / admin. * @custom:ethics Privacy default — no PII, no KYC, no surveillance. */ contract VaultfireForumRegistry { /// @notice Registry entry per category struct Entry { bytes groupId; // opaque MLS groupId (variable-length bytes) address publisher; // who registered this entry uint256 publishedAt; // block timestamp of first registration uint256 updatedAt; // last rotation timestamp (== publishedAt if never rotated) } /// @notice category string => entry. Keys are e.g. "general", "technical". mapping(string => Entry) private _entries; /// @notice list of all registered categories, in registration order string[] private _categories; event GroupRegistered( string indexed categoryHashIndexed, // indexed for filtering — keccak of category string category, // human-readable bytes groupId, address indexed publisher, uint256 timestamp ); event GroupRotated( string indexed categoryHashIndexed, string category, bytes oldGroupId, bytes newGroupId, address indexed publisher, uint256 timestamp ); /** * @notice Register a new groupId for a category. First-write-wins. * @dev Reverts if the category is already registered by someone else. * To rotate an existing category, the original publisher must call * this — they are allowed to overwrite their own entry. * @param category Lowercase category slug (e.g. "general") * @param groupId Opaque MLS groupId bytes */ function registerGroup(string calldata category, bytes calldata groupId) external { require(bytes(category).length > 0, "category required"); require(bytes(category).length <= 64, "category too long"); require(groupId.length > 0, "groupId required"); require(groupId.length <= 256, "groupId too long"); Entry storage e = _entries[category]; if (e.publisher == address(0)) { // Fresh registration e.groupId = groupId; e.publisher = msg.sender; e.publishedAt = block.timestamp; e.updatedAt = block.timestamp; _categories.push(category); emit GroupRegistered(category, category, groupId, msg.sender, block.timestamp); } else { // Rotation — only original publisher require(e.publisher == msg.sender, "not original publisher"); bytes memory oldGroupId = e.groupId; e.groupId = groupId; e.updatedAt = block.timestamp; emit GroupRotated(category, category, oldGroupId, groupId, msg.sender, block.timestamp); } } /** * @notice Read a category's current groupId. * @return groupId Opaque MLS groupId (empty bytes if unregistered) * @return publisher Address that registered/last rotated this category * @return publishedAt Original registration timestamp * @return updatedAt Last rotation timestamp */ function getGroup(string calldata category) external view returns (bytes memory groupId, address publisher, uint256 publishedAt, uint256 updatedAt) { Entry storage e = _entries[category]; return (e.groupId, e.publisher, e.publishedAt, e.updatedAt); } /// @notice Whether a category is currently registered. function isRegistered(string calldata category) external view returns (bool) { return _entries[category].publisher != address(0); } /// @notice All registered categories, in registration order. function listCategories() external view returns (string[] memory) { return _categories; } /// @notice Number of registered categories. function categoryCount() external view returns (uint256) { return _categories.length; } }