diff --git a/src/util/FeedSwitchV2.sol b/src/util/FeedSwitchV2.sol new file mode 100644 index 0000000..5893e33 --- /dev/null +++ b/src/util/FeedSwitchV2.sol @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.18; + +import "src/interfaces/IChainlinkFeed.sol"; + + +/// @title FeedSwitch +/// @notice A contract to switch between feeds after a timelock period +/// @dev The switch can only be initiated by the guardian and will be effective after the timelock period has passed, which could be zero. +/// If the switch is initiated but not yet effective, it can be cancelled by the guardian without timelock period (if is not zero). +/// The guardian can initiate the switch again to the previous feed multiple times. +contract FeedSwitchV2 { + error NotGuardian(); + error NotGov(); + error NotPendingGov(); + error FeedDecimalsMismatch(); + + address public pendingGov; + address public gov; + uint256 public timelockPeriod; + IChainlinkFeed public feed; + IChainlinkFeed public previousFeed; + + uint256 public switchCompletedAt; + + mapping(address => bool) public isGuardian; + + IChainlinkFeed public immutable initialFeed; + IChainlinkFeed public immutable fallbackFeed; + + event FeedSwitchInitiated(address indexed newFeed, uint256 effectiveAt); + event NewPendingGov(address indexed pendingGov); + event GovChanged(address indexed newGov); + event GuardianSet(address indexed guardian, bool isGuardian); + event TimelockPeriodChanged(uint256 newTimelockPeriod); + + constructor( + address _initialFeed, + address _fallbackFeed, + uint256 _timelockPeriod, + address _gov, + address _guardian + ) { + feed = IChainlinkFeed(_initialFeed); + initialFeed = IChainlinkFeed(_initialFeed); + fallbackFeed = IChainlinkFeed(_fallbackFeed); + if ( + fallbackFeed.decimals() != 18 || + feed.decimals() != 18 + ) revert FeedDecimalsMismatch(); + + timelockPeriod = _timelockPeriod; + gov = _gov; + isGuardian[_guardian] = true; + } + + modifier onlyGov() { + if (msg.sender != gov) revert NotGov(); + _; + } + + /// @notice Toggle the feed switch, entering or exiting the timelock period + /// @dev Can only be called by the guardian + function toggleFeedSwitch() external { + if (!isGuardian[msg.sender]) revert NotGuardian(); + + if (switchCompletedAt < block.timestamp) { + switchCompletedAt = block.timestamp + timelockPeriod; + } else switchCompletedAt = 0; + + if (address(feed) == address(initialFeed)) { + feed = fallbackFeed; + previousFeed = initialFeed; + } else { + feed = initialFeed; + previousFeed = fallbackFeed; + } + + emit FeedSwitchInitiated(address(feed), switchCompletedAt > 0 ? switchCompletedAt : block.timestamp); + } + + /// @notice Get the current feed data + /// @return roundId The round ID + /// @return price The price of the asset + /// @return startedAt The timestamp of the start of the round + /// @return updatedAt The timestamp of the last update + /// @return answeredInRound The round ID in which the price was answered + function latestRoundData() + public + view + returns ( + uint80 roundId, + int256 price, + uint256 startedAt, + uint256 updatedAt, + uint80 answeredInRound + ) + { + if (block.timestamp >= switchCompletedAt) { + return feed.latestRoundData(); + } else { + return previousFeed.latestRoundData(); + } + } + + /// @notice Get the latest price of the asset + /// @return price The price of the asset + function latestAnswer() external view returns (int256) { + (, int256 latestPrice, , , ) = latestRoundData(); + return latestPrice; + } + + /// @notice Get the number of decimals of the feed + /// @return decimals The number of decimals + function decimals() external pure returns (uint8) { + return 18; + } + + /// @notice Check if the feed switch is queued + /// @dev If not queued, will return 0 as time left + /// @return timeLeft The time left for the switch to be effective + function isFeedSwitchQueued() external view returns (uint256) { + bool isQueued = block.timestamp < switchCompletedAt; + if (!isQueued) return 0; + else return switchCompletedAt - block.timestamp; + } + + /// @notice Set a new pending governance + /// @dev Can only be called by the current governance, the new pending governance must call acceptGov to become the new governance + /// @param _pendingGov The address of the new pending governance + function setPendingGov(address _pendingGov) external onlyGov { + pendingGov = _pendingGov; + emit NewPendingGov(_pendingGov); + } + + /// @notice Accept the governance role + /// @dev Can only be called by the pending governance + function acceptGov() external { + if (msg.sender != pendingGov) revert NotPendingGov(); + gov = pendingGov; + pendingGov = address(0); + emit GovChanged(gov); + } + + /// @notice Set the guardian role for an address + /// @dev Can only be called by the governance + /// @param guardianAddr The address to set the guardian role for + /// @param _isGuardian Whether the address should be a guardian + function setGuardian(address guardianAddr, bool _isGuardian) external onlyGov { + isGuardian[guardianAddr] = _isGuardian; + emit GuardianSet(guardianAddr, _isGuardian); + } + + /// @notice Set a new timelock period + /// @dev Can only be called by the governance + /// @param _timelockPeriod The new timelock period in seconds + function setTimelockPeriod(uint256 _timelockPeriod) external onlyGov { + timelockPeriod = _timelockPeriod; + emit TimelockPeriodChanged(_timelockPeriod); + } +} diff --git a/test/util/FeedSwitchV2.t.sol b/test/util/FeedSwitchV2.t.sol new file mode 100644 index 0000000..6c1155e --- /dev/null +++ b/test/util/FeedSwitchV2.t.sol @@ -0,0 +1,613 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.18; + +import "forge-std/Test.sol"; +import {FeedSwitchV2} from "src/util/FeedSwitchV2.sol"; +import {MockFeed} from "test/mocks/MockFeed.sol"; + +contract FeedSwitchV2Test is Test { + FeedSwitchV2 feedSwitch; + MockFeed initialFeed; + MockFeed fallbackFeed; + + address gov = address(0x1); + address guardian = address(0x2); + address user = address(0x3); + + uint256 timelockPeriod = 18 hours; + + event FeedSwitchInitiated(address indexed newFeed, uint256 effectiveAt); + event NewPendingGov(address indexed pendingGov); + event GovChanged(address indexed newGov); + event GuardianSet(address indexed guardian, bool isGuardian); + event TimelockPeriodChanged(uint256 newTimelockPeriod); + + function setUp() public { + initialFeed = new MockFeed(18, 1e18); + fallbackFeed = new MockFeed(18, 0.95e18); + + feedSwitch = new FeedSwitchV2( + address(initialFeed), + address(fallbackFeed), + timelockPeriod, + gov, + guardian + ); + } + + /*////////////////////////////////////////////////////////////// + DEPLOYMENT TESTS + //////////////////////////////////////////////////////////////*/ + + function test_Deployment() public view { + assertEq(address(feedSwitch.feed()), address(initialFeed)); + assertEq(address(feedSwitch.initialFeed()), address(initialFeed)); + assertEq(address(feedSwitch.fallbackFeed()), address(fallbackFeed)); + assertEq(feedSwitch.timelockPeriod(), timelockPeriod); + assertEq(feedSwitch.gov(), gov); + assertEq(feedSwitch.isGuardian(guardian), true); + assertEq(feedSwitch.decimals(), 18); + assertEq(feedSwitch.switchCompletedAt(), 0); + } + + function test_Deployment_RevertsIfInitialFeedNotDecimals18() public { + MockFeed badFeed = new MockFeed(8, 1e8); + + vm.expectRevert(FeedSwitchV2.FeedDecimalsMismatch.selector); + new FeedSwitchV2( + address(badFeed), + address(fallbackFeed), + timelockPeriod, + gov, + guardian + ); + } + + function test_Deployment_RevertsIfFallbackFeedNotDecimals18() public { + MockFeed badFeed = new MockFeed(8, 1e8); + + vm.expectRevert(FeedSwitchV2.FeedDecimalsMismatch.selector); + new FeedSwitchV2( + address(initialFeed), + address(badFeed), + timelockPeriod, + gov, + guardian + ); + } + + /*////////////////////////////////////////////////////////////// + TOGGLE FEED SWITCH TESTS + //////////////////////////////////////////////////////////////*/ + + function test_ToggleFeedSwitch_InitiatesSwitchToFallback() public { + vm.prank(guardian); + vm.expectEmit(true, false, false, false); + emit FeedSwitchInitiated(address(fallbackFeed), block.timestamp + timelockPeriod); + feedSwitch.toggleFeedSwitch(); + + assertEq(address(feedSwitch.feed()), address(fallbackFeed)); + assertEq(address(feedSwitch.previousFeed()), address(initialFeed)); + assertEq(feedSwitch.switchCompletedAt(), block.timestamp + timelockPeriod); + } + + function test_ToggleFeedSwitch_RevertsIfNotGuardian() public { + vm.prank(user); + vm.expectRevert(FeedSwitchV2.NotGuardian.selector); + feedSwitch.toggleFeedSwitch(); + } + + function test_ToggleFeedSwitch_ToggleBackToInitial() public { + // First toggle: initial -> fallback + vm.prank(guardian); + feedSwitch.toggleFeedSwitch(); + + // Warp past timelock + vm.warp(block.timestamp + timelockPeriod + 1); + + // Second toggle: fallback -> initial + vm.prank(guardian); + vm.expectEmit(true, false, false, false); + emit FeedSwitchInitiated(address(initialFeed), block.timestamp); + feedSwitch.toggleFeedSwitch(); + + assertEq(address(feedSwitch.feed()), address(initialFeed)); + assertEq(address(feedSwitch.previousFeed()), address(fallbackFeed)); + } + + function test_ToggleFeedSwitch_CancelsDuringTimelock() public { + // Initiate switch + vm.prank(guardian); + feedSwitch.toggleFeedSwitch(); + uint256 expectedCompletedAt = block.timestamp + timelockPeriod; + assertEq(feedSwitch.switchCompletedAt(), expectedCompletedAt); + + // Warp partway through timelock + vm.warp(block.timestamp + timelockPeriod / 2); + + // Toggle again (should cancel and toggle feed) + vm.prank(guardian); + feedSwitch.toggleFeedSwitch(); + + // Should reset switchCompletedAt to 0 and toggle back to initial + assertEq(feedSwitch.switchCompletedAt(), 0); + assertEq(address(feedSwitch.feed()), address(initialFeed)); + } + + function test_ToggleFeedSwitch_MultipleGuardiansCanToggle() public { + address guardian2 = address(0x4); + + vm.prank(gov); + feedSwitch.setGuardian(guardian2, true); + + vm.prank(guardian2); + feedSwitch.toggleFeedSwitch(); + + assertEq(address(feedSwitch.feed()), address(fallbackFeed)); + } + + /*////////////////////////////////////////////////////////////// + LATEST ROUND DATA TESTS + //////////////////////////////////////////////////////////////*/ + + function test_LatestRoundData_ReturnsInitialFeedBeforeSwitch() public view { + ( + uint80 roundId, + int256 price, + uint256 startedAt, + uint256 updatedAt, + uint80 answeredInRound + ) = feedSwitch.latestRoundData(); + + ( + uint80 expectedRoundId, + int256 expectedPrice, + uint256 expectedStartedAt, + uint256 expectedUpdatedAt, + uint80 expectedAnsweredInRound + ) = initialFeed.latestRoundData(); + + assertEq(roundId, expectedRoundId); + assertEq(price, expectedPrice); + assertEq(startedAt, expectedStartedAt); + assertEq(updatedAt, expectedUpdatedAt); + assertEq(answeredInRound, expectedAnsweredInRound); + } + + function test_LatestRoundData_ReturnsPreviousFeedDuringTimelock() public { + vm.prank(guardian); + feedSwitch.toggleFeedSwitch(); + + // During timelock, should still return previous feed (initialFeed) + (, int256 price, , ,) = feedSwitch.latestRoundData(); + (, int256 expectedPrice, , ,) = initialFeed.latestRoundData(); + + assertEq(price, expectedPrice); + } + + function test_LatestRoundData_ReturnsNewFeedAfterTimelock() public { + vm.prank(guardian); + feedSwitch.toggleFeedSwitch(); + + // Warp past timelock + vm.warp(block.timestamp + timelockPeriod); + + (, int256 price, , ,) = feedSwitch.latestRoundData(); + (, int256 expectedPrice, , ,) = fallbackFeed.latestRoundData(); + + assertEq(price, expectedPrice); + } + + /*////////////////////////////////////////////////////////////// + LATEST ANSWER TESTS + //////////////////////////////////////////////////////////////*/ + + function test_LatestAnswer_ReturnsInitialFeedBeforeSwitch() public view { + int256 price = feedSwitch.latestAnswer(); + assertEq(uint256(price), 1e18); + } + + function test_LatestAnswer_ReturnsPreviousFeedDuringTimelock() public { + vm.prank(guardian); + feedSwitch.toggleFeedSwitch(); + + // During timelock + int256 price = feedSwitch.latestAnswer(); + assertEq(uint256(price), 1e18); // initialFeed price + } + + function test_LatestAnswer_ReturnsNewFeedAfterTimelock() public { + vm.prank(guardian); + feedSwitch.toggleFeedSwitch(); + + // Warp past timelock + vm.warp(block.timestamp + timelockPeriod); + + int256 price = feedSwitch.latestAnswer(); + assertEq(uint256(price), 0.95e18); // fallbackFeed price + } + + /*////////////////////////////////////////////////////////////// + IS FEED SWITCH QUEUED TESTS + //////////////////////////////////////////////////////////////*/ + + function test_IsFeedSwitchQueued_ReturnsZeroWhenNotQueued() public view { + uint256 timeLeft = feedSwitch.isFeedSwitchQueued(); + assertEq(timeLeft, 0); + } + + function test_IsFeedSwitchQueued_ReturnsTimeLeftWhenQueued() public { + vm.prank(guardian); + feedSwitch.toggleFeedSwitch(); + + uint256 timeLeft = feedSwitch.isFeedSwitchQueued(); + assertEq(timeLeft, timelockPeriod); + } + + function test_IsFeedSwitchQueued_DecrementsOverTime() public { + vm.prank(guardian); + feedSwitch.toggleFeedSwitch(); + + vm.warp(block.timestamp + 6 hours); + + uint256 timeLeft = feedSwitch.isFeedSwitchQueued(); + assertEq(timeLeft, timelockPeriod - 6 hours); + } + + function test_IsFeedSwitchQueued_ReturnsZeroAfterTimelock() public { + vm.prank(guardian); + feedSwitch.toggleFeedSwitch(); + + vm.warp(block.timestamp + timelockPeriod); + + uint256 timeLeft = feedSwitch.isFeedSwitchQueued(); + assertEq(timeLeft, 0); + } + + /*////////////////////////////////////////////////////////////// + GOVERNANCE TESTS + //////////////////////////////////////////////////////////////*/ + + function test_SetPendingGov() public { + address newGov = address(0x5); + + vm.prank(gov); + vm.expectEmit(true, false, false, false); + emit NewPendingGov(newGov); + feedSwitch.setPendingGov(newGov); + + assertEq(feedSwitch.pendingGov(), newGov); + } + + function test_SetPendingGov_RevertsIfNotGov() public { + vm.prank(user); + vm.expectRevert(FeedSwitchV2.NotGov.selector); + feedSwitch.setPendingGov(user); + } + + function test_AcceptGov() public { + address newGov = address(0x5); + + vm.prank(gov); + feedSwitch.setPendingGov(newGov); + + vm.prank(newGov); + vm.expectEmit(true, false, false, false); + emit GovChanged(newGov); + feedSwitch.acceptGov(); + + assertEq(feedSwitch.gov(), newGov); + assertEq(feedSwitch.pendingGov(), address(0)); + } + + function test_AcceptGov_RevertsIfNotPendingGov() public { + address newGov = address(0x5); + + vm.prank(gov); + feedSwitch.setPendingGov(newGov); + + vm.prank(user); + vm.expectRevert(FeedSwitchV2.NotPendingGov.selector); + feedSwitch.acceptGov(); + } + + /*////////////////////////////////////////////////////////////// + GUARDIAN TESTS + //////////////////////////////////////////////////////////////*/ + + function test_SetGuardian() public { + address newGuardian = address(0x6); + + vm.prank(gov); + vm.expectEmit(true, false, false, true); + emit GuardianSet(newGuardian, true); + feedSwitch.setGuardian(newGuardian, true); + + assertEq(feedSwitch.isGuardian(newGuardian), true); + } + + function test_SetGuardian_RemoveGuardian() public { + vm.prank(gov); + vm.expectEmit(true, false, false, true); + emit GuardianSet(guardian, false); + feedSwitch.setGuardian(guardian, false); + + assertEq(feedSwitch.isGuardian(guardian), false); + } + + function test_SetGuardian_RevertsIfNotGov() public { + vm.prank(user); + vm.expectRevert(FeedSwitchV2.NotGov.selector); + feedSwitch.setGuardian(user, true); + } + + /*////////////////////////////////////////////////////////////// + TIMELOCK PERIOD TESTS + //////////////////////////////////////////////////////////////*/ + + function test_SetTimelockPeriod() public { + uint256 newTimelockPeriod = 24 hours; + + vm.prank(gov); + vm.expectEmit(false, false, false, true); + emit TimelockPeriodChanged(newTimelockPeriod); + feedSwitch.setTimelockPeriod(newTimelockPeriod); + + assertEq(feedSwitch.timelockPeriod(), newTimelockPeriod); + } + + function test_SetTimelockPeriod_CanSetToZero() public { + vm.prank(gov); + feedSwitch.setTimelockPeriod(0); + + assertEq(feedSwitch.timelockPeriod(), 0); + } + + function test_SetTimelockPeriod_RevertsIfNotGov() public { + vm.prank(user); + vm.expectRevert(FeedSwitchV2.NotGov.selector); + feedSwitch.setTimelockPeriod(24 hours); + } + + /*////////////////////////////////////////////////////////////// + ZERO TIMELOCK PERIOD TESTS + //////////////////////////////////////////////////////////////*/ + + function test_ZeroTimelockPeriod_ImmediateSwitch() public { + FeedSwitchV2 instantSwitch = new FeedSwitchV2( + address(initialFeed), + address(fallbackFeed), + 0, // zero timelock + gov, + guardian + ); + + vm.prank(guardian); + instantSwitch.toggleFeedSwitch(); + + // Should immediately use the new feed + int256 price = instantSwitch.latestAnswer(); + assertEq(uint256(price), 0.95e18); // fallbackFeed price + } + + function test_TimelockUpdatedToZero_KeepsPreviousSwitchCompletedAt() public { + // Initiate switch with 18 hour timelock + vm.prank(guardian); + feedSwitch.toggleFeedSwitch(); + + uint256 originalSwitchCompletedAt = feedSwitch.switchCompletedAt(); + assertEq(originalSwitchCompletedAt, block.timestamp + timelockPeriod); + + // Still using initialFeed during timelock + assertEq(uint256(feedSwitch.latestAnswer()), 1e18); + + // Gov updates timelock to zero + vm.prank(gov); + feedSwitch.setTimelockPeriod(0); + + // switchCompletedAt is unchanged - the queued switch still uses original timelock + assertEq(feedSwitch.switchCompletedAt(), originalSwitchCompletedAt); + + // Warp partway through original timelock - still using initialFeed + vm.warp(block.timestamp + 6 hours); + assertEq(uint256(feedSwitch.latestAnswer()), 1e18); + assertGt(feedSwitch.isFeedSwitchQueued(), 0); // Still queued + + // Warp past original timelock - now uses fallbackFeed + vm.warp(originalSwitchCompletedAt); + assertEq(uint256(feedSwitch.latestAnswer()), 0.95e18); + assertEq(feedSwitch.isFeedSwitchQueued(), 0); // No longer queued + } + + function test_TimelockUpdatedToZero_CancelAndReswitchImmediate() public { + // Initiate switch with 18 hour timelock + vm.prank(guardian); + feedSwitch.toggleFeedSwitch(); + + // Still using initialFeed during timelock + assertEq(uint256(feedSwitch.latestAnswer()), 1e18); + + // Warp partway through timelock + vm.warp(block.timestamp + 6 hours); + assertEq(uint256(feedSwitch.latestAnswer()), 1e18); + + // Gov updates timelock to zero + vm.prank(gov); + feedSwitch.setTimelockPeriod(0); + + // Guardian cancels the current switch (toggle back to initial) + vm.prank(guardian); + feedSwitch.toggleFeedSwitch(); + assertEq(feedSwitch.switchCompletedAt(), 0); + assertEq(address(feedSwitch.feed()), address(initialFeed)); + + // Guardian re-initiates switch - now with zero timelock, it's immediate + vm.prank(guardian); + feedSwitch.toggleFeedSwitch(); + + // switchCompletedAt is block.timestamp + 0 = block.timestamp + assertEq(feedSwitch.switchCompletedAt(), block.timestamp); + + // Immediately uses fallbackFeed (block.timestamp >= switchCompletedAt) + assertEq(uint256(feedSwitch.latestAnswer()), 0.95e18); + assertEq(feedSwitch.isFeedSwitchQueued(), 0); // Not queued, already effective + } + + /*////////////////////////////////////////////////////////////// + COMPLEX SCENARIO TESTS + //////////////////////////////////////////////////////////////*/ + + function test_Scenario_SwitchCancelReinitiate() public { + // Initial state: using initialFeed + assertEq(uint256(feedSwitch.latestAnswer()), 1e18); + + // Guardian initiates switch + vm.prank(guardian); + feedSwitch.toggleFeedSwitch(); + assertEq(address(feedSwitch.feed()), address(fallbackFeed)); + + // During timelock, still returns initialFeed price + vm.warp(block.timestamp + 6 hours); + assertEq(uint256(feedSwitch.latestAnswer()), 1e18); + + // Guardian cancels (toggles back) + vm.prank(guardian); + feedSwitch.toggleFeedSwitch(); + assertEq(address(feedSwitch.feed()), address(initialFeed)); + assertEq(feedSwitch.switchCompletedAt(), 0); + + // Still returns initialFeed price + vm.warp(block.timestamp + 1 days); + assertEq(uint256(feedSwitch.latestAnswer()), 1e18); + + // Guardian re-initiates switch + vm.prank(guardian); + feedSwitch.toggleFeedSwitch(); + assertEq(address(feedSwitch.feed()), address(fallbackFeed)); + + // Wait for timelock to complete + vm.warp(block.timestamp + timelockPeriod); + + // Now returns fallbackFeed price + assertEq(uint256(feedSwitch.latestAnswer()), 0.95e18); + } + + function test_Scenario_MultipleSwitches() public { + // Switch to fallback + vm.prank(guardian); + feedSwitch.toggleFeedSwitch(); + vm.warp(block.timestamp + timelockPeriod); + assertEq(uint256(feedSwitch.latestAnswer()), 0.95e18); + + // Switch back to initial + vm.prank(guardian); + feedSwitch.toggleFeedSwitch(); + vm.warp(block.timestamp + timelockPeriod); + assertEq(uint256(feedSwitch.latestAnswer()), 1e18); + + // Switch again to fallback + vm.prank(guardian); + feedSwitch.toggleFeedSwitch(); + vm.warp(block.timestamp + timelockPeriod); + assertEq(uint256(feedSwitch.latestAnswer()), 0.95e18); + } + + function test_Scenario_FeedPriceChanges() public { + // Initial price from initialFeed + assertEq(uint256(feedSwitch.latestAnswer()), 1e18); + + // Change initial feed price + initialFeed.changeAnswer(1.5e18); + assertEq(uint256(feedSwitch.latestAnswer()), 1.5e18); + + // Switch to fallback + vm.prank(guardian); + feedSwitch.toggleFeedSwitch(); + + // During timelock, still using initialFeed (now at 1.5e18) + assertEq(uint256(feedSwitch.latestAnswer()), 1.5e18); + + // Change fallback feed price + fallbackFeed.changeAnswer(0.9e18); + + // Still using initialFeed during timelock + assertEq(uint256(feedSwitch.latestAnswer()), 1.5e18); + + // After timelock + vm.warp(block.timestamp + timelockPeriod); + assertEq(uint256(feedSwitch.latestAnswer()), 0.9e18); + } + + function test_Scenario_GovernanceTransfer() public { + address newGov = address(0x10); + + // Set new pending gov + vm.prank(gov); + feedSwitch.setPendingGov(newGov); + + // Old gov can still make changes + vm.prank(gov); + feedSwitch.setTimelockPeriod(24 hours); + assertEq(feedSwitch.timelockPeriod(), 24 hours); + + // New gov accepts + vm.prank(newGov); + feedSwitch.acceptGov(); + + // Old gov can no longer make changes + vm.prank(gov); + vm.expectRevert(FeedSwitchV2.NotGov.selector); + feedSwitch.setTimelockPeriod(12 hours); + + // New gov can make changes + vm.prank(newGov); + feedSwitch.setTimelockPeriod(12 hours); + assertEq(feedSwitch.timelockPeriod(), 12 hours); + } + + /*////////////////////////////////////////////////////////////// + FUZZ TESTS + //////////////////////////////////////////////////////////////*/ + + function testFuzz_SetTimelockPeriod(uint256 newPeriod) public { + vm.prank(gov); + feedSwitch.setTimelockPeriod(newPeriod); + assertEq(feedSwitch.timelockPeriod(), newPeriod); + } + + function testFuzz_IsFeedSwitchQueued_TimeProgression(uint256 timeElapsed) public { + vm.assume(timeElapsed < timelockPeriod); + + vm.prank(guardian); + feedSwitch.toggleFeedSwitch(); + + vm.warp(block.timestamp + timeElapsed); + + uint256 timeLeft = feedSwitch.isFeedSwitchQueued(); + assertEq(timeLeft, timelockPeriod - timeElapsed); + } + + function testFuzz_LatestAnswer_DuringTimelock(uint256 timeElapsed) public { + vm.assume(timeElapsed < timelockPeriod); + + vm.prank(guardian); + feedSwitch.toggleFeedSwitch(); + + vm.warp(block.timestamp + timeElapsed); + + // Should still return initialFeed price during timelock + assertEq(uint256(feedSwitch.latestAnswer()), 1e18); + } + + function testFuzz_LatestAnswer_AfterTimelock(uint256 timeElapsed) public { + vm.assume(timeElapsed >= timelockPeriod && timeElapsed < 365 days); + + vm.prank(guardian); + feedSwitch.toggleFeedSwitch(); + + vm.warp(block.timestamp + timeElapsed); + + // Should return fallbackFeed price after timelock + assertEq(uint256(feedSwitch.latestAnswer()), 0.95e18); + } +} diff --git a/test/util/FeedSwitchV2Fork.t.sol b/test/util/FeedSwitchV2Fork.t.sol new file mode 100644 index 0000000..d7ad661 --- /dev/null +++ b/test/util/FeedSwitchV2Fork.t.sol @@ -0,0 +1,422 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.18; + +import "forge-std/Test.sol"; +import {FeedSwitchV2} from "src/util/FeedSwitchV2.sol"; +import {ChainlinkBasePriceFeed, IChainlinkFeed} from "src/feeds/ChainlinkBasePriceFeed.sol"; +import {ConfigAddr} from "test/ConfigAddr.sol"; +import {CurveLPPessimisticFeed, ICurvePool} from "src/feeds/CurveLPPessimisticFeed.sol"; + +/// @title FeedSwitchV2 Fork Test +/// @notice Fork test for FeedSwitchV2 using our wrapped USDe(normalized via sUSDe) and USDT +contract FeedSwitchV2ForkTest is Test, ConfigAddr { + FeedSwitchV2 feedSwitch; + + // Chainlinkfeed (8 decimals) + address clUsdtToUsd = 0x3E7d1eAB13ad0104d2750B8863b489D65364e32D; + + // Wrapped feeds (18 decimals) + // This feed is the one already in use by the LP feed for DOLA/sUSDe + ChainlinkBasePriceFeed sUSDeWrappedFeed = ChainlinkBasePriceFeed(0x6277cB27232F35C75D3d908b26F3670e7d167400); + ChainlinkBasePriceFeed usdtWrappedFeed; + + CurveLPPessimisticFeed dolaSUSDeFeed; + ICurvePool curvePool = ICurvePool(0x744793B5110f6ca9cC7CDfe1CE16677c3Eb192ef); // DOLA/sUSDe Curve Pool + address dolaFixedPriceFeed = 0x5CB542EB054f81b8Fa1760c077f44AA80271c75D; // DOLA/USD fixed price feed + + address guardian = address(0x123); + address user = address(0x456); + + uint256 timelockPeriod = 18 hours; + + function setUp() public { + string memory url = vm.rpcUrl("mainnet"); + vm.createSelectFork(url, 21236794); + + + usdtWrappedFeed = new ChainlinkBasePriceFeed( + gov, + clUsdtToUsd, + address(0), + 24 hours // usdt heartbeat + ); + + // deploy FeedSwitchV2 with USDT as initial feed and sUSDe as fallback + feedSwitch = new FeedSwitchV2( + address(usdtWrappedFeed), + address(sUSDeWrappedFeed), + timelockPeriod, + gov, + guardian + ); + + // seploy LP feed using the feed switch together with fixed dola price feed + dolaSUSDeFeed = new CurveLPPessimisticFeed( + address(curvePool), + address(feedSwitch), + dolaFixedPriceFeed, + false + ); + } + + function test_deployment() public view { + assertEq(address(feedSwitch.feed()), address(usdtWrappedFeed)); + assertEq(address(feedSwitch.initialFeed()), address(usdtWrappedFeed)); + assertEq(address(feedSwitch.fallbackFeed()), address(sUSDeWrappedFeed)); + assertEq(feedSwitch.timelockPeriod(), timelockPeriod); + assertEq(feedSwitch.decimals(), 18); + assertEq(sUSDeWrappedFeed.decimals(), 18); + assertEq(usdtWrappedFeed.decimals(), 18); + assertEq(feedSwitch.switchCompletedAt(), 0); + } + + function test_toggleFeedSwitch() public { + vm.prank(guardian); + feedSwitch.toggleFeedSwitch(); + + assertEq(address(feedSwitch.feed()), address(sUSDeWrappedFeed)); + assertEq(address(feedSwitch.previousFeed()), address(usdtWrappedFeed)); + assertEq(feedSwitch.switchCompletedAt(), block.timestamp + timelockPeriod); + + int256 price = feedSwitch.latestAnswer(); + int256 expectedPrice = usdtWrappedFeed.latestAnswer(); + assertEq(price, expectedPrice); + + vm.warp(block.timestamp + timelockPeriod); + // after timelock, latest answer should be from the new feed + price = feedSwitch.latestAnswer(); + expectedPrice = sUSDeWrappedFeed.latestAnswer(); + assertEq(price, expectedPrice); + } + + function test_toggleFeedSwitch_revertsIfNotGuardian() public { + vm.prank(user); + vm.expectRevert(FeedSwitchV2.NotGuardian.selector); + feedSwitch.toggleFeedSwitch(); + } + + function test_toggleFeedSwitch_then_toggleBack() public { + // First toggle: USDT -> sUSDe + vm.prank(guardian); + feedSwitch.toggleFeedSwitch(); + + vm.warp(block.timestamp + timelockPeriod); + + // Second toggle: sUSDe -> USDT + vm.prank(guardian); + feedSwitch.toggleFeedSwitch(); + + assertEq(address(feedSwitch.feed()), address(usdtWrappedFeed)); + assertEq(address(feedSwitch.previousFeed()), address(sUSDeWrappedFeed)); + } + + function test_toggleFeedSwitch_then_cancel_DuringTimelock() public { + // Initiate switch + vm.prank(guardian); + feedSwitch.toggleFeedSwitch(); + uint256 expectedCompletedAt = block.timestamp + timelockPeriod; + assertEq(feedSwitch.switchCompletedAt(), expectedCompletedAt); + + // advance time but still before timelock has passed + vm.warp(block.timestamp + timelockPeriod / 2); + + // cancel switch + vm.prank(guardian); + feedSwitch.toggleFeedSwitch(); + + // should reset switchCompletedAt to 0 and toggle back to USDT + assertEq(feedSwitch.switchCompletedAt(), 0); + assertEq(address(feedSwitch.feed()), address(usdtWrappedFeed)); + } + + function test_latestRoundData_return_USDT_beforeSwitch_and_LP_Feed_price() public { + ( + uint80 roundId, + int256 price, + uint256 startedAt, + uint256 updatedAt, + uint80 answeredInRound + ) = feedSwitch.latestRoundData(); + + ( + uint80 expectedRoundId, + int256 expectedPrice, + uint256 expectedStartedAt, + uint256 expectedUpdatedAt, + uint80 expectedAnsweredInRound + ) = usdtWrappedFeed.latestRoundData(); + + assertEq(roundId, expectedRoundId); + assertEq(price, expectedPrice); + assertEq(startedAt, expectedStartedAt); + assertEq(updatedAt, expectedUpdatedAt); + assertEq(answeredInRound, expectedAnsweredInRound); + + // Check that nested CurveLPPessimisticFeed uses feed switch price and curve pool virtual price to get LP price + // mock dola fixed price feed at 1.1$ since usdt is slighlty above 1$ + vm.mockCall(dolaFixedPriceFeed, abi.encodeWithSelector(IChainlinkFeed.latestRoundData.selector), abi.encode(0,1.1e18,0,0,0)); + int256 lpPrice = dolaSUSDeFeed.latestAnswer(); + int256 expectedLpPrice = (price * int256(curvePool.get_virtual_price())) / 1e18; + assertEq(lpPrice, expectedLpPrice); + } + + function test_latestRoundData_returnsPreviousFeed_DuringTimelock_and_LP_Feed_price() public { + (, int256 usdtPrice, , ,) = usdtWrappedFeed.latestRoundData(); + + vm.prank(guardian); + feedSwitch.toggleFeedSwitch(); + + // during timelock, should still return previous feed (USDT) + (, int256 price, , ,) = feedSwitch.latestRoundData(); + + assertEq(price, usdtPrice); + + // mock dola fixed price feed at 1.1$ since usdt is slighlty above 1$ + vm.mockCall(dolaFixedPriceFeed, abi.encodeWithSelector(IChainlinkFeed.latestRoundData.selector), abi.encode(0,1.1e18,0,0,0)); + int256 lpPrice = dolaSUSDeFeed.latestAnswer(); + int256 expectedLpPrice = (price * int256(curvePool.get_virtual_price())) / 1e18; + assertEq(lpPrice, expectedLpPrice); + } + + function test_latestRoundData_returns_SUSDe_AfterTimelock() public { + vm.prank(guardian); + feedSwitch.toggleFeedSwitch(); + + vm.warp(block.timestamp + timelockPeriod); + + (, int256 price, , uint256 updatedAt ,) = feedSwitch.latestRoundData(); + (, int256 expectedPrice, ,uint256 expectedUpdatedAt ,) = sUSDeWrappedFeed.latestRoundData(); + + assertEq(price, expectedPrice); + assertEq(updatedAt, expectedUpdatedAt); + + console.log("price", price); + // mock dola fixed price feed at 1.1$ since sUSDe is slighlty above 1$ + vm.mockCall(dolaFixedPriceFeed, abi.encodeWithSelector(IChainlinkFeed.latestRoundData.selector), abi.encode(0,1.1e18,0,0,0)); + // LP Feed now uses sUSDe price + int256 lpPrice = dolaSUSDeFeed.latestAnswer(); + int256 expectedLpPrice = (price * int256(curvePool.get_virtual_price())) / 1e18; + assertEq(lpPrice, expectedLpPrice); + } + + function test_latestAnswer_returns_USDT_BeforeSwitch() public view { + int256 price = feedSwitch.latestAnswer(); + int256 expectedPrice = usdtWrappedFeed.latestAnswer(); + assertEq(price, expectedPrice); + } + + function test_latestAnswer_returns_SUSDe_AfterTimelock() public { + vm.prank(guardian); + feedSwitch.toggleFeedSwitch(); + + // Move after timelock + vm.warp(block.timestamp + timelockPeriod); + + int256 price = feedSwitch.latestAnswer(); + int256 expectedPrice = sUSDeWrappedFeed.latestAnswer(); + assertEq(price, expectedPrice); + } + + function test_isFeedSwitchQueued_returnsZero_WhenNotQueued() public view { + uint256 timeLeft = feedSwitch.isFeedSwitchQueued(); + assertEq(timeLeft, 0); + } + + function test_isFeedSwitchQueued_returnsTimeLeft_WhenQueued() public { + vm.prank(guardian); + feedSwitch.toggleFeedSwitch(); + + uint256 timeLeft = feedSwitch.isFeedSwitchQueued(); + assertEq(timeLeft, timelockPeriod); + } + + function test_isFeedSwitchQueued_decreaseOverTime() public { + vm.prank(guardian); + feedSwitch.toggleFeedSwitch(); + + vm.warp(block.timestamp + 6 hours); + + uint256 timeLeft = feedSwitch.isFeedSwitchQueued(); + assertEq(timeLeft, timelockPeriod - 6 hours); + } + + function test_isFeedSwitchQueued_returnsZero_AfterTimelock() public { + vm.prank(guardian); + feedSwitch.toggleFeedSwitch(); + + vm.warp(block.timestamp + timelockPeriod); + + uint256 timeLeft = feedSwitch.isFeedSwitchQueued(); + assertEq(timeLeft, 0); + } + + + function test_zeroTimelockPeriod_immediateSwitch() public { + vm.prank(gov); + feedSwitch.setTimelockPeriod(0); + + int256 sUSDePrice = sUSDeWrappedFeed.latestAnswer(); + + vm.prank(guardian); + feedSwitch.toggleFeedSwitch(); + + // Should immediately use the new feed + int256 price = feedSwitch.latestAnswer(); + assertEq(price, sUSDePrice); + + console.log("price", price); + // mock dola fixed price feed at 1.1$ since sUSDe is slighlty above 1$ + vm.mockCall(dolaFixedPriceFeed, abi.encodeWithSelector(IChainlinkFeed.latestRoundData.selector), abi.encode(0,1.1e18,0,0,0)); + // LP Feed now uses sUSDe price + int256 lpPrice = dolaSUSDeFeed.latestAnswer(); + int256 expectedLpPrice = (price * int256(curvePool.get_virtual_price())) / 1e18; + assertEq(lpPrice, expectedLpPrice); + } + + // Multiple switch tests + + function test_switchCancelReinitiate() public { + int256 usdtPrice = usdtWrappedFeed.latestAnswer(); + assertEq(feedSwitch.latestAnswer(), usdtPrice); + + // Guardian initiates switch + vm.prank(guardian); + feedSwitch.toggleFeedSwitch(); + assertEq(address(feedSwitch.feed()), address(sUSDeWrappedFeed)); + + // During timelock, still returns USDT price + vm.warp(block.timestamp + 6 hours); + assertEq(feedSwitch.latestAnswer(), usdtPrice); + + // Guardian cancels switch + vm.prank(guardian); + feedSwitch.toggleFeedSwitch(); + assertEq(address(feedSwitch.feed()), address(usdtWrappedFeed)); + assertEq(feedSwitch.switchCompletedAt(), 0); + + // Still returns USDT price + vm.warp(block.timestamp + 1 days); + assertEq(feedSwitch.latestAnswer(), usdtPrice); + + // Guardian re-initiates switch + vm.prank(guardian); + feedSwitch.toggleFeedSwitch(); + assertEq(address(feedSwitch.feed()), address(sUSDeWrappedFeed)); + + // Wait for timelock to complete + vm.warp(block.timestamp + timelockPeriod); + + // Now returns sUSDe price + int256 sUSDePrice = sUSDeWrappedFeed.latestAnswer(); + assertEq(feedSwitch.latestAnswer(), sUSDePrice); + } + + function test_multipleSwitches() public { + // Switch to sUSDe + vm.prank(guardian); + feedSwitch.toggleFeedSwitch(); + vm.warp(block.timestamp + timelockPeriod); + int256 sUSDePrice = sUSDeWrappedFeed.latestAnswer(); + assertEq(feedSwitch.latestAnswer(), sUSDePrice); + + // Switch back to USDT + vm.prank(guardian); + feedSwitch.toggleFeedSwitch(); + vm.warp(block.timestamp + timelockPeriod); + int256 usdtPrice = usdtWrappedFeed.latestAnswer(); + assertEq(feedSwitch.latestAnswer(), usdtPrice); + + // Switch again to sUSDe + vm.prank(guardian); + feedSwitch.toggleFeedSwitch(); + vm.warp(block.timestamp + timelockPeriod); + sUSDePrice = sUSDeWrappedFeed.latestAnswer(); + assertEq(feedSwitch.latestAnswer(), sUSDePrice); + } + + + // Admin tests + function test_setPendingGov() public { + address newGov = address(0x5); + + vm.prank(gov); + feedSwitch.setPendingGov(newGov); + + assertEq(feedSwitch.pendingGov(), newGov); + } + + function test_setPendingGov_revertsIfNotGov() public { + vm.prank(user); + vm.expectRevert(FeedSwitchV2.NotGov.selector); + feedSwitch.setPendingGov(user); + } + + function test_acceptGov() public { + address newGov = address(0x5); + + vm.prank(gov); + feedSwitch.setPendingGov(newGov); + + vm.prank(newGov); + feedSwitch.acceptGov(); + + assertEq(feedSwitch.gov(), newGov); + assertEq(feedSwitch.pendingGov(), address(0)); + } + + function test_acceptGov_revertsIfNotPendingGov() public { + address newGov = address(0x5); + + vm.prank(gov); + feedSwitch.setPendingGov(newGov); + + vm.prank(user); + vm.expectRevert(FeedSwitchV2.NotPendingGov.selector); + feedSwitch.acceptGov(); + } + + function test_setGuardian() public { + address newGuardian = address(0x6); + + vm.prank(gov); + feedSwitch.setGuardian(newGuardian, true); + + assertEq(feedSwitch.isGuardian(newGuardian), true); + } + + function test_setGuardian_removeGuardian() public { + vm.prank(gov); + feedSwitch.setGuardian(guardian, false); + + assertEq(feedSwitch.isGuardian(guardian), false); + } + + function test_setGuardian_revertsIfNotGov() public { + vm.prank(user); + vm.expectRevert(FeedSwitchV2.NotGov.selector); + feedSwitch.setGuardian(user, true); + } + + function test_setTimelockPeriod() public { + uint256 newTimelockPeriod = 24 hours; + + vm.prank(gov); + feedSwitch.setTimelockPeriod(newTimelockPeriod); + + assertEq(feedSwitch.timelockPeriod(), newTimelockPeriod); + } + + function test_setTimelockPeriod_canSetToZero() public { + vm.prank(gov); + feedSwitch.setTimelockPeriod(0); + + assertEq(feedSwitch.timelockPeriod(), 0); + } + + function test_setTimelockPeriod_revertsIfNotGov() public { + vm.prank(user); + vm.expectRevert(FeedSwitchV2.NotGov.selector); + feedSwitch.setTimelockPeriod(24 hours); + } + +}