diff --git a/contracts/src/access/ShieldedAccessControl.compact b/contracts/src/access/ShieldedAccessControl.compact new file mode 100644 index 00000000..2986cda6 --- /dev/null +++ b/contracts/src/access/ShieldedAccessControl.compact @@ -0,0 +1,537 @@ +// SPDX-License-Identifier: MIT + +pragma language_version >= 0.16.0; + +/** + * @module Shielded AccessControl + * @description A Shielded AccessControl library. + * This module provides a shielded role-based access control 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 holder. Role commitments are created with the following + * hashing scheme SHA256(roleId | account | nonce | merkleTreeIndex). + * + * @notice Using the SHA256 hashing function comes at a significant performace cost. In the future, we + * plan on migrating to a ZK-friendly hashing function when an implementation is available. + * + * 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: + * + * ```typescript + * import CompactStandardLibrary; + * import "./node_modules/@openzeppelin-compact/accessControl/src/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}: + * + * ```typescript + * circuit foo(): [] { + * assertOnlyRole(MY_ROLE); + * ... + * } + * ``` + * + * 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}. To set a custom `DEFAULT_ADMIN_ROLE`, implement the `Initializable` + * module and set `DEFAULT_ADMIN_ROLE` in the `initialize()` circuit. + * + * 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. + * + * @notice Roles can only be granted to ZswapCoinPublicKeys + * through the main role approval circuits (`grantRole` and `_grantRole`). + * In other words, role approvals to contract addresses are disallowed through these + * circuits. + * This is because Compact currently does not support contract-to-contract calls which means + * if a contract is granted a role, the contract cannot directly call the protected + * circuit. + * + * @notice This module does offer an experimental circuit that allows roles to be granted + * to contract addresses (`_unsafeGrantRole`). + * Note that the circuit name is very explicit ("unsafe") with this experimental circuit. + * Until contract-to-contract calls are supported, + * there is no direct way for a contract to call protected circuits. + * + * @notice The unsafe circuits are planned to become deprecated once contract-to-contract calls + * are supported. + * + * @notice Missing Features and Improvements: + * + * - Role events + * - An ERC165-like interface + */ +module ShieldedAccessControl { + import CompactStandardLibrary; + import "ShieldedAccessControlUtils" prefix Utils_; + + /** + * @description A Merkle tree of role commitments stored as SHA256(roleId | account | nonce | merkleTreeIndex) + * @type {Bytes<32>} roleCommitment - A role commitment created by the following hash: SHA256(roleId | account | nonce | merkleTreeIndex). + * @type {MerkleTree<10, roleCommitment>} + * @type {MerkleTree<10, Bytes<32>>} _operatorRoles +  */ + export ledger _operatorRoles: MerkleTree<10, Bytes<32>>; + + /** + * @description Mapping from a role identifier to an admin role identifier. + * @type {Bytes<32>} roleId - A hash representing a role identifier. + * @type {Bytes<32>} adminId - A hash representing an admin identifier. + * @type {Map} + * @type {Map, Bytes<32>>} _adminRoles +  */ + export ledger _adminRoles: Map, Bytes<32>>; + + /** + * @description A set of nullifiers used to revoke the permissions of a role + * @type {Bytes<32> roleCommitment - A role commitment created by the following hash: SHA256(roleId | account | nonce | merkleTreeIndex). + * @type {Set} _roleCommitmentNullifiers +  */ + export ledger _roleCommitmentNullifiers: Set>; + + export ledger _currentMerkleTreeIndex: Counter; + + export ledger DEFAULT_ADMIN_ROLE: Bytes<32>; + + /** + * @description Returns a Merkle path in the `_operatorRoles` Merkle tree, given the knowledge that a `roleCommitment` is at the given index. + * + * @param {Bytes<32>} roleCommitment - A commitment created by the following hash: SHA256(roleId | account | nonce | merkleTreeIndex). + * @param {Uint<64>} index - An index in the `_operatorRoles` Merkle tree + * @return {MerkleTreePath<10, Bytes<32>>} - The Merkle path of `roleCommitment` in the `_operatorRoles` Merkle tree +  */ + witness wit_getRoleCommitmentPath(roleCommitment: Bytes<32>): MerkleTreePath<10, Bytes<32>>; + + witness wit_secretNonce(roleId: Bytes<32>): Bytes<32>; + + witness wit_getRoleIndex(roleId: Bytes<32> , accountId: Bytes<32>): Uint<64>; + + export struct Role { + isApproved: Boolean; + roleCommitment: Bytes<32>; + commitmentNullifier: Bytes<32>; + } + + /** + * @description Computes the owner commitment from the given `id` and `counter`. + * + * ## Owner ID (`id`) + * The `id` is expected to be computed off-chain as: + * `id = SHA256(pk, nonce)` + * + * - `pk`: The owner's public key. + * - `nonce`: A secret nonce scoped to the instance, ideally rotated with each transfer. + * + * ## Commitment Derivation + * `commitment = SHA256(id, instanceSalt, counter, domain)` + * + * - `id`: See above. + * - `instanceSalt`: A unique per-deployment salt, stored during initialization. + * This prevents commitment collisions across deployments. + * - `counter`: Incremented with each ownership transfer, ensuring uniqueness + * even with repeated `id` values. Cast to `Field` then `Bytes<32>` for hashing. + * - `domain`: Domain separator `"ZOwnablePK:shield:"` (padded to 32 bytes) to prevent + * hash collisions when extending the module or using similar commitment schemes. + * + * @circuitInfo k=14, rows=14853 + * + * Requirements: + * + * - Contract is initialized. + * + * @param {Bytes<32>} id - The unique identifier of the owner calculated by `SHA256(pk, nonce)`. + * @param {Uint<64>} counter - The current counter or round. This increments by `1` + * after every transfer to prevent duplicate commitments given the same `id`. + * @returns {Bytes<32>} The commitment derived from `id` and `counter`. + */ + export pure circuit _computeRoleCommitment( + accountId: Bytes<32>, + roleId: Bytes<32>, + index: Uint<64>, + ): Bytes<32> { + return persistentHash>>( + [ + accountId, + roleId, + index as Field as Bytes<32>, + pad(32, "ShieldedAccessControl:commitment") + ] + ); + } + + export pure circuit _computeNullifier(roleCommitment: Bytes<32>): Bytes<32> { + return persistentHash>>([roleCommitment, pad(32, "ShieldedAccessControl:nullifier")]); + } + + /** + * @description Computes the unique identifier (`id`) of the owner from their + * public key and a secret nonce. + * + * ## ID Derivation + * `id = SHA256(pk, nonce)` + * + * - `pk`: The public key of the caller. This is passed explicitly to allow + * for off-chain derivation, testing, or scenarios where the caller is + * different from the subject of the computation. + * We recommend using an Air-Gapped Public Key. + * - `nonce`: A secret nonce tied to the identity. The generation strategy is + * left to the user, offering different security/convenience trade-offs. + * + * The result is a 32-byte commitment that uniquely identifies the owner. + * This value is later used in owner commitment hashing, + * and acts as a privacy-preserving alternative to a raw public key. + * + * @notice This module allows ownership to be tied to an identity commitment derived + * from a public key and secret nonce. + * While typically used with user public keys, this mechanism may also + * support contract addresses as identifiers in future contract-to-contract + * interactions. Both are treated as 32-byte values (`Bytes<32>`). + * + * Requirements: + * + * - `pk` is not a ContractAddress. + * + * @param {Either} pk - The public key of the identity being committed. + * @param {Bytes<32>} nonce - A private nonce to scope the commitment. + * @returns {Bytes<32>} The computed owner ID. + */ + export pure circuit _computeRoleId( + pk: Either, + nonce: Bytes<32> + ): Bytes<32> { + assert(pk.is_left, "ShieldedAccessControl: contract address owners are not yet supported"); + + return persistentHash>>([pk.left.bytes, nonce]); + } + + /** + * @description Returns `true` if `account` has been granted `roleId`. + * + * @circuitInfo k=16, rows=60150 + * + * Requirements: + * + * - An index for the intermediate role commitment produced by SHA256(roleId | account | nonce) + * must exist in the `_roleCommitmentIndex` map. + * - A nullifier for the role commitment produced by SHA256(roleId | account | nonce) + * must not exist in the `_roleCommitmentNullifiers` set. + * - A path for the role commitment produced by SHA256(roleId | account | nonce) must + * exist at `index` in the `_operatorRoles` Merkle tree. + * + * Disclosures: + * + * - The intermediate role commitment produced by SHA256(roleId | account | nonce). + * - The role commitment produced by SHA256(roleId | account | nonce). + * - The Merkle tree path for the role commitment stored at `index` in the `_operatorRoles` + * Merkle tree. + * - The type data of `account` - a ZswapCoinPublicKey or ContractAddress. + * + * @param {Bytes<32>} roleId - The role identifier. + * @param {Either} account - The account to check. + * @param {Bytes<32>} nonce - A nonce created using SHA256(SK | "role-nonce" | role | PK). + * @return {Boolean} - A boolean determining if the account has the specified role. +  */ + export circuit callerHasRole(roleId: Bytes<32>): Role { + const callerAsEither = Either { + is_left: true, + left: ownPublicKey(), + right: ContractAddress { bytes: pad(32, "") } + }; + const nonce = wit_secretNonce(roleId); + const accountId = _computeRoleId(callerAsEither, nonce); + return getRole(roleId, accountId); + } + + export circuit hasRole(roleId: Bytes<32>, accountId: Bytes<32>): Boolean { + const roleInfo = getRole(roleId, accountId); + return roleInfo.isApproved; + } + + /** + * @description Reverts if caller is missing `roleId`. + * + * @circuitInfo k=15, rows=29780 + * + * Requirements: + * + * - An index for the intermediate role commitment produced by SHA256(roleId | account | nonce) + * must exist in the `_roleCommitmentIndex` map. + * - A nullifier for the role commitment produced by SHA256(roleId | account | nonce) + * must not exist in the `_roleCommitmentNullifiers` set. + * - A path for the role commitment produced by SHA256(roleId | account | nonce) must + * exist at `index` in the `_operatorRoles` Merkle tree. + * - The caller must not be a ContractAddress. + * + * Disclosures: + * + * - The intermediate role commitment produced by SHA256(roleId | account | nonce). + * - The role commitment produced by SHA256(roleId | account | nonce). + * - The Merkle tree path for the role commitment stored at `index` in the `_operatorRoles` + * Merkle tree. + * - The type data of `account` - a ZswapCoinPublicKey or ContractAddress. + * + * @param {Bytes<32>} roleId - The role identifier. + * @param {Bytes<32>} nonce - A nonce created using SHA256(SK | "role-nonce" | role | PK) + * @return {[]} - Empty tuple. + */ + export circuit assertOnlyRole(roleId: Bytes<32>): [] { + const role = callerHasRole(roleId); + assert(role.isApproved, "ShieldedAccessControl: unauthorized account"); + } + + export circuit getRole(roleId: Bytes<32>, accountId: Bytes<32>): Role { + const index = wit_getRoleIndex(roleId, accountId); + const commitment = _computeRoleCommitment(accountId, roleId, index); + const commitmentNullifier = _computeNullifier(commitment); + const authPath = wit_getRoleCommitmentPath(commitment); + const rootMatches = _operatorRoles + .checkRoot(merkleTreePathRootNoLeafHash<10>(disclose(authPath))); + + if(!_roleCommitmentNullifiers.member(disclose(commitmentNullifier)) && rootMatches) { + return Role { + isApproved: true, + roleCommitment: disclose(commitment), + commitmentNullifier: disclose(commitmentNullifier) + }; + } else { + return Role { + isApproved: false, + roleCommitment: disclose(commitment), + commitmentNullifier: disclose(commitmentNullifier) + }; + } + } + + /** + * @description Returns the admin role that controls `roleId` or + * a byte array with all zero bytes if `roleId` doesn't exist. See {grantRole} and {revokeRole}. + * + * To change a role’s admin use {_setRoleAdmin}. + * + * @circuitInfo k=10, rows=207 + * + * @param {Bytes<32>} roleId - The role identifier. + * @return {Bytes<32>} roleAdmin - The admin role that controls `roleId`. + */ + export circuit getRoleAdmin(roleId: Bytes<32>): Bytes<32> { + if (_adminRoles.member(disclose(roleId))) { + return _adminRoles.lookup(disclose(roleId)); + } + return default>; + } + + /** + * @description Grants `roleId` to `account`. + * + * @circuitInfo k=18, rows=138761 + * + * Requirements: + * + * - `account` must not be a ContractAddress. + * - An index for the intermediate role commitment produced by SHA256(roleId | account | nonce) + * must exist in the `_roleCommitmentIndex` map. + * - A nullifier for the role commitment produced by SHA256(roleId | account | nonce) + * must not exist in the `_roleCommitmentNullifiers` set. + * - A path for the role commitment produced by SHA256(roleId | account | nonce) must + * exist at `index` in the `_operatorRoles` Merkle tree. + * + * Disclosures: + * + * - The intermediate role commitment produced by SHA256(roleId | account | nonce). + * - The role commitment produced by SHA256(roleId | account | nonce). + * - The Merkle tree path for the role commitment stored at `index` in the `_operatorRoles` + * Merkle tree. + * - The type data of `account` - a ZswapCoinPublicKey or ContractAddress. + * + * @param {Bytes<32>} roleId - The role identifier. + * @param {Either} account - A ZswapCoinPublicKey or ContractAddress. + * @param {Bytes<32>} nonce - A nonce created using SHA256(SK | "role-nonce" | role | PK) + * @return {[]} - Empty tuple. + */ + export circuit grantRole(roleId: Bytes<32>, accountId: Bytes<32>): [] { + assertOnlyRole(getRoleAdmin(roleId)); + _grantRole(roleId, accountId); + } + + /** + * @description Revokes `roleId` from `account`. + * + * @circuitInfo k=18, rows=138517 + * + * Requirements: + * + * - `account` must not be a ContractAddress. + * - An index for the intermediate role commitment produced by SHA256(roleId | account | nonce) + * must exist in the `_roleCommitmentIndex` map. + * - A nullifier for the role commitment produced by SHA256(roleId | account | nonce) + * must not exist in the `_roleCommitmentNullifiers` set. + * - A path for the role commitment produced by SHA256(roleId | account | nonce) must + * exist at `index` in the `_operatorRoles` Merkle tree. + * + * Disclosures: + * + * - The intermediate role commitment produced by SHA256(roleId | account | nonce). + * - The role commitment produced by SHA256(roleId | account | nonce). + * - The Merkle tree path for the role commitment stored at `index` in the `_operatorRoles` + * Merkle tree. + * - The type data of `account` - a ZswapCoinPublicKey or ContractAddress. + * + * @param {Bytes<32>} roleId - The role identifier. + * @param {Either} account - A ZswapCoinPublicKey or ContractAddress. + * @param {Bytes<32>} nonce - A nonce created using SHA256(SK | "role-nonce" | role | PK) + * @return {[]} - Empty tuple. + */ + export circuit revokeRole(roleId: Bytes<32>, accountId: Bytes<32>): [] { + assertOnlyRole(getRoleAdmin(roleId)); + _revokeRole(roleId, accountId); + } + + /** + * @description Revokes `roleId` from the calling account. + * + * @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). + * + * @circuitInfo k=17, rows=108992 + * + * Requirements: + * + * - The caller must be `callerConfirmation`. + * - The caller must not be a `ContractAddress`. + * - An index for the intermediate role commitment produced by SHA256(roleId | account | nonce) + * must exist in the `_roleCommitmentIndex` map. + * - A nullifier for the role commitment produced by SHA256(roleId | account | nonce) + * must not exist in the `_roleCommitmentNullifiers` set. + * - A path for the role commitment produced by SHA256(roleId | account | nonce) must + * exist at `index` in the `_operatorRoles` Merkle tree. + * + * Disclosures: + * + * - The intermediate role commitment produced by SHA256(roleId | account | nonce). + * - The role commitment produced by SHA256(roleId | account | nonce). + * - The Merkle tree path for the role commitment stored at `index` in the `_operatorRoles` + * Merkle tree. + * - The type data of `callerConfirmation` - a ZswapCoinPublicKey or ContractAddress. + * + * @param {Bytes<32>} roleId - The role identifier. + * @param {Either} callerConfirmation - A ZswapCoinPublicKey or ContractAddress. + * @param {Bytes<32>} nonce - A nonce created using SHA256(SK | "role-nonce" | role | PK) + * @return {[]} - Empty tuple. + */ + export circuit renounceRole(roleId: Bytes<32>, callerConfirmation: Bytes<32>): [] { + const nonce = wit_secretNonce(roleId); + const callerAsEither = Either { + is_left: true, + left: ownPublicKey(), + right: ContractAddress { bytes: pad(32, "") } + }; + assert(callerConfirmation == _computeRoleId(callerAsEither, nonce), "ShieldedAccessControl: bad confirmation"); + + _revokeRole(roleId, callerConfirmation); + } + + /** + * @description Sets `adminRole` as `roleId`'s admin role. + * + * @circuitInfo k=10, rows=209 + * + * @param {Bytes<32>} roleId - The role identifier. + * @param {Bytes<32>} adminRole - The admin role identifier. + * @return {[]} - Empty tuple. + */ + export circuit _setRoleAdmin(roleId: Bytes<32>, adminRole: Bytes<32>): [] { + _adminRoles.insert(disclose(roleId), disclose(adminRole)); + } + + /** + * @description Attempts to grant `roleId` to `account` and returns a boolean indicating if `roleId` was granted. + * Internal circuit without access restriction. + * + * @circuitInfo k=17, rows=109163 + * + * Requirements: + * + * - `account` must not be a ContractAddress. + * - An index for the intermediate role commitment produced by SHA256(roleId | account | nonce) + * must exist in the `_roleCommitmentIndex` map. + * - A nullifier for the role commitment produced by SHA256(roleId | account | nonce) + * must not exist in the `_roleCommitmentNullifiers` set. + * - A path for the role commitment produced by SHA256(roleId | account | nonce) must + * exist at `index` in the `_operatorRoles` Merkle tree. + * + * Disclosures: + * + * - The intermediate role commitment produced by SHA256(roleId | account | nonce). + * - The role commitment produced by SHA256(roleId | account | nonce). + * - The Merkle tree path for the role commitment stored at `index` in the `_operatorRoles` + * Merkle tree. + * - The type data of `account` - a ZswapCoinPublicKey or ContractAddress. + * + * @param {Bytes<32>} roleId - The role identifier. + * @param {Either} account - A ZswapCoinPublicKey or ContractAddress. + * @param {Bytes<32>} nonce - A nonce created using SHA256(SK | "role-nonce" | role | PK) + * @return {Boolean} roleGranted - A boolean indicating if `roleId` was granted. + */ + export circuit _grantRole(roleId: Bytes<32>, accountId: Bytes<32>): Boolean { + const role = getRole(roleId, accountId); + if (role.isApproved) { + return false; + } + + // Use ledger index as source of truth + _operatorRoles.insertHashIndex(role.roleCommitment, _currentMerkleTreeIndex); + _currentMerkleTreeIndex.increment(1); + return true; + } + + /** + * @description Attempts to revoke `roleId` from `account` and returns a boolean indicating if `roleId` was revoked. + * Internal circuit without access restriction. + * + * @circuitInfo k=17, rows=108916 + * + * Requirements: + * + * - An index for the intermediate role commitment produced by SHA256(roleId | account | nonce) + * must exist in the `_roleCommitmentIndex` map. + * - A nullifier for the role commitment produced by SHA256(roleId | account | nonce) + * must not exist in the `_roleCommitmentNullifiers` set. + * - A path for the role commitment produced by SHA256(roleId | account | nonce) must + * exist at `index` in the `_operatorRoles` Merkle tree. + * + * Disclosures: + * + * - The intermediate role commitment produced by SHA256(roleId | account | nonce). + * - The role commitment produced by SHA256(roleId | account | nonce). + * - The Merkle tree path for the role commitment stored at `index` in the `_operatorRoles` + * Merkle tree. + * - The type data of `account` - a ZswapCoinPublicKey or ContractAddress. + * + * @param {Bytes<32>} roleId - The role identifier. + * @param {Either} account - A ZswapCoinPublicKey or ContractAddress. + * @param {Bytes<32>} nonce - A nonce created using SHA256(SK | "role-nonce" | role | PK) + * @return {Boolean} roleRevoked - A boolean indicating if `roleId` was revoked. + */ + export circuit _revokeRole(roleId: Bytes<32>, accountId: Bytes<32>): Boolean { + const role = getRole(roleId, accountId); + if (!role.isApproved) { + return false; + } + + _roleCommitmentNullifiers.insert(disclose(role.commitmentNullifier)); + return true; + } +} diff --git a/contracts/src/access/ShieldedAccessControlUtils.compact b/contracts/src/access/ShieldedAccessControlUtils.compact new file mode 100644 index 00000000..5c08f0f6 --- /dev/null +++ b/contracts/src/access/ShieldedAccessControlUtils.compact @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: MIT + +pragma language_version >= 0.16.0; + +/** + * @module ShieldedAccessControlUtils. + * @description A library for common utilities used in the Shielded Access Control module. + */ +module ShieldedAccessControlUtils { + import CompactStandardLibrary; + + /** + * @description Returns whether `keyOrAddress` is a ContractAddress type. + * + * Disclosures: + * + * - The type data of `keyOrAddress` - a ZswapCoinPublicKey or ContractAddress. + * + * @param {Either} keyOrAddress - The target value to check, either a ZswapCoinPublicKey or a ContractAddress. + * @return {Boolean} - Returns true if `keyOrAddress` is a ContractAddress. + */ + export pure circuit isContractAddress(keyOrAddress: Either): Boolean { + return disclose(!keyOrAddress.is_left); + } +} diff --git a/contracts/src/access/test/ShieldedAccessControl.test.ts b/contracts/src/access/test/ShieldedAccessControl.test.ts new file mode 100644 index 00000000..c6dc0891 --- /dev/null +++ b/contracts/src/access/test/ShieldedAccessControl.test.ts @@ -0,0 +1,288 @@ +import { + CompactTypeBytes, + CompactTypeVector, + convert_bigint_to_Uint8Array, + persistentHash, + type WitnessContext, +} from '@midnight-ntwrk/compact-runtime'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + ContractAddress, + Either, + Ledger, + MerkleTreePath, + ShieldedAccessControl_Role as Role, + ZswapCoinPublicKey, + Contract as MockShieldedAccessControl +} from '../../../artifacts/MockShieldedAccessControl/contract/index.cjs'; +import { fmtHexString, ShieldedAccessControlPrivateState, ShieldedAccessControlWitnesses } from '../witnesses/ShieldedAccessControlWitnesses.js'; +import { ShieldedAccessControlSimulator } from './simulators/ShieldedAccessControlSimulator.js'; +import * as utils from './utils/address.js'; + +// Helpers +const buildCommitment = ( + accountId: Uint8Array, + roleId: Uint8Array, + index: bigint, +): Uint8Array => { + const rt_type = new CompactTypeVector(4, new CompactTypeBytes(32)); + const bIndex = convert_bigint_to_Uint8Array(32, index); + + const commitment = persistentHash(rt_type, [ + accountId, + roleId, + bIndex, + COMMITMENT_DOMAIN, + ]); + + return commitment; +}; + +const buildNullifier = ( + roleCommitment: Uint8Array, +): Uint8Array => { + const rt_type = new CompactTypeVector(2, new CompactTypeBytes(32)); + + const nullifier = persistentHash(rt_type, [ + roleCommitment, + NULLIFIER_DOMAIN, + ]); + + return nullifier; +}; + +const createIdHash = ( + pk: ZswapCoinPublicKey, + nonce: Uint8Array, +): Uint8Array => { + const rt_type = new CompactTypeVector(2, new CompactTypeBytes(32)); + + const bPK = pk.bytes; + return persistentHash(rt_type, [bPK, nonce]); +}; + +// PKs +const [ADMIN, Z_ADMIN] = utils.generatePubKeyPair('ADMIN'); +const [OPERATOR_1, Z_OPERATOR_1] = utils.generatePubKeyPair('OPERATOR_1'); +const [OPERATOR_2, Z_OPERATOR_2] = utils.generatePubKeyPair('OPERATOR_2'); +const [OPERATOR_3, Z_OPERATOR_3] = utils.generatePubKeyPair('OPERATOR_3'); +const [UNAUTHORIZED, Z_UNAUTHORIZED] = utils.generatePubKeyPair('UNAUTHORIZED'); + +// Roles +const DEFAULT_ADMIN_ROLE = utils.zeroUint8Array(); +const OPERATOR_1_ROLE = convert_bigint_to_Uint8Array(32, 1n); +const OPERATOR_2_ROLE = convert_bigint_to_Uint8Array(32, 2n); +const OPERATOR_3_ROLE = convert_bigint_to_Uint8Array(32, 3n); +const UNINITIALIZED_ROLE = convert_bigint_to_Uint8Array(32, 555n); +const BAD_ROLE = convert_bigint_to_Uint8Array(32, 99999999n); + +// Nonces +const ADMIN_SECRET_NONCE = Buffer.alloc(32, 'ADMIN_SECRET_NONCE'); +const OPERATOR_1_SECRET_NONCE = Buffer.alloc(32, 'OPERATOR_1_NONCE'); +const OPERATOR_2_SECRET_NONCE = Buffer.alloc(32, 'OPERATOR_2_NONCE'); +const OPERATOR_3_SECRET_NONCE = Buffer.alloc(32, 'OPERATOR_3_NONCE'); +const BAD_NONCE = Buffer.alloc(32, 'BAD_NONCE'); + +// Constants +const COMMITMENT_DOMAIN = new Uint8Array(32); +new TextEncoder().encodeInto('ShieldedAccessControl:commitment', COMMITMENT_DOMAIN); +const NULLIFIER_DOMAIN = new Uint8Array(32); +new TextEncoder().encodeInto('ShieldedAccessControl:nullifier', NULLIFIER_DOMAIN); + +const ADMIN_ID = createIdHash(Z_ADMIN, ADMIN_SECRET_NONCE); +const ADMIN_COMMITMENT = buildCommitment(ADMIN_ID, DEFAULT_ADMIN_ROLE, 0n); +const ADMIN_NULLIFIER = buildNullifier(ADMIN_COMMITMENT); + +const OPERATOR_1_ID = createIdHash(Z_OPERATOR_1, OPERATOR_1_SECRET_NONCE); +const OPERATOR_2_ID = createIdHash(Z_OPERATOR_2, OPERATOR_2_SECRET_NONCE); +const OPERATOR_3_ID = createIdHash(Z_OPERATOR_3, OPERATOR_3_SECRET_NONCE); + +const BAD_ID = createIdHash(Z_UNAUTHORIZED, new Uint8Array(32)); +const BAD_INDEX = 99999999n; +const BAD_COMMITMENT = buildCommitment(BAD_ID, BAD_ROLE, BAD_INDEX); + +let shieldedAccessControl: ShieldedAccessControlSimulator; + + +describe('ShieldedAccessControl', () => { + beforeEach(() => { + // Create private state object and generate nonce + const PS = ShieldedAccessControlPrivateState.withRoleAndNonce( + Buffer.from(DEFAULT_ADMIN_ROLE), + ADMIN_SECRET_NONCE, + ); + // Init contract for user with PS + shieldedAccessControl = new ShieldedAccessControlSimulator({ + privateState: PS, + coinPK: ADMIN + }); + }); + + describe('_computeRoleCommitment', () => { + it('computed commitment should match', () => { + expect(shieldedAccessControl._computeRoleCommitment(ADMIN_ID, DEFAULT_ADMIN_ROLE, 0n)).toEqual(ADMIN_COMMITMENT); + }); + + type ComputeRoleCommitmentCases = [ + method: keyof ShieldedAccessControlSimulator, + isValidId: boolean, + isValidRole: boolean, + isValidIndex: boolean, + args: unknown[], + ]; + + const checkedCircuits: ComputeRoleCommitmentCases[] = [ + ['_computeRoleCommitment', false, true, true, [BAD_ID, DEFAULT_ADMIN_ROLE, 0n]], + ['_computeRoleCommitment', true, false, true, [ADMIN_ID, BAD_ROLE, 0n]], + ['_computeRoleCommitment', true, true, false, [ADMIN_ID, DEFAULT_ADMIN_ROLE, BAD_INDEX]], + ['_computeRoleCommitment', false, true, false, [BAD_ID, DEFAULT_ADMIN_ROLE, BAD_INDEX]], + ['_computeRoleCommitment', false, false, false, [BAD_ID, BAD_ROLE, BAD_INDEX]], + ['_computeRoleCommitment', true, false, false, [ADMIN_ID, BAD_ROLE, BAD_INDEX]], + ['_computeRoleCommitment', false, false, true, [BAD_ID, BAD_ROLE, 0n]], + ] + + it.each(checkedCircuits)( + '%s should not match with isValidNonce(%s), isValidIndex(%s), isValidPath(%s)', + (circuitName, isValidId, isValidRole, isValidIndex, args) => { + // Test protected circuit + expect(() => { + ( + shieldedAccessControl[circuitName] as ( + ...args: unknown[] + ) => unknown + )(...args); + }).not.toEqual(ADMIN); + } + ) + }); + + describe('_computeNullifier', () => { + it('should match nullifier', () => { + expect(shieldedAccessControl._computeNullifier(ADMIN_COMMITMENT)).toEqual(ADMIN_NULLIFIER); + }); + + it('should not match with bad commitment', () => { + expect(shieldedAccessControl._computeNullifier(BAD_COMMITMENT)).not.toEqual(ADMIN_NULLIFIER); + }); + }); + + describe('_computeRoleId', () => { + const eitherAdmin = utils.createEitherTestUser('ADMIN'); + const eitherUnauthorized = utils.createEitherTestUser('UNAUTHORIZED'); + + it('should match role id', () => { + expect(shieldedAccessControl._computeRoleId(eitherAdmin, ADMIN_SECRET_NONCE)).toEqual(ADMIN_ID); + }); + + it('should fail for contract address', () => { + const eitherContract = utils.createEitherTestContractAddress('CONTRACT') + expect(() => { + shieldedAccessControl._computeRoleId(eitherContract, ADMIN_SECRET_NONCE); + }).toThrow('ShieldedAccessControl: contract address owners are not yet supported'); + }); + + type ComputeRoleIdCases = [ + method: keyof ShieldedAccessControlSimulator, + isValidAccount: boolean, + isValidNonce: boolean, + args: unknown[], + ]; + + const checkedCircuits: ComputeRoleIdCases[] = [ + ['_computeRoleId', true, false, [eitherAdmin, BAD_NONCE]], + ['_computeRoleId', false, true, [eitherUnauthorized, ADMIN_SECRET_NONCE]], + ['_computeRoleId', false, false, [eitherUnauthorized, BAD_NONCE]], + ]; + + it.each(checkedCircuits)( + '%s should not match role id with invalidAccount=%s or invalidNonce=%s', + (circuitName, isValidAccount, isValidNonce, args) => { + // Test circuit + expect(() => { + ( + shieldedAccessControl[circuitName] as ( + ...args: unknown[] + ) => unknown + )(...args); + }).not.toEqual(ADMIN_ID); + } + ) + }); + + // Complete testing once issue with pathForLeaf is resolved + describe.todo('wit_getRoleIndex', () => { + it.todo('should return 0 if no roles granted', () => { + const [_, index] = shieldedAccessControl.witnesses.wit_getRoleIndex(shieldedAccessControl.getWitnessContext(), UNINITIALIZED_ROLE, ADMIN_ID); + expect(index).toBe(0n); + }); + + it.todo('should return correct index', () => { + let granted = shieldedAccessControl._grantRole(DEFAULT_ADMIN_ROLE, ADMIN_ID); + expect(granted).toBe(true); + let [, adminIndex] = shieldedAccessControl.witnesses.wit_getRoleIndex(shieldedAccessControl.getWitnessContext(), DEFAULT_ADMIN_ROLE, ADMIN_ID); + expect(adminIndex).toBe(0n); + + shieldedAccessControl.privateState.injectSecretNonce(OPERATOR_1_ROLE, OPERATOR_1_SECRET_NONCE); + granted = shieldedAccessControl._grantRole(OPERATOR_1_ROLE, OPERATOR_1_ID); + expect(granted).toBe(true); + const [, operatorIndex] = shieldedAccessControl.witnesses.wit_getRoleIndex(shieldedAccessControl.getWitnessContext(), OPERATOR_1_ROLE, OPERATOR_1_ID); + expect(operatorIndex).toBe(1n); + + shieldedAccessControl.privateState.injectSecretNonce(OPERATOR_2_ROLE, OPERATOR_2_SECRET_NONCE); + granted = shieldedAccessControl._grantRole(OPERATOR_2_ROLE, OPERATOR_2_ID); + expect(granted).toBe(true); + shieldedAccessControl._grantRole(OPERATOR_2_ROLE, OPERATOR_2_ID); + const [, operatorIndex2] = shieldedAccessControl.witnesses.wit_getRoleIndex(shieldedAccessControl.getWitnessContext(), OPERATOR_2_ROLE, OPERATOR_2_ID); + expect(operatorIndex2).toBe(2n); + + shieldedAccessControl.privateState.injectSecretNonce(OPERATOR_3_ROLE, OPERATOR_3_SECRET_NONCE); + shieldedAccessControl._grantRole(OPERATOR_3_ROLE, OPERATOR_3_ID); + const [_, operatorIndex3] = shieldedAccessControl.witnesses.wit_getRoleIndex(shieldedAccessControl.getWitnessContext(), OPERATOR_3_ROLE, OPERATOR_3_ID); + expect(operatorIndex3).toBe(3n); + + let [, adminIndex2] = shieldedAccessControl.witnesses.wit_getRoleIndex(shieldedAccessControl.getWitnessContext(), DEFAULT_ADMIN_ROLE, ADMIN_ID); + expect(adminIndex2).toBe(0n); + }); + + it.todo('should return current Merkle tree index if role does not exist') + }); + + describe('wit_getRoleCommitmentPath', () => { + it('should return a Merkle tree path if one exists', () => { + + }); + }); + + describe('getRole', () => { + it('should return unapproved if role does not exist', () => { + expect(shieldedAccessControl.getRole(UNINITIALIZED_ROLE, ADMIN_ID).isApproved).toBe(false); + }); + + it('should return correct commitment', () => { + expect(shieldedAccessControl.getRole(DEFAULT_ADMIN_ROLE, ADMIN_ID).roleCommitment).toEqual(ADMIN_COMMITMENT); + }); + + it('should return correct nullifier', () => { + expect(shieldedAccessControl.getRole(DEFAULT_ADMIN_ROLE, ADMIN_ID).commitmentNullifier).toEqual(ADMIN_NULLIFIER); + }); + + it('should return approved role', () => { + shieldedAccessControl._grantRole(DEFAULT_ADMIN_ROLE, ADMIN_ID); + expect(shieldedAccessControl.getRole(DEFAULT_ADMIN_ROLE, ADMIN_ID).isApproved).toBe(true); + }); + }); + + describe('_grantRole', () => { + it('should return true for new role', () => { + expect(shieldedAccessControl._grantRole(DEFAULT_ADMIN_ROLE, ADMIN_ID)).toBe(true); + }); + + it('should return false if role already granted', () => { + shieldedAccessControl._grantRole(DEFAULT_ADMIN_ROLE, ADMIN_ID); + expect(shieldedAccessControl._grantRole(DEFAULT_ADMIN_ROLE, ADMIN_ID)).toBe(false); + }); + }); + + describe('') + +}); \ No newline at end of file diff --git a/contracts/src/access/test/ShieldedAccessControl_OLD.ts b/contracts/src/access/test/ShieldedAccessControl_OLD.ts new file mode 100644 index 00000000..ececb9aa --- /dev/null +++ b/contracts/src/access/test/ShieldedAccessControl_OLD.ts @@ -0,0 +1,1053 @@ +import { + CompactTypeBytes, + CompactTypeVector, + convert_bigint_to_Uint8Array, + persistentHash, + type WitnessContext, +} from '@midnight-ntwrk/compact-runtime'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { + ContractAddress, + Either, + Ledger, + MerkleTreePath, + ShieldedAccessControl_Role as Role, + ZswapCoinPublicKey, + Contract as MyContract +} from '../../../artifacts/MockShieldedAccessControl/contract/index.cjs'; +import { fmtHexString, ShieldedAccessControlPrivateState, ShieldedAccessControlWitnesses } from '../witnesses/ShieldedAccessControlWitnesses.js'; +import { ShieldedAccessControlSimulator } from './simulators/ShieldedAccessControlSimulator.js'; +import * as utils from './utils/address.js'; + +// PKs +const [ADMIN, Z_ADMIN] = utils.generateEitherPubKeyPair('ADMIN'); +const [UNAUTHORIZED, Z_UNAUTHORIZED] = + utils.generateEitherPubKeyPair('UNAUTHORIZED'); +const [CUSTOM_ADMIN, Z_CUSTOM_ADMIN] = + utils.generateEitherPubKeyPair('CUSTOM_ADMIN'); +const [OPERATOR_1, Z_OPERATOR_1] = utils.generateEitherPubKeyPair('OPERATOR_1'); +const [OPERATOR_2, Z_OPERATOR_2] = utils.generateEitherPubKeyPair('OPERATOR_2'); +const [OPERATOR_3, Z_OPERATOR_3] = utils.generateEitherPubKeyPair('OPERATOR_3'); +const [OPERATOR_CONTRACT, Z_OPERATOR_CONTRACT] = utils.generateEitherPubKeyPair( + 'OPERATOR_CONTRACT', + false, +); +const Z_OPERATOR_LIST = [Z_OPERATOR_1, Z_OPERATOR_2, Z_OPERATOR_3]; + +// Constants +const BAD_NONCE = Buffer.alloc(32, 'BAD_NONCE'); +const DOMAIN = new Uint8Array(32); +new TextEncoder().encodeInto('ShieldedAccessControl:shield:', DOMAIN); +const INIT_COUNTER = 0n; + +const EMPTY_ROOT = { field: 0n }; +const getRoleIndex = ( + { + ledger, + privateState, + }: WitnessContext, + roleId: Uint8Array, + account: Either +): bigint => { + const roleIdString = Buffer.from(roleId).toString('hex'); + const bNonce = privateState.roles[roleIdString]; + const rt_type = new CompactTypeVector(5, new CompactTypeBytes(32)); + const bAccount = utils.eitherToBytes(account); + // Iterate over each MT index to determine if commitment exists + for (let i = 0; i < (2 ** 11 - 1); i++) { + const bIndex = convert_bigint_to_Uint8Array(32, BigInt(i)); + const commitment = persistentHash(rt_type, [ + roleId, + bAccount, + bNonce, + bIndex, + DOMAIN, + ]); + try { + ledger.ShieldedAccessControl__operatorRoles.pathForLeaf( + BigInt(i), + commitment, + ); + return BigInt(i); + } catch (e: unknown) { + if (e instanceof Error) { + const [msg, index] = e.message.split(':'); + if (msg === 'invalid index into sparse merkle tree') { + // console.log(`role ${fmtHexString(roleIdString)} with commitment ${fmtHexString(commitment)} not found at index ${index}`); + } else { + throw e; + } + } + } + } + + console.log("WIT - Commitment DNE, returing MT index ", ledger.ShieldedAccessControl__currentMerkleTreeIndex.toString()); + + // If commitment doesn't exist return currentMTIndex + // Used for adding roles + return ledger.ShieldedAccessControl__currentMerkleTreeIndex; +} + +// Roles +const DEFAULT_ADMIN_ROLE = utils.zeroUint8Array(); +const OPERATOR_ROLE_1 = convert_bigint_to_Uint8Array(32, 1n); +const OPERATOR_ROLE_2 = convert_bigint_to_Uint8Array(32, 2n); +const OPERATOR_ROLE_3 = convert_bigint_to_Uint8Array(32, 3n); +const CUSTOM_ADMIN_ROLE = convert_bigint_to_Uint8Array(32, 4n); +const UNINITIALIZED_ROLE = convert_bigint_to_Uint8Array(32, 5n); +const OPERATOR_ROLE_LIST = [OPERATOR_ROLE_1, OPERATOR_ROLE_2, OPERATOR_ROLE_3]; + +// Role to string +const DEFAULT_ADMIN_ROLE_TO_STRING = + Buffer.from(DEFAULT_ADMIN_ROLE).toString('hex'); + +const ADMIN_SECRET_NONCE = Buffer.alloc(32, 'ADMIN_SECRET_NONCE'); +const OPERATOR_ROLE_1_SECRET_NONCE = Buffer.alloc( + 32, + 'OPERATOR_ROLE_1_SECRET_NONCE', +); +const OPERATOR_ROLE_2_SECRET_NONCE = Buffer.alloc( + 32, + 'OPERATOR_ROLE_2_SECRET_NONCE', +); +const OPERATOR_ROLE_3_SECRET_NONCE = Buffer.alloc( + 32, + 'OPERATOR_ROLE_3_SECRET_NONCE', +); +const OPERATOR_ROLE_SECRET_NONCES = [ + OPERATOR_ROLE_1_SECRET_NONCE, + OPERATOR_ROLE_2_SECRET_NONCE, + OPERATOR_ROLE_3_SECRET_NONCE, +]; +let shieldedAccessControl: ShieldedAccessControlSimulator; + +// Helpers +const buildCommitment = ( + roleId: Uint8Array, + account: Either, + nonce: Uint8Array, + index: bigint, +): Uint8Array => { + const rt_type = new CompactTypeVector(5, new CompactTypeBytes(32)); + const bAccount = utils.eitherToBytes(account); + const bIndex = convert_bigint_to_Uint8Array(32, index); + + const commitment = persistentHash(rt_type, [ + roleId, + bAccount, + nonce, + bIndex, + DOMAIN, + ]); + + return commitment; +}; + +const EXP_DEFAULT_ADMIN_COMMITMENT = buildCommitment( + DEFAULT_ADMIN_ROLE, + Z_ADMIN, + ADMIN_SECRET_NONCE, + INIT_COUNTER, +); + +function RETURN_BAD_INDEX( + context: WitnessContext, + roleId: Uint8Array, +): [ShieldedAccessControlPrivateState, bigint] { + return [context.privateState, 1023n]; +} + +function RETURN_BAD_PATH( + context: WitnessContext, + roleCommitment: Uint8Array, +): [ShieldedAccessControlPrivateState, MerkleTreePath] { + const defaultPath: MerkleTreePath = { + leaf: new Uint8Array(32), + path: Array.from({ length: 10 }, () => ({ + sibling: { field: 0n }, + goes_left: false, + })), + }; + return [context.privateState, defaultPath]; +} + +type RoleAndNonce = { + roleId: string; + nonce: Buffer; +}; + +describe('ShieldedAccessControl', () => { + beforeEach(() => { + // Create private state object and generate nonce + const PS = ShieldedAccessControlPrivateState.withRoleAndNonce( + Z_ADMIN, + Buffer.from(DEFAULT_ADMIN_ROLE), + ADMIN_SECRET_NONCE, + ); + // Init contract for user with PS + shieldedAccessControl = new ShieldedAccessControlSimulator(Z_ADMIN, { + privateState: PS, + }); + }); + + describe('checked circuits should fail for authorized caller with invalid witness values', () => { + beforeEach(() => { + shieldedAccessControl._grantRole(DEFAULT_ADMIN_ROLE, Z_ADMIN); + shieldedAccessControl.callerCtx.setCaller(ADMIN); + }); + + type FailingCircuits = [ + method: keyof ShieldedAccessControlSimulator, + isValidNonce: boolean, + isValidIndex: boolean, + isValidPath: boolean, + args: unknown[], + ]; + const checkedCircuits: FailingCircuits[] = [ + ['assertOnlyRole', false, true, true, [DEFAULT_ADMIN_ROLE]], + ['assertOnlyRole', true, false, true, [DEFAULT_ADMIN_ROLE]], + ['assertOnlyRole', true, true, false, [DEFAULT_ADMIN_ROLE]], + ['assertOnlyRole', false, false, true, [DEFAULT_ADMIN_ROLE]], + ['assertOnlyRole', true, false, false, [DEFAULT_ADMIN_ROLE]], + ['assertOnlyRole', false, true, false, [DEFAULT_ADMIN_ROLE]], + ['assertOnlyRole', false, false, false, [DEFAULT_ADMIN_ROLE]], + ['grantRole', false, true, true, [DEFAULT_ADMIN_ROLE, Z_ADMIN]], + ['grantRole', true, false, true, [DEFAULT_ADMIN_ROLE, Z_ADMIN]], + ['grantRole', true, true, false, [DEFAULT_ADMIN_ROLE, Z_ADMIN]], + ['grantRole', false, false, true, [DEFAULT_ADMIN_ROLE, Z_ADMIN]], + ['grantRole', true, false, false, [DEFAULT_ADMIN_ROLE, Z_ADMIN]], + ['grantRole', false, true, false, [DEFAULT_ADMIN_ROLE, Z_ADMIN]], + ['grantRole', false, false, false, [DEFAULT_ADMIN_ROLE, Z_ADMIN]], + ['revokeRole', false, true, true, [DEFAULT_ADMIN_ROLE, Z_ADMIN]], + ['revokeRole', true, false, true, [DEFAULT_ADMIN_ROLE, Z_ADMIN]], + ['revokeRole', true, true, false, [DEFAULT_ADMIN_ROLE, Z_ADMIN]], + ['revokeRole', false, false, true, [DEFAULT_ADMIN_ROLE, Z_ADMIN]], + ['revokeRole', true, false, false, [DEFAULT_ADMIN_ROLE, Z_ADMIN]], + ['revokeRole', false, true, false, [DEFAULT_ADMIN_ROLE, Z_ADMIN]], + ['revokeRole', false, false, false, [DEFAULT_ADMIN_ROLE, Z_ADMIN]], + ]; + + it.each(checkedCircuits)( + '%s should fail with isValidNonce(%s), isValidIndex(%s), isValidPath(%s)', + (circuitName, isValidNonce, isValidIndex, isValidPath, args) => { + if (isValidNonce) { + // Check nonce matches + expect( + shieldedAccessControl.privateState.getCurrentSecretNonce( + DEFAULT_ADMIN_ROLE, + ), + ).toEqual(ADMIN_SECRET_NONCE); + } else { + // Check nonce does not match + shieldedAccessControl.privateState.injectSecretNonce( + DEFAULT_ADMIN_ROLE, + BAD_NONCE, + ); + expect( + shieldedAccessControl.privateState.getCurrentSecretNonce( + DEFAULT_ADMIN_ROLE, + ), + ).not.toEqual(ADMIN_SECRET_NONCE); + } + + if (isValidIndex) { + // Check index matches + const [, witnessCalculatedIndex] = + shieldedAccessControl.witnesses.wit_getRoleIndex( + shieldedAccessControl.getWitnessContext(), + DEFAULT_ADMIN_ROLE, + Z_ADMIN + ); + expect(witnessCalculatedIndex).toBe(INIT_COUNTER); + } else { + // Check index does not match + shieldedAccessControl.overrideWitness( + 'wit_getRoleIndex', + RETURN_BAD_INDEX, + ); + const [, witnessCalculatedIndex] = + shieldedAccessControl.witnesses.wit_getRoleIndex( + shieldedAccessControl.getWitnessContext(), + DEFAULT_ADMIN_ROLE, + Z_ADMIN + ); + expect(witnessCalculatedIndex).not.toBe(INIT_COUNTER); + } + + if (isValidPath) { + // Check path matches + const truePath = shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__operatorRoles.findPathForLeaf( + EXP_DEFAULT_ADMIN_COMMITMENT, + ); + const [, witnessCalculatedPath] = + shieldedAccessControl.witnesses.wit_getRoleCommitmentPath( + shieldedAccessControl.getWitnessContext(), + EXP_DEFAULT_ADMIN_COMMITMENT, + ); + expect(witnessCalculatedPath).toEqual(truePath); + } else { + // Check path does not match + const truePath = shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__operatorRoles.findPathForLeaf( + EXP_DEFAULT_ADMIN_COMMITMENT, + ); + shieldedAccessControl.overrideWitness( + 'wit_getRoleCommitmentPath', + RETURN_BAD_PATH, + ); + const [, witnessCalculatedPath] = + shieldedAccessControl.witnesses.wit_getRoleIndex( + shieldedAccessControl.getWitnessContext(), + DEFAULT_ADMIN_ROLE, + Z_ADMIN + ); + expect(witnessCalculatedPath).not.toEqual(truePath); + } + + // Test protected circuit + expect(() => { + ( + shieldedAccessControl[circuitName] as ( + ...args: unknown[] + ) => unknown + )(...args); + }).toThrow('ShieldedAccessControl: unauthorized account'); + }, + ); + }); + + describe('checked circuits should fail for unauthorized caller with any witness value', () => { + beforeEach(() => { + shieldedAccessControl._grantRole(DEFAULT_ADMIN_ROLE, Z_ADMIN); + shieldedAccessControl.callerCtx.setCaller(UNAUTHORIZED); + }); + + type FailingCircuits = [ + method: keyof ShieldedAccessControlSimulator, + isValidNonce: boolean, + isValidIndex: boolean, + isValidPath: boolean, + args: unknown[], + ]; + const checkedCircuits: FailingCircuits[] = [ + ['assertOnlyRole', false, true, true, [DEFAULT_ADMIN_ROLE]], + ['assertOnlyRole', true, false, true, [DEFAULT_ADMIN_ROLE]], + ['assertOnlyRole', true, true, false, [DEFAULT_ADMIN_ROLE]], + ['assertOnlyRole', false, false, true, [DEFAULT_ADMIN_ROLE]], + ['assertOnlyRole', true, false, false, [DEFAULT_ADMIN_ROLE]], + ['assertOnlyRole', false, true, false, [DEFAULT_ADMIN_ROLE]], + ['assertOnlyRole', false, false, false, [DEFAULT_ADMIN_ROLE]], + ['assertOnlyRole', true, true, true, [DEFAULT_ADMIN_ROLE]], + ['grantRole', false, true, true, [DEFAULT_ADMIN_ROLE, Z_ADMIN]], + ['grantRole', true, false, true, [DEFAULT_ADMIN_ROLE, Z_ADMIN]], + ['grantRole', true, true, false, [DEFAULT_ADMIN_ROLE, Z_ADMIN]], + ['grantRole', false, false, true, [DEFAULT_ADMIN_ROLE, Z_ADMIN]], + ['grantRole', true, false, false, [DEFAULT_ADMIN_ROLE, Z_ADMIN]], + ['grantRole', false, true, false, [DEFAULT_ADMIN_ROLE, Z_ADMIN]], + ['grantRole', false, false, false, [DEFAULT_ADMIN_ROLE, Z_ADMIN]], + ['grantRole', true, true, true, [DEFAULT_ADMIN_ROLE, Z_ADMIN]], + ['revokeRole', false, true, true, [DEFAULT_ADMIN_ROLE, Z_ADMIN]], + ['revokeRole', true, false, true, [DEFAULT_ADMIN_ROLE, Z_ADMIN]], + ['revokeRole', true, true, false, [DEFAULT_ADMIN_ROLE, Z_ADMIN]], + ['revokeRole', false, false, true, [DEFAULT_ADMIN_ROLE, Z_ADMIN]], + ['revokeRole', true, false, false, [DEFAULT_ADMIN_ROLE, Z_ADMIN]], + ['revokeRole', false, true, false, [DEFAULT_ADMIN_ROLE, Z_ADMIN]], + ['revokeRole', false, false, false, [DEFAULT_ADMIN_ROLE, Z_ADMIN]], + ['revokeRole', true, true, true, [DEFAULT_ADMIN_ROLE, Z_ADMIN]], + ]; + + it.each(checkedCircuits)( + '%s should fail with isValidNonce(%s), isValidIndex(%s), isValidPath(%s)', + (circuitName, isValidNonce, isValidIndex, isValidPath, args) => { + if (isValidNonce) { + // Check nonce matches + expect( + shieldedAccessControl.privateState.getCurrentSecretNonce( + DEFAULT_ADMIN_ROLE, + ), + ).toEqual(ADMIN_SECRET_NONCE); + } else { + // Check nonce does not match + shieldedAccessControl.privateState.injectSecretNonce( + DEFAULT_ADMIN_ROLE, + BAD_NONCE, + ); + expect( + shieldedAccessControl.privateState.getCurrentSecretNonce( + DEFAULT_ADMIN_ROLE, + ), + ).not.toEqual(ADMIN_SECRET_NONCE); + } + + if (isValidIndex) { + // Check index matches + const [, witnessCalculatedIndex] = + shieldedAccessControl.witnesses.wit_getRoleIndex( + shieldedAccessControl.getWitnessContext(), + DEFAULT_ADMIN_ROLE, + Z_ADMIN + ); + expect(witnessCalculatedIndex).toBe(INIT_COUNTER); + } else { + // Check index does not match + shieldedAccessControl.overrideWitness( + 'wit_getRoleIndex', + RETURN_BAD_INDEX, + ); + const [, witnessCalculatedIndex] = + shieldedAccessControl.witnesses.wit_getRoleIndex( + shieldedAccessControl.getWitnessContext(), + DEFAULT_ADMIN_ROLE, + Z_ADMIN + ); + expect(witnessCalculatedIndex).not.toBe(INIT_COUNTER); + } + + if (isValidPath) { + // Check path matches + const truePath = shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__operatorRoles.findPathForLeaf( + EXP_DEFAULT_ADMIN_COMMITMENT, + ); + const [, witnessCalculatedPath] = + shieldedAccessControl.witnesses.wit_getRoleCommitmentPath( + shieldedAccessControl.getWitnessContext(), + EXP_DEFAULT_ADMIN_COMMITMENT, + ); + expect(witnessCalculatedPath).toEqual(truePath); + } else { + // Check path does not match + const truePath = shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__operatorRoles.findPathForLeaf( + EXP_DEFAULT_ADMIN_COMMITMENT, + ); + shieldedAccessControl.overrideWitness( + 'wit_getRoleCommitmentPath', + RETURN_BAD_PATH, + ); + const [, witnessCalculatedPath] = + shieldedAccessControl.witnesses.wit_getRoleIndex( + shieldedAccessControl.getWitnessContext(), + DEFAULT_ADMIN_ROLE, + Z_ADMIN + ); + expect(witnessCalculatedPath).not.toEqual(truePath); + } + + // Test protected circuit + expect(() => { + ( + shieldedAccessControl[circuitName] as ( + ...args: unknown[] + ) => unknown + )(...args); + }).toThrow('ShieldedAccessControl: unauthorized account'); + }, + ); + }); + + describe('unsupported contract address failure cases', () => { + beforeEach(() => { + shieldedAccessControl._grantRole(DEFAULT_ADMIN_ROLE, Z_ADMIN); + shieldedAccessControl.callerCtx.setCaller(ADMIN); + }); + + type FailingCircuits = [ + method: keyof ShieldedAccessControlSimulator, + args: unknown[], + ]; + const circuitsWithContractAddressCheck: FailingCircuits[] = [ + ['hasRole', [DEFAULT_ADMIN_ROLE, Z_OPERATOR_CONTRACT]], + ['_checkRole', [DEFAULT_ADMIN_ROLE, Z_OPERATOR_CONTRACT]], + ['grantRole', [DEFAULT_ADMIN_ROLE, Z_OPERATOR_CONTRACT]], + ['revokeRole', [DEFAULT_ADMIN_ROLE, Z_OPERATOR_CONTRACT]], + ['_grantRole', [DEFAULT_ADMIN_ROLE, Z_OPERATOR_CONTRACT]], + ['_revokeRole', [DEFAULT_ADMIN_ROLE, Z_OPERATOR_CONTRACT]], + ]; + + it.each(circuitsWithContractAddressCheck)( + '%s fails if contract address is queried', + (circuitName, args) => { + // Test protected circuit + expect(() => { + ( + shieldedAccessControl[circuitName] as ( + ...args: unknown[] + ) => unknown + )(...args); + }).toThrow( + 'ShieldedAccessControl: contract address roles are not yet supported', + ); + }, + ); + }); + + describe('hasRole', () => { + beforeEach(() => { + shieldedAccessControl._grantRole(DEFAULT_ADMIN_ROLE, Z_ADMIN); + }); + + type HasRoleTest = [ + isValidNonce: boolean, + isValidIndex: boolean, + isValidPath: boolean, + args: unknown[], + ]; + const falseCases: HasRoleTest[] = [ + [false, true, true, [DEFAULT_ADMIN_ROLE, Z_ADMIN]], + [true, false, true, [DEFAULT_ADMIN_ROLE, Z_ADMIN]], + [true, true, false, [DEFAULT_ADMIN_ROLE, Z_ADMIN]], + [false, false, true, [DEFAULT_ADMIN_ROLE, Z_ADMIN]], + [true, false, false, [DEFAULT_ADMIN_ROLE, Z_ADMIN]], + [false, true, false, [DEFAULT_ADMIN_ROLE, Z_ADMIN]], + [false, false, false, [DEFAULT_ADMIN_ROLE, Z_ADMIN]], + ]; + + const commitmentDoesNotMatchCases: HasRoleTest[] = [ + [false, true, true, [DEFAULT_ADMIN_ROLE, Z_ADMIN]], + [true, false, true, [DEFAULT_ADMIN_ROLE, Z_ADMIN]], + [false, false, true, [DEFAULT_ADMIN_ROLE, Z_ADMIN]], + [true, false, false, [DEFAULT_ADMIN_ROLE, Z_ADMIN]], + [false, true, false, [DEFAULT_ADMIN_ROLE, Z_ADMIN]], + [false, false, false, [DEFAULT_ADMIN_ROLE, Z_ADMIN]], + ]; + + it('should throw if caller is contract address', () => { + shieldedAccessControl.callerCtx.setCaller(OPERATOR_CONTRACT); + expect(() => { + shieldedAccessControl.hasRole(UNINITIALIZED_ROLE, Z_OPERATOR_CONTRACT); + }).toThrow( + 'ShieldedAccessControl: contract address roles are not yet supported', + ); + }); + + it('should return correct role commitment', () => { + const expCommitment = buildCommitment( + DEFAULT_ADMIN_ROLE, + Z_ADMIN, + ADMIN_SECRET_NONCE, + INIT_COUNTER, + ); + + const role = shieldedAccessControl.hasRole(DEFAULT_ADMIN_ROLE, Z_ADMIN); + expect(role.roleCommitment).toEqual(expCommitment); + }); + + it('should return true when admin has role', () => { + const role = shieldedAccessControl.hasRole(DEFAULT_ADMIN_ROLE, Z_ADMIN); + expect(role.isApproved).toEqual(true); + }); + + it('should return false when unauthorized does not have role', () => { + const role = shieldedAccessControl.hasRole( + DEFAULT_ADMIN_ROLE, + Z_UNAUTHORIZED, + ); + expect(role.isApproved).toEqual(false); + }); + + it('should return false when role does not exist', () => { + shieldedAccessControl.privateState.injectSecretNonce( + UNINITIALIZED_ROLE, + Buffer.alloc(32), + ); + const role = shieldedAccessControl.hasRole( + UNINITIALIZED_ROLE, + Z_UNAUTHORIZED, + ); + expect(role.isApproved).toBe(false); + }); + + it.each(falseCases)( + 'should return false with any invalid witness value - isValidNonce(%s), isValidIndex(%s), isValidPath(%s)', + (isValidNonce, isValidIndex, isValidPath, args) => { + if (isValidNonce) { + // Check nonce matches + expect( + shieldedAccessControl.privateState.getCurrentSecretNonce( + DEFAULT_ADMIN_ROLE, + ), + ).toEqual(ADMIN_SECRET_NONCE); + } else { + // Check nonce does not match + shieldedAccessControl.privateState.injectSecretNonce( + DEFAULT_ADMIN_ROLE, + BAD_NONCE, + ); + expect( + shieldedAccessControl.privateState.getCurrentSecretNonce( + DEFAULT_ADMIN_ROLE, + ), + ).not.toEqual(ADMIN_SECRET_NONCE); + } + + if (isValidIndex) { + // Check index matches + const [, witnessCalculatedIndex] = + shieldedAccessControl.witnesses.wit_getRoleIndex( + shieldedAccessControl.getWitnessContext(), + DEFAULT_ADMIN_ROLE, + Z_ADMIN + ); + expect(witnessCalculatedIndex).toBe(INIT_COUNTER); + } else { + // Check index does not match + shieldedAccessControl.overrideWitness( + 'wit_getRoleIndex', + RETURN_BAD_INDEX, + ); + const [, witnessCalculatedIndex] = + shieldedAccessControl.witnesses.wit_getRoleIndex( + shieldedAccessControl.getWitnessContext(), + DEFAULT_ADMIN_ROLE, + Z_ADMIN + ); + expect(witnessCalculatedIndex).not.toBe(INIT_COUNTER); + } + + if (isValidPath) { + // Check path matches + const truePath = shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__operatorRoles.findPathForLeaf( + EXP_DEFAULT_ADMIN_COMMITMENT, + ); + const [, witnessCalculatedPath] = + shieldedAccessControl.witnesses.wit_getRoleCommitmentPath( + shieldedAccessControl.getWitnessContext(), + EXP_DEFAULT_ADMIN_COMMITMENT, + ); + expect(witnessCalculatedPath).toEqual(truePath); + } else { + // Check path does not match + const truePath = shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__operatorRoles.findPathForLeaf( + EXP_DEFAULT_ADMIN_COMMITMENT, + ); + shieldedAccessControl.overrideWitness( + 'wit_getRoleCommitmentPath', + RETURN_BAD_PATH, + ); + const [, witnessCalculatedPath] = + shieldedAccessControl.witnesses.wit_getRoleIndex( + shieldedAccessControl.getWitnessContext(), + DEFAULT_ADMIN_ROLE, + Z_ADMIN + ); + expect(witnessCalculatedPath).not.toEqual(truePath); + } + + // Test false case circuit + const role = ( + shieldedAccessControl['hasRole'] as (...args: unknown[]) => Role + )(...args); + expect(role.isApproved).toBe(false); + }, + ); + + it.each(commitmentDoesNotMatchCases)( + 'commitment should not match with invalid nonce or index - isValidNonce(%s), isValidIndex(%s), isValidPath(%s)', + (isValidNonce, isValidIndex, isValidPath, args) => { + if (isValidNonce) { + // Check nonce matches + expect( + shieldedAccessControl.privateState.getCurrentSecretNonce( + DEFAULT_ADMIN_ROLE, + ), + ).toEqual(ADMIN_SECRET_NONCE); + } else { + // Check nonce does not match + shieldedAccessControl.privateState.injectSecretNonce( + DEFAULT_ADMIN_ROLE, + BAD_NONCE, + ); + expect( + shieldedAccessControl.privateState.getCurrentSecretNonce( + DEFAULT_ADMIN_ROLE, + ), + ).not.toEqual(ADMIN_SECRET_NONCE); + } + + if (isValidIndex) { + // Check index matches + const [, witnessCalculatedIndex] = + shieldedAccessControl.witnesses.wit_getRoleIndex( + shieldedAccessControl.getWitnessContext(), + DEFAULT_ADMIN_ROLE, + Z_ADMIN + ); + expect(witnessCalculatedIndex).toBe(INIT_COUNTER); + } else { + // Check index does not match + shieldedAccessControl.overrideWitness( + 'wit_getRoleIndex', + RETURN_BAD_INDEX, + ); + const [, witnessCalculatedIndex] = + shieldedAccessControl.witnesses.wit_getRoleIndex( + shieldedAccessControl.getWitnessContext(), + DEFAULT_ADMIN_ROLE, + Z_ADMIN + ); + expect(witnessCalculatedIndex).not.toBe(INIT_COUNTER); + } + + if (isValidPath) { + // Check path matches + const truePath = shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__operatorRoles.findPathForLeaf( + EXP_DEFAULT_ADMIN_COMMITMENT, + ); + const [, witnessCalculatedPath] = + shieldedAccessControl.witnesses.wit_getRoleCommitmentPath( + shieldedAccessControl.getWitnessContext(), + EXP_DEFAULT_ADMIN_COMMITMENT, + ); + expect(witnessCalculatedPath).toEqual(truePath); + } else { + // Check path does not match + const truePath = shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__operatorRoles.findPathForLeaf( + EXP_DEFAULT_ADMIN_COMMITMENT, + ); + shieldedAccessControl.overrideWitness( + 'wit_getRoleCommitmentPath', + RETURN_BAD_PATH, + ); + const [, witnessCalculatedPath] = + shieldedAccessControl.witnesses.wit_getRoleIndex( + shieldedAccessControl.getWitnessContext(), + DEFAULT_ADMIN_ROLE, + Z_ADMIN + ); + expect(witnessCalculatedPath).not.toEqual(truePath); + } + + // Test false case circuit + const role = ( + shieldedAccessControl['hasRole'] as (...args: unknown[]) => Role + )(...args); + expect(role.roleCommitment).not.toEqual(EXP_DEFAULT_ADMIN_COMMITMENT); + }, + ); + }); + + describe('assertOnlyRole', () => { + beforeEach(() => { + shieldedAccessControl._grantRole(DEFAULT_ADMIN_ROLE, Z_ADMIN); + shieldedAccessControl.callerCtx.setCaller(ADMIN); + }); + + it('should not fail when authorized caller has correct nonce, index, and path', () => { + shieldedAccessControl.callerCtx.setCaller(OPERATOR_1); + shieldedAccessControl.assertOnlyRole(new Uint8Array(32).fill(1)); + // Check nonce is correct + expect( + shieldedAccessControl.privateState.getCurrentSecretNonce( + DEFAULT_ADMIN_ROLE, + ), + ).toBe(ADMIN_SECRET_NONCE); + + // Check index matches + const [, witnessCalculatedIndex] = + shieldedAccessControl.witnesses.wit_getRoleIndex( + shieldedAccessControl.getWitnessContext(), + DEFAULT_ADMIN_ROLE, + Z_ADMIN + ); + expect(witnessCalculatedIndex).toBe(INIT_COUNTER); + + // Check path matches + const truePath = shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__operatorRoles.findPathForLeaf( + EXP_DEFAULT_ADMIN_COMMITMENT, + ); + const [, witnessCalculatedPath] = + shieldedAccessControl.witnesses.wit_getRoleCommitmentPath( + shieldedAccessControl.getWitnessContext(), + EXP_DEFAULT_ADMIN_COMMITMENT, + ); + expect(witnessCalculatedPath).toEqual(truePath); + + expect(() => + shieldedAccessControl.assertOnlyRole(DEFAULT_ADMIN_ROLE), + ).not.toThrow(); + }); + + it('should not fail for admin with multiple roles', () => { + shieldedAccessControl.privateState.injectSecretNonce( + OPERATOR_ROLE_1, + OPERATOR_ROLE_1_SECRET_NONCE, + ); + shieldedAccessControl.privateState.injectSecretNonce( + OPERATOR_ROLE_2, + OPERATOR_ROLE_2_SECRET_NONCE, + ); + shieldedAccessControl.privateState.injectSecretNonce( + OPERATOR_ROLE_3, + OPERATOR_ROLE_3_SECRET_NONCE, + ); + shieldedAccessControl._grantRole(OPERATOR_ROLE_1, Z_ADMIN); + shieldedAccessControl._grantRole(OPERATOR_ROLE_2, Z_ADMIN); + shieldedAccessControl._grantRole(OPERATOR_ROLE_3, Z_ADMIN); + expect(() => { + shieldedAccessControl.assertOnlyRole(DEFAULT_ADMIN_ROLE); + shieldedAccessControl.assertOnlyRole(OPERATOR_ROLE_1); + shieldedAccessControl.assertOnlyRole(OPERATOR_ROLE_2); + shieldedAccessControl.assertOnlyRole(OPERATOR_ROLE_3); + }).not.toThrow(); + }); + }); + + describe('_checkRole', () => { + beforeEach(() => { + shieldedAccessControl._grantRole(DEFAULT_ADMIN_ROLE, Z_ADMIN); + }); + + it('should not throw if admin has role', () => { + shieldedAccessControl.callerCtx.setCaller(OPERATOR_1); + console.log("ZswapState", shieldedAccessControl.circuitContext.currentZswapLocalState) + expect(() => + shieldedAccessControl._checkRole(DEFAULT_ADMIN_ROLE, Z_ADMIN), + ).not.toThrow(); + }); + + it('should throw if unauthorized does not have role', () => { + expect(() => + shieldedAccessControl._checkRole(DEFAULT_ADMIN_ROLE, Z_UNAUTHORIZED), + ).toThrow('ShieldedAccessControl: unauthorized account'); + }); + }); + + describe('getRoleAdmin', () => { + it('should return default admin role if admin role not set', () => { + expect(shieldedAccessControl.getRoleAdmin(OPERATOR_ROLE_1)).toEqual( + DEFAULT_ADMIN_ROLE, + ); + }); + + it('should return custom admin role if set', () => { + shieldedAccessControl._setRoleAdmin(OPERATOR_ROLE_1, CUSTOM_ADMIN_ROLE); + expect(shieldedAccessControl.getRoleAdmin(OPERATOR_ROLE_1)).toEqual( + CUSTOM_ADMIN_ROLE, + ); + }); + }); + + describe('grantRole', () => { + beforeEach(() => { + shieldedAccessControl._grantRole(DEFAULT_ADMIN_ROLE, Z_ADMIN); + shieldedAccessControl.callerCtx.setCaller(ADMIN); + }); + + it('admin should grant role', () => { + shieldedAccessControl.privateState.injectSecretNonce( + OPERATOR_ROLE_1, + OPERATOR_ROLE_1_SECRET_NONCE, + ); + shieldedAccessControl.grantRole(OPERATOR_ROLE_1, Z_OPERATOR_1); + const role: Role = shieldedAccessControl.hasRole( + OPERATOR_ROLE_1, + Z_OPERATOR_1, + ); + expect(role.isApproved).toBe(true); + }); + + it('path for role should exist in Merkle tree', () => { + expect( + shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__operatorRoles.findPathForLeaf( + EXP_DEFAULT_ADMIN_COMMITMENT, + ), + ).toBeDefined(); + }); + + it('should update Merkle tree root', () => { + expect( + shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__operatorRoles.root().field, + ).toBeGreaterThan(0n); + }); + + it('_currentMerkleTreeIndex should increment', () => { + // Starts at 1 because we grant role to self in beforeEach + expect( + shieldedAccessControl.getPublicState() + .ShieldedAccessControl__currentMerkleTreeIndex, + ).toBe(1n); + + shieldedAccessControl.privateState.injectSecretNonce( + OPERATOR_ROLE_1, + OPERATOR_ROLE_1_SECRET_NONCE, + ); + shieldedAccessControl.privateState.injectSecretNonce( + OPERATOR_ROLE_2, + OPERATOR_ROLE_2_SECRET_NONCE, + ); + shieldedAccessControl.privateState.injectSecretNonce( + OPERATOR_ROLE_3, + OPERATOR_ROLE_3_SECRET_NONCE, + ); + + shieldedAccessControl.grantRole(OPERATOR_ROLE_1, Z_OPERATOR_1); + expect( + shieldedAccessControl.getPublicState() + .ShieldedAccessControl__currentMerkleTreeIndex, + ).toBe(2n); + + shieldedAccessControl.grantRole(OPERATOR_ROLE_2, Z_OPERATOR_2); + expect( + shieldedAccessControl.getPublicState() + .ShieldedAccessControl__currentMerkleTreeIndex, + ).toBe(3n); + + shieldedAccessControl.grantRole(OPERATOR_ROLE_3, Z_OPERATOR_3); + expect( + shieldedAccessControl.getPublicState() + .ShieldedAccessControl__currentMerkleTreeIndex, + ).toBe(4n); + }); + + it('admin should grant multiple roles', () => { + for (let i = 0; i < OPERATOR_ROLE_LIST.length; i++) { + shieldedAccessControl.privateState.injectSecretNonce( + OPERATOR_ROLE_LIST[i], + OPERATOR_ROLE_SECRET_NONCES[i], + ); + for (let j = 0; j < Z_OPERATOR_LIST.length; j++) { + shieldedAccessControl.grantRole( + OPERATOR_ROLE_LIST[i], + Z_OPERATOR_LIST[j], + ); + const role: Role = shieldedAccessControl.hasRole( + OPERATOR_ROLE_LIST[i], + Z_OPERATOR_LIST[j], + ); + expect(role.isApproved).toBe(true); + + + expect( + shieldedAccessControl + .getPublicState() + .ShieldedAccessControl__operatorRoles.findPathForLeaf( + EXP_DEFAULT_ADMIN_COMMITMENT, + ), + ).toBeDefined(); + } + } + }); + + it('should throw if non-admin operator grants role', () => { + shieldedAccessControl.privateState.injectSecretNonce( + OPERATOR_ROLE_1, + OPERATOR_ROLE_1_SECRET_NONCE, + ); + shieldedAccessControl._grantRole(OPERATOR_ROLE_1, Z_OPERATOR_1); + + shieldedAccessControl.callerCtx.setCaller(OPERATOR_1); + expect(() => { + shieldedAccessControl.grantRole(OPERATOR_ROLE_1, Z_UNAUTHORIZED); + }).toThrow('ShieldedAccessControl: unauthorized account'); + }); + }); + + describe('revokeRole', () => { + beforeEach(() => { + shieldedAccessControl.callerCtx.setCaller(ADMIN); + console.log("TEST - Current MT Index", shieldedAccessControl.getPublicState().ShieldedAccessControl__currentMerkleTreeIndex.toString()); + console.log(shieldedAccessControl._grantRole(DEFAULT_ADMIN_ROLE, Z_ADMIN)); + console.log(shieldedAccessControl._grantRole(DEFAULT_ADMIN_ROLE, Z_ADMIN)); + console.log("TEST - ADMIN NONCE ", fmtHexString(ADMIN_SECRET_NONCE)); + console.log("TEST - OP NONCE ", fmtHexString(OPERATOR_ROLE_1_SECRET_NONCE)); + console.log("TEST - Current MT Index", shieldedAccessControl.getPublicState().ShieldedAccessControl__currentMerkleTreeIndex.toString()); + shieldedAccessControl.privateState.injectSecretNonce( + OPERATOR_ROLE_1, + OPERATOR_ROLE_1_SECRET_NONCE, + ); + shieldedAccessControl.grantRole(OPERATOR_ROLE_1, Z_OPERATOR_1); + console.log("TEST - Current MT Index", shieldedAccessControl.getPublicState().ShieldedAccessControl__currentMerkleTreeIndex.toString()); + + }); + + it('admin should revoke role', () => { + expect(shieldedAccessControl.hasRole(OPERATOR_ROLE_1, Z_OPERATOR_1).isApproved).toBe(true); + shieldedAccessControl.revokeRole(OPERATOR_ROLE_1, Z_OPERATOR_1); + expect(shieldedAccessControl.hasRole(OPERATOR_ROLE_1, Z_OPERATOR_1).isApproved).toBe(false); + }); + + it('commitment should be in nullifier set', () => { + console.log("TEST - Current MT Index", shieldedAccessControl.getPublicState().ShieldedAccessControl__currentMerkleTreeIndex.toString()); + const opRoleIndex = getRoleIndex({ ledger: shieldedAccessControl.getPublicState(), privateState: shieldedAccessControl.getPrivateState(), contractAddress: shieldedAccessControl.contractAddress }, OPERATOR_ROLE_1, Z_OPERATOR_1); + const adminRoleIndex = getRoleIndex(shieldedAccessControl.getWitnessContext(), DEFAULT_ADMIN_ROLE, Z_ADMIN); + console.log("OPERATOR INDEX ", opRoleIndex.toString(10)); + console.log("ADMIN INDEX ", adminRoleIndex.toString(10)); + const expCommitmentOp = buildCommitment(OPERATOR_ROLE_1, Z_OPERATOR_1, OPERATOR_ROLE_1_SECRET_NONCE, 0n); + const expCommitmentOp2 = buildCommitment(OPERATOR_ROLE_1, Z_OPERATOR_1, OPERATOR_ROLE_1_SECRET_NONCE, 0n); + const pathToOp = shieldedAccessControl.getPublicState().ShieldedAccessControl__operatorRoles.findPathForLeaf(expCommitmentOp); + const pathToAdmin = shieldedAccessControl.getPublicState().ShieldedAccessControl__operatorRoles.findPathForLeaf(EXP_DEFAULT_ADMIN_COMMITMENT); + //console.log("PATH TO OP ", pathToOp); + //console.log("PATH TO ADMIN ", pathToAdmin); + + //console.log("EXPECTED COMMITMENT ", expCommitmentOp); + const contractCommit = shieldedAccessControl.hasRole(OPERATOR_ROLE_1, Z_OPERATOR_1).roleCommitment; + //console.log("CONTRACT COMMITMENT ", contractCommit); + + shieldedAccessControl.revokeRole(OPERATOR_ROLE_1, Z_OPERATOR_1); + const it = shieldedAccessControl.getPublicState().ShieldedAccessControl_sanity[Symbol.iterator](); + console.log(EXP_DEFAULT_ADMIN_COMMITMENT); + console.log(it.next()); + console.log(shieldedAccessControl.getPublicState().ShieldedAccessControl_sanity.member(EXP_DEFAULT_ADMIN_COMMITMENT)); + console.log(expCommitmentOp) + console.log(it.next()); + console.log(shieldedAccessControl.getPublicState().ShieldedAccessControl_sanity.member(expCommitmentOp)); + expect(shieldedAccessControl.getPublicState().ShieldedAccessControl__roleCommitmentNullifiers.isEmpty()).toBe(false); + expect(shieldedAccessControl.getPublicState().ShieldedAccessControl__roleCommitmentNullifiers.member(expCommitmentOp)).toBe(true); + }); + + it('admin should revoke multiple roles', () => { + const expCommitment = buildCommitment(OPERATOR_ROLE_1, Z_OPERATOR_1, OPERATOR_ROLE_1_SECRET_NONCE, 1n); + shieldedAccessControl.revokeRole(OPERATOR_ROLE_1, Z_OPERATOR_1); + expect(shieldedAccessControl.getPublicState().ShieldedAccessControl__roleCommitmentNullifiers.member(expCommitment)).toBe(true); + + for (let i = 1; i < OPERATOR_ROLE_LIST.length; i++) { + shieldedAccessControl.privateState.injectSecretNonce( + OPERATOR_ROLE_LIST[i], + OPERATOR_ROLE_SECRET_NONCES[i], + ); + for (let j = 1; j < Z_OPERATOR_LIST.length; j++) { + shieldedAccessControl._grantRole( + OPERATOR_ROLE_LIST[i], + Z_OPERATOR_LIST[j], + ); + const expCommitment = buildCommitment(OPERATOR_ROLE_LIST[i], Z_OPERATOR_LIST[j], OPERATOR_ROLE_SECRET_NONCES[i], BigInt(1 + i)); + shieldedAccessControl.revokeRole(OPERATOR_ROLE_LIST[i], Z_OPERATOR_LIST[j]); + expect(shieldedAccessControl.getPublicState().ShieldedAccessControl__roleCommitmentNullifiers.member(expCommitment)).toBe(true); + } + } + }); + + it('should throw if non-admin operator revokes role', () => { + shieldedAccessControl.privateState.injectSecretNonce( + OPERATOR_ROLE_1, + OPERATOR_ROLE_1_SECRET_NONCE, + ); + shieldedAccessControl._grantRole(OPERATOR_ROLE_1, Z_OPERATOR_1); + + shieldedAccessControl.callerCtx.setCaller(OPERATOR_1); + expect(() => { + shieldedAccessControl.revokeRole(OPERATOR_ROLE_1, Z_UNAUTHORIZED); + }).toThrow('ShieldedAccessControl: unauthorized account'); + }); + }); +}); diff --git a/contracts/src/access/test/mocks/MockShieldedAccessControl.compact b/contracts/src/access/test/mocks/MockShieldedAccessControl.compact new file mode 100644 index 00000000..05c17395 --- /dev/null +++ b/contracts/src/access/test/mocks/MockShieldedAccessControl.compact @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: MIT + +pragma language_version >= 0.16.0; + +import CompactStandardLibrary; + +import "../../ShieldedAccessControl" prefix ShieldedAccessControl_; + +export { + ZswapCoinPublicKey, + ContractAddress, + Either, + Maybe, + MerkleTreePath, + ShieldedAccessControl_DEFAULT_ADMIN_ROLE, + ShieldedAccessControl__operatorRoles, + ShieldedAccessControl__currentMerkleTreeIndex, + ShieldedAccessControl__roleCommitmentNullifiers, + ShieldedAccessControl_Role +}; + +export pure circuit _computeRoleCommitment( + accountId: Bytes<32>, + roleId: Bytes<32>, + index: Uint<64>, +): Bytes<32> { + return ShieldedAccessControl__computeRoleCommitment(accountId, roleId, index); +} + +export pure circuit _computeRoleId( + pk: Either, + nonce: Bytes<32> +): Bytes<32> { + return ShieldedAccessControl__computeRoleId(pk, nonce); +} + +export pure circuit _computeNullifier(commitment: Bytes<32>): Bytes<32> { + return ShieldedAccessControl__computeNullifier(commitment); +} + +export circuit callerHasRole(roleId: Bytes<32>): ShieldedAccessControl_Role { + return ShieldedAccessControl_callerHasRole(roleId); +} + +export circuit hasRole(roleId: Bytes<32>, accountId: Bytes<32>): Boolean { + return ShieldedAccessControl_hasRole(roleId, accountId); +} + +export circuit assertOnlyRole(roleId: Bytes<32>): [] { + ShieldedAccessControl_assertOnlyRole(roleId); +} + +export circuit getRole(roleId: Bytes<32>, accountId: Bytes<32>): ShieldedAccessControl_Role { + return ShieldedAccessControl_getRole(roleId, accountId); +} + +export circuit getRoleAdmin(roleId: Bytes<32>): Bytes<32> { + return ShieldedAccessControl_getRoleAdmin(roleId); +} + +export circuit grantRole(roleId: Bytes<32>, accountId: Bytes<32>): [] { + ShieldedAccessControl_grantRole(roleId, accountId); +} + +export circuit revokeRole(roleId: Bytes<32>, accountId: Bytes<32>): [] { + ShieldedAccessControl_revokeRole(roleId, accountId); +} + +export circuit renounceRole(roleId: Bytes<32>, callerConfirmation: Bytes<32>): [] { + ShieldedAccessControl_renounceRole(roleId, callerConfirmation); +} + +export circuit _setRoleAdmin(roleId: Bytes<32>, adminRole: Bytes<32>): [] { + ShieldedAccessControl__setRoleAdmin(roleId, adminRole); +} + +export circuit _grantRole(roleId: Bytes<32>, accountId: Bytes<32>): Boolean { + return ShieldedAccessControl__grantRole(roleId, accountId); +} + +export circuit _revokeRole(roleId: Bytes<32>, accountId: Bytes<32>): Boolean { + return ShieldedAccessControl__revokeRole(roleId, accountId); +} \ No newline at end of file diff --git a/contracts/src/access/test/simulators/ShieldedAccessControlSimulator.ts b/contracts/src/access/test/simulators/ShieldedAccessControlSimulator.ts new file mode 100644 index 00000000..2118e91e --- /dev/null +++ b/contracts/src/access/test/simulators/ShieldedAccessControlSimulator.ts @@ -0,0 +1,403 @@ +import { + type CircuitContext, + type CoinPublicKey, + emptyZswapLocalState, + witnessContext, + type WitnessContext, +} from '@midnight-ntwrk/compact-runtime'; +import { sampleContractAddress } from '@midnight-ntwrk/zswap'; +import { + type ContractAddress, + type Either, + type Ledger, + ledger, + Contract as MockShieldedAccessControl, + type ShieldedAccessControl_Role as Role, + type ZswapCoinPublicKey, +} from '../../../../artifacts/MockShieldedAccessControl/contract/index.cjs'; +import { + ShieldedAccessControlPrivateState, + ShieldedAccessControlWitnesses, +} from '../../witnesses/ShieldedAccessControlWitnesses.js'; +import type { + ContextlessCircuits, + ExtractImpureCircuits, + ExtractPureCircuits, + SimulatorOptions, +} from '../types/test.js'; +import { AbstractContractSimulator } from '../utils/AbstractContractSimulator.js'; +import { SimulatorStateManager } from '../utils/SimulatorStateManager.js'; + +type ShieldedAccessControlSimOptions = SimulatorOptions< + ShieldedAccessControlPrivateState, + typeof ShieldedAccessControlWitnesses +>; + +/** + * @description A simulator implementation of a contract for testing purposes. + * @template P - The private state type, fixed to ShieldedAccessControlPrivateState. + * @template L - The ledger type, fixed to Contract.Ledger. + */ +export class ShieldedAccessControlSimulator extends AbstractContractSimulator< + ShieldedAccessControlPrivateState, + Ledger +> { + contract: MockShieldedAccessControl; + readonly contractAddress: string; + private stateManager: SimulatorStateManager; + private callerOverride: CoinPublicKey | null = null; + private _witnesses: ReturnType; + + private _pureCircuitProxy?: ContextlessCircuits< + ExtractPureCircuits< + MockShieldedAccessControl + >, + ShieldedAccessControlPrivateState + >; + + private _impureCircuitProxy?: ContextlessCircuits< + ExtractImpureCircuits< + MockShieldedAccessControl + >, + ShieldedAccessControlPrivateState + >; + + constructor( + options: ShieldedAccessControlSimOptions = {}, + ) { + super(); + + // Setup initial state + const { + privateState = options.privateState + ? options.privateState + : ShieldedAccessControlPrivateState.generate(), + witnesses = ShieldedAccessControlWitnesses(), + coinPK = options.coinPK ? options.coinPK : '0'.repeat(64), + address = sampleContractAddress(), + } = options; + + this.contract = + new MockShieldedAccessControl( + witnesses, + ); + + this.stateManager = new SimulatorStateManager( + this.contract, + privateState, + coinPK, + address, + ); + this.contractAddress = this.circuitContext.transactionContext.address; + this._witnesses = witnesses; + this.contract = + new MockShieldedAccessControl( + this._witnesses, + ); + } + + get circuitContext() { + return this.stateManager.getContext(); + } + + set circuitContext(ctx) { + this.stateManager.setContext(ctx); + } + + getPublicState(): Ledger { + return ledger(this.circuitContext.transactionContext.state); + } + + getWitnessContext(): WitnessContext< + Ledger, + ShieldedAccessControlPrivateState + > { + return witnessContext(this.getPublicState(), this.getPrivateState(), this.contractAddress); + } + + /** + * @description Constructs a caller-specific circuit context. + * If a caller override is present, it replaces the current Zswap local state with an empty one + * scoped to the overridden caller. Otherwise, the existing context is reused as-is. + * @returns A circuit context adjusted for the current simulated caller. + */ + protected getCallerContext(): CircuitContext { + return { + ...this.circuitContext, + currentZswapLocalState: this.callerOverride + ? emptyZswapLocalState(this.callerOverride) + : this.circuitContext.currentZswapLocalState, + }; + } + + /** + * @description Initializes and returns a proxy to pure contract circuits. + * The proxy automatically injects the current circuit context into each call, + * and returns only the result portion of each circuit's output. + * @notice The proxy is created only when first accessed a.k.a lazy initialization. + * This approach is efficient in cases where only pure or only impure circuits are used, + * avoiding unnecessary proxy creation. + * @returns A proxy object exposing pure circuit functions without requiring explicit context. + */ + protected get pureCircuit(): ContextlessCircuits< + ExtractPureCircuits< + MockShieldedAccessControl + >, + ShieldedAccessControlPrivateState + > { + if (!this._pureCircuitProxy) { + this._pureCircuitProxy = this.createPureCircuitProxy< + MockShieldedAccessControl['circuits'] + >(this.contract.circuits, () => this.circuitContext); + } + return this._pureCircuitProxy; + } + + /** + * @description Initializes and returns a proxy to impure contract circuits. + * The proxy automatically injects the current (possibly caller-modified) context into each call, + * and updates the circuit context with the one returned by the circuit after execution. + * @notice The proxy is created only when first accessed a.k.a. lazy initialization. + * This approach is efficient in cases where only pure or only impure circuits are used, + * avoiding unnecessary proxy creation. + * @returns A proxy object exposing impure circuit functions without requiring explicit context management. + */ + protected get impureCircuit(): ContextlessCircuits< + ExtractImpureCircuits< + MockShieldedAccessControl + >, + ShieldedAccessControlPrivateState + > { + if (!this._impureCircuitProxy) { + this._impureCircuitProxy = this.createImpureCircuitProxy< + MockShieldedAccessControl['impureCircuits'] + >( + this.contract.impureCircuits, + () => this.getCallerContext(), + (ctx: any) => { + this.circuitContext = ctx; + }, + ); + } + return this._impureCircuitProxy; + } + + /** + * @description Resets the cached circuit proxy instances. + * This is useful if the underlying contract state or circuit context has changed, + * and you want to ensure the proxies are recreated with updated context on next access. + */ + public resetCircuitProxies(): void { + this._pureCircuitProxy = undefined; + this._impureCircuitProxy = undefined; + } + + /** + * @description Helper method that provides access to both pure and impure circuit proxies. + * These proxies automatically inject the appropriate circuit context when invoked. + * @returns An object containing `pure` and `impure` circuit proxy interfaces. + */ + public get circuits() { + return { + pure: this.pureCircuit, + impure: this.impureCircuit, + }; + } + + public get witnesses(): ReturnType { + return this._witnesses; + } + + public set witnesses(newWitnesses: ReturnType< + typeof ShieldedAccessControlWitnesses + >) { + this._witnesses = newWitnesses; + this.contract = + new MockShieldedAccessControl( + this._witnesses, + ); + this.resetCircuitProxies(); + } + + public overrideWitness( + key: K, + fn: (typeof this._witnesses)[K], + ) { + this.witnesses = { + ...this._witnesses, + [key]: fn, + }; + } + + public _computeRoleCommitment( + accountId: Uint8Array, + roleId: Uint8Array, + index: bigint, + ): Uint8Array { + return this.circuits.pure._computeRoleCommitment(accountId, roleId, index); + } + + public _computeRoleId( + pk: Either, + nonce: Uint8Array + ): Uint8Array { + return this.circuits.pure._computeRoleId(pk, nonce); + } + + public _computeNullifier(commitment: Uint8Array): Uint8Array { + return this.circuits.pure._computeNullifier(commitment); + } + + public callerHasRole(roleId: Uint8Array): Role { + return this.circuits.impure.callerHasRole(roleId); + } + + /** + * @description Returns the current commitment representing the contract owner. + * The full commitment is: `SHA256(SHA256(pk, nonce), instanceSalt, counter, domain)`. + * @returns The current owner's commitment. + */ + public hasRole( + roleId: Uint8Array, + accountId: Uint8Array, + ): Boolean { + return this.circuits.impure.hasRole(roleId, accountId); + } + + /** + * @description Transfers ownership to `newOwnerId`. + * `newOwnerId` must be precalculated and given to the current owner off chain. + * @param newOwnerId The new owner's unique identifier (`SHA256(pk, nonce)`). + */ + public assertOnlyRole(roleId: Uint8Array) { + this.circuits.impure.assertOnlyRole(roleId); + } + + public getRole(roleId: Uint8Array, accountId: Uint8Array): Role { + return this.circuits.impure.getRole(roleId, accountId); + } + + /** + * @description Computes the owner commitment from the given `id` and `counter`. + * @param id - The unique identifier of the owner calculated by `SHA256(pk, nonce)`. + * @param counter - The current counter or round. This increments by `1` + * after every transfer to prevent duplicate commitments given the same `id`. + * @returns The commitment derived from `id` and `counter`. + */ + public getRoleAdmin(roleId: Uint8Array): Uint8Array { + return this.circuits.impure.getRoleAdmin(roleId); + } + + /** + * @description Computes the unique identifier (`id`) of the owner from their + * public key and a secret nonce. + * @param pk - The public key of the identity being committed. + * @param nonce - A private nonce to scope the commitment. + * @returns The computed owner ID. + */ + public grantRole( + roleId: Uint8Array, + accountId: Uint8Array + ) { + this.circuits.impure.grantRole(roleId, accountId); + } + + /** + * @description Transfers ownership to owner id `newOwnerId` without + * enforcing permission checks on the caller. + * @param newOwnerId - The unique identifier of the new owner calculated by `SHA256(pk, nonce)`. + */ + public revokeRole( + roleId: Uint8Array, + accountId: Uint8Array + ) { + this.circuits.impure.revokeRole(roleId, accountId); + } + + /** + * @description Transfers ownership to owner id `newOwnerId` without + * enforcing permission checks on the caller. + * @param newOwnerId - The unique identifier of the new owner calculated by `SHA256(pk, nonce)`. + */ + public renounceRole( + roleId: Uint8Array, + callerConfirmation: Uint8Array + ) { + this.circuits.impure.renounceRole(roleId, callerConfirmation); + } + + /** + * @description Transfers ownership to owner id `newOwnerId` without + * enforcing permission checks on the caller. + * @param newOwnerId - The unique identifier of the new owner calculated by `SHA256(pk, nonce)`. + */ + public _setRoleAdmin(roleId: Uint8Array, adminRole: Uint8Array) { + this.circuits.impure._setRoleAdmin(roleId, adminRole); + } + + /** + * @description Transfers ownership to owner id `newOwnerId` without + * enforcing permission checks on the caller. + * @param newOwnerId - The unique identifier of the new owner calculated by `SHA256(pk, nonce)`. + */ + public _grantRole( + roleId: Uint8Array, + accountId: Uint8Array + ): boolean { + return this.circuits.impure._grantRole(roleId, accountId); + } + + /** + * @description Transfers ownership to owner id `newOwnerId` without + * enforcing permission checks on the caller. + * @param newOwnerId - The unique identifier of the new owner calculated by `SHA256(pk, nonce)`. + */ + public _revokeRole( + roleId: Uint8Array, + accountId: Uint8Array + ): boolean { + return this.circuits.impure._revokeRole(roleId, accountId); + } + + public readonly privateState = { + /** + * @description Contextually sets a new nonce into the private state. + * @param newNonce The secret nonce. + * @returns The ZOwnablePK private state after setting the new nonce. + */ + injectSecretNonce: ( + roleId: Uint8Array, + newNonce: Buffer, + ): ShieldedAccessControlPrivateState => { + const currentState = this.stateManager.getContext().currentPrivateState; + const updatedState = { + roles: { ...currentState.roles }, + }; + const roleString = Buffer.from(roleId).toString('hex'); + updatedState.roles[roleString] = newNonce; + this.stateManager.updatePrivateState(updatedState); + return updatedState; + }, + + /** + * @description Returns the secret nonce for a given roleId. + * @returns The secret nonce. + */ + getCurrentSecretNonce: (roleId: Uint8Array): Uint8Array => { + const roleString = Buffer.from(roleId).toString('hex'); + return this.stateManager.getContext().currentPrivateState.roles[ + roleString + ]; + }, + }; + + public callerCtx = { + /** + * @description Sets the caller context. + * @param caller The caller in context of the proceeding circuit calls. + */ + setCaller: (caller: CoinPublicKey) => { + this.callerOverride = caller; + } + }; +} diff --git a/contracts/src/access/test/simulators/ZOwnablePKSimulator.ts b/contracts/src/access/test/simulators/ZOwnablePKSimulator.ts index e6e3f62b..adfb67ce 100644 --- a/contracts/src/access/test/simulators/ZOwnablePKSimulator.ts +++ b/contracts/src/access/test/simulators/ZOwnablePKSimulator.ts @@ -23,7 +23,7 @@ import type { SimulatorOptions, } from '../types/test.js'; import { AbstractContractSimulator } from '../utils/AbstractContractSimulator.js'; -import { SimulatorStateManager } from '../utils/SimualatorStateManager.js'; +import { SimulatorStateManager } from '../utils/SimulatorStateManager.js'; type OwnableSimOptions = SimulatorOptions< ZOwnablePKPrivateState, diff --git a/contracts/src/access/test/utils/SimualatorStateManager.ts b/contracts/src/access/test/utils/SimulatorStateManager.ts similarity index 100% rename from contracts/src/access/test/utils/SimualatorStateManager.ts rename to contracts/src/access/test/utils/SimulatorStateManager.ts diff --git a/contracts/src/access/test/utils/address.ts b/contracts/src/access/test/utils/address.ts index fb22e4be..b5f184f4 100644 --- a/contracts/src/access/test/utils/address.ts +++ b/contracts/src/access/test/utils/address.ts @@ -64,6 +64,7 @@ export const createEitherTestContractAddress = (str: string) => ({ const baseGeneratePubKeyPair = ( str: string, asEither: boolean, + asPK: boolean, ): [ string, ( @@ -72,15 +73,25 @@ const baseGeneratePubKeyPair = ( ), ] => { const pk = toHexPadded(str); - const zpk = asEither ? createEitherTestUser(str) : encodeToPK(str); - return [pk, zpk]; + + if (asEither && asPK) { + return [pk, createEitherTestUser(str)]; + } + if (asEither && !asPK) { + return [pk, createEitherTestContractAddress(str)]; + } + + return [pk, encodeToPK(str)]; }; export const generatePubKeyPair = (str: string) => - baseGeneratePubKeyPair(str, false) as [string, Compact.ZswapCoinPublicKey]; + baseGeneratePubKeyPair(str, false, false) as [ + string, + Compact.ZswapCoinPublicKey, + ]; -export const generateEitherPubKeyPair = (str: string) => - baseGeneratePubKeyPair(str, true) as [ +export const generateEitherPubKeyPair = (str: string, asPK = true) => + baseGeneratePubKeyPair(str, true, asPK) as [ string, Compact.Either, ]; @@ -99,3 +110,13 @@ export const ZERO_ADDRESS = { left: encodeToPK(''), right: { bytes: zeroUint8Array() }, }; + +export const eitherToBytes = ( + account: Compact.Either, +) => { + if (account.is_left) { + return account.left.bytes; + } + + return account.right.bytes; +}; diff --git a/contracts/src/access/witnesses/ShieldedAccessControlWitnesses.ts b/contracts/src/access/witnesses/ShieldedAccessControlWitnesses.ts new file mode 100644 index 00000000..cd905647 --- /dev/null +++ b/contracts/src/access/witnesses/ShieldedAccessControlWitnesses.ts @@ -0,0 +1,230 @@ +import { getRandomValues } from 'node:crypto'; +import { + CompactTypeBytes, + CompactTypeVector, + convert_bigint_to_Uint8Array, + persistentHash, + type WitnessContext, +} from '@midnight-ntwrk/compact-runtime'; +import type { + ContractAddress, + Either, + Ledger, + MerkleTreePath, + ZswapCoinPublicKey, +} from '../../../artifacts/MockShieldedAccessControl/contract/index.cjs'; +import { eitherToBytes } from '../test/utils/address'; + +const COMMITMENT_DOMAIN = new Uint8Array(32); +new TextEncoder().encodeInto('ShieldedAccessControl:commitment', COMMITMENT_DOMAIN); + +export function fmtHexString(bytes: string | Uint8Array): string { + if (bytes instanceof String) { + return `${bytes.slice(0, 4)}...${bytes.slice(-4)}`; + } else { + const buffStr = Buffer.from(bytes as Uint8Array).toString('hex'); + return `${buffStr.slice(0, 4)}...${buffStr.slice(-4)}`; + } +} + +export function createAccountId(account: Either, secretNonce: Uint8Array): Uint8Array { + const rt_type = new CompactTypeVector(2, new CompactTypeBytes(32)); + const bAccount = eitherToBytes(account); + return persistentHash(rt_type, [secretNonce, bAccount]); +} + +/** + * @description Interface defining the witness methods for ShieldedAccessControl operations. + * @template P - The private state type. + */ +export interface IShieldedAccessControlWitnesses

{ + /** + * Retrieves the secret nonce from the private state. + * @param context - The witness context containing the private state. + * @returns A tuple of the private state and the secret nonce as a Uint8Array. + */ + wit_secretNonce( + context: WitnessContext, + roleId: Uint8Array, + ): [P, Uint8Array]; + wit_getRoleCommitmentPath( + context: WitnessContext, + roleCommitment: Uint8Array, + ): [P, MerkleTreePath]; + wit_getRoleIndex( + context: WitnessContext, + roleId: Uint8Array, + accountId: Uint8Array + ): [P, bigint]; +} + +type RoleId = string; +type SecretNonce = Uint8Array; + +/** + * @description Represents the private state of an ownable contract, storing a secret nonce. + */ +export type ShieldedAccessControlPrivateState = { + /** @description A 32-byte secret nonce used as a privacy additive. */ + roles: Record; +}; + +/** + * @description Utility object for managing the private state of a Shielded AccessControl contract. + */ +export const ShieldedAccessControlPrivateState = { + /** + * @description Generates a new private state with a random secret nonce and a default roleId of 0. + * @returns A fresh ShieldedAccessControlPrivateState instance. + */ + generate: ( + ): ShieldedAccessControlPrivateState => { + const defaultRoleId: string = Buffer.alloc(32).toString('hex'); + const secretNonce = new Uint8Array(getRandomValues(Buffer.alloc(32))); + + const privateState: ShieldedAccessControlPrivateState = { + roles: {}, + }; + privateState.roles[defaultRoleId] = secretNonce; + return privateState; + }, + + /** + * @description Generates a new private state with a user-defined secret nonce. + * Useful for deterministic nonce generation or advanced use cases. + * + * @param nonce - The 32-byte secret nonce to use. + * @returns A fresh ShieldedAccessControlPrivateState instance with the provided nonce. + * + * @example + * ```typescript + * // For deterministic nonces (user-defined scheme) + * const deterministicNonce = myDeterministicScheme(...); + * const privateState = ShieldedAccessControlPrivateState.withNonce(deterministicNonce); + * ``` + */ + withRoleAndNonce: ( + roleId: Buffer, + nonce: Buffer, + ): ShieldedAccessControlPrivateState => { + const roleString = roleId.toString('hex'); + const privateState: ShieldedAccessControlPrivateState = { + roles: {}, + }; + privateState.roles[roleString] = nonce; + return privateState; + }, + + setRole: ( + privateState: ShieldedAccessControlPrivateState, + roleId: Buffer, + nonce: Buffer, + ): ShieldedAccessControlPrivateState => { + const roleString = roleId.toString('hex'); + privateState.roles[roleString] = nonce; + return privateState; + }, + + getRoleCommitmentPath: ( + ledger: Ledger, + roleCommitment: Uint8Array, + ): MerkleTreePath => { + const path = + ledger.ShieldedAccessControl__operatorRoles.findPathForLeaf( + roleCommitment, + ); + const defaultPath: MerkleTreePath = { + leaf: new Uint8Array(32), + path: Array.from({ length: 10 }, () => ({ + sibling: { field: 0n }, + goes_left: false, + })), + }; + return path ? path : defaultPath; + }, + + // If index cannot be found in MT return _currentMTIndex + getRoleIndex: ( + { + ledger, + privateState, + }: WitnessContext, + roleId: Uint8Array, + accountId: Uint8Array + ): bigint => { + const rt_type = new CompactTypeVector(4, new CompactTypeBytes(32)); + // Iterate over each MT index to determine if commitment exists + console.log("current MT index ", ledger.ShieldedAccessControl__currentMerkleTreeIndex); + for (let i = 0; i <= ledger.ShieldedAccessControl__currentMerkleTreeIndex; i++) { + const index = BigInt(i); + const bIndex = convert_bigint_to_Uint8Array(32, index); + const commitment = persistentHash(rt_type, [ + accountId, + roleId, + bIndex, + COMMITMENT_DOMAIN, + ]); + try { + const pathForLeaf = ledger.ShieldedAccessControl__operatorRoles.pathForLeaf( + index, + commitment, + ); + if (Buffer.from(pathForLeaf.leaf).compare(Buffer.from(commitment)) === 0) { + return index; + } + } catch (e: unknown) { + if (e instanceof Error) { + const [msg, index] = e.message.split(':'); + if (msg === 'invalid index into sparse merkle tree') { + // console.log(`accountId ${fmtHexString(accountId)} with commitment ${fmtHexString(commitment)} not found at index ${index}`); + } else { + throw e; + } + } + } + } + + console.log("WIT - Commitment DNE, returning MT index ", ledger.ShieldedAccessControl__currentMerkleTreeIndex.toString()); + + // If commitment doesn't exist return currentMTIndex + // Used for adding roles + return ledger.ShieldedAccessControl__currentMerkleTreeIndex; + }, +}; + +/** + * @description Factory function creating witness implementations for Shielded AccessControl operations. + * @returns An object implementing the Witnesses interface for ShieldedAccessControlPrivateState. + */ +export const ShieldedAccessControlWitnesses = + (): IShieldedAccessControlWitnesses => ({ + wit_secretNonce( + context: WitnessContext, + roleId: Uint8Array, + ): [ShieldedAccessControlPrivateState, Uint8Array] { + const roleString = Buffer.from(roleId).toString('hex'); + return [context.privateState, context.privateState.roles[roleString]]; + }, + wit_getRoleCommitmentPath( + context: WitnessContext, + roleCommitment: Uint8Array, + ): [ShieldedAccessControlPrivateState, MerkleTreePath] { + return [ + context.privateState, + ShieldedAccessControlPrivateState.getRoleCommitmentPath( + context.ledger, + roleCommitment, + ), + ]; + }, + wit_getRoleIndex( + context: WitnessContext, + roleId: Uint8Array, + accountId: Uint8Array + ): [ShieldedAccessControlPrivateState, bigint] { + return [ + context.privateState, + ShieldedAccessControlPrivateState.getRoleIndex(context, roleId, accountId), + ]; + }, + }); diff --git a/docs/modules/ROOT/pages/api/shieldedAccessControl.adoc b/docs/modules/ROOT/pages/api/shieldedAccessControl.adoc new file mode 100644 index 00000000..e69de29b diff --git a/docs/modules/ROOT/pages/shieldedAccessControl.adoc b/docs/modules/ROOT/pages/shieldedAccessControl.adoc new file mode 100644 index 00000000..e69de29b