Skip to content

Commit

Permalink
Merge pull request #96 from lidofinance/feature/tiebreaker-tests
Browse files Browse the repository at this point in the history
Tiebreaker improvements & natspec and unit tests
  • Loading branch information
Psirex authored Aug 28, 2024
2 parents 72a994a + 8831953 commit a251e6e
Show file tree
Hide file tree
Showing 2 changed files with 248 additions and 13 deletions.
69 changes: 56 additions & 13 deletions contracts/libraries/Tiebreaker.sol
Original file line number Diff line number Diff line change
Expand Up @@ -2,30 +2,38 @@
pragma solidity 0.8.26;

import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol";

import {Duration} from "../types/Duration.sol";
import {Timestamp, Timestamps} from "../types/Duration.sol";

import {ISealable} from "../interfaces/ISealable.sol";

import {SealableCalls} from "./SealableCalls.sol";
import {State as DualGovernanceState} from "./DualGovernanceStateMachine.sol";

/// @title Tiebreaker Library
/// @dev The mechanism design allows for a deadlock where the system is stuck in the RageQuit
/// state while protocol withdrawals are paused or dysfunctional and require a DAO vote to resume,
/// and includes a third-party arbiter Tiebreaker committee for resolving it. Tiebreaker gains
/// the power to execute pending proposals, bypassing the DG dynamic timelock, and unpause any
/// protocol contract under the specific conditions of the deadlock.
library Tiebreaker {
using SealableCalls for ISealable;
using EnumerableSet for EnumerableSet.AddressSet;

error TiebreakDisallowed();
error InvalidSealable(address value);
error InvalidTiebreakerCommittee(address value);
error InvalidTiebreakerActivationTimeout(Duration value);
error TiebreakNotAllowed();
error InvalidSealable(address sealable);
error InvalidTiebreakerCommittee(address account);
error InvalidTiebreakerActivationTimeout(Duration timeout);
error CallerIsNotTiebreakerCommittee(address caller);
error SealableWithdrawalBlockersLimitReached();

event SealableWithdrawalBlockerAdded(address sealable);
event SealableWithdrawalBlockerRemoved(address sealable);
event TiebreakerCommitteeSet(address newTiebreakerCommittee);
event TiebreakerActivationTimeoutSet(Duration newTiebreakerActivationTimeout);

/// @dev Context struct to store tiebreaker-related data.
/// @param tiebreakerCommittee Address of the tiebreaker committee.
/// @param tiebreakerActivationTimeout Duration for tiebreaker activation timeout.
/// @param sealableWithdrawalBlockers Set of addresses that are sealable withdrawal blockers.
struct Context {
/// @dev slot0 [0..159]
address tiebreakerCommittee;
Expand All @@ -39,6 +47,11 @@ library Tiebreaker {
// Setup functionality
// ---

/// @notice Adds a sealable withdrawal blocker.
/// @dev Reverts if the maximum number of sealable withdrawal blockers is reached or if the sealable is invalid.
/// @param self The context storage.
/// @param sealableWithdrawalBlocker The address of the sealable withdrawal blocker to add.
/// @param maxSealableWithdrawalBlockersCount The maximum number of sealable withdrawal blockers allowed.
function addSealableWithdrawalBlocker(
Context storage self,
address sealableWithdrawalBlocker,
Expand All @@ -48,7 +61,6 @@ library Tiebreaker {
if (sealableWithdrawalBlockersCount == maxSealableWithdrawalBlockersCount) {
revert SealableWithdrawalBlockersLimitReached();
}

(bool isCallSucceed, /* lowLevelError */, /* isPaused */ ) = ISealable(sealableWithdrawalBlocker).callIsPaused();
if (!isCallSucceed) {
revert InvalidSealable(sealableWithdrawalBlocker);
Expand All @@ -60,13 +72,20 @@ library Tiebreaker {
}
}

/// @notice Removes a sealable withdrawal blocker.
/// @param self The context storage.
/// @param sealableWithdrawalBlocker The address of the sealable withdrawal blocker to remove.
function removeSealableWithdrawalBlocker(Context storage self, address sealableWithdrawalBlocker) internal {
bool isSuccessfullyRemoved = self.sealableWithdrawalBlockers.remove(sealableWithdrawalBlocker);
if (isSuccessfullyRemoved) {
emit SealableWithdrawalBlockerRemoved(sealableWithdrawalBlocker);
}
}

/// @notice Sets the tiebreaker committee.
/// @dev Reverts if the new tiebreaker committee address is invalid.
/// @param self The context storage.
/// @param newTiebreakerCommittee The address of the new tiebreaker committee.
function setTiebreakerCommittee(Context storage self, address newTiebreakerCommittee) internal {
if (newTiebreakerCommittee == address(0) || newTiebreakerCommittee == self.tiebreakerCommittee) {
revert InvalidTiebreakerCommittee(newTiebreakerCommittee);
Expand All @@ -75,6 +94,12 @@ library Tiebreaker {
emit TiebreakerCommitteeSet(newTiebreakerCommittee);
}

/// @notice Sets the tiebreaker activation timeout.
/// @dev Reverts if the new timeout is outside the allowed range.
/// @param self The context storage.
/// @param minTiebreakerActivationTimeout The minimum allowed tiebreaker activation timeout.
/// @param newTiebreakerActivationTimeout The new tiebreaker activation timeout.
/// @param maxTiebreakerActivationTimeout The maximum allowed tiebreaker activation timeout.
function setTiebreakerActivationTimeout(
Context storage self,
Duration minTiebreakerActivationTimeout,
Expand All @@ -96,54 +121,72 @@ library Tiebreaker {
// Checks
// ---

/// @notice Checks if the caller is the tiebreaker committee.
/// @dev Reverts if the caller is not the tiebreaker committee.
/// @param self The context storage.
function checkCallerIsTiebreakerCommittee(Context storage self) internal view {
if (msg.sender != self.tiebreakerCommittee) {
revert InvalidTiebreakerCommittee(msg.sender);
revert CallerIsNotTiebreakerCommittee(msg.sender);
}
}

/// @notice Checks if a tie exists.
/// @dev Reverts if no tie exists.
/// @param self The context storage.
/// @param state The current state of dual governance.
/// @param normalOrVetoCooldownExitedAt The timestamp when normal or veto cooldown exited.
function checkTie(
Context storage self,
DualGovernanceState state,
Timestamp normalOrVetoCooldownExitedAt
) internal view {
if (!isTie(self, state, normalOrVetoCooldownExitedAt)) {
revert TiebreakDisallowed();
revert TiebreakNotAllowed();
}
}

// ---
// Getters
// ---

/// @notice Determines if a tie exists.
/// @param self The context storage.
/// @param state The current state of dual governance.
/// @param normalOrVetoCooldownExitedAt The timestamp when normal or veto cooldown exited.
/// @return True if a tie exists, false otherwise.
function isTie(
Context storage self,
DualGovernanceState state,
Timestamp normalOrVetoCooldownExitedAt
) internal view returns (bool) {
if (state == DualGovernanceState.Normal || state == DualGovernanceState.VetoCooldown) return false;

// when the governance is locked for long period of time
if (Timestamps.now() >= self.tiebreakerActivationTimeout.addTo(normalOrVetoCooldownExitedAt)) {
return true;
}

return state == DualGovernanceState.RageQuit && isSomeSealableWithdrawalBlockerPaused(self);
}

/// @notice Checks if any sealable withdrawal blocker is paused.
/// @param self The context storage.
/// @return True if any sealable withdrawal blocker is paused, false otherwise.
function isSomeSealableWithdrawalBlockerPaused(Context storage self) internal view returns (bool) {
uint256 sealableWithdrawalBlockersCount = self.sealableWithdrawalBlockers.length();
for (uint256 i = 0; i < sealableWithdrawalBlockersCount; ++i) {
(bool isCallSucceed, /* lowLevelError */, bool isPaused) =
ISealable(self.sealableWithdrawalBlockers.at(i)).callIsPaused();

// in normal condition this call must never fail, so if some sealable withdrawal blocker
// started behave unexpectedly tiebreaker action may be the last hope for the protocol saving
if (isPaused || !isCallSucceed) return true;
}
return false;
}

/// @notice Gets the tiebreaker information.
/// @param self The context storage.
/// @return tiebreakerCommittee The address of the tiebreaker committee.
/// @return tiebreakerActivationTimeout The duration of the tiebreaker activation timeout.
/// @return sealableWithdrawalBlockers The addresses of the sealable withdrawal blockers.
function getTiebreakerInfo(Context storage self)
internal
view
Expand Down
192 changes: 192 additions & 0 deletions test/unit/libraries/Tiebreaker.t.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;

import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol";

import {State as DualGovernanceState} from "contracts/libraries/DualGovernanceStateMachine.sol";
import {Tiebreaker} from "contracts/libraries/Tiebreaker.sol";
import {Duration, Durations, Timestamp, Timestamps} from "contracts/types/Duration.sol";
import {ISealable} from "contracts/interfaces/ISealable.sol";

import {UnitTest} from "test/utils/unit-test.sol";
import {SealableMock} from "../../mocks/SealableMock.sol";

contract TiebreakerTest is UnitTest {
using EnumerableSet for EnumerableSet.AddressSet;

Tiebreaker.Context private context;
SealableMock private mockSealable1;
SealableMock private mockSealable2;

function setUp() external {
mockSealable1 = new SealableMock();
mockSealable2 = new SealableMock();
}

function test_addSealableWithdrawalBlocker_HappyPath() external {
vm.expectEmit();
emit Tiebreaker.SealableWithdrawalBlockerAdded(address(mockSealable1));
Tiebreaker.addSealableWithdrawalBlocker(context, address(mockSealable1), 1);

assertTrue(context.sealableWithdrawalBlockers.contains(address(mockSealable1)));
}

function test_addSealableWithdrawalBlocker_RevertOn_LimitReached() external {
Tiebreaker.addSealableWithdrawalBlocker(context, address(mockSealable1), 1);

vm.expectRevert(Tiebreaker.SealableWithdrawalBlockersLimitReached.selector);
Tiebreaker.addSealableWithdrawalBlocker(context, address(mockSealable2), 1);
}

function test_addSealableWithdrawalBlocker_RevertOn_InvalidSealable() external {
mockSealable1.setShouldRevertIsPaused(true);

vm.expectRevert(abi.encodeWithSelector(Tiebreaker.InvalidSealable.selector, address(mockSealable1)));
// external call should be used to intercept the revert
this.external__addSealableWithdrawalBlocker(address(mockSealable1));

vm.expectRevert();
// external call should be used to intercept the revert
this.external__addSealableWithdrawalBlocker(address(0x123));
}

function test_removeSealableWithdrawalBlocker_HappyPath() external {
Tiebreaker.addSealableWithdrawalBlocker(context, address(mockSealable1), 1);
assertTrue(context.sealableWithdrawalBlockers.contains(address(mockSealable1)));

vm.expectEmit();
emit Tiebreaker.SealableWithdrawalBlockerRemoved(address(mockSealable1));

Tiebreaker.removeSealableWithdrawalBlocker(context, address(mockSealable1));
assertFalse(context.sealableWithdrawalBlockers.contains(address(mockSealable1)));
}

function test_setTiebreakerCommittee_HappyPath() external {
address newCommittee = address(0x123);

vm.expectEmit();
emit Tiebreaker.TiebreakerCommitteeSet(newCommittee);
Tiebreaker.setTiebreakerCommittee(context, newCommittee);

assertEq(context.tiebreakerCommittee, newCommittee);
}

function test_setTiebreakerCommittee_WithExistingCommitteeAddress() external {
address newCommittee = address(0x123);

Tiebreaker.setTiebreakerCommittee(context, newCommittee);
vm.expectRevert(abi.encodeWithSelector(Tiebreaker.InvalidTiebreakerCommittee.selector, newCommittee));
Tiebreaker.setTiebreakerCommittee(context, newCommittee);
}

function test_setTiebreakerCommittee_RevertOn_ZeroAddress() external {
vm.expectRevert(abi.encodeWithSelector(Tiebreaker.InvalidTiebreakerCommittee.selector, address(0)));
Tiebreaker.setTiebreakerCommittee(context, address(0));
}

function testFuzz_SetTiebreakerActivationTimeout(
Duration minTimeout,
Duration maxTimeout,
Duration timeout
) external {
vm.assume(minTimeout < timeout && timeout < maxTimeout);

vm.expectEmit();
emit Tiebreaker.TiebreakerActivationTimeoutSet(timeout);

Tiebreaker.setTiebreakerActivationTimeout(context, minTimeout, timeout, timeout);
assertEq(context.tiebreakerActivationTimeout, timeout);
}

function test_setTiebreakerActivationTimeout_RevertOn_InvalidTimeout() external {
Duration minTimeout = Duration.wrap(1 days);
Duration maxTimeout = Duration.wrap(10 days);
Duration newTimeout = Duration.wrap(15 days);

vm.expectRevert(abi.encodeWithSelector(Tiebreaker.InvalidTiebreakerActivationTimeout.selector, newTimeout));
Tiebreaker.setTiebreakerActivationTimeout(context, minTimeout, newTimeout, maxTimeout);

newTimeout = Duration.wrap(0 days);

vm.expectRevert(abi.encodeWithSelector(Tiebreaker.InvalidTiebreakerActivationTimeout.selector, newTimeout));
Tiebreaker.setTiebreakerActivationTimeout(context, minTimeout, newTimeout, maxTimeout);
}

function test_isSomeSealableWithdrawalBlockerPaused_HappyPath() external {
mockSealable1.pauseFor(1 days);
Tiebreaker.addSealableWithdrawalBlocker(context, address(mockSealable1), 2);

bool result = Tiebreaker.isSomeSealableWithdrawalBlockerPaused(context);
assertTrue(result);

mockSealable1.resume();

result = Tiebreaker.isSomeSealableWithdrawalBlockerPaused(context);
assertFalse(result);

mockSealable1.setShouldRevertIsPaused(true);

result = Tiebreaker.isSomeSealableWithdrawalBlockerPaused(context);
assertTrue(result);
}

function test_checkTie_HappyPath() external {
Timestamp cooldownExitedAt = Timestamps.from(block.timestamp);

Tiebreaker.addSealableWithdrawalBlocker(context, address(mockSealable1), 1);
Tiebreaker.setTiebreakerActivationTimeout(
context, Duration.wrap(1 days), Duration.wrap(3 days), Duration.wrap(10 days)
);

mockSealable1.pauseFor(1 days);
Tiebreaker.checkTie(context, DualGovernanceState.RageQuit, cooldownExitedAt);

_wait(Duration.wrap(3 days));
Tiebreaker.checkTie(context, DualGovernanceState.VetoSignalling, cooldownExitedAt);
}

function test_checkTie_RevertOn_NormalOrVetoCooldownState() external {
Timestamp cooldownExitedAt = Timestamps.from(block.timestamp);

vm.expectRevert(Tiebreaker.TiebreakNotAllowed.selector);
Tiebreaker.checkTie(context, DualGovernanceState.Normal, cooldownExitedAt);

vm.expectRevert(Tiebreaker.TiebreakNotAllowed.selector);
Tiebreaker.checkTie(context, DualGovernanceState.VetoCooldown, cooldownExitedAt);
}

function test_checkCallerIsTiebreakerCommittee_HappyPath() external {
context.tiebreakerCommittee = address(this);

vm.expectRevert(abi.encodeWithSelector(Tiebreaker.CallerIsNotTiebreakerCommittee.selector, address(0x456)));
vm.prank(address(0x456));
this.external__checkCallerIsTiebreakerCommittee();

this.external__checkCallerIsTiebreakerCommittee();
}

function test_getTimebreakerInfo_HappyPath() external {
Tiebreaker.addSealableWithdrawalBlocker(context, address(mockSealable1), 1);

Duration timeout = Duration.wrap(5 days);

context.tiebreakerActivationTimeout = timeout;
context.tiebreakerCommittee = address(0x123);

(address committee, Duration activationTimeout, address[] memory blockers) =
Tiebreaker.getTiebreakerInfo(context);

assertEq(committee, context.tiebreakerCommittee);
assertEq(activationTimeout, context.tiebreakerActivationTimeout);
assertEq(blockers[0], address(mockSealable1));
assertEq(blockers.length, 1);
}

function external__checkCallerIsTiebreakerCommittee() external view {
Tiebreaker.checkCallerIsTiebreakerCommittee(context);
}

function external__addSealableWithdrawalBlocker(address sealable) external {
Tiebreaker.addSealableWithdrawalBlocker(context, sealable, 1);
}
}

0 comments on commit a251e6e

Please sign in to comment.