Skip to content

Commit ea7eb99

Browse files
committed
fix: use slots (not timestamp) to verify atomic sub update
1 parent b475f1a commit ea7eb99

File tree

5 files changed

+147
-80
lines changed

5 files changed

+147
-80
lines changed

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

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -239,20 +239,21 @@ abstract contract Scheduler is IScheduler, SchedulerState {
239239
// Parse price feed updates with an expected timestamp range of [-10s, now]
240240
// We will validate the trigger conditions and timestamps ourselves
241241
// using the returned PriceFeeds.
242-
uint64 maxPublishTime = SafeCast.toUint64(block.timestamp) +
243-
FUTURE_TIMESTAMP_GRACE_PERIOD;
244-
uint64 minPublishTime = maxPublishTime - PAST_TIMESTAMP_GRACE_PERIOD;
242+
uint64 curTime = SafeCast.toUint64(block.timestamp);
243+
uint64 maxPublishTime = curTime + FUTURE_TIMESTAMP_MAX_VALIDITY_PERIOD;
244+
uint64 minPublishTime = curTime - PAST_TIMESTAMP_MAX_VALIDITY_PERIOD;
245245
PythStructs.PriceFeed[] memory priceFeeds;
246246
uint64[] memory slots;
247247
(priceFeeds, slots) = pyth.parsePriceFeedUpdatesWithSlots{
248248
value: pythFee
249249
}(updateData, priceIds, minPublishTime, maxPublishTime);
250250

251-
// Verify all price feeds have the same Pythnet slot
251+
// Verify all price feeds have the same Pythnet slot.
252+
// All feeds in a subscription must be updated at the same time.
252253
uint64 slot = slots[0];
253254
for (uint8 i = 1; i < slots.length; i++) {
254255
if (slots[i] != slot) {
255-
revert PriceTimestampMismatch();
256+
revert PriceSlotMismatch();
256257
}
257258
}
258259

@@ -294,7 +295,6 @@ abstract contract Scheduler is IScheduler, SchedulerState {
294295

295296
/**
296297
* @notice Validates whether the update trigger criteria is met for a subscription. Reverts if not met.
297-
* @dev This function assumes that all updates in priceFeeds have the same timestamp. The caller is expected to enforce this invariant.
298298
* @param subscriptionId The ID of the subscription (needed for reading previous prices).
299299
* @param params The subscription's parameters struct.
300300
* @param status The subscription's status struct.
@@ -306,9 +306,16 @@ abstract contract Scheduler is IScheduler, SchedulerState {
306306
SubscriptionStatus storage status,
307307
PythStructs.PriceFeed[] memory priceFeeds
308308
) internal view returns (bool) {
309-
// SECURITY NOTE: this check assumes that all updates in priceFeeds have the same timestamp.
310-
// The caller is expected to enforce this invariant.
311-
uint256 updateTimestamp = priceFeeds[0].price.publishTime;
309+
// Use the most recent timestamp, as some asset markets may be closed.
310+
// Closed markets will have a publishTime from their last trading period.
311+
// Since we verify all updates share the same Pythnet slot, we still ensure
312+
// that all price feeds are synchronized from the same update cycle.
313+
uint256 updateTimestamp = 0;
314+
for (uint8 i = 0; i < priceFeeds.length; i++) {
315+
if (priceFeeds[i].price.publishTime > updateTimestamp) {
316+
updateTimestamp = priceFeeds[i].price.publishTime;
317+
}
318+
}
312319

313320
// Reject updates if they're older than the latest stored ones
314321
if (

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ error InvalidPriceId(bytes32 providedPriceId, bytes32 expectedPriceId);
99
error InvalidPriceIdsLength(bytes32 providedLength, bytes32 expectedLength);
1010
error InvalidUpdateCriteria();
1111
error InvalidGasConfig();
12-
error PriceTimestampMismatch();
12+
error PriceSlotMismatch();
1313
error TooManyPriceIds(uint256 provided, uint256 maximum);
1414
error UpdateConditionsNotMet();
1515
error TimestampOlderThanLastUpdate(

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,13 @@ contract SchedulerState {
1212
/// Default max fee multiplier
1313
uint32 public constant DEFAULT_MAX_PRIORITY_FEE_MULTIPLIER_CAP_PCT = 10_000;
1414

15-
uint256 public constant PAST_TIMESTAMP_GRACE_PERIOD = 1 hours;
16-
uint256 public constant FUTURE_TIMESTAMP_GRACE_PERIOD = 10 seconds;
15+
// TODO: make these updateable via governance
16+
/// Maximum time in the past (relative to current block timestamp)
17+
/// for which a price update timestamp is considered valid
18+
uint64 public constant PAST_TIMESTAMP_MAX_VALIDITY_PERIOD = 1 hours;
19+
/// Maximum time in the future (relative to current block timestamp)
20+
/// for which a price update timestamp is considered valid
21+
uint64 public constant FUTURE_TIMESTAMP_MAX_VALIDITY_PERIOD = 10 seconds;
1722

1823
struct State {
1924
/// Monotonically increasing counter for subscription IDs

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

Lines changed: 76 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -88,15 +88,15 @@ contract SchedulerTest is Test, SchedulerEvents, PulseTestUtils {
8888

8989
reader = new MockReader(address(proxy));
9090

91-
// Start tests at timestamp 100 to avoid underflow when we set
92-
// `minPublishTime = timestamp - 10 seconds` in updatePriceFeeds
93-
vm.warp(100);
91+
// Start tests at a high timestamp to avoid underflow when we set
92+
// `minPublishTime = timestamp - 1 hour` in updatePriceFeeds
93+
vm.warp(100000);
9494

9595
// Give pusher 100 ETH for testing
9696
vm.deal(pusher, 100 ether);
9797
}
9898

99-
function testcreateSubscription() public {
99+
function testCreateSubscription() public {
100100
// Create subscription parameters
101101
bytes32[] memory priceIds = createPriceIds();
102102
address[] memory readerWhitelist = new address[](1);
@@ -269,11 +269,14 @@ contract SchedulerTest is Test, SchedulerEvents, PulseTestUtils {
269269

270270
bytes32[] memory initialPriceIds = createPriceIds(numInitialFeeds);
271271
uint64 publishTime = SafeCast.toUint64(block.timestamp);
272-
PythStructs.PriceFeed[] memory initialPriceFeeds = createMockPriceFeeds(
272+
PythStructs.PriceFeed[] memory initialPriceFeeds;
273+
uint64[] memory slots;
274+
(initialPriceFeeds, slots) = createMockPriceFeedsWithSlots(
273275
publishTime,
274276
numInitialFeeds
275277
);
276-
mockParsePriceFeedUpdates(pyth, initialPriceFeeds);
278+
279+
mockParsePriceFeedUpdatesWithSlots(pyth, initialPriceFeeds, slots);
277280
bytes[] memory updateData = createMockUpdateData(initialPriceFeeds);
278281

279282
vm.prank(pusher);
@@ -575,10 +578,14 @@ contract SchedulerTest is Test, SchedulerEvents, PulseTestUtils {
575578
// Create price feeds and mock Pyth response for first update
576579
bytes32[] memory priceIds = createPriceIds();
577580
uint64 publishTime1 = SafeCast.toUint64(block.timestamp);
578-
PythStructs.PriceFeed[] memory priceFeeds1 = createMockPriceFeeds(
579-
publishTime1
581+
PythStructs.PriceFeed[] memory priceFeeds1;
582+
uint64[] memory slots;
583+
(priceFeeds1, slots) = createMockPriceFeedsWithSlots(
584+
publishTime1,
585+
priceIds.length
580586
);
581-
mockParsePriceFeedUpdates(pyth, priceFeeds1);
587+
588+
mockParsePriceFeedUpdatesWithSlots(pyth, priceFeeds1, slots);
582589
bytes[] memory updateData1 = createMockUpdateData(priceFeeds1);
583590

584591
// Perform first update
@@ -630,7 +637,7 @@ contract SchedulerTest is Test, SchedulerEvents, PulseTestUtils {
630637
priceFeeds2[i].emaPrice.publishTime = publishTime2;
631638
}
632639

633-
mockParsePriceFeedUpdates(pyth, priceFeeds2); // Mock for the second call
640+
mockParsePriceFeedUpdatesWithSlots(pyth, priceFeeds2, slots); // Mock for the second call
634641
bytes[] memory updateData2 = createMockUpdateData(priceFeeds2);
635642

636643
// Perform second update
@@ -686,25 +693,24 @@ contract SchedulerTest is Test, SchedulerEvents, PulseTestUtils {
686693
);
687694
uint256 fundAmount = 1 ether;
688695
scheduler.addFunds{value: fundAmount}(subscriptionId);
689-
690696
// First update to set initial timestamp
691697
bytes32[] memory priceIds = createPriceIds();
692698
uint64 publishTime1 = SafeCast.toUint64(block.timestamp);
693-
PythStructs.PriceFeed[] memory priceFeeds1 = createMockPriceFeeds(
694-
publishTime1
695-
);
696-
mockParsePriceFeedUpdates(pyth, priceFeeds1);
699+
PythStructs.PriceFeed[] memory priceFeeds1;
700+
uint64[] memory slots1;
701+
(priceFeeds1, slots1) = createMockPriceFeedsWithSlots(publishTime1, 2);
702+
mockParsePriceFeedUpdatesWithSlots(pyth, priceFeeds1, slots1);
697703
bytes[] memory updateData1 = createMockUpdateData(priceFeeds1);
698704
vm.prank(pusher);
699705
scheduler.updatePriceFeeds(subscriptionId, updateData1, priceIds);
700706

701707
// Prepare second update within heartbeat interval
702708
vm.warp(block.timestamp + 30); // Advance time by 30 seconds (less than 60)
703709
uint64 publishTime2 = SafeCast.toUint64(block.timestamp);
704-
PythStructs.PriceFeed[] memory priceFeeds2 = createMockPriceFeeds(
705-
publishTime2 // Same prices, just new timestamp
706-
);
707-
mockParsePriceFeedUpdates(pyth, priceFeeds2); // Mock the response for the second update
710+
PythStructs.PriceFeed[] memory priceFeeds2;
711+
uint64[] memory slots2;
712+
(priceFeeds2, slots2) = createMockPriceFeedsWithSlots(publishTime2, 2);
713+
mockParsePriceFeedUpdatesWithSlots(pyth, priceFeeds2, slots2);
708714
bytes[] memory updateData2 = createMockUpdateData(priceFeeds2);
709715

710716
// Expect revert because heartbeat condition is not met
@@ -736,10 +742,10 @@ contract SchedulerTest is Test, SchedulerEvents, PulseTestUtils {
736742
// First update to set initial price
737743
bytes32[] memory priceIds = createPriceIds();
738744
uint64 publishTime1 = SafeCast.toUint64(block.timestamp);
739-
PythStructs.PriceFeed[] memory priceFeeds1 = createMockPriceFeeds(
740-
publishTime1
741-
);
742-
mockParsePriceFeedUpdates(pyth, priceFeeds1);
745+
PythStructs.PriceFeed[] memory priceFeeds1;
746+
uint64[] memory slots;
747+
(priceFeeds1, slots) = createMockPriceFeedsWithSlots(publishTime1, 2);
748+
mockParsePriceFeedUpdatesWithSlots(pyth, priceFeeds1, slots);
743749
bytes[] memory updateData1 = createMockUpdateData(priceFeeds1);
744750
vm.prank(pusher);
745751
scheduler.updatePriceFeeds(subscriptionId, updateData1, priceIds);
@@ -765,7 +771,7 @@ contract SchedulerTest is Test, SchedulerEvents, PulseTestUtils {
765771
priceFeeds2[i].price.publishTime = publishTime2;
766772
}
767773

768-
mockParsePriceFeedUpdates(pyth, priceFeeds2);
774+
mockParsePriceFeedUpdatesWithSlots(pyth, priceFeeds2, slots);
769775
bytes[] memory updateData2 = createMockUpdateData(priceFeeds2);
770776

771777
// Expect revert because deviation condition is not met
@@ -785,22 +791,22 @@ contract SchedulerTest is Test, SchedulerEvents, PulseTestUtils {
785791
// First update to establish last updated timestamp
786792
bytes32[] memory priceIds = createPriceIds();
787793
uint64 publishTime1 = SafeCast.toUint64(block.timestamp);
788-
PythStructs.PriceFeed[] memory priceFeeds1 = createMockPriceFeeds(
789-
publishTime1
790-
);
791-
mockParsePriceFeedUpdates(pyth, priceFeeds1);
794+
PythStructs.PriceFeed[] memory priceFeeds1;
795+
uint64[] memory slots1;
796+
(priceFeeds1, slots1) = createMockPriceFeedsWithSlots(publishTime1, 2);
797+
mockParsePriceFeedUpdatesWithSlots(pyth, priceFeeds1, slots1);
792798
bytes[] memory updateData1 = createMockUpdateData(priceFeeds1);
793799

794800
vm.prank(pusher);
795801
scheduler.updatePriceFeeds(subscriptionId, updateData1, priceIds);
796802

797803
// Prepare second update with an older timestamp
798804
uint64 publishTime2 = publishTime1 - 10; // Timestamp older than the first update
799-
PythStructs.PriceFeed[] memory priceFeeds2 = createMockPriceFeeds(
800-
publishTime2
801-
);
805+
PythStructs.PriceFeed[] memory priceFeeds2;
806+
uint64[] memory slots2;
807+
(priceFeeds2, slots2) = createMockPriceFeedsWithSlots(publishTime2, 2);
802808
// Mock Pyth response to return feeds with the older timestamp
803-
mockParsePriceFeedUpdates(pyth, priceFeeds2);
809+
mockParsePriceFeedUpdatesWithSlots(pyth, priceFeeds2, slots2);
804810
bytes[] memory updateData2 = createMockUpdateData(priceFeeds2);
805811

806812
// Expect revert with TimestampOlderThanLastUpdate (checked in _validateShouldUpdatePrices)
@@ -817,30 +823,32 @@ contract SchedulerTest is Test, SchedulerEvents, PulseTestUtils {
817823
scheduler.updatePriceFeeds(subscriptionId, updateData2, priceIds);
818824
}
819825

820-
function testUpdatePriceFeedsRevertsOnMismatchedTimestamps() public {
826+
function testUpdatePriceFeedsRevertsOnMismatchedSlots() public {
821827
// First add a subscription and funds
822828
uint256 subscriptionId = addTestSubscription();
823829
uint256 fundAmount = 1 ether;
824830
scheduler.addFunds{value: fundAmount}(subscriptionId);
825831

826-
// Create two price feeds with mismatched timestamps
832+
// Create two price feeds with same timestamp but different slots
827833
bytes32[] memory priceIds = createPriceIds(2);
828-
uint64 time1 = SafeCast.toUint64(block.timestamp);
829-
uint64 time2 = time1 + 10;
834+
uint64 publishTime = SafeCast.toUint64(block.timestamp);
830835
PythStructs.PriceFeed[] memory priceFeeds = new PythStructs.PriceFeed[](
831836
2
832837
);
833-
priceFeeds[0] = createSingleMockPriceFeed(time1);
834-
priceFeeds[1] = createSingleMockPriceFeed(time2);
838+
priceFeeds[0] = createSingleMockPriceFeed(publishTime);
839+
priceFeeds[1] = createSingleMockPriceFeed(publishTime);
835840

836-
// Mock Pyth response to return these feeds
837-
mockParsePriceFeedUpdates(pyth, priceFeeds);
838-
bytes[] memory updateData = createMockUpdateData(priceFeeds); // Data needs to match expected length
841+
// Create slots array with different slot values
842+
uint64[] memory slots = new uint64[](2);
843+
slots[0] = 100;
844+
slots[1] = 200; // Different slot
839845

840-
// Expect revert with PriceTimestampMismatch error
841-
vm.expectRevert(
842-
abi.encodeWithSelector(PriceTimestampMismatch.selector)
843-
);
846+
// Mock Pyth response to return these feeds with mismatched slots
847+
mockParsePriceFeedUpdatesWithSlots(pyth, priceFeeds, slots);
848+
bytes[] memory updateData = createMockUpdateData(priceFeeds);
849+
850+
// Expect revert with PriceSlotMismatch error
851+
vm.expectRevert(abi.encodeWithSelector(PriceSlotMismatch.selector));
844852

845853
// Attempt to update price feeds
846854
vm.prank(pusher);
@@ -855,10 +863,10 @@ contract SchedulerTest is Test, SchedulerEvents, PulseTestUtils {
855863

856864
bytes32[] memory priceIds = createPriceIds();
857865
uint64 publishTime = SafeCast.toUint64(block.timestamp);
858-
PythStructs.PriceFeed[] memory priceFeeds = createMockPriceFeeds(
859-
publishTime
860-
);
861-
mockParsePriceFeedUpdates(pyth, priceFeeds);
866+
PythStructs.PriceFeed[] memory priceFeeds;
867+
uint64[] memory slots;
868+
(priceFeeds, slots) = createMockPriceFeedsWithSlots(publishTime, 2);
869+
mockParsePriceFeedUpdatesWithSlots(pyth, priceFeeds, slots);
862870
bytes[] memory updateData = createMockUpdateData(priceFeeds);
863871

864872
vm.prank(pusher);
@@ -893,11 +901,10 @@ contract SchedulerTest is Test, SchedulerEvents, PulseTestUtils {
893901

894902
bytes32[] memory priceIds = createPriceIds(3);
895903
uint64 publishTime = SafeCast.toUint64(block.timestamp);
896-
PythStructs.PriceFeed[] memory priceFeeds = createMockPriceFeeds(
897-
publishTime,
898-
3
899-
);
900-
mockParsePriceFeedUpdates(pyth, priceFeeds);
904+
PythStructs.PriceFeed[] memory priceFeeds;
905+
uint64[] memory slots;
906+
(priceFeeds, slots) = createMockPriceFeedsWithSlots(publishTime, 3);
907+
mockParsePriceFeedUpdatesWithSlots(pyth, priceFeeds, slots);
901908
bytes[] memory updateData = createMockUpdateData(priceFeeds);
902909

903910
vm.prank(pusher);
@@ -964,10 +971,10 @@ contract SchedulerTest is Test, SchedulerEvents, PulseTestUtils {
964971

965972
// Update price feeds for the subscription
966973
uint64 publishTime = SafeCast.toUint64(block.timestamp);
967-
PythStructs.PriceFeed[] memory priceFeeds = createMockPriceFeeds(
968-
publishTime
969-
);
970-
mockParsePriceFeedUpdates(pyth, priceFeeds);
974+
PythStructs.PriceFeed[] memory priceFeeds;
975+
uint64[] memory slots;
976+
(priceFeeds, slots) = createMockPriceFeedsWithSlots(publishTime, 2);
977+
mockParsePriceFeedUpdatesWithSlots(pyth, priceFeeds, slots);
971978
bytes[] memory updateData = createMockUpdateData(priceFeeds);
972979

973980
vm.prank(pusher);
@@ -1025,20 +1032,20 @@ contract SchedulerTest is Test, SchedulerEvents, PulseTestUtils {
10251032

10261033
// Update price feeds for the subscription
10271034
uint64 publishTime = SafeCast.toUint64(block.timestamp + 10); // Slightly different time
1028-
PythStructs.PriceFeed[] memory priceFeeds = createMockPriceFeeds(
1035+
PythStructs.PriceFeed[] memory priceFeeds;
1036+
uint64[] memory slots;
1037+
(priceFeeds, slots) = createMockPriceFeedsWithSlots(
10291038
publishTime,
10301039
priceIds.length
10311040
);
1032-
mockParsePriceFeedUpdates(pyth, priceFeeds);
1041+
mockParsePriceFeedUpdatesWithSlots(pyth, priceFeeds, slots);
10331042
bytes[] memory updateData = createMockUpdateData(priceFeeds);
10341043

10351044
vm.prank(pusher);
10361045
scheduler.updatePriceFeeds(subscriptionId, updateData, priceIds);
10371046

10381047
// Try to access from the non-whitelisted address (should fail)
1039-
address randomUser = address(0xdead);
1040-
address manager = address(this); // Test contract is the manager
1041-
vm.startPrank(randomUser);
1048+
vm.startPrank(address(0xdead));
10421049
bytes32[] memory emptyPriceIds = new bytes32[](0);
10431050
vm.expectRevert(abi.encodeWithSelector(Unauthorized.selector));
10441051
scheduler.getPricesUnsafe(subscriptionId, emptyPriceIds);
@@ -1063,7 +1070,8 @@ contract SchedulerTest is Test, SchedulerEvents, PulseTestUtils {
10631070
);
10641071

10651072
// Try to access from the manager address (should succeed)
1066-
vm.startPrank(manager);
1073+
// Test contract is the manager
1074+
vm.startPrank(address(this));
10671075
PythStructs.Price[] memory pricesFromManager = scheduler
10681076
.getPricesUnsafe(subscriptionId, emptyPriceIds);
10691077
assertEq(
@@ -1082,9 +1090,9 @@ contract SchedulerTest is Test, SchedulerEvents, PulseTestUtils {
10821090

10831091
bytes32[] memory priceIds = createPriceIds();
10841092
uint64 publishTime = SafeCast.toUint64(block.timestamp);
1085-
PythStructs.PriceFeed[] memory priceFeeds = createMockPriceFeeds(
1086-
publishTime
1087-
);
1093+
PythStructs.PriceFeed[] memory priceFeeds;
1094+
uint64[] memory slots;
1095+
(priceFeeds, slots) = createMockPriceFeedsWithSlots(publishTime, 2);
10881096

10891097
// Ensure EMA prices are set in the mock price feeds
10901098
for (uint i = 0; i < priceFeeds.length; i++) {
@@ -1094,7 +1102,7 @@ contract SchedulerTest is Test, SchedulerEvents, PulseTestUtils {
10941102
priceFeeds[i].emaPrice.expo = priceFeeds[i].price.expo;
10951103
}
10961104

1097-
mockParsePriceFeedUpdates(pyth, priceFeeds);
1105+
mockParsePriceFeedUpdatesWithSlots(pyth, priceFeeds, slots);
10981106
bytes[] memory updateData = createMockUpdateData(priceFeeds);
10991107

11001108
vm.prank(pusher);

0 commit comments

Comments
 (0)