@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,479 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // OpenZeppelin Compact Contracts v0.1.0 (access/AccessControl.compact)
3
+
4
+ pragma language_version >= 0.21.0;
5
+
6
+ /**
7
+ * @module AccessControl
8
+ * @description An unshielded AccessControl library.
9
+ * This module provides a role-based access control mechanism, where roles can be used to
10
+ * represent a set of permissions.
11
+ *
12
+ * Authorization is based on a witness-derived identity scheme. Each caller proves knowledge
13
+ * of a secret key by injecting it via the `wit_AccessControlSK` witness. The module computes
14
+ * an account identifier as `persistentHash(secretKey)` which is a commitment that hides the key
15
+ * while providing a stable, pseudonymous on-chain identity.
16
+ *
17
+ * Because the account identifier is `H(secretKey)` with no per-deployment salt or domain
18
+ * separator, the same secret key produces the same identity across all contracts. This is
19
+ * intentional. It provides a linkable pseudonymous identity analogous to Solidity's
20
+ * `msg.sender`. Users who desire cross-contract unlinkability can use different secret keys
21
+ * per contract at the wallet layer.
22
+ *
23
+ * Roles are referred to by their `Bytes<32>` identifier. These should be exposed
24
+ * in the top-level contract and be unique. One way to achieve this is by
25
+ * using `export sealed ledger` hash digests that are initialized in the top-level contract:
26
+ *
27
+ * ```compact
28
+ * import CompactStandardLibrary;
29
+ * import "./node_modules/@openzeppelin-compact/accessControl/src/AccessControl" prefix AccessControl_;
30
+ *
31
+ * export sealed ledger MY_ROLE: Bytes<32>;
32
+ *
33
+ * constructor() {
34
+ * MY_ROLE = persistentHash<Bytes<32>>(pad(32, "MY_ROLE"));
35
+ * }
36
+ * ```
37
+ *
38
+ * To restrict access to a circuit, use {assertOnlyRole}:
39
+ *
40
+ * ```compact
41
+ * circuit foo(): [] {
42
+ * AccessControl_assertOnlyRole(MY_ROLE);
43
+ * // ... rest of circuit logic
44
+ * }
45
+ * ```
46
+ *
47
+ * Roles can be granted and revoked dynamically via the {grantRole} and
48
+ * {revokeRole} circuits. Each role has an associated admin role, and only
49
+ * accounts that have a role's admin role can call {grantRole} and {revokeRole}.
50
+ *
51
+ * By default, the admin role for all roles is `DEFAULT_ADMIN_ROLE` (zero bytes), which means
52
+ * that only accounts with this role will be able to grant or revoke other
53
+ * roles. More complex role relationships can be created by using
54
+ * {_setRoleAdmin}.
55
+ *
56
+ * WARNING: The `DEFAULT_ADMIN_ROLE` is also its own admin: it has permission to
57
+ * grant and revoke this role. Extra precautions should be taken to secure
58
+ * accounts that have been granted it.
59
+ *
60
+ * @notice Roles can only be granted to Bytes<32> identifiers
61
+ * through the main role approval circuits (`grantRole` and `_grantRole`).
62
+ * In other words, role approvals to contract addresses are disallowed through these
63
+ * circuits.
64
+ * This is because Compact currently does not support contract-to-contract calls which means
65
+ * if a contract is granted a role, the contract cannot directly call the protected
66
+ * circuit.
67
+ *
68
+ * @notice This module does offer an experimental circuit that allows roles to be granted
69
+ * to contract addresses (`_unsafeGrantRole`).
70
+ * Note that the circuit name is very explicit ("unsafe") with this experimental circuit.
71
+ * Until contract-to-contract calls are supported,
72
+ * there is no direct way for a contract to call protected circuits.
73
+ *
74
+ * @notice The unsafe circuits are planned to become deprecated once contract-to-contract calls
75
+ * are supported.
76
+ *
77
+ * @dev Migration plan (safe -> C2C-capable role grants)
78
+ *
79
+ * The account type is the final `Either<Bytes<32>, ContractAddress>` from day one, in both circuit
80
+ * signatures and the roles ledger map. This is deliberate. Midnight's upgrade mechanism, the
81
+ * Contract Maintenance Authority (CMA), can rotate a circuit's verifier key but cannot migrate
82
+ * ledger state (there is no state-rewrite primitive; see the `updatability` tutorial). Fixing the
83
+ * type now makes the future migration a pure verifier-key rotation with zero ledger-state change,
84
+ * which is the only kind of upgrade the CMA supports. Widening the type later would instead force
85
+ * a ledger-layout rewrite, which the CMA cannot perform.
86
+ *
87
+ * When C2C lands, an already-deployed contract migrates without changing its address or ledger
88
+ * state:
89
+ *
90
+ * - Minor release: mark `_unsafeGrantRole` deprecated (and emit a warning if possible); keep its
91
+ * implementation intact so existing callers continue to work.
92
+ * - Major release: ship `grantRole` / `_grantRole` with the `isContract` guard removed and drop
93
+ * `_unsafeGrantRole`. Operators adopt the new logic by signing a CMA `MaintenanceUpdate` that
94
+ * rotates the affected verifier keys (`VerifierKeyRemove` + `VerifierKeyInsert` for each guarded
95
+ * circuit, and a `VerifierKeyRemove` for `_unsafeGrantRole`). The contract address and all ledger
96
+ * state are preserved. Because the CMA allows multiple active VK versions per operation, old and
97
+ * new keys can coexist during a transition window so in-flight proofs still verify. By this
98
+ * point, anyone using `_unsafeGrantRole` should have migrated to the now C2C-capable circuits.
99
+ *
100
+ * @dev Security Considerations:
101
+ * - The `secretKey` must be kept private. Loss of the key prevents role holders
102
+ * from proving access. Key exposure allows impersonation.
103
+ * - It is strongly recommended to use cryptographically secure random values for the secret key
104
+ * (e.g., `crypto.getRandomValues()`). Weak or predictable keys can be brute-forced.
105
+ * - The `secretKey` is provided via the `wit_AccessControlSK` witness. As with all witnesses,
106
+ * the contract must not assume that the witness implementation matches the developer's code.
107
+ * Any DApp may provide any implementation. The ZK proof system constrains what values
108
+ * can produce valid proofs.
109
+ *
110
+ * @dev Canonicalization
111
+ * All `Either<Bytes<32>, ContractAddress>` values used as map keys in `_operatorRoles`
112
+ * are canonicalized before reads and writes. Canonicalization zeroes out the inactive
113
+ * branch of the Either, ensuring that two values with the same active branch always
114
+ * resolve to the same map key regardless of what data the inactive branch carries.
115
+ * `hasRole` canonicalizes on read. `_unsafeGrantRole` and `_revokeRole` canonicalize
116
+ * on write. Internal circuits use `_hasRole` which assumes pre-canonicalized inputs.
117
+ *
118
+ * @notice Missing Features and Improvements:
119
+ *
120
+ * - Role events
121
+ * - An ERC165-like interface
122
+ */
123
+ module AccessControl {
124
+ import CompactStandardLibrary;
125
+ import "../utils/Utils" prefix Utils_;
126
+
127
+ /**
128
+ * @description Mapping from a role identifier -> account -> its permissions.
129
+ * @type {Bytes<32>} roleId - A hash representing a role identifier.
130
+ * @type {Map<Either<Bytes<32>, ContractAddress>, Boolean>} hasRole - A mapping from an account to a
131
+ * Boolean determining if the account is approved for a role.
132
+ * @type {Map<roleId, hasRole>}
133
+ * @type {Map<Bytes<32>, Map<Either<Bytes<32>, ContractAddress>, Boolean>>} _operatorRoles
134
+ */
135
+ export ledger _operatorRoles: Map<Bytes<32>,
136
+ Map<Either<Bytes<32>, ContractAddress>, Boolean>>;
137
+
138
+ /**
139
+ * @description Mapping from a role identifier to an admin role identifier.
140
+ * @type {Bytes<32>} roleId - A hash representing a role identifier.
141
+ * @type {Bytes<32>} adminId - A hash representing an admin identifier.
142
+ * @type {Map<roleId, adminId>}
143
+ * @type {Map<Bytes<32>, Bytes<32>>} _adminRoles
144
+ */
145
+ export ledger _adminRoles: Map<Bytes<32>, Bytes<32>>;
146
+
147
+ /**
148
+ * @description The default admin role for all roles. Only accounts with this role will be able
149
+ * to grant or revoke other roles unless custom admin roles are created via {_setRoleAdmin}.
150
+ *
151
+ * @return {Bytes<32>} - The default admin role identifier (zero bytes).
152
+ */
153
+ export pure circuit DEFAULT_ADMIN_ROLE(): Bytes<32> {
154
+ return default<Bytes<32>>;
155
+ }
156
+
157
+ /**
158
+ * @witness wit_AccessControlSK
159
+ * @description Returns the caller's secret key used in deriving the account identifier.
160
+ *
161
+ * The same key produces the same account identifier across all contracts. Users who
162
+ * desire cross-contract unlinkability should use different keys per contract.
163
+ *
164
+ * @returns {Bytes<32>} secretKey - A 32-byte cryptographically secure random value.
165
+ */
166
+ witness wit_AccessControlSK(): Bytes<32>;
167
+
168
+ /**
169
+ * @description Returns `true` if `account` has been granted `roleId`.
170
+ *
171
+ * @circuitInfo k=10, rows=1019
172
+ *
173
+ * @param {Bytes<32>} roleId - The role identifier.
174
+ * @param {Either<Bytes<32>, ContractAddress>} account - The account to query.
175
+ * @return {Boolean} - Whether the account has the specified role.
176
+ */
177
+ export circuit hasRole(roleId: Bytes<32>,
178
+ account: Either<Bytes<32>, ContractAddress>
179
+ ): Boolean {
180
+ const canonAcct = Utils_canonicalize<Bytes<32>, ContractAddress>(account);
181
+ return _hasRole(roleId, canonAcct);
182
+ }
183
+
184
+ /**
185
+ * @description Returns `true` if `account` has been granted `roleId`. Internal variant
186
+ * that assumes `account` has already been canonicalized by the caller. External consumers
187
+ * should use {hasRole} which canonicalizes the input automatically.
188
+ *
189
+ * @param {Bytes<32>} roleId - The role identifier.
190
+ * @param {Either<Bytes<32>, ContractAddress>} account - The canonicalized account to query.
191
+ * @return {Boolean} - Whether the account has the specified role.
192
+ */
193
+ circuit _hasRole(roleId: Bytes<32>,
194
+ account: Either<Bytes<32>, ContractAddress>
195
+ ): Boolean {
196
+ if (_operatorRoles.member(disclose(roleId)) &&
197
+ _operatorRoles.lookup(roleId).member(disclose(account))) {
198
+ return _operatorRoles.lookup(roleId).lookup(disclose(account));
199
+ } else {
200
+ return false;
201
+ }
202
+ }
203
+
204
+ /**
205
+ * @description Reverts if the caller cannot prove ownership of `roleId`.
206
+ * In the case of an external (non-contract) caller, the caller’s identity is
207
+ * derived from the `wit_AccessControlSK` witness as `persistentHash(secretKey)`.
208
+ *
209
+ * @circuitInfo k=13, rows=2669
210
+ *
211
+ * Requirements:
212
+ *
213
+ * - The caller must have `roleId`.
214
+ *
215
+ * @param {Bytes<32>} roleId - The role identifier.
216
+ * @return {[]} - Empty tuple.
217
+ */
218
+ export circuit assertOnlyRole(roleId: Bytes<32>): [] {
219
+ // The account constructed via left<...>() is already canonical,
220
+ // but we route through _checkRole -> hasRole for consistency.
221
+ // The redundant canonicalization is negligible.
222
+ _checkRole(roleId, left<Bytes<32>, ContractAddress>(_computeAccountId()));
223
+ }
224
+
225
+ /**
226
+ * @description Reverts if `account` is missing `roleId`.
227
+ *
228
+ * @circuitInfo k=10, rows=987
229
+ *
230
+ * Requirements:
231
+ *
232
+ * - `account` must have `roleId`.
233
+ *
234
+ * @param {Bytes<32>} roleId - The role identifier.
235
+ * @param {Either<Bytes<32>, ContractAddress>} account - The account to query.
236
+ * @return {[]} - Empty tuple.
237
+ */
238
+ export circuit _checkRole(
239
+ roleId: Bytes<32>,
240
+ account: Either<Bytes<32>, ContractAddress>
241
+ ): [] {
242
+ assert(hasRole(roleId, account), "AccessControl: unauthorized account");
243
+ }
244
+
245
+ /**
246
+ * @description Returns the admin role that controls `roleId` or
247
+ * a byte array with all zero bytes if `roleId` doesn't exist. See {grantRole} and {revokeRole}.
248
+ *
249
+ * To change a role's admin use {_setRoleAdmin}.
250
+ *
251
+ * @circuitInfo k=9, rows=373
252
+ *
253
+ * @param {Bytes<32>} roleId - The role identifier.
254
+ * @return {Bytes<32>} roleAdmin - The admin role that controls `roleId`.
255
+ */
256
+ export circuit getRoleAdmin(roleId: Bytes<32>): Bytes<32> {
257
+ if (_adminRoles.member(disclose(roleId))) {
258
+ return _adminRoles.lookup(disclose(roleId));
259
+ }
260
+ return DEFAULT_ADMIN_ROLE();
261
+ }
262
+
263
+ /**
264
+ * @description Grants `roleId` to `account`.
265
+ *
266
+ * @circuitInfo k=13, rows=3536
267
+ *
268
+ * Requirements:
269
+ *
270
+ * - `account` must not be a ContractAddress.
271
+ * - The caller must have `roleId`'s admin role.
272
+ *
273
+ * @param {Bytes<32>} roleId - The role identifier.
274
+ * @param {Either<Bytes<32>, ContractAddress>} account - A Bytes<32> or ContractAddress.
275
+ * @return {[]} - Empty tuple.
276
+ */
277
+ export circuit grantRole(
278
+ roleId: Bytes<32>,
279
+ account: Either<Bytes<32>, ContractAddress>
280
+ ): [] {
281
+ assertOnlyRole(getRoleAdmin(roleId));
282
+ _grantRole(roleId, account);
283
+ }
284
+
285
+ /**
286
+ * @description Revokes `roleId` from `account`.
287
+ *
288
+ * @circuitInfo k=13, rows=3466
289
+ *
290
+ * Requirements:
291
+ *
292
+ * - The caller must have `roleId`'s admin role.
293
+ *
294
+ * @param {Bytes<32>} roleId - The role identifier.
295
+ * @param {Either<Bytes<32>, ContractAddress>} account - A Bytes<32> or ContractAddress.
296
+ * @return {[]} - Empty tuple.
297
+ */
298
+ export circuit revokeRole(
299
+ roleId: Bytes<32>,
300
+ account: Either<Bytes<32>, ContractAddress>
301
+ ): [] {
302
+ assertOnlyRole(getRoleAdmin(roleId));
303
+ _revokeRole(roleId, account);
304
+ }
305
+
306
+ /**
307
+ * @description Revokes `roleId` from the calling account.
308
+ *
309
+ * @notice Roles are often managed via {grantRole} and {revokeRole}: this circuit's
310
+ * purpose is to provide a mechanism for accounts to lose their privileges
311
+ * if they are compromised (such as when a trusted device is misplaced).
312
+ *
313
+ * The caller's identity is derived from the `wit_AccessControlSK` witness as
314
+ * `persistentHash(secretKey)`. The `callerConfirmation` parameter must match
315
+ * the caller's computed account identifier to prevent accidental renunciation.
316
+ *
317
+ * @circuitInfo k=13, rows=3322
318
+ *
319
+ * Requirements:
320
+ *
321
+ * - The caller's computed account identifier must match `callerConfirmation`.
322
+ * - The caller must not be a `ContractAddress`.
323
+ *
324
+ * @param {Bytes<32>} roleId - The role identifier.
325
+ * @param {Either<Bytes<32>, ContractAddress>} callerConfirmation - The caller's account identifier, must match the internally computed value.
326
+ * @return {[]} - Empty tuple.
327
+ */
328
+ export circuit renounceRole(
329
+ roleId: Bytes<32>,
330
+ callerConfirmation: Either<Bytes<32>, ContractAddress>
331
+ ): [] {
332
+ assert(callerConfirmation == left<Bytes<32>, ContractAddress>(_computeAccountId()),
333
+ "AccessControl: bad confirmation"
334
+ );
335
+
336
+ _revokeRole(roleId, callerConfirmation);
337
+ }
338
+
339
+ /**
340
+ * @description Sets `adminRole` as `roleId`'s admin role.
341
+ *
342
+ * @circuitInfo k=10, rows=581
343
+ *
344
+ * @param {Bytes<32>} roleId - The role identifier.
345
+ * @param {Bytes<32>} adminRole - The admin role identifier.
346
+ * @return {[]} - Empty tuple.
347
+ */
348
+ export circuit _setRoleAdmin(roleId: Bytes<32>, adminRole: Bytes<32>): [] {
349
+ _adminRoles.insert(disclose(roleId), disclose(adminRole));
350
+ }
351
+
352
+ /**
353
+ * @description Attempts to grant `roleId` to `account` and returns a boolean indicating if `roleId` was granted.
354
+ * Internal circuit without access restriction.
355
+ *
356
+ * @circuitInfo k=11, rows=1143
357
+ *
358
+ * Requirements:
359
+ *
360
+ * - `account` must not be a ContractAddress.
361
+ *
362
+ * @param {Bytes<32>} roleId - The role identifier.
363
+ * @param {Either<Bytes<32>, ContractAddress>} account - A Bytes<32> or ContractAddress.
364
+ * @return {Boolean} roleGranted - A boolean indicating if `roleId` was granted.
365
+ */
366
+ export circuit _grantRole(
367
+ roleId: Bytes<32>,
368
+ account: Either<Bytes<32>, ContractAddress>
369
+ ): Boolean {
370
+ const isCommitment = account.is_left;
371
+ assert(isCommitment, "AccessControl: unsafe role approval");
372
+ return _unsafeGrantRole(roleId, account);
373
+ }
374
+
375
+ /**
376
+ * @description Attempts to grant `roleId` to `account` and returns a boolean indicating if `roleId` was granted.
377
+ * Internal circuit without access restriction. It does NOT check if the role is granted to a ContractAddress.
378
+ *
379
+ * @circuitInfo k=11, rows=1142
380
+ *
381
+ * @notice External smart contracts cannot call the access control contract at this time, so granting a role to a ContractAddress may
382
+ * render a circuit permanently inaccessible.
383
+ *
384
+ * @param {Bytes<32>} roleId - The role identifier.
385
+ * @param {Either<Bytes<32>, ContractAddress>} account - A Bytes<32> or ContractAddress.
386
+ * @return {Boolean} roleGranted - A boolean indicating if `role` was granted.
387
+ */
388
+ export circuit _unsafeGrantRole(
389
+ roleId: Bytes<32>,
390
+ account: Either<Bytes<32>, ContractAddress>
391
+ ): Boolean {
392
+ const canonAcct = Utils_canonicalize<Bytes<32>, ContractAddress>(account);
393
+ if (_hasRole(roleId, canonAcct)) {
394
+ return false;
395
+ }
396
+
397
+ if (!_operatorRoles.member(disclose(roleId))) {
398
+ _operatorRoles.insert(
399
+ disclose(roleId),
400
+ default<Map<Either<Bytes<32>, ContractAddress>, Boolean>>
401
+ );
402
+ }
403
+
404
+ _operatorRoles.lookup(roleId).insert(disclose(canonAcct), true);
405
+ return true;
406
+ }
407
+
408
+ /**
409
+ * @description Attempts to revoke `roleId` from `account` and returns a boolean indicating if `roleId` was revoked.
410
+ * Internal circuit without access restriction.
411
+ *
412
+ * @circuitInfo k=11, rows=1073
413
+ *
414
+ * @param {Bytes<32>} roleId - The role identifier.
415
+ * @param {Either<Bytes<32>, ContractAddress>} account - A Bytes<32> or ContractAddress.
416
+ * @return {Boolean} roleRevoked - A boolean indicating if `roleId` was revoked.
417
+ */
418
+ export circuit _revokeRole(
419
+ roleId: Bytes<32>,
420
+ account: Either<Bytes<32>, ContractAddress>
421
+ ): Boolean {
422
+ const canonAcct = Utils_canonicalize<Bytes<32>, ContractAddress>(account);
423
+
424
+ if (!_hasRole(roleId, canonAcct)) {
425
+ return false;
426
+ }
427
+
428
+ _operatorRoles.lookup(roleId).insert(disclose(canonAcct), false);
429
+ return true;
430
+ }
431
+
432
+ /**
433
+ * @description Computes the caller's account identifier from the `wit_AccessControlSK` witness.
434
+ *
435
+ * ## ID Derivation
436
+ * `accountId = persistentHash(secretKey)`
437
+ *
438
+ * The result is a 32-byte commitment that uniquely identifies the caller.
439
+ *
440
+ * @circuitInfo k=13, rows=2294
441
+ *
442
+ * @returns {Bytes<32>} accountId - The computed account identifier.
443
+ */
444
+ circuit _computeAccountId(): Bytes<32> {
445
+ return computeAccountId(wit_AccessControlSK());
446
+ }
447
+
448
+ /**
449
+ * @description Computes an account identifier without on-chain state, allowing a user to derive
450
+ * their identity commitment before submitting it in a grant or revoke operation.
451
+ * This is the off-chain counterpart to {_computeAccountId} and produces an identical result
452
+ * given the same inputs.
453
+ *
454
+ * @warning OpSec: The `secretKey` parameter is a sensitive secret. Mishandling it can
455
+ * permanently compromise the privacy guarantees of this system:
456
+ *
457
+ * - **Never log or persist** the `secretKey` in plaintext — avoid browser devtools,
458
+ * application logs, analytics pipelines, or any observable side-channel.
459
+ * - **Store offline or in secure enclaves** — hardware security modules (HSMs),
460
+ * air-gapped devices, or encrypted vaults are strongly preferred over hot storage.
461
+ * - **Use cryptographically secure randomness** — generate keys with `crypto.getRandomValues()`
462
+ * or equivalent; weak or predictable keys can be brute-forced to reveal your identity.
463
+ * - **Treat key loss as identity loss** — a lost key cannot be recovered. Back up
464
+ * keys securely before using them in role commitments.
465
+ * - **Avoid calling this circuit in untrusted environments** — executing this in an
466
+ * unverified browser extension, compromised runtime, or shared machine may expose
467
+ * the key to a malicious observer.
468
+ *
469
+ * ## ID Derivation
470
+ * `accountId = persistentHash(secretKey)`
471
+ *
472
+ * @param {Bytes<32>} secretKey - A 32-byte cryptographically secure random value.
473
+ *
474
+ * @returns {Bytes<32>} accountId - The computed account identifier.
475
+ */
476
+ export pure circuit computeAccountId(secretKey: Bytes<32>): Bytes<32> {
477
+ return persistentHash<Vector<1, Bytes<32>>>([secretKey]);
478
+ }
479
+ }