diff --git a/script/rotation/README.md b/script/rotation/README.md new file mode 100644 index 0000000..1180db9 --- /dev/null +++ b/script/rotation/README.md @@ -0,0 +1,17 @@ +# script/rotation — admin / owner rotation Forge scripts + +Scripts for rotating admin / owner roles away from the compromised Chain 138 +deployer EOA `0x4A666F96fC8764181194447A7dFdb7d471b301C8`. + +**Nothing here broadcasts by default.** Forge simulates unless the operator +appends `--broadcast` explicitly, and even then the per-tx approval gate +applies per the runbook. + +| File | Purpose | +|---|---| +| `RotateChain138Admin.s.sol` | Stage 1 (OLD signs, grants NEW) + Stage 2 (NEW signs, revokes OLD) + read-only verifier | +| (follow-up PRs) | `RotateMainnetAdmin.s.sol`, `RotateCronosAdmin.s.sol`, `RotatePolygonAdmin.s.sol`, `RotateBaseAdmin.s.sol`, `RotateOptimismAdmin.s.sol`, `RotateBscAdmin.s.sol`, `RotateAvalancheAdmin.s.sol`, `RotateArbitrumAdmin.s.sol`, `RotateWemixAdmin.s.sol` | + +Operator runbook: `scripts/rotation/chain138-rotation-runbook.md`. + +Tracking: . diff --git a/script/rotation/RotateChain138Admin.s.sol b/script/rotation/RotateChain138Admin.s.sol new file mode 100644 index 0000000..56ad372 --- /dev/null +++ b/script/rotation/RotateChain138Admin.s.sol @@ -0,0 +1,317 @@ +// 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."); + } +} diff --git a/scripts/rotation/chain138-rotation-runbook.md b/scripts/rotation/chain138-rotation-runbook.md new file mode 100644 index 0000000..9d5c639 --- /dev/null +++ b/scripts/rotation/chain138-rotation-runbook.md @@ -0,0 +1,230 @@ +# Chain 138 Admin Rotation Runbook (Phase 1b) + +**Trigger:** Credential incident — the Chain 138 deployer private key for EOA +`0x4A666F96fC8764181194447A7dFdb7d471b301C8` was committed to `d-bis/proxmox` +git history. Tracking: . + +**Scope (this runbook):** Chain 138 only. +Covered: **cUSDT, cUSDC, DODOPMMIntegration** (DEFAULT_ADMIN_ROLE + Ownable owner; +on DODOPMMIntegration also POOL_MANAGER_ROLE + SWAP_OPERATOR_ROLE). +Out of scope here: ETH mainnet / Cronos / Polygon / Base / Optimism / BSC / +Avalanche / Arbitrum / Wemix. Those follow the same transfer-then-revoke +pattern, with per-chain scripts staged in follow-up PRs once the contracts on +those chains are enumerated. + +**Model:** transfer-then-revoke, two broadcasts per pass. +1. Stage 1 signed by OLD (compromised) deployer — grants NEW admin. +2. Stage 2 signed by NEW admin — revokes OLD. + +**Nothing in this PR broadcasts by default.** All `forge script` invocations +below are simulations unless the operator adds `--broadcast`, at which point +the per-tx approval gate applies. + +--- + +## 0. Preflight + +1. **Do not reuse the compromised key for new deployments.** It is used here + exactly twice (stage-1 grants, and this runbook's final `renounce` safety + net) and then never again. + +2. **Choose NEW_ADMIN_ADDRESS.** Options (in order of preference): + - Gnosis Safe / similar multisig on Chain 138. Preferred. + - Fresh EOA in a hardware wallet (Ledger / Keystone / GridPlus). Acceptable + for interim if multisig deployment is blocked. + - Fresh EOA in an HSM-backed signer. Acceptable. + + Record the selected address in `NEW_ADMIN_ADDRESS` (env) and in + `docs/11-references/ADDRESS_MATRIX_AND_STATUS.md` as part of the Stage 2 PR. + +3. **Fund NEW admin with gas on Chain 138.** A small amount (≤ 1 native) is + enough; the Stage 2 tx is a few `revokeRole` calls. + +4. **Confirm current on-chain state matches pre-rotation.** Run the verifier + with `VERIFY_PRE_ROTATION=1` (see §4) — it reads `owner()` and `hasRole` and + prints them; at this point OLD should still hold every role. + +5. **Announce rotation window on [issue #1](https://gitea.d-bis.org/d-bis/proxmox/issues/1).** + +--- + +## 1. Environment + +`smom-dbis-138/.env` (do not commit): + +```bash +# Canonical Chain 138 deploy RPC (per AGENTS.md) +RPC_URL_138=http://192.168.11.211:8545 + +# Deployer (OLD — compromised). Used ONLY for Stage 1. Rotate off after. +# Value stays in operator-held secret storage; never in the repo. +PRIVATE_KEY=0x + +# New admin +NEW_ADMIN_ADDRESS=0x + +# (optional — defaults ship in the script and match the canonical addresses +# in docs/11-references/EXPLORER_TOKEN_LIST_CROSSCHECK.md §5) +CUSDT_ADDRESS=0x93E66202A11B1772E55407B32B44e5Cd8eda7f22 +CUSDC_ADDRESS=0xf22258f57794CC8E06237084b353Ab30fFfa640b +DODO_PMM_INTEGRATION_ADDRESS=0x5BDc62f1ae7D630c37A8B363a1d49845356Ee72d +OLD_DEPLOYER_ADDRESS=0x4A666F96fC8764181194447A7dFdb7d471b301C8 +``` + +Load via the repo's helper: + +```bash +source smom-dbis-138/scripts/load-env.sh +``` + +--- + +## 2. Stage 1 — signed by OLD (compromised) key + +### 2.1 Simulate (safe, no broadcast) + +```bash +cd smom-dbis-138 +forge script script/rotation/RotateChain138Admin.s.sol:RotateStage1 \ + --rpc-url "$RPC_URL_138" \ + -vvv +``` + +Review the output. The script prints: +- Resolved addresses (sanity-check against this runbook). +- The calldata that would be broadcast. +- Expected post-state (NEW holds owner/admin, OLD still holds admin). + +If the preflight `require`s fail, fix the mismatch before broadcasting. The +most common causes: +- `NEW_ADMIN_ADDRESS` already holds `DEFAULT_ADMIN_ROLE` on one of the + targets — rotation already partially happened; switch to the Stage 1 + verifier and resume from where you left off. +- `PRIVATE_KEY` is not the OLD deployer — Stage 1 must be signed by OLD to + match `transferOwnership` / `grantRole` authority. + +### 2.2 Broadcast (requires per-tx approval) + +```bash +forge script script/rotation/RotateChain138Admin.s.sol:RotateStage1 \ + --rpc-url "$RPC_URL_138" \ + --broadcast --slow \ + -vvv +``` + +`--slow` (one tx at a time, wait for inclusion) is mandatory. Five calls total +(2 × `transferOwnership`, 5 × `grantRole`). Expect 7 txs in the broadcast log. + +### 2.3 Verify Stage 1 completion + +```bash +cast call $CUSDT_ADDRESS "owner()(address)" --rpc-url "$RPC_URL_138" +cast call $CUSDC_ADDRESS "owner()(address)" --rpc-url "$RPC_URL_138" +cast call $DODO_PMM_INTEGRATION_ADDRESS \ + "hasRole(bytes32,address)(bool)" \ + 0x0000000000000000000000000000000000000000000000000000000000000000 \ + $NEW_ADMIN_ADDRESS \ + --rpc-url "$RPC_URL_138" +``` + +All three should return `NEW_ADMIN_ADDRESS` / `true`. + +**Wait for NEW admin to confirm control**: NEW admin signs a single +`setReserveSystem(address)` call against `DODOPMMIntegration` with the same +argument already live (no-op change) to confirm the role works. Only then +proceed to Stage 2. + +--- + +## 3. Stage 2 — signed by NEW admin + +### 3.1 Swap `PRIVATE_KEY` to NEW admin + +Do NOT leave OLD's key loaded for Stage 2. If the operator workflow keeps +`PRIVATE_KEY` in a long-lived `.env`, swap it temporarily: + +```bash +export PRIVATE_KEY=0x +``` + +### 3.2 Simulate + +```bash +forge script script/rotation/RotateChain138Admin.s.sol:RotateStage2 \ + --rpc-url "$RPC_URL_138" \ + -vvv +``` + +### 3.3 Broadcast + +```bash +forge script script/rotation/RotateChain138Admin.s.sol:RotateStage2 \ + --rpc-url "$RPC_URL_138" \ + --broadcast --slow \ + -vvv +``` + +Five calls total (2 × `revokeRole` on cUSDT + cUSDC for DEFAULT_ADMIN_ROLE, +3 × `revokeRole` on DODOPMMIntegration for its three roles). Expect 5 txs in +the broadcast log. + +--- + +## 4. Final verification + +```bash +forge script script/rotation/RotateChain138Admin.s.sol:VerifyChain138RotationComplete \ + --rpc-url "$RPC_URL_138" \ + -vvv +``` + +Exit code 0 = rotation complete. The verifier reverts with a specific message +if any contract still names OLD as owner/admin. + +Follow up: + +- Comment on [issue #1](https://gitea.d-bis.org/d-bis/proxmox/issues/1) with + the rotation tx hashes (no private keys, no intermediate signer data). +- Update `docs/11-references/ADDRESS_MATRIX_AND_STATUS.md` so `admin()` / + owner columns reflect `NEW_ADMIN`. +- Start the corresponding runbook for the next chain (ETH mainnet is the + next priority per `docs/runbooks/MULTI_CHAIN_EXECUTION_KEY_ROTATION.md`). + +--- + +## 5. Rollback path + +Rollback is only possible between Stage 1 and Stage 2. After Stage 2, OLD no +longer has `DEFAULT_ADMIN_ROLE` and cannot revoke NEW. + +- **Between §2 and §3:** NEW admin signs `renounceRole(DEFAULT_ADMIN_ROLE, + NEW_ADMIN)` on each target and calls `transferOwnership(OLD_DEPLOYER)` on + cUSDT / cUSDC. OLD then re-holds everything, and the incident is re-opened. +- **After §3:** there is no rollback without a freshly-granted admin. If the + new admin keys are lost between stages, contract control is permanently + reduced (renounce) or requires a redeploy. + +--- + +## 6. Other chains — follow-up PRs + +Each subsequent chain uses a dedicated Forge script alongside this one, +because the contract set differs per chain (CCIP Receiver / Relay / Token +Pool / LINK consumer on mainnet, etc.). Template: + +| Chain | Expected targets (enumeration pending per-chain) | Script file (follow-up) | +|---|---|---| +| ETH mainnet | CCIPReceiverMainnet, CCIPRelayRouter, CCIPRelayBridge, CCIPLoggerMainnet, possibly token pools | `script/rotation/RotateMainnetAdmin.s.sol` | +| Cronos | CRO↔WEMIX bridge contracts, token pools | `script/rotation/RotateCronosAdmin.s.sol` | +| Polygon | PMM pools (if deployed), CCIP spokes | `script/rotation/RotatePolygonAdmin.s.sol` | +| Base | same | `script/rotation/RotateBaseAdmin.s.sol` | +| Optimism | same | `script/rotation/RotateOptimismAdmin.s.sol` | +| BSC | same | `script/rotation/RotateBscAdmin.s.sol` | +| Avalanche | same | `script/rotation/RotateAvalancheAdmin.s.sol` | +| Arbitrum | same | `script/rotation/RotateArbitrumAdmin.s.sol` | +| Wemix | CRO↔WEMIX bridge contracts | `script/rotation/RotateWemixAdmin.s.sol` | + +Per-chain enumeration via +`smom-dbis-138/scripts/deployment/list-deployer-tokens-all-networks.sh` is the +first step of each follow-up. Celo is not in scope without direct repo +evidence of the deployer being the admin there.