diff --git a/.env.template b/.env.template index ac8d8a98..f06d8e55 100644 --- a/.env.template +++ b/.env.template @@ -37,3 +37,8 @@ ETHERSCAN_API_KEY= # =========================================== # Recipient address for cross-chain transfers RECIPIENT_ADDRESS= + +# =========================================== +# ADMIN CONFIGURATION +# =========================================== +NEW_DEFAULT_ADMIN= \ No newline at end of file diff --git a/.github/workflows/transfer-admin-role.yml b/.github/workflows/transfer-admin-role.yml new file mode 100644 index 00000000..f5f37199 --- /dev/null +++ b/.github/workflows/transfer-admin-role.yml @@ -0,0 +1,44 @@ +name: Transfer Default Admin Role + +on: + workflow_dispatch: + inputs: + network: + description: 'Network to transfer admin role on' + required: true + type: choice + options: + - ethereum + - arbitrum + - sepolia + - arbitrum_sepolia + default: sepolia + new_default_admin_address: + description: 'New admin address' + required: true + type: string + +jobs: +# TODO: check if setup-matrix is needed + transfer-admin: + runs-on: ubuntu-latest + environment: ${{ inputs.network }} + + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + with: + version: stable + cache: true + + - name: Transfer default admin role + env: + ADMIN_PRIVATE_KEY: ${{ secrets.ADMIN_PRIVATE_KEY }} + CHAIN: ${{ inputs.network }} + RPC_URL: ${{ secrets.RPC_URL }} + NEW_DEFAULT_ADMIN: ${{ inputs.new_default_admin_address }} + run: make begin-default-admin-transfer diff --git a/Makefile b/Makefile index 824e345a..dafa0a98 100644 --- a/Makefile +++ b/Makefile @@ -193,3 +193,25 @@ send-tokens-to-ethereum-mainnet: --account $(ACCOUNT) \ --broadcast \ -vvv + +# +# Admin role transfer operations +# + +# Transfer admin role for a single chain +begin-default-admin-transfer: # CHAIN, RPC_URL, NEW_DEFAULT_ADMIN + @echo "Transferring admin role on $(CHAIN) to: $(NEW_DEFAULT_ADMIN)" + CHAIN=$(CHAIN) NEW_DEFAULT_ADMIN=$(NEW_DEFAULT_ADMIN) forge script script/TransferAdminRole.s.sol:BeginTransferAdminRole \ + --rpc-url $(RPC_URL) \ + $$(if [ "$(CI)" = "true" ]; then echo "--private-key $(ADMIN_PRIVATE_KEY)"; else echo "--account $(ACCOUNT)"; fi) \ + --broadcast \ + -vvv + +# Accept admin role for a single chain (run by new admin) +accept-default-admin-transfer: # CHAIN, RPC_URL + @echo "Accepting admin role on $(CHAIN)" + CHAIN=$(CHAIN) forge script script/TransferAdminRole.s.sol:AcceptAdminRole \ + --rpc-url $(RPC_URL) \ + $$(if [ "$(CI)" = "true" ]; then echo "--private-key $(NEW_DEFAULT_ADMIN_PRIVATE_KEY)"; else echo "--account $(ACCOUNT)"; fi) \ + --broadcast \ + -vvv diff --git a/script/TransferAdminRole.s.sol b/script/TransferAdminRole.s.sol new file mode 100644 index 00000000..e894538f --- /dev/null +++ b/script/TransferAdminRole.s.sol @@ -0,0 +1,125 @@ +// SPDX-FileCopyrightText: 2025 IEXEC BLOCKCHAIN TECH +// SPDX-License-Identifier: Apache-2.0 + +pragma solidity ^0.8.22; + +import {Script} from "forge-std/Script.sol"; +import {console} from "forge-std/console.sol"; +import {IAccessControlDefaultAdminRules} from + "@openzeppelin/contracts/access/extensions/IAccessControlDefaultAdminRules.sol"; +import {ConfigLib} from "./lib/ConfigLib.sol"; +import {RLCLiquidityUnifier} from "../src/RLCLiquidityUnifier.sol"; +import {RLCCrosschainToken} from "../src/RLCCrosschainToken.sol"; +import {IexecLayerZeroBridge} from "../src/bridges/layerZero/IexecLayerZeroBridge.sol"; + +/** + * @title BeginTransferAdminRole + * @dev Script to transfer the default admin role to a new admin address + * for all deployed smart contracts on the current chain. + */ +contract BeginTransferAdminRole is Script { + /** + * @notice Transfers the default admin role to a new admin for all contracts on the current chain + * @dev This function automatically detects which contracts are deployed on the current chain + * based on the configuration and transfers admin roles accordingly + */ + function run() external virtual { + address newAdmin = vm.envAddress("NEW_DEFAULT_ADMIN"); + string memory chain = vm.envString("CHAIN"); + console.log("Starting admin role transfer on chain:", chain); + console.log("New admin address:", newAdmin); + + ConfigLib.CommonConfigParams memory params = ConfigLib.readCommonConfig(chain); + vm.startBroadcast(); + beginTransferForAllContracts(params, newAdmin); + vm.stopBroadcast(); + } + /** + * @notice Validates that the new admin is different from the current admin + * @param currentDefaultAdmin The current admin address + * @param newAdmin The new admin address + */ + + function validateAdminTransfer(address currentDefaultAdmin, address newAdmin) internal pure { + require(newAdmin != address(0), "BeginTransferAdminRole: new admin cannot be zero address"); + require( + newAdmin != currentDefaultAdmin, "BeginTransferAdminRole: New admin must be different from current admin" + ); + } + + /** + * @notice Begins the admin transfer process for all relevant contracts + * @param params The configuration parameters for the current chain + * @param newAdmin The new admin address + */ + function beginTransferForAllContracts(ConfigLib.CommonConfigParams memory params, address newAdmin) internal { + if (params.approvalRequired) { + beginTransfer(params.rlcLiquidityUnifierAddress, newAdmin, "RLCLiquidityUnifier"); + } else { + beginTransfer(params.rlcCrosschainTokenAddress, newAdmin, "RLCCrosschainToken"); + } + beginTransfer(params.iexecLayerZeroBridgeAddress, newAdmin, "IexecLayerZeroBridge"); + } + + /** + * @notice Transfers the default admin role for any contract implementing IAccessControlDefaultAdminRules + * @param contractAddress The address of the contract + * @param newAdmin The new admin address + * @param contractName The name of the contract for logging purposes + */ + function beginTransfer(address contractAddress, address newAdmin, string memory contractName) public virtual { + IAccessControlDefaultAdminRules contractInstance = IAccessControlDefaultAdminRules(contractAddress); + + address currentAdmin = contractInstance.defaultAdmin(); + console.log("Current admin for", contractName, ":", currentAdmin); + validateAdminTransfer(currentAdmin, newAdmin); + contractInstance.beginDefaultAdminTransfer(newAdmin); + console.log("Admin transfer initiated for", contractName, "at:", contractAddress); + } +} + +/** + * @title AcceptAdminRole + * @dev Script to accept the default admin role transfer for all contracts on the current chain. + * This script should be run by the new admin after the BeginTransferAdminRole script has been executed. + */ +contract AcceptAdminRole is Script { + /** + * @notice Accepts the default admin role transfer for all contracts on the current chain + * @dev This function should be called by the new admin to complete the transfer process + */ + function run() external virtual { + string memory chain = vm.envString("CHAIN"); + console.log("Accepting admin role transfer on chain:", chain); + ConfigLib.CommonConfigParams memory params = ConfigLib.readCommonConfig(chain); + + vm.startBroadcast(); + acceptAdminRoleTransfer(params); + vm.stopBroadcast(); + } + + /** + * @notice Accepts the default admin role transfer for all contracts on the current chain + * @dev This function should be called by the new admin to complete the transfer process + */ + function acceptAdminRoleTransfer(ConfigLib.CommonConfigParams memory params) internal { + if (params.approvalRequired) { + acceptContractAdmin(params.rlcLiquidityUnifierAddress, "RLCLiquidityUnifier"); + } else { + acceptContractAdmin(params.rlcCrosschainTokenAddress, "RLCCrosschainToken"); + } + acceptContractAdmin(params.iexecLayerZeroBridgeAddress, "IexecLayerZeroBridge"); + } + + /** + * @notice Accepts the default admin role transfer for any contract implementing IAccessControlDefaultAdminRules + * @param contractAddress The address of the contract + * @param contractName The name of the contract for logging purposes + */ + function acceptContractAdmin(address contractAddress, string memory contractName) internal virtual { + console.log("Accepting admin role for", contractName, "at:", contractAddress); + IAccessControlDefaultAdminRules contractInstance = IAccessControlDefaultAdminRules(contractAddress); + contractInstance.acceptDefaultAdminTransfer(); + console.log("New admin for", contractName, ":", contractInstance.defaultAdmin()); + } +} diff --git a/test/units/TransferAdminRoleScript.t.sol b/test/units/TransferAdminRoleScript.t.sol new file mode 100644 index 00000000..47810683 --- /dev/null +++ b/test/units/TransferAdminRoleScript.t.sol @@ -0,0 +1,282 @@ +// SPDX-FileCopyrightText: 2025 IEXEC BLOCKCHAIN TECH +// SPDX-License-Identifier: Apache-2.0 + +pragma solidity ^0.8.22; + +import {BeginTransferAdminRole, AcceptAdminRole} from "../../script/TransferAdminRole.s.sol"; +import {TestHelperOz5} from "@layerzerolabs/test-devtools-evm-foundry/contracts/TestHelperOz5.sol"; +import {IAccessControlDefaultAdminRules} from + "@openzeppelin/contracts/access/extensions/IAccessControlDefaultAdminRules.sol"; +import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; +import {TestUtils} from "./utils/TestUtils.sol"; +import {RLCLiquidityUnifier} from "../../src/RLCLiquidityUnifier.sol"; +import {RLCCrosschainToken} from "../../src/RLCCrosschainToken.sol"; +import {IexecLayerZeroBridge} from "../../src/bridges/layerZero/IexecLayerZeroBridge.sol"; +import {CreateX} from "@createx/contracts/CreateX.sol"; +import {ConfigLib} from "./../../script/lib/ConfigLib.sol"; + +contract TransferAdminRoleScriptTest is TestHelperOz5, BeginTransferAdminRole, AcceptAdminRole { + using TestUtils for *; + + // Test addresses + address private newAdmin = makeAddr("newAdmin"); + address private admin = makeAddr("admin"); + address private upgrader = makeAddr("upgrader"); + address private pauser = makeAddr("pauser"); + uint16 private sourceEndpointId = 1; + uint16 private targetEndpointId = 2; + RLCLiquidityUnifier rlcLiquidityUnifier; + RLCCrosschainToken rlcCrosschainToken; + IexecLayerZeroBridge iexecLayerZeroBridgeL1; + IexecLayerZeroBridge iexecLayerZeroBridgeL2; + ConfigLib.CommonConfigParams params; + TestUtils.DeploymentResult deployment; + + function setUp() public virtual override { + super.setUp(); + setUpEndpoints(2, LibraryType.UltraLightNode); + deployment = TestUtils.setupDeployment( + TestUtils.DeploymentParams({ + iexecLayerZeroBridgeContractName: "IexecLayerZeroBridge", + lzEndpointSource: endpoints[sourceEndpointId], + lzEndpointDestination: endpoints[targetEndpointId], + initialAdmin: admin, + initialUpgrader: upgrader, + initialPauser: pauser + }) + ); + rlcLiquidityUnifier = deployment.rlcLiquidityUnifier; + rlcCrosschainToken = deployment.rlcCrosschainToken; + iexecLayerZeroBridgeL1 = deployment.iexecLayerZeroBridgeWithApproval; + iexecLayerZeroBridgeL2 = deployment.iexecLayerZeroBridgeWithoutApproval; + } + + // Override run functions to resolve inheritance conflict + function run() external pure override(BeginTransferAdminRole, AcceptAdminRole) { + // This function should not be called directly in tests + // Use super.beginTransferForAllContracts() or super.acceptAdminRoleTransfer() instead + revert("Use specific test functions instead"); + } + + // ====== BeginTransferAdminRole.validateAdminTransfer ====== + function test_ValidateAdminTransfer() public { + vm.startPrank(admin); + super.beginTransfer(address(rlcLiquidityUnifier), newAdmin, "RLCLiquidityUnifier"); + vm.stopPrank(); + } + + function test_ValidateAdminTransfer_RevertWhen_NewAdminIsZeroAddress() public { + vm.startPrank(admin); + vm.expectRevert("BeginTransferAdminRole: new admin cannot be zero address"); + this.beginTransfer(address(rlcLiquidityUnifier), address(0), "RLCLiquidityUnifier"); + vm.stopPrank(); + } + + function test_ValidateAdminTransfer_RevertWhen_NewAdminIsSameAsCurrentAdmin() public { + vm.startPrank(admin); + vm.expectRevert("BeginTransferAdminRole: New admin must be different from current admin"); + this.beginTransfer(address(rlcLiquidityUnifier), admin, "RLCLiquidityUnifier"); + vm.stopPrank(); + } + + // ====== BeginTransferAdminRole.beginTransfer ====== + function test_BeginTransfer_LiquidityUnifier() public { + vm.startPrank(admin); + assertEq(IAccessControlDefaultAdminRules(address(rlcLiquidityUnifier)).defaultAdmin(), admin); + super.beginTransfer(address(rlcLiquidityUnifier), newAdmin, "RLCLiquidityUnifier"); + // Verify that the admin transfer has been initiated + (address pendingAdmin,) = IAccessControlDefaultAdminRules(address(rlcLiquidityUnifier)).pendingDefaultAdmin(); + assertEq(pendingAdmin, newAdmin); + + // Current admin should still be the initial admin until acceptance + assertEq(IAccessControlDefaultAdminRules(address(rlcLiquidityUnifier)).defaultAdmin(), admin); + vm.stopPrank(); + } + + function test_BeginTransferAdminRole_Run_ApprovalRequired() public { + buildParams(true); + vm.startPrank(admin); + super.beginTransferForAllContracts(params, newAdmin); + vm.stopPrank(); + + // Verify that the admin transfer has been initiated for approval required (RLCLiquidityUnifier) + (address pendingAdmin,) = IAccessControlDefaultAdminRules(address(rlcLiquidityUnifier)).pendingDefaultAdmin(); + assertEq(pendingAdmin, newAdmin); + + // RLCCrosschainToken should not have pending admin since approvalRequired = true + (pendingAdmin,) = IAccessControlDefaultAdminRules(address(rlcCrosschainToken)).pendingDefaultAdmin(); + assertEq(pendingAdmin, address(0)); + + (pendingAdmin,) = IAccessControlDefaultAdminRules(address(iexecLayerZeroBridgeL1)).pendingDefaultAdmin(); + assertEq(pendingAdmin, newAdmin); + } + + function test_BeginTransferAdminRole_Run_AllContracts_NoApprovalRequired() public { + buildParams(false); + vm.startPrank(admin); + super.beginTransferForAllContracts(params, newAdmin); + vm.stopPrank(); + + // Verify that the admin transfer has been initiated for no approval required (RLCCrosschainToken) + (address pendingAdmin,) = IAccessControlDefaultAdminRules(address(rlcCrosschainToken)).pendingDefaultAdmin(); + assertEq(pendingAdmin, newAdmin); + + // RLCLiquidityUnifier should not have pending admin since approvalRequired = false + (pendingAdmin,) = IAccessControlDefaultAdminRules(address(rlcLiquidityUnifier)).pendingDefaultAdmin(); + assertEq(pendingAdmin, address(0)); + + (pendingAdmin,) = IAccessControlDefaultAdminRules(address(iexecLayerZeroBridgeL2)).pendingDefaultAdmin(); + assertEq(pendingAdmin, newAdmin); + } + + // ====== revert scenarios checks ====== + function test_BeginTransfer_RevertWhen_NewAdminIsZeroAddress() public { + vm.startPrank(admin); + vm.expectRevert("BeginTransferAdminRole: new admin cannot be zero address"); + this.beginTransfer(address(rlcLiquidityUnifier), address(0), "RLCLiquidityUnifier"); + vm.stopPrank(); + } + + function test_BeginTransfer_RevertWhen_NewAdminIsSameAsCurrentAdmin() public { + vm.startPrank(admin); + vm.expectRevert("BeginTransferAdminRole: New admin must be different from current admin"); + this.beginTransfer(address(rlcLiquidityUnifier), admin, "RLCLiquidityUnifier"); + vm.stopPrank(); + } + + function test_BeginTransfer_RevertWhen_NotAuthorizedToTransferAdmin() public { + address unauthorizedUser = makeAddr("unauthorizedUser"); + vm.startPrank(unauthorizedUser); + vm.expectRevert( + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, address(this), bytes32(0)) + ); + this.beginTransfer(address(rlcLiquidityUnifier), newAdmin, "RLCLiquidityUnifier"); + vm.stopPrank(); + } + + // ====== AcceptAdminRole.acceptContractAdmin ====== + function test_AcceptAdminRole_LiquidityUnifier() public { + beginTransferAsAdmin(address(rlcLiquidityUnifier), newAdmin, "RLCLiquidityUnifier"); + waitForAdminTransferDelay(address(rlcLiquidityUnifier)); + + vm.prank(newAdmin); + super.acceptContractAdmin(address(rlcLiquidityUnifier), "RLCLiquidityUnifier"); + assertEq(IAccessControlDefaultAdminRules(address(rlcLiquidityUnifier)).defaultAdmin(), newAdmin); + + (address pendingAdmin,) = IAccessControlDefaultAdminRules(address(rlcLiquidityUnifier)).pendingDefaultAdmin(); + assertEq(pendingAdmin, address(0)); + } + + function test_AcceptAdminRole_Run_ApprovalRequired() public { + beginTransferForAllContractsAsAdmin(newAdmin, true); + vm.stopPrank(); + + // Get the delay schedule and wait for it to pass + waitForAdminTransferDelay(address(rlcLiquidityUnifier)); + + // Accept admin role with approval required = true + buildParams(true); + vm.startPrank(newAdmin); + super.acceptAdminRoleTransfer(params); + vm.stopPrank(); + + assertEq(IAccessControlDefaultAdminRules(address(rlcLiquidityUnifier)).defaultAdmin(), newAdmin); + assertEq(IAccessControlDefaultAdminRules(address(iexecLayerZeroBridgeL1)).defaultAdmin(), newAdmin); + + // Verify that RLCCrosschainToken admin was not affected + assertEq(IAccessControlDefaultAdminRules(address(rlcCrosschainToken)).defaultAdmin(), admin); + } + + function test_AcceptAdminRole_Run_NoApprovalRequired() public { + beginTransferForAllContractsAsAdmin(newAdmin, false); + waitForAdminTransferDelay(address(rlcCrosschainToken)); + + // Accept admin role with approval required = false + buildParams(false); + vm.startPrank(newAdmin); + super.acceptAdminRoleTransfer(params); + vm.stopPrank(); + assertEq(IAccessControlDefaultAdminRules(address(rlcCrosschainToken)).defaultAdmin(), newAdmin); + assertEq(IAccessControlDefaultAdminRules(address(iexecLayerZeroBridgeL2)).defaultAdmin(), newAdmin); + + // Verify that RLCLiquidityUnifier admin was not affected + assertEq(IAccessControlDefaultAdminRules(address(rlcLiquidityUnifier)).defaultAdmin(), admin); + } + + function test_AcceptAdminRole_RevertWhen_WrongAddressTriesToAccept() public { + beginTransferAsAdmin(address(rlcLiquidityUnifier), newAdmin, "RLCLiquidityUnifier"); + waitForAdminTransferDelay(address(rlcLiquidityUnifier)); + + // Try to accept with wrong address + address wrongAddress = makeAddr("wrongAddress"); + vm.startPrank(wrongAddress); + vm.expectRevert( + abi.encodeWithSelector( + IAccessControlDefaultAdminRules.AccessControlInvalidDefaultAdmin.selector, wrongAddress + ) + ); + super.acceptContractAdmin(address(rlcLiquidityUnifier), "RLCLiquidityUnifier"); + vm.stopPrank(); + } + + function test_AcceptAdminRole_RevertWhen_DelayNotElapsed() public { + beginTransferAsAdmin(address(rlcLiquidityUnifier), newAdmin, "RLCLiquidityUnifier"); + + // Try to accept immediately without waiting for the delay + vm.startPrank(newAdmin); + vm.expectRevert( + abi.encodeWithSelector(IAccessControlDefaultAdminRules.AccessControlEnforcedDefaultAdminDelay.selector, 1) + ); + super.acceptContractAdmin(address(rlcLiquidityUnifier), "RLCLiquidityUnifier"); + vm.stopPrank(); + } + + /*////////////////////////////////////////////////////////////// + HELPER FUNCTION + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Helper function to configure params based on approval requirement + * @param approvalRequired Whether approval is required for the transfer + */ + function buildParams(bool approvalRequired) internal { + params.approvalRequired = approvalRequired; + params.iexecLayerZeroBridgeAddress = + approvalRequired ? address(iexecLayerZeroBridgeL1) : address(iexecLayerZeroBridgeL2); + params.rlcLiquidityUnifierAddress = approvalRequired ? address(rlcLiquidityUnifier) : address(0); + params.rlcCrosschainTokenAddress = approvalRequired ? address(0) : address(rlcCrosschainToken); + } + + /** + * @notice Helper function to initiate the admin transfer process + * @param _newAdmin The address of the new admin + * @param approvalRequired Whether approval is required for the transfer + */ + function beginTransferForAllContractsAsAdmin(address _newAdmin, bool approvalRequired) internal { + buildParams(approvalRequired); + vm.startPrank(admin); + super.beginTransferForAllContracts(params, _newAdmin); + vm.stopPrank(); + } + + /** + * @notice Helper function to initiate the admin transfer process + * @param contractAddress The address of the contract to transfer admin rights + * @param _newAdmin The address of the new admin + * @param contractName The name of the contract + */ + function beginTransferAsAdmin(address contractAddress, address _newAdmin, string memory contractName) internal { + vm.startPrank(admin); + super.beginTransfer(contractAddress, _newAdmin, contractName); + vm.stopPrank(); + } + + /** + * @notice Helper function to wait for the admin transfer delay to pass + * @param contractAddress The contract address to check the pending admin schedule + */ + function waitForAdminTransferDelay(address contractAddress) internal { + (, uint48 acceptSchedule) = IAccessControlDefaultAdminRules(contractAddress).pendingDefaultAdmin(); + vm.warp(acceptSchedule + 1); + } +} diff --git a/test/units/utils/TestUtils.sol b/test/units/utils/TestUtils.sol index ed48eef3..390c7b23 100644 --- a/test/units/utils/TestUtils.sol +++ b/test/units/utils/TestUtils.sol @@ -15,6 +15,7 @@ import {RLCLiquidityUnifier} from "../../../src/RLCLiquidityUnifier.sol"; import {Deploy as RLCLiquidityUnifierDeployScript} from "../../../script/RLCLiquidityUnifier.s.sol"; import {RLCCrosschainToken} from "../../../src/RLCCrosschainToken.sol"; import {Deploy as RLCCrosschainTokenDeployScript} from "../../../script/RLCCrosschainToken.s.sol"; +import {ConfigLib} from "./../../../script/lib/ConfigLib.sol"; library TestUtils { using OptionsBuilder for bytes;