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..2a4dffa8 --- /dev/null +++ b/contracts/src/access/ShieldedAccessControl.compact @@ -0,0 +1,782 @@ +// 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( secretKey ‖ 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. `secretKey` is a 32-byte + * cryptographically secure random value held in local private state (supplied by `wit_secretKey`); + * `instanceSalt` ensures the same key cannot be correlated across contracts. + * A single `secretKey` can be used across multiple roles within the same contract instance. + * - `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 secret key to generate a new `accountId` to be + * re-authorized. This creates stronger security invariants over traditional RBAC systems and enables + * privacy-preserving identity rotation. + * + * A single `secretKey` may be used for all roles within a contract instance. This is safe because the + * `role` identifier is mixed into the commitment (not the `accountId`), so different roles produce + * different commitments and different nullifiers even when the underlying `accountId` is the same. + * Revoking one role does not affect other roles held by the same `accountId`. + * + * If a role is revoked and the user needs to be re-authorized, they must generate a new `secretKey` + * to produce a new `accountId`. Other roles held under the old `secretKey` remain valid. The user + * will need to retain both keys until all roles under the old key are no longer needed. + * + * 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. + * - Role commitments inserted into the `_operatorRoles` MerkleTree are NOT visible in the + * public proof transcript. The MerkleTree insert hashes the value internally, and only + * the internal leaf hash is declared as a public input. Observers cannot recover the + * commitment from the leaf hash. This is in contrast to Set operations, where values + * are directly visible in the proof transcript. + * - Because commitments are hidden, outside observers will have knowledge of all role + * nullifiers but cannot link them to their corresponding commitments unless the + * commitment value space is small enough to enumerate. The strength of this unlinkability + * depends on the number of plausible `(role, accountId)` pairings. Since role identifiers + * are public, the anonymity set is effectively the number of plausible account IDs per role. + * - Outside observers will know when roles are 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 secret key is exposed. + * - Outside observers will NOT be able to identify the holder of any role + * so long as secret key values are kept private and generated using cryptographically + * secure random values. + * - Outside observers can correlate grant and revocation transactions for the same + * `(role, accountId)` pairing by matching nullifier values. During a grant, the nullifier + * is disclosed via the `_roleCommitmentNullifiers.member` check. During a revocation, the + * same nullifier is disclosed via `_roleCommitmentNullifiers.insert`. This allows an observer + * to determine that a specific grant and revocation are related, but does not reveal the + * identity of the role holder or the commitment value. + * - Role holders have a stable pseudonymous on-chain identity for the lifetime of their role + * commitment. Every call to a protected circuit (`assertOnlyRole`, `canProveRole`) discloses + * the same nullifier via the `_roleCommitmentNullifiers.member` check, enabling observers to + * link all actions performed under the same role instance. This provides pseudonymity, not anonymity. + * + * @dev Security Considerations: + * - The `secretKey` must be kept private. Loss of the key prevents role holders + * and admins from proving access or transferring it. Key exposure 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. + * Duplicate grants are benign from a security perspective. They produce identical + * commitments and a single nullifier invalidates all copies; however, they waste tree capacity. + * Implementing contracts are responsible for mitigating tree exhaustion risk. + * - A single `secretKey` compromise exposes all roles held by that user within the contract + * instance, since all roles share the same `accountId`. Users requiring compartmentalization + * between roles should use separate keys per role and accept the additional key management overhead. + * In the event of key compromise, the user should coordinate with admins to revoke all roles + * under the compromised `accountId` and re-grant under a new identity. + * - Admins can revoke `(role, accountId)` pairings that were never granted, since on-chain + * non-membership proofs are not available for the Merkle tree. This permanently burns the + * nullifier for that pairing, blocking any future grant. Admin trust is a fundamental + * assumption of this system. Off-chain validation should confirm that a pairing was actually + * granted before submitting a revocation transaction. + * - `renounceRole` allows burning nullifiers for roles the caller does not currently hold, + * provided they possess the correct `secretKey`. This requires knowledge of the secret key, + * at which point the `accountId` should be considered fully compromised regardless. + * - The Merkle tree capacity of 2^20 leaf slots is a permanent, irrecoverable resource. + * There is no mechanism to reclaim slots after revocation, no upgrade path, and no emergency + * recovery. Once exhausted, the contract is permanently unable to grant new roles. Deployers + * must monitor slot consumption off-chain and plan capacity accordingly. + * + * @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(secretKey, 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. + * If two deployments share the same instanceSalt and a user reuses their secretKey across both, + * the resulting accountId and all role commitments will be identical in both contracts. + * An observer who sees the same nullifier appear in both contracts' nullifier sets can conclude + * that it's the same user. + * 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_secretKey + * @description Returns the user's secret key used in deriving the shielded account identifier. + * + * The same key can be used across multiple roles within a contract instance. If a role is + * revoked and re-granted, a new secret key must be generated to produce a new `accountId`. + * + * @returns {Bytes<32>} secretKey - A secret key used in deriving the shielded account identifier. + */ + witness wit_secretKey(): 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 + * + * Requirements: + * + * - Contract is not initialized. + * + * @param {Bytes<32>} instanceSalt - Contract salt to prevent duplicate commitments if + * users reuse their secret key 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`. + * + * 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. + * + * 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. + * + * 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(); + 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 to the same `accountId`. 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. + * - The `(role, accountId)` pairing must not be already revoked. + * - 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 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. + * + * @warning The `_operatorRoles` Merkle tree has a fixed capacity of 2^20 leaf slots. + * Deployers should monitor slot consumption off-chain. Duplicate grants waste tree capacity + * but are otherwise benign. A single nullifier invalidates all duplicate commitments. + * Implementing contracts are responsible for mitigating tree exhaustion risk. + * + * Requirements: + * + * - The `(role, accountId)` pairing must not be already revoked. + * - Contract is initialized. + * + * 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 {[]} - Empty tuple. + */ + export circuit _grantRole(role: RoleIdentifier, accountId: AccountIdentifier): [] { + Initializable_assertInitialized(); + _updateRole(role, accountId, UpdateType.Grant); + } + + /** + * @description Revokes `role` from the calling account by inserting a role nullifier into the + * `_roleCommitmentNullifiers` set. Once revoked, a new `accountId` must be generated to be + * re-authorized for `role`. + * + * @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. + * + * Requirements: + * + * - The caller must provide a valid `accountId` for the role. + * - The `(role, accountId)` pairing must not be already revoked. + * - 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(), + "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. + * + * Requirements: + * + * - caller must prove they're an admin for `role`. + * - caller must not provide valid Merkle tree path for a different `(role, accountId)` pairing. + * - The `(role, accountId)` pairing must not be already revoked. + * - 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. + * + * 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: + * + * - The `(role, accountId)` pairing must not be already revoked. + * - Contract is initialized. + * + * Disclosures: + * + * - A nullifier for the respective role commitment. + * + * @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): [] { + Initializable_assertInitialized(); + _updateRole(role, accountId, UpdateType.Revoke); + } + + /** + * @description Core business logic for the grant/revoke role circuits. Asserts that the + * `(role, accountId)` pairing has not already been revoked. On success, dispatches on + * `updateType`: a `Grant` inserts the role commitment into `_operatorRoles`, and + * a `Revoke` inserts the nullifier into `_roleCommitmentNullifiers`. + * + * Disclosures: + * + * - A nullifier for the respective role commitment. + * - A role commitment (on Grant only). + * + * @notice The nullifier is disclosed via `_roleCommitmentNullifiers.member` on every call, + * regardless of the update type. Since `Set.member` publicly reveals its argument, the + * nullifier value is observable in the transaction transcript. This enables observers to + * correlate grant and revocation transactions for the same `(role, accountId)` pairing. + * + * @param {RoleIdentifier} role - The role identifier. + * @param {AccountIdentifier} accountId - The unique identifier of the account. + * @param {UpdateType} updateType - Whether to grant or revoke. + * + * @return {[]} - Empty tuple. + */ + circuit _updateRole( + role: RoleIdentifier, + accountId: AccountIdentifier, + updateType: UpdateType + ): [] { + const roleCommitment = computeRoleCommitment(role, accountId); + const roleNullifier = computeNullifier(roleCommitment); + const isRevoked = _roleCommitmentNullifiers.member(disclose(roleNullifier)); + assert(!isRevoked, "ShieldedAccessControl: role is already revoked"); + + if (updateType == UpdateType.Grant) { + _operatorRoles.insert(disclose(roleCommitment)); + } else { + _roleCommitmentNullifiers.insert(disclose(roleNullifier)); + } + } + + /** + * @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}. + * + * 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. + * + * 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): [] { + Initializable_assertInitialized(); + _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. + * + * 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 nullifier for the respective role commitment. + * + * @notice The nullifier is disclosed via `_roleCommitmentNullifiers.member` on every call. + * Since this circuit is invoked by `assertOnlyRole` and `canProveRole`, every protected + * operation discloses the same nullifier, allowing observers to link all actions performed + * under the same role instance across time. + * + * @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 as: + * `accountId = SHA256(secretKey, instanceSalt, accountIdDomain)` + * + * - `secretKey`: A 32-byte cryptographically secure random value. + * + * ## 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. + * + * @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 + * secret key and the instance salt. + * + * @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(secretKey, instanceSalt, accountIdDomain)` + * + * - `secretKey`: A 32-byte cryptographically secure random value supplied by the `wit_secretKey` witness. + * - `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 identity. + * + * @returns {AccountIdentifier} accountId - The computed account ID. + */ + circuit _computeAccountId(): AccountIdentifier { + return computeAccountId(wit_secretKey(), _instanceSalt); + } + + /** + * @description Computes an `accountId` 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 `secretKey` parameter is a sensitive secret. Mishandling it can + * permanently compromise the privacy guarantees of this system: + * + * - **Never log or persist** the `secretKey` 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. + * - **Use cryptographically secure randomness** — generate keys with `crypto.getRandomValues()` + * or equivalent; weak or predictable keys can be brute-forced to reveal your identity. + * - **Treat key loss as identity loss** — a lost key cannot be recovered. Back up + * keys 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 key to a malicious observer. + * + * ## ID Derivation + * `accountId = SHA256(secretKey, instanceSalt, accountIdDomain)` + * + * See {_computeAccountId} for further details. + * + * @param {Bytes<32>} secretKey - A 32-byte cryptographically secure random value. + * @param {Bytes<32>} instanceSalt - The unique per-deployment salt for the contract instance. + * + * @returns {AccountIdentifier} accountId - The computed account ID. + */ + export pure circuit computeAccountId( + secretKey: Bytes<32>, + instanceSalt: Bytes<32> + ): AccountIdentifier { + return persistentHash>>( + [secretKey, 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..4043759a --- /dev/null +++ b/contracts/src/access/test/ShieldedAccessControl.test.ts @@ -0,0 +1,1366 @@ +import { + CompactTypeBytes, + CompactTypeVector, + convertFieldToBytes, + type MerkleTreePath, + persistentHash, + type WitnessContext, +} from '@midnight-ntwrk/compact-runtime'; +import { beforeEach, describe, expect, it } from 'vitest'; +import type { Ledger } 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 = (sk: Uint8Array): Uint8Array => { + const rt_type = new CompactTypeVector(3, new CompactTypeBytes(32)); + + const bDomain = new TextEncoder().encode(ACCOUNT_DOMAIN); + return persistentHash(rt_type, [sk, 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; +}; + +// SKs +const ADMIN_SK = Buffer.alloc(32, 'ADMIN_SECRET_KEY'); +const OPERATOR_1_SK = Buffer.alloc(32, 'OPERATOR_1_SECRET_KEY'); +const OPERATOR_2_SK = Buffer.alloc(32, 'OPERATOR_2_SECRET_KEY'); +const OPERATOR_3_SK = Buffer.alloc(32, 'OPERATOR_3_SECRET_KEY'); +const UNAUTHORIZED_SK = Buffer.alloc(32, 'UNAUTHORIZED_SECRET_KEY'); +const BAD_SK = Buffer.alloc(32, 'BAD_SECRET_KEY'); + +// Roles +const ROLE_ADMIN = Buffer.from(convertFieldToBytes(32, 0n, '')); +const ROLE_OP1 = Buffer.from(convertFieldToBytes(32, 1n, '')); +const ROLE_OP2 = Buffer.from(convertFieldToBytes(32, 2n, '')); +const ROLE_OP3 = Buffer.from(convertFieldToBytes(32, 3n, '')); +const ROLE_NONEXISTENT = Buffer.from(convertFieldToBytes(32, 555n, '')); + +// Derived ids +const ADMIN_ACCOUNT_ID = buildAccountIdHash(ADMIN_SK); +const OP1_ACCOUNT_ID = buildAccountIdHash(OPERATOR_1_SK); +const OP2_ACCOUNT_ID = buildAccountIdHash(OPERATOR_2_SK); +const OP3_ACCOUNT_ID = buildAccountIdHash(OPERATOR_3_SK); +const BAD_ACCOUNT_ID = buildAccountIdHash(BAD_SK); + +// Commitments and nullifiers for common (role, accountId) pairings +const ADMIN_ROLE_COMMITMENT = buildRoleCommitmentHash( + ROLE_ADMIN, + ADMIN_ACCOUNT_ID, +); +const ADMIN_ROLE_NULLIFIER = buildNullifierHash(ADMIN_ROLE_COMMITMENT); + +const OP1_ROLE_COMMITMENT = buildRoleCommitmentHash(ROLE_OP1, OP1_ACCOUNT_ID); + +let contract: ShieldedAccessControlSimulator; + +describe('ShieldedAccessControl', () => { + describe('when not initialized', () => { + beforeEach(() => { + contract = new ShieldedAccessControlSimulator(INSTANCE_SALT, false); + }); + + const circuitsRequiringInit: [string, unknown[]][] = [ + ['canProveRole', [ROLE_ADMIN]], + ['assertOnlyRole', [ROLE_ADMIN]], + ['grantRole', [ROLE_ADMIN, ADMIN_ACCOUNT_ID]], + ['revokeRole', [ROLE_ADMIN, ADMIN_ACCOUNT_ID]], + ['renounceRole', [ROLE_ADMIN, ADMIN_ACCOUNT_ID]], + ['_grantRole', [ROLE_ADMIN, ADMIN_ACCOUNT_ID]], + ['_revokeRole', [ROLE_ADMIN, ADMIN_ACCOUNT_ID]], + ['_setRoleAdmin', [ROLE_ADMIN, ROLE_ADMIN]], + ]; + + it.each(circuitsRequiringInit)('%s should fail', (circuitName, args) => { + expect(() => { + ( + contract[circuitName as keyof ShieldedAccessControlSimulator] as ( + ...a: unknown[] + ) => unknown + )(...args); + }).toThrow('Initializable: contract not initialized'); + }); + + const circuitsNotRequiringInit: [string, unknown[]][] = [ + ['_uncheckedCanProveRole', [ROLE_ADMIN]], + ['getRoleAdmin', [ROLE_ADMIN]], + ['_computeAccountId', []], + ['computeRoleCommitment', [ROLE_ADMIN, ADMIN_ACCOUNT_ID]], + ['computeNullifier', [ADMIN_ROLE_COMMITMENT]], + ['DEFAULT_ADMIN_ROLE', []], + ['computeAccountId', [ADMIN_SK, INSTANCE_SALT]], + ]; + + it.each( + circuitsNotRequiringInit, + )('%s should succeed', (circuitName, args) => { + expect(() => { + ( + contract[circuitName as keyof ShieldedAccessControlSimulator] as ( + ...a: unknown[] + ) => unknown + )(...args); + }).not.toThrow('Initializable: contract not initialized'); + }); + + it('should fail with zero instanceSalt', () => { + expect(() => { + new ShieldedAccessControlSimulator(new Uint8Array(32), true); + }).toThrow('ShieldedAccessControl: Instance salt must not be 0'); + }); + }); + + describe('after initialization', () => { + beforeEach(() => { + contract = new ShieldedAccessControlSimulator(INSTANCE_SALT, true, { + privateState: ShieldedAccessControlPrivateState.withSecretKey(ADMIN_SK), + }); + }); + + describe('DEFAULT_ADMIN_ROLE', () => { + it('should return zero bytes', () => { + expect(contract.DEFAULT_ADMIN_ROLE()).toStrictEqual(new Uint8Array(32)); + }); + }); + + describe('computeAccountId', () => { + it('should match pre-computed accountId', () => { + expect(contract.computeAccountId(ADMIN_SK, INSTANCE_SALT)).toEqual( + ADMIN_ACCOUNT_ID, + ); + }); + + it('should produce different accountId with different key', () => { + expect(contract.computeAccountId(BAD_SK, INSTANCE_SALT)).not.toEqual( + ADMIN_ACCOUNT_ID, + ); + }); + + it('should produce different accountId with different salt', () => { + const differentSalt = new Uint8Array(32).fill(1); + expect(contract.computeAccountId(ADMIN_SK, differentSalt)).not.toEqual( + ADMIN_ACCOUNT_ID, + ); + }); + + it('should accept zero-byte secret key', () => { + const zeroKey = new Uint8Array(32); + expect(contract.computeAccountId(zeroKey, INSTANCE_SALT)).toEqual( + buildAccountIdHash(zeroKey), + ); + }); + }); + + describe('_computeAccountId', () => { + it('should match pre-computed accountId with correct key', () => { + expect(contract._computeAccountId()).toEqual(ADMIN_ACCOUNT_ID); + }); + + it('should not match after injecting a different key', () => { + contract.privateState.injectSecretKey(BAD_SK); + expect(contract._computeAccountId()).not.toEqual(ADMIN_ACCOUNT_ID); + expect(contract._computeAccountId()).toEqual(BAD_ACCOUNT_ID); + }); + + it('should produce same accountId with same sk and instanceSalt', () => { + // accountId is purely a function of secretKey + instanceSalt + const first = contract._computeAccountId(); + const second = contract._computeAccountId(); + expect(first).toEqual(second); + }); + + it('should produce different accountIds with different sk', () => { + const first = contract._computeAccountId(); + + contract.privateState.injectSecretKey(OPERATOR_2_SK); + const second = contract._computeAccountId(); + + expect(first).not.toEqual(second); + }); + + it('should produce different accountIds with same sk and different instanceSalt', () => { + // Confirm different salt vals + const diffSalt = new Uint8Array(32).fill(99887766); + expect(diffSalt).not.toEqual(INSTANCE_SALT); + + // Deploy new contract with diff salt + const isInit = true; + const newContract = new ShieldedAccessControlSimulator( + diffSalt, + isInit, + ); + + // Confirm accountIds are different + const first = contract._computeAccountId(); + const second = newContract._computeAccountId(); + expect(first).not.toEqual(second); + }); + }); + + describe('computeRoleCommitment', () => { + it('should match pre-computed commitment', () => { + expect( + contract.computeRoleCommitment(ROLE_ADMIN, ADMIN_ACCOUNT_ID), + ).toEqual(ADMIN_ROLE_COMMITMENT); + }); + + it('should differ with wrong role', () => { + expect( + contract.computeRoleCommitment(ROLE_OP1, ADMIN_ACCOUNT_ID), + ).not.toEqual(ADMIN_ROLE_COMMITMENT); + }); + + it('should differ with wrong accountId', () => { + expect( + contract.computeRoleCommitment(ROLE_ADMIN, BAD_ACCOUNT_ID), + ).not.toEqual(ADMIN_ROLE_COMMITMENT); + }); + + it('should differ with different instanceSalt', () => { + const newContract = new ShieldedAccessControlSimulator( + new Uint8Array(32).fill(1), + true, + { + privateState: + ShieldedAccessControlPrivateState.withSecretKey(ADMIN_SK), + }, + ); + expect( + newContract.computeRoleCommitment(ROLE_ADMIN, ADMIN_ACCOUNT_ID), + ).not.toEqual(ADMIN_ROLE_COMMITMENT); + }); + }); + + describe('computeNullifier', () => { + it('should match pre-computed nullifier', () => { + expect(contract.computeNullifier(ADMIN_ROLE_COMMITMENT)).toEqual( + ADMIN_ROLE_NULLIFIER, + ); + }); + + it('should differ with wrong commitment', () => { + expect(contract.computeNullifier(OP1_ROLE_COMMITMENT)).not.toEqual( + ADMIN_ROLE_NULLIFIER, + ); + }); + }); + + describe('_validateRole', () => { + beforeEach(() => { + contract._grantRole(ROLE_ADMIN, ADMIN_ACCOUNT_ID); + }); + + it('should fail when witness returns path for a different commitment', () => { + contract._grantRole(ROLE_OP1, OP1_ACCOUNT_ID); + contract.overrideWitness('wit_getRoleCommitmentPath', () => { + const ps = contract.getPrivateState(); + const path = contract + .getPublicState() + .ShieldedAccessControl__operatorRoles.findPathForLeaf( + OP1_ROLE_COMMITMENT, + ); + if (path) return [ps, path]; + throw new Error('Path should be defined'); + }); + + expect(() => + contract._validateRole(ROLE_ADMIN, ADMIN_ACCOUNT_ID), + ).toThrow( + 'ShieldedAccessControl: Path must contain leaf matching computed role commitment for the provided role, accountId pairing', + ); + }); + + describe('should return true', () => { + it('when role is granted', () => { + expect(contract._validateRole(ROLE_ADMIN, ADMIN_ACCOUNT_ID)).toBe( + true, + ); + }); + + it('when same accountId has multiple roles', () => { + contract._grantRole(ROLE_OP1, ADMIN_ACCOUNT_ID); + contract._grantRole(ROLE_OP2, ADMIN_ACCOUNT_ID); + contract._grantRole(ROLE_OP3, ADMIN_ACCOUNT_ID); + + expect(contract._validateRole(ROLE_ADMIN, ADMIN_ACCOUNT_ID)).toBe( + true, + ); + expect(contract._validateRole(ROLE_OP1, ADMIN_ACCOUNT_ID)).toBe(true); + expect(contract._validateRole(ROLE_OP2, ADMIN_ACCOUNT_ID)).toBe(true); + expect(contract._validateRole(ROLE_OP3, ADMIN_ACCOUNT_ID)).toBe(true); + }); + + it('when role is revoked and re-issued with a new accountId', () => { + contract._revokeRole(ROLE_ADMIN, ADMIN_ACCOUNT_ID); + + const newKey = Buffer.alloc(32, 'NEW_ADMIN_KEY'); + const newAccountId = buildAccountIdHash(newKey); + contract._grantRole(ROLE_ADMIN, newAccountId); + + expect(contract._validateRole(ROLE_ADMIN, newAccountId)).toBe(true); + }); + + it('when multiple users hold the same role', () => { + contract._grantRole(ROLE_OP1, ADMIN_ACCOUNT_ID); + contract._grantRole(ROLE_OP1, OP2_ACCOUNT_ID); + contract._grantRole(ROLE_OP1, OP3_ACCOUNT_ID); + + expect(contract._validateRole(ROLE_OP1, ADMIN_ACCOUNT_ID)).toBe(true); + expect(contract._validateRole(ROLE_OP1, OP2_ACCOUNT_ID)).toBe(true); + expect(contract._validateRole(ROLE_OP1, OP3_ACCOUNT_ID)).toBe(true); + }); + }); + + describe('should return false', () => { + it('when role was never granted', () => { + expect( + contract._validateRole(ROLE_NONEXISTENT, ADMIN_ACCOUNT_ID), + ).toBe(false); + }); + + it('when accountId does not match', () => { + expect(contract._validateRole(ROLE_ADMIN, BAD_ACCOUNT_ID)).toBe( + false, + ); + }); + + it('when role is revoked', () => { + contract._revokeRole(ROLE_ADMIN, ADMIN_ACCOUNT_ID); + expect(contract._validateRole(ROLE_ADMIN, ADMIN_ACCOUNT_ID)).toBe( + false, + ); + }); + + it('when invalid witness path is provided', () => { + contract.overrideWitness( + 'wit_getRoleCommitmentPath', + RETURN_BAD_PATH, + ); + expect(contract._validateRole(ROLE_ADMIN, ADMIN_ACCOUNT_ID)).toBe( + false, + ); + }); + + it('when invalid witness path is provided for a revoked role', () => { + contract._revokeRole(ROLE_ADMIN, ADMIN_ACCOUNT_ID); + contract.overrideWitness( + 'wit_getRoleCommitmentPath', + RETURN_BAD_PATH, + ); + expect(contract._validateRole(ROLE_ADMIN, ADMIN_ACCOUNT_ID)).toBe( + false, + ); + }); + }); + }); + + describe('assertOnlyRole', () => { + beforeEach(() => { + contract._grantRole(ROLE_ADMIN, ADMIN_ACCOUNT_ID); + }); + + describe('should fail', () => { + it('when witness returns path for a different commitment', () => { + contract._grantRole(ROLE_OP1, OP1_ACCOUNT_ID); + contract.overrideWitness('wit_getRoleCommitmentPath', () => { + const ps = contract.getPrivateState(); + const path = contract + .getPublicState() + .ShieldedAccessControl__operatorRoles.findPathForLeaf( + OP1_ROLE_COMMITMENT, + ); + if (path) return [ps, path]; + throw new Error('Path should be defined'); + }); + + expect(() => contract.assertOnlyRole(ROLE_ADMIN)).toThrow( + 'ShieldedAccessControl: Path must contain leaf matching computed role commitment for the provided role, accountId pairing', + ); + }); + + it('when caller has wrong secret key', () => { + contract.privateState.injectSecretKey(UNAUTHORIZED_SK); + expect(() => contract.assertOnlyRole(ROLE_ADMIN)).toThrow( + 'ShieldedAccessControl: unauthorized account', + ); + }); + + it('when witness provides invalid path', () => { + contract.overrideWitness( + 'wit_getRoleCommitmentPath', + RETURN_BAD_PATH, + ); + expect(() => contract.assertOnlyRole(ROLE_ADMIN)).toThrow( + 'ShieldedAccessControl: unauthorized account', + ); + }); + + it('when role is revoked', () => { + contract._revokeRole(ROLE_ADMIN, ADMIN_ACCOUNT_ID); + expect(() => contract.assertOnlyRole(ROLE_ADMIN)).toThrow( + 'ShieldedAccessControl: unauthorized account', + ); + }); + }); + + describe('should succeed', () => { + it('when caller has correct key and valid path', () => { + expect(() => contract.assertOnlyRole(ROLE_ADMIN)).not.toThrow(); + }); + + it('when caller holds multiple roles with same key', () => { + contract._grantRole(ROLE_OP1, ADMIN_ACCOUNT_ID); + contract._grantRole(ROLE_OP2, ADMIN_ACCOUNT_ID); + contract._grantRole(ROLE_OP3, ADMIN_ACCOUNT_ID); + + expect(() => { + contract.assertOnlyRole(ROLE_ADMIN); + contract.assertOnlyRole(ROLE_OP1); + contract.assertOnlyRole(ROLE_OP2); + contract.assertOnlyRole(ROLE_OP3); + }).not.toThrow(); + }); + + it('when role is revoked and re-issued with new accountId', () => { + contract._revokeRole(ROLE_ADMIN, ADMIN_ACCOUNT_ID); + + const newKey = Buffer.alloc(32, 'NEW_ADMIN_KEY'); + contract.privateState.injectSecretKey(newKey); + const newAccountId = buildAccountIdHash(newKey); + contract._grantRole(ROLE_ADMIN, newAccountId); + + expect(() => contract.assertOnlyRole(ROLE_ADMIN)).not.toThrow(); + }); + }); + }); + + describe('canProveRole', () => { + beforeEach(() => { + contract._grantRole(ROLE_ADMIN, ADMIN_ACCOUNT_ID); + }); + + it('should fail when witness returns path for a different commitment', () => { + contract._grantRole(ROLE_OP1, OP1_ACCOUNT_ID); + contract.overrideWitness('wit_getRoleCommitmentPath', () => { + const ps = contract.getPrivateState(); + const path = contract + .getPublicState() + .ShieldedAccessControl__operatorRoles.findPathForLeaf( + OP1_ROLE_COMMITMENT, + ); + if (path) return [ps, path]; + throw new Error('Path should be defined'); + }); + + expect(() => contract.canProveRole(ROLE_ADMIN)).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', () => { + expect(contract.canProveRole(ROLE_ADMIN)).toBe(true); + }); + + it('when caller holds multiple roles with same key', () => { + contract._grantRole(ROLE_OP1, ADMIN_ACCOUNT_ID); + contract._grantRole(ROLE_OP2, ADMIN_ACCOUNT_ID); + contract._grantRole(ROLE_OP3, ADMIN_ACCOUNT_ID); + + expect(contract.canProveRole(ROLE_ADMIN)).toBe(true); + expect(contract.canProveRole(ROLE_OP1)).toBe(true); + expect(contract.canProveRole(ROLE_OP2)).toBe(true); + expect(contract.canProveRole(ROLE_OP3)).toBe(true); + }); + + it('when role is revoked and re-issued with new accountId', () => { + contract._revokeRole(ROLE_ADMIN, ADMIN_ACCOUNT_ID); + + const newKey = Buffer.alloc(32, 'NEW_ADMIN_KEY'); + contract.privateState.injectSecretKey(newKey); + const newAccountId = buildAccountIdHash(newKey); + contract._grantRole(ROLE_ADMIN, newAccountId); + + expect(contract.canProveRole(ROLE_ADMIN)).toBe(true); + }); + + it('when multiple users hold the same role', () => { + contract._grantRole(ROLE_OP1, ADMIN_ACCOUNT_ID); + + // User 2 + contract._grantRole(ROLE_OP1, OP2_ACCOUNT_ID); + + // User 3 + contract._grantRole(ROLE_OP1, OP3_ACCOUNT_ID); + + // Prove as admin (who holds OP1) + expect(contract.canProveRole(ROLE_OP1)).toBe(true); + + // Prove as user 2 + contract.privateState.injectSecretKey(OPERATOR_2_SK); + expect(contract.canProveRole(ROLE_OP1)).toBe(true); + + // Prove as user 3 + contract.privateState.injectSecretKey(OPERATOR_3_SK); + expect(contract.canProveRole(ROLE_OP1)).toBe(true); + }); + }); + + describe('should return false', () => { + it('when caller does not have role', () => { + expect(contract.canProveRole(ROLE_OP1)).toBe(false); + }); + + it('when caller has wrong secret key', () => { + contract.privateState.injectSecretKey(BAD_SK); + expect(contract.canProveRole(ROLE_ADMIN)).toBe(false); + }); + + it('when role is revoked', () => { + contract._revokeRole(ROLE_ADMIN, ADMIN_ACCOUNT_ID); + expect(contract.canProveRole(ROLE_ADMIN)).toBe(false); + }); + + it('when witness provides invalid path', () => { + contract.overrideWitness( + 'wit_getRoleCommitmentPath', + RETURN_BAD_PATH, + ); + expect(contract.canProveRole(ROLE_ADMIN)).toBe(false); + }); + }); + }); + + describe('grantRole', () => { + beforeEach(() => { + contract._grantRole(ROLE_ADMIN, ADMIN_ACCOUNT_ID); + }); + + describe('should fail', () => { + it('when caller does not have admin role', () => { + contract.privateState.injectSecretKey(UNAUTHORIZED_SK); + expect(() => contract.grantRole(ROLE_OP1, OP1_ACCOUNT_ID)).toThrow( + 'ShieldedAccessControl: unauthorized account', + ); + }); + + it('when granting to an already-revoked accountId', () => { + contract._grantRole(ROLE_OP1, OP1_ACCOUNT_ID); + contract._revokeRole(ROLE_OP1, OP1_ACCOUNT_ID); + + expect(() => contract.grantRole(ROLE_OP1, OP1_ACCOUNT_ID)).toThrow( + 'ShieldedAccessControl: role is already revoked', + ); + }); + + it('when admin provides wrong secret key', () => { + contract.privateState.injectSecretKey(BAD_SK); + expect(() => contract.grantRole(ROLE_OP1, OP1_ACCOUNT_ID)).toThrow( + 'ShieldedAccessControl: unauthorized account', + ); + }); + + it('when admin provides invalid witness path', () => { + contract.overrideWitness( + 'wit_getRoleCommitmentPath', + RETURN_BAD_PATH, + ); + expect(() => contract.grantRole(ROLE_OP1, OP1_ACCOUNT_ID)).toThrow( + 'ShieldedAccessControl: unauthorized account', + ); + }); + + it('when admin role has been reassigned via _setRoleAdmin', () => { + contract._setRoleAdmin(ROLE_OP2, ROLE_OP1); + // ADMIN holds DEFAULT_ADMIN_ROLE but not ROLE_OP1 + expect(() => contract.grantRole(ROLE_OP2, OP2_ACCOUNT_ID)).toThrow( + 'ShieldedAccessControl: unauthorized account', + ); + }); + + it('when witness returns path for a different commitment', () => { + contract._grantRole(ROLE_OP1, OP1_ACCOUNT_ID); + contract.overrideWitness('wit_getRoleCommitmentPath', () => { + const ps = contract.getPrivateState(); + const path = contract + .getPublicState() + .ShieldedAccessControl__operatorRoles.findPathForLeaf( + OP1_ROLE_COMMITMENT, + ); + if (path) return [ps, path]; + throw new Error('Path should be defined'); + }); + + expect(() => + contract.grantRole(ROLE_ADMIN, ADMIN_ACCOUNT_ID), + ).toThrow( + 'ShieldedAccessControl: Path must contain leaf matching computed role commitment for the provided role, accountId pairing', + ); + }); + + it('when admin with duplicate grants is revoked', () => { + contract._grantRole(ROLE_ADMIN, ADMIN_ACCOUNT_ID); // duplicate + contract._revokeRole(ROLE_ADMIN, ADMIN_ACCOUNT_ID); + + expect(() => contract.grantRole(ROLE_OP1, OP1_ACCOUNT_ID)).toThrow( + 'ShieldedAccessControl: unauthorized account', + ); + }); + }); + + describe('should succeed', () => { + it('when caller has admin role', () => { + expect(() => + contract.grantRole(ROLE_OP1, OP1_ACCOUNT_ID), + ).not.toThrow(); + expect(contract._validateRole(ROLE_OP1, OP1_ACCOUNT_ID)).toBe(true); + }); + + it('when granting the same role multiple times to the same accountId', () => { + expect(() => + contract.grantRole(ROLE_OP1, OP1_ACCOUNT_ID), + ).not.toThrow(); + expect(() => + contract.grantRole(ROLE_OP1, OP1_ACCOUNT_ID), + ).not.toThrow(); + expect(() => + contract.grantRole(ROLE_OP1, OP1_ACCOUNT_ID), + ).not.toThrow(); + + expect(contract._validateRole(ROLE_OP1, OP1_ACCOUNT_ID)).toBe(true); + }); + + it('when caller has custom admin role', () => { + contract._setRoleAdmin(ROLE_OP2, ROLE_OP1); + contract.grantRole(ROLE_OP1, OP1_ACCOUNT_ID); + + // Switch to operator 1 + contract.privateState.injectSecretKey(OPERATOR_1_SK); + expect(() => + contract.grantRole(ROLE_OP2, OP2_ACCOUNT_ID), + ).not.toThrow(); + expect(contract._validateRole(ROLE_OP2, OP2_ACCOUNT_ID)).toBe(true); + }); + + it('when admin role is revoked and re-issued with new accountId', () => { + contract._revokeRole(ROLE_ADMIN, ADMIN_ACCOUNT_ID); + + const newKey = Buffer.alloc(32, 'NEW_ADMIN_KEY'); + contract.privateState.injectSecretKey(newKey); + const newAccountId = buildAccountIdHash(newKey); + contract._grantRole(ROLE_ADMIN, newAccountId); + + expect(() => + contract.grantRole(ROLE_OP1, OP1_ACCOUNT_ID), + ).not.toThrow(); + expect(contract._validateRole(ROLE_OP1, OP1_ACCOUNT_ID)).toBe(true); + }); + + it('when multiple admins exist', () => { + contract._grantRole(ROLE_ADMIN, OP1_ACCOUNT_ID); + contract._grantRole(ROLE_ADMIN, OP2_ACCOUNT_ID); + + // Admin 1 can grant + contract.privateState.injectSecretKey(OPERATOR_1_SK); + expect(() => + contract.grantRole(ROLE_OP1, OP1_ACCOUNT_ID), + ).not.toThrow(); + + // Admin 2 can grant + contract.privateState.injectSecretKey(OPERATOR_2_SK); + expect(() => + contract.grantRole(ROLE_OP2, OP2_ACCOUNT_ID), + ).not.toThrow(); + }); + + it('when admin holds multiple roles', () => { + contract._grantRole(ROLE_OP1, ADMIN_ACCOUNT_ID); + contract._grantRole(ROLE_OP2, ADMIN_ACCOUNT_ID); + + expect(() => + contract.grantRole(ROLE_OP3, OP3_ACCOUNT_ID), + ).not.toThrow(); + expect(contract._validateRole(ROLE_OP3, OP3_ACCOUNT_ID)).toBe(true); + }); + + it('when re-granting an active role (duplicate)', () => { + expect(() => + contract.grantRole(ROLE_ADMIN, ADMIN_ACCOUNT_ID), + ).not.toThrow(); + expect(contract._validateRole(ROLE_ADMIN, ADMIN_ACCOUNT_ID)).toBe( + true, + ); + }); + }); + }); + + describe('_grantRole', () => { + it('should insert commitment into Merkle tree', () => { + let root = contract + .getPublicState() + .ShieldedAccessControl__operatorRoles.root(); + expect(root.field).toBe(0n); + + contract._grantRole(ROLE_ADMIN, ADMIN_ACCOUNT_ID); + + root = contract + .getPublicState() + .ShieldedAccessControl__operatorRoles.root(); + expect(root.field).not.toBe(0n); + + const path = contract + .getPublicState() + .ShieldedAccessControl__operatorRoles.findPathForLeaf( + ADMIN_ROLE_COMMITMENT, + ); + expect(path).toBeDefined(); + expect(path?.leaf).toStrictEqual(ADMIN_ROLE_COMMITMENT); + }); + + it('should insert multiple commitments into Merkle tree', () => { + const root = contract + .getPublicState() + .ShieldedAccessControl__operatorRoles.root(); + expect(root.field).toBe(0n); + + contract._grantRole(ROLE_ADMIN, ADMIN_ACCOUNT_ID); + const root1 = contract + .getPublicState() + .ShieldedAccessControl__operatorRoles.root(); + expect(root1.field).not.toBe(root.field); + + contract._grantRole(ROLE_ADMIN, OP1_ACCOUNT_ID); + const root2 = contract + .getPublicState() + .ShieldedAccessControl__operatorRoles.root(); + expect(root2.field).not.toBe(root.field); + expect(root2.field).not.toBe(root1.field); + }); + + it('should insert multiple leaves for the same (role, accountId)', () => { + const rootBefore = contract + .getPublicState() + .ShieldedAccessControl__operatorRoles.root(); + + contract._grantRole(ROLE_OP1, OP1_ACCOUNT_ID); + const rootAfterFirst = contract + .getPublicState() + .ShieldedAccessControl__operatorRoles.root(); + + contract._grantRole(ROLE_OP1, OP1_ACCOUNT_ID); + const rootAfterSecond = contract + .getPublicState() + .ShieldedAccessControl__operatorRoles.root(); + + // Each grant should change the root (new leaf inserted) + expect(rootAfterFirst).not.toEqual(rootBefore); + expect(rootAfterSecond).not.toEqual(rootAfterFirst); + }); + + it('should invalidate all duplicates with a single revocation', () => { + contract._grantRole(ROLE_OP1, OP1_ACCOUNT_ID); + contract._grantRole(ROLE_OP1, OP1_ACCOUNT_ID); + contract._grantRole(ROLE_OP1, OP1_ACCOUNT_ID); + + expect(contract._validateRole(ROLE_OP1, OP1_ACCOUNT_ID)).toBe(true); + + contract._revokeRole(ROLE_OP1, OP1_ACCOUNT_ID); + + expect(contract._validateRole(ROLE_OP1, OP1_ACCOUNT_ID)).toBe(false); + }); + + it('should throw when granting to a revoked accountId', () => { + contract._grantRole(ROLE_ADMIN, ADMIN_ACCOUNT_ID); + contract._revokeRole(ROLE_ADMIN, ADMIN_ACCOUNT_ID); + + expect(() => contract._grantRole(ROLE_ADMIN, ADMIN_ACCOUNT_ID)).toThrow( + 'ShieldedAccessControl: role is already revoked', + ); + }); + + it('should not update tree when granting to a revoked accountId', () => { + contract._grantRole(ROLE_ADMIN, ADMIN_ACCOUNT_ID); + contract._revokeRole(ROLE_ADMIN, ADMIN_ACCOUNT_ID); + + const rootBefore = contract + .getPublicState() + .ShieldedAccessControl__operatorRoles.root(); + expect(() => contract._grantRole(ROLE_ADMIN, ADMIN_ACCOUNT_ID)).toThrow( + 'ShieldedAccessControl: role is already revoked', + ); + const rootAfter = contract + .getPublicState() + .ShieldedAccessControl__operatorRoles.root(); + expect(rootBefore).toEqual(rootAfter); + }); + + it('should allow granting same role to new accountId after revoking different accountId', () => { + contract._grantRole(ROLE_OP1, OP1_ACCOUNT_ID); + contract._revokeRole(ROLE_OP1, OP1_ACCOUNT_ID); + + // Different accountId for the same role + expect(() => + contract._grantRole(ROLE_OP1, OP2_ACCOUNT_ID), + ).not.toThrow(); + expect(contract._validateRole(ROLE_OP1, OP2_ACCOUNT_ID)).toBe(true); + }); + }); + + describe('revokeRole', () => { + beforeEach(() => { + contract._grantRole(ROLE_ADMIN, ADMIN_ACCOUNT_ID); + contract._grantRole(ROLE_OP1, OP1_ACCOUNT_ID); + }); + + describe('should fail', () => { + it('when caller does not have admin role', () => { + contract.privateState.injectSecretKey(UNAUTHORIZED_SK); + expect(() => contract.revokeRole(ROLE_OP1, OP1_ACCOUNT_ID)).toThrow( + 'ShieldedAccessControl: unauthorized account', + ); + }); + + it('when re-revoking an already revoked role', () => { + contract.revokeRole(ROLE_OP1, OP1_ACCOUNT_ID); + expect(() => contract.revokeRole(ROLE_OP1, OP1_ACCOUNT_ID)).toThrow( + 'ShieldedAccessControl: role is already revoked', + ); + }); + + it('when admin provides wrong secret key', () => { + contract.privateState.injectSecretKey(BAD_SK); + expect(() => contract.revokeRole(ROLE_OP1, OP1_ACCOUNT_ID)).toThrow( + 'ShieldedAccessControl: unauthorized account', + ); + }); + + it('when admin provides invalid witness path', () => { + contract.overrideWitness( + 'wit_getRoleCommitmentPath', + RETURN_BAD_PATH, + ); + expect(() => contract.revokeRole(ROLE_OP1, OP1_ACCOUNT_ID)).toThrow( + 'ShieldedAccessControl: unauthorized account', + ); + }); + }); + + describe('should succeed', () => { + it('when caller has admin role', () => { + expect(() => + contract.revokeRole(ROLE_OP1, OP1_ACCOUNT_ID), + ).not.toThrow(); + expect(contract._validateRole(ROLE_OP1, OP1_ACCOUNT_ID)).toBe(false); + }); + + it('when caller has custom admin role', () => { + contract._setRoleAdmin(ROLE_OP2, ROLE_OP1); + contract._grantRole(ROLE_OP2, OP2_ACCOUNT_ID); + + contract.privateState.injectSecretKey(OPERATOR_1_SK); + contract._grantRole(ROLE_OP1, OP1_ACCOUNT_ID); + + expect(() => + contract.revokeRole(ROLE_OP2, OP2_ACCOUNT_ID), + ).not.toThrow(); + expect(contract._validateRole(ROLE_OP2, OP2_ACCOUNT_ID)).toBe(false); + }); + + it('when admin self-revokes then cannot further grant or revoke', () => { + contract.revokeRole(ROLE_ADMIN, ADMIN_ACCOUNT_ID); + + expect(() => contract.grantRole(ROLE_OP1, OP1_ACCOUNT_ID)).toThrow( + 'ShieldedAccessControl: unauthorized account', + ); + expect(() => contract.revokeRole(ROLE_OP1, OP1_ACCOUNT_ID)).toThrow( + 'ShieldedAccessControl: unauthorized account', + ); + }); + + it('when revoking a role that was never granted', () => { + expect(() => + contract.revokeRole(ROLE_NONEXISTENT, ADMIN_ACCOUNT_ID), + ).not.toThrow(); + expect( + contract._validateRole(ROLE_NONEXISTENT, ADMIN_ACCOUNT_ID), + ).toBe(false); + }); + + it('when revoking a never-granted role should permanently block future grants', () => { + contract.revokeRole(ROLE_NONEXISTENT, OP2_ACCOUNT_ID); + + expect(() => + contract._grantRole(ROLE_NONEXISTENT, OP2_ACCOUNT_ID), + ).toThrow('ShieldedAccessControl: role is already revoked'); + }); + + it('when admin role is revoked and re-issued then can revoke again', () => { + contract._revokeRole(ROLE_ADMIN, ADMIN_ACCOUNT_ID); + + const newKey = Buffer.alloc(32, 'NEW_ADMIN_KEY'); + contract.privateState.injectSecretKey(newKey); + const newAccountId = buildAccountIdHash(newKey); + contract._grantRole(ROLE_ADMIN, newAccountId); + + expect(() => + contract.revokeRole(ROLE_OP1, OP1_ACCOUNT_ID), + ).not.toThrow(); + expect(contract._validateRole(ROLE_OP1, OP1_ACCOUNT_ID)).toBe(false); + }); + }); + }); + + describe('_revokeRole', () => { + beforeEach(() => { + contract._grantRole(ROLE_ADMIN, ADMIN_ACCOUNT_ID); + }); + + it('should insert nullifier into set', () => { + expect( + contract + .getPublicState() + .ShieldedAccessControl__roleCommitmentNullifiers.size(), + ).toBe(0n); + + contract._revokeRole(ROLE_ADMIN, ADMIN_ACCOUNT_ID); + + expect( + contract + .getPublicState() + .ShieldedAccessControl__roleCommitmentNullifiers.size(), + ).toBe(1n); + expect( + contract + .getPublicState() + .ShieldedAccessControl__roleCommitmentNullifiers.member( + ADMIN_ROLE_NULLIFIER, + ), + ).toBe(true); + }); + + it('should throw when re-revoking', () => { + contract._revokeRole(ROLE_ADMIN, ADMIN_ACCOUNT_ID); + expect(() => + contract._revokeRole(ROLE_ADMIN, ADMIN_ACCOUNT_ID), + ).toThrow('ShieldedAccessControl: role is already revoked'); + }); + + it('should not update nullifier set when re-revoking', () => { + contract._revokeRole(ROLE_ADMIN, ADMIN_ACCOUNT_ID); + const sizeBefore = contract + .getPublicState() + .ShieldedAccessControl__roleCommitmentNullifiers.size(); + + expect(() => + contract._revokeRole(ROLE_ADMIN, ADMIN_ACCOUNT_ID), + ).toThrow(); + const sizeAfter = contract + .getPublicState() + .ShieldedAccessControl__roleCommitmentNullifiers.size(); + expect(sizeBefore).toEqual(sizeAfter); + }); + + it('should allow revoking a role that was never granted', () => { + expect(() => + contract._revokeRole(ROLE_NONEXISTENT, ADMIN_ACCOUNT_ID), + ).not.toThrow(); + }); + }); + + describe('renounceRole', () => { + beforeEach(() => { + contract._grantRole(ROLE_ADMIN, ADMIN_ACCOUNT_ID); + }); + + it('should allow caller to renounce their own role', () => { + expect(() => + contract.renounceRole(ROLE_ADMIN, ADMIN_ACCOUNT_ID), + ).not.toThrow(); + expect(contract._validateRole(ROLE_ADMIN, ADMIN_ACCOUNT_ID)).toBe( + false, + ); + }); + + it('should update nullifier set', () => { + expect( + contract + .getPublicState() + .ShieldedAccessControl__roleCommitmentNullifiers.size(), + ).toBe(0n); + contract.renounceRole(ROLE_ADMIN, ADMIN_ACCOUNT_ID); + expect( + contract + .getPublicState() + .ShieldedAccessControl__roleCommitmentNullifiers.size(), + ).toBe(1n); + expect( + contract + .getPublicState() + .ShieldedAccessControl__roleCommitmentNullifiers.member( + ADMIN_ROLE_NULLIFIER, + ), + ).toBe(true); + }); + + it('should fail when caller provides wrong accountId', () => { + expect(() => contract.renounceRole(ROLE_ADMIN, BAD_ACCOUNT_ID)).toThrow( + 'ShieldedAccessControl: bad confirmation', + ); + }); + + it('should fail when caller has wrong secret key', () => { + contract.privateState.injectSecretKey(UNAUTHORIZED_SK); + expect(() => + contract.renounceRole(ROLE_ADMIN, ADMIN_ACCOUNT_ID), + ).toThrow('ShieldedAccessControl: bad confirmation'); + }); + + it('should throw when role is already revoked', () => { + contract._revokeRole(ROLE_ADMIN, ADMIN_ACCOUNT_ID); + expect(() => + contract.renounceRole(ROLE_ADMIN, ADMIN_ACCOUNT_ID), + ).toThrow('ShieldedAccessControl: role is already revoked'); + }); + + it('should permanently block re-grant to same accountId', () => { + contract.renounceRole(ROLE_ADMIN, ADMIN_ACCOUNT_ID); + expect(() => contract._grantRole(ROLE_ADMIN, ADMIN_ACCOUNT_ID)).toThrow( + 'ShieldedAccessControl: role is already revoked', + ); + }); + + it('should allow re-grant with new accountId after renounce', () => { + contract.renounceRole(ROLE_ADMIN, ADMIN_ACCOUNT_ID); + + const newKey = Buffer.alloc(32, 'NEW_ADMIN_KEY'); + contract.privateState.injectSecretKey(newKey); + const newAccountId = buildAccountIdHash(newKey); + contract._grantRole(ROLE_ADMIN, newAccountId); + + expect(contract._validateRole(ROLE_ADMIN, newAccountId)).toBe(true); + }); + + it('should not affect other roles held by same accountId', () => { + contract._grantRole(ROLE_OP1, ADMIN_ACCOUNT_ID); + contract._grantRole(ROLE_OP2, ADMIN_ACCOUNT_ID); + + contract.renounceRole(ROLE_ADMIN, ADMIN_ACCOUNT_ID); + + expect(contract._validateRole(ROLE_ADMIN, ADMIN_ACCOUNT_ID)).toBe( + false, + ); + expect(contract._validateRole(ROLE_OP1, ADMIN_ACCOUNT_ID)).toBe(true); + expect(contract._validateRole(ROLE_OP2, ADMIN_ACCOUNT_ID)).toBe(true); + }); + + // Pre-burn scenario: a user can burn a nullifier for a (role, accountId) pairing + // that was never granted. This permanently blocks future grants to that accountId + // for the specified role, but does not affect other accountIds holding the same role + it('should allow renouncing a role never granted to this accountId', () => { + // OP1 has ROLE_OP1, but ADMIN does not + contract._grantRole(ROLE_OP1, OP1_ACCOUNT_ID); + + // ADMIN renounces ROLE_OP1 despite never holding it + expect(() => + contract.renounceRole(ROLE_OP1, ADMIN_ACCOUNT_ID), + ).not.toThrow(); + + // OP1's grant is unaffected — different accountId, different nullifier + expect(contract._validateRole(ROLE_OP1, OP1_ACCOUNT_ID)).toBe(true); + + // ADMIN's accountId is now burned for ROLE_OP1 + expect(() => contract._grantRole(ROLE_OP1, ADMIN_ACCOUNT_ID)).toThrow( + 'ShieldedAccessControl: role is already revoked', + ); + }); + }); + + describe('getRoleAdmin', () => { + it('should return DEFAULT_ADMIN_ROLE when no admin set', () => { + expect(contract.getRoleAdmin(ROLE_OP1)).toStrictEqual( + new Uint8Array(32), + ); + expect(contract.getRoleAdmin(ROLE_OP1)).toStrictEqual( + contract.DEFAULT_ADMIN_ROLE(), + ); + }); + + it('should restore DEFAULT_ADMIN_ROLE grant/revoke authority after reset to zero bytes', () => { + contract._grantRole(ROLE_ADMIN, ADMIN_ACCOUNT_ID); + + // Reassign OP1's admin to OP2 + contract._setRoleAdmin(ROLE_OP1, ROLE_OP2); + + // DEFAULT_ADMIN_ROLE holder cannot grant ROLE_OP1 anymore + expect(() => contract.grantRole(ROLE_OP1, OP1_ACCOUNT_ID)).toThrow( + 'ShieldedAccessControl: unauthorized account', + ); + + // Reset OP1's admin back to DEFAULT_ADMIN_ROLE + contract._setRoleAdmin(ROLE_OP1, new Uint8Array(32)); + + // DEFAULT_ADMIN_ROLE holder can grant ROLE_OP1 again + expect(() => + contract.grantRole(ROLE_OP1, OP1_ACCOUNT_ID), + ).not.toThrow(); + expect(contract._validateRole(ROLE_OP1, OP1_ACCOUNT_ID)).toBe(true); + + // And can revoke + expect(() => + contract.revokeRole(ROLE_OP1, OP1_ACCOUNT_ID), + ).not.toThrow(); + expect(contract._validateRole(ROLE_OP1, OP1_ACCOUNT_ID)).toBe(false); + }); + + it('should return admin role after _setRoleAdmin', () => { + contract._setRoleAdmin(ROLE_OP1, ROLE_ADMIN); + expect(contract.getRoleAdmin(ROLE_OP1)).toEqual( + new Uint8Array(ROLE_ADMIN), + ); + }); + }); + + describe('_setRoleAdmin', () => { + it('should set admin role', () => { + contract._setRoleAdmin(ROLE_OP1, ROLE_ADMIN); + expect(contract.getRoleAdmin(ROLE_OP1)).toEqual( + new Uint8Array(ROLE_ADMIN), + ); + }); + + it('should update _adminRoles map', () => { + expect( + contract.getPublicState().ShieldedAccessControl__adminRoles.isEmpty(), + ).toBe(true); + + contract._setRoleAdmin(ROLE_OP1, ROLE_ADMIN); + contract._setRoleAdmin(ROLE_OP2, ROLE_ADMIN); + contract._setRoleAdmin(ROLE_OP3, ROLE_ADMIN); + + expect( + contract.getPublicState().ShieldedAccessControl__adminRoles.size(), + ).toBe(3n); + }); + + it('should override existing admin role', () => { + contract._setRoleAdmin(ROLE_OP1, ROLE_ADMIN); + contract._setRoleAdmin(ROLE_OP1, ROLE_OP2); + expect(contract.getRoleAdmin(ROLE_OP1)).toEqual( + new Uint8Array(ROLE_OP2), + ); + }); + + it('should return DEFAULT_ADMIN_ROLE when reset to zero bytes', () => { + contract._setRoleAdmin(ROLE_OP1, ROLE_ADMIN); + contract._setRoleAdmin(ROLE_OP1, new Uint8Array(32)); + expect(contract.getRoleAdmin(ROLE_OP1)).toStrictEqual( + contract.DEFAULT_ADMIN_ROLE(), + ); + }); + + it('should allow a role to be its own admin', () => { + contract._setRoleAdmin(ROLE_OP1, ROLE_OP1); + expect(contract.getRoleAdmin(ROLE_OP1)).toEqual( + new Uint8Array(ROLE_OP1), + ); + }); + + it('when new admin revokes after _setRoleAdmin reassignment', () => { + contract._setRoleAdmin(ROLE_OP2, ROLE_OP1); + contract._grantRole(ROLE_OP1, OP1_ACCOUNT_ID); + contract._grantRole(ROLE_OP2, OP2_ACCOUNT_ID); + + // Switch to operator 1 who is now admin of ROLE_OP2 + contract.privateState.injectSecretKey(OPERATOR_1_SK); + expect(() => + contract.revokeRole(ROLE_OP2, OP2_ACCOUNT_ID), + ).not.toThrow(); + expect(contract._validateRole(ROLE_OP2, OP2_ACCOUNT_ID)).toBe(false); + }); + + it('admin authority should not be transitive across role hierarchies', () => { + contract._grantRole(ROLE_ADMIN, ADMIN_ACCOUNT_ID); + contract._setRoleAdmin(ROLE_OP2, ROLE_OP1); + contract._grantRole(ROLE_OP1, OP1_ACCOUNT_ID); + + // ADMIN can grant ROLE_OP1 (admin is DEFAULT_ADMIN_ROLE) + expect(() => + contract.grantRole(ROLE_OP1, OP2_ACCOUNT_ID), + ).not.toThrow(); + + // But ADMIN cannot directly grant ROLE_OP2 (admin is ROLE_OP1, not DEFAULT_ADMIN_ROLE) + expect(() => contract.grantRole(ROLE_OP2, OP3_ACCOUNT_ID)).toThrow( + 'ShieldedAccessControl: unauthorized account', + ); + + // OP1 holder can grant ROLE_OP2 + contract.privateState.injectSecretKey(OPERATOR_1_SK); + expect(() => + contract.grantRole(ROLE_OP2, OP3_ACCOUNT_ID), + ).not.toThrow(); + }); + }); + + describe('single key across multiple roles', () => { + beforeEach(() => { + contract._grantRole(ROLE_ADMIN, ADMIN_ACCOUNT_ID); + contract._grantRole(ROLE_OP1, ADMIN_ACCOUNT_ID); + contract._grantRole(ROLE_OP2, ADMIN_ACCOUNT_ID); + contract._grantRole(ROLE_OP3, ADMIN_ACCOUNT_ID); + }); + + it('should validate all roles for same accountId', () => { + expect(contract._validateRole(ROLE_ADMIN, ADMIN_ACCOUNT_ID)).toBe(true); + expect(contract._validateRole(ROLE_OP1, ADMIN_ACCOUNT_ID)).toBe(true); + expect(contract._validateRole(ROLE_OP2, ADMIN_ACCOUNT_ID)).toBe(true); + expect(contract._validateRole(ROLE_OP3, ADMIN_ACCOUNT_ID)).toBe(true); + }); + + it('should prove all roles with same key', () => { + expect(contract.canProveRole(ROLE_ADMIN)).toBe(true); + expect(contract.canProveRole(ROLE_OP1)).toBe(true); + expect(contract.canProveRole(ROLE_OP2)).toBe(true); + expect(contract.canProveRole(ROLE_OP3)).toBe(true); + }); + + it('revoking one role should not affect others', () => { + contract._revokeRole(ROLE_OP2, ADMIN_ACCOUNT_ID); + + expect(contract.canProveRole(ROLE_ADMIN)).toBe(true); + expect(contract.canProveRole(ROLE_OP1)).toBe(true); + expect(contract.canProveRole(ROLE_OP2)).toBe(false); + expect(contract.canProveRole(ROLE_OP3)).toBe(true); + }); + }); + + describe('mock/module equivalence', () => { + beforeEach(() => { + contract._grantRole(ROLE_ADMIN, ADMIN_ACCOUNT_ID); + contract._grantRole(ROLE_OP1, OP1_ACCOUNT_ID); + }); + + it('_computeAccountId should match computeAccountId', () => { + const fromInternal = contract._computeAccountId(); + const fromPure = contract.computeAccountId(ADMIN_SK, INSTANCE_SALT); + expect(fromInternal).toEqual(fromPure); + }); + + it('_uncheckedCanProveRole should match canProveRole for granted role', () => { + expect(contract._uncheckedCanProveRole(ROLE_ADMIN)).toBe( + contract.canProveRole(ROLE_ADMIN), + ); + }); + + it('_uncheckedCanProveRole should match canProveRole for ungranted role', () => { + expect(contract._uncheckedCanProveRole(ROLE_OP2)).toBe( + contract.canProveRole(ROLE_OP2), + ); + }); + + it('_uncheckedCanProveRole should match canProveRole for revoked role', () => { + contract._revokeRole(ROLE_ADMIN, ADMIN_ACCOUNT_ID); + expect(contract._uncheckedCanProveRole(ROLE_ADMIN)).toBe( + contract.canProveRole(ROLE_ADMIN), + ); + }); + + it('_validateRole should be consistent with canProveRole', () => { + // canProveRole internally computes accountId then calls _validateRole + // so for the correct key, they should agree + expect(contract._validateRole(ROLE_ADMIN, ADMIN_ACCOUNT_ID)).toBe( + contract.canProveRole(ROLE_ADMIN), + ); + }); + + it('_validateRole should be consistent with canProveRole after revocation', () => { + contract._revokeRole(ROLE_OP1, OP1_ACCOUNT_ID); + contract.privateState.injectSecretKey(OPERATOR_1_SK); + expect(contract._validateRole(ROLE_OP1, OP1_ACCOUNT_ID)).toBe( + contract.canProveRole(ROLE_OP1), + ); + }); + + it('_validateRole should match canProveRole with malicious witness path', () => { + contract._grantRole(ROLE_OP1, OP1_ACCOUNT_ID); + contract.overrideWitness('wit_getRoleCommitmentPath', () => { + const ps = contract.getPrivateState(); + const path = contract + .getPublicState() + .ShieldedAccessControl__operatorRoles.findPathForLeaf( + OP1_ROLE_COMMITMENT, + ); + if (path) return [ps, path]; + throw new Error('Path should be defined'); + }); + + // Both should throw the same error for wrong-leaf path + expect(() => + contract._validateRole(ROLE_ADMIN, ADMIN_ACCOUNT_ID), + ).toThrow( + 'ShieldedAccessControl: Path must contain leaf matching computed role commitment for the provided role, accountId pairing', + ); + expect(() => contract.canProveRole(ROLE_ADMIN)).toThrow( + 'ShieldedAccessControl: Path must contain leaf matching computed role commitment for the provided role, accountId pairing', + ); + }); + + it('_validateRole should match canProveRole with invalid witness path', () => { + contract.overrideWitness('wit_getRoleCommitmentPath', RETURN_BAD_PATH); + expect(contract._validateRole(ROLE_ADMIN, ADMIN_ACCOUNT_ID)).toBe( + contract.canProveRole(ROLE_ADMIN), + ); + }); + + it('_computeAccountId should match computeAccountId after key rotation', () => { + const newKey = Buffer.alloc(32, 'ROTATED_KEY'); + contract.privateState.injectSecretKey(newKey); + + const fromInternal = contract._computeAccountId(); + const fromPure = contract.computeAccountId(newKey, INSTANCE_SALT); + expect(fromInternal).toEqual(fromPure); + }); + }); + }); +}); 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..c5a47f1c --- /dev/null +++ b/contracts/src/access/test/mocks/MockShieldedAccessControl.compact @@ -0,0 +1,156 @@ +// 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_; + +export { MerkleTreePath, + ShieldedAccessControl__operatorRoles, + ShieldedAccessControl__roleCommitmentNullifiers, + ShieldedAccessControl__adminRoles }; + +// witnesses are re-implemented in the Mock contract for testing +witness wit_getRoleCommitmentPath( + roleCommitment: ShieldedAccessControl_RoleCommitment + ): MerkleTreePath<20, ShieldedAccessControl_RoleCommitment>; + +witness wit_secretKey(): 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(); + 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 + ): [] { + 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 + ): [] { + return ShieldedAccessControl__revokeRole(role, accountId); +} + +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 (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(): ShieldedAccessControl_AccountIdentifier { + return disclose(ShieldedAccessControl_computeAccountId(wit_secretKey(), ShieldedAccessControl__instanceSalt)); +} + +export pure circuit computeAccountId( + secretKey: Bytes<32>, + instanceSalt: Bytes<32> + ): ShieldedAccessControl_AccountIdentifier { + + return ShieldedAccessControl_computeAccountId(secretKey, 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..ad6b6cab --- /dev/null +++ b/contracts/src/access/test/simulators/ShieldedAccessControlSimulator.ts @@ -0,0 +1,189 @@ +import type { MerkleTreePath } from '@midnight-ntwrk/compact-runtime'; +import { + type BaseSimulatorOptions, + createSimulator, +} from '@openzeppelin-compact/contracts-simulator'; +import { + ledger, + Contract as MockShieldedAccessControl, +} 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) { + 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) { + this.circuits.impure._revokeRole(role, accountId); + } + + 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(): Uint8Array { + return this.circuits.impure._computeAccountId(); + } + + public computeAccountId( + secretKey: Uint8Array, + instanceSalt: Uint8Array, + ): Uint8Array { + return this.circuits.pure.computeAccountId(secretKey, instanceSalt); + } + + public readonly privateState = { + /** + * @description Replaces the secret key in the private state. Used in tests to + * simulate switching between different user identities or injecting incorrect + * keys to test failure paths. + * @param newSK - The new secret key to set. + * @returns The updated private state. + */ + injectSecretKey: ( + newSK: Buffer, + ): ShieldedAccessControlPrivateState => { + const updatedState = { secretKey: newSK }; + this.circuitContextManager.updatePrivateState(updatedState); + return updatedState; + }, + + /** + * @description Returns the current secret key from the private state. + * @returns The secret key. + * @throws If the secret key is undefined. + */ + getCurrentSecretKey: (): Uint8Array => { + const sk = this.getPrivateState().secretKey; + if (typeof sk === 'undefined') { + throw new Error('Missing secret key'); + } + return sk; + }, + + /** + * @description Searches the `_operatorRoles` Merkle tree for a leaf matching + * the given role commitment using the ledger's `findPathForLeaf` method. + * Returns the path if found, undefined otherwise. + * @param roleCommitment - The role commitment to search for. + * @returns The Merkle tree path if the commitment exists, undefined otherwise. + */ + getCommitmentPathWithFindForLeaf: ( + roleCommitment: Uint8Array, + ): MerkleTreePath | undefined => { + return this.getPublicState().ShieldedAccessControl__operatorRoles.findPathForLeaf( + roleCommitment, + ); + }, + + /** + * @description Returns the Merkle tree path for a given role commitment using + * the witness implementation. Used to verify that the witness produces the + * expected path, or to compare against `getCommitmentPathWithFindForLeaf` + * to detect witness overrides or mismatches. + * @param roleCommitment - The role commitment to find a path for. + * @returns The Merkle tree path as returned by the witness. + */ + 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..1babe062 --- /dev/null +++ b/contracts/src/access/witnesses/ShieldedAccessControlWitnesses.ts @@ -0,0 +1,125 @@ +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 { + /** + * Returns the user's secret key from the private state. + * The same key is used across all roles within a contract instance. + * @param context - The witness context containing the private state. + * @returns A tuple of the private state and the secret key as a Uint8Array. + */ + wit_secretKey(context: WitnessContext): [P, Uint8Array]; + + /** + * Returns a Merkle tree path for a given role commitment. + * @param context - The witness context containing the private state and ledger. + * @param roleCommitment - The role commitment to find a path for. + * @returns A tuple of the private state and the Merkle tree path. + */ + wit_getRoleCommitmentPath( + context: WitnessContext, + roleCommitment: Uint8Array, + ): [P, MerkleTreePath]; +} + +/** + * @description Represents the private state of a Shielded AccessControl contract. + * Contains a single secret key used to derive the user's account identifier. + * The same key is used across all roles within a contract instance. + */ +export type ShieldedAccessControlPrivateState = { + /** @description A 32-byte secret key used to derive the shielded account identifier. */ + secretKey: Uint8Array; +}; + +/** + * @description Utility object for managing the private state of a Shielded AccessControl contract. + */ +export const ShieldedAccessControlPrivateState = { + /** + * @description Generates a new private state with a cryptographically random secret key. + * @returns A fresh ShieldedAccessControlPrivateState instance. + */ + generate: (): ShieldedAccessControlPrivateState => ({ + secretKey: new Uint8Array(getRandomValues(Buffer.alloc(32))), + }), + + /** + * @description Creates a new private state with a user-defined secret key. + * Useful for deterministic key generation in testing or advanced use cases. + * + * @param sk - The 32-byte secret key to use. + * @returns A fresh ShieldedAccessControlPrivateState instance with the provided key. + * + * @example + * ```typescript + * const deterministicKey = myDeterministicScheme(...); + * const privateState = ShieldedAccessControlPrivateState.withSecretKey(deterministicKey); + * ``` + */ + withSecretKey: (sk: Uint8Array): ShieldedAccessControlPrivateState => ({ + secretKey: sk, + }), + + /** + * @description Returns the Merkle tree path for a given role commitment, or a default + * invalid path if the commitment is not found in the tree. + * + * @param ledger - The contract ledger containing the operator roles Merkle tree. + * @param roleCommitment - The role commitment to search for. + * @returns The Merkle tree path if found, otherwise a default invalid path. + */ + 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_secretKey( + context: WitnessContext, + ): [ShieldedAccessControlPrivateState, Uint8Array] { + return [context.privateState, context.privateState.secretKey]; + }, + + 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(), }); /**