Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions target_chains/ethereum/contracts/contracts/pulse/README.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down Expand Up @@ -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.
Expand Down
53 changes: 40 additions & 13 deletions target_chains/ethereum/contracts/contracts/pulse/Scheduler.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -65,14 +73,17 @@ 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
];
SubscriptionParams storage currentParams = _state.subscriptionParams[
subscriptionId
];

// Add incoming funds to balance
currentStatus.balanceInWei += msg.value;

// Updates to permanent subscriptions are not allowed
if (currentParams.isPermanent) {
revert CannotUpdatePermanentSubscription();
Expand All @@ -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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's better if you do this check regardless. Otherwise I can increase feeds and deactivate, and then later activate it to bypass it right?

Copy link
Contributor Author

@tejasbadadare tejasbadadare Apr 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeahh was debating this, but one thing i was thinking was we shouldn't enforce this for people trying to deactivate or reduce their feeds. maybe they are low on balance and trying to reduce their burn rate. ultimately the minimum balance thing serves 2 purposes: disincentivize the attack vector of flooding the system with subscriptions, and help the user maintain enough balance in their account to ensure uninterrupted operation.

also, we do enforce the check when activating, so the scenario you mentioned wouldn't be a problem.

) {
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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -21,29 +23,46 @@ 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);
}

/// @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());
}

Expand All @@ -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";
}
Expand Down
Loading
Loading