// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; import {Script, console} from "forge-std/Script.sol"; /** * @title Rotate Chain 138 admin/owner roles away from compromised deployer EOA * @notice Transfer-then-revoke rotation for: * - CompliantUSDT (Ownable owner + AccessControl DEFAULT_ADMIN_ROLE) * - CompliantUSDC (Ownable owner + AccessControl DEFAULT_ADMIN_ROLE) * - DODOPMMIntegration (AccessControl DEFAULT_ADMIN_ROLE + * POOL_MANAGER_ROLE + SWAP_OPERATOR_ROLE) * * The scripts here DO NOT broadcast by default. They simulate via * `forge script` and print the calldata + expected state transitions. * Broadcasting requires explicit `--broadcast` plus per-tx operator * approval (see scripts/rotation/chain138-rotation-runbook.md). * * Two scripts are exposed: * - RotateStage1 — signed by the OLD (compromised) key. Transfers * Ownable ownership + grants AccessControl admin to NEW_ADMIN. * Does NOT revoke old admin yet (revocation happens in stage 2 * once the new admin has confirmed it can act). * - RotateStage2 — signed by the NEW key. Revokes DEFAULT_ADMIN_ROLE * (and other roles) from the OLD deployer. * * @dev Canonical addresses come from docs/11-references/EXPLORER_TOKEN_LIST_CROSSCHECK.md * and ADDRESS_MATRIX_AND_STATUS.md — DO NOT change them here without * updating both docs. */ interface IOwnable { function owner() external view returns (address); function transferOwnership(address newOwner) external; } interface IAccessControl { function hasRole(bytes32 role, address account) external view returns (bool); function grantRole(bytes32 role, address account) external; function revokeRole(bytes32 role, address account) external; function renounceRole(bytes32 role, address account) external; function DEFAULT_ADMIN_ROLE() external view returns (bytes32); } abstract contract Chain138RotationBase is Script { // ---- Canonical Chain 138 addresses (rules/chain138-tokens-and-pmm.mdc) ---- address internal constant DEFAULT_CUSDT = 0x93E66202A11B1772E55407B32B44e5Cd8eda7f22; address internal constant DEFAULT_CUSDC = 0xf22258f57794CC8E06237084b353Ab30fFfa640b; address internal constant DEFAULT_DODO_PMM_INTEGRATION = 0x5BDc62f1ae7D630c37A8B363a1d49845356Ee72d; address internal constant DEFAULT_OLD_DEPLOYER = 0x4A666F96fC8764181194447A7dFdb7d471b301C8; // Roles on DODOPMMIntegration (see contracts/dex/DODOPMMIntegration.sol) bytes32 internal constant DEFAULT_ADMIN_ROLE = 0x00; bytes32 internal constant POOL_MANAGER_ROLE = keccak256("POOL_MANAGER_ROLE"); bytes32 internal constant SWAP_OPERATOR_ROLE = keccak256("SWAP_OPERATOR_ROLE"); struct Targets { address cUSDT; address cUSDC; address dodoPmmIntegration; address oldDeployer; address newAdmin; } function _loadTargets() internal view returns (Targets memory t) { t.cUSDT = vm.envOr("CUSDT_ADDRESS", DEFAULT_CUSDT); t.cUSDC = vm.envOr("CUSDC_ADDRESS", DEFAULT_CUSDC); t.dodoPmmIntegration = vm.envOr( "DODO_PMM_INTEGRATION_ADDRESS", DEFAULT_DODO_PMM_INTEGRATION ); t.oldDeployer = vm.envOr("OLD_DEPLOYER_ADDRESS", DEFAULT_OLD_DEPLOYER); t.newAdmin = vm.envAddress("NEW_ADMIN_ADDRESS"); require(t.newAdmin != address(0), "NEW_ADMIN_ADDRESS not set"); require(t.newAdmin != t.oldDeployer, "NEW_ADMIN must differ from OLD"); } function _dumpTargets(Targets memory t, string memory label) internal pure { console.log("== Chain 138 rotation targets (%s) ==", label); console.log(" cUSDT :", t.cUSDT); console.log(" cUSDC :", t.cUSDC); console.log(" DODOPMMIntegration :", t.dodoPmmIntegration); console.log(" OLD deployer :", t.oldDeployer); console.log(" NEW admin :", t.newAdmin); } function _requireOwner( address contractAddr, address expectedOwner, string memory label ) internal view { address cur = IOwnable(contractAddr).owner(); require( cur == expectedOwner, string.concat( label, ": current owner != expected (aborting rotation)" ) ); } function _requireHasRole( address contractAddr, bytes32 role, address account, string memory label ) internal view { require( IAccessControl(contractAddr).hasRole(role, account), string.concat(label, ": account does not hold role (aborting)") ); } function _requireLacksRole( address contractAddr, bytes32 role, address account, string memory label ) internal view { require( !IAccessControl(contractAddr).hasRole(role, account), string.concat(label, ": account unexpectedly holds role") ); } } /** * @title RotateStage1 — signed by OLD deployer * @notice Transfers Ownable + grants DEFAULT_ADMIN_ROLE to NEW_ADMIN. * Does NOT revoke from OLD (that is Stage 2, signed by NEW). * * Required env: * PRIVATE_KEY — private key of the OLD deployer (compromised, * will be rotated off all other chains too) * NEW_ADMIN_ADDRESS — new admin EOA or multisig * * Optional env (defaults to canonical Chain 138 addresses): * CUSDT_ADDRESS * CUSDC_ADDRESS * DODO_PMM_INTEGRATION_ADDRESS * OLD_DEPLOYER_ADDRESS * * Usage: * # simulate (default; safe): * forge script script/rotation/RotateChain138Admin.s.sol:RotateStage1 \ * --rpc-url "$RPC_URL_138" -vvv * # broadcast (requires per-tx operator approval): * forge script script/rotation/RotateChain138Admin.s.sol:RotateStage1 \ * --rpc-url "$RPC_URL_138" --broadcast --slow -vvv */ contract RotateStage1 is Chain138RotationBase { function run() external { Targets memory t = _loadTargets(); _dumpTargets(t, "stage 1 - OLD signs"); // Preflight — abort if on-chain state does not match pre-rotation. _requireOwner(t.cUSDT, t.oldDeployer, "cUSDT"); _requireOwner(t.cUSDC, t.oldDeployer, "cUSDC"); _requireHasRole(t.cUSDT, DEFAULT_ADMIN_ROLE, t.oldDeployer, "cUSDT ADMIN"); _requireHasRole(t.cUSDC, DEFAULT_ADMIN_ROLE, t.oldDeployer, "cUSDC ADMIN"); _requireHasRole( t.dodoPmmIntegration, DEFAULT_ADMIN_ROLE, t.oldDeployer, "DODO ADMIN" ); _requireLacksRole(t.cUSDT, DEFAULT_ADMIN_ROLE, t.newAdmin, "cUSDT new-admin"); _requireLacksRole(t.cUSDC, DEFAULT_ADMIN_ROLE, t.newAdmin, "cUSDC new-admin"); _requireLacksRole( t.dodoPmmIntegration, DEFAULT_ADMIN_ROLE, t.newAdmin, "DODO new-admin" ); uint256 pk = vm.envUint("PRIVATE_KEY"); address signer = vm.addr(pk); require( signer == t.oldDeployer, "PRIVATE_KEY does not match OLD_DEPLOYER_ADDRESS (stage 1 must be signed by OLD)" ); vm.startBroadcast(pk); // --- cUSDT --- IOwnable(t.cUSDT).transferOwnership(t.newAdmin); IAccessControl(t.cUSDT).grantRole(DEFAULT_ADMIN_ROLE, t.newAdmin); console.log("cUSDT: ownership transferred + DEFAULT_ADMIN_ROLE granted to NEW"); // --- cUSDC --- IOwnable(t.cUSDC).transferOwnership(t.newAdmin); IAccessControl(t.cUSDC).grantRole(DEFAULT_ADMIN_ROLE, t.newAdmin); console.log("cUSDC: ownership transferred + DEFAULT_ADMIN_ROLE granted to NEW"); // --- DODOPMMIntegration (AccessControl only — no Ownable) --- IAccessControl(t.dodoPmmIntegration).grantRole(DEFAULT_ADMIN_ROLE, t.newAdmin); IAccessControl(t.dodoPmmIntegration).grantRole(POOL_MANAGER_ROLE, t.newAdmin); IAccessControl(t.dodoPmmIntegration).grantRole(SWAP_OPERATOR_ROLE, t.newAdmin); console.log( "DODOPMMIntegration: DEFAULT_ADMIN + POOL_MANAGER + SWAP_OPERATOR granted to NEW" ); vm.stopBroadcast(); console.log("\n== Stage 1 complete =="); console.log("Next: NEW admin runs RotateStage2 to revoke OLD."); console.log("Verify with VerifyChain138RotationComplete (read-only)."); } } /** * @title RotateStage2 — signed by NEW admin * @notice Revokes DEFAULT_ADMIN_ROLE (and DODO pool/swap roles) from OLD * deployer on cUSDT, cUSDC, DODOPMMIntegration. Must only run after * Stage 1 has completed and the NEW admin has confirmed it can * successfully call an admin-gated function (e.g. pause/unpause on a * test-only token, or `setReserveSystem` on DODOPMMIntegration). * * Required env: * PRIVATE_KEY — private key of the NEW admin * NEW_ADMIN_ADDRESS — must match the key above; used for sanity * * Optional env: same as Stage 1. */ contract RotateStage2 is Chain138RotationBase { function run() external { Targets memory t = _loadTargets(); _dumpTargets(t, "stage 2 - NEW signs"); // Preflight — new admin must already hold the roles; old admin still has them. _requireHasRole(t.cUSDT, DEFAULT_ADMIN_ROLE, t.newAdmin, "cUSDT new-admin"); _requireHasRole(t.cUSDC, DEFAULT_ADMIN_ROLE, t.newAdmin, "cUSDC new-admin"); _requireHasRole( t.dodoPmmIntegration, DEFAULT_ADMIN_ROLE, t.newAdmin, "DODO new-admin" ); _requireHasRole(t.cUSDT, DEFAULT_ADMIN_ROLE, t.oldDeployer, "cUSDT OLD"); _requireHasRole(t.cUSDC, DEFAULT_ADMIN_ROLE, t.oldDeployer, "cUSDC OLD"); _requireHasRole( t.dodoPmmIntegration, DEFAULT_ADMIN_ROLE, t.oldDeployer, "DODO OLD" ); require( IOwnable(t.cUSDT).owner() == t.newAdmin, "cUSDT owner != NEW (stage 1 missed?)" ); require( IOwnable(t.cUSDC).owner() == t.newAdmin, "cUSDC owner != NEW (stage 1 missed?)" ); uint256 pk = vm.envUint("PRIVATE_KEY"); address signer = vm.addr(pk); require( signer == t.newAdmin, "PRIVATE_KEY does not match NEW_ADMIN_ADDRESS (stage 2 must be signed by NEW)" ); vm.startBroadcast(pk); // --- cUSDT --- IAccessControl(t.cUSDT).revokeRole(DEFAULT_ADMIN_ROLE, t.oldDeployer); console.log("cUSDT: DEFAULT_ADMIN_ROLE revoked from OLD"); // --- cUSDC --- IAccessControl(t.cUSDC).revokeRole(DEFAULT_ADMIN_ROLE, t.oldDeployer); console.log("cUSDC: DEFAULT_ADMIN_ROLE revoked from OLD"); // --- DODOPMMIntegration --- IAccessControl(t.dodoPmmIntegration).revokeRole(SWAP_OPERATOR_ROLE, t.oldDeployer); IAccessControl(t.dodoPmmIntegration).revokeRole(POOL_MANAGER_ROLE, t.oldDeployer); IAccessControl(t.dodoPmmIntegration).revokeRole(DEFAULT_ADMIN_ROLE, t.oldDeployer); console.log( "DODOPMMIntegration: DEFAULT_ADMIN + POOL_MANAGER + SWAP_OPERATOR revoked from OLD" ); vm.stopBroadcast(); console.log("\n== Stage 2 complete =="); console.log("Run VerifyChain138RotationComplete to confirm end state."); } } /** * @title VerifyChain138RotationComplete — read-only end-state check * @notice Fails (reverts) if any contract still lists OLD as owner or admin. * Safe to run without a funded signer; no broadcast. */ contract VerifyChain138RotationComplete is Chain138RotationBase { function run() external view { Targets memory t = _loadTargets(); _dumpTargets(t, "verification"); // Ownable require( IOwnable(t.cUSDT).owner() == t.newAdmin, "cUSDT owner != NEW" ); require( IOwnable(t.cUSDC).owner() == t.newAdmin, "cUSDC owner != NEW" ); // AccessControl — NEW holds admin, OLD does not _requireHasRole(t.cUSDT, DEFAULT_ADMIN_ROLE, t.newAdmin, "cUSDT new-admin"); _requireHasRole(t.cUSDC, DEFAULT_ADMIN_ROLE, t.newAdmin, "cUSDC new-admin"); _requireHasRole( t.dodoPmmIntegration, DEFAULT_ADMIN_ROLE, t.newAdmin, "DODO new-admin" ); _requireLacksRole(t.cUSDT, DEFAULT_ADMIN_ROLE, t.oldDeployer, "cUSDT OLD"); _requireLacksRole(t.cUSDC, DEFAULT_ADMIN_ROLE, t.oldDeployer, "cUSDC OLD"); _requireLacksRole( t.dodoPmmIntegration, DEFAULT_ADMIN_ROLE, t.oldDeployer, "DODO OLD" ); _requireLacksRole( t.dodoPmmIntegration, POOL_MANAGER_ROLE, t.oldDeployer, "DODO POOL_MANAGER OLD" ); _requireLacksRole( t.dodoPmmIntegration, SWAP_OPERATOR_ROLE, t.oldDeployer, "DODO SWAP_OPERATOR OLD" ); console.log("\nPASS: rotation complete. OLD deployer has no admin authority on target contracts."); } }