Skip to content

Commit 50b341f

Browse files
committed
feat(scheduler): enhance price update logic and validation
- Updated price feed parsing to allow for a timestamp range of [-10s, now]. - Introduced validation for update conditions, including checks for heartbeat and price deviation. - Added new error handling for outdated timestamps and unmet update conditions. - Refactored subscription status to use a uint256 for last updated timestamp. - Expanded test coverage for update conditions and validation scenarios.
1 parent 7605555 commit 50b341f

File tree

4 files changed

+396
-37
lines changed

4 files changed

+396
-37
lines changed

target_chains/ethereum/contracts/contracts/pulse/scheduler/Scheduler.sol

Lines changed: 123 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
pragma solidity ^0.8.0;
44

55
import "@openzeppelin/contracts/utils/math/SafeCast.sol";
6+
import "@openzeppelin/contracts/utils/math/SignedMath.sol";
7+
import "@openzeppelin/contracts/utils/math/Math.sol";
68
import "@pythnetwork/pyth-sdk-solidity/IPyth.sol";
79
import "./IScheduler.sol";
810
import "./SchedulerState.sol";
@@ -168,34 +170,143 @@ abstract contract Scheduler is IScheduler, SchedulerState {
168170
revert InsufficientBalance();
169171
}
170172

171-
// Parse price feed updates with the same timestamp for all feeds
172-
uint64 publishTime = SafeCast.toUint64(block.timestamp);
173+
// Parse price feed updates with an expected timestamp range of [-10s, now]
174+
// We will validate the trigger conditions and timestamps ourselves
175+
// using the returned PriceFeeds.
176+
uint64 maxPublishTime = SafeCast.toUint64(block.timestamp);
177+
uint64 minPublishTime = maxPublishTime - 10 seconds;
173178
PythStructs.PriceFeed[] memory priceFeeds = pyth.parsePriceFeedUpdates{
174179
value: pythFee
175-
}(updateData, priceIds, publishTime, publishTime);
180+
}(updateData, priceIds, minPublishTime, maxPublishTime);
176181

177182
// Verify all price feeds have the same timestamp
178-
uint64 timestamp = SafeCast.toUint64(priceFeeds[0].price.publishTime);
183+
uint256 timestamp = priceFeeds[0].price.publishTime;
179184
for (uint8 i = 1; i < priceFeeds.length; i++) {
180-
if (
181-
SafeCast.toUint64(priceFeeds[i].price.publishTime) != timestamp
182-
) {
185+
if (priceFeeds[i].price.publishTime != timestamp) {
183186
revert PriceTimestampMismatch();
184187
}
185188
}
186189

190+
// Verify that update conditions are met, and that the timestamp
191+
// is more recent than latest stored update's. Reverts if not.
192+
_validateShouldUpdatePrices(subscriptionId, params, status, priceFeeds);
193+
194+
// Store the price updates, update status, and emit event
195+
_storePriceUpdatesAndStatus(
196+
subscriptionId,
197+
status,
198+
priceFeeds,
199+
pythFee
200+
);
201+
}
202+
203+
/**
204+
* @notice Stores the price updates, updates subscription status, and emits event.
205+
*/
206+
function _storePriceUpdatesAndStatus(
207+
uint256 subscriptionId,
208+
SubscriptionStatus storage status,
209+
PythStructs.PriceFeed[] memory priceFeeds,
210+
uint256 pythFee
211+
) internal {
187212
// Store the price updates
188213
for (uint8 i = 0; i < priceFeeds.length; i++) {
189-
_state.priceUpdates[subscriptionId][priceIds[i]] = priceFeeds[i];
214+
_state.priceUpdates[subscriptionId][priceFeeds[i].id] = priceFeeds[
215+
i
216+
];
190217
}
191-
192-
// Update subscription status
193-
status.priceLastUpdatedAt = timestamp;
218+
status.priceLastUpdatedAt = priceFeeds[0].price.publishTime;
194219
status.balanceInWei -= pythFee;
195220
status.totalUpdates += 1;
196221
status.totalSpent += pythFee;
197222

198-
emit PricesUpdated(subscriptionId, timestamp);
223+
emit PricesUpdated(subscriptionId, priceFeeds[0].price.publishTime);
224+
}
225+
226+
/**
227+
* @notice Validates whether the update trigger criteria is met for a subscription. Reverts if not met.
228+
* @dev This function assumes that all updates in priceFeeds have the same timestamp. The caller is expected to enforce this invariant.
229+
* @param subscriptionId The ID of the subscription (needed for reading previous prices).
230+
* @param params The subscription's parameters struct.
231+
* @param status The subscription's status struct.
232+
* @param priceFeeds The array of price feeds to validate.
233+
*/
234+
function _validateShouldUpdatePrices(
235+
uint256 subscriptionId,
236+
SubscriptionParams storage params,
237+
SubscriptionStatus storage status,
238+
PythStructs.PriceFeed[] memory priceFeeds
239+
) internal view returns (bool) {
240+
// SECURITY NOTE: this check assumes that all updates in priceFeeds have the same timestamp.
241+
// The caller is expected to enforce this invariant.
242+
uint256 updateTimestamp = priceFeeds[0].price.publishTime;
243+
244+
// Reject updates if they're older than the latest stored ones
245+
if (
246+
status.priceLastUpdatedAt > 0 &&
247+
updateTimestamp <= status.priceLastUpdatedAt
248+
) {
249+
revert TimestampOlderThanLastUpdate(
250+
updateTimestamp,
251+
status.priceLastUpdatedAt
252+
);
253+
}
254+
255+
// If updateOnHeartbeat is enabled and the heartbeat interval has passed, trigger update
256+
if (params.updateCriteria.updateOnHeartbeat) {
257+
uint256 lastUpdateTime = status.priceLastUpdatedAt;
258+
259+
if (
260+
lastUpdateTime == 0 ||
261+
updateTimestamp >=
262+
lastUpdateTime + params.updateCriteria.heartbeatSeconds
263+
) {
264+
return true;
265+
}
266+
}
267+
268+
// If updateOnDeviation is enabled, check if any price has deviated enough
269+
if (params.updateCriteria.updateOnDeviation) {
270+
for (uint8 i = 0; i < priceFeeds.length; i++) {
271+
// Get the previous price feed for this price ID using subscriptionId
272+
PythStructs.PriceFeed storage previousFeed = _state
273+
.priceUpdates[subscriptionId][priceFeeds[i].id];
274+
275+
// If there's no previous price, this is the first update
276+
if (previousFeed.id == bytes32(0)) {
277+
return true;
278+
}
279+
280+
// Calculate the deviation percentage
281+
int64 currentPrice = priceFeeds[i].price.price;
282+
int64 previousPrice = previousFeed.price.price;
283+
284+
// Skip if either price is zero to avoid division by zero
285+
if (previousPrice == 0 || currentPrice == 0) {
286+
continue;
287+
}
288+
289+
// Calculate absolute deviation basis points (scaled by 1e4)
290+
uint256 numerator = SignedMath.abs(
291+
currentPrice - previousPrice
292+
);
293+
uint256 denominator = SignedMath.abs(previousPrice);
294+
uint256 deviationBps = Math.mulDiv(
295+
numerator,
296+
10_000,
297+
denominator
298+
);
299+
300+
// If deviation exceeds threshold, trigger update
301+
if (
302+
deviationBps >= params.updateCriteria.deviationThresholdBps
303+
) {
304+
return true;
305+
}
306+
}
307+
}
308+
309+
revert UpdateConditionsNotMet();
199310
}
200311

201312
function getLatestPrices(

target_chains/ethereum/contracts/contracts/pulse/scheduler/SchedulerErrors.sol

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,8 @@ error InvalidUpdateCriteria();
1111
error InvalidGasConfig();
1212
error PriceTimestampMismatch();
1313
error TooManyPriceIds(uint256 provided, uint256 maximum);
14+
error UpdateConditionsNotMet();
15+
error TimestampOlderThanLastUpdate(
16+
uint256 providedUpdateTimestamp,
17+
uint256 lastUpdatedAt
18+
);

target_chains/ethereum/contracts/contracts/pulse/scheduler/SchedulerState.sol

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ contract SchedulerState {
3333
}
3434

3535
struct SubscriptionStatus {
36-
uint64 priceLastUpdatedAt;
36+
uint256 priceLastUpdatedAt;
3737
uint256 balanceInWei;
3838
uint256 totalUpdates;
3939
uint256 totalSpent;
@@ -57,8 +57,8 @@ contract SchedulerState {
5757

5858
// TODO: add updateOnConfidenceRatio?
5959

60-
// TODO: add "early update" support? i.e. update all feeds when at least one feed
60+
// TODO: add explicit "early update" support? i.e. update all feeds when at least one feed
6161
// meets the triggering conditions, rather than waiting for all feeds
62-
// to meet the conditions
62+
// to meet the conditions. Currently, "early update" is the only mode of operation.
6363
}
6464
}

0 commit comments

Comments
 (0)