Access Control

This directory provides ways to restrict who can access the circuits of a contract or when they can do it.

  • AccessControl provides a per-contract role based access control mechanism. Multiple hierarchical roles can be created and assigned each to multiple accounts within the same instance.

  • Ownable is a simpler mechanism with a single owner "role" that can be assigned to a single account. This simpler mechanism can be useful for quick tests but projects with production concerns are likely to outgrow it.

  • ZOwnablePK provides a privacy-preserving single owner access control mechanism using cryptographic commitments. The owner’s public key is never revealed on-chain, instead storing only a commitment that proves ownership without exposing identity, suitable for applications requiring administrative control with strong privacy guarantees.

Core

AccessControl

import "./node_modules/@openzeppelin-compact/contracts/src/access/AccessControl";

Roles are referred to by their Bytes<32> identifier. These should be exposed in the top-level contract and be unique. The best way to achieve this is by using export sealed ledger hash digests that are initialized in the top-level contract:

import CompactStandardLibrary;
import "./node_modules/@openzeppelin-compact/contracts/src/access/AccessControl"
  prefix AccessControl_;

export sealed ledger MY_ROLE: Bytes<32>;

constructor() {
  MY_ROLE = persistentHash<Bytes<32>>(pad(32, "MY_ROLE"));
}

To restrict access to a circuit, use assertOnlyRole:

circuit foo(): [] {
  assertOnlyRole(MY_ROLE);
  ...
}

Roles can be granted and revoked dynamically via the grantRole and revokeRole functions. 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() function.

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.
For an overview of the module, read the AccessControl guide.

hasRole(roleId: Bytes<32>, account: Either<ZswapCoinPublicKey, ContractAddress>) → Boolean circuit

Returns true if account has been granted roleId.

Constraints:

  • k=10, rows=487

assertOnlyRole(roleId: Bytes<32>) → [] circuit

Reverts if caller is missing roleId.

Requirements:

  • The caller must have roleId.

  • The caller must not be a ContractAddress.

Constraints:

  • k=10, rows=345

_checkRole(roleId: Bytes<32>, account: Either<ZswapCoinPublicKey, ContractAddress>) → [] circuit

Reverts if account is missing roleId.

Requirements:

  • account must have roleId.

Constraints:

  • k=10, rows=467

getRoleAdmin(roleId: Bytes<32>) → Bytes<32> circuit

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.

Constraints:

  • k=10, rows=207

grantRole(roleId: Bytes<32>, account: Either<ZswapCoinPublicKey, ContractAddress>) → [] circuit

Grants roleId to account.

Granting roles to contract addresses is currently disallowed until contract-to-contract interactions are supported in Compact. This restriction prevents permanently disabling access to a circuit.

Requirements:

  • account must not be a ContractAddress.

  • The caller must have roleId's admin role.

Constraints:

  • k=10, rows=994

revokeRole(roleId: Bytes<32>, account: Either<ZswapCoinPublicKey, ContractAddress>) → [] circuit

Revokes roleId from account.

Requirements:

  • The caller must have roleId's admin role.

Constraints:

  • k=10, rows=827

renounceRole(roleId: Bytes<32>, callerConfirmation: Either<ZswapCoinPublicKey, ContractAddress>) → [] circuit

Revokes roleId from the calling account.

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).

We do not provide functionality for smart contracts to renounce roles because self-executing transactions are not supported on Midnight at this time. We may revisit this in future if this feature is made available in Compact.

Requirements:

  • The caller must be callerConfirmation.

  • The caller must not be a ContractAddress.

Constraints:

  • k=10, rows=640

_setRoleAdmin(roleId: Bytes<32>, adminRole: Bytes<32>) → [] circuit

Sets adminRole as roleId's admin role.

Constraints:

  • k=10, rows=209

_grantRole(roleId: Bytes<32>, adminRole: Bytes<32>) → Boolean circuit

Attempts to grant roleId to account and returns a boolean indicating if roleId was granted.

Internal circuit without access restriction.

Granting roles to contract addresses is currently disallowed in this circuit until contract-to-contract interactions are supported in Compact. This restriction prevents permanently disabling access to a circuit.

Requirements:

  • account must not be a ContractAddress.

Constraints:

  • k=10, rows=734

_unsafeGrantRole(roleId: Bytes<32>, account: Either<ZswapCoinPublicKey, ContractAddress>) → Boolean circuit

Unsafe variant of _grantRole.

Granting roles to contract addresses is considered unsafe because contract-to-contract calls are not currently supported. Granting a role to a smart contract may render a circuit permanently inaccessible. Once contract-to-contract calls are supported, this circuit may be deprecated.

Constraints:

  • k=10, rows=733

_revokeRole(roleId: Bytes<32>, account: Either<ZswapCoinPublicKey, ContractAddress>) → Boolean circuit

Attempts to revoke roleId from account and returns a boolean indicating if roleId was revoked.

Internal circuit without access restriction.

Constraints:

  • k=10, rows=563

Ownable

import "./node_modules/@openzeppelin-compact/contracts/src/access/Ownable";

Ownable provides a basic access control mechanism where an account (an owner) can be granted exclusive access to specific circuits.

This module includes assertOnlyOwner to restrict a circuit to be used only by the owner.

For an overview of the module, read the Ownable guide.

initialize(initialOwner: Either<ZswapCoinPublicKey, ContractAddress>) → [] circuit

Initializes the contract by setting the initialOwner. This must be called in the contract’s constructor.

Requirements:

  • Contract is not already initialized.

  • initialOwner is not a ContractAddress.

  • initialOwner is not the zero address.

Constraints:

  • k=10, rows=258

owner() → Either<ZswapCoinPublicKey, ContractAddress> circuit

Returns the current contract owner.

Requirements:

  • Contract is initialized.

Constraints:

  • k=10, rows=84

transferOwnership(newOwner: Either<ZswapCoinPublicKey, ContractAddress>) → [] circuit

Transfers ownership of the contract to newOwner.

Ownership transfers to contract addresses are currently disallowed until contract-to-contract interactions are supported in Compact. This restriction prevents permanently disabling access to a circuit.

Requirements:

  • Contract is initialized.

  • The caller is the current contract owner.

  • newOwner is not a ContractAddress.

  • newOwner is not the zero address.

Constraints:

  • k=10, rows=338

_unsafeTransferOwnership(newOwner: Either<ZswapCoinPublicKey, ContractAddress>) → [] circuit

Unsafe variant of transferOwnership.

Ownership transfers to contract addresses are considered unsafe because contract-to-contract calls are not currently supported. Ownership privileges sent to a contract address may become uncallable. Once contract-to-contract calls are supported, this circuit may be deprecated.

Requirements:

  • Contract is initialized.

  • The caller is the current contract owner.

  • newOwner is not the zero address.

Constraints:

  • k=10, rows=335

renounceOwnership() → [] circuit

Leaves the contract without an owner. It will not be possible to call assertOnlyOwner circuits anymore. Can only be called by the current owner.

Requirements:

  • Contract is initialized.

  • The caller is the current contract owner.

Constraints:

  • k=10, rows=124

assertOnlyOwner() → [] circuit

Throws if called by any account other than the owner. Use this to restrict access of specific circuits to the owner.

Requirements:

  • Contract is initialized.

  • The caller is the current contract owner.

Constraints:

  • k=10, rows=115

_transferOwnership(newOwner: Either<ZswapCoinPublicKey, ContractAddress>) → [] circuit

Transfers ownership of the contract to a newOwner without enforcing permission checks on the caller.

Ownership transfers to contract addresses are currently disallowed until contract-to-contract interactions are supported in Compact. This restriction prevents permanently disabling access to a circuit.

Requirements:

  • Contract is initialized.

  • newOwner is not a ContractAddress.

Constraints:

  • k=10, rows=219

_unsafeUncheckedTransferOwnership(newOwner: Either<ZswapCoinPublicKey, ContractAddress>) → [] circuit

Unsafe variant of _transferOwnership.

Ownership transfers to contract addresses are considered unsafe because contract-to-contract calls are not currently supported. Ownership privileges sent to a contract address may become uncallable. Once contract-to-contract calls are supported, this circuit may be deprecated.

Requirements:

  • Contract is initialized.

Constraints:

  • k=10, rows=216

ZOwnablePK

import "./node_modules/@openzeppelin-compact/contracts/src/access/ZOwnablePK";

ZOwnablePK provides a privacy-preserving access control mechanism for contracts with a single administrative user. Unlike traditional Ownable implementations that store or expose the owner’s public key on-chain, this module stores only a commitment to a hashed identifier derived from the owner’s public key and a secret nonce. For the strongest security guarantees, use an Air-Gapped Public Key.

Ownable provides a basic access control mechanism where an account (an owner) can be granted exclusive access to specific circuits.

This module includes ZOwnablePK-assertOnlyOwner to restrict a circuit to be used only by the owner.

For an overview of the module, read the ZOwnablePK guide.

initialize(initialOwner: Either<ZswapCoinPublicKey, ContractAddress>) → [] circuit

Initializes the contract by setting the initial owner via ownerId and storing the instanceSalt that acts as a privacy additive for preventing duplicate commitments among other contracts implementing ZOwnablePK.

The ownerId must be calculated prior to contract deployment. See ZOwnablePK-_computeOwnerId]

Requirements:

  • Contract is not already initialized.

  • ownerId is not an empty array.

Constraints:

  • k=14, rows=14933

owner() → Bytes<32> circuit

Returns the current commitment representing the contract owner. The full commitment is: SHA256(SHA256(pk, nonce), instanceSalt, counter, domain).

Requirements:

  • Contract is initialized.

Constraints:

  • k=10, rows=57

transferOwnership(newOwnerId: Bytes<32>) → [] circuit

Transfers ownership of the contract to newOwnerId. newOwnerId must be precalculated and given to the current owner off chain.

Requirements:

  • Contract is initialized.

  • Caller is the current contract owner.

  • newOwnerId is not an empty array.

Constraints:

  • k=16, rows=39240

renounceOwnership() → [] circuit

Leaves the contract without an owner. It will not be possible to call ZOwnablePK-assertOnlyOwner circuits anymore. Can only be called by the current owner.

Requirements:

  • Contract is initialized.

  • Caller is the current owner.

Constraints:

  • k=15, rows=24442

assertOnlyOwner() → [] circuit

Throws if called by any account whose id hash SHA256(pk, nonce) does not match the stored owner commitment. Use this to only allow the owner to call specific circuits.

Requirements:

  • Contract is initialized.

  • Caller’s id (SHA256(pk, nonce)) when used in ZOwnablePK-_computeOwnerCommitment equals the stored _ownerCommitment, thus verifying themselves as the owner.

Constraints:

  • k=15, rows=24437

_computeOwnerCommitment(id: Bytes<32>, counter: Uint<64>) → Bytes<32> circuit

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.

Requirements:

  • Contract is initialized.

Constraints:

  • k=14, rows=14853

_computeOwnerId(pk: Either<ZswapCoinPublicKey, ContractAddress>, nonce: Bytes<32>) → Bytes<32> circuit

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. This value should be randomly generated and kept private. It may be rotated periodically for enhanced unlinkability.

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.

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:

  • Contract is initialized.

  • pk is not a ContractAddress.

_transferOwnership(newOwnerId: Bytes<32>) → [] circuit

Transfers ownership to owner id newOwnerId without enforcing permission checks on the caller.

Requirements:

  • Contract is initialized.

Constraints:

  • k=14, rows=14823