security(phase1b): Chain 138 admin transfer-then-revoke Forge scripts + runbook
Some checks failed
CI/CD Pipeline / Solidity Contracts (pull_request) Failing after 53s
CI/CD Pipeline / Security Scanning (pull_request) Successful in 2m32s
CI/CD Pipeline / Lint and Format (pull_request) Failing after 19s
CI/CD Pipeline / Terraform Validation (pull_request) Failing after 11s
CI/CD Pipeline / Kubernetes Validation (pull_request) Successful in 15s
Validation / validate-genesis (pull_request) Successful in 16s
Validation / validate-terraform (pull_request) Failing after 15s
Validation / validate-kubernetes (pull_request) Failing after 2s
Validation / validate-smart-contracts (pull_request) Failing after 3s
Validation / validate-security (pull_request) Failing after 2m1s
Validation / validate-documentation (pull_request) Failing after 6s

Part of the sequenced cleanup tracked in
d-bis/proxmox#1.

Scaffolding only. No broadcast. No secret values committed.

The Chain 138 deployer EOA 0x4A666F96...01C8 is in proxmox master in
plaintext and must be rotated off as owner/admin on all contracts it
controls. Because the key is an EOA (not a multisig), rotation is
transfer-then-revoke (not revoke-first) per
docs/runbooks/MULTI_CHAIN_EXECUTION_KEY_ROTATION.md.

- script/rotation/RotateChain138Admin.s.sol
  - RotateStage1 (signed by OLD): transferOwnership + grantRole
    DEFAULT_ADMIN_ROLE on cUSDT, cUSDC, DODOPMMIntegration; additionally
    POOL_MANAGER_ROLE + SWAP_OPERATOR_ROLE on DODOPMMIntegration.
    Preflight asserts OLD still holds each role and NEW does not.
  - RotateStage2 (signed by NEW): revokeRole on OLD for each of the
    above. Preflight asserts Stage 1 landed (NEW holds roles, OLD still
    holds roles).
  - VerifyChain138RotationComplete: read-only end-state check; reverts
    with a specific message if any contract still names OLD as owner or
    admin.
  - Default addresses are the canonical Chain 138 cUSDT / cUSDC /
    DODOPMMIntegration from docs/11-references/ADDRESS_MATRIX_AND_STATUS.md
    and .cursor/rules/chain138-tokens-and-pmm.mdc. Overridable via env.

- script/rotation/README.md: index + follow-up PR table for the other
  chains (mainnet, Cronos, Polygon, Base, OP, BSC, AVAX, Arbitrum,
  Wemix).

- scripts/rotation/chain138-rotation-runbook.md: authoritative operator
  runbook with simulate/broadcast cast sequences, Stage-1/Stage-2
  verification steps, rollback path (only possible between stages), and
  a per-chain template for the follow-up PRs.

Forge build passes (one mixed-case-variable lint note on field names
matching the contract names; not an error).

This PR does not cover other chains or execute the rotation — those
are explicit operator steps gated on per-tx approval.

Co-Authored-By: Nakamoto, S <defi@defi-oracle.io>
This commit is contained in:
Devin AI
2026-04-18 20:24:00 +00:00
parent 07d9ce4876
commit 1344219fd3
3 changed files with 564 additions and 0 deletions

View 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.