Ownable
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.
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.
Usage
Import the Ownable module into the implementing contract.
It’s recommended to prefix the module with Ownable_
to avoid circuit signature clashes.
pragma language_version >= 0.16.0;
import CompactStandardLibrary;
import "./node_modules/@openzeppelin-compact/ownable/src/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/ownable/src/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.
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 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.
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.