Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
40 changes: 38 additions & 2 deletions target_chains/ethereum/contracts/contracts/pulse/Scheduler.sol
Original file line number Diff line number Diff line change
Expand Up @@ -131,12 +131,17 @@ abstract contract Scheduler is IScheduler, SchedulerState {
}

// Clear price updates for removed price IDs before updating params
_clearRemovedPriceUpdates(
bool priceIdsChanged = _clearRemovedPriceUpdates(
subscriptionId,
currentParams.priceIds,
newParams.priceIds
);

// Reset priceLastUpdatedAt to 0 if price IDs have changed
if (priceIdsChanged) {
_state.subscriptionStatuses[subscriptionId].priceLastUpdatedAt = 0;
}

Copy link
Contributor

Choose a reason for hiding this comment

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

It would be more accurate to reset priceLastUpdatedAt only when new prices are added (not just on any change to price ids.)

// Update subscription parameters
_state.subscriptionParams[subscriptionId] = newParams;

Expand Down Expand Up @@ -211,12 +216,18 @@ 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 priceIdsChanged True if the price IDs list has changed (additions or removals), false otherwise.
*/
function _clearRemovedPriceUpdates(
uint256 subscriptionId,
bytes32[] storage currentPriceIds,
bytes32[] memory newPriceIds
) internal {
) internal returns (bool priceIdsChanged) {
// Check if the arrays have different lengths, which means the price IDs have changed
if (currentPriceIds.length != newPriceIds.length) {
priceIdsChanged = true;
}

// Iterate through old price IDs
for (uint i = 0; i < currentPriceIds.length; i++) {
bytes32 oldPriceId = currentPriceIds[i];
Expand All @@ -233,8 +244,33 @@ abstract contract Scheduler is IScheduler, SchedulerState {
// If not found in the new list, delete its stored update data
if (!found) {
delete _state.priceUpdates[subscriptionId][oldPriceId];
priceIdsChanged = true;
}
}

// Check if any new price IDs were added
if (!priceIdsChanged) {
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) {
priceIdsChanged = true;
break;
}
}
}

return priceIdsChanged;
}

function updatePriceFeeds(
Expand Down
114 changes: 114 additions & 0 deletions target_chains/ethereum/contracts/forge-test/PulseScheduler.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,120 @@ 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);
mockParsePriceFeedUpdatesWithSlots(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 change price IDs
(SchedulerState.SubscriptionParams memory currentParams, ) = scheduler
.getSubscription(subscriptionId);

// Create new price IDs (different from the original ones)
bytes32[] memory newPriceIds = createPriceIds(3); // Different number of price IDs

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 changing 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
mockParsePriceFeedUpdatesWithSlots(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);
mockParsePriceFeedUpdatesWithSlots(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
Expand Down
Loading