diff --git a/CHANGELOG.md b/CHANGELOG.md index 42519074..c91e9784 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add defensive Buffer copy to ZOwnablePKWitnesses (#397) - Disclose commitment instead of raw owner id in `_transferOwnership` in ZOwnablePK (#397) +- Use generic ledger type in ZOwnablePKWitnesses (#389) - Bump compact compiler to v0.29.0 (#366) ## 0.0.1-alpha.1 (2025-12-2) diff --git a/contracts/src/access/ShieldedAccessControl.compact b/contracts/src/access/ShieldedAccessControl.compact new file mode 100644 index 00000000..f4ec503c --- /dev/null +++ b/contracts/src/access/ShieldedAccessControl.compact @@ -0,0 +1,764 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.0.1-alpha.1 (access/ShieldedAccessControl.compact) + +pragma language_version >= 0.21.0; + +/** + * @module Shielded AccessControl + * @description A Shielded AccessControl library. + * + * This module provides a shielded role-based access control (RBAC) mechanism, where roles can be used to + * represent a set of permissions. Roles are stored as Merkle tree commitments to avoid + * disclosing information about role holders. Role commitments are created with the following + * hashing scheme, where `‖` denotes concatenation and all values are `Bytes<32>`: + * + * ``` + * roleCommitment := SHA256( role ‖ accountId ‖ instanceSalt ‖ commitmentDomain ) where + * + * accountId := SHA256( zcpk ‖ nonce ‖ instanceSalt ‖ accountIdDomain ) + * + * roleNullifier := SHA256( roleCommitment ‖ nullifierDomain ) + * + * commitmentDomain := pad(32, "ShieldedAccessControl:commitment") + * accountIdDomain := pad(32, "ShieldedAccessControl:accountId") + * nullifierDomain := pad(32, "ShieldedAccessControl:nullifier") + * ``` + * + * - `roleCommitment` is a Merkle tree leaf committing a `(roleId, accountId)` pairing, inserted + * into `_operatorRoles` on grant. The `instanceSalt` prevents commitment collisions across + * deployments that share the same role identifiers. + * - `accountId` is a privacy-preserving identity commitment. `zcpk` is the user's + * `ZswapCoinPublicKey`; `nonce` is a per-role secret held in local private state + * (supplied by `wit_secretNonce`); `instanceSalt` ensures the same key and nonce + * cannot be correlated across contracts. + * - `roleNullifier` is a one-time burn token inserted into `_roleCommitmentNullifiers` on + * revocation. Its presence permanently invalidates the corresponding role commitment, + * making re-grant under the same `accountId` impossible without generating a new identity. + * - `instanceSalt` should be an immutable, cryptographically strong random value provided on deployment + * - `commitmentDomain` is the following zero padded 32 byte string: "ShieldedAccessControl:commitment" + * - `accountIdDomain` is the following zero padded 32 byte string: "ShieldedAccessControl:accountId" + * - `nullifierDomain` is the following zero padded 32 byte string: "ShieldedAccessControl:nullifier" + * + * In this RBAC model, role commitments behave like private bearer tokens. Possession of a valid, non-revoked role + * commitment grants authorization. Revocation permanently burns the role instance, requiring explicit new issuance + * under a new account identifier. Users must rotate their identity (new nonce) to be re-authorized. This creates + * stronger security invariants over traditional RBAC systems and enables privacy-preserving identity rotation. + * + * Roles are referred to by their `Bytes<32>` identifier. These should be exposed + * in the top-level contract and be unique. One way to achieve this is by + * using `export sealed ledger` hash digests that are initialized in the top-level contract: + * + * ```compact + * import CompactStandardLibrary; + * import "./node_modules/@openzeppelin/compact-contracts/src/access/ShieldedAccessControl" prefix ShieldedAccessControl_; + * + * export sealed ledger MY_ROLE: Bytes<32>; + * + * constructor() { + * MY_ROLE = persistentHash>(pad(32, "MY_ROLE")); + * } + * ``` + * + * To restrict access to a circuit, use {assertOnlyRole}: + * + * ```compact + * circuit foo(): [] { + * ShieldedAccessControl_assertOnlyRole(MY_ROLE as ShieldedAccessControl_RoleIdentifier); + * // ... rest of circuit logic + * } + * ``` + * + * Roles can be granted and revoked dynamically via the {grantRole} and + * {revokeRole} circuits. Each role has an associated admin role, and only + * accounts that have a role's admin role can call {grantRole} and {revokeRole}. + * + * By default, the admin role for all roles is `DEFAULT_ADMIN_ROLE`, which means + * that only accounts with this role will be able to grant or revoke other + * roles. More complex role relationships can be created by using + * {_setRoleAdmin}. + * + * WARNING: The `DEFAULT_ADMIN_ROLE` is also its own admin: it has permission to + * grant and revoke this role. Extra precautions should be taken to secure + * accounts that have been granted it. + * + * @dev Privacy Assumptions + * - Outside observers will know when an admin is added and how many admins exist. + * - Outside observers will know which role identifiers are admin identifiers. + * - Outside observers will have knowledge of all role identifiers. + * - Outside observers will have knowledge of all role nullifiers and can link every nullifier to its corresponding commitment. + * - Outside observers will know when roles are a granted and revoked. + * - Outside observers can infer the total number of role grants made across all roles — not per-role counts, but the cumulative total. + * - Outside observers can link calls made by the same role instance across time. + * - Users can be retroactively deanonymized if their nonce is exposed or reused poorly. + * - Outside observers will NOT be able to identify the public address of any role holder + * so long as secret nonce values are kept private and generated using cryptographically + * secure random values. + * + * @dev Security Considerations: + * - The `secretNonce` must be kept private. Loss of the nonce prevents role holders + * and admins from proving access or transferring it. Nonce exposure, poor nonce selection and nonce + * reuse may weaken privacy guarantees and allow retroactive deanonymization. + * - It's strongly recommended to use cryptographically secure random values for the `_instanceSalt`. + * Failure to do so may weaken privacy guarantees. + * - The `_instanceSalt` is immutable and used to differentiate deployments. + * - The `_operatorRoles` Merkle tree has a fixed capacity of 2^20 leaf slots. + * Deployers should monitor slot consumption off-chain. A careless admin can exhaust + * capacity through repeated grants of the same active (role, accountId) pairing. + * + * @notice Using the SHA256 hashing function comes at a significant performance cost. In the future, we + * plan on migrating to a ZK-friendly hashing function when an implementation is available. + * + * @notice Missing Features and Improvements: + * - Role events + * - An ERC165-like interface + * - Migrate from SHA256 to a ZK-friendly hashing function when an implementation is available. + */ +module ShieldedAccessControl { + import CompactStandardLibrary; + import "../utils/Utils" prefix Utils_; + import "../security/Initializable" prefix Initializable_; + + export enum UpdateType { + Grant, + Revoke + }; + + // TODO: Standardize types across contracts https://github.com/OpenZeppelin/compact-contracts/issues/368 + export new type RoleCommitment = Bytes<32>; + export new type RoleIdentifier = Bytes<32>; + export new type AccountIdentifier = Bytes<32>; + export new type RoleNullifier = Bytes<32>; + + /** + * @ledger _operatorRoles + * @description A Merkle tree of role commitments stored as SHA256(role | accountId | instanceSalt | commitmentDomain) + * Role commitments are derived from a public role identifier (e.g., `persistentHash>(pad(32, "MY_ROLE")`), + * an account identifier (e.g., `SHA256(zcpk, nonce, instanceSalt, accountIdDomain)`), the `instanceSalt`, and a domain separator. + * @type {RoleCommitment} roleCommitment - A role commitment created by the following hash: SHA256( role | accountId | instanceSalt | commitmentDomain). +  */ + export ledger _operatorRoles: MerkleTree<20, RoleCommitment>; + + /** + * @ledger _adminRoles + * @description Mapping from a role identifier to an admin role identifier. +  */ + export ledger _adminRoles: Map; + + /** + * @description A set of nullifiers used to prove a role has been revoked + * @type {RoleNullifier} roleNullifier - A role nullifier created by the following hash: SHA256(roleCommitment | nullifierDomain). + * @type {Set} _roleCommitmentNullifiers +  */ + export ledger _roleCommitmentNullifiers: Set; + + /** + * @sealed @ledger _instanceSalt + * @description A per-instance value provided at initialization used to namespace + * commitments for this contract instance. + * + * This salt prevents commitment collisions across contracts that might otherwise use + * the same identifiers or domain parameters. It should be a cryptographically strong random value + * It is immutable after initialization. + */ + export sealed ledger _instanceSalt: Bytes<32>; + + /** + * @witness wit_getRoleCommitmentPath + * @description Returns a path to a role commitment in the `_operatorRoles` Merkle tree if one exists. Otherwise, returns an invalid path. + * + * @param {RoleCommitment} roleCommitment - A commitment created by the following hash: SHA256( role | accountId | instanceSalt | commitmentDomain). + * + * @return {MerkleTreePath<20, RoleCommitment>} - The Merkle tree path to `roleCommitment` in the `_operatorRoles` Merkle tree +  */ + witness wit_getRoleCommitmentPath( + roleCommitment: RoleCommitment + ): MerkleTreePath<20, RoleCommitment>; + + /** + * @witness wit_secretNonce + * @description Returns a private per-role nonce used in deriving the shielded account identifier for a role. + * + * Combined with the user's ZswapCoinPublicKey as `SHA256(zcpk, nonce, instanceSalt, accountIdDomain)` to produce an obfuscated, + * unlinkable identity commitment. Nonce MUST be unique per role to avoid cross-role linking. + * + * @param {RoleIdentifier} role - The unique identifier of a role. + * + * @returns {Bytes<32>} secretNonce - A private per-role nonce used in deriving the shielded account identifier. + */ + witness wit_secretNonce(role: RoleIdentifier): Bytes<32>; + + /** + * @description Initializes the contract by storing the `instanceSalt` that acts as a privacy additive + * for preventing duplicate commitments among other contracts implementing ShieldedAccessControl. + * + * @warning The `instanceSalt` must be calculated prior to contract deployment using a cryptographically + * secure random number generator e.g. crypto.getRandomValues() to maintain strong privacy guarantees + * + * @circuitInfo k=14, rows=14933 + * + * Requirements: + * + * - Contract is not initialized. + * + * @param {Bytes<32>} instanceSalt - Contract salt to prevent duplicate commitments if + * users reuse their PK and secretNonce witness across different contracts (not recommended). Must not be zero. + * + * @returns {[]} Empty tuple. + */ + export circuit initialize(instanceSalt: Bytes<32>): [] { + assert(instanceSalt != default>, "ShieldedAccessControl: Instance salt must not be 0"); + Initializable_initialize(); + + _instanceSalt = disclose(instanceSalt); + } + + /** + * @description The default admin role for all roles. Only accounts with this role will be able to grant or revoke other roles + * unless custom admin roles are created. + * + * @remarks The Compact language does not support constant declarations, + * so DEFAULT_ADMIN_ROLE is implemented as a circuit that returns a constant value by necessity. +  */ + export pure circuit DEFAULT_ADMIN_ROLE(): RoleIdentifier { + return default> as RoleIdentifier; + } + + /** + * @description Reverts if caller cannot provide a valid proof of ownership for `role`. + * + * @circuitInfo k=15, rows=19237 + * + * Requirements: + * + * - caller must prove ownership of `role`. + * - caller must not provide valid Merkle tree path for a different `(role, accountId)` pairing. + * - Contract is initialized. + * + * Disclosures: + * + * - A Merkle tree path to a role commitment. + * - A role commitment corresponding to a `(role, accountId)` pairing. + * - A nullifier for the respective role commitment. + * + * @param {RoleIdentifier} role - The role identifier. + * + * @return {[]} - Empty tuple. + */ + export circuit assertOnlyRole(role: RoleIdentifier): [] { + Initializable_assertInitialized(); + assert(_uncheckedCanProveRole(role), "ShieldedAccessControl: unauthorized account"); + } + + /** + * @description Returns `true` if a caller proves ownership of `role` and is not revoked. MAY return false for a legitimately credentialed + * caller if the proving environment supplies an invalid Merkle path. This circuit will never return true for an + * unauthorized caller. + * + * @circuitInfo k=15, rows=19237 + * + * Requirements: + * + * - caller must not provide valid Merkle tree path for a different `(role, accountId)` pairing. + * - Contract is initialized. + * + * Disclosures: + * + * - A Merkle tree path to a role commitment. + * - A role commitment corresponding to a `(role, accountId)` pairing. + * - A nullifier for the respective role commitment. + * + * @param {RoleIdentifier} role - The role identifier. + * + * @return {Boolean} - A boolean determining if a caller successfully proved ownership of `role` +  */ + export circuit canProveRole(role: RoleIdentifier): Boolean { + Initializable_assertInitialized(); + return _uncheckedCanProveRole(role); + } + + /** + * @description Returns `true` if a caller proves ownership of `role` and is not revoked. MAY return false for a legitimately credentialed + * caller if the proving environment supplies an invalid Merkle path. This circuit will never return true for an + * unauthorized caller. + * + * @warning This circuit does not perform an initialization check. It's only meant to be used as + * an internal helper in the Shielded Access Control module. Using this circuit outside of the + * module may cause undefined behavior and break security guarantees. + * + * @circuitInfo k=15, rows=19235 + * + * Requirements: + * + * - caller must not provide valid Merkle tree path for a different `(role, accountId)` pairing. + * + * Disclosures: + * + * - A Merkle tree path to a role commitment. + * - A role commitment corresponding to a `(role, accountId)` pairing. + * - A nullifier for the respective role commitment. + * + * @param {RoleIdentifier} role - The role identifier. + * + * @return {Boolean} - A boolean determining if a caller successfully proved ownership of `role` +  */ + circuit _uncheckedCanProveRole(role: RoleIdentifier): Boolean { + const accountId = _computeAccountId(role); + return _validateRole(role, accountId); + } + + /** + * @description Grants `role` to `accountId` by inserting a role commitment unique to the + * `(role, accountId)` pairing into the `_operatorRoles` Merkle tree. Duplicate role commitments can be issued + * so long as they remain unrevoked. This does not yield any additional authority and simply wastes + * limited Merkle tree storage slots. Once revoked, a role cannot be re-granted. A new `accountId` must be + * generated to be re-authorized for a revoked `role`. + * + * Requirements: + * + * - caller must prove they're an admin for `role`. + * - caller must not provide valid Merkle tree path for a different `(role, accountId)` pairing. + * - Contract is initialized. + * + * @circuitInfo k=15, rows=31312 + * + * Disclosures: + * + * - A Merkle tree path to a role commitment. + * - A role commitment corresponding to a `(role, accountId)` pairing. + * - A nullifier for the respective role commitment. + * - A role identifier. + * + * @param {RoleIdentifier} role - The role identifier. + * @param {AccountIdentifier} accountId - The unique identifier of the account. + * + * @return {[]} - Empty tuple. + */ + export circuit grantRole(role: RoleIdentifier, accountId: AccountIdentifier): [] { + // Initialization check performed in assertOnlyRole + assertOnlyRole(getRoleAdmin(role)); + _updateRole(role, accountId, UpdateType.Grant); + } + + /** + * @description Grants `role` to `accountId` by inserting a role commitment unique to the + * `(role, accountId)` pairing into the `_operatorRoles` Merkle tree. Duplicate role commitments can be issued + * so long as they remain unrevoked. This does not yield any additional authority and simply wastes + * limited Merkle tree storage slots. Once revoked, a role cannot be re-granted to the same `accountId`. A new `accountId` must be + * generated to be re-authorized for a revoked `role`. + * + * Internal circuit without access restriction. + * + * @warning Exposing this circuit directly in an implementing contract would allow anyone to grant + * roles without authorization. It must be wrapped with appropriate access control. + * + * ## Storage Caveat + * + * `_operatorRoles` is a fixed-depth Merkle tree with a maximum capacity of + * 2^20 = 1,048,576 leaf slots. At the time of writing, it's not possible to check non-membership of a value + * without using un-trusted witness inputs. This creates two risks: + * + * 1. Accidental duplicate grants through administrative error. + * 2. A deliberate griefing attack by a malicious admin exhausting the tree. + * + * Tree capacity should be treated as an operational concern and slot consumption + * should be monitored off-chain. The TypeScript layer should additionally + * validate commitment absence before submitting a grant transaction as a + * defence-in-depth measure against accidental exhaustion. + * + * @dev Commitment and nullifier checks are inlined in this circuit to avoid an expensive re-computation of the role commitment and nullifier + * + * Requirements: + * + * - Contract is initialized. + * + * @circuitInfo k=14, rows=12333 + * + * Disclosures: + * + * - A role commitment corresponding to a `(role, accountId)` pairing. + * - A nullifier for the respective role commitment. + * + * @param {RoleIdentifier} role - The role identifier. + * @param {AccountIdentifier} accountId - The unique identifier of the account. + * + * @return {Boolean} isGranted - Returns true if a role was granted successfully. + */ + export circuit _grantRole(role: RoleIdentifier, accountId: AccountIdentifier): Boolean { + Initializable_assertInitialized(); + return _updateRole(role, accountId, UpdateType.Grant); + } + + /** + * @description Revokes `role` from the calling account. Fails silently if role is already revoked. + * `role` existence is not checked, so a caller can renounce roles they don't own or don't exist. + * + * @notice Roles are often managed via {grantRole} and {revokeRole}: this circuit's + * purpose is to provide a mechanism for accounts to lose their privileges + * if they are compromised (such as when a trusted device is misplaced). + * + * @warning Outside observers may be able to use timing and pattern analysis to weaken pseudonymity + * guarantees if renounceRole is used in tandem with other on-chain actions. + * + * @circuitInfo k=15, rows=16663 + * + * Requirements: + * + * - The caller must provide a valid `accountId` for a `role`. + * - Contract is initialized. + * + * Disclosures: + * + * - A nullifier for the respective role commitment. + * + * @param {RoleIdentifier} role - The role identifier. + * @param {AccountIdentifier} accountIdConfirmation - The caller's account identifier, must match the internally computed value. + * + * @return {[]} - Empty tuple. + */ + export circuit renounceRole(role: RoleIdentifier, accountIdConfirmation: AccountIdentifier): [] { + Initializable_assertInitialized(); + + assert(accountIdConfirmation == _computeAccountId(role), + "ShieldedAccessControl: bad confirmation" + ); + + _updateRole(role, accountIdConfirmation, UpdateType.Revoke); + } + + /** + * @description Permanently revokes `role` from `accountId` by inserting a role nullifier into the + * `_roleCommitmentNullifiers` set. Once revoked, a new `accountId` must be generated to be re-authorized for + * `role`. At this time, proofs of non-membership on values in the Merkle tree LDT are not possible + * so a `(role, accountId)` pairing that does not exist can still be revoked. + * + * @circuitInfo k=15, rows=29301 + * + * Requirements: + * + * - caller must prove they're an admin for `role`. + * - caller must not provide valid Merkle tree path for a different `(role, accountId)` pairing. + * - Contract is initialized. + * + * Disclosures: + * + * - A Merkle tree path to a role commitment. + * - A role commitment corresponding to a `(role, accountId)` pairing. + * - A nullifier for the respective role commitment. + * - A role identifier. + * + * @param {RoleIdentifier} role - The role identifier. + * @param {AccountIdentifier} accountId - The unique identifier of the account. + * + * @return {[]} - Empty tuple. + */ + export circuit revokeRole(role: RoleIdentifier, accountId: AccountIdentifier): [] { + // Initialization check performed in assertOnlyRole + assertOnlyRole(getRoleAdmin(role)); + _updateRole(role, accountId, UpdateType.Revoke); + } + + /** + * @description Permanently revokes `role` from `accountId` by inserting a role nullifier into the + * `_roleCommitmentNullifiers` set. Once revoked, a new `accountId` must be generated to be re-authorized for + * `role`. At this time, proofs of non-membership on values in the Merkle tree LDT are not possible + * so a `(role, accountId)` pairing that does not exist can still be revoked. Returns false if a role is already + * revoked. Internal circuit without access restriction. + * + * @warning Exposing this circuit directly in an implementing contract would allow anyone to revoke + * roles without authorization. It must be wrapped with appropriate access control. + * + * Requirements: + * + * - Contract is initialized. + * + * @circuitInfo k=14, rows=10322 + * + * Disclosures: + * + * - A nullifier for the respective role commitment. + * + * @param {RoleIdentifier} role - The role identifier. + * @param {AccountIdentifier} accountId - The unique identifier of the account. + * + * @return {Boolean} isRevoked - Returns true if operation completes successfully. + */ + export circuit _revokeRole(role: RoleIdentifier, accountId: AccountIdentifier): Boolean { + Initializable_assertInitialized(); + return _updateRole(role, accountId, UpdateType.Revoke); + } + + /** + * @description Core business logic for the grant/revoke role circuits. Returns false if a role is revoked. + * Otherwise, dispatches on `updateType`: a `Grant` inserts the role commitment into `_operatorRoles`, and + * a `Revoke` inserts the nullifier into `_roleCommitmentNullifiers`. Returns true on success. + * + * @circuitInfo k=14, rows=12391 + * + * Disclosures: + * + * - A nullifier for the respective role commitment. + * - A role commitment on success + * + * @param {RoleIdentifier} role - The role identifier. + * @param {AccountIdentifier} accountId - The unique identifier of the account. + * + * @return {Boolean} isRevoked - Returns true if operation completes successfully. + */ + circuit _updateRole( + role: RoleIdentifier, + accountId: AccountIdentifier, + updateType: UpdateType + ): Boolean { + const roleCommitment = computeRoleCommitment(role, accountId); + const roleNullifier = computeNullifier(roleCommitment); + const isRevoked = _roleCommitmentNullifiers.member(disclose(roleNullifier)); + + if (isRevoked) { + return false; + } + + if (updateType == UpdateType.Grant) { + _operatorRoles.insert(disclose(roleCommitment)); + } else { + _roleCommitmentNullifiers.insert(disclose(roleNullifier)); + } + + return true; + } + + /** + * @description Returns the admin role that controls `role`. Returns `DEFAULT_ADMIN_ROLE` for + * roles with no explicitly set admin. Since `DEFAULT_ADMIN_ROLE` is the zero byte array, + * there is no distinction between a nonexistent `role` and one whose admin is `DEFAULT_ADMIN_ROLE`. + * See {grantRole} and {revokeRole}. + * + * To change a role’s admin use {_setRoleAdmin}. + * + * @circuitInfo k=9, rows=373 + * + * Disclosures: + * + * - A role identifier. + * + * @param {RoleIdentifier} role - The role identifier. + * + * @return {RoleIdentifier} roleAdmin - The admin role that controls `role`. + */ + export circuit getRoleAdmin(role: RoleIdentifier): RoleIdentifier { + if (_adminRoles.member(disclose(role))) { + return _adminRoles.lookup(disclose(role)); + } + return DEFAULT_ADMIN_ROLE(); + } + + /** + * @description Sets `adminId` as `role`'s admin identifier. Users with valid admin identifiers + * may grant and revoke access to the specified `role`. Internal circuit without access restriction. + * + * @warning Exposing this circuit directly in an implementing contract would allow anyone to assign + * arbitrary admin roles without authorization. It must be wrapped with appropriate access control. + * + * @circuitInfo k=10, rows=581 + * + * Disclosures: + * + * - The role identifier + * - The admin identifier + * + * @param {RoleIdentifier} role - The role identifier. + * @param {RoleIdentifier} adminId - The admin identifier for `role`. + * + * @return {[]} - Empty tuple. + */ + export circuit _setRoleAdmin(role: RoleIdentifier, adminId: RoleIdentifier): [] { + _adminRoles.insert(disclose(role), disclose(adminId)); + } + + /** + * @description Verifies whether `accountId` holds `role`. This circuit MAY return false for a + * legitimately credentialed account if the proving environment supplies an invalid Merkle path. + * + * @warning This circuit does not perform an initialization check. It's only meant to be used as + * an internal helper in the Shielded Access Control module. Using this circuit outside of the + * module may cause undefined behavior and break security guarantees. + * + * @circuitInfo k=14, rows=13179 + * + * Requirements: + * + * - caller must not provide valid Merkle tree path for a different `(role, accountId)` pairing. + * + * Disclosures: + * + * - A Merkle tree path to a role commitment. + * - A role commitment corresponding to a `(role, accountId)` pairing. + * - A nullifier for the respective role commitment. + * + * @param {RoleIdentifier} role - The role identifier. + * @param {AccountIdentifier} accountId - The unique identifier of the account. + * + * @return {Boolean} isValidRole - A boolean indicating whether `accountId` has a valid role + */ + circuit _validateRole(role: RoleIdentifier, accountId: AccountIdentifier): Boolean { + const roleCommitment = computeRoleCommitment(role, accountId); + const roleCommitmentPath = wit_getRoleCommitmentPath(roleCommitment); + const isValidPath = + _operatorRoles.checkRoot( + merkleTreePathRoot<20, RoleCommitment>(disclose(roleCommitmentPath)) + ); + + // If the path is valid we assert it’s a path for the queried leaf (not some other leaf). + if (isValidPath) { + assert(roleCommitmentPath.leaf == roleCommitment, + "ShieldedAccessControl: Path must contain leaf matching computed role commitment for the provided role, accountId pairing" + ); + } + + const roleNullifier = computeNullifier(roleCommitment); + const isRevoked = _roleCommitmentNullifiers.member(disclose(roleNullifier)); + + return isValidPath && !isRevoked; + } + + /** + * @description Computes the role commitment from the given `accountId` and `role`. + * + * @warning This circuit does not perform an initialization check. It's only meant to be used as + * an internal helper in the Shielded Access Control module. Using this circuit outside of the + * module may cause undefined behavior and break security guarantees. + * + * ## Account ID (`accountId`) + * The `accountId` is expected to be computed off-chain as: + * `accountId = SHA256(zcpk, nonce, instanceSalt, accountIdDomain)` + * + * - `zcpk`: The account's ZswapCoinPublicKey. + * - `nonce`: A secret nonce scoped to the role. + * + * ## Role Commitment Derivation + * `roleCommitment = SHA256(role, accountId, instanceSalt, commitmentDomain)` + * + * - `accountId`: See above. + * - `role`: A unique role identifier. + * - `instanceSalt`: A unique per-deployment salt, stored during initialization. + * This prevents commitment collisions across deployments. + * - `commitmentDomain`: Domain separator `"ShieldedAccessControl:commitment"` (padded to 32 bytes) to prevent + * hash collisions when extending the module or using similar commitment schemes. + * + * @circuitInfo k=13, rows=6421 + * + * @param {RoleIdentifier} role - The role identifier. + * @param {AccountIdentifier} accountId - The unique identifier of the account. + * + * @returns {RoleCommitment} The commitment derived from `accountId` and `role`. + */ + export circuit computeRoleCommitment( + role: RoleIdentifier, + accountId: AccountIdentifier, + ): RoleCommitment { + + return persistentHash>>( + [role as Bytes<32>, + accountId as Bytes<32>, + _instanceSalt, + pad(32, "ShieldedAccessControl:commitment")] + ) + as RoleCommitment; + } + + /** + * @description Computes the role nullifier for a given `roleCommitment`. + * + * ## Role Nullifier Derivation + * `roleNullifier = SHA256(roleCommitment, nullifierDomain)` + * + * - `roleCommitment`: See `computeRoleCommitment`. + * - `nullifierDomain`: Domain separator `"ShieldedAccessControl:nullifier"` (padded to 32 bytes) to prevent + * hash collisions when extending the module or using similar commitment schemes. + * + * @param {RoleCommitment} roleCommitment - The role commitment for a particular `(role, accountId)` pairing. + * + * @returns {RoleNullifier} roleNullifier - The associated nullifier for `roleCommitment`. + */ + export pure circuit computeNullifier(roleCommitment: RoleCommitment): RoleNullifier { + return persistentHash>>( + [roleCommitment as Bytes<32>, pad(32, "ShieldedAccessControl:nullifier")] + ) + as RoleNullifier; + } + + /** + * @description Computes the unique identifier (`accountId`) of a caller from their + * ZswapCoinPublicKey and a secret nonce. + * + * @warning This circuit does not perform an initialization check. It's only meant to be used as + * an internal helper in the Shielded Access Control module. Using this circuit outside of the + * module may cause undefined behavior and break security guarantees. + * + * ## ID Derivation + * `accountId = SHA256(zcpk, nonce, instanceSalt, accountIdDomain)` + * + * - `zcpk`: The ZswapCoinPublicKey of the caller. We recommend using an Air-Gapped Public Key. + * - `nonce`: A secret nonce tied to the identity. The generation strategy is + * left to the user, offering different security/convenience trade-offs. + * - `instanceSalt`: A unique per-deployment salt, stored during initialization. + * This prevents commitment collisions across deployments. + * - `accountIdDomain`: Domain separator `"ShieldedAccessControl:accountId"` (padded to 32 bytes) to prevent + * hash collisions when extending the module or using similar commitment schemes. + * + * The result is a 32-byte commitment that uniquely identifies the account. + * This value is later used in role commitment hashing, + * and acts as a privacy-preserving alternative to a raw public key. + * + * @circuitInfo k=13, rows=6659 + * + * @param {RoleIdentifier} role - A private nonce to scope the commitment. + * + * @returns {AccountIdentifier} accountId - The computed account ID. + */ + circuit _computeAccountId(role: RoleIdentifier): AccountIdentifier { + return computeAccountId(ownPublicKey(), wit_secretNonce(role), _instanceSalt); + } + + /** + * @description Computes an `accountId` locally without on-chain state, allowing a user to derive + * their shielded identity commitment before submitting it in a grant or revoke operation. + * This is the off-chain counterpart to {_computeAccountId} and produces an identical result + * given the same inputs. + * + * @warning OpSec: The `secretNonce` parameter is a sensitive secret. Mishandling it can + * permanently compromise the privacy guarantees of this system: + * + * - **Never log or persist** the `secretNonce` in plaintext — avoid browser devtools, + * application logs, analytics pipelines, or any observable side-channel. + * - **Store offline or in secure enclaves** — hardware security modules (HSMs), + * air-gapped devices, or encrypted vaults are strongly preferred over hot storage. + * - **Never reuse a nonce across roles** — reuse may allow an observer to correlate your + * identity across different role commitments, weakening privacy. + * - **Use cryptographically secure randomness** — generate nonces with `crypto.getRandomValues()` + * or equivalent; weak or predictable nonces can be brute-forced to reveal your identity. + * - **Treat nonce loss as identity loss** — a lost nonce cannot be recovered. Back up + * nonces securely before using them in role commitments. + * - **Avoid calling this circuit in untrusted environments** — executing this in an + * unverified browser extension, compromised runtime, or shared machine may expose + * the nonce to a malicious observer. + * + * ## ID Derivation + * See {_computeAccountId} for further details. + * + * @param {ZswapCoinPublicKey} account - The user's ZswapCoinPublicKey + * @param {Bytes<32>} secretNonce - A private nonce scoped to a particular role. + * @param {Bytes<32>} instanceSalt - The unique per-deployment salt for the contract instance. + * + * @returns {AccountIdentifier} accountId - The computed account ID. + */ + export pure circuit computeAccountId( + account: ZswapCoinPublicKey, + secretNonce: Bytes<32>, + instanceSalt: Bytes<32> + ): AccountIdentifier { + return persistentHash>>( + [account.bytes, secretNonce, instanceSalt, pad(32, "ShieldedAccessControl:accountId")] + ) + as AccountIdentifier; + } +} diff --git a/contracts/src/access/test/ShieldedAccessControl.test.ts b/contracts/src/access/test/ShieldedAccessControl.test.ts new file mode 100644 index 00000000..29728922 --- /dev/null +++ b/contracts/src/access/test/ShieldedAccessControl.test.ts @@ -0,0 +1,3137 @@ +import { + CompactTypeBytes, + CompactTypeVector, + convertFieldToBytes, + type MerkleTreePath, + persistentHash, + type WitnessContext, +} from '@midnight-ntwrk/compact-runtime'; +import { beforeEach, describe, expect, it } from 'vitest'; +import * as utils from '#test-utils/address.js'; +import { + type Ledger, + ShieldedAccessControl_UpdateType as UpdateType, + type ZswapCoinPublicKey, +} from '../../../artifacts/MockShieldedAccessControl/contract/index.js'; +import { ShieldedAccessControlPrivateState } from '../witnesses/ShieldedAccessControlWitnesses.js'; +import { ShieldedAccessControlSimulator } from './simulators/ShieldedAccessControlSimulator.js'; + +const INSTANCE_SALT = new Uint8Array(32).fill(48473095); +const COMMITMENT_DOMAIN = 'ShieldedAccessControl:commitment'; +const NULLIFIER_DOMAIN = 'ShieldedAccessControl:nullifier'; +const ACCOUNT_DOMAIN = 'ShieldedAccessControl:accountId'; + +const DEFAULT_MT_PATH: MerkleTreePath = { + leaf: new Uint8Array(32), + path: Array.from({ length: 20 }, () => ({ + sibling: { field: 0n }, + goes_left: false, + })), +}; + +const RETURN_BAD_PATH = ( + ctx: WitnessContext, + _commitment: Uint8Array, +): [ShieldedAccessControlPrivateState, MerkleTreePath] => { + return [ctx.privateState, DEFAULT_MT_PATH]; +}; + +// Helpers +const buildAccountIdHash = ( + pk: ZswapCoinPublicKey, + nonce: Uint8Array, +): Uint8Array => { + const rt_type = new CompactTypeVector(4, new CompactTypeBytes(32)); + + const bPK = pk.bytes; + const bDomain = new TextEncoder().encode(ACCOUNT_DOMAIN); + return persistentHash(rt_type, [bPK, nonce, INSTANCE_SALT, bDomain]); +}; + +const buildRoleCommitmentHash = ( + role: Uint8Array, + accountId: Uint8Array, +): Uint8Array => { + const rt_type = new CompactTypeVector(4, new CompactTypeBytes(32)); + const bDomain = new TextEncoder().encode(COMMITMENT_DOMAIN); + + const commitment = persistentHash(rt_type, [ + role, + accountId, + INSTANCE_SALT, + bDomain, + ]); + return commitment; +}; + +const buildNullifierHash = (commitment: Uint8Array): Uint8Array => { + const rt_type = new CompactTypeVector(2, new CompactTypeBytes(32)); + const bDomain = new TextEncoder().encode(NULLIFIER_DOMAIN); + + const nullifier = persistentHash(rt_type, [commitment, bDomain]); + return nullifier; +}; + +class ShieldedAccessControlConstant { + baseString: string; + publicKey: string; + zPublicKey: ZswapCoinPublicKey; + role: Buffer; + accountId: Uint8Array; + roleNullifier: Uint8Array; + roleCommitment: Uint8Array; + secretNonce: Buffer; + + constructor(baseString: string, roleIdentifier: bigint) { + this.baseString = baseString; + [this.publicKey, this.zPublicKey] = utils.generatePubKeyPair(baseString); + this.secretNonce = Buffer.alloc(32, `${baseString}_NONCE`); + this.accountId = buildAccountIdHash(this.zPublicKey, this.secretNonce); + this.role = Buffer.from(convertFieldToBytes(32, roleIdentifier, '')); + this.roleCommitment = buildRoleCommitmentHash(this.role, this.accountId); + this.roleNullifier = buildNullifierHash(this.roleCommitment); + } +} + +// PKs +const ADMIN = new ShieldedAccessControlConstant('ADMIN', 0n); +const OPERATOR_1 = new ShieldedAccessControlConstant('OPERATOR_1', 1n); +const OPERATOR_2 = new ShieldedAccessControlConstant('OPERATOR_2', 2n); +const OPERATOR_3 = new ShieldedAccessControlConstant('OPERATOR_3', 3n); +const UNAUTHORIZED = new ShieldedAccessControlConstant( + 'UNAUTHORIZED', + 99999999n, +); +const UNINITIALIZED = new ShieldedAccessControlConstant('UNINITIALIZED', 555n); +const BAD_INPUT = new ShieldedAccessControlConstant('BAD_INPUT', 666n); + +let shieldedAccessControl: ShieldedAccessControlSimulator; + +describe('ShieldedAccessControl', () => { + describe('when not initialized correctly', () => { + const isInit = false; + + beforeEach(() => { + shieldedAccessControl = new ShieldedAccessControlSimulator( + INSTANCE_SALT, + isInit, + ); + }); + + type FailingCircuits = [ + method: keyof ShieldedAccessControlSimulator, + args: unknown[], + ]; + // Circuit calls should fail before the args are used + const circuitsToFail: FailingCircuits[] = [ + ['canProveRole', [UNINITIALIZED.role]], + ['assertOnlyRole', [UNINITIALIZED.role]], + ['grantRole', [UNINITIALIZED.role, UNINITIALIZED.accountId]], + ['revokeRole', [UNINITIALIZED.role, UNINITIALIZED.accountId]], + ['renounceRole', [UNINITIALIZED.role, UNINITIALIZED.accountId]], + ['_grantRole', [UNINITIALIZED.role, UNINITIALIZED.accountId]], + ['_revokeRole', [UNINITIALIZED.role, UNINITIALIZED.accountId]], + ]; + it.each(circuitsToFail)('%s should fail', (circuitName, args) => { + expect(() => { + (shieldedAccessControl[circuitName] as (...args: unknown[]) => unknown)( + ...args, + ); + }).toThrow('Initializable: contract not initialized'); + }); + + type UncheckedCircuits = [ + method: keyof ShieldedAccessControlSimulator, + args: unknown[], + ]; + // Circuit calls should succeed + const circuitsToSucceed: UncheckedCircuits[] = [ + ['_uncheckedCanProveRole', [UNINITIALIZED.role]], + ['getRoleAdmin', [UNINITIALIZED.role]], + ['_setRoleAdmin', [UNINITIALIZED.role, UNINITIALIZED.role]], + ['_computeAccountId', [UNINITIALIZED.role]], + ['computeRoleCommitment', [UNINITIALIZED.role, UNINITIALIZED.accountId]], + ['computeNullifier', [UNINITIALIZED.roleCommitment]], + ['DEFAULT_ADMIN_ROLE', []], + ['_validateRole', [UNINITIALIZED.roleCommitment]], + [ + '_updateRole', + [ + UNINITIALIZED.roleCommitment, + UNINITIALIZED.accountId, + UpdateType.Grant, + ], + ], + [ + 'computeAccountId', + [ + UNINITIALIZED.zPublicKey, + UNINITIALIZED.secretNonce, + UNINITIALIZED.role, + ], + ], + ]; + it.each(circuitsToSucceed)('%s should succeed', (circuitName, args) => { + expect(() => { + (shieldedAccessControl[circuitName] as (...args: unknown[]) => unknown)( + ...args, + ); + }).not.toThrow('Initializable: contract not initialized'); + }); + + it('should fail with 0 instanceSalt', () => { + const isInit = true; + expect(() => { + new ShieldedAccessControlSimulator(new Uint8Array(32), isInit); + }).toThrow('ShieldedAccessControl: Instance salt must not be 0'); + }); + }); + + describe('after initialization', () => { + const isInit = true; + + beforeEach(() => { + // Create private state object and generate nonce + const PS = ShieldedAccessControlPrivateState.withRoleAndNonce( + ADMIN.role, + ADMIN.secretNonce, + ); + // Create contract simulator with PS + shieldedAccessControl = new ShieldedAccessControlSimulator( + INSTANCE_SALT, + isInit, + { + privateState: PS, + }, + ); + }); + + describe('DEFAULT_ADMIN_ROLE', () => { + it('should return 0', () => { + expect(shieldedAccessControl.DEFAULT_ADMIN_ROLE()).toStrictEqual( + new Uint8Array(32), + ); + }); + }); + + describe('assertOnlyRole', () => { + beforeEach(() => { + shieldedAccessControl._grantRole(ADMIN.role, ADMIN.accountId); + shieldedAccessControl.setPersistentCaller(ADMIN.publicKey); + }); + + describe('should fail', () => { + it('when wit_getRoleCommitmentPath returns a valid path for a different role, accountId pairing', () => { + shieldedAccessControl._grantRole( + OPERATOR_1.role, + OPERATOR_1.accountId, + ); + // Override witness to return valid path for OPERATOR_1 role commitment + shieldedAccessControl.overrideWitness( + 'wit_getRoleCommitmentPath', + () => { + const privateState = shieldedAccessControl.getPrivateState(); + const operator1MtPath = shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__operatorRoles.findPathForLeaf( + OPERATOR_1.roleCommitment, + ); + if (operator1MtPath) return [privateState, operator1MtPath]; + throw new Error('Merkle tree path should be defined'); + }, + ); + expect(() => { + shieldedAccessControl.assertOnlyRole(ADMIN.role); + }).toThrow( + 'ShieldedAccessControl: Path must contain leaf matching computed role commitment for the provided role, accountId pairing', + ); + }); + + it('when caller was never granted the role', () => { + shieldedAccessControl.as(UNAUTHORIZED.publicKey); + expect(() => + shieldedAccessControl.assertOnlyRole(ADMIN.role), + ).toThrow('ShieldedAccessControl: unauthorized account'); + }); + + it('when authorized caller has incorrect path', () => { + // Check caller is admin, has admin role + expect( + shieldedAccessControl.getCallerContext().currentZswapLocalState + .coinPublicKey, + ).toEqual(ADMIN.zPublicKey); + expect(shieldedAccessControl.getRoleAdmin(ADMIN.role)).toEqual( + new Uint8Array(ADMIN.role), + ); + expect( + shieldedAccessControl._validateRole(ADMIN.role, ADMIN.accountId), + ).toBe(true); + + // Check nonce is correct + expect( + shieldedAccessControl.privateState.getCurrentSecretNonce( + ADMIN.role, + ), + ).toBe(ADMIN.secretNonce); + + // Check path does not match + const truePath = + shieldedAccessControl.privateState.getCommitmentPathWithFindForLeaf( + ADMIN.roleCommitment, + ); + shieldedAccessControl.overrideWitness( + 'wit_getRoleCommitmentPath', + RETURN_BAD_PATH, + ); + const witnessCalculatedPath = + shieldedAccessControl.privateState.getCommitmentPathWithWitnessImpl( + ADMIN.roleCommitment, + ); + expect(witnessCalculatedPath).not.toEqual(truePath); + + expect(() => + shieldedAccessControl.assertOnlyRole(ADMIN.role), + ).toThrow('ShieldedAccessControl: unauthorized account'); + }); + + it('when authorized caller has incorrect nonce', () => { + // Check caller is admin, has admin role + expect( + shieldedAccessControl.getCallerContext().currentZswapLocalState + .coinPublicKey, + ).toEqual(ADMIN.zPublicKey); + expect(shieldedAccessControl.getRoleAdmin(ADMIN.role)).toEqual( + new Uint8Array(ADMIN.role), + ); + expect( + shieldedAccessControl._validateRole(ADMIN.role, ADMIN.accountId), + ).toBe(true); + + shieldedAccessControl.privateState.injectSecretNonce( + ADMIN.role, + UNAUTHORIZED.secretNonce, + ); + + // Check nonce is incorrect + expect( + shieldedAccessControl.privateState.getCurrentSecretNonce( + ADMIN.role, + ), + ).not.toBe(ADMIN.secretNonce); + + // Check path matches + const truePath = + shieldedAccessControl.privateState.getCommitmentPathWithFindForLeaf( + ADMIN.roleCommitment, + ); + const witnessCalculatedPath = + shieldedAccessControl.privateState.getCommitmentPathWithWitnessImpl( + ADMIN.roleCommitment, + ); + expect(witnessCalculatedPath).toEqual(truePath); + + expect(() => + shieldedAccessControl.assertOnlyRole(ADMIN.role), + ).toThrow('ShieldedAccessControl: unauthorized account'); + }); + + it('when unauthorized caller has correct nonce, and path', () => { + // Check UNAUTHORIZED user is not admin, doesnt have admin role + expect(shieldedAccessControl.getRoleAdmin(ADMIN.role)).not.toEqual( + new Uint8Array(UNAUTHORIZED.role), + ); + expect( + shieldedAccessControl._validateRole( + ADMIN.role, + UNAUTHORIZED.accountId, + ), + ).toBe(false); + + // Check nonce is correct + expect( + shieldedAccessControl.privateState.getCurrentSecretNonce( + ADMIN.role, + ), + ).toBe(ADMIN.secretNonce); + + // Check path matches + const truePath = + shieldedAccessControl.privateState.getCommitmentPathWithFindForLeaf( + ADMIN.roleCommitment, + ); + const witnessCalculatedPath = + shieldedAccessControl.privateState.getCommitmentPathWithWitnessImpl( + ADMIN.roleCommitment, + ); + expect(witnessCalculatedPath).toEqual(truePath); + + shieldedAccessControl.as(UNAUTHORIZED.publicKey); + // Check caller is UNAUTHORIZED user + expect( + shieldedAccessControl.getCallerContext().currentZswapLocalState + .coinPublicKey, + ).toEqual(UNAUTHORIZED.zPublicKey); + + expect(() => + shieldedAccessControl.assertOnlyRole(ADMIN.role), + ).toThrow('ShieldedAccessControl: unauthorized account'); + }); + + it('when role is revoked', () => { + shieldedAccessControl._revokeRole(ADMIN.role, ADMIN.accountId); + expect(() => + shieldedAccessControl.assertOnlyRole(ADMIN.role), + ).toThrow('ShieldedAccessControl: unauthorized account'); + }); + + it('when role is revoked and re-issued to the same accountId', () => { + shieldedAccessControl._revokeRole(ADMIN.role, ADMIN.accountId); + shieldedAccessControl._grantRole(ADMIN.role, ADMIN.accountId); + expect(() => + shieldedAccessControl.assertOnlyRole(ADMIN.role), + ).toThrow('ShieldedAccessControl: unauthorized account'); + }); + }); + + describe('should not fail', () => { + it('when accountId has multiple roles', () => { + shieldedAccessControl.privateState.injectSecretNonce( + OPERATOR_1.role, + OPERATOR_1.secretNonce, + ); + // A unique accountId must be constructed for each new role using its associated secretNonce + const operator1AccountId = buildAccountIdHash( + ADMIN.zPublicKey, + OPERATOR_1.secretNonce, + ); + + shieldedAccessControl.privateState.injectSecretNonce( + OPERATOR_2.role, + OPERATOR_2.secretNonce, + ); + const operator2AccountId = buildAccountIdHash( + ADMIN.zPublicKey, + OPERATOR_2.secretNonce, + ); + + shieldedAccessControl.privateState.injectSecretNonce( + OPERATOR_3.role, + OPERATOR_3.secretNonce, + ); + const operator3AccountId = buildAccountIdHash( + ADMIN.zPublicKey, + OPERATOR_3.secretNonce, + ); + + shieldedAccessControl._grantRole(OPERATOR_1.role, operator1AccountId); + shieldedAccessControl._grantRole(OPERATOR_2.role, operator2AccountId); + shieldedAccessControl._grantRole(OPERATOR_3.role, operator3AccountId); + expect(() => { + shieldedAccessControl.assertOnlyRole(ADMIN.role); + shieldedAccessControl.assertOnlyRole(OPERATOR_1.role); + shieldedAccessControl.assertOnlyRole(OPERATOR_2.role); + shieldedAccessControl.assertOnlyRole(OPERATOR_3.role); + }).not.toThrow(); + }); + + it('when authorized caller has correct nonce, and path', () => { + // Check nonce is correct + expect( + shieldedAccessControl.privateState.getCurrentSecretNonce( + ADMIN.role, + ), + ).toBe(ADMIN.secretNonce); + + // Check path matches + const truePath = + shieldedAccessControl.privateState.getCommitmentPathWithFindForLeaf( + ADMIN.roleCommitment, + ); + const witnessCalculatedPath = + shieldedAccessControl.privateState.getCommitmentPathWithWitnessImpl( + ADMIN.roleCommitment, + ); + expect(witnessCalculatedPath).toEqual(truePath); + + expect(() => + shieldedAccessControl.assertOnlyRole(ADMIN.role), + ).not.toThrow(); + }); + + it('when role is revoked and re-issued with a different accountId', () => { + shieldedAccessControl._revokeRole(ADMIN.role, ADMIN.accountId); + + shieldedAccessControl.privateState.injectSecretNonce( + ADMIN.role, + Buffer.alloc(32, 'NEW_ADMIN_NONCE'), + ); + const newAdminAccountId = buildAccountIdHash( + ADMIN.zPublicKey, + shieldedAccessControl.privateState.getCurrentSecretNonce( + ADMIN.role, + ), + ); + expect(newAdminAccountId).not.toEqual(ADMIN.accountId); + + shieldedAccessControl._grantRole(ADMIN.role, newAdminAccountId); + expect(() => + shieldedAccessControl.assertOnlyRole(ADMIN.role), + ).not.toThrow(); + }); + + it('when multiple users have the same role', () => { + // All users will use OPERATOR_1.secretNonce as their nonce value + // when generating their accountId for simplicity + shieldedAccessControl.privateState.injectSecretNonce( + OPERATOR_1.role, + OPERATOR_1.secretNonce, + ); + // A unique accountId must be constructed for each new role using its associated secretNonce + const operator1AdminAccountId = buildAccountIdHash( + ADMIN.zPublicKey, + OPERATOR_1.secretNonce, + ); + shieldedAccessControl._grantRole( + OPERATOR_1.role, + operator1AdminAccountId, + ); + shieldedAccessControl.as(ADMIN.publicKey); // assert ADMIN has OP_1 role + expect(shieldedAccessControl.assertOnlyRole(OPERATOR_1.role)); + + const operator1Op2AccountId = buildAccountIdHash( + OPERATOR_2.zPublicKey, + OPERATOR_1.secretNonce, + ); + shieldedAccessControl._grantRole( + OPERATOR_1.role, + operator1Op2AccountId, + ); + shieldedAccessControl.as(OPERATOR_2.publicKey); // assert OP_2 has OP_1 role + expect(shieldedAccessControl.assertOnlyRole(OPERATOR_1.role)); + + const operator1Op3AccountId = buildAccountIdHash( + OPERATOR_3.zPublicKey, + OPERATOR_1.secretNonce, + ); + shieldedAccessControl._grantRole( + OPERATOR_1.role, + operator1Op3AccountId, + ); + shieldedAccessControl.as(OPERATOR_3.publicKey); // assert OP_3 has OP_1 role + expect(shieldedAccessControl.assertOnlyRole(OPERATOR_1.role)); + }); + }); + }); + + describe('canProveRole', () => { + beforeEach(() => { + shieldedAccessControl._grantRole(ADMIN.role, ADMIN.accountId); + shieldedAccessControl.setPersistentCaller(ADMIN.publicKey); + }); + + it('should fail when caller provides valid path for a different role, accountId pairing', () => { + shieldedAccessControl._grantRole(OPERATOR_1.role, OPERATOR_1.accountId); + // Override witness to return valid path for OPERATOR_1 role commitment + shieldedAccessControl.overrideWitness( + 'wit_getRoleCommitmentPath', + () => { + const privateState = shieldedAccessControl.getPrivateState(); + const operator1MtPath = shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__operatorRoles.findPathForLeaf( + OPERATOR_1.roleCommitment, + ); + if (operator1MtPath) return [privateState, operator1MtPath]; + throw new Error('Merkle tree path should be defined'); + }, + ); + expect(() => { + shieldedAccessControl.canProveRole(ADMIN.role); + }).toThrow( + 'ShieldedAccessControl: Path must contain leaf matching computed role commitment for the provided role, accountId pairing', + ); + }); + + describe('should return true', () => { + it('when caller has role', () => { + // Check caller is admin, has admin role + expect( + shieldedAccessControl.getCallerContext().currentZswapLocalState + .coinPublicKey, + ).toEqual(ADMIN.zPublicKey); + expect( + shieldedAccessControl._validateRole(ADMIN.role, ADMIN.accountId), + ).toBe(true); + + expect(shieldedAccessControl.canProveRole(ADMIN.role)).toBe(true); + }); + + it('when caller has multiple roles', () => { + // setup test + shieldedAccessControl.privateState.injectSecretNonce( + OPERATOR_1.role, + OPERATOR_1.secretNonce, + ); + shieldedAccessControl.privateState.injectSecretNonce( + OPERATOR_2.role, + OPERATOR_2.secretNonce, + ); + shieldedAccessControl.privateState.injectSecretNonce( + OPERATOR_3.role, + OPERATOR_3.secretNonce, + ); + const account1 = buildAccountIdHash( + ADMIN.zPublicKey, + OPERATOR_1.secretNonce, + ); + const account2 = buildAccountIdHash( + ADMIN.zPublicKey, + OPERATOR_2.secretNonce, + ); + const account3 = buildAccountIdHash( + ADMIN.zPublicKey, + OPERATOR_3.secretNonce, + ); + shieldedAccessControl._grantRole(OPERATOR_1.role, account1); + shieldedAccessControl._grantRole(OPERATOR_2.role, account2); + shieldedAccessControl._grantRole(OPERATOR_3.role, account3); + + expect(shieldedAccessControl.canProveRole(ADMIN.role)).toBe(true); + expect(shieldedAccessControl.canProveRole(OPERATOR_1.role)).toBe( + true, + ); + expect(shieldedAccessControl.canProveRole(OPERATOR_2.role)).toBe( + true, + ); + expect(shieldedAccessControl.canProveRole(OPERATOR_3.role)).toBe( + true, + ); + }); + + it('when role is revoked and re-issued with a different accountId', () => { + shieldedAccessControl._revokeRole(ADMIN.role, ADMIN.accountId); + + shieldedAccessControl.privateState.injectSecretNonce( + ADMIN.role, + Buffer.alloc(32, 'NEW_ADMIN_NONCE'), + ); + const newAdminAccountId = buildAccountIdHash( + ADMIN.zPublicKey, + shieldedAccessControl.privateState.getCurrentSecretNonce( + ADMIN.role, + ), + ); + expect(newAdminAccountId).not.toEqual(ADMIN.accountId); + + shieldedAccessControl._grantRole(ADMIN.role, newAdminAccountId); + expect(shieldedAccessControl.canProveRole(ADMIN.role)).toBe(true); + }); + + it('when multiple users have the same role', () => { + // All users will use OPERATOR_1.secretNonce as their nonce value + // when generating their accountId for simplicity + shieldedAccessControl.privateState.injectSecretNonce( + OPERATOR_1.role, + OPERATOR_1.secretNonce, + ); + // A unique accountId must be constructed for each new role using its associated secretNonce + const operator1AdminAccountId = buildAccountIdHash( + ADMIN.zPublicKey, + OPERATOR_1.secretNonce, + ); + shieldedAccessControl._grantRole( + OPERATOR_1.role, + operator1AdminAccountId, + ); + shieldedAccessControl.as(ADMIN.publicKey); // prove ADMIN has OP_1 role + expect(shieldedAccessControl.canProveRole(OPERATOR_1.role)).toBe( + true, + ); + + const operator1Op2AccountId = buildAccountIdHash( + OPERATOR_2.zPublicKey, + OPERATOR_1.secretNonce, + ); + shieldedAccessControl._grantRole( + OPERATOR_1.role, + operator1Op2AccountId, + ); + shieldedAccessControl.as(OPERATOR_2.publicKey); // prove OP_2 has OP_1 role + expect(shieldedAccessControl.canProveRole(OPERATOR_1.role)).toBe( + true, + ); + + const operator1Op3AccountId = buildAccountIdHash( + OPERATOR_3.zPublicKey, + OPERATOR_1.secretNonce, + ); + shieldedAccessControl._grantRole( + OPERATOR_1.role, + operator1Op3AccountId, + ); + shieldedAccessControl.as(OPERATOR_3.publicKey); // prove OP_3 has OP_1 role + expect(shieldedAccessControl.canProveRole(OPERATOR_1.role)).toBe( + true, + ); + }); + }); + + describe('should return false', () => { + it('when caller does not have role', () => { + // setup test + shieldedAccessControl.privateState.injectSecretNonce( + OPERATOR_1.role, + OPERATOR_1.secretNonce, + ); + const accountId = buildAccountIdHash( + ADMIN.zPublicKey, + OPERATOR_1.secretNonce, + ); + + // Check does not have OPERATOR role + expect( + shieldedAccessControl._validateRole(OPERATOR_1.role, accountId), + ).toBe(false); + + expect(shieldedAccessControl.canProveRole(OPERATOR_1.role)).toBe( + false, + ); + }); + + it('when caller has revoked role', () => { + shieldedAccessControl._revokeRole(ADMIN.role, ADMIN.accountId); + + // check role revoked + expect( + shieldedAccessControl._validateRole(ADMIN.role, ADMIN.accountId), + ).toBe(false); + + expect(shieldedAccessControl.canProveRole(ADMIN.role)).toBe(false); + }); + + it('when revoked role is re-granted', () => { + shieldedAccessControl._revokeRole(ADMIN.role, ADMIN.accountId); + // check role revoked + expect( + shieldedAccessControl._validateRole(ADMIN.role, ADMIN.accountId), + ).toBe(false); + + shieldedAccessControl._grantRole(ADMIN.role, ADMIN.accountId); + expect(shieldedAccessControl.canProveRole(ADMIN.role)).toBe(false); + }); + + it('when an unauthorized caller has valid nonce', () => { + // UNAUTHORIZED uses the same private state (ADMIN.secretNonce for ADMIN.role), + // so their derived accountId won't match the committed one. + shieldedAccessControl.as(UNAUTHORIZED.publicKey); + expect(shieldedAccessControl.canProveRole(ADMIN.role)).toBe(false); + }); + + it('when an authorized caller provides invalid nonce', () => { + // Check caller is admin, has admin role + expect( + shieldedAccessControl.getCallerContext().currentZswapLocalState + .coinPublicKey, + ).toEqual(ADMIN.zPublicKey); + expect( + shieldedAccessControl._validateRole(ADMIN.role, ADMIN.accountId), + ).toBe(true); + + shieldedAccessControl.privateState.injectSecretNonce( + ADMIN.role, + BAD_INPUT.secretNonce, + ); + // nonce should not match + expect(ADMIN.secretNonce).not.toEqual( + shieldedAccessControl.privateState.getCurrentSecretNonce( + ADMIN.role, + ), + ); + + expect(shieldedAccessControl.canProveRole(ADMIN.role)).toBe(false); + }); + + it('when an authorized caller provides invalid witness path', () => { + // Check caller is admin, has admin role + expect( + shieldedAccessControl.getCallerContext().currentZswapLocalState + .coinPublicKey, + ).toEqual(ADMIN.zPublicKey); + expect( + shieldedAccessControl._validateRole(ADMIN.role, ADMIN.accountId), + ).toBe(true); + + shieldedAccessControl.overrideWitness( + 'wit_getRoleCommitmentPath', + RETURN_BAD_PATH, + ); + expect(shieldedAccessControl.canProveRole(ADMIN.role)).toBe(false); + }); + }); + }); + + describe('_uncheckedCanProveRole', () => { + beforeEach(() => { + shieldedAccessControl._grantRole(ADMIN.role, ADMIN.accountId); + shieldedAccessControl.setPersistentCaller(ADMIN.publicKey); + }); + + it('should fail when caller provides valid path for a different role, accountId pairing', () => { + shieldedAccessControl._grantRole(OPERATOR_1.role, OPERATOR_1.accountId); + // Override witness to return valid path for OPERATOR_1 role commitment + shieldedAccessControl.overrideWitness( + 'wit_getRoleCommitmentPath', + () => { + const privateState = shieldedAccessControl.getPrivateState(); + const operator1MtPath = shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__operatorRoles.findPathForLeaf( + OPERATOR_1.roleCommitment, + ); + if (operator1MtPath) return [privateState, operator1MtPath]; + throw new Error('Merkle tree path should be defined'); + }, + ); + expect(() => { + shieldedAccessControl._uncheckedCanProveRole(ADMIN.role); + }).toThrow( + 'ShieldedAccessControl: Path must contain leaf matching computed role commitment for the provided role, accountId pairing', + ); + }); + + describe('should return true', () => { + it('when caller has role', () => { + // Check caller is admin, has admin role + expect( + shieldedAccessControl.getCallerContext().currentZswapLocalState + .coinPublicKey, + ).toEqual(ADMIN.zPublicKey); + expect( + shieldedAccessControl._validateRole(ADMIN.role, ADMIN.accountId), + ).toBe(true); + + expect(shieldedAccessControl._uncheckedCanProveRole(ADMIN.role)).toBe( + true, + ); + }); + + it('when caller has multiple roles', () => { + // setup test + shieldedAccessControl.privateState.injectSecretNonce( + OPERATOR_1.role, + OPERATOR_1.secretNonce, + ); + shieldedAccessControl.privateState.injectSecretNonce( + OPERATOR_2.role, + OPERATOR_2.secretNonce, + ); + shieldedAccessControl.privateState.injectSecretNonce( + OPERATOR_3.role, + OPERATOR_3.secretNonce, + ); + const account1 = buildAccountIdHash( + ADMIN.zPublicKey, + OPERATOR_1.secretNonce, + ); + const account2 = buildAccountIdHash( + ADMIN.zPublicKey, + OPERATOR_2.secretNonce, + ); + const account3 = buildAccountIdHash( + ADMIN.zPublicKey, + OPERATOR_3.secretNonce, + ); + shieldedAccessControl._grantRole(OPERATOR_1.role, account1); + shieldedAccessControl._grantRole(OPERATOR_2.role, account2); + shieldedAccessControl._grantRole(OPERATOR_3.role, account3); + + expect(shieldedAccessControl._uncheckedCanProveRole(ADMIN.role)).toBe( + true, + ); + expect( + shieldedAccessControl._uncheckedCanProveRole(OPERATOR_1.role), + ).toBe(true); + expect( + shieldedAccessControl._uncheckedCanProveRole(OPERATOR_2.role), + ).toBe(true); + expect( + shieldedAccessControl._uncheckedCanProveRole(OPERATOR_3.role), + ).toBe(true); + }); + + it('when role is revoked and re-issued with a different accountId', () => { + shieldedAccessControl._revokeRole(ADMIN.role, ADMIN.accountId); + + shieldedAccessControl.privateState.injectSecretNonce( + ADMIN.role, + Buffer.alloc(32, 'NEW_ADMIN_NONCE'), + ); + const newAdminAccountId = buildAccountIdHash( + ADMIN.zPublicKey, + shieldedAccessControl.privateState.getCurrentSecretNonce( + ADMIN.role, + ), + ); + expect(newAdminAccountId).not.toEqual(ADMIN.accountId); + + shieldedAccessControl._grantRole(ADMIN.role, newAdminAccountId); + expect(shieldedAccessControl._uncheckedCanProveRole(ADMIN.role)).toBe( + true, + ); + }); + + it('when multiple users have the same role', () => { + // All users will use OPERATOR_1.secretNonce as their nonce value + // when generating their accountId for simplicity + shieldedAccessControl.privateState.injectSecretNonce( + OPERATOR_1.role, + OPERATOR_1.secretNonce, + ); + // A unique accountId must be constructed for each new role using its associated secretNonce + const operator1AdminAccountId = buildAccountIdHash( + ADMIN.zPublicKey, + OPERATOR_1.secretNonce, + ); + shieldedAccessControl._grantRole( + OPERATOR_1.role, + operator1AdminAccountId, + ); + shieldedAccessControl.as(ADMIN.publicKey); // prove ADMIN has OP_1 role + expect( + shieldedAccessControl._uncheckedCanProveRole(OPERATOR_1.role), + ).toBe(true); + + const operator1Op2AccountId = buildAccountIdHash( + OPERATOR_2.zPublicKey, + OPERATOR_1.secretNonce, + ); + shieldedAccessControl._grantRole( + OPERATOR_1.role, + operator1Op2AccountId, + ); + shieldedAccessControl.as(OPERATOR_2.publicKey); // prove OP_2 has OP_1 role + expect( + shieldedAccessControl._uncheckedCanProveRole(OPERATOR_1.role), + ).toBe(true); + + const operator1Op3AccountId = buildAccountIdHash( + OPERATOR_3.zPublicKey, + OPERATOR_1.secretNonce, + ); + shieldedAccessControl._grantRole( + OPERATOR_1.role, + operator1Op3AccountId, + ); + shieldedAccessControl.as(OPERATOR_3.publicKey); // prove OP_3 has OP_1 role + expect( + shieldedAccessControl._uncheckedCanProveRole(OPERATOR_1.role), + ).toBe(true); + }); + }); + + describe('should return false', () => { + it('when caller does not have role', () => { + // setup test + shieldedAccessControl.privateState.injectSecretNonce( + OPERATOR_1.role, + OPERATOR_1.secretNonce, + ); + const accountId = buildAccountIdHash( + ADMIN.zPublicKey, + OPERATOR_1.secretNonce, + ); + + // Check does not have OPERATOR role + expect( + shieldedAccessControl._validateRole(OPERATOR_1.role, accountId), + ).toBe(false); + + expect( + shieldedAccessControl._uncheckedCanProveRole(OPERATOR_1.role), + ).toBe(false); + }); + + it('when caller has revoked role', () => { + shieldedAccessControl._revokeRole(ADMIN.role, ADMIN.accountId); + + // check role revoked + expect( + shieldedAccessControl._validateRole(ADMIN.role, ADMIN.accountId), + ).toBe(false); + + expect(shieldedAccessControl._uncheckedCanProveRole(ADMIN.role)).toBe( + false, + ); + }); + + it('when revoked role is re-granted', () => { + shieldedAccessControl._revokeRole(ADMIN.role, ADMIN.accountId); + // check role revoked + expect( + shieldedAccessControl._validateRole(ADMIN.role, ADMIN.accountId), + ).toBe(false); + + shieldedAccessControl._grantRole(ADMIN.role, ADMIN.accountId); + expect(shieldedAccessControl._uncheckedCanProveRole(ADMIN.role)).toBe( + false, + ); + }); + + it('when an unauthorized caller has valid nonce', () => { + // UNAUTHORIZED uses the same private state (ADMIN.secretNonce for ADMIN.role), + // so their derived accountId won't match the committed one. + shieldedAccessControl.as(UNAUTHORIZED.publicKey); + expect(shieldedAccessControl._uncheckedCanProveRole(ADMIN.role)).toBe( + false, + ); + }); + + it('when an authorized caller provides invalid nonce', () => { + // Check caller is admin, has admin role + expect( + shieldedAccessControl.getCallerContext().currentZswapLocalState + .coinPublicKey, + ).toEqual(ADMIN.zPublicKey); + expect( + shieldedAccessControl._validateRole(ADMIN.role, ADMIN.accountId), + ).toBe(true); + + shieldedAccessControl.privateState.injectSecretNonce( + ADMIN.role, + BAD_INPUT.secretNonce, + ); + // nonce should not match + expect(ADMIN.secretNonce).not.toEqual( + shieldedAccessControl.privateState.getCurrentSecretNonce( + ADMIN.role, + ), + ); + + expect(shieldedAccessControl._uncheckedCanProveRole(ADMIN.role)).toBe( + false, + ); + }); + + it('when an authorized caller provides invalid witness path', () => { + // Check caller is admin, has admin role + expect( + shieldedAccessControl.getCallerContext().currentZswapLocalState + .coinPublicKey, + ).toEqual(ADMIN.zPublicKey); + expect( + shieldedAccessControl._validateRole(ADMIN.role, ADMIN.accountId), + ).toBe(true); + + shieldedAccessControl.overrideWitness( + 'wit_getRoleCommitmentPath', + RETURN_BAD_PATH, + ); + expect(shieldedAccessControl._uncheckedCanProveRole(ADMIN.role)).toBe( + false, + ); + }); + }); + }); + + describe('grantRole', () => { + beforeEach(() => { + shieldedAccessControl._grantRole(ADMIN.role, ADMIN.accountId); + shieldedAccessControl.setPersistentCaller(ADMIN.publicKey); + }); + + describe('should fail', () => { + it('when caller does not have the admin role', () => { + shieldedAccessControl.as(UNAUTHORIZED.publicKey); + expect(() => + shieldedAccessControl.grantRole( + OPERATOR_1.role, + OPERATOR_1.accountId, + ), + ).toThrow('ShieldedAccessControl: unauthorized account'); + }); + + it('when wit_getRoleCommitmentPath returns a valid path for a different role, accountId pairing', () => { + shieldedAccessControl._grantRole( + OPERATOR_1.role, + OPERATOR_1.accountId, + ); + // Override witness to return valid path for OPERATOR_1 role commitment + shieldedAccessControl.overrideWitness( + 'wit_getRoleCommitmentPath', + () => { + const privateState = shieldedAccessControl.getPrivateState(); + const operator1MtPath = shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__operatorRoles.findPathForLeaf( + OPERATOR_1.roleCommitment, + ); + if (operator1MtPath) return [privateState, operator1MtPath]; + throw new Error('Merkle tree path should be defined'); + }, + ); + expect(() => { + shieldedAccessControl.grantRole(ADMIN.role, ADMIN.accountId); + }).toThrow( + 'ShieldedAccessControl: Path must contain leaf matching computed role commitment for the provided role, accountId pairing', + ); + }); + + it('when admin with duplicate roles is revoked', () => { + // create duplicate roles + shieldedAccessControl._grantRole(ADMIN.role, ADMIN.accountId); + shieldedAccessControl._grantRole(ADMIN.role, ADMIN.accountId); + + shieldedAccessControl._revokeRole(ADMIN.role, ADMIN.accountId); + expect(() => + shieldedAccessControl.grantRole(ADMIN.role, ADMIN.accountId), + ).toThrow('ShieldedAccessControl: unauthorized account'); + }); + + it('when admin role is revoked', () => { + shieldedAccessControl._revokeRole(ADMIN.role, ADMIN.accountId); + expect(() => + shieldedAccessControl.grantRole(ADMIN.role, ADMIN.accountId), + ).toThrow('ShieldedAccessControl: unauthorized account'); + }); + + it('when admin provides incorrect nonce', () => { + shieldedAccessControl.privateState.injectSecretNonce( + ADMIN.role, + BAD_INPUT.secretNonce, + ); + expect( + shieldedAccessControl.privateState.getCurrentSecretNonce( + ADMIN.role, + ), + ).not.toEqual(ADMIN.secretNonce); + expect(() => + shieldedAccessControl.grantRole(ADMIN.role, ADMIN.accountId), + ).toThrow('ShieldedAccessControl: unauthorized account'); + }); + + it('when admin provides bad witness path', () => { + shieldedAccessControl.overrideWitness( + 'wit_getRoleCommitmentPath', + RETURN_BAD_PATH, + ); + expect(() => + shieldedAccessControl.grantRole(ADMIN.role, ADMIN.accountId), + ).toThrow('ShieldedAccessControl: unauthorized account'); + }); + + it('when non-admin caller has role', () => { + shieldedAccessControl._grantRole( + OPERATOR_1.role, + OPERATOR_1.accountId, + ); + + shieldedAccessControl.as(OPERATOR_1.publicKey); + // OP_1 has role but is not authorized to grant roles to other users + expect(() => + shieldedAccessControl.grantRole( + OPERATOR_1.role, + OPERATOR_2.accountId, + ), + ).toThrow('ShieldedAccessControl: unauthorized account'); + }); + + it('when admin role has been reassigned via _setRoleAdmin', () => { + // Make OPERATOR_1.role the admin of OPERATOR_2.role + shieldedAccessControl._setRoleAdmin(OPERATOR_2.role, OPERATOR_1.role); + + shieldedAccessControl.privateState.injectSecretNonce( + OPERATOR_1.role, + ADMIN.secretNonce, + ); + + // ADMIN holds DEFAULT_ADMIN_ROLE but not OPERATOR_1.role, + // so granting OPERATOR_2.role should fail + expect(() => + shieldedAccessControl.grantRole( + OPERATOR_2.role, + OPERATOR_2.accountId, + ), + ).toThrow('ShieldedAccessControl: unauthorized account'); + }); + }); + + describe('should not update _operatorRoles Merkle tree', () => { + it('when role is revoked', () => { + // setup test + shieldedAccessControl._grantRole( + OPERATOR_1.role, + OPERATOR_1.accountId, + ); + shieldedAccessControl._revokeRole( + OPERATOR_1.role, + OPERATOR_1.accountId, + ); + + const initialRoot = shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__operatorRoles.root(); + shieldedAccessControl.grantRole( + OPERATOR_1.role, + OPERATOR_1.accountId, + ); + + const updatedRoot = shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__operatorRoles.root(); + expect(initialRoot).toEqual(updatedRoot); + }); + }); + + describe('should grant role', () => { + it('when caller has the admin role', () => { + expect(() => + shieldedAccessControl.grantRole( + OPERATOR_1.role, + OPERATOR_1.accountId, + ), + ).not.toThrow(); + expect( + shieldedAccessControl._validateRole( + OPERATOR_1.role, + OPERATOR_1.accountId, + ), + ).toBe(true); + }); + + it('when caller has custom admin role', () => { + // Make OPERATOR_1.role the admin of OPERATOR_2.role. + shieldedAccessControl._setRoleAdmin(OPERATOR_2.role, OPERATOR_1.role); + // Grant OPERATOR_1.role to OPERATOR_1.accountId + shieldedAccessControl.grantRole( + OPERATOR_1.role, + OPERATOR_1.accountId, + ); + + // Switch to OPERATOR_1 as caller and inject their nonce for their role. + shieldedAccessControl.privateState.injectSecretNonce( + OPERATOR_1.role, + OPERATOR_1.secretNonce, + ); + shieldedAccessControl.setPersistentCaller(OPERATOR_1.publicKey); + + // OPERATOR_1.accountId (who holds OPERATOR_1.role) can now grant OPERATOR_2.role. + expect(() => + shieldedAccessControl.grantRole( + OPERATOR_2.role, + OPERATOR_2.accountId, + ), + ).not.toThrow(); + expect( + shieldedAccessControl._validateRole( + OPERATOR_2.role, + OPERATOR_2.accountId, + ), + ).toBe(true); + }); + + it('when admin role is revoked and re-issued with a different accountId', () => { + // setup test + shieldedAccessControl._revokeRole(ADMIN.role, ADMIN.accountId); + const newNonce = Buffer.alloc(32, 'NEW_ADMIN_NONCE'); + shieldedAccessControl.privateState.injectSecretNonce( + ADMIN.role, + newNonce, + ); + const newAccountId = buildAccountIdHash(ADMIN.zPublicKey, newNonce); + shieldedAccessControl._grantRole(ADMIN.role, newAccountId); + + expect(() => { + shieldedAccessControl.grantRole( + OPERATOR_1.role, + OPERATOR_1.accountId, + ); + }).not.toThrow(); + expect( + shieldedAccessControl._validateRole( + OPERATOR_1.role, + OPERATOR_1.accountId, + ), + ).toBe(true); + expect( + shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__operatorRoles.findPathForLeaf( + OPERATOR_1.roleCommitment, + ), + ).toBeDefined(); + }); + + it('when multiple admins of the same role exist', () => { + // setup test + const account1 = buildAccountIdHash( + OPERATOR_1.zPublicKey, + ADMIN.secretNonce, + ); + const account2 = buildAccountIdHash( + OPERATOR_2.zPublicKey, + ADMIN.secretNonce, + ); + const account3 = buildAccountIdHash( + OPERATOR_3.zPublicKey, + ADMIN.secretNonce, + ); + shieldedAccessControl._grantRole(ADMIN.role, account1); + shieldedAccessControl._grantRole(ADMIN.role, account2); + shieldedAccessControl._grantRole(ADMIN.role, account3); + + // check grant role succeeds as OP and role is valid + shieldedAccessControl.as(OPERATOR_1.publicKey); + expect(() => + shieldedAccessControl.grantRole(ADMIN.role, account1), + ).not.toThrow(); + expect( + shieldedAccessControl._validateRole(ADMIN.role, account1), + ).toBe(true); + + shieldedAccessControl.as(OPERATOR_2.publicKey); + expect(() => + shieldedAccessControl.grantRole(ADMIN.role, ADMIN.accountId), + ).not.toThrow(); + expect( + shieldedAccessControl._validateRole(ADMIN.role, account2), + ).toBe(true); + + shieldedAccessControl.as(OPERATOR_3.publicKey); + expect(() => + shieldedAccessControl.grantRole(ADMIN.role, ADMIN.accountId), + ).not.toThrow(); + expect( + shieldedAccessControl._validateRole(ADMIN.role, account3), + ).toBe(true); + }); + + it('when admin has multiple roles', () => { + shieldedAccessControl._grantRole(OPERATOR_1.role, ADMIN.accountId); + shieldedAccessControl._grantRole(OPERATOR_2.role, ADMIN.accountId); + shieldedAccessControl._grantRole(OPERATOR_3.role, ADMIN.accountId); + + expect(() => + shieldedAccessControl.grantRole( + OPERATOR_1.role, + OPERATOR_1.accountId, + ), + ).not.toThrow(); + expect( + shieldedAccessControl._validateRole( + OPERATOR_1.role, + OPERATOR_1.accountId, + ), + ).toBe(true); + }); + + it('when re-granting active role', () => { + expect(() => + shieldedAccessControl.grantRole(ADMIN.role, ADMIN.accountId), + ).not.toThrow(); + expect( + shieldedAccessControl._validateRole(ADMIN.role, ADMIN.accountId), + ).toBe(true); + }); + + it('when granting role that does not exist', () => { + expect(() => + shieldedAccessControl.grantRole( + UNINITIALIZED.role, + UNINITIALIZED.accountId, + ), + ).not.toThrow(); + expect( + shieldedAccessControl._validateRole( + UNINITIALIZED.role, + UNINITIALIZED.accountId, + ), + ).toBe(true); + }); + + it('when granting role with bad accountId', () => { + expect(() => + shieldedAccessControl.grantRole(ADMIN.role, BAD_INPUT.accountId), + ).not.toThrow(); + expect( + shieldedAccessControl._validateRole( + ADMIN.role, + BAD_INPUT.accountId, + ), + ).toBe(true); + }); + }); + }); + + describe('_grantRole', () => { + describe('should return true', () => { + it('when authorized user grants a new role', () => { + shieldedAccessControl.as(ADMIN.publicKey); + expect( + shieldedAccessControl._grantRole(ADMIN.role, ADMIN.accountId), + ).toBe(true); + }); + + it('when unauthorized user grants role', () => { + shieldedAccessControl.as(UNAUTHORIZED.publicKey); + expect( + shieldedAccessControl._grantRole(ADMIN.role, ADMIN.accountId), + ).toBe(true); + }); + + it('when re-granting active role ', () => { + shieldedAccessControl._grantRole(ADMIN.role, ADMIN.accountId); + + expect( + shieldedAccessControl._grantRole(ADMIN.role, ADMIN.accountId), + ).toBe(true); + }); + + it('when granting role that does not exist', () => { + expect( + shieldedAccessControl._grantRole( + UNINITIALIZED.role, + ADMIN.accountId, + ), + ).toBe(true); + }); + + it('when granting role with bad accountId', () => { + expect( + shieldedAccessControl._grantRole(ADMIN.role, BAD_INPUT.accountId), + ).toBe(true); + }); + }); + + describe('should update _operatorRoles merkle tree', () => { + it('when authorized user grants a new role', () => { + shieldedAccessControl.setPersistentCaller(ADMIN.publicKey); + // Check caller is admin, has admin role + expect( + shieldedAccessControl.getCallerContext().currentZswapLocalState + .coinPublicKey, + ).toEqual(ADMIN.zPublicKey); + expect(shieldedAccessControl.getRoleAdmin(ADMIN.role)).toEqual( + new Uint8Array(ADMIN.role), + ); + + // check merkle tree is empty + let merkleRoot = shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__operatorRoles.root(); + expect(merkleRoot.field).toBe(0n); + + // check merkle tree is updated + shieldedAccessControl.as(ADMIN.publicKey); + shieldedAccessControl._grantRole(ADMIN.role, ADMIN.accountId); + merkleRoot = shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__operatorRoles.root(); + expect(merkleRoot.field).not.toBe(0n); + + // check path exists for new role + const merkleTreePath = shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__operatorRoles.findPathForLeaf( + ADMIN.roleCommitment, + ); + expect(merkleTreePath).toBeDefined(); + expect(merkleTreePath?.leaf).toStrictEqual(ADMIN.roleCommitment); + }); + + it('when unauthorized user grants a new role', () => { + // Check UNAUTHORIZED is not admin + expect(shieldedAccessControl.getRoleAdmin(ADMIN.role)).not.toEqual( + new Uint8Array(UNAUTHORIZED.role), + ); + expect( + shieldedAccessControl._validateRole( + ADMIN.role, + UNAUTHORIZED.accountId, + ), + ).toBe(false); + + // check merkle tree is empty + let merkleRoot = shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__operatorRoles.root(); + expect(merkleRoot.field).toBe(0n); + + // check caller is UNAUTHORIZED user + shieldedAccessControl.as(UNAUTHORIZED.publicKey); + expect( + shieldedAccessControl.getCallerContext().currentZswapLocalState + .coinPublicKey, + ).toEqual(UNAUTHORIZED.zPublicKey); + + // check merkle tree is updated + shieldedAccessControl._grantRole(ADMIN.role, ADMIN.accountId); + merkleRoot = shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__operatorRoles.root(); + expect(merkleRoot.field).not.toBe(0n); + + // check path exists for new role + const merkleTreePath = shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__operatorRoles.findPathForLeaf( + ADMIN.roleCommitment, + ); + expect(merkleTreePath).toBeDefined(); + expect(merkleTreePath?.leaf).toStrictEqual(ADMIN.roleCommitment); + }); + + it('when granting role that does not exist', () => { + // check merkle tree is empty + let merkleRoot = shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__operatorRoles.root(); + expect(merkleRoot.field).toBe(0n); + + // check merkle tree is updated + shieldedAccessControl._grantRole( + UNINITIALIZED.role, + UNINITIALIZED.accountId, + ); + merkleRoot = shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__operatorRoles.root(); + expect(merkleRoot.field).not.toBe(0n); + + // check path exists for new role + const merkleTreePath = shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__operatorRoles.findPathForLeaf( + UNINITIALIZED.roleCommitment, + ); + expect(merkleTreePath).toBeDefined(); + expect(merkleTreePath?.leaf).toStrictEqual( + UNINITIALIZED.roleCommitment, + ); + }); + + it('when granting role with bad accountId', () => { + // check merkle tree is empty + let merkleRoot = shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__operatorRoles.root(); + expect(merkleRoot.field).toBe(0n); + + // check merkle tree is updated + shieldedAccessControl._grantRole(ADMIN.role, BAD_INPUT.accountId); + merkleRoot = shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__operatorRoles.root(); + expect(merkleRoot.field).not.toBe(0n); + + // check path exists for new role + const adminRoleBadAccountCommitment = buildRoleCommitmentHash( + ADMIN.role, + BAD_INPUT.accountId, + ); + const merkleTreePath = shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__operatorRoles.findPathForLeaf( + adminRoleBadAccountCommitment, + ); + expect(merkleTreePath).toBeDefined(); + expect(merkleTreePath?.leaf).toStrictEqual( + adminRoleBadAccountCommitment, + ); + }); + }); + + describe('should return false', () => { + it('when re-granting revoked role', () => { + shieldedAccessControl._grantRole(ADMIN.role, ADMIN.accountId); + shieldedAccessControl._revokeRole(ADMIN.role, ADMIN.accountId); + expect( + shieldedAccessControl._grantRole(ADMIN.role, ADMIN.accountId), + ).toBe(false); + }); + }); + + describe('should not update _operatorRoles merkle tree', () => { + it('when re-granting revoked role', () => { + shieldedAccessControl._grantRole(ADMIN.role, ADMIN.accountId); + shieldedAccessControl._revokeRole(ADMIN.role, ADMIN.accountId); + const merkleRoot = shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__operatorRoles.root(); + + shieldedAccessControl._grantRole(ADMIN.role, ADMIN.accountId); + const newMerkleRoot = shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__operatorRoles.root(); + expect(merkleRoot).toEqual(newMerkleRoot); + }); + }); + }); + + describe('renounceRole', () => { + beforeEach(() => { + shieldedAccessControl._grantRole(ADMIN.role, ADMIN.accountId); + shieldedAccessControl.setPersistentCaller(ADMIN.publicKey); + }); + + it('should allow caller to renounce their own role', () => { + expect(() => + shieldedAccessControl.renounceRole(ADMIN.role, ADMIN.accountId), + ).not.toThrow(); + expect( + shieldedAccessControl._validateRole(ADMIN.role, ADMIN.accountId), + ).toBe(false); + }); + + it('should allow caller to renounce role that does not exist', () => { + // Set ADMIN.secretNonce for UNINITIALIZED role so circuit computes ADMIN.accountId + shieldedAccessControl.privateState.injectSecretNonce( + UNINITIALIZED.role, + ADMIN.secretNonce, + ); + expect(() => + shieldedAccessControl.renounceRole( + UNINITIALIZED.role, + ADMIN.accountId, + ), + ).not.toThrow(); + expect( + shieldedAccessControl._validateRole( + UNINITIALIZED.role, + ADMIN.accountId, + ), + ).toBe(false); + }); + + it('should allow caller to renounce a role they do not have', () => { + // Set ADMIN.secretNonce for OPERATOR_1 role so circuit computes ADMIN.accountId + shieldedAccessControl.privateState.injectSecretNonce( + OPERATOR_1.role, + ADMIN.secretNonce, + ); + expect(() => + shieldedAccessControl.renounceRole(OPERATOR_1.role, ADMIN.accountId), + ).not.toThrow(); + expect( + shieldedAccessControl._validateRole(OPERATOR_1.role, ADMIN.accountId), + ).toBe(false); + }); + + it('should fail when caller provides bad nonce', () => { + shieldedAccessControl.privateState.injectSecretNonce( + ADMIN.role, + BAD_INPUT.secretNonce, + ); + + expect(() => + shieldedAccessControl.renounceRole(ADMIN.role, ADMIN.accountId), + ).toThrow('ShieldedAccessControl: bad confirmation'); + }); + + it('should fail when caller provides bad accountId', () => { + expect(() => + shieldedAccessControl.renounceRole(ADMIN.role, BAD_INPUT.accountId), + ).toThrow('ShieldedAccessControl: bad confirmation'); + }); + + it('should fail when unauthorized caller provides valid nonce, and accountId', () => { + // check we have valid secret nonce in private state + expect( + shieldedAccessControl.privateState.getCurrentSecretNonce(ADMIN.role), + ).toEqual(ADMIN.secretNonce); + + shieldedAccessControl.as(UNAUTHORIZED.publicKey); + expect(() => + shieldedAccessControl.renounceRole(ADMIN.role, ADMIN.accountId), + ).toThrow('ShieldedAccessControl: bad confirmation'); + }); + + it('should be a no-op when role is already revoked', () => { + shieldedAccessControl._revokeRole(ADMIN.role, ADMIN.accountId); + // renounceRole calls _revokeRole internally which silently returns false + // when the role is already revoked — no assertion, so no throw. + expect(() => + shieldedAccessControl.renounceRole(ADMIN.role, ADMIN.accountId), + ).not.toThrow(); + expect( + shieldedAccessControl._validateRole(ADMIN.role, ADMIN.accountId), + ).toBe(false); + }); + + it('should update nullifier set on successful renounce', () => { + const nullifierSetSize = shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__roleCommitmentNullifiers.size(); + expect(nullifierSetSize).toBe(0n); + + shieldedAccessControl.renounceRole(ADMIN.role, ADMIN.accountId); + const updatedSetSize = shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__roleCommitmentNullifiers.size(); + expect(updatedSetSize).toEqual(1n); + expect( + shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__roleCommitmentNullifiers.member( + ADMIN.roleNullifier, + ), + ).toBe(true); + }); + + it('should permanently block re-grant to the same accountId after renounce', () => { + shieldedAccessControl.renounceRole(ADMIN.role, ADMIN.accountId); + + // re-grant with same accountId — nullifier blocks it + shieldedAccessControl._grantRole(ADMIN.role, ADMIN.accountId); + expect( + shieldedAccessControl._validateRole(ADMIN.role, ADMIN.accountId), + ).toBe(false); + }); + + it('should allow re-grant with a new accountId after renounce', () => { + shieldedAccessControl.renounceRole(ADMIN.role, ADMIN.accountId); + + const newNonce = Buffer.alloc(32, 'NEW_ADMIN_NONCE'); + shieldedAccessControl.privateState.injectSecretNonce( + ADMIN.role, + newNonce, + ); + const newAccountId = buildAccountIdHash(ADMIN.zPublicKey, newNonce); + shieldedAccessControl._grantRole(ADMIN.role, newAccountId); + + expect( + shieldedAccessControl._validateRole(ADMIN.role, newAccountId), + ).toBe(true); + }); + }); + + describe('revokeRole', () => { + beforeEach(() => { + shieldedAccessControl._grantRole(ADMIN.role, ADMIN.accountId); + shieldedAccessControl._grantRole(OPERATOR_1.role, OPERATOR_1.accountId); + shieldedAccessControl.setPersistentCaller(ADMIN.publicKey); + }); + + describe('should fail', () => { + it('when caller does not have the admin role', () => { + shieldedAccessControl.as(UNAUTHORIZED.publicKey); + expect(() => + shieldedAccessControl.revokeRole( + OPERATOR_1.role, + OPERATOR_1.accountId, + ), + ).toThrow('ShieldedAccessControl: unauthorized account'); + }); + + it('when wit_getRoleCommitmentPath returns a valid path for a different role, accountId pairing', () => { + shieldedAccessControl._grantRole( + OPERATOR_1.role, + OPERATOR_1.accountId, + ); + // Override witness to return valid path for OPERATOR_1 role commitment + shieldedAccessControl.overrideWitness( + 'wit_getRoleCommitmentPath', + () => { + const privateState = shieldedAccessControl.getPrivateState(); + const operator1MtPath = shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__operatorRoles.findPathForLeaf( + OPERATOR_1.roleCommitment, + ); + if (operator1MtPath) return [privateState, operator1MtPath]; + throw new Error('Merkle tree path should be defined'); + }, + ); + expect(() => { + shieldedAccessControl.revokeRole(ADMIN.role, ADMIN.accountId); + }).toThrow( + 'ShieldedAccessControl: Path must contain leaf matching computed role commitment for the provided role, accountId pairing', + ); + }); + + it('when admin with duplicate roles is revoked', () => { + // create duplicate roles + shieldedAccessControl._grantRole(ADMIN.role, ADMIN.accountId); + shieldedAccessControl._grantRole(ADMIN.role, ADMIN.accountId); + + shieldedAccessControl._revokeRole(ADMIN.role, ADMIN.accountId); + expect(() => + shieldedAccessControl.revokeRole(ADMIN.role, ADMIN.accountId), + ).toThrow('ShieldedAccessControl: unauthorized account'); + }); + + it('when admin role is revoked', () => { + shieldedAccessControl._revokeRole(ADMIN.role, ADMIN.accountId); + expect(() => + shieldedAccessControl.grantRole(ADMIN.role, ADMIN.accountId), + ).toThrow('ShieldedAccessControl: unauthorized account'); + }); + + it('when admin provides bad witness path', () => { + shieldedAccessControl.overrideWitness( + 'wit_getRoleCommitmentPath', + RETURN_BAD_PATH, + ); + expect(() => + shieldedAccessControl.revokeRole(ADMIN.role, ADMIN.accountId), + ).toThrow('ShieldedAccessControl: unauthorized account'); + }); + + it('when non-admin caller has role', () => { + shieldedAccessControl._grantRole( + OPERATOR_1.role, + OPERATOR_1.accountId, + ); + + shieldedAccessControl.as(OPERATOR_1.publicKey); + // OP_1 has role but is not authorized to grant roles to other users + expect(() => + shieldedAccessControl.revokeRole( + OPERATOR_1.role, + OPERATOR_2.accountId, + ), + ).toThrow('ShieldedAccessControl: unauthorized account'); + }); + + it('when caller is admin of a different role', () => { + shieldedAccessControl._setRoleAdmin( + OPERATOR_1.role, + OPERATOR_1.accountId, + ); + shieldedAccessControl.as(OPERATOR_1.publicKey); + expect(() => + shieldedAccessControl.revokeRole(ADMIN.role, ADMIN.accountId), + ).toThrow('ShieldedAccessControl: unauthorized account'); + }); + + it('when admin provides bad nonce', () => { + shieldedAccessControl.privateState.injectSecretNonce( + ADMIN.role, + BAD_INPUT.secretNonce, + ); + expect(() => + shieldedAccessControl.revokeRole(ADMIN.role, ADMIN.accountId), + ).toThrow('ShieldedAccessControl: unauthorized account'); + }); + }); + + describe('should not update _roleCommitmentNullifiers set', () => { + it('when role is re-revoked', () => { + shieldedAccessControl.revokeRole( + OPERATOR_1.role, + OPERATOR_1.accountId, + ); + const nullifierSetSize = shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__roleCommitmentNullifiers.size(); + expect(() => + shieldedAccessControl.revokeRole( + OPERATOR_1.role, + OPERATOR_1.accountId, + ), + ).not.toThrow(); + expect( + shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__roleCommitmentNullifiers.size(), + ).toEqual(nullifierSetSize); + }); + }); + + describe('should revoke role', () => { + it('when caller has the admin role', () => { + expect(() => + shieldedAccessControl.revokeRole( + OPERATOR_1.role, + OPERATOR_1.accountId, + ), + ).not.toThrow(); + expect( + shieldedAccessControl._validateRole( + OPERATOR_1.role, + OPERATOR_1.accountId, + ), + ).toBe(false); + }); + + it('when caller has custom admin role', () => { + // setup test + shieldedAccessControl._grantRole( + OPERATOR_2.role, + OPERATOR_3.accountId, + ); + shieldedAccessControl.privateState.injectSecretNonce( + OPERATOR_1.role, + OPERATOR_1.secretNonce, + ); + // OP_1 is admin of OP_2 role + shieldedAccessControl._setRoleAdmin(OPERATOR_2.role, OPERATOR_1.role); + shieldedAccessControl.as(OPERATOR_1.publicKey); + + expect(() => + shieldedAccessControl.revokeRole( + OPERATOR_2.role, + OPERATOR_3.accountId, + ), + ).not.toThrow(); + expect( + shieldedAccessControl._validateRole( + OPERATOR_2.role, + OPERATOR_3.accountId, + ), + ).toBe(false); + }); + + it('when role does not exist', () => { + // create role commitment that doesn't exist + const commitment = buildRoleCommitmentHash( + UNINITIALIZED.role, + ADMIN.accountId, + ); + + // confirm role commitment not in Merkle tree + const path = shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__operatorRoles.findPathForLeaf(commitment); + expect(path).toBeUndefined(); + + expect(() => + shieldedAccessControl.revokeRole( + UNINITIALIZED.role, + ADMIN.accountId, + ), + ).not.toThrow(); + + expect( + shieldedAccessControl._validateRole( + UNINITIALIZED.role, + ADMIN.accountId, + ), + ).toBe(false); + }); + + it('when revoking role with bad accountId', () => { + expect(() => + shieldedAccessControl.revokeRole(ADMIN.role, BAD_INPUT.accountId), + ).not.toThrow(); + + expect( + shieldedAccessControl._validateRole( + ADMIN.role, + BAD_INPUT.accountId, + ), + ).toBe(false); + }); + + it('when multiple admins of the same role exist', () => { + // setup test + const account1 = buildAccountIdHash( + OPERATOR_1.zPublicKey, + ADMIN.secretNonce, + ); + const account2 = buildAccountIdHash( + OPERATOR_2.zPublicKey, + ADMIN.secretNonce, + ); + const account3 = buildAccountIdHash( + OPERATOR_3.zPublicKey, + ADMIN.secretNonce, + ); + shieldedAccessControl._grantRole(ADMIN.role, account1); + shieldedAccessControl._grantRole(ADMIN.role, account2); + shieldedAccessControl._grantRole(ADMIN.role, account3); + + // check revoke role succeeds as OP and role is valid + shieldedAccessControl.as(OPERATOR_1.publicKey); + expect(() => + shieldedAccessControl.revokeRole(ADMIN.role, account1), + ).not.toThrow(); + expect( + shieldedAccessControl._validateRole(ADMIN.role, account1), + ).toBe(false); + + shieldedAccessControl.as(OPERATOR_2.publicKey); + expect(() => + shieldedAccessControl.revokeRole(ADMIN.role, account2), + ).not.toThrow(); + expect( + shieldedAccessControl._validateRole(ADMIN.role, account2), + ).toBe(false); + + shieldedAccessControl.as(OPERATOR_3.publicKey); + expect(() => + shieldedAccessControl.revokeRole(ADMIN.role, account3), + ).not.toThrow(); + expect( + shieldedAccessControl._validateRole(ADMIN.role, account3), + ).toBe(false); + }); + + it('when admin has multiple roles', () => { + shieldedAccessControl._grantRole(OPERATOR_1.role, ADMIN.accountId); + shieldedAccessControl._grantRole(OPERATOR_2.role, ADMIN.accountId); + shieldedAccessControl._grantRole(OPERATOR_3.role, ADMIN.accountId); + + expect(() => + shieldedAccessControl.revokeRole( + OPERATOR_1.role, + OPERATOR_1.accountId, + ), + ).not.toThrow(); + expect( + shieldedAccessControl._validateRole( + OPERATOR_1.role, + OPERATOR_1.accountId, + ), + ).toBe(false); + }); + + it('when revoking role that does not exist', () => { + expect(() => + shieldedAccessControl.revokeRole( + UNINITIALIZED.role, + UNINITIALIZED.accountId, + ), + ).not.toThrow(); + expect( + shieldedAccessControl._validateRole( + UNINITIALIZED.role, + UNINITIALIZED.accountId, + ), + ).toBe(false); + }); + + it('when admin role is revoked and re-issued with a different accountId', () => { + // setup test + shieldedAccessControl._revokeRole(ADMIN.role, ADMIN.accountId); + const newNonce = Buffer.alloc(32, 'NEW_ADMIN_NONCE'); + shieldedAccessControl.privateState.injectSecretNonce( + ADMIN.role, + newNonce, + ); + const newAccountId = buildAccountIdHash(ADMIN.zPublicKey, newNonce); + shieldedAccessControl._grantRole(ADMIN.role, newAccountId); + + expect(() => { + shieldedAccessControl.revokeRole( + OPERATOR_1.role, + OPERATOR_1.accountId, + ); + }).not.toThrow(); + expect( + shieldedAccessControl._validateRole( + OPERATOR_1.role, + OPERATOR_1.accountId, + ), + ).toBe(false); + }); + + it('when admin self-revokes then cannot further grant or revoke', () => { + shieldedAccessControl.revokeRole(ADMIN.role, ADMIN.accountId); + expect( + shieldedAccessControl._validateRole(ADMIN.role, ADMIN.accountId), + ).toBe(false); + + expect(() => + shieldedAccessControl.grantRole( + OPERATOR_1.role, + OPERATOR_1.accountId, + ), + ).toThrow('ShieldedAccessControl: unauthorized account'); + + expect(() => + shieldedAccessControl.revokeRole( + OPERATOR_1.role, + OPERATOR_1.accountId, + ), + ).toThrow('ShieldedAccessControl: unauthorized account'); + }); + }); + }); + + describe('_revokeRole', () => { + beforeEach(() => { + shieldedAccessControl._grantRole(ADMIN.role, ADMIN.accountId); + shieldedAccessControl.setPersistentCaller(ADMIN.publicKey); + }); + + describe('should return true', () => { + it('when active role is revoked', () => { + // confirm role is active + const isValidRole = shieldedAccessControl._validateRole( + ADMIN.role, + ADMIN.accountId, + ); + expect(isValidRole).toBe(true); + + expect( + shieldedAccessControl._revokeRole(ADMIN.role, ADMIN.accountId), + ).toBe(true); + }); + + it('when an authorized user revokes role', () => { + // Check caller is admin, has admin role + expect( + shieldedAccessControl.getCallerContext().currentZswapLocalState + .coinPublicKey, + ).toEqual(ADMIN.zPublicKey); + expect(shieldedAccessControl.getRoleAdmin(ADMIN.role)).toEqual( + new Uint8Array(ADMIN.role), + ); + expect( + shieldedAccessControl._validateRole(ADMIN.role, ADMIN.accountId), + ).toBe(true); + + expect( + shieldedAccessControl._revokeRole(ADMIN.role, ADMIN.accountId), + ).toBe(true); + }); + + it('when unauthorized user revokes role', () => { + // Check UNAUTHORIZED is not admin + expect(shieldedAccessControl.getRoleAdmin(ADMIN.role)).not.toEqual( + new Uint8Array(UNAUTHORIZED.role), + ); + expect( + shieldedAccessControl._validateRole( + ADMIN.role, + UNAUTHORIZED.accountId, + ), + ).toBe(false); + + // check caller is UNAUTHORIZED user + shieldedAccessControl.as(UNAUTHORIZED.publicKey); + expect( + shieldedAccessControl.getCallerContext().currentZswapLocalState + .coinPublicKey, + ).toEqual(UNAUTHORIZED.zPublicKey); + expect( + shieldedAccessControl._revokeRole(ADMIN.role, ADMIN.accountId), + ).toBe(true); + }); + + it('when revoking role that does not exist', () => { + // create role commitment that doesn't exist + const commitment = buildRoleCommitmentHash( + UNINITIALIZED.role, + ADMIN.accountId, + ); + + // confirm role commitment not in Merkle tree + const path = shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__operatorRoles.findPathForLeaf(commitment); + expect(path).toBeUndefined(); + + expect( + shieldedAccessControl._revokeRole( + UNINITIALIZED.role, + ADMIN.accountId, + ), + ).toBe(true); + }); + + it('when revoking role with bad accountId', () => { + expect( + shieldedAccessControl._revokeRole(ADMIN.role, BAD_INPUT.accountId), + ).toBe(true); + }); + }); + + describe('should update nullifier set', () => { + it('when active role is revoked', () => { + // confirm role is active + const isValidRole = shieldedAccessControl._validateRole( + ADMIN.role, + ADMIN.accountId, + ); + expect(isValidRole).toBe(true); + + const initialSetSize = shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__roleCommitmentNullifiers.size(); + expect(initialSetSize).toBe(0n); + + shieldedAccessControl._revokeRole(ADMIN.role, ADMIN.accountId); + + const updatedSetSize = shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__roleCommitmentNullifiers.size(); + expect(updatedSetSize).toBe(1n); + expect( + shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__roleCommitmentNullifiers.member( + ADMIN.roleNullifier, + ), + ).toBe(true); + }); + + it('when an authorized user revokes role', () => { + // Check caller is admin, has admin role + expect( + shieldedAccessControl.getCallerContext().currentZswapLocalState + .coinPublicKey, + ).toEqual(ADMIN.zPublicKey); + expect(shieldedAccessControl.getRoleAdmin(ADMIN.role)).toEqual( + new Uint8Array(ADMIN.role), + ); + expect( + shieldedAccessControl._validateRole(ADMIN.role, ADMIN.accountId), + ).toBe(true); + + const initialSetSize = shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__roleCommitmentNullifiers.size(); + expect(initialSetSize).toBe(0n); + + shieldedAccessControl._revokeRole(ADMIN.role, ADMIN.accountId); + + const updatedSetSize = shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__roleCommitmentNullifiers.size(); + expect(updatedSetSize).toBe(1n); + expect( + shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__roleCommitmentNullifiers.member( + ADMIN.roleNullifier, + ), + ).toBe(true); + }); + + it('when unauthorized user revokes role', () => { + // Check UNAUTHORIZED is not admin + expect(shieldedAccessControl.getRoleAdmin(ADMIN.role)).not.toEqual( + new Uint8Array(UNAUTHORIZED.role), + ); + expect( + shieldedAccessControl._validateRole( + ADMIN.role, + UNAUTHORIZED.accountId, + ), + ).toBe(false); + + const initialSetSize = shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__roleCommitmentNullifiers.size(); + expect(initialSetSize).toBe(0n); + + // check caller is UNAUTHORIZED user + shieldedAccessControl.as(UNAUTHORIZED.publicKey); + expect( + shieldedAccessControl.getCallerContext().currentZswapLocalState + .coinPublicKey, + ).toEqual(UNAUTHORIZED.zPublicKey); + + shieldedAccessControl._revokeRole(ADMIN.role, ADMIN.accountId); + + const updatedSetSize = shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__roleCommitmentNullifiers.size(); + expect(updatedSetSize).toBe(1n); + expect( + shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__roleCommitmentNullifiers.member( + ADMIN.roleNullifier, + ), + ).toBe(true); + }); + + it('when revoking role that does not exist', () => { + // create role commitment that doesn't exist + const commitment = buildRoleCommitmentHash( + UNINITIALIZED.role, + ADMIN.accountId, + ); + + // confirm role commitment not in Merkle tree + const path = shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__operatorRoles.findPathForLeaf(commitment); + expect(path).toBeUndefined(); + + const initialSetSize = shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__roleCommitmentNullifiers.size(); + expect(initialSetSize).toBe(0n); + + shieldedAccessControl._revokeRole( + UNINITIALIZED.role, + ADMIN.accountId, + ); + + const updatedSetSize = shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__roleCommitmentNullifiers.size(); + expect(updatedSetSize).toBe(1n); + + const nullifier = buildNullifierHash(commitment); + + expect( + shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__roleCommitmentNullifiers.member( + nullifier, + ), + ).toBe(true); + }); + + it('when revoking role with bad accountId', () => { + const initialSetSize = shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__roleCommitmentNullifiers.size(); + expect(initialSetSize).toBe(0n); + + shieldedAccessControl._revokeRole(ADMIN.role, BAD_INPUT.accountId); + + const updatedSetSize = shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__roleCommitmentNullifiers.size(); + expect(updatedSetSize).toBe(1n); + + const commitment = buildRoleCommitmentHash( + ADMIN.role, + BAD_INPUT.accountId, + ); + const nullifier = buildNullifierHash(commitment); + expect( + shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__roleCommitmentNullifiers.member( + nullifier, + ), + ).toBe(true); + }); + }); + + describe('should return false', () => { + it('when authorized user re-revokes role', () => { + // Check caller is admin, has admin role + expect( + shieldedAccessControl.getCallerContext().currentZswapLocalState + .coinPublicKey, + ).toEqual(ADMIN.zPublicKey); + expect(shieldedAccessControl.getRoleAdmin(ADMIN.role)).toEqual( + new Uint8Array(ADMIN.role), + ); + expect( + shieldedAccessControl._validateRole(ADMIN.role, ADMIN.accountId), + ).toBe(true); + + shieldedAccessControl._revokeRole(ADMIN.role, ADMIN.accountId); + expect( + shieldedAccessControl._revokeRole(ADMIN.role, ADMIN.accountId), + ).toBe(false); + }); + + it('when unauthorized user re-revokes role', () => { + // Check UNAUTHORIZED is not admin + expect(shieldedAccessControl.getRoleAdmin(ADMIN.role)).not.toEqual( + new Uint8Array(UNAUTHORIZED.role), + ); + expect( + shieldedAccessControl._validateRole( + ADMIN.role, + UNAUTHORIZED.accountId, + ), + ).toBe(false); + + // revoke as ADMIN + shieldedAccessControl._revokeRole(ADMIN.role, ADMIN.accountId); + + // check caller is UNAUTHORIZED user + shieldedAccessControl.as(UNAUTHORIZED.publicKey); + expect( + shieldedAccessControl.getCallerContext().currentZswapLocalState + .coinPublicKey, + ).toEqual(UNAUTHORIZED.zPublicKey); + expect( + shieldedAccessControl._revokeRole(ADMIN.role, ADMIN.accountId), + ).toBe(false); + }); + }); + + describe('should not update nullifier set', () => { + it('when authorized user re-revokes role', () => { + shieldedAccessControl._revokeRole(ADMIN.role, ADMIN.accountId); + const initialSetSize = shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__roleCommitmentNullifiers.size(); + expect(initialSetSize).toBe(1n); + + // Check caller is admin, doesn't have admin role + expect( + shieldedAccessControl.getCallerContext().currentZswapLocalState + .coinPublicKey, + ).toEqual(ADMIN.zPublicKey); + expect( + shieldedAccessControl._validateRole(ADMIN.role, ADMIN.accountId), + ).toBe(false); + + shieldedAccessControl._revokeRole(ADMIN.role, ADMIN.accountId); + + const updatedSetSize = shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__roleCommitmentNullifiers.size(); + expect(updatedSetSize).toEqual(initialSetSize); + }); + + it('when unauthorized user re-revokes role', () => { + shieldedAccessControl._revokeRole(ADMIN.role, ADMIN.accountId); + const initialSetSize = shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__roleCommitmentNullifiers.size(); + expect(initialSetSize).toBe(1n); + + // Check UNAUTHORIZED is not admin + expect(shieldedAccessControl.getRoleAdmin(ADMIN.role)).not.toEqual( + new Uint8Array(UNAUTHORIZED.role), + ); + expect( + shieldedAccessControl._validateRole( + ADMIN.role, + UNAUTHORIZED.accountId, + ), + ).toBe(false); + + // re-revoke as UNAUTHORIZED + shieldedAccessControl.as(UNAUTHORIZED.publicKey); + shieldedAccessControl._revokeRole(ADMIN.role, ADMIN.accountId); + + const updatedSetSize = shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__roleCommitmentNullifiers.size(); + expect(updatedSetSize).toEqual(initialSetSize); + }); + }); + }); + + describe('_updateRole', () => { + beforeEach(() => { + shieldedAccessControl._grantRole(ADMIN.role, ADMIN.accountId); + }); + + describe('UpdateType.Grant', () => { + describe('should return true', () => { + it('when granting a new role', () => { + expect( + shieldedAccessControl._updateRole( + OPERATOR_1.role, + OPERATOR_1.accountId, + UpdateType.Grant, + ), + ).toBe(true); + }); + + it('when re-granting an active role', () => { + expect( + shieldedAccessControl._updateRole( + ADMIN.role, + ADMIN.accountId, + UpdateType.Grant, + ), + ).toBe(true); + }); + + it('when granting a different accountId for a role whose previous accountId was revoked', () => { + shieldedAccessControl._updateRole( + ADMIN.role, + ADMIN.accountId, + UpdateType.Revoke, + ); + expect( + shieldedAccessControl._updateRole( + ADMIN.role, + OPERATOR_1.accountId, + UpdateType.Grant, + ), + ).toBe(true); + expect( + shieldedAccessControl._validateRole( + ADMIN.role, + OPERATOR_1.accountId, + ), + ).toBe(true); + }); + }); + + describe('should update _operatorRoles merkle tree', () => { + it('when granting a new role', () => { + const initialRoot = shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__operatorRoles.root(); + + shieldedAccessControl._updateRole( + OPERATOR_1.role, + OPERATOR_1.accountId, + UpdateType.Grant, + ); + + const updatedRoot = shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__operatorRoles.root(); + expect(updatedRoot).not.toEqual(initialRoot); + + const path = shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__operatorRoles.findPathForLeaf( + OPERATOR_1.roleCommitment, + ); + expect(path).toBeDefined(); + expect(path?.leaf).toStrictEqual(OPERATOR_1.roleCommitment); + }); + }); + + describe('should return false', () => { + it('when granting a revoked role', () => { + shieldedAccessControl._updateRole( + ADMIN.role, + ADMIN.accountId, + UpdateType.Revoke, + ); + expect( + shieldedAccessControl._updateRole( + ADMIN.role, + ADMIN.accountId, + UpdateType.Grant, + ), + ).toBe(false); + }); + }); + + describe('should not update _operatorRoles merkle tree', () => { + it('when granting a revoked role', () => { + shieldedAccessControl._updateRole( + ADMIN.role, + ADMIN.accountId, + UpdateType.Revoke, + ); + const merkleRoot = shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__operatorRoles.root(); + + shieldedAccessControl._updateRole( + ADMIN.role, + ADMIN.accountId, + UpdateType.Grant, + ); + + const newMerkleRoot = shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__operatorRoles.root(); + expect(merkleRoot).toEqual(newMerkleRoot); + }); + }); + }); + + describe('UpdateType.Revoke', () => { + describe('should return true', () => { + it('when revoking an active role', () => { + expect( + shieldedAccessControl._updateRole( + ADMIN.role, + ADMIN.accountId, + UpdateType.Revoke, + ), + ).toBe(true); + }); + + it('when revoking a role that does not exist', () => { + expect( + shieldedAccessControl._updateRole( + UNINITIALIZED.role, + UNINITIALIZED.accountId, + UpdateType.Revoke, + ), + ).toBe(true); + }); + }); + + describe('should update nullifier set', () => { + it('when revoking an active role', () => { + const initialSetSize = shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__roleCommitmentNullifiers.size(); + expect(initialSetSize).toBe(0n); + + shieldedAccessControl._updateRole( + ADMIN.role, + ADMIN.accountId, + UpdateType.Revoke, + ); + + const updatedSetSize = shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__roleCommitmentNullifiers.size(); + expect(updatedSetSize).toBe(1n); + expect( + shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__roleCommitmentNullifiers.member( + ADMIN.roleNullifier, + ), + ).toBe(true); + }); + }); + + describe('should return false', () => { + it('when re-revoking an already revoked role', () => { + shieldedAccessControl._updateRole( + ADMIN.role, + ADMIN.accountId, + UpdateType.Revoke, + ); + expect( + shieldedAccessControl._updateRole( + ADMIN.role, + ADMIN.accountId, + UpdateType.Revoke, + ), + ).toBe(false); + }); + }); + + describe('should not update nullifier set', () => { + it('when re-revoking an already revoked role', () => { + shieldedAccessControl._updateRole( + ADMIN.role, + ADMIN.accountId, + UpdateType.Revoke, + ); + const initialSetSize = shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__roleCommitmentNullifiers.size(); + expect(initialSetSize).toBe(1n); + + shieldedAccessControl._updateRole( + ADMIN.role, + ADMIN.accountId, + UpdateType.Revoke, + ); + + const updatedSetSize = shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__roleCommitmentNullifiers.size(); + expect(updatedSetSize).toEqual(initialSetSize); + }); + }); + }); + }); + + describe('getRoleAdmin', () => { + it('should return zero bytes (DEFAULT_ADMIN_ROLE) for a role with no admin set', () => { + expect( + shieldedAccessControl.getRoleAdmin(OPERATOR_1.role), + ).toStrictEqual(new Uint8Array(32)); + expect( + shieldedAccessControl.getRoleAdmin(OPERATOR_1.role), + ).toStrictEqual(shieldedAccessControl.DEFAULT_ADMIN_ROLE()); + }); + + it('should return the admin role after _setRoleAdmin', () => { + shieldedAccessControl._setRoleAdmin(OPERATOR_1.role, ADMIN.role); + expect(shieldedAccessControl.getRoleAdmin(OPERATOR_1.role)).toEqual( + new Uint8Array(ADMIN.role), + ); + }); + }); + + describe('_setRoleAdmin', () => { + it('should set admin role', () => { + shieldedAccessControl._setRoleAdmin(OPERATOR_1.role, ADMIN.role); + expect(shieldedAccessControl.getRoleAdmin(OPERATOR_1.role)).toEqual( + new Uint8Array(ADMIN.role), + ); + }); + + it('should update _adminRoles map', () => { + expect( + shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__adminRoles.isEmpty(), + ).toBe(true); + + // setup test + shieldedAccessControl._setRoleAdmin(OPERATOR_1.role, ADMIN.role); + shieldedAccessControl._setRoleAdmin(OPERATOR_2.role, ADMIN.role); + shieldedAccessControl._setRoleAdmin(OPERATOR_3.role, ADMIN.role); + + // check updated state + expect( + shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__adminRoles.isEmpty(), + ).toBe(false); + expect( + shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__adminRoles.size(), + ).toBe(3n); + + // check new values exist + expect( + shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__adminRoles.member(OPERATOR_1.role), + ).toBe(true); + expect( + shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__adminRoles.lookup(OPERATOR_1.role), + ).toEqual(new Uint8Array(ADMIN.role)); + + expect( + shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__adminRoles.member(OPERATOR_2.role), + ).toBe(true); + expect( + shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__adminRoles.lookup(OPERATOR_2.role), + ).toEqual(new Uint8Array(ADMIN.role)); + + expect( + shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__adminRoles.member(OPERATOR_3.role), + ).toBe(true); + expect( + shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__adminRoles.lookup(OPERATOR_3.role), + ).toEqual(new Uint8Array(ADMIN.role)); + }); + + it('should override an existing admin role', () => { + shieldedAccessControl._setRoleAdmin(OPERATOR_1.role, ADMIN.role); + expect(shieldedAccessControl.getRoleAdmin(OPERATOR_1.role)).toEqual( + new Uint8Array(ADMIN.role), + ); + + shieldedAccessControl._setRoleAdmin(OPERATOR_1.role, OPERATOR_2.role); + expect(shieldedAccessControl.getRoleAdmin(OPERATOR_1.role)).toEqual( + new Uint8Array(OPERATOR_2.role), + ); + }); + + it('should return DEFAULT_ADMIN_ROLE when admin is explicitly set to zero bytes', () => { + // Set a custom admin first, then reset to zero bytes (DEFAULT_ADMIN_ROLE) + shieldedAccessControl._setRoleAdmin(OPERATOR_1.role, ADMIN.role); + shieldedAccessControl._setRoleAdmin( + OPERATOR_1.role, + new Uint8Array(32), + ); + + // getRoleAdmin takes the map-lookup path (member is true) but returns zero bytes, + // which should equal DEFAULT_ADMIN_ROLE + expect( + shieldedAccessControl.getRoleAdmin(OPERATOR_1.role), + ).toStrictEqual(new Uint8Array(32)); + expect( + shieldedAccessControl.getRoleAdmin(OPERATOR_1.role), + ).toStrictEqual(shieldedAccessControl.DEFAULT_ADMIN_ROLE()); + }); + + it('should allow a role to be set as its own admin', () => { + shieldedAccessControl._setRoleAdmin(OPERATOR_1.role, OPERATOR_1.role); + expect(shieldedAccessControl.getRoleAdmin(OPERATOR_1.role)).toEqual( + new Uint8Array(OPERATOR_1.role), + ); + }); + }); + + describe('_validateRole', () => { + beforeEach(() => { + shieldedAccessControl._grantRole(ADMIN.role, ADMIN.accountId); + shieldedAccessControl.setPersistentCaller(ADMIN.publicKey); + }); + + it('should fail when wit_getRoleCommitmentPath returns a valid path for a different role, accountId pairing', () => { + shieldedAccessControl._grantRole(OPERATOR_1.role, OPERATOR_1.accountId); + // Override witness to return valid path for OPERATOR_1 role commitment + shieldedAccessControl.overrideWitness( + 'wit_getRoleCommitmentPath', + () => { + const privateState = shieldedAccessControl.getPrivateState(); + const operator1MtPath = shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__operatorRoles.findPathForLeaf( + OPERATOR_1.roleCommitment, + ); + if (operator1MtPath) return [privateState, operator1MtPath]; + throw new Error('Merkle tree path should be defined'); + }, + ); + expect(() => { + shieldedAccessControl._validateRole(ADMIN.role, ADMIN.accountId); + }).toThrow( + 'ShieldedAccessControl: Path must contain leaf matching computed role commitment for the provided role, accountId pairing', + ); + }); + + describe('should return false', () => { + type CheckRoleCases = [ + badRoleId: boolean, + badAccountId: boolean, + args: unknown[], + ]; + const checkedCircuits: CheckRoleCases[] = [ + [false, true, [ADMIN.role, BAD_INPUT.accountId]], + [true, false, [BAD_INPUT.role, ADMIN.accountId]], + [false, false, [BAD_INPUT.role, BAD_INPUT.accountId]], + ]; + + it.each( + checkedCircuits, + )('when badRoleId=%s badAccountId=%s', (_badRoleId, _badAccountId, args) => { + // Test protected circuit + expect( + ( + shieldedAccessControl._validateRole as ( + ...args: unknown[] + ) => boolean + )(...args), + ).toBe(false); + }); + + it('when role does not exist', () => { + expect( + shieldedAccessControl._validateRole( + UNINITIALIZED.role, + ADMIN.accountId, + ), + ).toBe(false); + }); + + it('when revoked role is re-issued to the same accountId', () => { + shieldedAccessControl._revokeRole(ADMIN.role, ADMIN.accountId); + shieldedAccessControl._grantRole(ADMIN.role, ADMIN.accountId); + expect( + shieldedAccessControl._validateRole(ADMIN.role, ADMIN.accountId), + ).toBe(false); + }); + + it('when role is revoked, ', () => { + shieldedAccessControl._revokeRole(ADMIN.role, ADMIN.accountId); + const roleCheck = shieldedAccessControl._validateRole( + ADMIN.role, + ADMIN.accountId, + ); + expect(roleCheck).toBe(false); + }); + + it('when invalid witness is provided for a legitimately credentialed user', () => { + shieldedAccessControl.overrideWitness( + 'wit_getRoleCommitmentPath', + RETURN_BAD_PATH, + ); + expect( + shieldedAccessControl._validateRole(ADMIN.role, ADMIN.accountId), + ).toBe(false); + }); + + // an invalid witness should not violate the security invariant: revoked roles + // are permanent + it('when an invalid witness is provided for a revoked role', () => { + shieldedAccessControl._revokeRole(ADMIN.role, ADMIN.accountId); + shieldedAccessControl.overrideWitness( + 'wit_getRoleCommitmentPath', + RETURN_BAD_PATH, + ); + expect( + shieldedAccessControl._validateRole(ADMIN.role, ADMIN.accountId), + ).toBe(false); + }); + }); + + describe('should return true', () => { + it('when role is granted', () => { + expect( + shieldedAccessControl._validateRole(ADMIN.role, ADMIN.accountId), + ).toBe(true); + }); + + it('when accountId has multiple roles', () => { + shieldedAccessControl._grantRole(OPERATOR_1.role, ADMIN.accountId); + shieldedAccessControl._grantRole(OPERATOR_2.role, ADMIN.accountId); + shieldedAccessControl._grantRole(OPERATOR_3.role, ADMIN.accountId); + + expect( + shieldedAccessControl._validateRole(ADMIN.role, ADMIN.accountId), + ).toBe(true); + expect( + shieldedAccessControl._validateRole( + OPERATOR_1.role, + ADMIN.accountId, + ), + ).toBe(true); + expect( + shieldedAccessControl._validateRole( + OPERATOR_2.role, + ADMIN.accountId, + ), + ).toBe(true); + expect( + shieldedAccessControl._validateRole( + OPERATOR_3.role, + ADMIN.accountId, + ), + ).toBe(true); + }); + + it('when role is revoked and re-issued with a different accountId', () => { + shieldedAccessControl._revokeRole(ADMIN.role, ADMIN.accountId); + + shieldedAccessControl.privateState.injectSecretNonce( + ADMIN.role, + Buffer.alloc(32, 'NEW_ADMIN_NONCE'), + ); + const newAdminAccountId = buildAccountIdHash( + ADMIN.zPublicKey, + shieldedAccessControl.privateState.getCurrentSecretNonce( + ADMIN.role, + ), + ); + expect(newAdminAccountId).not.toEqual(ADMIN.accountId); + + shieldedAccessControl._grantRole(ADMIN.role, newAdminAccountId); + expect( + shieldedAccessControl._validateRole(ADMIN.role, newAdminAccountId), + ).toBe(true); + }); + + it('when multiple users have the same role', () => { + // All users will use OPERATOR_1.secretNonce as their nonce value + // when generating their accountId for simplicity + shieldedAccessControl.privateState.injectSecretNonce( + OPERATOR_1.role, + OPERATOR_1.secretNonce, + ); + // A unique accountId must be constructed for each new role using its associated secretNonce + const operator1AdminAccountId = buildAccountIdHash( + ADMIN.zPublicKey, + OPERATOR_1.secretNonce, + ); + shieldedAccessControl._grantRole( + OPERATOR_1.role, + operator1AdminAccountId, + ); + shieldedAccessControl.as(ADMIN.publicKey); // assert ADMIN has OP_1 role + expect( + shieldedAccessControl._validateRole( + OPERATOR_1.role, + operator1AdminAccountId, + ), + ).toBe(true); + + const operator1Op2AccountId = buildAccountIdHash( + OPERATOR_2.zPublicKey, + OPERATOR_1.secretNonce, + ); + shieldedAccessControl._grantRole( + OPERATOR_1.role, + operator1Op2AccountId, + ); + shieldedAccessControl.as(OPERATOR_2.publicKey); // assert OP_2 has OP_1 role + expect( + shieldedAccessControl._validateRole( + OPERATOR_1.role, + operator1Op2AccountId, + ), + ).toBe(true); + + const operator1Op3AccountId = buildAccountIdHash( + OPERATOR_3.zPublicKey, + OPERATOR_1.secretNonce, + ); + shieldedAccessControl._grantRole( + OPERATOR_1.role, + operator1Op3AccountId, + ); + shieldedAccessControl.as(OPERATOR_3.publicKey); // assert OP_3 has OP_1 role + expect( + shieldedAccessControl._validateRole( + OPERATOR_1.role, + operator1Op3AccountId, + ), + ).toBe(true); + }); + }); + }); + + describe('computeRoleCommitment', () => { + it('should match computed commitment', () => { + expect( + shieldedAccessControl.computeRoleCommitment( + ADMIN.role, + ADMIN.accountId, + ), + ).toEqual(ADMIN.roleCommitment); + }); + + type ComputeCommitmentCases = [ + isValidRoleId: boolean, + isValidAccountId: boolean, + args: unknown[], + ]; + + const checkedCircuits: ComputeCommitmentCases[] = [ + [false, true, [BAD_INPUT.role, ADMIN.accountId]], + [true, false, [ADMIN.role, BAD_INPUT.accountId]], + [false, false, [BAD_INPUT.role, BAD_INPUT.accountId]], + ]; + + it.each( + checkedCircuits, + )('should not compute commitment with isValidRoleId=%s, isValidAccountId=%s', (_isValidRoleId, _isValidAccountId, args) => { + // Test protected circuit + expect( + ( + shieldedAccessControl.computeRoleCommitment as ( + ...args: unknown[] + ) => Uint8Array + )(...args), + ).not.toEqual(ADMIN.roleCommitment); + }); + + it('should produce a different commitment for the same (role, accountId) when instanceSalt differs', () => { + const differentSalt = new Uint8Array(32).fill(1); + const otherInstance = new ShieldedAccessControlSimulator( + differentSalt, + true, + { + privateState: ShieldedAccessControlPrivateState.withRoleAndNonce( + ADMIN.role, + ADMIN.secretNonce, + ), + }, + ); + + const commitment1 = shieldedAccessControl.computeRoleCommitment( + ADMIN.role, + ADMIN.accountId, + ); + const commitment2 = otherInstance.computeRoleCommitment( + ADMIN.role, + ADMIN.accountId, + ); + expect(commitment1).not.toEqual(commitment2); + }); + }); + + describe('computeNullifier', () => { + it('should match nullifier', () => { + expect( + shieldedAccessControl.computeNullifier(ADMIN.roleCommitment), + ).toEqual(ADMIN.roleNullifier); + }); + + it('should not match bad commitment inputs', () => { + expect( + shieldedAccessControl.computeNullifier(BAD_INPUT.roleCommitment), + ).not.toEqual(ADMIN.roleNullifier); + }); + }); + + describe('_computeAccountId', () => { + beforeEach(() => { + shieldedAccessControl.setPersistentCaller(ADMIN.publicKey); + }); + + it('should match when authorized caller with correct nonce', () => { + expect(shieldedAccessControl._computeAccountId(ADMIN.role)).toEqual( + ADMIN.accountId, + ); + }); + + it('should not match when authorized caller with bad nonce', () => { + shieldedAccessControl.privateState.injectSecretNonce( + ADMIN.role, + BAD_INPUT.secretNonce, + ); + const computedAccountId = shieldedAccessControl._computeAccountId( + ADMIN.role, + ); + expect(computedAccountId).not.toEqual(ADMIN.accountId); + expect(computedAccountId).toEqual( + buildAccountIdHash(ADMIN.zPublicKey, BAD_INPUT.secretNonce), + ); + }); + + it('should not match when unauthorized caller with correct nonce', () => { + shieldedAccessControl.as(UNAUTHORIZED.publicKey); + const computedAccountId = shieldedAccessControl._computeAccountId( + ADMIN.role, + ); + expect(computedAccountId).not.toEqual(ADMIN.accountId); + expect(computedAccountId).toEqual( + buildAccountIdHash(UNAUTHORIZED.zPublicKey, ADMIN.secretNonce), + ); + }); + }); + + describe('computeAccountId', () => { + it('should match when given correct account and nonce', () => { + expect( + shieldedAccessControl.computeAccountId( + ADMIN.zPublicKey, + ADMIN.secretNonce, + INSTANCE_SALT, + ), + ).toEqual(ADMIN.accountId); + }); + + it('should not match when given correct account with bad nonce', () => { + const computedAccountId = shieldedAccessControl.computeAccountId( + ADMIN.zPublicKey, + BAD_INPUT.secretNonce, + INSTANCE_SALT, + ); + expect(computedAccountId).not.toEqual(ADMIN.accountId); + expect(computedAccountId).toEqual( + buildAccountIdHash(ADMIN.zPublicKey, BAD_INPUT.secretNonce), + ); + }); + + it('should not match when given unauthorized account with correct nonce', () => { + const computedAccountId = shieldedAccessControl.computeAccountId( + UNAUTHORIZED.zPublicKey, + ADMIN.secretNonce, + INSTANCE_SALT, + ); + expect(computedAccountId).not.toEqual(ADMIN.accountId); + expect(computedAccountId).toEqual( + buildAccountIdHash(UNAUTHORIZED.zPublicKey, ADMIN.secretNonce), + ); + }); + + it('should produce a different accountId for the same (account, nonce) when instanceSalt differs', () => { + const differentSalt = new Uint8Array(32).fill(1); + const accountId1 = shieldedAccessControl.computeAccountId( + ADMIN.zPublicKey, + ADMIN.secretNonce, + INSTANCE_SALT, + ); + const accountId2 = shieldedAccessControl.computeAccountId( + ADMIN.zPublicKey, + ADMIN.secretNonce, + differentSalt, + ); + expect(accountId1).not.toEqual(accountId2); + }); + + it('should succeed when secretNonce is zero bytes', () => { + const zeroNonce = new Uint8Array(32); + expect( + shieldedAccessControl.computeAccountId( + ADMIN.zPublicKey, + zeroNonce, + INSTANCE_SALT, + ), + ).toEqual(buildAccountIdHash(ADMIN.zPublicKey, zeroNonce)); + }); + }); + }); +}); diff --git a/contracts/src/access/test/mocks/MockAccessControl.compact b/contracts/src/access/test/mocks/MockAccessControl.compact index 273121cc..57695020 100644 --- a/contracts/src/access/test/mocks/MockAccessControl.compact +++ b/contracts/src/access/test/mocks/MockAccessControl.compact @@ -1,5 +1,10 @@ // SPDX-License-Identifier: MIT +// WARNING: FOR TESTING PURPOSES ONLY. +// This contract exposes internal circuits and bypasses safety checks that the +// corresponding production contract relies on. DO NOT deploy or use this +// contract in any production application. + pragma language_version >= 0.21.0; import CompactStandardLibrary; diff --git a/contracts/src/access/test/mocks/MockOwnable.compact b/contracts/src/access/test/mocks/MockOwnable.compact index ebbc6110..85ac844f 100644 --- a/contracts/src/access/test/mocks/MockOwnable.compact +++ b/contracts/src/access/test/mocks/MockOwnable.compact @@ -1,5 +1,10 @@ // SPDX-License-Identifier: MIT +// WARNING: FOR TESTING PURPOSES ONLY. +// This contract exposes internal circuits and bypasses safety checks that the +// corresponding production contract relies on. DO NOT deploy or use this +// contract in any production application. + pragma language_version >= 0.21.0; import CompactStandardLibrary; diff --git a/contracts/src/access/test/mocks/MockShieldedAccessControl.compact b/contracts/src/access/test/mocks/MockShieldedAccessControl.compact new file mode 100644 index 00000000..2f808ba4 --- /dev/null +++ b/contracts/src/access/test/mocks/MockShieldedAccessControl.compact @@ -0,0 +1,190 @@ +// SPDX-License-Identifier: MIT + +// WARNING: FOR TESTING PURPOSES ONLY. +// This contract exposes internal circuits and bypasses safety checks that the +// corresponding production contract relies on. DO NOT deploy or use this +// contract in any production application. + +pragma language_version >= 0.21.0; + +import CompactStandardLibrary; + +import "../../ShieldedAccessControl" prefix ShieldedAccessControl_; + +import "../../../security/Initializable" prefix Initializable_; + +export { ZswapCoinPublicKey, + ContractAddress, + Either, + Maybe, + MerkleTreePath, + ShieldedAccessControl_DEFAULT_ADMIN_ROLE, + ShieldedAccessControl__operatorRoles, + ShieldedAccessControl__roleCommitmentNullifiers, + ShieldedAccessControl__adminRoles, + ShieldedAccessControl_UpdateType }; + +// witnesses are re-implemented in the Mock contract for testing +witness wit_getRoleCommitmentPath( + roleCommitment: ShieldedAccessControl_RoleCommitment + ): MerkleTreePath<20, ShieldedAccessControl_RoleCommitment>; + +witness wit_secretNonce(role: ShieldedAccessControl_RoleIdentifier): Bytes<32>; + +/** + * @description `isInit` is a param for testing. + * + * If `isInit` is false, the constructor will not initialize the contract. + * This behavior is to test that circuits are not callable unless the + * contract is initialized. +*/ +constructor(instanceSalt: Bytes<32>, isInit: Boolean) { + if (disclose(isInit)) { + ShieldedAccessControl_initialize(instanceSalt); + } +} + +export pure circuit DEFAULT_ADMIN_ROLE(): ShieldedAccessControl_RoleIdentifier { + return ShieldedAccessControl_DEFAULT_ADMIN_ROLE(); +} + +export circuit assertOnlyRole(role: ShieldedAccessControl_RoleIdentifier): [] { + ShieldedAccessControl_assertOnlyRole(role); +} + +export circuit canProveRole(role: ShieldedAccessControl_RoleIdentifier): Boolean { + return ShieldedAccessControl_canProveRole(role); +} + +// _uncheckCanProveRole is re-implemented in the Mock contract for testing +export circuit _uncheckedCanProveRole(role: ShieldedAccessControl_RoleIdentifier): Boolean { + const accountId = _computeAccountId(role); + return _validateRole(role, accountId); +} + +export circuit grantRole( + role: ShieldedAccessControl_RoleIdentifier, + accountId: ShieldedAccessControl_AccountIdentifier + ): [] { + ShieldedAccessControl_grantRole(role, accountId); +} + +export circuit _grantRole( + role: ShieldedAccessControl_RoleIdentifier, + accountId: ShieldedAccessControl_AccountIdentifier + ): Boolean { + return ShieldedAccessControl__grantRole(role, accountId); +} + +export circuit renounceRole( + role: ShieldedAccessControl_RoleIdentifier, + accountIdConfirmation: ShieldedAccessControl_AccountIdentifier + ): [] { + ShieldedAccessControl_renounceRole(role, accountIdConfirmation); +} + +export circuit revokeRole( + role: ShieldedAccessControl_RoleIdentifier, + accountId: ShieldedAccessControl_AccountIdentifier + ): [] { + ShieldedAccessControl_revokeRole(role, accountId); +} + +export circuit _revokeRole( + role: ShieldedAccessControl_RoleIdentifier, + accountId: ShieldedAccessControl_AccountIdentifier + ): Boolean { + return ShieldedAccessControl__revokeRole(role, accountId); +} + +// _updateRole is re-implemented in the Mock contract for testing +export circuit _updateRole( + role: ShieldedAccessControl_RoleIdentifier, + accountId: ShieldedAccessControl_AccountIdentifier, + updateType: ShieldedAccessControl_UpdateType + ): Boolean { + const roleCommitment = computeRoleCommitment(role, accountId); + const roleNullifier = computeNullifier(roleCommitment); + const isRevoked = ShieldedAccessControl__roleCommitmentNullifiers.member(disclose(roleNullifier)); + + if (isRevoked) { + return false; + } + + // disclosure only necessary here because we’re exporting the circuit for testing + if (disclose(updateType) == ShieldedAccessControl_UpdateType.Grant) { + ShieldedAccessControl__operatorRoles.insert(disclose(roleCommitment)); + } else { + ShieldedAccessControl__roleCommitmentNullifiers.insert(disclose(roleNullifier)); + } + + return true; +} + +export circuit getRoleAdmin( + role: ShieldedAccessControl_RoleIdentifier + ): ShieldedAccessControl_RoleIdentifier { + return ShieldedAccessControl_getRoleAdmin(role); +} + +export circuit _setRoleAdmin( + role: ShieldedAccessControl_RoleIdentifier, + adminRole: ShieldedAccessControl_RoleIdentifier + ): [] { + ShieldedAccessControl__setRoleAdmin(role, adminRole); +} + +// _validateRole is re-implemented in the Mock contract for testing +export circuit _validateRole( + role: ShieldedAccessControl_RoleIdentifier, + accountId: ShieldedAccessControl_AccountIdentifier + ): Boolean { + const roleCommitment = computeRoleCommitment(role, accountId); + const roleCommitmentPath = wit_getRoleCommitmentPath(roleCommitment); + const isValidPath = + ShieldedAccessControl__operatorRoles.checkRoot( + merkleTreePathRoot<20, ShieldedAccessControl_RoleCommitment>( + disclose(roleCommitmentPath) + ) + ); + + // If the path is valid we assert it’s a path for the queried leaf (not some other leaf). + if (isValidPath) { + assert(roleCommitmentPath.leaf == roleCommitment, + "ShieldedAccessControl: Path must contain leaf matching computed role commitment for the provided role, accountId pairing" + ); + } + + const roleNullifier = computeNullifier(roleCommitment); + const isRevoked = ShieldedAccessControl__roleCommitmentNullifiers.member(disclose(roleNullifier)); + + return isValidPath && !isRevoked; +} + +export circuit computeRoleCommitment( + role: ShieldedAccessControl_RoleIdentifier, + accountId: ShieldedAccessControl_AccountIdentifier + ): ShieldedAccessControl_RoleCommitment { + + return ShieldedAccessControl_computeRoleCommitment(role, accountId); +} + +export pure circuit computeNullifier( + roleCommitment: ShieldedAccessControl_RoleCommitment + ): ShieldedAccessControl_RoleNullifier { + return ShieldedAccessControl_computeNullifier(roleCommitment); +} + +// circuit is reimplemented in the Mock contract for testing +export circuit _computeAccountId(role: ShieldedAccessControl_RoleIdentifier): ShieldedAccessControl_AccountIdentifier { + return disclose(ShieldedAccessControl_computeAccountId(ownPublicKey(), wit_secretNonce(role), ShieldedAccessControl__instanceSalt)); +} + +export pure circuit computeAccountId( + account: ZswapCoinPublicKey, + secretNonce: Bytes<32>, + instanceSalt: Bytes<32> + ): ShieldedAccessControl_AccountIdentifier { + + return ShieldedAccessControl_computeAccountId(account, secretNonce, instanceSalt); +} diff --git a/contracts/src/access/test/mocks/MockZOwnablePK.compact b/contracts/src/access/test/mocks/MockZOwnablePK.compact index e0e5e18a..41657f1d 100644 --- a/contracts/src/access/test/mocks/MockZOwnablePK.compact +++ b/contracts/src/access/test/mocks/MockZOwnablePK.compact @@ -1,5 +1,10 @@ // SPDX-License-Identifier: MIT +// WARNING: FOR TESTING PURPOSES ONLY. +// This contract exposes internal circuits and bypasses safety checks that the +// corresponding production contract relies on. DO NOT deploy or use this +// contract in any production application. + pragma language_version >= 0.21.0; import CompactStandardLibrary; diff --git a/contracts/src/access/test/simulators/ShieldedAccessControlSimulator.ts b/contracts/src/access/test/simulators/ShieldedAccessControlSimulator.ts new file mode 100644 index 00000000..6aa533aa --- /dev/null +++ b/contracts/src/access/test/simulators/ShieldedAccessControlSimulator.ts @@ -0,0 +1,191 @@ +import type { MerkleTreePath } from '@midnight-ntwrk/compact-runtime'; +import { + type BaseSimulatorOptions, + createSimulator, +} from '@openzeppelin-compact/contracts-simulator'; +import { + ledger, + Contract as MockShieldedAccessControl, + type ShieldedAccessControl_UpdateType as UpdateType, + type ZswapCoinPublicKey, +} from '../../../../artifacts/MockShieldedAccessControl/contract/index.js'; +import { + ShieldedAccessControlPrivateState, + ShieldedAccessControlWitnesses, +} from '../../witnesses/ShieldedAccessControlWitnesses.js'; + +type ShieldedAccessControlLedger = ReturnType; + +/** + * Type constructor args + */ +type ShieldedAccessControlArgs = readonly [ + instanceSalt: Uint8Array, + isInit: boolean, +]; + +const ShieldedAccessControlSimulatorBase = createSimulator< + ShieldedAccessControlPrivateState, + ReturnType, + ReturnType, + MockShieldedAccessControl, + ShieldedAccessControlArgs +>({ + contractFactory: (witnesses) => + new MockShieldedAccessControl(witnesses), + defaultPrivateState: () => ShieldedAccessControlPrivateState.generate(), + contractArgs: (instanceSalt, isInit) => { + return [instanceSalt, isInit]; + }, + ledgerExtractor: (state) => ledger(state), + witnessesFactory: () => + ShieldedAccessControlWitnesses(), +}); + +/** + * ShieldedAccessControlSimulator + */ +export class ShieldedAccessControlSimulator extends ShieldedAccessControlSimulatorBase { + constructor( + instanceSalt: Uint8Array, + isInit: boolean, + options: BaseSimulatorOptions< + ShieldedAccessControlPrivateState, + ReturnType + > = {}, + ) { + super([instanceSalt, isInit], options); + } + + public DEFAULT_ADMIN_ROLE(): Uint8Array { + return this.circuits.pure.DEFAULT_ADMIN_ROLE(); + } + + public assertOnlyRole(role: Uint8Array) { + this.circuits.impure.assertOnlyRole(role); + } + + public canProveRole(role: Uint8Array): boolean { + return this.circuits.impure.canProveRole(role); + } + + public _uncheckedCanProveRole(role: Uint8Array): boolean { + return this.circuits.impure._uncheckedCanProveRole(role); + } + + public grantRole(role: Uint8Array, accountId: Uint8Array) { + this.circuits.impure.grantRole(role, accountId); + } + + public _grantRole(role: Uint8Array, accountId: Uint8Array): boolean { + return this.circuits.impure._grantRole(role, accountId); + } + + public renounceRole(role: Uint8Array, callerConfirmation: Uint8Array) { + this.circuits.impure.renounceRole(role, callerConfirmation); + } + + public revokeRole(role: Uint8Array, accountId: Uint8Array) { + this.circuits.impure.revokeRole(role, accountId); + } + + public _revokeRole(role: Uint8Array, accountId: Uint8Array): boolean { + return this.circuits.impure._revokeRole(role, accountId); + } + + public _updateRole( + role: Uint8Array, + accountId: Uint8Array, + updateType: UpdateType, + ) { + return this.circuits.impure._updateRole(role, accountId, updateType); + } + + public getRoleAdmin(role: Uint8Array): Uint8Array { + return this.circuits.impure.getRoleAdmin(role); + } + + public _setRoleAdmin(role: Uint8Array, adminRole: Uint8Array) { + this.circuits.impure._setRoleAdmin(role, adminRole); + } + + public _validateRole(role: Uint8Array, accountId: Uint8Array): boolean { + return this.circuits.impure._validateRole(role, accountId); + } + + public computeRoleCommitment( + role: Uint8Array, + accountId: Uint8Array, + ): Uint8Array { + return this.circuits.impure.computeRoleCommitment(role, accountId); + } + + public computeNullifier(roleCommitment: Uint8Array): Uint8Array { + return this.circuits.pure.computeNullifier(roleCommitment); + } + + public _computeAccountId(role: Uint8Array): Uint8Array { + return this.circuits.impure._computeAccountId(role); + } + + public computeAccountId( + account: ZswapCoinPublicKey, + secretNonce: Uint8Array, + instanceSalt: Uint8Array, + ): Uint8Array { + return this.circuits.pure.computeAccountId( + account, + secretNonce, + instanceSalt, + ); + } + + public readonly privateState = { + /** + * @description Contextually sets a new nonce into the private state. + * @param newNonce The secret nonce. + * @returns The ShieldedAccessControl private state after setting the new nonce. + */ + injectSecretNonce: ( + role: Uint8Array, + newNonce: Buffer, + ): ShieldedAccessControlPrivateState => { + const currentState = this.getPrivateState(); + const updatedState = { + roles: { ...currentState.roles }, + }; + const roleString = Buffer.from(role).toString('hex'); + updatedState.roles[roleString] = newNonce; + this.circuitContextManager.updatePrivateState(updatedState); + return updatedState; + }, + + /** + * @description Returns the secret nonce for a given role. + * @returns The secret nonce. + */ + getCurrentSecretNonce: (role: Uint8Array): Uint8Array => { + const roleString = Buffer.from(role).toString('hex'); + const roleNonce = this.getPrivateState().roles[roleString]; + if (typeof roleNonce === 'undefined') { + throw new Error(`Missing secret nonce for role ${roleString}`); + } + return roleNonce; + }, + getCommitmentPathWithFindForLeaf: ( + roleCommitment: Uint8Array, + ): MerkleTreePath | undefined => { + return this.getPublicState().ShieldedAccessControl__operatorRoles.findPathForLeaf( + roleCommitment, + ); + }, + getCommitmentPathWithWitnessImpl: ( + roleCommitment: Uint8Array, + ): MerkleTreePath => { + return this.witnesses.wit_getRoleCommitmentPath( + this.getWitnessContext(), + roleCommitment, + )[1]; + }, + }; +} diff --git a/contracts/src/access/test/simulators/ZOwnablePKSimulator.ts b/contracts/src/access/test/simulators/ZOwnablePKSimulator.ts index 5dd24cf9..b1227ee1 100644 --- a/contracts/src/access/test/simulators/ZOwnablePKSimulator.ts +++ b/contracts/src/access/test/simulators/ZOwnablePKSimulator.ts @@ -14,15 +14,16 @@ import { ZOwnablePKWitnesses, } from '../../witnesses/ZOwnablePKWitnesses.js'; -/** - * Type constructor args - */ +/** Type constructor args */ type ZOwnablePKArgs = readonly [ owner: Uint8Array, instanceSalt: Uint8Array, isInit: boolean, ]; +/** Concrete ledger type extracted from the generated artifact */ +type ZOwnablePKLedger = ReturnType; + /** * Base simulator * @dev We deliberately use `any` as the base simulator type. @@ -47,7 +48,7 @@ const ZOwnablePKSimulatorBase: any = createSimulator< return [owner, instanceSalt, isInit]; }, ledgerExtractor: (state) => ledger(state), - witnessesFactory: () => ZOwnablePKWitnesses(), + witnessesFactory: () => ZOwnablePKWitnesses(), }); /** diff --git a/contracts/src/access/witnesses/ShieldedAccessControlWitnesses.ts b/contracts/src/access/witnesses/ShieldedAccessControlWitnesses.ts new file mode 100644 index 00000000..ab4be5c9 --- /dev/null +++ b/contracts/src/access/witnesses/ShieldedAccessControlWitnesses.ts @@ -0,0 +1,146 @@ +import { getRandomValues } from 'node:crypto'; +import type { + MerkleTreePath, + WitnessContext, +} from '@midnight-ntwrk/compact-runtime'; + +/** + * @description Interface defining the witness methods for ShieldedAccessControl operations + * @template L - The ledger type. + * @template P - The private state type. + */ +export interface IShieldedAccessControlWitnesses { + /** + * Retrieves the secret nonce from the private state. + * @param context - The witness context containing the private state. + * @returns A tuple of the private state and the secret nonce as a Uint8Array. + */ + wit_secretNonce( + context: WitnessContext, + role: Uint8Array, + ): [P, Uint8Array]; + wit_getRoleCommitmentPath( + context: WitnessContext, + roleCommitment: Uint8Array, + ): [P, MerkleTreePath]; +} + +type Role = string; +type SecretNonce = Uint8Array; + +/** + * @description Represents the private state of a Shielded AccessControl contract, storing + * mappings from a 32 byte hex string to a 32 byte secret nonce. + */ +export type ShieldedAccessControlPrivateState = { + /** @description A 32-byte secret nonce used as a privacy additive. */ + roles: Record; +}; + +/** + * @description Utility object for managing the private state of a Shielded AccessControl contract. + */ +export const ShieldedAccessControlPrivateState = { + /** + * @description Generates a new private state with a random secret nonce and a default role of 0. + * @returns A fresh ShieldedAccessControlPrivateState instance. + */ + generate: (): ShieldedAccessControlPrivateState => { + const defaultRoleId: string = Buffer.alloc(32).toString('hex'); + const secretNonce = new Uint8Array(getRandomValues(Buffer.alloc(32))); + + return { roles: { [defaultRoleId]: secretNonce } }; + }, + + /** + * @description Generates a new private state with a user-defined secret nonce. + * Useful for deterministic nonce generation or advanced use cases. + * + * @param nonce - The 32-byte secret nonce to use. + * @returns A fresh ShieldedAccessControlPrivateState instance with the provided nonce. + * + * @example + * ```typescript + * // For deterministic nonces (user-defined scheme) + * const deterministicNonce = myDeterministicScheme(...); + * const privateState = ShieldedAccessControlPrivateState.withNonce(deterministicNonce); + * ``` + */ + withRoleAndNonce: ( + role: Buffer, + nonce: Buffer, + ): ShieldedAccessControlPrivateState => { + const roleString = role.toString('hex'); + return { roles: { [roleString]: nonce } }; + }, + + setRole: ( + privateState: ShieldedAccessControlPrivateState, + role: Buffer, + nonce: Buffer, + ): ShieldedAccessControlPrivateState => { + const roleString = role.toString('hex'); + const roles: Record = {}; + + for (const [k, v] of Object.entries(privateState.roles)) { + if (typeof v === 'undefined') { + throw new Error(`Missing secret nonce for role ${k}`); + } + roles[k] = new Uint8Array(v); + } + + roles[roleString] = new Uint8Array(nonce); + return { roles }; + }, + + getRoleCommitmentPath: ( + ledger: L, + roleCommitment: Uint8Array, + ): MerkleTreePath => { + const path = + // cast ledger as any to avoid type gymnastics + (ledger as any).ShieldedAccessControl__operatorRoles.findPathForLeaf( + roleCommitment, + ); + const defaultPath = { + leaf: new Uint8Array(32), + path: Array.from({ length: 20 }, () => ({ + sibling: { field: 0n }, + goes_left: false, + })), + }; + return path ? path : defaultPath; + }, +}; + +/** + * @description Factory function creating witness implementations for Shielded AccessControl operations. + * @returns An object implementing the Witnesses interface for ShieldedAccessControlPrivateState. + */ +export const ShieldedAccessControlWitnesses = < + L, +>(): IShieldedAccessControlWitnesses => ({ + wit_secretNonce( + context: WitnessContext, + role: Uint8Array, + ): [ShieldedAccessControlPrivateState, Uint8Array] { + const roleString = Buffer.from(role).toString('hex'); + const roleNonce = context.privateState.roles[roleString]; + if (typeof roleNonce === 'undefined') { + throw new Error(`Missing secret nonce for role ${roleString}`); + } + return [context.privateState, roleNonce]; + }, + wit_getRoleCommitmentPath( + context: WitnessContext, + roleCommitment: Uint8Array, + ): [ShieldedAccessControlPrivateState, MerkleTreePath] { + return [ + context.privateState, + ShieldedAccessControlPrivateState.getRoleCommitmentPath( + context.ledger, + roleCommitment, + ), + ]; + }, +}); diff --git a/contracts/src/access/witnesses/ZOwnablePKWitnesses.ts b/contracts/src/access/witnesses/ZOwnablePKWitnesses.ts index ac2659fb..215fcdae 100644 --- a/contracts/src/access/witnesses/ZOwnablePKWitnesses.ts +++ b/contracts/src/access/witnesses/ZOwnablePKWitnesses.ts @@ -1,18 +1,18 @@ import { getRandomValues } from 'node:crypto'; import type { WitnessContext } from '@midnight-ntwrk/compact-runtime'; -import type { Ledger } from '../../../artifacts/MockZOwnablePK/contract/index.js'; /** * @description Interface defining the witness methods for ZOwnablePK operations. + * @template L - The ledger type. * @template P - The private state type. */ -export interface IZOwnablePKWitnesses

{ +export interface IZOwnablePKWitnesses { /** * Retrieves the secret nonce from the private state. - * @param context - The witness context containing the private state. + * @param context - The witness context containing the ledger and private state. * @returns A tuple of the private state and the secret nonce as a Uint8Array. */ - wit_secretNonce(context: WitnessContext): [P, Uint8Array]; + wit_secretNonce(context: WitnessContext): [P, Uint8Array]; } /** @@ -61,13 +61,16 @@ export const ZOwnablePKPrivateState = { /** * @description Factory function creating witness implementations for Ownable operations. + * @template L - The ledger type, supplied by the simulator. * @returns An object implementing the Witnesses interface for ZOwnablePKPrivateState. */ -export const ZOwnablePKWitnesses = - (): IZOwnablePKWitnesses => ({ - wit_secretNonce( - context: WitnessContext, - ): [ZOwnablePKPrivateState, Uint8Array] { - return [context.privateState, context.privateState.secretNonce]; - }, - }); +export const ZOwnablePKWitnesses = (): IZOwnablePKWitnesses< + L, + ZOwnablePKPrivateState +> => ({ + wit_secretNonce( + context: WitnessContext, + ): [ZOwnablePKPrivateState, Uint8Array] { + return [context.privateState, context.privateState.secretNonce]; + }, +}); diff --git a/contracts/src/archive/test/mocks/MockShieldedToken.compact b/contracts/src/archive/test/mocks/MockShieldedToken.compact index 68c0fc35..4310501b 100644 --- a/contracts/src/archive/test/mocks/MockShieldedToken.compact +++ b/contracts/src/archive/test/mocks/MockShieldedToken.compact @@ -1,5 +1,10 @@ // SPDX-License-Identifier: MIT +// WARNING: FOR TESTING PURPOSES ONLY. +// This contract exposes internal circuits and bypasses safety checks that the +// corresponding production contract relies on. DO NOT deploy or use this +// contract in any production application. + pragma language_version >= 0.21.0; import CompactStandardLibrary; diff --git a/contracts/src/security/test/mocks/MockInitializable.compact b/contracts/src/security/test/mocks/MockInitializable.compact index ca5dd3fc..d8a9daf9 100644 --- a/contracts/src/security/test/mocks/MockInitializable.compact +++ b/contracts/src/security/test/mocks/MockInitializable.compact @@ -1,3 +1,10 @@ +// SPDX-License-Identifier: MIT + +// WARNING: FOR TESTING PURPOSES ONLY. +// This contract exposes internal circuits and bypasses safety checks that the +// corresponding production contract relies on. DO NOT deploy or use this +// contract in any production application. + pragma language_version >= 0.21.0; import CompactStandardLibrary; diff --git a/contracts/src/security/test/mocks/MockPausable.compact b/contracts/src/security/test/mocks/MockPausable.compact index da9d79a9..4eed6cbf 100644 --- a/contracts/src/security/test/mocks/MockPausable.compact +++ b/contracts/src/security/test/mocks/MockPausable.compact @@ -1,3 +1,10 @@ +// SPDX-License-Identifier: MIT + +// WARNING: FOR TESTING PURPOSES ONLY. +// This contract exposes internal circuits and bypasses safety checks that the +// corresponding production contract relies on. DO NOT deploy or use this +// contract in any production application. + pragma language_version >= 0.21.0; import CompactStandardLibrary; diff --git a/contracts/src/token/test/mocks/MockFungibleToken.compact b/contracts/src/token/test/mocks/MockFungibleToken.compact index 2b86c588..7e23d955 100644 --- a/contracts/src/token/test/mocks/MockFungibleToken.compact +++ b/contracts/src/token/test/mocks/MockFungibleToken.compact @@ -1,5 +1,10 @@ // SPDX-License-Identifier: MIT +// WARNING: FOR TESTING PURPOSES ONLY. +// This contract exposes internal circuits and bypasses safety checks that the +// corresponding production contract relies on. DO NOT deploy or use this +// contract in any production application. + pragma language_version >= 0.21.0; import CompactStandardLibrary; diff --git a/contracts/src/token/test/mocks/MockMultiToken.compact b/contracts/src/token/test/mocks/MockMultiToken.compact index 37d89fd1..e66f2884 100644 --- a/contracts/src/token/test/mocks/MockMultiToken.compact +++ b/contracts/src/token/test/mocks/MockMultiToken.compact @@ -1,3 +1,10 @@ +// SPDX-License-Identifier: MIT + +// WARNING: FOR TESTING PURPOSES ONLY. +// This contract exposes internal circuits and bypasses safety checks that the +// corresponding production contract relies on. DO NOT deploy or use this +// contract in any production application. + pragma language_version >= 0.21.0; import CompactStandardLibrary; diff --git a/contracts/src/token/test/mocks/MockNonFungibleToken.compact b/contracts/src/token/test/mocks/MockNonFungibleToken.compact index a7515486..05a9071e 100644 --- a/contracts/src/token/test/mocks/MockNonFungibleToken.compact +++ b/contracts/src/token/test/mocks/MockNonFungibleToken.compact @@ -1,3 +1,10 @@ +// SPDX-License-Identifier: MIT + +// WARNING: FOR TESTING PURPOSES ONLY. +// This contract exposes internal circuits and bypasses safety checks that the +// corresponding production contract relies on. DO NOT deploy or use this +// contract in any production application. + pragma language_version >= 0.21.0; import CompactStandardLibrary; diff --git a/contracts/src/utils/test/mocks/MockUtils.compact b/contracts/src/utils/test/mocks/MockUtils.compact index 3f13ffac..649a90ef 100644 --- a/contracts/src/utils/test/mocks/MockUtils.compact +++ b/contracts/src/utils/test/mocks/MockUtils.compact @@ -1,3 +1,10 @@ +// SPDX-License-Identifier: MIT + +// WARNING: FOR TESTING PURPOSES ONLY. +// This contract exposes internal circuits and bypasses safety checks that the +// corresponding production contract relies on. DO NOT deploy or use this +// contract in any production application. + pragma language_version >= 0.21.0; import CompactStandardLibrary; diff --git a/contracts/test-utils/address.ts b/contracts/test-utils/address.ts index 38dae723..648e5511 100644 --- a/contracts/test-utils/address.ts +++ b/contracts/test-utils/address.ts @@ -56,7 +56,9 @@ export const encodeToAddress = (str: string): EncodedContractAddress => { * @param str String to hexify and encode. * @returns Defined Either object for ZswapCoinPublicKey. */ -export const createEitherTestUser = (str: string) => ({ +export const createEitherTestUser = ( + str: string, +): Either => ({ is_left: true, left: encodeToPK(str), right: encodeToAddress(''), diff --git a/packages/simulator/README.md b/packages/simulator/README.md index fcfd3ec0..48b2b496 100644 --- a/packages/simulator/README.md +++ b/packages/simulator/README.md @@ -20,7 +20,10 @@ import { Contract, ledger } from './artifacts/MyContract/contract/index.js'; // 1. Define your contract arguments type type MyContractArgs = readonly [owner: Uint8Array, value: bigint]; -// 2. Create the simulator +// 2. Define the extracted ledger type +type MyContractLedger = ReturnType; + +// 3. Create the simulator const MySimulator = createSimulator< MyPrivateState, ReturnType, @@ -31,10 +34,10 @@ const MySimulator = createSimulator< defaultPrivateState: () => MyPrivateState.generate(), contractArgs: (owner, value) => [owner, value], ledgerExtractor: (state) => ledger(state), - witnessesFactory: () => MyWitnesses(), + witnessesFactory: () => MyWitnesses(), }); -// 3. Use it! +// 4. Use it! const sim = new MySimulator([ownerAddress, 100n], { coinPK: deployerPK }); ``` @@ -52,6 +55,9 @@ import { MyContractWitnesses, MyContractPrivateState } from './MyContractWitness // Define contract constructor arguments as a tuple type type MyContractArgs = readonly [arg1: bigint, arg2: string]; +// Define the extracted ledger type +type MyContractLedger = ReturnType; + // Create the base simulator with full type information const MyContractSimulatorBase = createSimulator< MyContractPrivateState, // Private state type @@ -63,7 +69,7 @@ const MyContractSimulatorBase = createSimulator< defaultPrivateState: () => MyContractPrivateState.generate(), contractArgs: (arg1, arg2) => [arg1, arg2], ledgerExtractor: (state) => ledger(state), - witnessesFactory: () => MyContractWitnesses(), // Note: Must be a function! + witnessesFactory: () => MyContractWitnesses(), // Note: Must be a function! }); ``` @@ -76,7 +82,7 @@ If the Compact contract has no witnesses: // Some Compact contract examples use: export const MyContractWitnesses = {}; -// But for the simulator, wrap it in a function: +// But for the simulator, wrap it in a generic function: export const MyContractWitnesses = () => ({}); ``` diff --git a/packages/simulator/test/fixtures/sample-contracts/witnesses/SampleZOwnableWitnesses.ts b/packages/simulator/test/fixtures/sample-contracts/witnesses/SampleZOwnableWitnesses.ts index 464007f1..af447156 100644 --- a/packages/simulator/test/fixtures/sample-contracts/witnesses/SampleZOwnableWitnesses.ts +++ b/packages/simulator/test/fixtures/sample-contracts/witnesses/SampleZOwnableWitnesses.ts @@ -1,18 +1,18 @@ import { getRandomValues } from 'node:crypto'; import type { WitnessContext } from '@midnight-ntwrk/compact-runtime'; -import type { Ledger } from '../../artifacts/SampleZOwnable/contract/index.js'; /** * @description Interface defining the witness methods for SampleZOwnable operations. + * @template L - The ledger type. * @template P - The private state type. */ -export interface ISampleZOwnableWitnesses

{ +export interface ISampleZOwnableWitnesses { /** * Retrieves the secret nonce from the private state. - * @param context - The witness context containing the private state. + * @param context - The witness context containing the ledger and private state. * @returns A tuple of the private state and the secret nonce as a Uint8Array. */ - secretNonce(context: WitnessContext): [P, Uint8Array]; + secretNonce(context: WitnessContext): [P, Uint8Array]; } /** @@ -56,13 +56,16 @@ export const SampleZOwnablePrivateState = { /** * @description Factory function creating witness implementations for Ownable operations. + * @template L - The ledger type, supplied by the simulator. * @returns An object implementing the Witnesses interface for SampleZOwnablePrivateState. */ -export const SampleZOwnableWitnesses = - (): ISampleZOwnableWitnesses => ({ - secretNonce( - context: WitnessContext, - ): [SampleZOwnablePrivateState, Uint8Array] { - return [context.privateState, context.privateState.secretNonce]; - }, - }); +export const SampleZOwnableWitnesses = (): ISampleZOwnableWitnesses< + L, + SampleZOwnablePrivateState +> => ({ + secretNonce( + context: WitnessContext, + ): [SampleZOwnablePrivateState, Uint8Array] { + return [context.privateState, context.privateState.secretNonce]; + }, +}); diff --git a/packages/simulator/test/fixtures/sample-contracts/witnesses/WitnessWitnesses.ts b/packages/simulator/test/fixtures/sample-contracts/witnesses/WitnessWitnesses.ts index 7795cdfc..47c8009f 100644 --- a/packages/simulator/test/fixtures/sample-contracts/witnesses/WitnessWitnesses.ts +++ b/packages/simulator/test/fixtures/sample-contracts/witnesses/WitnessWitnesses.ts @@ -1,6 +1,5 @@ import { getRandomValues } from 'node:crypto'; import type { WitnessContext } from '@midnight-ntwrk/compact-runtime'; -import type { Ledger } from '../../artifacts/Witness/contract/index.js'; const randomBigInt = (bits: number): bigint => { const bytes = Math.ceil(bits / 8); @@ -16,14 +15,14 @@ const randomBigInt = (bits: number): bigint => { return result % max; }; -export interface IWitnessWitnesses

{ - wit_secretBytes(context: WitnessContext): [P, Uint8Array]; +export interface IWitnessWitnesses { + wit_secretBytes(context: WitnessContext): [P, Uint8Array]; wit_secretFieldPlusArg( - context: WitnessContext, + context: WitnessContext, arg: bigint, ): [P, bigint]; wit_secretUintPlusArgs( - context: WitnessContext, + context: WitnessContext, arg1: bigint, arg2: bigint, ): [P, bigint]; @@ -45,20 +44,23 @@ export const WitnessPrivateState = { }, }; -export const WitnessWitnesses = (): IWitnessWitnesses => ({ +export const WitnessWitnesses = (): IWitnessWitnesses< + L, + WitnessPrivateState +> => ({ wit_secretBytes( - context: WitnessContext, + context: WitnessContext, ): [WitnessPrivateState, Uint8Array] { return [context.privateState, context.privateState.secretBytes]; }, wit_secretFieldPlusArg( - context: WitnessContext, + context: WitnessContext, arg: bigint, ): [WitnessPrivateState, bigint] { return [context.privateState, context.privateState.secretField + arg]; }, wit_secretUintPlusArgs( - context: WitnessContext, + context: WitnessContext, arg1: bigint, arg2: bigint, ): [WitnessPrivateState, bigint] { diff --git a/packages/simulator/test/integration/SampleZOwnableSimulator.ts b/packages/simulator/test/integration/SampleZOwnableSimulator.ts index c288e259..e2e26f2f 100644 --- a/packages/simulator/test/integration/SampleZOwnableSimulator.ts +++ b/packages/simulator/test/integration/SampleZOwnableSimulator.ts @@ -11,14 +11,15 @@ import { SampleZOwnableWitnesses, } from '../fixtures/sample-contracts/witnesses/SampleZOwnableWitnesses'; -/** - * Type constructor args - */ +/** Type constructor args */ type SampleZOwnableArgs = readonly [ owner: Uint8Array, instanceSalt: Uint8Array, ]; +/** Concrete ledger type extracted from the generated artifact */ +type SampleZOwnableLedger = ReturnType; + /** * Base simulator */ @@ -36,7 +37,7 @@ const SampleZOwnableSimulatorBase = createSimulator< return [owner, instanceSalt]; }, ledgerExtractor: (state) => ledger(state), - witnessesFactory: () => SampleZOwnableWitnesses(), + witnessesFactory: () => SampleZOwnableWitnesses(), }); /** diff --git a/packages/simulator/test/integration/WitnessSimulator.ts b/packages/simulator/test/integration/WitnessSimulator.ts index c5b07041..e03ac46f 100644 --- a/packages/simulator/test/integration/WitnessSimulator.ts +++ b/packages/simulator/test/integration/WitnessSimulator.ts @@ -8,11 +8,12 @@ import { WitnessWitnesses, } from '../fixtures/sample-contracts/witnesses/WitnessWitnesses'; -/** - * Type constructor args - */ +/** Type constructor args */ type WitnessArgs = readonly []; +/** Concrete ledger type extracted from the generated artifact */ +type WitnessLedger = ReturnType; + /** * Base simulator */ @@ -30,7 +31,7 @@ const WitnessSimulatorBase = createSimulator< return []; }, ledgerExtractor: (state) => ledger(state), - witnessesFactory: () => WitnessWitnesses(), + witnessesFactory: () => WitnessWitnesses(), }); /**