Skip to content

fix(pulse): reset priceLastUpdatedAt when price IDs are added in updateSubscription #2674

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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
32 changes: 30 additions & 2 deletions target_chains/ethereum/contracts/contracts/pulse/Scheduler.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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];
Expand All @@ -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(
Expand Down
112 changes: 112 additions & 0 deletions target_chains/ethereum/contracts/forge-test/PulseScheduler.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading