Ownable
Access Control Defined
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. This library provides a variety of access control modules to suit your application and privacy needs.
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.
This library 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.
For more granular, role-based access control, see AccessControl.
Identity Model
Ownable uses a witness-derived identity scheme. The owner proves knowledge of a secret key
by injecting it via the wit_OwnableSK witness. The module computes an account identifier
as persistentHash(secretKey), which is a commitment that hides the key while providing a stable,
pseudonymous on-chain identity.
To derive an account identifier off-chain (e.g. for the initialOwner parameter during construction),
use computeAccountId:
const accountId = Ownable_computeAccountId(secretKey);The secretKey is a 32-byte cryptographically secure random value that the owner keeps private.
The secretKey must never be logged or persisted in plaintext. Loss of the key means loss of ownership.
Key exposure allows impersonation. Use crypto.getRandomValues() or equivalent to generate keys.
Ownership Transfers
Ownership can only be transferred to Bytes<32> account identifiers 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.
Usage
Import the Ownable module into the implementing contract.
It's recommended to prefix the module with Ownable_ to avoid circuit signature clashes.
The initialOwner should be an account identifier derived off-chain using computeAccountId(secretKey):
pragma language_version >= 0.21.0;
import CompactStandardLibrary;
import "./node_modules/@openzeppelin/compact-contracts/access/Ownable"
prefix Ownable_;
constructor(
initialOwner: Either<Bytes<32>, 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.
The newOwner should be an account identifier derived by the new owner using computeAccountId(secretKey):
export circuit transferOwnership(newOwner: Either<Bytes<32>, 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.21.0;
import CompactStandardLibrary;
import "./node_modules/@openzeppelin/compact-contracts/access/Ownable"
prefix Ownable_;
/**
* Set `initialOwner` as the owner of the contract.
* `initialOwner` should be derived off-chain using computeAccountId(secretKey).
*/
constructor(initialOwner: Either<Bytes<32>, ContractAddress>) {
Ownable_initialize(initialOwner);
}
/**
* The current owner of the contract.
*/
export circuit owner(): Either<Bytes<32>, ContractAddress> {
return Ownable_owner();
}
/**
* Transfers ownership of the contract.
* Can only be called by the current owner.
* `newOwner` should be derived off-chain by the new owner using computeAccountId(secretKey).
*/
export circuit transferOwnership(newOwner: Either<Bytes<32>, 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.
Shielded Ownership and ZOwnablePK
Privacy-preserving access control is a fundamental building block for confidential smart contracts on Midnight.
While the Ownable module stores the owner's account identifier on-chain,
many applications require administrative control without revealing who holds that authority.
Privacy-First Ownership
The ZOwnablePK module implements shielded ownership meaning 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.
This is useful in scenarios such as private treasuries, anonymous governance, or any application where revealing the administrator's identity may compromise the system's confidentiality.
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. 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 rfc6979 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.
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 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.
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.21.0;
import CompactStandardLibrary;
import "./node_modules/@openzeppelin/compact-contracts/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 call them by adding assertOnlyOwner as the first line in the circuit body:
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 derive their owner ID:
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.