diff --git a/README.md b/README.md index 996d686..6e11f00 100644 --- a/README.md +++ b/README.md @@ -142,14 +142,14 @@ bespoke spell in record time. ## Implemented Actions -| Description | Single ilk | Multi ilk | -| :----------------- | :----------------: | :----------------: | -| Wipe `line` | :white_check_mark: | :white_check_mark: | -| Set `Clip` breaker | :white_check_mark: | :white_check_mark: | -| Disable `DDM` | :white_check_mark: | :x: | -| Stop `OSM` | :white_check_mark: | :white_check_mark: | -| Halt `PSM` | :white_check_mark: | :x: | -| Stop `Splitter` | :x: | :white_check_mark: | +| Description | Single-ilk | Grouped | Multi-ilk / Global | +| :---------- | :--------: | :-----: | :----------------: | +| Wipe `line` | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| Set `Clip` breaker | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| Disable `DDM` | :white_check_mark: | :x: | :x: | +| Stop `OSM` | :white_check_mark: | :x: | :white_check_mark: | +| Halt `LitePSM` | :white_check_mark: | :x: | :x: | +| Stop `Splitter` | :x: | :x: | :white_check_mark: | ### Wipe `line` @@ -222,14 +222,27 @@ constructor. [spell-tag]: https://github.com/makerdao/dss-exec-lib/blob/69b658f35d8618272cd139dfc18c5713caf6b96b/src/DssExec.sol#L75 -Some types of emergency spells may come in 2 flavors: +Some types of emergency spells may come in 3 flavors: -1. Single ilk: applies the desired spell action for a single pre-defined ilk. -1. Multi ilk: applies the desired spell action for all applicable ilks. +1. Single-ilk: applies the desired spell action to a single pre-defined ilk. +1. Grouped: applies the desired spell action to a list of related ilks (i.e.: `ETH-A`, `ETH-B` and `ETH-C`) +1. Multi: applies the desired spell action to all applicable ilks. Furthermore, this repo provides on-chain factories for single ilk emergency spells to make it easier to deploy for new ilks. + +### About storage variables in `DssGroupedEmergencySpell` + +Regular spell actions are executed through a `delegatecall` from `MCD_PAUSE_PROXY`. For that reason, they usually should +not have storage variables, as they would be accessing and interacting with `MCD_PAUSE_PROXY`'s storage, not their own. + +However, Emergency Spells are not required to interact with `MCD_PAUSE` and `MCD_PAUSE_PROXY` at all. They execute +actions through regular `call` on `Mom` contracts, so we do not have this limitation. + +Even if the contract is somehow misused and used as a regular spell, interacting with `MCD_PAUSE`, there would not be a +problem because the storage should not be changed outside the constructor by the concrete implementations. + ### About the `done()` function Conforming spells have a [`done`][spell-done] public storage variable which is `false` when the spell is deployed and @@ -240,7 +253,7 @@ storage variable, it becomes a getter function that will return: - `false`: if the emergency spell can be scheduled in the current state, given it is lifted to the hat. - `true`: if the desired effects of the spell can be verified or if there is anything that would prevent the spell from - being scheduled (i.e.: bad system config) + being scheduled (i.e.: bad system config). Generally speaking, `done` should almost always return `false` for any emergency spell. If it returns `true` it means it has just been scheduled or there is most likely something wrong with the modules touched by it. The exception is the diff --git a/src/DssEmergencySpell.sol b/src/DssEmergencySpell.sol index 5a316d1..de8edc1 100644 --- a/src/DssEmergencySpell.sol +++ b/src/DssEmergencySpell.sol @@ -52,7 +52,7 @@ abstract contract DssEmergencySpell is DssEmergencySpellLike { /// @dev The chainlog contract reference. ChainlogLike internal constant _log = ChainlogLike(0xdA0Ab1e0017DEbCd72Be8599041a2aa3bA7e740F); - // @dev The reference to the `pause` contract. + /// @dev The reference to the `pause` contract. address public immutable pause = _log.getAddress("MCD_PAUSE"); /// @dev The chainlog address. address public constant log = address(_log); @@ -63,72 +63,56 @@ abstract contract DssEmergencySpell is DssEmergencySpellLike { bytes public constant sig = abi.encodeWithSelector(DssAction.execute.selector); /// @dev Emergency spells should not expire. uint256 public constant expiration = type(uint256).max; - // @dev An emergency spell does not need to be cast, as all actions happen during the schedule phase. - // Notice that cast is usually not supposed to revert, so it is implemented as a no-op. + /// @dev An emergency spell does not need to be cast, as all actions happen during the schedule phase. + /// Notice that cast is usually not supposed to revert, so it is implemented as a no-op. uint256 internal immutable _nextCastTime = type(uint256).max; - // @dev Office Hours is always `false` for emergency spells. + /// @dev Office Hours is always `false` for emergency spells. bool public constant officeHours = false; - // @dev `action` is expected to return a valid address. - // We also implement the `DssAction` interface in this contract. + /// @dev `action` is expected to return a valid address. + /// We also implement the `DssAction` interface in this contract. address public immutable action = address(this); - /** - * @dev In regular spells, `tag` is an immutable variable with the code hash of the spell action. - * It specifically uses a separate contract for spell action because `tag` is immutable and the code hash of - * the contract being initialized is not accessible in the constructor. - * Since we do not have a separate contract for actions in Emergency Spells, `tag` has to be turned into a - * getter function instead of an immutable variable. - * @return The contract codehash. - */ + /// @dev In regular spells, `tag` is an immutable variable with the code hash of the spell action. + /// It specifically uses a separate contract for spell action because `tag` is immutable and the code hash of + /// the contract being initialized is not accessible in the constructor. + /// Since we do not have a separate contract for actions in Emergency Spells, `tag` has to be turned into a + /// getter function instead of an immutable variable. + /// @return The contract codehash. function tag() external view returns (bytes32) { return address(this).codehash; } - /** - * @notice Triggers the emergency actions of the spell. - * @dev Emergency spells are triggered when scheduled. - * This function maintains the name for compatibility with regular spells, however nothing is actually being - * scheduled. Emergency spells take effect immediately, so there is no need to call `pause.plot()`. - */ + /// @notice Triggers the emergency actions of the spell. + /// @dev Emergency spells are triggered when scheduled. + /// This function maintains the name for compatibility with regular spells, however nothing is actually being + /// scheduled. Emergency spells take effect immediately, so there is no need to call `pause.plot()`. function schedule() external { _emergencyActions(); } - /** - * @notice Implements the emergency actions to be triggered by the spell. - */ + /// @notice Implements the emergency actions to be triggered by the spell. function _emergencyActions() internal virtual; - /** - * @notice Returns `_nextCastTime`. - * @dev This function exists only to keep interface compatibility with regular spells. - */ + /// @notice Returns `_nextCastTime`. + /// @dev This function exists only to keep interface compatibility with regular spells. function nextCastTime() external view returns (uint256 castTime) { return _nextCastTime; } - /** - * @notice No-op. - * @dev This function exists only to keep interface compatibility with regular spells. - */ + /// @notice No-op. + /// @dev This function exists only to keep interface compatibility with regular spells. function cast() external {} - /** - * @notice No-op. - * @dev This function exists only to keep interface compatibility with regular spells. - */ + /// @notice No-op. + /// @dev This function exists only to keep interface compatibility with regular spells. function execute() external {} - /** - * @notice No-op. - * @dev This function exists only to keep interface compatibility with regular spells. - */ + /// @notice No-op. + /// @dev This function exists only to keep interface compatibility with regular spells. function actions() external {} - /** - * @notice Returns `nextCastTime`, regardless of the input parameter. - * @dev This function exists only to keep interface compatibility with regular spells. - */ + /// @notice Returns `nextCastTime`, regardless of the input parameter. + /// @dev This function exists only to keep interface compatibility with regular spells. function nextCastTime(uint256) external view returns (uint256 castTime) { return _nextCastTime; } diff --git a/src/DssGroupedEmergencySpell.sol b/src/DssGroupedEmergencySpell.sol new file mode 100644 index 0000000..462cbb0 --- /dev/null +++ b/src/DssGroupedEmergencySpell.sol @@ -0,0 +1,131 @@ +// SPDX-FileCopyrightText: © 2024 Dai Foundation +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +pragma solidity ^0.8.16; + +import {DssEmergencySpell, DssEmergencySpellLike} from "./DssEmergencySpell.sol"; + +interface DssGroupedEmergencySpellLike is DssEmergencySpellLike { + function ilks() external view returns (bytes32[] memory); + function emergencyActionsInBatch(uint256 start, uint256 end) external; +} + +/// @title Grouped Emergency Spell +/// @notice Defines the base implementation for grouped emergency spells. +/// @custom:authors [amusingaxl] +/// @custom:reviewers [] +/// @custom:auditors [] +/// @custom:bounties [] +abstract contract DssGroupedEmergencySpell is DssEmergencySpell, DssGroupedEmergencySpellLike { + /// @dev The min size for the list of ilks + uint256 private constant MIN_ILKS = 1; + + /// @notice The list of ilks to which the spell is applicable. + /// @dev While spells should not have storage variables, we can make an exception here because this spell should not + /// change its own storage, and therefore, could not overwrite the PauseProxy state through delegate call even + /// if used incorrectly. + bytes32[] private ilkList; + + /// @param _ilks The list of ilks for which the spell should be applicable + /// @dev The list size must be at least 1. + /// The grouped spell is meant to be used for ilks that are a variation of the same collateral gem + /// (i.e.: ETH-A, ETH-B, ETH-C) + /// There has never been a case where MCD onboarded 4 or more ilks for the same collateral gem. + /// For cases where there is only one ilk for the same collateral gem, use the single-ilk version. + constructor(bytes32[] memory _ilks) { + // This is a workaround to Solidity's lack of support for immutable arrays, as described in + // https://github.com/ethereum/solidity/issues/12587 + uint256 len = _ilks.length; + require(len >= MIN_ILKS, "DssGroupedEmergencySpell/too-few-ilks"); + + ilkList = _ilks; + } + + /// @notice Returns the list of ilks to which the spell is applicable. + function ilks() external view returns (bytes32[] memory) { + return ilkList; + } + + /// @notice Returns the spell description. + function description() external view returns (string memory) { + // Join the list of ilks into a comma-separated string + string memory buf = _bytes32ToString(ilkList[0]); + // Start from one because the first item was already added. + for (uint256 i = 1; i < ilkList.length; i++) { + buf = string.concat(buf, ", ", _bytes32ToString(ilkList[i])); + } + + return string.concat(_descriptionPrefix(), " ", buf); + } + + /// @notice Converts a bytes32 value into a string. + function _bytes32ToString(bytes32 src) internal pure returns (string memory res) { + uint256 len = 0; + while (src[len] != 0 && len < 32) { + len++; + } + assembly { + res := mload(0x40) + // new "memory end" including padding (the string isn't larger than 32 bytes) + mstore(0x40, add(res, 0x40)) + // store len in memory + mstore(res, len) + // write actual data + mstore(add(res, 0x20), src) + } + } + + /// @dev Returns the description prefix to compose the final description. + function _descriptionPrefix() internal view virtual returns (string memory); + + /// @inheritdoc DssEmergencySpell + function _emergencyActions() internal override { + for (uint256 i = 0; i < ilkList.length; i++) { + _emergencyActions(ilkList[i]); + } + } + + /// @notice Executes the emergency actions for all ilks in the batch. + /// @dev This is an escape hatch to prevent the spell from being blocked in case it would hit the block gas limit. + /// In case `end` is greater than the ilk list length, the iteration will be automatically capped. + /// @param start The index to start the iteration (inclusive). + /// @param end The index to stop the iteration (inclusive). + function emergencyActionsInBatch(uint256 start, uint256 end) external { + end = end > ilkList.length - 1 ? ilkList.length - 1 : end; + require(start <= end, "DssGroupedEmergencySpell/bad-iteration"); + + for (uint256 i = start; i <= end; i++) { + _emergencyActions(ilkList[i]); + } + } + + /// @notice Executes the emergency actions for the specified ilk. + /// @param _ilk The ilk to set the related Clip breaker. + function _emergencyActions(bytes32 _ilk) internal virtual; + + /// @notice Returns whether the spell is done for all ilks or not. + /// @return res Whether the spells is done or not. + function done() external view returns (bool res) { + for (uint256 i = 0; i < ilkList.length; i++) { + if (!_done(ilkList[i])) { + return false; + } + } + return true; + } + + /// @notice Returns whether the spell is done or not for the specified ilk. + function _done(bytes32 _ilk) internal view virtual returns (bool); +} diff --git a/src/DssGroupedEmergencySpell.t.integration.sol b/src/DssGroupedEmergencySpell.t.integration.sol new file mode 100644 index 0000000..86a352b --- /dev/null +++ b/src/DssGroupedEmergencySpell.t.integration.sol @@ -0,0 +1,172 @@ +// SPDX-FileCopyrightText: © 2024 Dai Foundation +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +pragma solidity ^0.8.16; + +import {DssTest, DssInstance, MCD} from "dss-test/DssTest.sol"; +import {DssGroupedEmergencySpell} from "./DssGroupedEmergencySpell.sol"; + +contract DssGroupedEmergencySpellImpl is DssGroupedEmergencySpell { + mapping(bytes32 => bool) public isDone; + + function setDone(bytes32 ilk, bool val) external { + isDone[ilk] = val; + } + + function _descriptionPrefix() internal pure override returns (string memory) { + return "Grouped Emergency Spell:"; + } + + event EmergencyAction(bytes32 indexed ilk); + + constructor(bytes32[] memory _ilks) DssGroupedEmergencySpell(_ilks) {} + + function _emergencyActions(bytes32 ilk) internal override { + emit EmergencyAction(ilk); + isDone[ilk] = true; + } + + function _done(bytes32 ilk) internal view override returns (bool) { + return isDone[ilk]; + } +} + +contract DssGroupedEmergencySpellTest is DssTest { + address constant CHAINLOG = 0xdA0Ab1e0017DEbCd72Be8599041a2aa3bA7e740F; + DssInstance dss; + DssGroupedEmergencySpellImpl spell2; + DssGroupedEmergencySpellImpl spell3; + DssGroupedEmergencySpellImpl spellN; + address pause; + + function setUp() public { + vm.createSelectFork("mainnet"); + + dss = MCD.loadFromChainlog(CHAINLOG); + MCD.giveAdminAccess(dss); + pause = dss.chainlog.getAddress("MCD_PAUSE"); + + bytes32[] memory ilks2 = new bytes32[](2); + ilks2[0] = "WSTETH-A"; + ilks2[1] = "WSTETH-B"; + spell2 = new DssGroupedEmergencySpellImpl(ilks2); + bytes32[] memory ilks3 = new bytes32[](3); + ilks3[0] = "ETH-A"; + ilks3[1] = "ETH-B"; + ilks3[2] = "ETH-C"; + spell3 = new DssGroupedEmergencySpellImpl(ilks3); + bytes32[] memory ilksN = new bytes32[](8); + ilksN[0] = "ETH-A"; + ilksN[1] = "ETH-B"; + ilksN[2] = "ETH-C"; + ilksN[3] = "WSTETH-A"; + ilksN[4] = "WSTETH-B"; + ilksN[5] = "WBTC-A"; + ilksN[6] = "WBTC-B"; + ilksN[7] = "WBTC-C"; + spellN = new DssGroupedEmergencySpellImpl(ilksN); + } + + function testDescription() public view { + assertEq(spell2.description(), "Grouped Emergency Spell: WSTETH-A, WSTETH-B"); + assertEq(spell3.description(), "Grouped Emergency Spell: ETH-A, ETH-B, ETH-C"); + } + + function testEmergencyActions() public { + vm.expectEmit(true, true, true, true); + emit EmergencyAction("WSTETH-A"); + vm.expectEmit(true, true, true, true); + emit EmergencyAction("WSTETH-B"); + spell2.schedule(); + + vm.expectEmit(true, true, true, true); + emit EmergencyAction("ETH-A"); + vm.expectEmit(true, true, true, true); + emit EmergencyAction("ETH-B"); + vm.expectEmit(true, true, true, true); + emit EmergencyAction("ETH-C"); + spell3.schedule(); + + vm.expectEmit(true, true, true, true); + emit EmergencyAction("ETH-A"); + vm.expectEmit(true, true, true, true); + emit EmergencyAction("ETH-B"); + vm.expectEmit(true, true, true, true); + emit EmergencyAction("ETH-C"); + vm.expectEmit(true, true, true, true); + emit EmergencyAction("WSTETH-A"); + vm.expectEmit(true, true, true, true); + emit EmergencyAction("WSTETH-B"); + vm.expectEmit(true, true, true, true); + emit EmergencyAction("WBTC-A"); + vm.expectEmit(true, true, true, true); + emit EmergencyAction("WBTC-B"); + vm.expectEmit(true, true, true, true); + emit EmergencyAction("WBTC-C"); + spellN.schedule(); + } + + function testEmergencyActionsInBatches_Fuzz(uint256 batchSize) public { + uint256 count = spellN.ilks().length; + batchSize = bound(batchSize, 1, count); + uint256 start = 0; + // End is inclusive, so we need to subtract 1 + uint256 end = start + batchSize - 1; + + assertFalse(spellN.done(), "spellN unexpectedly done"); + + while (start < count) { + spellN.emergencyActionsInBatch(start, end); + + start += batchSize; + end += batchSize; + } + + assertTrue(spellN.done(), "spellN not done"); + } + + function testDone() public { + assertFalse(spell2.done(), "spell2 unexpectedly done"); + assertFalse(spell3.done(), "spell3 unexpectedly done"); + + { + // Tweak spell2 so it is considered done for WSTETH-A... + spell2.setDone("WSTETH-A", true); + // ... in this case it should still return false + assertFalse(spell2.done(), "spell2 unexpectedly done"); + // Then set done for WSTETH-B... + spell2.setDone("WSTETH-B", true); + // ... new the spell must finally return true + assertTrue(spell2.done(), "spell2 not done"); + } + + { + // Tweak spell3 so it is considered done for ETH-A... + spell3.setDone("ETH-A", true); + // ... in this case it should still return false + assertFalse(spell3.done(), "spell3 unexpectedly done"); + // Then set done for ETH-B... + spell3.setDone("ETH-B", true); + // ... it should still return false + assertFalse(spell3.done(), "spell3 unexpectedly done"); + // Then set done for ETH-C... + spell3.setDone("ETH-C", true); + // ... now the spell must finally return true + assertTrue(spell3.done(), "spell3 not done"); + } + } + + event EmergencyAction(bytes32 indexed ilk); +} diff --git a/src/clip-breaker/GroupedClipBreakerSpell.sol b/src/clip-breaker/GroupedClipBreakerSpell.sol new file mode 100644 index 0000000..b1de355 --- /dev/null +++ b/src/clip-breaker/GroupedClipBreakerSpell.sol @@ -0,0 +1,118 @@ +// SPDX-FileCopyrightText: © 2024 Dai Foundation +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +pragma solidity ^0.8.16; + +import {DssGroupedEmergencySpell} from "../DssGroupedEmergencySpell.sol"; + +interface ClipperMomLike { + function setBreaker(address clip, uint256 level, uint256 delay) external; +} + +interface ClipLike { + function stopped() external view returns (uint256); + function wards(address who) external view returns (uint256); +} + +interface IlkRegistryLike { + function xlip(bytes32 ilk) external view returns (address); +} + +/// @title Emergency Spell: Grouped Clip Breaker +/// @notice Prevents further collateral auctions to be held in the respective Clip contracts. +/// @custom:authors [amusingaxl] +/// @custom:reviewers [] +/// @custom:auditors [] +/// @custom:bounties [] +contract GroupedClipBreakerSpell is DssGroupedEmergencySpell { + /// @notice The ClipperMom from chainlog. + ClipperMomLike public immutable clipperMom = ClipperMomLike(_log.getAddress("CLIPPER_MOM")); + /// @notice The IlkRegistry from chainlog. + IlkRegistryLike public immutable ilkReg = IlkRegistryLike(_log.getAddress("ILK_REGISTRY")); + + /// @dev During an emergency, set the breaker level to 3 to prevent `kick()`, `redo()` and `take()`. + uint256 internal constant BREAKER_LEVEL = 3; + /// @dev The delay is not applicable for level 3 breakers, so we set it to zero. + uint256 internal constant BREAKER_DELAY = 0; + + /// @notice Emitted when the spell is scheduled. + /// @param ilk The ilk for which the Clip breaker was set. + /// @param clip The address of the Clip contract. + event SetBreaker(bytes32 indexed ilk, address indexed clip); + + /// @param _ilks The list of ilks for which the spell should be applicable + /// @dev The list size is be at least 1. + /// The grouped spell is meant to be used for ilks that are a variation of the same collateral gem + /// (i.e.: ETH-A, ETH-B, ETH-C) + constructor(bytes32[] memory _ilks) DssGroupedEmergencySpell(_ilks) {} + + /// @inheritdoc DssGroupedEmergencySpell + function _descriptionPrefix() internal pure override returns (string memory) { + return "Emergency Spell | Grouped Clip Breaker:"; + } + + /// @notice Sets the breaker for the related Clip contract. + /// @inheritdoc DssGroupedEmergencySpell + function _emergencyActions(bytes32 _ilk) internal override { + address clip = ilkReg.xlip(_ilk); + clipperMom.setBreaker(clip, BREAKER_LEVEL, BREAKER_DELAY); + emit SetBreaker(_ilk, clip); + } + + /// @notice Returns whether the spell is done or not for the specified ilk. + function _done(bytes32 _ilk) internal view override returns (bool) { + address clip = ilkReg.xlip(_ilk); + if (clip == address(0)) { + return true; + } + + try ClipLike(clip).wards(address(clipperMom)) returns (uint256 ward) { + // Ignore Clip instances that have not relied on ClipperMom. + if (ward == 0) { + return true; + } + } catch { + // If the call failed, it means the contract is most likely not a Clip instance. + return true; + } + + try ClipLike(clip).stopped() returns (uint256 stopped) { + return stopped == BREAKER_LEVEL; + } catch { + // If the call failed, it means the contract is most likely not a Clip instance. + return true; + } + } +} + +/// @title Emergency Spell Factory: Grouped Clip Breaker +/// @notice On-chain factory to deploy Grouped Clip Breaker emergency spells. +/// @custom:authors [amusingaxl] +/// @custom:reviewers [] +/// @custom:auditors [] +/// @custom:bounties [] +contract GroupedClipBreakerFactory { + /// @notice A new GroupedClipBreakerSpell has been deployed. + /// @param ilks The list of ilks for which the spell is applicable. + /// @param spell The deployed spell address. + event Deploy(bytes32[] indexed ilks, address spell); + + /// @notice Deploys a GroupedClipBreakerSpell contract. + /// @param ilks The list of ilks for which the spell is applicable. + function deploy(bytes32[] memory ilks) external returns (address spell) { + spell = address(new GroupedClipBreakerSpell(ilks)); + emit Deploy(ilks, spell); + } +} diff --git a/src/clip-breaker/GroupedClipBreakerSpell.t.integration.sol b/src/clip-breaker/GroupedClipBreakerSpell.t.integration.sol new file mode 100644 index 0000000..b0351cc --- /dev/null +++ b/src/clip-breaker/GroupedClipBreakerSpell.t.integration.sol @@ -0,0 +1,199 @@ +// SPDX-FileCopyrightText: © 2024 Dai Foundation +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +pragma solidity ^0.8.16; + +import {stdStorage, StdStorage} from "forge-std/Test.sol"; +import {DssTest, DssInstance, MCD} from "dss-test/DssTest.sol"; +import {DssEmergencySpellLike} from "../DssEmergencySpell.sol"; +import {GroupedClipBreakerSpell, GroupedClipBreakerFactory} from "./GroupedClipBreakerSpell.sol"; + +interface IlkRegistryLike { + function xlip(bytes32 ilk) external view returns (address); + function file(bytes32 ilk, bytes32 what, address data) external; +} + +interface ClipperMomLike { + function setBreaker(address clip, uint256 level, uint256 delay) external; +} + +interface ClipLike { + function stopped() external view returns (uint256); + function deny(address who) external; +} + +abstract contract GroupedClipBreakerSpellTest is DssTest { + using stdStorage for StdStorage; + + address constant CHAINLOG = 0xdA0Ab1e0017DEbCd72Be8599041a2aa3bA7e740F; + DssInstance dss; + address pauseProxy; + address chief; + IlkRegistryLike ilkReg; + ClipperMomLike clipperMom; + bytes32[] ilks; + ClipLike clipA; + ClipLike clipB; + ClipLike clipC; + GroupedClipBreakerFactory factory; + DssEmergencySpellLike spell; + + function setUp() public { + vm.createSelectFork("mainnet"); + + dss = MCD.loadFromChainlog(CHAINLOG); + MCD.giveAdminAccess(dss); + pauseProxy = dss.chainlog.getAddress("MCD_PAUSE_PROXY"); + chief = dss.chainlog.getAddress("MCD_ADM"); + ilkReg = IlkRegistryLike(dss.chainlog.getAddress("ILK_REGISTRY")); + clipperMom = ClipperMomLike(dss.chainlog.getAddress("CLIPPER_MOM")); + _setUpSub(); + factory = new GroupedClipBreakerFactory(); + spell = DssEmergencySpellLike(factory.deploy(ilks)); + + stdstore.target(chief).sig("hat()").checked_write(address(spell)); + + vm.makePersistent(chief); + } + + function _setUpSub() internal virtual; + + function testClipBreakerOnSchedule() public { + assertEq(clipA.stopped(), 0, "ClipA: before: clip already stopped"); + assertFalse(spell.done(), "ClipA: before: spell already done"); + assertEq(clipB.stopped(), 0, "ClipB: before: clip already stopped"); + assertFalse(spell.done(), "ClipB: before: spell already done"); + if (ilks.length > 2) { + assertEq(clipC.stopped(), 0, "ClipC: before: clip already stopped"); + } + assertFalse(spell.done(), "ClipC: before: spell already done"); + + vm.expectEmit(true, true, true, true); + emit SetBreaker(ilks[0], address(clipA)); + vm.expectEmit(true, true, true, true); + emit SetBreaker(ilks[1], address(clipB)); + if (ilks.length > 2) { + vm.expectEmit(true, true, true, true); + emit SetBreaker(ilks[2], address(clipC)); + } + spell.schedule(); + + assertEq(clipA.stopped(), 3, "ClipA: after: clip not stopped"); + assertTrue(spell.done(), "ClipA: after: spell not done"); + assertEq(clipB.stopped(), 3, "ClipB: after: clip not stopped"); + assertTrue(spell.done(), "ClipB: after: spell not done"); + if (ilks.length > 2) { + assertEq(clipC.stopped(), 3, "ClipC: after: clip not stopped"); + assertTrue(spell.done(), "ClipC: after: spell not done"); + } + } + + function testDoneWhenClipIsNotSetInIlkReg() public { + vm.startPrank(pauseProxy); + ilkReg.file(ilks[0], "xlip", address(0)); + ilkReg.file(ilks[1], "xlip", address(0)); + if (ilks.length > 2) { + ilkReg.file(ilks[2], "xlip", address(0)); + } + vm.stopPrank(); + + assertTrue(spell.done(), "spell not done"); + } + + function testDoneWhenClipperMomIsNotWardInClip() public { + uint256 before = vm.snapshotState(); + + vm.prank(pauseProxy); + clipA.deny(address(clipperMom)); + assertFalse(spell.done(), "ClipA: spell already done"); + vm.revertToState(before); + + vm.prank(pauseProxy); + clipB.deny(address(clipperMom)); + assertFalse(spell.done(), "ClipB: spell already done"); + vm.revertToState(before); + + if (ilks.length > 2) { + vm.prank(pauseProxy); + clipC.deny(address(clipperMom)); + assertFalse(spell.done(), "ClipC: spell already done"); + vm.revertToState(before); + } + + vm.startPrank(pauseProxy); + clipA.deny(address(clipperMom)); + clipB.deny(address(clipperMom)); + if (ilks.length > 2) { + clipC.deny(address(clipperMom)); + } + vm.stopPrank(); + assertTrue(spell.done(), "after: spell not done"); + } + + function testRevertClipBreakerWhenItDoesNotHaveTheHat() public { + stdstore.target(chief).sig("hat()").checked_write(address(0)); + + vm.expectRevert(); + spell.schedule(); + } + + event SetBreaker(bytes32 indexed ilk, address indexed clip); +} + +contract EthGroupedClipBreakerSpellTest is GroupedClipBreakerSpellTest { + function _setUpSub() internal override { + clipA = ClipLike(ilkReg.xlip("ETH-A")); + clipB = ClipLike(ilkReg.xlip("ETH-B")); + clipC = ClipLike(ilkReg.xlip("ETH-C")); + ilks = new bytes32[](3); + ilks[0] = "ETH-A"; + ilks[1] = "ETH-B"; + ilks[2] = "ETH-C"; + } + + function testDescription() public view { + assertEq(spell.description(), "Emergency Spell | Grouped Clip Breaker: ETH-A, ETH-B, ETH-C"); + } +} + +contract WstethGroupedClipBreakerSpellTest is GroupedClipBreakerSpellTest { + function _setUpSub() internal override { + clipA = ClipLike(ilkReg.xlip("WSTETH-A")); + clipB = ClipLike(ilkReg.xlip("WSTETH-B")); + ilks = new bytes32[](2); + ilks[0] = "WSTETH-A"; + ilks[1] = "WSTETH-B"; + } + + function testDescription() public view { + assertEq(spell.description(), "Emergency Spell | Grouped Clip Breaker: WSTETH-A, WSTETH-B"); + } +} + +contract WbtcGroupedClipBreakerSpellTest is GroupedClipBreakerSpellTest { + function _setUpSub() internal override { + clipA = ClipLike(ilkReg.xlip("WBTC-A")); + clipB = ClipLike(ilkReg.xlip("WBTC-B")); + clipC = ClipLike(ilkReg.xlip("WBTC-C")); + ilks = new bytes32[](3); + ilks[0] = "WBTC-A"; + ilks[1] = "WBTC-B"; + ilks[2] = "WBTC-C"; + } + + function testDescription() public view { + assertEq(spell.description(), "Emergency Spell | Grouped Clip Breaker: WBTC-A, WBTC-B, WBTC-C"); + } +} diff --git a/src/line-wipe/GroupedLineWipeSpell.sol b/src/line-wipe/GroupedLineWipeSpell.sol new file mode 100644 index 0000000..fb21941 --- /dev/null +++ b/src/line-wipe/GroupedLineWipeSpell.sol @@ -0,0 +1,104 @@ +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +pragma solidity ^0.8.16; + +import {DssGroupedEmergencySpell} from "../DssGroupedEmergencySpell.sol"; + +interface LineMomLike { + function autoLine() external view returns (address); + function ilks(bytes32 ilk) external view returns (uint256); + function wipe(bytes32 ilk) external returns (uint256); +} + +interface AutoLineLike { + function ilks(bytes32 ilk) + external + view + returns (uint256 maxLine, uint256 gap, uint48 ttl, uint48 last, uint48 lastInc); + function wards(address who) external view returns (uint256); +} + +interface VatLike { + function ilks(bytes32 ilk) + external + view + returns (uint256 Art, uint256 rate, uint256 spot, uint256 line, uint256 dust); + function wards(address who) external view returns (uint256); +} + +/// @title Emergency Spell: Grouped Line Wipe +/// @notice Prevents further debt from being generated for the specified ilks. +/// @custom:authors [amusingaxl] +/// @custom:reviewers [] +/// @custom:auditors [] +/// @custom:bounties [] +contract GroupedLineWipeSpell is DssGroupedEmergencySpell { + /// @notice The LineMom from chainlog. + LineMomLike public immutable lineMom = LineMomLike(_log.getAddress("LINE_MOM")); + /// @notice The AutoLine IAM. + AutoLineLike public immutable autoLine = AutoLineLike(LineMomLike(_log.getAddress("LINE_MOM")).autoLine()); + /// @notice The Vat from chainlog. + VatLike public immutable vat = VatLike(_log.getAddress("MCD_VAT")); + + /// @notice Emitted when the spell is scheduled. + /// @param ilk The ilk for which the Line wipe was set. + event Wipe(bytes32 indexed ilk); + + /// @param _ilks The list of ilks for which the spell should be applicable + /// @dev The list size is be at least 1. + /// The grouped spell is meant to be used for ilks that are a variation of the same collateral gem + /// (i.e.: ETH-A, ETH-B, ETH-C) + constructor(bytes32[] memory _ilks) DssGroupedEmergencySpell(_ilks) {} + + /// @inheritdoc DssGroupedEmergencySpell + function _descriptionPrefix() internal pure override returns (string memory) { + return "Emergency Spell | Grouped Line Wipe:"; + } + + /// @notice Wipes the line for the specified ilk.. + /// @param _ilk The ilk to be wiped. + function _emergencyActions(bytes32 _ilk) internal override { + lineMom.wipe(_ilk); + emit Wipe(_ilk); + } + + /// @notice Returns whether the spell is done or not for the specified ilk. + function _done(bytes32 _ilk) internal view override returns (bool) { + if (vat.wards(address(lineMom)) == 0 || autoLine.wards(address(lineMom)) == 0 || lineMom.ilks(_ilk) == 0) { + return true; + } + + (,,, uint256 line,) = vat.ilks(_ilk); + (uint256 maxLine, uint256 gap, uint48 ttl, uint48 last, uint48 lastInc) = autoLine.ilks(_ilk); + + return line == 0 && maxLine == 0 && gap == 0 && ttl == 0 && last == 0 && lastInc == 0; + } +} + +/// @title Emergency Spell Factory: Grouped Line Wipe +/// @notice On-chain factory to deploy Grouped Line Wipe emergency spells. +/// @custom:authors [amusingaxl] +/// @custom:reviewers [] +/// @custom:auditors [] +/// @custom:bounties [] +contract GroupedLineWipeFactory { + /// @notice A new GroupedLineWipeSpell has been deployed. + /// @param ilks The list of ilks for which the spell is applicable. + /// @param spell The deployed spell address. + event Deploy(bytes32[] indexed ilks, address spell); + + /// @notice Deploys a GroupedLineWipeSpell contract. + /// @param ilks The list of ilks for which the spell is applicable. + function deploy(bytes32[] memory ilks) external returns (address spell) { + spell = address(new GroupedLineWipeSpell(ilks)); + emit Deploy(ilks, spell); + } +} diff --git a/src/line-wipe/GroupedLineWipeSpell.t.integration.sol b/src/line-wipe/GroupedLineWipeSpell.t.integration.sol new file mode 100644 index 0000000..fe991e6 --- /dev/null +++ b/src/line-wipe/GroupedLineWipeSpell.t.integration.sol @@ -0,0 +1,232 @@ +// SPDX-FileCopyrightText: © 2024 Dai Foundation +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +pragma solidity ^0.8.16; + +import {stdStorage, StdStorage} from "forge-std/Test.sol"; +import {DssTest, DssInstance, MCD} from "dss-test/DssTest.sol"; +import {DssEmergencySpellLike} from "../DssEmergencySpell.sol"; +import {GroupedLineWipeSpell, GroupedLineWipeFactory} from "./GroupedLineWipeSpell.sol"; + +interface AutoLineLike { + function ilks(bytes32 ilk) + external + view + returns (uint256 maxLine, uint256 gap, uint48 ttl, uint48 last, uint48 lastInc); + function setIlk(bytes32 ilk, uint256 maxLine, uint256 gap, uint256 ttl) external; +} + +interface LineMomLike { + function delIlk(bytes32 ilk) external; +} + +interface VatLike { + function file(bytes32 ilk, bytes32 what, uint256 data) external; +} + +abstract contract GroupedLineWipeSpellTest is DssTest { + using stdStorage for StdStorage; + + address constant CHAINLOG = 0xdA0Ab1e0017DEbCd72Be8599041a2aa3bA7e740F; + DssInstance dss; + address pauseProxy; + VatLike vat; + address chief; + LineMomLike lineMom; + AutoLineLike autoLine; + bytes32[] ilks; + GroupedLineWipeFactory factory; + DssEmergencySpellLike spell; + + function setUp() public { + vm.createSelectFork("mainnet"); + + dss = MCD.loadFromChainlog(CHAINLOG); + MCD.giveAdminAccess(dss); + pauseProxy = dss.chainlog.getAddress("MCD_PAUSE_PROXY"); + vat = VatLike(dss.chainlog.getAddress("MCD_VAT")); + chief = dss.chainlog.getAddress("MCD_ADM"); + lineMom = LineMomLike(dss.chainlog.getAddress("LINE_MOM")); + autoLine = AutoLineLike(dss.chainlog.getAddress("MCD_IAM_AUTO_LINE")); + _setUpSub(); + factory = new GroupedLineWipeFactory(); + spell = DssEmergencySpellLike(factory.deploy(ilks)); + + stdstore.target(chief).sig("hat()").checked_write(address(spell)); + + vm.makePersistent(chief); + } + + function _setUpSub() internal virtual; + + function testAutoLineWipeOnSchedule() public { + uint256 pmaxLine; + uint256 pgap; + + (pmaxLine, pgap,,,) = autoLine.ilks(ilks[0]); + assertGt(pmaxLine, 0, "ilk0: before: auto-line maxLine already wiped"); + assertGt(pgap, 0, "ilk0: before: auto-line gap already wiped"); + assertFalse(spell.done(), "ilk0: before: spell already done"); + + (pmaxLine, pgap,,,) = autoLine.ilks(ilks[1]); + assertGt(pmaxLine, 0, "ilk1: before: auto-line maxLine already wiped"); + assertGt(pgap, 0, "ilk1: before: auto-line gap already wiped"); + assertFalse(spell.done(), "ilk1: before: spell already done"); + + if (ilks.length > 2) { + (pmaxLine, pgap,,,) = autoLine.ilks(ilks[2]); + assertGt(pmaxLine, 0, "ilk2: before: auto-line maxLine already wiped"); + assertGt(pgap, 0, "ilk2: before: auto-line gap already wiped"); + assertFalse(spell.done(), "ilk2: before: spell already done"); + } + + vm.expectEmit(true, true, true, false); + emit Wipe(ilks[0]); + vm.expectEmit(true, true, true, false); + emit Wipe(ilks[1]); + if (ilks.length > 2) { + vm.expectEmit(true, true, true, false); + emit Wipe(ilks[2]); + } + spell.schedule(); + + uint256 maxLine; + uint256 gap; + + (maxLine, gap,,,) = autoLine.ilks(ilks[0]); + assertEq(maxLine, 0, "ilk0: after: auto-line maxLine not wiped"); + assertEq(gap, 0, "ilk0: after: auto-line gap not wiped (gap)"); + assertTrue(spell.done(), "ilk0: after: spell not done"); + + (maxLine, gap,,,) = autoLine.ilks(ilks[1]); + assertEq(maxLine, 0, "ilk1: after: auto-line maxLine not wiped"); + assertEq(gap, 0, "ilk1: after: auto-line gap not wiped"); + assertTrue(spell.done(), "ilk1: after: spell not done"); + + if (ilks.length > 2) { + (maxLine, gap,,,) = autoLine.ilks(ilks[2]); + assertEq(maxLine, 0, "ilk2: after: auto-line maxLine not wiped"); + assertEq(gap, 0, "ilk2: after: auto-line gap not wiped (gap)"); + assertTrue(spell.done(), "ilk2: after: spell not done"); + } + } + + function testDoneWhenIlkIsNotAddedToLineMom() public { + uint256 before = vm.snapshotState(); + + vm.prank(pauseProxy); + lineMom.delIlk(ilks[0]); + assertFalse(spell.done(), "ilk0: spell done"); + vm.revertToState(before); + + vm.prank(pauseProxy); + lineMom.delIlk(ilks[1]); + assertFalse(spell.done(), "ilk1: spell done"); + vm.revertToState(before); + + if (ilks.length > 2) { + vm.prank(pauseProxy); + lineMom.delIlk(ilks[2]); + assertFalse(spell.done(), "ilk2: spell done"); + vm.revertToState(before); + } + + vm.startPrank(pauseProxy); + lineMom.delIlk(ilks[0]); + lineMom.delIlk(ilks[1]); + if (ilks.length > 2) { + lineMom.delIlk(ilks[2]); + } + assertTrue(spell.done(), "spell not done"); + } + + function testDoneWhenAutoLineIsNotActiveButLineIsNonZero() public { + uint256 before = vm.snapshotState(); + + spell.schedule(); + assertTrue(spell.done(), "before: spell not done"); + + vm.prank(pauseProxy); + vat.file(ilks[0], "line", 10 ** 45); + assertFalse(spell.done(), "ilk0: after: spell still done"); + vm.revertToState(before); + + vm.prank(pauseProxy); + vat.file(ilks[1], "line", 10 ** 45); + assertFalse(spell.done(), "ilk1: after: spell still done"); + vm.revertToState(before); + + if (ilks.length > 2) { + vm.prank(pauseProxy); + vat.file(ilks[2], "line", 10 ** 45); + assertFalse(spell.done(), "ilk2: after: spell still done"); + vm.revertToState(before); + } + } + + function testRevertAutoLineWipeWhenItDoesNotHaveTheHat() public { + stdstore.target(chief).sig("hat()").checked_write(address(0)); + + vm.expectRevert(); + spell.schedule(); + } + + event Wipe(bytes32 indexed ilk); +} + +contract EthGroupedLineWipeSpellTest is GroupedLineWipeSpellTest { + function _setUpSub() internal override { + ilks = new bytes32[](3); + ilks[0] = "ETH-A"; + ilks[1] = "ETH-B"; + ilks[2] = "ETH-C"; + } + + function testDescription() public view { + assertEq(spell.description(), "Emergency Spell | Grouped Line Wipe: ETH-A, ETH-B, ETH-C"); + } +} + +contract WstethGroupedLineWipeSpellTest is GroupedLineWipeSpellTest { + function _setUpSub() internal override { + ilks = new bytes32[](2); + ilks[0] = "WSTETH-A"; + ilks[1] = "WSTETH-B"; + } + + function testDescription() public view { + assertEq(spell.description(), "Emergency Spell | Grouped Line Wipe: WSTETH-A, WSTETH-B"); + } +} + +contract WbtcGroupedLineWipeSpellTest is GroupedLineWipeSpellTest { + function _setUpSub() internal override { + ilks = new bytes32[](3); + ilks[0] = "WBTC-A"; + ilks[1] = "WBTC-B"; + ilks[2] = "WBTC-C"; + + // WBTC debt ceiling was set to zero when this test was written, so we need to overwrite the state. + vm.startPrank(pauseProxy); + autoLine.setIlk(ilks[0], 1, 1, 1); + autoLine.setIlk(ilks[1], 1, 1, 1); + autoLine.setIlk(ilks[2], 1, 1, 1); + vm.stopPrank(); + } + + function testDescription() public view { + assertEq(spell.description(), "Emergency Spell | Grouped Line Wipe: WBTC-A, WBTC-B, WBTC-C"); + } +}