| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470 |
- // SPDX-License-Identifier: Apache 2
- pragma solidity ^0.8.0;
- import "forge-std/Test.sol";
- import "forge-std/console.sol";
- import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
- import "../contracts/pulse/SchedulerUpgradeable.sol";
- import "../contracts/pulse/IScheduler.sol";
- import "../contracts/pulse/SchedulerState.sol";
- import "../contracts/pulse/SchedulerEvents.sol";
- import "../contracts/pulse/SchedulerErrors.sol";
- import "./utils/PulseSchedulerTestUtils.t.sol";
- import "@pythnetwork/pyth-sdk-solidity/IPyth.sol";
- contract MockReader {
- address private _scheduler;
- constructor(address scheduler) {
- _scheduler = scheduler;
- }
- function getPricesUnsafe(
- uint256 subscriptionId,
- bytes32[] memory priceIds
- ) external view returns (PythStructs.Price[] memory) {
- return IScheduler(_scheduler).getPricesUnsafe(subscriptionId, priceIds);
- }
- function getEmaPriceUnsafe(
- uint256 subscriptionId,
- bytes32[] memory priceIds
- ) external view returns (PythStructs.Price[] memory) {
- return
- IScheduler(_scheduler).getEmaPriceUnsafe(subscriptionId, priceIds);
- }
- function verifyPriceFeeds(
- uint256 subscriptionId,
- bytes32[] memory priceIds,
- PythStructs.PriceFeed[] memory expectedFeeds
- ) external view returns (bool) {
- PythStructs.Price[] memory actualPrices = IScheduler(_scheduler)
- .getPricesUnsafe(subscriptionId, priceIds);
- if (actualPrices.length != expectedFeeds.length) {
- return false;
- }
- for (uint i = 0; i < actualPrices.length; i++) {
- if (
- actualPrices[i].price != expectedFeeds[i].price.price ||
- actualPrices[i].conf != expectedFeeds[i].price.conf ||
- actualPrices[i].publishTime !=
- expectedFeeds[i].price.publishTime
- ) {
- return false;
- }
- }
- return true;
- }
- }
- contract SchedulerTest is Test, SchedulerEvents, PulseSchedulerTestUtils {
- ERC1967Proxy public proxy;
- SchedulerUpgradeable public scheduler;
- MockReader public reader;
- address public owner;
- address public admin;
- address public pyth;
- address public pusher;
- function setUp() public {
- owner = address(1);
- admin = address(2);
- pyth = address(3);
- pusher = address(4);
- uint128 minBalancePerFeed = 10 ** 16; // 0.01 ether
- uint128 keeperFee = 10 ** 14; // 0.0001 ether
- SchedulerUpgradeable _scheduler = new SchedulerUpgradeable();
- proxy = new ERC1967Proxy(
- address(_scheduler),
- abi.encodeWithSelector(
- SchedulerUpgradeable.initialize.selector,
- owner,
- admin,
- pyth,
- minBalancePerFeed,
- keeperFee
- )
- );
- scheduler = SchedulerUpgradeable(address(proxy));
- reader = new MockReader(address(proxy));
- // Start tests at a high timestamp to avoid underflow when we set
- // `minPublishTime = timestamp - 1 hour` in updatePriceFeeds
- vm.warp(100000);
- // Give pusher 100 ETH for testing
- vm.deal(pusher, 100 ether);
- }
- function testCreateSubscription() public {
- SchedulerState.SubscriptionParams
- memory params = createDefaultSubscriptionParams(2, address(reader));
- bytes32[] memory priceIds = params.priceIds; // Get the generated price IDs
- // Calculate minimum balance
- uint256 minimumBalance = scheduler.getMinimumBalance(
- uint8(priceIds.length)
- );
- // Add subscription with minimum balance
- vm.expectEmit();
- emit SubscriptionCreated(1, address(this));
- uint256 subscriptionId = scheduler.createSubscription{
- value: minimumBalance
- }(params);
- assertEq(subscriptionId, 1, "Subscription ID should be 1");
- // Verify subscription was added correctly
- (
- SchedulerState.SubscriptionParams memory storedParams,
- SchedulerState.SubscriptionStatus memory status
- ) = scheduler.getSubscription(subscriptionId);
- assertEq(
- storedParams.priceIds.length,
- priceIds.length,
- "Price IDs length mismatch"
- );
- assertEq(
- storedParams.readerWhitelist.length,
- params.readerWhitelist.length,
- "Whitelist length mismatch"
- );
- assertEq(
- storedParams.whitelistEnabled,
- params.whitelistEnabled,
- "whitelistEnabled should match"
- );
- assertTrue(storedParams.isActive, "Subscription should be active");
- assertEq(
- storedParams.updateCriteria.heartbeatSeconds,
- params.updateCriteria.heartbeatSeconds,
- "Heartbeat seconds mismatch"
- );
- assertEq(
- storedParams.updateCriteria.deviationThresholdBps,
- params.updateCriteria.deviationThresholdBps,
- "Deviation threshold mismatch"
- );
- assertEq(
- status.balanceInWei,
- minimumBalance,
- "Initial balance should match minimum balance"
- );
- }
- function testUpdateSubscription() public {
- // First add a subscription
- uint256 subscriptionId = addTestSubscription(
- scheduler,
- address(reader)
- );
- // Create updated parameters
- bytes32[] memory newPriceIds = createPriceIds(3); // Add one more price ID
- address[] memory newReaderWhitelist = new address[](2);
- newReaderWhitelist[0] = address(reader);
- newReaderWhitelist[1] = address(0x123);
- SchedulerState.UpdateCriteria memory newUpdateCriteria = SchedulerState
- .UpdateCriteria({
- updateOnHeartbeat: true,
- heartbeatSeconds: 120, // Changed from 60
- updateOnDeviation: true,
- deviationThresholdBps: 200 // Changed from 100
- });
- SchedulerState.SubscriptionParams memory newParams = SchedulerState
- .SubscriptionParams({
- priceIds: newPriceIds,
- readerWhitelist: newReaderWhitelist,
- whitelistEnabled: false, // Changed from true
- isActive: true,
- isPermanent: false,
- updateCriteria: newUpdateCriteria
- });
- // Add the required funds to cover the new minimum balance
- scheduler.addFunds{
- value: scheduler.getMinimumBalance(uint8(newPriceIds.length))
- }(subscriptionId);
- // Update subscription
- vm.expectEmit();
- emit SubscriptionUpdated(subscriptionId);
- scheduler.updateSubscription(subscriptionId, newParams);
- // Verify subscription was updated correctly
- (SchedulerState.SubscriptionParams memory storedParams, ) = scheduler
- .getSubscription(subscriptionId);
- assertEq(
- storedParams.priceIds.length,
- newPriceIds.length,
- "Price IDs length mismatch"
- );
- assertEq(
- storedParams.readerWhitelist.length,
- newReaderWhitelist.length,
- "Whitelist length mismatch"
- );
- assertEq(
- storedParams.whitelistEnabled,
- false,
- "whitelistEnabled should be false"
- );
- assertEq(
- storedParams.updateCriteria.heartbeatSeconds,
- 120,
- "Heartbeat seconds mismatch"
- );
- assertEq(
- storedParams.updateCriteria.deviationThresholdBps,
- 200,
- "Deviation threshold mismatch"
- );
- }
- function testUpdateSubscriptionClearsRemovedPriceFeeds() public {
- // 1. Setup: Add subscription with 3 price feeds, update prices
- uint8 numInitialFeeds = 3;
- uint256 subscriptionId = addTestSubscriptionWithFeeds(
- scheduler,
- numInitialFeeds,
- address(reader)
- );
- uint256 fundAmount = 1 ether;
- scheduler.addFunds{value: fundAmount}(subscriptionId);
- bytes32[] memory initialPriceIds = createPriceIds(numInitialFeeds);
- uint64 publishTime = SafeCast.toUint64(block.timestamp);
- PythStructs.PriceFeed[] memory initialPriceFeeds;
- uint64[] memory slots;
- (initialPriceFeeds, slots) = createMockPriceFeedsWithSlots(
- publishTime,
- numInitialFeeds
- );
- mockParsePriceFeedUpdatesWithSlotsStrict(
- pyth,
- initialPriceFeeds,
- slots
- );
- bytes[] memory updateData = createMockUpdateData(initialPriceFeeds);
- vm.prank(pusher);
- scheduler.updatePriceFeeds(subscriptionId, updateData);
- // Verify initial state: All 3 feeds should be readable
- assertTrue(
- reader.verifyPriceFeeds(
- subscriptionId,
- initialPriceIds,
- initialPriceFeeds
- ),
- "Initial price feeds verification failed"
- );
- // 2. Action: Update subscription to remove the last price feed
- bytes32[] memory newPriceIds = new bytes32[](numInitialFeeds - 1);
- for (uint i = 0; i < newPriceIds.length; i++) {
- newPriceIds[i] = initialPriceIds[i];
- }
- bytes32 removedPriceId = initialPriceIds[numInitialFeeds - 1]; // The ID we removed
- (SchedulerState.SubscriptionParams memory currentParams, ) = scheduler
- .getSubscription(subscriptionId);
- SchedulerState.SubscriptionParams memory newParams = currentParams; // Copy existing params
- newParams.priceIds = newPriceIds; // Update price IDs
- vm.expectEmit(); // Expect SubscriptionUpdated
- emit SubscriptionUpdated(subscriptionId);
- scheduler.updateSubscription(subscriptionId, newParams);
- // 3. Verification:
- // - Querying the removed price ID should revert
- bytes32[] memory removedIdArray = new bytes32[](1);
- removedIdArray[0] = removedPriceId;
- vm.expectRevert(
- abi.encodeWithSelector(
- InvalidPriceId.selector,
- removedPriceId,
- bytes32(0)
- )
- );
- scheduler.getPricesUnsafe(subscriptionId, removedIdArray);
- // - Querying the remaining price IDs should still work
- PythStructs.PriceFeed[]
- memory expectedRemainingFeeds = new PythStructs.PriceFeed[](
- newPriceIds.length
- );
- for (uint i = 0; i < newPriceIds.length; i++) {
- expectedRemainingFeeds[i] = initialPriceFeeds[i]; // Prices remain from the initial update
- }
- assertTrue(
- reader.verifyPriceFeeds(
- subscriptionId,
- newPriceIds,
- expectedRemainingFeeds
- ),
- "Remaining price feeds verification failed after update"
- );
- // - Querying all feeds (empty array) should return only the remaining feeds
- PythStructs.Price[] memory allPricesAfterUpdate = scheduler
- .getPricesUnsafe(subscriptionId, new bytes32[](0));
- assertEq(
- allPricesAfterUpdate.length,
- newPriceIds.length,
- "Querying all should only return remaining feeds"
- );
- }
- // 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
- memory params = createDefaultSubscriptionParams(
- numFeeds,
- address(reader)
- );
- // Calculate minimum balance
- uint256 minimumBalance = scheduler.getMinimumBalance(
- uint8(params.priceIds.length)
- );
- // Try to add subscription with insufficient funds
- vm.expectRevert(abi.encodeWithSelector(InsufficientBalance.selector));
- scheduler.createSubscription{value: minimumBalance - 1 wei}(params);
- }
- function testActivateDeactivateSubscription() public {
- // Add multiple subscriptions
- uint256 subId1 = addTestSubscription(scheduler, address(reader)); // ID 1
- uint256 subId2 = addTestSubscription(scheduler, address(reader)); // ID 2
- uint256 subId3 = addTestSubscription(scheduler, address(reader)); // ID 3
- // --- Verify initial state ---
- (uint256[] memory activeIds, , uint256 totalCount) = scheduler
- .getActiveSubscriptions(0, 10);
- assertEq(totalCount, 3, "Initial: Total count should be 3");
- assertEq(activeIds.length, 3, "Initial: Active IDs length should be 3");
- assertEq(activeIds[0], subId1, "Initial: ID 1 should be active");
- assertEq(activeIds[1], subId2, "Initial: ID 2 should be active");
- assertEq(activeIds[2], subId3, "Initial: ID 3 should be active");
- // --- Deactivate the middle subscription (ID 2) ---
- (SchedulerState.SubscriptionParams memory params2, ) = scheduler
- .getSubscription(subId2);
- params2.isActive = false;
- vm.expectEmit();
- emit SubscriptionDeactivated(subId2);
- vm.expectEmit();
- emit SubscriptionUpdated(subId2);
- scheduler.updateSubscription(subId2, params2);
- // Verify state after deactivating ID 2
- (activeIds, , totalCount) = scheduler.getActiveSubscriptions(0, 10);
- assertEq(totalCount, 2, "After Deact 2: Total count should be 2");
- assertEq(
- activeIds.length,
- 2,
- "After Deact 2: Active IDs length should be 2"
- );
- assertEq(activeIds[0], subId1, "After Deact 2: ID 1 should be active");
- assertEq(
- activeIds[1],
- subId3,
- "After Deact 2: ID 3 should be active (moved)"
- ); // ID 3 takes the place of ID 2
- // --- Deactivate the last subscription (ID 3, now at index 1) ---
- (SchedulerState.SubscriptionParams memory params3, ) = scheduler
- .getSubscription(subId3);
- params3.isActive = false;
- vm.expectEmit();
- emit SubscriptionDeactivated(subId3);
- vm.expectEmit();
- emit SubscriptionUpdated(subId3);
- scheduler.updateSubscription(subId3, params3);
- // Verify state after deactivating ID 3
- (activeIds, , totalCount) = scheduler.getActiveSubscriptions(0, 10);
- assertEq(totalCount, 1, "After Deact 3: Total count should be 1");
- assertEq(
- activeIds.length,
- 1,
- "After Deact 3: Active IDs length should be 1"
- );
- assertEq(
- activeIds[0],
- subId1,
- "After Deact 3: Only ID 1 should be active"
- );
- // --- Reactivate the middle subscription (ID 2) ---
- params2.isActive = true; // Use the params struct from earlier
- vm.expectEmit();
- emit SubscriptionActivated(subId2);
- vm.expectEmit();
- emit SubscriptionUpdated(subId2);
- scheduler.updateSubscription(subId2, params2);
- // Verify state after reactivating ID 2
- (activeIds, , totalCount) = scheduler.getActiveSubscriptions(0, 10);
- assertEq(totalCount, 2, "After React 2: Total count should be 2");
- assertEq(
- activeIds.length,
- 2,
- "After React 2: Active IDs length should be 2"
- );
- assertEq(activeIds[0], subId1, "After React 2: ID 1 should be active");
- assertEq(activeIds[1], subId2, "After React 2: ID 2 should be active"); // ID 2 is added back to the end
- // --- Reactivate the last subscription (ID 3) ---
- params3.isActive = true; // Use the params struct from earlier
- vm.expectEmit();
- emit SubscriptionActivated(subId3);
- vm.expectEmit();
- emit SubscriptionUpdated(subId3);
- scheduler.updateSubscription(subId3, params3);
- // Verify final state (all active)
- (activeIds, , totalCount) = scheduler.getActiveSubscriptions(0, 10);
- assertEq(totalCount, 3, "Final: Total count should be 3");
- assertEq(activeIds.length, 3, "Final: Active IDs length should be 3");
- assertEq(activeIds[0], subId1, "Final: ID 1 should be active");
- assertEq(activeIds[1], subId2, "Final: ID 2 should be active");
- assertEq(activeIds[2], subId3, "Final: ID 3 should be active"); // ID 3 is added back to the end
- // --- Deactivate all remaining subscriptions ---
- // Deactivate ID 1 (first element)
- (SchedulerState.SubscriptionParams memory params1, ) = scheduler
- .getSubscription(subId1);
- params1.isActive = false;
- vm.expectEmit();
- emit SubscriptionDeactivated(subId1);
- vm.expectEmit();
- emit SubscriptionUpdated(subId1);
- scheduler.updateSubscription(subId1, params1);
- // Verify state after deactivating ID 1
- (activeIds, , totalCount) = scheduler.getActiveSubscriptions(0, 10);
- assertEq(totalCount, 2, "After Deact 1: Total count should be 2");
- assertEq(
- activeIds.length,
- 2,
- "After Deact 1: Active IDs length should be 2"
- );
- assertEq(
- activeIds[0],
- subId3,
- "After Deact 1: ID 3 should be at index 0"
- ); // ID 3 moved to front
- assertEq(
- activeIds[1],
- subId2,
- "After Deact 1: ID 2 should be at index 1"
- );
- // Deactivate ID 2 (now last element)
- params2.isActive = false; // Use existing params struct
- vm.expectEmit();
- emit SubscriptionDeactivated(subId2);
- vm.expectEmit();
- emit SubscriptionUpdated(subId2);
- scheduler.updateSubscription(subId2, params2);
- // Verify state after deactivating ID 2
- (activeIds, , totalCount) = scheduler.getActiveSubscriptions(0, 10);
- assertEq(
- totalCount,
- 1,
- "After Deact 2 (again): Total count should be 1"
- );
- assertEq(
- activeIds.length,
- 1,
- "After Deact 2 (again): Active IDs length should be 1"
- );
- assertEq(
- activeIds[0],
- subId3,
- "After Deact 2 (again): Only ID 3 should be active"
- );
- // Deactivate ID 3 (last remaining element)
- params3.isActive = false; // Use existing params struct
- vm.expectEmit();
- emit SubscriptionDeactivated(subId3);
- vm.expectEmit();
- emit SubscriptionUpdated(subId3);
- scheduler.updateSubscription(subId3, params3);
- // Verify final empty state
- (activeIds, , totalCount) = scheduler.getActiveSubscriptions(0, 10);
- assertEq(totalCount, 0, "Empty: Total count should be 0");
- assertEq(activeIds.length, 0, "Empty: Active IDs length should be 0");
- }
- function testAddFunds() public {
- // First add a subscription
- uint256 subscriptionId = addTestSubscription(
- scheduler,
- address(reader)
- );
- // Get initial balance (which includes minimum balance)
- (, SchedulerState.SubscriptionStatus memory initialStatus) = scheduler
- .getSubscription(subscriptionId);
- uint256 initialBalance = initialStatus.balanceInWei;
- // Add funds
- uint256 fundAmount = 1 ether;
- scheduler.addFunds{value: fundAmount}(subscriptionId);
- // Verify funds were added
- (, SchedulerState.SubscriptionStatus memory status) = scheduler
- .getSubscription(subscriptionId);
- assertEq(
- status.balanceInWei,
- initialBalance + fundAmount,
- "Balance should match initial balance plus added funds"
- );
- }
- function testAddFundsWithInactiveSubscriptionReverts() public {
- // Create a subscription with minimum balance
- uint256 subscriptionId = addTestSubscription(
- scheduler,
- address(reader)
- );
- // Get subscription parameters and calculate minimum balance
- (SchedulerState.SubscriptionParams memory params, ) = scheduler
- .getSubscription(subscriptionId);
- uint256 minimumBalance = scheduler.getMinimumBalance(
- uint8(params.priceIds.length)
- );
- // Deactivate the subscription
- SchedulerState.SubscriptionParams memory testParams = params;
- testParams.isActive = false;
- scheduler.updateSubscription(subscriptionId, testParams);
- // Withdraw funds to get below minimum
- uint256 withdrawAmount = minimumBalance - 1 wei;
- scheduler.withdrawFunds(subscriptionId, withdrawAmount);
- // Verify balance is now below minimum
- (
- SchedulerState.SubscriptionParams memory testUpdatedParams,
- SchedulerState.SubscriptionStatus memory testUpdatedStatus
- ) = scheduler.getSubscription(subscriptionId);
- assertEq(
- testUpdatedStatus.balanceInWei,
- 1 wei,
- "Balance should be 1 wei after withdrawal"
- );
- // Try to add funds to inactive subscription (should fail with InactiveSubscription)
- vm.expectRevert(abi.encodeWithSelector(InactiveSubscription.selector));
- scheduler.addFunds{value: 1 wei}(subscriptionId);
- // Try to reactivate with insufficient balance (should fail)
- testUpdatedParams.isActive = true;
- vm.expectRevert(abi.encodeWithSelector(InsufficientBalance.selector));
- scheduler.updateSubscription(subscriptionId, testUpdatedParams);
- }
- function testAddFundsEnforcesMinimumBalance() public {
- uint256 subscriptionId = addTestSubscriptionWithFeeds(
- scheduler,
- 2,
- address(reader)
- );
- (SchedulerState.SubscriptionParams memory params, ) = scheduler
- .getSubscription(subscriptionId);
- uint256 minimumBalance = scheduler.getMinimumBalance(
- uint8(params.priceIds.length)
- );
- // Send multiple price updates to drain the balance below minimum
- for (uint i = 0; i < 5; i++) {
- // Advance time to satisfy heartbeat criteria
- vm.warp(block.timestamp + 60);
- // Create price feeds with current timestamp
- uint64 publishTime = SafeCast.toUint64(block.timestamp);
- PythStructs.PriceFeed[] memory priceFeeds;
- uint64[] memory slots;
- (priceFeeds, slots) = createMockPriceFeedsWithSlots(
- publishTime,
- params.priceIds.length
- );
- // Mock Pyth response
- mockParsePriceFeedUpdatesWithSlotsStrict(pyth, priceFeeds, slots);
- bytes[] memory updateData = createMockUpdateData(priceFeeds);
- // Perform update
- vm.prank(pusher);
- scheduler.updatePriceFeeds(subscriptionId, updateData);
- }
- // Verify balance is now below minimum
- (
- ,
- SchedulerState.SubscriptionStatus memory statusAfterUpdates
- ) = scheduler.getSubscription(subscriptionId);
- assertTrue(
- statusAfterUpdates.balanceInWei < minimumBalance,
- "Balance should be below minimum after updates"
- );
- // Try to add funds that would still leave balance below minimum
- // Expect a revert with InsufficientBalance
- uint256 insufficientFunds = minimumBalance -
- statusAfterUpdates.balanceInWei -
- 1;
- vm.expectRevert(abi.encodeWithSelector(InsufficientBalance.selector));
- scheduler.addFunds{value: insufficientFunds}(subscriptionId);
- // Add sufficient funds to get back above minimum
- uint256 sufficientFunds = minimumBalance -
- statusAfterUpdates.balanceInWei +
- 1;
- scheduler.addFunds{value: sufficientFunds}(subscriptionId);
- // Verify balance is now above minimum
- (
- ,
- SchedulerState.SubscriptionStatus memory statusAfterAddingFunds
- ) = scheduler.getSubscription(subscriptionId);
- assertTrue(
- statusAfterAddingFunds.balanceInWei >= minimumBalance,
- "Balance should be at or above minimum after adding sufficient funds"
- );
- }
- function testWithdrawFunds() public {
- // Add a subscription and get the parameters
- uint256 subscriptionId = addTestSubscription(
- scheduler,
- address(reader)
- );
- (SchedulerState.SubscriptionParams memory params, ) = scheduler
- .getSubscription(subscriptionId);
- uint256 minimumBalance = scheduler.getMinimumBalance(
- uint8(params.priceIds.length)
- );
- // Add extra funds
- uint256 extraFunds = 1 ether;
- scheduler.addFunds{value: extraFunds}(subscriptionId);
- // Get initial balance
- uint256 initialBalance = address(this).balance;
- // Withdraw extra funds
- scheduler.withdrawFunds(subscriptionId, extraFunds);
- // Verify funds were withdrawn
- (, SchedulerState.SubscriptionStatus memory status) = scheduler
- .getSubscription(subscriptionId);
- assertEq(
- status.balanceInWei,
- minimumBalance,
- "Remaining balance should be minimum balance"
- );
- assertEq(
- address(this).balance,
- initialBalance + extraFunds,
- "Withdrawn amount not received"
- );
- // Try to withdraw below minimum balance
- vm.expectRevert(abi.encodeWithSelector(InsufficientBalance.selector));
- scheduler.withdrawFunds(subscriptionId, 1 wei);
- // Deactivate subscription
- params.isActive = false;
- scheduler.updateSubscription(subscriptionId, params);
- // Now we should be able to withdraw all funds
- scheduler.withdrawFunds(subscriptionId, minimumBalance);
- // Verify all funds were withdrawn
- (, status) = scheduler.getSubscription(subscriptionId);
- assertEq(
- status.balanceInWei,
- 0,
- "Balance should be 0 after withdrawing all funds"
- );
- }
- function testPermanentSubscription() public {
- uint256 subscriptionId = addTestSubscription(
- scheduler,
- address(reader)
- );
- // Verify subscription was created as non-permanent initially
- (SchedulerState.SubscriptionParams memory params, ) = scheduler
- .getSubscription(subscriptionId);
- assertFalse(params.isPermanent, "Should not be permanent initially");
- // Make it permanent
- params.isPermanent = true;
- scheduler.updateSubscription(subscriptionId, params);
- // Verify subscription is now permanent
- (SchedulerState.SubscriptionParams memory storedParams, ) = scheduler
- .getSubscription(subscriptionId);
- assertTrue(
- storedParams.isPermanent,
- "Subscription should be permanent"
- );
- // Test 1: Cannot disable isPermanent flag
- SchedulerState.SubscriptionParams memory updatedParams = storedParams;
- updatedParams.isPermanent = false;
- vm.expectRevert(
- abi.encodeWithSelector(CannotUpdatePermanentSubscription.selector)
- );
- scheduler.updateSubscription(subscriptionId, updatedParams);
- // Test 2: Cannot remove price feeds
- updatedParams = storedParams;
- bytes32[] memory reducedPriceIds = new bytes32[](
- params.priceIds.length - 1
- );
- for (uint i = 0; i < reducedPriceIds.length; i++) {
- reducedPriceIds[i] = params.priceIds[i];
- }
- updatedParams.priceIds = reducedPriceIds;
- vm.expectRevert(
- abi.encodeWithSelector(CannotUpdatePermanentSubscription.selector)
- );
- scheduler.updateSubscription(subscriptionId, updatedParams);
- // Test 3: Cannot withdraw funds
- uint256 extraFunds = 1 ether;
- vm.deal(address(0x123), extraFunds);
- // Anyone can add funds (not just manager)
- vm.prank(address(0x123));
- scheduler.addFunds{value: extraFunds}(subscriptionId);
- vm.expectRevert(
- abi.encodeWithSelector(CannotUpdatePermanentSubscription.selector)
- );
- scheduler.withdrawFunds(subscriptionId, 0.1 ether);
- // Test 4: Cannot add more price feeds
- updatedParams = storedParams;
- bytes32[] memory expandedPriceIds = new bytes32[](
- params.priceIds.length + 1
- );
- for (uint i = 0; i < params.priceIds.length; i++) {
- expandedPriceIds[i] = params.priceIds[i];
- }
- expandedPriceIds[params.priceIds.length] = bytes32(
- uint256(keccak256(abi.encodePacked("additional-price-id")))
- );
- updatedParams.priceIds = expandedPriceIds;
- vm.expectRevert(
- abi.encodeWithSelector(CannotUpdatePermanentSubscription.selector)
- );
- scheduler.updateSubscription(subscriptionId, updatedParams);
- // Verify price feeds were not added (length should remain the same)
- (storedParams, ) = scheduler.getSubscription(subscriptionId);
- assertEq(
- storedParams.priceIds.length,
- params.priceIds.length, // Verify length hasn't changed
- "Should not be able to add price feeds to permanent subscription"
- );
- // Test 6: Cannot change updateCriteria
- updatedParams = storedParams;
- updatedParams.updateCriteria.heartbeatSeconds =
- storedParams.updateCriteria.heartbeatSeconds +
- 60;
- vm.expectRevert(
- abi.encodeWithSelector(CannotUpdatePermanentSubscription.selector)
- );
- scheduler.updateSubscription(subscriptionId, updatedParams);
- // Test 7: Cannot change whitelistEnabled
- updatedParams = storedParams;
- updatedParams.whitelistEnabled = !storedParams.whitelistEnabled;
- vm.expectRevert(
- abi.encodeWithSelector(CannotUpdatePermanentSubscription.selector)
- );
- scheduler.updateSubscription(subscriptionId, updatedParams);
- // Test 8: Cannot change the set of readers in the whitelist (add one)
- updatedParams = storedParams;
- address[] memory expandedWhitelist = new address[](
- storedParams.readerWhitelist.length + 1
- );
- for (uint i = 0; i < storedParams.readerWhitelist.length; i++) {
- expandedWhitelist[i] = storedParams.readerWhitelist[i];
- }
- expandedWhitelist[storedParams.readerWhitelist.length] = address(0x456);
- updatedParams.readerWhitelist = expandedWhitelist;
- vm.expectRevert(
- abi.encodeWithSelector(CannotUpdatePermanentSubscription.selector)
- );
- scheduler.updateSubscription(subscriptionId, updatedParams);
- // Test 9: Cannot change the set of readers in the whitelist (remove one)
- // Requires at least one reader in the initial setup
- if (storedParams.readerWhitelist.length > 0) {
- updatedParams = storedParams;
- address[] memory reducedWhitelist = new address[](
- storedParams.readerWhitelist.length - 1
- );
- for (uint i = 0; i < reducedWhitelist.length; i++) {
- reducedWhitelist[i] = storedParams.readerWhitelist[i];
- }
- updatedParams.readerWhitelist = reducedWhitelist;
- vm.expectRevert(
- abi.encodeWithSelector(
- CannotUpdatePermanentSubscription.selector
- )
- );
- scheduler.updateSubscription(subscriptionId, updatedParams);
- }
- // Test 10: Cannot deactivate a permanent subscription
- updatedParams = storedParams;
- updatedParams.isActive = false;
- vm.expectRevert(
- abi.encodeWithSelector(CannotUpdatePermanentSubscription.selector)
- );
- scheduler.updateSubscription(subscriptionId, updatedParams);
- }
- function testMakeExistingSubscriptionPermanent() public {
- // First create a non-permanent subscription
- uint256 subscriptionId = addTestSubscription(
- scheduler,
- address(reader)
- );
- // Verify it's not permanent
- (SchedulerState.SubscriptionParams memory params, ) = scheduler
- .getSubscription(subscriptionId);
- assertFalse(
- params.isPermanent,
- "Subscription should not be permanent initially"
- );
- // Make it permanent
- params.isPermanent = true;
- scheduler.updateSubscription(subscriptionId, params);
- // Verify it's now permanent
- (params, ) = scheduler.getSubscription(subscriptionId);
- assertTrue(params.isPermanent, "Subscription should now be permanent");
- // Verify we can't make it non-permanent again
- params.isPermanent = false;
- vm.expectRevert(
- abi.encodeWithSelector(CannotUpdatePermanentSubscription.selector)
- );
- scheduler.updateSubscription(subscriptionId, params);
- }
- function testPermanentSubscriptionDepositLimit() public {
- // Test 1: Creating a permanent subscription with deposit exceeding MAX_DEPOSIT_LIMIT should fail
- SchedulerState.SubscriptionParams
- memory params = createDefaultSubscriptionParams(2, address(reader));
- params.isPermanent = true;
- uint256 maxDepositLimit = 100 ether; // Same as MAX_DEPOSIT_LIMIT in SchedulerState
- uint256 excessiveDeposit = maxDepositLimit + 1 ether;
- vm.deal(address(this), excessiveDeposit);
- vm.expectRevert(
- abi.encodeWithSelector(MaxDepositLimitExceeded.selector)
- );
- scheduler.createSubscription{value: excessiveDeposit}(params);
- // Test 2: Creating a permanent subscription with deposit within MAX_DEPOSIT_LIMIT should succeed
- uint256 validDeposit = maxDepositLimit;
- vm.deal(address(this), validDeposit);
- uint256 subscriptionId = scheduler.createSubscription{
- value: validDeposit
- }(params);
- // Verify subscription was created correctly
- (
- SchedulerState.SubscriptionParams memory storedParams,
- SchedulerState.SubscriptionStatus memory status
- ) = scheduler.getSubscription(subscriptionId);
- assertTrue(
- storedParams.isPermanent,
- "Subscription should be permanent"
- );
- assertEq(
- status.balanceInWei,
- validDeposit,
- "Balance should match deposit amount"
- );
- // Test 3: Adding funds to a permanent subscription with deposit exceeding MAX_DEPOSIT_LIMIT should fail
- uint256 largeAdditionalFunds = maxDepositLimit + 1;
- vm.deal(address(this), largeAdditionalFunds);
- vm.expectRevert(
- abi.encodeWithSelector(MaxDepositLimitExceeded.selector)
- );
- scheduler.addFunds{value: largeAdditionalFunds}(subscriptionId);
- // Test 4: Adding funds to a permanent subscription within MAX_DEPOSIT_LIMIT should succeed
- // Create a non-permanent subscription to test partial funding
- SchedulerState.SubscriptionParams
- memory nonPermanentParams = createDefaultSubscriptionParams(
- 2,
- address(reader)
- );
- uint256 minimumBalance = scheduler.getMinimumBalance(
- uint8(nonPermanentParams.priceIds.length)
- );
- vm.deal(address(this), minimumBalance);
- uint256 nonPermanentSubId = scheduler.createSubscription{
- value: minimumBalance
- }(nonPermanentParams);
- // Add funds to the non-permanent subscription (should be within limit)
- uint256 validAdditionalFunds = 5 ether;
- vm.deal(address(this), validAdditionalFunds);
- scheduler.addFunds{value: validAdditionalFunds}(nonPermanentSubId);
- // Verify funds were added correctly
- (
- ,
- SchedulerState.SubscriptionStatus memory nonPermanentStatus
- ) = scheduler.getSubscription(nonPermanentSubId);
- assertEq(
- nonPermanentStatus.balanceInWei,
- minimumBalance + validAdditionalFunds,
- "Balance should be increased by the funded amount"
- );
- // Test 5: Non-permanent subscriptions should not be subject to the deposit limit
- uint256 largeDeposit = maxDepositLimit * 2;
- vm.deal(address(this), largeDeposit);
- SchedulerState.SubscriptionParams
- memory unlimitedParams = createDefaultSubscriptionParams(
- 2,
- address(reader)
- );
- uint256 unlimitedSubId = scheduler.createSubscription{
- value: largeDeposit
- }(unlimitedParams);
- // Verify subscription was created with the large deposit
- (, SchedulerState.SubscriptionStatus memory unlimitedStatus) = scheduler
- .getSubscription(unlimitedSubId);
- assertEq(
- unlimitedStatus.balanceInWei,
- largeDeposit,
- "Non-permanent subscription should accept large deposits"
- );
- }
- function testAnyoneCanAddFunds() public {
- // Create a subscription
- uint256 subscriptionId = addTestSubscription(
- scheduler,
- address(reader)
- );
- // Get initial balance
- (, SchedulerState.SubscriptionStatus memory initialStatus) = scheduler
- .getSubscription(subscriptionId);
- uint256 initialBalance = initialStatus.balanceInWei;
- // Have a different address add funds
- address funder = address(0x123);
- uint256 fundAmount = 1 ether;
- vm.deal(funder, fundAmount);
- vm.prank(funder);
- scheduler.addFunds{value: fundAmount}(subscriptionId);
- // Verify funds were added
- (, SchedulerState.SubscriptionStatus memory status) = scheduler
- .getSubscription(subscriptionId);
- assertEq(
- status.balanceInWei,
- initialBalance + fundAmount,
- "Balance should be increased by the funded amount"
- );
- }
- function testUpdatePriceFeedsUpdatesPricesCorrectly() public {
- // --- First Update ---
- // Add a subscription and funds
- uint256 subscriptionId = addTestSubscription(
- scheduler,
- address(reader)
- ); // Uses heartbeat 60s, deviation 100bps
- uint256 fundAmount = 2 ether; // Add enough for two updates
- scheduler.addFunds{value: fundAmount}(subscriptionId);
- // Create price feeds and mock Pyth response for first update
- bytes32[] memory priceIds = createPriceIds();
- uint64 publishTime1 = SafeCast.toUint64(block.timestamp);
- PythStructs.PriceFeed[] memory priceFeeds1;
- uint64[] memory slots;
- (priceFeeds1, slots) = createMockPriceFeedsWithSlots(
- publishTime1,
- priceIds.length
- );
- mockParsePriceFeedUpdatesWithSlotsStrict(pyth, priceFeeds1, slots);
- bytes[] memory updateData1 = createMockUpdateData(priceFeeds1);
- // Perform first update
- vm.expectEmit();
- emit PricesUpdated(subscriptionId, publishTime1);
- vm.prank(pusher);
- scheduler.updatePriceFeeds(subscriptionId, updateData1);
- // Verify first update
- (, SchedulerState.SubscriptionStatus memory status1) = scheduler
- .getSubscription(subscriptionId);
- assertEq(
- status1.priceLastUpdatedAt,
- publishTime1,
- "First update timestamp incorrect"
- );
- assertEq(
- status1.totalUpdates,
- priceIds.length,
- "Total updates should be equal to the number of price feeds"
- );
- assertTrue(
- status1.totalSpent > 0,
- "Total spent should be > 0 after first update"
- );
- uint256 spentAfterFirst = status1.totalSpent; // Store spent amount
- // --- Second Update ---
- // Advance time beyond heartbeat interval (e.g., 100 seconds)
- vm.warp(block.timestamp + 100);
- // Create price feeds for second update by cloning first update and modifying
- uint64 publishTime2 = SafeCast.toUint64(block.timestamp);
- PythStructs.PriceFeed[]
- memory priceFeeds2 = new PythStructs.PriceFeed[](
- priceFeeds1.length
- );
- for (uint i = 0; i < priceFeeds1.length; i++) {
- priceFeeds2[i] = priceFeeds1[i]; // Clone the feed struct
- priceFeeds2[i].price.publishTime = publishTime2; // Update timestamp
- // Apply a 100 bps price increase (satisfies update criteria)
- int64 priceDiff = int64(
- (uint64(priceFeeds1[i].price.price) * 100) / 10_000
- );
- priceFeeds2[i].price.price = priceFeeds1[i].price.price + priceDiff;
- priceFeeds2[i].emaPrice.publishTime = publishTime2;
- }
- mockParsePriceFeedUpdatesWithSlotsStrict(pyth, priceFeeds2, slots); // Mock for the second call
- bytes[] memory updateData2 = createMockUpdateData(priceFeeds2);
- // Perform second update
- vm.expectEmit();
- emit PricesUpdated(subscriptionId, publishTime2);
- vm.prank(pusher);
- scheduler.updatePriceFeeds(subscriptionId, updateData2);
- // Verify second update
- (, SchedulerState.SubscriptionStatus memory status2) = scheduler
- .getSubscription(subscriptionId);
- assertEq(
- status2.priceLastUpdatedAt,
- publishTime2,
- "Second update timestamp incorrect"
- );
- assertEq(
- status2.totalUpdates,
- priceIds.length * 2,
- "Total updates should be equal to the number of price feeds * 2 (first + second update)"
- );
- assertTrue(
- status2.totalSpent > spentAfterFirst,
- "Total spent should increase after second update"
- );
- // Verify price feed data using the reader contract for the second update
- assertTrue(
- reader.verifyPriceFeeds(
- subscriptionId,
- new bytes32[](0),
- priceFeeds2
- ),
- "Price feeds verification failed after second update"
- );
- }
- function testUpdatePriceFeedsPaysKeeperCorrectly() public {
- // Set gas price
- uint256 gasPrice = 0.1 gwei;
- vm.txGasPrice(gasPrice);
- // Add subscription and funds
- uint256 subscriptionId = addTestSubscription(
- scheduler,
- address(reader)
- );
- // Prepare update data
- (SchedulerState.SubscriptionParams memory params, ) = scheduler
- .getSubscription(subscriptionId);
- (
- PythStructs.PriceFeed[] memory priceFeeds,
- uint64[] memory slots
- ) = createMockPriceFeedsWithSlots(
- SafeCast.toUint64(block.timestamp),
- params.priceIds.length
- );
- uint256 mockPythFee = MOCK_PYTH_FEE_PER_FEED * params.priceIds.length;
- mockParsePriceFeedUpdatesWithSlotsStrict(pyth, priceFeeds, slots);
- bytes[] memory updateData = createMockUpdateData(priceFeeds);
- // Get state before
- uint256 pusherBalanceBefore = pusher.balance;
- (, SchedulerState.SubscriptionStatus memory statusBefore) = scheduler
- .getSubscription(subscriptionId);
- console.log(
- "Subscription balance before update:",
- vm.toString(statusBefore.balanceInWei)
- );
- // Perform update
- vm.prank(pusher);
- scheduler.updatePriceFeeds(subscriptionId, updateData);
- // Get state after
- (, SchedulerState.SubscriptionStatus memory statusAfter) = scheduler
- .getSubscription(subscriptionId);
- // Calculate total fee deducted from subscription
- uint256 totalFeeDeducted = statusBefore.balanceInWei -
- statusAfter.balanceInWei;
- // Calculate minimum keeper fee (overhead + feed-specific fee)
- // The real cost is more because of the gas used in the updatePriceFeeds function
- uint256 minKeeperFee = (scheduler.GAS_OVERHEAD() * gasPrice) +
- (uint256(scheduler.getSingleUpdateKeeperFeeInWei()) *
- params.priceIds.length);
- assertGt(
- totalFeeDeducted,
- minKeeperFee + mockPythFee,
- "Total fee deducted should be greater than the sum of keeper fee and Pyth fee (since gas usage of updatePriceFeeds is not accounted for)"
- );
- assertEq(
- statusAfter.totalSpent,
- statusBefore.totalSpent + totalFeeDeducted,
- "Total spent should increase by the total fee deducted"
- );
- assertEq(
- pusher.balance,
- pusherBalanceBefore + totalFeeDeducted - mockPythFee,
- "Pusher balance should increase by the keeper fee"
- );
- // This assertion is self-evident based on the calculations above, but keeping it for clarity
- assertEq(
- statusAfter.balanceInWei,
- statusBefore.balanceInWei - totalFeeDeducted,
- "Subscription balance should decrease by the total fee deducted"
- );
- }
- function testUpdatePriceFeedsRevertsInsufficientBalanceForKeeperFee()
- public
- {
- // Set gas price
- uint256 gasPrice = 0.5 gwei;
- vm.txGasPrice(gasPrice);
- // Mock the minimum balance for the subscription to be
- // zero so that we can test the keeper fee
- vm.mockCall(
- address(scheduler),
- abi.encodeWithSelector(Scheduler.getMinimumBalance.selector),
- abi.encode(0)
- );
- // Add subscription
- uint256 subscriptionId = addTestSubscription(
- scheduler,
- address(reader)
- );
- bytes32[] memory priceIds = createPriceIds();
- // Prepare update data and get Pyth fee
- uint64 publishTime = SafeCast.toUint64(block.timestamp);
- PythStructs.PriceFeed[] memory priceFeeds;
- uint64[] memory slots;
- (priceFeeds, slots) = createMockPriceFeedsWithSlots(
- publishTime,
- priceIds.length
- );
- uint256 mockPythFee = MOCK_PYTH_FEE_PER_FEED * priceIds.length;
- mockParsePriceFeedUpdatesWithSlotsStrict(pyth, priceFeeds, slots);
- bytes[] memory updateData = createMockUpdateData(priceFeeds);
- // Calculate minimum keeper fee (overhead + feed-specific fee)
- // The real cost is more because of the gas used in the updatePriceFeeds function
- uint256 minKeeperFee = (scheduler.GAS_OVERHEAD() * gasPrice) +
- (uint256(scheduler.getSingleUpdateKeeperFeeInWei()) *
- priceIds.length);
- // Fund subscription without enough for Pyth fee + keeper fee
- // It won't be enough because of the gas cost of updatePriceFeeds
- uint256 fundAmount = mockPythFee + minKeeperFee;
- scheduler.addFunds{value: fundAmount}(subscriptionId);
- // Get and print the subscription balance before attempting the update
- (, SchedulerState.SubscriptionStatus memory status) = scheduler
- .getSubscription(subscriptionId);
- console.log(
- "Subscription balance before update:",
- vm.toString(status.balanceInWei)
- );
- console.log("Required Pyth fee:", vm.toString(mockPythFee));
- console.log("Minimum keeper fee:", vm.toString(minKeeperFee));
- console.log(
- "Total minimum required:",
- vm.toString(mockPythFee + minKeeperFee)
- );
- // Expect revert due to insufficient balance for total fee
- vm.expectRevert(abi.encodeWithSelector(InsufficientBalance.selector));
- vm.prank(pusher);
- scheduler.updatePriceFeeds(subscriptionId, updateData);
- }
- function testUpdatePriceFeedsRevertsOnHeartbeatUpdateConditionNotMet()
- public
- {
- // Add a subscription with only heartbeat criteria (60 seconds)
- uint32 heartbeat = 60;
- SchedulerState.UpdateCriteria memory criteria = SchedulerState
- .UpdateCriteria({
- updateOnHeartbeat: true,
- heartbeatSeconds: heartbeat,
- updateOnDeviation: false,
- deviationThresholdBps: 0
- });
- uint256 subscriptionId = addTestSubscriptionWithUpdateCriteria(
- scheduler,
- criteria,
- address(reader)
- );
- uint256 fundAmount = 1 ether;
- scheduler.addFunds{value: fundAmount}(subscriptionId);
- // First update to set initial timestamp
- uint64 publishTime1 = SafeCast.toUint64(block.timestamp);
- PythStructs.PriceFeed[] memory priceFeeds1;
- uint64[] memory slots1;
- (priceFeeds1, slots1) = createMockPriceFeedsWithSlots(publishTime1, 2);
- mockParsePriceFeedUpdatesWithSlotsStrict(pyth, priceFeeds1, slots1);
- bytes[] memory updateData1 = createMockUpdateData(priceFeeds1);
- vm.prank(pusher);
- scheduler.updatePriceFeeds(subscriptionId, updateData1);
- // Prepare second update within heartbeat interval
- vm.warp(block.timestamp + 30); // Advance time by 30 seconds (less than 60)
- uint64 publishTime2 = SafeCast.toUint64(block.timestamp);
- PythStructs.PriceFeed[] memory priceFeeds2;
- uint64[] memory slots2;
- (priceFeeds2, slots2) = createMockPriceFeedsWithSlots(publishTime2, 2);
- mockParsePriceFeedUpdatesWithSlotsStrict(pyth, priceFeeds2, slots2);
- bytes[] memory updateData2 = createMockUpdateData(priceFeeds2);
- // Expect revert because heartbeat condition is not met
- vm.expectRevert(
- abi.encodeWithSelector(UpdateConditionsNotMet.selector)
- );
- vm.prank(pusher);
- scheduler.updatePriceFeeds(subscriptionId, updateData2);
- }
- function testUpdatePriceFeedsRevertsOnDeviationUpdateConditionNotMet()
- public
- {
- // Add a subscription with only deviation criteria (100 bps / 1%)
- uint16 deviationBps = 100;
- SchedulerState.UpdateCriteria memory criteria = SchedulerState
- .UpdateCriteria({
- updateOnHeartbeat: false,
- heartbeatSeconds: 0,
- updateOnDeviation: true,
- deviationThresholdBps: deviationBps
- });
- uint256 subscriptionId = addTestSubscriptionWithUpdateCriteria(
- scheduler,
- criteria,
- address(reader)
- );
- uint256 fundAmount = 1 ether;
- scheduler.addFunds{value: fundAmount}(subscriptionId);
- // First update to set initial price
- uint64 publishTime1 = SafeCast.toUint64(block.timestamp);
- PythStructs.PriceFeed[] memory priceFeeds1;
- uint64[] memory slots;
- (priceFeeds1, slots) = createMockPriceFeedsWithSlots(publishTime1, 2);
- mockParsePriceFeedUpdatesWithSlotsStrict(pyth, priceFeeds1, slots);
- bytes[] memory updateData1 = createMockUpdateData(priceFeeds1);
- vm.prank(pusher);
- scheduler.updatePriceFeeds(subscriptionId, updateData1);
- // Prepare second update with price deviation less than threshold (e.g., 50 bps)
- vm.warp(block.timestamp + 1000); // Advance time significantly (doesn't matter for deviation)
- uint64 publishTime2 = SafeCast.toUint64(block.timestamp);
- // Clone priceFeeds1 and apply a 50 bps deviation to its prices
- PythStructs.PriceFeed[]
- memory priceFeeds2 = new PythStructs.PriceFeed[](
- priceFeeds1.length
- );
- for (uint i = 0; i < priceFeeds1.length; i++) {
- priceFeeds2[i].id = priceFeeds1[i].id;
- // Apply 50 bps deviation to the price
- int64 priceDiff = int64(
- (uint64(priceFeeds1[i].price.price) * 50) / 10_000
- );
- priceFeeds2[i].price.price = priceFeeds1[i].price.price + priceDiff;
- priceFeeds2[i].price.conf = priceFeeds1[i].price.conf;
- priceFeeds2[i].price.expo = priceFeeds1[i].price.expo;
- priceFeeds2[i].price.publishTime = publishTime2;
- }
- mockParsePriceFeedUpdatesWithSlotsStrict(pyth, priceFeeds2, slots);
- bytes[] memory updateData2 = createMockUpdateData(priceFeeds2);
- // Expect revert because deviation condition is not met
- vm.expectRevert(
- abi.encodeWithSelector(UpdateConditionsNotMet.selector)
- );
- vm.prank(pusher);
- scheduler.updatePriceFeeds(subscriptionId, updateData2);
- }
- function testUpdatePriceFeedsRevertsOnOlderTimestamp() public {
- // Add a subscription and funds
- uint256 subscriptionId = addTestSubscription(
- scheduler,
- address(reader)
- );
- uint256 fundAmount = 1 ether;
- scheduler.addFunds{value: fundAmount}(subscriptionId);
- // First update to establish last updated timestamp
- uint64 publishTime1 = SafeCast.toUint64(block.timestamp);
- PythStructs.PriceFeed[] memory priceFeeds1;
- uint64[] memory slots1;
- (priceFeeds1, slots1) = createMockPriceFeedsWithSlots(publishTime1, 2);
- mockParsePriceFeedUpdatesWithSlotsStrict(pyth, priceFeeds1, slots1);
- bytes[] memory updateData1 = createMockUpdateData(priceFeeds1);
- vm.prank(pusher);
- scheduler.updatePriceFeeds(subscriptionId, updateData1);
- // Prepare second update with an older timestamp
- uint64 publishTime2 = publishTime1 - 10; // Timestamp older than the first update
- PythStructs.PriceFeed[] memory priceFeeds2;
- uint64[] memory slots2;
- (priceFeeds2, slots2) = createMockPriceFeedsWithSlots(publishTime2, 2);
- // Mock Pyth response to return feeds with the older timestamp
- mockParsePriceFeedUpdatesWithSlotsStrict(pyth, priceFeeds2, slots2);
- bytes[] memory updateData2 = createMockUpdateData(priceFeeds2);
- // Expect revert with TimestampOlderThanLastUpdate (checked in _validateShouldUpdatePrices)
- vm.expectRevert(
- abi.encodeWithSelector(
- TimestampOlderThanLastUpdate.selector,
- publishTime2,
- publishTime1
- )
- );
- // Attempt to update price feeds
- vm.prank(pusher);
- scheduler.updatePriceFeeds(subscriptionId, updateData2);
- }
- function testUpdatePriceFeedsRevertsOnMismatchedSlots() public {
- // First add a subscription and funds
- uint256 subscriptionId = addTestSubscription(
- scheduler,
- address(reader)
- );
- uint256 fundAmount = 1 ether;
- scheduler.addFunds{value: fundAmount}(subscriptionId);
- // Create two price feeds with same timestamp but different slots
- uint64 publishTime = SafeCast.toUint64(block.timestamp);
- PythStructs.PriceFeed[] memory priceFeeds = new PythStructs.PriceFeed[](
- 2
- );
- priceFeeds[0] = createSingleMockPriceFeed(publishTime);
- priceFeeds[1] = createSingleMockPriceFeed(publishTime);
- // Create slots array with different slot values
- uint64[] memory slots = new uint64[](2);
- slots[0] = 100;
- slots[1] = 200; // Different slot
- // Mock Pyth response to return these feeds with mismatched slots
- mockParsePriceFeedUpdatesWithSlotsStrict(pyth, priceFeeds, slots);
- bytes[] memory updateData = createMockUpdateData(priceFeeds);
- // Expect revert with PriceSlotMismatch error
- vm.expectRevert(abi.encodeWithSelector(PriceSlotMismatch.selector));
- // Attempt to update price feeds
- vm.prank(pusher);
- scheduler.updatePriceFeeds(subscriptionId, updateData);
- }
- function testUpdateSubscriptionEnforcesMinimumBalance() public {
- // Setup: Create subscription with 2 feeds, funded exactly to minimum
- uint8 initialNumFeeds = 2;
- uint256 subscriptionId = addTestSubscriptionWithFeeds(
- scheduler,
- initialNumFeeds,
- address(reader)
- );
- (
- SchedulerState.SubscriptionParams memory currentParams,
- SchedulerState.SubscriptionStatus memory initialStatus
- ) = scheduler.getSubscription(subscriptionId);
- uint256 initialMinimumBalance = scheduler.getMinimumBalance(
- initialNumFeeds
- );
- assertEq(
- initialStatus.balanceInWei,
- initialMinimumBalance,
- "Initial balance should be the minimum"
- );
- // Prepare new params with more feeds (4)
- uint8 newNumFeeds = 4;
- SchedulerState.SubscriptionParams memory newParams = currentParams;
- newParams.priceIds = createPriceIds(newNumFeeds); // Increase feeds
- newParams.isActive = true; // Keep it active
- // Action 1: Try to update with insufficient funds
- vm.expectRevert(abi.encodeWithSelector(InsufficientBalance.selector));
- scheduler.updateSubscription(subscriptionId, newParams);
- // Action 2: Supply enough funds to the updateSubscription call to meet the new minimum balance
- uint256 newMinimumBalance = scheduler.getMinimumBalance(newNumFeeds);
- uint256 requiredFunds = newMinimumBalance - initialMinimumBalance;
- scheduler.updateSubscription{value: requiredFunds}(
- subscriptionId,
- newParams
- );
- // Verification 2: Update should now succeed
- (SchedulerState.SubscriptionParams memory updatedParams, ) = scheduler
- .getSubscription(subscriptionId);
- assertEq(
- updatedParams.priceIds.length,
- newNumFeeds,
- "Number of price feeds should be updated"
- );
- // Scenario 3: Deactivating while adding feeds - should NOT check min balance
- // Reset state: create another subscription funded to minimum
- uint8 initialNumFeeds_deact = 2;
- uint256 subId_deact = addTestSubscriptionWithFeeds(
- scheduler,
- initialNumFeeds_deact,
- address(reader)
- );
- // Prepare params to add feeds (4) but also deactivate
- uint8 newNumFeeds_deact = 4;
- (
- SchedulerState.SubscriptionParams memory currentParams_deact,
- ) = scheduler.getSubscription(subId_deact);
- SchedulerState.SubscriptionParams
- memory newParams_deact = currentParams_deact;
- newParams_deact.priceIds = createPriceIds(newNumFeeds_deact);
- newParams_deact.isActive = false; // Deactivate
- // Action 3: Update (should succeed even with insufficient min balance for 4 feeds)
- scheduler.updateSubscription(subId_deact, newParams_deact);
- // Verification 3: Subscription should be inactive and have 4 feeds
- (
- SchedulerState.SubscriptionParams memory updatedParams_deact,
- ) = scheduler.getSubscription(subId_deact);
- assertFalse(
- updatedParams_deact.isActive,
- "Subscription should be inactive"
- );
- assertEq(
- updatedParams_deact.priceIds.length,
- newNumFeeds_deact,
- "Number of price feeds should be updated even when deactivating"
- );
- // Scenario 4: Reducing number of feeds still checks minimum balance
- // Create a subscription with 2 feeds funded to minimum
- uint8 initialNumFeeds_reduce = 2;
- uint256 subId_reduce = addTestSubscriptionWithFeeds(
- scheduler,
- initialNumFeeds_reduce,
- address(reader)
- );
- // Deplete the balance by updating price feeds multiple times
- uint64 publishTime = SafeCast.toUint64(block.timestamp);
- for (uint i = 0; i < 50; i++) {
- // Advance publish time by 60s for each update to satisfy update criteria
- (
- PythStructs.PriceFeed[] memory priceFeeds_reduce,
- uint64[] memory slots_reduce
- ) = createMockPriceFeedsWithSlots(publishTime + (i * 60), 2);
- mockParsePriceFeedUpdatesWithSlotsStrict(
- pyth,
- priceFeeds_reduce,
- slots_reduce
- );
- bytes[] memory updateData_reduce = createMockUpdateData(
- priceFeeds_reduce
- );
- vm.prank(pusher);
- scheduler.updatePriceFeeds(subId_reduce, updateData_reduce);
- }
- // Check that balance is now below minimum for 1 feed
- (, SchedulerState.SubscriptionStatus memory status_reduce) = scheduler
- .getSubscription(subId_reduce);
- uint256 minBalanceForOneFeed = scheduler.getMinimumBalance(1);
- assertTrue(
- status_reduce.balanceInWei < minBalanceForOneFeed,
- "Balance should be below minimum for 1 feed"
- );
- // Prepare params to reduce feeds from 2 to 1
- (
- SchedulerState.SubscriptionParams memory currentParams_reduce,
- ) = scheduler.getSubscription(subId_reduce);
- SchedulerState.SubscriptionParams
- memory newParams_reduce = currentParams_reduce;
- newParams_reduce.priceIds = new bytes32[](1);
- newParams_reduce.priceIds[0] = currentParams_reduce.priceIds[0];
- // Action 4: Update should fail due to insufficient balance
- vm.expectRevert(abi.encodeWithSelector(InsufficientBalance.selector));
- scheduler.updateSubscription(subId_reduce, newParams_reduce);
- // Add funds to cover minimum balance for 1 feed
- uint256 additionalFunds = minBalanceForOneFeed -
- status_reduce.balanceInWei +
- 0.01 ether;
- // Now the update should succeed
- scheduler.updateSubscription{value: additionalFunds}(
- subId_reduce,
- newParams_reduce
- );
- // Verify the subscription now has 1 feed
- (
- SchedulerState.SubscriptionParams memory updatedParams_reduce,
- ) = scheduler.getSubscription(subId_reduce);
- assertEq(
- updatedParams_reduce.priceIds.length,
- 1,
- "Number of price feeds should be reduced to 1"
- );
- }
- function testGetPricesUnsafeAllFeeds() public {
- // First add a subscription, funds, and update price feeds
- uint256 subscriptionId = addTestSubscription(
- scheduler,
- address(reader)
- );
- uint256 fundAmount = 1 ether;
- scheduler.addFunds{value: fundAmount}(subscriptionId);
- bytes32[] memory priceIds = createPriceIds();
- uint64 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);
- // Get all latest prices (empty priceIds array)
- bytes32[] memory emptyPriceIds = new bytes32[](0);
- PythStructs.Price[] memory latestPrices = scheduler.getPricesUnsafe(
- subscriptionId,
- emptyPriceIds
- );
- // Verify all price feeds were returned
- assertEq(
- latestPrices.length,
- priceIds.length,
- "Should return all price feeds"
- );
- // Verify price feed data using the reader contract
- assertTrue(
- reader.verifyPriceFeeds(subscriptionId, emptyPriceIds, priceFeeds),
- "Price feeds verification failed"
- );
- }
- function testGetPricesUnsafeSelectiveFeeds() public {
- // First add a subscription with 3 price feeds, funds, and update price feeds
- uint256 subscriptionId = addTestSubscriptionWithFeeds(
- scheduler,
- 3,
- address(reader)
- );
- uint256 fundAmount = 1 ether;
- scheduler.addFunds{value: fundAmount}(subscriptionId);
- bytes32[] memory priceIds = createPriceIds(3);
- uint64 publishTime = SafeCast.toUint64(block.timestamp);
- PythStructs.PriceFeed[] memory priceFeeds;
- uint64[] memory slots;
- (priceFeeds, slots) = createMockPriceFeedsWithSlots(publishTime, 3);
- mockParsePriceFeedUpdatesWithSlotsStrict(pyth, priceFeeds, slots);
- bytes[] memory updateData = createMockUpdateData(priceFeeds);
- vm.prank(pusher);
- scheduler.updatePriceFeeds(subscriptionId, updateData);
- // Get only the first price feed
- bytes32[] memory selectedPriceIds = new bytes32[](1);
- selectedPriceIds[0] = priceIds[0];
- PythStructs.Price[] memory latestPrices = scheduler.getPricesUnsafe(
- subscriptionId,
- selectedPriceIds
- );
- // Verify only one price feed was returned
- assertEq(latestPrices.length, 1, "Should return only one price feed");
- // Create expected price feed array with just the first feed
- PythStructs.PriceFeed[]
- memory expectedFeeds = new PythStructs.PriceFeed[](1);
- expectedFeeds[0] = priceFeeds[0];
- // Verify price feed data using the reader contract
- assertTrue(
- reader.verifyPriceFeeds(
- subscriptionId,
- selectedPriceIds,
- expectedFeeds
- ),
- "Price feeds verification failed"
- );
- }
- function testDisabledWhitelistAllowsUnrestrictedReads() public {
- uint256 subscriptionId = addTestSubscription(
- scheduler,
- address(reader)
- );
- // Get params and modify them
- (SchedulerState.SubscriptionParams memory params, ) = scheduler
- .getSubscription(subscriptionId);
- params.whitelistEnabled = false;
- params.readerWhitelist = new address[](0);
- scheduler.updateSubscription(subscriptionId, params);
- // Fund the subscription with enough to update it
- scheduler.addFunds{value: 1 ether}(subscriptionId);
- // Update price feeds for the subscription
- uint64 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);
- // Try to access from a non-whitelisted address (should succeed)
- address randomUser = address(0xdead);
- vm.startPrank(randomUser);
- bytes32[] memory emptyPriceIds = new bytes32[](0);
- // Should not revert since whitelist is disabled
- scheduler.getPricesUnsafe(subscriptionId, emptyPriceIds);
- vm.stopPrank();
- // Verify the data is correct using the test's reader
- assertTrue(
- reader.verifyPriceFeeds(subscriptionId, emptyPriceIds, priceFeeds),
- "Whitelist Disabled: Price feeds verification failed"
- );
- }
- function testEnabledWhitelistEnforcesOnlyAuthorizedReads() public {
- uint256 subscriptionId = addTestSubscription(
- scheduler,
- address(reader)
- );
- // Fund the subscription with enough to update it
- scheduler.addFunds{value: 1 ether}(subscriptionId);
- // Get the price IDs from the created subscription
- (SchedulerState.SubscriptionParams memory params, ) = scheduler
- .getSubscription(subscriptionId);
- bytes32[] memory priceIds = params.priceIds;
- // Update price feeds for the subscription
- uint64 publishTime = SafeCast.toUint64(block.timestamp + 10); // Slightly different time
- PythStructs.PriceFeed[] memory priceFeeds;
- uint64[] memory slots;
- (priceFeeds, slots) = createMockPriceFeedsWithSlots(
- publishTime,
- priceIds.length
- );
- mockParsePriceFeedUpdatesWithSlotsStrict(pyth, priceFeeds, slots);
- bytes[] memory updateData = createMockUpdateData(priceFeeds);
- vm.prank(pusher);
- scheduler.updatePriceFeeds(subscriptionId, updateData);
- // Try to access from a non-whitelisted address (should fail)
- vm.startPrank(address(0xdead));
- bytes32[] memory emptyPriceIds = new bytes32[](0);
- vm.expectRevert(abi.encodeWithSelector(Unauthorized.selector));
- scheduler.getPricesUnsafe(subscriptionId, emptyPriceIds);
- vm.stopPrank();
- // Try to access from the whitelisted reader address (should succeed)
- // Note: We call via the reader contract instance itself
- PythStructs.Price[] memory pricesFromReader = reader.getPricesUnsafe(
- subscriptionId,
- emptyPriceIds
- );
- assertEq(
- pricesFromReader.length,
- priceIds.length,
- "Whitelist Enabled: Reader should get correct number of prices"
- );
- // Verify the data obtained by the whitelisted reader is correct
- assertTrue(
- reader.verifyPriceFeeds(subscriptionId, emptyPriceIds, priceFeeds),
- "Whitelist Enabled: Price feeds verification failed via reader"
- );
- // Try to access from the manager address (should succeed)
- // Test contract is the manager
- vm.startPrank(address(this));
- PythStructs.Price[] memory pricesFromManager = scheduler
- .getPricesUnsafe(subscriptionId, emptyPriceIds);
- assertEq(
- pricesFromManager.length,
- priceIds.length,
- "Whitelist Enabled: Manager should get correct number of prices"
- );
- vm.stopPrank();
- }
- function testGetEmaPriceUnsafe() public {
- // First add a subscription, funds, and update price feeds
- uint256 subscriptionId = addTestSubscription(
- scheduler,
- address(reader)
- );
- uint256 fundAmount = 1 ether;
- scheduler.addFunds{value: fundAmount}(subscriptionId);
- bytes32[] memory priceIds = createPriceIds();
- uint64 publishTime = SafeCast.toUint64(block.timestamp);
- PythStructs.PriceFeed[] memory priceFeeds;
- uint64[] memory slots;
- (priceFeeds, slots) = createMockPriceFeedsWithSlots(publishTime, 2);
- // Ensure EMA prices are set in the mock price feeds
- for (uint i = 0; i < priceFeeds.length; i++) {
- priceFeeds[i].emaPrice.price = priceFeeds[i].price.price * 2; // Make EMA price different for testing
- priceFeeds[i].emaPrice.conf = priceFeeds[i].price.conf;
- priceFeeds[i].emaPrice.publishTime = publishTime;
- priceFeeds[i].emaPrice.expo = priceFeeds[i].price.expo;
- }
- mockParsePriceFeedUpdatesWithSlotsStrict(pyth, priceFeeds, slots);
- bytes[] memory updateData = createMockUpdateData(priceFeeds);
- vm.prank(pusher);
- scheduler.updatePriceFeeds(subscriptionId, updateData);
- // Get EMA prices
- bytes32[] memory emptyPriceIds = new bytes32[](0);
- PythStructs.Price[] memory emaPrices = scheduler.getEmaPriceUnsafe(
- subscriptionId,
- emptyPriceIds
- );
- // Verify all EMA prices were returned
- assertEq(
- emaPrices.length,
- priceIds.length,
- "Should return all EMA prices"
- );
- // Verify EMA price values
- for (uint i = 0; i < emaPrices.length; i++) {
- assertEq(
- emaPrices[i].price,
- priceFeeds[i].emaPrice.price,
- "EMA price value mismatch"
- );
- assertEq(
- emaPrices[i].publishTime,
- priceFeeds[i].emaPrice.publishTime,
- "EMA price publish time mismatch"
- );
- }
- }
- function testGetActiveSubscriptions() public {
- // Add two subscriptions with the test contract as manager
- addTestSubscription(scheduler, address(reader));
- addTestSubscription(scheduler, address(reader));
- // Create a subscription with pusher as manager
- vm.startPrank(pusher);
- bytes32[] memory priceIds = createPriceIds();
- address[] memory emptyWhitelist = new address[](0);
- SchedulerState.UpdateCriteria memory updateCriteria = SchedulerState
- .UpdateCriteria({
- updateOnHeartbeat: true,
- heartbeatSeconds: 60,
- updateOnDeviation: true,
- deviationThresholdBps: 100
- });
- SchedulerState.SubscriptionParams memory pusherParams = SchedulerState
- .SubscriptionParams({
- priceIds: priceIds,
- readerWhitelist: emptyWhitelist,
- whitelistEnabled: false,
- isActive: true,
- isPermanent: false,
- updateCriteria: updateCriteria
- });
- uint256 minimumBalance = scheduler.getMinimumBalance(
- uint8(priceIds.length)
- );
- vm.deal(pusher, minimumBalance);
- scheduler.createSubscription{value: minimumBalance}(pusherParams);
- vm.stopPrank();
- // Get active subscriptions directly - should work without any special permissions
- uint256[] memory activeIds;
- SchedulerState.SubscriptionParams[] memory activeParams;
- uint256 totalCount;
- (activeIds, activeParams, totalCount) = scheduler
- .getActiveSubscriptions(0, 10);
- // We added 3 subscriptions and all should be active
- assertEq(activeIds.length, 3, "Should have 3 active subscriptions");
- assertEq(
- activeParams.length,
- 3,
- "Should have 3 active subscription params"
- );
- assertEq(totalCount, 3, "Total count should be 3");
- // Verify subscription params
- for (uint i = 0; i < activeIds.length; i++) {
- (
- SchedulerState.SubscriptionParams memory storedParams,
- ) = scheduler.getSubscription(activeIds[i]);
- assertEq(
- activeParams[i].priceIds.length,
- storedParams.priceIds.length,
- "Price IDs length mismatch"
- );
- assertEq(
- activeParams[i].updateCriteria.heartbeatSeconds,
- storedParams.updateCriteria.heartbeatSeconds,
- "Heartbeat seconds mismatch"
- );
- }
- // Test pagination - get only the first subscription
- vm.prank(owner);
- (uint256[] memory firstPageIds, , uint256 firstPageTotal) = scheduler
- .getActiveSubscriptions(0, 1);
- assertEq(
- firstPageIds.length,
- 1,
- "Should have 1 subscription in first page"
- );
- assertEq(firstPageTotal, 3, "Total count should still be 3");
- // Test pagination - get the second page
- vm.prank(owner);
- (uint256[] memory secondPageIds, , uint256 secondPageTotal) = scheduler
- .getActiveSubscriptions(1, 2);
- assertEq(
- secondPageIds.length,
- 2,
- "Should have 2 subscriptions in second page"
- );
- assertEq(secondPageTotal, 3, "Total count should still be 3");
- // Test pagination - start index beyond total count
- vm.prank(owner);
- (uint256[] memory emptyPageIds, , uint256 emptyPageTotal) = scheduler
- .getActiveSubscriptions(10, 10);
- assertEq(
- emptyPageIds.length,
- 0,
- "Should have 0 subscriptions when start index is beyond total"
- );
- assertEq(emptyPageTotal, 3, "Total count should still be 3");
- }
- function testSubscriptionParamValidations() public {
- uint256 initialSubId = 0; // For update tests
- // === Empty Price IDs ===
- SchedulerState.SubscriptionParams
- memory emptyPriceIdsParams = createDefaultSubscriptionParams(
- 1,
- address(reader)
- );
- emptyPriceIdsParams.priceIds = new bytes32[](0);
- vm.expectRevert(abi.encodeWithSelector(EmptyPriceIds.selector));
- scheduler.createSubscription{value: 1 ether}(emptyPriceIdsParams);
- initialSubId = addTestSubscription(scheduler, address(reader)); // Create a valid one for update test
- vm.expectRevert(abi.encodeWithSelector(EmptyPriceIds.selector));
- scheduler.updateSubscription(initialSubId, emptyPriceIdsParams);
- // === Duplicate Price IDs ===
- SchedulerState.SubscriptionParams
- memory duplicatePriceIdsParams = createDefaultSubscriptionParams(
- 2,
- address(reader)
- );
- bytes32 duplicateId = duplicatePriceIdsParams.priceIds[0];
- duplicatePriceIdsParams.priceIds[1] = duplicateId;
- vm.expectRevert(
- abi.encodeWithSelector(DuplicatePriceId.selector, duplicateId)
- );
- scheduler.createSubscription{value: 1 ether}(duplicatePriceIdsParams);
- initialSubId = addTestSubscription(scheduler, address(reader));
- vm.expectRevert(
- abi.encodeWithSelector(DuplicatePriceId.selector, duplicateId)
- );
- scheduler.updateSubscription(initialSubId, duplicatePriceIdsParams);
- // === Too Many Whitelist Readers ===
- SchedulerState.SubscriptionParams
- memory largeWhitelistParams = createDefaultSubscriptionParams(
- 1,
- address(reader)
- );
- uint whitelistLength = uint(scheduler.MAX_READER_WHITELIST_SIZE()) + 1;
- address[] memory largeWhitelist = new address[](whitelistLength);
- for (uint i = 0; i < whitelistLength; i++) {
- largeWhitelist[i] = address(uint160(i + 1)); // Unique addresses
- }
- largeWhitelistParams.readerWhitelist = largeWhitelist;
- vm.expectRevert(
- abi.encodeWithSelector(
- TooManyWhitelistedReaders.selector,
- largeWhitelist.length,
- scheduler.MAX_READER_WHITELIST_SIZE()
- )
- );
- scheduler.createSubscription{value: 1 ether}(largeWhitelistParams);
- initialSubId = addTestSubscription(scheduler, address(reader));
- vm.expectRevert(
- abi.encodeWithSelector(
- TooManyWhitelistedReaders.selector,
- largeWhitelist.length,
- scheduler.MAX_READER_WHITELIST_SIZE()
- )
- );
- scheduler.updateSubscription(initialSubId, largeWhitelistParams);
- // === Duplicate Whitelist Address ===
- SchedulerState.SubscriptionParams
- memory duplicateWhitelistParams = createDefaultSubscriptionParams(
- 1,
- address(reader)
- );
- address[] memory duplicateWhitelist = new address[](2);
- duplicateWhitelist[0] = address(reader);
- duplicateWhitelist[1] = address(reader); // Duplicate
- duplicateWhitelistParams.readerWhitelist = duplicateWhitelist;
- vm.expectRevert(
- abi.encodeWithSelector(
- DuplicateWhitelistAddress.selector,
- address(reader)
- )
- );
- scheduler.createSubscription{value: 1 ether}(duplicateWhitelistParams);
- initialSubId = addTestSubscription(scheduler, address(reader));
- vm.expectRevert(
- abi.encodeWithSelector(
- DuplicateWhitelistAddress.selector,
- address(reader)
- )
- );
- scheduler.updateSubscription(initialSubId, duplicateWhitelistParams);
- // === Invalid Heartbeat (Zero Seconds) ===
- SchedulerState.SubscriptionParams
- memory invalidHeartbeatParams = createDefaultSubscriptionParams(
- 1,
- address(reader)
- );
- invalidHeartbeatParams.updateCriteria.updateOnHeartbeat = true;
- invalidHeartbeatParams.updateCriteria.heartbeatSeconds = 0; // Invalid
- vm.expectRevert(abi.encodeWithSelector(InvalidUpdateCriteria.selector));
- scheduler.createSubscription{value: 1 ether}(invalidHeartbeatParams);
- initialSubId = addTestSubscription(scheduler, address(reader));
- vm.expectRevert(abi.encodeWithSelector(InvalidUpdateCriteria.selector));
- scheduler.updateSubscription(initialSubId, invalidHeartbeatParams);
- // === Invalid Deviation (Zero Bps) ===
- SchedulerState.SubscriptionParams
- memory invalidDeviationParams = createDefaultSubscriptionParams(
- 1,
- address(reader)
- );
- invalidDeviationParams.updateCriteria.updateOnDeviation = true;
- invalidDeviationParams.updateCriteria.deviationThresholdBps = 0; // Invalid
- vm.expectRevert(abi.encodeWithSelector(InvalidUpdateCriteria.selector));
- scheduler.createSubscription{value: 1 ether}(invalidDeviationParams);
- initialSubId = addTestSubscription(scheduler, address(reader));
- vm.expectRevert(abi.encodeWithSelector(InvalidUpdateCriteria.selector));
- scheduler.updateSubscription(initialSubId, invalidDeviationParams);
- }
- function testUpdatePriceFeedsSucceedsWithStaleFeedIfLatestIsValid() public {
- // Add a subscription and funds
- uint256 subscriptionId = addTestSubscription(
- scheduler,
- address(reader)
- );
- // Advance time past the validity period
- vm.warp(
- block.timestamp +
- scheduler.PAST_TIMESTAMP_MAX_VALIDITY_PERIOD() +
- 600
- ); // Warp 1 hour 10 mins
- uint64 currentTime = SafeCast.toUint64(block.timestamp);
- uint64 validPublishTime = currentTime - 1800; // 30 mins ago (within 1 hour validity)
- uint64 stalePublishTime = currentTime -
- (scheduler.PAST_TIMESTAMP_MAX_VALIDITY_PERIOD() + 300); // 1 hour 5 mins ago (outside validity)
- PythStructs.PriceFeed[] memory priceFeeds = new PythStructs.PriceFeed[](
- 2
- );
- priceFeeds[0] = createSingleMockPriceFeed(stalePublishTime);
- priceFeeds[1] = createSingleMockPriceFeed(validPublishTime);
- uint64[] memory slots = new uint64[](2);
- slots[0] = 100;
- slots[1] = 100; // Same slot
- // Mock Pyth response (should succeed in the real world as minValidTime is 0)
- mockParsePriceFeedUpdatesWithSlotsStrict(pyth, priceFeeds, slots);
- bytes[] memory updateData = createMockUpdateData(priceFeeds);
- // Expect PricesUpdated event with the latest valid timestamp
- vm.expectEmit();
- emit PricesUpdated(subscriptionId, validPublishTime);
- // Perform update - should succeed because the latest timestamp in the update data is valid
- vm.prank(pusher);
- scheduler.updatePriceFeeds(subscriptionId, updateData);
- // Verify last updated timestamp
- (, SchedulerState.SubscriptionStatus memory status) = scheduler
- .getSubscription(subscriptionId);
- assertEq(
- status.priceLastUpdatedAt,
- validPublishTime,
- "Last updated timestamp should be the latest valid one"
- );
- }
- function testUpdatePriceFeedsRevertsIfLatestTimestampIsTooOld() public {
- // Add a subscription and funds
- uint256 subscriptionId = addTestSubscription(
- scheduler,
- address(reader)
- );
- // Advance time past the validity period
- vm.warp(
- block.timestamp +
- scheduler.PAST_TIMESTAMP_MAX_VALIDITY_PERIOD() +
- 600
- ); // Warp 1 hour 10 mins
- uint64 currentTime = SafeCast.toUint64(block.timestamp);
- // Make the *latest* timestamp too old
- uint64 stalePublishTime1 = currentTime -
- (scheduler.PAST_TIMESTAMP_MAX_VALIDITY_PERIOD() + 300); // 1 hour 5 mins ago
- uint64 stalePublishTime2 = currentTime -
- (scheduler.PAST_TIMESTAMP_MAX_VALIDITY_PERIOD() + 600); // 1 hour 10 mins ago
- PythStructs.PriceFeed[] memory priceFeeds = new PythStructs.PriceFeed[](
- 2
- );
- priceFeeds[0] = createSingleMockPriceFeed(stalePublishTime2); // Oldest
- priceFeeds[1] = createSingleMockPriceFeed(stalePublishTime1); // Latest, but still too old
- uint64[] memory slots = new uint64[](2);
- slots[0] = 100;
- slots[1] = 100; // Same slot
- // Mock Pyth response (should succeed in the real world as minValidTime is 0)
- mockParsePriceFeedUpdatesWithSlotsStrict(pyth, priceFeeds, slots);
- bytes[] memory updateData = createMockUpdateData(priceFeeds);
- // Expect revert with TimestampTooOld (checked in _validateShouldUpdatePrices)
- vm.expectRevert(
- abi.encodeWithSelector(
- TimestampTooOld.selector,
- stalePublishTime1, // The latest timestamp from the update
- currentTime
- )
- );
- // Attempt to update price feeds
- vm.prank(pusher);
- scheduler.updatePriceFeeds(subscriptionId, updateData);
- }
- // Required to receive ETH when withdrawing funds
- receive() external payable {}
- function testUpdateSubscriptionRemovesPriceUpdatesForRemovedPriceIds()
- public
- {
- // 1. Setup: Add subscription with 3 price feeds, update prices
- uint8 numInitialFeeds = 3;
- uint256 subscriptionId = addTestSubscriptionWithFeeds(
- scheduler,
- numInitialFeeds,
- address(reader)
- );
- scheduler.addFunds{value: 1 ether}(subscriptionId);
- // Get initial price IDs and create mock price feeds
- bytes32[] memory initialPriceIds = createPriceIds(numInitialFeeds);
- uint64 publishTime = SafeCast.toUint64(block.timestamp);
- // Setup and perform initial price update
- (
- PythStructs.PriceFeed[] memory priceFeeds,
- uint64[] memory slots
- ) = createMockPriceFeedsWithSlots(publishTime, numInitialFeeds);
- mockParsePriceFeedUpdatesWithSlotsStrict(pyth, priceFeeds, slots);
- vm.prank(pusher);
- scheduler.updatePriceFeeds(
- subscriptionId,
- createMockUpdateData(priceFeeds)
- );
- // Store the removed price ID for later use
- bytes32 removedPriceId = initialPriceIds[numInitialFeeds - 1];
- // 2. Action: Update subscription to remove the last price feed
- (SchedulerState.SubscriptionParams memory params, ) = scheduler
- .getSubscription(subscriptionId);
- // Create new price IDs array without the last ID
- bytes32[] memory newPriceIds = new bytes32[](numInitialFeeds - 1);
- for (uint i = 0; i < newPriceIds.length; i++) {
- newPriceIds[i] = initialPriceIds[i];
- }
- params.priceIds = newPriceIds;
- vm.expectEmit();
- emit SubscriptionUpdated(subscriptionId);
- scheduler.updateSubscription(subscriptionId, params);
- // 3. Verification:
- // - Verify that the removed price ID is no longer part of the subscription's price IDs
- (SchedulerState.SubscriptionParams memory updatedParams, ) = scheduler
- .getSubscription(subscriptionId);
- assertEq(
- updatedParams.priceIds.length,
- numInitialFeeds - 1,
- "Subscription should have one less price ID"
- );
- bool removedPriceIdFound = false;
- for (uint i = 0; i < updatedParams.priceIds.length; i++) {
- if (updatedParams.priceIds[i] == removedPriceId) {
- removedPriceIdFound = true;
- break;
- }
- }
- assertFalse(
- removedPriceIdFound,
- "Removed price ID should not be in the subscription's price IDs"
- );
- // - Querying all feeds should return only the remaining feeds
- PythStructs.Price[] memory allPricesAfterUpdate = scheduler
- .getPricesUnsafe(subscriptionId, new bytes32[](0));
- assertEq(
- allPricesAfterUpdate.length,
- newPriceIds.length,
- "Querying all should only return remaining feeds"
- );
- // - Verify that trying to get the price of the removed feed directly reverts
- bytes32[] memory removedIdArray = new bytes32[](1);
- removedIdArray[0] = removedPriceId;
- vm.expectRevert(
- abi.encodeWithSelector(
- InvalidPriceId.selector,
- removedPriceId,
- bytes32(0)
- )
- );
- scheduler.getPricesUnsafe(subscriptionId, removedIdArray);
- }
- function testUpdateSubscriptionRevertsWithTooManyPriceIds() public {
- // 1. Setup: Create a subscription with a valid number of price IDs
- uint8 initialNumFeeds = 2;
- uint256 subscriptionId = addTestSubscriptionWithFeeds(
- scheduler,
- initialNumFeeds,
- address(reader)
- );
- // 2. Prepare params with too many price IDs (MAX_PRICE_IDS_PER_SUBSCRIPTION + 1)
- (SchedulerState.SubscriptionParams memory currentParams, ) = scheduler
- .getSubscription(subscriptionId);
- uint16 tooManyFeeds = uint16(
- scheduler.MAX_PRICE_IDS_PER_SUBSCRIPTION()
- ) + 1;
- bytes32[] memory tooManyPriceIds = createPriceIds(tooManyFeeds);
- SchedulerState.SubscriptionParams memory newParams = currentParams;
- newParams.priceIds = tooManyPriceIds;
- // 3. Expect revert when trying to update with too many price IDs
- vm.expectRevert(
- abi.encodeWithSelector(
- TooManyPriceIds.selector,
- tooManyFeeds,
- scheduler.MAX_PRICE_IDS_PER_SUBSCRIPTION()
- )
- );
- scheduler.updateSubscription(subscriptionId, newParams);
- }
- }
|