@lazy-sol/access-control 1.0.6 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ui.html ADDED
@@ -0,0 +1,506 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <title>Role-based Access Control (RBAC) Inspector</title>
6
+ <!-- Include Web3.js from a CDN -->
7
+ <script src="https://cdn.jsdelivr.net/npm/web3@latest/dist/web3.min.js"></script>
8
+ <style>
9
+ table#roles_list {
10
+ width: 100%;
11
+ border-collapse: collapse;
12
+ }
13
+ table#roles_list th, table#roles_list td {
14
+ border: 1px solid grey;
15
+ }
16
+ table#roles_list tr td {
17
+ font-family: monospace;
18
+ }
19
+ </style>
20
+ </head>
21
+ <body>
22
+ <fieldset id="root_container" style="display: none;"><legend id="connection_state" style="text-align: right;"></legend>
23
+ <div id="main_app" style="display: none;">
24
+ <fieldset><legend>Role-based Access Control (RBAC) Features and Roles Inspector</legend>
25
+ <form onsubmit="inspect_features_roles(event)">
26
+ <table>
27
+ <tr>
28
+ <td><label for="contract_address">Contract Address:</label></td>
29
+ <td><input id="contract_address" type="text" required size="42" style="font-family: monospace;" value="0xA9f50518f9119d5F7A324EC76481c5246970dEa6"/></td>
30
+ </tr>
31
+ <tr><td></td><td><input type="submit" value="Inspect"/></td></tr>
32
+ </table>
33
+ </form>
34
+ </fieldset>
35
+ <div id="features_roles_container" style="display: none;">
36
+ <fieldset>
37
+ <legend id="features_title">RBAC Features</legend>
38
+ <div id="features_hex">Loading...</div>
39
+ </fieldset>
40
+ <fieldset>
41
+ <legend id="roles_list_title">RBAC Operators and Roles</legend>
42
+ <table id="roles_list"><tr><td>Loading...</td></tr></table>
43
+ </fieldset>
44
+ </div>
45
+ </div>
46
+ </fieldset>
47
+ <div style="position: fixed; left: 0; bottom: 0; margin: 0.1em 0.2em;">&copy; 2024 <a href="https://github.com/lazy-sol/">Lazy So[u]l</a></div>
48
+ <div style="position: fixed; right: 0; bottom: 0; margin: 0.1em 0.2em; font-family: monospace;">
49
+ [<a href="https://lazy-sol.github.io/advanced-erc20/ui.html">Advanced ERC20</a>]
50
+ [<a href="https://lazy-sol.github.io/tiny-erc721/ui.html">Tiny ERC721</a>]
51
+ [RBAC Inspector]
52
+ </div>
53
+ </body>
54
+ <script type="text/javascript">
55
+ // verify if MetaMask is installed
56
+ if(!window.ethereum) {
57
+ if(window.confirm("MetaMask is not installed. You will be redirected to the MetaMask installation page.")) {
58
+ window.setTimeout(function() {
59
+ window.location.href = "https://metamask.io/download/";
60
+ }, 0);
61
+ }
62
+ window.stop();
63
+ }
64
+ </script>
65
+
66
+ <script type="text/javascript">
67
+ // static HTML page anchors
68
+ const root_container = document.getElementById("root_container");
69
+ const connection_state = document.getElementById("connection_state");
70
+ const main_app = document.getElementById("main_app");
71
+ const contract_address = document.getElementById("contract_address");
72
+ const features_roles_container = document.getElementById("features_roles_container");
73
+ const features_title = document.getElementById("features_title");
74
+ const features_hex = document.getElementById("features_hex");
75
+ const roles_list_title = document.getElementById("roles_list_title");
76
+ const roles_list = document.getElementById("roles_list");
77
+ </script>
78
+
79
+ <script type="text/javascript">
80
+ // auxiliary pure functions
81
+
82
+ // auxiliary function to log and display non-fatal error, doesn't stop execution
83
+ function non_fatal_error(message, error) {
84
+ console.error("%s: %o", message, error);
85
+ window.alert(message + (error? ": " + json_stringify(error): ""));
86
+ }
87
+
88
+ // auxiliary function to log and display fatal error and stop execution
89
+ function fatal_error(message, error) {
90
+ non_fatal_error("FATAL: " + message, error);
91
+ window.stop();
92
+ throw error? error: "error";
93
+ }
94
+
95
+ // save JSON.stringify works properly with BigInt
96
+ function json_stringify(input) {
97
+ return JSON.stringify(input, (key, value) =>
98
+ typeof value === 'bigint' ? value.toString() : value
99
+ );
100
+ }
101
+ </script>
102
+
103
+ <script type="text/javascript">
104
+ // config and app state
105
+
106
+ // config stores settings which don't change
107
+ const CONF = {
108
+ // supported networks, where our contracts are known to be deployed
109
+ chains: {
110
+ 1: {
111
+ name: "Mainnet",
112
+ block_explorer: {
113
+ tx: "https://etherscan.io/tx/",
114
+ address: "https://etherscan.io/address/",
115
+ token: "https://etherscan.io/token/",
116
+ },
117
+ },
118
+ 11155111: {
119
+ name: "Sepolia",
120
+ block_explorer: {
121
+ tx: "https://sepolia.etherscan.io/tx/",
122
+ address: "https://sepolia.etherscan.io/address/",
123
+ token: "https://sepolia.etherscan.io/token/",
124
+ },
125
+ },
126
+ 8453: {
127
+ name: "Base",
128
+ block_explorer: {
129
+ tx: "https://basescan.org/tx/",
130
+ address: "https://basescan.org/address/",
131
+ token: "https://basescan.org/token/",
132
+ },
133
+ },
134
+ 84532: {
135
+ name: "Base Sepolia",
136
+ block_explorer: {
137
+ tx: "https://sepolia.basescan.org//tx/",
138
+ address: "https://sepolia.basescan.org/address/",
139
+ token: "https://sepolia.basescan.org/token/",
140
+ },
141
+ },
142
+ },
143
+ };
144
+
145
+ // state stores settings which may change due to user actions,
146
+ // due to the async nature of the app, many incoming events, it makes sense to follow
147
+ // the state of the app by listening to all the events and updating the state
148
+ const STATE = {
149
+ // currently connected network (recognized decimal Chain ID)
150
+ chain_id: undefined,
151
+ // currently connected account (accounts[0])
152
+ A0: undefined,
153
+ // currently connected chain config
154
+ chain: function() {
155
+ return this.chain_id? CONF.chains[this.chain_id]: undefined;
156
+ },
157
+ // function to display the chain we're connected to
158
+ update_chain_id: function(chain_id) {
159
+ this.chain_id = chain_id? parseInt(chain_id): undefined;
160
+ },
161
+ // function to update currently connected account
162
+ update_A0: function(accounts) {
163
+ // MetaMask is locked or not connected
164
+ if(!accounts || !accounts.length) {
165
+ this.A0 = undefined;
166
+ }
167
+ else {
168
+ [this.A0] = accounts;
169
+ }
170
+ },
171
+ // function to refresh the connection state UI
172
+ refresh_ui: function() {
173
+ if(CONF.chains[this.chain_id]) {
174
+ if(this.A0) {
175
+ connection_state.innerHTML = `Connected <span style="font-family: monospace;">${this.A0}</span>`;
176
+ }
177
+ else {
178
+ connection_state.innerHTML = `
179
+ MetaMask is not connected.
180
+ <input type="button" onclick="metamask_connect()" value="Connect"/>
181
+ `;
182
+ }
183
+ }
184
+ else if(this.chain_id) {
185
+ connection_state.innerHTML = `
186
+ Unsupported Chain ID: ${this.chain_id}
187
+ <input type="button" onclick="add_sepolia_network()" value="Switch to Sepolia"/>
188
+ `;
189
+ }
190
+ else {
191
+ connection_state.innerHTML = `
192
+ MetaMask disconnected.
193
+ <input type="button" onclick="metamask_connect()" value="Connect"/>
194
+ `;
195
+ }
196
+ root_container.style["display"] = "block";
197
+ },
198
+ // function to check if we're connected to supported network
199
+ connected: function() {
200
+ return this.chain() && this.A0;
201
+ },
202
+ }
203
+ </script>
204
+
205
+ <script type="text/javascript">
206
+ // main app section
207
+
208
+ // define window.ethereum shortcut
209
+ const ethereum = window.ethereum;
210
+ // MetaMask is installed, create a new Web3 instance
211
+ const web3 = new Web3(window.ethereum);
212
+
213
+ // function to connect to MetaMask
214
+ function metamask_connect() {
215
+ ethereum.request({ method: 'eth_requestAccounts' }).then(function(accounts) {
216
+ STATE.update_A0(accounts);
217
+ STATE.refresh_ui();
218
+ reload_main_app();
219
+ }).catch(function(e) {
220
+ non_fatal_error("Error connecting MetaMask", e);
221
+ });
222
+ }
223
+
224
+ // check if MetaMask is connected
225
+ ethereum.request({ method: 'eth_accounts' }).then(function(accounts) {
226
+ // check current connected network
227
+ ethereum.request({ method: 'eth_chainId' }).then(function(chain_id) {
228
+ STATE.update_chain_id(chain_id);
229
+ STATE.update_A0(accounts);
230
+ STATE.refresh_ui();
231
+ reload_main_app();
232
+ }).catch(function(e) {
233
+ fatal_error("Error getting Chain ID", e);
234
+ });
235
+ }).catch(function(e) {
236
+ fatal_error("Error connecting to MetaMask", e);
237
+ });
238
+
239
+ // Handle the case when MetaMask connects another account
240
+ ethereum.on('accountsChanged', function(accounts) {
241
+ console.log("account has been switched", accounts)
242
+ STATE.update_A0(accounts);
243
+ STATE.refresh_ui();
244
+ reload_main_app();
245
+ });
246
+
247
+ // Handle the case when MetaMask is connected
248
+ ethereum.on('connect', function(connect_info) {
249
+ console.log('MetaMask has been connected', connect_info);
250
+ STATE.update_chain_id(connect_info.chainId);
251
+ STATE.refresh_ui();
252
+ reload_main_app();
253
+ });
254
+
255
+ // MetaMask disconnect listener
256
+ ethereum.on('disconnect', function(error) {
257
+ console.warn('MetaMask has been disconnected', error);
258
+ STATE.update_A0();
259
+ STATE.update_chain_id();
260
+ STATE.refresh_ui();
261
+ reload_main_app();
262
+ });
263
+
264
+ // network switch listener
265
+ ethereum.on('chainChanged', function(chain_id) {
266
+ console.log('network has been changed', chain_id)
267
+ STATE.update_chain_id(chain_id);
268
+ STATE.refresh_ui();
269
+ reload_main_app();
270
+ });
271
+
272
+ // define an async routine to switch the network to Sepolia
273
+ async function add_sepolia_network() {
274
+ const sepolia_chain_id = '0xaa36a7'; // Hexadecimal chain ID for Sepolia
275
+
276
+ try {
277
+ // Check if Sepolia is already added
278
+ await ethereum.request({
279
+ method: 'wallet_switchEthereumChain',
280
+ params: [{ chainId: sepolia_chain_id }],
281
+ });
282
+ }
283
+ catch(e) {
284
+ // Error code 4902 indicates that the chain has not been added to MetaMask
285
+ if(e.code !== 4902) {
286
+ // Handle errors when switching the network
287
+ fatal_error("Error switching network to Sepolia", e)
288
+ }
289
+
290
+ try {
291
+ // Add the Sepolia network
292
+ await ethereum.request({
293
+ method: 'wallet_addEthereumChain',
294
+ params: [{
295
+ chainId: sepolia_chain_id,
296
+ rpcUrl: 'https://sepolia.infura.io/v3/',
297
+ chainName: 'Sepolia Test Network',
298
+ nativeCurrency: {
299
+ name: 'SepoliaETH',
300
+ symbol: 'SepoliaETH',
301
+ decimals: 18,
302
+ },
303
+ blockExplorerUrls: ['https://sepolia.etherscan.io/'],
304
+ }],
305
+ });
306
+ }
307
+ catch (e) {
308
+ // Handle errors when adding the network
309
+ fatal_error("Error adding Sepolia network", e);
310
+ }
311
+ }
312
+ }
313
+
314
+ // writes the entire roles_list table
315
+ function features_roles_update({features, roles, assignments}) {
316
+ if(!Array.isArray(roles)) {
317
+ roles = Object.entries(roles).map(([operator, role]) => Object.assign({}, {operator, role}));
318
+ }
319
+
320
+ if(assignments && assignments.length) {
321
+ features_hex.innerHTML = features?
322
+ `Features enabled: <span style="font-family: monospace">0x${features.toString(16).toUpperCase()}</span>`:
323
+ "No features enabled.";
324
+
325
+ if(roles && roles.length) {
326
+ const table_body = roles.map(render_role_row).join("\n");
327
+ roles_list.innerHTML = `<tr><th>Operator Address</th><th>Assigned Role</th></tr>\n${table_body}`;
328
+ }
329
+ else {
330
+ roles_list.innerHTML = "No roles assigned.";
331
+ }
332
+ }
333
+ else {
334
+ features_hex.innerHTML = "No features enabled or not an RBAC contract.";
335
+ roles_list.innerHTML = "No roles assigned or not an RBAC contract.";
336
+ }
337
+ features_roles_container.style["display"] = "block";
338
+ }
339
+
340
+ // renders an HTML of the role list table row
341
+ function render_role_row({operator, role}) {
342
+ return `<tr><td>${operator}</td><td>0x${role.toString(16).toUpperCase()}</td></tr>`;
343
+ }
344
+
345
+ // reloads the entire app, used when network or accounts have been changed
346
+ function reload_main_app() {
347
+ if(!STATE.connected()) {
348
+ main_app.style["display"] = "none";
349
+ return;
350
+ }
351
+
352
+ features_roles_container.style.display = "none";
353
+ features_title.innerHTML = "RBAC Features";
354
+ features_hex.innerHTML = "Loading features...";
355
+ roles_list_title.innerHTML = "RBAC Operators and Roles";
356
+ roles_list.innerHTML = "Loading operators and roles...";
357
+
358
+ main_app.style["display"] = "block";
359
+ }
360
+
361
+ // function to extract RBAC roles from the target contract
362
+ async function extract_rbac_features_roles(target_address) {
363
+ // the earliest versions of the AccessControl emitted both FeaturesUpdated and RoleUpdated;
364
+ // the features were assigned to a zero address
365
+ // TODO: the earliest versions didn't emit an event on deployment
366
+ // later versions of the AccessControl emitted only RoleUpdated event;
367
+ // the features were assigned to a zero address, and later to the contract address (self-role)
368
+ // the most recent versions of the AccessControl emit only RoleUpdated event;
369
+ // the features are assigned to the contract address, argument naming is improved and made clearer
370
+ const v0_abi = [{
371
+ // the earliest versions only
372
+ "name": "FeaturesUpdated",
373
+ "type": "event",
374
+ "anonymous": false,
375
+ "inputs": [
376
+ {"indexed": true, "internalType": "address", "name": "_by", "type": "address"},
377
+ {"indexed": false, "internalType": "uint256", "name": "_requested", "type": "uint256"},
378
+ {"indexed": false, "internalType": "uint256", "name": "_actual", "type": "uint256"}
379
+ ],
380
+ }, {
381
+ // both the earliest and later versions
382
+ "name": "RoleUpdated",
383
+ "type": "event",
384
+ "anonymous": false,
385
+ "inputs": [
386
+ {"indexed": true, "internalType": "address", "name": "_by", "type": "address"},
387
+ {"indexed": true, "internalType": "address", "name": "_to", "type": "address"},
388
+ {"indexed": false, "internalType": "uint256", "name": "_requested", "type": "uint256"},
389
+ {"indexed": false, "internalType": "uint256", "name": "_actual", "type": "uint256"}
390
+ ],
391
+ }, {
392
+ // both the earliest and later versions
393
+ "name": "userRoles",
394
+ "type": "function",
395
+ "inputs": [{"internalType": "address", "name": "", "type": "address"}],
396
+ "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}],
397
+ "stateMutability": "view",
398
+ }];
399
+ const v1_abi = [{
400
+ // the latest versions only
401
+ "name": "RoleUpdated",
402
+ "type": "event",
403
+ "anonymous": false,
404
+ "inputs": [
405
+ {"indexed": true, "internalType": "address", "name": "operator", "type": "address"},
406
+ {"indexed": false, "internalType": "uint256", "name": "requested", "type": "uint256"},
407
+ {"indexed": false, "internalType": "uint256", "name": "assigned", "type": "uint256"}
408
+ ],
409
+ }, {
410
+ // the latest versions only
411
+ "name": "getRole",
412
+ "type": "function",
413
+ "inputs": [{"internalType": "address", "name": "operator", "type": "address"}],
414
+ "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}],
415
+ "stateMutability": "view",
416
+ }];
417
+
418
+ const v0_contract = new web3.eth.Contract(v0_abi, target_address);
419
+ const v1_contract = new web3.eth.Contract(v1_abi, target_address);
420
+
421
+ const features_v0 = await v0_contract.getPastEvents("FeaturesUpdated", {
422
+ fromBlock: "earliest",
423
+ });
424
+ console.log("v0.getPastEvents(FeaturesUpdated)", features_v0);
425
+ const role_v0 = await v0_contract.getPastEvents("RoleUpdated", {
426
+ fromBlock: "earliest",
427
+ });
428
+ console.log("v0.getPastEvents(RoleUpdated)", role_v0);
429
+ const role_v1 = await v1_contract.getPastEvents("RoleUpdated", {
430
+ fromBlock: "earliest",
431
+ });
432
+ console.log("v1.getPastEvents(RoleUpdated)", role_v1);
433
+ const assignments = features_v0.map(function(event) {
434
+ const {_actual: assigned} = event.returnValues;
435
+ // fix to the most recent format: feature is self-role
436
+ return {operator: target_address, assigned};
437
+ }).concat(role_v0.map(function(event) {
438
+ let {_to: operator, _actual: assigned} = event.returnValues;
439
+ if(operator === "0x0000000000000000000000000000000000000000") {
440
+ // fix to the most recent format: feature is self-role
441
+ operator = target_address;
442
+ }
443
+ return {operator, assigned};
444
+ })).concat(role_v1.map(function(event) {
445
+ const {operator, assigned} = event.returnValues;
446
+ return {operator, assigned};
447
+ }));
448
+
449
+ let features = 0;
450
+ v0_contract.methods["userRoles"]();
451
+
452
+ const roles = {};
453
+ assignments.forEach(function({operator, assigned}) {
454
+ if(operator === target_address) {
455
+ features = assigned;
456
+ }
457
+ else if(!BigInt(assigned)) {
458
+ delete roles[operator];
459
+ }
460
+ else {
461
+ roles[operator] = assigned;
462
+ }
463
+ });
464
+ console.log("extracted RBAC features and roles", features, roles);
465
+
466
+ return {features, roles, assignments};
467
+ }
468
+
469
+ // loads and parses features and roles on the RBAC contract,
470
+ // always returns false since is used as a form submit listener
471
+ function inspect_features_roles(e) {
472
+ if(e && e.preventDefault) {
473
+ e.preventDefault();
474
+ }
475
+
476
+ if(!contract_address.value) {
477
+ alert("Contract Address is required");
478
+ return false;
479
+ }
480
+ if(!STATE.connected()) {
481
+ alert("Not connected to the network. Please reload the page if the problem persists.")
482
+ return false;
483
+ }
484
+
485
+ web3.eth.getCode(contract_address.value).then(function(code) {
486
+ if(code.length < 4) {
487
+ alert("no code at " + contract_address.value);
488
+ }
489
+ }).catch(function(e) {
490
+ non_fatal_error("can't load contract at " + contract_address.value, e);
491
+ });
492
+
493
+ extract_rbac_features_roles(contract_address.value).then(features_roles_update).catch(function(e) {
494
+ console.warn("failed to load RBAC features and roles list", e);
495
+ features_hex.innerHTML = "Failed to load RBAC features: " + e;
496
+ roles_list.innerHTML = "Failed to load RBAC roles list: " + e;
497
+ features_roles_container.style["display"] = "block";
498
+ }).finally(function() {
499
+ features_title.innerHTML = "RBAC Features for " + contract_address.value;
500
+ roles_list_title.innerHTML = "RBAC Operators and Roles for " + contract_address.value;
501
+ });
502
+
503
+ return false;
504
+ }
505
+ </script>
506
+ </html>