A confidential security token implementation combining CMTAT compliance features with the Zama Confidential Blockchain Protocol for private balances.
- Overview
- Architecture
- Summary
- Deployment Variants
- Installation
- Compiler & EVM Version
- Security
- Roles
- Contract Functions
- Role-Based Access Control
- Troubleshooting
- References
CMTAT Confidential implements the ERC-7984 standard (Confidential Fungible Token) with CMTAT regulatory compliance modules. All token balances and transfer amounts are encrypted using Fully Homomorphic Encryption (FHE), ensuring transfer amount and balance privacy while maintaining regulatory compliance capabilities.
CMTAT is a security token framework by Capital Markets and Technology Association that includes various compliance features such as conditional transfer, account freeze, and token pause. The specification are blockchain agnostic with implementation available for several different blockchain ecosystem such as Ethereum, Solana, Tezos, and Aztec (which also enables confidential transactions).
CMTAT Confidential is built on top of OpenZeppelin Confidential Contracts, including its ERC-7984 implementation, and on top of the Solidity CMTAT implementation for compliance modules.
Fully Homomorphic Encryption (FHE) enables computing directly on encrypted data without ever decrypting it. The Zama Protocol uses FHE combined with Multi-Party Computation (MPC) for threshold decryption and Zero-Knowledge Proofs (ZKPoKs) for input validation, providing:
- End-to-end encryption: Transaction inputs and state remain encrypted - no one can see the data, not even node operators
- Composability: Confidential contracts can interact with other contracts and dApps
- Programmable confidentiality: Smart contracts define who can decrypt what through the Access Control List (ACL)
- Confidential Balances: All balances are stored as
euint64(encrypted unsigned 64-bit integers) - only authorized parties can decrypt - Confidential Transfers: Transfer amounts are submitted as encrypted inputs with Zero-Knowledge Proofs of Knowledge (ZKPoKs)
- Regulatory Compliance: Pause, freeze, and forced transfer capabilities for compliance
- Role-Based Access Control: Granular permissions for minting, burning, pausing, and enforcement
- Document Management: Attach terms, documents, and metadata to tokens
- ERC-7984 Standard: Based on OpenZeppelin's confidential token implementation
CMTAT-Confidential
├── ERC7984 (OpenZeppelin Confidential Contracts)
│ ├── Encrypted balances (euint64)
│ ├── Confidential transfers
│ ├── Operator system
│ └── Disclosure mechanism
│
├── CMTATBaseGeneric (CMTAT Modules)
│ ├── PauseModule - Pause/unpause transfers
│ ├── EnforcementModule - Freeze/unfreeze addresses
│ ├── AccessControlModule - Role-based permissions
│ ├── DocumentEngineModule - ERC-1643 document management
│ └── ExtraInformationModule - Token metadata (tokenId, terms, info)
│
├── FHE Modules (custom extensions)
│ ├── ERC7984MintModule - Modular mint with authorization hook
│ ├── ERC7984BurnModule - Modular burn with authorization hook
│ ├── ERC7984EnforcementModule - Forced transfer and forced burn
│ ├── ERC7984BalanceViewModule - Per-account balance observers (holder + role slots)
│ ├── ERC7984PublishTotalSupplyModule - Public total supply disclosure (in CMTATConfidentialBase)
│ └── ERC7984TotalSupplyViewModule - Total supply observer list with auto ACL re-grant (CMTATConfidential only)
│
└── Zama Protocol Infrastructure (configured via ZamaEthereumConfig)
├── ACL - Access Control List for encrypted data permissions
├── FHEVMExecutor (Coprocessor) - Performs FHE computations
├── KMSVerifier - Verifies decryption proofs from Key Management System
└── InputVerifier - Validates encrypted inputs and ZKPoKs
- Symbolic Execution: When a contract calls an FHE operation, the host chain produces a pointer to the result and emits an event to notify the coprocessor network
- Coprocessor Computation: The coprocessors perform the actual FHE computation off-chain
- Threshold Decryption: Decryption requests go through the Key Management Service (KMS), which uses MPC to ensure no single party can access the private key
This section maps the CMTAT framework features to the CMTAT Confidential implementation, showing how standard functionalities are adapted for Fully Homomorphic Encryption.
| CMTAT Framework Mandatory Functionalities | CMTATConfidential Corresponding Features |
|---|---|
| Know total supply | confidentialTotalSupply() returns euint64 (encrypted) |
| Know balance | confidentialBalanceOf() returns euint64 (encrypted) |
| Transfer tokens | confidentialTransfer() with encrypted amount + ZKPoK |
| Create tokens (mint) | mint() with encrypted amount + ZKPoK |
| Cancel tokens (burn) | burn() with encrypted amount + ZKPoK |
| Pause tokens | pause() - inherited from CMTAT |
| Unpause tokens | unpause() - inherited from CMTAT |
| Deactivate contract | deactivateContract() - inherited from CMTAT |
| Freeze | setAddressFrozen(address, true) - inherited from CMTAT |
| Unfreeze | setAddressFrozen(address, false) - inherited from CMTAT |
| Name attribute | ERC20 name() - public |
| Ticker symbol attribute | ERC20 symbol() - public |
| Token ID attribute | tokenId() - inherited from CMTAT |
| Reference to legally required documentation | terms() - inherited from CMTAT |
| Functionalities | CMTAT Confidential Features | Available |
|---|---|---|
| Forced Transfer | forcedTransfer() with encrypted amount |
✔ |
| Forced Burn | forcedBurn() with encrypted amount |
✔ |
| Operator System | setOperator() / confidentialTransferFrom() |
✔ |
| Public Disclosure | requestDiscloseEncryptedAmount() / discloseEncryptedAmount() |
✔ |
| On-chain snapshot | Not implemented | ✘ |
| Freeze partial tokens | Not implemented (all balances are encrypted) | ✘ |
| Integrated allowlisting | Not implemented | ✘ |
| RuleEngine / transfer hook | Not implemented | ✘ |
| Upgradability | Not implemented (standalone only) | ✘ |
| Functionalities | CMTAT Confidential | Note |
|---|---|---|
| Mint while paused | ✔ | Minting is allowed when contract is paused (same as CMTAT) |
| Burn while paused | ✔ | Burning is allowed when contract is paused (same as CMTAT) |
| Self burn | ✘ | Only BURNER_ROLE can burn tokens |
| Standard burn on frozen address | ✘ | Use forcedBurn() |
| Forced burn from frozen address | ✔ | forcedBurn() with FORCED_OPS_ROLE |
Burn via forcedTransfer |
✘ | forcedTransfer reverts if to is address(0) -- use forcedBurn() |
| Balance overflow protection | ✔ | Uses FHESafeMath: transfers 0 on overflow/underflow (privacy-preserving) |
| Aspect | CMTAT (Standard) | CMTAT Confidential (Confidential) |
|---|---|---|
| Balance type | uint256 (public) |
euint64 (encrypted) |
| Value range | Up to ~1.15 × 10⁷⁷ (uint256) |
Up to ~1.84 × 10¹⁹ (uint64 max = 18,446,744,073,709,551,615) |
| Transfer amount | uint256 (public) |
externalEuint64 + ZKPoK |
| Total supply | uint256 (public) |
euint64 (encrypted) |
| Balance visibility | Anyone can read | Only ACL-authorized parties can decrypt |
| Transfer validation | Reverts on insufficient balance | Transfers 0 silently (privacy-preserving) |
| Allowance system | ERC20 approve/allowance |
Operator system with time-limited access |
| Forced Burn | Through forcedTransferor forcedBurn if implemented |
Through forcedBurn since the function is implemented |
Important: The
euint64type has a significantly smaller range thanuint256. Decimals above 18 are rejected at construction (CMTAT_DecimalsTooHigh). See Choosing Decimals for the full supply impact table.
To decrypt encrypted values (balances, amounts, total supply), the requesting party must:
- Have ACL permission granted via
FHE.allow()orFHE.allowTransient()(verifiable withFHE.isAllowed()orFHE.isSenderAllowed()) - Or the value must be marked publicly decryptable via
FHE.makePubliclyDecryptable() - Request decryption through the Zama Relayer SDK (
@zama-fhe/relayer-sdk) - Submit the decryption proof on-chain via
FHE.checkSignatures()(reverts if the proof is invalid)
Two deployment-ready contracts are provided. Both share the same abstract base (CMTATConfidentialBase) and are functionally identical except for total supply visibility.
CMTATConfidential |
CMTATConfidentialLite |
|
|---|---|---|
| Confidential balances & transfers | ✔ | ✔ |
| Mint / Burn / Forced ops | ✔ | ✔ |
| Pause / Freeze | ✔ | ✔ |
| Per-account balance observers | ✔ | ✔ |
publishTotalSupply (public disclosure) |
✔ | ✔ |
| Total supply observer list (auto ACL) | ✔ | ✘ |
SUPPLY_OBSERVER_ROLE |
✔ | ✔ |
SUPPLY_PUBLISHER_ROLE |
✔ | ✔ |
| Contract size | ~20.5 KB | ~19.2 KB |
Choose CMTATConfidentialLite when automatic per-observer ACL re-grant on every mint/burn is not required and you want to minimize deployment cost. publishTotalSupply (one-shot public disclosure) is available in both variants.
# Clone the repository
git clone --recursive https://github.com/your-repo/CMTAT-Confidential.git
cd CMTAT-Confidential
# Install dependencies
npm install
# Compile contracts
npm run compile
# Run tests
npm run test- Solidity pragmas use
^0.8.27across the codebase to stay compatible with OpenZeppelin Confidential Contracts. - Hardhat is configured to compile with
0.8.34. - The configured EVM version is
prague(seehardhat.config.ts).
The contract-level version() string is pinned to 0.1.0 via CMTATConfidentialVersionModule.
Aderyn static analysis reported 0 high and 8 low severity findings. All are accepted or not applicable. Full rationale in aderyn-report-feedback.md.
| ID | Finding | Instances | Disposition |
|---|---|---|---|
| L-1 | Centralization Risk | 10 | Accepted — role-based access control is mandatory for a regulated security token |
| L-2 | Unspecific Solidity Pragma (^0.8.27) |
12 | Accepted — lower bound required by OZ Confidential submodule; Hardhat compiles with 0.8.34 |
| L-3 | PUSH0 Opcode | 12 | Not applicable — target is Ethereum mainnet, EVM version set to prague in hardhat.config.ts |
| L-4 | Modifier Invoked Only Once | 1 | Accepted — consistent with the module authorization pattern across all modules |
| L-5 | Empty Block | 16 | Accepted — modifier-only authorization hooks and intentional virtual extension points |
| L-6 | Internal Function Used Only Once | 1 | Accepted — required by the OpenZeppelin initializer modifier pattern |
| L-7 | State Change Without Event | 1 | Not applicable — occurs in a mock contract (ConfidentialReceiverMock), not production code |
| L-8 | Unchecked Return | 12 | Not applicable — FHE.allow() / FHE.makePubliclyDecryptable() return the same handle (fluent interface), not an error code |
| Role | Description |
|---|---|
DEFAULT_ADMIN_ROLE |
Can grant/revoke all roles, deactivate contract |
MINTER_ROLE |
Can mint new tokens |
BURNER_ROLE |
Can burn tokens |
PAUSER_ROLE |
Can pause/unpause all transfers |
ENFORCER_ROLE |
Can freeze and unfreeze addresses |
FORCED_OPS_ROLE |
Can execute forced transfers and forced burns on frozen addresses |
OBSERVER_ROLE |
Can assign per-account balance observers via setRoleObserver |
SUPPLY_OBSERVER_ROLE |
Can manage total supply observers |
SUPPLY_PUBLISHER_ROLE |
Can call publishTotalSupply |
Mint tokens to an address (requires MINTER_ROLE):
// With encrypted input and proof
function mint(
address to,
externalEuint64 encryptedAmount,
bytes calldata inputProof
) public onlyRole(MINTER_ROLE) returns (euint64 transferred);
// With existing encrypted handle (caller must have ACL access)
function mint(
address to,
euint64 amount
) public onlyRole(MINTER_ROLE) returns (euint64 transferred);Burn tokens from an address (requires BURNER_ROLE):
function burn(
address from,
externalEuint64 encryptedAmount,
bytes calldata inputProof
) public onlyRole(BURNER_ROLE) returns (euint64 transferred);ERC-7984 exposes eight transfer function variants:
| Function | Description |
|---|---|
confidentialTransfer(to, amount) |
Transfer using existing handle |
confidentialTransfer(to, amount, proof) |
Transfer with encrypted input |
confidentialTransferFrom(from, to, amount) |
Operator transfer using handle |
confidentialTransferFrom(from, to, amount, proof) |
Operator transfer with proof |
confidentialTransferAndCall(...) |
Transfer with ERC-1363 callback |
confidentialTransferFromAndCall(...) |
Operator transfer with callback |
Example transfer:
import { fhevm } from 'hardhat';
// Create encrypted input
const encryptedInput = await fhevm
.createEncryptedInput(tokenAddress, senderAddress)
.add64(1000) // Amount to transfer
.encrypt();
// Execute transfer
await token.connect(sender)['confidentialTransfer(address,bytes32,bytes)'](
recipientAddress,
encryptedInput.handles[0],
encryptedInput.inputProof
);Enforcers can move tokens from frozen addresses for regulatory compliance. Forced transfers can be performed even when the contract is deactivated.
function forcedTransfer(
address from, // Must be frozen
address to, // Must not be address(0)
externalEuint64 encryptedAmount,
bytes calldata inputProof
) public onlyRole(FORCED_OPS_ROLE) returns (euint64 transferred);Requirements:
- The
fromaddress must be frozen - The
toaddress must not beaddress(0)-- useforcedBurn()for burning - Can be performed even when the contract is deactivated
Note: This is intentionally stricter than standard CMTAT, which allows
forcedTransferon any address (frozen or not). CMTAT Confidential requires the address to be frozen first, creating an explicit audit trail (freeze event followed by forced transfer).
Enforcers can burn tokens directly from frozen addresses without the holder's consent. This is the dedicated burn equivalent of forcedTransfer. Forced burns can be performed even when the contract is deactivated.
function forcedBurn(
address from, // Must be frozen
externalEuint64 encryptedAmount,
bytes calldata inputProof
) public onlyRole(FORCED_OPS_ROLE) returns (euint64 burned);Requirements:
- The
fromaddress must be frozen - Can be performed even when the contract is deactivated
Note: Same freeze requirement as
forcedTransferfor consistency. The enforcer creates the encrypted input specifying how many tokens to burn.
By default the total supply is encrypted and inaccessible to third parties. Two mechanisms are available to open read access, gated by SUPPLY_OBSERVER_ROLE (observer list) or SUPPLY_PUBLISHER_ROLE (public disclosure).
Register addresses that will automatically receive ACL access to the total supply handle after every mint or burn:
// Grant SUPPLY_OBSERVER_ROLE to the compliance manager
await token.grantRole(SUPPLY_OBSERVER_ROLE, complianceManager.address);
// Grant SUPPLY_PUBLISHER_ROLE to the compliance manager (separate permission)
await token.grantRole(SUPPLY_PUBLISHER_ROLE, complianceManager.address);
// Register a regulator as a total supply observer
await token.connect(complianceManager).addTotalSupplyObserver(regulatorAddress);
// Remove an observer (stops future grants; past ACL grants are irrevocable)
await token.connect(complianceManager).removeTotalSupplyObserver(regulatorAddress);
// Inspect the current observer list
const observers = await token.totalSupplyObservers();Once registered, the observer can decrypt off-chain using the standard user-decryption flow:
const handle = await token.confidentialTotalSupply();
const supply = await fhevm.userDecryptEuint(FhevmType.euint64, handle, tokenAddress, observer);Option 2 — Public disclosure (anyone, irrevocable per handle) — CMTATConfidential and CMTATConfidentialLite
Mark the current total supply handle as publicly decryptable. Any off-chain party can then request decryption via the Zama Relayer SDK without ACL access. After the next mint or burn, the new handle will not be publicly decryptable — call again if needed.
await token.connect(complianceManager).publishTotalSupply();| Mechanism | Availability | Access scope | Stays current after mint/burn |
|---|---|---|---|
addTotalSupplyObserver |
CMTATConfidential only |
Specific registered addresses | Yes — re-granted automatically via _afterMint/_afterBurn hooks |
publishTotalSupply |
CMTATConfidential and CMTATConfidentialLite |
SUPPLY_PUBLISHER_ROLE |
No — must be called again after each mint/burn |
Gas note: In
CMTATConfidential, every mint or burn triggers_updateTotalSupplyObserversACL(), which iterates over all registered total supply observers and callsFHE.allow()for each one. Additionally,_updateruns a chain of balance observer ACL grants. Keep both observer lists small to control gas costs per operation.
Pause all transfers (requires PAUSER_ROLE):
function pause() public onlyRole(PAUSER_ROLE);
function unpause() public onlyRole(PAUSER_ROLE);Freeze specific addresses (requires ENFORCER_ROLE):
function setAddressFrozen(address account, bool freeze) public onlyRole(ENFORCER_ROLE);
function isFrozen(address account) public view returns (bool);Permanently deactivate the contract (requires DEFAULT_ADMIN_ROLE, contract must be paused):
function deactivateContract() public onlyRole(DEFAULT_ADMIN_ROLE);Operators can transfer tokens on behalf of holders using confidentialTransferFrom. Unlike ERC-20 allowances, operators have time-limited unlimited access.
// Set operator for 24 hours
const expirationTimestamp = Math.floor(Date.now() / 1000) + 86400;
await token.connect(holder).setOperator(operatorAddress, expirationTimestamp);
// Check operator status
const isOp = await token.isOperator(holderAddress, operatorAddress);Warning: Setting an operator grants them access to transfer ALL your tokens during the approval period.
Only the balance holder can decrypt their balance:
import { FhevmType } from '@fhevm/hardhat-plugin';
// Get encrypted balance handle
const balanceHandle = await token.confidentialBalanceOf(holderAddress);
// Decrypt (only works for the holder)
const balance = await fhevm.userDecryptEuint(
FhevmType.euint64,
balanceHandle,
tokenAddress,
holder // Signer must be the balance owner
);Holders can publicly disclose amounts:
// Request disclosure (makes handle publicly decryptable)
function requestDiscloseEncryptedAmount(euint64 encryptedAmount) public;
// Finalize with decryption proof
function discloseEncryptedAmount(
euint64 encryptedAmount,
uint64 cleartextAmount,
bytes calldata decryptionProof
) public;import { ethers } from 'hardhat';
const extraInfoAttributes = {
tokenId: 'TOKEN-001',
terms: {
name: 'Terms Document',
uri: 'https://example.com/terms',
documentHash: ethers.ZeroHash,
},
information: 'Security token for XYZ',
};
const token = await ethers.deployContract('CMTATConfidential', [
'My Token', // name
'MTK', // symbol
'https://example.com/metadata', // contractURI
6, // decimals (choose per token, e.g. 0, 6, 8)
adminAddress, // admin with DEFAULT_ADMIN_ROLE
extraInfoAttributes, // token metadata
]);
// Grant roles
await token.grantRole(MINTER_ROLE, minterAddress);
await token.grantRole(BURNER_ROLE, burnerAddress);
await token.grantRole(PAUSER_ROLE, pauserAddress);
await token.grantRole(ENFORCER_ROLE, enforcerAddress); // freeze/unfreeze addresses
await token.grantRole(FORCED_OPS_ROLE, enforcerAddress); // forced transfer / forced burnDecimals are configurable at deployment for both CMTATConfidential and CMTATConfidentialLite. The constructor argument order is: name, symbol, contractURI, decimals, admin, extraInfoAttributes.
Warning: ERC-7984 balances use
euint64(max18,446,744,073,709,551,615raw units). The maximum human-readable supply isuint64 max / 10^decimals:
Decimals Max supply 0 ~18.4 × 10¹⁸ tokens 6 ~18.4 trillion tokens (recommended default) 8 ~184 billion tokens 18 ~18 tokens Values above 18 are rejected at construction (
CMTAT_DecimalsTooHigh), because even a single token (1 × 10^decimalsraw units) would overflowuint64. Values of 9 or more are technically valid but severely constrain the maximum supply — verify thatexpectedMaxSupply × 10^decimals ≤ 18,446,744,073,709,551,615before deploying.Note: FHE overflow is silent — a mint or transfer that exceeds
uint64max will transfer0without reverting.
| Package | Version |
|---|---|
@fhevm/solidity |
0.9.1 |
@fhevm/hardhat-plugin |
0.3.0-1 |
@openzeppelin/contracts |
5.5.0 |
@openzeppelin/contracts-upgradeable |
5.5.0 |
| Submodule | |
| OpenZeppelin Confidential Contracts | v0.3.1 |
| CMTAT | v3.2.0 |
CMTAT-Confidential/
├── contracts/
│ ├── CMTATConfidentialBase.sol # Abstract base (all shared logic)
│ ├── CMTATConfidential.sol # Full variant (+ total supply visibility)
│ ├── CMTATConfidentialLite.sol # Lite variant (smaller, no total supply module)
│ └── modules/
│ ├── ERC7984MintModule.sol # Mint with authorization hook
│ ├── ERC7984BurnModule.sol # Burn with authorization hook
│ ├── ERC7984EnforcementModule.sol # Forced transfer and forced burn
│ ├── ERC7984BalanceViewModule.sol # Per-account balance observers
│ ├── CMTATConfidentialVersionModule.sol # CMTAT Confidential version override (0.1.0)
│ ├── ERC7984PublishTotalSupplyModule.sol # Public total supply disclosure
│ └── ERC7984TotalSupplyViewModule.sol # Total supply observer list (auto ACL)
├── CMTAT/ # CMTAT submodule (compliance modules)
├── openzeppelin-confidential-contracts/ # OZ submodule (ERC7984)
├── docs/
│ ├── fhe/ # Zama FHE documentation
│ └── openzeppelin-confidential/ # OZ confidential docs
├── test/
│ ├── CMTATConfidential.test.ts # Full variant core tests
│ ├── CMTATConfidentialLite.test.ts # Lite variant core tests (shared suite)
│ ├── ERC7984BalanceViewModule.test.ts # Balance observer module tests
│ ├── ERC7984PublishTotalSupplyModule.test.ts # Public disclosure module tests
│ ├── ERC7984TotalSupplyViewModule.test.ts # Total supply observer module tests
│ └── helpers/
│ ├── deploy.ts # Shared deploy helper + role constants
│ ├── core-tests.ts # Shared Mocha test suite
│ └── accounts.ts # Account impersonation utilities
└── hardhat.config.js
Answer: Yes, as an issuer with the FORCED_OPS_ROLE, you can burn tokens from any holder without their consent using the forcedBurn() function.
How it works:
- First freeze the holder's address using
setAddressFrozen(holderAddress, true)(requiresENFORCER_ROLE) - Use the
forcedBurn()function to burn tokens directly from the frozen address (requiresFORCED_OPS_ROLE) - This function can be performed even when the contract is deactivated
- Only accounts with
FORCED_OPS_ROLEcan execute forced burns
Use cases for regulatory compliance:
- Court orders requiring asset seizure
- Sanctions compliance
- Error correction (e.g., tokens sent to wrong address)
Code example:
// Step 1: Freeze the holder's address
await token.connect(enforcer).setAddressFrozen(holderAddress, true);
// Step 2: Create encrypted input for the amount to burn
const encryptedInput = await fhevm
.createEncryptedInput(tokenAddress, enforcerAddress)
.add64(amountToBurn)
.encrypt();
// Step 3: Force burn tokens from the frozen address
await token.connect(enforcer)['forcedBurn(address,bytes32,bytes)'](
holderAddress, // from (must be frozen)
encryptedInput.handles[0], // encrypted amount
encryptedInput.inputProof // ZKPoK proof
);Note: The regular burn() function requires BURNER_ROLE and will fail if the target address is frozen. For frozen addresses, use forcedBurn() instead (requires FORCED_OPS_ROLE). The from address must be frozen before calling forcedBurn(). Note that forcedTransfer() reverts if to is address(0) -- use forcedBurn() for burning.
Design choice: Standard CMTAT allows
forcedTransferandforcedBurnon any address (frozen or not). CMTAT Confidential intentionally requires the address to be frozen first, creating an explicit audit trail (freeze event followed by forced burn/transfer).
Answer: Transfers in CMTAT Confidential use encrypted inputs to preserve confidentiality. Here's the complete process:
Encrypted inputs are data values submitted in ciphertext form, accompanied by Zero-Knowledge Proofs of Knowledge (ZKPoKs) to ensure validity without revealing the plaintext.
const encryptedInput = await fhevm
.createEncryptedInput(tokenContractAddress, yourAddress)
.add64(amount) // Amount to transfer (will be encrypted)
.encrypt();await token.confidentialTransfer(
recipientAddress,
encryptedInput.handles[0], // externalEuint64 handle
encryptedInput.inputProof // ZKPoK proof
);- Input verification: The
FHE.fromExternal()function validates the ciphertext and ZKPoK - Type conversion: Converts
externalEuint64intoeuint64for contract operations - Balance check: If balance is insufficient, transfer executes but transfers 0 (FHE doesn't reveal balance)
You can authorize an operator to transfer on your behalf using time-limited approval:
const expirationTimestamp = Math.round(Date.now() / 1000) + 60 * 60 * 24; // 24 hours
await token.connect(holder).setOperator(operatorAddress, expirationTimestamp);
// Operator can now call confidentialTransferFrom
await token.connect(operator).confidentialTransferFrom(
holderAddress,
recipientAddress,
encryptedAmount,
inputProof
);Important: Setting an operator allows them to transfer all your tokens. Carefully vet operators before approval.
- Your address must not be frozen
- The recipient address must not be frozen
- The contract must not be paused or deactivated
Answer: Yes - the Zama Protocol mainnet on Ethereum is now live.
Ethereum mainnet does not natively support FHE operations. The Zama Protocol uses a coprocessor architecture where the host chain (Ethereum) performs symbolic execution, and the actual FHE computations are performed off-chain by coprocessors. The infrastructure includes:
- ACL (Access Control List): Manages permissions for encrypted data
- FHEVMExecutor (Coprocessor): Performs encrypted computations off-chain
- KMSVerifier: Verifies decryption proofs from the Key Management System
- InputVerifier: Validates encrypted inputs and ZKPoKs
| Network | Chain ID | Status |
|---|---|---|
| Local development | 31337 | Supported (mock coprocessor via hardhat plugin) |
| Ethereum Sepolia | 11155111 | Supported (Zama testnet infrastructure) |
| Ethereum Mainnet | 1 | Live |
-
Inherit from
ZamaEthereumConfig: This automatically configures coprocessor addresses:import {ZamaEthereumConfig} from "@fhevm/solidity/config/ZamaConfig.sol"; contract MyToken is ERC7984, ZamaEthereumConfig { // Constructor automatically calls FHE.setCoprocessor() }
-
Target network must have Zama infrastructure: The coprocessor contracts must be deployed and operational
-
Users need compatible tools: Client applications must use the Relayer SDK to create encrypted inputs and request decryptions
Answer: Yes, it is technically possible using encrypted addresses (eaddress type in fhEVM).
The fhEVM library provides the eaddress type which encrypts Ethereum addresses. This enables:
- Hiding sender and recipient addresses in transfers
- Omnibus account patterns where multiple users share a single on-chain address
The OpenZeppelin Confidential Contracts library includes ERC7984Omnibus extension:
- Uses a single omnibus address visible on-chain
- Maintains encrypted mappings of actual user addresses to balances
- Transfers happen between encrypted addresses within the omnibus
function confidentialTransferFromOmnibus(
address omnibusFrom,
address omnibusTo,
externalEaddress externalSender, // encrypted sender
externalEaddress externalRecipient, // encrypted recipient
externalEuint64 externalAmount,
bytes calldata inputProof
) public virtual returns (euint64);| Benefit | Cost |
|---|---|
| Address privacy | Higher gas costs for encrypted address operations |
| Regulatory compliance (omnibus accounts) | More complex user experience |
| Institutional custody patterns | Requires trusted omnibus operators |
Hiding participant identities may conflict with AML/KYC requirements in some jurisdictions. Consider your compliance obligations before implementing address privacy.
Answer: The total supply is private (encrypted) by default in ERC7984.
- The
_totalSupplyvariable is stored aseuint64(encrypted unsigned 64-bit integer) - The
confidentialTotalSupply()function returns an encrypted handle, not a plaintext value - Access is controlled by the ACL (Access Control List)
// Returns an encrypted handle (euint64), not the actual value
function confidentialTotalSupply() public view returns (euint64);CMTAT Confidential provides two built-in mechanisms:
Option A — Authorized observers (stays current automatically) — ERC7984TotalSupplyViewModule, CMTATConfidential only
Register specific addresses that automatically receive ACL access after every mint or burn:
token.addTotalSupplyObserver(regulatorAddress); // re-granted on every mint/burn
token.removeTotalSupplyObserver(regulatorAddress); // stops future grantsOnce registered, the observer decrypts using standard user-decryption — see Decrypting Balances.
Option B — Public disclosure — ERC7984PublishTotalSupplyModule, available on both CMTATConfidential and CMTATConfidentialLite
Call publishTotalSupply() (requires SUPPLY_PUBLISHER_ROLE) to mark the current handle as publicly decryptable. Must be called again after each mint or burn since the handle changes. This will revert if total supply has never been initialized (no mint/burn yet).
token.publishTotalSupply();Note:
publishTotalSupply()reverts until the total supply handle is initialized (i.e., at least one mint or burn has occurred).
Internally this calls FHE.makePubliclyDecryptable(), which triggers the following asynchronous three-step process:
Public decryption splits work between on-chain and off-chain:
Step 1: On-chain - Mark as publicly decryptable
The contract sets the ciphertext handle's status as publicly decryptable, globally and permanently authorizing any entity to request its off-chain cleartext value.
FHE.makePubliclyDecryptable(confidentialTotalSupply());Step 2: Off-chain - Request decryption from KMS
Any off-chain client can submit the ciphertext handle to the Zama Relayer's Key Management System (KMS) using the Relayer SDK (@zama-fhe/relayer-sdk).
const result = await fhevmInstance.publicDecrypt([totalSupplyHandle]);
// Returns:
// - clearValues: mapping of handles to decrypted values
// - abiEncodedClearValues: ABI-encoded byte string of all cleartext values
// - decryptionProof: cryptographic proof from the KMSStep 3: On-chain - Verify and use the decrypted value
The caller submits the cleartext and decryption proof back to a contract function. The contract calls FHE.checkSignatures, which reverts if the proof is invalid.
FHE.checkSignatures(handlesList, abiEncodedCleartexts, decryptionProof);
// Now you can use the verified cleartext valueImportant: The decryption proof is cryptographically bound to the specific order of handles passed in the input array.
To access encrypted values, accounts need proper ACL permissions:
| Function | Purpose |
|---|---|
FHE.allow(handle, address) |
Permanent access for specific address |
FHE.allowThis(handle) |
Shorthand for FHE.allow(handle, address(this)) - allow current contract |
FHE.allowTransient(handle, address) |
Temporary access (current transaction only) |
FHE.makePubliclyDecryptable(handle) |
Allow anyone to decrypt off-chain |
FHE.isAllowed(handle, address) |
Check if an address has access to a ciphertext |
FHE.isSenderAllowed(handle) |
Check if msg.sender has access to a ciphertext |
- Prevents market manipulation based on supply information
- Protects issuer's business information
- Consistent with the privacy-first design of confidential tokens
Note: If you need public total supply, implement a function that goes through the full decryption process and emits the result as an event. Consider the privacy implications carefully.
Answer: Yes, but only through the asynchronous decryption process -- there is no direct way to "read" a plaintext balance.
The issuer must have ACL permission on the holder's balance ciphertext. By default, only the balance holder and the contract itself have access. To allow the issuer to decrypt:
Option 1: Grant the issuer ACL access (on-chain)
The contract can grant ACL permission to the issuer's address on the holder's balance handle. This would need to be built into the contract logic (e.g., a function that grants the issuer access to a specific balance):
// Inside the contract, an admin function could grant access
function grantBalanceAccess(address holder, address viewer) public onlyRole(DEFAULT_ADMIN_ROLE) {
FHE.allow(confidentialBalanceOf(holder), viewer);
}Once the issuer has ACL access, they can decrypt the balance off-chain:
const balanceHandle = await token.confidentialBalanceOf(holderAddress);
const balance = await fhevm.userDecryptEuint(
FhevmType.euint64,
balanceHandle,
tokenAddress,
issuer // Signer with ACL access
);Option 2: Public decryption
The holder (or a contract function) can mark their balance as publicly decryptable, then anyone can request decryption through the three-step process (see FAQ #5).
Zama's FHEVM does not use a "viewing key" model like some other privacy systems (e.g., Zcash). Instead, access is controlled through the ACL (Access Control List):
| Concept | Zama FHE Approach |
|---|---|
| Viewing key | Not applicable -- uses ACL permissions instead |
| Grant read access | FHE.allow(handle, address) grants permanent access to a specific ciphertext |
| Temporary access | FHE.allowTransient(handle, address) grants access for the current transaction only |
| Public access | FHE.makePubliclyDecryptable(handle) allows anyone to decrypt |
The ACL model is more flexible than viewing keys: you can grant access per-ciphertext, per-address, and with different durations (permanent, transient, or public).
Yes, if they are granted ACL permission. The contract logic decides who gets access.
The handle staleness problem
Every FHE arithmetic operation (mint, transfer, burn) produces a new ciphertext handle for the affected balance. A third party who was granted ACL access to an old handle loses the ability to read the balance the moment that handle is replaced. Access must therefore be re-granted after every update — it cannot be set once and forgotten.
CMTAT Confidential solves this with the ERC7984ObserverAccess extension. After every _update, the contract automatically calls FHE.allow() on the new balance handle for each registered observer, keeping their ACL access current without any manual intervention.
Two observer slots per holder
ERC7984BalanceViewModule (built on ERC7984ObserverAccess) provides two independent observer slots per address:
| Slot | Set by | Typical use |
|---|---|---|
Holder observer (setObserver) |
The holder themselves | Personal wallet app, portfolio dashboard |
Role observer (setRoleObserver, requires OBSERVER_ROLE) |
The issuer / compliance team | Regulator, auditor, compliance tool |
Both slots receive FHE.allow() on every balance update automatically.
Observer ACL scope: balance and transfer amount
On every _update, observers are granted ACL access to two ciphertext handles:
- The account's new balance handle — so the observer can read the current balance at any time
- The transferred amount handle — so the observer can reconstruct individual transaction amounts
This is intentional design for regulatory compliance: a compliance observer needs transfer-level granularity, not just balance snapshots. An observer set via setRoleObserver should therefore be treated as having access to individual transaction amounts for the account they are observing.
Decrypting as an observer
Once access is granted the observer can decrypt off-chain through the standard user-decryption flow:
// Read the current handle
const handle = await token.confidentialBalanceOf(holderAddress);
// Decrypt — observer must have ACL access on that handle
const balance = await fhevm.userDecryptEuint(
FhevmType.euint64,
handle,
tokenAddress,
observer // signer with ACL access
);ACL access is permanent and cannot be revoked
FHE.allow() is a one-way operation — once an observer is granted access to a handle, that access cannot be removed. Removing an observer with setObserver / setRoleObserver only stops future grants: the observer retains read access to all handles they were allowed on before removal.
Common patterns
| Party | How access is granted |
|---|---|
| The holder themselves | Automatically by the contract on every transfer/mint |
| Regulatory observer | Issuer calls setRoleObserver(holder, regulatorAddress) |
| Personal observer | Holder calls setObserver(holderAddress, walletAppAddress) |
| Auditor (temporary) | Admin calls FHE.allowTransient() during the audit transaction |
Answer: Yes. The inputProof (Zero-Knowledge Proof of Knowledge) is generated by whoever creates the encrypted input, not by the token holder.
When any party calls a function that requires an encrypted amount (mint, burn, forcedBurn, forcedTransfer), they:
- Choose the plaintext amount they want to encrypt
- Generate the encrypted input using the FHEVM client library
- The library produces a ciphertext handle + a ZKPoK proof
The ZKPoK proves that the caller knows the plaintext value inside the ciphertext, without revealing it. It does not prove anything about token ownership or balances.
// The enforcer (issuer) creates the encrypted input themselves
const encryptedInput = await fhevm
.createEncryptedInput(tokenAddress, enforcerAddress) // enforcer's address, NOT holder's
.add64(amountToBurn)
.encrypt();
// The enforcer calls forcedBurn with their own proof
await token.connect(enforcer)['forcedBurn(address,bytes32,bytes)'](
holderAddress, // target to burn from
encryptedInput.handles[0], // enforcer's encrypted amount
encryptedInput.inputProof // enforcer's proof
);| Question | Answer |
|---|---|
| Who generates the inputProof? | The caller of the function (e.g., the enforcer/issuer) |
| Does the holder need to participate? | No -- forced operations don't require holder involvement |
| Is the proof tied to the holder's balance? | No -- it proves knowledge of the encrypted input value, not the balance |
| Can the issuer specify any amount? | Yes, but if the amount exceeds the holder's balance, FHE transfers/burns 0 silently |
The inputProof is tied to the caller's address and the contract address (passed to createEncryptedInput), not to the token holder. This is what enables administrative operations like forcedBurn and forcedTransfer without the holder's participation.
| Term | Definition |
|---|---|
| FHE (Fully Homomorphic Encryption) | Cryptographic scheme that enables arbitrary computations directly on ciphertext without ever decrypting it. The result, once decrypted, is identical to what would have been produced on the plaintext. It is the core primitive behind confidential balances and transfers in CMTAT Confidential. |
| euint64 | Encrypted unsigned 64-bit integer — the on-chain type used to store confidential balances and transfer amounts. It is a pointer to a ciphertext managed by the Zama coprocessor network, not a raw encrypted value. Maximum representable value is ~18.4 × 10¹⁸. |
| externalEuint64 | The user-facing form of an encrypted 64-bit integer: a ciphertext handle produced by the client library and submitted alongside a ZKPoK. Converted to euint64 on-chain via FHE.fromExternal() after the proof is verified. |
| ZKPoK (Zero-Knowledge Proof of Knowledge) | A cryptographic proof that the submitter knows the plaintext inside an encrypted input, without revealing that plaintext. Required for every encrypted input to prevent malleability and replay attacks. Verified on-chain by the InputVerifier contract. |
| Ciphertext Handle | A 32-byte pointer returned by every FHE operation (e.g., euint64). It references the actual ciphertext stored and computed by the coprocessor network, not the ciphertext itself. Arithmetic on handles triggers off-chain coprocessor computation and produces new handles. |
| ACL (Access Control List) | On-chain permission registry that tracks which addresses may decrypt a given ciphertext handle. Access is granted with FHE.allow() (permanent) or FHE.allowTransient() (current transaction only). Without ACL access, a party cannot request decryption of a handle. |
| Coprocessor (FHEVMExecutor) | Off-chain network of nodes that performs the actual FHE computations. When a Solidity contract calls an FHE operation, the host chain emits an event; coprocessors pick it up, compute the result, and make the new ciphertext available. |
| KMS (Key Management System) | Zama's key management infrastructure that holds the FHE private key in a distributed manner using MPC. Decryption requests are sent to the KMS, which returns a decrypted value together with a cryptographic proof that can be verified on-chain. |
| MPC (Multi-Party Computation) | Threshold cryptography protocol used by the KMS so that no single node ever holds the complete FHE private key. Decryption only succeeds when a quorum of KMS nodes cooperates, preventing any single point of compromise. |
| Symbolic Execution | The on-chain execution model for FHE operations: the EVM does not perform the actual FHE computation — it only records an intent (emits an event with a new handle). The coprocessor network executes the computation off-chain asynchronously. |
| ERC-7984 | The Confidential Fungible Token standard (drafted by OpenZeppelin). It defines the interface for tokens with encrypted balances and transfer amounts, including the operator system, disclosure mechanism, and ACL-based access patterns used by CMTAT Confidential. |
| Operator | An address authorized by a token holder to call confidentialTransferFrom on their behalf. Unlike ERC-20 allowances, operators are granted time-limited unlimited access — they may transfer any amount until the approval timestamp expires. |
| Observer | An ACL-authorized third party (e.g., a regulator or auditor) granted read access to one or more encrypted balance handles. Implemented via ERC7984ObserverAccess: the contract grants FHE.allow() on balances affected by each transfer, giving the observer a continuously updated view. |
-
CMTAT - Capital Markets and Technology Association Token Standard
-
Openzeppelin
-
Zama
-
Part of this project was carried out with the help of Claude Code and Codex
This project is licensed under the MPL-2.0 License.