security(phase1b): Chain 138 admin transfer-then-revoke Forge scripts + runbook #1
17
script/rotation/README.md
Normal file
17
script/rotation/README.md
Normal file
@@ -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: <https://gitea.d-bis.org/d-bis/proxmox/issues/1>.
|
||||
317
script/rotation/RotateChain138Admin.s.sol
Normal file
317
script/rotation/RotateChain138Admin.s.sol
Normal file
@@ -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.");
|
||||
}
|
||||
}
|
||||
230
scripts/rotation/chain138-rotation-runbook.md
Normal file
230
scripts/rotation/chain138-rotation-runbook.md
Normal file
@@ -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: <https://gitea.d-bis.org/d-bis/proxmox/issues/1>.
|
||||
|
||||
**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<OLD_DEPLOYER_PRIVATE_KEY>
|
||||
|
||||
# New admin
|
||||
NEW_ADMIN_ADDRESS=0x<NEW_ADMIN>
|
||||
|
||||
# (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<NEW_ADMIN_PRIVATE_KEY>
|
||||
```
|
||||
|
||||
### 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.
|
||||
Reference in New Issue
Block a user