diff --git a/contracts/SDCollateral.sol b/contracts/SDCollateral.sol index 87868349..b4f0e079 100644 --- a/contracts/SDCollateral.sol +++ b/contracts/SDCollateral.sol @@ -208,6 +208,8 @@ contract SDCollateral is ISDCollateral, AccessControlUpgradeable, ReentrancyGuar emit UpdatedStaderConfig(_staderConfig); } + + function updatePoolThreshold( uint8 _poolId, uint256 _minThreshold, diff --git a/contracts/SDUtilityPool.sol b/contracts/SDUtilityPool.sol index a7422bff..a0642a0e 100644 --- a/contracts/SDUtilityPool.sol +++ b/contracts/SDUtilityPool.sol @@ -28,6 +28,9 @@ contract SDUtilityPool is ISDUtilityPool, AccessControlUpgradeable, PausableUpgr uint256 public constant MAX_PROTOCOL_FEE = 1e17; // 10% + uint256 public constant RISK_CONFIG_APPLY = 1 days; + + // State variables /// @notice Percentage of protocol fee expressed in gwei @@ -80,6 +83,12 @@ contract SDUtilityPool is ISDUtilityPool, AccessControlUpgradeable, PausableUpgr /// @notice risk configuration RiskConfig public riskConfig; + /// @notice pending risk configuration + RiskConfig public pendingRiskConfig; + /// @notice risk config propose time + uint public riskConfigProposeTime; + + /// @notice chronological collection of liquidations OperatorLiquidation[] public liquidations; @@ -620,19 +629,55 @@ contract SDUtilityPool is ISDUtilityPool, AccessControlUpgradeable, PausableUpgr } /** - * @notice Updates the risk configuration + * @notice Apply the queued risk configuration after timelock period + * @dev Reverts if no configuration is queued or timelock period hasn't elapsed + */ + function applyRiskConfig() external onlyRole(DEFAULT_ADMIN_ROLE) { + if(riskConfigProposeTime == 0) revert InvalidInput(); + if(riskConfigProposeTime + RISK_CONFIG_APPLY > block.timestamp) + revert RiskConfigApplyTimeDontReach(); + _updateRiskConfig(pendingRiskConfig.liquidationThreshold, pendingRiskConfig.liquidationBonusPercent, pendingRiskConfig.liquidationFeePercent, pendingRiskConfig.ltv); + riskConfigProposeTime = 0; // Reset after applying + } + /** + * @notice Queue a new risk configuration to be applied after timelock period * @param liquidationThreshold The new liquidation threshold percent (1 - 100) * @param liquidationBonusPercent The new liquidation bonus percent (0 - 100) * @param liquidationFeePercent The new liquidation fee percent (0 - 100) * @param ltv The new loan-to-value ratio (1 - 100) */ - function updateRiskConfig( + function queueRiskConfig( uint256 liquidationThreshold, uint256 liquidationBonusPercent, uint256 liquidationFeePercent, uint256 ltv ) external onlyRole(DEFAULT_ADMIN_ROLE) { - _updateRiskConfig(liquidationThreshold, liquidationBonusPercent, liquidationFeePercent, ltv); + if (liquidationThreshold > 100 || liquidationThreshold == 0) revert InvalidInput(); + if (liquidationBonusPercent > 100) revert InvalidInput(); + if (liquidationFeePercent > 100) revert InvalidInput(); + if (ltv > 100 || ltv == 0) revert InvalidInput(); + + pendingRiskConfig = RiskConfig({ + liquidationThreshold: liquidationThreshold, + liquidationBonusPercent: liquidationBonusPercent, + liquidationFeePercent: liquidationFeePercent, + ltv: ltv + }); + + riskConfigProposeTime = block.timestamp; + + emit RiskConfigQueued(liquidationThreshold, liquidationBonusPercent, liquidationFeePercent, ltv); + } + + /** + * @notice Cancel a queued risk configuration + * @dev Only callable by admin, useful for emergency situations + */ + function cancelQueuedRiskConfig() external onlyRole(DEFAULT_ADMIN_ROLE) { + if (riskConfigProposeTime == 0) revert InvalidInput(); + riskConfigProposeTime = 0; + delete pendingRiskConfig; + emit RiskConfigCancelled(); } //Getters diff --git a/contracts/interfaces/ISDUtilityPool.sol b/contracts/interfaces/ISDUtilityPool.sol index ef8b7f87..f9a70e92 100644 --- a/contracts/interfaces/ISDUtilityPool.sol +++ b/contracts/interfaces/ISDUtilityPool.sol @@ -41,6 +41,7 @@ interface ISDUtilityPool { error MaxLimitOnWithdrawRequestCountReached(); error RequestIdNotFinalized(uint256 requestId); error AlreadyLiquidated(); + error RiskConfigApplyTimeDontReach(); event WithdrawnProtocolFee(uint256 amount); event ProtocolFeeFactorUpdated(uint256 protocolFeeFactor); @@ -73,6 +74,13 @@ interface ISDUtilityPool { uint256 liquidationFeePercent, uint256 ltv ); + event RiskConfigQueued( + uint256 liquidationThreshold, + uint256 liquidationBonusPercent, + uint256 liquidationFeePercent, + uint256 ltv + ); + event RiskConfigCancelled(); event AccruedFees(uint256 feeAccumulated, uint256 totalProtocolFee, uint256 totalUtilizedSD); event WithdrawRequestReceived(address caller, uint256 nextRequestId, uint256 sdAmountToWithdraw); event UpdatedConservativeEthPerKey(uint256 conservativeEthPerKey); diff --git a/test/foundry_tests/SDUtilityPool.t.sol b/test/foundry_tests/SDUtilityPool.t.sol index 29237ea3..4aab8cb5 100644 --- a/test/foundry_tests/SDUtilityPool.t.sol +++ b/test/foundry_tests/SDUtilityPool.t.sol @@ -777,10 +777,37 @@ contract SDUtilityPoolTest is Test { function test_UpdateRiskConfig(uint256 randomSeed) public { vm.assume(randomSeed > 0); vm.assume(randomSeed < 100); - vm.expectRevert(); - sdUtilityPool.updateRiskConfig(randomSeed, randomSeed, randomSeed, randomSeed); + + // Queue the risk config + vm.prank(staderAdmin); + sdUtilityPool.queueRiskConfig(randomSeed, randomSeed, randomSeed, randomSeed); + + // Wait for timelock period + vm.warp(block.timestamp + sdUtilityPool.RISK_CONFIG_APPLY()); + + // Apply the queued config vm.prank(staderAdmin); - sdUtilityPool.updateRiskConfig(randomSeed, randomSeed, randomSeed, randomSeed); + sdUtilityPool.applyRiskConfig(); + + // Verify the config was applied + (uint liquidationThreshold, uint liquidationBonusPercent, uint liquidationFeePercent, uint ltv) = sdUtilityPool.riskConfig(); + assertEq(randomSeed, liquidationThreshold); + assertEq(randomSeed, liquidationBonusPercent); + assertEq(randomSeed, liquidationFeePercent); + assertEq(randomSeed, ltv); + } + + function test_CancelQueuedRiskConfig() public { + // Queue a risk config + vm.prank(staderAdmin); + sdUtilityPool.queueRiskConfig(50, 10, 5, 80); + + // Cancel it + vm.prank(staderAdmin); + sdUtilityPool.cancelQueuedRiskConfig(); + + // Verify timelock was reset + assertEq(sdUtilityPool.riskConfigProposeTime(), 0); } function test_LiquidationCall(uint16 randomSeed) public {