diff --git a/target_chains/ethereum/contracts/contracts/pulse/Scheduler.sol b/target_chains/ethereum/contracts/contracts/pulse/Scheduler.sol index 1df288e1f1..dcbd007046 100644 --- a/target_chains/ethereum/contracts/contracts/pulse/Scheduler.sol +++ b/target_chains/ethereum/contracts/contracts/pulse/Scheduler.sol @@ -136,12 +136,17 @@ abstract contract Scheduler is IScheduler, SchedulerState { } // Clear price updates for removed price IDs before updating params - _clearRemovedPriceUpdates( + bool newPriceIdsAdded = _clearRemovedPriceUpdates( subscriptionId, currentParams.priceIds, newParams.priceIds ); + // Reset priceLastUpdatedAt to 0 if new price IDs were added + if (newPriceIdsAdded) { + _state.subscriptionStatuses[subscriptionId].priceLastUpdatedAt = 0; + } + // Update subscription parameters _state.subscriptionParams[subscriptionId] = newParams; @@ -216,12 +221,13 @@ abstract contract Scheduler is IScheduler, SchedulerState { * @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. + * @return newPriceIdsAdded True if any new price IDs were added, false otherwise. */ function _clearRemovedPriceUpdates( uint256 subscriptionId, bytes32[] storage currentPriceIds, bytes32[] memory newPriceIds - ) internal { + ) internal returns (bool newPriceIdsAdded) { // Iterate through old price IDs for (uint i = 0; i < currentPriceIds.length; i++) { bytes32 oldPriceId = currentPriceIds[i]; @@ -240,6 +246,28 @@ abstract contract Scheduler is IScheduler, SchedulerState { delete _state.priceUpdates[subscriptionId][oldPriceId]; } } + + // Check if any new price IDs were added + for (uint i = 0; i < newPriceIds.length; i++) { + bytes32 newPriceId = newPriceIds[i]; + bool found = false; + + // Check if the new price ID exists in the current list + for (uint j = 0; j < currentPriceIds.length; j++) { + if (currentPriceIds[j] == newPriceId) { + found = true; + break; + } + } + + // If a new price ID was added, mark as changed + if (!found) { + newPriceIdsAdded = true; + break; + } + } + + return newPriceIdsAdded; } function updatePriceFeeds( diff --git a/target_chains/ethereum/contracts/forge-test/PulseScheduler.t.sol b/target_chains/ethereum/contracts/forge-test/PulseScheduler.t.sol index 4ee6730070..8e9d9e7456 100644 --- a/target_chains/ethereum/contracts/forge-test/PulseScheduler.t.sol +++ b/target_chains/ethereum/contracts/forge-test/PulseScheduler.t.sol @@ -332,6 +332,118 @@ contract SchedulerTest is Test, SchedulerEvents, PulseSchedulerTestUtils { ); } + // Helper function to reduce stack depth in testUpdateSubscriptionResetsPriceLastUpdatedAt + function _setupSubscriptionAndFirstUpdate() + private + returns (uint256 subscriptionId, uint64 publishTime) + { + // Setup subscription with heartbeat criteria + uint32 heartbeatSeconds = 60; // 60 second heartbeat + SchedulerState.UpdateCriteria memory criteria = SchedulerState + .UpdateCriteria({ + updateOnHeartbeat: true, + heartbeatSeconds: heartbeatSeconds, + updateOnDeviation: false, + deviationThresholdBps: 0 + }); + + subscriptionId = addTestSubscriptionWithUpdateCriteria( + scheduler, + criteria, + address(reader) + ); + scheduler.addFunds{value: 1 ether}(subscriptionId); + + // Update prices to set priceLastUpdatedAt to a non-zero value + publishTime = SafeCast.toUint64(block.timestamp); + PythStructs.PriceFeed[] memory priceFeeds; + uint64[] memory slots; + (priceFeeds, slots) = createMockPriceFeedsWithSlots(publishTime, 2); + mockParsePriceFeedUpdatesWithSlotsStrict(pyth, priceFeeds, slots); + bytes[] memory updateData = createMockUpdateData(priceFeeds); + + vm.prank(pusher); + scheduler.updatePriceFeeds(subscriptionId, updateData); + + return (subscriptionId, publishTime); + } + + function testUpdateSubscriptionResetsPriceLastUpdatedAt() public { + // 1. Setup subscription and perform first update + ( + uint256 subscriptionId, + uint64 publishTime1 + ) = _setupSubscriptionAndFirstUpdate(); + + // Verify priceLastUpdatedAt is set + (, SchedulerState.SubscriptionStatus memory status) = scheduler + .getSubscription(subscriptionId); + assertEq( + status.priceLastUpdatedAt, + publishTime1, + "priceLastUpdatedAt should be set to the first update timestamp" + ); + + // 2. Update subscription to add price IDs + (SchedulerState.SubscriptionParams memory currentParams, ) = scheduler + .getSubscription(subscriptionId); + bytes32[] memory newPriceIds = createPriceIds(3); + + SchedulerState.SubscriptionParams memory newParams = currentParams; + newParams.priceIds = newPriceIds; + + // Update the subscription + scheduler.updateSubscription(subscriptionId, newParams); + + // 3. Verify priceLastUpdatedAt is reset to 0 + (, status) = scheduler.getSubscription(subscriptionId); + assertEq( + status.priceLastUpdatedAt, + 0, + "priceLastUpdatedAt should be reset to 0 after adding new price IDs" + ); + + // 4. Verify immediate update is possible + _verifyImmediateUpdatePossible(subscriptionId); + } + + function _verifyImmediateUpdatePossible(uint256 subscriptionId) private { + // Create new price feeds for the new price IDs + uint64 publishTime2 = SafeCast.toUint64(block.timestamp + 1); // Just 1 second later + PythStructs.PriceFeed[] memory priceFeeds; + uint64[] memory slots; + (priceFeeds, slots) = createMockPriceFeedsWithSlots(publishTime2, 3); // 3 feeds for new price IDs + mockParsePriceFeedUpdatesWithSlotsStrict(pyth, priceFeeds, slots); + bytes[] memory updateData = createMockUpdateData(priceFeeds); + + // This should succeed even though we haven't waited for heartbeatSeconds + // because priceLastUpdatedAt was reset to 0 + vm.prank(pusher); + scheduler.updatePriceFeeds(subscriptionId, updateData); + + // Verify the update was processed + (, SchedulerState.SubscriptionStatus memory status) = scheduler + .getSubscription(subscriptionId); + assertEq( + status.priceLastUpdatedAt, + publishTime2, + "Second update should be processed with new timestamp" + ); + + // Verify that normal heartbeat criteria apply again for subsequent updates + uint64 publishTime3 = SafeCast.toUint64(block.timestamp + 10); // Only 10 seconds later + (priceFeeds, slots) = createMockPriceFeedsWithSlots(publishTime3, 3); + mockParsePriceFeedUpdatesWithSlotsStrict(pyth, priceFeeds, slots); + updateData = createMockUpdateData(priceFeeds); + + // This should fail because we haven't waited for heartbeatSeconds since the last update + vm.expectRevert( + abi.encodeWithSelector(UpdateConditionsNotMet.selector) + ); + vm.prank(pusher); + scheduler.updatePriceFeeds(subscriptionId, updateData); + } + function testcreateSubscriptionWithInsufficientFundsReverts() public { uint8 numFeeds = 2; SchedulerState.SubscriptionParams