Skip to content

Commit 463881d

Browse files
rplusqclaude
andcommitted
feat: migrate StakingRewardDistributor to AccessControl and make LockedTokenStaker upgradeable
- Replace Ownable with AccessControl in StakingRewardDistributor - Add DEFAULT_ADMIN_ROLE for timelock and REWARD_MANAGER_ROLE for treasury - Implement migrateToAccessControl() for safe live contract upgrade - Update all access control checks to use role-based permissions - Make LockedTokenStaker upgradeable - Convert to Initializable pattern with proxy support - Add unique identifier requirement to prevent salt collisions - Support multiple instances (Reown, WalletConnect, Backers) - Enhance Pauser contract - Add isStakingRewardDistributorPaused flag and controls - Integrate with StakingRewardDistributor pause checks - Maintain role separation (PAUSER_ROLE vs UNPAUSER_ROLE) - Add comprehensive fork tests - Test complete upgrade path with 7-day timelock delays - Verify role assignments and permissions - Test pause/unpause flow with Manager Timelock - Include StakeWeight upgrade for full integration testing - Update deployment scripts and helpers - Fix deployment count from 14 to 15 contracts - Add proxy helpers for LockedTokenStaker - Support unique identifiers for multiple instances 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent f7c8a13 commit 463881d

11 files changed

Lines changed: 570 additions & 60 deletions

File tree

evm/script/Base.s.sol

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -114,12 +114,12 @@ abstract contract BaseScript is Script, StdCheats {
114114
});
115115
}
116116
// Length per address is 32 bytes => 64 characters
117-
// 10 addresses are needed for the Optimism deployments
118-
// If the length is not 0 nor 64 * 14, we assume the deployments are missing contracts and we append as much
119-
// as needed to make it 64 * 14 bytes
120-
if (data.length != 64 * 14) {
117+
// 15 addresses are needed for the Optimism deployments
118+
// If the length is not 0 nor 64 * 15, we assume the deployments are missing contracts and we append as much
119+
// as needed to make it 64 * 15 bytes
120+
if (data.length != 64 * 15) {
121121
console2.log("Appending zeroes to deployments");
122-
while (data.length < 64 * 14) {
122+
while (data.length < 64 * 15) {
123123
data = bytes.concat(data, abi.encode(bytes32(0)));
124124
}
125125
}

evm/script/deploy/OptimismDeploy.s.sol

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ import {
2222
newWalletConnectConfig,
2323
newPauser,
2424
newStakeWeight,
25-
newStakingRewardDistributor
25+
newStakingRewardDistributor,
26+
newLockedTokenStaker
2627
} from "script/helpers/Proxy.sol";
2728

2829
struct OptimismDeploymentParams {
@@ -233,19 +234,36 @@ contract OptimismDeploy is BaseScript {
233234
}
234235

235236
if (address(deps.lockedTokenStakerReown) == address(0)) {
236-
deps.lockedTokenStakerReown = new LockedTokenStaker{
237-
salt: keccak256(abi.encodePacked("walletconnect.lockedtokenstaker"))
238-
}(deps.merkleVesterReown, WalletConnectConfig(address(deps.config)));
237+
deps.lockedTokenStakerReown = newLockedTokenStaker({
238+
initialOwner: address(deps.adminTimelock),
239+
init: LockedTokenStaker.Init({
240+
vesterContract: address(deps.merkleVesterReown),
241+
config: address(deps.config)
242+
}),
243+
identifier: "reown"
244+
});
239245
}
240246

241247
if (address(deps.lockedTokenStakerWalletConnect) == address(0)) {
242-
deps.lockedTokenStakerWalletConnect =
243-
new LockedTokenStaker(deps.merkleVesterWalletConnect, WalletConnectConfig(address(deps.config)));
248+
deps.lockedTokenStakerWalletConnect = newLockedTokenStaker({
249+
initialOwner: address(deps.adminTimelock),
250+
init: LockedTokenStaker.Init({
251+
vesterContract: address(deps.merkleVesterWalletConnect),
252+
config: address(deps.config)
253+
}),
254+
identifier: "walletconnect"
255+
});
244256
}
245257

246258
if (address(deps.lockedTokenStakerBackers) == address(0)) {
247-
deps.lockedTokenStakerBackers =
248-
new LockedTokenStaker(deps.merkleVesterBackers, WalletConnectConfig(address(deps.config)));
259+
deps.lockedTokenStakerBackers = newLockedTokenStaker({
260+
initialOwner: address(deps.adminTimelock),
261+
init: LockedTokenStaker.Init({
262+
vesterContract: address(deps.merkleVesterBackers),
263+
config: address(deps.config)
264+
}),
265+
identifier: "backers"
266+
});
249267
}
250268

251269
if (vm.envOr("BROADCAST", false)) {
@@ -356,9 +374,10 @@ contract OptimismDeploy is BaseScript {
356374
console2.log("L2WCT default admin is Admin MultiSig");
357375
}
358376

359-
// StakingRewardDistributor
360-
if (deps.stakingRewardDistributor.owner() != address(params.treasury)) {
361-
revert("StakingRewardDistributor owner is not Treasury");
377+
// StakingRewardDistributor - check REWARD_MANAGER_ROLE instead of owner
378+
bytes32 REWARD_MANAGER_ROLE = keccak256("REWARD_MANAGER_ROLE");
379+
if (!deps.stakingRewardDistributor.hasRole(REWARD_MANAGER_ROLE, address(params.treasury))) {
380+
revert("StakingRewardDistributor: Treasury does not have REWARD_MANAGER_ROLE");
362381
}
363382

364383
// Config

evm/script/helpers/Proxy.sol

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { Staking } from "src/Staking.sol";
1212
import { StakeWeight } from "src/StakeWeight.sol";
1313
import { StakingRewardDistributor } from "src/StakingRewardDistributor.sol";
1414
import { MockERC20 } from "test/mocks/MockERC20.sol";
15+
import { LockedTokenStaker } from "src/LockedTokenStaker.sol";
1516

1617
function newWCT(address initialOwner, WCT.Init memory init) returns (WCT) {
1718
bytes32 salt = keccak256(abi.encodePacked("walletconnect.wct"));
@@ -127,6 +128,26 @@ function newStakeWeight(address initialOwner, StakeWeight.Init memory init) retu
127128
return StakeWeight(address(proxy));
128129
}
129130

131+
function newLockedTokenStaker(
132+
address initialOwner,
133+
LockedTokenStaker.Init memory init,
134+
string memory identifier
135+
)
136+
returns (LockedTokenStaker)
137+
{
138+
require(bytes(identifier).length > 0, "Identifier cannot be empty");
139+
bytes32 salt = keccak256(abi.encodePacked("walletconnect.lockedtokenstaker.", identifier));
140+
141+
LockedTokenStaker impl = new LockedTokenStaker{ salt: salt }();
142+
TransparentUpgradeableProxy proxy = new TransparentUpgradeableProxy{ salt: salt }({
143+
_logic: address(impl),
144+
initialOwner: address(initialOwner),
145+
_data: abi.encodeCall(LockedTokenStaker.initialize, init)
146+
});
147+
148+
return LockedTokenStaker(address(proxy));
149+
}
150+
130151
function newMockERC20(address initialOwner) returns (MockERC20) {
131152
bytes32 salt = keccak256(abi.encodePacked("walletconnect.mockerc20"));
132153

evm/src/LockedTokenStaker.sol

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
// SPDX-License-Identifier: MIT
22
pragma solidity 0.8.25;
33

4+
import { Initializable } from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
5+
import { AccessControlUpgradeable } from "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol";
46
import { SafeCast } from "@openzeppelin/contracts/utils/math/SafeCast.sol";
57
import { StakeWeight } from "src/StakeWeight.sol";
68
import { WalletConnectConfig } from "src/WalletConnectConfig.sol";
@@ -22,14 +24,14 @@ import { Pauser } from "./Pauser.sol";
2224
* @notice This contract handles staking without token transfer, as the tokens are already locked in the vesting
2325
* contract. Then on postClaim, it prevents claiming with an active lock in the staking contract.
2426
*/
25-
contract LockedTokenStaker is IPostClaimHandler {
27+
contract LockedTokenStaker is Initializable, IPostClaimHandler {
2628
using SafeERC20 for IERC20;
2729

2830
// The address of the vester contract that will call this handler
29-
MerkleVester public immutable vesterContract;
31+
MerkleVester public vesterContract;
3032

3133
// The configuration for the WalletConnect system
32-
WalletConnectConfig public immutable config;
34+
WalletConnectConfig public config;
3335

3436
error InvalidCaller();
3537
error TerminatedAllocation();
@@ -38,14 +40,27 @@ contract LockedTokenStaker is IPostClaimHandler {
3840
error CannotClaimLockedTokens(uint256 remainingAllocation, uint256 lockedAmount, uint256 claimAmount);
3941
error Paused();
4042

43+
/// @notice Initialization parameters
44+
struct Init {
45+
address vesterContract;
46+
address config;
47+
}
48+
49+
/// @custom:oz-upgrades-unsafe-allow constructor
50+
constructor() {
51+
_disableInitializers();
52+
}
53+
4154
/**
42-
* @notice Constructor to set up the ClaimAndStakeHandler
43-
* @param vesterContract_ The address of the vester contract
44-
* @param config_ The configuration for the WalletConnect system
55+
* @notice Initialize the contract
56+
* @param init Initialization parameters
4557
*/
46-
constructor(MerkleVester vesterContract_, WalletConnectConfig config_) {
47-
vesterContract = vesterContract_;
48-
config = config_;
58+
function initialize(Init memory init) external initializer {
59+
if (init.vesterContract == address(0)) revert InvalidCaller();
60+
if (init.config == address(0)) revert InvalidCaller();
61+
62+
vesterContract = MerkleVester(init.vesterContract);
63+
config = WalletConnectConfig(init.config);
4964
}
5065

5166
modifier whenNotPaused() {

evm/src/Pauser.sol

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ contract Pauser is Initializable, AccessControlUpgradeable {
3737
/// @notice Flag indicating if wallet reward manager is paused
3838
bool public isWalletRewardManagerPaused;
3939

40+
/// @notice Flag indicating if staking reward distributor is paused
41+
bool public isStakingRewardDistributorPaused;
42+
4043
/// @notice Configuration for contract initialization
4144
struct Init {
4245
address admin;
@@ -111,6 +114,17 @@ contract Pauser is Initializable, AccessControlUpgradeable {
111114
_setIsWalletRewardManagerPaused(isPaused);
112115
}
113116

117+
/// @notice Pauses or unpauses staking reward distributor
118+
/// @param isPaused The new pause state
119+
function setIsStakingRewardDistributorPaused(bool isPaused) external {
120+
if (isPaused) {
121+
_checkRole(PAUSER_ROLE);
122+
} else {
123+
_checkRole(UNPAUSER_ROLE);
124+
}
125+
_setIsStakingRewardDistributorPaused(isPaused);
126+
}
127+
114128
/// @dev Sets the node reward manager pause state
115129
/// @param isPaused The new pause state
116130
function _setIsNodeRewardManagerPaused(bool isPaused) private {
@@ -140,6 +154,7 @@ contract Pauser is Initializable, AccessControlUpgradeable {
140154
_setIsLockedTokenStakerPaused(true);
141155
_setIsNodeRewardManagerPaused(true);
142156
_setIsWalletRewardManagerPaused(true);
157+
_setIsStakingRewardDistributorPaused(true);
143158
}
144159

145160
/// @notice Unpauses all actions
@@ -149,6 +164,7 @@ contract Pauser is Initializable, AccessControlUpgradeable {
149164
_setIsLockedTokenStakerPaused(false);
150165
_setIsNodeRewardManagerPaused(false);
151166
_setIsWalletRewardManagerPaused(false);
167+
_setIsStakingRewardDistributorPaused(false);
152168
}
153169

154170
/// @dev Sets the staking pause state
@@ -183,4 +199,15 @@ contract Pauser is Initializable, AccessControlUpgradeable {
183199
flagName: "isLockedTokenStakerPaused"
184200
});
185201
}
202+
203+
/// @dev Sets the staking reward distributor pause state
204+
/// @param isPaused The new pause state
205+
function _setIsStakingRewardDistributorPaused(bool isPaused) private {
206+
isStakingRewardDistributorPaused = isPaused;
207+
emit FlagUpdated({
208+
selector: this.isStakingRewardDistributorPaused.selector,
209+
isPaused: isPaused,
210+
flagName: "isStakingRewardDistributorPaused"
211+
});
212+
}
186213
}

0 commit comments

Comments
 (0)