From bad01bedc478cf9decc176ba475a21d676532580 Mon Sep 17 00:00:00 2001 From: Taras Shchybovyk Date: Thu, 23 Apr 2026 10:28:27 -0700 Subject: [PATCH 01/17] feat: SafeTimelockFactory + TimelockControllerImpl + ICallValidator Purely additive: no existing file modified. - SafeTimelockFactory deploys canonical Gnosis Safes (via upstream SafeProxyFactory) and TimelockControllerImpl clones (EIP-1167), and maintains a registry of (isSafe / isTimelock / getSafesByDeployer / getTimelocksByDeployer) used later by AppController to detect governance-owned apps. - TimelockControllerImpl extends OZ TimelockControllerUpgradeable with on-chain pending-op enumeration (getPendingOperationIds / getPendingOperations) and a schedule-time validator hook that calls ICallValidator.canCall(address(this), data) on the target, failing closed if the target explicitly returns false. Targets that don't implement the interface are allowed through for backwards compat. - ICallValidator is a one-method interface targets may implement to reject doomed operations at schedule time instead of waiting for the delay to pass. - ISafe / ISafeProxyFactory / ISafeTimelockFactory interfaces. AppController integration comes in a follow-up commit. --- src/factories/SafeTimelockFactory.sol | 138 +++++++++++++++++ src/governance/TimelockControllerImpl.sol | 169 +++++++++++++++++++++ src/interfaces/ICallValidator.sol | 16 ++ src/interfaces/ISafe.sol | 16 ++ src/interfaces/ISafeProxyFactory.sol | 11 ++ src/interfaces/ISafeTimelockFactory.sol | 140 +++++++++++++++++ src/storage/SafeTimelockFactoryStorage.sol | 66 ++++++++ 7 files changed, 556 insertions(+) create mode 100644 src/factories/SafeTimelockFactory.sol create mode 100644 src/governance/TimelockControllerImpl.sol create mode 100644 src/interfaces/ICallValidator.sol create mode 100644 src/interfaces/ISafe.sol create mode 100644 src/interfaces/ISafeProxyFactory.sol create mode 100644 src/interfaces/ISafeTimelockFactory.sol create mode 100644 src/storage/SafeTimelockFactoryStorage.sol diff --git a/src/factories/SafeTimelockFactory.sol b/src/factories/SafeTimelockFactory.sol new file mode 100644 index 0000000..d92ce95 --- /dev/null +++ b/src/factories/SafeTimelockFactory.sol @@ -0,0 +1,138 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.27; + +import {Create2} from "@openzeppelin/contracts/utils/Create2.sol"; +import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol"; +import {Initializable} from "@openzeppelin-upgrades/contracts/proxy/utils/Initializable.sol"; +import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; +import {SafeTimelockFactoryStorage} from "../storage/SafeTimelockFactoryStorage.sol"; +import {ISafeTimelockFactory} from "../interfaces/ISafeTimelockFactory.sol"; +import {ISafe} from "../interfaces/ISafe.sol"; +import {ISafeProxyFactory} from "../interfaces/ISafeProxyFactory.sol"; +import {TimelockControllerImpl} from "../governance/TimelockControllerImpl.sol"; + +/** + * @title SafeTimelockFactory + * @notice Factory for deploying verified Gnosis Safes and TimelockControllers + * @dev Deployed entities can be verified as "official" via isSafe() and isTimelock() + */ +contract SafeTimelockFactory is Initializable, SafeTimelockFactoryStorage { + using EnumerableSet for EnumerableSet.AddressSet; + + constructor( + address _safeSingleton, + address _safeProxyFactory, + address _safeFallbackHandler, + address _timelockImplementation + ) SafeTimelockFactoryStorage(_safeSingleton, _safeProxyFactory, _safeFallbackHandler, _timelockImplementation) { + _disableInitializers(); + } + + function initialize() external initializer {} + + /// EXTERNAL FUNCTIONS + + /// @inheritdoc ISafeTimelockFactory + function deploySafe(SafeConfig calldata config, bytes32 salt) external returns (address safe) { + safe = _deploySafe(config, salt); + _safes.add(safe); + _safesByDeployer[msg.sender].add(safe); + emit SafeDeployed(msg.sender, safe, config.owners, config.threshold, salt); + } + + /// @inheritdoc ISafeTimelockFactory + function deployTimelock(TimelockConfig calldata config, bytes32 salt) external returns (address timelock) { + _validateTimelockConfig(config); + timelock = _deployTimelock(config, salt); + _timelocks.add(timelock); + _timelocksByDeployer[msg.sender].add(timelock); + emit TimelockDeployed(msg.sender, timelock, config.minDelay, config.proposers, config.executors, salt); + } + + /// VIEW FUNCTIONS + + /// @inheritdoc ISafeTimelockFactory + function isSafe(address safe) external view returns (bool) { + return _safes.contains(safe); + } + + /// @inheritdoc ISafeTimelockFactory + function isTimelock(address timelock) external view returns (bool) { + return _timelocks.contains(timelock); + } + + /// @inheritdoc ISafeTimelockFactory + function getTimelocksByDeployer(address deployer) external view returns (address[] memory) { + return _timelocksByDeployer[deployer].values(); + } + + /// @inheritdoc ISafeTimelockFactory + function getSafesByDeployer(address deployer) external view returns (address[] memory) { + return _safesByDeployer[deployer].values(); + } + + /// @inheritdoc ISafeTimelockFactory + function calculateSafeAddress(address deployer, SafeConfig calldata config, bytes32 salt) + external + view + returns (address) + { + bytes memory initializer = _encodeSafeInitializer(config); + uint256 saltNonce = uint256(_deriveSalt(deployer, salt)); + bytes32 creationSalt = keccak256(abi.encodePacked(keccak256(initializer), saltNonce)); + bytes memory proxyCreationCode = ISafeProxyFactory(safeProxyFactory).proxyCreationCode(); + bytes memory deploymentData = abi.encodePacked(proxyCreationCode, uint256(uint160(safeSingleton))); + return Create2.computeAddress(creationSalt, keccak256(deploymentData), safeProxyFactory); + } + + /// @inheritdoc ISafeTimelockFactory + function calculateTimelockAddress(address deployer, bytes32 salt) external view returns (address) { + bytes32 mixedSalt = _deriveSalt(deployer, salt); + return Clones.predictDeterministicAddress(timelockImplementation, mixedSalt); + } + + /// INTERNAL FUNCTIONS + + function _validateTimelockConfig(TimelockConfig calldata config) internal pure { + require(config.proposers.length > 0, NoProposers()); + require(config.executors.length > 0, NoExecutors()); + for (uint256 i = 0; i < config.proposers.length; i++) { + require(config.proposers[i] != address(0), ZeroAddressProposer()); + } + for (uint256 i = 0; i < config.executors.length; i++) { + require(config.executors[i] != address(0), ZeroAddressExecutor()); + } + } + + function _deploySafe(SafeConfig calldata config, bytes32 salt) internal returns (address safe) { + bytes memory initializer = _encodeSafeInitializer(config); + uint256 saltNonce = uint256(_deriveSalt(msg.sender, salt)); + safe = ISafeProxyFactory(safeProxyFactory).createProxyWithNonce(safeSingleton, initializer, saltNonce); + } + + function _encodeSafeInitializer(SafeConfig calldata config) internal view returns (bytes memory) { + return abi.encodeWithSelector( + ISafe.setup.selector, + config.owners, + config.threshold, + address(0), + "", + safeFallbackHandler, + address(0), + 0, + payable(address(0)) + ); + } + + function _deployTimelock(TimelockConfig calldata config, bytes32 salt) internal returns (address timelock) { + bytes32 mixedSalt = _deriveSalt(msg.sender, salt); + timelock = Clones.cloneDeterministic(timelockImplementation, mixedSalt); + + // forgefmt: disable-next-item + TimelockControllerImpl(payable(timelock)).initialize(config.minDelay, config.proposers, config.executors, address(0)); + } + + function _deriveSalt(address deployer, bytes32 salt) internal view returns (bytes32) { + return keccak256(abi.encodePacked(address(this), deployer, salt)); + } +} diff --git a/src/governance/TimelockControllerImpl.sol b/src/governance/TimelockControllerImpl.sol new file mode 100644 index 0000000..8a139a9 --- /dev/null +++ b/src/governance/TimelockControllerImpl.sol @@ -0,0 +1,169 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.27; + +import { + TimelockControllerUpgradeable +} from "@openzeppelin-upgrades/contracts/governance/TimelockControllerUpgradeable.sol"; +import {ICallValidator} from "../interfaces/ICallValidator.sol"; + +/** + * @title TimelockControllerImpl + * @notice Implementation contract for TimelockController minimal proxies + * @dev Wraps TimelockControllerUpgradeable and adds on-chain pending operation enumeration. + * Overrides schedule/scheduleBatch/execute/executeBatch/cancel to maintain a set of + * pending operation IDs, enabling clients to enumerate queued operations without + * requiring event log scanning. + * + * Also validates targets at schedule-time: if a target implements ICallValidator, + * canCall(address(this), data) must return true or the schedule is rejected. + */ +contract TimelockControllerImpl is TimelockControllerUpgradeable { + struct PendingOp { + bytes32 id; + address target; + bytes data; + uint256 executableAt; + } + + // Append-only array of pending operation IDs (swap-and-pop on removal). + bytes32[] private _pendingIds; + // id => 1-based index into _pendingIds (0 means not in set). + mapping(bytes32 => uint256) private _pendingIndex; + // id => stored op metadata for enumeration + mapping(bytes32 => PendingOp) private _pendingOps; + + constructor() { + _disableInitializers(); + } + + /** + * @notice Initialize the timelock controller + * @param minDelay Minimum delay for operations + * @param proposers Addresses granted proposer and canceller roles + * @param executors Addresses granted executor role + * @param admin Optional admin address (use address(0) for self-administered) + */ + function initialize(uint256 minDelay, address[] memory proposers, address[] memory executors, address admin) + external + initializer + { + __TimelockController_init(minDelay, proposers, executors, admin); + } + + // ── Overrides ──────────────────────────────────────────────────────────── + + function schedule( + address target, + uint256 value, + bytes calldata data, + bytes32 predecessor, + bytes32 salt, + uint256 delay + ) public override { + _validateTarget(target, data); + super.schedule(target, value, data, predecessor, salt, delay); + bytes32 id = hashOperation(target, value, data, predecessor, salt); + _addPending(id, target, data, block.timestamp + delay); + } + + function scheduleBatch( + address[] calldata targets, + uint256[] calldata values, + bytes[] calldata payloads, + bytes32 predecessor, + bytes32 salt, + uint256 delay + ) public override { + for (uint256 i = 0; i < targets.length; i++) { + _validateTarget(targets[i], payloads[i]); + } + super.scheduleBatch(targets, values, payloads, predecessor, salt, delay); + // For batch ops store empty data — callers should use getPendingOperationIds and decode individually + bytes32 id = hashOperationBatch(targets, values, payloads, predecessor, salt); + _addPending(id, address(0), "", block.timestamp + delay); + } + + function execute(address target, uint256 value, bytes calldata payload, bytes32 predecessor, bytes32 salt) + public + payable + override + { + bytes32 id = hashOperation(target, value, payload, predecessor, salt); + super.execute(target, value, payload, predecessor, salt); + _removePending(id); + } + + function executeBatch( + address[] calldata targets, + uint256[] calldata values, + bytes[] calldata payloads, + bytes32 predecessor, + bytes32 salt + ) public payable override { + bytes32 id = hashOperationBatch(targets, values, payloads, predecessor, salt); + super.executeBatch(targets, values, payloads, predecessor, salt); + _removePending(id); + } + + function cancel(bytes32 id) public override { + super.cancel(id); + _removePending(id); + } + + // ── Enumeration ────────────────────────────────────────────────────────── + + /** + * @notice Returns all currently pending operation IDs. + */ + function getPendingOperationIds() external view returns (bytes32[] memory) { + return _pendingIds; + } + + /** + * @notice Returns all currently pending operations with metadata. + * @dev For single-call ops, target and data are populated. + * For batch ops, target is address(0) and data is empty. + */ + function getPendingOperations() external view returns (PendingOp[] memory ops) { + ops = new PendingOp[](_pendingIds.length); + for (uint256 i = 0; i < _pendingIds.length; i++) { + ops[i] = _pendingOps[_pendingIds[i]]; + } + } + + // ── Internal helpers ───────────────────────────────────────────────────── + + /** + * @dev If `target` implements ICallValidator, ask whether this Timelock may execute `data`. + * Targets that do not implement the interface are allowed through (backwards compatible). + */ + function _validateTarget(address target, bytes calldata data) private view { + try ICallValidator(target).canCall(address(this), data) returns (bool allowed) { + require(allowed, "TimelockController: target rejected the call"); + } catch { + // Target does not implement canCall — allow + } + } + + function _addPending(bytes32 id, address target, bytes memory data, uint256 executableAt) private { + if (_pendingIndex[id] != 0) return; // already tracked + _pendingIds.push(id); + _pendingIndex[id] = _pendingIds.length; // 1-based + _pendingOps[id] = PendingOp({id: id, target: target, data: data, executableAt: executableAt}); + } + + function _removePending(bytes32 id) private { + uint256 idx = _pendingIndex[id]; + if (idx == 0) return; // not tracked (pre-upgrade op) + uint256 i = idx - 1; + uint256 last = _pendingIds.length - 1; + if (i != last) { + bytes32 lastId = _pendingIds[last]; + _pendingIds[i] = lastId; + _pendingIndex[lastId] = idx; // keep 1-based + } + _pendingIds.pop(); + delete _pendingIndex[id]; + delete _pendingOps[id]; + } +} diff --git a/src/interfaces/ICallValidator.sol b/src/interfaces/ICallValidator.sol new file mode 100644 index 0000000..8625e2f --- /dev/null +++ b/src/interfaces/ICallValidator.sol @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +/** + * @title ICallValidator + * @notice Optional interface that Timelock targets can implement to reject + * operations at schedule-time rather than waiting until execute-time. + */ +interface ICallValidator { + /** + * @notice Returns true if `caller` is authorized to execute `data` on this contract. + * @param caller The address that will ultimately call (e.g., the Timelock). + * @param data The calldata that will be forwarded to the target. + */ + function canCall(address caller, bytes calldata data) external view returns (bool); +} diff --git a/src/interfaces/ISafe.sol b/src/interfaces/ISafe.sol new file mode 100644 index 0000000..0c98a5a --- /dev/null +++ b/src/interfaces/ISafe.sol @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +/// @dev Interface for Gnosis Safe setup function +interface ISafe { + function setup( + address[] calldata _owners, + uint256 _threshold, + address to, + bytes calldata data, + address fallbackHandler, + address paymentToken, + uint256 payment, + address payable paymentReceiver + ) external; +} diff --git a/src/interfaces/ISafeProxyFactory.sol b/src/interfaces/ISafeProxyFactory.sol new file mode 100644 index 0000000..7fe3e8a --- /dev/null +++ b/src/interfaces/ISafeProxyFactory.sol @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +/// @dev Interface for Gnosis SafeProxyFactory +interface ISafeProxyFactory { + function createProxyWithNonce(address _singleton, bytes memory initializer, uint256 saltNonce) + external + returns (address proxy); + + function proxyCreationCode() external pure returns (bytes memory); +} diff --git a/src/interfaces/ISafeTimelockFactory.sol b/src/interfaces/ISafeTimelockFactory.sol new file mode 100644 index 0000000..8a78456 --- /dev/null +++ b/src/interfaces/ISafeTimelockFactory.sol @@ -0,0 +1,140 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +/** + * @title ISafeTimelockFactory + * @notice Factory for deploying verified Gnosis Safes and TimelockControllers + * @dev Deployed entities can be verified as "official" via isSafe() and isTimelock() + */ +interface ISafeTimelockFactory { + /// STRUCTS + /// @notice Configuration for deploying a Gnosis Safe + struct SafeConfig { + address[] owners; // Addresses that can sign transactions + uint256 threshold; // Number of required signatures + } + + /// @notice Configuration for deploying a TimelockController + struct TimelockConfig { + uint256 minDelay; // Minimum delay in seconds before execution + address[] proposers; // Addresses that can propose and cancel operations + address[] executors; // Addresses that can execute ready operations + } + + /// EVENTS + + /// @notice Emitted when a Gnosis Safe is deployed + event SafeDeployed( + address indexed deployer, address indexed safe, address[] owners, uint256 threshold, bytes32 salt + ); + + /// @notice Emitted when a TimelockController is deployed + event TimelockDeployed( + address indexed deployer, + address indexed timelock, + uint256 minDelay, + address[] proposers, + address[] executors, + bytes32 salt + ); + + /// ERRORS + + /// @notice Thrown when proposers array is empty + error NoProposers(); + + /// @notice Thrown when executors array is empty + error NoExecutors(); + + /// @notice Thrown when a proposer address is zero + error ZeroAddressProposer(); + + /// @notice Thrown when an executor address is zero + error ZeroAddressExecutor(); + + /// EXTERNAL FUNCTIONS + + /** + * @notice Deploys a new Gnosis Safe with deterministic address + * @param config Safe configuration (owners, threshold) + * @param salt User-provided salt for deterministic deployment + * @return safe The deployed Safe address + */ + function deploySafe(SafeConfig calldata config, bytes32 salt) external returns (address safe); + + /** + * @notice Deploys a new TimelockController with deterministic address + * @param config Timelock configuration (minDelay, proposers, executors) + * @param salt User-provided salt for deterministic deployment + * @return timelock The deployed TimelockController address + */ + function deployTimelock(TimelockConfig calldata config, bytes32 salt) external returns (address timelock); + + /// VIEW FUNCTIONS + + /** + * @notice Checks if an address is a Safe deployed by this factory + * @param safe The address to check + * @return True if the address is a factory-deployed Safe + */ + function isSafe(address safe) external view returns (bool); + + /** + * @notice Checks if an address is a Timelock deployed by this factory + * @param timelock The address to check + * @return True if the address is a factory-deployed Timelock + */ + function isTimelock(address timelock) external view returns (bool); + + /** + * @notice Pre-computes the address of a Safe deployment + * @param deployer The address that will deploy + * @param config Safe configuration + * @param salt User-provided salt + * @return The computed Safe address + */ + function calculateSafeAddress(address deployer, SafeConfig calldata config, bytes32 salt) + external + view + returns (address); + + /** + * @notice Pre-computes the address of a Timelock deployment + * @param deployer The address that will deploy + * @param salt User-provided salt + * @return The computed TimelockController address + */ + function calculateTimelockAddress(address deployer, bytes32 salt) external view returns (address); + + /** + * @notice Returns the official Gnosis Safe singleton address + * @return The Safe singleton (master copy) address + */ + function safeSingleton() external view returns (address); + + /** + * @notice Returns the official Gnosis Safe proxy factory address + * @return The SafeProxyFactory address + */ + function safeProxyFactory() external view returns (address); + + /** + * @notice Returns the TimelockController implementation address for minimal proxies + * @return The TimelockControllerImpl address + */ + function timelockImplementation() external view returns (address); + + /** + * @notice Returns all Timelocks deployed by a given deployer + * @param deployer The deployer address + * @return Array of Timelock addresses + */ + function getTimelocksByDeployer(address deployer) external view returns (address[] memory); + + /** + * @notice Returns all Safes deployed by a given deployer + * @param deployer The deployer address + * @return Array of Safe addresses + */ + function getSafesByDeployer(address deployer) external view returns (address[] memory); +} diff --git a/src/storage/SafeTimelockFactoryStorage.sol b/src/storage/SafeTimelockFactoryStorage.sol new file mode 100644 index 0000000..bcc2a2d --- /dev/null +++ b/src/storage/SafeTimelockFactoryStorage.sol @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.27; + +import {ISafeTimelockFactory} from "../interfaces/ISafeTimelockFactory.sol"; +import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; + +/** + * @title SafeTimelockFactoryStorage + * @notice Storage contract for SafeTimelockFactory + */ +abstract contract SafeTimelockFactoryStorage is ISafeTimelockFactory { + using EnumerableSet for EnumerableSet.AddressSet; + + /// IMMUTABLES + + /// @notice The official Gnosis Safe singleton (master copy) address + address public immutable safeSingleton; + + /// @notice The official Gnosis Safe proxy factory address + address public immutable safeProxyFactory; + + /// @notice The default fallback handler for Safes + address public immutable safeFallbackHandler; + + /// @notice The TimelockController implementation for minimal proxies + address public immutable timelockImplementation; + + /// STATE VARIABLES + + /// @notice Set of all Safes deployed by this factory + EnumerableSet.AddressSet internal _safes; + + /// @notice Set of all Timelocks deployed by this factory + EnumerableSet.AddressSet internal _timelocks; + + /// @notice Timelocks indexed by deployer address + mapping(address => EnumerableSet.AddressSet) internal _timelocksByDeployer; + + /// @notice Safes indexed by deployer address + mapping(address => EnumerableSet.AddressSet) internal _safesByDeployer; + + /// STORAGE GAP + + /// @notice Storage gap for future upgrades + uint256[44] private __gap; + + /// CONSTRUCTOR + + /** + * @param _safeSingleton The official Gnosis Safe singleton address + * @param _safeProxyFactory The official Gnosis Safe proxy factory address + * @param _safeFallbackHandler The default fallback handler for Safes + * @param _timelockImplementation The TimelockController implementation for minimal proxies + */ + constructor( + address _safeSingleton, + address _safeProxyFactory, + address _safeFallbackHandler, + address _timelockImplementation + ) { + safeSingleton = _safeSingleton; + safeProxyFactory = _safeProxyFactory; + safeFallbackHandler = _safeFallbackHandler; + timelockImplementation = _timelockImplementation; + } +} From 3e3f728f77c37acd403cdd378698c53041b9a695 Mon Sep 17 00:00:00 2001 From: Taras Shchybovyk Date: Thu, 23 Apr 2026 10:47:28 -0700 Subject: [PATCH 02/17] feat: wire SafeTimelockFactory into AppController storage - AppControllerStorage gains an immutable safeTimelockFactory reference (constructor arg). No storage slot consumed; the field is used to detect governance-owned apps without adding a separate registry lookup. - AppConfigStorage adds `bool timelocked` at byte 30 of the existing slot (immediately after BillingType at byte 29). This position was zero on every pre-v1.5.0 app, so the upgrade is byte-safe. An explicit byte-layout comment documents the invariant so a future reorder doesn't silently collide with isolated-billing's existing byte 29. - AppController constructor takes the factory arg and forwards it. - All historical release deploy scripts (v1.0.4, v1.1.1, v1.4.0) pass ISafeTimelockFactory(address(0)) so the repo still compiles; they are already applied on-chain and never run again. - script/Deploy.s.sol (fresh-deploy fast path, used in tests) carries a TODO to deploy the factory before AppController when the v1.5.0 governance release lands. No governance behavior is enabled by this commit. The flag stays false and none of the sensitive-op runtime gates exist yet. Follow-up commits add canCall, team roles, transferOwnership semantics, and the release script. --- script/Deploy.s.sol | 8 +++++++- script/releases/v1.0.4-init/1-deployContracts.s.sol | 6 +++++- .../1-deployAppControllerImpl.s.sol | 5 ++++- .../1-deployAppControllerImpl.s.sol | 5 ++++- src/AppController.sol | 6 ++++-- src/interfaces/IAppController.sol | 12 ++++++++++++ src/storage/AppControllerStorage.sol | 8 +++++++- 7 files changed, 43 insertions(+), 7 deletions(-) diff --git a/script/Deploy.s.sol b/script/Deploy.s.sol index 8cb680e..b744207 100644 --- a/script/Deploy.s.sol +++ b/script/Deploy.s.sol @@ -24,6 +24,7 @@ import {ComputeAVSRegistrar} from "../src/ComputeAVSRegistrar.sol"; import {ComputeOperator} from "../src/ComputeOperator.sol"; import {ImageAllowlist} from "../src/ImageAllowlist.sol"; import {IImageAllowlist} from "../src/interfaces/IImageAllowlist.sol"; +import {ISafeTimelockFactory} from "../src/interfaces/ISafeTimelockFactory.sol"; contract Deploy is Parser { struct Proxies { @@ -114,7 +115,12 @@ contract Deploy is Parser { _releaseManager: params.releaseManager, _computeAVSRegistrar: IComputeAVSRegistrar(address(proxies.computeAVSRegistrar)), _computeOperator: IComputeOperator(address(proxies.computeOperator)), - _appBeacon: appBeacon + _appBeacon: appBeacon, + // TODO(v1.5.0-governance): deploy SafeTimelockFactory before + // AppController and pass the factory address here. Fresh-deploy + // path only — production v1.5.0 upgrade uses a dedicated release + // script that deploys the factory in phase 1. + _safeTimelockFactory: ISafeTimelockFactory(address(0)) }), imageAllowlist: new ImageAllowlist() }); diff --git a/script/releases/v1.0.4-init/1-deployContracts.s.sol b/script/releases/v1.0.4-init/1-deployContracts.s.sol index 88ac49e..2351973 100644 --- a/script/releases/v1.0.4-init/1-deployContracts.s.sol +++ b/script/releases/v1.0.4-init/1-deployContracts.s.sol @@ -22,6 +22,7 @@ import {ComputeOperator} from "../../../src/ComputeOperator.sol"; import {IAppController} from "../../../src/interfaces/IAppController.sol"; import {IComputeAVSRegistrar} from "../../../src/interfaces/IComputeAVSRegistrar.sol"; import {IComputeOperator} from "../../../src/interfaces/IComputeOperator.sol"; +import {ISafeTimelockFactory} from "../../../src/interfaces/ISafeTimelockFactory.sol"; /** * Purpose: use an EOA to deploy all compute contracts. @@ -82,7 +83,10 @@ contract Deploy is EOADeployer { _releaseManager: Env.releaseManager(), _computeAVSRegistrar: IComputeAVSRegistrar(address(computeAVSRegistrarProxy)), _computeOperator: IComputeOperator(address(computeOperatorProxy)), - _appBeacon: appBeacon + _appBeacon: appBeacon, + // v1.0.4 predates SafeTimelockFactory. Historical script kept + // compilable against the current constructor; this path never runs. + _safeTimelockFactory: ISafeTimelockFactory(address(0)) }); // Upgrade proxies using ProxyAdmin diff --git a/script/releases/v1.1.1-app-suspension/1-deployAppControllerImpl.s.sol b/script/releases/v1.1.1-app-suspension/1-deployAppControllerImpl.s.sol index b231135..08c2680 100644 --- a/script/releases/v1.1.1-app-suspension/1-deployAppControllerImpl.s.sol +++ b/script/releases/v1.1.1-app-suspension/1-deployAppControllerImpl.s.sol @@ -7,6 +7,7 @@ import "../Env.sol"; import "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol"; import {AppController} from "../../../src/AppController.sol"; +import {ISafeTimelockFactory} from "../../../src/interfaces/ISafeTimelockFactory.sol"; /** * Purpose: deploy new AppController implementation with suspension functionality @@ -26,7 +27,9 @@ contract DeployAppControllerImpl is EOADeployer { _releaseManager: Env.releaseManager(), _computeAVSRegistrar: Env.proxy.computeAVSRegistrar(), _computeOperator: Env.proxy.computeOperator(), - _appBeacon: Env.beacon.appBeacon() + _appBeacon: Env.beacon.appBeacon(), + // v1.1.1 predates SafeTimelockFactory. Historical script; never runs again. + _safeTimelockFactory: ISafeTimelockFactory(address(0)) }); // Register new implementation in Env system diff --git a/script/releases/v1.4.0-isolated-billing/1-deployAppControllerImpl.s.sol b/script/releases/v1.4.0-isolated-billing/1-deployAppControllerImpl.s.sol index 8fd0fe1..96b57e0 100644 --- a/script/releases/v1.4.0-isolated-billing/1-deployAppControllerImpl.s.sol +++ b/script/releases/v1.4.0-isolated-billing/1-deployAppControllerImpl.s.sol @@ -7,6 +7,7 @@ import "../Env.sol"; import "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol"; import {AppController} from "../../../src/AppController.sol"; +import {ISafeTimelockFactory} from "../../../src/interfaces/ISafeTimelockFactory.sol"; /** * Purpose: deploy new AppController implementation with isolated billing functionality @@ -26,7 +27,9 @@ contract DeployAppControllerImpl is EOADeployer { _releaseManager: Env.releaseManager(), _computeAVSRegistrar: Env.proxy.computeAVSRegistrar(), _computeOperator: Env.proxy.computeOperator(), - _appBeacon: Env.beacon.appBeacon() + _appBeacon: Env.beacon.appBeacon(), + // v1.4.0 predates SafeTimelockFactory. Historical script; never runs again. + _safeTimelockFactory: ISafeTimelockFactory(address(0)) }); // Register new implementation in Env system diff --git a/src/AppController.sol b/src/AppController.sol index 1791d4b..1fbcf6f 100644 --- a/src/AppController.sol +++ b/src/AppController.sol @@ -20,6 +20,7 @@ import {IAppController} from "./interfaces/IAppController.sol"; import {BeaconProxy} from "@openzeppelin/contracts/proxy/beacon/BeaconProxy.sol"; import {IBeacon} from "@openzeppelin/contracts/proxy/beacon/IBeacon.sol"; import {IApp} from "./interfaces/IApp.sol"; +import {ISafeTimelockFactory} from "./interfaces/ISafeTimelockFactory.sol"; contract AppController is Initializable, SignatureUtilsMixin, PermissionControllerMixin, AppControllerStorage { using EnumerableSet for EnumerableSet.AddressSet; @@ -51,11 +52,12 @@ contract AppController is Initializable, SignatureUtilsMixin, PermissionControll IReleaseManager _releaseManager, IComputeAVSRegistrar _computeAVSRegistrar, IComputeOperator _computeOperator, - IBeacon _appBeacon + IBeacon _appBeacon, + ISafeTimelockFactory _safeTimelockFactory ) SignatureUtilsMixin(_version) PermissionControllerMixin(_permissionController) - AppControllerStorage(_releaseManager, _computeOperator, _computeAVSRegistrar, _appBeacon) + AppControllerStorage(_releaseManager, _computeOperator, _computeAVSRegistrar, _appBeacon, _safeTimelockFactory) { _disableInitializers(); } diff --git a/src/interfaces/IAppController.sol b/src/interfaces/IAppController.sol index 24a4177..6e83395 100644 --- a/src/interfaces/IAppController.sol +++ b/src/interfaces/IAppController.sol @@ -96,11 +96,23 @@ interface IAppController { /// @notice Internal storage config for an app, extends AppConfig with additional fields struct AppConfigStorage { + // Slot layout (all 32 bytes packed): + // bytes 0-19: address creator (20 bytes) + // bytes 20-23: uint32 operatorSetId ( 4 bytes) + // bytes 24-27: uint32 latestReleaseBlockNumber ( 4 bytes) + // byte 28: AppStatus status ( 1 byte) + // byte 29: BillingType billingType ( 1 byte) ← present on v1.4.0 chain state + // byte 30: bool timelocked ( 1 byte) ← new in v1.5.0; safely zero on all v1.4.0 apps + // byte 31: (unused) address creator; uint32 operatorSetId; uint32 latestReleaseBlockNumber; AppStatus status; BillingType billingType; + // true = owner is a factory Timelock; sensitive ops must go through + // Timelock.schedule → execute. Must NOT be placed at byte 29 — that + // byte already holds `billingType` on existing deployed contracts. + bool timelocked; } /// @notice User configuration and state diff --git a/src/storage/AppControllerStorage.sol b/src/storage/AppControllerStorage.sol index 0bf97a4..2904f65 100644 --- a/src/storage/AppControllerStorage.sol +++ b/src/storage/AppControllerStorage.sol @@ -9,6 +9,7 @@ import {IApp} from "../interfaces/IApp.sol"; import {IAppController} from "../interfaces/IAppController.sol"; import {IBeacon} from "@openzeppelin/contracts/proxy/beacon/IBeacon.sol"; import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; +import {ISafeTimelockFactory} from "../interfaces/ISafeTimelockFactory.sol"; abstract contract AppControllerStorage is IAppController { using EnumerableSet for EnumerableSet.AddressSet; @@ -38,6 +39,9 @@ abstract contract AppControllerStorage is IAppController { /// @notice The beacon used for creating App proxies IBeacon public immutable appBeacon; + /// @notice Factory used to verify Safe and Timelock deployments for governance detection + ISafeTimelockFactory public immutable safeTimelockFactory; + /// @notice Set of all created apps EnumerableSet.AddressSet internal _allApps; @@ -57,12 +61,14 @@ abstract contract AppControllerStorage is IAppController { IReleaseManager _releaseManager, IComputeOperator _computeOperator, IComputeAVSRegistrar _computeAVSRegistrar, - IBeacon _appBeacon + IBeacon _appBeacon, + ISafeTimelockFactory _safeTimelockFactory ) { releaseManager = _releaseManager; computeOperator = _computeOperator; computeAVSRegistrar = _computeAVSRegistrar; appBeacon = _appBeacon; + safeTimelockFactory = _safeTimelockFactory; } /** From 145659cec810e4dc266aeeaa3302dd0ef36721c1 Mon Sep 17 00:00:00 2001 From: Taras Shchybovyk Date: Thu, 23 Apr 2026 10:51:26 -0700 Subject: [PATCH 03/17] feat: timelocked gate for upgradeApp, terminateApp, terminateAppByAdmin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the app's creator is a factory-deployed Timelock the sensitive operations must be routed through Timelock.schedule → execute. Until now those calls were gated only by PermissionControllerMixin, so any admin the Timelock had granted via UAM could trigger an instant upgrade or termination and bypass the delay entirely. - _deployApp sets _appConfigs[app].timelocked = isTimelock(msg.sender) at creation. Previously the flag was unset on this path, so a Timelock calling createApp for itself skipped all of the new gates. Defensive zero-address check for historical deployments that have no factory wired in. - upgradeApp requires msg.sender == creator when timelocked. - terminateApp requires msg.sender == creator when timelocked. - terminateAppByAdmin refuses to run against timelocked apps entirely — protocol admin must go through the Timelock, not around it. - New view getAppTimelocked for off-chain tooling. transferOwnership isn't on this branch yet; that semantic comes with the team-RBAC overhaul in a later commit. For now the flag is only written at creation. --- src/AppController.sol | 35 +++++++++++++++++++++++++++++++ src/interfaces/IAppController.sol | 9 ++++++++ 2 files changed, 44 insertions(+) diff --git a/src/AppController.sol b/src/AppController.sol index 1fbcf6f..cd50acd 100644 --- a/src/AppController.sol +++ b/src/AppController.sol @@ -119,6 +119,13 @@ contract AppController is Initializable, SignatureUtilsMixin, PermissionControll appIsActive(app) returns (uint256) { + // When the app's owner is a factory-deployed Timelock, the sensitive + // path must go through Timelock.schedule → execute so the delay window + // actually applies. Any other PermissionController-permitted caller + // (e.g. a co-admin granted via UAM) would bypass the queue otherwise. + if (_appConfigs[app].timelocked) { + require(msg.sender == _appConfigs[app].creator, InvalidPermissions()); + } return _upgradeApp(app, release); } @@ -147,11 +154,23 @@ contract AppController is Initializable, SignatureUtilsMixin, PermissionControll /// @inheritdoc IAppController function terminateApp(IApp app) external checkCanCall(address(app)) appIsActive(app) { + // When timelocked, only the Timelock owner itself (acting via + // schedule → execute) may terminate. Termination is irreversible; + // bypassing the queue here defeats the entire purpose of + // transferring ownership to a Timelock. + if (_appConfigs[app].timelocked) { + require(msg.sender == _appConfigs[app].creator, InvalidPermissions()); + } _terminateApp(app); } /// @inheritdoc IAppController function terminateAppByAdmin(IApp app) external checkCanCall(address(this)) appIsActive(app) { + // Protocol admin may not unilaterally terminate a Timelock-owned app; + // doing so would bypass the delay the user specifically opted into. + // If intervention is required, protocol admin should schedule the + // termination through the Timelock itself. + require(!_appConfigs[app].timelocked, InvalidPermissions()); _terminateApp(app); emit AppTerminatedByAdmin(app); } @@ -203,6 +222,17 @@ contract AppController is Initializable, SignatureUtilsMixin, PermissionControll _appConfigs[app].latestReleaseBlockNumber = 0; _appConfigs[app].creator = msg.sender; _appConfigs[app].billingType = _billingType; + // If the creator is a factory-deployed Timelock, mark the app + // timelocked at creation so sensitive-op gates fire immediately. + // Not doing this here leaves a window where any co-admin could run + // upgradeApp / terminateApp before ownership is "transferred" — and + // in fact createApp never involves transferOwnership at all. + // safeTimelockFactory may be address(0) on historical deployments + // (pre-v1.5.0); in that case isTimelock is guaranteed to return false + // via the interface contract, but we also defensively short-circuit. + if (address(safeTimelockFactory) != address(0)) { + _appConfigs[app].timelocked = safeTimelockFactory.isTimelock(msg.sender); + } _allApps.add(address(app)); emit AppCreated(msg.sender, app, operatorSetId); @@ -474,6 +504,11 @@ contract AppController is Initializable, SignatureUtilsMixin, PermissionControll return _appConfigs[app].billingType; } + /// @inheritdoc IAppController + function getAppTimelocked(IApp app) external view returns (bool) { + return _appConfigs[app].timelocked; + } + /// @inheritdoc IAppController function getAppOperatorSetId(IApp app) external view returns (uint32) { return _appConfigs[app].operatorSetId; diff --git a/src/interfaces/IAppController.sol b/src/interfaces/IAppController.sol index 6e83395..a86d0b2 100644 --- a/src/interfaces/IAppController.sol +++ b/src/interfaces/IAppController.sol @@ -286,6 +286,15 @@ interface IAppController { */ function getBillingType(IApp app) external view returns (BillingType); + /** + * @notice Returns whether the app's creator is a factory Timelock. + * @param app The app to check + * @return True iff sensitive ops (upgrade/terminate) must go through + * Timelock.schedule → execute — i.e. direct calls by any non-owner + * are rejected regardless of PermissionController grants. + */ + function getAppTimelocked(IApp app) external view returns (bool); + /** * @notice Gets the operator set ID for a given app * @param app The app to get the operator set ID for From a7a52f52ebc67f02bdfe5d4e09c23170e4f98e7d Mon Sep 17 00:00:00 2001 From: Taras Shchybovyk Date: Thu, 23 Apr 2026 10:57:29 -0700 Subject: [PATCH 04/17] feat: wire SafeTimelockFactory into fresh-deploy path + regression tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Deploy.s.sol now deploys TimelockControllerImpl and SafeTimelockFactory (impl + proxy) before the AppController and threads the factory proxy into the AppController constructor. Safe infrastructure addresses are left as zero — tests don't use deploySafe, and production releases will wire them via a dedicated release script. - Five regression tests covering the timelocked gate on createApp, upgradeApp, terminateApp, and terminateAppByAdmin. Each tests mocks isTimelock(developer)=true, grants a PermissionController admin to a coAdmin, then confirms the coAdmin is blocked from the sensitive path while the Timelock-as-creator is not. Closes the bypass path that the audit flagged as V-1 / G-1 / A-1. --- script/Deploy.s.sol | 26 ++++++++-- test/AppController.t.sol | 101 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+), 5 deletions(-) diff --git a/script/Deploy.s.sol b/script/Deploy.s.sol index b744207..b340825 100644 --- a/script/Deploy.s.sol +++ b/script/Deploy.s.sol @@ -25,6 +25,8 @@ import {ComputeOperator} from "../src/ComputeOperator.sol"; import {ImageAllowlist} from "../src/ImageAllowlist.sol"; import {IImageAllowlist} from "../src/interfaces/IImageAllowlist.sol"; import {ISafeTimelockFactory} from "../src/interfaces/ISafeTimelockFactory.sol"; +import {SafeTimelockFactory} from "../src/factories/SafeTimelockFactory.sol"; +import {TimelockControllerImpl} from "../src/governance/TimelockControllerImpl.sol"; contract Deploy is Parser { struct Proxies { @@ -91,6 +93,24 @@ contract Deploy is Parser { UpgradeableBeacon appBeacon = new UpgradeableBeacon(address(new App(params.version, IPermissionController(params.permissionController)))); + // Deploy SafeTimelockFactory (needed by AppController for governance + // detection). Safe infrastructure addresses (singleton / proxy factory + // / fallback handler) are left as zero here; tests and local deploys + // don't exercise deploySafe. Production releases use a dedicated + // release script that wires real Safe addresses. + TimelockControllerImpl timelockImpl = new TimelockControllerImpl(); + SafeTimelockFactory safeTimelockFactoryImpl = new SafeTimelockFactory({ + _safeSingleton: address(0), + _safeProxyFactory: address(0), + _safeFallbackHandler: address(0), + _timelockImplementation: address(timelockImpl) + }); + TransparentUpgradeableProxy safeTimelockFactoryProxy = new TransparentUpgradeableProxy( + address(safeTimelockFactoryImpl), + address(params.proxyAdmin), + abi.encodeCall(SafeTimelockFactory.initialize, ()) + ); + // Deploy implementation contracts Implementations memory impls = Implementations({ app: App(appBeacon.implementation()), @@ -116,11 +136,7 @@ contract Deploy is Parser { _computeAVSRegistrar: IComputeAVSRegistrar(address(proxies.computeAVSRegistrar)), _computeOperator: IComputeOperator(address(proxies.computeOperator)), _appBeacon: appBeacon, - // TODO(v1.5.0-governance): deploy SafeTimelockFactory before - // AppController and pass the factory address here. Fresh-deploy - // path only — production v1.5.0 upgrade uses a dedicated release - // script that deploys the factory in phase 1. - _safeTimelockFactory: ISafeTimelockFactory(address(0)) + _safeTimelockFactory: ISafeTimelockFactory(address(safeTimelockFactoryProxy)) }), imageAllowlist: new ImageAllowlist() }); diff --git a/test/AppController.t.sol b/test/AppController.t.sol index 94a36b2..eea8e04 100644 --- a/test/AppController.t.sol +++ b/test/AppController.t.sol @@ -2,6 +2,7 @@ pragma solidity ^0.8.27; import {IAppController} from "../src/interfaces/IAppController.sol"; +import {AppController} from "../src/AppController.sol"; import {ComputeDeployer} from "./utils/ComputeDeployer.sol"; import {IApp} from "../src/interfaces/IApp.sol"; import {PermissionControllerMixin} from "@eigenlayer-contracts/src/contracts/mixins/PermissionControllerMixin.sol"; @@ -1348,4 +1349,104 @@ contract AppControllerTest is ComputeDeployer { assertEq(appController.getActiveAppCount(developer), 0); assertEq(uint256(appController.getAppStatus(app)), uint256(IAppController.AppStatus.TERMINATED)); } + + // ========== Timelocked-gate regression tests ========== + // + // These tests pin down the runtime invariant that sensitive ops against a + // Timelock-owned app must go through schedule → execute. The gate lives + // in upgradeApp / terminateApp / terminateAppByAdmin and fires whenever + // `_appConfigs[app].timelocked == true`, which is set at creation if + // msg.sender is a factory-registered Timelock. + // + // We mock `isTimelock(developer)` to true so createApp flips the flag, + // then assert that a PermissionController-permitted co-admin is blocked + // from calling the sensitive op directly. + + function _mockIsTimelock(address account) internal { + address factory = address(AppController(address(appController)).safeTimelockFactory()); + vm.mockCall(factory, abi.encodeWithSignature("isTimelock(address)", account), abi.encode(true)); + } + + function test_createApp_flagsTimelockedWhenCallerIsTimelock() public { + _mockIsTimelock(developer); + + vm.prank(developer); + IApp app = appController.createApp(SALT, _assembleRelease()); + + assertTrue(appController.getAppTimelocked(app), "Timelock-created app must have timelocked=true at creation"); + } + + function test_createApp_doesNotFlagTimelockedForNonTimelockCaller() public { + // No mock: factory returns false for random addresses. Regression + // guard that the fix doesn't over-apply the flag. + vm.prank(developer); + IApp app = appController.createApp(SALT, _assembleRelease()); + + assertFalse(appController.getAppTimelocked(app), "EOA-created app must not be flagged timelocked"); + } + + function test_upgradeApp_timelockedBlocksNonOwnerEvenWithPermission() public { + _mockIsTimelock(developer); + vm.prank(developer); + IApp app = appController.createApp(SALT, _assembleRelease()); + require(appController.getAppTimelocked(app), "precondition: app must be timelocked"); + + // Grant a PermissionController admin to another address — the exact + // path a compromised or cooperating admin would use to upgrade + // instantly before the fix. + // developer (app creator) becomes admin, then promotes a co-admin. + vm.prank(developer); + permissionController.acceptAdmin(address(app)); + address coAdmin = makeAddr("coAdmin"); + vm.prank(developer); + permissionController.addPendingAdmin(address(app), coAdmin); + vm.prank(coAdmin); + permissionController.acceptAdmin(address(app)); + + // Direct upgrade from the co-admin MUST revert. + vm.prank(coAdmin); + vm.expectRevert(PermissionControllerMixin.InvalidPermissions.selector); + appController.upgradeApp(app, _assembleRelease()); + + // The Timelock itself (app.creator) can still call upgrade directly — + // representing the path where a scheduled op is being executed. + vm.prank(developer); + appController.upgradeApp(app, _assembleRelease()); + } + + function test_terminateApp_timelockedBlocksNonOwner() public { + _mockIsTimelock(developer); + vm.prank(developer); + IApp app = appController.createApp(SALT, _assembleRelease()); + + // developer (app creator) becomes admin, then promotes a co-admin. + vm.prank(developer); + permissionController.acceptAdmin(address(app)); + address coAdmin = makeAddr("coAdmin"); + vm.prank(developer); + permissionController.addPendingAdmin(address(app), coAdmin); + vm.prank(coAdmin); + permissionController.acceptAdmin(address(app)); + + vm.prank(coAdmin); + vm.expectRevert(PermissionControllerMixin.InvalidPermissions.selector); + appController.terminateApp(app); + + // Timelock (creator) can terminate. + vm.prank(developer); + appController.terminateApp(app); + assertEq(uint256(appController.getAppStatus(app)), uint256(IAppController.AppStatus.TERMINATED)); + } + + function test_terminateAppByAdmin_refusesTimelockedApp() public { + _mockIsTimelock(developer); + vm.prank(developer); + IApp app = appController.createApp(SALT, _assembleRelease()); + + // Even the protocol admin cannot terminate a timelocked app directly — + // they must go through the Timelock to preserve the delay invariant. + vm.prank(admin); + vm.expectRevert(PermissionControllerMixin.InvalidPermissions.selector); + appController.terminateAppByAdmin(app); + } } From 22ecea347f1f6abb45d9e862e9b8e1d31198fb9b Mon Sep 17 00:00:00 2001 From: Taras Shchybovyk Date: Thu, 23 Apr 2026 11:01:16 -0700 Subject: [PATCH 05/17] feat: transferOwnership with timelocked-flag handoff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an app's owner is handed off to a new address, the timelocked flag must update atomically. Before this commit, apps could only acquire the flag via the createApp path, so there was no way to opt an already-running app into delayed governance — and no way back out. - New transferOwnership(IApp, address). Gated by checkCanCall like other sensitive ops. When the app is already timelocked the caller must be the Timelock itself (msg.sender == creator), otherwise any admin granted via UAM could move the app out of governance without going through schedule → execute. - New owner that is a factory-registered Timelock sets timelocked=true; any other address (EOA, externally deployed Safe, arbitrary contract) clears it. - Active-app counter migration only happens for BillingType.DEFAULT apps. ISOLATED apps bill the app address, which doesn't change on transfer, so the counter must NOT move or a future terminate would double-decrement. - New AppOwnershipTransferred event. Six regression tests covering both directions of the flag flip, the timelocked-gate, zero-address rejection, and the two billing accounting branches. --- src/AppController.sol | 35 +++++++++ src/interfaces/IAppController.sol | 18 +++++ test/AppController.t.sol | 121 ++++++++++++++++++++++++++++++ 3 files changed, 174 insertions(+) diff --git a/src/AppController.sol b/src/AppController.sol index cd50acd..27cd764 100644 --- a/src/AppController.sol +++ b/src/AppController.sol @@ -129,6 +129,41 @@ contract AppController is Initializable, SignatureUtilsMixin, PermissionControll return _upgradeApp(app, release); } + /// @inheritdoc IAppController + function transferOwnership(IApp app, address newOwner) external checkCanCall(address(app)) appExists(app) { + require(newOwner != address(0), InvalidPermissions()); + + AppConfigStorage storage config = _appConfigs[app]; + + // When already timelocked, only the Timelock owner itself may move the + // app. Otherwise any admin the Timelock had granted via UAM could + // transfer out of its governance entirely, bypassing the queue delay. + if (config.timelocked) { + require(msg.sender == config.creator, InvalidPermissions()); + } + + address previousOwner = config.creator; + config.creator = newOwner; + + // Flip the flag based on the new owner. Non-factory addresses (EOAs, + // externally-deployed Safes, arbitrary contracts) clear it — they have + // no schedule→execute semantics we can trust. Factory-deployed + // Timelocks enable it. + config.timelocked = address(safeTimelockFactory) != address(0) && safeTimelockFactory.isTimelock(newOwner); + + // ISOLATED billing apps bill the app address, not the creator, so + // ownership transfer has no effect on billing accounting. DEFAULT + // billing apps bill the creator, so we need to move the active-app + // counter from the previous creator to the new one; otherwise a future + // terminate/suspend would underflow the new owner's counter. + if (config.billingType == BillingType.DEFAULT && _isActive(config.status)) { + _userConfigs[previousOwner].activeAppCount--; + _userConfigs[newOwner].activeAppCount++; + } + + emit AppOwnershipTransferred(app, previousOwner, newOwner); + } + /// @inheritdoc IAppController function updateAppMetadataURI(IApp app, string calldata metadataURI) external diff --git a/src/interfaces/IAppController.sol b/src/interfaces/IAppController.sol index a86d0b2..c40397d 100644 --- a/src/interfaces/IAppController.sol +++ b/src/interfaces/IAppController.sol @@ -59,6 +59,9 @@ interface IAppController { /// @notice Emitted when an app's metadata URI is updated event AppMetadataURIUpdated(IApp indexed app, string metadataURI); + /// @notice Emitted when app ownership is transferred to a new address + event AppOwnershipTransferred(IApp indexed app, address indexed previousOwner, address indexed newOwner); + /// @notice Enum for app status enum AppStatus { NONE, // App has not been created yet @@ -174,6 +177,21 @@ interface IAppController { */ function upgradeApp(IApp app, Release calldata release) external returns (uint256); + /** + * @notice Transfers app ownership to a new address. + * @param app The app to transfer ownership of + * @param newOwner The new owner address + * @dev Caller must be UAM permissioned for the app. + * @dev When `newOwner` is a factory-deployed Timelock the app's `timelocked` + * flag is flipped to true, causing subsequent sensitive ops to require + * msg.sender == owner (i.e. go through schedule → execute). + * When `newOwner` is not a factory Timelock (EOA, Safe, non-factory + * contract) the flag is cleared. + * @dev When the app is already timelocked, only the current Timelock owner + * itself may call — any other admin would bypass the queue delay. + */ + function transferOwnership(IApp app, address newOwner) external; + /** * @notice Updates the metadata URI for an app * @param app The app to update the metadata URI for diff --git a/test/AppController.t.sol b/test/AppController.t.sol index eea8e04..0c7432a 100644 --- a/test/AppController.t.sol +++ b/test/AppController.t.sol @@ -1449,4 +1449,125 @@ contract AppControllerTest is ComputeDeployer { vm.expectRevert(PermissionControllerMixin.InvalidPermissions.selector); appController.terminateAppByAdmin(app); } + + // ========== transferOwnership ========== + + event AppOwnershipTransferred(IApp indexed app, address indexed previousOwner, address indexed newOwner); + + function test_transferOwnership_toEOA_clearsTimelocked() public { + // Start with a Timelock-owned app. + _mockIsTimelock(developer); + vm.prank(developer); + IApp app = appController.createApp(SALT, _assembleRelease()); + vm.prank(developer); + permissionController.acceptAdmin(address(app)); + assertTrue(appController.getAppTimelocked(app)); + + address plainEOA = makeAddr("plainEOA"); + + vm.expectEmit(true, true, true, true); + emit AppOwnershipTransferred(app, developer, plainEOA); + + vm.prank(developer); + appController.transferOwnership(app, plainEOA); + + assertFalse(appController.getAppTimelocked(app), "timelocked must clear when new owner is not a Timelock"); + assertEq(appController.getAppCreator(app), plainEOA); + } + + function test_transferOwnership_toTimelock_setsTimelocked() public { + // Non-timelocked starting state. + vm.prank(developer); + IApp app = appController.createApp(SALT, _assembleRelease()); + vm.prank(developer); + permissionController.acceptAdmin(address(app)); + assertFalse(appController.getAppTimelocked(app)); + + address newTimelock = makeAddr("newTimelock"); + _mockIsTimelock(newTimelock); + + vm.prank(developer); + appController.transferOwnership(app, newTimelock); + + assertTrue(appController.getAppTimelocked(app), "timelocked must set when new owner is a Timelock"); + assertEq(appController.getAppCreator(app), newTimelock); + } + + function test_transferOwnership_timelockedBlocksNonOwner() public { + _mockIsTimelock(developer); + vm.prank(developer); + IApp app = appController.createApp(SALT, _assembleRelease()); + + vm.prank(developer); + permissionController.acceptAdmin(address(app)); + address coAdmin = makeAddr("coAdmin"); + vm.prank(developer); + permissionController.addPendingAdmin(address(app), coAdmin); + vm.prank(coAdmin); + permissionController.acceptAdmin(address(app)); + + address attacker = makeAddr("attacker"); + vm.prank(coAdmin); + vm.expectRevert(PermissionControllerMixin.InvalidPermissions.selector); + appController.transferOwnership(app, attacker); + + // Timelock itself can still transfer. + vm.prank(developer); + appController.transferOwnership(app, attacker); + assertEq(appController.getAppCreator(app), attacker); + } + + function test_transferOwnership_revertsZeroAddress() public { + vm.prank(developer); + IApp app = appController.createApp(SALT, _assembleRelease()); + vm.prank(developer); + permissionController.acceptAdmin(address(app)); + + vm.prank(developer); + vm.expectRevert(PermissionControllerMixin.InvalidPermissions.selector); + appController.transferOwnership(app, address(0)); + } + + function test_transferOwnership_defaultBilling_movesActiveCounter() public { + // Default billing: creator is the billing account. Counter must move + // so future terminate/suspend on the new owner doesn't underflow. + vm.prank(developer); + IApp app = appController.createApp(SALT, _assembleRelease()); + vm.prank(developer); + permissionController.acceptAdmin(address(app)); + + address newOwner = makeAddr("newOwner"); + _setMaxActiveAppsPerUser(newOwner, 10); + + uint32 beforeDev = appController.getActiveAppCount(developer); + uint32 beforeNew = appController.getActiveAppCount(newOwner); + + vm.prank(developer); + appController.transferOwnership(app, newOwner); + + assertEq(appController.getActiveAppCount(developer), beforeDev - 1); + assertEq(appController.getActiveAppCount(newOwner), beforeNew + 1); + } + + function test_transferOwnership_isolatedBilling_doesNotMoveCounter() public { + // ISOLATED billing apps bill the app address, so the creator's + // active-app counter was never incremented and must not move on transfer. + _setMaxActiveAppsPerUser(address(appController.calculateAppId(developer, SALT)), 10); + + vm.prank(developer); + IApp app = appController.createAppWithIsolatedBilling(SALT, _assembleRelease()); + vm.prank(developer); + permissionController.acceptAdmin(address(app)); + + uint32 beforeApp = appController.getActiveAppCount(address(app)); + uint32 beforeDev = appController.getActiveAppCount(developer); + + address newOwner = makeAddr("newOwner"); + vm.prank(developer); + appController.transferOwnership(app, newOwner); + + assertEq(appController.getActiveAppCount(address(app)), beforeApp); + assertEq(appController.getActiveAppCount(developer), beforeDev); + assertEq(appController.getActiveAppCount(newOwner), 0); + } } From 104a290982790557760e6e1cefd3bf4482c8dada Mon Sep 17 00:00:00 2001 From: Taras Shchybovyk Date: Thu, 23 Apr 2026 11:09:42 -0700 Subject: [PATCH 06/17] feat: hardened ICallValidator integration (AppController + Timelock) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ICallValidator now inherits IERC165. Targets MUST advertise the interface via supportsInterface for their canCall to be consulted; a target that reverts canCall without advertising is treated as non-implementing (backwards compatible). A target that advertises and reverts is treated as a definitive "no" and the schedule is rejected — closing the fail-open bypass the audit called out (G-3 / V-4 / D-2 / A-7 / P-9). - AppController implements ICallValidator + supportsInterface. canCall rejects at schedule time any operation that deterministically fails the runtime gate: non-owner upgradeApp/terminateApp/transferOwnership on a timelocked app, or terminateAppByAdmin against any timelocked app. All other calls pass through to runtime (PermissionController stays authoritative for non-governance auth). - TimelockControllerImpl._validateTarget now: 1. Short-circuits for EOAs / zero-code addresses. 2. Probes ERC-165 supportsInterface with a 30k gas cap. 3. Only calls canCall if the target claims support, capped at 200k gas with bounded returndata (32 bytes — ignores returndata bombs). 4. Treats canCall revert as rejection (fail closed) rather than silent pass-through. Tests: - 5 new AppController.t.sol cases for canCall + supportsInterface. - 8 new TimelockControllerImplValidation.t.sol cases with mock targets: no-ERC165, ERC165-says-no, allow, reject, revert, OOG, returndata-bomb, EOA fast path. Full suite: 141 tests pass. --- src/AppController.sol | 55 ++++++- src/governance/TimelockControllerImpl.sol | 68 +++++++- src/interfaces/ICallValidator.sol | 14 +- test/AppController.t.sol | 57 +++++++ test/TimelockControllerImplValidation.t.sol | 167 ++++++++++++++++++++ 5 files changed, 351 insertions(+), 10 deletions(-) create mode 100644 test/TimelockControllerImplValidation.t.sol diff --git a/src/AppController.sol b/src/AppController.sol index 27cd764..9099cba 100644 --- a/src/AppController.sol +++ b/src/AppController.sol @@ -21,8 +21,16 @@ import {BeaconProxy} from "@openzeppelin/contracts/proxy/beacon/BeaconProxy.sol" import {IBeacon} from "@openzeppelin/contracts/proxy/beacon/IBeacon.sol"; import {IApp} from "./interfaces/IApp.sol"; import {ISafeTimelockFactory} from "./interfaces/ISafeTimelockFactory.sol"; - -contract AppController is Initializable, SignatureUtilsMixin, PermissionControllerMixin, AppControllerStorage { +import {ICallValidator} from "./interfaces/ICallValidator.sol"; +import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; + +contract AppController is + Initializable, + SignatureUtilsMixin, + PermissionControllerMixin, + AppControllerStorage, + ICallValidator +{ using EnumerableSet for EnumerableSet.AddressSet; /// MODIFIERS @@ -544,6 +552,49 @@ contract AppController is Initializable, SignatureUtilsMixin, PermissionControll return _appConfigs[app].timelocked; } + /// @inheritdoc IERC165 + function supportsInterface(bytes4 interfaceId) external pure returns (bool) { + return interfaceId == type(ICallValidator).interfaceId || interfaceId == type(IERC165).interfaceId; + } + + /// @inheritdoc ICallValidator + /// @dev Schedule-time validation hook consumed by TimelockControllerImpl. + /// Rejects operations we can statically prove will revert at execute + /// time given the current on-chain state. For any call we can't + /// reason about here, returns true and lets PermissionController + /// enforce at runtime — conservative: schedule-time rejection must + /// be a superset of nothing, never of authorized paths. + function canCall(address caller, bytes calldata data) external view returns (bool) { + if (data.length < 4) return true; + bytes4 selector = bytes4(data[:4]); + + // For the sensitive ops gated by `if (timelocked) msg.sender == creator`, + // a schedule proposed by a non-owner Timelock will always revert. We + // block it at schedule time so the delay window isn't consumed by a + // doomed op. + if ( + selector == this.upgradeApp.selector || selector == this.terminateApp.selector + || selector == this.transferOwnership.selector + ) { + // Decode the first arg (IApp) and check ownership if timelocked. + // abi.decode skips the 4-byte selector. + IApp app = abi.decode(data[4:36], (IApp)); + AppConfigStorage storage config = _appConfigs[app]; + if (config.timelocked && caller != config.creator) { + return false; + } + } + + // terminateAppByAdmin now refuses timelocked apps unconditionally; a + // scheduled terminateAppByAdmin against a timelocked app is doomed. + if (selector == this.terminateAppByAdmin.selector) { + IApp app = abi.decode(data[4:36], (IApp)); + if (_appConfigs[app].timelocked) return false; + } + + return true; + } + /// @inheritdoc IAppController function getAppOperatorSetId(IApp app) external view returns (uint32) { return _appConfigs[app].operatorSetId; diff --git a/src/governance/TimelockControllerImpl.sol b/src/governance/TimelockControllerImpl.sol index 8a139a9..4decfc9 100644 --- a/src/governance/TimelockControllerImpl.sol +++ b/src/governance/TimelockControllerImpl.sol @@ -5,6 +5,7 @@ import { TimelockControllerUpgradeable } from "@openzeppelin-upgrades/contracts/governance/TimelockControllerUpgradeable.sol"; import {ICallValidator} from "../interfaces/ICallValidator.sol"; +import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; /** * @title TimelockControllerImpl @@ -133,16 +134,69 @@ contract TimelockControllerImpl is TimelockControllerUpgradeable { // ── Internal helpers ───────────────────────────────────────────────────── + /// Gas cap for the ERC-165 probe. A compliant target needs ~2k gas; + /// 30k is generous while still bounding returndata-bomb griefing. + uint256 private constant ERC165_PROBE_GAS = 30_000; + + /// Gas cap for the `canCall` query. Validation is view-only logic + /// (storage reads, comparisons) so 200k is plenty; any implementation + /// that needs more is almost certainly misbehaving. + uint256 private constant CANCALL_QUERY_GAS = 200_000; + + /// interfaceId for ICallValidator, computed inline to avoid importing + /// ERC-165's library just to read one constant. + bytes4 private constant I_CALL_VALIDATOR_INTERFACE_ID = type(ICallValidator).interfaceId; + /** - * @dev If `target` implements ICallValidator, ask whether this Timelock may execute `data`. - * Targets that do not implement the interface are allowed through (backwards compatible). + * @dev Schedule-time target validation. + * + * A target that advertises {ICallValidator} via ERC-165 gets its + * `canCall(this, data)` consulted; if it returns false the schedule + * reverts. Targets that don't advertise the interface are allowed + * through (backwards compatible with any external contract that the + * system might Timelock in the future). + * + * Hardening choices addressed here: + * - ERC-165 first, so a target that reverts for any non-authorization + * reason (including OOG) is NOT silently treated as "not implemented". + * Only a target that explicitly returns `false` from `supportsInterface` + * or doesn't respond to the probe is skipped. + * - Bounded gas on both probes to stop a malicious target from + * exhausting the outer call via runaway `canCall` computation. + * - Bounded returndata (only the first 32 bytes are considered) to + * prevent returndata-bomb griefing; excess is ignored. + * - When `supportsInterface` says yes but `canCall` reverts, that is + * treated as a definitive "no" — the target made a claim it couldn't + * back up, so its pending op is rejected rather than silently allowed. */ function _validateTarget(address target, bytes calldata data) private view { - try ICallValidator(target).canCall(address(this), data) returns (bool allowed) { - require(allowed, "TimelockController: target rejected the call"); - } catch { - // Target does not implement canCall — allow - } + // Only contracts can implement ICallValidator; EOAs and zero-code + // addresses short-circuit without any external calls. + if (target.code.length == 0) return; + + if (!_supportsCallValidator(target)) return; + + // Target claims to implement the interface; ask it. + bytes memory canCallCalldata = abi.encodeWithSelector(ICallValidator.canCall.selector, address(this), data); + (bool ok, bytes memory ret) = target.staticcall{gas: CANCALL_QUERY_GAS}(canCallCalldata); + + // A revert here means the target advertised ICallValidator but its + // implementation failed. Fail closed — do not let it through as if + // the interface was absent. + require(ok && ret.length >= 32, "TimelockController: canCall reverted"); + + bool allowed = abi.decode(ret, (bool)); + require(allowed, "TimelockController: target rejected the call"); + } + + /// @dev Probe `target` for ICallValidator support via ERC-165. + /// Returns false (allow through, treat as non-implementing) on any + /// failure or non-bool return; returns true only on an explicit `true`. + function _supportsCallValidator(address target) private view returns (bool) { + bytes memory probe = abi.encodeWithSelector(IERC165.supportsInterface.selector, I_CALL_VALIDATOR_INTERFACE_ID); + (bool ok, bytes memory ret) = target.staticcall{gas: ERC165_PROBE_GAS}(probe); + if (!ok || ret.length < 32) return false; + return abi.decode(ret, (bool)); } function _addPending(bytes32 id, address target, bytes memory data, uint256 executableAt) private { diff --git a/src/interfaces/ICallValidator.sol b/src/interfaces/ICallValidator.sol index 8625e2f..f3716d0 100644 --- a/src/interfaces/ICallValidator.sol +++ b/src/interfaces/ICallValidator.sol @@ -1,12 +1,24 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.27; +import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; + /** * @title ICallValidator * @notice Optional interface that Timelock targets can implement to reject * operations at schedule-time rather than waiting until execute-time. + * + * @dev Targets MUST opt in via ERC-165 `supportsInterface` so that a reverting + * `canCall` is never silently interpreted as "not implemented": + * + * supportsInterface(type(ICallValidator).interfaceId) == true + * + * If a target advertises the interface and `canCall` reverts, the caller + * should fail the request — not fall through as if the interface was + * absent. See `TimelockControllerImpl._validateTarget` for the enforcement + * point. */ -interface ICallValidator { +interface ICallValidator is IERC165 { /** * @notice Returns true if `caller` is authorized to execute `data` on this contract. * @param caller The address that will ultimately call (e.g., the Timelock). diff --git a/test/AppController.t.sol b/test/AppController.t.sol index 0c7432a..c198e17 100644 --- a/test/AppController.t.sol +++ b/test/AppController.t.sol @@ -7,6 +7,8 @@ import {ComputeDeployer} from "./utils/ComputeDeployer.sol"; import {IApp} from "../src/interfaces/IApp.sol"; import {PermissionControllerMixin} from "@eigenlayer-contracts/src/contracts/mixins/PermissionControllerMixin.sol"; import {IReleaseManagerTypes} from "@eigenlayer-contracts/src/contracts/interfaces/IReleaseManager.sol"; +import {ICallValidator} from "../src/interfaces/ICallValidator.sol"; +import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; contract AppControllerTest is ComputeDeployer { bytes32 public constant SALT = keccak256("test_salt"); @@ -1570,4 +1572,59 @@ contract AppControllerTest is ComputeDeployer { assertEq(appController.getActiveAppCount(developer), beforeDev); assertEq(appController.getActiveAppCount(newOwner), 0); } + + // ========== ICallValidator / canCall ========== + + function test_supportsInterface_advertisesCallValidator() public { + AppController ac = AppController(address(appController)); + bytes4 callValidatorId = type(ICallValidator).interfaceId; + bytes4 erc165Id = type(IERC165).interfaceId; + + assertTrue(ac.supportsInterface(callValidatorId), "AppController must advertise ICallValidator"); + assertTrue(ac.supportsInterface(erc165Id), "AppController must advertise IERC165"); + assertFalse(ac.supportsInterface(0xdeadbeef), "Unrelated interface must return false"); + } + + function test_canCall_returnsTrueForShortCalldata() public view { + // Fallback: anything under 4 bytes can't be a meaningful call; defer. + assertTrue(ICallValidator(address(appController)).canCall(address(this), "")); + assertTrue(ICallValidator(address(appController)).canCall(address(this), hex"00")); + } + + function test_canCall_rejectsNonTimelockCallerOnTimelockedUpgradeApp() public { + _mockIsTimelock(developer); + vm.prank(developer); + IApp app = appController.createApp(SALT, _assembleRelease()); + + bytes memory callData = abi.encodeWithSelector(IAppController.upgradeApp.selector, app, _assembleRelease()); + address notOwner = makeAddr("notOwner"); + + assertFalse( + ICallValidator(address(appController)).canCall(notOwner, callData), + "canCall must reject non-owner schedule of timelocked upgradeApp" + ); + assertTrue( + ICallValidator(address(appController)).canCall(developer, callData), + "canCall must accept owner (Timelock itself) schedule of timelocked upgradeApp" + ); + } + + function test_canCall_doesNotRejectNonTimelockedUpgradeApp() public { + // App is not timelocked: canCall defers to runtime (returns true). + vm.prank(developer); + IApp app = appController.createApp(SALT, _assembleRelease()); + bytes memory callData = abi.encodeWithSelector(IAppController.upgradeApp.selector, app, _assembleRelease()); + assertTrue(ICallValidator(address(appController)).canCall(makeAddr("anyone"), callData)); + } + + function test_canCall_rejectsTerminateAppByAdminOnTimelockedApp() public { + _mockIsTimelock(developer); + vm.prank(developer); + IApp app = appController.createApp(SALT, _assembleRelease()); + + bytes memory callData = abi.encodeWithSelector(IAppController.terminateAppByAdmin.selector, app); + // terminateAppByAdmin against a timelocked app is unconditionally doomed + // regardless of caller — canCall reflects that. + assertFalse(ICallValidator(address(appController)).canCall(admin, callData)); + } } diff --git a/test/TimelockControllerImplValidation.t.sol b/test/TimelockControllerImplValidation.t.sol new file mode 100644 index 0000000..9b931b9 --- /dev/null +++ b/test/TimelockControllerImplValidation.t.sol @@ -0,0 +1,167 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.27; + +import {Test} from "forge-std/Test.sol"; +import {TimelockControllerImpl} from "../src/governance/TimelockControllerImpl.sol"; +import {ICallValidator} from "../src/interfaces/ICallValidator.sol"; +import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; +import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol"; + +/// @dev Behaviors exercised here come from TimelockControllerImpl._validateTarget. +/// We spin up a real TimelockControllerImpl, grant the deployer PROPOSER_ROLE, +/// and call schedule() against various mock targets that exhibit specific +/// misbehaviors (reverts, returndata-bombs, OOG in canCall, honest rejects). +contract TimelockControllerImplValidationTest is Test { + TimelockControllerImpl internal timelock; + address internal proposer = address(0xA11CE); + + function setUp() public { + TimelockControllerImpl impl = new TimelockControllerImpl(); + // Impl has initializers disabled; deploy via clone to call initialize. + timelock = TimelockControllerImpl(payable(Clones.clone(address(impl)))); + + address[] memory proposers = new address[](1); + proposers[0] = proposer; + address[] memory executors = new address[](1); + executors[0] = proposer; + timelock.initialize(60, proposers, executors, address(0)); + } + + function _schedule(address target, bytes memory data) internal { + vm.prank(proposer); + timelock.schedule(target, 0, data, bytes32(0), bytes32(0), 60); + } + + /// --- Target that does NOT advertise ICallValidator at all --- + + function test_schedule_targetWithoutERC165_isAllowedThrough() public { + InertContract inert = new InertContract(); + _schedule(address(inert), hex"12345678"); + } + + function test_schedule_EOA_isAllowedThrough() public { + // Zero-code address fast path. + _schedule(makeAddr("eoa"), hex"12345678"); + } + + /// --- Target that returns false from supportsInterface --- + + function test_schedule_targetClaimsNoInterface_isAllowedThrough() public { + ERC165SaysNo liar = new ERC165SaysNo(); + _schedule(address(liar), hex"12345678"); + } + + /// --- Target that advertises ICallValidator --- + + function test_schedule_validatorAllows_schedulesSuccessfully() public { + AllowingValidator v = new AllowingValidator(); + _schedule(address(v), hex"12345678"); + } + + function test_schedule_validatorRejects_reverts() public { + RejectingValidator v = new RejectingValidator(); + vm.expectRevert(bytes("TimelockController: target rejected the call")); + _schedule(address(v), hex"12345678"); + } + + function test_schedule_validatorReverts_failsClosed() public { + // Advertises the interface, but canCall reverts. MUST reject rather than + // silently pass through as "no interface" — that was the pre-hardening + // bug the audit called out (G-3 / V-4 / D-2 / P-9). + RevertingValidator v = new RevertingValidator(); + vm.expectRevert(bytes("TimelockController: canCall reverted")); + _schedule(address(v), hex"12345678"); + } + + function test_schedule_validatorOOG_failsClosed() public { + // Validator runs an infinite loop; the bounded gas on the staticcall + // cuts it off and the scheduling call fails with "canCall reverted". + InfiniteLoopValidator v = new InfiniteLoopValidator(); + vm.expectRevert(bytes("TimelockController: canCall reverted")); + _schedule(address(v), hex"12345678"); + } + + function test_schedule_validatorReturndataBomb_failsClosed() public { + // Validator returns a huge blob. The hardened validator reads only + // the first 32 bytes; abi.decode(bool) succeeds with whatever the + // first 32 bytes encode (here: 1 → allowed), so this should succeed + // without OOGing the outer call. + ReturndataBombValidator v = new ReturndataBombValidator(); + _schedule(address(v), hex"12345678"); + } +} + +/// --- Mock targets --- + +contract InertContract { + // No ERC-165 — calls to supportsInterface revert with no reason. + // Used to verify fast-path for non-validator targets. + + } + +contract ERC165SaysNo is IERC165 { + function supportsInterface(bytes4) external pure returns (bool) { + return false; + } +} + +contract AllowingValidator is ICallValidator { + function supportsInterface(bytes4 interfaceId) external pure returns (bool) { + return interfaceId == type(ICallValidator).interfaceId || interfaceId == type(IERC165).interfaceId; + } + + function canCall(address, bytes calldata) external pure returns (bool) { + return true; + } +} + +contract RejectingValidator is ICallValidator { + function supportsInterface(bytes4 interfaceId) external pure returns (bool) { + return interfaceId == type(ICallValidator).interfaceId || interfaceId == type(IERC165).interfaceId; + } + + function canCall(address, bytes calldata) external pure returns (bool) { + return false; + } +} + +contract RevertingValidator is ICallValidator { + function supportsInterface(bytes4 interfaceId) external pure returns (bool) { + return interfaceId == type(ICallValidator).interfaceId || interfaceId == type(IERC165).interfaceId; + } + + function canCall(address, bytes calldata) external pure returns (bool) { + revert("no"); + } +} + +contract InfiniteLoopValidator is ICallValidator { + function supportsInterface(bytes4 interfaceId) external pure returns (bool) { + return interfaceId == type(ICallValidator).interfaceId || interfaceId == type(IERC165).interfaceId; + } + + function canCall(address, bytes calldata) external view returns (bool) { + // staticcall so state writes would revert; burn gas in a tight loop. + uint256 i; + while (true) { + i++; + } + return true; // unreachable + } +} + +contract ReturndataBombValidator is ICallValidator { + function supportsInterface(bytes4 interfaceId) external pure returns (bool) { + return interfaceId == type(ICallValidator).interfaceId || interfaceId == type(IERC165).interfaceId; + } + + function canCall(address, bytes calldata) external pure returns (bool) { + // Return true (first 32 bytes encode bool(true)), then a padding blob. + // The hardened validator should only read the first 32 bytes. + bytes memory blob = new bytes(1024); + blob[31] = 0x01; // decodes as true + assembly { + return(add(blob, 32), mload(blob)) + } + } +} From 2724c75a5ae95cde0c0bc87c525ff158c3c6bbd2 Mon Sep 17 00:00:00 2001 From: Taras Shchybovyk Date: Thu, 23 Apr 2026 11:14:06 -0700 Subject: [PATCH 07/17] feat: cap _pendingIds to bound on-chain enumeration cost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unbounded growth of _pendingIds lets a misbehaving or compromised proposer brick getPendingOperations() / getPendingOperationIds() for off-chain tooling by queuing arbitrarily many ops with large calldata blobs. Audit D-1 — High. Introduce MAX_PENDING_OPS = 128 and a new TooManyPendingOperations error. _addPending reverts when the cap is hit; executing or cancelling an op frees a slot. 128 keeps getPendingOperations well under the block gas limit even with multi-hundred-byte calldata per op, and is an order of magnitude above any realistic governance queue depth. Test covers filling the cap, rejecting the next schedule, and being able to schedule again after cancelling. --- src/governance/TimelockControllerImpl.sol | 15 +++++++++++ test/TimelockControllerImplValidation.t.sol | 29 +++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/src/governance/TimelockControllerImpl.sol b/src/governance/TimelockControllerImpl.sol index 4decfc9..505e646 100644 --- a/src/governance/TimelockControllerImpl.sol +++ b/src/governance/TimelockControllerImpl.sol @@ -199,8 +199,23 @@ contract TimelockControllerImpl is TimelockControllerUpgradeable { return abi.decode(ret, (bool)); } + /// Maximum number of concurrently-pending operations tracked in the + /// on-chain enumeration set. Chosen so that getPendingOperations() stays + /// well under the block gas limit even when every entry carries a + /// several-hundred-byte calldata blob. A normal governance workload never + /// approaches this cap; the point is to make _pendingIds growth bounded + /// so a single misbehaving (or compromised) proposer cannot brick + /// off-chain tooling that reads the set. Queued ops above the cap simply + /// cannot be scheduled until older ones are executed or cancelled — + /// scheduling itself reverts, giving callers an immediate error instead + /// of silently committing a non-enumerable op. + uint256 private constant MAX_PENDING_OPS = 128; + + error TooManyPendingOperations(); + function _addPending(bytes32 id, address target, bytes memory data, uint256 executableAt) private { if (_pendingIndex[id] != 0) return; // already tracked + if (_pendingIds.length >= MAX_PENDING_OPS) revert TooManyPendingOperations(); _pendingIds.push(id); _pendingIndex[id] = _pendingIds.length; // 1-based _pendingOps[id] = PendingOp({id: id, target: target, data: data, executableAt: executableAt}); diff --git a/test/TimelockControllerImplValidation.t.sol b/test/TimelockControllerImplValidation.t.sol index 9b931b9..3ad0bd5 100644 --- a/test/TimelockControllerImplValidation.t.sol +++ b/test/TimelockControllerImplValidation.t.sol @@ -89,6 +89,35 @@ contract TimelockControllerImplValidationTest is Test { ReturndataBombValidator v = new ReturndataBombValidator(); _schedule(address(v), hex"12345678"); } + + // ========== Pending-ops list cap (D-1) ========== + + function test_schedule_capsPendingOperationsList() public { + // Fill the pending set to its max (128) by scheduling 128 distinct ops + // against an inert target that short-circuits validation. Each gets a + // unique salt so hashOperation produces distinct ids. + InertContract target = new InertContract(); + uint256 cap = 128; // must match TimelockControllerImpl.MAX_PENDING_OPS + + for (uint256 i = 0; i < cap; i++) { + vm.prank(proposer); + timelock.schedule(address(target), 0, hex"dead", bytes32(0), bytes32(i + 1), 60); + } + + // The 129th schedule must revert with TooManyPendingOperations. + vm.prank(proposer); + vm.expectRevert(abi.encodeWithSignature("TooManyPendingOperations()")); + timelock.schedule(address(target), 0, hex"dead", bytes32(0), bytes32(uint256(cap + 1)), 60); + + // After canceling one op, scheduling a new one must succeed again — + // the cap is a ceiling on concurrently-pending, not a permanent limit. + bytes32 firstId = timelock.hashOperation(address(target), 0, hex"dead", bytes32(0), bytes32(uint256(1))); + vm.prank(proposer); + timelock.cancel(firstId); + + vm.prank(proposer); + timelock.schedule(address(target), 0, hex"dead", bytes32(0), bytes32(uint256(cap + 2)), 60); + } } /// --- Mock targets --- From 216b8a67b695c728e5774ce18601cdac40cce034 Mon Sep 17 00:00:00 2001 From: Taras Shchybovyk Date: Thu, 23 Apr 2026 11:19:13 -0700 Subject: [PATCH 08/17] feat: v1.5.0-governance release scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two zeus phases: 1. EOA (1-deployGovernanceContracts.s.sol) — deploys in order: - TimelockControllerImpl (direct, no proxy; immutable master for the minimal-proxy Timelock clones created by the factory) - SafeTimelockFactory impl, referencing the canonical Gnosis Safe infrastructure (singleton / proxy factory / fallback handler) pulled from zeus env - SafeTimelockFactory TransparentUpgradeableProxy, owned by the existing protocol ProxyAdmin - New AppController implementation, wired to the factory proxy 2. Multisig (2-upgradeAppController.s.sol) — Env.proxyAdmin().upgrade points the AppController proxy at the new impl. No initializer call; the storage layout is append-only (new `bool timelocked` lands at byte 30, previously zero on every app). Env.sol additions: - Env.proxy.safeTimelockFactory(), Env.impl.safeTimelockFactory() - Env.timelockControllerImpl() - Env.safeSingleton() / safeProxyFactory() / safeFallbackHandler() Zeus must supply the Safe infrastructure addresses per chain via env keys safeSingleton / safeProxyFactory / safeFallbackHandler. --- script/releases/Env.sol | 32 ++++ .../1-deployGovernanceContracts.s.sol | 144 ++++++++++++++++++ .../2-upgradeAppController.s.sol | 39 +++++ .../releases/v1.5.0-governance/upgrade.json | 9 ++ 4 files changed, 224 insertions(+) create mode 100644 script/releases/v1.5.0-governance/1-deployGovernanceContracts.s.sol create mode 100644 script/releases/v1.5.0-governance/2-upgradeAppController.s.sol create mode 100644 script/releases/v1.5.0-governance/upgrade.json diff --git a/script/releases/Env.sol b/script/releases/Env.sol index 1607e51..c9200b5 100644 --- a/script/releases/Env.sol +++ b/script/releases/Env.sol @@ -22,6 +22,8 @@ import {ComputeAVSRegistrar} from "../../src/ComputeAVSRegistrar.sol"; import {ComputeOperator} from "../../src/ComputeOperator.sol"; import {ImageAllowlist} from "../../src/ImageAllowlist.sol"; import {USDCCredits} from "../../src/USDCCredits.sol"; +import {SafeTimelockFactory} from "../../src/factories/SafeTimelockFactory.sol"; +import {TimelockControllerImpl} from "../../src/governance/TimelockControllerImpl.sol"; library Env { using ZEnvHelpers for *; @@ -110,6 +112,10 @@ library Env { return USDCCredits(_deployedProxy(type(USDCCredits).name)); } + function safeTimelockFactory(DeployedProxy) internal view returns (SafeTimelockFactory) { + return SafeTimelockFactory(_deployedProxy(type(SafeTimelockFactory).name)); + } + function appBeacon(DeployedBeacon) internal view returns (UpgradeableBeacon) { return UpgradeableBeacon(_deployedBeacon(type(App).name)); } @@ -141,6 +147,16 @@ library Env { return USDCCredits(_deployedImpl(type(USDCCredits).name)); } + function safeTimelockFactory(DeployedImpl) internal view returns (SafeTimelockFactory) { + return SafeTimelockFactory(_deployedImpl(type(SafeTimelockFactory).name)); + } + + /// @notice TimelockControllerImpl — the clone-master for Timelocks created + /// by SafeTimelockFactory. Not behind a proxy; deployed directly. + function timelockControllerImpl() internal view returns (TimelockControllerImpl) { + return TimelockControllerImpl(payable(_deployedContract(type(TimelockControllerImpl).name))); + } + /** * governance contracts */ @@ -202,6 +218,22 @@ library Env { return _envU256("USDC_MINIMUM_PURCHASE"); } + /** + * Safe infrastructure — canonical Gnosis Safe singletons per chain. + * Consumed by the SafeTimelockFactory constructor. + */ + function safeSingleton() internal view returns (address) { + return _envAddress("safeSingleton"); + } + + function safeProxyFactory() internal view returns (address) { + return _envAddress("safeProxyFactory"); + } + + function safeFallbackHandler() internal view returns (address) { + return _envAddress("safeFallbackHandler"); + } + /** * Helpers */ diff --git a/script/releases/v1.5.0-governance/1-deployGovernanceContracts.s.sol b/script/releases/v1.5.0-governance/1-deployGovernanceContracts.s.sol new file mode 100644 index 0000000..eb3d597 --- /dev/null +++ b/script/releases/v1.5.0-governance/1-deployGovernanceContracts.s.sol @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.27; + +import {EOADeployer} from "zeus-templates/templates/EOADeployer.sol"; +import "../Env.sol"; + +import "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; +import "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol"; + +import {AppController} from "../../../src/AppController.sol"; +import {SafeTimelockFactory} from "../../../src/factories/SafeTimelockFactory.sol"; +import {TimelockControllerImpl} from "../../../src/governance/TimelockControllerImpl.sol"; +import {ISafeTimelockFactory} from "../../../src/interfaces/ISafeTimelockFactory.sol"; + +/** + * @title DeployGovernanceContracts (v1.5.0-governance phase 1) + * @notice EOA phase of the governance release. + * + * Deploys, in order: + * 1. TimelockControllerImpl — clone-master for Timelocks created by the + * factory. Deployed directly (not behind a proxy); immutable. + * 2. SafeTimelockFactory implementation — references the Timelock impl + * above and the canonical Gnosis Safe infrastructure for the current + * chain (pulled from zeus env). + * 3. SafeTimelockFactory proxy — TransparentUpgradeableProxy pointing at + * the impl. ProxyAdmin is the existing protocol ProxyAdmin. + * 4. New AppController implementation — wired to the factory proxy so + * the timelocked-flag detection (safeTimelockFactory.isTimelock) has + * a real target once the AppController proxy is upgraded in phase 2. + * + * The AppController proxy itself is NOT upgraded here; the multisig phase + * does that in 2-upgradeAppController.s.sol. + */ +contract DeployGovernanceContracts is EOADeployer { + using Env for *; + + function _runAsEOA() internal override { + vm.startBroadcast(); + + // 1. TimelockControllerImpl — no constructor args, immutable. + TimelockControllerImpl timelockImpl = new TimelockControllerImpl(); + deployContract({name: type(TimelockControllerImpl).name, deployedTo: address(timelockImpl)}); + + // 2. SafeTimelockFactory implementation. + SafeTimelockFactory safeTimelockFactoryImpl = new SafeTimelockFactory({ + _safeSingleton: Env.safeSingleton(), + _safeProxyFactory: Env.safeProxyFactory(), + _safeFallbackHandler: Env.safeFallbackHandler(), + _timelockImplementation: address(timelockImpl) + }); + deployImpl({name: type(SafeTimelockFactory).name, deployedTo: address(safeTimelockFactoryImpl)}); + + // 3. SafeTimelockFactory proxy (TransparentUpgradeableProxy). + TransparentUpgradeableProxy safeTimelockFactoryProxy = new TransparentUpgradeableProxy( + address(safeTimelockFactoryImpl), + address(Env.proxyAdmin()), + abi.encodeCall(SafeTimelockFactory.initialize, ()) + ); + deployProxy({name: type(SafeTimelockFactory).name, deployedTo: address(safeTimelockFactoryProxy)}); + + // 4. New AppController implementation — wired to the factory proxy. + AppController newAppControllerImpl = new AppController({ + _version: Env.deployVersion(), + _permissionController: Env.permissionController(), + _releaseManager: Env.releaseManager(), + _computeAVSRegistrar: Env.proxy.computeAVSRegistrar(), + _computeOperator: Env.proxy.computeOperator(), + _appBeacon: Env.beacon.appBeacon(), + _safeTimelockFactory: ISafeTimelockFactory(address(safeTimelockFactoryProxy)) + }); + deployImpl({name: type(AppController).name, deployedTo: address(newAppControllerImpl)}); + + vm.stopBroadcast(); + } + + function testScript() public virtual { + runAsEOA(); + + _validateNewAddresses({afterUpgrade: false}); + _validateConstructors(); + } + + /// @dev Validate all phase-1 deployments are non-zero and wired correctly. + function _validateNewAddresses(bool afterUpgrade) internal view { + assertTrue(address(Env.timelockControllerImpl()) != address(0), "TimelockControllerImpl zero"); + assertTrue(address(Env.impl.safeTimelockFactory()) != address(0), "SafeTimelockFactory impl zero"); + assertTrue(address(Env.proxy.safeTimelockFactory()) != address(0), "SafeTimelockFactory proxy zero"); + assertTrue(address(Env.impl.appController()) != address(0), "AppController impl zero"); + + // Proxy points at the fresh impl. + assertEq( + _getProxyImpl(address(Env.proxy.safeTimelockFactory())), + address(Env.impl.safeTimelockFactory()), + "SafeTimelockFactory proxy -> impl mismatch" + ); + + // Only after phase 2 does the AppController proxy point at the new impl. + if (afterUpgrade) { + assertEq( + _getProxyImpl(address(Env.proxy.appController())), + address(Env.impl.appController()), + "AppController proxy -> impl mismatch" + ); + } + } + + /// @dev Cross-check immutables on the freshly deployed implementations. + function _validateConstructors() internal view { + SafeTimelockFactory factoryImpl = Env.impl.safeTimelockFactory(); + assertEq(factoryImpl.safeSingleton(), Env.safeSingleton(), "factory safeSingleton mismatch"); + assertEq(factoryImpl.safeProxyFactory(), Env.safeProxyFactory(), "factory safeProxyFactory mismatch"); + assertEq(factoryImpl.safeFallbackHandler(), Env.safeFallbackHandler(), "factory safeFallbackHandler mismatch"); + assertEq( + factoryImpl.timelockImplementation(), + address(Env.timelockControllerImpl()), + "factory timelockImplementation mismatch" + ); + + AppController appImpl = Env.impl.appController(); + assertEq(appImpl.version(), Env.deployVersion(), "AppController version mismatch"); + assertEq( + address(appImpl.permissionController()), + address(Env.permissionController()), + "permissionController mismatch" + ); + assertEq(address(appImpl.releaseManager()), address(Env.releaseManager()), "releaseManager mismatch"); + assertEq( + address(appImpl.computeAVSRegistrar()), + address(Env.proxy.computeAVSRegistrar()), + "computeAVSRegistrar mismatch" + ); + assertEq(address(appImpl.computeOperator()), address(Env.proxy.computeOperator()), "computeOperator mismatch"); + assertEq(address(appImpl.appBeacon()), address(Env.beacon.appBeacon()), "appBeacon mismatch"); + assertEq( + address(appImpl.safeTimelockFactory()), + address(Env.proxy.safeTimelockFactory()), + "safeTimelockFactory mismatch" + ); + } + + function _getProxyImpl(address proxy) internal view returns (address) { + return ProxyAdmin(Env.proxyAdmin()).getProxyImplementation(ITransparentUpgradeableProxy(proxy)); + } +} diff --git a/script/releases/v1.5.0-governance/2-upgradeAppController.s.sol b/script/releases/v1.5.0-governance/2-upgradeAppController.s.sol new file mode 100644 index 0000000..59c0c2c --- /dev/null +++ b/script/releases/v1.5.0-governance/2-upgradeAppController.s.sol @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.27; + +import {MultisigBuilder} from "zeus-templates/templates/MultisigBuilder.sol"; +import "./1-deployGovernanceContracts.s.sol"; +import "../Env.sol"; + +import "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol"; + +/** + * @title UpgradeAppController (v1.5.0-governance phase 2) + * @notice Multisig phase: point the AppController proxy at the new + * implementation deployed in phase 1. No initializer call — the + * impl's storage layout is append-only (new `bool timelocked` sits + * at byte 30 of an existing slot, previously zero on every app). + * + * Once this upgrade lands, existing apps retain their prior state + * (creator, operatorSetId, status, billingType) and gain the + * timelocked flag defaulted to false. New governance features — + * transferOwnership, hardened sensitive-op gates, ICallValidator + * schedule-time checks — become active immediately. + */ +contract UpgradeAppController is MultisigBuilder, DeployGovernanceContracts { + using Env for *; + + function _runAsMultisig() internal override prank(Env.computeOpsMultisig()) { + Env.proxyAdmin() + .upgrade( + ITransparentUpgradeableProxy(address(Env.proxy.appController())), address(Env.impl.appController()) + ); + } + + function testScript() public virtual override { + runAsEOA(); + execute(); + _validateNewAddresses({afterUpgrade: true}); + _validateConstructors(); + } +} diff --git a/script/releases/v1.5.0-governance/upgrade.json b/script/releases/v1.5.0-governance/upgrade.json new file mode 100644 index 0000000..d738e45 --- /dev/null +++ b/script/releases/v1.5.0-governance/upgrade.json @@ -0,0 +1,9 @@ +{ + "name": "governance", + "from": "1.4.0", + "to": "1.5.0", + "phases": [ + { "type": "eoa", "filename": "1-deployGovernanceContracts.s.sol" }, + { "type": "multisig", "filename": "2-upgradeAppController.s.sol" } + ] +} From e4bffee4b5d3375b4ae2804983dfa1ecf9dd97e1 Mon Sep 17 00:00:00 2001 From: Taras Shchybovyk Date: Thu, 23 Apr 2026 11:23:04 -0700 Subject: [PATCH 09/17] chore: lower MAX_PENDING_OPS from 128 to 32 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Smaller cap is more aggressive about preventing on-chain enumeration bloat. 32 is still well above any realistic governance queue depth — a team with that many concurrent pending ops has bigger problems than list enumeration cost. --- src/governance/TimelockControllerImpl.sol | 2 +- test/TimelockControllerImplValidation.t.sol | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/governance/TimelockControllerImpl.sol b/src/governance/TimelockControllerImpl.sol index 505e646..30143dc 100644 --- a/src/governance/TimelockControllerImpl.sol +++ b/src/governance/TimelockControllerImpl.sol @@ -209,7 +209,7 @@ contract TimelockControllerImpl is TimelockControllerUpgradeable { /// cannot be scheduled until older ones are executed or cancelled — /// scheduling itself reverts, giving callers an immediate error instead /// of silently committing a non-enumerable op. - uint256 private constant MAX_PENDING_OPS = 128; + uint256 private constant MAX_PENDING_OPS = 32; error TooManyPendingOperations(); diff --git a/test/TimelockControllerImplValidation.t.sol b/test/TimelockControllerImplValidation.t.sol index 3ad0bd5..00ace0f 100644 --- a/test/TimelockControllerImplValidation.t.sol +++ b/test/TimelockControllerImplValidation.t.sol @@ -97,7 +97,7 @@ contract TimelockControllerImplValidationTest is Test { // against an inert target that short-circuits validation. Each gets a // unique salt so hashOperation produces distinct ids. InertContract target = new InertContract(); - uint256 cap = 128; // must match TimelockControllerImpl.MAX_PENDING_OPS + uint256 cap = 32; // must match TimelockControllerImpl.MAX_PENDING_OPS for (uint256 i = 0; i < cap; i++) { vm.prank(proposer); From ed84cd9a21fe12b41988da33d1be8351e67860ec Mon Sep 17 00:00:00 2001 From: Taras Shchybovyk Date: Thu, 23 Apr 2026 12:59:58 -0700 Subject: [PATCH 10/17] feat: per-app team RBAC (ADMIN / PAUSER / DEVELOPER) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the PermissionController-based app-level auth with an AppController-owned role set. The Timelock guarantees only bind when the admin set the gates trust is a set this contract controls — which was the underlying reason the previous gate (checkCanCall) couldn't actually protect operational-layer control of a Timelocked app. Roles, stored as mapping(IApp => mapping(TeamRole => AddressSet)): - ADMIN → full app-level authority. Required for upgradeApp, transferOwnership, terminateApp, and for managing team membership. When the app is timelocked, ADMIN is further narrowed to msg.sender == creator for the three critical ops. - PAUSER → operational. Can call stopApp. - DEVELOPER → operational. Can call updateAppMetadataURI. Auditor findings A-2 and A-3 addressed by design: - grantTeamRole(ADMIN, _) on a timelocked app requires msg.sender == creator. Grants of PAUSER/DEVELOPER do NOT — those are operational powers the ADMIN can revoke at any time. - revokeTeamRole(ADMIN, _) on a timelocked app requires msg.sender == creator, closing the one-tx Timelock-stripping path. - renounceTeamRole refuses to drop the last ADMIN via CannotRevokeLastAdmin. migrateAdmins(IApp[]) seeds ADMIN from the existing PermissionController admin set per app — intended to be run once per existing app after the v1.5.0 upgrade. Platform-admin gated (checkCanCall on AppController). Changes: - Added TeamRole enum, events, errors, and function signatures to IAppController. - AppControllerStorage adds _teamRoles mapping; __gap shrunk by 1 slot to keep the overall storage footprint stable. - Swapped checkCanCall(address(app)) on upgradeApp, transferOwnership, terminateApp, updateAppMetadataURI, startApp, stopApp for role-based gates (onlyAdmin or onlyRoleOrAdmin). - _deployApp now seeds the creator as the initial ADMIN and emits TeamRoleGranted. - transferOwnership auto-grants ADMIN to the new owner so the new team is not stranded without governance rights. Tests: - Updated 7 existing tests where the expected revert shape shifted (InvalidPermissions → InvalidTeamRole when the role gate fires first, and so on). The co-admin-in-timelocked tests were rewritten to grant ADMIN via the new RBAC path instead of via UAM. - Added 15 new tests covering grant/revoke/renounce semantics, the A-2/A-3 timelocked ADMIN-grant gate, PAUSER/DEVELOPER behavior on stopApp and updateAppMetadataURI, transferOwnership auto-ADMIN, migrateAdmins seeding, and the platform-admin gate on migrateAdmins. Full suite: 158 tests pass (from 142 pre-RBAC). --- src/AppController.sol | 147 ++++++++++++-- src/interfaces/IAppController.sol | 126 ++++++++++-- src/storage/AppControllerStorage.sol | 9 +- test/AppController.t.sol | 293 ++++++++++++++++++++++++--- 4 files changed, 502 insertions(+), 73 deletions(-) diff --git a/src/AppController.sol b/src/AppController.sol index 9099cba..0361511 100644 --- a/src/AppController.sol +++ b/src/AppController.sol @@ -35,6 +35,19 @@ contract AppController is /// MODIFIERS + /// @notice Modifier to require the caller hold `role` on `app` (or ADMIN, + /// which is a superset). Reverts with InvalidTeamRole otherwise. + modifier onlyRoleOrAdmin(IApp app, TeamRole role) { + if (!_hasRoleOrAdmin(app, role, msg.sender)) revert InvalidTeamRole(); + _; + } + + /// @notice Modifier to require the caller is an ADMIN on `app`. + modifier onlyAdmin(IApp app) { + if (!_teamRoles[app][TeamRole.ADMIN].contains(msg.sender)) revert InvalidTeamRole(); + _; + } + /// @notice Modifier to ensure app exists modifier appExists(IApp app) { require(_exists(_appConfigs[app].status), InvalidAppStatus()); @@ -121,16 +134,11 @@ contract AppController is } /// @inheritdoc IAppController - function upgradeApp(IApp app, Release calldata release) - external - checkCanCall(address(app)) - appIsActive(app) - returns (uint256) - { - // When the app's owner is a factory-deployed Timelock, the sensitive - // path must go through Timelock.schedule → execute so the delay window - // actually applies. Any other PermissionController-permitted caller - // (e.g. a co-admin granted via UAM) would bypass the queue otherwise. + function upgradeApp(IApp app, Release calldata release) external onlyAdmin(app) appIsActive(app) returns (uint256) { + // Critical op. ADMIN is required (modifier); when the app is + // timelocked, further narrow to msg.sender == creator so only the + // Timelock (via schedule → execute) can trigger. A co-admin cannot + // bypass the delay even with the ADMIN role. if (_appConfigs[app].timelocked) { require(msg.sender == _appConfigs[app].creator, InvalidPermissions()); } @@ -138,14 +146,14 @@ contract AppController is } /// @inheritdoc IAppController - function transferOwnership(IApp app, address newOwner) external checkCanCall(address(app)) appExists(app) { + function transferOwnership(IApp app, address newOwner) external onlyAdmin(app) appExists(app) { require(newOwner != address(0), InvalidPermissions()); AppConfigStorage storage config = _appConfigs[app]; - // When already timelocked, only the Timelock owner itself may move the - // app. Otherwise any admin the Timelock had granted via UAM could - // transfer out of its governance entirely, bypassing the queue delay. + // Critical op: timelocked apps may only be moved by the Timelock + // itself. Without this, any ADMIN could hand the app to a new owner + // instantly, dropping timelocked protection. if (config.timelocked) { require(msg.sender == config.creator, InvalidPermissions()); } @@ -159,6 +167,13 @@ contract AppController is // Timelocks enable it. config.timelocked = address(safeTimelockFactory) != address(0) && safeTimelockFactory.isTimelock(newOwner); + // Grant ADMIN to the new owner so they can govern the app going + // forward. Without this the new owner would inherit ownership in + // the storage field but have no ADMIN privileges through the gates. + if (_teamRoles[app][TeamRole.ADMIN].add(newOwner)) { + emit TeamRoleGranted(app, TeamRole.ADMIN, newOwner); + } + // ISOLATED billing apps bill the app address, not the creator, so // ownership transfer has no effect on billing accounting. DEFAULT // billing apps bill the creator, so we need to move the active-app @@ -175,19 +190,19 @@ contract AppController is /// @inheritdoc IAppController function updateAppMetadataURI(IApp app, string calldata metadataURI) external - checkCanCall(address(app)) + onlyRoleOrAdmin(app, TeamRole.DEVELOPER) appExists(app) { emit AppMetadataURIUpdated(app, metadataURI); } /// @inheritdoc IAppController - function startApp(IApp app) external checkCanCall(address(app)) appExists(app) { + function startApp(IApp app) external onlyAdmin(app) appExists(app) { _startApp(app); } /// @inheritdoc IAppController - function stopApp(IApp app) external checkCanCall(address(app)) { + function stopApp(IApp app) external onlyRoleOrAdmin(app, TeamRole.PAUSER) { AppConfigStorage storage config = _appConfigs[app]; require(config.status == AppStatus.STARTED, InvalidAppStatus()); config.status = AppStatus.STOPPED; @@ -196,11 +211,10 @@ contract AppController is } /// @inheritdoc IAppController - function terminateApp(IApp app) external checkCanCall(address(app)) appIsActive(app) { - // When timelocked, only the Timelock owner itself (acting via - // schedule → execute) may terminate. Termination is irreversible; - // bypassing the queue here defeats the entire purpose of - // transferring ownership to a Timelock. + function terminateApp(IApp app) external onlyAdmin(app) appIsActive(app) { + // Critical op: timelocked apps can only be terminated via the + // Timelock. Termination is irreversible; bypassing the queue + // defeats the entire purpose of handing ownership to a Timelock. if (_appConfigs[app].timelocked) { require(msg.sender == _appConfigs[app].creator, InvalidPermissions()); } @@ -245,6 +259,79 @@ contract AppController is _setMaxActiveAppsPerUser(account, 0); } + /// TEAM ROLE MANAGEMENT + + /// @inheritdoc IAppController + function grantTeamRole(IApp app, TeamRole role, address account) external onlyAdmin(app) { + // Grants of the critical ADMIN role on a timelocked app must go + // through the Timelock queue — admitting a new admin instantly + // would effectively bypass the delay on every future upgrade. + // + // Grants of operational roles (PAUSER / DEVELOPER) are NOT + // timelock-gated on purpose: the power granted is limited and + // always revocable by ADMIN (itself gated). This is what fixes + // audit finding A-2 — the asymmetry is intentional and bounded. + if (role == TeamRole.ADMIN && _appConfigs[app].timelocked) { + if (msg.sender != _appConfigs[app].creator) revert InvalidTeamRole(); + } + + if (_teamRoles[app][role].add(account)) { + emit TeamRoleGranted(app, role, account); + } + } + + /// @inheritdoc IAppController + function revokeTeamRole(IApp app, TeamRole role, address account) external onlyAdmin(app) { + // Mirror of grant: revoking ADMIN on a timelocked app must go + // through the Timelock itself. Fixes audit finding A-3 — without + // this gate, any co-admin could strip the Timelock in one tx. + if (role == TeamRole.ADMIN && _appConfigs[app].timelocked) { + if (msg.sender != _appConfigs[app].creator) revert InvalidTeamRole(); + } + + if (role == TeamRole.ADMIN && _teamRoles[app][TeamRole.ADMIN].length() == 1) { + revert CannotRevokeLastAdmin(); + } + + if (_teamRoles[app][role].remove(account)) { + emit TeamRoleRevoked(app, role, account); + } + } + + /// @inheritdoc IAppController + function renounceTeamRole(IApp app, TeamRole role) external { + // No timelock gate on renounce — a member giving up their own + // rights never grows anyone else's power. Still blocked if it + // would leave the team with zero admins. + if (role == TeamRole.ADMIN && _teamRoles[app][TeamRole.ADMIN].length() == 1) { + revert CannotRevokeLastAdmin(); + } + if (_teamRoles[app][role].remove(msg.sender)) { + emit TeamRoleRevoked(app, role, msg.sender); + } + } + + /// @inheritdoc IAppController + function hasTeamRole(IApp app, TeamRole role, address account) external view returns (bool) { + return _teamRoles[app][role].contains(account); + } + + /// @inheritdoc IAppController + function migrateAdmins(IApp[] calldata apps) external checkCanCall(address(this)) { + for (uint256 i = 0; i < apps.length; i++) { + IApp app = apps[i]; + // Pull the current UAM admin set and seed it into our own ADMIN + // role. Idempotent: EnumerableSet.add returns false for entries + // already in the set, so repeated calls are safe. + address[] memory admins = permissionController.getAdmins(address(app)); + for (uint256 j = 0; j < admins.length; j++) { + if (_teamRoles[app][TeamRole.ADMIN].add(admins[j])) { + emit TeamRoleGranted(app, TeamRole.ADMIN, admins[j]); + } + } + } + } + /// INTERNAL FUNCTIONS /** @@ -278,12 +365,28 @@ contract AppController is } _allApps.add(address(app)); + // Seed the creator as initial ADMIN on the app's team. All subsequent + // ADMIN-gated calls (grant/revoke/upgrade/transfer/terminate) flow + // through this set. Without this bootstrap, a freshly created app + // would have zero admins and be unreachable. + _teamRoles[app][TeamRole.ADMIN].add(msg.sender); + emit TeamRoleGranted(app, TeamRole.ADMIN, msg.sender); + emit AppCreated(msg.sender, app, operatorSetId); _upgradeApp(app, release); _startApp(app); } + /** + * @notice Returns true if `account` holds `role` on `app`, OR holds + * ADMIN on `app` (ADMIN is always a superset of every other + * role). Used by operational-op modifiers. + */ + function _hasRoleOrAdmin(IApp app, TeamRole role, address account) internal view returns (bool) { + return _teamRoles[app][role].contains(account) || _teamRoles[app][TeamRole.ADMIN].contains(account); + } + /** * @notice Checks if an app status is not NONE * @param status The app status to check diff --git a/src/interfaces/IAppController.sol b/src/interfaces/IAppController.sol index c40397d..cada6f1 100644 --- a/src/interfaces/IAppController.sol +++ b/src/interfaces/IAppController.sol @@ -29,6 +29,12 @@ interface IAppController { /// @notice Thrown when trying to suspend an account that still has active apps error AccountHasActiveApps(); + /// @notice Thrown when a caller lacks the required per-app team role. + error InvalidTeamRole(); + + /// @notice Thrown when revoking/renouncing ADMIN would leave the team with zero admins. + error CannotRevokeLastAdmin(); + /// @notice Emitted when a new app is successfully created event AppCreated(address indexed creator, IApp indexed app, uint32 operatorSetId); @@ -62,6 +68,12 @@ interface IAppController { /// @notice Emitted when app ownership is transferred to a new address event AppOwnershipTransferred(IApp indexed app, address indexed previousOwner, address indexed newOwner); + /// @notice Emitted when a team role is granted on an app. + event TeamRoleGranted(IApp indexed app, TeamRole indexed role, address indexed account); + + /// @notice Emitted when a team role is revoked on an app (by an admin or via renounce). + event TeamRoleRevoked(IApp indexed app, TeamRole indexed role, address indexed account); + /// @notice Enum for app status enum AppStatus { NONE, // App has not been created yet @@ -77,6 +89,30 @@ interface IAppController { ISOLATED // Billed to the app's own address } + /// @notice Per-app team roles. + /// @dev ADMIN is the authoritative role for CRITICAL app-level ops + /// (upgradeApp / transferOwnership / terminateApp) and for managing + /// team membership. PAUSER and DEVELOPER are OPERATIONAL: + /// + /// - PAUSER → may call stopApp + /// - DEVELOPER → may call updateAppMetadataURI + /// - ADMIN → may call everything operational, plus all + /// critical ops, plus grant/revoke team roles, + /// plus transferOwnership. + /// + /// Roles live in `AppController` storage, NOT in + /// PermissionController. That's intentional — the Timelock + /// guarantees only bind if the admin set the gates trust is a + /// set this contract controls. PermissionController admins are + /// still used for platform-admin-level functions on this + /// contract itself (setMaxActiveAppsPerUser, setMaxGlobalActiveApps, + /// migrateAdmins, terminateAppByAdmin, suspend). + enum TeamRole { + ADMIN, + PAUSER, + DEVELOPER + } + /** * @notice A struct containing a release and its environment * @param rmsRelease The release to publish @@ -163,11 +199,14 @@ interface IAppController { function createAppWithIsolatedBilling(bytes32 salt, Release calldata release) external returns (IApp app); /** - * @notice Upgrades an app with a new release to the ReleaseManager + * @notice Upgrades an app with a new release to the ReleaseManager. Critical op. * @param app The app to upgrade with the release * @param release The release to upgrade to * @return releaseId The unique identifier for the published release - * @dev Caller must be UAM permissioned for the app + * @dev Caller must hold ADMIN on the app's team. + * @dev When the app is timelocked, caller must additionally be the creator + * (the Timelock itself) — in practice, this forces the call to come + * from a scheduled → executed Timelock operation. * @dev The rms release must have exactly one artifact, with the digest being the docker * image digest and the registry being the docker registry it is stored at. * @dev The env must be a JSON marshalled bytes representing the public environment variables for the app. @@ -178,49 +217,49 @@ interface IAppController { function upgradeApp(IApp app, Release calldata release) external returns (uint256); /** - * @notice Transfers app ownership to a new address. + * @notice Transfers app ownership to a new address. Critical op. * @param app The app to transfer ownership of * @param newOwner The new owner address - * @dev Caller must be UAM permissioned for the app. + * @dev Caller must hold ADMIN on the app's team. * @dev When `newOwner` is a factory-deployed Timelock the app's `timelocked` - * flag is flipped to true, causing subsequent sensitive ops to require - * msg.sender == owner (i.e. go through schedule → execute). - * When `newOwner` is not a factory Timelock (EOA, Safe, non-factory - * contract) the flag is cleared. - * @dev When the app is already timelocked, only the current Timelock owner - * itself may call — any other admin would bypass the queue delay. + * flag is flipped to true; otherwise it's cleared. + * @dev When the app is already timelocked, only the Timelock itself (via + * schedule → execute) may transfer. The new owner is automatically + * granted ADMIN on the new team so they can govern going forward. */ function transferOwnership(IApp app, address newOwner) external; /** - * @notice Updates the metadata URI for an app + * @notice Updates the metadata URI for an app. Operational. * @param app The app to update the metadata URI for * @param metadataURI The new metadata URI - * @dev Caller must be UAM permissioned for the app + * @dev Permitted to ADMIN or DEVELOPER. */ function updateAppMetadataURI(IApp app, string calldata metadataURI) external; /** - * @notice Starts an app, which starts the instance backing it + * @notice Starts an app, which starts the instance backing it. * @param app The app to start - * @dev Caller must be UAM permissioned for the app - * @dev App must be AppStatus.STOPPED + * @dev Permitted to ADMIN only — starting commits capacity; treated as + * privileged. App must be STOPPED. */ function startApp(IApp app) external; /** - * @notice Stops an app, which stops the instance backing it + * @notice Stops an app, which stops the instance backing it. Operational. * @param app The app to stop - * @dev Caller must be UAM permissioned for the app - * @dev App must be AppStatus.STARTED + * @dev Permitted to ADMIN or PAUSER. + * @dev App must be AppStatus.STARTED. */ function stopApp(IApp app) external; /** - * @notice Terminates an app permanently + * @notice Terminates an app permanently. Critical op. * @param app The app to terminate - * @dev Caller must be UAM permissioned for the app - * @dev Once terminated, no further write operations are allowed + * @dev Caller must hold ADMIN on the app's team. + * @dev When the app is timelocked, caller must additionally be the + * creator (Timelock). + * @dev Once terminated, no further write operations are allowed. */ function terminateApp(IApp app) external; @@ -242,6 +281,51 @@ interface IAppController { */ function suspend(address account, IApp[] calldata apps) external; + /** + * @notice Grant a team role to `account` on `app`. + * @dev Caller must be ADMIN on the app. + * @dev When the team is timelocked AND `role == ADMIN`, the caller must + * additionally be the creator (the Timelock itself) — which in + * practice means going through schedule → execute. Grants of PAUSER + * / DEVELOPER on a timelocked app do NOT require going through the + * Timelock; those are operational powers the admin can revoke at + * any time. Fixes audit finding A-2. + */ + function grantTeamRole(IApp app, TeamRole role, address account) external; + + /** + * @notice Revoke a team role from `account` on `app`. + * @dev Caller must be ADMIN on the app. + * @dev When the team is timelocked AND `role == ADMIN`, the caller must + * additionally be the creator. Revoking any ADMIN below the + * last-admin floor reverts. Operational role revocations on a + * timelocked app are NOT timelock-gated. Fixes audit finding A-3. + */ + function revokeTeamRole(IApp app, TeamRole role, address account) external; + + /** + * @notice Renounce your own team role on `app`. + * @dev Renouncing ADMIN below the last-admin floor reverts. + */ + function renounceTeamRole(IApp app, TeamRole role) external; + + /** + * @notice Returns true iff `account` holds `role` on `app`. + */ + function hasTeamRole(IApp app, TeamRole role, address account) external view returns (bool); + + /** + * @notice Migrate pre-v1.5.0 apps from PermissionController-based auth to + * the new per-app team RBAC. For each app in `apps`, every admin + * currently registered in PermissionController is granted the + * ADMIN role in AppController's own storage. No-op for apps that + * already have an ADMIN. + * @dev Caller must be UAM permissioned for the AppController itself + * (platform admin). Intended to be called once per app after the + * v1.5.0 upgrade; safe to call again (idempotent per-(app, admin)). + */ + function migrateAdmins(IApp[] calldata apps) external; + /** * @notice Gets the maximum global active apps limit * @return The maximum number of active apps globally diff --git a/src/storage/AppControllerStorage.sol b/src/storage/AppControllerStorage.sol index 2904f65..3aafd27 100644 --- a/src/storage/AppControllerStorage.sol +++ b/src/storage/AppControllerStorage.sol @@ -57,6 +57,13 @@ abstract contract AppControllerStorage is IAppController { /// @inheritdoc IAppController uint32 public globalActiveAppCount; + /// @notice Per-app, per-role set of addresses holding the role. + /// app → role → { account, account, ... } + /// @dev This slot was inside __gap on v1.4.0 and is guaranteed zero on + /// every existing app. __gap below shrinks by 1 to compensate so + /// the end of the storage footprint is stable. + mapping(IApp => mapping(TeamRole => EnumerableSet.AddressSet)) internal _teamRoles; + constructor( IReleaseManager _releaseManager, IComputeOperator _computeOperator, @@ -76,5 +83,5 @@ abstract contract AppControllerStorage is IAppController { * variables without shifting down storage in the inheritance chain. * See https://docs.openzeppelin.com/contracts/4.x/upgradeable#storage_gaps */ - uint256[45] private __gap; + uint256[44] private __gap; } diff --git a/test/AppController.t.sol b/test/AppController.t.sol index c198e17..95b8a00 100644 --- a/test/AppController.t.sol +++ b/test/AppController.t.sol @@ -277,7 +277,7 @@ contract AppControllerTest is ComputeDeployer { // Try to upgrade as unauthorized user vm.prank(user); - vm.expectRevert(PermissionControllerMixin.InvalidPermissions.selector); + vm.expectRevert(IAppController.InvalidTeamRole.selector); appController.upgradeApp(app, release); } @@ -373,9 +373,12 @@ contract AppControllerTest is ComputeDeployer { // Verify this app doesn't exist assertEq(uint256(appController.getAppStatus(fakeApp)), uint256(IAppController.AppStatus.NONE)); - // Try to start a non-existent app - should revert with InvalidAppStatus + // Try to start a non-existent app — now fails on the onlyAdmin + // check first (the caller holds no team role on an address whose + // team was never created). This is strictly stronger than the + // pre-RBAC InvalidAppStatus revert. vm.prank(fakeAppAddress); - vm.expectRevert(abi.encodeWithSelector(IAppController.InvalidAppStatus.selector)); + vm.expectRevert(IAppController.InvalidTeamRole.selector); appController.startApp(fakeApp); // Verify status is still NONE @@ -765,7 +768,7 @@ contract AppControllerTest is ComputeDeployer { // Try to update metadata as unauthorized user vm.prank(user); - vm.expectRevert(PermissionControllerMixin.InvalidPermissions.selector); + vm.expectRevert(IAppController.InvalidTeamRole.selector); appController.updateAppMetadataURI(app, "https://example.com/metadata"); } @@ -797,9 +800,11 @@ contract AppControllerTest is ComputeDeployer { // Verify this app doesn't exist assertEq(uint256(appController.getAppStatus(fakeApp)), uint256(IAppController.AppStatus.NONE)); - // Try to update metadata for a non-existent app - should revert + // Try to update metadata for a non-existent app — caller holds no + // DEVELOPER/ADMIN role on the fake team, so the outer role gate + // rejects first. Strictly stronger than the pre-RBAC behavior. vm.prank(fakeAppAddress); - vm.expectRevert(abi.encodeWithSelector(IAppController.InvalidAppStatus.selector)); + vm.expectRevert(IAppController.InvalidTeamRole.selector); appController.updateAppMetadataURI(fakeApp, "https://example.com/fake-metadata"); } @@ -1393,19 +1398,17 @@ contract AppControllerTest is ComputeDeployer { IApp app = appController.createApp(SALT, _assembleRelease()); require(appController.getAppTimelocked(app), "precondition: app must be timelocked"); - // Grant a PermissionController admin to another address — the exact - // path a compromised or cooperating admin would use to upgrade - // instantly before the fix. - // developer (app creator) becomes admin, then promotes a co-admin. - vm.prank(developer); - permissionController.acceptAdmin(address(app)); + // Timelock (creator) grants ADMIN role to a co-admin. Under the RBAC + // model, only ADMIN can even attempt upgradeApp; holding ADMIN is + // the strongest authority the co-admin could plausibly have. address coAdmin = makeAddr("coAdmin"); + // Timelock ADMIN grant on a timelocked app requires msg.sender == creator; + // developer IS the creator (via _mockIsTimelock), so this passes directly. vm.prank(developer); - permissionController.addPendingAdmin(address(app), coAdmin); - vm.prank(coAdmin); - permissionController.acceptAdmin(address(app)); + appController.grantTeamRole(app, IAppController.TeamRole.ADMIN, coAdmin); - // Direct upgrade from the co-admin MUST revert. + // Direct upgrade from the co-admin MUST revert with InvalidPermissions — + // the role check passes but the creator-only gate fires. vm.prank(coAdmin); vm.expectRevert(PermissionControllerMixin.InvalidPermissions.selector); appController.upgradeApp(app, _assembleRelease()); @@ -1421,14 +1424,9 @@ contract AppControllerTest is ComputeDeployer { vm.prank(developer); IApp app = appController.createApp(SALT, _assembleRelease()); - // developer (app creator) becomes admin, then promotes a co-admin. - vm.prank(developer); - permissionController.acceptAdmin(address(app)); address coAdmin = makeAddr("coAdmin"); vm.prank(developer); - permissionController.addPendingAdmin(address(app), coAdmin); - vm.prank(coAdmin); - permissionController.acceptAdmin(address(app)); + appController.grantTeamRole(app, IAppController.TeamRole.ADMIN, coAdmin); vm.prank(coAdmin); vm.expectRevert(PermissionControllerMixin.InvalidPermissions.selector); @@ -1500,13 +1498,12 @@ contract AppControllerTest is ComputeDeployer { vm.prank(developer); IApp app = appController.createApp(SALT, _assembleRelease()); - vm.prank(developer); - permissionController.acceptAdmin(address(app)); + // Timelock grants ADMIN to coAdmin. ADMIN is the strongest role a + // co-admin could plausibly have; the creator-only gate must still + // block them from moving the app out of governance. address coAdmin = makeAddr("coAdmin"); vm.prank(developer); - permissionController.addPendingAdmin(address(app), coAdmin); - vm.prank(coAdmin); - permissionController.acceptAdmin(address(app)); + appController.grantTeamRole(app, IAppController.TeamRole.ADMIN, coAdmin); address attacker = makeAddr("attacker"); vm.prank(coAdmin); @@ -1522,8 +1519,7 @@ contract AppControllerTest is ComputeDeployer { function test_transferOwnership_revertsZeroAddress() public { vm.prank(developer); IApp app = appController.createApp(SALT, _assembleRelease()); - vm.prank(developer); - permissionController.acceptAdmin(address(app)); + // developer is auto-granted ADMIN at createApp time; no UAM step needed. vm.prank(developer); vm.expectRevert(PermissionControllerMixin.InvalidPermissions.selector); @@ -1627,4 +1623,243 @@ contract AppControllerTest is ComputeDeployer { // regardless of caller — canCall reflects that. assertFalse(ICallValidator(address(appController)).canCall(admin, callData)); } + + // ========== Team-role RBAC ========== + + function test_createApp_grantsAdminRoleToCreator() public { + vm.prank(developer); + IApp app = appController.createApp(SALT, _assembleRelease()); + + assertTrue( + appController.hasTeamRole(app, IAppController.TeamRole.ADMIN, developer), + "creator must be seeded as ADMIN on create" + ); + } + + function test_grantTeamRole_asAdmin() public { + vm.prank(developer); + IApp app = appController.createApp(SALT, _assembleRelease()); + + address pauser = makeAddr("pauser"); + vm.prank(developer); + appController.grantTeamRole(app, IAppController.TeamRole.PAUSER, pauser); + + assertTrue(appController.hasTeamRole(app, IAppController.TeamRole.PAUSER, pauser)); + } + + function test_grantTeamRole_nonAdminCannotGrant() public { + vm.prank(developer); + IApp app = appController.createApp(SALT, _assembleRelease()); + + address outsider = makeAddr("outsider"); + vm.prank(outsider); + vm.expectRevert(IAppController.InvalidTeamRole.selector); + appController.grantTeamRole(app, IAppController.TeamRole.PAUSER, outsider); + } + + function test_grantTeamRole_timelockedOperationalRoleNotGated() public { + // A-2 fix: PAUSER/DEVELOPER grants on timelocked apps are NOT + // routed through the Timelock. Any existing ADMIN can grant — the + // power delegated is operational only. + _mockIsTimelock(developer); + vm.prank(developer); + IApp app = appController.createApp(SALT, _assembleRelease()); + + // Give coAdmin ADMIN via the Timelock (creator), then confirm + // coAdmin can grant PAUSER freely (no creator-only gate). + address coAdmin = makeAddr("coAdmin"); + vm.prank(developer); + appController.grantTeamRole(app, IAppController.TeamRole.ADMIN, coAdmin); + + address pauser = makeAddr("pauser"); + vm.prank(coAdmin); + appController.grantTeamRole(app, IAppController.TeamRole.PAUSER, pauser); + assertTrue(appController.hasTeamRole(app, IAppController.TeamRole.PAUSER, pauser)); + } + + function test_grantTeamRole_timelockedAdminGrantGatedByCreator() public { + // A-2 fix: ADMIN grants on timelocked apps MUST come from the + // Timelock itself. Otherwise a compromised co-admin could add + // another ADMIN in one tx and circumvent the delay on upgrades. + _mockIsTimelock(developer); + vm.prank(developer); + IApp app = appController.createApp(SALT, _assembleRelease()); + + address coAdmin = makeAddr("coAdmin"); + vm.prank(developer); + appController.grantTeamRole(app, IAppController.TeamRole.ADMIN, coAdmin); + + // coAdmin has ADMIN but is NOT the creator: grant attempt must fail. + address usurper = makeAddr("usurper"); + vm.prank(coAdmin); + vm.expectRevert(IAppController.InvalidTeamRole.selector); + appController.grantTeamRole(app, IAppController.TeamRole.ADMIN, usurper); + + // The Timelock itself can still grant. + vm.prank(developer); + appController.grantTeamRole(app, IAppController.TeamRole.ADMIN, usurper); + assertTrue(appController.hasTeamRole(app, IAppController.TeamRole.ADMIN, usurper)); + } + + function test_revokeTeamRole_timelockedAdminRevokeGatedByCreator() public { + // A-3 fix: revoking ADMIN on a timelocked app requires msg.sender == creator. + // Without this, a co-admin could strip the Timelock in one tx. + _mockIsTimelock(developer); + vm.prank(developer); + IApp app = appController.createApp(SALT, _assembleRelease()); + + address coAdmin = makeAddr("coAdmin"); + vm.prank(developer); + appController.grantTeamRole(app, IAppController.TeamRole.ADMIN, coAdmin); + + // coAdmin attempts to revoke the Timelock — MUST fail. + vm.prank(coAdmin); + vm.expectRevert(IAppController.InvalidTeamRole.selector); + appController.revokeTeamRole(app, IAppController.TeamRole.ADMIN, developer); + + // The Timelock itself can revoke coAdmin. + vm.prank(developer); + appController.revokeTeamRole(app, IAppController.TeamRole.ADMIN, coAdmin); + assertFalse(appController.hasTeamRole(app, IAppController.TeamRole.ADMIN, coAdmin)); + } + + function test_revokeTeamRole_cannotRevokeLastAdmin() public { + vm.prank(developer); + IApp app = appController.createApp(SALT, _assembleRelease()); + + // Sole ADMIN trying to revoke themselves via revokeTeamRole reverts. + vm.prank(developer); + vm.expectRevert(IAppController.CannotRevokeLastAdmin.selector); + appController.revokeTeamRole(app, IAppController.TeamRole.ADMIN, developer); + } + + function test_renounceTeamRole_cannotRenounceLastAdmin() public { + vm.prank(developer); + IApp app = appController.createApp(SALT, _assembleRelease()); + + vm.prank(developer); + vm.expectRevert(IAppController.CannotRevokeLastAdmin.selector); + appController.renounceTeamRole(app, IAppController.TeamRole.ADMIN); + } + + function test_renounceTeamRole_operationalRoleOk() public { + vm.prank(developer); + IApp app = appController.createApp(SALT, _assembleRelease()); + + address pauser = makeAddr("pauser"); + vm.prank(developer); + appController.grantTeamRole(app, IAppController.TeamRole.PAUSER, pauser); + + vm.prank(pauser); + appController.renounceTeamRole(app, IAppController.TeamRole.PAUSER); + assertFalse(appController.hasTeamRole(app, IAppController.TeamRole.PAUSER, pauser)); + } + + function test_stopApp_pauserCanCall() public { + vm.prank(developer); + IApp app = appController.createApp(SALT, _assembleRelease()); + + address pauser = makeAddr("pauser"); + vm.prank(developer); + appController.grantTeamRole(app, IAppController.TeamRole.PAUSER, pauser); + + vm.prank(pauser); + appController.stopApp(app); + assertEq(uint256(appController.getAppStatus(app)), uint256(IAppController.AppStatus.STOPPED)); + } + + function test_stopApp_developerCannotCall() public { + vm.prank(developer); + IApp app = appController.createApp(SALT, _assembleRelease()); + + address dev = makeAddr("dev"); + vm.prank(developer); + appController.grantTeamRole(app, IAppController.TeamRole.DEVELOPER, dev); + + vm.prank(dev); + vm.expectRevert(IAppController.InvalidTeamRole.selector); + appController.stopApp(app); + } + + function test_updateAppMetadataURI_developerCanCall() public { + vm.prank(developer); + IApp app = appController.createApp(SALT, _assembleRelease()); + + address dev = makeAddr("dev"); + vm.prank(developer); + appController.grantTeamRole(app, IAppController.TeamRole.DEVELOPER, dev); + + vm.prank(dev); + appController.updateAppMetadataURI(app, "ipfs://new"); + } + + function test_updateAppMetadataURI_pauserCannotCall() public { + vm.prank(developer); + IApp app = appController.createApp(SALT, _assembleRelease()); + + address pauser = makeAddr("pauser"); + vm.prank(developer); + appController.grantTeamRole(app, IAppController.TeamRole.PAUSER, pauser); + + vm.prank(pauser); + vm.expectRevert(IAppController.InvalidTeamRole.selector); + appController.updateAppMetadataURI(app, "ipfs://new"); + } + + function test_transferOwnership_grantsAdminToNewOwner() public { + vm.prank(developer); + IApp app = appController.createApp(SALT, _assembleRelease()); + + address newOwner = makeAddr("newOwner"); + _setMaxActiveAppsPerUser(newOwner, 10); + + vm.prank(developer); + appController.transferOwnership(app, newOwner); + + assertTrue( + appController.hasTeamRole(app, IAppController.TeamRole.ADMIN, newOwner), + "new owner must be granted ADMIN automatically" + ); + } + + function test_migrateAdmins_seedsAdminFromPermissionController() public { + vm.prank(developer); + IApp app = appController.createApp(SALT, _assembleRelease()); + + // Set up two UAM admins who are NOT yet in the team-role set. + address pcAdminA = makeAddr("pcAdminA"); + address pcAdminB = makeAddr("pcAdminB"); + vm.prank(developer); + permissionController.acceptAdmin(address(app)); + vm.prank(developer); + permissionController.addPendingAdmin(address(app), pcAdminA); + vm.prank(pcAdminA); + permissionController.acceptAdmin(address(app)); + vm.prank(developer); + permissionController.addPendingAdmin(address(app), pcAdminB); + vm.prank(pcAdminB); + permissionController.acceptAdmin(address(app)); + + assertFalse(appController.hasTeamRole(app, IAppController.TeamRole.ADMIN, pcAdminA)); + assertFalse(appController.hasTeamRole(app, IAppController.TeamRole.ADMIN, pcAdminB)); + + IApp[] memory apps = new IApp[](1); + apps[0] = app; + vm.prank(admin); + appController.migrateAdmins(apps); + + assertTrue(appController.hasTeamRole(app, IAppController.TeamRole.ADMIN, pcAdminA)); + assertTrue(appController.hasTeamRole(app, IAppController.TeamRole.ADMIN, pcAdminB)); + } + + function test_migrateAdmins_callerMustBePlatformAdmin() public { + vm.prank(developer); + IApp app = appController.createApp(SALT, _assembleRelease()); + + IApp[] memory apps = new IApp[](1); + apps[0] = app; + vm.prank(user); + vm.expectRevert(); + appController.migrateAdmins(apps); + } } From 3c09c08db692292fa792921c4010e040f1264299 Mon Sep 17 00:00:00 2001 From: Taras Shchybovyk Date: Fri, 24 Apr 2026 14:54:24 -0700 Subject: [PATCH 11/17] refactor: extract AppAuthority for per-app ownership + RBAC Move ownership and role state out of AppController into a dedicated AppAuthority contract. AppController delegates auth checks to AppAuthority; role management (grant/revoke/renounce/hasRole) is called directly on AppAuthority by clients. Critical ops remain on AppController and are owner-gated via AppAuthority.isScopeOwner. Fixes the Option-2 invariants at the type level: - The owner is always ADMIN on their scope; rotation only through transferScopeOwnership (add-before-remove preserves last-admin). - ADMIN mutations (grant/revoke) are owner-only unconditionally. - The owner cannot renounce or self-revoke ADMIN. Storage layout: _teamRoles removed from AppController; __gap restored to 45 slots. AppAuthority is a new upgradeable contract behind its own proxy. AppController gets an IAppAuthority immutable. Cached `creator` stays on AppController for billing / App.initialize / event stability; it mirrors AppAuthority.scopeOwner on transfer. migrateAdmins becomes migrateAppsToAppAuthority: initializes each legacy app's scope in AppAuthority from the cached creator, then seeds ADMIN from the app's PermissionController admins (operational- only under this model; no critical-op exposure). AppController runtime size: 33,131 -> 30,288 bytes (-2,843). Tests: 185/185 passing (was 161; +24 AppAuthority unit tests). --- script/Deploy.s.sol | 26 +- script/Parser.s.sol | 8 +- script/releases/Env.sol | 9 + .../v1.0.4-init/1-deployContracts.s.sol | 8 +- .../1-deployAppControllerImpl.s.sol | 6 +- .../1-deployAppControllerImpl.s.sol | 6 +- .../1-deployGovernanceContracts.s.sol | 28 +- src/AppController.sol | 235 +++++------- src/governance/AppAuthority.sol | 181 +++++++++ src/interfaces/IAppAuthority.sol | 146 ++++++++ src/interfaces/IAppController.sol | 111 ++---- src/storage/AppControllerStorage.sol | 20 +- test/AppAuthority.t.sol | 353 ++++++++++++++++++ test/AppController.t.sol | 211 +++++++---- test/utils/ComputeDeployer.sol | 3 + 15 files changed, 1039 insertions(+), 312 deletions(-) create mode 100644 src/governance/AppAuthority.sol create mode 100644 src/interfaces/IAppAuthority.sol create mode 100644 test/AppAuthority.t.sol diff --git a/script/Deploy.s.sol b/script/Deploy.s.sol index b340825..5236a69 100644 --- a/script/Deploy.s.sol +++ b/script/Deploy.s.sol @@ -27,6 +27,8 @@ import {IImageAllowlist} from "../src/interfaces/IImageAllowlist.sol"; import {ISafeTimelockFactory} from "../src/interfaces/ISafeTimelockFactory.sol"; import {SafeTimelockFactory} from "../src/factories/SafeTimelockFactory.sol"; import {TimelockControllerImpl} from "../src/governance/TimelockControllerImpl.sol"; +import {IAppAuthority} from "../src/interfaces/IAppAuthority.sol"; +import {AppAuthority} from "../src/governance/AppAuthority.sol"; contract Deploy is Parser { struct Proxies { @@ -42,6 +44,7 @@ contract Deploy is Parser { ComputeOperator computeOperator; AppController appController; ImageAllowlist imageAllowlist; + AppAuthority appAuthority; } function run(string memory environment) public { @@ -111,6 +114,15 @@ contract Deploy is Parser { abi.encodeCall(SafeTimelockFactory.initialize, ()) ); + // Deploy AppAuthority (owns per-app RBAC state consumed by + // AppController). The consumer (AppController proxy) is already + // known — it's the proxy we just created above. The impl holds + // the consumer immutable; the proxy just fronts it. + AppAuthority appAuthorityImpl = new AppAuthority(address(proxies.appController)); + TransparentUpgradeableProxy appAuthorityProxy = new TransparentUpgradeableProxy( + address(appAuthorityImpl), address(params.proxyAdmin), abi.encodeCall(AppAuthority.initialize, ()) + ); + // Deploy implementation contracts Implementations memory impls = Implementations({ app: App(appBeacon.implementation()), @@ -136,9 +148,11 @@ contract Deploy is Parser { _computeAVSRegistrar: IComputeAVSRegistrar(address(proxies.computeAVSRegistrar)), _computeOperator: IComputeOperator(address(proxies.computeOperator)), _appBeacon: appBeacon, - _safeTimelockFactory: ISafeTimelockFactory(address(safeTimelockFactoryProxy)) + _safeTimelockFactory: ISafeTimelockFactory(address(safeTimelockFactoryProxy)), + _appAuthority: IAppAuthority(address(appAuthorityProxy)) }), - imageAllowlist: new ImageAllowlist() + imageAllowlist: new ImageAllowlist(), + appAuthority: appAuthorityImpl }); // Upgrade proxies using ProxyAdmin @@ -193,7 +207,9 @@ contract Deploy is Parser { computeOperator: IComputeOperator(address(proxies.computeOperator)), computeOperatorImpl: impls.computeOperator, imageAllowlist: IImageAllowlist(address(proxies.imageAllowlist)), - imageAllowlistImpl: impls.imageAllowlist + imageAllowlistImpl: impls.imageAllowlist, + appAuthority: IAppAuthority(address(appAuthorityProxy)), + appAuthorityImpl: impls.appAuthority }); } @@ -216,7 +232,9 @@ contract Deploy is Parser { vm.serializeAddress(addresses, "computeOperator", address(deployedContracts.computeOperator)); vm.serializeAddress(addresses, "computeOperatorImpl", address(deployedContracts.computeOperatorImpl)); vm.serializeAddress(addresses, "imageAllowlist", address(deployedContracts.imageAllowlist)); - addresses = vm.serializeAddress(addresses, "imageAllowlistImpl", address(deployedContracts.imageAllowlistImpl)); + vm.serializeAddress(addresses, "imageAllowlistImpl", address(deployedContracts.imageAllowlistImpl)); + vm.serializeAddress(addresses, "appAuthority", address(deployedContracts.appAuthority)); + addresses = vm.serializeAddress(addresses, "appAuthorityImpl", address(deployedContracts.appAuthorityImpl)); // Add the chainInfo object string memory chainInfo = "chainInfo"; diff --git a/script/Parser.s.sol b/script/Parser.s.sol index 982a879..4f774e0 100644 --- a/script/Parser.s.sol +++ b/script/Parser.s.sol @@ -23,6 +23,8 @@ import {ComputeAVSRegistrar} from "../src/ComputeAVSRegistrar.sol"; import {ComputeOperator} from "../src/ComputeOperator.sol"; import {ImageAllowlist} from "../src/ImageAllowlist.sol"; import {IImageAllowlist} from "../src/interfaces/IImageAllowlist.sol"; +import {IAppAuthority} from "../src/interfaces/IAppAuthority.sol"; +import {AppAuthority} from "../src/governance/AppAuthority.sol"; contract Parser is Script { struct DeployParams { @@ -52,6 +54,8 @@ contract Parser is Script { ComputeOperator computeOperatorImpl; IImageAllowlist imageAllowlist; ImageAllowlist imageAllowlistImpl; + IAppAuthority appAuthority; + AppAuthority appAuthorityImpl; } function parseDeployParams(string memory environment) public view returns (DeployParams memory) { @@ -93,7 +97,9 @@ contract Parser is Script { computeOperator: IComputeOperator(vm.parseJsonAddress(json, ".addresses.computeOperator")), computeOperatorImpl: ComputeOperator(vm.parseJsonAddress(json, ".addresses.computeOperatorImpl")), imageAllowlist: IImageAllowlist(vm.parseJsonAddress(json, ".addresses.imageAllowlist")), - imageAllowlistImpl: ImageAllowlist(vm.parseJsonAddress(json, ".addresses.imageAllowlistImpl")) + imageAllowlistImpl: ImageAllowlist(vm.parseJsonAddress(json, ".addresses.imageAllowlistImpl")), + appAuthority: IAppAuthority(vm.parseJsonAddress(json, ".addresses.appAuthority")), + appAuthorityImpl: AppAuthority(vm.parseJsonAddress(json, ".addresses.appAuthorityImpl")) }); return deployedContracts; diff --git a/script/releases/Env.sol b/script/releases/Env.sol index c9200b5..5f80986 100644 --- a/script/releases/Env.sol +++ b/script/releases/Env.sol @@ -24,6 +24,7 @@ import {ImageAllowlist} from "../../src/ImageAllowlist.sol"; import {USDCCredits} from "../../src/USDCCredits.sol"; import {SafeTimelockFactory} from "../../src/factories/SafeTimelockFactory.sol"; import {TimelockControllerImpl} from "../../src/governance/TimelockControllerImpl.sol"; +import {AppAuthority} from "../../src/governance/AppAuthority.sol"; library Env { using ZEnvHelpers for *; @@ -116,6 +117,10 @@ library Env { return SafeTimelockFactory(_deployedProxy(type(SafeTimelockFactory).name)); } + function appAuthority(DeployedProxy) internal view returns (AppAuthority) { + return AppAuthority(_deployedProxy(type(AppAuthority).name)); + } + function appBeacon(DeployedBeacon) internal view returns (UpgradeableBeacon) { return UpgradeableBeacon(_deployedBeacon(type(App).name)); } @@ -151,6 +156,10 @@ library Env { return SafeTimelockFactory(_deployedImpl(type(SafeTimelockFactory).name)); } + function appAuthority(DeployedImpl) internal view returns (AppAuthority) { + return AppAuthority(_deployedImpl(type(AppAuthority).name)); + } + /// @notice TimelockControllerImpl — the clone-master for Timelocks created /// by SafeTimelockFactory. Not behind a proxy; deployed directly. function timelockControllerImpl() internal view returns (TimelockControllerImpl) { diff --git a/script/releases/v1.0.4-init/1-deployContracts.s.sol b/script/releases/v1.0.4-init/1-deployContracts.s.sol index 2351973..a354d88 100644 --- a/script/releases/v1.0.4-init/1-deployContracts.s.sol +++ b/script/releases/v1.0.4-init/1-deployContracts.s.sol @@ -23,6 +23,7 @@ import {IAppController} from "../../../src/interfaces/IAppController.sol"; import {IComputeAVSRegistrar} from "../../../src/interfaces/IComputeAVSRegistrar.sol"; import {IComputeOperator} from "../../../src/interfaces/IComputeOperator.sol"; import {ISafeTimelockFactory} from "../../../src/interfaces/ISafeTimelockFactory.sol"; +import {IAppAuthority} from "../../../src/interfaces/IAppAuthority.sol"; /** * Purpose: use an EOA to deploy all compute contracts. @@ -84,9 +85,10 @@ contract Deploy is EOADeployer { _computeAVSRegistrar: IComputeAVSRegistrar(address(computeAVSRegistrarProxy)), _computeOperator: IComputeOperator(address(computeOperatorProxy)), _appBeacon: appBeacon, - // v1.0.4 predates SafeTimelockFactory. Historical script kept - // compilable against the current constructor; this path never runs. - _safeTimelockFactory: ISafeTimelockFactory(address(0)) + // v1.0.4 predates SafeTimelockFactory + AppAuthority. Historical script + // kept compilable against the current constructor; this path never runs. + _safeTimelockFactory: ISafeTimelockFactory(address(0)), + _appAuthority: IAppAuthority(address(0)) }); // Upgrade proxies using ProxyAdmin diff --git a/script/releases/v1.1.1-app-suspension/1-deployAppControllerImpl.s.sol b/script/releases/v1.1.1-app-suspension/1-deployAppControllerImpl.s.sol index 08c2680..a5d6794 100644 --- a/script/releases/v1.1.1-app-suspension/1-deployAppControllerImpl.s.sol +++ b/script/releases/v1.1.1-app-suspension/1-deployAppControllerImpl.s.sol @@ -8,6 +8,7 @@ import "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol"; import {AppController} from "../../../src/AppController.sol"; import {ISafeTimelockFactory} from "../../../src/interfaces/ISafeTimelockFactory.sol"; +import {IAppAuthority} from "../../../src/interfaces/IAppAuthority.sol"; /** * Purpose: deploy new AppController implementation with suspension functionality @@ -28,8 +29,9 @@ contract DeployAppControllerImpl is EOADeployer { _computeAVSRegistrar: Env.proxy.computeAVSRegistrar(), _computeOperator: Env.proxy.computeOperator(), _appBeacon: Env.beacon.appBeacon(), - // v1.1.1 predates SafeTimelockFactory. Historical script; never runs again. - _safeTimelockFactory: ISafeTimelockFactory(address(0)) + // v1.1.1 predates SafeTimelockFactory + AppAuthority. Historical script; never runs again. + _safeTimelockFactory: ISafeTimelockFactory(address(0)), + _appAuthority: IAppAuthority(address(0)) }); // Register new implementation in Env system diff --git a/script/releases/v1.4.0-isolated-billing/1-deployAppControllerImpl.s.sol b/script/releases/v1.4.0-isolated-billing/1-deployAppControllerImpl.s.sol index 96b57e0..4b59b87 100644 --- a/script/releases/v1.4.0-isolated-billing/1-deployAppControllerImpl.s.sol +++ b/script/releases/v1.4.0-isolated-billing/1-deployAppControllerImpl.s.sol @@ -8,6 +8,7 @@ import "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol"; import {AppController} from "../../../src/AppController.sol"; import {ISafeTimelockFactory} from "../../../src/interfaces/ISafeTimelockFactory.sol"; +import {IAppAuthority} from "../../../src/interfaces/IAppAuthority.sol"; /** * Purpose: deploy new AppController implementation with isolated billing functionality @@ -28,8 +29,9 @@ contract DeployAppControllerImpl is EOADeployer { _computeAVSRegistrar: Env.proxy.computeAVSRegistrar(), _computeOperator: Env.proxy.computeOperator(), _appBeacon: Env.beacon.appBeacon(), - // v1.4.0 predates SafeTimelockFactory. Historical script; never runs again. - _safeTimelockFactory: ISafeTimelockFactory(address(0)) + // v1.4.0 predates SafeTimelockFactory + AppAuthority. Historical script; never runs again. + _safeTimelockFactory: ISafeTimelockFactory(address(0)), + _appAuthority: IAppAuthority(address(0)) }); // Register new implementation in Env system diff --git a/script/releases/v1.5.0-governance/1-deployGovernanceContracts.s.sol b/script/releases/v1.5.0-governance/1-deployGovernanceContracts.s.sol index eb3d597..7c23ddf 100644 --- a/script/releases/v1.5.0-governance/1-deployGovernanceContracts.s.sol +++ b/script/releases/v1.5.0-governance/1-deployGovernanceContracts.s.sol @@ -10,7 +10,9 @@ import "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol"; import {AppController} from "../../../src/AppController.sol"; import {SafeTimelockFactory} from "../../../src/factories/SafeTimelockFactory.sol"; import {TimelockControllerImpl} from "../../../src/governance/TimelockControllerImpl.sol"; +import {AppAuthority} from "../../../src/governance/AppAuthority.sol"; import {ISafeTimelockFactory} from "../../../src/interfaces/ISafeTimelockFactory.sol"; +import {IAppAuthority} from "../../../src/interfaces/IAppAuthority.sol"; /** * @title DeployGovernanceContracts (v1.5.0-governance phase 1) @@ -58,7 +60,24 @@ contract DeployGovernanceContracts is EOADeployer { ); deployProxy({name: type(SafeTimelockFactory).name, deployedTo: address(safeTimelockFactoryProxy)}); - // 4. New AppController implementation — wired to the factory proxy. + // 4. AppAuthority implementation — consumer-bound to the existing + // AppController proxy. AppAuthority's `consumer` immutable is the + // AppController proxy address; AppController at the proxy already + // exists (it's the v1.4.0 AppController we're about to upgrade). + AppAuthority appAuthorityImpl = new AppAuthority(address(Env.proxy.appController())); + deployImpl({name: type(AppAuthority).name, deployedTo: address(appAuthorityImpl)}); + + // 5. AppAuthority proxy. + TransparentUpgradeableProxy appAuthorityProxy = new TransparentUpgradeableProxy( + address(appAuthorityImpl), address(Env.proxyAdmin()), abi.encodeCall(AppAuthority.initialize, ()) + ); + deployProxy({name: type(AppAuthority).name, deployedTo: address(appAuthorityProxy)}); + + // 6. New AppController implementation — wired to the factory proxy + // and the AppAuthority proxy. The AppAuthority immutable is the + // proxy address; the impl's consumer check authenticates calls + // from AppController's proxy, which is what the upgraded impl + // (delegatecalled from the proxy) will look like. AppController newAppControllerImpl = new AppController({ _version: Env.deployVersion(), _permissionController: Env.permissionController(), @@ -66,7 +85,8 @@ contract DeployGovernanceContracts is EOADeployer { _computeAVSRegistrar: Env.proxy.computeAVSRegistrar(), _computeOperator: Env.proxy.computeOperator(), _appBeacon: Env.beacon.appBeacon(), - _safeTimelockFactory: ISafeTimelockFactory(address(safeTimelockFactoryProxy)) + _safeTimelockFactory: ISafeTimelockFactory(address(safeTimelockFactoryProxy)), + _appAuthority: IAppAuthority(address(appAuthorityProxy)) }); deployImpl({name: type(AppController).name, deployedTo: address(newAppControllerImpl)}); @@ -136,6 +156,10 @@ contract DeployGovernanceContracts is EOADeployer { address(Env.proxy.safeTimelockFactory()), "safeTimelockFactory mismatch" ); + assertEq(address(appImpl.appAuthority()), address(Env.proxy.appAuthority()), "appAuthority mismatch"); + + AppAuthority authorityImpl = Env.impl.appAuthority(); + assertEq(authorityImpl.consumer(), address(Env.proxy.appController()), "AppAuthority consumer mismatch"); } function _getProxyImpl(address proxy) internal view returns (address) { diff --git a/src/AppController.sol b/src/AppController.sol index 0361511..eaea142 100644 --- a/src/AppController.sol +++ b/src/AppController.sol @@ -2,9 +2,8 @@ pragma solidity ^0.8.27; import {Create2} from "@openzeppelin/contracts/utils/Create2.sol"; -import {OwnableUpgradeable} from "@openzeppelin-upgrades/contracts/access/OwnableUpgradeable.sol"; -import {Initializable} from "@openzeppelin-upgrades/contracts/proxy/utils/Initializable.sol"; import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; +import {Initializable} from "@openzeppelin-upgrades/contracts/proxy/utils/Initializable.sol"; import {SignatureUtilsMixin} from "@eigenlayer-contracts/src/contracts/mixins/SignatureUtilsMixin.sol"; import {IPermissionController} from "@eigenlayer-contracts/src/contracts/interfaces/IPermissionController.sol"; import {PermissionControllerMixin} from "@eigenlayer-contracts/src/contracts/mixins/PermissionControllerMixin.sol"; @@ -17,7 +16,7 @@ import {IComputeAVSRegistrar} from "./interfaces/IComputeAVSRegistrar.sol"; import {IComputeOperator} from "./interfaces/IComputeOperator.sol"; import {AppControllerStorage} from "./storage/AppControllerStorage.sol"; import {IAppController} from "./interfaces/IAppController.sol"; -import {BeaconProxy} from "@openzeppelin/contracts/proxy/beacon/BeaconProxy.sol"; +import {IAppAuthority} from "./interfaces/IAppAuthority.sol"; import {IBeacon} from "@openzeppelin/contracts/proxy/beacon/IBeacon.sol"; import {IApp} from "./interfaces/IApp.sol"; import {ISafeTimelockFactory} from "./interfaces/ISafeTimelockFactory.sol"; @@ -37,14 +36,24 @@ contract AppController is /// @notice Modifier to require the caller hold `role` on `app` (or ADMIN, /// which is a superset). Reverts with InvalidTeamRole otherwise. - modifier onlyRoleOrAdmin(IApp app, TeamRole role) { - if (!_hasRoleOrAdmin(app, role, msg.sender)) revert InvalidTeamRole(); + /// @dev Role state lives in AppAuthority; AppController just queries it. + modifier onlyRoleOrAdmin(IApp app, IAppAuthority.Role role) { + if (!appAuthority.hasRoleOrAdmin(app, role, msg.sender)) revert InvalidTeamRole(); _; } /// @notice Modifier to require the caller is an ADMIN on `app`. modifier onlyAdmin(IApp app) { - if (!_teamRoles[app][TeamRole.ADMIN].contains(msg.sender)) revert InvalidTeamRole(); + if (!appAuthority.hasRole(app, IAppAuthority.Role.ADMIN, msg.sender)) revert InvalidTeamRole(); + _; + } + + /// @notice Modifier to require the caller is the current owner of `app`. + /// Used on critical ops — ADMIN alone is not enough. Ownership + /// lives in AppAuthority, mirrored into `_appConfigs[app].creator` + /// for billing, events, and ABI stability. + modifier onlyCreator(IApp app) { + if (!appAuthority.isScopeOwner(app, msg.sender)) revert NotCreator(); _; } @@ -74,11 +83,14 @@ contract AppController is IComputeAVSRegistrar _computeAVSRegistrar, IComputeOperator _computeOperator, IBeacon _appBeacon, - ISafeTimelockFactory _safeTimelockFactory + ISafeTimelockFactory _safeTimelockFactory, + IAppAuthority _appAuthority ) SignatureUtilsMixin(_version) PermissionControllerMixin(_permissionController) - AppControllerStorage(_releaseManager, _computeOperator, _computeAVSRegistrar, _appBeacon, _safeTimelockFactory) + AppControllerStorage( + _releaseManager, _computeOperator, _computeAVSRegistrar, _appBeacon, _safeTimelockFactory, _appAuthority + ) { _disableInitializers(); } @@ -134,31 +146,34 @@ contract AppController is } /// @inheritdoc IAppController - function upgradeApp(IApp app, Release calldata release) external onlyAdmin(app) appIsActive(app) returns (uint256) { - // Critical op. ADMIN is required (modifier); when the app is - // timelocked, further narrow to msg.sender == creator so only the - // Timelock (via schedule → execute) can trigger. A co-admin cannot - // bypass the delay even with the ADMIN role. - if (_appConfigs[app].timelocked) { - require(msg.sender == _appConfigs[app].creator, InvalidPermissions()); - } + function upgradeApp(IApp app, Release calldata release) + external + onlyCreator(app) + appIsActive(app) + returns (uint256) + { + // Critical op: only the current owner (`creator`) may call. For + // timelocked apps, `creator` is the Timelock itself, which forces + // the call through schedule → execute. For non-timelocked apps the + // owner acts directly. Co-ADMINs cannot upgrade. return _upgradeApp(app, release); } /// @inheritdoc IAppController - function transferOwnership(IApp app, address newOwner) external onlyAdmin(app) appExists(app) { + function transferOwnership(IApp app, address newOwner) external onlyCreator(app) appExists(app) { require(newOwner != address(0), InvalidPermissions()); AppConfigStorage storage config = _appConfigs[app]; + address previousOwner = config.creator; - // Critical op: timelocked apps may only be moved by the Timelock - // itself. Without this, any ADMIN could hand the app to a new owner - // instantly, dropping timelocked protection. - if (config.timelocked) { - require(msg.sender == config.creator, InvalidPermissions()); - } + // Rotate ownership + ADMIN atomically in AppAuthority. AppAuthority + // enforces the add-before-remove ordering so the ADMIN set never + // empties during the swap. + appAuthority.transferScopeOwnership(app, newOwner); - address previousOwner = config.creator; + // Mirror the owner into our local cache for billing / App.initialize + // / event stability. AppAuthority is the source of truth; this is + // the cache. config.creator = newOwner; // Flip the flag based on the new owner. Non-factory addresses (EOAs, @@ -167,13 +182,6 @@ contract AppController is // Timelocks enable it. config.timelocked = address(safeTimelockFactory) != address(0) && safeTimelockFactory.isTimelock(newOwner); - // Grant ADMIN to the new owner so they can govern the app going - // forward. Without this the new owner would inherit ownership in - // the storage field but have no ADMIN privileges through the gates. - if (_teamRoles[app][TeamRole.ADMIN].add(newOwner)) { - emit TeamRoleGranted(app, TeamRole.ADMIN, newOwner); - } - // ISOLATED billing apps bill the app address, not the creator, so // ownership transfer has no effect on billing accounting. DEFAULT // billing apps bill the creator, so we need to move the active-app @@ -190,7 +198,7 @@ contract AppController is /// @inheritdoc IAppController function updateAppMetadataURI(IApp app, string calldata metadataURI) external - onlyRoleOrAdmin(app, TeamRole.DEVELOPER) + onlyRoleOrAdmin(app, IAppAuthority.Role.DEVELOPER) appExists(app) { emit AppMetadataURIUpdated(app, metadataURI); @@ -202,7 +210,7 @@ contract AppController is } /// @inheritdoc IAppController - function stopApp(IApp app) external onlyRoleOrAdmin(app, TeamRole.PAUSER) { + function stopApp(IApp app) external onlyRoleOrAdmin(app, IAppAuthority.Role.PAUSER) { AppConfigStorage storage config = _appConfigs[app]; require(config.status == AppStatus.STARTED, InvalidAppStatus()); config.status = AppStatus.STOPPED; @@ -211,13 +219,9 @@ contract AppController is } /// @inheritdoc IAppController - function terminateApp(IApp app) external onlyAdmin(app) appIsActive(app) { - // Critical op: timelocked apps can only be terminated via the - // Timelock. Termination is irreversible; bypassing the queue - // defeats the entire purpose of handing ownership to a Timelock. - if (_appConfigs[app].timelocked) { - require(msg.sender == _appConfigs[app].creator, InvalidPermissions()); - } + function terminateApp(IApp app) external onlyCreator(app) appIsActive(app) { + // Critical op: only the current owner (`creator`) may terminate. + // Termination is irreversible; a co-ADMIN cannot trigger it. _terminateApp(app); } @@ -259,77 +263,43 @@ contract AppController is _setMaxActiveAppsPerUser(account, 0); } - /// TEAM ROLE MANAGEMENT - - /// @inheritdoc IAppController - function grantTeamRole(IApp app, TeamRole role, address account) external onlyAdmin(app) { - // Grants of the critical ADMIN role on a timelocked app must go - // through the Timelock queue — admitting a new admin instantly - // would effectively bypass the delay on every future upgrade. - // - // Grants of operational roles (PAUSER / DEVELOPER) are NOT - // timelock-gated on purpose: the power granted is limited and - // always revocable by ADMIN (itself gated). This is what fixes - // audit finding A-2 — the asymmetry is intentional and bounded. - if (role == TeamRole.ADMIN && _appConfigs[app].timelocked) { - if (msg.sender != _appConfigs[app].creator) revert InvalidTeamRole(); - } - - if (_teamRoles[app][role].add(account)) { - emit TeamRoleGranted(app, role, account); - } - } - - /// @inheritdoc IAppController - function revokeTeamRole(IApp app, TeamRole role, address account) external onlyAdmin(app) { - // Mirror of grant: revoking ADMIN on a timelocked app must go - // through the Timelock itself. Fixes audit finding A-3 — without - // this gate, any co-admin could strip the Timelock in one tx. - if (role == TeamRole.ADMIN && _appConfigs[app].timelocked) { - if (msg.sender != _appConfigs[app].creator) revert InvalidTeamRole(); - } - - if (role == TeamRole.ADMIN && _teamRoles[app][TeamRole.ADMIN].length() == 1) { - revert CannotRevokeLastAdmin(); - } + /// TEAM ROLE MANAGEMENT — delegated to AppAuthority + /// + /// Role management (grant / revoke / renounce / hasRole) lives in + /// AppAuthority directly. Clients call `appAuthority.grantRole(app, ...)` + /// etc. AppController does not re-expose those entry points; doing so + /// would add an extra hop with no auth delta and double the audit + /// surface. See IAppAuthority for the role API. + + /// @inheritdoc IAppController + function migrateAppsToAppAuthority(IApp[] calldata apps) external checkCanCall(address(this)) { + // For every pre-v1.5.0 app: + // (1) If AppAuthority has no owner recorded, initialize the scope + // with AppController's cached `creator` field. + // (2) Seed AppAuthority's ADMIN role with the app's + // PermissionController admins (operational-only under Option 2; + // no critical-op exposure). + // Idempotent: re-running is safe because initializeScope reverts on + // reinit (handled), and grantRole is set-semantics. + uint256 n = apps.length; + IApp[] memory scopes = new IApp[](n); + address[][] memory allAdmins = new address[][](n); + + for (uint256 i = 0; i < n; i++) { + IApp app = apps[i]; + address cachedOwner = _appConfigs[app].creator; - if (_teamRoles[app][role].remove(account)) { - emit TeamRoleRevoked(app, role, account); - } - } + // Initialize the scope if not already initialized. scopeOwner + // returns address(0) for uninitialized scopes. + if (appAuthority.scopeOwner(app) == address(0) && cachedOwner != address(0)) { + appAuthority.initializeScope(app, cachedOwner); + } - /// @inheritdoc IAppController - function renounceTeamRole(IApp app, TeamRole role) external { - // No timelock gate on renounce — a member giving up their own - // rights never grows anyone else's power. Still blocked if it - // would leave the team with zero admins. - if (role == TeamRole.ADMIN && _teamRoles[app][TeamRole.ADMIN].length() == 1) { - revert CannotRevokeLastAdmin(); - } - if (_teamRoles[app][role].remove(msg.sender)) { - emit TeamRoleRevoked(app, role, msg.sender); + scopes[i] = app; + allAdmins[i] = permissionController.getAdmins(address(app)); } - } - /// @inheritdoc IAppController - function hasTeamRole(IApp app, TeamRole role, address account) external view returns (bool) { - return _teamRoles[app][role].contains(account); - } - - /// @inheritdoc IAppController - function migrateAdmins(IApp[] calldata apps) external checkCanCall(address(this)) { - for (uint256 i = 0; i < apps.length; i++) { - IApp app = apps[i]; - // Pull the current UAM admin set and seed it into our own ADMIN - // role. Idempotent: EnumerableSet.add returns false for entries - // already in the set, so repeated calls are safe. - address[] memory admins = permissionController.getAdmins(address(app)); - for (uint256 j = 0; j < admins.length; j++) { - if (_teamRoles[app][TeamRole.ADMIN].add(admins[j])) { - emit TeamRoleGranted(app, TeamRole.ADMIN, admins[j]); - } - } - } + appAuthority.migrateAdmins(scopes, allAdmins); } /// INTERNAL FUNCTIONS @@ -357,20 +327,14 @@ contract AppController is // Not doing this here leaves a window where any co-admin could run // upgradeApp / terminateApp before ownership is "transferred" — and // in fact createApp never involves transferOwnership at all. - // safeTimelockFactory may be address(0) on historical deployments - // (pre-v1.5.0); in that case isTimelock is guaranteed to return false - // via the interface contract, but we also defensively short-circuit. if (address(safeTimelockFactory) != address(0)) { _appConfigs[app].timelocked = safeTimelockFactory.isTimelock(msg.sender); } _allApps.add(address(app)); - // Seed the creator as initial ADMIN on the app's team. All subsequent - // ADMIN-gated calls (grant/revoke/upgrade/transfer/terminate) flow - // through this set. Without this bootstrap, a freshly created app - // would have zero admins and be unreachable. - _teamRoles[app][TeamRole.ADMIN].add(msg.sender); - emit TeamRoleGranted(app, TeamRole.ADMIN, msg.sender); + // Register the scope + seed creator as ADMIN in AppAuthority. All + // subsequent auth checks consult AppAuthority directly. + appAuthority.initializeScope(app, msg.sender); emit AppCreated(msg.sender, app, operatorSetId); @@ -378,15 +342,6 @@ contract AppController is _startApp(app); } - /** - * @notice Returns true if `account` holds `role` on `app`, OR holds - * ADMIN on `app` (ADMIN is always a superset of every other - * role). Used by operational-op modifiers. - */ - function _hasRoleOrAdmin(IApp app, TeamRole role, address account) internal view returns (bool) { - return _teamRoles[app][role].contains(account) || _teamRoles[app][TeamRole.ADMIN].contains(account); - } - /** * @notice Checks if an app status is not NONE * @param status The app status to check @@ -662,34 +617,28 @@ contract AppController is /// @inheritdoc ICallValidator /// @dev Schedule-time validation hook consumed by TimelockControllerImpl. - /// Rejects operations we can statically prove will revert at execute - /// time given the current on-chain state. For any call we can't - /// reason about here, returns true and lets PermissionController - /// enforce at runtime — conservative: schedule-time rejection must - /// be a superset of nothing, never of authorized paths. + /// AppController is a common Timelock target; a scheduled critical + /// op from a non-owner is doomed at execute time. Reject at + /// schedule time so the delay window isn't burned on a doomed op. + /// + /// Role-management selectors are NOT on this contract anymore — + /// they live on AppAuthority. Scheduling role ops targets + /// AppAuthority directly, which has its own ICallValidator surface + /// (if wired) to validate there. function canCall(address caller, bytes calldata data) external view returns (bool) { - if (data.length < 4) return true; + if (data.length < 36) return true; bytes4 selector = bytes4(data[:4]); - // For the sensitive ops gated by `if (timelocked) msg.sender == creator`, - // a schedule proposed by a non-owner Timelock will always revert. We - // block it at schedule time so the delay window isn't consumed by a - // doomed op. + // Owner-gated critical ops (upgrade / terminate / transferOwnership). if ( selector == this.upgradeApp.selector || selector == this.terminateApp.selector || selector == this.transferOwnership.selector ) { - // Decode the first arg (IApp) and check ownership if timelocked. - // abi.decode skips the 4-byte selector. IApp app = abi.decode(data[4:36], (IApp)); - AppConfigStorage storage config = _appConfigs[app]; - if (config.timelocked && caller != config.creator) { - return false; - } + if (!appAuthority.isScopeOwner(app, caller)) return false; } - // terminateAppByAdmin now refuses timelocked apps unconditionally; a - // scheduled terminateAppByAdmin against a timelocked app is doomed. + // terminateAppByAdmin refuses timelocked apps unconditionally. if (selector == this.terminateAppByAdmin.selector) { IApp app = abi.decode(data[4:36], (IApp)); if (_appConfigs[app].timelocked) return false; diff --git a/src/governance/AppAuthority.sol b/src/governance/AppAuthority.sol new file mode 100644 index 0000000..8e36f53 --- /dev/null +++ b/src/governance/AppAuthority.sol @@ -0,0 +1,181 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.27; + +import {Initializable} from "@openzeppelin-upgrades/contracts/proxy/utils/Initializable.sol"; +import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; +import {IAppAuthority} from "../interfaces/IAppAuthority.sol"; +import {IApp} from "../interfaces/IApp.sol"; + +/** + * @title AppAuthority + * @notice Per-app ownership + RBAC state, extracted from AppController. + * AppController is the sole consumer — it authenticates callers of + * app-lifecycle operations and delegates the auth state here. + * + * @dev The contract enforces the Option-2 invariants by construction: + * - Only the owner may grant/revoke/transfer ADMIN. + * - The owner is always ADMIN on their scope, cannot renounce or + * self-revoke. + * - `transferScopeOwnership` is the only path that rotates the owner, + * and it adds the new owner + removes the previous owner from ADMIN + * atomically. + * + * @dev Why extract: the Timelock guarantee binds iff the admin set the + * critical-op gate trusts is a set the contract enforcing the gate + * controls. AppController delegates to this contract; this contract's + * mutation surface is minimal and fully enumerable. Moving the auth + * logic here makes the trust boundary explicit and shrinks the audit + * surface of AppController accordingly. + */ +contract AppAuthority is Initializable, IAppAuthority { + using EnumerableSet for EnumerableSet.AddressSet; + + /// @notice The consumer contract authorized to call mutation methods that + /// pass through app-lifecycle events (scope initialization, + /// ownership transfer, admin migration). + /// @dev Set at construction. Role-level grants/revokes/renounces are NOT + /// consumer-gated — users interact with them directly. + address public immutable consumer; + + /// @notice Per-scope owner. Zero means uninitialized. + mapping(IApp => address) internal _scopeOwner; + + /// @notice Per-scope, per-role set of holders. + mapping(IApp => mapping(Role => EnumerableSet.AddressSet)) internal _roles; + + /** + * @dev Reserved storage for future variables. + * See https://docs.openzeppelin.com/contracts/4.x/upgradeable#storage_gaps + */ + uint256[48] private __gap; + + modifier onlyConsumer() { + if (msg.sender != consumer) revert OnlyConsumer(); + _; + } + + constructor(address _consumer) { + if (_consumer == address(0)) revert ZeroAddress(); + consumer = _consumer; + _disableInitializers(); + } + + /// @notice No-op initializer. All state is derived at construction + /// (consumer immutable) or via `initializeScope`. Exists so the + /// proxy can be deployed via upgradeAndCall. + function initialize() external initializer {} + + /// @inheritdoc IAppAuthority + function initializeScope(IApp scope, address owner) external onlyConsumer { + if (owner == address(0)) revert ZeroAddress(); + if (_scopeOwner[scope] != address(0)) revert ScopeAlreadyInitialized(); + + _scopeOwner[scope] = owner; + _roles[scope][Role.ADMIN].add(owner); + + emit ScopeInitialized(scope, owner); + emit RoleGranted(scope, Role.ADMIN, owner); + } + + /// @inheritdoc IAppAuthority + function transferScopeOwnership(IApp scope, address newOwner) external onlyConsumer { + if (newOwner == address(0)) revert ZeroAddress(); + + address previousOwner = _scopeOwner[scope]; + if (previousOwner == address(0)) revert ScopeNotInitialized(); + + _scopeOwner[scope] = newOwner; + + // Add new owner first so the ADMIN set never empties between the + // two writes (preserves the last-admin invariant during the swap). + if (_roles[scope][Role.ADMIN].add(newOwner)) { + emit RoleGranted(scope, Role.ADMIN, newOwner); + } + if (_roles[scope][Role.ADMIN].remove(previousOwner)) { + emit RoleRevoked(scope, Role.ADMIN, previousOwner); + } + + emit ScopeOwnershipTransferred(scope, previousOwner, newOwner); + } + + /// @inheritdoc IAppAuthority + function grantRole(IApp scope, Role role, address account) external { + if (account == address(0)) revert ZeroAddress(); + + if (role == Role.ADMIN) { + if (msg.sender != _scopeOwner[scope]) revert NotScopeOwner(); + } else { + if (!_roles[scope][Role.ADMIN].contains(msg.sender)) revert InvalidRole(); + } + + if (_roles[scope][role].add(account)) { + emit RoleGranted(scope, role, account); + } + } + + /// @inheritdoc IAppAuthority + function revokeRole(IApp scope, Role role, address account) external { + if (role == Role.ADMIN) { + if (msg.sender != _scopeOwner[scope]) revert NotScopeOwner(); + if (account == _scopeOwner[scope]) revert CannotRemoveOwnerAdmin(); + } else { + if (!_roles[scope][Role.ADMIN].contains(msg.sender)) revert InvalidRole(); + } + + if (role == Role.ADMIN && _roles[scope][Role.ADMIN].length() == 1) { + revert CannotRemoveLastAdmin(); + } + + if (_roles[scope][role].remove(account)) { + emit RoleRevoked(scope, role, account); + } + } + + /// @inheritdoc IAppAuthority + function renounceRole(IApp scope, Role role) external { + if (role == Role.ADMIN) { + if (msg.sender == _scopeOwner[scope]) revert CannotRemoveOwnerAdmin(); + if (_roles[scope][Role.ADMIN].length() == 1) revert CannotRemoveLastAdmin(); + } + + if (_roles[scope][role].remove(msg.sender)) { + emit RoleRevoked(scope, role, msg.sender); + } + } + + /// @inheritdoc IAppAuthority + function migrateAdmins(IApp[] calldata scopes, address[][] calldata admins) external onlyConsumer { + require(scopes.length == admins.length, InvalidRole()); + for (uint256 i = 0; i < scopes.length; i++) { + IApp scope = scopes[i]; + address[] calldata scopeAdmins = admins[i]; + for (uint256 j = 0; j < scopeAdmins.length; j++) { + address adm = scopeAdmins[j]; + if (adm == address(0)) continue; + if (_roles[scope][Role.ADMIN].add(adm)) { + emit RoleGranted(scope, Role.ADMIN, adm); + } + } + } + } + + /// @inheritdoc IAppAuthority + function scopeOwner(IApp scope) external view returns (address) { + return _scopeOwner[scope]; + } + + /// @inheritdoc IAppAuthority + function isScopeOwner(IApp scope, address account) external view returns (bool) { + return _scopeOwner[scope] == account; + } + + /// @inheritdoc IAppAuthority + function hasRole(IApp scope, Role role, address account) external view returns (bool) { + return _roles[scope][role].contains(account); + } + + /// @inheritdoc IAppAuthority + function hasRoleOrAdmin(IApp scope, Role role, address account) external view returns (bool) { + return _roles[scope][role].contains(account) || _roles[scope][Role.ADMIN].contains(account); + } +} diff --git a/src/interfaces/IAppAuthority.sol b/src/interfaces/IAppAuthority.sol new file mode 100644 index 0000000..e01ad22 --- /dev/null +++ b/src/interfaces/IAppAuthority.sol @@ -0,0 +1,146 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import {IApp} from "./IApp.sol"; + +/** + * @title IAppAuthority + * @notice Per-app RBAC + owner registry. Centralizes the auth surface that + * AppController previously owned inline — scope ownership (the + * "owner" concept formerly called `creator`) and team roles + * (ADMIN / PAUSER / DEVELOPER). + * + * @dev Design invariants: + * 1. Every scope (app) has at most one owner. `scopeOwner[app] == 0` + * means the scope has not been initialized. + * 2. The owner is always ADMIN on their scope. `transferOwnership` is + * the only path that rotates the owner; it adds the new owner to + * ADMIN and removes the previous owner atomically. + * 3. ADMIN role mutations (grant/revoke) are owner-only. The owner + * cannot renounce or self-revoke ADMIN — `transferOwnership` is the + * only path out. + * 4. Critical ops gated at the consumer layer (upgrade/transfer/ + * terminate on AppController) use `isScopeOwner` — NOT + * `hasRole(ADMIN)`. ADMIN is operational-only in this model. + * 5. `canCall` validates schedule-time that a Timelock operation + * targeting a consumer contract will pass runtime gates, so a + * doomed op doesn't burn a pending-op slot on the Timelock. + */ +interface IAppAuthority { + /// @notice Thrown when a caller is not the current owner of the scope. + error NotScopeOwner(); + + /// @notice Thrown when a caller lacks the required role. + error InvalidRole(); + + /// @notice Thrown when the owner attempts to renounce or self-revoke ADMIN. + error CannotRemoveOwnerAdmin(); + + /// @notice Thrown when revoking/renouncing ADMIN would leave zero admins. + error CannotRemoveLastAdmin(); + + /// @notice Thrown when a scope already has an owner and an attempt is + /// made to initialize it. + error ScopeAlreadyInitialized(); + + /// @notice Thrown when an attempted operation requires the scope to be + /// initialized but it isn't. + error ScopeNotInitialized(); + + /// @notice Thrown when the caller is not the authorized consumer + /// (AppController) and a consumer-only method is called. + error OnlyConsumer(); + + /// @notice Thrown when a zero address is passed where non-zero is required. + error ZeroAddress(); + + /// @notice Emitted when a scope is first initialized with an owner. + event ScopeInitialized(IApp indexed scope, address indexed owner); + + /// @notice Emitted when scope ownership is transferred. + event ScopeOwnershipTransferred(IApp indexed scope, address indexed previousOwner, address indexed newOwner); + + /// @notice Emitted when a role is granted on a scope. + event RoleGranted(IApp indexed scope, Role indexed role, address indexed account); + + /// @notice Emitted when a role is revoked on a scope (by owner, admin, or self-renounce). + event RoleRevoked(IApp indexed scope, Role indexed role, address indexed account); + + /// @notice Per-app team roles. ADMIN is operational-only; it does NOT + /// convey power over critical ops. PAUSER and DEVELOPER are + /// delegable subsets of ADMIN's operational surface. + enum Role { + ADMIN, + PAUSER, + DEVELOPER + } + + /** + * @notice Initialize a scope's owner. May only be called by the consumer + * (AppController) once per scope, at app creation time. Grants + * the owner ADMIN atomically. + * @dev Consumer-only; reverts `OnlyConsumer` from any other caller. + */ + function initializeScope(IApp scope, address owner) external; + + /** + * @notice Transfer ownership of a scope to a new address. Atomically + * adds `newOwner` to ADMIN and removes `previousOwner` from + * ADMIN. Consumer-only — the consumer contract is responsible + * for authenticating the caller. + * @dev Consumer-only; reverts `OnlyConsumer` from any other caller. + */ + function transferScopeOwnership(IApp scope, address newOwner) external; + + /** + * @notice Grant a role to `account` on `scope`. + * @dev For `role == ADMIN`: caller must be the scope's owner. + * @dev For non-ADMIN: caller must hold ADMIN on the scope. + */ + function grantRole(IApp scope, Role role, address account) external; + + /** + * @notice Revoke a role from `account` on `scope`. + * @dev For `role == ADMIN`: caller must be the scope's owner. The owner + * cannot revoke their own ADMIN. Revoking below the last-ADMIN + * floor reverts. + * @dev For non-ADMIN: caller must hold ADMIN on the scope. + */ + function revokeRole(IApp scope, Role role, address account) external; + + /** + * @notice Renounce your own role on `scope`. + * @dev The owner cannot renounce ADMIN. Non-owners may renounce ADMIN + * as long as the set doesn't empty. + */ + function renounceRole(IApp scope, Role role) external; + + /** + * @notice Migrate admins from an external source (e.g., legacy + * PermissionController) into the ADMIN role on each supplied + * scope. Consumer-only. Idempotent. + * @dev For each (scope, admin) pair, grants ADMIN if not already present. + * Refuses to migrate into scopes that have already been initialized + * if the admin would conflict with the owner invariant — specifically, + * migrated admins are operational-only under this model, so the + * downgraded-from-critical-op semantics are already applied. + */ + function migrateAdmins(IApp[] calldata scopes, address[][] calldata admins) external; + + /// @notice The current owner of a scope, or address(0) if uninitialized. + function scopeOwner(IApp scope) external view returns (address); + + /// @notice Whether `account` is the current owner of `scope`. + function isScopeOwner(IApp scope, address account) external view returns (bool); + + /// @notice Whether `account` holds `role` on `scope`. + function hasRole(IApp scope, Role role, address account) external view returns (bool); + + /// @notice Whether `account` holds `role` on `scope`, OR holds ADMIN + /// (which is a superset for operational roles). + function hasRoleOrAdmin(IApp scope, Role role, address account) external view returns (bool); + + /// @notice The consumer contract (AppController) that owns the + /// consumer-only mutation surface. + function consumer() external view returns (address); +} diff --git a/src/interfaces/IAppController.sol b/src/interfaces/IAppController.sol index cada6f1..3aeeb55 100644 --- a/src/interfaces/IAppController.sol +++ b/src/interfaces/IAppController.sol @@ -29,11 +29,14 @@ interface IAppController { /// @notice Thrown when trying to suspend an account that still has active apps error AccountHasActiveApps(); - /// @notice Thrown when a caller lacks the required per-app team role. - error InvalidTeamRole(); + /// @notice Thrown when a caller is not the current owner (creator) of an app. + /// Critical ops (upgrade/transfer/terminate) are owner-gated. + error NotCreator(); - /// @notice Thrown when revoking/renouncing ADMIN would leave the team with zero admins. - error CannotRevokeLastAdmin(); + /// @notice Thrown when a caller lacks the required operational role on an + /// app. Operational roles (PAUSER, DEVELOPER) are queried from + /// AppAuthority; this error is raised when the queried check fails. + error InvalidTeamRole(); /// @notice Emitted when a new app is successfully created event AppCreated(address indexed creator, IApp indexed app, uint32 operatorSetId); @@ -68,12 +71,6 @@ interface IAppController { /// @notice Emitted when app ownership is transferred to a new address event AppOwnershipTransferred(IApp indexed app, address indexed previousOwner, address indexed newOwner); - /// @notice Emitted when a team role is granted on an app. - event TeamRoleGranted(IApp indexed app, TeamRole indexed role, address indexed account); - - /// @notice Emitted when a team role is revoked on an app (by an admin or via renounce). - event TeamRoleRevoked(IApp indexed app, TeamRole indexed role, address indexed account); - /// @notice Enum for app status enum AppStatus { NONE, // App has not been created yet @@ -89,29 +86,10 @@ interface IAppController { ISOLATED // Billed to the app's own address } - /// @notice Per-app team roles. - /// @dev ADMIN is the authoritative role for CRITICAL app-level ops - /// (upgradeApp / transferOwnership / terminateApp) and for managing - /// team membership. PAUSER and DEVELOPER are OPERATIONAL: - /// - /// - PAUSER → may call stopApp - /// - DEVELOPER → may call updateAppMetadataURI - /// - ADMIN → may call everything operational, plus all - /// critical ops, plus grant/revoke team roles, - /// plus transferOwnership. - /// - /// Roles live in `AppController` storage, NOT in - /// PermissionController. That's intentional — the Timelock - /// guarantees only bind if the admin set the gates trust is a - /// set this contract controls. PermissionController admins are - /// still used for platform-admin-level functions on this - /// contract itself (setMaxActiveAppsPerUser, setMaxGlobalActiveApps, - /// migrateAdmins, terminateAppByAdmin, suspend). - enum TeamRole { - ADMIN, - PAUSER, - DEVELOPER - } + // Team-role enum lives in IAppAuthority (IAppAuthority.Role). AppController + // consults AppAuthority for operational role checks (PAUSER, DEVELOPER) + // and is the only caller of consumer-gated methods on AppAuthority + // (initializeScope, transferScopeOwnership, migrateAdmins). /** * @notice A struct containing a release and its environment @@ -203,10 +181,10 @@ interface IAppController { * @param app The app to upgrade with the release * @param release The release to upgrade to * @return releaseId The unique identifier for the published release - * @dev Caller must hold ADMIN on the app's team. - * @dev When the app is timelocked, caller must additionally be the creator - * (the Timelock itself) — in practice, this forces the call to come - * from a scheduled → executed Timelock operation. + * @dev Caller must be the app's current owner (`creator`). For timelocked + * apps this is the Timelock itself, so the call is forced through + * schedule → execute. For non-timelocked apps (EOA / Safe-owned) the + * owner acts directly. Co-ADMINs cannot upgrade. * @dev The rms release must have exactly one artifact, with the digest being the docker * image digest and the registry being the docker registry it is stored at. * @dev The env must be a JSON marshalled bytes representing the public environment variables for the app. @@ -220,12 +198,12 @@ interface IAppController { * @notice Transfers app ownership to a new address. Critical op. * @param app The app to transfer ownership of * @param newOwner The new owner address - * @dev Caller must hold ADMIN on the app's team. + * @dev Caller must be the app's current owner (`creator`). * @dev When `newOwner` is a factory-deployed Timelock the app's `timelocked` * flag is flipped to true; otherwise it's cleared. - * @dev When the app is already timelocked, only the Timelock itself (via - * schedule → execute) may transfer. The new owner is automatically - * granted ADMIN on the new team so they can govern going forward. + * @dev The new owner is atomically granted ADMIN on the team and the + * previous owner is atomically removed from ADMIN. This is the only + * path that rotates ADMIN membership for the owner. */ function transferOwnership(IApp app, address newOwner) external; @@ -256,9 +234,8 @@ interface IAppController { /** * @notice Terminates an app permanently. Critical op. * @param app The app to terminate - * @dev Caller must hold ADMIN on the app's team. - * @dev When the app is timelocked, caller must additionally be the - * creator (Timelock). + * @dev Caller must be the app's current owner (`creator`). Co-ADMINs + * cannot terminate. * @dev Once terminated, no further write operations are allowed. */ function terminateApp(IApp app) external; @@ -282,49 +259,17 @@ interface IAppController { function suspend(address account, IApp[] calldata apps) external; /** - * @notice Grant a team role to `account` on `app`. - * @dev Caller must be ADMIN on the app. - * @dev When the team is timelocked AND `role == ADMIN`, the caller must - * additionally be the creator (the Timelock itself) — which in - * practice means going through schedule → execute. Grants of PAUSER - * / DEVELOPER on a timelocked app do NOT require going through the - * Timelock; those are operational powers the admin can revoke at - * any time. Fixes audit finding A-2. - */ - function grantTeamRole(IApp app, TeamRole role, address account) external; - - /** - * @notice Revoke a team role from `account` on `app`. - * @dev Caller must be ADMIN on the app. - * @dev When the team is timelocked AND `role == ADMIN`, the caller must - * additionally be the creator. Revoking any ADMIN below the - * last-admin floor reverts. Operational role revocations on a - * timelocked app are NOT timelock-gated. Fixes audit finding A-3. - */ - function revokeTeamRole(IApp app, TeamRole role, address account) external; - - /** - * @notice Renounce your own team role on `app`. - * @dev Renouncing ADMIN below the last-admin floor reverts. - */ - function renounceTeamRole(IApp app, TeamRole role) external; - - /** - * @notice Returns true iff `account` holds `role` on `app`. - */ - function hasTeamRole(IApp app, TeamRole role, address account) external view returns (bool); - - /** - * @notice Migrate pre-v1.5.0 apps from PermissionController-based auth to - * the new per-app team RBAC. For each app in `apps`, every admin - * currently registered in PermissionController is granted the - * ADMIN role in AppController's own storage. No-op for apps that - * already have an ADMIN. + * @notice Migrate pre-v1.5.0 apps to AppAuthority-based RBAC. For each + * app in `apps`: + * - seeds AppAuthority.scopeOwner(app) from AppController.creator + * if not already set; + * - seeds AppAuthority.ADMIN role with the app's PermissionController + * admins (operational-only role under the Option-2 model). * @dev Caller must be UAM permissioned for the AppController itself * (platform admin). Intended to be called once per app after the * v1.5.0 upgrade; safe to call again (idempotent per-(app, admin)). */ - function migrateAdmins(IApp[] calldata apps) external; + function migrateAppsToAppAuthority(IApp[] calldata apps) external; /** * @notice Gets the maximum global active apps limit diff --git a/src/storage/AppControllerStorage.sol b/src/storage/AppControllerStorage.sol index 3aafd27..229b3cd 100644 --- a/src/storage/AppControllerStorage.sol +++ b/src/storage/AppControllerStorage.sol @@ -10,6 +10,7 @@ import {IAppController} from "../interfaces/IAppController.sol"; import {IBeacon} from "@openzeppelin/contracts/proxy/beacon/IBeacon.sol"; import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; import {ISafeTimelockFactory} from "../interfaces/ISafeTimelockFactory.sol"; +import {IAppAuthority} from "../interfaces/IAppAuthority.sol"; abstract contract AppControllerStorage is IAppController { using EnumerableSet for EnumerableSet.AddressSet; @@ -42,6 +43,12 @@ abstract contract AppControllerStorage is IAppController { /// @notice Factory used to verify Safe and Timelock deployments for governance detection ISafeTimelockFactory public immutable safeTimelockFactory; + /// @notice Authority contract that owns per-app ownership and RBAC state. + /// @dev AppController delegates auth to this contract — ownership transfer, + /// role management, and schedule-time validation all flow through + /// AppAuthority. The Option-2 invariants are enforced there, not here. + IAppAuthority public immutable appAuthority; + /// @notice Set of all created apps EnumerableSet.AddressSet internal _allApps; @@ -57,25 +64,20 @@ abstract contract AppControllerStorage is IAppController { /// @inheritdoc IAppController uint32 public globalActiveAppCount; - /// @notice Per-app, per-role set of addresses holding the role. - /// app → role → { account, account, ... } - /// @dev This slot was inside __gap on v1.4.0 and is guaranteed zero on - /// every existing app. __gap below shrinks by 1 to compensate so - /// the end of the storage footprint is stable. - mapping(IApp => mapping(TeamRole => EnumerableSet.AddressSet)) internal _teamRoles; - constructor( IReleaseManager _releaseManager, IComputeOperator _computeOperator, IComputeAVSRegistrar _computeAVSRegistrar, IBeacon _appBeacon, - ISafeTimelockFactory _safeTimelockFactory + ISafeTimelockFactory _safeTimelockFactory, + IAppAuthority _appAuthority ) { releaseManager = _releaseManager; computeOperator = _computeOperator; computeAVSRegistrar = _computeAVSRegistrar; appBeacon = _appBeacon; safeTimelockFactory = _safeTimelockFactory; + appAuthority = _appAuthority; } /** @@ -83,5 +85,5 @@ abstract contract AppControllerStorage is IAppController { * variables without shifting down storage in the inheritance chain. * See https://docs.openzeppelin.com/contracts/4.x/upgradeable#storage_gaps */ - uint256[44] private __gap; + uint256[45] private __gap; } diff --git a/test/AppAuthority.t.sol b/test/AppAuthority.t.sol new file mode 100644 index 0000000..f72f357 --- /dev/null +++ b/test/AppAuthority.t.sol @@ -0,0 +1,353 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.27; + +import "forge-std/Test.sol"; +import {AppAuthority} from "../src/governance/AppAuthority.sol"; +import {IAppAuthority} from "../src/interfaces/IAppAuthority.sol"; +import {IApp} from "../src/interfaces/IApp.sol"; + +contract AppAuthorityTest is Test { + AppAuthority internal authority; + + address internal consumer = makeAddr("consumer"); + address internal alice = makeAddr("alice"); + address internal bob = makeAddr("bob"); + address internal carol = makeAddr("carol"); + + IApp internal constant APP_A = IApp(address(0xA0A0)); + IApp internal constant APP_B = IApp(address(0xB0B0)); + + function setUp() public { + // Deploy the impl directly. Tests don't need the proxy layer — + // they exercise pure authority logic. `_disableInitializers` in the + // constructor means initialize() is unreachable, which is fine: the + // initializer body is a no-op. + authority = new AppAuthority(consumer); + } + + // ========== scope initialization ========== + + function test_initializeScope_consumerOnly() public { + vm.prank(alice); + vm.expectRevert(IAppAuthority.OnlyConsumer.selector); + authority.initializeScope(APP_A, alice); + } + + function test_initializeScope_rejectsZeroOwner() public { + vm.prank(consumer); + vm.expectRevert(IAppAuthority.ZeroAddress.selector); + authority.initializeScope(APP_A, address(0)); + } + + function test_initializeScope_seedsOwnerAndAdmin() public { + vm.prank(consumer); + authority.initializeScope(APP_A, alice); + + assertEq(authority.scopeOwner(APP_A), alice); + assertTrue(authority.isScopeOwner(APP_A, alice)); + assertTrue(authority.hasRole(APP_A, IAppAuthority.Role.ADMIN, alice)); + } + + function test_initializeScope_rejectsReinit() public { + vm.prank(consumer); + authority.initializeScope(APP_A, alice); + + vm.prank(consumer); + vm.expectRevert(IAppAuthority.ScopeAlreadyInitialized.selector); + authority.initializeScope(APP_A, bob); + } + + // ========== ownership transfer ========== + + function test_transferScopeOwnership_consumerOnly() public { + vm.prank(consumer); + authority.initializeScope(APP_A, alice); + + vm.prank(alice); // even the owner can't directly — goes via consumer + vm.expectRevert(IAppAuthority.OnlyConsumer.selector); + authority.transferScopeOwnership(APP_A, bob); + } + + function test_transferScopeOwnership_rotatesAdmin() public { + vm.prank(consumer); + authority.initializeScope(APP_A, alice); + + vm.prank(consumer); + authority.transferScopeOwnership(APP_A, bob); + + assertEq(authority.scopeOwner(APP_A), bob); + assertTrue(authority.hasRole(APP_A, IAppAuthority.Role.ADMIN, bob)); + assertFalse(authority.hasRole(APP_A, IAppAuthority.Role.ADMIN, alice)); + } + + function test_transferScopeOwnership_rejectsUninitialized() public { + vm.prank(consumer); + vm.expectRevert(IAppAuthority.ScopeNotInitialized.selector); + authority.transferScopeOwnership(APP_A, bob); + } + + function test_transferScopeOwnership_rejectsZeroNewOwner() public { + vm.prank(consumer); + authority.initializeScope(APP_A, alice); + + vm.prank(consumer); + vm.expectRevert(IAppAuthority.ZeroAddress.selector); + authority.transferScopeOwnership(APP_A, address(0)); + } + + // ========== grantRole ========== + + function test_grantRole_adminRequiresOwner() public { + vm.prank(consumer); + authority.initializeScope(APP_A, alice); + + // alice (owner) can grant ADMIN + vm.prank(alice); + authority.grantRole(APP_A, IAppAuthority.Role.ADMIN, bob); + assertTrue(authority.hasRole(APP_A, IAppAuthority.Role.ADMIN, bob)); + + // bob (ADMIN but not owner) cannot grant ADMIN + vm.prank(bob); + vm.expectRevert(IAppAuthority.NotScopeOwner.selector); + authority.grantRole(APP_A, IAppAuthority.Role.ADMIN, carol); + } + + function test_grantRole_pauserRequiresAdmin() public { + vm.prank(consumer); + authority.initializeScope(APP_A, alice); + + // alice (owner, so ADMIN) can grant PAUSER + vm.prank(alice); + authority.grantRole(APP_A, IAppAuthority.Role.PAUSER, bob); + assertTrue(authority.hasRole(APP_A, IAppAuthority.Role.PAUSER, bob)); + + // carol (not ADMIN) cannot grant PAUSER + vm.prank(carol); + vm.expectRevert(IAppAuthority.InvalidRole.selector); + authority.grantRole(APP_A, IAppAuthority.Role.PAUSER, carol); + + // bob (granted ADMIN manually for this test) can grant PAUSER + vm.prank(alice); + authority.grantRole(APP_A, IAppAuthority.Role.ADMIN, bob); + + vm.prank(bob); + authority.grantRole(APP_A, IAppAuthority.Role.PAUSER, carol); + assertTrue(authority.hasRole(APP_A, IAppAuthority.Role.PAUSER, carol)); + } + + function test_grantRole_rejectsZeroAccount() public { + vm.prank(consumer); + authority.initializeScope(APP_A, alice); + + vm.prank(alice); + vm.expectRevert(IAppAuthority.ZeroAddress.selector); + authority.grantRole(APP_A, IAppAuthority.Role.ADMIN, address(0)); + } + + // ========== revokeRole ========== + + function test_revokeRole_adminRequiresOwner() public { + vm.prank(consumer); + authority.initializeScope(APP_A, alice); + + vm.prank(alice); + authority.grantRole(APP_A, IAppAuthority.Role.ADMIN, bob); + + // bob (ADMIN, not owner) cannot revoke alice + vm.prank(bob); + vm.expectRevert(IAppAuthority.NotScopeOwner.selector); + authority.revokeRole(APP_A, IAppAuthority.Role.ADMIN, alice); + + // alice (owner) can revoke bob + vm.prank(alice); + authority.revokeRole(APP_A, IAppAuthority.Role.ADMIN, bob); + assertFalse(authority.hasRole(APP_A, IAppAuthority.Role.ADMIN, bob)); + } + + function test_revokeRole_ownerCannotRevokeSelf() public { + vm.prank(consumer); + authority.initializeScope(APP_A, alice); + + vm.prank(alice); + vm.expectRevert(IAppAuthority.CannotRemoveOwnerAdmin.selector); + authority.revokeRole(APP_A, IAppAuthority.Role.ADMIN, alice); + } + + function test_revokeRole_cannotRemoveLastAdmin() public { + vm.prank(consumer); + authority.initializeScope(APP_A, alice); + + // Cannot revoke alice when she's the last admin. But the owner-self + // check fires first. Add bob then revoke alice's own role — still + // blocked by CannotRemoveOwnerAdmin. + vm.prank(alice); + authority.grantRole(APP_A, IAppAuthority.Role.ADMIN, bob); + + // Remove bob: OK, alice remains. + vm.prank(alice); + authority.revokeRole(APP_A, IAppAuthority.Role.ADMIN, bob); + + // Can't revoke alice (owner-self block is stricter than last-admin). + vm.prank(alice); + vm.expectRevert(IAppAuthority.CannotRemoveOwnerAdmin.selector); + authority.revokeRole(APP_A, IAppAuthority.Role.ADMIN, alice); + } + + function test_revokeRole_pauserRequiresAdmin() public { + vm.prank(consumer); + authority.initializeScope(APP_A, alice); + + vm.prank(alice); + authority.grantRole(APP_A, IAppAuthority.Role.PAUSER, bob); + + // carol isn't admin — cannot revoke + vm.prank(carol); + vm.expectRevert(IAppAuthority.InvalidRole.selector); + authority.revokeRole(APP_A, IAppAuthority.Role.PAUSER, bob); + + // alice (owner/admin) can revoke + vm.prank(alice); + authority.revokeRole(APP_A, IAppAuthority.Role.PAUSER, bob); + assertFalse(authority.hasRole(APP_A, IAppAuthority.Role.PAUSER, bob)); + } + + // ========== renounceRole ========== + + function test_renounceRole_ownerCannotRenounceAdmin() public { + vm.prank(consumer); + authority.initializeScope(APP_A, alice); + + vm.prank(alice); + vm.expectRevert(IAppAuthority.CannotRemoveOwnerAdmin.selector); + authority.renounceRole(APP_A, IAppAuthority.Role.ADMIN); + } + + function test_renounceRole_coAdminCanRenounce() public { + vm.prank(consumer); + authority.initializeScope(APP_A, alice); + + vm.prank(alice); + authority.grantRole(APP_A, IAppAuthority.Role.ADMIN, bob); + + vm.prank(bob); + authority.renounceRole(APP_A, IAppAuthority.Role.ADMIN); + + assertFalse(authority.hasRole(APP_A, IAppAuthority.Role.ADMIN, bob)); + assertTrue(authority.hasRole(APP_A, IAppAuthority.Role.ADMIN, alice)); + } + + function test_renounceRole_operationalRoleOk() public { + vm.prank(consumer); + authority.initializeScope(APP_A, alice); + + vm.prank(alice); + authority.grantRole(APP_A, IAppAuthority.Role.PAUSER, bob); + + vm.prank(bob); + authority.renounceRole(APP_A, IAppAuthority.Role.PAUSER); + + assertFalse(authority.hasRole(APP_A, IAppAuthority.Role.PAUSER, bob)); + } + + // ========== migrateAdmins ========== + + function test_migrateAdmins_consumerOnly() public { + IApp[] memory scopes = new IApp[](1); + address[][] memory admins = new address[][](1); + scopes[0] = APP_A; + admins[0] = new address[](0); + + vm.prank(alice); + vm.expectRevert(IAppAuthority.OnlyConsumer.selector); + authority.migrateAdmins(scopes, admins); + } + + function test_migrateAdmins_seedsAdmins() public { + vm.prank(consumer); + authority.initializeScope(APP_A, alice); + + IApp[] memory scopes = new IApp[](1); + address[][] memory admins = new address[][](1); + scopes[0] = APP_A; + admins[0] = new address[](2); + admins[0][0] = bob; + admins[0][1] = carol; + + vm.prank(consumer); + authority.migrateAdmins(scopes, admins); + + assertTrue(authority.hasRole(APP_A, IAppAuthority.Role.ADMIN, bob)); + assertTrue(authority.hasRole(APP_A, IAppAuthority.Role.ADMIN, carol)); + } + + function test_migrateAdmins_idempotent() public { + vm.prank(consumer); + authority.initializeScope(APP_A, alice); + + IApp[] memory scopes = new IApp[](1); + address[][] memory admins = new address[][](1); + scopes[0] = APP_A; + admins[0] = new address[](1); + admins[0][0] = bob; + + vm.prank(consumer); + authority.migrateAdmins(scopes, admins); + + // Run again: safe. + vm.prank(consumer); + authority.migrateAdmins(scopes, admins); + + assertTrue(authority.hasRole(APP_A, IAppAuthority.Role.ADMIN, bob)); + } + + function test_migrateAdmins_skipsZeroAddress() public { + vm.prank(consumer); + authority.initializeScope(APP_A, alice); + + IApp[] memory scopes = new IApp[](1); + address[][] memory admins = new address[][](1); + scopes[0] = APP_A; + admins[0] = new address[](2); + admins[0][0] = address(0); + admins[0][1] = bob; + + vm.prank(consumer); + authority.migrateAdmins(scopes, admins); + + assertFalse(authority.hasRole(APP_A, IAppAuthority.Role.ADMIN, address(0))); + assertTrue(authority.hasRole(APP_A, IAppAuthority.Role.ADMIN, bob)); + } + + // ========== hasRoleOrAdmin ========== + + function test_hasRoleOrAdmin_adminIsSupersetForOperational() public { + vm.prank(consumer); + authority.initializeScope(APP_A, alice); + + // alice is ADMIN only. PAUSER query still returns true because ADMIN is + // a superset for operational roles. + assertTrue(authority.hasRoleOrAdmin(APP_A, IAppAuthority.Role.PAUSER, alice)); + assertTrue(authority.hasRoleOrAdmin(APP_A, IAppAuthority.Role.DEVELOPER, alice)); + + // Direct role checks are strict. + assertFalse(authority.hasRole(APP_A, IAppAuthority.Role.PAUSER, alice)); + } + + // ========== scope isolation ========== + + function test_scopesAreIsolated() public { + vm.prank(consumer); + authority.initializeScope(APP_A, alice); + vm.prank(consumer); + authority.initializeScope(APP_B, bob); + + // A-scope ADMIN on alice has no bearing on B-scope. + assertTrue(authority.hasRole(APP_A, IAppAuthority.Role.ADMIN, alice)); + assertFalse(authority.hasRole(APP_B, IAppAuthority.Role.ADMIN, alice)); + + // Owner gates are scope-local. + vm.prank(alice); + vm.expectRevert(IAppAuthority.NotScopeOwner.selector); + authority.grantRole(APP_B, IAppAuthority.Role.ADMIN, carol); + } +} diff --git a/test/AppController.t.sol b/test/AppController.t.sol index 95b8a00..4caebac 100644 --- a/test/AppController.t.sol +++ b/test/AppController.t.sol @@ -2,6 +2,7 @@ pragma solidity ^0.8.27; import {IAppController} from "../src/interfaces/IAppController.sol"; +import {IAppAuthority} from "../src/interfaces/IAppAuthority.sol"; import {AppController} from "../src/AppController.sol"; import {ComputeDeployer} from "./utils/ComputeDeployer.sol"; import {IApp} from "../src/interfaces/IApp.sol"; @@ -275,9 +276,11 @@ contract AppControllerTest is ComputeDeployer { IAppController.Release memory release = IAppController.Release({rmsRelease: rmsRelease, publicEnv: "", encryptedEnv: ""}); - // Try to upgrade as unauthorized user + // Try to upgrade as unauthorized user. Critical ops are owner-gated + // unconditionally now — not even an ADMIN other than the owner can + // trigger an upgrade. vm.prank(user); - vm.expectRevert(IAppController.InvalidTeamRole.selector); + vm.expectRevert(IAppController.NotCreator.selector); appController.upgradeApp(app, release); } @@ -1405,12 +1408,13 @@ contract AppControllerTest is ComputeDeployer { // Timelock ADMIN grant on a timelocked app requires msg.sender == creator; // developer IS the creator (via _mockIsTimelock), so this passes directly. vm.prank(developer); - appController.grantTeamRole(app, IAppController.TeamRole.ADMIN, coAdmin); + appAuthority.grantRole(app, IAppAuthority.Role.ADMIN, coAdmin); - // Direct upgrade from the co-admin MUST revert with InvalidPermissions — - // the role check passes but the creator-only gate fires. + // Direct upgrade from the co-admin MUST revert with NotCreator — + // the owner-gate blocks everyone except the current owner, regardless + // of ADMIN membership. vm.prank(coAdmin); - vm.expectRevert(PermissionControllerMixin.InvalidPermissions.selector); + vm.expectRevert(IAppController.NotCreator.selector); appController.upgradeApp(app, _assembleRelease()); // The Timelock itself (app.creator) can still call upgrade directly — @@ -1426,10 +1430,10 @@ contract AppControllerTest is ComputeDeployer { address coAdmin = makeAddr("coAdmin"); vm.prank(developer); - appController.grantTeamRole(app, IAppController.TeamRole.ADMIN, coAdmin); + appAuthority.grantRole(app, IAppAuthority.Role.ADMIN, coAdmin); vm.prank(coAdmin); - vm.expectRevert(PermissionControllerMixin.InvalidPermissions.selector); + vm.expectRevert(IAppController.NotCreator.selector); appController.terminateApp(app); // Timelock (creator) can terminate. @@ -1503,11 +1507,11 @@ contract AppControllerTest is ComputeDeployer { // block them from moving the app out of governance. address coAdmin = makeAddr("coAdmin"); vm.prank(developer); - appController.grantTeamRole(app, IAppController.TeamRole.ADMIN, coAdmin); + appAuthority.grantRole(app, IAppAuthority.Role.ADMIN, coAdmin); address attacker = makeAddr("attacker"); vm.prank(coAdmin); - vm.expectRevert(PermissionControllerMixin.InvalidPermissions.selector); + vm.expectRevert(IAppController.NotCreator.selector); appController.transferOwnership(app, attacker); // Timelock itself can still transfer. @@ -1605,12 +1609,22 @@ contract AppControllerTest is ComputeDeployer { ); } - function test_canCall_doesNotRejectNonTimelockedUpgradeApp() public { - // App is not timelocked: canCall defers to runtime (returns true). + function test_canCall_rejectsNonOwnerUpgradeAppEvenWhenNotTimelocked() public { + // Owner-gated model: canCall rejects any non-owner schedule of a + // critical op, regardless of timelocked state. Previously this was + // only checked for timelocked apps; now owner-gating is unconditional + // so schedule-time rejection matches runtime. vm.prank(developer); IApp app = appController.createApp(SALT, _assembleRelease()); bytes memory callData = abi.encodeWithSelector(IAppController.upgradeApp.selector, app, _assembleRelease()); - assertTrue(ICallValidator(address(appController)).canCall(makeAddr("anyone"), callData)); + + assertFalse( + ICallValidator(address(appController)).canCall(makeAddr("anyone"), callData), + "canCall must reject non-owner schedule even for non-timelocked apps" + ); + assertTrue( + ICallValidator(address(appController)).canCall(developer, callData), "canCall must accept owner schedule" + ); } function test_canCall_rejectsTerminateAppByAdminOnTimelockedApp() public { @@ -1631,8 +1645,7 @@ contract AppControllerTest is ComputeDeployer { IApp app = appController.createApp(SALT, _assembleRelease()); assertTrue( - appController.hasTeamRole(app, IAppController.TeamRole.ADMIN, developer), - "creator must be seeded as ADMIN on create" + appAuthority.hasRole(app, IAppAuthority.Role.ADMIN, developer), "creator must be seeded as ADMIN on create" ); } @@ -1642,9 +1655,9 @@ contract AppControllerTest is ComputeDeployer { address pauser = makeAddr("pauser"); vm.prank(developer); - appController.grantTeamRole(app, IAppController.TeamRole.PAUSER, pauser); + appAuthority.grantRole(app, IAppAuthority.Role.PAUSER, pauser); - assertTrue(appController.hasTeamRole(app, IAppController.TeamRole.PAUSER, pauser)); + assertTrue(appAuthority.hasRole(app, IAppAuthority.Role.PAUSER, pauser)); } function test_grantTeamRole_nonAdminCannotGrant() public { @@ -1653,8 +1666,8 @@ contract AppControllerTest is ComputeDeployer { address outsider = makeAddr("outsider"); vm.prank(outsider); - vm.expectRevert(IAppController.InvalidTeamRole.selector); - appController.grantTeamRole(app, IAppController.TeamRole.PAUSER, outsider); + vm.expectRevert(IAppAuthority.InvalidRole.selector); + appAuthority.grantRole(app, IAppAuthority.Role.PAUSER, outsider); } function test_grantTeamRole_timelockedOperationalRoleNotGated() public { @@ -1669,77 +1682,101 @@ contract AppControllerTest is ComputeDeployer { // coAdmin can grant PAUSER freely (no creator-only gate). address coAdmin = makeAddr("coAdmin"); vm.prank(developer); - appController.grantTeamRole(app, IAppController.TeamRole.ADMIN, coAdmin); + appAuthority.grantRole(app, IAppAuthority.Role.ADMIN, coAdmin); address pauser = makeAddr("pauser"); vm.prank(coAdmin); - appController.grantTeamRole(app, IAppController.TeamRole.PAUSER, pauser); - assertTrue(appController.hasTeamRole(app, IAppController.TeamRole.PAUSER, pauser)); + appAuthority.grantRole(app, IAppAuthority.Role.PAUSER, pauser); + assertTrue(appAuthority.hasRole(app, IAppAuthority.Role.PAUSER, pauser)); } - function test_grantTeamRole_timelockedAdminGrantGatedByCreator() public { - // A-2 fix: ADMIN grants on timelocked apps MUST come from the - // Timelock itself. Otherwise a compromised co-admin could add - // another ADMIN in one tx and circumvent the delay on upgrades. + function test_grantTeamRole_adminGrantGatedByCreator() public { + // Owner-gated model: ADMIN grants are creator-only regardless of + // timelocked/Safe/EOA ownership. Applies to timelocked apps (Timelock + // is creator) and to non-timelocked apps (EOA/Safe is creator). A + // co-ADMIN cannot mint another ADMIN. _mockIsTimelock(developer); vm.prank(developer); IApp app = appController.createApp(SALT, _assembleRelease()); address coAdmin = makeAddr("coAdmin"); vm.prank(developer); - appController.grantTeamRole(app, IAppController.TeamRole.ADMIN, coAdmin); + appAuthority.grantRole(app, IAppAuthority.Role.ADMIN, coAdmin); - // coAdmin has ADMIN but is NOT the creator: grant attempt must fail. + // coAdmin has ADMIN but is NOT the owner: grant attempt must fail. address usurper = makeAddr("usurper"); vm.prank(coAdmin); - vm.expectRevert(IAppController.InvalidTeamRole.selector); - appController.grantTeamRole(app, IAppController.TeamRole.ADMIN, usurper); + vm.expectRevert(IAppAuthority.NotScopeOwner.selector); + appAuthority.grantRole(app, IAppAuthority.Role.ADMIN, usurper); - // The Timelock itself can still grant. + // The Timelock (owner) can still grant. vm.prank(developer); - appController.grantTeamRole(app, IAppController.TeamRole.ADMIN, usurper); - assertTrue(appController.hasTeamRole(app, IAppController.TeamRole.ADMIN, usurper)); + appAuthority.grantRole(app, IAppAuthority.Role.ADMIN, usurper); + assertTrue(appAuthority.hasRole(app, IAppAuthority.Role.ADMIN, usurper)); } - function test_revokeTeamRole_timelockedAdminRevokeGatedByCreator() public { - // A-3 fix: revoking ADMIN on a timelocked app requires msg.sender == creator. - // Without this, a co-admin could strip the Timelock in one tx. + function test_grantTeamRole_adminGrantGatedByCreator_nonTimelocked() public { + // Safe/EOA-owned app: same owner-only rule applies. This is what + // prevents a co-ADMIN from silently escalating to owner on Safe-owned + // apps and bypassing the multisig. + vm.prank(developer); + IApp app = appController.createApp(SALT, _assembleRelease()); + + address coAdmin = makeAddr("coAdmin"); + vm.prank(developer); + appAuthority.grantRole(app, IAppAuthority.Role.ADMIN, coAdmin); + + address usurper = makeAddr("usurper"); + vm.prank(coAdmin); + vm.expectRevert(IAppAuthority.NotScopeOwner.selector); + appAuthority.grantRole(app, IAppAuthority.Role.ADMIN, usurper); + } + + function test_revokeTeamRole_adminRevokeGatedByCreator() public { + // Mirror of grant: revoking ADMIN is owner-only unconditionally. _mockIsTimelock(developer); vm.prank(developer); IApp app = appController.createApp(SALT, _assembleRelease()); address coAdmin = makeAddr("coAdmin"); vm.prank(developer); - appController.grantTeamRole(app, IAppController.TeamRole.ADMIN, coAdmin); + appAuthority.grantRole(app, IAppAuthority.Role.ADMIN, coAdmin); // coAdmin attempts to revoke the Timelock — MUST fail. vm.prank(coAdmin); - vm.expectRevert(IAppController.InvalidTeamRole.selector); - appController.revokeTeamRole(app, IAppController.TeamRole.ADMIN, developer); + vm.expectRevert(IAppAuthority.NotScopeOwner.selector); + appAuthority.revokeRole(app, IAppAuthority.Role.ADMIN, developer); - // The Timelock itself can revoke coAdmin. + // The Timelock (creator) can revoke coAdmin. vm.prank(developer); - appController.revokeTeamRole(app, IAppController.TeamRole.ADMIN, coAdmin); - assertFalse(appController.hasTeamRole(app, IAppController.TeamRole.ADMIN, coAdmin)); + appAuthority.revokeRole(app, IAppAuthority.Role.ADMIN, coAdmin); + assertFalse(appAuthority.hasRole(app, IAppAuthority.Role.ADMIN, coAdmin)); } - function test_revokeTeamRole_cannotRevokeLastAdmin() public { + function test_revokeTeamRole_creatorCannotRevokeSelf() public { + // The creator is the only address that can call revokeTeamRole(ADMIN), + // but they cannot revoke their own ADMIN — the invariant + // `creator ∈ ADMIN` must hold. transferOwnership is the only path that + // rotates. vm.prank(developer); IApp app = appController.createApp(SALT, _assembleRelease()); - // Sole ADMIN trying to revoke themselves via revokeTeamRole reverts. vm.prank(developer); - vm.expectRevert(IAppController.CannotRevokeLastAdmin.selector); - appController.revokeTeamRole(app, IAppController.TeamRole.ADMIN, developer); + vm.expectRevert(IAppAuthority.CannotRemoveOwnerAdmin.selector); + appAuthority.revokeRole(app, IAppAuthority.Role.ADMIN, developer); } - function test_renounceTeamRole_cannotRenounceLastAdmin() public { + function test_renounceTeamRole_creatorCannotRenounceAdmin() public { + // The creator cannot drop out of ADMIN via renounce — doing so would + // leave the owner slot pointing at an address that isn't ADMIN + // (though ADMIN is operational-only in this model, keeping the + // invariant still matters for startApp and role-management clarity). vm.prank(developer); IApp app = appController.createApp(SALT, _assembleRelease()); vm.prank(developer); - vm.expectRevert(IAppController.CannotRevokeLastAdmin.selector); - appController.renounceTeamRole(app, IAppController.TeamRole.ADMIN); + vm.expectRevert(IAppAuthority.CannotRemoveOwnerAdmin.selector); + appAuthority.renounceRole(app, IAppAuthority.Role.ADMIN); } function test_renounceTeamRole_operationalRoleOk() public { @@ -1748,11 +1785,11 @@ contract AppControllerTest is ComputeDeployer { address pauser = makeAddr("pauser"); vm.prank(developer); - appController.grantTeamRole(app, IAppController.TeamRole.PAUSER, pauser); + appAuthority.grantRole(app, IAppAuthority.Role.PAUSER, pauser); vm.prank(pauser); - appController.renounceTeamRole(app, IAppController.TeamRole.PAUSER); - assertFalse(appController.hasTeamRole(app, IAppController.TeamRole.PAUSER, pauser)); + appAuthority.renounceRole(app, IAppAuthority.Role.PAUSER); + assertFalse(appAuthority.hasRole(app, IAppAuthority.Role.PAUSER, pauser)); } function test_stopApp_pauserCanCall() public { @@ -1761,7 +1798,7 @@ contract AppControllerTest is ComputeDeployer { address pauser = makeAddr("pauser"); vm.prank(developer); - appController.grantTeamRole(app, IAppController.TeamRole.PAUSER, pauser); + appAuthority.grantRole(app, IAppAuthority.Role.PAUSER, pauser); vm.prank(pauser); appController.stopApp(app); @@ -1774,7 +1811,7 @@ contract AppControllerTest is ComputeDeployer { address dev = makeAddr("dev"); vm.prank(developer); - appController.grantTeamRole(app, IAppController.TeamRole.DEVELOPER, dev); + appAuthority.grantRole(app, IAppAuthority.Role.DEVELOPER, dev); vm.prank(dev); vm.expectRevert(IAppController.InvalidTeamRole.selector); @@ -1787,7 +1824,7 @@ contract AppControllerTest is ComputeDeployer { address dev = makeAddr("dev"); vm.prank(developer); - appController.grantTeamRole(app, IAppController.TeamRole.DEVELOPER, dev); + appAuthority.grantRole(app, IAppAuthority.Role.DEVELOPER, dev); vm.prank(dev); appController.updateAppMetadataURI(app, "ipfs://new"); @@ -1799,7 +1836,7 @@ contract AppControllerTest is ComputeDeployer { address pauser = makeAddr("pauser"); vm.prank(developer); - appController.grantTeamRole(app, IAppController.TeamRole.PAUSER, pauser); + appAuthority.grantRole(app, IAppAuthority.Role.PAUSER, pauser); vm.prank(pauser); vm.expectRevert(IAppController.InvalidTeamRole.selector); @@ -1817,11 +1854,59 @@ contract AppControllerTest is ComputeDeployer { appController.transferOwnership(app, newOwner); assertTrue( - appController.hasTeamRole(app, IAppController.TeamRole.ADMIN, newOwner), + appAuthority.hasRole(app, IAppAuthority.Role.ADMIN, newOwner), "new owner must be granted ADMIN automatically" ); } + function test_transferOwnership_removesPreviousOwnerFromAdmin() public { + // Fix for audit A-3 / V-10: the previous owner must be removed from + // the ADMIN set on transfer. Otherwise an old EOA (or an old + // Timelock post-handoff) retains operational powers and, in the + // non-timelocked case, can re-grab the app. + vm.prank(developer); + IApp app = appController.createApp(SALT, _assembleRelease()); + + address newOwner = makeAddr("newOwner"); + _setMaxActiveAppsPerUser(newOwner, 10); + + vm.prank(developer); + appController.transferOwnership(app, newOwner); + + assertFalse( + appAuthority.hasRole(app, IAppAuthority.Role.ADMIN, developer), + "previous owner must be removed from ADMIN on transfer" + ); + assertTrue(appAuthority.hasRole(app, IAppAuthority.Role.ADMIN, newOwner), "new owner must still be ADMIN"); + } + + function test_transferOwnership_previousOwnerLosesCriticalPower() public { + // After transfer, the previous owner cannot perform critical ops + // even though they used to. This is the direct user-visible + // consequence of the owner-gate + ADMIN cleanup together. + vm.prank(developer); + IApp app = appController.createApp(SALT, _assembleRelease()); + + address newOwner = makeAddr("newOwner"); + _setMaxActiveAppsPerUser(newOwner, 10); + + vm.prank(developer); + appController.transferOwnership(app, newOwner); + + // Previous owner cannot upgrade, terminate, or transfer. + vm.prank(developer); + vm.expectRevert(IAppController.NotCreator.selector); + appController.upgradeApp(app, _assembleRelease()); + + vm.prank(developer); + vm.expectRevert(IAppController.NotCreator.selector); + appController.terminateApp(app); + + vm.prank(developer); + vm.expectRevert(IAppController.NotCreator.selector); + appController.transferOwnership(app, makeAddr("someoneElse")); + } + function test_migrateAdmins_seedsAdminFromPermissionController() public { vm.prank(developer); IApp app = appController.createApp(SALT, _assembleRelease()); @@ -1840,16 +1925,16 @@ contract AppControllerTest is ComputeDeployer { vm.prank(pcAdminB); permissionController.acceptAdmin(address(app)); - assertFalse(appController.hasTeamRole(app, IAppController.TeamRole.ADMIN, pcAdminA)); - assertFalse(appController.hasTeamRole(app, IAppController.TeamRole.ADMIN, pcAdminB)); + assertFalse(appAuthority.hasRole(app, IAppAuthority.Role.ADMIN, pcAdminA)); + assertFalse(appAuthority.hasRole(app, IAppAuthority.Role.ADMIN, pcAdminB)); IApp[] memory apps = new IApp[](1); apps[0] = app; vm.prank(admin); - appController.migrateAdmins(apps); + appController.migrateAppsToAppAuthority(apps); - assertTrue(appController.hasTeamRole(app, IAppController.TeamRole.ADMIN, pcAdminA)); - assertTrue(appController.hasTeamRole(app, IAppController.TeamRole.ADMIN, pcAdminB)); + assertTrue(appAuthority.hasRole(app, IAppAuthority.Role.ADMIN, pcAdminA)); + assertTrue(appAuthority.hasRole(app, IAppAuthority.Role.ADMIN, pcAdminB)); } function test_migrateAdmins_callerMustBePlatformAdmin() public { @@ -1860,6 +1945,6 @@ contract AppControllerTest is ComputeDeployer { apps[0] = app; vm.prank(user); vm.expectRevert(); - appController.migrateAdmins(apps); + appController.migrateAppsToAppAuthority(apps); } } diff --git a/test/utils/ComputeDeployer.sol b/test/utils/ComputeDeployer.sol index 37ce85d..783e148 100644 --- a/test/utils/ComputeDeployer.sol +++ b/test/utils/ComputeDeployer.sol @@ -5,6 +5,7 @@ import "forge-std/Test.sol"; import {Deploy} from "../../script/Deploy.s.sol"; import {Parser} from "../../script/Parser.s.sol"; import {IAppController} from "../../src/interfaces/IAppController.sol"; +import {IAppAuthority} from "../../src/interfaces/IAppAuthority.sol"; import {ComputeAVSRegistrar} from "../../src/ComputeAVSRegistrar.sol"; import {ComputeOperator} from "../../src/ComputeOperator.sol"; import {ImageAllowlist} from "../../src/ImageAllowlist.sol"; @@ -27,6 +28,7 @@ contract ComputeDeployer is Test { address public admin = makeAddr("admin"); IAppController public appController; + IAppAuthority public appAuthority; PermissionController public permissionController; ReleaseManager public releaseManager; AllocationManager public allocationManager; @@ -66,6 +68,7 @@ contract ComputeDeployer is Test { Parser.DeployedContracts memory deployed = deployer.deployForTesting(params); appController = deployed.appController; + appAuthority = deployed.appAuthority; computeAVSRegistrar = ComputeAVSRegistrar(address(deployed.computeAVSRegistrar)); computeOperator = ComputeOperator(address(deployed.computeOperator)); imageAllowlist = ImageAllowlist(address(deployed.imageAllowlist)); From 527d72e937a88a393c6ba79cdc03019cf45a60ef Mon Sep 17 00:00:00 2001 From: Taras Shchybovyk Date: Fri, 24 Apr 2026 16:46:31 -0700 Subject: [PATCH 12/17] refactor: drop timelocked flag + SafeTimelockFactory coupling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the `timelocked` boolean from AppConfigStorage and every path that wrote or read it. Under the owner-gated (Option-2) model the flag is vestigial: critical ops are gated on `msg.sender == creator` unconditionally, so a Timelock owner naturally forces schedule → execute without a separate classification on AppController. Behavior changes: - terminateAppByAdmin no longer refuses Timelock-owned apps. Protocol admin (UAM-gated) can terminate any app uniformly. If the protocol wants delay-gated terminations, that's an operational decision about the UAM multisig, not an AppController concern. - AppController no longer imports ISafeTimelockFactory. The factory is still deployed and usable for attested Safe/Timelock deployments, but AppController's correctness no longer depends on it. - getAppTimelocked view removed. Storage: AppConfigStorage byte 30 returns to unused (zero on all existing chain state). SafeTimelockFactory immutable dropped from AppControllerStorage; constructor goes 8 args → 7. ISafe / ISafeProxyFactory interface files gain comments documenting why they're hand-rolled (minimal surface, Safe v1.3.0–v1.4.1 stable signatures) and when to reconsider. Also closes audit findings: - V-1 / A-7 (factory floors): factory boolean is no longer a security claim AppController relies on; user picks their own config. - V-6 (Timelock → EOA drops protection): no protection to drop. - A-4 (suspend not timelock-gated): no asymmetry to fix. AppController runtime size: 30,288 → 29,058 (−1,230). Total shrinkage vs pre-refactor: 33,131 → 29,058 (−4,073 / −12%). Tests: 179/179 passing (was 185; 6 dropped tests were about the flag-flipping behavior and the terminateAppByAdmin carve-out — both gone now). --- script/Deploy.s.sol | 15 +- .../v1.0.4-init/1-deployContracts.s.sol | 6 +- .../1-deployAppControllerImpl.s.sol | 4 +- .../1-deployAppControllerImpl.s.sol | 4 +- .../1-deployGovernanceContracts.s.sol | 17 +- src/AppController.sol | 52 ++--- src/interfaces/IAppController.sol | 43 ++-- src/interfaces/ISafe.sol | 11 +- src/interfaces/ISafeProxyFactory.sol | 8 +- src/storage/AppControllerStorage.sol | 6 - test/AppController.t.sol | 205 +++++------------- 11 files changed, 116 insertions(+), 255 deletions(-) diff --git a/script/Deploy.s.sol b/script/Deploy.s.sol index 5236a69..d0f467d 100644 --- a/script/Deploy.s.sol +++ b/script/Deploy.s.sol @@ -24,7 +24,6 @@ import {ComputeAVSRegistrar} from "../src/ComputeAVSRegistrar.sol"; import {ComputeOperator} from "../src/ComputeOperator.sol"; import {ImageAllowlist} from "../src/ImageAllowlist.sol"; import {IImageAllowlist} from "../src/interfaces/IImageAllowlist.sol"; -import {ISafeTimelockFactory} from "../src/interfaces/ISafeTimelockFactory.sol"; import {SafeTimelockFactory} from "../src/factories/SafeTimelockFactory.sol"; import {TimelockControllerImpl} from "../src/governance/TimelockControllerImpl.sol"; import {IAppAuthority} from "../src/interfaces/IAppAuthority.sol"; @@ -96,11 +95,12 @@ contract Deploy is Parser { UpgradeableBeacon appBeacon = new UpgradeableBeacon(address(new App(params.version, IPermissionController(params.permissionController)))); - // Deploy SafeTimelockFactory (needed by AppController for governance - // detection). Safe infrastructure addresses (singleton / proxy factory - // / fallback handler) are left as zero here; tests and local deploys - // don't exercise deploySafe. Production releases use a dedicated - // release script that wires real Safe addresses. + // Deploy SafeTimelockFactory so tests / local deploys can exercise + // the deployTimelock / deploySafe paths. AppController no longer + // depends on this factory — governance is whatever the app's owner + // contract is. Safe infrastructure addresses are zero here; local + // deploys don't exercise deploySafe. Production releases wire real + // Safe addresses in the v1.5.0 release script. TimelockControllerImpl timelockImpl = new TimelockControllerImpl(); SafeTimelockFactory safeTimelockFactoryImpl = new SafeTimelockFactory({ _safeSingleton: address(0), @@ -108,7 +108,7 @@ contract Deploy is Parser { _safeFallbackHandler: address(0), _timelockImplementation: address(timelockImpl) }); - TransparentUpgradeableProxy safeTimelockFactoryProxy = new TransparentUpgradeableProxy( + new TransparentUpgradeableProxy( address(safeTimelockFactoryImpl), address(params.proxyAdmin), abi.encodeCall(SafeTimelockFactory.initialize, ()) @@ -148,7 +148,6 @@ contract Deploy is Parser { _computeAVSRegistrar: IComputeAVSRegistrar(address(proxies.computeAVSRegistrar)), _computeOperator: IComputeOperator(address(proxies.computeOperator)), _appBeacon: appBeacon, - _safeTimelockFactory: ISafeTimelockFactory(address(safeTimelockFactoryProxy)), _appAuthority: IAppAuthority(address(appAuthorityProxy)) }), imageAllowlist: new ImageAllowlist(), diff --git a/script/releases/v1.0.4-init/1-deployContracts.s.sol b/script/releases/v1.0.4-init/1-deployContracts.s.sol index a354d88..b76af2e 100644 --- a/script/releases/v1.0.4-init/1-deployContracts.s.sol +++ b/script/releases/v1.0.4-init/1-deployContracts.s.sol @@ -22,7 +22,6 @@ import {ComputeOperator} from "../../../src/ComputeOperator.sol"; import {IAppController} from "../../../src/interfaces/IAppController.sol"; import {IComputeAVSRegistrar} from "../../../src/interfaces/IComputeAVSRegistrar.sol"; import {IComputeOperator} from "../../../src/interfaces/IComputeOperator.sol"; -import {ISafeTimelockFactory} from "../../../src/interfaces/ISafeTimelockFactory.sol"; import {IAppAuthority} from "../../../src/interfaces/IAppAuthority.sol"; /** @@ -85,9 +84,8 @@ contract Deploy is EOADeployer { _computeAVSRegistrar: IComputeAVSRegistrar(address(computeAVSRegistrarProxy)), _computeOperator: IComputeOperator(address(computeOperatorProxy)), _appBeacon: appBeacon, - // v1.0.4 predates SafeTimelockFactory + AppAuthority. Historical script - // kept compilable against the current constructor; this path never runs. - _safeTimelockFactory: ISafeTimelockFactory(address(0)), + // v1.0.4 predates AppAuthority. Historical script kept compilable + // against the current constructor; this path never runs. _appAuthority: IAppAuthority(address(0)) }); diff --git a/script/releases/v1.1.1-app-suspension/1-deployAppControllerImpl.s.sol b/script/releases/v1.1.1-app-suspension/1-deployAppControllerImpl.s.sol index a5d6794..3108426 100644 --- a/script/releases/v1.1.1-app-suspension/1-deployAppControllerImpl.s.sol +++ b/script/releases/v1.1.1-app-suspension/1-deployAppControllerImpl.s.sol @@ -7,7 +7,6 @@ import "../Env.sol"; import "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol"; import {AppController} from "../../../src/AppController.sol"; -import {ISafeTimelockFactory} from "../../../src/interfaces/ISafeTimelockFactory.sol"; import {IAppAuthority} from "../../../src/interfaces/IAppAuthority.sol"; /** @@ -29,8 +28,7 @@ contract DeployAppControllerImpl is EOADeployer { _computeAVSRegistrar: Env.proxy.computeAVSRegistrar(), _computeOperator: Env.proxy.computeOperator(), _appBeacon: Env.beacon.appBeacon(), - // v1.1.1 predates SafeTimelockFactory + AppAuthority. Historical script; never runs again. - _safeTimelockFactory: ISafeTimelockFactory(address(0)), + // v1.1.1 predates AppAuthority. Historical script; never runs again. _appAuthority: IAppAuthority(address(0)) }); diff --git a/script/releases/v1.4.0-isolated-billing/1-deployAppControllerImpl.s.sol b/script/releases/v1.4.0-isolated-billing/1-deployAppControllerImpl.s.sol index 4b59b87..67c3292 100644 --- a/script/releases/v1.4.0-isolated-billing/1-deployAppControllerImpl.s.sol +++ b/script/releases/v1.4.0-isolated-billing/1-deployAppControllerImpl.s.sol @@ -7,7 +7,6 @@ import "../Env.sol"; import "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol"; import {AppController} from "../../../src/AppController.sol"; -import {ISafeTimelockFactory} from "../../../src/interfaces/ISafeTimelockFactory.sol"; import {IAppAuthority} from "../../../src/interfaces/IAppAuthority.sol"; /** @@ -29,8 +28,7 @@ contract DeployAppControllerImpl is EOADeployer { _computeAVSRegistrar: Env.proxy.computeAVSRegistrar(), _computeOperator: Env.proxy.computeOperator(), _appBeacon: Env.beacon.appBeacon(), - // v1.4.0 predates SafeTimelockFactory + AppAuthority. Historical script; never runs again. - _safeTimelockFactory: ISafeTimelockFactory(address(0)), + // v1.4.0 predates AppAuthority. Historical script; never runs again. _appAuthority: IAppAuthority(address(0)) }); diff --git a/script/releases/v1.5.0-governance/1-deployGovernanceContracts.s.sol b/script/releases/v1.5.0-governance/1-deployGovernanceContracts.s.sol index 7c23ddf..44c428a 100644 --- a/script/releases/v1.5.0-governance/1-deployGovernanceContracts.s.sol +++ b/script/releases/v1.5.0-governance/1-deployGovernanceContracts.s.sol @@ -11,7 +11,6 @@ import {AppController} from "../../../src/AppController.sol"; import {SafeTimelockFactory} from "../../../src/factories/SafeTimelockFactory.sol"; import {TimelockControllerImpl} from "../../../src/governance/TimelockControllerImpl.sol"; import {AppAuthority} from "../../../src/governance/AppAuthority.sol"; -import {ISafeTimelockFactory} from "../../../src/interfaces/ISafeTimelockFactory.sol"; import {IAppAuthority} from "../../../src/interfaces/IAppAuthority.sol"; /** @@ -73,11 +72,11 @@ contract DeployGovernanceContracts is EOADeployer { ); deployProxy({name: type(AppAuthority).name, deployedTo: address(appAuthorityProxy)}); - // 6. New AppController implementation — wired to the factory proxy - // and the AppAuthority proxy. The AppAuthority immutable is the - // proxy address; the impl's consumer check authenticates calls - // from AppController's proxy, which is what the upgraded impl - // (delegatecalled from the proxy) will look like. + // 6. New AppController implementation — wired to the AppAuthority + // proxy. AppController no longer references SafeTimelockFactory; + // the factory is still deployed (steps 2–3) because users / + // tooling can use it to deploy attested Safes and Timelocks, but + // AppController's correctness no longer depends on it. AppController newAppControllerImpl = new AppController({ _version: Env.deployVersion(), _permissionController: Env.permissionController(), @@ -85,7 +84,6 @@ contract DeployGovernanceContracts is EOADeployer { _computeAVSRegistrar: Env.proxy.computeAVSRegistrar(), _computeOperator: Env.proxy.computeOperator(), _appBeacon: Env.beacon.appBeacon(), - _safeTimelockFactory: ISafeTimelockFactory(address(safeTimelockFactoryProxy)), _appAuthority: IAppAuthority(address(appAuthorityProxy)) }); deployImpl({name: type(AppController).name, deployedTo: address(newAppControllerImpl)}); @@ -151,11 +149,6 @@ contract DeployGovernanceContracts is EOADeployer { ); assertEq(address(appImpl.computeOperator()), address(Env.proxy.computeOperator()), "computeOperator mismatch"); assertEq(address(appImpl.appBeacon()), address(Env.beacon.appBeacon()), "appBeacon mismatch"); - assertEq( - address(appImpl.safeTimelockFactory()), - address(Env.proxy.safeTimelockFactory()), - "safeTimelockFactory mismatch" - ); assertEq(address(appImpl.appAuthority()), address(Env.proxy.appAuthority()), "appAuthority mismatch"); AppAuthority authorityImpl = Env.impl.appAuthority(); diff --git a/src/AppController.sol b/src/AppController.sol index eaea142..7feab91 100644 --- a/src/AppController.sol +++ b/src/AppController.sol @@ -19,7 +19,6 @@ import {IAppController} from "./interfaces/IAppController.sol"; import {IAppAuthority} from "./interfaces/IAppAuthority.sol"; import {IBeacon} from "@openzeppelin/contracts/proxy/beacon/IBeacon.sol"; import {IApp} from "./interfaces/IApp.sol"; -import {ISafeTimelockFactory} from "./interfaces/ISafeTimelockFactory.sol"; import {ICallValidator} from "./interfaces/ICallValidator.sol"; import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; @@ -83,14 +82,11 @@ contract AppController is IComputeAVSRegistrar _computeAVSRegistrar, IComputeOperator _computeOperator, IBeacon _appBeacon, - ISafeTimelockFactory _safeTimelockFactory, IAppAuthority _appAuthority ) SignatureUtilsMixin(_version) PermissionControllerMixin(_permissionController) - AppControllerStorage( - _releaseManager, _computeOperator, _computeAVSRegistrar, _appBeacon, _safeTimelockFactory, _appAuthority - ) + AppControllerStorage(_releaseManager, _computeOperator, _computeAVSRegistrar, _appBeacon, _appAuthority) { _disableInitializers(); } @@ -152,10 +148,11 @@ contract AppController is appIsActive(app) returns (uint256) { - // Critical op: only the current owner (`creator`) may call. For - // timelocked apps, `creator` is the Timelock itself, which forces - // the call through schedule → execute. For non-timelocked apps the - // owner acts directly. Co-ADMINs cannot upgrade. + // Critical op: only the current owner may call. If the owner is a + // Timelock, this forces the call through schedule → execute; if it's + // a Safe, through the multisig; if it's an EOA, directly. The + // governance mechanism is whatever the owner contract is — AppController + // doesn't need to classify it. return _upgradeApp(app, release); } @@ -176,12 +173,6 @@ contract AppController is // the cache. config.creator = newOwner; - // Flip the flag based on the new owner. Non-factory addresses (EOAs, - // externally-deployed Safes, arbitrary contracts) clear it — they have - // no schedule→execute semantics we can trust. Factory-deployed - // Timelocks enable it. - config.timelocked = address(safeTimelockFactory) != address(0) && safeTimelockFactory.isTimelock(newOwner); - // ISOLATED billing apps bill the app address, not the creator, so // ownership transfer has no effect on billing accounting. DEFAULT // billing apps bill the creator, so we need to move the active-app @@ -227,11 +218,13 @@ contract AppController is /// @inheritdoc IAppController function terminateAppByAdmin(IApp app) external checkCanCall(address(this)) appIsActive(app) { - // Protocol admin may not unilaterally terminate a Timelock-owned app; - // doing so would bypass the delay the user specifically opted into. - // If intervention is required, protocol admin should schedule the - // termination through the Timelock itself. - require(!_appConfigs[app].timelocked, InvalidPermissions()); + // Protocol admin (UAM-gated) may terminate any app. This is protocol + // policy that sits above app-level governance — abuse, legal, or + // platform-level concerns require a uniform lever regardless of what + // the user chose as the app's owner. If the protocol wants its own + // termination actions to be delay-gated, the protocol's UAM admin + // multisig should itself be behind a Timelock — that's an operational + // decision, not an AppController concern. _terminateApp(app); emit AppTerminatedByAdmin(app); } @@ -322,14 +315,6 @@ contract AppController is _appConfigs[app].latestReleaseBlockNumber = 0; _appConfigs[app].creator = msg.sender; _appConfigs[app].billingType = _billingType; - // If the creator is a factory-deployed Timelock, mark the app - // timelocked at creation so sensitive-op gates fire immediately. - // Not doing this here leaves a window where any co-admin could run - // upgradeApp / terminateApp before ownership is "transferred" — and - // in fact createApp never involves transferOwnership at all. - if (address(safeTimelockFactory) != address(0)) { - _appConfigs[app].timelocked = safeTimelockFactory.isTimelock(msg.sender); - } _allApps.add(address(app)); // Register the scope + seed creator as ADMIN in AppAuthority. All @@ -605,11 +590,6 @@ contract AppController is return _appConfigs[app].billingType; } - /// @inheritdoc IAppController - function getAppTimelocked(IApp app) external view returns (bool) { - return _appConfigs[app].timelocked; - } - /// @inheritdoc IERC165 function supportsInterface(bytes4 interfaceId) external pure returns (bool) { return interfaceId == type(ICallValidator).interfaceId || interfaceId == type(IERC165).interfaceId; @@ -638,12 +618,6 @@ contract AppController is if (!appAuthority.isScopeOwner(app, caller)) return false; } - // terminateAppByAdmin refuses timelocked apps unconditionally. - if (selector == this.terminateAppByAdmin.selector) { - IApp app = abi.decode(data[4:36], (IApp)); - if (_appConfigs[app].timelocked) return false; - } - return true; } diff --git a/src/interfaces/IAppController.sol b/src/interfaces/IAppController.sol index 3aeeb55..c315302 100644 --- a/src/interfaces/IAppController.sol +++ b/src/interfaces/IAppController.sol @@ -119,17 +119,18 @@ interface IAppController { // bytes 24-27: uint32 latestReleaseBlockNumber ( 4 bytes) // byte 28: AppStatus status ( 1 byte) // byte 29: BillingType billingType ( 1 byte) ← present on v1.4.0 chain state - // byte 30: bool timelocked ( 1 byte) ← new in v1.5.0; safely zero on all v1.4.0 apps - // byte 31: (unused) + // bytes 30-31: (unused) + // + // Byte 30 was briefly earmarked for a `timelocked` boolean in an + // earlier v1.5.0 draft. That design has been retired — critical ops + // are owner-gated and the governance mechanism is determined by the + // owner contract itself, not a flag on AppController. Byte 30 is + // therefore unused and guaranteed zero on all chain state. address creator; uint32 operatorSetId; uint32 latestReleaseBlockNumber; AppStatus status; BillingType billingType; - // true = owner is a factory Timelock; sensitive ops must go through - // Timelock.schedule → execute. Must NOT be placed at byte 29 — that - // byte already holds `billingType` on existing deployed contracts. - bool timelocked; } /// @notice User configuration and state @@ -181,10 +182,11 @@ interface IAppController { * @param app The app to upgrade with the release * @param release The release to upgrade to * @return releaseId The unique identifier for the published release - * @dev Caller must be the app's current owner (`creator`). For timelocked - * apps this is the Timelock itself, so the call is forced through - * schedule → execute. For non-timelocked apps (EOA / Safe-owned) the - * owner acts directly. Co-ADMINs cannot upgrade. + * @dev Caller must be the app's current owner (`creator`). If the owner + * is a Timelock, the call is forced through schedule → execute; + * if it's a Safe, through the multisig threshold; if it's an EOA, + * directly. The governance mechanism is whatever the owner contract + * is — AppController does not classify it. Co-ADMINs cannot upgrade. * @dev The rms release must have exactly one artifact, with the digest being the docker * image digest and the registry being the docker registry it is stored at. * @dev The env must be a JSON marshalled bytes representing the public environment variables for the app. @@ -199,11 +201,13 @@ interface IAppController { * @param app The app to transfer ownership of * @param newOwner The new owner address * @dev Caller must be the app's current owner (`creator`). - * @dev When `newOwner` is a factory-deployed Timelock the app's `timelocked` - * flag is flipped to true; otherwise it's cleared. - * @dev The new owner is atomically granted ADMIN on the team and the - * previous owner is atomically removed from ADMIN. This is the only - * path that rotates ADMIN membership for the owner. + * @dev The new owner is atomically granted ADMIN on the team in + * AppAuthority and the previous owner is atomically removed from + * ADMIN. This is the only path that rotates ADMIN membership for + * the owner. + * @dev The new owner's contract type (EOA / Safe / Timelock / other) + * determines the governance mechanism for future critical ops. + * AppController does not classify or enforce a choice here. */ function transferOwnership(IApp app, address newOwner) external; @@ -333,15 +337,6 @@ interface IAppController { */ function getBillingType(IApp app) external view returns (BillingType); - /** - * @notice Returns whether the app's creator is a factory Timelock. - * @param app The app to check - * @return True iff sensitive ops (upgrade/terminate) must go through - * Timelock.schedule → execute — i.e. direct calls by any non-owner - * are rejected regardless of PermissionController grants. - */ - function getAppTimelocked(IApp app) external view returns (bool); - /** * @notice Gets the operator set ID for a given app * @param app The app to get the operator set ID for diff --git a/src/interfaces/ISafe.sol b/src/interfaces/ISafe.sol index 0c98a5a..6d07d76 100644 --- a/src/interfaces/ISafe.sol +++ b/src/interfaces/ISafe.sol @@ -1,7 +1,16 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.27; -/// @dev Interface for Gnosis Safe setup function +// Minimal interface for the Gnosis Safe singleton. Hand-rolled to avoid +// pulling the full safe-global/safe-contracts dependency for two function +// selectors. +// +// Signature source: Safe singleton `setup(address[],uint256,address,bytes, +// address,address,uint256,address)`. Stable across Safe v1.3.0 / v1.3.0-l2 / +// v1.4.1 / v1.4.1-l2 — the versions currently deployed on the chains we +// target (mainnet, Sepolia, OP-stack L2s). If Safe ships a breaking change +// in a future major version, update this file and pin the deployed +// singleton in zeus env accordingly. interface ISafe { function setup( address[] calldata _owners, diff --git a/src/interfaces/ISafeProxyFactory.sol b/src/interfaces/ISafeProxyFactory.sol index 7fe3e8a..da454e3 100644 --- a/src/interfaces/ISafeProxyFactory.sol +++ b/src/interfaces/ISafeProxyFactory.sol @@ -1,7 +1,13 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.27; -/// @dev Interface for Gnosis SafeProxyFactory +// Minimal interface for Gnosis SafeProxyFactory. Hand-rolled to avoid +// depending on safe-global/safe-contracts for two function selectors. +// +// Signature source: SafeProxyFactory v1.3.0 through v1.4.1 — stable across +// the factory versions currently deployed on our target chains. +// `createProxyWithNonce` is what we call; `proxyCreationCode` is used to +// precompute deterministic Safe addresses via CREATE2. interface ISafeProxyFactory { function createProxyWithNonce(address _singleton, bytes memory initializer, uint256 saltNonce) external diff --git a/src/storage/AppControllerStorage.sol b/src/storage/AppControllerStorage.sol index 229b3cd..7157841 100644 --- a/src/storage/AppControllerStorage.sol +++ b/src/storage/AppControllerStorage.sol @@ -9,7 +9,6 @@ import {IApp} from "../interfaces/IApp.sol"; import {IAppController} from "../interfaces/IAppController.sol"; import {IBeacon} from "@openzeppelin/contracts/proxy/beacon/IBeacon.sol"; import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; -import {ISafeTimelockFactory} from "../interfaces/ISafeTimelockFactory.sol"; import {IAppAuthority} from "../interfaces/IAppAuthority.sol"; abstract contract AppControllerStorage is IAppController { @@ -40,9 +39,6 @@ abstract contract AppControllerStorage is IAppController { /// @notice The beacon used for creating App proxies IBeacon public immutable appBeacon; - /// @notice Factory used to verify Safe and Timelock deployments for governance detection - ISafeTimelockFactory public immutable safeTimelockFactory; - /// @notice Authority contract that owns per-app ownership and RBAC state. /// @dev AppController delegates auth to this contract — ownership transfer, /// role management, and schedule-time validation all flow through @@ -69,14 +65,12 @@ abstract contract AppControllerStorage is IAppController { IComputeOperator _computeOperator, IComputeAVSRegistrar _computeAVSRegistrar, IBeacon _appBeacon, - ISafeTimelockFactory _safeTimelockFactory, IAppAuthority _appAuthority ) { releaseManager = _releaseManager; computeOperator = _computeOperator; computeAVSRegistrar = _computeAVSRegistrar; appBeacon = _appBeacon; - safeTimelockFactory = _safeTimelockFactory; appAuthority = _appAuthority; } diff --git a/test/AppController.t.sol b/test/AppController.t.sol index 4caebac..2551f31 100644 --- a/test/AppController.t.sol +++ b/test/AppController.t.sol @@ -1360,53 +1360,22 @@ contract AppControllerTest is ComputeDeployer { assertEq(uint256(appController.getAppStatus(app)), uint256(IAppController.AppStatus.TERMINATED)); } - // ========== Timelocked-gate regression tests ========== + // ========== Owner-gated critical ops ========== // - // These tests pin down the runtime invariant that sensitive ops against a - // Timelock-owned app must go through schedule → execute. The gate lives - // in upgradeApp / terminateApp / terminateAppByAdmin and fires whenever - // `_appConfigs[app].timelocked == true`, which is set at creation if - // msg.sender is a factory-registered Timelock. - // - // We mock `isTimelock(developer)` to true so createApp flips the flag, - // then assert that a PermissionController-permitted co-admin is blocked - // from calling the sensitive op directly. - - function _mockIsTimelock(address account) internal { - address factory = address(AppController(address(appController)).safeTimelockFactory()); - vm.mockCall(factory, abi.encodeWithSignature("isTimelock(address)", account), abi.encode(true)); - } - - function test_createApp_flagsTimelockedWhenCallerIsTimelock() public { - _mockIsTimelock(developer); + // These tests pin down the runtime invariant that sensitive ops on any + // app are gated on the current owner — not on ADMIN membership. If the + // owner happens to be a Timelock, critical ops naturally flow through + // schedule → execute; if a Safe, through the multisig; if an EOA, + // directly. AppController does not classify the owner contract. + function test_upgradeApp_blocksCoAdminNonOwner() public { vm.prank(developer); IApp app = appController.createApp(SALT, _assembleRelease()); - assertTrue(appController.getAppTimelocked(app), "Timelock-created app must have timelocked=true at creation"); - } - - function test_createApp_doesNotFlagTimelockedForNonTimelockCaller() public { - // No mock: factory returns false for random addresses. Regression - // guard that the fix doesn't over-apply the flag. - vm.prank(developer); - IApp app = appController.createApp(SALT, _assembleRelease()); - - assertFalse(appController.getAppTimelocked(app), "EOA-created app must not be flagged timelocked"); - } - - function test_upgradeApp_timelockedBlocksNonOwnerEvenWithPermission() public { - _mockIsTimelock(developer); - vm.prank(developer); - IApp app = appController.createApp(SALT, _assembleRelease()); - require(appController.getAppTimelocked(app), "precondition: app must be timelocked"); - - // Timelock (creator) grants ADMIN role to a co-admin. Under the RBAC - // model, only ADMIN can even attempt upgradeApp; holding ADMIN is - // the strongest authority the co-admin could plausibly have. + // Owner grants ADMIN to a co-admin. Under the owner-gated model, + // holding ADMIN is the strongest authority a co-admin could + // plausibly have — but ADMIN does NOT convey upgrade power. address coAdmin = makeAddr("coAdmin"); - // Timelock ADMIN grant on a timelocked app requires msg.sender == creator; - // developer IS the creator (via _mockIsTimelock), so this passes directly. vm.prank(developer); appAuthority.grantRole(app, IAppAuthority.Role.ADMIN, coAdmin); @@ -1417,14 +1386,12 @@ contract AppControllerTest is ComputeDeployer { vm.expectRevert(IAppController.NotCreator.selector); appController.upgradeApp(app, _assembleRelease()); - // The Timelock itself (app.creator) can still call upgrade directly — - // representing the path where a scheduled op is being executed. + // The owner can still upgrade directly. vm.prank(developer); appController.upgradeApp(app, _assembleRelease()); } - function test_terminateApp_timelockedBlocksNonOwner() public { - _mockIsTimelock(developer); + function test_terminateApp_blocksCoAdminNonOwner() public { vm.prank(developer); IApp app = appController.createApp(SALT, _assembleRelease()); @@ -1436,85 +1403,67 @@ contract AppControllerTest is ComputeDeployer { vm.expectRevert(IAppController.NotCreator.selector); appController.terminateApp(app); - // Timelock (creator) can terminate. + // Owner can terminate. vm.prank(developer); appController.terminateApp(app); assertEq(uint256(appController.getAppStatus(app)), uint256(IAppController.AppStatus.TERMINATED)); } - function test_terminateAppByAdmin_refusesTimelockedApp() public { - _mockIsTimelock(developer); + function test_terminateAppByAdmin_worksOnAnyApp() public { + // Protocol admin can terminate any app uniformly. No longer gated on + // governance type. If the protocol wants its own termination actions + // to be delay-gated, that's an operational decision about the UAM + // admin multisig — not an AppController concern. vm.prank(developer); IApp app = appController.createApp(SALT, _assembleRelease()); - // Even the protocol admin cannot terminate a timelocked app directly — - // they must go through the Timelock to preserve the delay invariant. vm.prank(admin); - vm.expectRevert(PermissionControllerMixin.InvalidPermissions.selector); appController.terminateAppByAdmin(app); + + assertEq(uint256(appController.getAppStatus(app)), uint256(IAppController.AppStatus.TERMINATED)); } // ========== transferOwnership ========== event AppOwnershipTransferred(IApp indexed app, address indexed previousOwner, address indexed newOwner); - function test_transferOwnership_toEOA_clearsTimelocked() public { - // Start with a Timelock-owned app. - _mockIsTimelock(developer); + function test_transferOwnership_emitsEvent() public { vm.prank(developer); IApp app = appController.createApp(SALT, _assembleRelease()); vm.prank(developer); permissionController.acceptAdmin(address(app)); - assertTrue(appController.getAppTimelocked(app)); - address plainEOA = makeAddr("plainEOA"); + address newOwner = makeAddr("newOwner"); + _setMaxActiveAppsPerUser(newOwner, 10); vm.expectEmit(true, true, true, true); - emit AppOwnershipTransferred(app, developer, plainEOA); - - vm.prank(developer); - appController.transferOwnership(app, plainEOA); - - assertFalse(appController.getAppTimelocked(app), "timelocked must clear when new owner is not a Timelock"); - assertEq(appController.getAppCreator(app), plainEOA); - } + emit AppOwnershipTransferred(app, developer, newOwner); - function test_transferOwnership_toTimelock_setsTimelocked() public { - // Non-timelocked starting state. vm.prank(developer); - IApp app = appController.createApp(SALT, _assembleRelease()); - vm.prank(developer); - permissionController.acceptAdmin(address(app)); - assertFalse(appController.getAppTimelocked(app)); - - address newTimelock = makeAddr("newTimelock"); - _mockIsTimelock(newTimelock); - - vm.prank(developer); - appController.transferOwnership(app, newTimelock); + appController.transferOwnership(app, newOwner); - assertTrue(appController.getAppTimelocked(app), "timelocked must set when new owner is a Timelock"); - assertEq(appController.getAppCreator(app), newTimelock); + assertEq(appController.getAppCreator(app), newOwner); } - function test_transferOwnership_timelockedBlocksNonOwner() public { - _mockIsTimelock(developer); + function test_transferOwnership_blocksCoAdminNonOwner() public { vm.prank(developer); IApp app = appController.createApp(SALT, _assembleRelease()); - // Timelock grants ADMIN to coAdmin. ADMIN is the strongest role a - // co-admin could plausibly have; the creator-only gate must still - // block them from moving the app out of governance. + // Owner grants ADMIN to coAdmin. ADMIN is the strongest role a + // co-admin could plausibly have; the owner-gate must still block + // them from moving the app. address coAdmin = makeAddr("coAdmin"); vm.prank(developer); appAuthority.grantRole(app, IAppAuthority.Role.ADMIN, coAdmin); address attacker = makeAddr("attacker"); + _setMaxActiveAppsPerUser(attacker, 10); + vm.prank(coAdmin); vm.expectRevert(IAppController.NotCreator.selector); appController.transferOwnership(app, attacker); - // Timelock itself can still transfer. + // Owner can still transfer. vm.prank(developer); appController.transferOwnership(app, attacker); assertEq(appController.getAppCreator(app), attacker); @@ -1591,53 +1540,23 @@ contract AppControllerTest is ComputeDeployer { assertTrue(ICallValidator(address(appController)).canCall(address(this), hex"00")); } - function test_canCall_rejectsNonTimelockCallerOnTimelockedUpgradeApp() public { - _mockIsTimelock(developer); - vm.prank(developer); - IApp app = appController.createApp(SALT, _assembleRelease()); - - bytes memory callData = abi.encodeWithSelector(IAppController.upgradeApp.selector, app, _assembleRelease()); - address notOwner = makeAddr("notOwner"); - - assertFalse( - ICallValidator(address(appController)).canCall(notOwner, callData), - "canCall must reject non-owner schedule of timelocked upgradeApp" - ); - assertTrue( - ICallValidator(address(appController)).canCall(developer, callData), - "canCall must accept owner (Timelock itself) schedule of timelocked upgradeApp" - ); - } - - function test_canCall_rejectsNonOwnerUpgradeAppEvenWhenNotTimelocked() public { + function test_canCall_rejectsNonOwnerUpgradeApp() public { // Owner-gated model: canCall rejects any non-owner schedule of a - // critical op, regardless of timelocked state. Previously this was - // only checked for timelocked apps; now owner-gating is unconditional - // so schedule-time rejection matches runtime. + // critical op. Schedule-time rejection matches the runtime + // onlyCreator gate. vm.prank(developer); IApp app = appController.createApp(SALT, _assembleRelease()); bytes memory callData = abi.encodeWithSelector(IAppController.upgradeApp.selector, app, _assembleRelease()); assertFalse( ICallValidator(address(appController)).canCall(makeAddr("anyone"), callData), - "canCall must reject non-owner schedule even for non-timelocked apps" + "canCall must reject non-owner schedule" ); assertTrue( ICallValidator(address(appController)).canCall(developer, callData), "canCall must accept owner schedule" ); } - function test_canCall_rejectsTerminateAppByAdminOnTimelockedApp() public { - _mockIsTimelock(developer); - vm.prank(developer); - IApp app = appController.createApp(SALT, _assembleRelease()); - - bytes memory callData = abi.encodeWithSelector(IAppController.terminateAppByAdmin.selector, app); - // terminateAppByAdmin against a timelocked app is unconditionally doomed - // regardless of caller — canCall reflects that. - assertFalse(ICallValidator(address(appController)).canCall(admin, callData)); - } - // ========== Team-role RBAC ========== function test_createApp_grantsAdminRoleToCreator() public { @@ -1670,16 +1589,13 @@ contract AppControllerTest is ComputeDeployer { appAuthority.grantRole(app, IAppAuthority.Role.PAUSER, outsider); } - function test_grantTeamRole_timelockedOperationalRoleNotGated() public { - // A-2 fix: PAUSER/DEVELOPER grants on timelocked apps are NOT - // routed through the Timelock. Any existing ADMIN can grant — the - // power delegated is operational only. - _mockIsTimelock(developer); + function test_grantTeamRole_operationalRoleNotOwnerGated() public { + // PAUSER/DEVELOPER grants are NOT owner-gated — any existing ADMIN + // can grant these bounded operational roles. vm.prank(developer); IApp app = appController.createApp(SALT, _assembleRelease()); - // Give coAdmin ADMIN via the Timelock (creator), then confirm - // coAdmin can grant PAUSER freely (no creator-only gate). + // Give coAdmin ADMIN, then confirm coAdmin can grant PAUSER freely. address coAdmin = makeAddr("coAdmin"); vm.prank(developer); appAuthority.grantRole(app, IAppAuthority.Role.ADMIN, coAdmin); @@ -1690,12 +1606,10 @@ contract AppControllerTest is ComputeDeployer { assertTrue(appAuthority.hasRole(app, IAppAuthority.Role.PAUSER, pauser)); } - function test_grantTeamRole_adminGrantGatedByCreator() public { - // Owner-gated model: ADMIN grants are creator-only regardless of - // timelocked/Safe/EOA ownership. Applies to timelocked apps (Timelock - // is creator) and to non-timelocked apps (EOA/Safe is creator). A - // co-ADMIN cannot mint another ADMIN. - _mockIsTimelock(developer); + function test_grantTeamRole_adminGrantGatedByOwner() public { + // Owner-gated model: ADMIN grants are owner-only regardless of + // whether the owner is an EOA, Safe, or Timelock. A co-ADMIN + // cannot mint another ADMIN. vm.prank(developer); IApp app = appController.createApp(SALT, _assembleRelease()); @@ -1709,32 +1623,14 @@ contract AppControllerTest is ComputeDeployer { vm.expectRevert(IAppAuthority.NotScopeOwner.selector); appAuthority.grantRole(app, IAppAuthority.Role.ADMIN, usurper); - // The Timelock (owner) can still grant. + // The owner can still grant. vm.prank(developer); appAuthority.grantRole(app, IAppAuthority.Role.ADMIN, usurper); assertTrue(appAuthority.hasRole(app, IAppAuthority.Role.ADMIN, usurper)); } - function test_grantTeamRole_adminGrantGatedByCreator_nonTimelocked() public { - // Safe/EOA-owned app: same owner-only rule applies. This is what - // prevents a co-ADMIN from silently escalating to owner on Safe-owned - // apps and bypassing the multisig. - vm.prank(developer); - IApp app = appController.createApp(SALT, _assembleRelease()); - - address coAdmin = makeAddr("coAdmin"); - vm.prank(developer); - appAuthority.grantRole(app, IAppAuthority.Role.ADMIN, coAdmin); - - address usurper = makeAddr("usurper"); - vm.prank(coAdmin); - vm.expectRevert(IAppAuthority.NotScopeOwner.selector); - appAuthority.grantRole(app, IAppAuthority.Role.ADMIN, usurper); - } - - function test_revokeTeamRole_adminRevokeGatedByCreator() public { + function test_revokeTeamRole_adminRevokeGatedByOwner() public { // Mirror of grant: revoking ADMIN is owner-only unconditionally. - _mockIsTimelock(developer); vm.prank(developer); IApp app = appController.createApp(SALT, _assembleRelease()); @@ -1861,9 +1757,10 @@ contract AppControllerTest is ComputeDeployer { function test_transferOwnership_removesPreviousOwnerFromAdmin() public { // Fix for audit A-3 / V-10: the previous owner must be removed from - // the ADMIN set on transfer. Otherwise an old EOA (or an old - // Timelock post-handoff) retains operational powers and, in the - // non-timelocked case, can re-grab the app. + // the ADMIN set on transfer. Otherwise an old owner (EOA, Safe, or + // old Timelock post-handoff) retains operational powers and, if + // they remain ADMIN while the new owner holds a weaker key, they + // can re-grab the app. vm.prank(developer); IApp app = appController.createApp(SALT, _assembleRelease()); From e915ec83e2fd7ee51fdb78651de2575dd0c751f1 Mon Sep 17 00:00:00 2001 From: Taras Shchybovyk Date: Fri, 24 Apr 2026 17:01:14 -0700 Subject: [PATCH 13/17] docs: replace internal "Option-2" references with descriptive text "Option-2" was a label from a design discussion, not anything defined in the codebase. Reviewers coming to these files cold had no way to know what it referred to. Swap it for descriptions of the actual invariants: owner-gated critical ops, operational-only ADMIN, owner cannot renounce/self-revoke, transferScopeOwnership is the sole owner rotation path. --- src/AppController.sol | 6 ++++-- src/governance/AppAuthority.sol | 29 ++++++++++++++++------------ src/interfaces/IAppController.sol | 4 +++- src/storage/AppControllerStorage.sol | 9 ++++++--- 4 files changed, 30 insertions(+), 18 deletions(-) diff --git a/src/AppController.sol b/src/AppController.sol index 7feab91..727494f 100644 --- a/src/AppController.sol +++ b/src/AppController.sol @@ -270,8 +270,10 @@ contract AppController is // (1) If AppAuthority has no owner recorded, initialize the scope // with AppController's cached `creator` field. // (2) Seed AppAuthority's ADMIN role with the app's - // PermissionController admins (operational-only under Option 2; - // no critical-op exposure). + // PermissionController admins. ADMIN is an operational-only + // role in this model — it does NOT confer upgrade / transfer / + // terminate power, so migrated admins carry no critical-op + // exposure; the owner remains the only critical-op authority. // Idempotent: re-running is safe because initializeScope reverts on // reinit (handled), and grantRole is set-semantics. uint256 n = apps.length; diff --git a/src/governance/AppAuthority.sol b/src/governance/AppAuthority.sol index 8e36f53..69f5854 100644 --- a/src/governance/AppAuthority.sol +++ b/src/governance/AppAuthority.sol @@ -12,19 +12,24 @@ import {IApp} from "../interfaces/IApp.sol"; * AppController is the sole consumer — it authenticates callers of * app-lifecycle operations and delegates the auth state here. * - * @dev The contract enforces the Option-2 invariants by construction: - * - Only the owner may grant/revoke/transfer ADMIN. - * - The owner is always ADMIN on their scope, cannot renounce or - * self-revoke. - * - `transferScopeOwnership` is the only path that rotates the owner, - * and it adds the new owner + removes the previous owner from ADMIN - * atomically. + * @dev The contract enforces the owner-gated RBAC model by construction: + * - Only the scope owner may grant / revoke ADMIN. ADMIN itself is an + * operational-only role — it does NOT confer critical-op power on + * the consumer (upgrade / transfer / terminate). Those are gated on + * `isScopeOwner` at the consumer layer. + * - The owner is always ADMIN on their scope; they cannot renounce or + * self-revoke ADMIN. `transferScopeOwnership` is the only path that + * rotates the owner, and it adds the new owner + removes the previous + * owner from ADMIN atomically. + * - Non-ADMIN roles (PAUSER, DEVELOPER) are bounded, revocable + * operational powers any ADMIN may grant. They carry no critical-op + * authority. * - * @dev Why extract: the Timelock guarantee binds iff the admin set the - * critical-op gate trusts is a set the contract enforcing the gate - * controls. AppController delegates to this contract; this contract's - * mutation surface is minimal and fully enumerable. Moving the auth - * logic here makes the trust boundary explicit and shrinks the audit + * @dev Why extract: the critical-op gate on AppController + * (`msg.sender == scopeOwner`) binds iff the owner identity the gate + * reads is mutated only through paths this contract controls. Owning + * the state in a small dedicated contract with a narrow mutation + * surface makes that trust boundary explicit and shrinks the audit * surface of AppController accordingly. */ contract AppAuthority is Initializable, IAppAuthority { diff --git a/src/interfaces/IAppController.sol b/src/interfaces/IAppController.sol index c315302..c579a54 100644 --- a/src/interfaces/IAppController.sol +++ b/src/interfaces/IAppController.sol @@ -268,7 +268,9 @@ interface IAppController { * - seeds AppAuthority.scopeOwner(app) from AppController.creator * if not already set; * - seeds AppAuthority.ADMIN role with the app's PermissionController - * admins (operational-only role under the Option-2 model). + * admins. ADMIN is an operational-only role — it does NOT + * confer critical-op power (upgrade / transfer / terminate), + * so migrated admins cannot replay pre-v1.5.0 critical ops. * @dev Caller must be UAM permissioned for the AppController itself * (platform admin). Intended to be called once per app after the * v1.5.0 upgrade; safe to call again (idempotent per-(app, admin)). diff --git a/src/storage/AppControllerStorage.sol b/src/storage/AppControllerStorage.sol index 7157841..ca234d2 100644 --- a/src/storage/AppControllerStorage.sol +++ b/src/storage/AppControllerStorage.sol @@ -40,9 +40,12 @@ abstract contract AppControllerStorage is IAppController { IBeacon public immutable appBeacon; /// @notice Authority contract that owns per-app ownership and RBAC state. - /// @dev AppController delegates auth to this contract — ownership transfer, - /// role management, and schedule-time validation all flow through - /// AppAuthority. The Option-2 invariants are enforced there, not here. + /// @dev AppController delegates auth to this contract — ownership + /// transfer, role management, and the scope-owner reads used by + /// `onlyCreator` / `canCall` all flow through AppAuthority. The + /// owner-gated invariants (only the owner may mutate ADMIN; owner + /// is always in ADMIN; transferScopeOwnership is the only owner + /// rotation path) are enforced in AppAuthority, not here. IAppAuthority public immutable appAuthority; /// @notice Set of all created apps From b1a22d630ec712759bb64d7f04baebe1ee64031a Mon Sep 17 00:00:00 2001 From: Taras Shchybovyk Date: Fri, 24 Apr 2026 21:16:20 -0700 Subject: [PATCH 14/17] docs: drop branch-local artifacts and stale references MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four docstring/comment cleanups, no code changes: - IAppController.AppConfigStorage: remove the slot-layout block that documented a retired draft (byte 30 timelocked flag) and the inter-file nav comment about AppAuthority.Role. Struct declaration speaks for itself. - Deploy.s.sol: rewrite the SafeTimelockFactory comment so it describes the code's behavior for any reader (zero Safe infra addresses means deploySafe unavailable; deployTimelock works), instead of a branch-relative "no longer depends" framing. - v1.5.0-governance/1-deployGovernanceContracts.s.sol: expand the "phase 1" docstring to say what release it's a step of and what it actually deploys (now 6 items — AppAuthority impl + proxy were missing from the list). Drops the stale "timelocked-flag detection" reference. - v1.5.0-governance/2-upgradeAppController.s.sol: same shape — drops "phase 2" in favor of "Second step of the v1.5.0 release" and stale "new bool timelocked" language. --- script/Deploy.s.sol | 13 ++++---- .../1-deployGovernanceContracts.s.sol | 33 ++++++++++--------- .../2-upgradeAppController.s.sol | 24 ++++++++------ src/interfaces/IAppController.sol | 18 ---------- 4 files changed, 39 insertions(+), 49 deletions(-) diff --git a/script/Deploy.s.sol b/script/Deploy.s.sol index d0f467d..01faa6b 100644 --- a/script/Deploy.s.sol +++ b/script/Deploy.s.sol @@ -95,12 +95,13 @@ contract Deploy is Parser { UpgradeableBeacon appBeacon = new UpgradeableBeacon(address(new App(params.version, IPermissionController(params.permissionController)))); - // Deploy SafeTimelockFactory so tests / local deploys can exercise - // the deployTimelock / deploySafe paths. AppController no longer - // depends on this factory — governance is whatever the app's owner - // contract is. Safe infrastructure addresses are zero here; local - // deploys don't exercise deploySafe. Production releases wire real - // Safe addresses in the v1.5.0 release script. + // SafeTimelockFactory for tests and local deploys that need to + // exercise the deployTimelock / deploySafe paths. Safe infra + // addresses (singleton / proxy factory / fallback handler) are zero + // here because local environments don't have canonical Safe + // deployments; deploySafe is not callable in that configuration but + // deployTimelock remains functional. Production deploys set real + // Safe addresses via zeus env in the release scripts. TimelockControllerImpl timelockImpl = new TimelockControllerImpl(); SafeTimelockFactory safeTimelockFactoryImpl = new SafeTimelockFactory({ _safeSingleton: address(0), diff --git a/script/releases/v1.5.0-governance/1-deployGovernanceContracts.s.sol b/script/releases/v1.5.0-governance/1-deployGovernanceContracts.s.sol index 44c428a..8b7b21c 100644 --- a/script/releases/v1.5.0-governance/1-deployGovernanceContracts.s.sol +++ b/script/releases/v1.5.0-governance/1-deployGovernanceContracts.s.sol @@ -14,23 +14,26 @@ import {AppAuthority} from "../../../src/governance/AppAuthority.sol"; import {IAppAuthority} from "../../../src/interfaces/IAppAuthority.sol"; /** - * @title DeployGovernanceContracts (v1.5.0-governance phase 1) - * @notice EOA phase of the governance release. + * @title DeployGovernanceContracts + * @notice First step of the v1.5.0 release. Run by an EOA; deploys all new + * impls and non-upgrade proxies. The AppController proxy itself is + * NOT upgraded here — that's `2-upgradeAppController.s.sol`, run by + * the ops multisig. * * Deploys, in order: - * 1. TimelockControllerImpl — clone-master for Timelocks created by the - * factory. Deployed directly (not behind a proxy); immutable. - * 2. SafeTimelockFactory implementation — references the Timelock impl - * above and the canonical Gnosis Safe infrastructure for the current - * chain (pulled from zeus env). - * 3. SafeTimelockFactory proxy — TransparentUpgradeableProxy pointing at - * the impl. ProxyAdmin is the existing protocol ProxyAdmin. - * 4. New AppController implementation — wired to the factory proxy so - * the timelocked-flag detection (safeTimelockFactory.isTimelock) has - * a real target once the AppController proxy is upgraded in phase 2. - * - * The AppController proxy itself is NOT upgraded here; the multisig phase - * does that in 2-upgradeAppController.s.sol. + * 1. `TimelockControllerImpl` — clone master for Timelocks created by the + * factory. Deployed directly, no proxy. + * 2. `SafeTimelockFactory` impl — references the Timelock impl above and + * the canonical Gnosis Safe infrastructure (pulled from zeus env). + * 3. `SafeTimelockFactory` proxy — `TransparentUpgradeableProxy` behind + * the existing protocol `ProxyAdmin`. + * 4. `AppAuthority` impl — consumer-bound to the existing AppController + * proxy address. The consumer immutable is how AppAuthority + * authenticates mutation calls from the upgraded AppController. + * 5. `AppAuthority` proxy — `TransparentUpgradeableProxy` behind the same + * `ProxyAdmin`. + * 6. New `AppController` impl — wired to the AppAuthority proxy. Critical + * ops delegate to AppAuthority for owner and role checks. */ contract DeployGovernanceContracts is EOADeployer { using Env for *; diff --git a/script/releases/v1.5.0-governance/2-upgradeAppController.s.sol b/script/releases/v1.5.0-governance/2-upgradeAppController.s.sol index 59c0c2c..cde811d 100644 --- a/script/releases/v1.5.0-governance/2-upgradeAppController.s.sol +++ b/script/releases/v1.5.0-governance/2-upgradeAppController.s.sol @@ -8,17 +8,21 @@ import "../Env.sol"; import "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol"; /** - * @title UpgradeAppController (v1.5.0-governance phase 2) - * @notice Multisig phase: point the AppController proxy at the new - * implementation deployed in phase 1. No initializer call — the - * impl's storage layout is append-only (new `bool timelocked` sits - * at byte 30 of an existing slot, previously zero on every app). + * @title UpgradeAppController + * @notice Second step of the v1.5.0 release. Run by the ops multisig after + * the EOA-deployed impls from `1-deployGovernanceContracts.s.sol` + * are on-chain. Points the AppController proxy at the new impl via + * `ProxyAdmin.upgrade`. No initializer call — the new impl's + * storage layout is append-only vs. v1.4.0, so every existing app + * keeps its prior state (creator, operatorSetId, status, billingType) + * with no rewrites required. * - * Once this upgrade lands, existing apps retain their prior state - * (creator, operatorSetId, status, billingType) and gain the - * timelocked flag defaulted to false. New governance features — - * transferOwnership, hardened sensitive-op gates, ICallValidator - * schedule-time checks — become active immediately. + * Post-upgrade, AppController delegates auth to AppAuthority (also + * deployed in phase 1). Critical ops are owner-gated; operational + * roles (PAUSER, DEVELOPER) can be granted via AppAuthority. A + * follow-up call to `migrateAppsToAppAuthority` seeds AppAuthority + * state for existing apps — without it, auth checks on pre-upgrade + * apps revert because AppAuthority has no owner recorded. */ contract UpgradeAppController is MultisigBuilder, DeployGovernanceContracts { using Env for *; diff --git a/src/interfaces/IAppController.sol b/src/interfaces/IAppController.sol index c579a54..0af57a1 100644 --- a/src/interfaces/IAppController.sol +++ b/src/interfaces/IAppController.sol @@ -86,11 +86,6 @@ interface IAppController { ISOLATED // Billed to the app's own address } - // Team-role enum lives in IAppAuthority (IAppAuthority.Role). AppController - // consults AppAuthority for operational role checks (PAUSER, DEVELOPER) - // and is the only caller of consumer-gated methods on AppAuthority - // (initializeScope, transferScopeOwnership, migrateAdmins). - /** * @notice A struct containing a release and its environment * @param rmsRelease The release to publish @@ -113,19 +108,6 @@ interface IAppController { /// @notice Internal storage config for an app, extends AppConfig with additional fields struct AppConfigStorage { - // Slot layout (all 32 bytes packed): - // bytes 0-19: address creator (20 bytes) - // bytes 20-23: uint32 operatorSetId ( 4 bytes) - // bytes 24-27: uint32 latestReleaseBlockNumber ( 4 bytes) - // byte 28: AppStatus status ( 1 byte) - // byte 29: BillingType billingType ( 1 byte) ← present on v1.4.0 chain state - // bytes 30-31: (unused) - // - // Byte 30 was briefly earmarked for a `timelocked` boolean in an - // earlier v1.5.0 draft. That design has been retired — critical ops - // are owner-gated and the governance mechanism is determined by the - // owner contract itself, not a flag on AppController. Byte 30 is - // therefore unused and guaranteed zero on all chain state. address creator; uint32 operatorSetId; uint32 latestReleaseBlockNumber; From 8f6e0e43e2c7e73887acff561dc1def6e663f5a5 Mon Sep 17 00:00:00 2001 From: Taras Shchybovyk Date: Thu, 30 Apr 2026 16:00:30 -0700 Subject: [PATCH 15/17] review: fix self-transfer bug, PC leftover, migration gap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address three review comments from @solimander on PR #20: 1. AppAuthority.transferScopeOwnership: self-transfer (newOwner == previousOwner) would collapse the ADMIN add+remove pair into a net remove, stranding the owner without ADMIN. Add explicit guard + SameOwnerTransfer error. Test verifies the owner's ADMIN role survives the rejected call. 2. AppController._isDeveloper: was still reading from permissionController.isAdmin(app, developer) — leftover from the pre-v1.5.0 auth model. Switch to appAuthority.hasRole(app, ADMIN, developer). Update the pre-existing getAppsByDeveloper test to reflect the new semantic (creator is auto-seeded as ADMIN on every app, so getAppsByDeveloper returns all apps the address has ADMIN on; transferOwnership removes them atomically). 3. v1.5.0-governance/2-upgradeAppController: the migration from legacy PC admins to AppAuthority was documented as a follow-up call, but there was no script for it — owner-gated ops on pre-existing apps would revert in the gap between the proxy upgrade and the (absent) migration call. Fold the migration into the phase-2 multisig tx: after ProxyAdmin.upgrade, paginate through getApps() and call migrateAppsToAppAuthority in batches of 50. Atomic — no deployment window where owner-gated ops are broken. Tests: 184 → 185 passing (+1 self-transfer test; 1 pre-existing test renamed and rewritten for the new _isDeveloper semantic). --- .../2-upgradeAppController.s.sol | 52 +++++++++++++++---- src/AppController.sol | 15 ++++-- src/governance/AppAuthority.sol | 4 ++ src/interfaces/IAppAuthority.sol | 7 +++ test/AppAuthority.t.sol | 16 ++++++ test/AppController.t.sol | 37 +++++++------ 6 files changed, 99 insertions(+), 32 deletions(-) diff --git a/script/releases/v1.5.0-governance/2-upgradeAppController.s.sol b/script/releases/v1.5.0-governance/2-upgradeAppController.s.sol index cde811d..d608ff3 100644 --- a/script/releases/v1.5.0-governance/2-upgradeAppController.s.sol +++ b/script/releases/v1.5.0-governance/2-upgradeAppController.s.sol @@ -7,31 +7,61 @@ import "../Env.sol"; import "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol"; +import {IApp} from "../../../src/interfaces/IApp.sol"; +import {IAppController} from "../../../src/interfaces/IAppController.sol"; + /** * @title UpgradeAppController * @notice Second step of the v1.5.0 release. Run by the ops multisig after * the EOA-deployed impls from `1-deployGovernanceContracts.s.sol` - * are on-chain. Points the AppController proxy at the new impl via - * `ProxyAdmin.upgrade`. No initializer call — the new impl's - * storage layout is append-only vs. v1.4.0, so every existing app - * keeps its prior state (creator, operatorSetId, status, billingType) - * with no rewrites required. + * are on-chain. + * + * Performs two actions atomically in a single multisig transaction: + * 1. `ProxyAdmin.upgrade` points AppController at the new impl. From + * this call on, AppController delegates auth to AppAuthority. + * 2. `migrateAppsToAppAuthority` seeds AppAuthority state for every + * pre-existing app: initializes each scope from the cached + * `creator`, seeds ADMIN from the app's legacy PermissionController + * admins. Paginated in batches to stay under the block gas limit. + * + * Atomicity matters: between steps 1 and 2, pre-existing apps have + * `appAuthority.scopeOwner(app) == address(0)`, so every owner-gated + * critical op would revert. Running both in one multisig tx closes + * that window entirely. * - * Post-upgrade, AppController delegates auth to AppAuthority (also - * deployed in phase 1). Critical ops are owner-gated; operational - * roles (PAUSER, DEVELOPER) can be granted via AppAuthority. A - * follow-up call to `migrateAppsToAppAuthority` seeds AppAuthority - * state for existing apps — without it, auth checks on pre-upgrade - * apps revert because AppAuthority has no owner recorded. + * The storage layout is append-only vs. v1.4.0 plus the + * `pendingReleaseBlockNumber` field added in v1.4.1 — existing apps + * keep their prior state (creator, operatorSetId, status, + * billingType, latestReleaseBlockNumber) with no rewrites required. */ contract UpgradeAppController is MultisigBuilder, DeployGovernanceContracts { using Env for *; + /// @notice Page size for the post-upgrade migration loop. Conservative + /// default that keeps the outer multisig tx well under block + /// gas limits even with dozens of admins per app. + uint256 internal constant MIGRATION_PAGE_SIZE = 50; + function _runAsMultisig() internal override prank(Env.computeOpsMultisig()) { + // Step 1 — upgrade the AppController proxy to the new impl. Env.proxyAdmin() .upgrade( ITransparentUpgradeableProxy(address(Env.proxy.appController())), address(Env.impl.appController()) ); + + // Step 2 — seed AppAuthority state for every existing app. + // `getApps` is an unchanged v1.4.0 view; it enumerates the full + // `_allApps` set which the upgrade does not touch. Paginate to + // avoid one giant calldata/gas spike. + IAppController controller = IAppController(address(Env.proxy.appController())); + uint256 offset = 0; + while (true) { + (IApp[] memory apps,) = controller.getApps(offset, MIGRATION_PAGE_SIZE); + if (apps.length == 0) break; + controller.migrateAppsToAppAuthority(apps); + offset += apps.length; + if (apps.length < MIGRATION_PAGE_SIZE) break; + } } function testScript() public virtual override { diff --git a/src/AppController.sol b/src/AppController.sol index 81be812..d369412 100644 --- a/src/AppController.sol +++ b/src/AppController.sol @@ -531,13 +531,18 @@ contract AppController is } /** - * @notice Check if address is developer of app - * @param app The app to check - * @param developer The developer to check - * @return True if the developer is the developer of the app + * @notice Check whether `developer` has app-level authority on `app`. + * @dev Used as a predicate by `getAppsByDeveloper` to enumerate apps a + * given address can operate on. After the v1.5.0 migration, app- + * level authority is AppAuthority's ADMIN role (operational + * superset, held by the scope owner and anyone they've granted). + * Pre-migration fallback via PermissionController is intentionally + * NOT consulted here — addresses that existed only as + * PermissionController admins get reflected in AppAuthority's ADMIN + * set via `migrateAppsToAppAuthority`. */ function _isDeveloper(IApp app, address developer) private view returns (bool) { - return permissionController.isAdmin(address(app), developer); + return appAuthority.hasRole(app, IAppAuthority.Role.ADMIN, developer); } /** diff --git a/src/governance/AppAuthority.sol b/src/governance/AppAuthority.sol index 69f5854..c98abc6 100644 --- a/src/governance/AppAuthority.sol +++ b/src/governance/AppAuthority.sol @@ -88,6 +88,10 @@ contract AppAuthority is Initializable, IAppAuthority { address previousOwner = _scopeOwner[scope]; if (previousOwner == address(0)) revert ScopeNotInitialized(); + // Self-transfer would collapse the ADMIN add+remove pair into a net + // remove — the owner would lose their ADMIN role despite the owner + // pointer not changing. Reject explicitly. + if (newOwner == previousOwner) revert SameOwnerTransfer(); _scopeOwner[scope] = newOwner; diff --git a/src/interfaces/IAppAuthority.sol b/src/interfaces/IAppAuthority.sol index e01ad22..a42a039 100644 --- a/src/interfaces/IAppAuthority.sol +++ b/src/interfaces/IAppAuthority.sol @@ -54,6 +54,13 @@ interface IAppAuthority { /// @notice Thrown when a zero address is passed where non-zero is required. error ZeroAddress(); + /// @notice Thrown when `transferScopeOwnership` is called with the new + /// owner equal to the current owner. The operation is a no-op + /// in intent, but the naive implementation would incorrectly + /// remove the owner from ADMIN (add/remove on the same address + /// collapses to a net remove). Reject explicitly. + error SameOwnerTransfer(); + /// @notice Emitted when a scope is first initialized with an owner. event ScopeInitialized(IApp indexed scope, address indexed owner); diff --git a/test/AppAuthority.t.sol b/test/AppAuthority.t.sol index f72f357..842e5a0 100644 --- a/test/AppAuthority.t.sol +++ b/test/AppAuthority.t.sol @@ -95,6 +95,22 @@ contract AppAuthorityTest is Test { authority.transferScopeOwnership(APP_A, address(0)); } + function test_transferScopeOwnership_rejectsSelfTransfer() public { + // Self-transfer would collapse add(alice) + remove(alice) on the + // ADMIN set into a net remove — alice ends up not-an-ADMIN despite + // the owner pointer unchanged. Must reject explicitly. + vm.prank(consumer); + authority.initializeScope(APP_A, alice); + + vm.prank(consumer); + vm.expectRevert(IAppAuthority.SameOwnerTransfer.selector); + authority.transferScopeOwnership(APP_A, alice); + + // State must be untouched. + assertEq(authority.scopeOwner(APP_A), alice); + assertTrue(authority.hasRole(APP_A, IAppAuthority.Role.ADMIN, alice)); + } + // ========== grantRole ========== function test_grantRole_adminRequiresOwner() public { diff --git a/test/AppController.t.sol b/test/AppController.t.sol index d8d1246..fa81b2e 100644 --- a/test/AppController.t.sol +++ b/test/AppController.t.sol @@ -644,34 +644,39 @@ contract AppControllerTest is ComputeDeployer { assertEq(address(otherApps[1]), address(otherApp2)); } - function test_getAppsByCreator_worksWithoutAdminRights() public { + function test_getAppsByDeveloper_filtersByAppAuthorityAdmin() public { + // Developer creates two apps; both auto-seed developer as ADMIN in + // AppAuthority at createApp time. vm.prank(developer); IApp app1 = appController.createApp(keccak256("admin_test_1"), _assembleRelease()); vm.prank(developer); IApp app2 = appController.createApp(keccak256("admin_test_2"), _assembleRelease()); - // Only accept admin on app1, leave app2 without accepting admin - vm.prank(developer); - permissionController.acceptAdmin(address(app1)); - - // getAppsByCreator should return BOTH apps (filters by creator, not admin) + // Both apps list developer as creator AND as ADMIN. (IApp[] memory creatorApps,) = appController.getAppsByCreator(developer, 0, 10); assertEq(creatorApps.length, 2); - assertEq(address(creatorApps[0]), address(app1)); - assertEq(address(creatorApps[1]), address(app2)); - // getAppsByDeveloper should only return app1 (developer only accepted admin on app1) (IApp[] memory devApps,) = appController.getAppsByDeveloper(developer, 0, 10); + assertEq(devApps.length, 2, "developer is ADMIN on every app they created"); + + // Transfer app2 to another owner. transferScopeOwnership removes + // developer from ADMIN on app2 atomically. + address newOwner = makeAddr("newOwner"); + _setMaxActiveAppsPerUser(newOwner, 10); + vm.prank(developer); + appController.transferOwnership(app2, newOwner); + + // Now developer is ADMIN only on app1, but remains "creator" on + // app1 only (creator got overwritten on app2 transfer). So + // getAppsByDeveloper reflects the change. + (devApps,) = appController.getAppsByDeveloper(developer, 0, 10); assertEq(devApps.length, 1); assertEq(address(devApps[0]), address(app1)); - // Verify the creator field is set correctly for both apps - assertEq(appController.getAppCreator(app1), developer); - assertEq(appController.getAppCreator(app2), developer); - - // Verify developer is only admin of app1 - assertTrue(permissionController.isAdmin(address(app1), developer)); - assertFalse(permissionController.isAdmin(address(app2), developer)); + // Sanity: app2's new owner is now ADMIN on app2. + (IApp[] memory newOwnerApps,) = appController.getAppsByDeveloper(newOwner, 0, 10); + assertEq(newOwnerApps.length, 1); + assertEq(address(newOwnerApps[0]), address(app2)); } // ========== Helper Functions ========== From c70f43e2b31acb9646614c88941c32d2eb0c0f01 Mon Sep 17 00:00:00 2001 From: Taras Shchybovyk Date: Thu, 7 May 2026 13:26:12 -0700 Subject: [PATCH 16/17] feat: two-step transferOwnership + receiver quota check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address two review comments on AppController.transferOwnership: 1. Consent — transferring a DEFAULT-billed active app was unilateral. The receiver became the billing account (paying for compute, consuming their maxActiveApps capacity) without signing anything. Split into propose + accept: - transferOwnership(app, newOwner) records pendingOwner only. No AppAuthority rotation, no counter migration. Emits OwnershipTransferProposed. If an earlier proposal exists, emits OwnershipTransferCancelled for it first. - acceptOwnership(app) — pending-owner-only. Rotates scope ownership + ADMIN in AppAuthority, mirrors creator, migrates the active-app counter. Emits AppOwnershipTransferred. - cancelOwnershipTransfer(app) — owner-only rescind. - getPendingOwner(app) — view. 2. Quota — transferOwnership bumped newOwner.activeAppCount without checking their maxActiveApps, so an owner could push an app to a zero-quota or at-cap account. acceptOwnership now enforces the same `activeAppCount < maxActiveApps` check createApp uses, before the counter migrates. DEFAULT-billed active apps only; ISOLATED bills the app address and doesn't consume user quota. Storage: AppConfigStorage gains `address pendingOwner` (20 bytes) in slot 2, alongside the master-introduced pendingReleaseBlockNumber. No shift to prior fields; __gap unchanged. canCall selector list extended: transferOwnership and cancelOwnershipTransfer stay owner-gated at schedule time. acceptOwnership is NOT in the list — it's gated on the per-app pending-owner field, which is dynamic state not worth duplicating in schedule-time logic. Also closes audit items this refactor touches: - G-1 (Low): transferOwnership bypassed per-user quota. Fixed by acceptOwnership's MaxActiveAppsExceeded check. - A-6 (Medium): mistyped owner address was irreversible. Fixed by cancelOwnershipTransfer + the inherent two-step nature — the wrong receiver simply never accepts. Tests: 185 → 192. Added propose/accept flow, non-pending rejection, no-proposal rejection, receiver-quota enforcement, cancellation, re-propose supersession, owner-retains-authority-during-pending, plus the existing transfer tests rewritten for the two-step semantic. ABI is a breaking semantic change on transferOwnership (no longer atomic). CLI and SDK must implement the accept step. New entry points: acceptOwnership, cancelOwnershipTransfer, getPendingOwner. New events: OwnershipTransferProposed, OwnershipTransferCancelled. New error: NotPendingOwner. --- src/AppController.sol | 76 ++++++++--- src/interfaces/IAppController.sol | 67 ++++++++-- test/AppController.t.sol | 215 ++++++++++++++++++++++++++---- 3 files changed, 302 insertions(+), 56 deletions(-) diff --git a/src/AppController.sol b/src/AppController.sol index d369412..2bc22d7 100644 --- a/src/AppController.sol +++ b/src/AppController.sol @@ -161,29 +161,57 @@ contract AppController is require(newOwner != address(0), InvalidPermissions()); AppConfigStorage storage config = _appConfigs[app]; + address previousPending = config.pendingOwner; + config.pendingOwner = newOwner; + + // Surface the supersession explicitly so off-chain consumers can + // invalidate any UI state tied to the previous proposal. + if (previousPending != address(0) && previousPending != newOwner) { + emit OwnershipTransferCancelled(app, msg.sender, previousPending); + } + emit OwnershipTransferProposed(app, msg.sender, newOwner); + } + + /// @inheritdoc IAppController + function acceptOwnership(IApp app) external appExists(app) { + AppConfigStorage storage config = _appConfigs[app]; + if (msg.sender != config.pendingOwner) revert NotPendingOwner(); + address previousOwner = config.creator; - // Rotate ownership + ADMIN atomically in AppAuthority. AppAuthority - // enforces the add-before-remove ordering so the ADMIN set never - // empties during the swap. - appAuthority.transferScopeOwnership(app, newOwner); - - // Mirror the owner into our local cache for billing / App.initialize - // / event stability. AppAuthority is the source of truth; this is - // the cache. - config.creator = newOwner; - - // ISOLATED billing apps bill the app address, not the creator, so - // ownership transfer has no effect on billing accounting. DEFAULT - // billing apps bill the creator, so we need to move the active-app - // counter from the previous creator to the new one; otherwise a future - // terminate/suspend would underflow the new owner's counter. + // For DEFAULT-billed active apps, the counter is about to migrate + // to the receiver — they must have capacity. Matches `createApp` + // semantics exactly: strictly-less-than check against both the + // per-user cap and the global cap (global is already at capacity + // in this scenario means we're transferring from one user to + // another, net zero, so it can never fail — but we still enforce + // the user-level cap). + if (config.billingType == BillingType.DEFAULT && _isActive(config.status)) { + UserConfig storage receiverCfg = _userConfigs[msg.sender]; + require(receiverCfg.activeAppCount < receiverCfg.maxActiveApps, MaxActiveAppsExceeded()); + } + + // Rotate ownership + ADMIN atomically in AppAuthority. + appAuthority.transferScopeOwnership(app, msg.sender); + + config.creator = msg.sender; + delete config.pendingOwner; + if (config.billingType == BillingType.DEFAULT && _isActive(config.status)) { _userConfigs[previousOwner].activeAppCount--; - _userConfigs[newOwner].activeAppCount++; + _userConfigs[msg.sender].activeAppCount++; } - emit AppOwnershipTransferred(app, previousOwner, newOwner); + emit AppOwnershipTransferred(app, previousOwner, msg.sender); + } + + /// @inheritdoc IAppController + function cancelOwnershipTransfer(IApp app) external onlyCreator(app) appExists(app) { + AppConfigStorage storage config = _appConfigs[app]; + address pending = config.pendingOwner; + if (pending == address(0)) return; + delete config.pendingOwner; + emit OwnershipTransferCancelled(app, msg.sender, pending); } /// @inheritdoc IAppController @@ -597,6 +625,11 @@ contract AppController is return _appConfigs[app].creator; } + /// @inheritdoc IAppController + function getPendingOwner(IApp app) external view returns (address) { + return _appConfigs[app].pendingOwner; + } + /** * @notice Resolves the billing account for an app * @param app The app instance to resolve billing for @@ -631,10 +664,15 @@ contract AppController is if (data.length < 36) return true; bytes4 selector = bytes4(data[:4]); - // Owner-gated critical ops (upgrade / terminate / transferOwnership). + // Owner-gated critical ops. transferOwnership is a proposal under + // the two-step model — still owner-gated at the proposer side. + // cancelOwnershipTransfer is also owner-gated. acceptOwnership is + // intentionally NOT listed: it's gated on the pending-owner field, + // which is per-app dynamic state; we let the runtime check handle + // it rather than duplicate the lookup here. if ( selector == this.upgradeApp.selector || selector == this.terminateApp.selector - || selector == this.transferOwnership.selector + || selector == this.transferOwnership.selector || selector == this.cancelOwnershipTransfer.selector ) { IApp app = abi.decode(data[4:36], (IApp)); if (!appAuthority.isScopeOwner(app, caller)) return false; diff --git a/src/interfaces/IAppController.sol b/src/interfaces/IAppController.sol index b79caa4..4147b16 100644 --- a/src/interfaces/IAppController.sol +++ b/src/interfaces/IAppController.sol @@ -41,6 +41,10 @@ interface IAppController { /// @notice Thrown when trying to confirm an upgrade with no pending release error NoPendingUpgrade(); + /// @notice Thrown when `acceptOwnership` is called by an address that is + /// not the current pending owner of the app. + error NotPendingOwner(); + /// @notice Emitted when a new app is successfully created event AppCreated(address indexed creator, IApp indexed app, uint32 operatorSetId); @@ -77,6 +81,15 @@ interface IAppController { /// @notice Emitted when app ownership is transferred to a new address event AppOwnershipTransferred(IApp indexed app, address indexed previousOwner, address indexed newOwner); + /// @notice Emitted when the current owner proposes a new owner. The + /// proposed owner must call `acceptOwnership` to complete. + event OwnershipTransferProposed(IApp indexed app, address indexed currentOwner, address indexed proposedOwner); + + /// @notice Emitted when a pending ownership proposal is cancelled — + /// either explicitly by the current owner, or implicitly by + /// being superseded by a new proposal. + event OwnershipTransferCancelled(IApp indexed app, address indexed currentOwner, address indexed cancelledOwner); + /// @notice Enum for app status enum AppStatus { NONE, // App has not been created yet @@ -146,6 +159,10 @@ interface IAppController { uint32 pendingReleaseBlockNumber; AppStatus status; BillingType billingType; + // Pending owner for two-step transferOwnership. Zero means no + // pending proposal. Fits in the second storage slot alongside the + // first-slot packed fields above; does not shift prior layout. + address pendingOwner; } /// @notice User configuration and state @@ -212,19 +229,53 @@ interface IAppController { function upgradeApp(IApp app, Release calldata release) external returns (uint256); /** - * @notice Transfers app ownership to a new address. Critical op. - * @param app The app to transfer ownership of - * @param newOwner The new owner address + * @notice Propose transferring app ownership to a new address. Step 1 + * of a two-step transfer. The proposed owner must call + * `acceptOwnership(app)` to complete the transfer — the current + * owner cannot push ownership (and the associated billing / quota + * consumption on DEFAULT-billed apps) onto an unwilling receiver. + * @param app The app to propose ownership transfer for + * @param newOwner The proposed new owner address * @dev Caller must be the app's current owner (`creator`). - * @dev The new owner is atomically granted ADMIN on the team in - * AppAuthority and the previous owner is atomically removed from - * ADMIN. This is the only path that rotates ADMIN membership for - * the owner. + * @dev No state changes to AppAuthority or active-app counters at this + * step — only the pending-owner field updates. If an older + * proposal existed, it is silently superseded. + * @dev Use `cancelOwnershipTransfer(app)` to rescind a pending proposal. + */ + function transferOwnership(IApp app, address newOwner) external; + + /** + * @notice Accept a pending ownership transfer. Step 2 of the two-step + * flow. Atomically rotates scope ownership and ADMIN in + * AppAuthority, mirrors the owner into `creator`, migrates the + * active-app counter (for DEFAULT-billed apps), and verifies + * the receiver has capacity. + * @param app The app to accept ownership of + * @dev Caller must equal the current pending owner of `app`. + * @dev For DEFAULT-billed active apps, the caller's `activeAppCount` + * must be strictly less than their `maxActiveApps` — same rule as + * `createApp`. This prevents an unwilling receiver from exceeding + * their quota or being force-billed above their cap. * @dev The new owner's contract type (EOA / Safe / Timelock / other) * determines the governance mechanism for future critical ops. * AppController does not classify or enforce a choice here. */ - function transferOwnership(IApp app, address newOwner) external; + function acceptOwnership(IApp app) external; + + /** + * @notice Rescind a pending ownership proposal. No-op if no proposal + * exists for `app`. + * @param app The app whose pending ownership transfer should be cancelled + * @dev Caller must be the app's current owner (`creator`). + */ + function cancelOwnershipTransfer(IApp app) external; + + /** + * @notice Returns the address currently pending acceptance as the new + * owner of `app`, or `address(0)` if no proposal exists. + * @param app The app to query + */ + function getPendingOwner(IApp app) external view returns (address); /** * @notice Confirms a pending upgrade, promoting the pending release to the confirmed (latest) release diff --git a/test/AppController.t.sol b/test/AppController.t.sol index fa81b2e..acf9fe4 100644 --- a/test/AppController.t.sol +++ b/test/AppController.t.sol @@ -659,12 +659,14 @@ contract AppControllerTest is ComputeDeployer { (IApp[] memory devApps,) = appController.getAppsByDeveloper(developer, 0, 10); assertEq(devApps.length, 2, "developer is ADMIN on every app they created"); - // Transfer app2 to another owner. transferScopeOwnership removes - // developer from ADMIN on app2 atomically. + // Transfer app2 to another owner via the two-step flow. The ADMIN + // rotation happens at accept time. address newOwner = makeAddr("newOwner"); _setMaxActiveAppsPerUser(newOwner, 10); vm.prank(developer); appController.transferOwnership(app2, newOwner); + vm.prank(newOwner); + appController.acceptOwnership(app2); // Now developer is ADMIN only on app1, but remains "creator" on // app1 only (creator got overwritten on app2 transfer). So @@ -1533,26 +1535,93 @@ contract AppControllerTest is ComputeDeployer { assertEq(uint256(appController.getAppStatus(app)), uint256(IAppController.AppStatus.TERMINATED)); } - // ========== transferOwnership ========== + // ========== transferOwnership (two-step) ========== event AppOwnershipTransferred(IApp indexed app, address indexed previousOwner, address indexed newOwner); + event OwnershipTransferProposed(IApp indexed app, address indexed currentOwner, address indexed proposedOwner); + event OwnershipTransferCancelled(IApp indexed app, address indexed currentOwner, address indexed cancelledOwner); - function test_transferOwnership_emitsEvent() public { + function test_transferOwnership_proposeEmitsEvent() public { vm.prank(developer); IApp app = appController.createApp(SALT, _assembleRelease()); + + address newOwner = makeAddr("newOwner"); + + vm.expectEmit(true, true, true, true); + emit OwnershipTransferProposed(app, developer, newOwner); + vm.prank(developer); - permissionController.acceptAdmin(address(app)); + appController.transferOwnership(app, newOwner); + + // Propose alone does NOT change creator; it only records the pending. + assertEq(appController.getAppCreator(app), developer); + assertEq(appController.getPendingOwner(app), newOwner); + } + + function test_transferOwnership_fullTwoStepFlow() public { + vm.prank(developer); + IApp app = appController.createApp(SALT, _assembleRelease()); address newOwner = makeAddr("newOwner"); _setMaxActiveAppsPerUser(newOwner, 10); + vm.prank(developer); + appController.transferOwnership(app, newOwner); + vm.expectEmit(true, true, true, true); emit AppOwnershipTransferred(app, developer, newOwner); - vm.prank(developer); - appController.transferOwnership(app, newOwner); + vm.prank(newOwner); + appController.acceptOwnership(app); assertEq(appController.getAppCreator(app), newOwner); + assertEq(appController.getPendingOwner(app), address(0)); + } + + function test_acceptOwnership_rejectsNonPendingCaller() public { + vm.prank(developer); + IApp app = appController.createApp(SALT, _assembleRelease()); + + address proposed = makeAddr("proposed"); + vm.prank(developer); + appController.transferOwnership(app, proposed); + + address imposter = makeAddr("imposter"); + vm.prank(imposter); + vm.expectRevert(IAppController.NotPendingOwner.selector); + appController.acceptOwnership(app); + } + + function test_acceptOwnership_rejectsWhenNoProposalPending() public { + vm.prank(developer); + IApp app = appController.createApp(SALT, _assembleRelease()); + + // No proposal — any accept attempt reverts because pendingOwner == 0. + vm.prank(developer); + vm.expectRevert(IAppController.NotPendingOwner.selector); + appController.acceptOwnership(app); + } + + function test_acceptOwnership_enforcesReceiverQuota() public { + // Receiver with no quota must not be able to accept a DEFAULT-billed + // active app — the counter bump would push them past maxActiveApps + // (which defaults to 0 for fresh accounts). + vm.prank(developer); + IApp app = appController.createApp(SALT, _assembleRelease()); + + address broke = makeAddr("broke"); // maxActiveApps == 0 (not provisioned) + vm.prank(developer); + appController.transferOwnership(app, broke); + + vm.prank(broke); + vm.expectRevert(IAppController.MaxActiveAppsExceeded.selector); + appController.acceptOwnership(app); + + // Once provisioned, accept succeeds. + _setMaxActiveAppsPerUser(broke, 1); + vm.prank(broke); + appController.acceptOwnership(app); + assertEq(appController.getAppCreator(app), broke); } function test_transferOwnership_blocksCoAdminNonOwner() public { @@ -1561,7 +1630,7 @@ contract AppControllerTest is ComputeDeployer { // Owner grants ADMIN to coAdmin. ADMIN is the strongest role a // co-admin could plausibly have; the owner-gate must still block - // them from moving the app. + // them from proposing a transfer. address coAdmin = makeAddr("coAdmin"); vm.prank(developer); appAuthority.grantRole(app, IAppAuthority.Role.ADMIN, coAdmin); @@ -1573,29 +1642,104 @@ contract AppControllerTest is ComputeDeployer { vm.expectRevert(IAppController.NotCreator.selector); appController.transferOwnership(app, attacker); - // Owner can still transfer. + // Owner can propose + attacker-as-receiver accepts. vm.prank(developer); appController.transferOwnership(app, attacker); + vm.prank(attacker); + appController.acceptOwnership(app); assertEq(appController.getAppCreator(app), attacker); } function test_transferOwnership_revertsZeroAddress() public { vm.prank(developer); IApp app = appController.createApp(SALT, _assembleRelease()); - // developer is auto-granted ADMIN at createApp time; no UAM step needed. vm.prank(developer); vm.expectRevert(PermissionControllerMixin.InvalidPermissions.selector); appController.transferOwnership(app, address(0)); } - function test_transferOwnership_defaultBilling_movesActiveCounter() public { - // Default billing: creator is the billing account. Counter must move - // so future terminate/suspend on the new owner doesn't underflow. + function test_transferOwnership_cancelByOwner() public { vm.prank(developer); IApp app = appController.createApp(SALT, _assembleRelease()); + + address proposed = makeAddr("proposed"); vm.prank(developer); - permissionController.acceptAdmin(address(app)); + appController.transferOwnership(app, proposed); + assertEq(appController.getPendingOwner(app), proposed); + + vm.expectEmit(true, true, true, true); + emit OwnershipTransferCancelled(app, developer, proposed); + + vm.prank(developer); + appController.cancelOwnershipTransfer(app); + assertEq(appController.getPendingOwner(app), address(0)); + + // Previously-proposed address can no longer accept. + vm.prank(proposed); + vm.expectRevert(IAppController.NotPendingOwner.selector); + appController.acceptOwnership(app); + } + + function test_transferOwnership_resupercedesOldProposal() public { + vm.prank(developer); + IApp app = appController.createApp(SALT, _assembleRelease()); + + address first = makeAddr("first"); + vm.prank(developer); + appController.transferOwnership(app, first); + + address second = makeAddr("second"); + _setMaxActiveAppsPerUser(second, 10); + + // Re-proposing should emit both Cancelled(first) and Proposed(second). + vm.expectEmit(true, true, true, true); + emit OwnershipTransferCancelled(app, developer, first); + vm.expectEmit(true, true, true, true); + emit OwnershipTransferProposed(app, developer, second); + + vm.prank(developer); + appController.transferOwnership(app, second); + + // First can no longer accept; second can. + vm.prank(first); + vm.expectRevert(IAppController.NotPendingOwner.selector); + appController.acceptOwnership(app); + + vm.prank(second); + appController.acceptOwnership(app); + assertEq(appController.getAppCreator(app), second); + } + + function test_transferOwnership_ownerRetainsAuthorityDuringPending() public { + // Pending proposal does NOT freeze the current owner's authority. + vm.prank(developer); + IApp app = appController.createApp(SALT, _assembleRelease()); + + address proposed = makeAddr("proposed"); + vm.prank(developer); + appController.transferOwnership(app, proposed); + + // Owner can still upgrade. Receiver has not accepted yet. + vm.prank(developer); + appController.upgradeApp(app, _assembleRelease()); + + // Owner can still terminate (which invalidates the pending). + vm.prank(developer); + appController.terminateApp(app); + + // Receiver cannot accept a terminated app (appExists check fails + // at the top of acceptOwnership? Actually appExists allows TERMINATED; + // the accept will proceed but the downstream billing migration is + // a no-op since _isActive is false. The transfer of ownership on a + // terminated app is a degenerate but non-harmful case.) + // We only assert that the pending pointer wasn't cleared by terminate. + assertEq(appController.getPendingOwner(app), proposed); + } + + function test_transferOwnership_defaultBilling_movesActiveCounter() public { + vm.prank(developer); + IApp app = appController.createApp(SALT, _assembleRelease()); address newOwner = makeAddr("newOwner"); _setMaxActiveAppsPerUser(newOwner, 10); @@ -1605,27 +1749,35 @@ contract AppControllerTest is ComputeDeployer { vm.prank(developer); appController.transferOwnership(app, newOwner); + // Propose alone must NOT touch counters — that's the whole point + // of the two-step flow. + assertEq(appController.getActiveAppCount(developer), beforeDev); + assertEq(appController.getActiveAppCount(newOwner), beforeNew); + + vm.prank(newOwner); + appController.acceptOwnership(app); + // Accept migrates the counter. assertEq(appController.getActiveAppCount(developer), beforeDev - 1); assertEq(appController.getActiveAppCount(newOwner), beforeNew + 1); } function test_transferOwnership_isolatedBilling_doesNotMoveCounter() public { - // ISOLATED billing apps bill the app address, so the creator's - // active-app counter was never incremented and must not move on transfer. _setMaxActiveAppsPerUser(address(appController.calculateAppId(developer, SALT)), 10); vm.prank(developer); IApp app = appController.createAppWithIsolatedBilling(SALT, _assembleRelease()); - vm.prank(developer); - permissionController.acceptAdmin(address(app)); uint32 beforeApp = appController.getActiveAppCount(address(app)); uint32 beforeDev = appController.getActiveAppCount(developer); address newOwner = makeAddr("newOwner"); + // Isolated billing doesn't bill the user, so receiver quota is + // irrelevant — no provisioning needed. vm.prank(developer); appController.transferOwnership(app, newOwner); + vm.prank(newOwner); + appController.acceptOwnership(app); assertEq(appController.getActiveAppCount(address(app)), beforeApp); assertEq(appController.getActiveAppCount(developer), beforeDev); @@ -1858,19 +2010,20 @@ contract AppControllerTest is ComputeDeployer { vm.prank(developer); appController.transferOwnership(app, newOwner); + vm.prank(newOwner); + appController.acceptOwnership(app); assertTrue( - appAuthority.hasRole(app, IAppAuthority.Role.ADMIN, newOwner), - "new owner must be granted ADMIN automatically" + appAuthority.hasRole(app, IAppAuthority.Role.ADMIN, newOwner), "new owner must be granted ADMIN on accept" ); } function test_transferOwnership_removesPreviousOwnerFromAdmin() public { // Fix for audit A-3 / V-10: the previous owner must be removed from - // the ADMIN set on transfer. Otherwise an old owner (EOA, Safe, or - // old Timelock post-handoff) retains operational powers and, if - // they remain ADMIN while the new owner holds a weaker key, they - // can re-grab the app. + // the ADMIN set on ownership handoff. Otherwise an old owner (EOA, + // Safe, or old Timelock post-handoff) retains operational powers + // and, if they remain ADMIN while the new owner holds a weaker key, + // they can re-grab the app. vm.prank(developer); IApp app = appController.createApp(SALT, _assembleRelease()); @@ -1879,18 +2032,20 @@ contract AppControllerTest is ComputeDeployer { vm.prank(developer); appController.transferOwnership(app, newOwner); + vm.prank(newOwner); + appController.acceptOwnership(app); assertFalse( appAuthority.hasRole(app, IAppAuthority.Role.ADMIN, developer), - "previous owner must be removed from ADMIN on transfer" + "previous owner must be removed from ADMIN on accept" ); assertTrue(appAuthority.hasRole(app, IAppAuthority.Role.ADMIN, newOwner), "new owner must still be ADMIN"); } function test_transferOwnership_previousOwnerLosesCriticalPower() public { - // After transfer, the previous owner cannot perform critical ops - // even though they used to. This is the direct user-visible - // consequence of the owner-gate + ADMIN cleanup together. + // After the transfer completes (accept), the previous owner cannot + // perform critical ops even though they used to. This is the direct + // user-visible consequence of the owner-gate + ADMIN cleanup. vm.prank(developer); IApp app = appController.createApp(SALT, _assembleRelease()); @@ -1899,8 +2054,10 @@ contract AppControllerTest is ComputeDeployer { vm.prank(developer); appController.transferOwnership(app, newOwner); + vm.prank(newOwner); + appController.acceptOwnership(app); - // Previous owner cannot upgrade, terminate, or transfer. + // Previous owner cannot upgrade, terminate, or propose a new transfer. vm.prank(developer); vm.expectRevert(IAppController.NotCreator.selector); appController.upgradeApp(app, _assembleRelease()); From e1a284b9ba97cc62360a7ed5aeff75174a85aad2 Mon Sep 17 00:00:00 2001 From: Taras Shchybovyk Date: Thu, 7 May 2026 13:44:15 -0700 Subject: [PATCH 17/17] chore: regenerate Go bindings for AppController MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picks up the ABI additions from c70f43e (two-step transferOwnership) and 8f6e0e4 (migrateAppsToAppAuthority): - acceptOwnership(IApp) - cancelOwnershipTransfer(IApp) - getPendingOwner(IApp) -> address - migrateAppsToAppAuthority(IApp[]) - OwnershipTransferProposed(IApp, address, address) event - OwnershipTransferCancelled(IApp, address, address) event - AppConfigStorage.pendingOwner field Go consumers (sidecar, ecloud-billing-api, coordinator tooling) must update to the new surface. transferOwnership semantics change: no longer atomic — receiver must call acceptOwnership in a second tx. --- pkg/bindings/v1/AppController/binding.go | 696 ++++++++++++++++++++++- pkg/bindings/v2/AppController/binding.go | 443 ++++++++++++++- 2 files changed, 1134 insertions(+), 5 deletions(-) diff --git a/pkg/bindings/v1/AppController/binding.go b/pkg/bindings/v1/AppController/binding.go index 33e7007..6ee07a4 100644 --- a/pkg/bindings/v1/AppController/binding.go +++ b/pkg/bindings/v1/AppController/binding.go @@ -75,7 +75,7 @@ type IReleaseManagerTypesRelease struct { // AppControllerMetaData contains all meta data concerning the AppController contract. var AppControllerMetaData = &bind.MetaData{ - ABI: "[{\"type\":\"constructor\",\"inputs\":[{\"name\":\"_version\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"_permissionController\",\"type\":\"address\",\"internalType\":\"contractIPermissionController\"},{\"name\":\"_releaseManager\",\"type\":\"address\",\"internalType\":\"contractIReleaseManager\"},{\"name\":\"_computeAVSRegistrar\",\"type\":\"address\",\"internalType\":\"contractIComputeAVSRegistrar\"},{\"name\":\"_computeOperator\",\"type\":\"address\",\"internalType\":\"contractIComputeOperator\"},{\"name\":\"_appBeacon\",\"type\":\"address\",\"internalType\":\"contractIBeacon\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"API_PERMISSION_TYPEHASH\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"appBeacon\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIBeacon\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"calculateApiPermissionDigestHash\",\"inputs\":[{\"name\":\"permission\",\"type\":\"bytes4\",\"internalType\":\"bytes4\"},{\"name\":\"expiry\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"calculateAppId\",\"inputs\":[{\"name\":\"deployer\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"salt\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIApp\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"computeAVSRegistrar\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIComputeAVSRegistrar\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"computeOperator\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIComputeOperator\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"confirmUpgrade\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"internalType\":\"contractIApp\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"createApp\",\"inputs\":[{\"name\":\"salt\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"release\",\"type\":\"tuple\",\"internalType\":\"structIAppController.Release\",\"components\":[{\"name\":\"rmsRelease\",\"type\":\"tuple\",\"internalType\":\"structIReleaseManagerTypes.Release\",\"components\":[{\"name\":\"artifacts\",\"type\":\"tuple[]\",\"internalType\":\"structIReleaseManagerTypes.Artifact[]\",\"components\":[{\"name\":\"digest\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"registry\",\"type\":\"string\",\"internalType\":\"string\"}]},{\"name\":\"upgradeByTime\",\"type\":\"uint32\",\"internalType\":\"uint32\"}]},{\"name\":\"publicEnv\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"encryptedEnv\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"containerPolicy\",\"type\":\"tuple\",\"internalType\":\"structIAppController.ContainerPolicy\",\"components\":[{\"name\":\"args\",\"type\":\"string[]\",\"internalType\":\"string[]\"},{\"name\":\"cmdOverride\",\"type\":\"string[]\",\"internalType\":\"string[]\"},{\"name\":\"env\",\"type\":\"tuple[]\",\"internalType\":\"structIAppController.EnvVar[]\",\"components\":[{\"name\":\"key\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"value\",\"type\":\"string\",\"internalType\":\"string\"}]},{\"name\":\"envOverride\",\"type\":\"tuple[]\",\"internalType\":\"structIAppController.EnvVar[]\",\"components\":[{\"name\":\"key\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"value\",\"type\":\"string\",\"internalType\":\"string\"}]},{\"name\":\"restartPolicy\",\"type\":\"string\",\"internalType\":\"string\"}]}]}],\"outputs\":[{\"name\":\"app\",\"type\":\"address\",\"internalType\":\"contractIApp\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"createAppWithIsolatedBilling\",\"inputs\":[{\"name\":\"salt\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"release\",\"type\":\"tuple\",\"internalType\":\"structIAppController.Release\",\"components\":[{\"name\":\"rmsRelease\",\"type\":\"tuple\",\"internalType\":\"structIReleaseManagerTypes.Release\",\"components\":[{\"name\":\"artifacts\",\"type\":\"tuple[]\",\"internalType\":\"structIReleaseManagerTypes.Artifact[]\",\"components\":[{\"name\":\"digest\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"registry\",\"type\":\"string\",\"internalType\":\"string\"}]},{\"name\":\"upgradeByTime\",\"type\":\"uint32\",\"internalType\":\"uint32\"}]},{\"name\":\"publicEnv\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"encryptedEnv\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"containerPolicy\",\"type\":\"tuple\",\"internalType\":\"structIAppController.ContainerPolicy\",\"components\":[{\"name\":\"args\",\"type\":\"string[]\",\"internalType\":\"string[]\"},{\"name\":\"cmdOverride\",\"type\":\"string[]\",\"internalType\":\"string[]\"},{\"name\":\"env\",\"type\":\"tuple[]\",\"internalType\":\"structIAppController.EnvVar[]\",\"components\":[{\"name\":\"key\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"value\",\"type\":\"string\",\"internalType\":\"string\"}]},{\"name\":\"envOverride\",\"type\":\"tuple[]\",\"internalType\":\"structIAppController.EnvVar[]\",\"components\":[{\"name\":\"key\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"value\",\"type\":\"string\",\"internalType\":\"string\"}]},{\"name\":\"restartPolicy\",\"type\":\"string\",\"internalType\":\"string\"}]}]}],\"outputs\":[{\"name\":\"app\",\"type\":\"address\",\"internalType\":\"contractIApp\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"domainSeparator\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getActiveAppCount\",\"inputs\":[{\"name\":\"user\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint32\",\"internalType\":\"uint32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getAppCreator\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"internalType\":\"contractIApp\"}],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getAppLatestReleaseBlockNumber\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"internalType\":\"contractIApp\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint32\",\"internalType\":\"uint32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getAppOperatorSetId\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"internalType\":\"contractIApp\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint32\",\"internalType\":\"uint32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getAppPendingReleaseBlockNumber\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"internalType\":\"contractIApp\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint32\",\"internalType\":\"uint32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getAppStatus\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"internalType\":\"contractIApp\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint8\",\"internalType\":\"enumIAppController.AppStatus\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getApps\",\"inputs\":[{\"name\":\"offset\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"limit\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"apps\",\"type\":\"address[]\",\"internalType\":\"contractIApp[]\"},{\"name\":\"appConfigsMem\",\"type\":\"tuple[]\",\"internalType\":\"structIAppController.AppConfig[]\",\"components\":[{\"name\":\"creator\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"operatorSetId\",\"type\":\"uint32\",\"internalType\":\"uint32\"},{\"name\":\"latestReleaseBlockNumber\",\"type\":\"uint32\",\"internalType\":\"uint32\"},{\"name\":\"pendingReleaseBlockNumber\",\"type\":\"uint32\",\"internalType\":\"uint32\"},{\"name\":\"status\",\"type\":\"uint8\",\"internalType\":\"enumIAppController.AppStatus\"}]}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getAppsByBillingAccount\",\"inputs\":[{\"name\":\"account\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"offset\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"limit\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"apps\",\"type\":\"address[]\",\"internalType\":\"contractIApp[]\"},{\"name\":\"appConfigsMem\",\"type\":\"tuple[]\",\"internalType\":\"structIAppController.AppConfig[]\",\"components\":[{\"name\":\"creator\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"operatorSetId\",\"type\":\"uint32\",\"internalType\":\"uint32\"},{\"name\":\"latestReleaseBlockNumber\",\"type\":\"uint32\",\"internalType\":\"uint32\"},{\"name\":\"pendingReleaseBlockNumber\",\"type\":\"uint32\",\"internalType\":\"uint32\"},{\"name\":\"status\",\"type\":\"uint8\",\"internalType\":\"enumIAppController.AppStatus\"}]}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getAppsByCreator\",\"inputs\":[{\"name\":\"creator\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"offset\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"limit\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"apps\",\"type\":\"address[]\",\"internalType\":\"contractIApp[]\"},{\"name\":\"appConfigsMem\",\"type\":\"tuple[]\",\"internalType\":\"structIAppController.AppConfig[]\",\"components\":[{\"name\":\"creator\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"operatorSetId\",\"type\":\"uint32\",\"internalType\":\"uint32\"},{\"name\":\"latestReleaseBlockNumber\",\"type\":\"uint32\",\"internalType\":\"uint32\"},{\"name\":\"pendingReleaseBlockNumber\",\"type\":\"uint32\",\"internalType\":\"uint32\"},{\"name\":\"status\",\"type\":\"uint8\",\"internalType\":\"enumIAppController.AppStatus\"}]}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getAppsByDeveloper\",\"inputs\":[{\"name\":\"developer\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"offset\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"limit\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"apps\",\"type\":\"address[]\",\"internalType\":\"contractIApp[]\"},{\"name\":\"appConfigsMem\",\"type\":\"tuple[]\",\"internalType\":\"structIAppController.AppConfig[]\",\"components\":[{\"name\":\"creator\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"operatorSetId\",\"type\":\"uint32\",\"internalType\":\"uint32\"},{\"name\":\"latestReleaseBlockNumber\",\"type\":\"uint32\",\"internalType\":\"uint32\"},{\"name\":\"pendingReleaseBlockNumber\",\"type\":\"uint32\",\"internalType\":\"uint32\"},{\"name\":\"status\",\"type\":\"uint8\",\"internalType\":\"enumIAppController.AppStatus\"}]}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getBillingAccount\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"internalType\":\"contractIApp\"}],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getBillingType\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"internalType\":\"contractIApp\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint8\",\"internalType\":\"enumIAppController.BillingType\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getMaxActiveAppsPerUser\",\"inputs\":[{\"name\":\"user\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint32\",\"internalType\":\"uint32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"globalActiveAppCount\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint32\",\"internalType\":\"uint32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"initialize\",\"inputs\":[{\"name\":\"admin\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"maxGlobalActiveApps\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint32\",\"internalType\":\"uint32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"permissionController\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIPermissionController\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"releaseManager\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIReleaseManager\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"setMaxActiveAppsPerUser\",\"inputs\":[{\"name\":\"user\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"limit\",\"type\":\"uint32\",\"internalType\":\"uint32\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"setMaxGlobalActiveApps\",\"inputs\":[{\"name\":\"limit\",\"type\":\"uint32\",\"internalType\":\"uint32\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"startApp\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"internalType\":\"contractIApp\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"stopApp\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"internalType\":\"contractIApp\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"suspend\",\"inputs\":[{\"name\":\"account\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"apps\",\"type\":\"address[]\",\"internalType\":\"contractIApp[]\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"terminateApp\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"internalType\":\"contractIApp\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"terminateAppByAdmin\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"internalType\":\"contractIApp\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"updateAppMetadataURI\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"internalType\":\"contractIApp\"},{\"name\":\"metadataURI\",\"type\":\"string\",\"internalType\":\"string\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"upgradeApp\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"internalType\":\"contractIApp\"},{\"name\":\"release\",\"type\":\"tuple\",\"internalType\":\"structIAppController.Release\",\"components\":[{\"name\":\"rmsRelease\",\"type\":\"tuple\",\"internalType\":\"structIReleaseManagerTypes.Release\",\"components\":[{\"name\":\"artifacts\",\"type\":\"tuple[]\",\"internalType\":\"structIReleaseManagerTypes.Artifact[]\",\"components\":[{\"name\":\"digest\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"registry\",\"type\":\"string\",\"internalType\":\"string\"}]},{\"name\":\"upgradeByTime\",\"type\":\"uint32\",\"internalType\":\"uint32\"}]},{\"name\":\"publicEnv\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"encryptedEnv\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"containerPolicy\",\"type\":\"tuple\",\"internalType\":\"structIAppController.ContainerPolicy\",\"components\":[{\"name\":\"args\",\"type\":\"string[]\",\"internalType\":\"string[]\"},{\"name\":\"cmdOverride\",\"type\":\"string[]\",\"internalType\":\"string[]\"},{\"name\":\"env\",\"type\":\"tuple[]\",\"internalType\":\"structIAppController.EnvVar[]\",\"components\":[{\"name\":\"key\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"value\",\"type\":\"string\",\"internalType\":\"string\"}]},{\"name\":\"envOverride\",\"type\":\"tuple[]\",\"internalType\":\"structIAppController.EnvVar[]\",\"components\":[{\"name\":\"key\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"value\",\"type\":\"string\",\"internalType\":\"string\"}]},{\"name\":\"restartPolicy\",\"type\":\"string\",\"internalType\":\"string\"}]}]}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"version\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"string\",\"internalType\":\"string\"}],\"stateMutability\":\"view\"},{\"type\":\"event\",\"name\":\"AppCreated\",\"inputs\":[{\"name\":\"creator\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"app\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"contractIApp\"},{\"name\":\"operatorSetId\",\"type\":\"uint32\",\"indexed\":false,\"internalType\":\"uint32\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"AppMetadataURIUpdated\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"contractIApp\"},{\"name\":\"metadataURI\",\"type\":\"string\",\"indexed\":false,\"internalType\":\"string\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"AppStarted\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"contractIApp\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"AppStopped\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"contractIApp\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"AppSuspended\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"contractIApp\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"AppTerminated\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"contractIApp\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"AppTerminatedByAdmin\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"contractIApp\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"AppUpgraded\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"contractIApp\"},{\"name\":\"rmsReleaseId\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"release\",\"type\":\"tuple\",\"indexed\":false,\"internalType\":\"structIAppController.Release\",\"components\":[{\"name\":\"rmsRelease\",\"type\":\"tuple\",\"internalType\":\"structIReleaseManagerTypes.Release\",\"components\":[{\"name\":\"artifacts\",\"type\":\"tuple[]\",\"internalType\":\"structIReleaseManagerTypes.Artifact[]\",\"components\":[{\"name\":\"digest\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"registry\",\"type\":\"string\",\"internalType\":\"string\"}]},{\"name\":\"upgradeByTime\",\"type\":\"uint32\",\"internalType\":\"uint32\"}]},{\"name\":\"publicEnv\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"encryptedEnv\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"containerPolicy\",\"type\":\"tuple\",\"internalType\":\"structIAppController.ContainerPolicy\",\"components\":[{\"name\":\"args\",\"type\":\"string[]\",\"internalType\":\"string[]\"},{\"name\":\"cmdOverride\",\"type\":\"string[]\",\"internalType\":\"string[]\"},{\"name\":\"env\",\"type\":\"tuple[]\",\"internalType\":\"structIAppController.EnvVar[]\",\"components\":[{\"name\":\"key\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"value\",\"type\":\"string\",\"internalType\":\"string\"}]},{\"name\":\"envOverride\",\"type\":\"tuple[]\",\"internalType\":\"structIAppController.EnvVar[]\",\"components\":[{\"name\":\"key\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"value\",\"type\":\"string\",\"internalType\":\"string\"}]},{\"name\":\"restartPolicy\",\"type\":\"string\",\"internalType\":\"string\"}]}]}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"GlobalMaxActiveAppsSet\",\"inputs\":[{\"name\":\"limit\",\"type\":\"uint32\",\"indexed\":false,\"internalType\":\"uint32\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"Initialized\",\"inputs\":[{\"name\":\"version\",\"type\":\"uint8\",\"indexed\":false,\"internalType\":\"uint8\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"MaxActiveAppsSet\",\"inputs\":[{\"name\":\"user\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"limit\",\"type\":\"uint32\",\"indexed\":false,\"internalType\":\"uint32\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"UpgradeConfirmed\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"contractIApp\"},{\"name\":\"pendingReleaseBlockNumber\",\"type\":\"uint32\",\"indexed\":false,\"internalType\":\"uint32\"}],\"anonymous\":false},{\"type\":\"error\",\"name\":\"AccountHasActiveApps\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"AppAlreadyExists\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"AppDoesNotExist\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"GlobalMaxActiveAppsExceeded\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidAppStatus\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidPermissions\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidReleaseMetadataURI\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidShortString\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidSignature\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"MaxActiveAppsExceeded\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"MoreThanOneArtifact\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"NoPendingUpgrade\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"SignatureExpired\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"StringTooLong\",\"inputs\":[{\"name\":\"str\",\"type\":\"string\",\"internalType\":\"string\"}]}]", + ABI: "[{\"type\":\"constructor\",\"inputs\":[{\"name\":\"_version\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"_permissionController\",\"type\":\"address\",\"internalType\":\"contractIPermissionController\"},{\"name\":\"_releaseManager\",\"type\":\"address\",\"internalType\":\"contractIReleaseManager\"},{\"name\":\"_computeAVSRegistrar\",\"type\":\"address\",\"internalType\":\"contractIComputeAVSRegistrar\"},{\"name\":\"_computeOperator\",\"type\":\"address\",\"internalType\":\"contractIComputeOperator\"},{\"name\":\"_appBeacon\",\"type\":\"address\",\"internalType\":\"contractIBeacon\"},{\"name\":\"_appAuthority\",\"type\":\"address\",\"internalType\":\"contractIAppAuthority\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"API_PERMISSION_TYPEHASH\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"acceptOwnership\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"internalType\":\"contractIApp\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"appAuthority\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIAppAuthority\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"appBeacon\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIBeacon\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"calculateApiPermissionDigestHash\",\"inputs\":[{\"name\":\"permission\",\"type\":\"bytes4\",\"internalType\":\"bytes4\"},{\"name\":\"expiry\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"calculateAppId\",\"inputs\":[{\"name\":\"deployer\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"salt\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIApp\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"canCall\",\"inputs\":[{\"name\":\"caller\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"data\",\"type\":\"bytes\",\"internalType\":\"bytes\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"cancelOwnershipTransfer\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"internalType\":\"contractIApp\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"computeAVSRegistrar\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIComputeAVSRegistrar\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"computeOperator\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIComputeOperator\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"confirmUpgrade\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"internalType\":\"contractIApp\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"createApp\",\"inputs\":[{\"name\":\"salt\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"release\",\"type\":\"tuple\",\"internalType\":\"structIAppController.Release\",\"components\":[{\"name\":\"rmsRelease\",\"type\":\"tuple\",\"internalType\":\"structIReleaseManagerTypes.Release\",\"components\":[{\"name\":\"artifacts\",\"type\":\"tuple[]\",\"internalType\":\"structIReleaseManagerTypes.Artifact[]\",\"components\":[{\"name\":\"digest\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"registry\",\"type\":\"string\",\"internalType\":\"string\"}]},{\"name\":\"upgradeByTime\",\"type\":\"uint32\",\"internalType\":\"uint32\"}]},{\"name\":\"publicEnv\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"encryptedEnv\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"containerPolicy\",\"type\":\"tuple\",\"internalType\":\"structIAppController.ContainerPolicy\",\"components\":[{\"name\":\"args\",\"type\":\"string[]\",\"internalType\":\"string[]\"},{\"name\":\"cmdOverride\",\"type\":\"string[]\",\"internalType\":\"string[]\"},{\"name\":\"env\",\"type\":\"tuple[]\",\"internalType\":\"structIAppController.EnvVar[]\",\"components\":[{\"name\":\"key\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"value\",\"type\":\"string\",\"internalType\":\"string\"}]},{\"name\":\"envOverride\",\"type\":\"tuple[]\",\"internalType\":\"structIAppController.EnvVar[]\",\"components\":[{\"name\":\"key\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"value\",\"type\":\"string\",\"internalType\":\"string\"}]},{\"name\":\"restartPolicy\",\"type\":\"string\",\"internalType\":\"string\"}]}]}],\"outputs\":[{\"name\":\"app\",\"type\":\"address\",\"internalType\":\"contractIApp\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"createAppWithIsolatedBilling\",\"inputs\":[{\"name\":\"salt\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"release\",\"type\":\"tuple\",\"internalType\":\"structIAppController.Release\",\"components\":[{\"name\":\"rmsRelease\",\"type\":\"tuple\",\"internalType\":\"structIReleaseManagerTypes.Release\",\"components\":[{\"name\":\"artifacts\",\"type\":\"tuple[]\",\"internalType\":\"structIReleaseManagerTypes.Artifact[]\",\"components\":[{\"name\":\"digest\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"registry\",\"type\":\"string\",\"internalType\":\"string\"}]},{\"name\":\"upgradeByTime\",\"type\":\"uint32\",\"internalType\":\"uint32\"}]},{\"name\":\"publicEnv\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"encryptedEnv\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"containerPolicy\",\"type\":\"tuple\",\"internalType\":\"structIAppController.ContainerPolicy\",\"components\":[{\"name\":\"args\",\"type\":\"string[]\",\"internalType\":\"string[]\"},{\"name\":\"cmdOverride\",\"type\":\"string[]\",\"internalType\":\"string[]\"},{\"name\":\"env\",\"type\":\"tuple[]\",\"internalType\":\"structIAppController.EnvVar[]\",\"components\":[{\"name\":\"key\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"value\",\"type\":\"string\",\"internalType\":\"string\"}]},{\"name\":\"envOverride\",\"type\":\"tuple[]\",\"internalType\":\"structIAppController.EnvVar[]\",\"components\":[{\"name\":\"key\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"value\",\"type\":\"string\",\"internalType\":\"string\"}]},{\"name\":\"restartPolicy\",\"type\":\"string\",\"internalType\":\"string\"}]}]}],\"outputs\":[{\"name\":\"app\",\"type\":\"address\",\"internalType\":\"contractIApp\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"domainSeparator\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getActiveAppCount\",\"inputs\":[{\"name\":\"user\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint32\",\"internalType\":\"uint32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getAppCreator\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"internalType\":\"contractIApp\"}],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getAppLatestReleaseBlockNumber\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"internalType\":\"contractIApp\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint32\",\"internalType\":\"uint32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getAppOperatorSetId\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"internalType\":\"contractIApp\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint32\",\"internalType\":\"uint32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getAppPendingReleaseBlockNumber\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"internalType\":\"contractIApp\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint32\",\"internalType\":\"uint32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getAppStatus\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"internalType\":\"contractIApp\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint8\",\"internalType\":\"enumIAppController.AppStatus\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getApps\",\"inputs\":[{\"name\":\"offset\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"limit\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"apps\",\"type\":\"address[]\",\"internalType\":\"contractIApp[]\"},{\"name\":\"appConfigsMem\",\"type\":\"tuple[]\",\"internalType\":\"structIAppController.AppConfig[]\",\"components\":[{\"name\":\"creator\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"operatorSetId\",\"type\":\"uint32\",\"internalType\":\"uint32\"},{\"name\":\"latestReleaseBlockNumber\",\"type\":\"uint32\",\"internalType\":\"uint32\"},{\"name\":\"pendingReleaseBlockNumber\",\"type\":\"uint32\",\"internalType\":\"uint32\"},{\"name\":\"status\",\"type\":\"uint8\",\"internalType\":\"enumIAppController.AppStatus\"}]}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getAppsByBillingAccount\",\"inputs\":[{\"name\":\"account\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"offset\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"limit\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"apps\",\"type\":\"address[]\",\"internalType\":\"contractIApp[]\"},{\"name\":\"appConfigsMem\",\"type\":\"tuple[]\",\"internalType\":\"structIAppController.AppConfig[]\",\"components\":[{\"name\":\"creator\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"operatorSetId\",\"type\":\"uint32\",\"internalType\":\"uint32\"},{\"name\":\"latestReleaseBlockNumber\",\"type\":\"uint32\",\"internalType\":\"uint32\"},{\"name\":\"pendingReleaseBlockNumber\",\"type\":\"uint32\",\"internalType\":\"uint32\"},{\"name\":\"status\",\"type\":\"uint8\",\"internalType\":\"enumIAppController.AppStatus\"}]}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getAppsByCreator\",\"inputs\":[{\"name\":\"creator\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"offset\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"limit\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"apps\",\"type\":\"address[]\",\"internalType\":\"contractIApp[]\"},{\"name\":\"appConfigsMem\",\"type\":\"tuple[]\",\"internalType\":\"structIAppController.AppConfig[]\",\"components\":[{\"name\":\"creator\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"operatorSetId\",\"type\":\"uint32\",\"internalType\":\"uint32\"},{\"name\":\"latestReleaseBlockNumber\",\"type\":\"uint32\",\"internalType\":\"uint32\"},{\"name\":\"pendingReleaseBlockNumber\",\"type\":\"uint32\",\"internalType\":\"uint32\"},{\"name\":\"status\",\"type\":\"uint8\",\"internalType\":\"enumIAppController.AppStatus\"}]}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getAppsByDeveloper\",\"inputs\":[{\"name\":\"developer\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"offset\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"limit\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"apps\",\"type\":\"address[]\",\"internalType\":\"contractIApp[]\"},{\"name\":\"appConfigsMem\",\"type\":\"tuple[]\",\"internalType\":\"structIAppController.AppConfig[]\",\"components\":[{\"name\":\"creator\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"operatorSetId\",\"type\":\"uint32\",\"internalType\":\"uint32\"},{\"name\":\"latestReleaseBlockNumber\",\"type\":\"uint32\",\"internalType\":\"uint32\"},{\"name\":\"pendingReleaseBlockNumber\",\"type\":\"uint32\",\"internalType\":\"uint32\"},{\"name\":\"status\",\"type\":\"uint8\",\"internalType\":\"enumIAppController.AppStatus\"}]}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getBillingAccount\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"internalType\":\"contractIApp\"}],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getBillingType\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"internalType\":\"contractIApp\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint8\",\"internalType\":\"enumIAppController.BillingType\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getMaxActiveAppsPerUser\",\"inputs\":[{\"name\":\"user\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint32\",\"internalType\":\"uint32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getPendingOwner\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"internalType\":\"contractIApp\"}],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"globalActiveAppCount\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint32\",\"internalType\":\"uint32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"initialize\",\"inputs\":[{\"name\":\"admin\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"maxGlobalActiveApps\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint32\",\"internalType\":\"uint32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"migrateAppsToAppAuthority\",\"inputs\":[{\"name\":\"apps\",\"type\":\"address[]\",\"internalType\":\"contractIApp[]\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"permissionController\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIPermissionController\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"releaseManager\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIReleaseManager\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"setMaxActiveAppsPerUser\",\"inputs\":[{\"name\":\"user\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"limit\",\"type\":\"uint32\",\"internalType\":\"uint32\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"setMaxGlobalActiveApps\",\"inputs\":[{\"name\":\"limit\",\"type\":\"uint32\",\"internalType\":\"uint32\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"startApp\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"internalType\":\"contractIApp\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"stopApp\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"internalType\":\"contractIApp\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"supportsInterface\",\"inputs\":[{\"name\":\"interfaceId\",\"type\":\"bytes4\",\"internalType\":\"bytes4\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"pure\"},{\"type\":\"function\",\"name\":\"suspend\",\"inputs\":[{\"name\":\"account\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"apps\",\"type\":\"address[]\",\"internalType\":\"contractIApp[]\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"terminateApp\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"internalType\":\"contractIApp\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"terminateAppByAdmin\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"internalType\":\"contractIApp\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"transferOwnership\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"internalType\":\"contractIApp\"},{\"name\":\"newOwner\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"updateAppMetadataURI\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"internalType\":\"contractIApp\"},{\"name\":\"metadataURI\",\"type\":\"string\",\"internalType\":\"string\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"upgradeApp\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"internalType\":\"contractIApp\"},{\"name\":\"release\",\"type\":\"tuple\",\"internalType\":\"structIAppController.Release\",\"components\":[{\"name\":\"rmsRelease\",\"type\":\"tuple\",\"internalType\":\"structIReleaseManagerTypes.Release\",\"components\":[{\"name\":\"artifacts\",\"type\":\"tuple[]\",\"internalType\":\"structIReleaseManagerTypes.Artifact[]\",\"components\":[{\"name\":\"digest\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"registry\",\"type\":\"string\",\"internalType\":\"string\"}]},{\"name\":\"upgradeByTime\",\"type\":\"uint32\",\"internalType\":\"uint32\"}]},{\"name\":\"publicEnv\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"encryptedEnv\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"containerPolicy\",\"type\":\"tuple\",\"internalType\":\"structIAppController.ContainerPolicy\",\"components\":[{\"name\":\"args\",\"type\":\"string[]\",\"internalType\":\"string[]\"},{\"name\":\"cmdOverride\",\"type\":\"string[]\",\"internalType\":\"string[]\"},{\"name\":\"env\",\"type\":\"tuple[]\",\"internalType\":\"structIAppController.EnvVar[]\",\"components\":[{\"name\":\"key\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"value\",\"type\":\"string\",\"internalType\":\"string\"}]},{\"name\":\"envOverride\",\"type\":\"tuple[]\",\"internalType\":\"structIAppController.EnvVar[]\",\"components\":[{\"name\":\"key\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"value\",\"type\":\"string\",\"internalType\":\"string\"}]},{\"name\":\"restartPolicy\",\"type\":\"string\",\"internalType\":\"string\"}]}]}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"version\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"string\",\"internalType\":\"string\"}],\"stateMutability\":\"view\"},{\"type\":\"event\",\"name\":\"AppCreated\",\"inputs\":[{\"name\":\"creator\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"app\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"contractIApp\"},{\"name\":\"operatorSetId\",\"type\":\"uint32\",\"indexed\":false,\"internalType\":\"uint32\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"AppMetadataURIUpdated\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"contractIApp\"},{\"name\":\"metadataURI\",\"type\":\"string\",\"indexed\":false,\"internalType\":\"string\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"AppOwnershipTransferred\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"contractIApp\"},{\"name\":\"previousOwner\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"newOwner\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"AppStarted\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"contractIApp\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"AppStopped\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"contractIApp\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"AppSuspended\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"contractIApp\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"AppTerminated\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"contractIApp\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"AppTerminatedByAdmin\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"contractIApp\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"AppUpgraded\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"contractIApp\"},{\"name\":\"rmsReleaseId\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"release\",\"type\":\"tuple\",\"indexed\":false,\"internalType\":\"structIAppController.Release\",\"components\":[{\"name\":\"rmsRelease\",\"type\":\"tuple\",\"internalType\":\"structIReleaseManagerTypes.Release\",\"components\":[{\"name\":\"artifacts\",\"type\":\"tuple[]\",\"internalType\":\"structIReleaseManagerTypes.Artifact[]\",\"components\":[{\"name\":\"digest\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"registry\",\"type\":\"string\",\"internalType\":\"string\"}]},{\"name\":\"upgradeByTime\",\"type\":\"uint32\",\"internalType\":\"uint32\"}]},{\"name\":\"publicEnv\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"encryptedEnv\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"containerPolicy\",\"type\":\"tuple\",\"internalType\":\"structIAppController.ContainerPolicy\",\"components\":[{\"name\":\"args\",\"type\":\"string[]\",\"internalType\":\"string[]\"},{\"name\":\"cmdOverride\",\"type\":\"string[]\",\"internalType\":\"string[]\"},{\"name\":\"env\",\"type\":\"tuple[]\",\"internalType\":\"structIAppController.EnvVar[]\",\"components\":[{\"name\":\"key\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"value\",\"type\":\"string\",\"internalType\":\"string\"}]},{\"name\":\"envOverride\",\"type\":\"tuple[]\",\"internalType\":\"structIAppController.EnvVar[]\",\"components\":[{\"name\":\"key\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"value\",\"type\":\"string\",\"internalType\":\"string\"}]},{\"name\":\"restartPolicy\",\"type\":\"string\",\"internalType\":\"string\"}]}]}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"GlobalMaxActiveAppsSet\",\"inputs\":[{\"name\":\"limit\",\"type\":\"uint32\",\"indexed\":false,\"internalType\":\"uint32\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"Initialized\",\"inputs\":[{\"name\":\"version\",\"type\":\"uint8\",\"indexed\":false,\"internalType\":\"uint8\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"MaxActiveAppsSet\",\"inputs\":[{\"name\":\"user\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"limit\",\"type\":\"uint32\",\"indexed\":false,\"internalType\":\"uint32\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"OwnershipTransferCancelled\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"contractIApp\"},{\"name\":\"currentOwner\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"cancelledOwner\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"OwnershipTransferProposed\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"contractIApp\"},{\"name\":\"currentOwner\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"proposedOwner\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"UpgradeConfirmed\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"contractIApp\"},{\"name\":\"pendingReleaseBlockNumber\",\"type\":\"uint32\",\"indexed\":false,\"internalType\":\"uint32\"}],\"anonymous\":false},{\"type\":\"error\",\"name\":\"AccountHasActiveApps\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"AppAlreadyExists\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"AppDoesNotExist\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"GlobalMaxActiveAppsExceeded\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidAppStatus\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidPermissions\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidReleaseMetadataURI\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidShortString\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidSignature\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidTeamRole\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"MaxActiveAppsExceeded\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"MoreThanOneArtifact\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"NoPendingUpgrade\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"NotCreator\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"NotPendingOwner\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"SignatureExpired\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"StringTooLong\",\"inputs\":[{\"name\":\"str\",\"type\":\"string\",\"internalType\":\"string\"}]}]", } // AppControllerABI is the input ABI used to generate the binding from. @@ -255,6 +255,37 @@ func (_AppController *AppControllerCallerSession) APIPERMISSIONTYPEHASH() ([32]b return _AppController.Contract.APIPERMISSIONTYPEHASH(&_AppController.CallOpts) } +// AppAuthority is a free data retrieval call binding the contract method 0x8029bbca. +// +// Solidity: function appAuthority() view returns(address) +func (_AppController *AppControllerCaller) AppAuthority(opts *bind.CallOpts) (common.Address, error) { + var out []interface{} + err := _AppController.contract.Call(opts, &out, "appAuthority") + + if err != nil { + return *new(common.Address), err + } + + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + + return out0, err + +} + +// AppAuthority is a free data retrieval call binding the contract method 0x8029bbca. +// +// Solidity: function appAuthority() view returns(address) +func (_AppController *AppControllerSession) AppAuthority() (common.Address, error) { + return _AppController.Contract.AppAuthority(&_AppController.CallOpts) +} + +// AppAuthority is a free data retrieval call binding the contract method 0x8029bbca. +// +// Solidity: function appAuthority() view returns(address) +func (_AppController *AppControllerCallerSession) AppAuthority() (common.Address, error) { + return _AppController.Contract.AppAuthority(&_AppController.CallOpts) +} + // AppBeacon is a free data retrieval call binding the contract method 0x8a52d0b5. // // Solidity: function appBeacon() view returns(address) @@ -348,6 +379,37 @@ func (_AppController *AppControllerCallerSession) CalculateAppId(deployer common return _AppController.Contract.CalculateAppId(&_AppController.CallOpts, deployer, salt) } +// CanCall is a free data retrieval call binding the contract method 0x9614801b. +// +// Solidity: function canCall(address caller, bytes data) view returns(bool) +func (_AppController *AppControllerCaller) CanCall(opts *bind.CallOpts, caller common.Address, data []byte) (bool, error) { + var out []interface{} + err := _AppController.contract.Call(opts, &out, "canCall", caller, data) + + if err != nil { + return *new(bool), err + } + + out0 := *abi.ConvertType(out[0], new(bool)).(*bool) + + return out0, err + +} + +// CanCall is a free data retrieval call binding the contract method 0x9614801b. +// +// Solidity: function canCall(address caller, bytes data) view returns(bool) +func (_AppController *AppControllerSession) CanCall(caller common.Address, data []byte) (bool, error) { + return _AppController.Contract.CanCall(&_AppController.CallOpts, caller, data) +} + +// CanCall is a free data retrieval call binding the contract method 0x9614801b. +// +// Solidity: function canCall(address caller, bytes data) view returns(bool) +func (_AppController *AppControllerCallerSession) CanCall(caller common.Address, data []byte) (bool, error) { + return _AppController.Contract.CanCall(&_AppController.CallOpts, caller, data) +} + // ComputeAVSRegistrar is a free data retrieval call binding the contract method 0xef6d92c6. // // Solidity: function computeAVSRegistrar() view returns(address) @@ -900,6 +962,37 @@ func (_AppController *AppControllerCallerSession) GetMaxActiveAppsPerUser(user c return _AppController.Contract.GetMaxActiveAppsPerUser(&_AppController.CallOpts, user) } +// GetPendingOwner is a free data retrieval call binding the contract method 0x66f6a5ed. +// +// Solidity: function getPendingOwner(address app) view returns(address) +func (_AppController *AppControllerCaller) GetPendingOwner(opts *bind.CallOpts, app common.Address) (common.Address, error) { + var out []interface{} + err := _AppController.contract.Call(opts, &out, "getPendingOwner", app) + + if err != nil { + return *new(common.Address), err + } + + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + + return out0, err + +} + +// GetPendingOwner is a free data retrieval call binding the contract method 0x66f6a5ed. +// +// Solidity: function getPendingOwner(address app) view returns(address) +func (_AppController *AppControllerSession) GetPendingOwner(app common.Address) (common.Address, error) { + return _AppController.Contract.GetPendingOwner(&_AppController.CallOpts, app) +} + +// GetPendingOwner is a free data retrieval call binding the contract method 0x66f6a5ed. +// +// Solidity: function getPendingOwner(address app) view returns(address) +func (_AppController *AppControllerCallerSession) GetPendingOwner(app common.Address) (common.Address, error) { + return _AppController.Contract.GetPendingOwner(&_AppController.CallOpts, app) +} + // GlobalActiveAppCount is a free data retrieval call binding the contract method 0xa8aa2bd3. // // Solidity: function globalActiveAppCount() view returns(uint32) @@ -1024,6 +1117,37 @@ func (_AppController *AppControllerCallerSession) ReleaseManager() (common.Addre return _AppController.Contract.ReleaseManager(&_AppController.CallOpts) } +// SupportsInterface is a free data retrieval call binding the contract method 0x01ffc9a7. +// +// Solidity: function supportsInterface(bytes4 interfaceId) pure returns(bool) +func (_AppController *AppControllerCaller) SupportsInterface(opts *bind.CallOpts, interfaceId [4]byte) (bool, error) { + var out []interface{} + err := _AppController.contract.Call(opts, &out, "supportsInterface", interfaceId) + + if err != nil { + return *new(bool), err + } + + out0 := *abi.ConvertType(out[0], new(bool)).(*bool) + + return out0, err + +} + +// SupportsInterface is a free data retrieval call binding the contract method 0x01ffc9a7. +// +// Solidity: function supportsInterface(bytes4 interfaceId) pure returns(bool) +func (_AppController *AppControllerSession) SupportsInterface(interfaceId [4]byte) (bool, error) { + return _AppController.Contract.SupportsInterface(&_AppController.CallOpts, interfaceId) +} + +// SupportsInterface is a free data retrieval call binding the contract method 0x01ffc9a7. +// +// Solidity: function supportsInterface(bytes4 interfaceId) pure returns(bool) +func (_AppController *AppControllerCallerSession) SupportsInterface(interfaceId [4]byte) (bool, error) { + return _AppController.Contract.SupportsInterface(&_AppController.CallOpts, interfaceId) +} + // Version is a free data retrieval call binding the contract method 0x54fd4d50. // // Solidity: function version() view returns(string) @@ -1055,6 +1179,48 @@ func (_AppController *AppControllerCallerSession) Version() (string, error) { return _AppController.Contract.Version(&_AppController.CallOpts) } +// AcceptOwnership is a paid mutator transaction binding the contract method 0x51710e45. +// +// Solidity: function acceptOwnership(address app) returns() +func (_AppController *AppControllerTransactor) AcceptOwnership(opts *bind.TransactOpts, app common.Address) (*types.Transaction, error) { + return _AppController.contract.Transact(opts, "acceptOwnership", app) +} + +// AcceptOwnership is a paid mutator transaction binding the contract method 0x51710e45. +// +// Solidity: function acceptOwnership(address app) returns() +func (_AppController *AppControllerSession) AcceptOwnership(app common.Address) (*types.Transaction, error) { + return _AppController.Contract.AcceptOwnership(&_AppController.TransactOpts, app) +} + +// AcceptOwnership is a paid mutator transaction binding the contract method 0x51710e45. +// +// Solidity: function acceptOwnership(address app) returns() +func (_AppController *AppControllerTransactorSession) AcceptOwnership(app common.Address) (*types.Transaction, error) { + return _AppController.Contract.AcceptOwnership(&_AppController.TransactOpts, app) +} + +// CancelOwnershipTransfer is a paid mutator transaction binding the contract method 0x7b37e561. +// +// Solidity: function cancelOwnershipTransfer(address app) returns() +func (_AppController *AppControllerTransactor) CancelOwnershipTransfer(opts *bind.TransactOpts, app common.Address) (*types.Transaction, error) { + return _AppController.contract.Transact(opts, "cancelOwnershipTransfer", app) +} + +// CancelOwnershipTransfer is a paid mutator transaction binding the contract method 0x7b37e561. +// +// Solidity: function cancelOwnershipTransfer(address app) returns() +func (_AppController *AppControllerSession) CancelOwnershipTransfer(app common.Address) (*types.Transaction, error) { + return _AppController.Contract.CancelOwnershipTransfer(&_AppController.TransactOpts, app) +} + +// CancelOwnershipTransfer is a paid mutator transaction binding the contract method 0x7b37e561. +// +// Solidity: function cancelOwnershipTransfer(address app) returns() +func (_AppController *AppControllerTransactorSession) CancelOwnershipTransfer(app common.Address) (*types.Transaction, error) { + return _AppController.Contract.CancelOwnershipTransfer(&_AppController.TransactOpts, app) +} + // ConfirmUpgrade is a paid mutator transaction binding the contract method 0xbbc1c204. // // Solidity: function confirmUpgrade(address app) returns() @@ -1139,6 +1305,27 @@ func (_AppController *AppControllerTransactorSession) Initialize(admin common.Ad return _AppController.Contract.Initialize(&_AppController.TransactOpts, admin) } +// MigrateAppsToAppAuthority is a paid mutator transaction binding the contract method 0xa6a062cd. +// +// Solidity: function migrateAppsToAppAuthority(address[] apps) returns() +func (_AppController *AppControllerTransactor) MigrateAppsToAppAuthority(opts *bind.TransactOpts, apps []common.Address) (*types.Transaction, error) { + return _AppController.contract.Transact(opts, "migrateAppsToAppAuthority", apps) +} + +// MigrateAppsToAppAuthority is a paid mutator transaction binding the contract method 0xa6a062cd. +// +// Solidity: function migrateAppsToAppAuthority(address[] apps) returns() +func (_AppController *AppControllerSession) MigrateAppsToAppAuthority(apps []common.Address) (*types.Transaction, error) { + return _AppController.Contract.MigrateAppsToAppAuthority(&_AppController.TransactOpts, apps) +} + +// MigrateAppsToAppAuthority is a paid mutator transaction binding the contract method 0xa6a062cd. +// +// Solidity: function migrateAppsToAppAuthority(address[] apps) returns() +func (_AppController *AppControllerTransactorSession) MigrateAppsToAppAuthority(apps []common.Address) (*types.Transaction, error) { + return _AppController.Contract.MigrateAppsToAppAuthority(&_AppController.TransactOpts, apps) +} + // SetMaxActiveAppsPerUser is a paid mutator transaction binding the contract method 0xd49fec2b. // // Solidity: function setMaxActiveAppsPerUser(address user, uint32 limit) returns() @@ -1286,6 +1473,27 @@ func (_AppController *AppControllerTransactorSession) TerminateAppByAdmin(app co return _AppController.Contract.TerminateAppByAdmin(&_AppController.TransactOpts, app) } +// TransferOwnership is a paid mutator transaction binding the contract method 0x6d435421. +// +// Solidity: function transferOwnership(address app, address newOwner) returns() +func (_AppController *AppControllerTransactor) TransferOwnership(opts *bind.TransactOpts, app common.Address, newOwner common.Address) (*types.Transaction, error) { + return _AppController.contract.Transact(opts, "transferOwnership", app, newOwner) +} + +// TransferOwnership is a paid mutator transaction binding the contract method 0x6d435421. +// +// Solidity: function transferOwnership(address app, address newOwner) returns() +func (_AppController *AppControllerSession) TransferOwnership(app common.Address, newOwner common.Address) (*types.Transaction, error) { + return _AppController.Contract.TransferOwnership(&_AppController.TransactOpts, app, newOwner) +} + +// TransferOwnership is a paid mutator transaction binding the contract method 0x6d435421. +// +// Solidity: function transferOwnership(address app, address newOwner) returns() +func (_AppController *AppControllerTransactorSession) TransferOwnership(app common.Address, newOwner common.Address) (*types.Transaction, error) { + return _AppController.Contract.TransferOwnership(&_AppController.TransactOpts, app, newOwner) +} + // UpdateAppMetadataURI is a paid mutator transaction binding the contract method 0x65aa9a65. // // Solidity: function updateAppMetadataURI(address app, string metadataURI) returns() @@ -1627,6 +1835,168 @@ func (_AppController *AppControllerFilterer) ParseAppMetadataURIUpdated(log type return event, nil } +// AppControllerAppOwnershipTransferredIterator is returned from FilterAppOwnershipTransferred and is used to iterate over the raw logs and unpacked data for AppOwnershipTransferred events raised by the AppController contract. +type AppControllerAppOwnershipTransferredIterator struct { + Event *AppControllerAppOwnershipTransferred // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *AppControllerAppOwnershipTransferredIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(AppControllerAppOwnershipTransferred) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(AppControllerAppOwnershipTransferred) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *AppControllerAppOwnershipTransferredIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *AppControllerAppOwnershipTransferredIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// AppControllerAppOwnershipTransferred represents a AppOwnershipTransferred event raised by the AppController contract. +type AppControllerAppOwnershipTransferred struct { + App common.Address + PreviousOwner common.Address + NewOwner common.Address + Raw types.Log // Blockchain specific contextual infos +} + +// FilterAppOwnershipTransferred is a free log retrieval operation binding the contract event 0x3fa03516e5ee455b2d2779f21b254735e2c1f82cf338619c1b96816df2a467a4. +// +// Solidity: event AppOwnershipTransferred(address indexed app, address indexed previousOwner, address indexed newOwner) +func (_AppController *AppControllerFilterer) FilterAppOwnershipTransferred(opts *bind.FilterOpts, app []common.Address, previousOwner []common.Address, newOwner []common.Address) (*AppControllerAppOwnershipTransferredIterator, error) { + + var appRule []interface{} + for _, appItem := range app { + appRule = append(appRule, appItem) + } + var previousOwnerRule []interface{} + for _, previousOwnerItem := range previousOwner { + previousOwnerRule = append(previousOwnerRule, previousOwnerItem) + } + var newOwnerRule []interface{} + for _, newOwnerItem := range newOwner { + newOwnerRule = append(newOwnerRule, newOwnerItem) + } + + logs, sub, err := _AppController.contract.FilterLogs(opts, "AppOwnershipTransferred", appRule, previousOwnerRule, newOwnerRule) + if err != nil { + return nil, err + } + return &AppControllerAppOwnershipTransferredIterator{contract: _AppController.contract, event: "AppOwnershipTransferred", logs: logs, sub: sub}, nil +} + +// WatchAppOwnershipTransferred is a free log subscription operation binding the contract event 0x3fa03516e5ee455b2d2779f21b254735e2c1f82cf338619c1b96816df2a467a4. +// +// Solidity: event AppOwnershipTransferred(address indexed app, address indexed previousOwner, address indexed newOwner) +func (_AppController *AppControllerFilterer) WatchAppOwnershipTransferred(opts *bind.WatchOpts, sink chan<- *AppControllerAppOwnershipTransferred, app []common.Address, previousOwner []common.Address, newOwner []common.Address) (event.Subscription, error) { + + var appRule []interface{} + for _, appItem := range app { + appRule = append(appRule, appItem) + } + var previousOwnerRule []interface{} + for _, previousOwnerItem := range previousOwner { + previousOwnerRule = append(previousOwnerRule, previousOwnerItem) + } + var newOwnerRule []interface{} + for _, newOwnerItem := range newOwner { + newOwnerRule = append(newOwnerRule, newOwnerItem) + } + + logs, sub, err := _AppController.contract.WatchLogs(opts, "AppOwnershipTransferred", appRule, previousOwnerRule, newOwnerRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(AppControllerAppOwnershipTransferred) + if err := _AppController.contract.UnpackLog(event, "AppOwnershipTransferred", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseAppOwnershipTransferred is a log parse operation binding the contract event 0x3fa03516e5ee455b2d2779f21b254735e2c1f82cf338619c1b96816df2a467a4. +// +// Solidity: event AppOwnershipTransferred(address indexed app, address indexed previousOwner, address indexed newOwner) +func (_AppController *AppControllerFilterer) ParseAppOwnershipTransferred(log types.Log) (*AppControllerAppOwnershipTransferred, error) { + event := new(AppControllerAppOwnershipTransferred) + if err := _AppController.contract.UnpackLog(event, "AppOwnershipTransferred", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + // AppControllerAppStartedIterator is returned from FilterAppStarted and is used to iterate over the raw logs and unpacked data for AppStarted events raised by the AppController contract. type AppControllerAppStartedIterator struct { Event *AppControllerAppStarted // Event containing the contract specifics and raw log @@ -2906,6 +3276,330 @@ func (_AppController *AppControllerFilterer) ParseMaxActiveAppsSet(log types.Log return event, nil } +// AppControllerOwnershipTransferCancelledIterator is returned from FilterOwnershipTransferCancelled and is used to iterate over the raw logs and unpacked data for OwnershipTransferCancelled events raised by the AppController contract. +type AppControllerOwnershipTransferCancelledIterator struct { + Event *AppControllerOwnershipTransferCancelled // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *AppControllerOwnershipTransferCancelledIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(AppControllerOwnershipTransferCancelled) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(AppControllerOwnershipTransferCancelled) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *AppControllerOwnershipTransferCancelledIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *AppControllerOwnershipTransferCancelledIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// AppControllerOwnershipTransferCancelled represents a OwnershipTransferCancelled event raised by the AppController contract. +type AppControllerOwnershipTransferCancelled struct { + App common.Address + CurrentOwner common.Address + CancelledOwner common.Address + Raw types.Log // Blockchain specific contextual infos +} + +// FilterOwnershipTransferCancelled is a free log retrieval operation binding the contract event 0xcad70233aeb5045d6c0748ae334980c91d2ebb929df025f4e53f236e3365cdae. +// +// Solidity: event OwnershipTransferCancelled(address indexed app, address indexed currentOwner, address indexed cancelledOwner) +func (_AppController *AppControllerFilterer) FilterOwnershipTransferCancelled(opts *bind.FilterOpts, app []common.Address, currentOwner []common.Address, cancelledOwner []common.Address) (*AppControllerOwnershipTransferCancelledIterator, error) { + + var appRule []interface{} + for _, appItem := range app { + appRule = append(appRule, appItem) + } + var currentOwnerRule []interface{} + for _, currentOwnerItem := range currentOwner { + currentOwnerRule = append(currentOwnerRule, currentOwnerItem) + } + var cancelledOwnerRule []interface{} + for _, cancelledOwnerItem := range cancelledOwner { + cancelledOwnerRule = append(cancelledOwnerRule, cancelledOwnerItem) + } + + logs, sub, err := _AppController.contract.FilterLogs(opts, "OwnershipTransferCancelled", appRule, currentOwnerRule, cancelledOwnerRule) + if err != nil { + return nil, err + } + return &AppControllerOwnershipTransferCancelledIterator{contract: _AppController.contract, event: "OwnershipTransferCancelled", logs: logs, sub: sub}, nil +} + +// WatchOwnershipTransferCancelled is a free log subscription operation binding the contract event 0xcad70233aeb5045d6c0748ae334980c91d2ebb929df025f4e53f236e3365cdae. +// +// Solidity: event OwnershipTransferCancelled(address indexed app, address indexed currentOwner, address indexed cancelledOwner) +func (_AppController *AppControllerFilterer) WatchOwnershipTransferCancelled(opts *bind.WatchOpts, sink chan<- *AppControllerOwnershipTransferCancelled, app []common.Address, currentOwner []common.Address, cancelledOwner []common.Address) (event.Subscription, error) { + + var appRule []interface{} + for _, appItem := range app { + appRule = append(appRule, appItem) + } + var currentOwnerRule []interface{} + for _, currentOwnerItem := range currentOwner { + currentOwnerRule = append(currentOwnerRule, currentOwnerItem) + } + var cancelledOwnerRule []interface{} + for _, cancelledOwnerItem := range cancelledOwner { + cancelledOwnerRule = append(cancelledOwnerRule, cancelledOwnerItem) + } + + logs, sub, err := _AppController.contract.WatchLogs(opts, "OwnershipTransferCancelled", appRule, currentOwnerRule, cancelledOwnerRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(AppControllerOwnershipTransferCancelled) + if err := _AppController.contract.UnpackLog(event, "OwnershipTransferCancelled", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseOwnershipTransferCancelled is a log parse operation binding the contract event 0xcad70233aeb5045d6c0748ae334980c91d2ebb929df025f4e53f236e3365cdae. +// +// Solidity: event OwnershipTransferCancelled(address indexed app, address indexed currentOwner, address indexed cancelledOwner) +func (_AppController *AppControllerFilterer) ParseOwnershipTransferCancelled(log types.Log) (*AppControllerOwnershipTransferCancelled, error) { + event := new(AppControllerOwnershipTransferCancelled) + if err := _AppController.contract.UnpackLog(event, "OwnershipTransferCancelled", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// AppControllerOwnershipTransferProposedIterator is returned from FilterOwnershipTransferProposed and is used to iterate over the raw logs and unpacked data for OwnershipTransferProposed events raised by the AppController contract. +type AppControllerOwnershipTransferProposedIterator struct { + Event *AppControllerOwnershipTransferProposed // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *AppControllerOwnershipTransferProposedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(AppControllerOwnershipTransferProposed) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(AppControllerOwnershipTransferProposed) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *AppControllerOwnershipTransferProposedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *AppControllerOwnershipTransferProposedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// AppControllerOwnershipTransferProposed represents a OwnershipTransferProposed event raised by the AppController contract. +type AppControllerOwnershipTransferProposed struct { + App common.Address + CurrentOwner common.Address + ProposedOwner common.Address + Raw types.Log // Blockchain specific contextual infos +} + +// FilterOwnershipTransferProposed is a free log retrieval operation binding the contract event 0x8fed7b39cda3ba71166209a9aabe8392412ec261885b32365ee6c870f2cc6197. +// +// Solidity: event OwnershipTransferProposed(address indexed app, address indexed currentOwner, address indexed proposedOwner) +func (_AppController *AppControllerFilterer) FilterOwnershipTransferProposed(opts *bind.FilterOpts, app []common.Address, currentOwner []common.Address, proposedOwner []common.Address) (*AppControllerOwnershipTransferProposedIterator, error) { + + var appRule []interface{} + for _, appItem := range app { + appRule = append(appRule, appItem) + } + var currentOwnerRule []interface{} + for _, currentOwnerItem := range currentOwner { + currentOwnerRule = append(currentOwnerRule, currentOwnerItem) + } + var proposedOwnerRule []interface{} + for _, proposedOwnerItem := range proposedOwner { + proposedOwnerRule = append(proposedOwnerRule, proposedOwnerItem) + } + + logs, sub, err := _AppController.contract.FilterLogs(opts, "OwnershipTransferProposed", appRule, currentOwnerRule, proposedOwnerRule) + if err != nil { + return nil, err + } + return &AppControllerOwnershipTransferProposedIterator{contract: _AppController.contract, event: "OwnershipTransferProposed", logs: logs, sub: sub}, nil +} + +// WatchOwnershipTransferProposed is a free log subscription operation binding the contract event 0x8fed7b39cda3ba71166209a9aabe8392412ec261885b32365ee6c870f2cc6197. +// +// Solidity: event OwnershipTransferProposed(address indexed app, address indexed currentOwner, address indexed proposedOwner) +func (_AppController *AppControllerFilterer) WatchOwnershipTransferProposed(opts *bind.WatchOpts, sink chan<- *AppControllerOwnershipTransferProposed, app []common.Address, currentOwner []common.Address, proposedOwner []common.Address) (event.Subscription, error) { + + var appRule []interface{} + for _, appItem := range app { + appRule = append(appRule, appItem) + } + var currentOwnerRule []interface{} + for _, currentOwnerItem := range currentOwner { + currentOwnerRule = append(currentOwnerRule, currentOwnerItem) + } + var proposedOwnerRule []interface{} + for _, proposedOwnerItem := range proposedOwner { + proposedOwnerRule = append(proposedOwnerRule, proposedOwnerItem) + } + + logs, sub, err := _AppController.contract.WatchLogs(opts, "OwnershipTransferProposed", appRule, currentOwnerRule, proposedOwnerRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(AppControllerOwnershipTransferProposed) + if err := _AppController.contract.UnpackLog(event, "OwnershipTransferProposed", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseOwnershipTransferProposed is a log parse operation binding the contract event 0x8fed7b39cda3ba71166209a9aabe8392412ec261885b32365ee6c870f2cc6197. +// +// Solidity: event OwnershipTransferProposed(address indexed app, address indexed currentOwner, address indexed proposedOwner) +func (_AppController *AppControllerFilterer) ParseOwnershipTransferProposed(log types.Log) (*AppControllerOwnershipTransferProposed, error) { + event := new(AppControllerOwnershipTransferProposed) + if err := _AppController.contract.UnpackLog(event, "OwnershipTransferProposed", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + // AppControllerUpgradeConfirmedIterator is returned from FilterUpgradeConfirmed and is used to iterate over the raw logs and unpacked data for UpgradeConfirmed events raised by the AppController contract. type AppControllerUpgradeConfirmedIterator struct { Event *AppControllerUpgradeConfirmed // Event containing the contract specifics and raw log diff --git a/pkg/bindings/v2/AppController/binding.go b/pkg/bindings/v2/AppController/binding.go index 4547471..8c68989 100644 --- a/pkg/bindings/v2/AppController/binding.go +++ b/pkg/bindings/v2/AppController/binding.go @@ -70,7 +70,7 @@ type IReleaseManagerTypesRelease struct { // AppControllerMetaData contains all meta data concerning the AppController contract. var AppControllerMetaData = bind.MetaData{ - ABI: "[{\"type\":\"constructor\",\"inputs\":[{\"name\":\"_version\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"_permissionController\",\"type\":\"address\",\"internalType\":\"contractIPermissionController\"},{\"name\":\"_releaseManager\",\"type\":\"address\",\"internalType\":\"contractIReleaseManager\"},{\"name\":\"_computeAVSRegistrar\",\"type\":\"address\",\"internalType\":\"contractIComputeAVSRegistrar\"},{\"name\":\"_computeOperator\",\"type\":\"address\",\"internalType\":\"contractIComputeOperator\"},{\"name\":\"_appBeacon\",\"type\":\"address\",\"internalType\":\"contractIBeacon\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"API_PERMISSION_TYPEHASH\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"appBeacon\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIBeacon\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"calculateApiPermissionDigestHash\",\"inputs\":[{\"name\":\"permission\",\"type\":\"bytes4\",\"internalType\":\"bytes4\"},{\"name\":\"expiry\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"calculateAppId\",\"inputs\":[{\"name\":\"deployer\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"salt\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIApp\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"computeAVSRegistrar\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIComputeAVSRegistrar\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"computeOperator\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIComputeOperator\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"confirmUpgrade\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"internalType\":\"contractIApp\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"createApp\",\"inputs\":[{\"name\":\"salt\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"release\",\"type\":\"tuple\",\"internalType\":\"structIAppController.Release\",\"components\":[{\"name\":\"rmsRelease\",\"type\":\"tuple\",\"internalType\":\"structIReleaseManagerTypes.Release\",\"components\":[{\"name\":\"artifacts\",\"type\":\"tuple[]\",\"internalType\":\"structIReleaseManagerTypes.Artifact[]\",\"components\":[{\"name\":\"digest\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"registry\",\"type\":\"string\",\"internalType\":\"string\"}]},{\"name\":\"upgradeByTime\",\"type\":\"uint32\",\"internalType\":\"uint32\"}]},{\"name\":\"publicEnv\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"encryptedEnv\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"containerPolicy\",\"type\":\"tuple\",\"internalType\":\"structIAppController.ContainerPolicy\",\"components\":[{\"name\":\"args\",\"type\":\"string[]\",\"internalType\":\"string[]\"},{\"name\":\"cmdOverride\",\"type\":\"string[]\",\"internalType\":\"string[]\"},{\"name\":\"env\",\"type\":\"tuple[]\",\"internalType\":\"structIAppController.EnvVar[]\",\"components\":[{\"name\":\"key\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"value\",\"type\":\"string\",\"internalType\":\"string\"}]},{\"name\":\"envOverride\",\"type\":\"tuple[]\",\"internalType\":\"structIAppController.EnvVar[]\",\"components\":[{\"name\":\"key\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"value\",\"type\":\"string\",\"internalType\":\"string\"}]},{\"name\":\"restartPolicy\",\"type\":\"string\",\"internalType\":\"string\"}]}]}],\"outputs\":[{\"name\":\"app\",\"type\":\"address\",\"internalType\":\"contractIApp\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"createAppWithIsolatedBilling\",\"inputs\":[{\"name\":\"salt\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"release\",\"type\":\"tuple\",\"internalType\":\"structIAppController.Release\",\"components\":[{\"name\":\"rmsRelease\",\"type\":\"tuple\",\"internalType\":\"structIReleaseManagerTypes.Release\",\"components\":[{\"name\":\"artifacts\",\"type\":\"tuple[]\",\"internalType\":\"structIReleaseManagerTypes.Artifact[]\",\"components\":[{\"name\":\"digest\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"registry\",\"type\":\"string\",\"internalType\":\"string\"}]},{\"name\":\"upgradeByTime\",\"type\":\"uint32\",\"internalType\":\"uint32\"}]},{\"name\":\"publicEnv\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"encryptedEnv\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"containerPolicy\",\"type\":\"tuple\",\"internalType\":\"structIAppController.ContainerPolicy\",\"components\":[{\"name\":\"args\",\"type\":\"string[]\",\"internalType\":\"string[]\"},{\"name\":\"cmdOverride\",\"type\":\"string[]\",\"internalType\":\"string[]\"},{\"name\":\"env\",\"type\":\"tuple[]\",\"internalType\":\"structIAppController.EnvVar[]\",\"components\":[{\"name\":\"key\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"value\",\"type\":\"string\",\"internalType\":\"string\"}]},{\"name\":\"envOverride\",\"type\":\"tuple[]\",\"internalType\":\"structIAppController.EnvVar[]\",\"components\":[{\"name\":\"key\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"value\",\"type\":\"string\",\"internalType\":\"string\"}]},{\"name\":\"restartPolicy\",\"type\":\"string\",\"internalType\":\"string\"}]}]}],\"outputs\":[{\"name\":\"app\",\"type\":\"address\",\"internalType\":\"contractIApp\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"domainSeparator\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getActiveAppCount\",\"inputs\":[{\"name\":\"user\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint32\",\"internalType\":\"uint32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getAppCreator\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"internalType\":\"contractIApp\"}],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getAppLatestReleaseBlockNumber\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"internalType\":\"contractIApp\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint32\",\"internalType\":\"uint32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getAppOperatorSetId\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"internalType\":\"contractIApp\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint32\",\"internalType\":\"uint32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getAppPendingReleaseBlockNumber\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"internalType\":\"contractIApp\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint32\",\"internalType\":\"uint32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getAppStatus\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"internalType\":\"contractIApp\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint8\",\"internalType\":\"enumIAppController.AppStatus\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getApps\",\"inputs\":[{\"name\":\"offset\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"limit\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"apps\",\"type\":\"address[]\",\"internalType\":\"contractIApp[]\"},{\"name\":\"appConfigsMem\",\"type\":\"tuple[]\",\"internalType\":\"structIAppController.AppConfig[]\",\"components\":[{\"name\":\"creator\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"operatorSetId\",\"type\":\"uint32\",\"internalType\":\"uint32\"},{\"name\":\"latestReleaseBlockNumber\",\"type\":\"uint32\",\"internalType\":\"uint32\"},{\"name\":\"pendingReleaseBlockNumber\",\"type\":\"uint32\",\"internalType\":\"uint32\"},{\"name\":\"status\",\"type\":\"uint8\",\"internalType\":\"enumIAppController.AppStatus\"}]}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getAppsByBillingAccount\",\"inputs\":[{\"name\":\"account\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"offset\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"limit\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"apps\",\"type\":\"address[]\",\"internalType\":\"contractIApp[]\"},{\"name\":\"appConfigsMem\",\"type\":\"tuple[]\",\"internalType\":\"structIAppController.AppConfig[]\",\"components\":[{\"name\":\"creator\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"operatorSetId\",\"type\":\"uint32\",\"internalType\":\"uint32\"},{\"name\":\"latestReleaseBlockNumber\",\"type\":\"uint32\",\"internalType\":\"uint32\"},{\"name\":\"pendingReleaseBlockNumber\",\"type\":\"uint32\",\"internalType\":\"uint32\"},{\"name\":\"status\",\"type\":\"uint8\",\"internalType\":\"enumIAppController.AppStatus\"}]}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getAppsByCreator\",\"inputs\":[{\"name\":\"creator\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"offset\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"limit\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"apps\",\"type\":\"address[]\",\"internalType\":\"contractIApp[]\"},{\"name\":\"appConfigsMem\",\"type\":\"tuple[]\",\"internalType\":\"structIAppController.AppConfig[]\",\"components\":[{\"name\":\"creator\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"operatorSetId\",\"type\":\"uint32\",\"internalType\":\"uint32\"},{\"name\":\"latestReleaseBlockNumber\",\"type\":\"uint32\",\"internalType\":\"uint32\"},{\"name\":\"pendingReleaseBlockNumber\",\"type\":\"uint32\",\"internalType\":\"uint32\"},{\"name\":\"status\",\"type\":\"uint8\",\"internalType\":\"enumIAppController.AppStatus\"}]}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getAppsByDeveloper\",\"inputs\":[{\"name\":\"developer\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"offset\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"limit\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"apps\",\"type\":\"address[]\",\"internalType\":\"contractIApp[]\"},{\"name\":\"appConfigsMem\",\"type\":\"tuple[]\",\"internalType\":\"structIAppController.AppConfig[]\",\"components\":[{\"name\":\"creator\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"operatorSetId\",\"type\":\"uint32\",\"internalType\":\"uint32\"},{\"name\":\"latestReleaseBlockNumber\",\"type\":\"uint32\",\"internalType\":\"uint32\"},{\"name\":\"pendingReleaseBlockNumber\",\"type\":\"uint32\",\"internalType\":\"uint32\"},{\"name\":\"status\",\"type\":\"uint8\",\"internalType\":\"enumIAppController.AppStatus\"}]}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getBillingAccount\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"internalType\":\"contractIApp\"}],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getBillingType\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"internalType\":\"contractIApp\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint8\",\"internalType\":\"enumIAppController.BillingType\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getMaxActiveAppsPerUser\",\"inputs\":[{\"name\":\"user\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint32\",\"internalType\":\"uint32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"globalActiveAppCount\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint32\",\"internalType\":\"uint32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"initialize\",\"inputs\":[{\"name\":\"admin\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"maxGlobalActiveApps\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint32\",\"internalType\":\"uint32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"permissionController\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIPermissionController\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"releaseManager\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIReleaseManager\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"setMaxActiveAppsPerUser\",\"inputs\":[{\"name\":\"user\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"limit\",\"type\":\"uint32\",\"internalType\":\"uint32\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"setMaxGlobalActiveApps\",\"inputs\":[{\"name\":\"limit\",\"type\":\"uint32\",\"internalType\":\"uint32\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"startApp\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"internalType\":\"contractIApp\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"stopApp\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"internalType\":\"contractIApp\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"suspend\",\"inputs\":[{\"name\":\"account\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"apps\",\"type\":\"address[]\",\"internalType\":\"contractIApp[]\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"terminateApp\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"internalType\":\"contractIApp\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"terminateAppByAdmin\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"internalType\":\"contractIApp\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"updateAppMetadataURI\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"internalType\":\"contractIApp\"},{\"name\":\"metadataURI\",\"type\":\"string\",\"internalType\":\"string\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"upgradeApp\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"internalType\":\"contractIApp\"},{\"name\":\"release\",\"type\":\"tuple\",\"internalType\":\"structIAppController.Release\",\"components\":[{\"name\":\"rmsRelease\",\"type\":\"tuple\",\"internalType\":\"structIReleaseManagerTypes.Release\",\"components\":[{\"name\":\"artifacts\",\"type\":\"tuple[]\",\"internalType\":\"structIReleaseManagerTypes.Artifact[]\",\"components\":[{\"name\":\"digest\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"registry\",\"type\":\"string\",\"internalType\":\"string\"}]},{\"name\":\"upgradeByTime\",\"type\":\"uint32\",\"internalType\":\"uint32\"}]},{\"name\":\"publicEnv\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"encryptedEnv\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"containerPolicy\",\"type\":\"tuple\",\"internalType\":\"structIAppController.ContainerPolicy\",\"components\":[{\"name\":\"args\",\"type\":\"string[]\",\"internalType\":\"string[]\"},{\"name\":\"cmdOverride\",\"type\":\"string[]\",\"internalType\":\"string[]\"},{\"name\":\"env\",\"type\":\"tuple[]\",\"internalType\":\"structIAppController.EnvVar[]\",\"components\":[{\"name\":\"key\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"value\",\"type\":\"string\",\"internalType\":\"string\"}]},{\"name\":\"envOverride\",\"type\":\"tuple[]\",\"internalType\":\"structIAppController.EnvVar[]\",\"components\":[{\"name\":\"key\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"value\",\"type\":\"string\",\"internalType\":\"string\"}]},{\"name\":\"restartPolicy\",\"type\":\"string\",\"internalType\":\"string\"}]}]}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"version\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"string\",\"internalType\":\"string\"}],\"stateMutability\":\"view\"},{\"type\":\"event\",\"name\":\"AppCreated\",\"inputs\":[{\"name\":\"creator\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"app\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"contractIApp\"},{\"name\":\"operatorSetId\",\"type\":\"uint32\",\"indexed\":false,\"internalType\":\"uint32\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"AppMetadataURIUpdated\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"contractIApp\"},{\"name\":\"metadataURI\",\"type\":\"string\",\"indexed\":false,\"internalType\":\"string\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"AppStarted\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"contractIApp\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"AppStopped\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"contractIApp\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"AppSuspended\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"contractIApp\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"AppTerminated\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"contractIApp\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"AppTerminatedByAdmin\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"contractIApp\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"AppUpgraded\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"contractIApp\"},{\"name\":\"rmsReleaseId\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"release\",\"type\":\"tuple\",\"indexed\":false,\"internalType\":\"structIAppController.Release\",\"components\":[{\"name\":\"rmsRelease\",\"type\":\"tuple\",\"internalType\":\"structIReleaseManagerTypes.Release\",\"components\":[{\"name\":\"artifacts\",\"type\":\"tuple[]\",\"internalType\":\"structIReleaseManagerTypes.Artifact[]\",\"components\":[{\"name\":\"digest\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"registry\",\"type\":\"string\",\"internalType\":\"string\"}]},{\"name\":\"upgradeByTime\",\"type\":\"uint32\",\"internalType\":\"uint32\"}]},{\"name\":\"publicEnv\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"encryptedEnv\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"containerPolicy\",\"type\":\"tuple\",\"internalType\":\"structIAppController.ContainerPolicy\",\"components\":[{\"name\":\"args\",\"type\":\"string[]\",\"internalType\":\"string[]\"},{\"name\":\"cmdOverride\",\"type\":\"string[]\",\"internalType\":\"string[]\"},{\"name\":\"env\",\"type\":\"tuple[]\",\"internalType\":\"structIAppController.EnvVar[]\",\"components\":[{\"name\":\"key\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"value\",\"type\":\"string\",\"internalType\":\"string\"}]},{\"name\":\"envOverride\",\"type\":\"tuple[]\",\"internalType\":\"structIAppController.EnvVar[]\",\"components\":[{\"name\":\"key\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"value\",\"type\":\"string\",\"internalType\":\"string\"}]},{\"name\":\"restartPolicy\",\"type\":\"string\",\"internalType\":\"string\"}]}]}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"GlobalMaxActiveAppsSet\",\"inputs\":[{\"name\":\"limit\",\"type\":\"uint32\",\"indexed\":false,\"internalType\":\"uint32\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"Initialized\",\"inputs\":[{\"name\":\"version\",\"type\":\"uint8\",\"indexed\":false,\"internalType\":\"uint8\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"MaxActiveAppsSet\",\"inputs\":[{\"name\":\"user\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"limit\",\"type\":\"uint32\",\"indexed\":false,\"internalType\":\"uint32\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"UpgradeConfirmed\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"contractIApp\"},{\"name\":\"pendingReleaseBlockNumber\",\"type\":\"uint32\",\"indexed\":false,\"internalType\":\"uint32\"}],\"anonymous\":false},{\"type\":\"error\",\"name\":\"AccountHasActiveApps\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"AppAlreadyExists\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"AppDoesNotExist\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"GlobalMaxActiveAppsExceeded\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidAppStatus\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidPermissions\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidReleaseMetadataURI\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidShortString\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidSignature\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"MaxActiveAppsExceeded\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"MoreThanOneArtifact\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"NoPendingUpgrade\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"SignatureExpired\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"StringTooLong\",\"inputs\":[{\"name\":\"str\",\"type\":\"string\",\"internalType\":\"string\"}]}]", + ABI: "[{\"type\":\"constructor\",\"inputs\":[{\"name\":\"_version\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"_permissionController\",\"type\":\"address\",\"internalType\":\"contractIPermissionController\"},{\"name\":\"_releaseManager\",\"type\":\"address\",\"internalType\":\"contractIReleaseManager\"},{\"name\":\"_computeAVSRegistrar\",\"type\":\"address\",\"internalType\":\"contractIComputeAVSRegistrar\"},{\"name\":\"_computeOperator\",\"type\":\"address\",\"internalType\":\"contractIComputeOperator\"},{\"name\":\"_appBeacon\",\"type\":\"address\",\"internalType\":\"contractIBeacon\"},{\"name\":\"_appAuthority\",\"type\":\"address\",\"internalType\":\"contractIAppAuthority\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"API_PERMISSION_TYPEHASH\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"acceptOwnership\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"internalType\":\"contractIApp\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"appAuthority\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIAppAuthority\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"appBeacon\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIBeacon\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"calculateApiPermissionDigestHash\",\"inputs\":[{\"name\":\"permission\",\"type\":\"bytes4\",\"internalType\":\"bytes4\"},{\"name\":\"expiry\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"calculateAppId\",\"inputs\":[{\"name\":\"deployer\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"salt\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIApp\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"canCall\",\"inputs\":[{\"name\":\"caller\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"data\",\"type\":\"bytes\",\"internalType\":\"bytes\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"cancelOwnershipTransfer\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"internalType\":\"contractIApp\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"computeAVSRegistrar\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIComputeAVSRegistrar\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"computeOperator\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIComputeOperator\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"confirmUpgrade\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"internalType\":\"contractIApp\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"createApp\",\"inputs\":[{\"name\":\"salt\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"release\",\"type\":\"tuple\",\"internalType\":\"structIAppController.Release\",\"components\":[{\"name\":\"rmsRelease\",\"type\":\"tuple\",\"internalType\":\"structIReleaseManagerTypes.Release\",\"components\":[{\"name\":\"artifacts\",\"type\":\"tuple[]\",\"internalType\":\"structIReleaseManagerTypes.Artifact[]\",\"components\":[{\"name\":\"digest\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"registry\",\"type\":\"string\",\"internalType\":\"string\"}]},{\"name\":\"upgradeByTime\",\"type\":\"uint32\",\"internalType\":\"uint32\"}]},{\"name\":\"publicEnv\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"encryptedEnv\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"containerPolicy\",\"type\":\"tuple\",\"internalType\":\"structIAppController.ContainerPolicy\",\"components\":[{\"name\":\"args\",\"type\":\"string[]\",\"internalType\":\"string[]\"},{\"name\":\"cmdOverride\",\"type\":\"string[]\",\"internalType\":\"string[]\"},{\"name\":\"env\",\"type\":\"tuple[]\",\"internalType\":\"structIAppController.EnvVar[]\",\"components\":[{\"name\":\"key\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"value\",\"type\":\"string\",\"internalType\":\"string\"}]},{\"name\":\"envOverride\",\"type\":\"tuple[]\",\"internalType\":\"structIAppController.EnvVar[]\",\"components\":[{\"name\":\"key\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"value\",\"type\":\"string\",\"internalType\":\"string\"}]},{\"name\":\"restartPolicy\",\"type\":\"string\",\"internalType\":\"string\"}]}]}],\"outputs\":[{\"name\":\"app\",\"type\":\"address\",\"internalType\":\"contractIApp\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"createAppWithIsolatedBilling\",\"inputs\":[{\"name\":\"salt\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"release\",\"type\":\"tuple\",\"internalType\":\"structIAppController.Release\",\"components\":[{\"name\":\"rmsRelease\",\"type\":\"tuple\",\"internalType\":\"structIReleaseManagerTypes.Release\",\"components\":[{\"name\":\"artifacts\",\"type\":\"tuple[]\",\"internalType\":\"structIReleaseManagerTypes.Artifact[]\",\"components\":[{\"name\":\"digest\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"registry\",\"type\":\"string\",\"internalType\":\"string\"}]},{\"name\":\"upgradeByTime\",\"type\":\"uint32\",\"internalType\":\"uint32\"}]},{\"name\":\"publicEnv\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"encryptedEnv\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"containerPolicy\",\"type\":\"tuple\",\"internalType\":\"structIAppController.ContainerPolicy\",\"components\":[{\"name\":\"args\",\"type\":\"string[]\",\"internalType\":\"string[]\"},{\"name\":\"cmdOverride\",\"type\":\"string[]\",\"internalType\":\"string[]\"},{\"name\":\"env\",\"type\":\"tuple[]\",\"internalType\":\"structIAppController.EnvVar[]\",\"components\":[{\"name\":\"key\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"value\",\"type\":\"string\",\"internalType\":\"string\"}]},{\"name\":\"envOverride\",\"type\":\"tuple[]\",\"internalType\":\"structIAppController.EnvVar[]\",\"components\":[{\"name\":\"key\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"value\",\"type\":\"string\",\"internalType\":\"string\"}]},{\"name\":\"restartPolicy\",\"type\":\"string\",\"internalType\":\"string\"}]}]}],\"outputs\":[{\"name\":\"app\",\"type\":\"address\",\"internalType\":\"contractIApp\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"domainSeparator\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getActiveAppCount\",\"inputs\":[{\"name\":\"user\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint32\",\"internalType\":\"uint32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getAppCreator\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"internalType\":\"contractIApp\"}],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getAppLatestReleaseBlockNumber\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"internalType\":\"contractIApp\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint32\",\"internalType\":\"uint32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getAppOperatorSetId\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"internalType\":\"contractIApp\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint32\",\"internalType\":\"uint32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getAppPendingReleaseBlockNumber\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"internalType\":\"contractIApp\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint32\",\"internalType\":\"uint32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getAppStatus\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"internalType\":\"contractIApp\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint8\",\"internalType\":\"enumIAppController.AppStatus\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getApps\",\"inputs\":[{\"name\":\"offset\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"limit\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"apps\",\"type\":\"address[]\",\"internalType\":\"contractIApp[]\"},{\"name\":\"appConfigsMem\",\"type\":\"tuple[]\",\"internalType\":\"structIAppController.AppConfig[]\",\"components\":[{\"name\":\"creator\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"operatorSetId\",\"type\":\"uint32\",\"internalType\":\"uint32\"},{\"name\":\"latestReleaseBlockNumber\",\"type\":\"uint32\",\"internalType\":\"uint32\"},{\"name\":\"pendingReleaseBlockNumber\",\"type\":\"uint32\",\"internalType\":\"uint32\"},{\"name\":\"status\",\"type\":\"uint8\",\"internalType\":\"enumIAppController.AppStatus\"}]}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getAppsByBillingAccount\",\"inputs\":[{\"name\":\"account\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"offset\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"limit\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"apps\",\"type\":\"address[]\",\"internalType\":\"contractIApp[]\"},{\"name\":\"appConfigsMem\",\"type\":\"tuple[]\",\"internalType\":\"structIAppController.AppConfig[]\",\"components\":[{\"name\":\"creator\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"operatorSetId\",\"type\":\"uint32\",\"internalType\":\"uint32\"},{\"name\":\"latestReleaseBlockNumber\",\"type\":\"uint32\",\"internalType\":\"uint32\"},{\"name\":\"pendingReleaseBlockNumber\",\"type\":\"uint32\",\"internalType\":\"uint32\"},{\"name\":\"status\",\"type\":\"uint8\",\"internalType\":\"enumIAppController.AppStatus\"}]}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getAppsByCreator\",\"inputs\":[{\"name\":\"creator\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"offset\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"limit\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"apps\",\"type\":\"address[]\",\"internalType\":\"contractIApp[]\"},{\"name\":\"appConfigsMem\",\"type\":\"tuple[]\",\"internalType\":\"structIAppController.AppConfig[]\",\"components\":[{\"name\":\"creator\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"operatorSetId\",\"type\":\"uint32\",\"internalType\":\"uint32\"},{\"name\":\"latestReleaseBlockNumber\",\"type\":\"uint32\",\"internalType\":\"uint32\"},{\"name\":\"pendingReleaseBlockNumber\",\"type\":\"uint32\",\"internalType\":\"uint32\"},{\"name\":\"status\",\"type\":\"uint8\",\"internalType\":\"enumIAppController.AppStatus\"}]}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getAppsByDeveloper\",\"inputs\":[{\"name\":\"developer\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"offset\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"limit\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"apps\",\"type\":\"address[]\",\"internalType\":\"contractIApp[]\"},{\"name\":\"appConfigsMem\",\"type\":\"tuple[]\",\"internalType\":\"structIAppController.AppConfig[]\",\"components\":[{\"name\":\"creator\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"operatorSetId\",\"type\":\"uint32\",\"internalType\":\"uint32\"},{\"name\":\"latestReleaseBlockNumber\",\"type\":\"uint32\",\"internalType\":\"uint32\"},{\"name\":\"pendingReleaseBlockNumber\",\"type\":\"uint32\",\"internalType\":\"uint32\"},{\"name\":\"status\",\"type\":\"uint8\",\"internalType\":\"enumIAppController.AppStatus\"}]}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getBillingAccount\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"internalType\":\"contractIApp\"}],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getBillingType\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"internalType\":\"contractIApp\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint8\",\"internalType\":\"enumIAppController.BillingType\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getMaxActiveAppsPerUser\",\"inputs\":[{\"name\":\"user\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint32\",\"internalType\":\"uint32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getPendingOwner\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"internalType\":\"contractIApp\"}],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"globalActiveAppCount\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint32\",\"internalType\":\"uint32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"initialize\",\"inputs\":[{\"name\":\"admin\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"maxGlobalActiveApps\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint32\",\"internalType\":\"uint32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"migrateAppsToAppAuthority\",\"inputs\":[{\"name\":\"apps\",\"type\":\"address[]\",\"internalType\":\"contractIApp[]\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"permissionController\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIPermissionController\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"releaseManager\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIReleaseManager\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"setMaxActiveAppsPerUser\",\"inputs\":[{\"name\":\"user\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"limit\",\"type\":\"uint32\",\"internalType\":\"uint32\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"setMaxGlobalActiveApps\",\"inputs\":[{\"name\":\"limit\",\"type\":\"uint32\",\"internalType\":\"uint32\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"startApp\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"internalType\":\"contractIApp\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"stopApp\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"internalType\":\"contractIApp\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"supportsInterface\",\"inputs\":[{\"name\":\"interfaceId\",\"type\":\"bytes4\",\"internalType\":\"bytes4\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"pure\"},{\"type\":\"function\",\"name\":\"suspend\",\"inputs\":[{\"name\":\"account\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"apps\",\"type\":\"address[]\",\"internalType\":\"contractIApp[]\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"terminateApp\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"internalType\":\"contractIApp\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"terminateAppByAdmin\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"internalType\":\"contractIApp\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"transferOwnership\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"internalType\":\"contractIApp\"},{\"name\":\"newOwner\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"updateAppMetadataURI\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"internalType\":\"contractIApp\"},{\"name\":\"metadataURI\",\"type\":\"string\",\"internalType\":\"string\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"upgradeApp\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"internalType\":\"contractIApp\"},{\"name\":\"release\",\"type\":\"tuple\",\"internalType\":\"structIAppController.Release\",\"components\":[{\"name\":\"rmsRelease\",\"type\":\"tuple\",\"internalType\":\"structIReleaseManagerTypes.Release\",\"components\":[{\"name\":\"artifacts\",\"type\":\"tuple[]\",\"internalType\":\"structIReleaseManagerTypes.Artifact[]\",\"components\":[{\"name\":\"digest\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"registry\",\"type\":\"string\",\"internalType\":\"string\"}]},{\"name\":\"upgradeByTime\",\"type\":\"uint32\",\"internalType\":\"uint32\"}]},{\"name\":\"publicEnv\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"encryptedEnv\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"containerPolicy\",\"type\":\"tuple\",\"internalType\":\"structIAppController.ContainerPolicy\",\"components\":[{\"name\":\"args\",\"type\":\"string[]\",\"internalType\":\"string[]\"},{\"name\":\"cmdOverride\",\"type\":\"string[]\",\"internalType\":\"string[]\"},{\"name\":\"env\",\"type\":\"tuple[]\",\"internalType\":\"structIAppController.EnvVar[]\",\"components\":[{\"name\":\"key\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"value\",\"type\":\"string\",\"internalType\":\"string\"}]},{\"name\":\"envOverride\",\"type\":\"tuple[]\",\"internalType\":\"structIAppController.EnvVar[]\",\"components\":[{\"name\":\"key\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"value\",\"type\":\"string\",\"internalType\":\"string\"}]},{\"name\":\"restartPolicy\",\"type\":\"string\",\"internalType\":\"string\"}]}]}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"version\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"string\",\"internalType\":\"string\"}],\"stateMutability\":\"view\"},{\"type\":\"event\",\"name\":\"AppCreated\",\"inputs\":[{\"name\":\"creator\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"app\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"contractIApp\"},{\"name\":\"operatorSetId\",\"type\":\"uint32\",\"indexed\":false,\"internalType\":\"uint32\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"AppMetadataURIUpdated\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"contractIApp\"},{\"name\":\"metadataURI\",\"type\":\"string\",\"indexed\":false,\"internalType\":\"string\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"AppOwnershipTransferred\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"contractIApp\"},{\"name\":\"previousOwner\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"newOwner\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"AppStarted\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"contractIApp\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"AppStopped\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"contractIApp\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"AppSuspended\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"contractIApp\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"AppTerminated\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"contractIApp\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"AppTerminatedByAdmin\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"contractIApp\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"AppUpgraded\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"contractIApp\"},{\"name\":\"rmsReleaseId\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"release\",\"type\":\"tuple\",\"indexed\":false,\"internalType\":\"structIAppController.Release\",\"components\":[{\"name\":\"rmsRelease\",\"type\":\"tuple\",\"internalType\":\"structIReleaseManagerTypes.Release\",\"components\":[{\"name\":\"artifacts\",\"type\":\"tuple[]\",\"internalType\":\"structIReleaseManagerTypes.Artifact[]\",\"components\":[{\"name\":\"digest\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"registry\",\"type\":\"string\",\"internalType\":\"string\"}]},{\"name\":\"upgradeByTime\",\"type\":\"uint32\",\"internalType\":\"uint32\"}]},{\"name\":\"publicEnv\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"encryptedEnv\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"containerPolicy\",\"type\":\"tuple\",\"internalType\":\"structIAppController.ContainerPolicy\",\"components\":[{\"name\":\"args\",\"type\":\"string[]\",\"internalType\":\"string[]\"},{\"name\":\"cmdOverride\",\"type\":\"string[]\",\"internalType\":\"string[]\"},{\"name\":\"env\",\"type\":\"tuple[]\",\"internalType\":\"structIAppController.EnvVar[]\",\"components\":[{\"name\":\"key\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"value\",\"type\":\"string\",\"internalType\":\"string\"}]},{\"name\":\"envOverride\",\"type\":\"tuple[]\",\"internalType\":\"structIAppController.EnvVar[]\",\"components\":[{\"name\":\"key\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"value\",\"type\":\"string\",\"internalType\":\"string\"}]},{\"name\":\"restartPolicy\",\"type\":\"string\",\"internalType\":\"string\"}]}]}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"GlobalMaxActiveAppsSet\",\"inputs\":[{\"name\":\"limit\",\"type\":\"uint32\",\"indexed\":false,\"internalType\":\"uint32\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"Initialized\",\"inputs\":[{\"name\":\"version\",\"type\":\"uint8\",\"indexed\":false,\"internalType\":\"uint8\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"MaxActiveAppsSet\",\"inputs\":[{\"name\":\"user\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"limit\",\"type\":\"uint32\",\"indexed\":false,\"internalType\":\"uint32\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"OwnershipTransferCancelled\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"contractIApp\"},{\"name\":\"currentOwner\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"cancelledOwner\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"OwnershipTransferProposed\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"contractIApp\"},{\"name\":\"currentOwner\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"proposedOwner\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"UpgradeConfirmed\",\"inputs\":[{\"name\":\"app\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"contractIApp\"},{\"name\":\"pendingReleaseBlockNumber\",\"type\":\"uint32\",\"indexed\":false,\"internalType\":\"uint32\"}],\"anonymous\":false},{\"type\":\"error\",\"name\":\"AccountHasActiveApps\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"AppAlreadyExists\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"AppDoesNotExist\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"GlobalMaxActiveAppsExceeded\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidAppStatus\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidPermissions\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidReleaseMetadataURI\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidShortString\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidSignature\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidTeamRole\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"MaxActiveAppsExceeded\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"MoreThanOneArtifact\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"NoPendingUpgrade\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"NotCreator\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"NotPendingOwner\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"SignatureExpired\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"StringTooLong\",\"inputs\":[{\"name\":\"str\",\"type\":\"string\",\"internalType\":\"string\"}]}]", ID: "AppController", } @@ -97,9 +97,9 @@ func (c *AppController) Instance(backend bind.ContractBackend, addr common.Addre // PackConstructor is the Go binding used to pack the parameters required for // contract deployment. // -// Solidity: constructor(string _version, address _permissionController, address _releaseManager, address _computeAVSRegistrar, address _computeOperator, address _appBeacon) returns() -func (appController *AppController) PackConstructor(_version string, _permissionController common.Address, _releaseManager common.Address, _computeAVSRegistrar common.Address, _computeOperator common.Address, _appBeacon common.Address) []byte { - enc, err := appController.abi.Pack("", _version, _permissionController, _releaseManager, _computeAVSRegistrar, _computeOperator, _appBeacon) +// Solidity: constructor(string _version, address _permissionController, address _releaseManager, address _computeAVSRegistrar, address _computeOperator, address _appBeacon, address _appAuthority) returns() +func (appController *AppController) PackConstructor(_version string, _permissionController common.Address, _releaseManager common.Address, _computeAVSRegistrar common.Address, _computeOperator common.Address, _appBeacon common.Address, _appAuthority common.Address) []byte { + enc, err := appController.abi.Pack("", _version, _permissionController, _releaseManager, _computeAVSRegistrar, _computeOperator, _appBeacon, _appAuthority) if err != nil { panic(err) } @@ -141,6 +141,63 @@ func (appController *AppController) UnpackAPIPERMISSIONTYPEHASH(data []byte) ([3 return out0, nil } +// PackAcceptOwnership is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x51710e45. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function acceptOwnership(address app) returns() +func (appController *AppController) PackAcceptOwnership(app common.Address) []byte { + enc, err := appController.abi.Pack("acceptOwnership", app) + if err != nil { + panic(err) + } + return enc +} + +// TryPackAcceptOwnership is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x51710e45. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function acceptOwnership(address app) returns() +func (appController *AppController) TryPackAcceptOwnership(app common.Address) ([]byte, error) { + return appController.abi.Pack("acceptOwnership", app) +} + +// PackAppAuthority is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x8029bbca. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function appAuthority() view returns(address) +func (appController *AppController) PackAppAuthority() []byte { + enc, err := appController.abi.Pack("appAuthority") + if err != nil { + panic(err) + } + return enc +} + +// TryPackAppAuthority is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x8029bbca. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function appAuthority() view returns(address) +func (appController *AppController) TryPackAppAuthority() ([]byte, error) { + return appController.abi.Pack("appAuthority") +} + +// UnpackAppAuthority is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0x8029bbca. +// +// Solidity: function appAuthority() view returns(address) +func (appController *AppController) UnpackAppAuthority(data []byte) (common.Address, error) { + out, err := appController.abi.Unpack("appAuthority", data) + if err != nil { + return *new(common.Address), err + } + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + return out0, nil +} + // PackAppBeacon is the Go binding used to pack the parameters required for calling // the contract method with ID 0x8a52d0b5. This method will panic if any // invalid/nil inputs are passed. @@ -246,6 +303,63 @@ func (appController *AppController) UnpackCalculateAppId(data []byte) (common.Ad return out0, nil } +// PackCanCall is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x9614801b. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function canCall(address caller, bytes data) view returns(bool) +func (appController *AppController) PackCanCall(caller common.Address, data []byte) []byte { + enc, err := appController.abi.Pack("canCall", caller, data) + if err != nil { + panic(err) + } + return enc +} + +// TryPackCanCall is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x9614801b. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function canCall(address caller, bytes data) view returns(bool) +func (appController *AppController) TryPackCanCall(caller common.Address, data []byte) ([]byte, error) { + return appController.abi.Pack("canCall", caller, data) +} + +// UnpackCanCall is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0x9614801b. +// +// Solidity: function canCall(address caller, bytes data) view returns(bool) +func (appController *AppController) UnpackCanCall(data []byte) (bool, error) { + out, err := appController.abi.Unpack("canCall", data) + if err != nil { + return *new(bool), err + } + out0 := *abi.ConvertType(out[0], new(bool)).(*bool) + return out0, nil +} + +// PackCancelOwnershipTransfer is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x7b37e561. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function cancelOwnershipTransfer(address app) returns() +func (appController *AppController) PackCancelOwnershipTransfer(app common.Address) []byte { + enc, err := appController.abi.Pack("cancelOwnershipTransfer", app) + if err != nil { + panic(err) + } + return enc +} + +// TryPackCancelOwnershipTransfer is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x7b37e561. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function cancelOwnershipTransfer(address app) returns() +func (appController *AppController) TryPackCancelOwnershipTransfer(app common.Address) ([]byte, error) { + return appController.abi.Pack("cancelOwnershipTransfer", app) +} + // PackComputeAVSRegistrar is the Go binding used to pack the parameters required for calling // the contract method with ID 0xef6d92c6. This method will panic if any // invalid/nil inputs are passed. @@ -934,6 +1048,41 @@ func (appController *AppController) UnpackGetMaxActiveAppsPerUser(data []byte) ( return out0, nil } +// PackGetPendingOwner is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x66f6a5ed. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function getPendingOwner(address app) view returns(address) +func (appController *AppController) PackGetPendingOwner(app common.Address) []byte { + enc, err := appController.abi.Pack("getPendingOwner", app) + if err != nil { + panic(err) + } + return enc +} + +// TryPackGetPendingOwner is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x66f6a5ed. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function getPendingOwner(address app) view returns(address) +func (appController *AppController) TryPackGetPendingOwner(app common.Address) ([]byte, error) { + return appController.abi.Pack("getPendingOwner", app) +} + +// UnpackGetPendingOwner is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0x66f6a5ed. +// +// Solidity: function getPendingOwner(address app) view returns(address) +func (appController *AppController) UnpackGetPendingOwner(data []byte) (common.Address, error) { + out, err := appController.abi.Unpack("getPendingOwner", data) + if err != nil { + return *new(common.Address), err + } + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + return out0, nil +} + // PackGlobalActiveAppCount is the Go binding used to pack the parameters required for calling // the contract method with ID 0xa8aa2bd3. This method will panic if any // invalid/nil inputs are passed. @@ -1026,6 +1175,28 @@ func (appController *AppController) UnpackMaxGlobalActiveApps(data []byte) (uint return out0, nil } +// PackMigrateAppsToAppAuthority is the Go binding used to pack the parameters required for calling +// the contract method with ID 0xa6a062cd. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function migrateAppsToAppAuthority(address[] apps) returns() +func (appController *AppController) PackMigrateAppsToAppAuthority(apps []common.Address) []byte { + enc, err := appController.abi.Pack("migrateAppsToAppAuthority", apps) + if err != nil { + panic(err) + } + return enc +} + +// TryPackMigrateAppsToAppAuthority is the Go binding used to pack the parameters required for calling +// the contract method with ID 0xa6a062cd. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function migrateAppsToAppAuthority(address[] apps) returns() +func (appController *AppController) TryPackMigrateAppsToAppAuthority(apps []common.Address) ([]byte, error) { + return appController.abi.Pack("migrateAppsToAppAuthority", apps) +} + // PackPermissionController is the Go binding used to pack the parameters required for calling // the contract method with ID 0x4657e26a. This method will panic if any // invalid/nil inputs are passed. @@ -1184,6 +1355,41 @@ func (appController *AppController) TryPackStopApp(app common.Address) ([]byte, return appController.abi.Pack("stopApp", app) } +// PackSupportsInterface is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x01ffc9a7. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function supportsInterface(bytes4 interfaceId) pure returns(bool) +func (appController *AppController) PackSupportsInterface(interfaceId [4]byte) []byte { + enc, err := appController.abi.Pack("supportsInterface", interfaceId) + if err != nil { + panic(err) + } + return enc +} + +// TryPackSupportsInterface is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x01ffc9a7. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function supportsInterface(bytes4 interfaceId) pure returns(bool) +func (appController *AppController) TryPackSupportsInterface(interfaceId [4]byte) ([]byte, error) { + return appController.abi.Pack("supportsInterface", interfaceId) +} + +// UnpackSupportsInterface is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0x01ffc9a7. +// +// Solidity: function supportsInterface(bytes4 interfaceId) pure returns(bool) +func (appController *AppController) UnpackSupportsInterface(data []byte) (bool, error) { + out, err := appController.abi.Unpack("supportsInterface", data) + if err != nil { + return *new(bool), err + } + out0 := *abi.ConvertType(out[0], new(bool)).(*bool) + return out0, nil +} + // PackSuspend is the Go binding used to pack the parameters required for calling // the contract method with ID 0xcb1e6ff7. This method will panic if any // invalid/nil inputs are passed. @@ -1250,6 +1456,28 @@ func (appController *AppController) TryPackTerminateAppByAdmin(app common.Addres return appController.abi.Pack("terminateAppByAdmin", app) } +// PackTransferOwnership is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x6d435421. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function transferOwnership(address app, address newOwner) returns() +func (appController *AppController) PackTransferOwnership(app common.Address, newOwner common.Address) []byte { + enc, err := appController.abi.Pack("transferOwnership", app, newOwner) + if err != nil { + panic(err) + } + return enc +} + +// TryPackTransferOwnership is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x6d435421. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function transferOwnership(address app, address newOwner) returns() +func (appController *AppController) TryPackTransferOwnership(app common.Address, newOwner common.Address) ([]byte, error) { + return appController.abi.Pack("transferOwnership", app, newOwner) +} + // PackUpdateAppMetadataURI is the Go binding used to pack the parameters required for calling // the contract method with ID 0x65aa9a65. This method will panic if any // invalid/nil inputs are passed. @@ -1427,6 +1655,49 @@ func (appController *AppController) UnpackAppMetadataURIUpdatedEvent(log *types. return out, nil } +// AppControllerAppOwnershipTransferred represents a AppOwnershipTransferred event raised by the AppController contract. +type AppControllerAppOwnershipTransferred struct { + App common.Address + PreviousOwner common.Address + NewOwner common.Address + Raw *types.Log // Blockchain specific contextual infos +} + +const AppControllerAppOwnershipTransferredEventName = "AppOwnershipTransferred" + +// ContractEventName returns the user-defined event name. +func (AppControllerAppOwnershipTransferred) ContractEventName() string { + return AppControllerAppOwnershipTransferredEventName +} + +// UnpackAppOwnershipTransferredEvent is the Go binding that unpacks the event data emitted +// by contract. +// +// Solidity: event AppOwnershipTransferred(address indexed app, address indexed previousOwner, address indexed newOwner) +func (appController *AppController) UnpackAppOwnershipTransferredEvent(log *types.Log) (*AppControllerAppOwnershipTransferred, error) { + event := "AppOwnershipTransferred" + if len(log.Topics) == 0 || log.Topics[0] != appController.abi.Events[event].ID { + return nil, errors.New("event signature mismatch") + } + out := new(AppControllerAppOwnershipTransferred) + if len(log.Data) > 0 { + if err := appController.abi.UnpackIntoInterface(out, event, log.Data); err != nil { + return nil, err + } + } + var indexed abi.Arguments + for _, arg := range appController.abi.Events[event].Inputs { + if arg.Indexed { + indexed = append(indexed, arg) + } + } + if err := abi.ParseTopics(out, indexed, log.Topics[1:]); err != nil { + return nil, err + } + out.Raw = log + return out, nil +} + // AppControllerAppStarted represents a AppStarted event raised by the AppController contract. type AppControllerAppStarted struct { App common.Address @@ -1799,6 +2070,92 @@ func (appController *AppController) UnpackMaxActiveAppsSetEvent(log *types.Log) return out, nil } +// AppControllerOwnershipTransferCancelled represents a OwnershipTransferCancelled event raised by the AppController contract. +type AppControllerOwnershipTransferCancelled struct { + App common.Address + CurrentOwner common.Address + CancelledOwner common.Address + Raw *types.Log // Blockchain specific contextual infos +} + +const AppControllerOwnershipTransferCancelledEventName = "OwnershipTransferCancelled" + +// ContractEventName returns the user-defined event name. +func (AppControllerOwnershipTransferCancelled) ContractEventName() string { + return AppControllerOwnershipTransferCancelledEventName +} + +// UnpackOwnershipTransferCancelledEvent is the Go binding that unpacks the event data emitted +// by contract. +// +// Solidity: event OwnershipTransferCancelled(address indexed app, address indexed currentOwner, address indexed cancelledOwner) +func (appController *AppController) UnpackOwnershipTransferCancelledEvent(log *types.Log) (*AppControllerOwnershipTransferCancelled, error) { + event := "OwnershipTransferCancelled" + if len(log.Topics) == 0 || log.Topics[0] != appController.abi.Events[event].ID { + return nil, errors.New("event signature mismatch") + } + out := new(AppControllerOwnershipTransferCancelled) + if len(log.Data) > 0 { + if err := appController.abi.UnpackIntoInterface(out, event, log.Data); err != nil { + return nil, err + } + } + var indexed abi.Arguments + for _, arg := range appController.abi.Events[event].Inputs { + if arg.Indexed { + indexed = append(indexed, arg) + } + } + if err := abi.ParseTopics(out, indexed, log.Topics[1:]); err != nil { + return nil, err + } + out.Raw = log + return out, nil +} + +// AppControllerOwnershipTransferProposed represents a OwnershipTransferProposed event raised by the AppController contract. +type AppControllerOwnershipTransferProposed struct { + App common.Address + CurrentOwner common.Address + ProposedOwner common.Address + Raw *types.Log // Blockchain specific contextual infos +} + +const AppControllerOwnershipTransferProposedEventName = "OwnershipTransferProposed" + +// ContractEventName returns the user-defined event name. +func (AppControllerOwnershipTransferProposed) ContractEventName() string { + return AppControllerOwnershipTransferProposedEventName +} + +// UnpackOwnershipTransferProposedEvent is the Go binding that unpacks the event data emitted +// by contract. +// +// Solidity: event OwnershipTransferProposed(address indexed app, address indexed currentOwner, address indexed proposedOwner) +func (appController *AppController) UnpackOwnershipTransferProposedEvent(log *types.Log) (*AppControllerOwnershipTransferProposed, error) { + event := "OwnershipTransferProposed" + if len(log.Topics) == 0 || log.Topics[0] != appController.abi.Events[event].ID { + return nil, errors.New("event signature mismatch") + } + out := new(AppControllerOwnershipTransferProposed) + if len(log.Data) > 0 { + if err := appController.abi.UnpackIntoInterface(out, event, log.Data); err != nil { + return nil, err + } + } + var indexed abi.Arguments + for _, arg := range appController.abi.Events[event].Inputs { + if arg.Indexed { + indexed = append(indexed, arg) + } + } + if err := abi.ParseTopics(out, indexed, log.Topics[1:]); err != nil { + return nil, err + } + out.Raw = log + return out, nil +} + // AppControllerUpgradeConfirmed represents a UpgradeConfirmed event raised by the AppController contract. type AppControllerUpgradeConfirmed struct { App common.Address @@ -1871,6 +2228,9 @@ func (appController *AppController) UnpackError(raw []byte) (any, error) { if bytes.Equal(raw[:4], appController.abi.Errors["InvalidSignature"].ID.Bytes()[:4]) { return appController.UnpackInvalidSignatureError(raw[4:]) } + if bytes.Equal(raw[:4], appController.abi.Errors["InvalidTeamRole"].ID.Bytes()[:4]) { + return appController.UnpackInvalidTeamRoleError(raw[4:]) + } if bytes.Equal(raw[:4], appController.abi.Errors["MaxActiveAppsExceeded"].ID.Bytes()[:4]) { return appController.UnpackMaxActiveAppsExceededError(raw[4:]) } @@ -1880,6 +2240,12 @@ func (appController *AppController) UnpackError(raw []byte) (any, error) { if bytes.Equal(raw[:4], appController.abi.Errors["NoPendingUpgrade"].ID.Bytes()[:4]) { return appController.UnpackNoPendingUpgradeError(raw[4:]) } + if bytes.Equal(raw[:4], appController.abi.Errors["NotCreator"].ID.Bytes()[:4]) { + return appController.UnpackNotCreatorError(raw[4:]) + } + if bytes.Equal(raw[:4], appController.abi.Errors["NotPendingOwner"].ID.Bytes()[:4]) { + return appController.UnpackNotPendingOwnerError(raw[4:]) + } if bytes.Equal(raw[:4], appController.abi.Errors["SignatureExpired"].ID.Bytes()[:4]) { return appController.UnpackSignatureExpiredError(raw[4:]) } @@ -2096,6 +2462,29 @@ func (appController *AppController) UnpackInvalidSignatureError(raw []byte) (*Ap return out, nil } +// AppControllerInvalidTeamRole represents a InvalidTeamRole error raised by the AppController contract. +type AppControllerInvalidTeamRole struct { +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error InvalidTeamRole() +func AppControllerInvalidTeamRoleErrorID() common.Hash { + return common.HexToHash("0x8a008e174a035e142dfc4a2bb0f04b943d719f4126df368adee849db4d07a5ac") +} + +// UnpackInvalidTeamRoleError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error InvalidTeamRole() +func (appController *AppController) UnpackInvalidTeamRoleError(raw []byte) (*AppControllerInvalidTeamRole, error) { + out := new(AppControllerInvalidTeamRole) + if err := appController.abi.UnpackIntoInterface(out, "InvalidTeamRole", raw); err != nil { + return nil, err + } + return out, nil +} + // AppControllerMaxActiveAppsExceeded represents a MaxActiveAppsExceeded error raised by the AppController contract. type AppControllerMaxActiveAppsExceeded struct { } @@ -2165,6 +2554,52 @@ func (appController *AppController) UnpackNoPendingUpgradeError(raw []byte) (*Ap return out, nil } +// AppControllerNotCreator represents a NotCreator error raised by the AppController contract. +type AppControllerNotCreator struct { +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error NotCreator() +func AppControllerNotCreatorErrorID() common.Hash { + return common.HexToHash("0x93687c0be1f4d8f5b3c5ab55b81e360246e6c249b742b9b16b45d7e97dfe5645") +} + +// UnpackNotCreatorError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error NotCreator() +func (appController *AppController) UnpackNotCreatorError(raw []byte) (*AppControllerNotCreator, error) { + out := new(AppControllerNotCreator) + if err := appController.abi.UnpackIntoInterface(out, "NotCreator", raw); err != nil { + return nil, err + } + return out, nil +} + +// AppControllerNotPendingOwner represents a NotPendingOwner error raised by the AppController contract. +type AppControllerNotPendingOwner struct { +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error NotPendingOwner() +func AppControllerNotPendingOwnerErrorID() common.Hash { + return common.HexToHash("0x1853971cac2844d8fedc34d0d65cff78483e8a606ad8448df68a3af766d8a29d") +} + +// UnpackNotPendingOwnerError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error NotPendingOwner() +func (appController *AppController) UnpackNotPendingOwnerError(raw []byte) (*AppControllerNotPendingOwner, error) { + out := new(AppControllerNotPendingOwner) + if err := appController.abi.UnpackIntoInterface(out, "NotPendingOwner", raw); err != nil { + return nil, err + } + return out, nil +} + // AppControllerSignatureExpired represents a SignatureExpired error raised by the AppController contract. type AppControllerSignatureExpired struct { }