Skip to content

Commit 80d3bf3

Browse files
fix(pulse): validate freshness using the latest timestamp in the update data (#2635)
* fix: validate min ts using highest ts in the update data * doc: comment * merge * test: fix tests after merge
1 parent b44aea5 commit 80d3bf3

File tree

5 files changed

+142
-18
lines changed

5 files changed

+142
-18
lines changed

apps/argus/Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

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

Lines changed: 30 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -271,18 +271,20 @@ abstract contract Scheduler is IScheduler, SchedulerState {
271271
revert InsufficientBalance();
272272
}
273273

274-
// Parse the price feed updates with an acceptable timestamp range of [-1h, +10s] from now.
275-
// We will validate the trigger conditions ourselves.
274+
// Parse the price feed updates with an acceptable timestamp range of [0, now+10s].
275+
// Note: We don't want to reject update data if it contains a price
276+
// from a market that closed a few days ago, since it will contain a timestamp
277+
// from the last trading period. Thus, we use a minimum timestamp of zero while parsing,
278+
// and we enforce the past max validity ourselves in _validateShouldUpdatePrices using
279+
// the highest timestamp in the update data.
276280
uint64 curTime = SafeCast.toUint64(block.timestamp);
277281
(
278282
PythStructs.PriceFeed[] memory priceFeeds,
279283
uint64[] memory slots
280284
) = pyth.parsePriceFeedUpdatesWithSlots{value: pythFee}(
281285
updateData,
282286
params.priceIds,
283-
curTime > PAST_TIMESTAMP_MAX_VALIDITY_PERIOD
284-
? curTime - PAST_TIMESTAMP_MAX_VALIDITY_PERIOD
285-
: 0,
287+
0, // We enforce the past max validity ourselves in _validateShouldUpdatePrices
286288
curTime + FUTURE_TIMESTAMP_MAX_VALIDITY_PERIOD
287289
);
288290
status.balanceInWei -= pythFee;
@@ -299,15 +301,14 @@ abstract contract Scheduler is IScheduler, SchedulerState {
299301

300302
// Verify that update conditions are met, and that the timestamp
301303
// is more recent than latest stored update's. Reverts if not.
302-
_validateShouldUpdatePrices(subscriptionId, params, status, priceFeeds);
304+
uint256 latestPublishTime = _validateShouldUpdatePrices(
305+
subscriptionId,
306+
params,
307+
status,
308+
priceFeeds
309+
);
303310

304311
// Update status and store the updates
305-
uint256 latestPublishTime = 0; // Use the most recent publish time from the validated feeds
306-
for (uint8 i = 0; i < priceFeeds.length; i++) {
307-
if (priceFeeds[i].price.publishTime > latestPublishTime) {
308-
latestPublishTime = priceFeeds[i].price.publishTime;
309-
}
310-
}
311312
status.priceLastUpdatedAt = latestPublishTime;
312313
status.totalUpdates += priceFeeds.length;
313314

@@ -324,13 +325,14 @@ abstract contract Scheduler is IScheduler, SchedulerState {
324325
* @param params The subscription's parameters struct.
325326
* @param status The subscription's status struct.
326327
* @param priceFeeds The array of price feeds to validate.
328+
* @return The timestamp of the update if the trigger criteria is met, reverts if not met.
327329
*/
328330
function _validateShouldUpdatePrices(
329331
uint256 subscriptionId,
330332
SubscriptionParams storage params,
331333
SubscriptionStatus storage status,
332334
PythStructs.PriceFeed[] memory priceFeeds
333-
) internal view returns (bool) {
335+
) internal view returns (uint256) {
334336
// Use the most recent timestamp, as some asset markets may be closed.
335337
// Closed markets will have a publishTime from their last trading period.
336338
// Since we verify all updates share the same Pythnet slot, we still ensure
@@ -342,6 +344,18 @@ abstract contract Scheduler is IScheduler, SchedulerState {
342344
}
343345
}
344346

347+
// Calculate the minimum acceptable timestamp (clamped at 0)
348+
// The maximum acceptable timestamp is enforced by the parsePriceFeedUpdatesWithSlots call
349+
uint256 minAllowedTimestamp = (block.timestamp >
350+
PAST_TIMESTAMP_MAX_VALIDITY_PERIOD)
351+
? (block.timestamp - PAST_TIMESTAMP_MAX_VALIDITY_PERIOD)
352+
: 0;
353+
354+
// Validate that the update timestamp is not too old
355+
if (updateTimestamp < minAllowedTimestamp) {
356+
revert TimestampTooOld(updateTimestamp, block.timestamp);
357+
}
358+
345359
// Reject updates if they're older than the latest stored ones
346360
if (
347361
status.priceLastUpdatedAt > 0 &&
@@ -362,7 +376,7 @@ abstract contract Scheduler is IScheduler, SchedulerState {
362376
updateTimestamp >=
363377
lastUpdateTime + params.updateCriteria.heartbeatSeconds
364378
) {
365-
return true;
379+
return updateTimestamp;
366380
}
367381
}
368382

@@ -375,7 +389,7 @@ abstract contract Scheduler is IScheduler, SchedulerState {
375389

376390
// If there's no previous price, this is the first update
377391
if (previousFeed.id == bytes32(0)) {
378-
return true;
392+
return updateTimestamp;
379393
}
380394

381395
// Calculate the deviation percentage
@@ -402,7 +416,7 @@ abstract contract Scheduler is IScheduler, SchedulerState {
402416
if (
403417
deviationBps >= params.updateCriteria.deviationThresholdBps
404418
) {
405-
return true;
419+
return updateTimestamp;
406420
}
407421
}
408422
}

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ error PriceSlotMismatch();
2121
// Update criteria errors
2222
error InvalidUpdateCriteria();
2323
error UpdateConditionsNotMet();
24+
error TimestampTooOld(
25+
uint256 providedUpdateTimestamp,
26+
uint256 currentTimestamp
27+
);
2428
error TimestampOlderThanLastUpdate(
2529
uint256 providedUpdateTimestamp,
2630
uint256 lastUpdatedAt

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,16 @@ contract SchedulerState {
1010
/// Maximum number of addresses in the reader whitelist
1111
uint8 public constant MAX_READER_WHITELIST_SIZE = 255;
1212

13-
// TODO: make these updateable via governance
1413
/// Maximum time in the past (relative to current block timestamp)
1514
/// for which a price update timestamp is considered valid
15+
/// when validating the update conditions.
16+
/// @dev Note: We don't use this when parsing update data from the Pyth contract
17+
/// because don't want to reject update data if it contains a price from a market
18+
/// that closed a few days ago, since it will contain a timestamp from the last
19+
/// trading period. We enforce this value ourselves against the maximum
20+
/// timestamp in the provided update data.
1621
uint64 public constant PAST_TIMESTAMP_MAX_VALIDITY_PERIOD = 1 hours;
22+
1723
/// Maximum time in the future (relative to current block timestamp)
1824
/// for which a price update timestamp is considered valid
1925
uint64 public constant FUTURE_TIMESTAMP_MAX_VALIDITY_PERIOD = 10 seconds;

target_chains/ethereum/contracts/forge-test/PulseScheduler.t.sol

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1825,6 +1825,106 @@ contract SchedulerTest is Test, SchedulerEvents, PulseSchedulerTestUtils {
18251825
scheduler.updateSubscription(initialSubId, invalidDeviationParams);
18261826
}
18271827

1828+
function testUpdatePriceFeedsSucceedsWithStaleFeedIfLatestIsValid() public {
1829+
// Add a subscription and funds
1830+
uint256 subscriptionId = addTestSubscription(
1831+
scheduler,
1832+
address(reader)
1833+
);
1834+
1835+
// Advance time past the validity period
1836+
vm.warp(
1837+
block.timestamp +
1838+
scheduler.PAST_TIMESTAMP_MAX_VALIDITY_PERIOD() +
1839+
600
1840+
); // Warp 1 hour 10 mins
1841+
1842+
uint64 currentTime = SafeCast.toUint64(block.timestamp);
1843+
uint64 validPublishTime = currentTime - 1800; // 30 mins ago (within 1 hour validity)
1844+
uint64 stalePublishTime = currentTime -
1845+
(scheduler.PAST_TIMESTAMP_MAX_VALIDITY_PERIOD() + 300); // 1 hour 5 mins ago (outside validity)
1846+
1847+
PythStructs.PriceFeed[] memory priceFeeds = new PythStructs.PriceFeed[](
1848+
2
1849+
);
1850+
priceFeeds[0] = createSingleMockPriceFeed(stalePublishTime);
1851+
priceFeeds[1] = createSingleMockPriceFeed(validPublishTime);
1852+
1853+
uint64[] memory slots = new uint64[](2);
1854+
slots[0] = 100;
1855+
slots[1] = 100; // Same slot
1856+
1857+
// Mock Pyth response (should succeed in the real world as minValidTime is 0)
1858+
mockParsePriceFeedUpdatesWithSlots(pyth, priceFeeds, slots);
1859+
bytes[] memory updateData = createMockUpdateData(priceFeeds);
1860+
1861+
// Expect PricesUpdated event with the latest valid timestamp
1862+
vm.expectEmit();
1863+
emit PricesUpdated(subscriptionId, validPublishTime);
1864+
1865+
// Perform update - should succeed because the latest timestamp in the update data is valid
1866+
vm.prank(pusher);
1867+
scheduler.updatePriceFeeds(subscriptionId, updateData);
1868+
1869+
// Verify last updated timestamp
1870+
(, SchedulerState.SubscriptionStatus memory status) = scheduler
1871+
.getSubscription(subscriptionId);
1872+
assertEq(
1873+
status.priceLastUpdatedAt,
1874+
validPublishTime,
1875+
"Last updated timestamp should be the latest valid one"
1876+
);
1877+
}
1878+
1879+
function testUpdatePriceFeedsRevertsIfLatestTimestampIsTooOld() public {
1880+
// Add a subscription and funds
1881+
uint256 subscriptionId = addTestSubscription(
1882+
scheduler,
1883+
address(reader)
1884+
);
1885+
1886+
// Advance time past the validity period
1887+
vm.warp(
1888+
block.timestamp +
1889+
scheduler.PAST_TIMESTAMP_MAX_VALIDITY_PERIOD() +
1890+
600
1891+
); // Warp 1 hour 10 mins
1892+
1893+
uint64 currentTime = SafeCast.toUint64(block.timestamp);
1894+
// Make the *latest* timestamp too old
1895+
uint64 stalePublishTime1 = currentTime -
1896+
(scheduler.PAST_TIMESTAMP_MAX_VALIDITY_PERIOD() + 300); // 1 hour 5 mins ago
1897+
uint64 stalePublishTime2 = currentTime -
1898+
(scheduler.PAST_TIMESTAMP_MAX_VALIDITY_PERIOD() + 600); // 1 hour 10 mins ago
1899+
1900+
PythStructs.PriceFeed[] memory priceFeeds = new PythStructs.PriceFeed[](
1901+
2
1902+
);
1903+
priceFeeds[0] = createSingleMockPriceFeed(stalePublishTime2); // Oldest
1904+
priceFeeds[1] = createSingleMockPriceFeed(stalePublishTime1); // Latest, but still too old
1905+
1906+
uint64[] memory slots = new uint64[](2);
1907+
slots[0] = 100;
1908+
slots[1] = 100; // Same slot
1909+
1910+
// Mock Pyth response (should succeed in the real world as minValidTime is 0)
1911+
mockParsePriceFeedUpdatesWithSlots(pyth, priceFeeds, slots);
1912+
bytes[] memory updateData = createMockUpdateData(priceFeeds);
1913+
1914+
// Expect revert with TimestampTooOld (checked in _validateShouldUpdatePrices)
1915+
vm.expectRevert(
1916+
abi.encodeWithSelector(
1917+
TimestampTooOld.selector,
1918+
stalePublishTime1, // The latest timestamp from the update
1919+
currentTime
1920+
)
1921+
);
1922+
1923+
// Attempt to update price feeds
1924+
vm.prank(pusher);
1925+
scheduler.updatePriceFeeds(subscriptionId, updateData);
1926+
}
1927+
18281928
// Required to receive ETH when withdrawing funds
18291929
receive() external payable {}
18301930
}

0 commit comments

Comments
 (0)