浏览代码

feat(pulse): add governance instructions, admin transfer capability (#2608)

* feat(pulse): add governance instructions, admin transfer

* fix: small improvements

* feat: make updateSubscription payable
Tejas Badadare 7 月之前
父节点
当前提交
e2caed936f

+ 4 - 3
target_chains/ethereum/contracts/contracts/pulse/IScheduler.sol

@@ -36,15 +36,16 @@ interface IScheduler is SchedulerEvents {
 
     /**
      * @notice Updates an existing subscription
-     * @dev You can activate or deactivate a subscription by setting isActive to true or false.
-     * @dev Reactivating a subscription requires the subscription to hold at least the minimum balance (calculated by getMinimumBalance()).
+     * @dev You can activate or deactivate a subscription by setting isActive to true or false. Reactivating a subscription
+     *      requires the subscription to hold at least the minimum balance (calculated by getMinimumBalance()).
+     * @dev Any Ether sent with this call (`msg.value`) will be added to the subscription's balance before processing the update.
      * @param subscriptionId The ID of the subscription to update
      * @param newSubscriptionParams The new parameters for the subscription
      */
     function updateSubscription(
         uint256 subscriptionId,
         SchedulerState.SubscriptionParams calldata newSubscriptionParams
-    ) external;
+    ) external payable;
 
     /**
      * @notice Updates price feeds for a subscription.

+ 2 - 2
target_chains/ethereum/contracts/contracts/pulse/README.md

@@ -1,4 +1,4 @@
-# Pyth Pulse Scheduler Contract
+# Pyth Pulse Contract
 
 Pyth Pulse is a service that regularly pushes Pyth price updates to on-chain contracts based on configurable conditions. It ensures that on-chain prices remain up-to-date without requiring users to manually update prices or run any infrastructure themselves. This is helpful for users who prefer to consume from a push-style feed rather than integrate the pull model, where users post the price update on-chain immediately before using it.
 
@@ -26,7 +26,7 @@ Pyth Pulse ensures that on-chain Pyth prices remain up-to-date according to user
 
 ### Components
 
-1.  **Scheduler Contract (This Contract):** Deployed on the target EVM blockchain, this contract manages the state of the subscription metadata and price feeds.
+1.  **Pulse Contract (This Contract):** Deployed on the target EVM blockchain, this contract manages the state of the subscription metadata and price feeds.
     - Manages user **subscriptions**, storing metadata like the set of desired Pyth price feed IDs, update trigger conditions (time-based heartbeat and/or price deviation percentage), and optional reader whitelists.
     - Receives price updates pushed by providers. Verifies the price updates using the core Pyth protocol contract (`IPyth`).
     - Stores the latest verified price updates for each feed within a subscription.

+ 40 - 13
target_chains/ethereum/contracts/contracts/pulse/Scheduler.sol

@@ -12,12 +12,20 @@ import "./SchedulerState.sol";
 import "./SchedulerErrors.sol";
 
 abstract contract Scheduler is IScheduler, SchedulerState {
-    function _initialize(address admin, address pythAddress) internal {
+    function _initialize(
+        address admin,
+        address pythAddress,
+        uint128 minimumBalancePerFeed,
+        uint128 singleUpdateKeeperFeeInWei
+    ) internal {
         require(admin != address(0), "admin is zero address");
         require(pythAddress != address(0), "pyth is zero address");
 
         _state.pyth = pythAddress;
+        _state.admin = admin;
         _state.subscriptionNumber = 1;
+        _state.minimumBalancePerFeed = minimumBalancePerFeed;
+        _state.singleUpdateKeeperFeeInWei = singleUpdateKeeperFeeInWei;
     }
 
     function createSubscription(
@@ -65,7 +73,7 @@ abstract contract Scheduler is IScheduler, SchedulerState {
     function updateSubscription(
         uint256 subscriptionId,
         SubscriptionParams memory newParams
-    ) external override onlyManager(subscriptionId) {
+    ) external payable override onlyManager(subscriptionId) {
         SubscriptionStatus storage currentStatus = _state.subscriptionStatuses[
             subscriptionId
         ];
@@ -73,6 +81,9 @@ abstract contract Scheduler is IScheduler, SchedulerState {
             subscriptionId
         ];
 
+        // Add incoming funds to balance
+        currentStatus.balanceInWei += msg.value;
+
         // Updates to permanent subscriptions are not allowed
         if (currentParams.isPermanent) {
             revert CannotUpdatePermanentSubscription();
@@ -91,6 +102,19 @@ abstract contract Scheduler is IScheduler, SchedulerState {
         // Validate the new parameters, including setting default gas config
         _validateAndPrepareSubscriptionParams(newParams);
 
+        // Check minimum balance if number of feeds increases and subscription remains active
+        if (
+            willBeActive &&
+            newParams.priceIds.length > currentParams.priceIds.length
+        ) {
+            uint256 minimumBalance = this.getMinimumBalance(
+                uint8(newParams.priceIds.length)
+            );
+            if (currentStatus.balanceInWei < minimumBalance) {
+                revert InsufficientBalance();
+            }
+        }
+
         // Handle activation/deactivation
         if (!wasActive && willBeActive) {
             // Reactivating a subscription - ensure minimum balance
@@ -258,18 +282,22 @@ abstract contract Scheduler is IScheduler, SchedulerState {
             revert InsufficientBalance();
         }
 
-        // Parse price feed updates with an expected timestamp range of [-10s, now]
-        // We will validate the trigger conditions and timestamps ourselves
+        // Parse the price feed updates with an acceptable timestamp range of [-1h, +10s] from now.
+        // We will validate the trigger conditions ourselves.
         uint64 curTime = SafeCast.toUint64(block.timestamp);
         uint64 maxPublishTime = curTime + FUTURE_TIMESTAMP_MAX_VALIDITY_PERIOD;
         uint64 minPublishTime = curTime > PAST_TIMESTAMP_MAX_VALIDITY_PERIOD
             ? curTime - PAST_TIMESTAMP_MAX_VALIDITY_PERIOD
             : 0;
-        PythStructs.PriceFeed[] memory priceFeeds;
-        uint64[] memory slots;
-        (priceFeeds, slots) = pyth.parsePriceFeedUpdatesWithSlots{
-            value: pythFee
-        }(updateData, priceIds, minPublishTime, maxPublishTime);
+        (
+            PythStructs.PriceFeed[] memory priceFeeds,
+            uint64[] memory slots
+        ) = pyth.parsePriceFeedUpdatesWithSlots{value: pythFee}(
+                updateData,
+                priceIds,
+                minPublishTime,
+                maxPublishTime
+            );
 
         // Verify all price feeds have the same Pythnet slot.
         // All feeds in a subscription must be updated at the same time.
@@ -622,10 +650,9 @@ abstract contract Scheduler is IScheduler, SchedulerState {
      */
     function getMinimumBalance(
         uint8 numPriceFeeds
-    ) external pure override returns (uint256 minimumBalanceInWei) {
-        // Placeholder implementation
-        // TODO: make this governable
-        return uint256(numPriceFeeds) * 0.01 ether;
+    ) external view override returns (uint256 minimumBalanceInWei) {
+        // TODO: Consider adding a base minimum balance independent of feed count
+        return uint256(numPriceFeeds) * this.getMinimumBalancePerFeed();
     }
 
     // ACCESS CONTROL MODIFIERS

+ 89 - 0
target_chains/ethereum/contracts/contracts/pulse/SchedulerGovernance.sol

@@ -0,0 +1,89 @@
+// SPDX-License-Identifier: Apache 2
+pragma solidity ^0.8.0;
+
+import "./SchedulerState.sol";
+import "./SchedulerErrors.sol";
+
+/**
+ * @dev `SchedulerGovernance` defines governance capabilities for the Pulse contract.
+ */
+abstract contract SchedulerGovernance is SchedulerState {
+    event NewAdminProposed(address oldAdmin, address newAdmin);
+    event NewAdminAccepted(address oldAdmin, address newAdmin);
+    event SingleUpdateKeeperFeeSet(uint oldFee, uint newFee);
+    event MinimumBalancePerFeedSet(uint oldBalance, uint newBalance);
+
+    /**
+     * @dev Returns the address of the proposed admin.
+     */
+    function proposedAdmin() public view virtual returns (address) {
+        return _state.proposedAdmin;
+    }
+
+    /**
+     * @dev Returns the address of the current admin.
+     */
+    function getAdmin() external view returns (address) {
+        return _state.admin;
+    }
+
+    /**
+     * @dev Proposes a new admin for the contract. Replaces the proposed admin if there is one.
+     * Can only be called by either admin or owner.
+     */
+    function proposeAdmin(address newAdmin) public virtual {
+        require(newAdmin != address(0), "newAdmin is zero address");
+
+        _authorizeAdminAction();
+
+        _state.proposedAdmin = newAdmin;
+        emit NewAdminProposed(_state.admin, newAdmin);
+    }
+
+    /**
+     * @dev The proposed admin accepts the admin transfer.
+     */
+    function acceptAdmin() external {
+        if (msg.sender != _state.proposedAdmin) revert Unauthorized();
+
+        address oldAdmin = _state.admin;
+        _state.admin = msg.sender;
+
+        _state.proposedAdmin = address(0);
+        emit NewAdminAccepted(oldAdmin, msg.sender);
+    }
+
+    /**
+     * @dev Authorization check for admin actions
+     * Must be implemented by the inheriting contract.
+     */
+    function _authorizeAdminAction() internal virtual;
+
+    /**
+     * @dev Set the keeper fee for single updates in Wei.
+     * Calls {_authorizeAdminAction}.
+     * Emits a {SingleUpdateKeeperFeeSet} event.
+     */
+    function setSingleUpdateKeeperFeeInWei(uint128 newFee) external {
+        _authorizeAdminAction();
+
+        uint oldFee = _state.singleUpdateKeeperFeeInWei;
+        _state.singleUpdateKeeperFeeInWei = newFee;
+
+        emit SingleUpdateKeeperFeeSet(oldFee, newFee);
+    }
+
+    /**
+     * @dev Set the minimum balance required per feed in a subscription.
+     * Calls {_authorizeAdminAction}.
+     * Emits a {MinimumBalancePerFeedSet} event.
+     */
+    function setMinimumBalancePerFeed(uint128 newMinimumBalance) external {
+        _authorizeAdminAction();
+
+        uint oldBalance = _state.minimumBalancePerFeed;
+        _state.minimumBalancePerFeed = newMinimumBalance;
+
+        emit MinimumBalancePerFeedSet(oldBalance, newMinimumBalance);
+    }
+}

+ 23 - 0
target_chains/ethereum/contracts/contracts/pulse/SchedulerState.sol

@@ -23,6 +23,15 @@ contract SchedulerState {
         uint256 subscriptionNumber;
         /// Pyth contract for parsing updates and verifying sigs & timestamps
         address pyth;
+        /// Admin address for governance actions
+        address admin;
+        // proposedAdmin is the new admin's account address proposed by either the owner or the current admin.
+        // If there is no pending transfer request, this value will hold `address(0)`.
+        address proposedAdmin;
+        /// Fee in wei charged to subscribers per single update triggered by a keeper
+        uint128 singleUpdateKeeperFeeInWei;
+        /// Minimum balance required per price feed in a subscription
+        uint128 minimumBalancePerFeed;
         /// Sub ID -> subscription parameters (which price feeds, when to update, etc)
         mapping(uint256 => SubscriptionParams) subscriptionParams;
         /// Sub ID -> subscription status (metadata about their sub)
@@ -62,4 +71,18 @@ contract SchedulerState {
         bool updateOnDeviation;
         uint32 deviationThresholdBps;
     }
+
+    /**
+     * @dev Returns the minimum balance required per feed in a subscription.
+     */
+    function getMinimumBalancePerFeed() external view returns (uint128) {
+        return _state.minimumBalancePerFeed;
+    }
+
+    /**
+     * @dev Returns the fee in wei charged to subscribers per single update triggered by a keeper.
+     */
+    function getSingleUpdateKeeperFeeInWei() external view returns (uint128) {
+        return _state.singleUpdateKeeperFeeInWei;
+    }
 }

+ 37 - 4
target_chains/ethereum/contracts/contracts/pulse/SchedulerUpgradeable.sol

@@ -6,12 +6,14 @@ import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
 import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
 import "@openzeppelin/contracts-upgradeable/access/Ownable2StepUpgradeable.sol";
 import "./Scheduler.sol";
-
+import "./SchedulerGovernance.sol";
+import "./SchedulerErrors.sol";
 contract SchedulerUpgradeable is
     Initializable,
     Ownable2StepUpgradeable,
     UUPSUpgradeable,
-    Scheduler
+    Scheduler,
+    SchedulerGovernance
 {
     event ContractUpgraded(
         address oldImplementation,
@@ -21,15 +23,23 @@ contract SchedulerUpgradeable is
     function initialize(
         address owner,
         address admin,
-        address pythAddress
+        address pythAddress,
+        uint128 minimumBalancePerFeed,
+        uint128 singleUpdateKeeperFeeInWei
     ) external initializer {
         require(owner != address(0), "owner is zero address");
         require(admin != address(0), "admin is zero address");
+        require(pythAddress != address(0), "pyth is zero address");
 
         __Ownable_init();
         __UUPSUpgradeable_init();
 
-        Scheduler._initialize(admin, pythAddress);
+        Scheduler._initialize(
+            admin,
+            pythAddress,
+            minimumBalancePerFeed,
+            singleUpdateKeeperFeeInWei
+        );
 
         _transferOwnership(owner);
     }
@@ -37,13 +47,22 @@ contract SchedulerUpgradeable is
     /// @custom:oz-upgrades-unsafe-allow constructor
     constructor() initializer {}
 
+    /// Only the owner can upgrade the contract
     function _authorizeUpgrade(address) internal override onlyOwner {}
 
+    /// Authorize actions that both admin and owner can perform
+    function _authorizeAdminAction() internal view override {
+        if (msg.sender != owner() && msg.sender != _state.admin)
+            revert Unauthorized();
+    }
+
     function upgradeTo(address newImplementation) external override onlyProxy {
         address oldImplementation = _getImplementation();
         _authorizeUpgrade(newImplementation);
         _upgradeToAndCallUUPS(newImplementation, new bytes(0), false);
 
+        magicCheck();
+
         emit ContractUpgraded(oldImplementation, _getImplementation());
     }
 
@@ -55,9 +74,23 @@ contract SchedulerUpgradeable is
         _authorizeUpgrade(newImplementation);
         _upgradeToAndCallUUPS(newImplementation, data, true);
 
+        magicCheck();
+
         emit ContractUpgraded(oldImplementation, _getImplementation());
     }
 
+    /// Sanity check to ensure we are upgrading the proxy to a compatible contract.
+    function magicCheck() internal view {
+        // Calling a method using `this.<method>` will cause a contract call that will use
+        // the new contract. This call will fail if the method does not exists or the magic is different.
+        if (this.schedulerUpgradableMagic() != 0x50554C53)
+            revert("Invalid upgrade magic");
+    }
+
+    function schedulerUpgradableMagic() public pure virtual returns (uint32) {
+        return 0x50554C53; // "PULS" ASCII in hex
+    }
+
     function version() public pure returns (string memory) {
         return "1.0.0";
     }

+ 99 - 1
target_chains/ethereum/contracts/forge-test/PulseScheduler.t.sol

@@ -75,11 +75,14 @@ contract SchedulerTest is Test, SchedulerEvents, PulseSchedulerTestUtils {
         pyth = address(3);
         pusher = address(4);
 
+        uint128 minBalancePerFeed = 10 ** 16; // 0.01 ether
+        uint128 keeperFee = 10 ** 15; // 0.001 ether
+
         SchedulerUpgradeable _scheduler = new SchedulerUpgradeable();
         proxy = new ERC1967Proxy(address(_scheduler), "");
         scheduler = SchedulerUpgradeable(address(proxy));
 
-        scheduler.initialize(owner, admin, pyth);
+        scheduler.initialize(owner, admin, pyth, minBalancePerFeed, keeperFee);
 
         reader = new MockReader(address(proxy));
 
@@ -181,6 +184,11 @@ contract SchedulerTest is Test, SchedulerEvents, PulseSchedulerTestUtils {
                 updateCriteria: newUpdateCriteria
             });
 
+        // Add the required funds to cover the new minimum balance
+        scheduler.addFunds{
+            value: scheduler.getMinimumBalance(uint8(newPriceIds.length))
+        }(subscriptionId);
+
         // Update subscription
         vm.expectEmit();
         emit SubscriptionUpdated(subscriptionId);
@@ -1092,6 +1100,96 @@ contract SchedulerTest is Test, SchedulerEvents, PulseSchedulerTestUtils {
         scheduler.updatePriceFeeds(subscriptionId, updateData, priceIds);
     }
 
+    function testUpdateSubscriptionEnforcesMinimumBalanceOnAddingFeeds()
+        public
+    {
+        // Setup: Create subscription with 2 feeds, funded exactly to minimum
+        uint8 initialNumFeeds = 2;
+        uint256 subscriptionId = addTestSubscriptionWithFeeds(
+            scheduler,
+            initialNumFeeds,
+            address(reader)
+        );
+        (
+            SchedulerState.SubscriptionParams memory currentParams,
+            SchedulerState.SubscriptionStatus memory initialStatus
+        ) = scheduler.getSubscription(subscriptionId);
+        uint256 initialMinimumBalance = scheduler.getMinimumBalance(
+            initialNumFeeds
+        );
+        assertEq(
+            initialStatus.balanceInWei,
+            initialMinimumBalance,
+            "Initial balance should be the minimum"
+        );
+
+        // Prepare new params with more feeds (4)
+        uint8 newNumFeeds = 4;
+        SchedulerState.SubscriptionParams memory newParams = currentParams;
+        newParams.priceIds = createPriceIds(newNumFeeds); // Increase feeds
+        newParams.isActive = true; // Keep it active
+
+        // Action 1: Try to update with insufficient funds
+        vm.expectRevert(abi.encodeWithSelector(InsufficientBalance.selector));
+        scheduler.updateSubscription(subscriptionId, newParams);
+
+        // Action 2: Supply enough funds to the updateSubscription call to meet the new minimum balance
+        uint256 newMinimumBalance = scheduler.getMinimumBalance(newNumFeeds);
+        uint256 requiredFunds = newMinimumBalance - initialMinimumBalance;
+
+        scheduler.updateSubscription{value: requiredFunds}(
+            subscriptionId,
+            newParams
+        );
+
+        // Verification 2: Update should now succeed
+        (SchedulerState.SubscriptionParams memory updatedParams, ) = scheduler
+            .getSubscription(subscriptionId);
+        assertEq(
+            updatedParams.priceIds.length,
+            newNumFeeds,
+            "Number of price feeds should be updated"
+        );
+
+        // Scenario 3: Deactivating while adding feeds - should NOT check min balance
+        // Reset state: create another subscription funded to minimum
+        uint8 initialNumFeeds_deact = 2;
+        uint256 subId_deact = addTestSubscriptionWithFeeds(
+            scheduler,
+            initialNumFeeds_deact,
+            address(reader)
+        );
+
+        // Prepare params to add feeds (4) but also deactivate
+        uint8 newNumFeeds_deact = 4;
+        (
+            SchedulerState.SubscriptionParams memory currentParams_deact,
+
+        ) = scheduler.getSubscription(subId_deact);
+        SchedulerState.SubscriptionParams
+            memory newParams_deact = currentParams_deact;
+        newParams_deact.priceIds = createPriceIds(newNumFeeds_deact);
+        newParams_deact.isActive = false; // Deactivate
+
+        // Action 3: Update (should succeed even with insufficient funds for 4 feeds)
+        scheduler.updateSubscription(subId_deact, newParams_deact);
+
+        // Verification 3: Subscription should be inactive and have 4 feeds
+        (
+            SchedulerState.SubscriptionParams memory updatedParams_deact,
+
+        ) = scheduler.getSubscription(subId_deact);
+        assertFalse(
+            updatedParams_deact.isActive,
+            "Subscription should be inactive"
+        );
+        assertEq(
+            updatedParams_deact.priceIds.length,
+            newNumFeeds_deact,
+            "Number of price feeds should be updated even when deactivating"
+        );
+    }
+
     function testGetPricesUnsafeAllFeeds() public {
         // First add a subscription, funds, and update price feeds
         uint256 subscriptionId = addTestSubscription(

+ 10 - 1
target_chains/ethereum/contracts/forge-test/PulseSchedulerGasBenchmark.t.sol

@@ -29,7 +29,16 @@ contract PulseSchedulerGasBenchmark is Test, PulseSchedulerTestUtils {
         proxy = new ERC1967Proxy(address(_scheduler), "");
         scheduler = SchedulerUpgradeable(address(proxy));
 
-        scheduler.initialize(manager, admin, pyth);
+        uint128 minBalancePerFeed = 10 ** 16; // 0.01 ether
+        uint128 keeperFee = 10 ** 15; // 0.001 ether
+
+        scheduler.initialize(
+            manager,
+            admin,
+            pyth,
+            minBalancePerFeed,
+            keeperFee
+        );
 
         // Start tests at a high timestamp to avoid underflow when we set
         // `minPublishTime = timestamp - 1 hour` in updatePriceFeeds

+ 126 - 0
target_chains/ethereum/contracts/forge-test/PulseSchedulerGovernance.t.sol

@@ -0,0 +1,126 @@
+// SPDX-License-Identifier: Apache 2
+
+pragma solidity ^0.8.0;
+
+import "forge-std/Test.sol";
+import "forge-std/console.sol";
+import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
+import "../contracts/pulse/SchedulerUpgradeable.sol";
+import "../contracts/pulse/SchedulerErrors.sol";
+contract SchedulerInvalidMagic is SchedulerUpgradeable {
+    function schedulerUpgradableMagic() public pure override returns (uint32) {
+        return 0x12345678; // Incorrect magic
+    }
+}
+
+contract PulseSchedulerGovernanceTest is Test {
+    ERC1967Proxy public proxy;
+    SchedulerUpgradeable public scheduler;
+    SchedulerUpgradeable public scheduler2;
+    SchedulerInvalidMagic public schedulerInvalidMagic;
+
+    address public owner = address(1);
+    address public admin = address(2);
+    address public admin2 = address(3);
+    address public pyth = address(4);
+
+    function setUp() public {
+        SchedulerUpgradeable _scheduler = new SchedulerUpgradeable();
+        // Deploy proxy contract and point it to implementation
+        proxy = new ERC1967Proxy(address(_scheduler), "");
+        // Wrap in ABI to support easier calls
+        scheduler = SchedulerUpgradeable(address(proxy));
+
+        // For testing upgrades
+        scheduler2 = new SchedulerUpgradeable();
+        schedulerInvalidMagic = new SchedulerInvalidMagic();
+
+        uint128 minBalancePerFeed = 10 ** 16; // 0.01 ether
+        uint128 keeperFee = 10 ** 15; // 0.001 ether
+
+        scheduler.initialize(owner, admin, pyth, minBalancePerFeed, keeperFee);
+    }
+
+    function testGetAdmin() public {
+        assertEq(scheduler.getAdmin(), admin);
+    }
+
+    function testProposeAdminByOwner() public {
+        vm.prank(owner);
+        scheduler.proposeAdmin(admin2);
+
+        assertEq(scheduler.proposedAdmin(), admin2);
+    }
+
+    function testProposeAdminByAdmin() public {
+        vm.prank(admin);
+        scheduler.proposeAdmin(admin2);
+
+        assertEq(scheduler.proposedAdmin(), admin2);
+    }
+
+    function testProposeAdminByUnauthorized() public {
+        address unauthorized = address(5);
+        vm.prank(unauthorized);
+        vm.expectRevert(Unauthorized.selector);
+        scheduler.proposeAdmin(admin2);
+    }
+
+    function testAcceptAdminByProposed() public {
+        vm.prank(owner);
+        scheduler.proposeAdmin(admin2);
+
+        vm.prank(admin2);
+        scheduler.acceptAdmin();
+
+        assertEq(scheduler.getAdmin(), admin2);
+        assertEq(scheduler.proposedAdmin(), address(0));
+    }
+
+    function testAcceptAdminByUnauthorized() public {
+        vm.prank(owner);
+        scheduler.proposeAdmin(admin2);
+
+        address unauthorized = address(5);
+        vm.prank(unauthorized);
+        vm.expectRevert(Unauthorized.selector);
+        scheduler.acceptAdmin();
+    }
+
+    function testUpgradeByOwner() public {
+        vm.prank(owner);
+        scheduler.upgradeTo(address(scheduler2));
+
+        // Verify contract works
+        assertEq(scheduler.getAdmin(), admin);
+    }
+
+    function testUpgradeByAdmin() public {
+        vm.prank(admin);
+        vm.expectRevert("Ownable: caller is not the owner");
+        scheduler.upgradeTo(address(scheduler2));
+    }
+
+    function testUpgradeInvalidMagic() public {
+        vm.prank(owner);
+        vm.expectRevert("Invalid upgrade magic");
+        scheduler.upgradeTo(address(schedulerInvalidMagic));
+    }
+
+    function testProposeZeroAddressAdmin() public {
+        vm.prank(owner);
+        vm.expectRevert("newAdmin is zero address");
+        scheduler.proposeAdmin(address(0));
+    }
+
+    function testProposeThenChangeProposedAdmin() public {
+        vm.prank(owner);
+        scheduler.proposeAdmin(admin2);
+        assertEq(scheduler.proposedAdmin(), admin2);
+
+        address admin3 = address(6);
+        vm.prank(admin);
+        scheduler.proposeAdmin(admin3);
+        assertEq(scheduler.proposedAdmin(), admin3);
+    }
+}