Skip to content

Commit

Permalink
add initial version of LiFiTimelockController [LiFiTimelockController…
Browse files Browse the repository at this point in the history
… v1.0.0] (#903)

* add initial version of LiFiTimelockController

* use built-in access management and add basic tests

* Make diamond configurable

* remove openRole access

* add audit report and update log

---------

Co-authored-by: Ed Zynda <[email protected]>
Co-authored-by: Ed Zynda <[email protected]>
  • Loading branch information
3 people authored Jan 11, 2025
1 parent 609feb4 commit 1173477
Show file tree
Hide file tree
Showing 4 changed files with 304 additions and 6 deletions.
24 changes: 18 additions & 6 deletions audit/auditLog.json
Original file line number Diff line number Diff line change
Expand Up @@ -105,12 +105,19 @@
"auditReportPath": "./audit/reports/2025.01.09_SafeTransferETHUpdate.pdf",
"auditCommitHash": "e4f27b0c4f572e5c64ca132dc2e496a3fe6c7c1d"
},
"audit20250110": {
"audit20250110_1": {
"auditCompletedOn": "10.01.2025",
"auditedBy": "Cantina (security firm)",
"auditorGitHandle": "cantinaxyz",
"auditReportPath": "./audit/reports/report-cantinacode-lifi-1216.pdf",
"auditCommitHash": "c9e6246167f4ff44f4535e466a4ceb902552aa13"
},
"audit20250110_2": {
"auditCompletedOn": "10.01.2025",
"auditedBy": "Sujith Somraaj (individual security researcher)",
"auditorGitHandle": "sujithsomraaj",
"auditReportPath": "./audit/reports/2025.01.10_Timelock(v1.0.0).pdf",
"auditCommitHash": "af28afab139a158650f4ef4f4442f2b5fcdfe497"
}
},
"auditedContracts": {
Expand Down Expand Up @@ -163,15 +170,15 @@
"audit20241107"
],
"2.0.2": [
"audit20250110"
"audit20250110_1"
]
},
"GasZipPeriphery": {
"1.0.0": [
"audit20241107"
],
"1.0.1": [
"audit20250110"
"audit20250110_1"
]
},
"IGasZip": {
Expand All @@ -184,7 +191,7 @@
"audit20241202"
],
"1.0.2": [
"audit20250110"
"audit20250110_1"
]
},
"LiFiDEXAggregator": {
Expand All @@ -198,6 +205,11 @@
"audit20250109_3"
]
},
"LiFiTimelockController": {
"1.0.0": [
"audit20250110_2"
]
},
"LiFuelFeeCollector": {
"1.0.2": [
"audit20250109_3"
Expand All @@ -208,7 +220,7 @@
"audit20241122"
],
"1.0.1": [
"audit20250110"
"audit20250110_1"
],
"1.0.2": [
"audit20250109_3"
Expand All @@ -230,7 +242,7 @@
"audit20241206"
],
"1.0.2": [
"audit20250110"
"audit20250110_1"
],
"1.1.0": [
"audit20250109_2"
Expand Down
Binary file added audit/reports/2025.01.10_Timelock(v1.0.0).pdf
Binary file not shown.
63 changes: 63 additions & 0 deletions src/Security/LiFiTimelockController.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;

import { TimelockController } from "@openzeppelin/contracts/governance/TimelockController.sol";

/// @title LiFiTimelockController
/// @author LI.FI (https://li.fi)
/// @notice Custom version of Openzeppelin TimelockController to add timelock functionality for any diamondCuts on PROD diamonds
/// @custom:version 1.0.0
/// @notice Interface for diamond pause functionality
/// @dev This interface defines the unpauseDiamond function that must be implemented by the diamond contract
interface EmergencyPause {
/// @notice Unpauses the diamond with specified blacklist
/// @param _blacklist Array of addresses to exclude from reactivation
function unpauseDiamond(address[] calldata _blacklist) external;
}

contract LiFiTimelockController is TimelockController {
/// @notice The address of the diamond contract that this timelock controls
address public diamond;

/// @notice Emitted when the diamond address is updated
/// @param diamond The new diamond address
event DiamondAddressUpdated(address indexed diamond);

/// @param _minDelay Initial minimum delay for operations
/// @param _proposers Accounts to be granted proposer and canceller roles
/// @param _executors Accounts to be granted executor role
/// @param _admin The address that will be the admin of the TimelockController (= the LI.FI MultiSig SAFE)
constructor(
uint256 _minDelay,
address[] memory _proposers,
address[] memory _executors,
address _admin,
address _diamond
) TimelockController(_minDelay, _proposers, _executors, _admin) {
diamond = _diamond;
emit DiamondAddressUpdated(diamond);
}

/// @notice Updates the address of the diamond contract
/// @dev Can only be called by admin role or if the role is open (granted to address(0))
/// @param _diamond The new diamond address to set
function setDiamondAddress(
address _diamond
) external onlyRole(TIMELOCK_ADMIN_ROLE) {
diamond = _diamond;
emit DiamondAddressUpdated(diamond);
}

/// @notice Unpauses the diamond contract by re-adding all facetAddress-to-function-selector mappings to storage
/// This function bypasses the minDelay so that we are able to unpause our diamond without any minDelay
/// The unpause function can only remove existing facets (blacklist), not add new code, therefore we consider this minDelay exception to be safe
/// @dev Can only be executed by the TimelockController admin (= the LI.FI MultiSig SAFE)
/// @param _blacklist The address(es) of facet(s) that should not be reactivated
/// @custom:security This function intentionally bypasses timelock delay for emergency unpausing
function unpauseDiamond(
address[] calldata _blacklist
) external onlyRole(TIMELOCK_ADMIN_ROLE) {
// call the diamond directly (bypassing the minDelay)
EmergencyPause(diamond).unpauseDiamond(_blacklist);
}
}
223 changes: 223 additions & 0 deletions test/solidity/Security/LiFiTimelockController.t.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;

import { Test } from "forge-std/Test.sol";
import { LiFiTimelockController } from "lifi/Security/LiFiTimelockController.sol";
import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol";

// Mock contract to simulate a diamond that can be unpaused
contract MockDiamond is Ownable {
address[] public blacklist;
bool public unpaused;

error Unauthorized();

function unpauseDiamond(address[] calldata _blacklist) external onlyOwner {
blacklist = _blacklist;
unpaused = true;
}
}

contract LiFiTimelockControllerTest is Test {
LiFiTimelockController public timelock;
MockDiamond public mockDiamond;

address public admin = address(0x1);
address public proposer = address(0x2);
address public executor = address(0x3);
address public unauthorized = address(0x4);

uint256 public constant MIN_DELAY = 1 days;

// Events
event RoleGranted(
bytes32 indexed role,
address indexed account,
address indexed sender
);
event RoleRevoked(
bytes32 indexed role,
address indexed account,
address indexed sender
);
event DiamondAddressUpdated(address indexed diamond);

function setUp() public {
// Setup proposers and executors arrays
address[] memory proposers = new address[](1);
proposers[0] = proposer;

address[] memory executors = new address[](1);
executors[0] = executor;

// Deploy MockDiamond first
mockDiamond = new MockDiamond();

// Then deploy timelock with correct mockDiamond address
timelock = new LiFiTimelockController(
MIN_DELAY,
proposers,
executors,
admin,
address(mockDiamond)
);

// Transfer ownership of MockDiamond to timelock
mockDiamond.transferOwnership(address(timelock));
}

function test_InitializesWithCorrectRolesAndDelay() public {
// Check roles
bytes32 adminRole = timelock.TIMELOCK_ADMIN_ROLE();
bytes32 proposerRole = timelock.PROPOSER_ROLE();
bytes32 executorRole = timelock.EXECUTOR_ROLE();
bytes32 cancellerRole = timelock.CANCELLER_ROLE();

assertTrue(timelock.hasRole(adminRole, admin));
assertTrue(timelock.hasRole(adminRole, address(timelock)));
assertTrue(timelock.hasRole(proposerRole, proposer));
assertTrue(timelock.hasRole(executorRole, executor));
assertTrue(timelock.hasRole(cancellerRole, proposer));

// Check delay
assertEq(timelock.getMinDelay(), MIN_DELAY);
}

function test_SetDiamondAddressUpdatesStorageAndEmitsEvent() public {
// Check initial diamond address
assertEq(timelock.diamond(), address(mockDiamond));

// Set new diamond address as admin
address newDiamond = address(0x7);
vm.startPrank(admin);

vm.expectEmit(true, true, true, true);
emit DiamondAddressUpdated(newDiamond);
timelock.setDiamondAddress(newDiamond);

// Verify new diamond address
assertEq(timelock.diamond(), newDiamond);
vm.stopPrank();
}

function testRevert_WhenUnauthorizedUserTriesToSetDiamondAddress() public {
address newDiamond = address(0x7);
vm.startPrank(unauthorized);

vm.expectRevert();
timelock.setDiamondAddress(newDiamond);

vm.stopPrank();
}

function test_UnpauseDiamondWithBlacklist() public {
vm.startPrank(admin);

address[] memory blacklist = new address[](2);
blacklist[0] = address(0x5);
blacklist[1] = address(0x6);

timelock.unpauseDiamond(blacklist);

assertTrue(mockDiamond.unpaused());
assertEq(mockDiamond.blacklist(0), blacklist[0]);
assertEq(mockDiamond.blacklist(1), blacklist[1]);

vm.stopPrank();
}

function testRevert_WhenCallingUnpauseDiamondDirectlyOnMockDiamond()
public
{
address[] memory blacklist = new address[](1);
blacklist[0] = address(0x5);

// Try to call unpauseDiamond directly on the mock diamond
vm.expectRevert("Ownable: caller is not the owner");
mockDiamond.unpauseDiamond(blacklist);
}

function testRevert_WhenUnauthorizedUserTriesToUnpauseDiamond() public {
// Verify ownership
assertEq(mockDiamond.owner(), address(timelock));

// Try to call as unauthorized user
vm.startPrank(unauthorized);

address[] memory blacklist = new address[](1);
blacklist[0] = address(0x5);

vm.expectRevert();
timelock.unpauseDiamond(blacklist);

vm.stopPrank();
}

function test_AdminCanGrantAndRevokeRoles() public {
bytes32 adminRole = timelock.TIMELOCK_ADMIN_ROLE();
address newAdmin = address(0x5);

// Grant role
vm.startPrank(admin);
vm.expectEmit(true, true, true, true);
emit RoleGranted(adminRole, newAdmin, admin);
timelock.grantRole(adminRole, newAdmin);
assertTrue(timelock.hasRole(adminRole, newAdmin));

// Revoke role
vm.expectEmit(true, true, true, true);
emit RoleRevoked(adminRole, newAdmin, admin);
timelock.revokeRole(adminRole, newAdmin);
assertFalse(timelock.hasRole(adminRole, newAdmin));

vm.stopPrank();
}

function test_EnforcesMinDelayForScheduledOperations() public {
// First grant PROPOSER_ROLE to the proposer if not already granted in constructor
bytes32 proposerRole = timelock.PROPOSER_ROLE();
bytes32 executorRole = timelock.EXECUTOR_ROLE();

vm.startPrank(admin);
timelock.grantRole(proposerRole, proposer);
timelock.grantRole(executorRole, executor);
vm.stopPrank();

vm.startPrank(proposer);

bytes memory data = abi.encodeWithSelector(
timelock.updateDelay.selector,
2 days
);

timelock.schedule(
address(timelock),
0,
data,
bytes32(0),
bytes32(0),
MIN_DELAY
);

vm.stopPrank();

// Switch to executor for execution
vm.startPrank(executor);

// Try to execute before delay
vm.expectRevert("TimelockController: operation is not ready");
timelock.execute(address(timelock), 0, data, bytes32(0), bytes32(0));

// Wait for delay
vm.warp(block.timestamp + MIN_DELAY);

// Should succeed now
timelock.execute(address(timelock), 0, data, bytes32(0), bytes32(0));

assertEq(timelock.getMinDelay(), 2 days);

vm.stopPrank();
}

receive() external payable {}
}

0 comments on commit 1173477

Please sign in to comment.