Skip to content

Commit e2caed9

Browse files
feat(pulse): add governance instructions, admin transfer capability (#2608)
* feat(pulse): add governance instructions, admin transfer * fix: small improvements * feat: make updateSubscription payable
1 parent 3c32ca2 commit e2caed9

File tree

9 files changed

+430
-24
lines changed

9 files changed

+430
-24
lines changed

target_chains/ethereum/contracts/contracts/pulse/IScheduler.sol

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,15 +36,16 @@ interface IScheduler is SchedulerEvents {
3636

3737
/**
3838
* @notice Updates an existing subscription
39-
* @dev You can activate or deactivate a subscription by setting isActive to true or false.
40-
* @dev Reactivating a subscription requires the subscription to hold at least the minimum balance (calculated by getMinimumBalance()).
39+
* @dev You can activate or deactivate a subscription by setting isActive to true or false. Reactivating a subscription
40+
* requires the subscription to hold at least the minimum balance (calculated by getMinimumBalance()).
41+
* @dev Any Ether sent with this call (`msg.value`) will be added to the subscription's balance before processing the update.
4142
* @param subscriptionId The ID of the subscription to update
4243
* @param newSubscriptionParams The new parameters for the subscription
4344
*/
4445
function updateSubscription(
4546
uint256 subscriptionId,
4647
SchedulerState.SubscriptionParams calldata newSubscriptionParams
47-
) external;
48+
) external payable;
4849

4950
/**
5051
* @notice Updates price feeds for a subscription.

target_chains/ethereum/contracts/contracts/pulse/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Pyth Pulse Scheduler Contract
1+
# Pyth Pulse Contract
22

33
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.
44

@@ -26,7 +26,7 @@ Pyth Pulse ensures that on-chain Pyth prices remain up-to-date according to user
2626

2727
### Components
2828

29-
1. **Scheduler Contract (This Contract):** Deployed on the target EVM blockchain, this contract manages the state of the subscription metadata and price feeds.
29+
1. **Pulse Contract (This Contract):** Deployed on the target EVM blockchain, this contract manages the state of the subscription metadata and price feeds.
3030
- 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.
3131
- Receives price updates pushed by providers. Verifies the price updates using the core Pyth protocol contract (`IPyth`).
3232
- Stores the latest verified price updates for each feed within a subscription.

target_chains/ethereum/contracts/contracts/pulse/Scheduler.sol

Lines changed: 40 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,20 @@ import "./SchedulerState.sol";
1212
import "./SchedulerErrors.sol";
1313

1414
abstract contract Scheduler is IScheduler, SchedulerState {
15-
function _initialize(address admin, address pythAddress) internal {
15+
function _initialize(
16+
address admin,
17+
address pythAddress,
18+
uint128 minimumBalancePerFeed,
19+
uint128 singleUpdateKeeperFeeInWei
20+
) internal {
1621
require(admin != address(0), "admin is zero address");
1722
require(pythAddress != address(0), "pyth is zero address");
1823

1924
_state.pyth = pythAddress;
25+
_state.admin = admin;
2026
_state.subscriptionNumber = 1;
27+
_state.minimumBalancePerFeed = minimumBalancePerFeed;
28+
_state.singleUpdateKeeperFeeInWei = singleUpdateKeeperFeeInWei;
2129
}
2230

2331
function createSubscription(
@@ -65,14 +73,17 @@ abstract contract Scheduler is IScheduler, SchedulerState {
6573
function updateSubscription(
6674
uint256 subscriptionId,
6775
SubscriptionParams memory newParams
68-
) external override onlyManager(subscriptionId) {
76+
) external payable override onlyManager(subscriptionId) {
6977
SubscriptionStatus storage currentStatus = _state.subscriptionStatuses[
7078
subscriptionId
7179
];
7280
SubscriptionParams storage currentParams = _state.subscriptionParams[
7381
subscriptionId
7482
];
7583

84+
// Add incoming funds to balance
85+
currentStatus.balanceInWei += msg.value;
86+
7687
// Updates to permanent subscriptions are not allowed
7788
if (currentParams.isPermanent) {
7889
revert CannotUpdatePermanentSubscription();
@@ -91,6 +102,19 @@ abstract contract Scheduler is IScheduler, SchedulerState {
91102
// Validate the new parameters, including setting default gas config
92103
_validateAndPrepareSubscriptionParams(newParams);
93104

105+
// Check minimum balance if number of feeds increases and subscription remains active
106+
if (
107+
willBeActive &&
108+
newParams.priceIds.length > currentParams.priceIds.length
109+
) {
110+
uint256 minimumBalance = this.getMinimumBalance(
111+
uint8(newParams.priceIds.length)
112+
);
113+
if (currentStatus.balanceInWei < minimumBalance) {
114+
revert InsufficientBalance();
115+
}
116+
}
117+
94118
// Handle activation/deactivation
95119
if (!wasActive && willBeActive) {
96120
// Reactivating a subscription - ensure minimum balance
@@ -258,18 +282,22 @@ abstract contract Scheduler is IScheduler, SchedulerState {
258282
revert InsufficientBalance();
259283
}
260284

261-
// Parse price feed updates with an expected timestamp range of [-10s, now]
262-
// We will validate the trigger conditions and timestamps ourselves
285+
// Parse the price feed updates with an acceptable timestamp range of [-1h, +10s] from now.
286+
// We will validate the trigger conditions ourselves.
263287
uint64 curTime = SafeCast.toUint64(block.timestamp);
264288
uint64 maxPublishTime = curTime + FUTURE_TIMESTAMP_MAX_VALIDITY_PERIOD;
265289
uint64 minPublishTime = curTime > PAST_TIMESTAMP_MAX_VALIDITY_PERIOD
266290
? curTime - PAST_TIMESTAMP_MAX_VALIDITY_PERIOD
267291
: 0;
268-
PythStructs.PriceFeed[] memory priceFeeds;
269-
uint64[] memory slots;
270-
(priceFeeds, slots) = pyth.parsePriceFeedUpdatesWithSlots{
271-
value: pythFee
272-
}(updateData, priceIds, minPublishTime, maxPublishTime);
292+
(
293+
PythStructs.PriceFeed[] memory priceFeeds,
294+
uint64[] memory slots
295+
) = pyth.parsePriceFeedUpdatesWithSlots{value: pythFee}(
296+
updateData,
297+
priceIds,
298+
minPublishTime,
299+
maxPublishTime
300+
);
273301

274302
// Verify all price feeds have the same Pythnet slot.
275303
// All feeds in a subscription must be updated at the same time.
@@ -622,10 +650,9 @@ abstract contract Scheduler is IScheduler, SchedulerState {
622650
*/
623651
function getMinimumBalance(
624652
uint8 numPriceFeeds
625-
) external pure override returns (uint256 minimumBalanceInWei) {
626-
// Placeholder implementation
627-
// TODO: make this governable
628-
return uint256(numPriceFeeds) * 0.01 ether;
653+
) external view override returns (uint256 minimumBalanceInWei) {
654+
// TODO: Consider adding a base minimum balance independent of feed count
655+
return uint256(numPriceFeeds) * this.getMinimumBalancePerFeed();
629656
}
630657

631658
// ACCESS CONTROL MODIFIERS
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
// SPDX-License-Identifier: Apache 2
2+
pragma solidity ^0.8.0;
3+
4+
import "./SchedulerState.sol";
5+
import "./SchedulerErrors.sol";
6+
7+
/**
8+
* @dev `SchedulerGovernance` defines governance capabilities for the Pulse contract.
9+
*/
10+
abstract contract SchedulerGovernance is SchedulerState {
11+
event NewAdminProposed(address oldAdmin, address newAdmin);
12+
event NewAdminAccepted(address oldAdmin, address newAdmin);
13+
event SingleUpdateKeeperFeeSet(uint oldFee, uint newFee);
14+
event MinimumBalancePerFeedSet(uint oldBalance, uint newBalance);
15+
16+
/**
17+
* @dev Returns the address of the proposed admin.
18+
*/
19+
function proposedAdmin() public view virtual returns (address) {
20+
return _state.proposedAdmin;
21+
}
22+
23+
/**
24+
* @dev Returns the address of the current admin.
25+
*/
26+
function getAdmin() external view returns (address) {
27+
return _state.admin;
28+
}
29+
30+
/**
31+
* @dev Proposes a new admin for the contract. Replaces the proposed admin if there is one.
32+
* Can only be called by either admin or owner.
33+
*/
34+
function proposeAdmin(address newAdmin) public virtual {
35+
require(newAdmin != address(0), "newAdmin is zero address");
36+
37+
_authorizeAdminAction();
38+
39+
_state.proposedAdmin = newAdmin;
40+
emit NewAdminProposed(_state.admin, newAdmin);
41+
}
42+
43+
/**
44+
* @dev The proposed admin accepts the admin transfer.
45+
*/
46+
function acceptAdmin() external {
47+
if (msg.sender != _state.proposedAdmin) revert Unauthorized();
48+
49+
address oldAdmin = _state.admin;
50+
_state.admin = msg.sender;
51+
52+
_state.proposedAdmin = address(0);
53+
emit NewAdminAccepted(oldAdmin, msg.sender);
54+
}
55+
56+
/**
57+
* @dev Authorization check for admin actions
58+
* Must be implemented by the inheriting contract.
59+
*/
60+
function _authorizeAdminAction() internal virtual;
61+
62+
/**
63+
* @dev Set the keeper fee for single updates in Wei.
64+
* Calls {_authorizeAdminAction}.
65+
* Emits a {SingleUpdateKeeperFeeSet} event.
66+
*/
67+
function setSingleUpdateKeeperFeeInWei(uint128 newFee) external {
68+
_authorizeAdminAction();
69+
70+
uint oldFee = _state.singleUpdateKeeperFeeInWei;
71+
_state.singleUpdateKeeperFeeInWei = newFee;
72+
73+
emit SingleUpdateKeeperFeeSet(oldFee, newFee);
74+
}
75+
76+
/**
77+
* @dev Set the minimum balance required per feed in a subscription.
78+
* Calls {_authorizeAdminAction}.
79+
* Emits a {MinimumBalancePerFeedSet} event.
80+
*/
81+
function setMinimumBalancePerFeed(uint128 newMinimumBalance) external {
82+
_authorizeAdminAction();
83+
84+
uint oldBalance = _state.minimumBalancePerFeed;
85+
_state.minimumBalancePerFeed = newMinimumBalance;
86+
87+
emit MinimumBalancePerFeedSet(oldBalance, newMinimumBalance);
88+
}
89+
}

target_chains/ethereum/contracts/contracts/pulse/SchedulerState.sol

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,15 @@ contract SchedulerState {
2323
uint256 subscriptionNumber;
2424
/// Pyth contract for parsing updates and verifying sigs & timestamps
2525
address pyth;
26+
/// Admin address for governance actions
27+
address admin;
28+
// proposedAdmin is the new admin's account address proposed by either the owner or the current admin.
29+
// If there is no pending transfer request, this value will hold `address(0)`.
30+
address proposedAdmin;
31+
/// Fee in wei charged to subscribers per single update triggered by a keeper
32+
uint128 singleUpdateKeeperFeeInWei;
33+
/// Minimum balance required per price feed in a subscription
34+
uint128 minimumBalancePerFeed;
2635
/// Sub ID -> subscription parameters (which price feeds, when to update, etc)
2736
mapping(uint256 => SubscriptionParams) subscriptionParams;
2837
/// Sub ID -> subscription status (metadata about their sub)
@@ -62,4 +71,18 @@ contract SchedulerState {
6271
bool updateOnDeviation;
6372
uint32 deviationThresholdBps;
6473
}
74+
75+
/**
76+
* @dev Returns the minimum balance required per feed in a subscription.
77+
*/
78+
function getMinimumBalancePerFeed() external view returns (uint128) {
79+
return _state.minimumBalancePerFeed;
80+
}
81+
82+
/**
83+
* @dev Returns the fee in wei charged to subscribers per single update triggered by a keeper.
84+
*/
85+
function getSingleUpdateKeeperFeeInWei() external view returns (uint128) {
86+
return _state.singleUpdateKeeperFeeInWei;
87+
}
6588
}

target_chains/ethereum/contracts/contracts/pulse/SchedulerUpgradeable.sol

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,14 @@ import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
66
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
77
import "@openzeppelin/contracts-upgradeable/access/Ownable2StepUpgradeable.sol";
88
import "./Scheduler.sol";
9-
9+
import "./SchedulerGovernance.sol";
10+
import "./SchedulerErrors.sol";
1011
contract SchedulerUpgradeable is
1112
Initializable,
1213
Ownable2StepUpgradeable,
1314
UUPSUpgradeable,
14-
Scheduler
15+
Scheduler,
16+
SchedulerGovernance
1517
{
1618
event ContractUpgraded(
1719
address oldImplementation,
@@ -21,29 +23,46 @@ contract SchedulerUpgradeable is
2123
function initialize(
2224
address owner,
2325
address admin,
24-
address pythAddress
26+
address pythAddress,
27+
uint128 minimumBalancePerFeed,
28+
uint128 singleUpdateKeeperFeeInWei
2529
) external initializer {
2630
require(owner != address(0), "owner is zero address");
2731
require(admin != address(0), "admin is zero address");
32+
require(pythAddress != address(0), "pyth is zero address");
2833

2934
__Ownable_init();
3035
__UUPSUpgradeable_init();
3136

32-
Scheduler._initialize(admin, pythAddress);
37+
Scheduler._initialize(
38+
admin,
39+
pythAddress,
40+
minimumBalancePerFeed,
41+
singleUpdateKeeperFeeInWei
42+
);
3343

3444
_transferOwnership(owner);
3545
}
3646

3747
/// @custom:oz-upgrades-unsafe-allow constructor
3848
constructor() initializer {}
3949

50+
/// Only the owner can upgrade the contract
4051
function _authorizeUpgrade(address) internal override onlyOwner {}
4152

53+
/// Authorize actions that both admin and owner can perform
54+
function _authorizeAdminAction() internal view override {
55+
if (msg.sender != owner() && msg.sender != _state.admin)
56+
revert Unauthorized();
57+
}
58+
4259
function upgradeTo(address newImplementation) external override onlyProxy {
4360
address oldImplementation = _getImplementation();
4461
_authorizeUpgrade(newImplementation);
4562
_upgradeToAndCallUUPS(newImplementation, new bytes(0), false);
4663

64+
magicCheck();
65+
4766
emit ContractUpgraded(oldImplementation, _getImplementation());
4867
}
4968

@@ -55,9 +74,23 @@ contract SchedulerUpgradeable is
5574
_authorizeUpgrade(newImplementation);
5675
_upgradeToAndCallUUPS(newImplementation, data, true);
5776

77+
magicCheck();
78+
5879
emit ContractUpgraded(oldImplementation, _getImplementation());
5980
}
6081

82+
/// Sanity check to ensure we are upgrading the proxy to a compatible contract.
83+
function magicCheck() internal view {
84+
// Calling a method using `this.<method>` will cause a contract call that will use
85+
// the new contract. This call will fail if the method does not exists or the magic is different.
86+
if (this.schedulerUpgradableMagic() != 0x50554C53)
87+
revert("Invalid upgrade magic");
88+
}
89+
90+
function schedulerUpgradableMagic() public pure virtual returns (uint32) {
91+
return 0x50554C53; // "PULS" ASCII in hex
92+
}
93+
6194
function version() public pure returns (string memory) {
6295
return "1.0.0";
6396
}

0 commit comments

Comments
 (0)