Access Control

Access control—that is, "who is allowed to do this thing"—is incredibly important in the world of smart contracts. The access control of your contract may govern who can mint tokens, vote on proposals, freeze transfers, and many other things. It is therefore critical to understand how you implement it, lest someone else steals your whole system.

Ownership and Ownable

The most common and basic form of access control is the concept of ownership: there’s an account that is the owner of a contract and can do administrative tasks on it. This approach is perfectly reasonable for contracts that have a single administrative user.

OpenZeppelin Contracts for Compact provides an Ownable module for implementing ownership in your contracts. The initial owner must be set by using the initialize circuit during construction. This can later be changed with transferOwnership.

Usage

Import the Ownable module into the implementing contract. It’s recommended to prefix the module with Ownable_ to avoid circuit signature clashes.

// MyOwnableContract.compact

pragma language_version >= 0.16.0;

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

constructor(
  initialOwner: Either<ZswapCoinPublicKey, ContractAddress>
) {
  Ownable_initialize(initialOwner);
}

To protect a circuit so that only the contract owner may call it, insert the assertOnlyOwner circuit in the beginning of the circuit body like this:

export circuit mySensitiveCircuit(): [] {
  Ownable_assertOnlyOwner();

  // Do something
}

Contracts may expose transferOwnership to allow the owner to transfer ownership.

export circuit transferOwnership(
  newOwner: Either<ZswapCoinPublicKey, ContractAddress>
): [] {
  Ownable_transferOwnership(newOwner);
}

Here’s a complete contract showcasing how to integrate the Ownable module and protect sensitive circuits.

// SimpleOwnable.compact

pragma language_version >= 0.16.0;

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

/**
 * Set `initialOwner` as the owner of the contract.
*/
constructor(initialOwner: Either<ZswapCoinPublicKey, ContractAddress>) {
  Ownable_initialize(initialOwner);
}

/**
 * The current owner of the contact.
 */
export circuit owner(): Either<ZswapCoinPublicKey, ContractAddress> {
  return Ownable_owner();
}

/**
 * Transfers ownership of the contract.
 * Can only be called by the current owner.
 */
export circuit transferOwnership(
  newOwner: Either<ZswapCoinPublicKey, ContractAddress>
): [] {
  Ownable_transferOwnership(newOwner);
}

/**
 * Leaves the contract without an owner.
 * Can only be called by the current owner.
 * Renouncing ownership means `mySensitiveCircuit` can never be called again.
 */
export circuit renounceOwnership(): [] {
  Ownable_renounceOwnership();
}

/**
 * This is the protected circuit that only the current owner can call.
 */
export circuit mySensitiveCircuit(): [] {
  // Protects the circuit
  Ownable_assertOnlyOwner();

  // Do something
}
For more complex logic, contracts may transfer ownership to another user irrespective of the caller by leveraging _transferOwnership. This is generally more useful when contract addresses are the owner or when a contract has a unique deployment process.

Ownership transfers

Ownership can only be transferred to ZswapCoinPublicKeys through the main transfer circuits (transferOwnership and _transferOwnership). In other words, ownership transfers 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 ownership, the owner contract cannot directly call the protected circuit.

Experimental features

This module offers experimental circuits that allow ownership to be granted to contract addresses (_unsafeTransferOwnership and _unsafeUncheckedTransferOwnership). Note that the circuit names are very explicit ("unsafe") with these experimental circuits. Until contract-to-contract calls are supported, there is no direct way for a contract to call circuits of other contracts or transfer ownership back to a user.

The unsafe circuits are planned to become deprecated once contract-to-contract calls become available.

Shielded Ownership and ZOwnablePK

Privacy-preserving access control is a fundamental building block for confidential smart contracts on Midnight. While traditional ownership patterns expose the owner’s identity on-chain, many applications require administrative control without revealing who holds that authority.

Privacy-First Ownership

The most common approach to access control in traditional smart contracts is ownership: there’s an account that is the owner of a contract and can perform administrative tasks. However, this approach reveals the owner’s identity to all observers, creating privacy and security risks. In privacy-sensitive applications—such as confidential voting systems, private treasuries, or anonymous governance—revealing the administrator’s identity may compromise the entire system’s confidentiality. This library provides the ZOwnablePK module that implements shielded ownership—administrative control without identity disclosure. The owner’s public key is never revealed on-chain; instead, the contract stores only a cryptographic commitment that proves ownership without exposing the underlying identity.

Commitment Scheme

The ZOwnablePK module employs a two-layer cryptographic commitment scheme designed to provide privacy, unlinkability, and collision resistance across deployments and ownership transfers.

Owner ID Computation

The foundation of the system is the owner identifier, computed as:

id = SHA256(pk, nonce)

Where pk is the owner’s public key and nonce is a secret value that may be either randomly generated for maximum privacy or deterministically derived for recoverability. This identifier serves as a privacy-preserving alternative to exposing the raw public key, ensuring the owner’s identity remains confidential.

Owner Commitment Computation

The final ownership commitment stored on-chain is computed as:

commitment = SHA256(id, instanceSalt, counter, pad(32, "ZOwnablePK:shield:"))

This multi-element hash provides several security properties:

  • id: The privacy-preserving owner identifier described above.

  • instanceSalt: A unique per-deployment salt that prevents commitment collisions across different contract instances, even when the same owner and nonce are used.

  • counter: Incremented with each ownership transfer to ensure unlinkability—each transfer produces a completely different commitment even with the same underlying owner.

  • pad(32, "ZOwnablePK:shield:"): A domain separator padded to 32 bytes that prevents hash collisions with other commitment schemes and enables safe protocol extensions.

Security Properties

This commitment scheme ensures that:

  • Public keys are never revealed on-chain.

  • Observers cannot correlate past and future ownership.

  • Cross-contract collisions are prevented through instance-specific salting.

Nonce Generation Strategies

The choice of nonce generation strategy represents a fundamental trade-off between simplicity/security and recoverability. Both approaches are valid, and the best choice depends on your specific threat model and operational requirements.

Random Nonce

Generating a cryptographically strong random nonce provides the strongest privacy guarantees:

const randomNonce = crypto.getRandomValues(new Uint8Array(32));
const ownerId = ZOwnablePK._computeOwnerId(publicKey, randomNonce);

This approach is easy to generate and ensures maximum unlinkability—even with sophisticated analysis, observers cannot correlate ownership across different contracts or time periods. However, it requires secure backup of both the private key and the nonce. Loss of either component results in permanent, irrecoverable loss of ownership.

Deterministic Nonce

Deriving the nonce deterministically enables recovery through derivation schemes. Some examples:

  • H(passphrase + context) - recoverable from passphrase only, but passphrase becomes critical single point of failure.

  • H(publicKey + userPassphrase + context) - requires both public key and passphrase.

  • H(signature + context) where signature = sign(context) - leverages wallet without exposing private key.

When using signature-based nonce derivation, ensure the wallet/library uses deterministic signatures (Ed25519 or RFC 6979 for ECDSA). Non-deterministic signatures will generate different nonces on each signing, making recovery impossible. Test the implementation by signing the same message twice then verify that the signatures match.

Context-Dependent Derivations:

  • Include contract address, deployment timestamp, user ID, etc.

  • Trade-off: more context is more unique but harder to recreate.

Approaches that avoid private key exposure (public key + passphrase, signature-based) are generally recommended for operational security.

Deriving the nonce deterministically from an Air-Gapped Public Key and user passphrase provides a balance of security and recoverability:

// Example: Scrypt-based derivation
import { scryptSync } from 'node:crypto';

const deterministicNonce = scryptSync(
  userPassphrase
  publicKey + ":ZOwnablePK:nonce:v1",
  32,
  { N: 16384, r: 8, p: 1 } // Standard scrypt parameters
);
const recoverableOwnerId = ZOwnablePK._computeOwnerId(publicKey, deterministicNonce);

Security Considerations

The ZOwnablePK module remains agnostic to nonce generation methods, placing the security/convenience decision entirely with the user. Key considerations include:

  • Backup requirements: Random nonces require additional secure storage.

  • Recovery scenarios: Deterministic nonces enable recovery.

  • Cross-contract correlation: Reusing nonce strategies may reduce privacy across deployments.

  • Rotation costs: Changing nonces requires ownership transfer transactions with associated DUST costs.

Users should carefully evaluate their threat model, operational requirements, and privacy needs when selecting a nonce generation strategy, as this choice cannot be easily changed without transferring ownership.

Air-Gapped Public Key (AGPK)

For maximum privacy guarantees, users should employ an Air-Gapped Public Key (AGPK) exclusively for contract ownership and administrative circuits. An AGPK is a public key that maintains complete isolation from all other on-chain activities, similar to how air-gapped systems are isolated from networks to prevent data leakage.

The Privacy Enhancement

While ZOwnablePK provides cryptographic privacy through its commitment scheme, operational security practices like using an AGPK provide an additional layer of protection against correlation attacks. Even with the strongest cryptographic commitments, reusing a public key across different on-chain activities can potentially compromise privacy through transaction pattern analysis.

AGPK Principles

An Air-Gapped Public Key must adhere to strict isolation principles:

  • Never used before: The private key material (including any seed, parent key, or entropy source from which this key is derived) has never generated any public key that appears in any on-chain transaction, across any blockchain network. The key material must be cryptographically pure.

  • Never used elsewhere: From the moment of AGPK generation until its destruction, the private key material is used exclusively for this contract’s administrative functions (i.e. assertOnlyOwner). No other public keys may ever be derived from or generated with the same key material.

  • Never used again: Users commit to destroying all copies of the private key material upon ownership renunciation or transfer. This relies entirely on user discipline and cannot be externally verified or enforced.

Best Practices Recommendation

While neither required nor enforced by the ZOwnablePK module, an Air-Gapped Public Key provides strong operational privacy hygiene for shielded contract administration. Users should evaluate their threat model and privacy requirements when deciding whether to implement AGPK practices.

The effectiveness of an AGPK depends entirely on abiding by the AGPK principles. A single transaction using the key outside the administrative context compromises all privacy benefits.

Usage

Import the ZOwnablePK module into the implementing contract and expose the ownership-handling circuits. It’s recommended to prefix the module with ZOwnablePK_ to avoid circuit signature clashes.

// MyZOwnablePKContract.compact

pragma language_version >= 0.16.0;

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

constructor(
  initOwnerCommitment: Bytes<32>,
  instanceSalt: Bytes<32>,
) {
  ZOwnablePK_initialize(initOwnerCommitment, instanceSalt);
}

export circuit owner(): Bytes<32> {
  return ZOwnablePK_owner();
}

export circuit transferOwnership(newOwnerCommitment: Bytes<32>): [] {
  return ZOwnablePK_transferOwnership(disclose(newOwnerCommitment));
}

export circuit renounceOwnership(): [] {
  return ZOwnablePK_renounceOwnership();
}

Similar to the Ownable module, circuits can be protected so that only the contract owner may them by adding assertOnlyOwner as the first line in the circuit body like this:

export circuit mySensitiveCircuit(): [] {
  ZOwnablePK_assertOnlyOwner();

  // Do something
}

This covers the basics for creating a contract, but before deploying the contract, the owner’s id must be derived for the commitment scheme because it’s required to deploy the contract.

First, the owner needs to generate a secret nonce that’s stored in the owner’s private state. See Nonce Generation Strategies.

Once the owner has the secret nonce generated, they can insert their public key and nonce into the following:

import {
  CompactTypeBytes,
  CompactTypeVector,
  persistentHash,
} from '@midnight-ntwrk/compact-runtime';
import { getRandomValues } from 'node:crypto';

// Owner ID
const generateId = (
  pk: Uint8Array,
  nonce: Uint8Array,
): Uint8Array => {
  const rt_type = new CompactTypeVector(2, new CompactTypeBytes(32));
  return persistentHash(rt_type, [pk, nonce]);
};

// Instance salt for the constructor
const generateInstanceSalt = (): Uint8Array => {
  return getRandomValues(new Uint8Array(32));
}
Another way to get the user ID is to expose _computeOwnerId in the contract and call this circuit off chain through a contract simulator. Be on the lookout for future tooling that makes this process easier.

Role-Based Access Control

While the simplicity of ownership can be useful for simple systems or quick prototyping, different levels of authorization are often needed. You may want for an account to have permission to ban users from a system, but not create new tokens. Role-Based Access Control (RBAC) offers flexibility in this regard.

In essence, we will be defining multiple roles, each allowed to perform different sets of actions. An account may have, for example, 'moderator', 'minter' or 'admin' roles, which you will then check for instead of simply using assertOnlyOwner. This check can be enforced through the assertOnlyRole circuit. Separately, you will be able to define rules for how accounts can be granted a role, have it revoked, and more.

Most software uses access control systems that are role-based: some users are regular users, some may be supervisors or managers, and a few will often have administrative privileges.

Using AccessControl

The Compact contracts library provides AccessControl for implementing role-based access control. Its usage is straightforward: for each role that you want to define, you will create a new role identifier that is used to grant, revoke, and check if an account has that role.

Here’s a simple example of using AccessControl with FungibleToken to define a 'minter' role, which allows accounts that have this role to create new tokens:

// AccessControlMinter.compact

pragma language_version >= 0.16.0;

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

export sealed ledger MINTER_ROLE: Bytes<32>;

/**
 * Initialize FungibleToken and MINTER_ROLE
 */
constructor(
  name: Opaque<"string">,
  symbol: Opaque<"string">,
  decimals: Uint<8>,
  minter: Either<ZswapCoinPublicKey, ContractAddress>
) {
  FungibleToken_initialize(name, symbol, decimals);
  MINTER_ROLE = persistentHash<Bytes<32>>(pad(32, "MINTER_ROLE"));
  AccessControl__grantRole(MINTER_ROLE, minter);
}

export circuit mint(
  recipient: Either<ZswapCoinPublicKey, ContractAddress>,
  value: Uint<128>,
): [] {
  AccessControl_assertOnlyRole(MINTER_ROLE);
  FungibleToken__mint(recipient, value);
}
Make sure you fully understand how AccessControl works before using it on your system, or copy-pasting the examples from this guide.

While clear and explicit, this isn’t anything we wouldn’t have been able to achieve with Ownable. Indeed, where AccessControl shines is in scenarios where granular permissions are required, which can be implemented by defining multiple roles.

Let’s augment our FungibleToken example by also defining a 'burner' role, which lets accounts destroy tokens.

// AccessControlMinter.compact

pragma language_version >= 0.16.0;

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

export sealed ledger MINTER_ROLE: Bytes<32>;
export sealed ledger BURNER_ROLE: Bytes<32>;

/**
 * Initialize FungibleToken and MINTER_ROLE
 */
constructor(
  name: Opaque<"string">,
  symbol: Opaque<"string">,
  decimals: Uint<8>,
  minter: Either<ZswapCoinPublicKey, ContractAddress>,
  burner: Either<ZswapCoinPublicKey, ContractAddress>
) {
  FungibleToken_initialize(name, symbol, decimals);
  MINTER_ROLE = persistentHash<Bytes<32>>(pad(32, "MINTER_ROLE"));
  BURNER_ROLE = persistentHash<Bytes<32>>(pad(32, "BURNER_ROLE"));
  AccessControl__grantRole(MINTER_ROLE, minter);
  AccessControl__grantRole(BURNER_ROLE, burner);
}

export circuit mint(
  recipient: Either<ZswapCoinPublicKey, ContractAddress>,
  value: Uint<128>,
): [] {
  AccessControl_assertOnlyRole(MINTER_ROLE);
  FungibleToken__mint(recipient, value);
}

export circuit burn(
  recipient: Either<ZswapCoinPublicKey, ContractAddress>,
  value: Uint<128>,
): [] {
  AccessControl_assertOnlyRole(BURNER_ROLE);
  FungibleToken__burn(recipient, value);
}

So clean! By splitting concerns this way, more granular levels of permission may be implemented than were possible with the simpler ownership approach to access control. Limiting what each component of a system is able to do is known as the principle of least privilege, and is a good security practice. Note that each account may still have more than one role, if so desired.

Granting and Revoking Roles

The FungibleToken example above uses _grantRole, an internal circuit that is useful when programmatically assigning roles (such as during construction). But what if we later want to grant the 'minter' role to additional accounts?

By default, accounts with a role cannot grant it or revoke it from other accounts: all having a role does is making the hasRole check pass. To grant and revoke roles dynamically, you will need help from the role’s admin.

Every role has an associated admin role, which grants permission to call the grantRole and revokeRole circuits. A role can be granted or revoked by using these if the calling account has the corresponding admin role. Multiple roles may have the same admin role to make management easier. A role’s admin can even be the same role itself, which would cause accounts with that role to be able to also grant and revoke it.

This mechanism can be used to create complex permissioning structures resembling organizational charts, but it also provides an easy way to manage simpler applications. AccessControl includes a special role, called DEFAULT_ADMIN_ROLE, which acts as the default admin role for all roles. An account with this role will be able to manage any other role, unless _setRoleAdmin is used to select a new admin role.

Since it is the admin for all roles by default, and in fact it is also its own admin, this role carries significant risk.

Let’s take a look at the FungibleToken example, this time taking advantage of the default admin role:

// AccessControlMinter.compact

pragma language_version >= 0.16.0;

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

export sealed ledger MINTER_ROLE: Bytes<32>;
export sealed ledger BURNER_ROLE: Bytes<32>;

/**
 * Initialize FungibleToken and MINTER_ROLE
 */
constructor(
  name: Opaque<"string">,
  symbol: Opaque<"string">,
  decimals: Uint<8>,
) {
  FungibleToken_initialize(name, symbol, decimals);
  MINTER_ROLE = persistentHash<Bytes<32>>(pad(32, "MINTER_ROLE"));
  BURNER_ROLE = persistentHash<Bytes<32>>(pad(32, "BURNER_ROLE"));
  // Grant the contract deployer the default admin role: it will be able
  // to grant and revoke any roles
  AccessControl__grantRole(
    AccessControl_DEFAULT_ADMIN_ROLE,
    left<ZswapCoinPublicKey,ContractAddress>(ownPublicKey()),
  );
}

export circuit mint(
  recipient: Either<ZswapCoinPublicKey, ContractAddress>,
  value: Uint<128>,
  ): [] {
  AccessControl_assertOnlyRole(MINTER_ROLE);
  FungibleToken__mint(recipient, value);
}

export circuit burn(
  recipient: Either<ZswapCoinPublicKey, ContractAddress>,
  value: Uint<128>,
  ): [] {
  AccessControl_assertOnlyRole(BURNER_ROLE);
  FungibleToken__burn(recipient, value);
}

Note that, unlike the previous examples, no accounts are granted the 'minter' or 'burner' roles. However, because those roles' admin role is the default admin role, and that role was granted to ownPublicKey(), that same account can call grantRole to give minting or burning permission, and revokeRole to remove it.

Dynamic role allocation is often a desirable property, for example in systems where trust in a participant may vary over time. It can also be used to support use cases such as KYC, where the list of role-bearers may not be known up-front, or may be prohibitively expensive to include in a single transaction.

Experimental features

This module offers an experimental circuit that allow access control permissions 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 permissioned circuits of other contracts or grant/revoke role permissions.

The unsafe circuits are planned to become deprecated once contract-to-contract calls become available.