diff --git a/target_chains/ethereum/contracts/contracts/pulse/scheduler/IScheduler.sol b/target_chains/ethereum/contracts/contracts/pulse/scheduler/IScheduler.sol index aae5638346..3dddbbd5f3 100644 --- a/target_chains/ethereum/contracts/contracts/pulse/scheduler/IScheduler.sol +++ b/target_chains/ethereum/contracts/contracts/pulse/scheduler/IScheduler.sol @@ -8,16 +8,15 @@ import "./SchedulerEvents.sol"; import "./SchedulerState.sol"; interface IScheduler is SchedulerEvents { - // CORE FUNCTIONS - /** - * @notice Adds a new subscription + * @notice Creates a new subscription + * @dev Requires msg.value to be at least the minimum balance for the subscription (calculated by getMinimumBalance()). * @param subscriptionParams The parameters for the subscription * @return subscriptionId The ID of the newly created subscription */ - function addSubscription( + function createSubscription( SchedulerState.SubscriptionParams calldata subscriptionParams - ) external returns (uint256 subscriptionId); + ) external payable returns (uint256 subscriptionId); /** * @notice Gets a subscription's parameters and status @@ -37,6 +36,8 @@ 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()). * @param subscriptionId The ID of the subscription to update * @param newSubscriptionParams The new parameters for the subscription */ @@ -45,12 +46,6 @@ interface IScheduler is SchedulerEvents { SchedulerState.SubscriptionParams calldata newSubscriptionParams ) external; - /** - * @notice Deactivates a subscription - * @param subscriptionId The ID of the subscription to deactivate - */ - function deactivateSubscription(uint256 subscriptionId) external; - /** * @notice Updates price feeds for a subscription. * Verifies the updateData using the Pyth contract and validates that all feeds have the same timestamp. @@ -64,16 +59,36 @@ interface IScheduler is SchedulerEvents { bytes32[] calldata priceIds ) external; - /** - * @notice Gets the latest prices for a subscription - * @param subscriptionId The ID of the subscription - * @param priceIds Optional array of price IDs to retrieve. If empty, returns all price feeds for the subscription. - * @return The latest price feeds for the requested price IDs + /** @notice Returns the price of a price feed without any sanity checks. + * @dev This function returns the most recent price update in this contract without any recency checks. + * This function is unsafe as the returned price update may be arbitrarily far in the past. + * + * Users of this function should check the `publishTime` in the price to ensure that the returned price is + * sufficiently recent for their application. If you are considering using this function, it may be + * safer / easier to use `getPriceNoOlderThan`. + * @return prices - please read the documentation of PythStructs.Price to understand how to use this safely. + */ + function getPricesUnsafe( + uint256 subscriptionId, + bytes32[] calldata priceIds + ) external view returns (PythStructs.Price[] memory prices); + + /** @notice Returns the exponentially-weighted moving average price of a price feed without any sanity checks. + * @dev This function returns the same price as `getEmaPrice` in the case where the price is available. + * However, if the price is not recent this function returns the latest available price. + * + * The returned price can be from arbitrarily far in the past; this function makes no guarantees that + * the returned price is recent or useful for any particular application. + * + * Users of this function should check the `publishTime` in the price to ensure that the returned price is + * sufficiently recent for their application. If you are considering using this function, it may be + * safer / easier to use either `getEmaPrice` or `getEmaPriceNoOlderThan`. + * @return price - please read the documentation of PythStructs.Price to understand how to use this safely. */ - function getLatestPrices( + function getEmaPriceUnsafe( uint256 subscriptionId, bytes32[] calldata priceIds - ) external view returns (PythStructs.PriceFeed[] memory); + ) external view returns (PythStructs.Price[] memory price); /** * @notice Adds funds to a subscription's balance @@ -82,23 +97,40 @@ interface IScheduler is SchedulerEvents { function addFunds(uint256 subscriptionId) external payable; /** - * @notice Withdraws funds from a subscription's balance + * @notice Withdraws funds from a subscription's balance. + * @dev A minimum balance must be maintained for active subscriptions. To withdraw past + * the minimum balance limit, deactivate the subscription first. * @param subscriptionId The ID of the subscription * @param amount The amount to withdraw */ function withdrawFunds(uint256 subscriptionId, uint256 amount) external; + /** + * @notice Returns the minimum balance an active subscription of a given size needs to hold. + * @param numPriceFeeds The number of price feeds in the subscription. + */ + function getMinimumBalance( + uint8 numPriceFeeds + ) external view returns (uint256 minimumBalanceInWei); + /** * @notice Gets all active subscriptions with their parameters * @dev This function has no access control to allow keepers to discover active subscriptions + * @param startIndex The starting index for pagination + * @param maxResults The maximum number of results to return * @return subscriptionIds Array of active subscription IDs * @return subscriptionParams Array of subscription parameters for each active subscription + * @return totalCount Total number of active subscriptions */ - function getActiveSubscriptions() + function getActiveSubscriptions( + uint256 startIndex, + uint256 maxResults + ) external view returns ( uint256[] memory subscriptionIds, - SchedulerState.SubscriptionParams[] memory subscriptionParams + SchedulerState.SubscriptionParams[] memory subscriptionParams, + uint256 totalCount ); } diff --git a/target_chains/ethereum/contracts/contracts/pulse/scheduler/Scheduler.sol b/target_chains/ethereum/contracts/contracts/pulse/scheduler/Scheduler.sol index 8f6d5897e5..a41907bf49 100644 --- a/target_chains/ethereum/contracts/contracts/pulse/scheduler/Scheduler.sol +++ b/target_chains/ethereum/contracts/contracts/pulse/scheduler/Scheduler.sol @@ -6,6 +6,7 @@ import "@openzeppelin/contracts/utils/math/SafeCast.sol"; import "@openzeppelin/contracts/utils/math/SignedMath.sol"; import "@openzeppelin/contracts/utils/math/Math.sol"; import "@pythnetwork/pyth-sdk-solidity/IPyth.sol"; +import "@pythnetwork/pyth-sdk-solidity/PythErrors.sol"; import "./IScheduler.sol"; import "./SchedulerState.sol"; import "./SchedulerErrors.sol"; @@ -19,13 +20,15 @@ abstract contract Scheduler is IScheduler, SchedulerState { _state.subscriptionNumber = 1; } - function addSubscription( - SubscriptionParams calldata subscriptionParams - ) external override returns (uint256 subscriptionId) { - if (subscriptionParams.priceIds.length > MAX_PRICE_IDS) { + function createSubscription( + SubscriptionParams memory subscriptionParams + ) external payable override returns (uint256 subscriptionId) { + if ( + subscriptionParams.priceIds.length > MAX_PRICE_IDS_PER_SUBSCRIPTION + ) { revert TooManyPriceIds( subscriptionParams.priceIds.length, - MAX_PRICE_IDS + MAX_PRICE_IDS_PER_SUBSCRIPTION ); } @@ -37,14 +40,32 @@ abstract contract Scheduler is IScheduler, SchedulerState { revert InvalidUpdateCriteria(); } - // Validate gas config + // If gas config is unset, set it to the default (100x multipliers) if ( - subscriptionParams.gasConfig.maxGasPrice == 0 || - subscriptionParams.gasConfig.maxGasLimit == 0 + subscriptionParams.gasConfig.maxBaseFeeMultiplierCapPct == 0 || + subscriptionParams.gasConfig.maxPriorityFeeMultiplierCapPct == 0 ) { - revert InvalidGasConfig(); + subscriptionParams + .gasConfig + .maxPriorityFeeMultiplierCapPct = DEFAULT_MAX_PRIORITY_FEE_MULTIPLIER_CAP_PCT; + subscriptionParams + .gasConfig + .maxBaseFeeMultiplierCapPct = DEFAULT_MAX_BASE_FEE_MULTIPLIER_CAP_PCT; } + // Calculate minimum balance required for this subscription + uint256 minimumBalance = this.getMinimumBalance( + uint8(subscriptionParams.priceIds.length) + ); + + // Ensure enough funds were provided + if (msg.value < minimumBalance) { + revert InsufficientBalance(); + } + + // Set subscription to active + subscriptionParams.isActive = true; + subscriptionId = _state.subscriptionNumber++; // Store the subscription parameters @@ -55,10 +76,9 @@ abstract contract Scheduler is IScheduler, SchedulerState { subscriptionId ]; status.priceLastUpdatedAt = 0; - status.balanceInWei = 0; + status.balanceInWei = msg.value; status.totalUpdates = 0; status.totalSpent = 0; - status.isActive = true; // Map subscription ID to manager _state.subscriptionManager[subscriptionId] = msg.sender; @@ -67,77 +87,123 @@ abstract contract Scheduler is IScheduler, SchedulerState { return subscriptionId; } - function getSubscription( - uint256 subscriptionId - ) - external - view - override - returns ( - SubscriptionParams memory params, - SubscriptionStatus memory status - ) - { - return ( - _state.subscriptionParams[subscriptionId], - _state.subscriptionStatuses[subscriptionId] - ); - } - function updateSubscription( uint256 subscriptionId, - SubscriptionParams calldata newSubscriptionParams + SubscriptionParams memory newParams ) external override onlyManager(subscriptionId) { - if (!_state.subscriptionStatuses[subscriptionId].isActive) { - revert InactiveSubscription(); + SchedulerState.SubscriptionStatus storage currentStatus = _state + .subscriptionStatuses[subscriptionId]; + SchedulerState.SubscriptionParams storage currentParams = _state + .subscriptionParams[subscriptionId]; + bool wasActive = currentParams.isActive; + bool willBeActive = newParams.isActive; + + // If subscription is inactive and will remain inactive, no need to validate parameters + if (!wasActive && !willBeActive) { + // Update subscription parameters + _state.subscriptionParams[subscriptionId] = newParams; + emit SubscriptionUpdated(subscriptionId); + return; } - if (newSubscriptionParams.priceIds.length > MAX_PRICE_IDS) { + // Validate parameters for active or to-be-activated subscriptions + if (newParams.priceIds.length > MAX_PRICE_IDS_PER_SUBSCRIPTION) { revert TooManyPriceIds( - newSubscriptionParams.priceIds.length, - MAX_PRICE_IDS + newParams.priceIds.length, + MAX_PRICE_IDS_PER_SUBSCRIPTION ); } // Validate update criteria if ( - !newSubscriptionParams.updateCriteria.updateOnHeartbeat && - !newSubscriptionParams.updateCriteria.updateOnDeviation + !newParams.updateCriteria.updateOnHeartbeat && + !newParams.updateCriteria.updateOnDeviation ) { revert InvalidUpdateCriteria(); } - // Validate gas config + // If gas config is unset, set it to the default (100x multipliers) if ( - newSubscriptionParams.gasConfig.maxGasPrice == 0 || - newSubscriptionParams.gasConfig.maxGasLimit == 0 + newParams.gasConfig.maxBaseFeeMultiplierCapPct == 0 || + newParams.gasConfig.maxPriorityFeeMultiplierCapPct == 0 ) { - revert InvalidGasConfig(); + newParams + .gasConfig + .maxPriorityFeeMultiplierCapPct = DEFAULT_MAX_PRIORITY_FEE_MULTIPLIER_CAP_PCT; + newParams + .gasConfig + .maxBaseFeeMultiplierCapPct = DEFAULT_MAX_BASE_FEE_MULTIPLIER_CAP_PCT; + } + + // Handle activation/deactivation + if (!wasActive && willBeActive) { + // Reactivating a subscription - ensure minimum balance + uint256 minimumBalance = this.getMinimumBalance( + uint8(newParams.priceIds.length) + ); + + // Check if balance meets minimum requirement + if (currentStatus.balanceInWei < minimumBalance) { + revert InsufficientBalance(); + } + + currentParams.isActive = true; + emit SubscriptionActivated(subscriptionId); + } else if (wasActive && !willBeActive) { + // Deactivating a subscription + currentParams.isActive = false; + emit SubscriptionDeactivated(subscriptionId); } + // Clear price updates for removed price IDs before updating params + _clearRemovedPriceUpdates( + subscriptionId, + currentParams.priceIds, + newParams.priceIds + ); + // Update subscription parameters - _state.subscriptionParams[subscriptionId] = newSubscriptionParams; + _state.subscriptionParams[subscriptionId] = newParams; emit SubscriptionUpdated(subscriptionId); } - function deactivateSubscription( - uint256 subscriptionId - ) external override onlyManager(subscriptionId) { - if (!_state.subscriptionStatuses[subscriptionId].isActive) { - revert InactiveSubscription(); - } - - _state.subscriptionStatuses[subscriptionId].isActive = false; + /** + * @notice Internal helper to clear stored PriceFeed data for price IDs removed from a subscription. + * @param subscriptionId The ID of the subscription being updated. + * @param currentPriceIds The array of price IDs currently associated with the subscription. + * @param newPriceIds The new array of price IDs for the subscription. + */ + function _clearRemovedPriceUpdates( + uint256 subscriptionId, + bytes32[] storage currentPriceIds, + bytes32[] memory newPriceIds + ) internal { + // Iterate through old price IDs + for (uint i = 0; i < currentPriceIds.length; i++) { + bytes32 oldPriceId = currentPriceIds[i]; + bool found = false; + + // Check if the old price ID exists in the new list + for (uint j = 0; j < newPriceIds.length; j++) { + if (newPriceIds[j] == oldPriceId) { + found = true; + break; // Found it, no need to check further + } + } - emit SubscriptionDeactivated(subscriptionId); + // If not found in the new list, delete its stored update data + if (!found) { + delete _state.priceUpdates[subscriptionId][oldPriceId]; + } + } } function updatePriceFeeds( uint256 subscriptionId, bytes[] calldata updateData, bytes32[] calldata priceIds - ) external override onlyPusher { + ) external override { SubscriptionStatus storage status = _state.subscriptionStatuses[ subscriptionId ]; @@ -145,7 +211,7 @@ abstract contract Scheduler is IScheduler, SchedulerState { subscriptionId ]; - if (!status.isActive) { + if (!params.isActive) { revert InactiveSubscription(); } @@ -309,17 +375,19 @@ abstract contract Scheduler is IScheduler, SchedulerState { revert UpdateConditionsNotMet(); } - function getLatestPrices( + /// FETCH PRICES + + /** + * @notice Internal helper function to retrieve price feeds for a subscription. + * @param subscriptionId The ID of the subscription. + * @param priceIds The specific price IDs requested, or empty array to get all. + * @return priceFeeds An array of PriceFeed structs corresponding to the requested IDs. + */ + function _getPricesInternal( uint256 subscriptionId, bytes32[] calldata priceIds - ) - external - view - override - onlyWhitelistedReader(subscriptionId) - returns (PythStructs.PriceFeed[] memory) - { - if (!_state.subscriptionStatuses[subscriptionId].isActive) { + ) internal view returns (PythStructs.PriceFeed[] memory priceFeeds) { + if (!_state.subscriptionParams[subscriptionId].isActive) { revert InactiveSubscription(); } @@ -334,9 +402,14 @@ abstract contract Scheduler is IScheduler, SchedulerState { params.priceIds.length ); for (uint8 i = 0; i < params.priceIds.length; i++) { - allFeeds[i] = _state.priceUpdates[subscriptionId][ - params.priceIds[i] - ]; + PythStructs.PriceFeed storage priceFeed = _state.priceUpdates[ + subscriptionId + ][params.priceIds[i]]; + // Check if the price feed exists (price ID is valid and has been updated) + if (priceFeed.id == bytes32(0)) { + revert InvalidPriceId(params.priceIds[i], bytes32(0)); + } + allFeeds[i] = priceFeed; } return allFeeds; } @@ -347,31 +420,67 @@ abstract contract Scheduler is IScheduler, SchedulerState { priceIds.length ); for (uint8 i = 0; i < priceIds.length; i++) { - // Verify the requested price ID is part of the subscription - bool validPriceId = false; - for (uint8 j = 0; j < params.priceIds.length; j++) { - if (priceIds[i] == params.priceIds[j]) { - validPriceId = true; - break; - } - } + PythStructs.PriceFeed storage priceFeed = _state.priceUpdates[ + subscriptionId + ][priceIds[i]]; - if (!validPriceId) { - revert InvalidPriceId(priceIds[i], params.priceIds[0]); + // Check if the price feed exists (price ID is valid and has been updated) + if (priceFeed.id == bytes32(0)) { + revert InvalidPriceId(priceIds[i], bytes32(0)); } + requestedFeeds[i] = priceFeed; + } + return requestedFeeds; + } - requestedFeeds[i] = _state.priceUpdates[subscriptionId][ - priceIds[i] - ]; + function getPricesUnsafe( + uint256 subscriptionId, + bytes32[] calldata priceIds + ) + external + view + override + onlyWhitelistedReader(subscriptionId) + returns (PythStructs.Price[] memory prices) + { + PythStructs.PriceFeed[] memory priceFeeds = _getPricesInternal( + subscriptionId, + priceIds + ); + prices = new PythStructs.Price[](priceFeeds.length); + for (uint i = 0; i < priceFeeds.length; i++) { + prices[i] = priceFeeds[i].price; } + return prices; + } - return requestedFeeds; + function getEmaPriceUnsafe( + uint256 subscriptionId, + bytes32[] calldata priceIds + ) + external + view + override + onlyWhitelistedReader(subscriptionId) + returns (PythStructs.Price[] memory prices) + { + PythStructs.PriceFeed[] memory priceFeeds = _getPricesInternal( + subscriptionId, + priceIds + ); + prices = new PythStructs.Price[](priceFeeds.length); + for (uint i = 0; i < priceFeeds.length; i++) { + prices[i] = priceFeeds[i].emaPrice; + } + return prices; } + /// BALANCE MANAGEMENT + function addFunds( uint256 subscriptionId ) external payable override onlyManager(subscriptionId) { - if (!_state.subscriptionStatuses[subscriptionId].isActive) { + if (!_state.subscriptionParams[subscriptionId].isActive) { revert InactiveSubscription(); } @@ -385,63 +494,124 @@ abstract contract Scheduler is IScheduler, SchedulerState { SubscriptionStatus storage status = _state.subscriptionStatuses[ subscriptionId ]; + SubscriptionParams storage params = _state.subscriptionParams[ + subscriptionId + ]; if (status.balanceInWei < amount) { revert InsufficientBalance(); } + // If subscription is active, ensure minimum balance is maintained + if (params.isActive) { + uint256 minimumBalance = this.getMinimumBalance( + uint8(params.priceIds.length) + ); + if (status.balanceInWei - amount < minimumBalance) { + revert InsufficientBalance(); + } + } + status.balanceInWei -= amount; (bool sent, ) = msg.sender.call{value: amount}(""); require(sent, "Failed to send funds"); } + // FETCH SUBSCRIPTIONS + + function getSubscription( + uint256 subscriptionId + ) + external + view + override + returns ( + SubscriptionParams memory params, + SubscriptionStatus memory status + ) + { + return ( + _state.subscriptionParams[subscriptionId], + _state.subscriptionStatuses[subscriptionId] + ); + } + // This function is intentionally public with no access control to allow keepers to discover active subscriptions - function getActiveSubscriptions() + function getActiveSubscriptions( + uint256 startIndex, + uint256 maxResults + ) external view override returns ( uint256[] memory subscriptionIds, - SubscriptionParams[] memory subscriptionParams + SubscriptionParams[] memory subscriptionParams, + uint256 totalCount ) { - // TODO: This is gonna be expensive because we're iterating through - // all subscriptions, including deactivated ones. But because its a view - // function maybe it's not bad? We can optimize this. - - // Count active subscriptions first to determine array size - uint256 activeCount = 0; + // Count active subscriptions first to determine total count + // TODO: Optimize this. store numActiveSubscriptions or something. + totalCount = 0; for (uint256 i = 1; i < _state.subscriptionNumber; i++) { - if (_state.subscriptionStatuses[i].isActive) { - activeCount++; + if (_state.subscriptionParams[i].isActive) { + totalCount++; } } + // If startIndex is beyond the total count, return empty arrays + if (startIndex >= totalCount) { + return (new uint256[](0), new SubscriptionParams[](0), totalCount); + } + + // Calculate how many results to return (bounded by maxResults and remaining items) + uint256 resultCount = totalCount - startIndex; + if (resultCount > maxResults) { + resultCount = maxResults; + } + // Create arrays for subscription IDs and parameters - subscriptionIds = new uint256[](activeCount); - subscriptionParams = new SubscriptionParams[](activeCount); + subscriptionIds = new uint256[](resultCount); + subscriptionParams = new SubscriptionParams[](resultCount); - // Populate arrays with active subscription data - uint256 index = 0; - for (uint256 i = 1; i < _state.subscriptionNumber; i++) { - if (_state.subscriptionStatuses[i].isActive) { - subscriptionIds[index] = i; - subscriptionParams[index] = _state.subscriptionParams[i]; - index++; + // Find and populate the requested page of active subscriptions + uint256 activeIndex = 0; + uint256 resultIndex = 0; + + for ( + uint256 i = 1; + i < _state.subscriptionNumber && resultIndex < resultCount; + i++ + ) { + if (_state.subscriptionParams[i].isActive) { + if (activeIndex >= startIndex) { + subscriptionIds[resultIndex] = i; + subscriptionParams[resultIndex] = _state.subscriptionParams[ + i + ]; + resultIndex++; + } + activeIndex++; } } - return (subscriptionIds, subscriptionParams); + return (subscriptionIds, subscriptionParams, totalCount); } - // ACCESS CONTROL MODIFIERS - - modifier onlyPusher() { - // TODO: we may not make this permissioned. - _; + /** + * @notice Returns the minimum balance an active subscription of a given size needs to hold. + * @param numPriceFeeds The number of price feeds in the subscription. + */ + function getMinimumBalance( + uint8 numPriceFeeds + ) external pure override returns (uint256 minimumBalanceInWei) { + // Simple implementation - minimum balance is 0.01 ETH per price feed + return numPriceFeeds * 0.01 ether; } + // ACCESS CONTROL MODIFIERS + modifier onlyManager(uint256 subscriptionId) { if (_state.subscriptionManager[subscriptionId] != msg.sender) { revert Unauthorized(); diff --git a/target_chains/ethereum/contracts/contracts/pulse/scheduler/SchedulerEvents.sol b/target_chains/ethereum/contracts/contracts/pulse/scheduler/SchedulerEvents.sol index f8acce50a4..7f0c242032 100644 --- a/target_chains/ethereum/contracts/contracts/pulse/scheduler/SchedulerEvents.sol +++ b/target_chains/ethereum/contracts/contracts/pulse/scheduler/SchedulerEvents.sol @@ -10,5 +10,6 @@ interface SchedulerEvents { ); event SubscriptionUpdated(uint256 indexed subscriptionId); event SubscriptionDeactivated(uint256 indexed subscriptionId); + event SubscriptionActivated(uint256 indexed subscriptionId); event PricesUpdated(uint256 indexed subscriptionId, uint256 timestamp); } diff --git a/target_chains/ethereum/contracts/contracts/pulse/scheduler/SchedulerState.sol b/target_chains/ethereum/contracts/contracts/pulse/scheduler/SchedulerState.sol index d638da6f90..bf87aa1d56 100644 --- a/target_chains/ethereum/contracts/contracts/pulse/scheduler/SchedulerState.sol +++ b/target_chains/ethereum/contracts/contracts/pulse/scheduler/SchedulerState.sol @@ -5,21 +5,25 @@ pragma solidity ^0.8.0; import "@pythnetwork/pyth-sdk-solidity/PythStructs.sol"; contract SchedulerState { - // Maximum number of price feeds per subscription - uint8 public constant MAX_PRICE_IDS = 10; + /// Maximum number of price feeds per subscription + uint8 public constant MAX_PRICE_IDS_PER_SUBSCRIPTION = 255; + /// Default max gas multiplier + uint32 public constant DEFAULT_MAX_BASE_FEE_MULTIPLIER_CAP_PCT = 10_000; + /// Default max fee multiplier + uint32 public constant DEFAULT_MAX_PRIORITY_FEE_MULTIPLIER_CAP_PCT = 10_000; struct State { - // Monotonically increasing counter for subscription IDs + /// Monotonically increasing counter for subscription IDs uint256 subscriptionNumber; - // Pyth contract for parsing updates and verifying sigs & timestamps + /// Pyth contract for parsing updates and verifying sigs & timestamps address pyth; - // Sub ID -> subscription parameters (which price feeds, when to update, etc) + /// Sub ID -> subscription parameters (which price feeds, when to update, etc) mapping(uint256 => SubscriptionParams) subscriptionParams; - // Sub ID -> subscription status (metadata about their sub) + /// Sub ID -> subscription status (metadata about their sub) mapping(uint256 => SubscriptionStatus) subscriptionStatuses; - // Sub ID -> price ID -> latest parsed price update for the subscribed feed + /// Sub ID -> price ID -> latest parsed price update for the subscribed feed mapping(uint256 => mapping(bytes32 => PythStructs.PriceFeed)) priceUpdates; - // Sub ID -> manager address + /// Sub ID -> manager address mapping(uint256 => address) subscriptionManager; } State internal _state; @@ -28,6 +32,7 @@ contract SchedulerState { bytes32[] priceIds; address[] readerWhitelist; bool whitelistEnabled; + bool isActive; UpdateCriteria updateCriteria; GasConfig gasConfig; } @@ -37,16 +42,19 @@ contract SchedulerState { uint256 balanceInWei; uint256 totalUpdates; uint256 totalSpent; - bool isActive; } + /// @dev When pushing prices, providers will use a "fast gas" estimation as default. + /// If the gas is insufficient to land the transaction, the provider will linearly scale + /// base fee and priority fee multipliers until the transaction lands. + /// These parameters allow the subscriber to impose limits on these multipliers. + /// For example, with maxBaseFeeMultiplierCapPct = 10_000 (default), the provider can + /// use a max of 100x (10000%) of the estimated gas as reported by the RPC. struct GasConfig { - // TODO: Figure out what controls to give users for gas strategy - - // Gas price limit to prevent runaway costs in high-gas environments - uint256 maxGasPrice; - // Gas limit for update operations - uint256 maxGasLimit; + /// Base gas fee price multiplier limit percent for update operations + uint32 maxBaseFeeMultiplierCapPct; + /// Priority fee multiplier limit for update operations + uint32 maxPriorityFeeMultiplierCapPct; } struct UpdateCriteria { @@ -54,11 +62,5 @@ contract SchedulerState { uint32 heartbeatSeconds; bool updateOnDeviation; uint32 deviationThresholdBps; - - // TODO: add updateOnConfidenceRatio? - - // TODO: add explicit "early update" support? i.e. update all feeds when at least one feed - // meets the triggering conditions, rather than waiting for all feeds - // to meet the conditions. Currently, "early update" is the only mode of operation. } } diff --git a/target_chains/ethereum/contracts/forge-test/PulseScheduler.t.sol b/target_chains/ethereum/contracts/forge-test/PulseScheduler.t.sol index 6a831d21e0..13395ffaf8 100644 --- a/target_chains/ethereum/contracts/forge-test/PulseScheduler.t.sol +++ b/target_chains/ethereum/contracts/forge-test/PulseScheduler.t.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.0; import "forge-std/Test.sol"; +import "forge-std/console.sol"; import "@pythnetwork/pyth-sdk-solidity/IPyth.sol"; import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; import "./utils/PulseTestUtils.t.sol"; @@ -19,11 +20,19 @@ contract MockReader { _scheduler = scheduler; } - function getLatestPrices( + function getPricesUnsafe( uint256 subscriptionId, bytes32[] memory priceIds - ) external view returns (PythStructs.PriceFeed[] memory) { - return IScheduler(_scheduler).getLatestPrices(subscriptionId, priceIds); + ) external view returns (PythStructs.Price[] memory) { + return IScheduler(_scheduler).getPricesUnsafe(subscriptionId, priceIds); + } + + function getEmaPriceUnsafe( + uint256 subscriptionId, + bytes32[] memory priceIds + ) external view returns (PythStructs.Price[] memory) { + return + IScheduler(_scheduler).getEmaPriceUnsafe(subscriptionId, priceIds); } function verifyPriceFeeds( @@ -31,19 +40,18 @@ contract MockReader { bytes32[] memory priceIds, PythStructs.PriceFeed[] memory expectedFeeds ) external view returns (bool) { - PythStructs.PriceFeed[] memory actualFeeds = IScheduler(_scheduler) - .getLatestPrices(subscriptionId, priceIds); + PythStructs.Price[] memory actualPrices = IScheduler(_scheduler) + .getPricesUnsafe(subscriptionId, priceIds); - if (actualFeeds.length != expectedFeeds.length) { + if (actualPrices.length != expectedFeeds.length) { return false; } - for (uint i = 0; i < actualFeeds.length; i++) { + for (uint i = 0; i < actualPrices.length; i++) { if ( - actualFeeds[i].id != expectedFeeds[i].id || - actualFeeds[i].price.price != expectedFeeds[i].price.price || - actualFeeds[i].price.conf != expectedFeeds[i].price.conf || - actualFeeds[i].price.publishTime != + actualPrices[i].price != expectedFeeds[i].price.price || + actualPrices[i].conf != expectedFeeds[i].price.conf || + actualPrices[i].publishTime != expectedFeeds[i].price.publishTime ) { return false; @@ -83,9 +91,12 @@ contract SchedulerTest is Test, SchedulerEvents, PulseTestUtils { // Start tests at timestamp 100 to avoid underflow when we set // `minPublishTime = timestamp - 10 seconds` in updatePriceFeeds vm.warp(100); + + // Give pusher 100 ETH for testing + vm.deal(pusher, 100 ether); } - function testAddSubscription() public { + function testcreateSubscription() public { // Create subscription parameters bytes32[] memory priceIds = createPriceIds(); address[] memory readerWhitelist = new address[](1); @@ -100,8 +111,8 @@ contract SchedulerTest is Test, SchedulerEvents, PulseTestUtils { }); SchedulerState.GasConfig memory gasConfig = SchedulerState.GasConfig({ - maxGasPrice: 100 gwei, - maxGasLimit: 1_000_000 + maxBaseFeeMultiplierCapPct: 10_000, + maxPriorityFeeMultiplierCapPct: 10_000 }); SchedulerState.SubscriptionParams memory params = SchedulerState @@ -109,15 +120,23 @@ contract SchedulerTest is Test, SchedulerEvents, PulseTestUtils { priceIds: priceIds, readerWhitelist: readerWhitelist, whitelistEnabled: true, + isActive: true, updateCriteria: updateCriteria, gasConfig: gasConfig }); - // Add subscription + // Calculate minimum balance + uint256 minimumBalance = scheduler.getMinimumBalance( + uint8(priceIds.length) + ); + + // Add subscription with minimum balance vm.expectEmit(); emit SubscriptionCreated(1, address(this)); - uint256 subscriptionId = scheduler.addSubscription(params); + uint256 subscriptionId = scheduler.createSubscription{ + value: minimumBalance + }(params); assertEq(subscriptionId, 1, "Subscription ID should be 1"); // Verify subscription was added correctly @@ -141,6 +160,7 @@ contract SchedulerTest is Test, SchedulerEvents, PulseTestUtils { true, "whitelistEnabled should be true" ); + assertTrue(storedParams.isActive, "Subscription should be active"); assertEq( storedParams.updateCriteria.heartbeatSeconds, 60, @@ -152,13 +172,16 @@ contract SchedulerTest is Test, SchedulerEvents, PulseTestUtils { "Deviation threshold mismatch" ); assertEq( - storedParams.gasConfig.maxGasPrice, - 100 gwei, - "Max gas price mismatch" + storedParams.gasConfig.maxBaseFeeMultiplierCapPct, + 10_000, + "Max gas multiplier mismatch" ); - assertTrue(status.isActive, "Subscription should be active"); - assertEq(status.balanceInWei, 0, "Initial balance should be 0"); + assertEq( + status.balanceInWei, + minimumBalance, + "Initial balance should match minimum balance" + ); } function testUpdateSubscription() public { @@ -181,8 +204,8 @@ contract SchedulerTest is Test, SchedulerEvents, PulseTestUtils { SchedulerState.GasConfig memory newGasConfig = SchedulerState .GasConfig({ - maxGasPrice: 200 gwei, // Changed from 100 gwei - maxGasLimit: 2_000_000 // Changed from 1_000_000 + maxBaseFeeMultiplierCapPct: 20_000, // Changed from 10_000 + maxPriorityFeeMultiplierCapPct: 20_000 // Changed from 10_000 }); SchedulerState.SubscriptionParams memory newParams = SchedulerState @@ -190,6 +213,7 @@ contract SchedulerTest is Test, SchedulerEvents, PulseTestUtils { priceIds: newPriceIds, readerWhitelist: newReaderWhitelist, whitelistEnabled: false, // Changed from true + isActive: true, updateCriteria: newUpdateCriteria, gasConfig: newGasConfig }); @@ -230,33 +254,219 @@ contract SchedulerTest is Test, SchedulerEvents, PulseTestUtils { "Deviation threshold mismatch" ); assertEq( - storedParams.gasConfig.maxGasPrice, - 200 gwei, - "Max gas price mismatch" + storedParams.gasConfig.maxBaseFeeMultiplierCapPct, + 20_000, + "Max gas multiplier mismatch" ); } - function testDeactivateSubscription() public { - // First add a subscription - uint256 subscriptionId = addTestSubscription(); + function testUpdateSubscriptionClearsRemovedPriceFeeds() public { + // 1. Setup: Add subscription with 3 price feeds, update prices + uint256 numInitialFeeds = 3; + uint256 subscriptionId = addTestSubscriptionWithFeeds(numInitialFeeds); + uint256 fundAmount = 1 ether; + scheduler.addFunds{value: fundAmount}(subscriptionId); + + bytes32[] memory initialPriceIds = createPriceIds(numInitialFeeds); + uint64 publishTime = SafeCast.toUint64(block.timestamp); + PythStructs.PriceFeed[] memory initialPriceFeeds = createMockPriceFeeds( + publishTime, + numInitialFeeds + ); + mockParsePriceFeedUpdates(pyth, initialPriceFeeds); + bytes[] memory updateData = createMockUpdateData(initialPriceFeeds); + + vm.prank(pusher); + scheduler.updatePriceFeeds(subscriptionId, updateData, initialPriceIds); + + // Verify initial state: All 3 feeds should be readable + assertTrue( + reader.verifyPriceFeeds( + subscriptionId, + initialPriceIds, + initialPriceFeeds + ), + "Initial price feeds verification failed" + ); + + // 2. Action: Update subscription to remove the last price feed + bytes32[] memory newPriceIds = new bytes32[](numInitialFeeds - 1); + for (uint i = 0; i < newPriceIds.length; i++) { + newPriceIds[i] = initialPriceIds[i]; + } + bytes32 removedPriceId = initialPriceIds[numInitialFeeds - 1]; // The ID we removed + + (SchedulerState.SubscriptionParams memory currentParams, ) = scheduler + .getSubscription(subscriptionId); + SchedulerState.SubscriptionParams memory newParams = currentParams; // Copy existing params + newParams.priceIds = newPriceIds; // Update price IDs + + vm.expectEmit(); // Expect SubscriptionUpdated + emit SubscriptionUpdated(subscriptionId); + scheduler.updateSubscription(subscriptionId, newParams); + + // 3. Verification: + // - Querying the removed price ID should revert + bytes32[] memory removedIdArray = new bytes32[](1); + removedIdArray[0] = removedPriceId; + vm.expectRevert( + abi.encodeWithSelector( + InvalidPriceId.selector, + removedPriceId, + bytes32(0) + ) + ); + scheduler.getPricesUnsafe(subscriptionId, removedIdArray); + + // - Querying the remaining price IDs should still work + PythStructs.PriceFeed[] + memory expectedRemainingFeeds = new PythStructs.PriceFeed[]( + newPriceIds.length + ); + for (uint i = 0; i < newPriceIds.length; i++) { + expectedRemainingFeeds[i] = initialPriceFeeds[i]; // Prices remain from the initial update + } + assertTrue( + reader.verifyPriceFeeds( + subscriptionId, + newPriceIds, + expectedRemainingFeeds + ), + "Remaining price feeds verification failed after update" + ); + + // - Querying all feeds (empty array) should return only the remaining feeds + PythStructs.Price[] memory allPricesAfterUpdate = scheduler + .getPricesUnsafe(subscriptionId, new bytes32[](0)); + assertEq( + allPricesAfterUpdate.length, + newPriceIds.length, + "Querying all should only return remaining feeds" + ); + } + + function testcreateSubscriptionWithInsufficientFundsReverts() public { + // Create subscription parameters + bytes32[] memory priceIds = createPriceIds(); + address[] memory readerWhitelist = new address[](1); + readerWhitelist[0] = address(reader); + + SchedulerState.UpdateCriteria memory updateCriteria = SchedulerState + .UpdateCriteria({ + updateOnHeartbeat: true, + heartbeatSeconds: 60, + updateOnDeviation: true, + deviationThresholdBps: 100 + }); + + SchedulerState.GasConfig memory gasConfig = SchedulerState.GasConfig({ + maxBaseFeeMultiplierCapPct: 10_000, + maxPriorityFeeMultiplierCapPct: 10_000 + }); + + SchedulerState.SubscriptionParams memory params = SchedulerState + .SubscriptionParams({ + priceIds: priceIds, + readerWhitelist: readerWhitelist, + whitelistEnabled: true, + isActive: true, + updateCriteria: updateCriteria, + gasConfig: gasConfig + }); + + // Calculate minimum balance + uint256 minimumBalance = scheduler.getMinimumBalance( + uint8(priceIds.length) + ); + + // Try to add subscription with insufficient funds + vm.expectRevert(abi.encodeWithSelector(InsufficientBalance.selector)); + scheduler.createSubscription{value: minimumBalance - 1 wei}(params); + } + + function testActivateDeactivateSubscription() public { + // First add a subscription with minimum balance + bytes32[] memory priceIds = createPriceIds(); + address[] memory readerWhitelist = new address[](1); + readerWhitelist[0] = address(reader); + + SchedulerState.UpdateCriteria memory updateCriteria = SchedulerState + .UpdateCriteria({ + updateOnHeartbeat: true, + heartbeatSeconds: 60, + updateOnDeviation: true, + deviationThresholdBps: 100 + }); + + SchedulerState.GasConfig memory gasConfig = SchedulerState.GasConfig({ + maxBaseFeeMultiplierCapPct: 10_000, + maxPriorityFeeMultiplierCapPct: 10_000 + }); + + SchedulerState.SubscriptionParams memory params = SchedulerState + .SubscriptionParams({ + priceIds: priceIds, + readerWhitelist: readerWhitelist, + whitelistEnabled: true, + isActive: true, + updateCriteria: updateCriteria, + gasConfig: gasConfig + }); + + uint256 minimumBalance = scheduler.getMinimumBalance( + uint8(priceIds.length) + ); + uint256 subscriptionId = scheduler.createSubscription{ + value: minimumBalance + }(params); + + // Deactivate subscription using updateSubscription + params.isActive = false; - // Deactivate subscription vm.expectEmit(); emit SubscriptionDeactivated(subscriptionId); + vm.expectEmit(); + emit SubscriptionUpdated(subscriptionId); - scheduler.deactivateSubscription(subscriptionId); + scheduler.updateSubscription(subscriptionId, params); // Verify subscription was deactivated - (, SchedulerState.SubscriptionStatus memory status) = scheduler - .getSubscription(subscriptionId); + ( + SchedulerState.SubscriptionParams memory storedParams, + SchedulerState.SubscriptionStatus memory status + ) = scheduler.getSubscription(subscriptionId); + + assertFalse(storedParams.isActive, "Subscription should be inactive"); + + // Reactivate subscription using updateSubscription + params.isActive = true; + + vm.expectEmit(); + emit SubscriptionActivated(subscriptionId); + vm.expectEmit(); + emit SubscriptionUpdated(subscriptionId); + + scheduler.updateSubscription(subscriptionId, params); + + // Verify subscription was reactivated + (storedParams, status) = scheduler.getSubscription(subscriptionId); - assertFalse(status.isActive, "Subscription should be inactive"); + assertTrue(storedParams.isActive, "Subscription should be active"); + assertTrue( + storedParams.isActive, + "Subscription params should show active" + ); } function testAddFunds() public { // First add a subscription uint256 subscriptionId = addTestSubscription(); + // Get initial balance (which includes minimum balance) + (, SchedulerState.SubscriptionStatus memory initialStatus) = scheduler + .getSubscription(subscriptionId); + uint256 initialBalance = initialStatus.balanceInWei; + // Add funds uint256 fundAmount = 1 ether; scheduler.addFunds{value: fundAmount}(subscriptionId); @@ -267,23 +477,57 @@ contract SchedulerTest is Test, SchedulerEvents, PulseTestUtils { assertEq( status.balanceInWei, - fundAmount, - "Balance should match added funds" + initialBalance + fundAmount, + "Balance should match initial balance plus added funds" ); } function testWithdrawFunds() public { - // First add a subscription and funds - uint256 subscriptionId = addTestSubscription(); - uint256 fundAmount = 1 ether; - scheduler.addFunds{value: fundAmount}(subscriptionId); + // First add a subscription with minimum balance + bytes32[] memory priceIds = createPriceIds(); + uint256 minimumBalance = scheduler.getMinimumBalance( + uint8(priceIds.length) + ); + + address[] memory readerWhitelist = new address[](1); + readerWhitelist[0] = address(reader); + + SchedulerState.UpdateCriteria memory updateCriteria = SchedulerState + .UpdateCriteria({ + updateOnHeartbeat: true, + heartbeatSeconds: 60, + updateOnDeviation: true, + deviationThresholdBps: 100 + }); + + SchedulerState.GasConfig memory gasConfig = SchedulerState.GasConfig({ + maxBaseFeeMultiplierCapPct: 10_000, + maxPriorityFeeMultiplierCapPct: 10_000 + }); + + SchedulerState.SubscriptionParams memory params = SchedulerState + .SubscriptionParams({ + priceIds: priceIds, + readerWhitelist: readerWhitelist, + whitelistEnabled: true, + isActive: true, + updateCriteria: updateCriteria, + gasConfig: gasConfig + }); + + uint256 subscriptionId = scheduler.createSubscription{ + value: minimumBalance + }(params); + + // Add extra funds + uint256 extraFunds = 1 ether; + scheduler.addFunds{value: extraFunds}(subscriptionId); // Get initial balance uint256 initialBalance = address(this).balance; - // Withdraw half the funds - uint256 withdrawAmount = fundAmount / 2; - scheduler.withdrawFunds(subscriptionId, withdrawAmount); + // Withdraw extra funds + scheduler.withdrawFunds(subscriptionId, extraFunds); // Verify funds were withdrawn (, SchedulerState.SubscriptionStatus memory status) = scheduler @@ -291,14 +535,34 @@ contract SchedulerTest is Test, SchedulerEvents, PulseTestUtils { assertEq( status.balanceInWei, - fundAmount - withdrawAmount, - "Remaining balance incorrect" + minimumBalance, + "Remaining balance should be minimum balance" ); assertEq( address(this).balance, - initialBalance + withdrawAmount, + initialBalance + extraFunds, "Withdrawn amount not received" ); + + // Try to withdraw below minimum balance + vm.expectRevert(abi.encodeWithSelector(InsufficientBalance.selector)); + scheduler.withdrawFunds(subscriptionId, 1 wei); + + // Deactivate subscription + params.isActive = false; + scheduler.updateSubscription(subscriptionId, params); + + // Now we should be able to withdraw all funds + scheduler.withdrawFunds(subscriptionId, minimumBalance); + + // Verify all funds were withdrawn + (, status) = scheduler.getSubscription(subscriptionId); + + assertEq( + status.balanceInWei, + 0, + "Balance should be 0 after withdrawing all funds" + ); } function testUpdatePriceFeedsWorks() public { @@ -405,7 +669,7 @@ contract SchedulerTest is Test, SchedulerEvents, PulseTestUtils { ); } - function testUpdatePriceFeedsRevertsOnUpdateConditionsNotMet_Heartbeat() + function testUpdatePriceFeedsRevertsOnHeartbeatUpdateConditionNotMet() public { // Add a subscription with only heartbeat criteria (60 seconds) @@ -451,7 +715,7 @@ contract SchedulerTest is Test, SchedulerEvents, PulseTestUtils { scheduler.updatePriceFeeds(subscriptionId, updateData2, priceIds); } - function testUpdatePriceFeedsRevertsOnUpdateConditionsNotMet_Deviation() + function testUpdatePriceFeedsRevertsOnDeviationUpdateConditionNotMet() public { // Add a subscription with only deviation criteria (100 bps / 1%) @@ -583,7 +847,7 @@ contract SchedulerTest is Test, SchedulerEvents, PulseTestUtils { scheduler.updatePriceFeeds(subscriptionId, updateData, priceIds); } - function testGetLatestPricesAllFeeds() public { + function testGetPricesUnsafeAllFeeds() public { // First add a subscription, funds, and update price feeds uint256 subscriptionId = addTestSubscription(); uint256 fundAmount = 1 ether; @@ -602,7 +866,7 @@ contract SchedulerTest is Test, SchedulerEvents, PulseTestUtils { // Get all latest prices (empty priceIds array) bytes32[] memory emptyPriceIds = new bytes32[](0); - PythStructs.PriceFeed[] memory latestPrices = scheduler.getLatestPrices( + PythStructs.Price[] memory latestPrices = scheduler.getPricesUnsafe( subscriptionId, emptyPriceIds ); @@ -621,7 +885,7 @@ contract SchedulerTest is Test, SchedulerEvents, PulseTestUtils { ); } - function testGetLatestPricesSelectiveFeeds() public { + function testGetPricesUnsafeSelectiveFeeds() public { // First add a subscription with 3 price feeds, funds, and update price feeds uint256 subscriptionId = addTestSubscriptionWithFeeds(3); uint256 fundAmount = 1 ether; @@ -643,7 +907,7 @@ contract SchedulerTest is Test, SchedulerEvents, PulseTestUtils { bytes32[] memory selectedPriceIds = new bytes32[](1); selectedPriceIds[0] = priceIds[0]; - PythStructs.PriceFeed[] memory latestPrices = scheduler.getLatestPrices( + PythStructs.Price[] memory latestPrices = scheduler.getPricesUnsafe( subscriptionId, selectedPriceIds ); @@ -667,11 +931,9 @@ contract SchedulerTest is Test, SchedulerEvents, PulseTestUtils { ); } - function testOptionalWhitelist() public { + function testDisabledWhitelistAllowsUnrestrictedReads() public { // Add a subscription with whitelistEnabled = false bytes32[] memory priceIds = createPriceIds(); - address[] memory emptyWhitelist = new address[](0); - SchedulerState.UpdateCriteria memory updateCriteria = SchedulerState .UpdateCriteria({ updateOnHeartbeat: true, @@ -681,25 +943,26 @@ contract SchedulerTest is Test, SchedulerEvents, PulseTestUtils { }); SchedulerState.GasConfig memory gasConfig = SchedulerState.GasConfig({ - maxGasPrice: 100 gwei, - maxGasLimit: 1_000_000 + maxBaseFeeMultiplierCapPct: 10_000, + maxPriorityFeeMultiplierCapPct: 10_000 }); SchedulerState.SubscriptionParams memory params = SchedulerState .SubscriptionParams({ priceIds: priceIds, - readerWhitelist: emptyWhitelist, + readerWhitelist: new address[](0), whitelistEnabled: false, // No whitelist + isActive: true, updateCriteria: updateCriteria, gasConfig: gasConfig }); - uint256 subscriptionId = scheduler.addSubscription(params); - - // Update price feeds - uint256 fundAmount = 1 ether; - scheduler.addFunds{value: fundAmount}(subscriptionId); + // Create subscription and fund the subscription with enough to update it + uint256 subscriptionId = scheduler.createSubscription{value: 1 ether}( + params + ); + // Update price feeds for the subscription uint64 publishTime = SafeCast.toUint64(block.timestamp); PythStructs.PriceFeed[] memory priceFeeds = createMockPriceFeeds( publishTime @@ -710,31 +973,166 @@ contract SchedulerTest is Test, SchedulerEvents, PulseTestUtils { vm.prank(pusher); scheduler.updatePriceFeeds(subscriptionId, updateData, priceIds); - // Try to access from a non-whitelisted address + // Try to access from a non-whitelisted address (should succeed) address randomUser = address(0xdead); vm.startPrank(randomUser); bytes32[] memory emptyPriceIds = new bytes32[](0); // Should not revert since whitelist is disabled - // We'll just check that it doesn't revert - scheduler.getLatestPrices(subscriptionId, emptyPriceIds); + scheduler.getPricesUnsafe(subscriptionId, emptyPriceIds); vm.stopPrank(); - // Verify the data is correct + // Verify the data is correct using the test's reader assertTrue( reader.verifyPriceFeeds(subscriptionId, emptyPriceIds, priceFeeds), - "Price feeds verification failed" + "Whitelist Disabled: Price feeds verification failed" ); } + function testEnabledWhitelistEnforcesOnlyAuthorizedReads() public { + // Add a subscription with whitelistEnabled = true + bytes32[] memory priceIds = createPriceIds(2); + address[] memory readerWhitelist = new address[](1); + readerWhitelist[0] = address(reader); // Only the test's reader is whitelisted + + SchedulerState.UpdateCriteria memory updateCriteria = SchedulerState + .UpdateCriteria({ + updateOnHeartbeat: true, + heartbeatSeconds: 60, + updateOnDeviation: true, + deviationThresholdBps: 100 + }); + + SchedulerState.GasConfig memory gasConfig = SchedulerState.GasConfig({ + maxBaseFeeMultiplierCapPct: 10_000, + maxPriorityFeeMultiplierCapPct: 10_000 + }); + + SchedulerState.SubscriptionParams memory params = SchedulerState + .SubscriptionParams({ + priceIds: priceIds, + readerWhitelist: readerWhitelist, + whitelistEnabled: true, // Whitelist IS enabled + isActive: true, + updateCriteria: updateCriteria, + gasConfig: gasConfig + }); + + // Create subscription and fund the subscription with enough to update it + uint256 subscriptionId = scheduler.createSubscription{value: 1 ether}( + params + ); + + // Update price feeds for the subscription + uint64 publishTime = SafeCast.toUint64(block.timestamp + 10); // Slightly different time + PythStructs.PriceFeed[] memory priceFeeds = createMockPriceFeeds( + publishTime, + priceIds.length + ); + mockParsePriceFeedUpdates(pyth, priceFeeds); + bytes[] memory updateData = createMockUpdateData(priceFeeds); + + vm.prank(pusher); + scheduler.updatePriceFeeds(subscriptionId, updateData, priceIds); + + // Try to access from the non-whitelisted address (should fail) + address randomUser = address(0xdead); + address manager = address(this); // Test contract is the manager + vm.startPrank(randomUser); + bytes32[] memory emptyPriceIds = new bytes32[](0); + vm.expectRevert(abi.encodeWithSelector(Unauthorized.selector)); + scheduler.getPricesUnsafe(subscriptionId, emptyPriceIds); + vm.stopPrank(); + + // Try to access from the whitelisted reader address (should succeed) + // Note: We call via the reader contract instance itself + PythStructs.Price[] memory pricesFromReader = reader.getPricesUnsafe( + subscriptionId, + emptyPriceIds + ); + assertEq( + pricesFromReader.length, + priceIds.length, + "Whitelist Enabled: Reader should get correct number of prices" + ); + + // Verify the data obtained by the whitelisted reader is correct + assertTrue( + reader.verifyPriceFeeds(subscriptionId, emptyPriceIds, priceFeeds), + "Whitelist Enabled: Price feeds verification failed via reader" + ); + + // Try to access from the manager address (should succeed) + vm.startPrank(manager); + PythStructs.Price[] memory pricesFromManager = scheduler + .getPricesUnsafe(subscriptionId, emptyPriceIds); + assertEq( + pricesFromManager.length, + priceIds.length, + "Whitelist Enabled: Manager should get correct number of prices" + ); + vm.stopPrank(); + } + + function testGetEmaPriceUnsafe() public { + // First add a subscription, funds, and update price feeds + uint256 subscriptionId = addTestSubscription(); + uint256 fundAmount = 1 ether; + scheduler.addFunds{value: fundAmount}(subscriptionId); + + bytes32[] memory priceIds = createPriceIds(); + uint64 publishTime = SafeCast.toUint64(block.timestamp); + PythStructs.PriceFeed[] memory priceFeeds = createMockPriceFeeds( + publishTime + ); + + // Ensure EMA prices are set in the mock price feeds + for (uint i = 0; i < priceFeeds.length; i++) { + priceFeeds[i].emaPrice.price = priceFeeds[i].price.price * 2; // Make EMA price different for testing + priceFeeds[i].emaPrice.conf = priceFeeds[i].price.conf; + priceFeeds[i].emaPrice.publishTime = publishTime; + priceFeeds[i].emaPrice.expo = priceFeeds[i].price.expo; + } + + mockParsePriceFeedUpdates(pyth, priceFeeds); + bytes[] memory updateData = createMockUpdateData(priceFeeds); + + vm.prank(pusher); + scheduler.updatePriceFeeds(subscriptionId, updateData, priceIds); + + // Get EMA prices + bytes32[] memory emptyPriceIds = new bytes32[](0); + PythStructs.Price[] memory emaPrices = scheduler.getEmaPriceUnsafe( + subscriptionId, + emptyPriceIds + ); + + // Verify all EMA prices were returned + assertEq( + emaPrices.length, + priceIds.length, + "Should return all EMA prices" + ); + + // Verify EMA price values + for (uint i = 0; i < emaPrices.length; i++) { + assertEq( + emaPrices[i].price, + priceFeeds[i].emaPrice.price, + "EMA price value mismatch" + ); + assertEq( + emaPrices[i].publishTime, + priceFeeds[i].emaPrice.publishTime, + "EMA price publish time mismatch" + ); + } + } + function testGetActiveSubscriptions() public { - // Add multiple subscriptions with the test contract as manager + // Add two subscriptions with the test contract as manager addTestSubscription(); addTestSubscription(); - uint256 subscriptionId = addTestSubscription(); - - // Verify we can deactivate our own subscription - scheduler.deactivateSubscription(subscriptionId); // Create a subscription with pusher as manager vm.startPrank(pusher); @@ -750,36 +1148,43 @@ contract SchedulerTest is Test, SchedulerEvents, PulseTestUtils { }); SchedulerState.GasConfig memory gasConfig = SchedulerState.GasConfig({ - maxGasPrice: 100 gwei, - maxGasLimit: 1_000_000 + maxBaseFeeMultiplierCapPct: 10_000, + maxPriorityFeeMultiplierCapPct: 10_000 }); - SchedulerState.SubscriptionParams memory params = SchedulerState + SchedulerState.SubscriptionParams memory pusherParams = SchedulerState .SubscriptionParams({ priceIds: priceIds, readerWhitelist: emptyWhitelist, whitelistEnabled: false, + isActive: true, updateCriteria: updateCriteria, gasConfig: gasConfig }); - scheduler.addSubscription(params); + uint256 minimumBalance = scheduler.getMinimumBalance( + uint8(priceIds.length) + ); + vm.deal(pusher, minimumBalance); + scheduler.createSubscription{value: minimumBalance}(pusherParams); vm.stopPrank(); - // Get active subscriptions - use owner who has admin rights - vm.prank(owner); - ( - uint256[] memory activeIds, - SchedulerState.SubscriptionParams[] memory activeParams - ) = scheduler.getActiveSubscriptions(); + // Get active subscriptions directly - should work without any special permissions + uint256[] memory activeIds; + SchedulerState.SubscriptionParams[] memory activeParams; + uint256 totalCount; + + (activeIds, activeParams, totalCount) = scheduler + .getActiveSubscriptions(0, 10); - // Verify active subscriptions + // We added 3 subscriptions and all should be active assertEq(activeIds.length, 3, "Should have 3 active subscriptions"); assertEq( activeParams.length, 3, "Should have 3 active subscription params" ); + assertEq(totalCount, 3, "Total count should be 3"); // Verify subscription params for (uint i = 0; i < activeIds.length; i++) { @@ -800,6 +1205,42 @@ contract SchedulerTest is Test, SchedulerEvents, PulseTestUtils { "Heartbeat seconds mismatch" ); } + + // Test pagination - get only the first subscription + vm.prank(owner); + (uint256[] memory firstPageIds, , uint256 firstPageTotal) = scheduler + .getActiveSubscriptions(0, 1); + + assertEq( + firstPageIds.length, + 1, + "Should have 1 subscription in first page" + ); + assertEq(firstPageTotal, 3, "Total count should still be 3"); + + // Test pagination - get the second page + vm.prank(owner); + (uint256[] memory secondPageIds, , uint256 secondPageTotal) = scheduler + .getActiveSubscriptions(1, 2); + + assertEq( + secondPageIds.length, + 2, + "Should have 2 subscriptions in second page" + ); + assertEq(secondPageTotal, 3, "Total count should still be 3"); + + // Test pagination - start index beyond total count + vm.prank(owner); + (uint256[] memory emptyPageIds, , uint256 emptyPageTotal) = scheduler + .getActiveSubscriptions(10, 10); + + assertEq( + emptyPageIds.length, + 0, + "Should have 0 subscriptions when start index is beyond total" + ); + assertEq(emptyPageTotal, 3, "Total count should still be 3"); } // Helper function to add a test subscription @@ -817,8 +1258,8 @@ contract SchedulerTest is Test, SchedulerEvents, PulseTestUtils { }); SchedulerState.GasConfig memory gasConfig = SchedulerState.GasConfig({ - maxGasPrice: 100 gwei, - maxGasLimit: 1_000_000 + maxBaseFeeMultiplierCapPct: 10_000, + maxPriorityFeeMultiplierCapPct: 10_000 }); SchedulerState.SubscriptionParams memory params = SchedulerState @@ -826,11 +1267,15 @@ contract SchedulerTest is Test, SchedulerEvents, PulseTestUtils { priceIds: priceIds, readerWhitelist: readerWhitelist, whitelistEnabled: true, + isActive: true, updateCriteria: updateCriteria, gasConfig: gasConfig }); - return scheduler.addSubscription(params); + uint256 minimumBalance = scheduler.getMinimumBalance( + uint8(priceIds.length) + ); + return scheduler.createSubscription{value: minimumBalance}(params); } // Helper function to add a test subscription with variable number of feeds @@ -850,8 +1295,8 @@ contract SchedulerTest is Test, SchedulerEvents, PulseTestUtils { }); SchedulerState.GasConfig memory gasConfig = SchedulerState.GasConfig({ - maxGasPrice: 100 gwei, - maxGasLimit: 1_000_000 + maxBaseFeeMultiplierCapPct: 10_000, + maxPriorityFeeMultiplierCapPct: 10_000 }); SchedulerState.SubscriptionParams memory params = SchedulerState @@ -859,11 +1304,15 @@ contract SchedulerTest is Test, SchedulerEvents, PulseTestUtils { priceIds: priceIds, readerWhitelist: readerWhitelist, whitelistEnabled: true, + isActive: true, updateCriteria: updateCriteria, gasConfig: gasConfig }); - return scheduler.addSubscription(params); + uint256 minimumBalance = scheduler.getMinimumBalance( + uint8(priceIds.length) + ); + return scheduler.createSubscription{value: minimumBalance}(params); } // Helper function to add a test subscription with specific update criteria @@ -875,8 +1324,8 @@ contract SchedulerTest is Test, SchedulerEvents, PulseTestUtils { readerWhitelist[0] = address(reader); SchedulerState.GasConfig memory gasConfig = SchedulerState.GasConfig({ - maxGasPrice: 100 gwei, - maxGasLimit: 1_000_000 + maxBaseFeeMultiplierCapPct: 10_000, + maxPriorityFeeMultiplierCapPct: 10_000 }); SchedulerState.SubscriptionParams memory params = SchedulerState @@ -884,11 +1333,15 @@ contract SchedulerTest is Test, SchedulerEvents, PulseTestUtils { priceIds: priceIds, readerWhitelist: readerWhitelist, whitelistEnabled: true, + isActive: true, updateCriteria: updateCriteria, // Use provided criteria gasConfig: gasConfig }); - return scheduler.addSubscription(params); + uint256 minimumBalance = scheduler.getMinimumBalance( + uint8(priceIds.length) + ); + return scheduler.createSubscription{value: minimumBalance}(params); } // Required to receive ETH when withdrawing funds