diff --git a/contracts/upgrade/IProtocolUpgradeManager.sol b/contracts/upgrade/IProtocolUpgradeManager.sol new file mode 100644 index 0000000..9d7b9b7 --- /dev/null +++ b/contracts/upgrade/IProtocolUpgradeManager.sol @@ -0,0 +1,200 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +/** + * @title IProtocolUpgradeManager + * @notice Interface for the Protocol Upgrade & Version Management Framework. + * @dev Mirrors the external surface of {ProtocolUpgradeManager}. Upgradeable modules may + * depend on this interface (in particular {isUpgradeAuthorized}) to gate their + * `_authorizeUpgrade` hooks without importing the full implementation. + */ +interface IProtocolUpgradeManager { + // ============ Types ============ + + struct Version { + uint64 major; + uint64 minor; + uint64 patch; + } + + struct ModuleState { + bool registered; + address currentImplementation; + Version currentVersion; + bytes32 storageLayoutHash; + bytes32 codeHash; + address previousImplementation; + Version previousVersion; + bytes32 previousStorageLayoutHash; + uint256 upgradeCount; + } + + enum UpgradeStatus { + None, + Proposed, + Approved, + Executed, + Cancelled + } + + struct UpgradeProposal { + uint256 id; + bytes32 moduleId; + address currentImplementation; + address newImplementation; + Version fromVersion; + Version toVersion; + bytes32 newStorageLayoutHash; + bytes32 migrationHash; + bool storageAttested; + bool storageCompatible; + bool migrationValidated; + UpgradeStatus status; + address proposer; + uint256 proposedAt; + uint256 executeAfter; + string description; + } + + // ============ Registration ============ + + function registerModule( + bytes32 moduleId, + address implementation, + Version calldata initialVersion, + bytes32 storageLayoutHash + ) external; + + // ============ Upgrade Lifecycle ============ + + function proposeUpgrade( + bytes32 moduleId, + address newImplementation, + Version calldata toVersion, + bytes32 newStorageLayoutHash, + bytes32 migrationHash, + string calldata description + ) external returns (uint256 proposalId); + + function attestStorageCompatibility(uint256 proposalId, bool compatible) external; + + function validateMigration(uint256 proposalId, bytes32 migrationHash) external; + + function approveUpgrade(uint256 proposalId) external; + + function executeUpgrade(uint256 proposalId) external; + + function cancelUpgrade(uint256 proposalId) external; + + // ============ Recovery ============ + + function rollbackUpgrade(bytes32 moduleId, string calldata reason) external; + + // ============ Admin ============ + + function setUpgradeTimelock(uint256 newTimelock) external; + + function pause() external; + + function unpause() external; + + // ============ Views ============ + + function isUpgradeAuthorized(bytes32 moduleId, address newImplementation) + external + view + returns (bool); + + function getModuleState(bytes32 moduleId) external view returns (ModuleState memory); + + function isModuleRegistered(bytes32 moduleId) external view returns (bool); + + function getModuleVersion(bytes32 moduleId) external view returns (Version memory); + + function versionString(bytes32 moduleId) external view returns (string memory); + + function getRegisteredModuleCount() external view returns (uint256); + + function getRegisteredModuleAt(uint256 index) external view returns (bytes32); + + function getAllRegisteredModuleIds() external view returns (bytes32[] memory); + + function getUpgradeProposal(uint256 proposalId) external view returns (UpgradeProposal memory); + + function getProposalCount() external view returns (uint256); + + function getModuleUpgradeHistory(bytes32 moduleId) external view returns (uint256[] memory); + + function getModuleUpgradeCount(bytes32 moduleId) external view returns (uint256); + + function latestAuthorized(bytes32 moduleId) external view returns (address); + + function upgradeTimelock() external view returns (uint256); + + // ============ Events ============ + + event ModuleRegistered( + bytes32 indexed moduleId, + address indexed implementation, + uint64 major, + uint64 minor, + uint64 patch, + bytes32 storageLayoutHash + ); + + event UpgradeProposed( + uint256 indexed proposalId, + bytes32 indexed moduleId, + address indexed newImplementation, + uint64 toMajor, + uint64 toMinor, + uint64 toPatch, + bytes32 migrationHash, + address proposer + ); + + event StorageCompatibilityAttested( + uint256 indexed proposalId, + bytes32 indexed moduleId, + bool compatible, + address indexed validator + ); + + event MigrationValidated( + uint256 indexed proposalId, + bytes32 indexed moduleId, + bytes32 migrationHash, + address indexed validator + ); + + event UpgradeApproved( + uint256 indexed proposalId, + bytes32 indexed moduleId, + uint256 executeAfter, + address indexed approver + ); + + event UpgradeExecuted( + uint256 indexed proposalId, + bytes32 indexed moduleId, + address oldImplementation, + address newImplementation, + address executor + ); + + event UpgradeCancelled( + uint256 indexed proposalId, + bytes32 indexed moduleId, + address indexed canceller + ); + + event UpgradeRolledBack( + bytes32 indexed moduleId, + address oldImplementation, + address restoredImplementation, + string reason, + address indexed guardian + ); + + event UpgradeTimelockUpdated(uint256 oldTimelock, uint256 newTimelock); +} diff --git a/contracts/upgrade/ProtocolUpgradeManager.sol b/contracts/upgrade/ProtocolUpgradeManager.sol new file mode 100644 index 0000000..e54dd79 --- /dev/null +++ b/contracts/upgrade/ProtocolUpgradeManager.sol @@ -0,0 +1,637 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import "@openzeppelin/contracts/access/AccessControl.sol"; +import "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; +import "@openzeppelin/contracts/utils/Pausable.sol"; +import "../governance/GovernanceOwnable.sol"; + +/** + * @title ProtocolUpgradeManager + * @notice Protocol Upgrade & Version Management Framework for TruthBounty. + * @dev Authorises protocol upgrades through a governance-gated, timelocked, deterministic + * flow while preserving storage compatibility and validating migrations. This contract + * is an on-chain authorisation & versioning registry: it does NOT hold proxy-admin + * rights and never calls `upgradeToAndCall` itself. Upgradeable modules gate their own + * `_authorizeUpgrade` by querying {isUpgradeAuthorized}. + * + * Upgrade lifecycle (deterministic): + * + * registerModule + * │ + * ▼ + * proposeUpgrade ──► attestStorageCompatibility ──► validateMigration ──► approveUpgrade + * │ │ + * │ (start timelock) + * ▼ ▼ + * cancelUpgrade executeUpgrade + * │ + * ▼ + * rollbackUpgrade (recovery) + * + * Safety properties: + * - Versions are strictly monotonic per module (no accidental downgrades except rollback). + * - Minor/patch upgrades must be attested storage-compatible (append-only layout). + * - Major upgrades that break storage layout require a validated migration. + * - Every state transition is timelocked (execution) and fully evented (auditable). + * - A previously-active implementation is retained so a compromised upgrade is recoverable. + */ +contract ProtocolUpgradeManager is ReentrancyGuard, Pausable, GovernanceOwnable { + // ============ Roles ============ + + bytes32 public constant ADMIN_ROLE = keccak256("ADMIN_ROLE"); + bytes32 public constant PROPOSER_ROLE = keccak256("PROPOSER_ROLE"); + bytes32 public constant VALIDATOR_ROLE = keccak256("VALIDATOR_ROLE"); + bytes32 public constant UPGRADER_ROLE = keccak256("UPGRADER_ROLE"); + bytes32 public constant EXECUTOR_ROLE = keccak256("EXECUTOR_ROLE"); + bytes32 public constant GUARDIAN_ROLE = keccak256("GUARDIAN_ROLE"); + bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE"); + + // ============ Constants ============ + + uint256 public constant MIN_UPGRADE_DELAY = 1 hours; + uint256 public constant MAX_UPGRADE_DELAY = 30 days; + + // ============ Data Structures ============ + + /// @notice Semantic version (major.minor.patch). + struct Version { + uint64 major; + uint64 minor; + uint64 patch; + } + + /// @notice Current registry state for a single module. + struct ModuleState { + bool registered; + address currentImplementation; + Version currentVersion; + bytes32 storageLayoutHash; // storage-layout fingerprint of the current implementation + bytes32 codeHash; // extcodehash of the current implementation (determinism/audit) + address previousImplementation; // retained known-good target for rollback + Version previousVersion; + bytes32 previousStorageLayoutHash; + uint256 upgradeCount; + } + + enum UpgradeStatus { + None, + Proposed, + Approved, + Executed, + Cancelled + } + + struct UpgradeProposal { + uint256 id; + bytes32 moduleId; + address currentImplementation; + address newImplementation; + Version fromVersion; + Version toVersion; + bytes32 newStorageLayoutHash; + bytes32 migrationHash; // bytes32(0) when no migration is required + bool storageAttested; + bool storageCompatible; + bool migrationValidated; + UpgradeStatus status; + address proposer; + uint256 proposedAt; + uint256 executeAfter; + string description; + } + + // ============ State Variables ============ + + /// @notice Configurable execution timelock applied at approval time. + uint256 public upgradeTimelock = 2 days; + + /// @notice Per-module registry state. + mapping(bytes32 => ModuleState) public modules; + + /// @notice Enumeration of registered module ids. + bytes32[] private _registeredModuleIds; + + /// @notice Upgrade proposals keyed by sequential id. + mapping(uint256 => UpgradeProposal) private _proposals; + uint256 public proposalCount; + + /// @notice Executed-upgrade proposal ids per module (audit trail). + mapping(bytes32 => uint256[]) private _moduleHistory; + + /// @notice Implementation currently authorised to be adopted by a module's proxy. + mapping(bytes32 => address) public latestAuthorized; + + // ============ Events ============ + + event ModuleRegistered( + bytes32 indexed moduleId, + address indexed implementation, + uint64 major, + uint64 minor, + uint64 patch, + bytes32 storageLayoutHash + ); + + event UpgradeProposed( + uint256 indexed proposalId, + bytes32 indexed moduleId, + address indexed newImplementation, + uint64 toMajor, + uint64 toMinor, + uint64 toPatch, + bytes32 migrationHash, + address proposer + ); + + event StorageCompatibilityAttested( + uint256 indexed proposalId, + bytes32 indexed moduleId, + bool compatible, + address indexed validator + ); + + event MigrationValidated( + uint256 indexed proposalId, + bytes32 indexed moduleId, + bytes32 migrationHash, + address indexed validator + ); + + event UpgradeApproved( + uint256 indexed proposalId, + bytes32 indexed moduleId, + uint256 executeAfter, + address indexed approver + ); + + event UpgradeExecuted( + uint256 indexed proposalId, + bytes32 indexed moduleId, + address oldImplementation, + address newImplementation, + address executor + ); + + event UpgradeCancelled( + uint256 indexed proposalId, + bytes32 indexed moduleId, + address indexed canceller + ); + + event UpgradeRolledBack( + bytes32 indexed moduleId, + address oldImplementation, + address restoredImplementation, + string reason, + address indexed guardian + ); + + event UpgradeTimelockUpdated(uint256 oldTimelock, uint256 newTimelock); + + // ============ Errors ============ + + error ModuleAlreadyRegistered(bytes32 moduleId); + error ModuleNotRegistered(bytes32 moduleId); + error InvalidAddress(); + error NoContractCode(address target); + error VersionNotNewer(); + error VersionMismatch(); + error ProposalNotFound(uint256 proposalId); + error InvalidProposalStatus(uint256 proposalId, UpgradeStatus status); + error StorageNotAttested(); + error StorageIncompatible(); + error MigrationHashMismatch(); + error MigrationNotValidated(); + error MigrationRequired(); + error TimelockNotPassed(uint256 executeAfter); + error SameImplementation(); + error NoPreviousImplementation(); + error InvalidTimelock(); + error NotAuthorizedToCancel(); + error NotAuthorizedToRollback(); + + // ============ Constructor ============ + + constructor(address initialAdmin, address _governanceController) { + require(initialAdmin != address(0), "Invalid admin"); + + _grantRole(DEFAULT_ADMIN_ROLE, initialAdmin); + _grantRole(ADMIN_ROLE, initialAdmin); + _grantRole(PROPOSER_ROLE, initialAdmin); + _grantRole(VALIDATOR_ROLE, initialAdmin); + _grantRole(UPGRADER_ROLE, initialAdmin); + _grantRole(EXECUTOR_ROLE, initialAdmin); + _grantRole(GUARDIAN_ROLE, initialAdmin); + _grantRole(PAUSER_ROLE, initialAdmin); + + _setRoleAdmin(PROPOSER_ROLE, ADMIN_ROLE); + _setRoleAdmin(VALIDATOR_ROLE, ADMIN_ROLE); + _setRoleAdmin(UPGRADER_ROLE, ADMIN_ROLE); + _setRoleAdmin(EXECUTOR_ROLE, ADMIN_ROLE); + _setRoleAdmin(GUARDIAN_ROLE, ADMIN_ROLE); + _setRoleAdmin(PAUSER_ROLE, ADMIN_ROLE); + + _initializeGovernance(_governanceController, initialAdmin, initialAdmin); + } + + // ============ Module Registration ============ + + /** + * @notice Register a module and establish its baseline (current) version. + * @param moduleId Unique module identifier (e.g. keccak256("TRUTH_BOUNTY")). + * @param implementation Address of the currently active implementation (must have code). + * @param initialVersion Baseline semantic version. + * @param storageLayoutHash Fingerprint of the implementation's storage layout. + */ + function registerModule( + bytes32 moduleId, + address implementation, + Version calldata initialVersion, + bytes32 storageLayoutHash + ) external onlyRole(ADMIN_ROLE) nonReentrant { + if (implementation == address(0)) revert InvalidAddress(); + if (modules[moduleId].registered) revert ModuleAlreadyRegistered(moduleId); + if (implementation.code.length == 0) revert NoContractCode(implementation); + + ModuleState storage m = modules[moduleId]; + m.registered = true; + m.currentImplementation = implementation; + m.currentVersion = initialVersion; + m.storageLayoutHash = storageLayoutHash; + m.codeHash = implementation.codehash; + + _registeredModuleIds.push(moduleId); + latestAuthorized[moduleId] = implementation; + + emit ModuleRegistered( + moduleId, + implementation, + initialVersion.major, + initialVersion.minor, + initialVersion.patch, + storageLayoutHash + ); + } + + // ============ Upgrade Lifecycle ============ + + /** + * @notice Propose an upgrade for a registered module. + * @dev Enforces monotonic versioning and that the proposal starts from the module's + * current version. The proposal must subsequently pass storage attestation and, + * where required, migration validation before it can be approved. + * @param moduleId Target module. + * @param newImplementation Candidate implementation (must have code, differ from current). + * @param toVersion Target semantic version (strictly newer than current). + * @param newStorageLayoutHash Storage-layout fingerprint of the candidate implementation. + * @param migrationHash Hash committing to the migration steps; bytes32(0) if none needed. + * @param description Human-readable summary of the upgrade. + * @return proposalId The id of the created proposal. + */ + function proposeUpgrade( + bytes32 moduleId, + address newImplementation, + Version calldata toVersion, + bytes32 newStorageLayoutHash, + bytes32 migrationHash, + string calldata description + ) external onlyRole(PROPOSER_ROLE) whenNotPaused nonReentrant returns (uint256 proposalId) { + ModuleState storage m = modules[moduleId]; + if (!m.registered) revert ModuleNotRegistered(moduleId); + if (newImplementation == address(0)) revert InvalidAddress(); + if (newImplementation.code.length == 0) revert NoContractCode(newImplementation); + if (newImplementation == m.currentImplementation) revert SameImplementation(); + if (!_isNewer(toVersion, m.currentVersion)) revert VersionNotNewer(); + + proposalId = ++proposalCount; + + UpgradeProposal storage p = _proposals[proposalId]; + p.id = proposalId; + p.moduleId = moduleId; + p.currentImplementation = m.currentImplementation; + p.newImplementation = newImplementation; + p.fromVersion = m.currentVersion; + p.toVersion = toVersion; + p.newStorageLayoutHash = newStorageLayoutHash; + p.migrationHash = migrationHash; + p.status = UpgradeStatus.Proposed; + p.proposer = msg.sender; + p.proposedAt = block.timestamp; + p.description = description; + + emit UpgradeProposed( + proposalId, + moduleId, + newImplementation, + toVersion.major, + toVersion.minor, + toVersion.patch, + migrationHash, + msg.sender + ); + } + + /** + * @notice Attest whether a proposed implementation preserves storage compatibility. + * @dev Storage layout cannot be introspected on-chain, so compatibility is attested by a + * trusted VALIDATOR (typically backed by OpenZeppelin upgrade-safety tooling in CI). + * For minor/patch upgrades the attestation must be `true` (append-only layout); this + * is enforced at {approveUpgrade}. + */ + function attestStorageCompatibility(uint256 proposalId, bool compatible) + external + onlyRole(VALIDATOR_ROLE) + { + UpgradeProposal storage p = _requireProposal(proposalId); + if (p.status != UpgradeStatus.Proposed) revert InvalidProposalStatus(proposalId, p.status); + + p.storageAttested = true; + p.storageCompatible = compatible; + + emit StorageCompatibilityAttested(proposalId, p.moduleId, compatible, msg.sender); + } + + /** + * @notice Validate the migration bound to a proposal by supplying its committed hash. + * @dev The supplied hash must equal the `migrationHash` recorded at proposal time, + * binding off-chain migration review to the on-chain approval. + */ + function validateMigration(uint256 proposalId, bytes32 migrationHash) + external + onlyRole(VALIDATOR_ROLE) + { + UpgradeProposal storage p = _requireProposal(proposalId); + if (p.status != UpgradeStatus.Proposed) revert InvalidProposalStatus(proposalId, p.status); + if (p.migrationHash != migrationHash) revert MigrationHashMismatch(); + + p.migrationValidated = true; + + emit MigrationValidated(proposalId, p.moduleId, migrationHash, msg.sender); + } + + /** + * @notice Approve a proposal and start its execution timelock. + * @dev Enforces the storage-compatibility and migration-validation policy: + * - storage must be attested; + * - a same-major (minor/patch) upgrade must be attested compatible; + * - a storage-incompatible upgrade must carry a validated migration. + */ + function approveUpgrade(uint256 proposalId) + external + onlyRole(UPGRADER_ROLE) + whenNotPaused + { + UpgradeProposal storage p = _requireProposal(proposalId); + if (p.status != UpgradeStatus.Proposed) revert InvalidProposalStatus(proposalId, p.status); + if (!p.storageAttested) revert StorageNotAttested(); + + bool sameMajor = p.toVersion.major == p.fromVersion.major; + if (sameMajor && !p.storageCompatible) revert StorageIncompatible(); + + // A layout-breaking upgrade must be accompanied by a validated migration. + if (!p.storageCompatible && p.migrationHash == bytes32(0)) revert MigrationRequired(); + if (p.migrationHash != bytes32(0) && !p.migrationValidated) revert MigrationNotValidated(); + + p.status = UpgradeStatus.Approved; + p.executeAfter = block.timestamp + upgradeTimelock; + + emit UpgradeApproved(proposalId, p.moduleId, p.executeAfter, msg.sender); + } + + /** + * @notice Execute an approved upgrade once its timelock has elapsed. + * @dev Advances the module's current version, retains the outgoing implementation for + * rollback, and points {latestAuthorized} at the new implementation so the module's + * proxy can adopt it. The registry state — not this call — performs the proxy switch. + */ + function executeUpgrade(uint256 proposalId) + external + onlyRole(EXECUTOR_ROLE) + whenNotPaused + nonReentrant + { + UpgradeProposal storage p = _requireProposal(proposalId); + if (p.status != UpgradeStatus.Approved) revert InvalidProposalStatus(proposalId, p.status); + if (block.timestamp < p.executeAfter) revert TimelockNotPassed(p.executeAfter); + + ModuleState storage m = modules[p.moduleId]; + + // Guard against the current implementation having moved since proposal time. + if (m.currentImplementation != p.currentImplementation) revert VersionMismatch(); + + address oldImplementation = m.currentImplementation; + + // Retain outgoing implementation as the known-good rollback target. + m.previousImplementation = oldImplementation; + m.previousVersion = m.currentVersion; + m.previousStorageLayoutHash = m.storageLayoutHash; + + m.currentImplementation = p.newImplementation; + m.currentVersion = p.toVersion; + m.storageLayoutHash = p.newStorageLayoutHash; + m.codeHash = p.newImplementation.codehash; + m.upgradeCount += 1; + + latestAuthorized[p.moduleId] = p.newImplementation; + _moduleHistory[p.moduleId].push(proposalId); + + p.status = UpgradeStatus.Executed; + + emit UpgradeExecuted(proposalId, p.moduleId, oldImplementation, p.newImplementation, msg.sender); + } + + /** + * @notice Cancel a pending (proposed or approved) upgrade. + * @dev Allowed for the proposer, an ADMIN, or a GUARDIAN (veto). Executed proposals are + * immutable. + */ + function cancelUpgrade(uint256 proposalId) external { + UpgradeProposal storage p = _requireProposal(proposalId); + if (p.status != UpgradeStatus.Proposed && p.status != UpgradeStatus.Approved) { + revert InvalidProposalStatus(proposalId, p.status); + } + if ( + msg.sender != p.proposer && + !hasRole(ADMIN_ROLE, msg.sender) && + !hasRole(GUARDIAN_ROLE, msg.sender) + ) { + revert NotAuthorizedToCancel(); + } + + p.status = UpgradeStatus.Cancelled; + + emit UpgradeCancelled(proposalId, p.moduleId, msg.sender); + } + + // ============ Recovery ============ + + /** + * @notice Roll a module back to its previously active implementation. + * @dev Recovery path for a compromised or defective upgrade. Restricted to GUARDIAN or + * ADMIN and intentionally executable even while paused, so assets can be protected + * during an incident. Only a single step back is retained; deeper rollbacks are + * performed by registering the desired known-good target through a fresh proposal. + */ + function rollbackUpgrade(bytes32 moduleId, string calldata reason) external nonReentrant { + if (!hasRole(GUARDIAN_ROLE, msg.sender) && !hasRole(ADMIN_ROLE, msg.sender)) { + revert NotAuthorizedToRollback(); + } + + ModuleState storage m = modules[moduleId]; + if (!m.registered) revert ModuleNotRegistered(moduleId); + if (m.previousImplementation == address(0)) revert NoPreviousImplementation(); + + address demoted = m.currentImplementation; + Version memory demotedVersion = m.currentVersion; + bytes32 demotedStorageHash = m.storageLayoutHash; + + address restored = m.previousImplementation; + + m.currentImplementation = restored; + m.currentVersion = m.previousVersion; + m.storageLayoutHash = m.previousStorageLayoutHash; + m.codeHash = restored.codehash; + + // Preserve reversibility: the demoted implementation becomes the new rollback target. + m.previousImplementation = demoted; + m.previousVersion = demotedVersion; + m.previousStorageLayoutHash = demotedStorageHash; + + latestAuthorized[moduleId] = restored; + + emit UpgradeRolledBack(moduleId, demoted, restored, reason, msg.sender); + } + + // ============ Admin ============ + + /** + * @notice Update the execution timelock applied to future approvals. + */ + function setUpgradeTimelock(uint256 newTimelock) external onlyRole(ADMIN_ROLE) { + if (newTimelock < MIN_UPGRADE_DELAY || newTimelock > MAX_UPGRADE_DELAY) { + revert InvalidTimelock(); + } + uint256 old = upgradeTimelock; + upgradeTimelock = newTimelock; + emit UpgradeTimelockUpdated(old, newTimelock); + } + + function pause() external onlyRole(PAUSER_ROLE) { + _pause(); + } + + function unpause() external onlyRole(PAUSER_ROLE) { + _unpause(); + } + + // ============ Proxy Integration ============ + + /** + * @notice Whether an implementation is authorised to be adopted by a module's proxy. + * @dev Intended to be consulted from an upgradeable module's `_authorizeUpgrade` hook: + * `require(upgradeManager.isUpgradeAuthorized(MODULE_ID, newImplementation));` + */ + function isUpgradeAuthorized(bytes32 moduleId, address newImplementation) + external + view + returns (bool) + { + return latestAuthorized[moduleId] == newImplementation && newImplementation != address(0); + } + + // ============ Views: Modules ============ + + function getModuleState(bytes32 moduleId) external view returns (ModuleState memory) { + if (!modules[moduleId].registered) revert ModuleNotRegistered(moduleId); + return modules[moduleId]; + } + + function isModuleRegistered(bytes32 moduleId) external view returns (bool) { + return modules[moduleId].registered; + } + + function getModuleVersion(bytes32 moduleId) external view returns (Version memory) { + if (!modules[moduleId].registered) revert ModuleNotRegistered(moduleId); + return modules[moduleId].currentVersion; + } + + /// @notice Human-readable "major.minor.patch" for a module's current version. + function versionString(bytes32 moduleId) external view returns (string memory) { + ModuleState storage m = modules[moduleId]; + if (!m.registered) revert ModuleNotRegistered(moduleId); + return string.concat( + _toString(m.currentVersion.major), + ".", + _toString(m.currentVersion.minor), + ".", + _toString(m.currentVersion.patch) + ); + } + + function getRegisteredModuleCount() external view returns (uint256) { + return _registeredModuleIds.length; + } + + function getRegisteredModuleAt(uint256 index) external view returns (bytes32) { + require(index < _registeredModuleIds.length, "Index out of bounds"); + return _registeredModuleIds[index]; + } + + function getAllRegisteredModuleIds() external view returns (bytes32[] memory) { + return _registeredModuleIds; + } + + // ============ Views: Proposals ============ + + function getUpgradeProposal(uint256 proposalId) external view returns (UpgradeProposal memory) { + if (proposalId == 0 || proposalId > proposalCount) revert ProposalNotFound(proposalId); + return _proposals[proposalId]; + } + + function getProposalCount() external view returns (uint256) { + return proposalCount; + } + + function getModuleUpgradeHistory(bytes32 moduleId) external view returns (uint256[] memory) { + return _moduleHistory[moduleId]; + } + + function getModuleUpgradeCount(bytes32 moduleId) external view returns (uint256) { + return _moduleHistory[moduleId].length; + } + + // ============ Internal Helpers ============ + + function _requireProposal(uint256 proposalId) internal view returns (UpgradeProposal storage p) { + if (proposalId == 0 || proposalId > proposalCount) revert ProposalNotFound(proposalId); + p = _proposals[proposalId]; + } + + /// @dev Strict semantic-version ordering: a > b. + function _isNewer(Version memory a, Version memory b) internal pure returns (bool) { + return _encode(a) > _encode(b); + } + + /// @dev Pack a version into a single comparable integer (major | minor | patch). + function _encode(Version memory v) internal pure returns (uint256) { + return (uint256(v.major) << 128) | (uint256(v.minor) << 64) | uint256(v.patch); + } + + function _toString(uint256 value) internal pure returns (string memory) { + if (value == 0) return "0"; + uint256 temp = value; + uint256 digits; + while (temp != 0) { + digits++; + temp /= 10; + } + bytes memory buffer = new bytes(digits); + while (value != 0) { + digits -= 1; + buffer[digits] = bytes1(uint8(48 + uint256(value % 10))); + value /= 10; + } + return string(buffer); + } +} diff --git a/docs/upgrade-framework.md b/docs/upgrade-framework.md new file mode 100644 index 0000000..9555830 --- /dev/null +++ b/docs/upgrade-framework.md @@ -0,0 +1,137 @@ +# Protocol Upgrade & Version Management Framework + +## Overview + +The Upgrade & Version Management Framework governs how the TruthBounty protocol evolves +after deployment. It ensures that every upgrade is **versioned**, **authorised**, +**storage-compatible**, **migration-validated**, **timelocked**, **auditable**, and +**recoverable** — so the protocol can adopt new features and security fixes without +disruptive redeployments or risking user assets. + +The framework is implemented by +[`contracts/upgrade/ProtocolUpgradeManager.sol`](../contracts/upgrade/ProtocolUpgradeManager.sol) +(interface: [`IProtocolUpgradeManager.sol`](../contracts/upgrade/IProtocolUpgradeManager.sol)). + +It is an **on-chain authorisation & versioning registry**. It does not hold proxy-admin +rights and never calls `upgradeToAndCall` itself. Instead, each upgradeable module gates its +own upgrade hook against the registry (see [Proxy integration](#proxy-integration)). This +mirrors the sibling `BootstrapController` and `MigrationManager` frameworks and avoids +converting the whole protocol to a single monolithic proxy admin. + +## Concepts + +| Concept | Meaning | +|---------|---------| +| **Module** | An upgradeable protocol component, keyed by `bytes32` id (e.g. `keccak256("TRUTH_BOUNTY")`). | +| **Version** | Semantic `major.minor.patch`. Strictly monotonic per module (no accidental downgrade). | +| **Storage layout hash** | A fingerprint of an implementation's storage layout, recorded for audit and compatibility gating. | +| **Migration hash** | A commitment to the off-chain migration steps required by a layout-breaking upgrade. | +| **Upgrade proposal** | A pending transition from the current version/implementation to a target, moving through a fixed lifecycle. | +| **Authorised implementation** | The single implementation a module's proxy is currently permitted to adopt (`latestAuthorized`). | + +## Lifecycle + +``` +registerModule + │ + ▼ +proposeUpgrade ──► attestStorageCompatibility ──► validateMigration ──► approveUpgrade + │ (only if migration │ + │ hash is set) (starts timelock) + ▼ ▼ +cancelUpgrade ◄──────────────── (proposer / admin / guardian) ─────► executeUpgrade + │ + ▼ + rollbackUpgrade (recovery) +``` + +1. **registerModule** — an `ADMIN` establishes a module's baseline: current implementation + (must have code), version, and storage-layout hash. The baseline implementation is + immediately marked authorised. +2. **proposeUpgrade** — a `PROPOSER` proposes a transition to a strictly newer version with a + new implementation (must have code, differ from current) and its storage-layout hash. A + `migrationHash` of `0` means no migration is required. +3. **attestStorageCompatibility** — a `VALIDATOR` attests whether the new implementation + preserves storage compatibility. Layout cannot be introspected on-chain, so this is backed + by tooling (e.g. OpenZeppelin upgrade-safety checks) in CI. +4. **validateMigration** — when a `migrationHash` is set, a `VALIDATOR` must supply the exact + committed hash, binding off-chain migration review to on-chain approval. +5. **approveUpgrade** — an `UPGRADER` (governance-gated) approves, enforcing the + [storage-compatibility policy](#storage-compatibility-policy) and starting the timelock. +6. **executeUpgrade** — an `EXECUTOR` finalises the upgrade after the timelock elapses. The + module advances to the new version/implementation, the outgoing implementation is retained + for rollback, and `latestAuthorized` is re-pointed. +7. **cancelUpgrade** — the proposer, an `ADMIN`, or a `GUARDIAN` may veto any non-executed + proposal. +8. **rollbackUpgrade** — a `GUARDIAN`/`ADMIN` recovery path that restores the previously + active implementation. It is intentionally executable even while the contract is paused. + +## Storage compatibility policy + +Enforced at `approveUpgrade`: + +- Storage compatibility **must be attested** before approval. +- A **same-major** upgrade (minor/patch) must be attested **compatible** — layouts may only + grow append-only within a major version. +- A **storage-incompatible** upgrade **must** carry a validated **migration** (`migrationHash` + ≠ 0 and validated). This makes layout-breaking changes explicit and reviewed. + +## Roles + +| Role | Capability | +|------|------------| +| `DEFAULT_ADMIN_ROLE` / `ADMIN_ROLE` | Register modules, set timelock, manage roles, cancel, rollback. | +| `PROPOSER_ROLE` | Create upgrade proposals. | +| `VALIDATOR_ROLE` | Attest storage compatibility; validate migrations. | +| `UPGRADER_ROLE` | Approve proposals (governance-gated authorisation step). | +| `EXECUTOR_ROLE` | Execute approved proposals after the timelock. | +| `GUARDIAN_ROLE` | Veto pending proposals; perform emergency rollback. | +| `PAUSER_ROLE` | Pause/unpause the manager. | + +Separating `PROPOSER`, `VALIDATOR`, `UPGRADER`, and `EXECUTOR` enforces multi-party control: +no single actor can propose, validate, approve, and execute an upgrade alone. + +## Timelock + +Approvals start a configurable `upgradeTimelock` (default **2 days**, bounded to +`[1 hour, 30 days]`, settable by `ADMIN` via `setUpgradeTimelock`). Execution reverts with +`TimelockNotPassed` until it elapses, giving the community a window to review or veto. + +## Proxy integration + +An upgradeable (e.g. UUPS) module consults the registry from its authorisation hook: + +```solidity +import "../upgrade/IProtocolUpgradeManager.sol"; + +contract MyModule is UUPSUpgradeable { + IProtocolUpgradeManager public upgradeManager; + bytes32 public constant MODULE_ID = keccak256("MY_MODULE"); + + function _authorizeUpgrade(address newImplementation) internal view override { + require( + upgradeManager.isUpgradeAuthorized(MODULE_ID, newImplementation), + "upgrade not authorised" + ); + } +} +``` + +Only an implementation that has completed the full lifecycle (or the current/rolled-back +implementation) is authorised at any time. + +## Auditability + +Every transition emits an event (`ModuleRegistered`, `UpgradeProposed`, +`StorageCompatibilityAttested`, `MigrationValidated`, `UpgradeApproved`, `UpgradeExecuted`, +`UpgradeCancelled`, `UpgradeRolledBack`, `UpgradeTimelockUpdated`). On-chain history is +queryable via `getUpgradeProposal`, `getModuleUpgradeHistory`, `getModuleState`, and the +module enumeration getters — a complete, deterministic upgrade trail. + +## Testing + +```bash +npm install +npm run compile +npx hardhat test test/ProtocolUpgradeManager.test.ts +``` diff --git a/test/ProtocolUpgradeManager.test.ts b/test/ProtocolUpgradeManager.test.ts new file mode 100644 index 0000000..c3cb861 --- /dev/null +++ b/test/ProtocolUpgradeManager.test.ts @@ -0,0 +1,506 @@ +import { expect } from "chai"; +import { loadFixture, time } from "@nomicfoundation/hardhat-network-helpers"; +import { ethers } from "hardhat"; + +describe("ProtocolUpgradeManager", function () { + const MODULE_BOUNTY = ethers.id("TRUTH_BOUNTY"); + const MODULE_STAKING = ethers.id("STAKING"); + + const V1 = { major: 1n, minor: 0n, patch: 0n }; + const V1_0_1 = { major: 1n, minor: 0n, patch: 1n }; + const V1_1_0 = { major: 1n, minor: 1n, patch: 0n }; + const V2 = { major: 2n, minor: 0n, patch: 0n }; + + const STORAGE_HASH_V1 = ethers.id("layout-v1"); + const STORAGE_HASH_V2 = ethers.id("layout-v2"); + const MIGRATION_HASH = ethers.id("migration-1->2"); + const ZERO_HASH = ethers.ZeroHash; + + // Deploy a fresh contract to use as a stand-in "implementation" address (needs code). + async function deployImpl(name = "Impl", symbol = "IMP") { + const MockERC20 = await ethers.getContractFactory("MockERC20"); + const impl = await MockERC20.deploy(name, symbol); + await impl.waitForDeployment(); + return impl; + } + + async function deployFixture() { + const [admin, proposer, validator, upgrader, executor, guardian, user] = + await ethers.getSigners(); + + const Manager = await ethers.getContractFactory("ProtocolUpgradeManager"); + const manager = await Manager.deploy(admin.address, ethers.ZeroAddress); + await manager.waitForDeployment(); + + await manager.grantRole(await manager.PROPOSER_ROLE(), proposer.address); + await manager.grantRole(await manager.VALIDATOR_ROLE(), validator.address); + await manager.grantRole(await manager.UPGRADER_ROLE(), upgrader.address); + await manager.grantRole(await manager.EXECUTOR_ROLE(), executor.address); + await manager.grantRole(await manager.GUARDIAN_ROLE(), guardian.address); + + const implV1 = await deployImpl("V1", "V1"); + const implV2 = await deployImpl("V2", "V2"); + + return { + admin, + proposer, + validator, + upgrader, + executor, + guardian, + user, + manager, + implV1, + implV2, + }; + } + + // Registers MODULE_BOUNTY at V1 => implV1. + async function withRegisteredModule() { + const ctx = await loadFixture(deployFixture); + await ctx.manager + .connect(ctx.admin) + .registerModule(MODULE_BOUNTY, await ctx.implV1.getAddress(), V1, STORAGE_HASH_V1); + return ctx; + } + + // Drives a proposal all the way to Approved (compatible minor bump, no migration). + async function withApprovedUpgrade() { + const ctx = await withRegisteredModule(); + const { manager, proposer, validator, upgrader, implV2 } = ctx; + + await manager + .connect(proposer) + .proposeUpgrade(MODULE_BOUNTY, await implV2.getAddress(), V1_1_0, STORAGE_HASH_V2, ZERO_HASH, "minor"); + await manager.connect(validator).attestStorageCompatibility(1, true); + await manager.connect(upgrader).approveUpgrade(1); + + return { ...ctx, proposalId: 1n }; + } + + describe("Deployment", function () { + it("grants all operational roles to the initial admin", async function () { + const { manager, admin } = await loadFixture(deployFixture); + expect(await manager.hasRole(await manager.DEFAULT_ADMIN_ROLE(), admin.address)).to.be.true; + expect(await manager.hasRole(await manager.ADMIN_ROLE(), admin.address)).to.be.true; + expect(await manager.hasRole(await manager.PROPOSER_ROLE(), admin.address)).to.be.true; + expect(await manager.hasRole(await manager.VALIDATOR_ROLE(), admin.address)).to.be.true; + expect(await manager.hasRole(await manager.UPGRADER_ROLE(), admin.address)).to.be.true; + expect(await manager.hasRole(await manager.EXECUTOR_ROLE(), admin.address)).to.be.true; + expect(await manager.hasRole(await manager.GUARDIAN_ROLE(), admin.address)).to.be.true; + expect(await manager.hasRole(await manager.PAUSER_ROLE(), admin.address)).to.be.true; + }); + + it("uses a 2 day default timelock", async function () { + const { manager } = await loadFixture(deployFixture); + expect(await manager.upgradeTimelock()).to.equal(2n * 24n * 60n * 60n); + }); + + it("starts with no registered modules or proposals", async function () { + const { manager } = await loadFixture(deployFixture); + expect(await manager.getRegisteredModuleCount()).to.equal(0n); + expect(await manager.getProposalCount()).to.equal(0n); + }); + }); + + describe("Module registration", function () { + it("registers a module and sets baseline state", async function () { + const { manager, admin, implV1 } = await loadFixture(deployFixture); + const addr = await implV1.getAddress(); + + await expect(manager.connect(admin).registerModule(MODULE_BOUNTY, addr, V1, STORAGE_HASH_V1)) + .to.emit(manager, "ModuleRegistered") + .withArgs(MODULE_BOUNTY, addr, 1n, 0n, 0n, STORAGE_HASH_V1); + + const state = await manager.getModuleState(MODULE_BOUNTY); + expect(state.registered).to.be.true; + expect(state.currentImplementation).to.equal(addr); + expect(state.currentVersion.major).to.equal(1n); + expect(state.storageLayoutHash).to.equal(STORAGE_HASH_V1); + expect(state.previousImplementation).to.equal(ethers.ZeroAddress); + + expect(await manager.versionString(MODULE_BOUNTY)).to.equal("1.0.0"); + expect(await manager.latestAuthorized(MODULE_BOUNTY)).to.equal(addr); + expect(await manager.getRegisteredModuleCount()).to.equal(1n); + }); + + it("reverts on zero implementation address", async function () { + const { manager, admin } = await loadFixture(deployFixture); + await expect( + manager.connect(admin).registerModule(MODULE_BOUNTY, ethers.ZeroAddress, V1, STORAGE_HASH_V1) + ).to.be.revertedWithCustomError(manager, "InvalidAddress"); + }); + + it("reverts when implementation has no code", async function () { + const { manager, admin, user } = await loadFixture(deployFixture); + await expect( + manager.connect(admin).registerModule(MODULE_BOUNTY, user.address, V1, STORAGE_HASH_V1) + ).to.be.revertedWithCustomError(manager, "NoContractCode"); + }); + + it("reverts on duplicate registration", async function () { + const { manager, admin, implV1 } = await withRegisteredModule(); + await expect( + manager.connect(admin).registerModule(MODULE_BOUNTY, await implV1.getAddress(), V1, STORAGE_HASH_V1) + ).to.be.revertedWithCustomError(manager, "ModuleAlreadyRegistered"); + }); + + it("reverts when caller lacks ADMIN_ROLE", async function () { + const { manager, user, implV1 } = await loadFixture(deployFixture); + await expect( + manager.connect(user).registerModule(MODULE_BOUNTY, await implV1.getAddress(), V1, STORAGE_HASH_V1) + ).to.be.revertedWithCustomError(manager, "AccessControlUnauthorizedAccount"); + }); + }); + + describe("proposeUpgrade", function () { + it("creates a proposal with expected fields", async function () { + const { manager, proposer, implV2 } = await withRegisteredModule(); + const addr = await implV2.getAddress(); + + await expect( + manager.connect(proposer).proposeUpgrade(MODULE_BOUNTY, addr, V1_1_0, STORAGE_HASH_V2, ZERO_HASH, "minor bump") + ) + .to.emit(manager, "UpgradeProposed") + .withArgs(1n, MODULE_BOUNTY, addr, 1n, 1n, 0n, ZERO_HASH, proposer.address); + + const p = await manager.getUpgradeProposal(1); + expect(p.moduleId).to.equal(MODULE_BOUNTY); + expect(p.newImplementation).to.equal(addr); + expect(p.fromVersion.major).to.equal(1n); + expect(p.toVersion.minor).to.equal(1n); + expect(p.status).to.equal(1n); // Proposed + expect(await manager.getProposalCount()).to.equal(1n); + }); + + it("reverts for an unregistered module", async function () { + const { manager, proposer, implV2 } = await withRegisteredModule(); + await expect( + manager.connect(proposer).proposeUpgrade(MODULE_STAKING, await implV2.getAddress(), V2, STORAGE_HASH_V2, ZERO_HASH, "x") + ).to.be.revertedWithCustomError(manager, "ModuleNotRegistered"); + }); + + it("enforces strictly monotonic versions", async function () { + const { manager, proposer, implV2 } = await withRegisteredModule(); + await expect( + manager.connect(proposer).proposeUpgrade(MODULE_BOUNTY, await implV2.getAddress(), V1, STORAGE_HASH_V2, ZERO_HASH, "same") + ).to.be.revertedWithCustomError(manager, "VersionNotNewer"); + }); + + it("rejects reusing the current implementation", async function () { + const { manager, proposer, implV1 } = await withRegisteredModule(); + await expect( + manager.connect(proposer).proposeUpgrade(MODULE_BOUNTY, await implV1.getAddress(), V2, STORAGE_HASH_V2, ZERO_HASH, "same impl") + ).to.be.revertedWithCustomError(manager, "SameImplementation"); + }); + + it("rejects an implementation with no code", async function () { + const { manager, proposer, user } = await withRegisteredModule(); + await expect( + manager.connect(proposer).proposeUpgrade(MODULE_BOUNTY, user.address, V2, STORAGE_HASH_V2, ZERO_HASH, "eoa") + ).to.be.revertedWithCustomError(manager, "NoContractCode"); + }); + + it("reverts when caller lacks PROPOSER_ROLE", async function () { + const { manager, user, implV2 } = await withRegisteredModule(); + await expect( + manager.connect(user).proposeUpgrade(MODULE_BOUNTY, await implV2.getAddress(), V2, STORAGE_HASH_V2, ZERO_HASH, "x") + ).to.be.revertedWithCustomError(manager, "AccessControlUnauthorizedAccount"); + }); + }); + + describe("Storage compatibility & migration policy", function () { + it("blocks a minor/patch upgrade that is attested incompatible", async function () { + const { manager, proposer, validator, upgrader, implV2 } = await withRegisteredModule(); + await manager + .connect(proposer) + .proposeUpgrade(MODULE_BOUNTY, await implV2.getAddress(), V1_0_1, STORAGE_HASH_V2, ZERO_HASH, "patch"); + + await expect(manager.connect(validator).attestStorageCompatibility(1, false)) + .to.emit(manager, "StorageCompatibilityAttested") + .withArgs(1n, MODULE_BOUNTY, false, validator.address); + + await expect(manager.connect(upgrader).approveUpgrade(1)).to.be.revertedWithCustomError( + manager, + "StorageIncompatible" + ); + }); + + it("requires attestation before approval", async function () { + const { manager, proposer, upgrader, implV2 } = await withRegisteredModule(); + await manager + .connect(proposer) + .proposeUpgrade(MODULE_BOUNTY, await implV2.getAddress(), V1_1_0, STORAGE_HASH_V2, ZERO_HASH, "minor"); + await expect(manager.connect(upgrader).approveUpgrade(1)).to.be.revertedWithCustomError( + manager, + "StorageNotAttested" + ); + }); + + it("requires a migration for a storage-incompatible major upgrade", async function () { + const { manager, proposer, validator, upgrader, implV2 } = await withRegisteredModule(); + // Major bump, no migration hash, attested incompatible. + await manager + .connect(proposer) + .proposeUpgrade(MODULE_BOUNTY, await implV2.getAddress(), V2, STORAGE_HASH_V2, ZERO_HASH, "major no migration"); + await manager.connect(validator).attestStorageCompatibility(1, false); + + await expect(manager.connect(upgrader).approveUpgrade(1)).to.be.revertedWithCustomError( + manager, + "MigrationRequired" + ); + }); + + it("validates a migration hash and gates approval on it", async function () { + const { manager, proposer, validator, upgrader, implV2 } = await withRegisteredModule(); + await manager + .connect(proposer) + .proposeUpgrade(MODULE_BOUNTY, await implV2.getAddress(), V2, STORAGE_HASH_V2, MIGRATION_HASH, "major"); + await manager.connect(validator).attestStorageCompatibility(1, false); + + // Not yet validated => approval blocked. + await expect(manager.connect(upgrader).approveUpgrade(1)).to.be.revertedWithCustomError( + manager, + "MigrationNotValidated" + ); + + // Wrong hash rejected. + await expect( + manager.connect(validator).validateMigration(1, ethers.id("wrong")) + ).to.be.revertedWithCustomError(manager, "MigrationHashMismatch"); + + await expect(manager.connect(validator).validateMigration(1, MIGRATION_HASH)) + .to.emit(manager, "MigrationValidated") + .withArgs(1n, MODULE_BOUNTY, MIGRATION_HASH, validator.address); + + await expect(manager.connect(upgrader).approveUpgrade(1)).to.emit(manager, "UpgradeApproved"); + }); + + it("only VALIDATOR can attest / validate", async function () { + const { manager, proposer, user, implV2 } = await withRegisteredModule(); + await manager + .connect(proposer) + .proposeUpgrade(MODULE_BOUNTY, await implV2.getAddress(), V2, STORAGE_HASH_V2, ZERO_HASH, "x"); + await expect( + manager.connect(user).attestStorageCompatibility(1, true) + ).to.be.revertedWithCustomError(manager, "AccessControlUnauthorizedAccount"); + }); + }); + + describe("approveUpgrade / executeUpgrade", function () { + it("sets executeAfter to now + timelock on approval", async function () { + const { manager, proposalId } = await withApprovedUpgrade(); + const p = await manager.getUpgradeProposal(proposalId); + expect(p.status).to.equal(2n); // Approved + const now = BigInt(await time.latest()); + expect(p.executeAfter).to.be.closeTo(now + (await manager.upgradeTimelock()), 5n); + }); + + it("blocks execution before the timelock elapses", async function () { + const { manager, executor, proposalId } = await withApprovedUpgrade(); + await expect(manager.connect(executor).executeUpgrade(proposalId)).to.be.revertedWithCustomError( + manager, + "TimelockNotPassed" + ); + }); + + it("executes after the timelock and updates module state", async function () { + const { manager, executor, implV1, implV2, proposalId } = await withApprovedUpgrade(); + await time.increase(2 * 24 * 60 * 60 + 1); + + const oldAddr = await implV1.getAddress(); + const newAddr = await implV2.getAddress(); + + await expect(manager.connect(executor).executeUpgrade(proposalId)) + .to.emit(manager, "UpgradeExecuted") + .withArgs(proposalId, MODULE_BOUNTY, oldAddr, newAddr, executor.address); + + const state = await manager.getModuleState(MODULE_BOUNTY); + expect(state.currentImplementation).to.equal(newAddr); + expect(state.currentVersion.minor).to.equal(1n); + expect(state.storageLayoutHash).to.equal(STORAGE_HASH_V2); + expect(state.previousImplementation).to.equal(oldAddr); + expect(state.upgradeCount).to.equal(1n); + + expect(await manager.versionString(MODULE_BOUNTY)).to.equal("1.1.0"); + expect(await manager.isUpgradeAuthorized(MODULE_BOUNTY, newAddr)).to.be.true; + expect(await manager.isUpgradeAuthorized(MODULE_BOUNTY, oldAddr)).to.be.false; + + const history = await manager.getModuleUpgradeHistory(MODULE_BOUNTY); + expect(history.length).to.equal(1); + expect(history[0]).to.equal(proposalId); + }); + + it("cannot execute a non-approved proposal", async function () { + const { manager, executor } = await withRegisteredModule(); + await expect(manager.connect(executor).executeUpgrade(1)).to.be.revertedWithCustomError( + manager, + "ProposalNotFound" + ); + }); + + it("only EXECUTOR can execute", async function () { + const { manager, user, proposalId } = await withApprovedUpgrade(); + await time.increase(2 * 24 * 60 * 60 + 1); + await expect(manager.connect(user).executeUpgrade(proposalId)).to.be.revertedWithCustomError( + manager, + "AccessControlUnauthorizedAccount" + ); + }); + }); + + describe("cancelUpgrade", function () { + it("lets the proposer cancel a pending proposal", async function () { + const { manager, proposer, implV2 } = await withRegisteredModule(); + await manager + .connect(proposer) + .proposeUpgrade(MODULE_BOUNTY, await implV2.getAddress(), V2, STORAGE_HASH_V2, ZERO_HASH, "x"); + + await expect(manager.connect(proposer).cancelUpgrade(1)) + .to.emit(manager, "UpgradeCancelled") + .withArgs(1n, MODULE_BOUNTY, proposer.address); + + const p = await manager.getUpgradeProposal(1); + expect(p.status).to.equal(4n); // Cancelled + }); + + it("lets a guardian veto an approved proposal", async function () { + const { manager, guardian, proposalId } = await withApprovedUpgrade(); + await expect(manager.connect(guardian).cancelUpgrade(proposalId)).to.emit(manager, "UpgradeCancelled"); + }); + + it("blocks an unauthorized caller from cancelling", async function () { + const { manager, user, proposalId } = await withApprovedUpgrade(); + await expect(manager.connect(user).cancelUpgrade(proposalId)).to.be.revertedWithCustomError( + manager, + "NotAuthorizedToCancel" + ); + }); + + it("cannot cancel an executed proposal", async function () { + const { manager, executor, guardian, proposalId } = await withApprovedUpgrade(); + await time.increase(2 * 24 * 60 * 60 + 1); + await manager.connect(executor).executeUpgrade(proposalId); + await expect(manager.connect(guardian).cancelUpgrade(proposalId)).to.be.revertedWithCustomError( + manager, + "InvalidProposalStatus" + ); + }); + }); + + describe("rollbackUpgrade", function () { + it("restores the previous implementation and re-points authorization", async function () { + const { manager, executor, guardian, implV1, implV2, proposalId } = await withApprovedUpgrade(); + await time.increase(2 * 24 * 60 * 60 + 1); + await manager.connect(executor).executeUpgrade(proposalId); + + const oldAddr = await implV1.getAddress(); + const newAddr = await implV2.getAddress(); + + await expect(manager.connect(guardian).rollbackUpgrade(MODULE_BOUNTY, "incident")) + .to.emit(manager, "UpgradeRolledBack") + .withArgs(MODULE_BOUNTY, newAddr, oldAddr, "incident", guardian.address); + + const state = await manager.getModuleState(MODULE_BOUNTY); + expect(state.currentImplementation).to.equal(oldAddr); + expect(state.currentVersion.minor).to.equal(0n); + expect(await manager.isUpgradeAuthorized(MODULE_BOUNTY, oldAddr)).to.be.true; + // Rollback is reversible: the demoted impl becomes the new previous. + expect(state.previousImplementation).to.equal(newAddr); + }); + + it("reverts when there is no previous implementation", async function () { + const { manager, guardian } = await withRegisteredModule(); + await expect(manager.connect(guardian).rollbackUpgrade(MODULE_BOUNTY, "x")).to.be.revertedWithCustomError( + manager, + "NoPreviousImplementation" + ); + }); + + it("blocks an unauthorized caller from rolling back", async function () { + const { manager, user, executor, proposalId } = await withApprovedUpgrade(); + await time.increase(2 * 24 * 60 * 60 + 1); + await manager.connect(executor).executeUpgrade(proposalId); + await expect(manager.connect(user).rollbackUpgrade(MODULE_BOUNTY, "x")).to.be.revertedWithCustomError( + manager, + "NotAuthorizedToRollback" + ); + }); + }); + + describe("Pausing", function () { + it("blocks execution while paused but allows rollback", async function () { + const { manager, admin, executor, guardian, proposalId } = await withApprovedUpgrade(); + await time.increase(2 * 24 * 60 * 60 + 1); + + await manager.connect(admin).pause(); + await expect(manager.connect(executor).executeUpgrade(proposalId)).to.be.revertedWithCustomError( + manager, + "EnforcedPause" + ); + + await manager.connect(admin).unpause(); + await manager.connect(executor).executeUpgrade(proposalId); + + // Rollback remains available even during a pause (asset-protection path). + await manager.connect(admin).pause(); + await expect(manager.connect(guardian).rollbackUpgrade(MODULE_BOUNTY, "incident")).to.emit( + manager, + "UpgradeRolledBack" + ); + }); + + it("only PAUSER can pause", async function () { + const { manager, user } = await loadFixture(deployFixture); + await expect(manager.connect(user).pause()).to.be.revertedWithCustomError( + manager, + "AccessControlUnauthorizedAccount" + ); + }); + }); + + describe("setUpgradeTimelock", function () { + it("updates the timelock within bounds", async function () { + const { manager, admin } = await loadFixture(deployFixture); + await expect(manager.connect(admin).setUpgradeTimelock(3 * 24 * 60 * 60)) + .to.emit(manager, "UpgradeTimelockUpdated") + .withArgs(2n * 24n * 60n * 60n, 3n * 24n * 60n * 60n); + expect(await manager.upgradeTimelock()).to.equal(3n * 24n * 60n * 60n); + }); + + it("rejects out-of-bounds timelocks", async function () { + const { manager, admin } = await loadFixture(deployFixture); + await expect(manager.connect(admin).setUpgradeTimelock(60)).to.be.revertedWithCustomError( + manager, + "InvalidTimelock" + ); + await expect( + manager.connect(admin).setUpgradeTimelock(31 * 24 * 60 * 60) + ).to.be.revertedWithCustomError(manager, "InvalidTimelock"); + }); + }); + + describe("Version helpers", function () { + it("orders versions across all components", async function () { + const { manager, proposer, validator, upgrader, executor, implV1, implV2 } = + await withRegisteredModule(); + + // 1.0.0 -> 1.0.1 (patch) is newer. + await manager + .connect(proposer) + .proposeUpgrade(MODULE_BOUNTY, await implV2.getAddress(), V1_0_1, STORAGE_HASH_V2, ZERO_HASH, "patch"); + await manager.connect(validator).attestStorageCompatibility(1, true); + await manager.connect(upgrader).approveUpgrade(1); + await time.increase(2 * 24 * 60 * 60 + 1); + await manager.connect(executor).executeUpgrade(1); + expect(await manager.versionString(MODULE_BOUNTY)).to.equal("1.0.1"); + + // Now proposing 1.0.1 again must fail (not newer). + await expect( + manager + .connect(proposer) + .proposeUpgrade(MODULE_BOUNTY, await implV1.getAddress(), V1_0_1, STORAGE_HASH_V1, ZERO_HASH, "same") + ).to.be.revertedWithCustomError(manager, "VersionNotNewer"); + }); + }); +});