Skip to content

Commit 30a31e1

Browse files
committed
feat(oft): split role handoff into grant and revoke scripts
Two-step, verifiable handoff off the deployer EOA: - GrantRolesAndTransferOwnership: grants ADMIN+owner to the Timelock, MANAGER+delegate to the Safe, PAUSER to the pauser, and transfers ownership, WITHOUT stripping the deployer. Requires the Timelock/Safe to have code on the current chain (guards a mistyped/cross-chain address) and rejects the deployer as any target (a role granted to the deployer would be stripped in step 2, leaving it holderless). - RevokeDeployerRole: revoke-only. Preconditions assert the Timelock already holds ADMIN + owner before the deployer revokes MANAGER/PAUSER and renounces DEFAULT_ADMIN_ROLE last, so the handoff cannot lock the contract out of an admin. Splitting grant from the irreversible renounce leaves a window to verify the new holders are controllable before stripping the deployer.
1 parent 7b84266 commit 30a31e1

2 files changed

Lines changed: 154 additions & 0 deletions

File tree

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity ^0.8.20;
3+
4+
import { console } from "forge-std/Script.sol";
5+
import { ListaOFTAdapterV2 } from "../../../../contracts/oft/v2/ListaOFTAdapterV2.sol";
6+
import { OFTScriptBase } from "./OFTScriptBase.sol";
7+
8+
/**
9+
* @title GrantRolesAndTransferOwnership
10+
* @notice Handoff step 1 of 2. Grants every role/authority to the target governance
11+
* holders WITHOUT stripping the deployer:
12+
* - Timelock -> DEFAULT_ADMIN_ROLE + Ownable owner
13+
* - Safe -> MANAGER + LayerZero endpoint delegate
14+
* - Pauser -> PAUSER
15+
* Run this first, verify on-chain that the new holders are correct and
16+
* actually controllable, THEN run RevokeDeployerRole to strip the deployer.
17+
* Splitting the grant from the irreversible renounce leaves a safe window: if
18+
* a target is wrong, the deployer still holds admin and can correct it.
19+
*
20+
* Env:
21+
* DEPLOYER_PRIVATE_KEY (required, must currently hold DEFAULT_ADMIN_ROLE + owner)
22+
* OAPP (required) local proxy address
23+
* TIMELOCK (required) new DEFAULT_ADMIN_ROLE holder + Ownable owner (must be a contract)
24+
* SAFE (required) new MANAGER holder + LayerZero delegate (must be a contract)
25+
* PAUSER (required) new PAUSER holder
26+
*
27+
* Usage:
28+
* OAPP=0x.. TIMELOCK=0x.. SAFE=0x.. PAUSER=0x.. \
29+
* forge script scripts/foundry/oft/v2/GrantRolesAndTransferOwnership.s.sol \
30+
* --rpc-url bsc --broadcast
31+
*
32+
* @dev ListaOFTAdapterV2 is a typed handle only; every selector used here
33+
* (grantRole, setDelegate, transferOwnership) is identical on ListaOFTv2.
34+
*/
35+
contract GrantRolesAndTransferOwnership is OFTScriptBase {
36+
function run() external {
37+
uint256 pk = vm.envUint("DEPLOYER_PRIVATE_KEY");
38+
address deployer = vm.addr(pk);
39+
address oapp = vm.envAddress("OAPP");
40+
address timelock = vm.envAddress("TIMELOCK");
41+
address safe = vm.envAddress("SAFE");
42+
address pauser = vm.envAddress("PAUSER");
43+
44+
require(timelock != address(0) && safe != address(0) && pauser != address(0), "zero target");
45+
// All targets must differ from the deployer: a role granted to the deployer here
46+
// would be stripped by RevokeDeployerRole, leaving that role holderless (e.g. no
47+
// PAUSER = no emergency pause path).
48+
require(timelock != deployer && safe != deployer && pauser != deployer, "target is deployer");
49+
// Guard against a mistyped or cross-chain address that has no code on THIS chain:
50+
// handing upgrade/config authority to an EOA-less, uncontrollable address bricks
51+
// governance. The admin/config authorities must be deployed contracts here.
52+
require(timelock.code.length > 0, "TIMELOCK has no code on this chain");
53+
require(safe.code.length > 0, "SAFE has no code on this chain");
54+
55+
ListaOFTAdapterV2 oft = ListaOFTAdapterV2(oapp);
56+
bytes32 ADMIN = oft.DEFAULT_ADMIN_ROLE();
57+
bytes32 MANAGER = oft.MANAGER();
58+
bytes32 PAUSER = oft.PAUSER();
59+
60+
require(oft.hasRole(ADMIN, deployer), "deployer lacks ADMIN");
61+
require(oft.owner() == deployer, "deployer is not owner");
62+
63+
console.log("OApp:", oapp);
64+
console.log("Timelock (ADMIN + owner):", timelock);
65+
console.log("Safe (MANAGER + delegate):", safe);
66+
console.log("Pauser (PAUSER):", pauser);
67+
68+
vm.startBroadcast(pk);
69+
oft.grantRole(ADMIN, timelock);
70+
oft.grantRole(MANAGER, safe);
71+
oft.grantRole(PAUSER, pauser);
72+
// setDelegate is onlyOwner -> must run while the deployer is still owner,
73+
// i.e. BEFORE transferOwnership below.
74+
oft.setDelegate(safe);
75+
oft.transferOwnership(timelock);
76+
vm.stopBroadcast();
77+
78+
// verify the grants + ownership landed (reverts the broadcast otherwise).
79+
require(oft.hasRole(ADMIN, timelock), "timelock missing ADMIN");
80+
require(oft.hasRole(MANAGER, safe), "safe missing MANAGER");
81+
require(oft.hasRole(PAUSER, pauser), "pauser missing PAUSER");
82+
require(oft.owner() == timelock, "owner not timelock");
83+
// The deployer intentionally KEEPS ADMIN/MANAGER here; RevokeDeployerRole strips
84+
// it only after the operator has confirmed the new holders are controllable.
85+
console.log("Granted + ownership transferred. Deployer still ADMIN until RevokeDeployerRole.");
86+
}
87+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity ^0.8.20;
3+
4+
import { console } from "forge-std/Script.sol";
5+
import { ListaOFTAdapterV2 } from "../../../../contracts/oft/v2/ListaOFTAdapterV2.sol";
6+
import { OFTScriptBase } from "./OFTScriptBase.sol";
7+
8+
/**
9+
* @title RevokeDeployerRole
10+
* @notice Handoff step 2 of 2 (final, irreversible). Strips the deployer of PAUSER,
11+
* MANAGER and DEFAULT_ADMIN_ROLE. Run ONLY after
12+
* GrantRolesAndTransferOwnership has handed authority to the Timelock + Safe
13+
* and you have verified those holders are correct and controllable.
14+
*
15+
* Preconditions assert the new admin + owner are already in place, so the
16+
* deployer can never renounce the last admin and lock the contract out.
17+
* DEFAULT_ADMIN_ROLE is renounced LAST (after the MANAGER/PAUSER revokes it
18+
* authorizes). Run once per chain.
19+
*
20+
* Env:
21+
* DEPLOYER_PRIVATE_KEY (required, still holds DEFAULT_ADMIN_ROLE)
22+
* OAPP (required) local proxy address
23+
* TIMELOCK (required) expected DEFAULT_ADMIN_ROLE holder + owner (precondition check)
24+
*
25+
* Usage:
26+
* OAPP=0x.. TIMELOCK=0x.. forge script scripts/foundry/oft/v2/RevokeDeployerRole.s.sol \
27+
* --rpc-url bsc --broadcast
28+
*
29+
* @dev ListaOFTAdapterV2 is a typed handle only; every selector used here
30+
* (revokeRole, renounceRole) is identical on ListaOFTv2.
31+
*/
32+
contract RevokeDeployerRole is OFTScriptBase {
33+
function run() external {
34+
uint256 pk = vm.envUint("DEPLOYER_PRIVATE_KEY");
35+
address deployer = vm.addr(pk);
36+
address oapp = vm.envAddress("OAPP");
37+
address timelock = vm.envAddress("TIMELOCK");
38+
39+
ListaOFTAdapterV2 oft = ListaOFTAdapterV2(oapp);
40+
bytes32 ADMIN = oft.DEFAULT_ADMIN_ROLE();
41+
bytes32 MANAGER = oft.MANAGER();
42+
bytes32 PAUSER = oft.PAUSER();
43+
44+
// Preconditions: the handoff (grant step) must already be in place, so stripping
45+
// the deployer cannot leave the contract without an admin or owner.
46+
require(timelock != address(0) && timelock != deployer, "bad TIMELOCK");
47+
require(oft.hasRole(ADMIN, timelock), "timelock lacks ADMIN - run grant step first");
48+
require(oft.owner() == timelock, "owner not timelock - run grant step first");
49+
require(oft.hasRole(ADMIN, deployer), "deployer already stripped");
50+
51+
console.log("OApp:", oapp);
52+
console.log("Deployer (stripped):", deployer);
53+
console.log("Timelock (retains ADMIN + owner):", timelock);
54+
55+
vm.startBroadcast(pk);
56+
oft.revokeRole(PAUSER, deployer);
57+
oft.revokeRole(MANAGER, deployer);
58+
oft.renounceRole(ADMIN, deployer); // LAST
59+
vm.stopBroadcast();
60+
61+
// final invariant: the deployer holds nothing.
62+
require(!oft.hasRole(ADMIN, deployer), "deployer still ADMIN");
63+
require(!oft.hasRole(MANAGER, deployer), "deployer still MANAGER");
64+
require(!oft.hasRole(PAUSER, deployer), "deployer still PAUSER");
65+
console.log("Deployer fully stripped.");
66+
}
67+
}

0 commit comments

Comments
 (0)