@openzeppelin/compact-contracts 0.1.0

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.
@@ -0,0 +1,784 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // OpenZeppelin Compact Contracts v0.1.0 (access/ShieldedAccessControl.compact)
3
+
4
+ pragma language_version >= 0.21.0;
5
+
6
+ /**
7
+ * @module ShieldedAccessControl
8
+ * @description A Shielded AccessControl library.
9
+ *
10
+ * This module provides a shielded role-based access control (RBAC) mechanism, where roles can be used to
11
+ * represent a set of permissions. Roles are stored as Merkle tree commitments to avoid
12
+ * disclosing information about role holders. Role commitments are created with the following
13
+ * hashing scheme, where `‖` denotes concatenation and all values are `Bytes<32>`:
14
+ *
15
+ * ```
16
+ * roleCommitment := SHA256( role ‖ accountId ‖ instanceSalt ‖ commitmentDomain ) where
17
+ *
18
+ * accountId := SHA256( secretKey ‖ instanceSalt ‖ accountIdDomain )
19
+ *
20
+ * roleNullifier := SHA256( roleCommitment ‖ nullifierDomain )
21
+ *
22
+ * commitmentDomain := pad(32, "ShieldedAccessControl:commitment")
23
+ * accountIdDomain := pad(32, "ShieldedAccessControl:accountId")
24
+ * nullifierDomain := pad(32, "ShieldedAccessControl:nullifier")
25
+ * ```
26
+ *
27
+ * - `roleCommitment` is a Merkle tree leaf committing a `(roleId, accountId)` pairing, inserted
28
+ * into `_operatorRoles` on grant. The `instanceSalt` prevents commitment collisions across
29
+ * deployments that share the same role identifiers.
30
+ * - `accountId` is a privacy-preserving identity commitment. `secretKey` is a 32-byte
31
+ * cryptographically secure random value held in local private state (supplied by `wit_secretKey`);
32
+ * `instanceSalt` ensures the same key cannot be correlated across contracts.
33
+ * A single `secretKey` can be used across multiple roles within the same contract instance.
34
+ * - `roleNullifier` is a one-time burn token inserted into `_roleCommitmentNullifiers` on
35
+ * revocation. Its presence permanently invalidates the corresponding role commitment,
36
+ * making re-grant under the same `accountId` impossible without generating a new identity.
37
+ * - `instanceSalt` should be an immutable, cryptographically strong random value provided on deployment
38
+ * - `commitmentDomain` is the following zero padded 32 byte string: "ShieldedAccessControl:commitment"
39
+ * - `accountIdDomain` is the following zero padded 32 byte string: "ShieldedAccessControl:accountId"
40
+ * - `nullifierDomain` is the following zero padded 32 byte string: "ShieldedAccessControl:nullifier"
41
+ *
42
+ * In this RBAC model, role commitments behave like private bearer tokens. Possession of a valid, non-revoked role
43
+ * commitment grants authorization. Revocation permanently burns the role instance, requiring explicit new issuance
44
+ * under a new account identifier. Users must rotate their secret key to generate a new `accountId` to be
45
+ * re-authorized. This creates stronger security invariants over traditional RBAC systems and enables
46
+ * privacy-preserving identity rotation.
47
+ *
48
+ * A single `secretKey` may be used for all roles within a contract instance. This is safe because the
49
+ * `role` identifier is mixed into the commitment (not the `accountId`), so different roles produce
50
+ * different commitments and different nullifiers even when the underlying `accountId` is the same.
51
+ * Revoking one role does not affect other roles held by the same `accountId`.
52
+ *
53
+ * If a role is revoked and the user needs to be re-authorized, they must generate a new `secretKey`
54
+ * to produce a new `accountId`. Other roles held under the old `secretKey` remain valid. The user
55
+ * will need to retain both keys until all roles under the old key are no longer needed.
56
+ *
57
+ * Roles are referred to by their `Bytes<32>` identifier. These should be exposed
58
+ * in the top-level contract and be unique. One way to achieve this is by
59
+ * using `export sealed ledger` hash digests that are initialized in the top-level contract:
60
+ *
61
+ * ```compact
62
+ * import CompactStandardLibrary;
63
+ * import "./node_modules/@openzeppelin/compact-contracts/src/access/ShieldedAccessControl" prefix ShieldedAccessControl_;
64
+ *
65
+ * export sealed ledger MY_ROLE: Bytes<32>;
66
+ *
67
+ * constructor(instanceSalt: Bytes<32>, defaultAdmin: ShieldedAccessControl_AccountIdentifier) {
68
+ * MY_ROLE = persistentHash<Bytes<32>>(pad(32, "MY_ROLE"));
69
+ * ShieldedAccessControl_initialize(instanceSalt);
70
+ * ShieldedAccessControl__grantRole(ShieldedAccessControl_DEFAULT_ADMIN_ROLE(), defaultAdmin);
71
+ * }
72
+ * ```
73
+ *
74
+ * To restrict access to a circuit, use {assertOnlyRole}:
75
+ *
76
+ * ```compact
77
+ * circuit foo(): [] {
78
+ * ShieldedAccessControl_assertOnlyRole(MY_ROLE as ShieldedAccessControl_RoleIdentifier);
79
+ * // ... rest of circuit logic
80
+ * }
81
+ * ```
82
+ *
83
+ * Roles can be granted and revoked dynamically via the {grantRole} and
84
+ * {revokeRole} circuits. Each role has an associated admin role, and only
85
+ * accounts that have a role's admin role can call {grantRole} and {revokeRole}.
86
+ *
87
+ * By default, the admin role for all roles is `DEFAULT_ADMIN_ROLE`, which means
88
+ * that only accounts with this role will be able to grant or revoke other
89
+ * roles. More complex role relationships can be created by using
90
+ * {_setRoleAdmin}.
91
+ *
92
+ * WARNING: The `DEFAULT_ADMIN_ROLE` is also its own admin: it has permission to
93
+ * grant and revoke this role. Extra precautions should be taken to secure
94
+ * accounts that have been granted it.
95
+ *
96
+ * @dev Privacy Assumptions
97
+ * - Outside observers will know when an admin is added and how many admins exist.
98
+ * - Outside observers will know which role identifiers are admin identifiers.
99
+ * - Outside observers will have knowledge of all role identifiers.
100
+ * - Role commitments inserted into the `_operatorRoles` MerkleTree are NOT visible in the
101
+ * public proof transcript. The MerkleTree insert hashes the value internally, and only
102
+ * the internal leaf hash is declared as a public input. Observers cannot recover the
103
+ * commitment from the leaf hash. This is in contrast to Set operations, where values
104
+ * are directly visible in the proof transcript.
105
+ * - Because commitments are hidden, outside observers will have knowledge of all role
106
+ * nullifiers but cannot link them to their corresponding commitments unless the
107
+ * commitment value space is small enough to enumerate. The strength of this unlinkability
108
+ * depends on the number of plausible `(role, accountId)` pairings. Since role identifiers
109
+ * are public, the anonymity set is effectively the number of plausible account IDs per role.
110
+ * - Outside observers will know when roles are granted and revoked.
111
+ * - Outside observers can infer the total number of role grants made across all roles—not per-role counts,
112
+ * but the cumulative total.
113
+ * - Outside observers can link calls made by the same role instance across time.
114
+ * - Users can be retroactively deanonymized if their secret key is exposed.
115
+ * - Outside observers will NOT be able to identify the holder of any role
116
+ * so long as secret key values are kept private and generated using cryptographically
117
+ * secure random values.
118
+ * - Outside observers can correlate grant and revocation transactions for the same
119
+ * `(role, accountId)` pairing by matching nullifier values. During a grant, the nullifier
120
+ * is disclosed via the `_roleCommitmentNullifiers.member` check. During a revocation, the
121
+ * same nullifier is disclosed via `_roleCommitmentNullifiers.insert`. This allows an observer
122
+ * to determine that a specific grant and revocation are related, but does not reveal the
123
+ * identity of the role holder or the commitment value.
124
+ * - Role holders have a stable pseudonymous on-chain identity for the lifetime of their role
125
+ * commitment. Every call to a protected circuit (`assertOnlyRole`, `canProveRole`) discloses
126
+ * the same nullifier via the `_roleCommitmentNullifiers.member` check, enabling observers to
127
+ * link all actions performed under the same role instance. This provides pseudonymity, not anonymity.
128
+ *
129
+ * @dev Security Considerations:
130
+ * - The `secretKey` must be kept private. Loss of the key prevents role holders
131
+ * and admins from proving access or transferring it. Key exposure may weaken privacy
132
+ * guarantees and allow retroactive deanonymization.
133
+ * - It's strongly recommended to use cryptographically secure random values for the `_instanceSalt`.
134
+ * Failure to do so may weaken privacy guarantees.
135
+ * - The `_instanceSalt` is immutable and used to differentiate deployments.
136
+ * - The `_operatorRoles` Merkle tree has a fixed capacity of 2^20 leaf slots.
137
+ * Deployers should monitor slot consumption off-chain. A careless admin can exhaust
138
+ * capacity through repeated grants of the same active (role, accountId) pairing.
139
+ * Duplicate grants are benign from a security perspective. They produce identical
140
+ * commitments and a single nullifier invalidates all copies; however, they waste tree capacity.
141
+ * Implementing contracts are responsible for mitigating tree exhaustion risk.
142
+ * - A single `secretKey` compromise exposes all roles held by that user within the contract
143
+ * instance, since all roles share the same `accountId`. Users requiring compartmentalization
144
+ * between roles should use separate keys per role and accept the additional key management overhead.
145
+ * In the event of key compromise, the user should coordinate with admins to revoke all roles
146
+ * under the compromised `accountId` and re-grant under a new identity.
147
+ * - Admins can revoke `(role, accountId)` pairings that were never granted, since on-chain
148
+ * non-membership proofs are not available for the Merkle tree. This permanently burns the
149
+ * nullifier for that pairing, blocking any future grant. Admin trust is a fundamental
150
+ * assumption of this system. Off-chain validation should confirm that a pairing was actually
151
+ * granted before submitting a revocation transaction.
152
+ * - `renounceRole` allows burning nullifiers for roles the caller does not currently hold,
153
+ * provided they possess the correct `secretKey`. This requires knowledge of the secret key,
154
+ * at which point the `accountId` should be considered fully compromised regardless.
155
+ * - The Merkle tree capacity of 2^20 leaf slots is a permanent, irrecoverable resource.
156
+ * There is no mechanism to reclaim slots after revocation, no upgrade path, and no emergency
157
+ * recovery. Once exhausted, the contract is permanently unable to grant new roles. Deployers
158
+ * must monitor slot consumption off-chain and plan capacity accordingly.
159
+ *
160
+ * @notice Using the SHA256 hashing function comes at a significant performance cost. In the future, we
161
+ * plan on migrating to a ZK-friendly hashing function when an implementation is available.
162
+ *
163
+ * @notice Missing Features and Improvements:
164
+ * - Role events
165
+ * - An ERC165-like interface
166
+ * - Migrate from SHA256 to a ZK-friendly hashing function when an implementation is available.
167
+ */
168
+ module ShieldedAccessControl {
169
+ import CompactStandardLibrary;
170
+ import "../utils/Utils" prefix Utils_;
171
+ import "../security/Initializable" prefix Initializable_;
172
+
173
+ export enum UpdateType {
174
+ Grant,
175
+ Revoke
176
+ };
177
+
178
+ // TODO: Standardize types across contracts https://github.com/OpenZeppelin/compact-contracts/issues/368
179
+ export new type RoleCommitment = Bytes<32>;
180
+ export new type RoleIdentifier = Bytes<32>;
181
+ export new type AccountIdentifier = Bytes<32>;
182
+ export new type RoleNullifier = Bytes<32>;
183
+
184
+ /**
185
+ * @ledger _operatorRoles
186
+ * @description A Merkle tree of role commitments stored as SHA256(role | accountId | instanceSalt | commitmentDomain)
187
+ * Role commitments are derived from a public role identifier (e.g., `persistentHash<Bytes<32>>(pad(32, "MY_ROLE")`),
188
+ * an account identifier (e.g., `SHA256(secretKey, instanceSalt, accountIdDomain)`), the `instanceSalt`, and a domain separator.
189
+ * @type {RoleCommitment} roleCommitment - A role commitment created by the following hash: SHA256( role | accountId | instanceSalt | commitmentDomain).
190
+ */
191
+ export ledger _operatorRoles: MerkleTree<20, RoleCommitment>;
192
+
193
+ /**
194
+ * @ledger _adminRoles
195
+ * @description Mapping from a role identifier to an admin role identifier.
196
+ */
197
+ export ledger _adminRoles: Map<RoleIdentifier, RoleIdentifier>;
198
+
199
+ /**
200
+ * @description A set of nullifiers used to prove a role has been revoked
201
+ * @type {RoleNullifier} roleNullifier - A role nullifier created by the following hash: SHA256(roleCommitment | nullifierDomain).
202
+ * @type {Set<RoleNullifier>} _roleCommitmentNullifiers
203
+ */
204
+ export ledger _roleCommitmentNullifiers: Set<RoleNullifier>;
205
+
206
+ /**
207
+ * @sealed @ledger _instanceSalt
208
+ * @description A per-instance value provided at initialization used to namespace
209
+ * commitments for this contract instance.
210
+ *
211
+ * This salt prevents commitment collisions across contracts that might otherwise use
212
+ * the same identifiers or domain parameters. It should be a cryptographically strong random value.
213
+ * If two deployments share the same instanceSalt and a user reuses their secretKey across both,
214
+ * the resulting accountId and all role commitments will be identical in both contracts.
215
+ * An observer who sees the same nullifier appear in both contracts' nullifier sets can conclude
216
+ * that it's the same user.
217
+ * It is immutable after initialization.
218
+ */
219
+ export sealed ledger _instanceSalt: Bytes<32>;
220
+
221
+ /**
222
+ * @witness wit_getRoleCommitmentPath
223
+ * @description Returns a path to a role commitment in the `_operatorRoles` Merkle tree if one exists. Otherwise, returns an invalid path.
224
+ *
225
+ * @param {RoleCommitment} roleCommitment - A commitment created by the following hash: SHA256( role | accountId | instanceSalt | commitmentDomain).
226
+ *
227
+ * @return {MerkleTreePath<20, RoleCommitment>} - The Merkle tree path to `roleCommitment` in the `_operatorRoles` Merkle tree
228
+ */
229
+ witness wit_getRoleCommitmentPath(
230
+ roleCommitment: RoleCommitment
231
+ ): MerkleTreePath<20, RoleCommitment>;
232
+
233
+ /**
234
+ * @witness wit_secretKey
235
+ * @description Returns the user's secret key used in deriving the shielded account identifier.
236
+ *
237
+ * The same key can be used across multiple roles within a contract instance. If a role is
238
+ * revoked and re-granted, a new secret key must be generated to produce a new `accountId`.
239
+ *
240
+ * @returns {Bytes<32>} secretKey - A secret key used in deriving the shielded account identifier.
241
+ */
242
+ witness wit_secretKey(): Bytes<32>;
243
+
244
+ /**
245
+ * @description Initializes the contract by storing the `instanceSalt` that acts as a privacy additive
246
+ * for preventing duplicate commitments among other contracts implementing ShieldedAccessControl.
247
+ *
248
+ * @warning The `instanceSalt` must be calculated prior to contract deployment using a cryptographically
249
+ * secure random number generator e.g. crypto.getRandomValues() to maintain strong privacy guarantees
250
+ *
251
+ * Requirements:
252
+ *
253
+ * - Contract is not initialized.
254
+ *
255
+ * @param {Bytes<32>} instanceSalt - Contract salt to prevent duplicate commitments if
256
+ * users reuse their secret key across different contracts (not recommended). Must not be zero.
257
+ *
258
+ * @returns {[]} Empty tuple.
259
+ */
260
+ export circuit initialize(instanceSalt: Bytes<32>): [] {
261
+ assert(instanceSalt != default<Bytes<32>>, "ShieldedAccessControl: Instance salt must not be 0");
262
+ Initializable_initialize();
263
+
264
+ _instanceSalt = disclose(instanceSalt);
265
+ }
266
+
267
+ /**
268
+ * @description The default admin role for all roles. Only accounts with this role will be able to grant or revoke other roles
269
+ * unless custom admin roles are created.
270
+ *
271
+ * @remarks The Compact language does not support constant declarations,
272
+ * so DEFAULT_ADMIN_ROLE is implemented as a circuit that returns a constant value by necessity.
273
+ */
274
+ export pure circuit DEFAULT_ADMIN_ROLE(): RoleIdentifier {
275
+ return default<Bytes<32>> as RoleIdentifier;
276
+ }
277
+
278
+ /**
279
+ * @description Reverts if caller cannot provide a valid proof of ownership for `role`.
280
+ *
281
+ * Requirements:
282
+ *
283
+ * - caller must prove ownership of `role`.
284
+ * - caller must not provide valid Merkle tree path for a different `(role, accountId)` pairing.
285
+ * - Contract is initialized.
286
+ *
287
+ * Disclosures:
288
+ *
289
+ * - A Merkle tree path to a role commitment.
290
+ * - A role commitment corresponding to a `(role, accountId)` pairing.
291
+ * - A nullifier for the respective role commitment.
292
+ *
293
+ * @param {RoleIdentifier} role - The role identifier.
294
+ *
295
+ * @return {[]} - Empty tuple.
296
+ */
297
+ export circuit assertOnlyRole(role: RoleIdentifier): [] {
298
+ Initializable_assertInitialized();
299
+ assert(_uncheckedCanProveRole(role), "ShieldedAccessControl: unauthorized account");
300
+ }
301
+
302
+ /**
303
+ * @description Returns `true` if a caller proves ownership of `role` and is not revoked. MAY return false for a legitimately credentialed
304
+ * caller if the proving environment supplies an invalid Merkle path. This circuit will never return true for an
305
+ * unauthorized caller.
306
+ *
307
+ * Requirements:
308
+ *
309
+ * - caller must not provide valid Merkle tree path for a different `(role, accountId)` pairing.
310
+ * - Contract is initialized.
311
+ *
312
+ * Disclosures:
313
+ *
314
+ * - A Merkle tree path to a role commitment.
315
+ * - A role commitment corresponding to a `(role, accountId)` pairing.
316
+ * - A nullifier for the respective role commitment.
317
+ *
318
+ * @param {RoleIdentifier} role - The role identifier.
319
+ *
320
+ * @return {Boolean} - A boolean determining if a caller successfully proved ownership of `role`
321
+ */
322
+ export circuit canProveRole(role: RoleIdentifier): Boolean {
323
+ Initializable_assertInitialized();
324
+ return _uncheckedCanProveRole(role);
325
+ }
326
+
327
+ /**
328
+ * @description Returns `true` if a caller proves ownership of `role` and is not revoked. MAY return false for a legitimately credentialed
329
+ * caller if the proving environment supplies an invalid Merkle path. This circuit will never return true for an
330
+ * unauthorized caller.
331
+ *
332
+ * @warning This circuit does not perform an initialization check. It's only meant to be used as
333
+ * an internal helper in the Shielded Access Control module. Using this circuit outside of the
334
+ * module may cause undefined behavior and break security guarantees.
335
+ *
336
+ * Requirements:
337
+ *
338
+ * - caller must not provide valid Merkle tree path for a different `(role, accountId)` pairing.
339
+ *
340
+ * Disclosures:
341
+ *
342
+ * - A Merkle tree path to a role commitment.
343
+ * - A role commitment corresponding to a `(role, accountId)` pairing.
344
+ * - A nullifier for the respective role commitment.
345
+ *
346
+ * @param {RoleIdentifier} role - The role identifier.
347
+ *
348
+ * @return {Boolean} - A boolean determining if a caller successfully proved ownership of `role`
349
+ */
350
+ circuit _uncheckedCanProveRole(role: RoleIdentifier): Boolean {
351
+ const accountId = _computeAccountId();
352
+ return _validateRole(role, accountId);
353
+ }
354
+
355
+ /**
356
+ * @description Grants `role` to `accountId` by inserting a role commitment unique to the
357
+ * `(role, accountId)` pairing into the `_operatorRoles` Merkle tree. Duplicate role commitments can be issued
358
+ * so long as they remain unrevoked. This does not yield any additional authority and simply wastes
359
+ * limited Merkle tree storage slots. Once revoked, a role cannot be re-granted to the same `accountId`. A new
360
+ * `accountId` must be generated to be re-authorized for a revoked `role`.
361
+ *
362
+ * Requirements:
363
+ *
364
+ * - caller must prove they're an admin for `role`.
365
+ * - caller must not provide valid Merkle tree path for a different `(role, accountId)` pairing.
366
+ * - The `(role, accountId)` pairing must not be already revoked.
367
+ * - Contract is initialized.
368
+ *
369
+ * Disclosures:
370
+ *
371
+ * - A Merkle tree path to a role commitment.
372
+ * - A role commitment corresponding to a `(role, accountId)` pairing.
373
+ * - A nullifier for the respective role commitment.
374
+ * - A role identifier.
375
+ *
376
+ * @param {RoleIdentifier} role - The role identifier.
377
+ * @param {AccountIdentifier} accountId - The unique identifier of the account.
378
+ *
379
+ * @return {[]} - Empty tuple.
380
+ */
381
+ export circuit grantRole(role: RoleIdentifier, accountId: AccountIdentifier): [] {
382
+ // Initialization check performed in assertOnlyRole
383
+ assertOnlyRole(getRoleAdmin(role));
384
+ _updateRole(role, accountId, UpdateType.Grant);
385
+ }
386
+
387
+ /**
388
+ * @description Grants `role` to `accountId` by inserting a role commitment unique to the
389
+ * `(role, accountId)` pairing into the `_operatorRoles` Merkle tree. Duplicate role commitments can be issued
390
+ * so long as they remain unrevoked. This does not yield any additional authority and simply wastes
391
+ * limited Merkle tree storage slots. Once revoked, a role cannot be re-granted to the same `accountId`. A new
392
+ * `accountId` must be generated to be re-authorized for a revoked `role`.
393
+ *
394
+ * Internal circuit without access restriction.
395
+ *
396
+ * @warning Exposing this circuit directly in an implementing contract would allow anyone to grant
397
+ * roles without authorization. It must be wrapped with appropriate access control.
398
+ *
399
+ * @warning The `_operatorRoles` Merkle tree has a fixed capacity of 2^20 leaf slots.
400
+ * Deployers should monitor slot consumption off-chain. Duplicate grants waste tree capacity
401
+ * but are otherwise benign. A single nullifier invalidates all duplicate commitments.
402
+ * Implementing contracts are responsible for mitigating tree exhaustion risk.
403
+ *
404
+ * Requirements:
405
+ *
406
+ * - The `(role, accountId)` pairing must not be already revoked.
407
+ * - Contract is initialized.
408
+ *
409
+ * Disclosures:
410
+ *
411
+ * - A role commitment corresponding to a `(role, accountId)` pairing.
412
+ * - A nullifier for the respective role commitment.
413
+ *
414
+ * @param {RoleIdentifier} role - The role identifier.
415
+ * @param {AccountIdentifier} accountId - The unique identifier of the account.
416
+ *
417
+ * @return {[]} - Empty tuple.
418
+ */
419
+ export circuit _grantRole(role: RoleIdentifier, accountId: AccountIdentifier): [] {
420
+ Initializable_assertInitialized();
421
+ _updateRole(role, accountId, UpdateType.Grant);
422
+ }
423
+
424
+ /**
425
+ * @description Revokes `role` from the calling account by inserting a role nullifier into the
426
+ * `_roleCommitmentNullifiers` set. Once revoked, a new `accountId` must be generated to be
427
+ * re-authorized for `role`.
428
+ *
429
+ * @notice Roles are often managed via {grantRole} and {revokeRole}: this circuit's
430
+ * purpose is to provide a mechanism for accounts to lose their privileges
431
+ * if they are compromised (such as when a trusted device is misplaced).
432
+ *
433
+ * @warning Outside observers may be able to use timing and pattern analysis to weaken pseudonymity
434
+ * guarantees if renounceRole is used in tandem with other on-chain actions.
435
+ *
436
+ * Requirements:
437
+ *
438
+ * - The caller must provide a valid `accountId` for the role.
439
+ * - The `(role, accountId)` pairing must not be already revoked.
440
+ * - Contract is initialized.
441
+ *
442
+ * Disclosures:
443
+ *
444
+ * - A nullifier for the respective role commitment.
445
+ *
446
+ * @param {RoleIdentifier} role - The role identifier.
447
+ * @param {AccountIdentifier} accountIdConfirmation - The caller's account identifier, must match the internally computed value.
448
+ *
449
+ * @return {[]} - Empty tuple.
450
+ */
451
+ export circuit renounceRole(role: RoleIdentifier, accountIdConfirmation: AccountIdentifier): [] {
452
+ Initializable_assertInitialized();
453
+
454
+ assert(accountIdConfirmation == _computeAccountId(),
455
+ "ShieldedAccessControl: bad confirmation"
456
+ );
457
+
458
+ _updateRole(role, accountIdConfirmation, UpdateType.Revoke);
459
+ }
460
+
461
+ /**
462
+ * @description Permanently revokes `role` from `accountId` by inserting a role nullifier into the
463
+ * `_roleCommitmentNullifiers` set. Once revoked, a new `accountId` must be generated to be re-authorized for
464
+ * `role`. At this time, proofs of non-membership on values in the Merkle tree LDT are not possible
465
+ * so a `(role, accountId)` pairing that does not exist can still be revoked.
466
+ *
467
+ * Requirements:
468
+ *
469
+ * - caller must prove they're an admin for `role`.
470
+ * - caller must not provide valid Merkle tree path for a different `(role, accountId)` pairing.
471
+ * - The `(role, accountId)` pairing must not be already revoked.
472
+ * - Contract is initialized.
473
+ *
474
+ * Disclosures:
475
+ *
476
+ * - A Merkle tree path to a role commitment.
477
+ * - A role commitment corresponding to a `(role, accountId)` pairing.
478
+ * - A nullifier for the respective role commitment.
479
+ * - A role identifier.
480
+ *
481
+ * @param {RoleIdentifier} role - The role identifier.
482
+ * @param {AccountIdentifier} accountId - The unique identifier of the account.
483
+ *
484
+ * @return {[]} - Empty tuple.
485
+ */
486
+ export circuit revokeRole(role: RoleIdentifier, accountId: AccountIdentifier): [] {
487
+ // Initialization check performed in assertOnlyRole
488
+ assertOnlyRole(getRoleAdmin(role));
489
+ _updateRole(role, accountId, UpdateType.Revoke);
490
+ }
491
+
492
+ /**
493
+ * @description Permanently revokes `role` from `accountId` by inserting a role nullifier into the
494
+ * `_roleCommitmentNullifiers` set. Once revoked, a new `accountId` must be generated to be re-authorized for
495
+ * `role`. At this time, proofs of non-membership on values in the Merkle tree LDT are not possible
496
+ * so a `(role, accountId)` pairing that does not exist can still be revoked.
497
+ *
498
+ * Internal circuit without access restriction.
499
+ *
500
+ * @warning Exposing this circuit directly in an implementing contract would allow anyone to revoke
501
+ * roles without authorization. It must be wrapped with appropriate access control.
502
+ *
503
+ * Requirements:
504
+ *
505
+ * - The `(role, accountId)` pairing must not be already revoked.
506
+ * - Contract is initialized.
507
+ *
508
+ * Disclosures:
509
+ *
510
+ * - A nullifier for the respective role commitment.
511
+ *
512
+ * @param {RoleIdentifier} role - The role identifier.
513
+ * @param {AccountIdentifier} accountId - The unique identifier of the account.
514
+ *
515
+ * @return {[]} - Empty tuple.
516
+ */
517
+ export circuit _revokeRole(role: RoleIdentifier, accountId: AccountIdentifier): [] {
518
+ Initializable_assertInitialized();
519
+ _updateRole(role, accountId, UpdateType.Revoke);
520
+ }
521
+
522
+ /**
523
+ * @description Core business logic for the grant/revoke role circuits. Asserts that the
524
+ * `(role, accountId)` pairing has not already been revoked. On success, dispatches on
525
+ * `updateType`: a `Grant` inserts the role commitment into `_operatorRoles`, and
526
+ * a `Revoke` inserts the nullifier into `_roleCommitmentNullifiers`.
527
+ *
528
+ * Disclosures:
529
+ *
530
+ * - A nullifier for the respective role commitment.
531
+ * - A role commitment (on Grant only).
532
+ *
533
+ * @notice The nullifier is disclosed via `_roleCommitmentNullifiers.member` on every call,
534
+ * regardless of the update type. Since `Set.member` publicly reveals its argument, the
535
+ * nullifier value is observable in the transaction transcript. This enables observers to
536
+ * correlate grant and revocation transactions for the same `(role, accountId)` pairing.
537
+ *
538
+ * @param {RoleIdentifier} role - The role identifier.
539
+ * @param {AccountIdentifier} accountId - The unique identifier of the account.
540
+ * @param {UpdateType} updateType - Whether to grant or revoke.
541
+ *
542
+ * @return {[]} - Empty tuple.
543
+ */
544
+ circuit _updateRole(
545
+ role: RoleIdentifier,
546
+ accountId: AccountIdentifier,
547
+ updateType: UpdateType
548
+ ): [] {
549
+ const roleCommitment = computeRoleCommitment(role, accountId);
550
+ const roleNullifier = computeNullifier(roleCommitment);
551
+ const isRevoked = _roleCommitmentNullifiers.member(disclose(roleNullifier));
552
+ assert(!isRevoked, "ShieldedAccessControl: role is already revoked");
553
+
554
+ if (updateType == UpdateType.Grant) {
555
+ _operatorRoles.insert(disclose(roleCommitment));
556
+ } else {
557
+ _roleCommitmentNullifiers.insert(disclose(roleNullifier));
558
+ }
559
+ }
560
+
561
+ /**
562
+ * @description Returns the admin role that controls `role`. Returns `DEFAULT_ADMIN_ROLE` for
563
+ * roles with no explicitly set admin. Since `DEFAULT_ADMIN_ROLE` is the zero byte array,
564
+ * there is no distinction between a nonexistent `role` and one whose admin is `DEFAULT_ADMIN_ROLE`.
565
+ * See {grantRole} and {revokeRole}.
566
+ *
567
+ * To change a role's admin use {_setRoleAdmin}.
568
+ *
569
+ * Disclosures:
570
+ *
571
+ * - A role identifier.
572
+ *
573
+ * @param {RoleIdentifier} role - The role identifier.
574
+ *
575
+ * @return {RoleIdentifier} roleAdmin - The admin role that controls `role`.
576
+ */
577
+ export circuit getRoleAdmin(role: RoleIdentifier): RoleIdentifier {
578
+ if (_adminRoles.member(disclose(role))) {
579
+ return _adminRoles.lookup(disclose(role));
580
+ }
581
+ return DEFAULT_ADMIN_ROLE();
582
+ }
583
+
584
+ /**
585
+ * @description Sets `adminId` as `role`'s admin identifier. Users with valid admin identifiers
586
+ * may grant and revoke access to the specified `role`. Internal circuit without access restriction.
587
+ *
588
+ * @warning Exposing this circuit directly in an implementing contract would allow anyone to assign
589
+ * arbitrary admin roles without authorization. It must be wrapped with appropriate access control.
590
+ *
591
+ * Disclosures:
592
+ *
593
+ * - The role identifier
594
+ * - The admin identifier
595
+ *
596
+ * @param {RoleIdentifier} role - The role identifier.
597
+ * @param {RoleIdentifier} adminId - The admin identifier for `role`.
598
+ *
599
+ * @return {[]} - Empty tuple.
600
+ */
601
+ export circuit _setRoleAdmin(role: RoleIdentifier, adminId: RoleIdentifier): [] {
602
+ Initializable_assertInitialized();
603
+ _adminRoles.insert(disclose(role), disclose(adminId));
604
+ }
605
+
606
+ /**
607
+ * @description Verifies whether `accountId` holds `role`. This circuit MAY return false for a
608
+ * legitimately credentialed account if the proving environment supplies an invalid Merkle path.
609
+ *
610
+ * @warning This circuit does not perform an initialization check. It's only meant to be used as
611
+ * an internal helper in the Shielded Access Control module. Using this circuit outside of the
612
+ * module may cause undefined behavior and break security guarantees.
613
+ *
614
+ * Requirements:
615
+ *
616
+ * - caller must not provide valid Merkle tree path for a different `(role, accountId)` pairing.
617
+ *
618
+ * Disclosures:
619
+ *
620
+ * - A Merkle tree path to a role commitment.
621
+ * - A nullifier for the respective role commitment.
622
+ *
623
+ * @notice The nullifier is disclosed via `_roleCommitmentNullifiers.member` on every call.
624
+ * Since this circuit is invoked by `assertOnlyRole` and `canProveRole`, every protected
625
+ * operation discloses the same nullifier, allowing observers to link all actions performed
626
+ * under the same role instance across time.
627
+ *
628
+ * @param {RoleIdentifier} role - The role identifier.
629
+ * @param {AccountIdentifier} accountId - The unique identifier of the account.
630
+ *
631
+ * @return {Boolean} isValidRole - A boolean indicating whether `accountId` has a valid role
632
+ */
633
+ circuit _validateRole(role: RoleIdentifier, accountId: AccountIdentifier): Boolean {
634
+ const roleCommitment = computeRoleCommitment(role, accountId);
635
+ const roleCommitmentPath = wit_getRoleCommitmentPath(roleCommitment);
636
+ const isValidPath =
637
+ _operatorRoles.checkRoot(
638
+ merkleTreePathRoot<20, RoleCommitment>(disclose(roleCommitmentPath))
639
+ );
640
+
641
+ // If the path is valid we assert it's a path for the queried leaf (not some other leaf).
642
+ if (isValidPath) {
643
+ assert(roleCommitmentPath.leaf == roleCommitment,
644
+ "ShieldedAccessControl: Path must contain leaf matching computed role commitment for the provided role, accountId pairing"
645
+ );
646
+ }
647
+
648
+ const roleNullifier = computeNullifier(roleCommitment);
649
+ const isRevoked = _roleCommitmentNullifiers.member(disclose(roleNullifier));
650
+
651
+ return isValidPath && !isRevoked;
652
+ }
653
+
654
+ /**
655
+ * @description Computes the role commitment from the given `accountId` and `role`.
656
+ *
657
+ * @warning This circuit does not perform an initialization check. It's only meant to be used as
658
+ * an internal helper in the Shielded Access Control module. Using this circuit outside of the
659
+ * module may cause undefined behavior and break security guarantees.
660
+ *
661
+ * ## Account ID (`accountId`)
662
+ * The `accountId` is expected to be computed as:
663
+ * `accountId = SHA256(secretKey, instanceSalt, accountIdDomain)`
664
+ *
665
+ * - `secretKey`: A 32-byte cryptographically secure random value.
666
+ *
667
+ * ## Role Commitment Derivation
668
+ * `roleCommitment = SHA256(role, accountId, instanceSalt, commitmentDomain)`
669
+ *
670
+ * - `accountId`: See above.
671
+ * - `role`: A unique role identifier.
672
+ * - `instanceSalt`: A unique per-deployment salt, stored during initialization.
673
+ * This prevents commitment collisions across deployments.
674
+ * - `commitmentDomain`: Domain separator `"ShieldedAccessControl:commitment"` (padded to 32 bytes) to prevent
675
+ * hash collisions when extending the module or using similar commitment schemes.
676
+ *
677
+ * @param {RoleIdentifier} role - The role identifier.
678
+ * @param {AccountIdentifier} accountId - The unique identifier of the account.
679
+ *
680
+ * @returns {RoleCommitment} The commitment derived from `accountId` and `role`.
681
+ */
682
+ export circuit computeRoleCommitment(
683
+ role: RoleIdentifier,
684
+ accountId: AccountIdentifier,
685
+ ): RoleCommitment {
686
+
687
+ return persistentHash<Vector<4, Bytes<32>>>(
688
+ [role as Bytes<32>,
689
+ accountId as Bytes<32>,
690
+ _instanceSalt,
691
+ pad(32, "ShieldedAccessControl:commitment")]
692
+ )
693
+ as RoleCommitment;
694
+ }
695
+
696
+ /**
697
+ * @description Computes the role nullifier for a given `roleCommitment`.
698
+ *
699
+ * ## Role Nullifier Derivation
700
+ * `roleNullifier = SHA256(roleCommitment, nullifierDomain)`
701
+ *
702
+ * - `roleCommitment`: See `computeRoleCommitment`.
703
+ * - `nullifierDomain`: Domain separator `"ShieldedAccessControl:nullifier"` (padded to 32 bytes) to prevent
704
+ * hash collisions when extending the module or using similar commitment schemes.
705
+ *
706
+ * @param {RoleCommitment} roleCommitment - The role commitment for a particular `(role, accountId)` pairing.
707
+ *
708
+ * @returns {RoleNullifier} roleNullifier - The associated nullifier for `roleCommitment`.
709
+ */
710
+ export pure circuit computeNullifier(roleCommitment: RoleCommitment): RoleNullifier {
711
+ return persistentHash<Vector<2, Bytes<32>>>(
712
+ [roleCommitment as Bytes<32>, pad(32, "ShieldedAccessControl:nullifier")]
713
+ )
714
+ as RoleNullifier;
715
+ }
716
+
717
+ /**
718
+ * @description Computes the unique identifier (`accountId`) of a caller from their
719
+ * secret key and the instance salt.
720
+ *
721
+ * @warning This circuit does not perform an initialization check. It's only meant to be used as
722
+ * an internal helper in the Shielded Access Control module. Using this circuit outside of the
723
+ * module may cause undefined behavior and break security guarantees.
724
+ *
725
+ * ## ID Derivation
726
+ * `accountId = SHA256(secretKey, instanceSalt, accountIdDomain)`
727
+ *
728
+ * - `secretKey`: A 32-byte cryptographically secure random value supplied by the `wit_secretKey` witness.
729
+ * - `instanceSalt`: A unique per-deployment salt, stored during initialization.
730
+ * This prevents commitment collisions across deployments.
731
+ * - `accountIdDomain`: Domain separator `"ShieldedAccessControl:accountId"` (padded to 32 bytes) to prevent
732
+ * hash collisions when extending the module or using similar commitment schemes.
733
+ *
734
+ * The result is a 32-byte commitment that uniquely identifies the account.
735
+ * This value is later used in role commitment hashing,
736
+ * and acts as a privacy-preserving identity.
737
+ *
738
+ * @returns {AccountIdentifier} accountId - The computed account ID.
739
+ */
740
+ circuit _computeAccountId(): AccountIdentifier {
741
+ return computeAccountId(wit_secretKey(), _instanceSalt);
742
+ }
743
+
744
+ /**
745
+ * @description Computes an `accountId` without on-chain state, allowing a user to derive
746
+ * their shielded identity commitment before submitting it in a grant or revoke operation.
747
+ * This is the off-chain counterpart to {_computeAccountId} and produces an identical result
748
+ * given the same inputs.
749
+ *
750
+ * @warning OpSec: The `secretKey` parameter is a sensitive secret. Mishandling it can
751
+ * permanently compromise the privacy guarantees of this system:
752
+ *
753
+ * - **Never log or persist** the `secretKey` in plaintext — avoid browser devtools,
754
+ * application logs, analytics pipelines, or any observable side-channel.
755
+ * - **Store offline or in secure enclaves** — hardware security modules (HSMs),
756
+ * air-gapped devices, or encrypted vaults are strongly preferred over hot storage.
757
+ * - **Use cryptographically secure randomness** — generate keys with `crypto.getRandomValues()`
758
+ * or equivalent; weak or predictable keys can be brute-forced to reveal your identity.
759
+ * - **Treat key loss as identity loss** — a lost key cannot be recovered. Back up
760
+ * keys securely before using them in role commitments.
761
+ * - **Avoid calling this circuit in untrusted environments** — executing this in an
762
+ * unverified browser extension, compromised runtime, or shared machine may expose
763
+ * the key to a malicious observer.
764
+ *
765
+ * ## ID Derivation
766
+ * `accountId = SHA256(secretKey, instanceSalt, accountIdDomain)`
767
+ *
768
+ * See {_computeAccountId} for further details.
769
+ *
770
+ * @param {Bytes<32>} secretKey - A 32-byte cryptographically secure random value.
771
+ * @param {Bytes<32>} instanceSalt - The unique per-deployment salt for the contract instance.
772
+ *
773
+ * @returns {AccountIdentifier} accountId - The computed account ID.
774
+ */
775
+ export pure circuit computeAccountId(
776
+ secretKey: Bytes<32>,
777
+ instanceSalt: Bytes<32>
778
+ ): AccountIdentifier {
779
+ return persistentHash<Vector<3, Bytes<32>>>(
780
+ [secretKey, instanceSalt, pad(32, "ShieldedAccessControl:accountId")]
781
+ )
782
+ as AccountIdentifier;
783
+ }
784
+ }