diff --git a/contracts/src/Interfaces/IOracleAdapter.sol b/contracts/src/Interfaces/IOracleAdapter.sol index 20fab15d4..083d33ca4 100644 --- a/contracts/src/Interfaces/IOracleAdapter.sol +++ b/contracts/src/Interfaces/IOracleAdapter.sol @@ -16,4 +16,11 @@ interface IOracleAdapter { * @return denominator The denominator of the rate */ function getFXRateIfValid(address rateFeedID) external view returns (uint256 numerator, uint256 denominator); + + /** + * @notice Returns true if the L2 sequencer has been up and operational for at least the specified duration. + * @param since The minimum number of seconds the L2 sequencer must have been up (e.g., 1 hours = 3600). + * @return up True if the sequencer has been up for at least `since` seconds, false otherwise + */ + function isL2SequencerUp(uint256 since) external view returns (bool up); } diff --git a/contracts/src/Interfaces/IPriceFeed.sol b/contracts/src/Interfaces/IPriceFeed.sol index c9bb33a1a..63c813fd8 100644 --- a/contracts/src/Interfaces/IPriceFeed.sol +++ b/contracts/src/Interfaces/IPriceFeed.sol @@ -4,4 +4,5 @@ pragma solidity ^0.8.0; interface IPriceFeed { function fetchPrice() external returns (uint256); + function isL2SequencerUp() external view returns (bool); } diff --git a/contracts/src/PriceFeeds/FXPriceFeed.sol b/contracts/src/PriceFeeds/FXPriceFeed.sol index 7d8eb538e..44747c521 100644 --- a/contracts/src/PriceFeeds/FXPriceFeed.sol +++ b/contracts/src/PriceFeeds/FXPriceFeed.sol @@ -23,6 +23,12 @@ contract FXPriceFeed is IPriceFeed, OwnableUpgradeable { /// @notice The identifier address for the specific rate feed to query address public rateFeedID; + // @notice Whether the rate from the OracleAdapter should be inverted + bool public invertRateFeed; + + /// @notice The grace period for the L2 sequencer to recover from failure + uint256 public l2SequencerGracePeriod; + /// @notice The watchdog contract address authorized to trigger emergency shutdown address public watchdogAddress; @@ -35,15 +41,20 @@ contract FXPriceFeed is IPriceFeed, OwnableUpgradeable { /// @notice Whether the contract has been shutdown due to an oracle failure bool public isShutdown; - // @notice Whether the rate from the OracleAdapter should be inverted - bool public invertRateFeed; + /// @notice Thrown when the attempting to shutdown an already shutdown contract + error AlreadyShutdown(); - /// @notice Thrown when the attempting to shutdown the contract when it is already shutdown - error IsShutDown(); /// @notice Thrown when a non-watchdog address attempts to shutdown the contract - error CallerNotWatchdog(); + error OnlyWatchdog(); /// @notice Thrown when a zero address is provided as a parameter error ZeroAddress(); + /// @notice Thrown when an invalid grace period is provided + error InvalidL2SequencerGracePeriod(); + + /// @notice Emitted when the OracleAdapter contract is updated + /// @param _oldOracleAdapterAddress The previous OracleAdapter contract + /// @param _newOracleAdapterAddress The new OracleAdapter contract + event OracleAdapterUpdated(address indexed _oldOracleAdapterAddress, address indexed _newOracleAdapterAddress); /// @notice Emitted when the rate feed ID is updated /// @param _oldRateFeedID The previous rate feed ID @@ -55,6 +66,11 @@ contract FXPriceFeed is IPriceFeed, OwnableUpgradeable { /// @param _newInvertRateFeed The new invert rate feed flag event InvertRateFeedUpdated(bool _oldInvertRateFeed, bool _newInvertRateFeed); + /// @notice Emitted when the L2 sequencer grace period is updated + /// @param _oldL2SequencerGracePeriod The previous L2 sequencer grace period + /// @param _newL2SequencerGracePeriod The new L2 sequencer grace period + event L2SequencerGracePeriodUpdated(uint256 indexed _oldL2SequencerGracePeriod, uint256 indexed _newL2SequencerGracePeriod); + /// @notice Emitted when the watchdog address is updated /// @param _oldWatchdogAddress The previous watchdog address /// @param _newWatchdogAddress The new watchdog address @@ -78,6 +94,7 @@ contract FXPriceFeed is IPriceFeed, OwnableUpgradeable { * @param _oracleAdapterAddress The address of the OracleAdapter contract * @param _rateFeedID The address of the rate feed ID * @param _invertRateFeed Whether the rate from the OracleAdapter should be inverted + * @param _l2SequencerGracePeriod The grace period for the L2 sequencer to recover from failure * @param _borrowerOperationsAddress The address of the BorrowerOperations contract * @param _watchdogAddress The address of the watchdog contract * @param _initialOwner The address of the initial owner @@ -86,6 +103,7 @@ contract FXPriceFeed is IPriceFeed, OwnableUpgradeable { address _oracleAdapterAddress, address _rateFeedID, bool _invertRateFeed, + uint256 _l2SequencerGracePeriod, address _borrowerOperationsAddress, address _watchdogAddress, address _initialOwner @@ -99,6 +117,7 @@ contract FXPriceFeed is IPriceFeed, OwnableUpgradeable { oracleAdapter = IOracleAdapter(_oracleAdapterAddress); rateFeedID = _rateFeedID; invertRateFeed = _invertRateFeed; + l2SequencerGracePeriod = _l2SequencerGracePeriod; borrowerOperations = IBorrowerOperations(_borrowerOperationsAddress); watchdogAddress = _watchdogAddress; @@ -107,6 +126,24 @@ contract FXPriceFeed is IPriceFeed, OwnableUpgradeable { _transferOwnership(_initialOwner); } + + /** + * @notice Sets the OracleAdapter contract + * @param _newOracleAdapterAddress The address of the new OracleAdapter contract + */ + function setOracleAdapter(address _newOracleAdapterAddress) external onlyOwner { + if (_newOracleAdapterAddress == address(0)) revert ZeroAddress(); + + address oldOracleAdapter = address(oracleAdapter); + oracleAdapter = IOracleAdapter(_newOracleAdapterAddress); + + emit OracleAdapterUpdated(oldOracleAdapter, _newOracleAdapterAddress); + } + + /** + * @notice Sets the rate feed ID to be queried + * @param _newRateFeedID The address of the new rate feed ID + */ function setRateFeedID(address _newRateFeedID) external onlyOwner { if (_newRateFeedID == address(0)) revert ZeroAddress(); @@ -127,6 +164,19 @@ contract FXPriceFeed is IPriceFeed, OwnableUpgradeable { emit InvertRateFeedUpdated(oldInvertRateFeed, _invertRateFeed); } + /** + * @notice Sets the L2 sequencer grace period + * @param _newL2SequencerGracePeriod The new L2 sequencer grace period (in seconds) + */ + function setL2SequencerGracePeriod(uint256 _newL2SequencerGracePeriod) external onlyOwner { + if (_newL2SequencerGracePeriod == 0) revert InvalidL2SequencerGracePeriod(); + + uint256 oldL2SequencerGracePeriod = l2SequencerGracePeriod; + l2SequencerGracePeriod = _newL2SequencerGracePeriod; + + emit L2SequencerGracePeriodUpdated(oldL2SequencerGracePeriod, _newL2SequencerGracePeriod); + } + /** * @notice Sets the watchdog address * @param _newWatchdogAddress The address of the new watchdog contract @@ -140,6 +190,14 @@ contract FXPriceFeed is IPriceFeed, OwnableUpgradeable { emit WatchdogAddressUpdated(oldWatchdogAddress, _newWatchdogAddress); } + /** + * @notice Checks if the L2 sequencer is up and the grace period has passed + * @return True if the L2 sequencer is up and the grace period has passed, false otherwise + */ + function isL2SequencerUp() public view returns (bool) { + return oracleAdapter.isL2SequencerUp(l2SequencerGracePeriod); + } + /** * @notice Fetches the price of the FX rate, if valid * @dev If the contract is shutdown due to oracle failure, the last valid price is returned @@ -175,8 +233,8 @@ contract FXPriceFeed is IPriceFeed, OwnableUpgradeable { * - The shutdown state is permanent and cannot be reversed */ function shutdown() external { - if (isShutdown) revert IsShutDown(); - if (msg.sender != watchdogAddress) revert CallerNotWatchdog(); + if (isShutdown) revert AlreadyShutdown(); + if (msg.sender != watchdogAddress) revert OnlyWatchdog(); isShutdown = true; borrowerOperations.shutdownFromOracleFailure(); diff --git a/contracts/src/TroveManager.sol b/contracts/src/TroveManager.sol index 7349299c0..a896cb4f9 100644 --- a/contracts/src/TroveManager.sol +++ b/contracts/src/TroveManager.sol @@ -163,6 +163,7 @@ contract TroveManager is LiquityBase, ITroveManager, ITroveEvents { error NotEnoughBoldBalance(); error MinCollNotReached(uint256 _coll); error BatchSharesRatioTooHigh(); + error L2SequencerDown(); // --- Events --- @@ -402,6 +403,7 @@ contract TroveManager is LiquityBase, ITroveManager, ITroveEvents { * Attempt to liquidate a custom list of troves provided by the caller. */ function batchLiquidateTroves(uint256[] memory _troveArray) public override { + _requireL2SequencerIsUp(); if (_troveArray.length == 0) { revert EmptyData(); } @@ -1198,6 +1200,12 @@ contract TroveManager is LiquityBase, ITroveManager, ITroveEvents { } } + function _requireL2SequencerIsUp() internal view { + if (!priceFeed.isL2SequencerUp()) { + revert L2SequencerDown(); + } + } + // --- Trove property getters --- function getUnbackedPortionPriceAndRedeemability() external returns (uint256, uint256, bool) { diff --git a/contracts/test/FXPriceFeed.t.sol b/contracts/test/FXPriceFeed.t.sol index 0d9f95109..0dd3c2802 100644 --- a/contracts/test/FXPriceFeed.t.sol +++ b/contracts/test/FXPriceFeed.t.sol @@ -21,6 +21,7 @@ contract MockBorrowerOperations { contract MockOracleAdapter { uint256 numerator; uint256 denominator; + bool public sequencerUp = true; function setFXRate(uint256 _numerator, uint256 _denominator) external { numerator = _numerator; @@ -30,6 +31,14 @@ contract MockOracleAdapter { function getFXRateIfValid(address) external view returns (uint256, uint256) { return (numerator, denominator); } + + function setIsL2SequencerUp(bool _isUp) external { + sequencerUp = _isUp; + } + + function isL2SequencerUp(uint256) external view returns (bool) { + return sequencerUp; + } } @@ -38,11 +47,15 @@ contract FXPriceFeedTest is Test { event WatchdogAddressUpdated(address indexed _oldWatchdogAddress, address indexed _newWatchdogAddress); event InvertRateFeedUpdated(bool _oldInvertRateFeed, bool _newInvertRateFeed); event FXPriceFeedShutdown(); + event OracleAdapterUpdated(address indexed _oldOracleAdapterAddress, address indexed _newOracleAdapterAddress); + event L2SequencerGracePeriodUpdated(uint256 indexed _oldL2SequencerGracePeriod, uint256 indexed _newL2SequencerGracePeriod); FXPriceFeed public fxPriceFeed; MockOracleAdapter public mockOracleAdapter; MockBorrowerOperations public mockBorrowerOperations; + MockFXPriceFeed public mockFXPriceFeed; + uint256 public l2SequencerGracePeriod = 6 hours; address public rateFeedID = makeAddr("rateFeedID"); address public watchdog = makeAddr("watchdog"); address public owner = makeAddr("owner"); @@ -56,6 +69,7 @@ contract FXPriceFeedTest is Test { address(mockOracleAdapter), rateFeedID, false, + l2SequencerGracePeriod, address(mockBorrowerOperations), watchdog, owner @@ -67,6 +81,7 @@ contract FXPriceFeedTest is Test { function setUp() public { mockOracleAdapter = new MockOracleAdapter(); mockOracleAdapter.setFXRate(mockRateNumerator, mockRateDenominator); + mockOracleAdapter.setIsL2SequencerUp(true); mockBorrowerOperations = new MockBorrowerOperations(); @@ -81,6 +96,7 @@ contract FXPriceFeedTest is Test { address(mockOracleAdapter), rateFeedID, false, + l2SequencerGracePeriod, address(mockBorrowerOperations), watchdog, owner @@ -95,6 +111,7 @@ contract FXPriceFeedTest is Test { address(0), rateFeedID, false, + l2SequencerGracePeriod, address(mockBorrowerOperations), watchdog, owner @@ -109,6 +126,7 @@ contract FXPriceFeedTest is Test { address(mockOracleAdapter), address(0), false, + l2SequencerGracePeriod, address(mockBorrowerOperations), watchdog, owner @@ -123,6 +141,7 @@ contract FXPriceFeedTest is Test { address(mockOracleAdapter), rateFeedID, false, + l2SequencerGracePeriod, address(0), watchdog, owner @@ -137,6 +156,7 @@ contract FXPriceFeedTest is Test { address(mockOracleAdapter), rateFeedID, false, + l2SequencerGracePeriod, address(mockBorrowerOperations), address(0), owner @@ -151,6 +171,7 @@ contract FXPriceFeedTest is Test { address(mockOracleAdapter), rateFeedID, false, + l2SequencerGracePeriod, address(mockBorrowerOperations), watchdog, address(0) @@ -166,6 +187,7 @@ contract FXPriceFeedTest is Test { address(mockOracleAdapter), rateFeedID, false, + l2SequencerGracePeriod, address(mockBorrowerOperations), watchdog, owner @@ -173,6 +195,7 @@ contract FXPriceFeedTest is Test { assertEq(address(newFeed.oracleAdapter()), address(mockOracleAdapter)); assertEq(newFeed.rateFeedID(), rateFeedID); + assertEq(newFeed.l2SequencerGracePeriod(), l2SequencerGracePeriod); assertEq(address(newFeed.borrowerOperations()), address(mockBorrowerOperations)); assertEq(newFeed.watchdogAddress(), watchdog); assertEq(newFeed.owner(), owner); @@ -186,6 +209,7 @@ contract FXPriceFeedTest is Test { address(mockOracleAdapter), rateFeedID, false, + l2SequencerGracePeriod, address(mockBorrowerOperations), watchdog, owner @@ -196,6 +220,7 @@ contract FXPriceFeedTest is Test { address(mockOracleAdapter), rateFeedID, false, + l2SequencerGracePeriod, address(mockBorrowerOperations), watchdog, owner @@ -330,7 +355,7 @@ contract FXPriceFeedTest is Test { address notWatchdog = makeAddr("notWatchdog"); vm.prank(notWatchdog); - vm.expectRevert(FXPriceFeed.CallerNotWatchdog.selector); + vm.expectRevert(FXPriceFeed.OnlyWatchdog.selector); fxPriceFeed.shutdown(); vm.stopPrank(); } @@ -352,8 +377,79 @@ contract FXPriceFeedTest is Test { function test_shutdown_whenAlreadyShutdown_shouldRevert() initialized public { vm.prank(watchdog); fxPriceFeed.shutdown(); - vm.expectRevert(FXPriceFeed.IsShutDown.selector); + vm.expectRevert(FXPriceFeed.AlreadyShutdown.selector); fxPriceFeed.shutdown(); vm.stopPrank(); } + + function test_setOracleAdapter_whenCalledByNonOwner_shouldRevert() initialized public { + address notOwner = makeAddr("notOwner"); + + vm.prank(notOwner); + vm.expectRevert("Ownable: caller is not the owner"); + fxPriceFeed.setOracleAdapter(makeAddr("newOracleAdapter")); + vm.stopPrank(); + } + + function test_setOracleAdapter_whenNewAddressIsZero_shouldRevert() initialized public { + vm.prank(owner); + vm.expectRevert(FXPriceFeed.ZeroAddress.selector); + fxPriceFeed.setOracleAdapter(address(0)); + vm.stopPrank(); + } + + function test_setOracleAdapter_whenCalledByOwner_shouldSucceed() initialized public { + address newOracleAdapter = makeAddr("newOracleAdapter"); + + vm.prank(owner); + vm.expectEmit(); + emit OracleAdapterUpdated(address(mockOracleAdapter), newOracleAdapter); + fxPriceFeed.setOracleAdapter(newOracleAdapter); + vm.stopPrank(); + + assertEq(address(fxPriceFeed.oracleAdapter()), newOracleAdapter); + } + + function test_setL2SequencerGracePeriod_whenCalledByNonOwner_shouldRevert() initialized public { + address notOwner = makeAddr("notOwner"); + + vm.prank(notOwner); + vm.expectRevert("Ownable: caller is not the owner"); + fxPriceFeed.setL2SequencerGracePeriod(12 hours); + vm.stopPrank(); + } + + function test_setL2SequencerGracePeriod_whenNewPeriodIsZero_shouldRevert() initialized public { + vm.prank(owner); + vm.expectRevert(FXPriceFeed.InvalidL2SequencerGracePeriod.selector); + fxPriceFeed.setL2SequencerGracePeriod(0); + vm.stopPrank(); + } + + function test_setL2SequencerGracePeriod_whenCalledByOwner_shouldSucceed() initialized public { + uint256 oldGracePeriod = fxPriceFeed.l2SequencerGracePeriod(); + uint256 newGracePeriod = 12 hours; + + vm.prank(owner); + vm.expectEmit(); + emit L2SequencerGracePeriodUpdated(oldGracePeriod, newGracePeriod); + fxPriceFeed.setL2SequencerGracePeriod(newGracePeriod); + vm.stopPrank(); + + assertEq(fxPriceFeed.l2SequencerGracePeriod(), newGracePeriod); + } + + function test_isL2SequencerUp_whenSequencerIsUp_shouldReturnTrue() initialized public { + mockOracleAdapter.setIsL2SequencerUp(true); + + bool result = fxPriceFeed.isL2SequencerUp(); + assertTrue(result); + } + + function test_isL2SequencerUp_whenSequencerIsDown_shouldReturnFalse() initialized public { + mockOracleAdapter.setIsL2SequencerUp(false); + + bool result = fxPriceFeed.isL2SequencerUp(); + assertFalse(result); + } } diff --git a/contracts/test/TestContracts/Interfaces/IMockFXPriceFeed.sol b/contracts/test/TestContracts/Interfaces/IMockFXPriceFeed.sol index f7e0be41e..d2a05e163 100644 --- a/contracts/test/TestContracts/Interfaces/IMockFXPriceFeed.sol +++ b/contracts/test/TestContracts/Interfaces/IMockFXPriceFeed.sol @@ -9,4 +9,5 @@ interface IMockFXPriceFeed is IPriceFeed { function setPrice(uint256 _price) external; function getPrice() external view returns (uint256); function setValidPrice(bool valid) external; + function setL2SequencerUp(bool up) external; } diff --git a/contracts/test/TestContracts/MockFXPriceFeed.sol b/contracts/test/TestContracts/MockFXPriceFeed.sol index 6d2d0e753..1c4b7a493 100644 --- a/contracts/test/TestContracts/MockFXPriceFeed.sol +++ b/contracts/test/TestContracts/MockFXPriceFeed.sol @@ -13,6 +13,7 @@ contract MockFXPriceFeed is IMockFXPriceFeed { string private _revertMsg = "MockFXPriceFeed: no valid price"; uint256 private _price = 200 * 1e18; bool private _hasValidPrice = true; + bool private _isL2SequencerUp = true; function getPrice() external view override returns (uint256) { return _price; @@ -26,12 +27,20 @@ contract MockFXPriceFeed is IMockFXPriceFeed { _price = price; } + function setL2SequencerUp(bool up) external { + _isL2SequencerUp = up; + } + function fetchPrice() external view override returns (uint256) { require(_hasValidPrice, _revertMsg); return _price; } + function isL2SequencerUp() external view override returns (bool) { + return _isL2SequencerUp; + } + function REVERT_MSG() external view override returns (string memory) { return _revertMsg; } diff --git a/contracts/test/troveManager.t.sol b/contracts/test/troveManager.t.sol index 621383f88..a63b80915 100644 --- a/contracts/test/troveManager.t.sol +++ b/contracts/test/troveManager.t.sol @@ -217,4 +217,19 @@ contract TroveManagerTest is DevTestSetup { liquidatedTroves[1] = troveIDs.B; troveManager.batchLiquidateTroves(liquidatedTroves); } + + function testLiquidationRevertsWhenL2SequencerIsDown() public { + priceFeed.setPrice(2000e18); + uint256 ATroveId = openTroveNoHints100pct(A, 100 ether, 100_000e18, 1e17); + uint256 BTroveId = openTroveNoHints100pct(B, 100 ether, 100_000e18, 1e17); + + + priceFeed.setPrice(1_000e18); + priceFeed.setL2SequencerUp(false); + + vm.startPrank(A); + vm.expectRevert(TroveManager.L2SequencerDown.selector); + troveManager.liquidate(ATroveId); + vm.stopPrank(); + } }