diff --git a/packages/contracts/src/dollar/core/UbiquityPoolSecurityMonitor.md b/packages/contracts/src/dollar/core/UbiquityPoolSecurityMonitor.md new file mode 100644 index 000000000..284ee1981 --- /dev/null +++ b/packages/contracts/src/dollar/core/UbiquityPoolSecurityMonitor.md @@ -0,0 +1,57 @@ +# UbiquityPoolSecurityMonitor Off-Chain part + +A crucial component of the `UbiquityPoolSecurityMonitor` contract workflow is its off-chain integration. The `checkLiquidityVertex()` function must be periodically triggered by OpenZeppelin Defender to ensure continuous liquidity monitoring and security assessments. + +The workflow consists of four key components: + +1. **[OpenZeppelin Actions](https://docs.openzeppelin.com/defender/module/actions)**: Executes a cron job that triggers the Relayer to call the `checkLiquidityVertex()` function. +2. **[OpenZeppelin Relayer](https://docs.openzeppelin.com/defender/module/relayers)**: Performs the transaction that invokes the `checkLiquidityVertex()` function. +3. **UbiquityPoolSecurityMonitor Contract**: Conducts the on-chain liquidity check, takes necessary actions if an incident occurs, and emits the `MonitorPaused` event. +4. **[OpenZeppelin Monitor](https://docs.openzeppelin.com/defender/module/monitor)**: Listens for the `MonitorPaused` event and sends alerts via email or other designated channels. + +### Workflow diagram +![Workflow Diagram](../../../../../utils/ubiquity-pool-security-monitor-workflow.png) + + +### OpenZeppelin Defender Setup + +To integrate OpenZeppelin Defender with the `UbiquityPoolSecurityMonitor`, follow the steps below: + +#### 1. Relayer Setup + +Complete only **Part 1** of the [OpenZeppelin Defender Relayer tutorial](https://docs.openzeppelin.com/defender/tutorial/relayer). This will configure the Relayer to handle transactions for calling the `checkLiquidityVertex()` function. + +#### 2. Actions Setup + +Follow the [OpenZeppelin Defender Actions tutorial](https://docs.openzeppelin.com/defender/tutorial/actions) to set up Actions. While configuring your Action: + - choose the Relayer you set up in step 1 + - choose `Schedule` as trigger + - use the following script for your newly created Action: + +```javascript +const { Defender } = require('@openzeppelin/defender-sdk'); + +exports.handler = async function (credentials) { + const client = new Defender(credentials); + + const txRes = await client.relaySigner.sendTransaction({ + to: '0x0000000000000000000000000000000000000000', // Replace with the actual UbiquityPoolSecurityMonitor contract address + speed: 'fast', + data: '0x9ba8a26c', // Encoded function signature for checkLiquidityVertex() of the UbiquityPoolSecurityMonitor + gasLimit: '80000', + }); + + return txRes.hash; +}; +``` + +#### 3. Monitor Setup + +In **Settings -> Notifications**, configure the desired channels you want to use for managing notifications for your monitor. + +Follow the [OpenZeppelin Defender Monitor tutorial](https://docs.openzeppelin.com/defender/tutorial/monitor) to configure a Monitor that listens for the MonitorPaused event emitted by the UbiquityPoolSecurityMonitor contract. +You will need to pass the ABI array of the **UbiquityPoolSecurityMonitor** contract. Once the ABI is provided, you will be able to choose and subscribe to any event emitted by the contract. +Then, in the **Monitor's alert section**, select the appropriate alert option for your setup. + + + diff --git a/packages/contracts/src/dollar/core/UbiquityPoolSecurityMonitor.sol b/packages/contracts/src/dollar/core/UbiquityPoolSecurityMonitor.sol new file mode 100644 index 000000000..4678d8450 --- /dev/null +++ b/packages/contracts/src/dollar/core/UbiquityPoolSecurityMonitor.sol @@ -0,0 +1,313 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {SafeMath} from "@openzeppelin/contracts/utils/math/SafeMath.sol"; +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import {AccessControlFacet} from "../facets/AccessControlFacet.sol"; +import {UbiquityPoolFacet} from "../facets/UbiquityPoolFacet.sol"; +import {LibUbiquityPool} from "../libraries/LibUbiquityPool.sol"; +import {ERC20Ubiquity} from "./ERC20Ubiquity.sol"; +import {ManagerFacet} from "../facets/ManagerFacet.sol"; +import "../libraries/Constants.sol"; +import "forge-std/console.sol"; + +contract UbiquityPoolSecurityMonitor is Initializable, UUPSUpgradeable { + using SafeMath for uint256; + + /** + * @notice Instance of AccessControlFacet used for role-based access control. + */ + AccessControlFacet public accessControlFacet; + + /** + * @notice Instance of UbiquityPoolFacet used to interact with the pool's functionalities. + */ + UbiquityPoolFacet public ubiquityPoolFacet; + + /** + * @notice Instance of ManagerFacet used to interact with manager-related functionalities. + */ + ManagerFacet public managerFacet; + + /** + * @notice The highest recorded collateral liquidity value (in USD) used as a reference point. + */ + uint256 public liquidityVertex; + + /** + * @notice Flag indicating whether the liquidity monitor is paused. + */ + bool public monitorPaused; + + /** + * @notice The threshold percentage at which liquidity differences are considered critical. + * @dev Default is set to 30%. + */ + uint256 public thresholdPercentage; + + /** + * @notice Emitted when the liquidity vertex is updated to a new value. + * @param liquidityVertex The new liquidity vertex value (in USD). + */ + event LiquidityVertexUpdated(uint256 liquidityVertex); + + /** + * @notice Emitted when the liquidity vertex is manually dropped. + * @param liquidityVertex The dropped liquidity vertex value (in USD). + */ + event LiquidityVertexDropped(uint256 liquidityVertex); + + /** + * @notice Emitted when the monitor pauses due to a liquidity drop exceeding the threshold. + * @param collateralLiquidity The current collateral liquidity (in USD) when the monitor pauses. + * @param diffPercentage The percentage difference between the current liquidity and the vertex. + */ + event MonitorPaused(uint256 collateralLiquidity, uint256 diffPercentage); + + /** + * @notice Emitted when the monitor's paused state is toggled. + * @param paused Boolean flag indicating the new paused state (true = paused, false = active). + */ + event PausedToggled(bool paused); + + /** + * @notice Modifier that restricts access to functions to only addresses with the DEFENDER_RELAYER_ROLE. + * @dev This role is required for relayer functions in the security monitor system. + * If the caller does not have the required role, the transaction is reverted. + */ + modifier onlyDefender() { + require( + accessControlFacet.hasRole(DEFENDER_RELAYER_ROLE, msg.sender), + "Ubiquity Pool Security Monitor: not defender relayer" + ); + _; + } + + /** + * @notice Modifier that restricts access to functions to only addresses with the DEFAULT_ADMIN_ROLE. + * @dev This role is needed for administrative tasks, such as managing settings or configurations. + * If the caller does not have the admin role, the transaction is reverted. + */ + modifier onlyMonitorAdmin() { + require( + accessControlFacet.hasRole(DEFAULT_ADMIN_ROLE, msg.sender), + "Ubiquity Pool Security Monitor: not admin" + ); + _; + } + + /** + * @notice Initializes the UbiquityPoolSecurityMonitor contract. + * @param _accessControlFacet The address of the AccessControlFacet contract for managing roles. + * @param _ubiquityPoolFacet The address of the UbiquityPoolFacet contract for pool interactions. + * @param _managerFacet The address of the ManagerFacet contract for manager-related interactions. + * @dev Sets the default threshold percentage to 30% and assigns the provided facet contracts. + * This function is only called once during the initialization of the upgradeable contract. + */ + function initialize( + address _accessControlFacet, + address _ubiquityPoolFacet, + address _managerFacet + ) public initializer { + thresholdPercentage = 30; + + accessControlFacet = AccessControlFacet(_accessControlFacet); + ubiquityPoolFacet = UbiquityPoolFacet(_ubiquityPoolFacet); + managerFacet = ManagerFacet(_managerFacet); + } + + /** + * @notice Updates the ManagerFacet contract used by the monitor. + * @param _newManagerFacet The address of the new ManagerFacet contract. + * @dev This function is restricted to addresses with the DEFAULT_ADMIN_ROLE via the `onlyMonitorAdmin` modifier. + */ + function setManagerFacet( + address _newManagerFacet + ) external onlyMonitorAdmin { + managerFacet = ManagerFacet(_newManagerFacet); + } + + /** + * @notice Updates the UbiquityPoolFacet contract used by the monitor. + * @param _newUbiquityPoolFacet The address of the new UbiquityPoolFacet contract. + * @dev This function is restricted to addresses with the DEFAULT_ADMIN_ROLE via the `onlyMonitorAdmin` modifier. + */ + function setUbiquityPoolFacet( + address _newUbiquityPoolFacet + ) external onlyMonitorAdmin { + ubiquityPoolFacet = UbiquityPoolFacet(_newUbiquityPoolFacet); + } + + /** + * @notice Updates the AccessControlFacet contract used by the monitor. + * @param _newAccessControlFacet The address of the new AccessControlFacet contract. + * @dev This function is restricted to addresses with the DEFAULT_ADMIN_ROLE via the `onlyMonitorAdmin` modifier. + */ + function setAccessControlFacet( + address _newAccessControlFacet + ) external onlyMonitorAdmin { + accessControlFacet = AccessControlFacet(_newAccessControlFacet); + } + + /** + * @notice Updates the threshold percentage used to detect significant liquidity drops. + * @param _newThresholdPercentage The new threshold percentage to be set. + * @dev This function is restricted to addresses with the DEFAULT_ADMIN_ROLE via the `onlyMonitorAdmin` modifier. + */ + function setThresholdPercentage( + uint256 _newThresholdPercentage + ) external onlyMonitorAdmin { + thresholdPercentage = _newThresholdPercentage; + } + + /** + * @notice Toggles the paused state of the liquidity monitor. + * @dev This function is restricted to addresses with the DEFAULT_ADMIN_ROLE via the `onlyMonitorAdmin` modifier. + * Emits the `PausedToggled` event with the updated paused state. + */ + function togglePaused() external onlyMonitorAdmin { + monitorPaused = !monitorPaused; + emit PausedToggled(monitorPaused); + } + + /** + * @notice Resets the liquidity vertex to the current collateral liquidity in the pool. + * @dev This function is used to restart the monitor and reset the liquidity vertex after a + * significant liquidity drop incident. It ensures that the new vertex is set to the + * current collateral liquidity. + * Emits the `LiquidityVertexDropped` event with the updated liquidity vertex value. + * Requires the current collateral liquidity to be greater than zero. + * @dev This function is restricted to addresses with the DEFAULT_ADMIN_ROLE via the `onlyMonitorAdmin` modifier. + */ + function dropLiquidityVertex() external onlyMonitorAdmin { + uint256 currentCollateralLiquidity = ubiquityPoolFacet + .collateralUsdBalance(); + require(currentCollateralLiquidity > 0, "Insufficient liquidity"); + + liquidityVertex = currentCollateralLiquidity; + + emit LiquidityVertexDropped(liquidityVertex); + } + + /** + * @notice Checks the current collateral liquidity and compares it with the recorded liquidity vertex. + * @dev This function ensures that the liquidity monitor is not paused and compares the current collateral + * liquidity in the pool against the stored liquidity vertex: + * - If the current liquidity exceeds the vertex, the vertex is updated. + * - If the current liquidity is below the vertex, the function checks whether the drop exceeds + * the configured threshold percentage. + * @dev Requires the current collateral liquidity to be greater than zero and ensures the monitor is not paused. + * This function is restricted to addresses with the DEFENDER_RELAYER_ROLE via the `onlyDefender` modifier. + */ + function checkLiquidityVertex() external onlyDefender { + require(!monitorPaused, "Monitor paused"); + + uint256 currentCollateralLiquidity = ubiquityPoolFacet + .collateralUsdBalance(); + + require(currentCollateralLiquidity > 0, "Insufficient liquidity"); + + if (currentCollateralLiquidity > liquidityVertex) { + _updateLiquidityVertex(currentCollateralLiquidity); + } else if (currentCollateralLiquidity < liquidityVertex) { + _checkThresholdPercentage(currentCollateralLiquidity); + } + } + + /** + * @notice Updates the liquidity vertex to a new value when the current liquidity reaches a new higher value. + * @param _newLiquidityVertex The new collateral liquidity value to set as the liquidity vertex. + * @dev This internal function updates the recorded liquidity vertex to the provided value and + * emits the `LiquidityVertexUpdated` event. It is used when the current collateral liquidity + * exceeds the previously recorded vertex, ensuring that the vertex always reflects the highest + * observed liquidity level. + */ + function _updateLiquidityVertex(uint256 _newLiquidityVertex) internal { + liquidityVertex = _newLiquidityVertex; + emit LiquidityVertexUpdated(liquidityVertex); + } + + /** + * @notice Checks if the difference between the current collateral liquidity and the liquidity vertex + * exceeds the configured threshold percentage. + * @param _currentCollateralLiquidity The current collateral liquidity in the pool. + * @dev This internal function is used when the current collateral liquidity is lower than the + * recorded liquidity vertex. It calculates the percentage difference and, if the difference + * exceeds the threshold percentage, the monitor is paused, the UbiquityDollarToken is paused, + * and collateral in the Ubiquity Pool is disabled. + * Emits the `MonitorPaused` event when the monitor is paused due to a significant liquidity drop. + * This event is caught by the defender monitor, which alerts about the liquidity issue after detecting it. + */ + function _checkThresholdPercentage( + uint256 _currentCollateralLiquidity + ) internal { + uint256 liquidityDiffPercentage = liquidityVertex + .sub(_currentCollateralLiquidity) + .mul(100) + .div(liquidityVertex); + + if (liquidityDiffPercentage >= thresholdPercentage) { + monitorPaused = true; + + // Pause the UbiquityDollarToken + _pauseUbiquityDollarToken(); + + // Pause LibUbiquityPool by disabling collateral + _pauseLibUbiquityPool(); + + emit MonitorPaused( + _currentCollateralLiquidity, + liquidityDiffPercentage + ); + } + } + + /** + * @notice Pauses all collaterals in the Ubiquity Pool. + * @dev This internal function retrieves all collateral addresses from the UbiquityPoolFacet + * and attempts to pause each collateral by toggling its state. If any collateral information + * cannot be retrieved, it is assumed that the collateral may already be paused, and the function + * continues to the next collateral without reverting. + * The purpose of this function is to disable all collateral operations when a significant + * liquidity issue is detected and the monitor is paused. + */ + function _pauseLibUbiquityPool() internal { + address[] memory allCollaterals = ubiquityPoolFacet.allCollaterals(); + + for (uint256 i = 0; i < allCollaterals.length; i++) { + try + ubiquityPoolFacet.collateralInformation(allCollaterals[i]) + returns ( + LibUbiquityPool.CollateralInformation memory collateralInfo + ) { + ubiquityPoolFacet.toggleCollateral(collateralInfo.index); + } catch { + // Assume collateral is already paused if information cannot be retrieved + continue; + } + } + } + + /** + * @notice Pauses the UbiquityDollarToken. + * @dev This internal function pauses the UbiquityDollarToken by calling its `pause` function. + * It retrieves the UbiquityDollarToken contract address via the ManagerFacet and pauses it + * to prevent further transactions involving the dollar token during a significant liquidity issue. + */ + function _pauseUbiquityDollarToken() internal { + ERC20Ubiquity dollar = ERC20Ubiquity(managerFacet.dollarTokenAddress()); + dollar.pause(); + } + + /** + * @notice Authorizes an upgrade to a new contract implementation. + * @param newImplementation The address of the new implementation contract. + * @dev This function is protected by the `onlyMonitorAdmin` modifier, meaning only an admin + * can authorize contract upgrades. This is an internal function that overrides UUPSUpgradeable's + * _authorizeUpgrade function. + */ + function _authorizeUpgrade( + address newImplementation + ) internal override onlyMonitorAdmin {} +} diff --git a/packages/contracts/src/dollar/libraries/Constants.sol b/packages/contracts/src/dollar/libraries/Constants.sol index 006e1e9c4..44f6355d6 100644 --- a/packages/contracts/src/dollar/libraries/Constants.sol +++ b/packages/contracts/src/dollar/libraries/Constants.sol @@ -68,6 +68,9 @@ bytes32 constant GOVERNANCE_TOKEN_MANAGER_ROLE = keccak256( "GOVERNANCE_TOKEN_MANAGER_ROLE" ); +/// @dev Role name for Governance token manager +bytes32 constant DEFENDER_RELAYER_ROLE = keccak256("DEFENDER_RELAYER_ROLE"); + /// @dev ETH pseudo address used to distinguish ERC20 tokens and ETH in `LibCollectableDust.sendDust()` address constant ETH_ADDRESS = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; diff --git a/packages/contracts/test/diamond/DiamondTestSetup.sol b/packages/contracts/test/diamond/DiamondTestSetup.sol index c15c8edfa..39362a306 100644 --- a/packages/contracts/test/diamond/DiamondTestSetup.sol +++ b/packages/contracts/test/diamond/DiamondTestSetup.sol @@ -1,6 +1,8 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.19; +import "forge-std/Test.sol"; + import {IERC165} from "@openzeppelin/contracts/interfaces/IERC165.sol"; import {Diamond, DiamondArgs} from "../../src/dollar/Diamond.sol"; import {ERC1155Ubiquity} from "../../src/dollar/core/ERC1155Ubiquity.sol"; diff --git a/packages/contracts/test/dollar/core/UbiquityPoolSecurityMonitorTest.sol b/packages/contracts/test/dollar/core/UbiquityPoolSecurityMonitorTest.sol new file mode 100644 index 000000000..3f95097cf --- /dev/null +++ b/packages/contracts/test/dollar/core/UbiquityPoolSecurityMonitorTest.sol @@ -0,0 +1,630 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import "forge-std/Test.sol"; +import "../../../src/dollar/core/UbiquityPoolSecurityMonitor.sol"; +import "../../helpers/LocalTestHelper.sol"; +import {DiamondTestSetup} from "../../../test/diamond/DiamondTestSetup.sol"; +import {DEFAULT_ADMIN_ROLE, PAUSER_ROLE} from "../../../src/dollar/libraries/Constants.sol"; +import {MockChainLinkFeed} from "../../../src/dollar/mocks/MockChainLinkFeed.sol"; +import {MockERC20} from "../../../src/dollar/mocks/MockERC20.sol"; +import {MockCurveStableSwapNG} from "../../../src/dollar/mocks/MockCurveStableSwapNG.sol"; +import {MockCurveTwocryptoOptimized} from "../../../src/dollar/mocks/MockCurveTwocryptoOptimized.sol"; +import {ERC20Ubiquity} from "../../../src/dollar/core/ERC20Ubiquity.sol"; + +contract UbiquityPoolSecurityMonitorTest is DiamondTestSetup { + UbiquityPoolSecurityMonitor monitor; + address defenderRelayer = address(0x456); + address unauthorized = address(0x123); + address newManagerFacet = address(0x457); + address newUbiquityPoolFacet = address(0x458); + address newAccessControlFacet = address(0x459); + + MockERC20 collateralToken; + MockERC20 collateralToken2; + MockERC20 collateralToken3; + + MockERC20 stableToken; + MockERC20 wethToken; + + // mock three ChainLink price feeds, one for each token + MockChainLinkFeed collateralTokenPriceFeed; + MockChainLinkFeed ethUsdPriceFeed; + MockChainLinkFeed stableUsdPriceFeed; + + // mock two curve pools Stablecoin/Dollar and Governance/WETH + MockCurveStableSwapNG curveDollarPlainPool; + MockCurveTwocryptoOptimized curveGovernanceEthPool; + + address user = address(1); + + event MonitorPaused(uint256 collateralLiquidity, uint256 diffPercentage); + event LiquidityVertexDropped(uint256 liquidityVertex); + event PausedToggled(bool paused); + event LiquidityVertexUpdated(uint256 collateralLiquidity); + + function setUp() public override { + super.setUp(); + + vm.startPrank(admin); + + collateralToken = new MockERC20("COLLATERAL", "CLT", 18); + collateralToken2 = new MockERC20("COLLATERAL-2", "CLT", 18); + collateralToken3 = new MockERC20("COLLATERAL-3", "CLT", 18); + + wethToken = new MockERC20("WETH", "WETH", 18); + stableToken = new MockERC20("STABLE", "STABLE", 18); + + collateralTokenPriceFeed = new MockChainLinkFeed(); + ethUsdPriceFeed = new MockChainLinkFeed(); + stableUsdPriceFeed = new MockChainLinkFeed(); + + curveDollarPlainPool = new MockCurveStableSwapNG( + address(stableToken), + address(dollarToken) + ); + + curveGovernanceEthPool = new MockCurveTwocryptoOptimized( + address(governanceToken), + address(wethToken) + ); + + // add collateral token to the pool + uint256 poolCeiling = 50_000e18; // max 50_000 of collateral tokens is allowed + ubiquityPoolFacet.addCollateralToken( + address(collateralToken), + address(collateralTokenPriceFeed), + poolCeiling + ); + ubiquityPoolFacet.addCollateralToken( + address(collateralToken2), + address(collateralTokenPriceFeed), + poolCeiling + ); + ubiquityPoolFacet.addCollateralToken( + address(collateralToken3), + address(collateralTokenPriceFeed), + poolCeiling + ); + + // set collateral price initial feed mock params + collateralTokenPriceFeed.updateMockParams( + 1, // round id + 100_000_000, // answer, 100_000_000 = $1.00 (chainlink 8 decimals answer is converted to 6 decimals pool price) + block.timestamp, // started at + block.timestamp, // updated at + 1 // answered in round + ); + + // set ETH/USD price initial feed mock params + ethUsdPriceFeed.updateMockParams( + 1, // round id + 2000_00000000, // answer, 2000_00000000 = $2000 (8 decimals) + block.timestamp, // started at + block.timestamp, // updated at + 1 // answered in round + ); + + // set stable/USD price feed initial mock params + stableUsdPriceFeed.updateMockParams( + 1, // round id + 100_000_000, // answer, 100_000_000 = $1.00 (8 decimals) + block.timestamp, // started at + block.timestamp, // updated at + 1 // answered in round + ); + + // set ETH/Governance initial price to 20k in Curve pool mock (20k GOV == 1 ETH) + curveGovernanceEthPool.updateMockParams(20_000e18); + + curveDollarPlainPool.updateMockParams(1.01e18); + + // set price feed for collateral token + ubiquityPoolFacet.setCollateralChainLinkPriceFeed( + address(collateralToken), // collateral token address + address(collateralTokenPriceFeed), // price feed address + 1 days // price feed staleness threshold in seconds + ); + ubiquityPoolFacet.setCollateralChainLinkPriceFeed( + address(collateralToken2), // collateral token address + address(collateralTokenPriceFeed), // price feed address + 1 days // price feed staleness threshold in seconds + ); + ubiquityPoolFacet.setCollateralChainLinkPriceFeed( + address(collateralToken3), // collateral token address + address(collateralTokenPriceFeed), // price feed address + 1 days // price feed staleness threshold in seconds + ); + + // set price feed for ETH/USD pair + ubiquityPoolFacet.setEthUsdChainLinkPriceFeed( + address(ethUsdPriceFeed), // price feed address + 1 days // price feed staleness threshold in seconds + ); + + // set price feed for stable/USD pair + ubiquityPoolFacet.setStableUsdChainLinkPriceFeed( + address(stableUsdPriceFeed), // price feed address + 1 days // price feed staleness threshold in seconds + ); + + // enable collateral at index 0,1,2 + ubiquityPoolFacet.toggleCollateral(0); + ubiquityPoolFacet.toggleCollateral(1); + ubiquityPoolFacet.toggleCollateral(2); + + // set mint and redeem initial fees + ubiquityPoolFacet.setFees( + 0, // collateral index + 10000, // 1% mint fee + 20000 // 2% redeem fee + ); + // set redemption delay to 2 blocks + ubiquityPoolFacet.setRedemptionDelayBlocks(2); + // set mint price threshold to $1.01 and redeem price to $0.99 + ubiquityPoolFacet.setPriceThresholds(1010000, 990000); + // set collateral ratio to 100% + ubiquityPoolFacet.setCollateralRatio(1_000_000); + // set Governance-ETH pool + ubiquityPoolFacet.setGovernanceEthPoolAddress( + address(curveGovernanceEthPool) + ); + + // set Curve plain pool in manager facet + managerFacet.setStableSwapPlainPoolAddress( + address(curveDollarPlainPool) + ); + + accessControlFacet.grantRole(DEFENDER_RELAYER_ROLE, defenderRelayer); + + // Initialize the UbiquityPoolSecurityMonitor contract + monitor = new UbiquityPoolSecurityMonitor(); + monitor.initialize( + address(accessControlFacet), + address(ubiquityPoolFacet), + address(managerFacet) + ); + + accessControlFacet.grantRole(DEFAULT_ADMIN_ROLE, address(monitor)); + accessControlFacet.grantRole(PAUSER_ROLE, address(monitor)); + + // stop being admin + vm.stopPrank(); + + // mint 2000 Governance tokens to the user + deal(address(governanceToken), user, 2000e18); + // mint 2000 collateral tokens to the user + collateralToken.mint(address(user), 2000e18); + collateralToken2.mint(address(user), 2000e18); + collateralToken3.mint(address(user), 2000e18); + + vm.startPrank(user); + // user approves the pool to transfer collaterals + collateralToken.approve(address(ubiquityPoolFacet), 100e18); + collateralToken2.approve(address(ubiquityPoolFacet), 100e18); + collateralToken3.approve(address(ubiquityPoolFacet), 100e18); + + ubiquityPoolFacet.mintDollar(0, 1e18, 0.9e18, 1e18, 0, true); + ubiquityPoolFacet.mintDollar(1, 1e18, 0.9e18, 1e18, 0, true); + ubiquityPoolFacet.mintDollar(2, 1e18, 0.9e18, 1e18, 0, true); + + vm.stopPrank(); + + vm.prank(defenderRelayer); + monitor.checkLiquidityVertex(); + } + + function testSetManagerFacet() public { + vm.prank(admin); + monitor.setManagerFacet(newManagerFacet); + } + + function testUnauthorizedSetManagerFacet() public { + vm.expectRevert("Ubiquity Pool Security Monitor: not admin"); + monitor.setManagerFacet(newManagerFacet); + } + + function testSetUbiquityPoolFacet() public { + vm.prank(admin); + monitor.setUbiquityPoolFacet(newUbiquityPoolFacet); + } + + function testUnauthorizedSetUbiquityPoolFacet() public { + vm.expectRevert("Ubiquity Pool Security Monitor: not admin"); + monitor.setUbiquityPoolFacet(newUbiquityPoolFacet); + } + + function testSetAccessControlFacet() public { + vm.prank(admin); + monitor.setAccessControlFacet(newAccessControlFacet); + } + + function testUnauthorizedSetAccessControlFacet() public { + vm.expectRevert("Ubiquity Pool Security Monitor: not admin"); + monitor.setAccessControlFacet(newAccessControlFacet); + } + + function testSetThresholdPercentage() public { + uint256 newThresholdPercentage = 20; + + vm.prank(admin); + monitor.setThresholdPercentage(newThresholdPercentage); + } + + function testUnauthorizedSetThresholdPercentage() public { + uint256 newThresholdPercentage = 30; + + vm.expectRevert("Ubiquity Pool Security Monitor: not admin"); + monitor.setThresholdPercentage(newThresholdPercentage); + } + + /** + * @notice Tests the dropLiquidityVertex function and ensures the correct event is emitted. + * @dev Simulates a call from an account with the DEFAULT_ADMIN_ROLE to drop the liquidity vertex. + * Verifies that the current collateral liquidity is set as the new liquidity vertex and the + * LiquidityVertexDropped event is emitted with the correct value. + */ + function testDropLiquidityVertex() public { + // Get the current collateral liquidity from the UbiquityPoolFacet + uint256 currentCollateralLiquidity = ubiquityPoolFacet + .collateralUsdBalance(); + + // Expect the LiquidityVertexDropped event to be emitted with the current collateral liquidity + vm.expectEmit(true, true, true, false); + emit LiquidityVertexDropped(currentCollateralLiquidity); + + // Simulate the admin account calling the dropLiquidityVertex function + vm.prank(admin); + monitor.dropLiquidityVertex(); + // The LiquidityVertexDropped event should be emitted with the correct liquidity value + } + + function testUnauthorizedDropLiquidityVertex() public { + vm.expectRevert("Ubiquity Pool Security Monitor: not admin"); + monitor.dropLiquidityVertex(); + } + + function testTogglePaused() public { + vm.expectEmit(true, true, true, false); + emit PausedToggled(true); + + vm.prank(admin); + monitor.togglePaused(); + } + + function testUnauthorizedTogglePaused() public { + vm.expectRevert("Ubiquity Pool Security Monitor: not admin"); + monitor.togglePaused(); + } + + /** + * @notice Tests the update of liquidity vertex after the collateral liquidity is increased via minting. + * @dev Simulates a user increasing liquidity by calling mintDollar and then checks if the liquidity vertex + * is updated correctly when the defender relayer calls checkLiquidityVertex. + */ + function testCheckLiquidity() public { + // Simulate the user minting dollars to increase the collateral liquidity + vm.prank(user); + ubiquityPoolFacet.mintDollar(1, 1e18, 0.9e18, 1e18, 0, true); + + // Fetch the updated collateral liquidity after minting + uint256 currentCollateralLiquidity = ubiquityPoolFacet + .collateralUsdBalance(); + + // Expect the LiquidityVertexUpdated event to be emitted with the new liquidity value + vm.expectEmit(true, true, true, false); + emit LiquidityVertexUpdated(currentCollateralLiquidity); + + // Simulate the defender relayer calling checkLiquidityVertex to update the liquidity vertex + vm.prank(defenderRelayer); + monitor.checkLiquidityVertex(); + } + + function testUnauthorizedCheckLiquidity() public { + vm.prank(unauthorized); + vm.expectRevert("Ubiquity Pool Security Monitor: not defender relayer"); + + monitor.checkLiquidityVertex(); + } + + /** + * @notice Tests that the `MonitorPaused` event is emitted when the liquidity drops below the configured threshold. + * @dev Simulates a scenario where the collateral liquidity drops below the threshold by redeeming dollars, + * and checks if the monitor pauses and emits the `MonitorPaused` event. + */ + function testMonitorPausedEventEmittedAfterLiquidityDropBelowThreshold() + public + { + curveDollarPlainPool.updateMockParams(0.99e18); + + vm.prank(user); + ubiquityPoolFacet.redeemDollar(0, 1e18, 0, 0); + + // Fetch the current collateral liquidity after redemption + uint256 currentCollateralLiquidity = ubiquityPoolFacet + .collateralUsdBalance(); + + // Expect the MonitorPaused event to be emitted with the current liquidity and percentage drop + vm.expectEmit(true, true, true, false); + emit MonitorPaused(currentCollateralLiquidity, 32); // 32 represents the percentage difference + + // Simulate the defender relayer calling checkLiquidityVertex to trigger the liquidity check + vm.prank(defenderRelayer); + monitor.checkLiquidityVertex(); + } + + /** + * @notice Tests that the `checkLiquidityVertex` function reverts with "Monitor paused" after the monitor is paused due to liquidity dropping below the threshold. + * @dev Simulates a drop in collateral liquidity by redeeming dollars and ensures that once the liquidity drop exceeds the threshold, + * the monitor is paused and subsequent calls to `checkLiquidityVertex` revert with the message "Monitor paused". + */ + function testMonitorPausedRevertAfterLiquidityDropBelowThreshold() public { + curveDollarPlainPool.updateMockParams(0.99e18); + + // Simulate a user redeeming dollars, leading to a decrease in collateral liquidity + vm.prank(user); + ubiquityPoolFacet.redeemDollar(0, 1e18, 0, 0); + + // Simulate the defender relayer calling checkLiquidityVertex to trigger the monitor pause + vm.prank(defenderRelayer); + monitor.checkLiquidityVertex(); + + // Expect a revert with "Monitor paused" message when trying to check liquidity again + vm.expectRevert("Monitor paused"); + vm.prank(defenderRelayer); + monitor.checkLiquidityVertex(); + } + + /** + * @notice Tests that both the monitor and the Ubiquity Dollar are paused after a liquidity drop below the threshold. + * @dev Simulates a collateral price drop and ensures that: + * - The monitor is paused after the liquidity drop. + * - The collateral information is no longer valid and reverts with "Invalid collateral". + * - The Ubiquity Dollar token is paused after the liquidity drop. + */ + function testMonitorAndDollarPauseAfterLiquidityDropBelowThreshold() + public + { + curveDollarPlainPool.updateMockParams(0.99e18); + + // Simulate a user redeeming dollars, leading to a decrease in collateral liquidity + vm.prank(user); + ubiquityPoolFacet.redeemDollar(0, 1e18, 0, 0); + + // Simulate the defender relayer calling checkLiquidityVertex to trigger the monitor pause + vm.prank(defenderRelayer); + monitor.checkLiquidityVertex(); + + // Expect a revert with "Invalid collateral" when trying to retrieve collateral information + vm.expectRevert("Invalid collateral"); + ubiquityPoolFacet.collateralInformation(address(collateralToken)); + + // Assert that the monitor is paused + bool monitorPaused = monitor.monitorPaused(); + assertTrue( + monitorPaused, + "Monitor should be paused after liquidity drop" + ); + + // Assert that the Ubiquity Dollar token is paused + ERC20Ubiquity dollarToken = ERC20Ubiquity( + managerFacet.dollarTokenAddress() + ); + bool dollarIsPaused = dollarToken.paused(); + assertTrue( + dollarIsPaused, + "Dollar should be paused after liquidity drop" + ); + } + + /** + * @notice Tests that the monitor is not paused when the liquidity drop does not exceed the configured threshold. + * @dev Simulates a small collateral liquidity drop by redeeming dollars and ensures that: + * - The monitor does not pause if the liquidity drop remains above the threshold. + * - Collateral information remains valid and accessible after the liquidity drop. + */ + function testLiquidityDropDoesNotPauseMonitor() public { + curveDollarPlainPool.updateMockParams(0.99e18); + + // Simulate a user redeeming a small amount of dollars, causing a minor decrease in collateral liquidity + vm.prank(user); + ubiquityPoolFacet.redeemDollar(0, 1e17, 0, 0); + + // Simulate the defender relayer calling checkLiquidityVertex to verify the monitor status + vm.prank(defenderRelayer); + monitor.checkLiquidityVertex(); + + // Ensure collateral information remains valid after the minor liquidity drop + ubiquityPoolFacet.collateralInformation(address(collateralToken)); + + // Assert that the monitor is not paused after the liquidity drop + bool monitorPaused = monitor.monitorPaused(); + assertFalse( + monitorPaused, + "Monitor should Not be paused after liquidity drop" + ); + } + + /** + * @notice Tests that the monitor is paused after a significant liquidity drop, even when collateral was toggled before. + * @dev Simulates a scenario where collateral liquidity drops below the threshold and collateral is toggled prior to the incident. + * Ensures that: + * - The monitor pauses after the liquidity drop. + * - Any collateral that was toggled prior to the liquidity check does not interfere with the monitor's behavior. + * - Collateral information becomes inaccessible after the monitor is paused. + */ + function testLiquidityDropPausesMonitorWhenCollateralToggled() public { + curveDollarPlainPool.updateMockParams(0.99e18); + + // Simulate a user redeeming dollars, causing a significant liquidity drop + vm.prank(user); + ubiquityPoolFacet.redeemDollar(0, 1e18, 0, 0); + + // Simulate the admin toggling the collateral state + vm.prank(admin); + ubiquityPoolFacet.toggleCollateral(0); + + // Simulate the defender relayer calling checkLiquidityVertex to trigger the monitor pause + vm.prank(defenderRelayer); + monitor.checkLiquidityVertex(); + + // Assert that the monitor is paused after the liquidity drop + bool monitorPaused = monitor.monitorPaused(); + assertTrue( + monitorPaused, + "Monitor should be paused after liquidity drop, and any prior manipulation of collateral does not interfere with the ongoing incident management process." + ); + + // Ensure that collateral information is inaccessible after the monitor is paused + address[] memory allCollaterals = ubiquityPoolFacet.allCollaterals(); + for (uint256 i = 0; i < allCollaterals.length; i++) { + vm.expectRevert("Invalid collateral"); + + // Simulate the user trying to access collateral information, expecting a revert + vm.prank(user); + ubiquityPoolFacet.collateralInformation(allCollaterals[i]); + } + } + + /** + * @notice Tests that the monitor is not paused when the liquidity drop does not exceed the threshold, even if collateral is toggled. + * @dev Simulates a scenario where collateral liquidity drops but not enough to trigger the monitor pause. + * Ensures that: + * - The monitor remains active after a minor liquidity drop. + * - Any collateral that was toggled prior to the liquidity check does not affect the monitor’s behavior. + */ + function testLiquidityDropDoesNotPauseMonitorWhenCollateralToggled() + public + { + curveDollarPlainPool.updateMockParams(0.99e18); + + // Simulate a user redeeming a small amount of dollars, causing a minor liquidity drop + vm.prank(user); + ubiquityPoolFacet.redeemDollar(0, 1e17, 0, 0); + + // Simulate the admin toggling the collateral state + vm.prank(admin); + ubiquityPoolFacet.toggleCollateral(0); + + // Simulate the defender relayer calling checkLiquidityVertex to verify the monitor status + vm.prank(defenderRelayer); + monitor.checkLiquidityVertex(); + + // Assert that the monitor is not paused after the minor liquidity drop + bool monitorPaused = monitor.monitorPaused(); + assertFalse( + monitorPaused, + "Monitor should Not be paused after liquidity drop, and any prior manipulation of collateral does not affect it" + ); + } + + /** + * @notice Tests that `checkLiquidityVertex` reverts with "Monitor paused" when the monitor is manually paused. + * @dev Simulates pausing the monitor and ensures that any subsequent calls to `checkLiquidityVertex` revert with the appropriate error message. + */ + function testCheckLiquidityRevertsWhenMonitorIsPaused() public { + // Expect the PausedToggled event to be emitted when the monitor is paused + vm.expectEmit(true, true, true, false); + emit PausedToggled(true); + + // Simulate the admin manually pausing the monitor + vm.prank(admin); + monitor.togglePaused(); + + // Expect a revert with "Monitor paused" when checkLiquidityVertex is called while the monitor is paused + vm.expectRevert("Monitor paused"); + + // Simulate the defender relayer attempting to check liquidity while the monitor is paused + vm.prank(defenderRelayer); + monitor.checkLiquidityVertex(); + } + + /** + * @notice Tests that the `mintDollar` function reverts with "Collateral disabled" due to a liquidity drop. + * @dev Simulates a liquidity drop below the threshold and ensures that collateral is disabled, causing subsequent attempts to mint dollars to fail. + */ + function testMintDollarRevertsWhenCollateralDisabledDueToLiquidityDrop() + public + { + curveDollarPlainPool.updateMockParams(0.99e18); + + // Simulate a user redeeming dollars, causing a significant liquidity drop + vm.prank(user); + ubiquityPoolFacet.redeemDollar(0, 1e18, 0, 0); + + // Simulate the defender relayer calling checkLiquidityVertex to trigger the monitor pause and disable collateral + vm.prank(defenderRelayer); + monitor.checkLiquidityVertex(); + + // Attempt to mint dollars for each collateral, expecting a revert with "Collateral disabled" + uint256 collateralCount = 3; + for (uint256 i = 0; i < collateralCount; i++) { + vm.expectRevert("Collateral disabled"); + + vm.prank(user); + ubiquityPoolFacet.mintDollar(i, 1e18, 0.9e18, 1e18, 0, true); + } + } + + /** + * @notice Tests that the `redeemDollar` function reverts with "Collateral disabled" due to a liquidity drop. + * @dev Simulates a liquidity drop below the threshold and ensures that collateral is disabled, causing subsequent attempts to redeem dollars to fail. + */ + function testRedeemDollarRevertsWhenCollateralDisabledDueToLiquidityDrop() + public + { + curveDollarPlainPool.updateMockParams(0.99e18); + + // Simulate a user redeeming dollars, causing a significant liquidity drop + vm.prank(user); + ubiquityPoolFacet.redeemDollar(0, 1e18, 0, 0); + + // Simulate the defender relayer calling checkLiquidityVertex to trigger the monitor pause and disable collateral + vm.prank(defenderRelayer); + monitor.checkLiquidityVertex(); + + // Expect a revert with "Collateral disabled" when trying to redeem dollars after the collateral is disabled + vm.expectRevert("Collateral disabled"); + + // Simulate the user attempting to redeem dollars, which should revert due to disabled collateral + vm.prank(user); + ubiquityPoolFacet.redeemDollar(1, 1e18, 0, 0); + } + + /** + * @notice Tests that the Ubiquity Dollar token reverts transfers with "Pausable: paused" when the token is paused due to a liquidity drop. + * @dev Simulates a liquidity drop below the threshold and ensures that the Ubiquity Dollar token is paused, preventing transfers. + */ + function testDollarTokenRevertsOnTransferWhenPausedDueToLiquidityDrop() + public + { + curveDollarPlainPool.updateMockParams(0.99e18); + + // Simulate a user redeeming dollars, causing a significant liquidity drop + vm.prank(user); + ubiquityPoolFacet.redeemDollar(0, 1e18, 0, 0); + + // Simulate the defender relayer calling checkLiquidityVertex to trigger the monitor pause and pause the dollar token + vm.prank(defenderRelayer); + monitor.checkLiquidityVertex(); + + // Assert that the Dollar token is paused after the liquidity drop + bool isPaused = dollarToken.paused(); + assertTrue( + isPaused, + "Expected the Dollar token to be paused after the liquidity drop" + ); + + // Get the Ubiquity Dollar token contract + ERC20Ubiquity dollarToken = ERC20Ubiquity( + managerFacet.dollarTokenAddress() + ); + + // Expect a revert with "Pausable: paused" when trying to transfer the paused token + vm.expectRevert("Pausable: paused"); + + // Simulate the user attempting to transfer the paused dollar token, which should revert + vm.prank(user); + dollarToken.transfer(address(0x123), 1e18); + } +} diff --git a/utils/ubiquity-pool-security-monitor-workflow.png b/utils/ubiquity-pool-security-monitor-workflow.png new file mode 100644 index 000000000..c46003db0 Binary files /dev/null and b/utils/ubiquity-pool-security-monitor-workflow.png differ