PulseScheduler.t.sol 42 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187
  1. // SPDX-License-Identifier: Apache 2
  2. pragma solidity ^0.8.0;
  3. import "forge-std/Test.sol";
  4. import "forge-std/console.sol";
  5. import "@pythnetwork/pyth-sdk-solidity/IPyth.sol";
  6. import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
  7. import "./utils/PulseTestUtils.t.sol";
  8. import "../contracts/pulse/scheduler/SchedulerUpgradeable.sol";
  9. import "../contracts/pulse/scheduler/IScheduler.sol";
  10. import "../contracts/pulse/scheduler/SchedulerState.sol";
  11. import "../contracts/pulse/scheduler/SchedulerEvents.sol";
  12. import "../contracts/pulse/scheduler/SchedulerErrors.sol";
  13. contract MockReader {
  14. address private _scheduler;
  15. constructor(address scheduler) {
  16. _scheduler = scheduler;
  17. }
  18. function getPricesUnsafe(
  19. uint256 subscriptionId,
  20. bytes32[] memory priceIds
  21. ) external view returns (PythStructs.Price[] memory) {
  22. return IScheduler(_scheduler).getPricesUnsafe(subscriptionId, priceIds);
  23. }
  24. function getEmaPriceUnsafe(
  25. uint256 subscriptionId,
  26. bytes32[] memory priceIds
  27. ) external view returns (PythStructs.Price[] memory) {
  28. return
  29. IScheduler(_scheduler).getEmaPriceUnsafe(subscriptionId, priceIds);
  30. }
  31. function verifyPriceFeeds(
  32. uint256 subscriptionId,
  33. bytes32[] memory priceIds,
  34. PythStructs.PriceFeed[] memory expectedFeeds
  35. ) external view returns (bool) {
  36. PythStructs.Price[] memory actualPrices = IScheduler(_scheduler)
  37. .getPricesUnsafe(subscriptionId, priceIds);
  38. if (actualPrices.length != expectedFeeds.length) {
  39. return false;
  40. }
  41. for (uint i = 0; i < actualPrices.length; i++) {
  42. if (
  43. actualPrices[i].price != expectedFeeds[i].price.price ||
  44. actualPrices[i].conf != expectedFeeds[i].price.conf ||
  45. actualPrices[i].publishTime !=
  46. expectedFeeds[i].price.publishTime
  47. ) {
  48. return false;
  49. }
  50. }
  51. return true;
  52. }
  53. }
  54. contract SchedulerTest is Test, SchedulerEvents, PulseTestUtils {
  55. ERC1967Proxy public proxy;
  56. SchedulerUpgradeable public scheduler;
  57. MockReader public reader;
  58. address public owner;
  59. address public admin;
  60. address public pyth;
  61. address public pusher;
  62. // Constants
  63. uint96 constant PYTH_FEE = 1 wei;
  64. function setUp() public {
  65. owner = address(1);
  66. admin = address(2);
  67. pyth = address(3);
  68. pusher = address(4);
  69. SchedulerUpgradeable _scheduler = new SchedulerUpgradeable();
  70. proxy = new ERC1967Proxy(address(_scheduler), "");
  71. scheduler = SchedulerUpgradeable(address(proxy));
  72. scheduler.initialize(owner, admin, pyth);
  73. reader = new MockReader(address(proxy));
  74. // Start tests at timestamp 100 to avoid underflow when we set
  75. // `minPublishTime = timestamp - 10 seconds` in updatePriceFeeds
  76. vm.warp(100);
  77. // Give pusher 100 ETH for testing
  78. vm.deal(pusher, 100 ether);
  79. }
  80. function testAddSubscription() public {
  81. // Create subscription parameters
  82. bytes32[] memory priceIds = createPriceIds();
  83. address[] memory readerWhitelist = new address[](1);
  84. readerWhitelist[0] = address(reader);
  85. SchedulerState.UpdateCriteria memory updateCriteria = SchedulerState
  86. .UpdateCriteria({
  87. updateOnHeartbeat: true,
  88. heartbeatSeconds: 60,
  89. updateOnDeviation: true,
  90. deviationThresholdBps: 100
  91. });
  92. SchedulerState.GasConfig memory gasConfig = SchedulerState.GasConfig({
  93. maxGasMultiplierCapPct: 10_000,
  94. maxFeeMultiplierCapPct: 10_000
  95. });
  96. SchedulerState.SubscriptionParams memory params = SchedulerState
  97. .SubscriptionParams({
  98. priceIds: priceIds,
  99. readerWhitelist: readerWhitelist,
  100. whitelistEnabled: true,
  101. isActive: true,
  102. updateCriteria: updateCriteria,
  103. gasConfig: gasConfig
  104. });
  105. // Calculate minimum balance
  106. uint256 minimumBalance = scheduler.getMinimumBalance(
  107. uint8(priceIds.length)
  108. );
  109. // Add subscription with minimum balance
  110. vm.expectEmit();
  111. emit SubscriptionCreated(1, address(this));
  112. uint256 subscriptionId = scheduler.addSubscription{
  113. value: minimumBalance
  114. }(params);
  115. assertEq(subscriptionId, 1, "Subscription ID should be 1");
  116. // Verify subscription was added correctly
  117. (
  118. SchedulerState.SubscriptionParams memory storedParams,
  119. SchedulerState.SubscriptionStatus memory status
  120. ) = scheduler.getSubscription(subscriptionId);
  121. assertEq(
  122. storedParams.priceIds.length,
  123. priceIds.length,
  124. "Price IDs length mismatch"
  125. );
  126. assertEq(
  127. storedParams.readerWhitelist.length,
  128. readerWhitelist.length,
  129. "Whitelist length mismatch"
  130. );
  131. assertEq(
  132. storedParams.whitelistEnabled,
  133. true,
  134. "whitelistEnabled should be true"
  135. );
  136. assertTrue(storedParams.isActive, "Subscription should be active");
  137. assertEq(
  138. storedParams.updateCriteria.heartbeatSeconds,
  139. 60,
  140. "Heartbeat seconds mismatch"
  141. );
  142. assertEq(
  143. storedParams.updateCriteria.deviationThresholdBps,
  144. 100,
  145. "Deviation threshold mismatch"
  146. );
  147. assertEq(
  148. storedParams.gasConfig.maxGasMultiplierCapPct,
  149. 10_000,
  150. "Max gas multiplier mismatch"
  151. );
  152. assertEq(
  153. status.balanceInWei,
  154. minimumBalance,
  155. "Initial balance should match minimum balance"
  156. );
  157. }
  158. function testUpdateSubscription() public {
  159. // First add a subscription
  160. uint256 subscriptionId = addTestSubscription();
  161. // Create updated parameters
  162. bytes32[] memory newPriceIds = createPriceIds(3); // Add one more price ID
  163. address[] memory newReaderWhitelist = new address[](2);
  164. newReaderWhitelist[0] = address(reader);
  165. newReaderWhitelist[1] = address(0x123);
  166. SchedulerState.UpdateCriteria memory newUpdateCriteria = SchedulerState
  167. .UpdateCriteria({
  168. updateOnHeartbeat: true,
  169. heartbeatSeconds: 120, // Changed from 60
  170. updateOnDeviation: true,
  171. deviationThresholdBps: 200 // Changed from 100
  172. });
  173. SchedulerState.GasConfig memory newGasConfig = SchedulerState
  174. .GasConfig({
  175. maxGasMultiplierCapPct: 20_000, // Changed from 10_000
  176. maxFeeMultiplierCapPct: 20_000 // Changed from 10_000
  177. });
  178. SchedulerState.SubscriptionParams memory newParams = SchedulerState
  179. .SubscriptionParams({
  180. priceIds: newPriceIds,
  181. readerWhitelist: newReaderWhitelist,
  182. whitelistEnabled: false, // Changed from true
  183. isActive: true,
  184. updateCriteria: newUpdateCriteria,
  185. gasConfig: newGasConfig
  186. });
  187. // Update subscription
  188. vm.expectEmit();
  189. emit SubscriptionUpdated(subscriptionId);
  190. scheduler.updateSubscription(subscriptionId, newParams);
  191. // Verify subscription was updated correctly
  192. (SchedulerState.SubscriptionParams memory storedParams, ) = scheduler
  193. .getSubscription(subscriptionId);
  194. assertEq(
  195. storedParams.priceIds.length,
  196. newPriceIds.length,
  197. "Price IDs length mismatch"
  198. );
  199. assertEq(
  200. storedParams.readerWhitelist.length,
  201. newReaderWhitelist.length,
  202. "Whitelist length mismatch"
  203. );
  204. assertEq(
  205. storedParams.whitelistEnabled,
  206. false,
  207. "whitelistEnabled should be false"
  208. );
  209. assertEq(
  210. storedParams.updateCriteria.heartbeatSeconds,
  211. 120,
  212. "Heartbeat seconds mismatch"
  213. );
  214. assertEq(
  215. storedParams.updateCriteria.deviationThresholdBps,
  216. 200,
  217. "Deviation threshold mismatch"
  218. );
  219. assertEq(
  220. storedParams.gasConfig.maxGasMultiplierCapPct,
  221. 20_000,
  222. "Max gas multiplier mismatch"
  223. );
  224. }
  225. function testAddSubscriptionWithInsufficientFundsReverts() public {
  226. // Create subscription parameters
  227. bytes32[] memory priceIds = createPriceIds();
  228. address[] memory readerWhitelist = new address[](1);
  229. readerWhitelist[0] = address(reader);
  230. SchedulerState.UpdateCriteria memory updateCriteria = SchedulerState
  231. .UpdateCriteria({
  232. updateOnHeartbeat: true,
  233. heartbeatSeconds: 60,
  234. updateOnDeviation: true,
  235. deviationThresholdBps: 100
  236. });
  237. SchedulerState.GasConfig memory gasConfig = SchedulerState.GasConfig({
  238. maxGasMultiplierCapPct: 10_000,
  239. maxFeeMultiplierCapPct: 10_000
  240. });
  241. SchedulerState.SubscriptionParams memory params = SchedulerState
  242. .SubscriptionParams({
  243. priceIds: priceIds,
  244. readerWhitelist: readerWhitelist,
  245. whitelistEnabled: true,
  246. isActive: true,
  247. updateCriteria: updateCriteria,
  248. gasConfig: gasConfig
  249. });
  250. // Calculate minimum balance
  251. uint256 minimumBalance = scheduler.getMinimumBalance(
  252. uint8(priceIds.length)
  253. );
  254. // Try to add subscription with insufficient funds
  255. vm.expectRevert(abi.encodeWithSelector(InsufficientBalance.selector));
  256. scheduler.addSubscription{value: minimumBalance - 1 wei}(params);
  257. }
  258. function testActivateDeactivateSubscription() public {
  259. // First add a subscription with minimum balance
  260. bytes32[] memory priceIds = createPriceIds();
  261. address[] memory readerWhitelist = new address[](1);
  262. readerWhitelist[0] = address(reader);
  263. SchedulerState.UpdateCriteria memory updateCriteria = SchedulerState
  264. .UpdateCriteria({
  265. updateOnHeartbeat: true,
  266. heartbeatSeconds: 60,
  267. updateOnDeviation: true,
  268. deviationThresholdBps: 100
  269. });
  270. SchedulerState.GasConfig memory gasConfig = SchedulerState.GasConfig({
  271. maxGasMultiplierCapPct: 10_000,
  272. maxFeeMultiplierCapPct: 10_000
  273. });
  274. SchedulerState.SubscriptionParams memory params = SchedulerState
  275. .SubscriptionParams({
  276. priceIds: priceIds,
  277. readerWhitelist: readerWhitelist,
  278. whitelistEnabled: true,
  279. isActive: true,
  280. updateCriteria: updateCriteria,
  281. gasConfig: gasConfig
  282. });
  283. uint256 minimumBalance = scheduler.getMinimumBalance(
  284. uint8(priceIds.length)
  285. );
  286. uint256 subscriptionId = scheduler.addSubscription{
  287. value: minimumBalance
  288. }(params);
  289. // Deactivate subscription using updateSubscription
  290. params.isActive = false;
  291. vm.expectEmit();
  292. emit SubscriptionDeactivated(subscriptionId);
  293. vm.expectEmit();
  294. emit SubscriptionUpdated(subscriptionId);
  295. scheduler.updateSubscription(subscriptionId, params);
  296. // Verify subscription was deactivated
  297. (
  298. SchedulerState.SubscriptionParams memory storedParams,
  299. SchedulerState.SubscriptionStatus memory status
  300. ) = scheduler.getSubscription(subscriptionId);
  301. assertFalse(storedParams.isActive, "Subscription should be inactive");
  302. // Reactivate subscription using updateSubscription
  303. params.isActive = true;
  304. vm.expectEmit();
  305. emit SubscriptionActivated(subscriptionId);
  306. vm.expectEmit();
  307. emit SubscriptionUpdated(subscriptionId);
  308. scheduler.updateSubscription(subscriptionId, params);
  309. // Verify subscription was reactivated
  310. (storedParams, status) = scheduler.getSubscription(subscriptionId);
  311. assertTrue(storedParams.isActive, "Subscription should be active");
  312. assertTrue(
  313. storedParams.isActive,
  314. "Subscription params should show active"
  315. );
  316. }
  317. function testAddFunds() public {
  318. // First add a subscription
  319. uint256 subscriptionId = addTestSubscription();
  320. // Get initial balance (which includes minimum balance)
  321. (, SchedulerState.SubscriptionStatus memory initialStatus) = scheduler
  322. .getSubscription(subscriptionId);
  323. uint256 initialBalance = initialStatus.balanceInWei;
  324. // Add funds
  325. uint256 fundAmount = 1 ether;
  326. scheduler.addFunds{value: fundAmount}(subscriptionId);
  327. // Verify funds were added
  328. (, SchedulerState.SubscriptionStatus memory status) = scheduler
  329. .getSubscription(subscriptionId);
  330. assertEq(
  331. status.balanceInWei,
  332. initialBalance + fundAmount,
  333. "Balance should match initial balance plus added funds"
  334. );
  335. }
  336. function testWithdrawFunds() public {
  337. // First add a subscription with minimum balance
  338. bytes32[] memory priceIds = createPriceIds();
  339. uint256 minimumBalance = scheduler.getMinimumBalance(
  340. uint8(priceIds.length)
  341. );
  342. address[] memory readerWhitelist = new address[](1);
  343. readerWhitelist[0] = address(reader);
  344. SchedulerState.UpdateCriteria memory updateCriteria = SchedulerState
  345. .UpdateCriteria({
  346. updateOnHeartbeat: true,
  347. heartbeatSeconds: 60,
  348. updateOnDeviation: true,
  349. deviationThresholdBps: 100
  350. });
  351. SchedulerState.GasConfig memory gasConfig = SchedulerState.GasConfig({
  352. maxGasMultiplierCapPct: 10_000,
  353. maxFeeMultiplierCapPct: 10_000
  354. });
  355. SchedulerState.SubscriptionParams memory params = SchedulerState
  356. .SubscriptionParams({
  357. priceIds: priceIds,
  358. readerWhitelist: readerWhitelist,
  359. whitelistEnabled: true,
  360. isActive: true,
  361. updateCriteria: updateCriteria,
  362. gasConfig: gasConfig
  363. });
  364. uint256 subscriptionId = scheduler.addSubscription{
  365. value: minimumBalance
  366. }(params);
  367. // Add extra funds
  368. uint256 extraFunds = 1 ether;
  369. scheduler.addFunds{value: extraFunds}(subscriptionId);
  370. // Get initial balance
  371. uint256 initialBalance = address(this).balance;
  372. // Withdraw extra funds
  373. scheduler.withdrawFunds(subscriptionId, extraFunds);
  374. // Verify funds were withdrawn
  375. (, SchedulerState.SubscriptionStatus memory status) = scheduler
  376. .getSubscription(subscriptionId);
  377. assertEq(
  378. status.balanceInWei,
  379. minimumBalance,
  380. "Remaining balance should be minimum balance"
  381. );
  382. assertEq(
  383. address(this).balance,
  384. initialBalance + extraFunds,
  385. "Withdrawn amount not received"
  386. );
  387. // Try to withdraw below minimum balance
  388. vm.expectRevert(abi.encodeWithSelector(InsufficientBalance.selector));
  389. scheduler.withdrawFunds(subscriptionId, 1 wei);
  390. // Deactivate subscription
  391. params.isActive = false;
  392. scheduler.updateSubscription(subscriptionId, params);
  393. // Now we should be able to withdraw all funds
  394. scheduler.withdrawFunds(subscriptionId, minimumBalance);
  395. // Verify all funds were withdrawn
  396. (, status) = scheduler.getSubscription(subscriptionId);
  397. assertEq(
  398. status.balanceInWei,
  399. 0,
  400. "Balance should be 0 after withdrawing all funds"
  401. );
  402. }
  403. function testUpdatePriceFeedsWorks() public {
  404. // --- First Update ---
  405. // Add a subscription and funds
  406. uint256 subscriptionId = addTestSubscription(); // Uses heartbeat 60s, deviation 100bps
  407. uint256 fundAmount = 2 ether; // Add enough for two updates
  408. scheduler.addFunds{value: fundAmount}(subscriptionId);
  409. // Create price feeds and mock Pyth response for first update
  410. bytes32[] memory priceIds = createPriceIds();
  411. uint64 publishTime1 = SafeCast.toUint64(block.timestamp);
  412. PythStructs.PriceFeed[] memory priceFeeds1 = createMockPriceFeeds(
  413. publishTime1
  414. );
  415. mockParsePriceFeedUpdates(pyth, priceFeeds1);
  416. bytes[] memory updateData1 = createMockUpdateData(priceFeeds1);
  417. // Perform first update
  418. vm.expectEmit();
  419. emit PricesUpdated(subscriptionId, publishTime1);
  420. vm.prank(pusher);
  421. vm.breakpoint("a");
  422. scheduler.updatePriceFeeds(subscriptionId, updateData1, priceIds);
  423. // Verify first update
  424. (, SchedulerState.SubscriptionStatus memory status1) = scheduler
  425. .getSubscription(subscriptionId);
  426. assertEq(
  427. status1.priceLastUpdatedAt,
  428. publishTime1,
  429. "First update timestamp incorrect"
  430. );
  431. assertEq(
  432. status1.totalUpdates,
  433. 1,
  434. "Total updates should be 1 after first update"
  435. );
  436. assertTrue(
  437. status1.totalSpent > 0,
  438. "Total spent should be > 0 after first update"
  439. );
  440. uint256 spentAfterFirst = status1.totalSpent; // Store spent amount
  441. // --- Second Update ---
  442. // Advance time beyond heartbeat interval (e.g., 100 seconds)
  443. vm.warp(block.timestamp + 100);
  444. // Create price feeds for second update by cloning first update and modifying
  445. uint64 publishTime2 = SafeCast.toUint64(block.timestamp);
  446. PythStructs.PriceFeed[]
  447. memory priceFeeds2 = new PythStructs.PriceFeed[](
  448. priceFeeds1.length
  449. );
  450. for (uint i = 0; i < priceFeeds1.length; i++) {
  451. priceFeeds2[i] = priceFeeds1[i]; // Clone the feed struct
  452. priceFeeds2[i].price.publishTime = publishTime2; // Update timestamp
  453. // Apply a 100 bps price increase (satisfies update criteria)
  454. int64 priceDiff = int64(
  455. (uint64(priceFeeds1[i].price.price) * 100) / 10_000
  456. );
  457. priceFeeds2[i].price.price = priceFeeds1[i].price.price + priceDiff;
  458. priceFeeds2[i].emaPrice.publishTime = publishTime2;
  459. }
  460. mockParsePriceFeedUpdates(pyth, priceFeeds2); // Mock for the second call
  461. bytes[] memory updateData2 = createMockUpdateData(priceFeeds2);
  462. // Perform second update
  463. vm.expectEmit();
  464. emit PricesUpdated(subscriptionId, publishTime2);
  465. vm.prank(pusher);
  466. vm.breakpoint("b");
  467. scheduler.updatePriceFeeds(subscriptionId, updateData2, priceIds);
  468. // Verify second update
  469. (, SchedulerState.SubscriptionStatus memory status2) = scheduler
  470. .getSubscription(subscriptionId);
  471. assertEq(
  472. status2.priceLastUpdatedAt,
  473. publishTime2,
  474. "Second update timestamp incorrect"
  475. );
  476. assertEq(
  477. status2.totalUpdates,
  478. 2,
  479. "Total updates should be 2 after second update"
  480. );
  481. assertTrue(
  482. status2.totalSpent > spentAfterFirst,
  483. "Total spent should increase after second update"
  484. );
  485. // Verify price feed data using the reader contract for the second update
  486. assertTrue(
  487. reader.verifyPriceFeeds(
  488. subscriptionId,
  489. new bytes32[](0),
  490. priceFeeds2
  491. ),
  492. "Price feeds verification failed after second update"
  493. );
  494. }
  495. function testUpdatePriceFeedsRevertsOnHeartbeatUpdateConditionNotMet()
  496. public
  497. {
  498. // Add a subscription with only heartbeat criteria (60 seconds)
  499. uint32 heartbeat = 60;
  500. SchedulerState.UpdateCriteria memory criteria = SchedulerState
  501. .UpdateCriteria({
  502. updateOnHeartbeat: true,
  503. heartbeatSeconds: heartbeat,
  504. updateOnDeviation: false,
  505. deviationThresholdBps: 0
  506. });
  507. uint256 subscriptionId = addTestSubscriptionWithUpdateCriteria(
  508. criteria
  509. );
  510. uint256 fundAmount = 1 ether;
  511. scheduler.addFunds{value: fundAmount}(subscriptionId);
  512. // First update to set initial timestamp
  513. bytes32[] memory priceIds = createPriceIds();
  514. uint64 publishTime1 = SafeCast.toUint64(block.timestamp);
  515. PythStructs.PriceFeed[] memory priceFeeds1 = createMockPriceFeeds(
  516. publishTime1
  517. );
  518. mockParsePriceFeedUpdates(pyth, priceFeeds1);
  519. bytes[] memory updateData1 = createMockUpdateData(priceFeeds1);
  520. vm.prank(pusher);
  521. scheduler.updatePriceFeeds(subscriptionId, updateData1, priceIds);
  522. // Prepare second update within heartbeat interval
  523. vm.warp(block.timestamp + 30); // Advance time by 30 seconds (less than 60)
  524. uint64 publishTime2 = SafeCast.toUint64(block.timestamp);
  525. PythStructs.PriceFeed[] memory priceFeeds2 = createMockPriceFeeds(
  526. publishTime2 // Same prices, just new timestamp
  527. );
  528. mockParsePriceFeedUpdates(pyth, priceFeeds2); // Mock the response for the second update
  529. bytes[] memory updateData2 = createMockUpdateData(priceFeeds2);
  530. // Expect revert because heartbeat condition is not met
  531. vm.expectRevert(
  532. abi.encodeWithSelector(UpdateConditionsNotMet.selector)
  533. );
  534. vm.prank(pusher);
  535. scheduler.updatePriceFeeds(subscriptionId, updateData2, priceIds);
  536. }
  537. function testUpdatePriceFeedsRevertsOnDeviationUpdateConditionNotMet()
  538. public
  539. {
  540. // Add a subscription with only deviation criteria (100 bps / 1%)
  541. uint16 deviationBps = 100;
  542. SchedulerState.UpdateCriteria memory criteria = SchedulerState
  543. .UpdateCriteria({
  544. updateOnHeartbeat: false,
  545. heartbeatSeconds: 0,
  546. updateOnDeviation: true,
  547. deviationThresholdBps: deviationBps
  548. });
  549. uint256 subscriptionId = addTestSubscriptionWithUpdateCriteria(
  550. criteria
  551. );
  552. uint256 fundAmount = 1 ether;
  553. scheduler.addFunds{value: fundAmount}(subscriptionId);
  554. // First update to set initial price
  555. bytes32[] memory priceIds = createPriceIds();
  556. uint64 publishTime1 = SafeCast.toUint64(block.timestamp);
  557. PythStructs.PriceFeed[] memory priceFeeds1 = createMockPriceFeeds(
  558. publishTime1
  559. );
  560. mockParsePriceFeedUpdates(pyth, priceFeeds1);
  561. bytes[] memory updateData1 = createMockUpdateData(priceFeeds1);
  562. vm.prank(pusher);
  563. scheduler.updatePriceFeeds(subscriptionId, updateData1, priceIds);
  564. // Prepare second update with price deviation less than threshold (e.g., 50 bps)
  565. vm.warp(block.timestamp + 1000); // Advance time significantly (doesn't matter for deviation)
  566. uint64 publishTime2 = SafeCast.toUint64(block.timestamp);
  567. // Clone priceFeeds1 and apply a 50 bps deviation to its prices
  568. PythStructs.PriceFeed[]
  569. memory priceFeeds2 = new PythStructs.PriceFeed[](
  570. priceFeeds1.length
  571. );
  572. for (uint i = 0; i < priceFeeds1.length; i++) {
  573. priceFeeds2[i].id = priceFeeds1[i].id;
  574. // Apply 50 bps deviation to the price
  575. int64 priceDiff = int64(
  576. (uint64(priceFeeds1[i].price.price) * 50) / 10_000
  577. );
  578. priceFeeds2[i].price.price = priceFeeds1[i].price.price + priceDiff;
  579. priceFeeds2[i].price.conf = priceFeeds1[i].price.conf;
  580. priceFeeds2[i].price.expo = priceFeeds1[i].price.expo;
  581. priceFeeds2[i].price.publishTime = publishTime2;
  582. }
  583. mockParsePriceFeedUpdates(pyth, priceFeeds2);
  584. bytes[] memory updateData2 = createMockUpdateData(priceFeeds2);
  585. // Expect revert because deviation condition is not met
  586. vm.expectRevert(
  587. abi.encodeWithSelector(UpdateConditionsNotMet.selector)
  588. );
  589. vm.prank(pusher);
  590. scheduler.updatePriceFeeds(subscriptionId, updateData2, priceIds);
  591. }
  592. function testUpdatePriceFeedsRevertsOnOlderTimestamp() public {
  593. // Add a subscription and funds
  594. uint256 subscriptionId = addTestSubscription();
  595. uint256 fundAmount = 1 ether;
  596. scheduler.addFunds{value: fundAmount}(subscriptionId);
  597. // First update to establish last updated timestamp
  598. bytes32[] memory priceIds = createPriceIds();
  599. uint64 publishTime1 = SafeCast.toUint64(block.timestamp);
  600. PythStructs.PriceFeed[] memory priceFeeds1 = createMockPriceFeeds(
  601. publishTime1
  602. );
  603. mockParsePriceFeedUpdates(pyth, priceFeeds1);
  604. bytes[] memory updateData1 = createMockUpdateData(priceFeeds1);
  605. vm.prank(pusher);
  606. scheduler.updatePriceFeeds(subscriptionId, updateData1, priceIds);
  607. // Prepare second update with an older timestamp
  608. uint64 publishTime2 = publishTime1 - 10; // Timestamp older than the first update
  609. PythStructs.PriceFeed[] memory priceFeeds2 = createMockPriceFeeds(
  610. publishTime2
  611. );
  612. // Mock Pyth response to return feeds with the older timestamp
  613. mockParsePriceFeedUpdates(pyth, priceFeeds2);
  614. bytes[] memory updateData2 = createMockUpdateData(priceFeeds2);
  615. // Expect revert with TimestampOlderThanLastUpdate (checked in _validateShouldUpdatePrices)
  616. vm.expectRevert(
  617. abi.encodeWithSelector(
  618. TimestampOlderThanLastUpdate.selector,
  619. publishTime2,
  620. publishTime1
  621. )
  622. );
  623. // Attempt to update price feeds
  624. vm.prank(pusher);
  625. scheduler.updatePriceFeeds(subscriptionId, updateData2, priceIds);
  626. }
  627. function testUpdatePriceFeedsRevertsOnMismatchedTimestamps() public {
  628. // First add a subscription and funds
  629. uint256 subscriptionId = addTestSubscription();
  630. uint256 fundAmount = 1 ether;
  631. scheduler.addFunds{value: fundAmount}(subscriptionId);
  632. // Create two price feeds with mismatched timestamps
  633. bytes32[] memory priceIds = createPriceIds(2);
  634. uint64 time1 = SafeCast.toUint64(block.timestamp);
  635. uint64 time2 = time1 + 10;
  636. PythStructs.PriceFeed[] memory priceFeeds = new PythStructs.PriceFeed[](
  637. 2
  638. );
  639. priceFeeds[0] = createSingleMockPriceFeed(time1);
  640. priceFeeds[1] = createSingleMockPriceFeed(time2);
  641. // Mock Pyth response to return these feeds
  642. mockParsePriceFeedUpdates(pyth, priceFeeds);
  643. bytes[] memory updateData = createMockUpdateData(priceFeeds); // Data needs to match expected length
  644. // Expect revert with PriceTimestampMismatch error
  645. vm.expectRevert(
  646. abi.encodeWithSelector(PriceTimestampMismatch.selector)
  647. );
  648. // Attempt to update price feeds
  649. vm.prank(pusher);
  650. scheduler.updatePriceFeeds(subscriptionId, updateData, priceIds);
  651. }
  652. function testGetPricesUnsafeAllFeeds() public {
  653. // First add a subscription, funds, and update price feeds
  654. uint256 subscriptionId = addTestSubscription();
  655. uint256 fundAmount = 1 ether;
  656. scheduler.addFunds{value: fundAmount}(subscriptionId);
  657. bytes32[] memory priceIds = createPriceIds();
  658. uint64 publishTime = SafeCast.toUint64(block.timestamp);
  659. PythStructs.PriceFeed[] memory priceFeeds = createMockPriceFeeds(
  660. publishTime
  661. );
  662. mockParsePriceFeedUpdates(pyth, priceFeeds);
  663. bytes[] memory updateData = createMockUpdateData(priceFeeds);
  664. vm.prank(pusher);
  665. scheduler.updatePriceFeeds(subscriptionId, updateData, priceIds);
  666. // Get all latest prices (empty priceIds array)
  667. bytes32[] memory emptyPriceIds = new bytes32[](0);
  668. PythStructs.Price[] memory latestPrices = scheduler.getPricesUnsafe(
  669. subscriptionId,
  670. emptyPriceIds
  671. );
  672. // Verify all price feeds were returned
  673. assertEq(
  674. latestPrices.length,
  675. priceIds.length,
  676. "Should return all price feeds"
  677. );
  678. // Verify price feed data using the reader contract
  679. assertTrue(
  680. reader.verifyPriceFeeds(subscriptionId, emptyPriceIds, priceFeeds),
  681. "Price feeds verification failed"
  682. );
  683. }
  684. function testGetPricesUnsafeSelectiveFeeds() public {
  685. // First add a subscription with 3 price feeds, funds, and update price feeds
  686. uint256 subscriptionId = addTestSubscriptionWithFeeds(3);
  687. uint256 fundAmount = 1 ether;
  688. scheduler.addFunds{value: fundAmount}(subscriptionId);
  689. bytes32[] memory priceIds = createPriceIds(3);
  690. uint64 publishTime = SafeCast.toUint64(block.timestamp);
  691. PythStructs.PriceFeed[] memory priceFeeds = createMockPriceFeeds(
  692. publishTime,
  693. 3
  694. );
  695. mockParsePriceFeedUpdates(pyth, priceFeeds);
  696. bytes[] memory updateData = createMockUpdateData(priceFeeds);
  697. vm.prank(pusher);
  698. scheduler.updatePriceFeeds(subscriptionId, updateData, priceIds);
  699. // Get only the first price feed
  700. bytes32[] memory selectedPriceIds = new bytes32[](1);
  701. selectedPriceIds[0] = priceIds[0];
  702. PythStructs.Price[] memory latestPrices = scheduler.getPricesUnsafe(
  703. subscriptionId,
  704. selectedPriceIds
  705. );
  706. // Verify only one price feed was returned
  707. assertEq(latestPrices.length, 1, "Should return only one price feed");
  708. // Create expected price feed array with just the first feed
  709. PythStructs.PriceFeed[]
  710. memory expectedFeeds = new PythStructs.PriceFeed[](1);
  711. expectedFeeds[0] = priceFeeds[0];
  712. // Verify price feed data using the reader contract
  713. assertTrue(
  714. reader.verifyPriceFeeds(
  715. subscriptionId,
  716. selectedPriceIds,
  717. expectedFeeds
  718. ),
  719. "Price feeds verification failed"
  720. );
  721. }
  722. function testOptionalWhitelist() public {
  723. // Add a subscription with whitelistEnabled = false
  724. bytes32[] memory priceIds = createPriceIds();
  725. address[] memory emptyWhitelist = new address[](0);
  726. SchedulerState.UpdateCriteria memory updateCriteria = SchedulerState
  727. .UpdateCriteria({
  728. updateOnHeartbeat: true,
  729. heartbeatSeconds: 60,
  730. updateOnDeviation: true,
  731. deviationThresholdBps: 100
  732. });
  733. SchedulerState.GasConfig memory gasConfig = SchedulerState.GasConfig({
  734. maxGasMultiplierCapPct: 10_000,
  735. maxFeeMultiplierCapPct: 10_000
  736. });
  737. SchedulerState.SubscriptionParams memory params = SchedulerState
  738. .SubscriptionParams({
  739. priceIds: priceIds,
  740. readerWhitelist: emptyWhitelist,
  741. whitelistEnabled: false, // No whitelist
  742. isActive: true,
  743. updateCriteria: updateCriteria,
  744. gasConfig: gasConfig
  745. });
  746. uint256 minimumBalance = scheduler.getMinimumBalance(
  747. uint8(priceIds.length)
  748. );
  749. uint256 subscriptionId = scheduler.addSubscription{
  750. value: minimumBalance
  751. }(params);
  752. // Update price feeds
  753. uint256 fundAmount = 1 ether;
  754. scheduler.addFunds{value: fundAmount}(subscriptionId);
  755. uint64 publishTime = SafeCast.toUint64(block.timestamp);
  756. PythStructs.PriceFeed[] memory priceFeeds = createMockPriceFeeds(
  757. publishTime
  758. );
  759. mockParsePriceFeedUpdates(pyth, priceFeeds);
  760. bytes[] memory updateData = createMockUpdateData(priceFeeds);
  761. vm.prank(pusher);
  762. scheduler.updatePriceFeeds(subscriptionId, updateData, priceIds);
  763. // Try to access from a non-whitelisted address
  764. address randomUser = address(0xdead);
  765. vm.startPrank(randomUser);
  766. bytes32[] memory emptyPriceIds = new bytes32[](0);
  767. // Should not revert since whitelist is disabled
  768. // We'll just check that it doesn't revert
  769. scheduler.getPricesUnsafe(subscriptionId, emptyPriceIds);
  770. vm.stopPrank();
  771. // Verify the data is correct
  772. assertTrue(
  773. reader.verifyPriceFeeds(subscriptionId, emptyPriceIds, priceFeeds),
  774. "Price feeds verification failed"
  775. );
  776. }
  777. function testGetEmaPriceUnsafe() public {
  778. // First add a subscription, funds, and update price feeds
  779. uint256 subscriptionId = addTestSubscription();
  780. uint256 fundAmount = 1 ether;
  781. scheduler.addFunds{value: fundAmount}(subscriptionId);
  782. bytes32[] memory priceIds = createPriceIds();
  783. uint64 publishTime = SafeCast.toUint64(block.timestamp);
  784. PythStructs.PriceFeed[] memory priceFeeds = createMockPriceFeeds(
  785. publishTime
  786. );
  787. // Ensure EMA prices are set in the mock price feeds
  788. for (uint i = 0; i < priceFeeds.length; i++) {
  789. priceFeeds[i].emaPrice.price = priceFeeds[i].price.price * 2; // Make EMA price different for testing
  790. priceFeeds[i].emaPrice.conf = priceFeeds[i].price.conf;
  791. priceFeeds[i].emaPrice.publishTime = publishTime;
  792. priceFeeds[i].emaPrice.expo = priceFeeds[i].price.expo;
  793. }
  794. mockParsePriceFeedUpdates(pyth, priceFeeds);
  795. bytes[] memory updateData = createMockUpdateData(priceFeeds);
  796. vm.prank(pusher);
  797. scheduler.updatePriceFeeds(subscriptionId, updateData, priceIds);
  798. // Get EMA prices
  799. bytes32[] memory emptyPriceIds = new bytes32[](0);
  800. PythStructs.Price[] memory emaPrices = scheduler.getEmaPriceUnsafe(
  801. subscriptionId,
  802. emptyPriceIds
  803. );
  804. // Verify all EMA prices were returned
  805. assertEq(
  806. emaPrices.length,
  807. priceIds.length,
  808. "Should return all EMA prices"
  809. );
  810. // Verify EMA price values
  811. for (uint i = 0; i < emaPrices.length; i++) {
  812. assertEq(
  813. emaPrices[i].price,
  814. priceFeeds[i].emaPrice.price,
  815. "EMA price value mismatch"
  816. );
  817. assertEq(
  818. emaPrices[i].publishTime,
  819. priceFeeds[i].emaPrice.publishTime,
  820. "EMA price publish time mismatch"
  821. );
  822. }
  823. }
  824. function testGetActiveSubscriptions() public {
  825. // Add two subscriptions with the test contract as manager
  826. addTestSubscription();
  827. addTestSubscription();
  828. // Create a subscription with pusher as manager
  829. vm.startPrank(pusher);
  830. bytes32[] memory priceIds = createPriceIds();
  831. address[] memory emptyWhitelist = new address[](0);
  832. SchedulerState.UpdateCriteria memory updateCriteria = SchedulerState
  833. .UpdateCriteria({
  834. updateOnHeartbeat: true,
  835. heartbeatSeconds: 60,
  836. updateOnDeviation: true,
  837. deviationThresholdBps: 100
  838. });
  839. SchedulerState.GasConfig memory gasConfig = SchedulerState.GasConfig({
  840. maxGasMultiplierCapPct: 10_000,
  841. maxFeeMultiplierCapPct: 10_000
  842. });
  843. SchedulerState.SubscriptionParams memory pusherParams = SchedulerState
  844. .SubscriptionParams({
  845. priceIds: priceIds,
  846. readerWhitelist: emptyWhitelist,
  847. whitelistEnabled: false,
  848. isActive: true,
  849. updateCriteria: updateCriteria,
  850. gasConfig: gasConfig
  851. });
  852. uint256 minimumBalance = scheduler.getMinimumBalance(
  853. uint8(priceIds.length)
  854. );
  855. vm.deal(pusher, minimumBalance);
  856. scheduler.addSubscription{value: minimumBalance}(pusherParams);
  857. vm.stopPrank();
  858. // Get active subscriptions directly - should work without any special permissions
  859. uint256[] memory activeIds;
  860. SchedulerState.SubscriptionParams[] memory activeParams;
  861. uint256 totalCount;
  862. (activeIds, activeParams, totalCount) = scheduler
  863. .getActiveSubscriptions(0, 10);
  864. // We added 3 subscriptions and all should be active
  865. assertEq(activeIds.length, 3, "Should have 3 active subscriptions");
  866. assertEq(
  867. activeParams.length,
  868. 3,
  869. "Should have 3 active subscription params"
  870. );
  871. assertEq(totalCount, 3, "Total count should be 3");
  872. // Verify subscription params
  873. for (uint i = 0; i < activeIds.length; i++) {
  874. (
  875. SchedulerState.SubscriptionParams memory storedParams,
  876. ) = scheduler.getSubscription(activeIds[i]);
  877. assertEq(
  878. activeParams[i].priceIds.length,
  879. storedParams.priceIds.length,
  880. "Price IDs length mismatch"
  881. );
  882. assertEq(
  883. activeParams[i].updateCriteria.heartbeatSeconds,
  884. storedParams.updateCriteria.heartbeatSeconds,
  885. "Heartbeat seconds mismatch"
  886. );
  887. }
  888. // Test pagination - get only the first subscription
  889. vm.prank(owner);
  890. (uint256[] memory firstPageIds, , uint256 firstPageTotal) = scheduler
  891. .getActiveSubscriptions(0, 1);
  892. assertEq(
  893. firstPageIds.length,
  894. 1,
  895. "Should have 1 subscription in first page"
  896. );
  897. assertEq(firstPageTotal, 3, "Total count should still be 3");
  898. // Test pagination - get the second page
  899. vm.prank(owner);
  900. (uint256[] memory secondPageIds, , uint256 secondPageTotal) = scheduler
  901. .getActiveSubscriptions(1, 2);
  902. assertEq(
  903. secondPageIds.length,
  904. 2,
  905. "Should have 2 subscriptions in second page"
  906. );
  907. assertEq(secondPageTotal, 3, "Total count should still be 3");
  908. // Test pagination - start index beyond total count
  909. vm.prank(owner);
  910. (uint256[] memory emptyPageIds, , uint256 emptyPageTotal) = scheduler
  911. .getActiveSubscriptions(10, 10);
  912. assertEq(
  913. emptyPageIds.length,
  914. 0,
  915. "Should have 0 subscriptions when start index is beyond total"
  916. );
  917. assertEq(emptyPageTotal, 3, "Total count should still be 3");
  918. }
  919. // Helper function to add a test subscription
  920. function addTestSubscription() internal returns (uint256) {
  921. bytes32[] memory priceIds = createPriceIds();
  922. address[] memory readerWhitelist = new address[](1);
  923. readerWhitelist[0] = address(reader);
  924. SchedulerState.UpdateCriteria memory updateCriteria = SchedulerState
  925. .UpdateCriteria({
  926. updateOnHeartbeat: true,
  927. heartbeatSeconds: 60,
  928. updateOnDeviation: true,
  929. deviationThresholdBps: 100
  930. });
  931. SchedulerState.GasConfig memory gasConfig = SchedulerState.GasConfig({
  932. maxGasMultiplierCapPct: 10_000,
  933. maxFeeMultiplierCapPct: 10_000
  934. });
  935. SchedulerState.SubscriptionParams memory params = SchedulerState
  936. .SubscriptionParams({
  937. priceIds: priceIds,
  938. readerWhitelist: readerWhitelist,
  939. whitelistEnabled: true,
  940. isActive: true,
  941. updateCriteria: updateCriteria,
  942. gasConfig: gasConfig
  943. });
  944. uint256 minimumBalance = scheduler.getMinimumBalance(
  945. uint8(priceIds.length)
  946. );
  947. return scheduler.addSubscription{value: minimumBalance}(params);
  948. }
  949. // Helper function to add a test subscription with variable number of feeds
  950. function addTestSubscriptionWithFeeds(
  951. uint256 numFeeds
  952. ) internal returns (uint256) {
  953. bytes32[] memory priceIds = createPriceIds(numFeeds);
  954. address[] memory readerWhitelist = new address[](1);
  955. readerWhitelist[0] = address(reader);
  956. SchedulerState.UpdateCriteria memory updateCriteria = SchedulerState
  957. .UpdateCriteria({
  958. updateOnHeartbeat: true,
  959. heartbeatSeconds: 60,
  960. updateOnDeviation: true,
  961. deviationThresholdBps: 100
  962. });
  963. SchedulerState.GasConfig memory gasConfig = SchedulerState.GasConfig({
  964. maxGasMultiplierCapPct: 10_000,
  965. maxFeeMultiplierCapPct: 10_000
  966. });
  967. SchedulerState.SubscriptionParams memory params = SchedulerState
  968. .SubscriptionParams({
  969. priceIds: priceIds,
  970. readerWhitelist: readerWhitelist,
  971. whitelistEnabled: true,
  972. isActive: true,
  973. updateCriteria: updateCriteria,
  974. gasConfig: gasConfig
  975. });
  976. uint256 minimumBalance = scheduler.getMinimumBalance(
  977. uint8(priceIds.length)
  978. );
  979. return scheduler.addSubscription{value: minimumBalance}(params);
  980. }
  981. // Helper function to add a test subscription with specific update criteria
  982. function addTestSubscriptionWithUpdateCriteria(
  983. SchedulerState.UpdateCriteria memory updateCriteria
  984. ) internal returns (uint256) {
  985. bytes32[] memory priceIds = createPriceIds();
  986. address[] memory readerWhitelist = new address[](1);
  987. readerWhitelist[0] = address(reader);
  988. SchedulerState.GasConfig memory gasConfig = SchedulerState.GasConfig({
  989. maxGasMultiplierCapPct: 10_000,
  990. maxFeeMultiplierCapPct: 10_000
  991. });
  992. SchedulerState.SubscriptionParams memory params = SchedulerState
  993. .SubscriptionParams({
  994. priceIds: priceIds,
  995. readerWhitelist: readerWhitelist,
  996. whitelistEnabled: true,
  997. isActive: true,
  998. updateCriteria: updateCriteria, // Use provided criteria
  999. gasConfig: gasConfig
  1000. });
  1001. uint256 minimumBalance = scheduler.getMinimumBalance(
  1002. uint8(priceIds.length)
  1003. );
  1004. return scheduler.addSubscription{value: minimumBalance}(params);
  1005. }
  1006. // Required to receive ETH when withdrawing funds
  1007. receive() external payable {}
  1008. }