Skip to content

Commit ec6aea9

Browse files
authored
feat: add config checker for staking roles (#102)
* feat: add get-staking-roles script and update package.json and README - Introduced a new script `get-staking-roles` to report roles of ProtocolStaking contracts and their beneficiaries. - Updated `package.json` to include the new script in the npm tasks. - Enhanced the README to document the usage of the new script and added it to the list of available scripts. - Created a new `staking-addresses.json` file to store addresses for ProtocolStaking and OperatorStaking contracts. * fix: trigger ci * feat: enhance chains-config-checker with testnet support and documentation updates - Added support for the Sepolia testnet in the chains-config-checker, including new RPC endpoint in `.env.example`. - Updated `staking-addresses.json` to include separate configurations for mainnet and testnet, with deployment blocks for each contract. - Enhanced the `README.md` to clarify the usage of the `get-staking-roles` script across both mainnet and testnet, including details on deployment blocks. - Modified the `get-staking-roles.js` script to handle network-specific configurations and improve error handling for missing RPC URLs. chore: streamline staking address configurations and enhance script functionality - Removed the RPC_SEPOLIA variable from `.env.example` as it is no longer needed. - Updated `README.md` to clarify the usage of the `get-staking-roles` script, emphasizing the separation of mainnet and testnet configurations. - Introduced a new `staking-addresses-testnet.json` file for testnet-specific staking addresses. - Refactored `get-staking-roles.js` to utilize network-specific configuration files, improving clarity and maintainability. * docs: update README.md to include detailed usage for getStakingRoles script - Added a new section for the `getStakingRoles` script, outlining its functionality to report roles of ProtocolStaking contracts and beneficiaries of OperatorStaking contracts. - Clarified the usage instructions for mainnet and testnet configurations, including command examples and environment variable requirements. - Enhanced documentation to improve clarity and usability for developers working with staking roles.
1 parent bc1b812 commit ec6aea9

5 files changed

Lines changed: 349 additions & 1 deletion

File tree

contracts/chains-config-checker/README.md

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ Currently, most useful scripts are:
3030
[*] get-token-roles
3131
[*] get-oft-owners
3232
[*] get-multisig-info
33+
[*] get-staking-roles
3334
```
3435
### getCurrentPausers
3536

@@ -326,3 +327,69 @@ Detected 2 active plugin address(es):
326327
2. ...
327328
...
328329
```
330+
331+
### getStakingRoles
332+
333+
Reports the roles of ProtocolStaking contracts and the beneficiaries of OperatorStaking contracts. Addresses are maintained in two config files:
334+
335+
- `staking-addresses.json` — mainnet (Ethereum)
336+
- `staking-addresses-testnet.json` — testnet (Sepolia)
337+
338+
#### Usage
339+
340+
```bash
341+
npm run get-staking-roles # mainnet (default)
342+
npm run get-staking-roles -- --mainnet # explicit
343+
npm run get-staking-roles -- --testnet # testnet
344+
```
345+
346+
The `--` separates npm's args from the script's args. Only one of `--mainnet` / `--testnet` may be passed.
347+
348+
The script will:
349+
1. For each **ProtocolStaking** contract (KMS, Coprocessor), scan `RoleGranted`/`RoleRevoked` events to compute current holders of `DEFAULT_ADMIN_ROLE`, `MANAGER_ROLE`, and `ELIGIBLE_ACCOUNT_ROLE`.
350+
2. Verify that all `ELIGIBLE_ACCOUNT_ROLE` holders are known **OperatorStaking** addresses in the selected config file.
351+
3. For each **OperatorStaking** contract, read the `beneficiary()` from its OperatorRewarder.
352+
353+
**Environment variables:**
354+
355+
| Variable | Description |
356+
|----------------|-----------------------------------------------------------------------------|
357+
| `RPC_ETHEREUM` | Archive-capable RPC endpoint. Point it at Ethereum mainnet or Sepolia depending on the flag you pass. |
358+
359+
`RPC_ETHEREUM` must be set, otherwise the script exits with an error. The endpoint must be **archive-capable**: the script binary-searches for each ProtocolStaking deployment block via `eth_getCode` at historical blocks, which pruned nodes reject.
360+
361+
#### Example Output
362+
363+
```
364+
=== ProtocolStaking Roles ===
365+
366+
[ProtocolStaking - KMS]
367+
Contract: 0xe9b1...
368+
...
369+
370+
DEFAULT_ADMIN_ROLE:
371+
1. 0xB6D6...Ef3
372+
373+
MANAGER_ROLE:
374+
1. 0xB6D6...Ef3
375+
376+
ELIGIBLE_ACCOUNT_ROLE:
377+
1. 0x8305... <- Zama (kms)
378+
2. 0xB968... <- Dfns (kms)
379+
...
380+
All eligible accounts are known OperatorStaking addresses.
381+
382+
[ProtocolStaking - COPROCESSOR]
383+
...
384+
385+
=== OperatorStaking Beneficiaries ===
386+
387+
[KMS]
388+
Zama : 0x31bB...
389+
Dfns : 0xb59F...
390+
...
391+
392+
[COPROCESSOR]
393+
Zama : 0x31bB...
394+
...
395+
```

contracts/chains-config-checker/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
"get-oft-owners-evm": "node utils/get-oft-owners-evm.js",
1010
"get-oft-owners-solana": "node utils/get-oft-owners-solana.js",
1111
"get-oft-owners": "node utils/get-oft-owners-evm.js && node utils/get-oft-owners-solana.js",
12-
"get-multisig-info": "node utils/get-multisig-info.js"
12+
"get-multisig-info": "node utils/get-multisig-info.js",
13+
"get-staking-roles": "node utils/get-staking-roles.js"
1314
},
1415
"dependencies": {
1516
"@layerzerolabs/lz-solana-sdk-v2": "^3.0.136",
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
{
2+
"protocolStaking": {
3+
"kms": "0x0309b4308A6AC121B9b3A960aC7Bc9bd8256cf38",
4+
"coprocessor": "0xc22E393D2A1C1BD65c88d34a3bE4DD77e8952E71"
5+
},
6+
"operatorStaking": {
7+
"kms": {
8+
"Zama": "0x454D1738C8eD25C744aF01730EE39a27B683A246",
9+
"Dfns": "0x8e0bFD7736E9628E2179fB98d44223eF9840fBC7",
10+
"Figment": "0x1a5f6C8FFdd869b30FFC73cC9424025829aCad04",
11+
"Fireblocks": "0xe85765700Ef107E94fd57FbF1D1863ff87a2948D",
12+
"InfStones": "0x5F1310b6E8F7DcC24A9A6F74229cf66EE075d4D6",
13+
"Unit410": "0xFcC6F9cA8CC4A491B05306D57374a3F6c1f52484",
14+
"LayerZero": "0x6c12eB5d89E6f89399610C7b3Efca40671E82F06",
15+
"Ledger": "0xe52419533D0322a57d6db28d32463aa6717FeA3c",
16+
"Omakase": "0xb1A7026C28cB91604FB7B1669f060aB74A30c255",
17+
"Stake Capital": "0xdd0a1B86C8bf653e5bA575bE81bBD733E59803Ae",
18+
"OpenZeppelin": "0x76427A3830295406d4aBae5b4754749048f58098",
19+
"Etherscan": "0xDF3f304c291466F21BB711d00E48a0d9AD9D64aF",
20+
"Conduit": "0xd6C131CD3c1243934658781a9F7A2CBd1E40f6bF"
21+
},
22+
"coprocessor": {
23+
"Zama": "0x1504646d2e4F924db4c6D6F8e42713e5492604ce",
24+
"Blockscape": "0xd32b8E13D9e9733f21068168637e68131122C212",
25+
"P2P": "0x419Bcec8A8B60688AC7EfeFECC5f83E922191b2A",
26+
"Artifact": "0x98B50c22245994360Ecf1F695a7383A3f983AeF4",
27+
"Luganodes": "0xe89d9ca0579F19B77af04b201E73A26CECA07600"
28+
}
29+
}
30+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
{
2+
"protocolStaking": {
3+
"kms": "0xe9b176CCaA8840DC3b3567bb83e2cD2a6c36F4Ab",
4+
"coprocessor": "0x7147485b892158f2B875f7aC5Ea48A9937C66AE8"
5+
},
6+
"operatorStaking": {
7+
"kms": {
8+
"Zama": "0x8305d7c59886462B04C71Ecc5c5C331520C2a8E4",
9+
"Dfns": "0xB9689c08A634B076849E61Ea4E42AB44aE2e5e2d",
10+
"Figment": "0x8a3Bb2a9B28dAD4230a6dfE17124C398eA6416BF",
11+
"Fireblocks": "0xF1BA887932d7559B3b00a58fE92F36CA8D7751d3",
12+
"InfStones": "0x80c03b5be1417D18bAe68d5a1F0f2A97457b0C3c",
13+
"Unit410": "0xFCAA267679B8364957560d7420E66Bb012013091",
14+
"LayerZero": "0x9b5Cd13b8eFbB58Dc25A05CF411D8056058aDFfF",
15+
"Ledger": "0x78F597F1Dcf5dA536745558368c825654A1A044B",
16+
"Omakase": "0xff54739b16576FA5402F211D0b938469Ab9A5f3F",
17+
"Stake Capital": "0xFf021fB13cA64e5354c62c954b949a88cfDEb25E",
18+
"OpenZeppelin": "0x75355a85c6FB9df5f0C80FF54e8747EEe9a0BF57",
19+
"Etherscan": "0x93c931278A2aad1916783F952f94276eA5111442",
20+
"Conduit": "0x50C271E25Ee953DD21E916311db81E228c9Bdb59"
21+
},
22+
"coprocessor": {
23+
"Zama": "0x5c9401fdA261fDb97188126e130e001DB38F1310",
24+
"Blockscape": "0x126D6B697aD04228657Ba677c71CfE20A8745b03",
25+
"P2P": "0xdB4FE5977d4f78f251C0F821C18C1F7A16Ad3A5e",
26+
"Artifact": "0xf5A0f502C98Df9dC22A4E4f251eC3c75f2aD8098",
27+
"Luganodes": "0xdD7fA0C796b3ca4fc1654F4eFFea5c4E9fF57a23"
28+
}
29+
}
30+
}
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
#!/usr/bin/env node
2+
3+
require('dotenv').config({ path: require('path').resolve(__dirname, '../.env') });
4+
const path = require('path');
5+
const { ethers } = require('ethers');
6+
const { findDeploymentBlock } = require('./get-deployment-block');
7+
8+
const MAX_BLOCK_RANGE = 49999;
9+
10+
// Both networks read RPC_ETHEREUM; point it at an Ethereum mainnet or Sepolia
11+
// archive RPC depending on which --mainnet/--testnet flag you pass.
12+
const NETWORKS = {
13+
mainnet: {
14+
label: 'Mainnet (Ethereum)',
15+
configFile: 'staking-addresses.json',
16+
},
17+
testnet: {
18+
label: 'Testnet (Sepolia)',
19+
configFile: 'staking-addresses-testnet.json',
20+
},
21+
};
22+
23+
const ROLE_EVENT_ABI = [
24+
'event RoleGranted(bytes32 indexed role, address indexed account, address indexed sender)',
25+
'event RoleRevoked(bytes32 indexed role, address indexed account, address indexed sender)',
26+
];
27+
28+
const OPERATOR_STAKING_ABI = [
29+
'function rewarder() view returns (address)',
30+
];
31+
32+
const OPERATOR_REWARDER_ABI = [
33+
'function beneficiary() view returns (address)',
34+
];
35+
36+
const ROLE_HASHES = {
37+
DEFAULT_ADMIN_ROLE: ethers.ZeroHash,
38+
MANAGER_ROLE: ethers.keccak256(ethers.toUtf8Bytes('MANAGER_ROLE')),
39+
ELIGIBLE_ACCOUNT_ROLE: ethers.keccak256(ethers.toUtf8Bytes('ELIGIBLE_ACCOUNT_ROLE')),
40+
};
41+
42+
const ROLE_NAMES_BY_HASH = new Map(
43+
Object.entries(ROLE_HASHES).map(([name, hash]) => [hash, name])
44+
);
45+
46+
function parseNetworkFlag(argv) {
47+
const flags = argv.filter((a) => a === '--mainnet' || a === '--testnet');
48+
if (flags.length > 1) {
49+
console.error('Error: pass only one of --mainnet or --testnet');
50+
process.exit(1);
51+
}
52+
return flags[0] === '--testnet' ? 'testnet' : 'mainnet';
53+
}
54+
55+
async function queryEventsInChunks(contract, filter, fromBlock, toBlock, label) {
56+
const events = [];
57+
let currentFrom = fromBlock;
58+
59+
while (currentFrom <= toBlock) {
60+
const currentTo = Math.min(currentFrom + MAX_BLOCK_RANGE, toBlock);
61+
const progress = Math.round(((currentFrom - fromBlock) / (toBlock - fromBlock)) * 100) || 0;
62+
process.stdout.write(`\r ${label}: ${progress}% (block ${currentFrom})...`);
63+
64+
const chunk = await contract.queryFilter(filter, currentFrom, currentTo);
65+
events.push(...chunk);
66+
67+
currentFrom = currentTo + 1;
68+
}
69+
70+
console.log(`\r ${label}: 100% - found ${events.length} events`);
71+
return events;
72+
}
73+
74+
// Build a set of all known OperatorStaking addresses (lowercased) for cross-referencing
75+
function getAllOperatorStakingAddresses(stakingAddresses) {
76+
const addresses = new Set();
77+
for (const roleOperators of Object.values(stakingAddresses.operatorStaking)) {
78+
for (const addr of Object.values(roleOperators)) {
79+
addresses.add(addr.toLowerCase());
80+
}
81+
}
82+
return addresses;
83+
}
84+
85+
// Reverse-lookup: address -> "Name (role)"
86+
function getOperatorStakingLabel(stakingAddresses, address) {
87+
const lower = address.toLowerCase();
88+
for (const [role, operators] of Object.entries(stakingAddresses.operatorStaking)) {
89+
for (const [name, addr] of Object.entries(operators)) {
90+
if (addr.toLowerCase() === lower) return `${name} (${role})`;
91+
}
92+
}
93+
return null;
94+
}
95+
96+
async function getProtocolStakingRoles(provider, rpcUrl, contractAddress) {
97+
const contract = new ethers.Contract(contractAddress, ROLE_EVENT_ABI, provider);
98+
99+
console.log(` Finding deployment block for ${contractAddress}...`);
100+
const fromBlock = await findDeploymentBlock(contractAddress, { rpcUrl, silent: true });
101+
const toBlock = await provider.getBlockNumber();
102+
console.log(` Deployment block: ${fromBlock}, current block: ${toBlock}`);
103+
104+
console.log(' Fetching role events...');
105+
const grantedEvents = await queryEventsInChunks(contract, contract.filters.RoleGranted(), fromBlock, toBlock, 'RoleGranted');
106+
const revokedEvents = await queryEventsInChunks(contract, contract.filters.RoleRevoked(), fromBlock, toBlock, 'RoleRevoked');
107+
108+
const allEvents = [
109+
...grantedEvents.map((e) => ({ type: 'grant', role: e.args.role, account: e.args.account, block: e.blockNumber, logIndex: e.index })),
110+
...revokedEvents.map((e) => ({ type: 'revoke', role: e.args.role, account: e.args.account, block: e.blockNumber, logIndex: e.index })),
111+
]
112+
.filter((e) => ROLE_NAMES_BY_HASH.has(e.role))
113+
.sort((a, b) => (a.block !== b.block ? a.block - b.block : a.logIndex - b.logIndex));
114+
115+
const roleSets = new Map(Object.keys(ROLE_HASHES).map((name) => [name, new Set()]));
116+
117+
for (const event of allEvents) {
118+
const roleName = ROLE_NAMES_BY_HASH.get(event.role);
119+
const set = roleSets.get(roleName);
120+
if (event.type === 'grant') {
121+
set.add(event.account);
122+
} else {
123+
set.delete(event.account);
124+
}
125+
}
126+
127+
return Object.fromEntries([...roleSets.entries()].map(([name, set]) => [name, Array.from(set)]));
128+
}
129+
130+
async function getOperatorStakingBeneficiary(provider, address) {
131+
const opStaking = new ethers.Contract(address, OPERATOR_STAKING_ABI, provider);
132+
const rewarderAddr = await opStaking.rewarder();
133+
const rewarder = new ethers.Contract(rewarderAddr, OPERATOR_REWARDER_ABI, provider);
134+
return rewarder.beneficiary();
135+
}
136+
137+
function printRoles(roles, stakingAddresses, allOpStakingAddresses) {
138+
for (const [roleName, holders] of Object.entries(roles)) {
139+
console.log(`\n ${roleName}:`);
140+
if (holders.length === 0) {
141+
console.log(' (none)');
142+
continue;
143+
}
144+
for (let i = 0; i < holders.length; i++) {
145+
const addr = holders[i];
146+
const label = getOperatorStakingLabel(stakingAddresses, addr);
147+
const suffix = label ? ` <- ${label}` : '';
148+
console.log(` ${i + 1}. ${addr}${suffix}`);
149+
}
150+
151+
// For ELIGIBLE_ACCOUNT_ROLE, check that every holder is a known OperatorStaking address
152+
if (roleName === 'ELIGIBLE_ACCOUNT_ROLE') {
153+
const unknown = holders.filter((a) => !allOpStakingAddresses.has(a.toLowerCase()));
154+
if (unknown.length > 0) {
155+
console.log(' WARNING: Unknown addresses in ELIGIBLE_ACCOUNT_ROLE:');
156+
for (const addr of unknown) {
157+
console.log(` - ${addr}`);
158+
}
159+
} else {
160+
console.log(' All eligible accounts are known OperatorStaking addresses.');
161+
}
162+
}
163+
}
164+
}
165+
166+
async function main() {
167+
const networkKey = parseNetworkFlag(process.argv.slice(2));
168+
const network = NETWORKS[networkKey];
169+
const stakingAddresses = require(path.resolve(__dirname, '..', network.configFile));
170+
171+
const rpcUrl = process.env.RPC_ETHEREUM;
172+
if (!rpcUrl) {
173+
console.error('Error: RPC_ETHEREUM not configured');
174+
process.exit(1);
175+
}
176+
177+
console.log(`\n### ${network.label} (${network.configFile}) ###`);
178+
179+
const provider = new ethers.JsonRpcProvider(rpcUrl);
180+
const allOpStakingAddresses = getAllOperatorStakingAddresses(stakingAddresses);
181+
let hadError = false;
182+
183+
// Part 1: ProtocolStaking roles
184+
console.log('\n=== ProtocolStaking Roles ===');
185+
186+
for (const [role, address] of Object.entries(stakingAddresses.protocolStaking)) {
187+
console.log(`\n[ProtocolStaking - ${role.toUpperCase()}]`);
188+
console.log(` Contract: ${address}`);
189+
try {
190+
const roles = await getProtocolStakingRoles(provider, rpcUrl, address);
191+
printRoles(roles, stakingAddresses, allOpStakingAddresses);
192+
} catch (error) {
193+
console.error(` Error: ${error.message}`);
194+
hadError = true;
195+
}
196+
}
197+
198+
// Part 2: OperatorStaking beneficiaries
199+
console.log('\n=== OperatorStaking Beneficiaries ===');
200+
201+
for (const [role, operators] of Object.entries(stakingAddresses.operatorStaking)) {
202+
console.log(`\n[${role.toUpperCase()}]`);
203+
204+
for (const [name, address] of Object.entries(operators)) {
205+
try {
206+
const beneficiary = await getOperatorStakingBeneficiary(provider, address);
207+
console.log(` ${name.padEnd(15)} : ${beneficiary}`);
208+
} catch (error) {
209+
console.error(` ${name.padEnd(15)} : Error - ${error.message}`);
210+
hadError = true;
211+
}
212+
}
213+
}
214+
215+
if (hadError) {
216+
process.exit(1);
217+
}
218+
}
219+
220+
main();

0 commit comments

Comments
 (0)