@lazy-sol/access-control 1.0.6 → 1.1.1

Sign up to get free protection for your applications and to get access to all the features.
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>