docs/examples/openzeppelin/erc7984-tutorial.md
This tutorial explains how to create a confidential fungible token using Fully Homomorphic Encryption (FHE) and the OpenZeppelin smart contract library. By following this guide, you will learn how to build a token where balances and transactions remain encrypted while maintaining full functionality.
Confidential tokens make sense in many real-world scenarios:
FHE enables these benefits by allowing computations on encrypted data without decryption, ensuring privacy while maintaining the security and transparency of blockchain.
Before starting this tutorial, ensure you have:
For help with these steps, refer to the following tutorial:
Our confidential token will inherit from several key contracts:
ERC7984 - OpenZeppelin's base for confidential tokensOwnable2Step - Access control for minting and administrative functionsZamaEthereumConfig - FHE configuration for the Ethereum mainnet or Ethereum Sepolia testnet networksLet's create our confidential token contract in contracts/ERC7984Example.sol. This contract will demonstrate the core functionality of ERC7984 tokens.
A few key points about this implementation:
While this example uses a clear initial mint for simplicity, in production you may want to consider:
// SPDX-License-Identifier: BSD-3-Clause-Clear
pragma solidity ^0.8.24;
import {Ownable2Step, Ownable} from "@openzeppelin/contracts/access/Ownable2Step.sol";
import {FHE, externalEuint64, euint64} from "@fhevm/solidity/lib/FHE.sol";
import {ZamaEthereumConfig} from "@fhevm/solidity/config/ZamaConfig.sol";
import {ERC7984} from "@openzeppelin/confidential-contracts/token/ERC7984.sol";
contract ERC7984Example is ZamaEthereumConfig, ERC7984, Ownable2Step {
constructor(
address owner,
uint64 amount,
string memory name_,
string memory symbol_,
string memory tokenURI_
) ERC7984(name_, symbol_, tokenURI_) Ownable(owner) {
euint64 encryptedAmount = FHE.asEuint64(amount);
_mint(owner, encryptedAmount);
}
}
Now let's test the token transfer process. We'll create a test that:
Create a new file test/ERC7984Example.test.ts with the following test:
import { expect } from 'chai';
import { ethers, fhevm } from 'hardhat';
describe('ERC7984Example', function () {
let token: any;
let owner: any;
let recipient: any;
let other: any;
const INITIAL_AMOUNT = 1000;
const TRANSFER_AMOUNT = 100;
beforeEach(async function () {
[owner, recipient, other] = await ethers.getSigners();
// Deploy ERC7984Example contract
token = await ethers.deployContract('ERC7984Example', [
owner.address,
INITIAL_AMOUNT,
'Confidential Token',
'CTKN',
'https://example.com/token'
]);
});
describe('Confidential Transfer Process', function () {
it('should transfer tokens from owner to recipient', async function () {
// Create encrypted input for transfer amount
const encryptedInput = await fhevm
.createEncryptedInput(await token.getAddress(), owner.address)
.add64(TRANSFER_AMOUNT)
.encrypt();
// Perform the confidential transfer
await expect(token
.connect(owner)
['confidentialTransfer(address,bytes32,bytes)'](
recipient.address,
encryptedInput.handles[0],
encryptedInput.inputProof
)).to.not.be.reverted;
// Check that both addresses have balance handles (without decryption for now)
const recipientBalanceHandle = await token.confidentialBalanceOf(recipient.address);
const ownerBalanceHandle = await token.confidentialBalanceOf(owner.address);
expect(recipientBalanceHandle).to.not.be.undefined;
expect(ownerBalanceHandle).to.not.be.undefined;
});
});
});
To run the tests, use:
npx hardhat test test/ERC7984Example.test.ts
The basic ERC7984Example contract provides core functionality, but you can extend it with additional features. For example:
Visible Mint - Allows the owner to mint tokens with a clear amount:
function mint(address to, uint64 amount) external onlyOwner {
_mint(to, FHE.asEuint64(amount));
}
confidentialMint for privacy.onlyOwner with role-based access via AccessControl (e.g., MINTER_ROLE) for multi-signer workflows._mint and enforce it consistently for both visible and confidential flows.Confidential Mint - Allows minting with encrypted amounts for enhanced privacy:
function confidentialMint(
address to,
externalEuint64 encryptedAmount,
bytes calldata inputProof
) external onlyOwner returns (euint64 transferred) {
return _mint(to, FHE.fromExternal(encryptedAmount, inputProof));
}
encryptedAmount and inputProof are produced off-chain with the SDK. Always validate and revert on malformed inputs.const enc = await fhevm
.createEncryptedInput(await token.getAddress(), owner.address)
.add64(1_000)
.encrypt();
await token.confidentialMint(recipient.address, enc.handles[0], enc.inputProof);
Visible Burn - Allows the owner to burn tokens with a clear amount:
function burn(address from, uint64 amount) external onlyOwner {
_burn(from, FHE.asEuint64(amount));
}
Confidential Burn - Allows burning with encrypted amounts:
function confidentialBurn(
address from,
externalEuint64 encryptedAmount,
bytes calldata inputProof
) external onlyOwner returns (euint64 transferred) {
return _burn(from, FHE.fromExternal(encryptedAmount, inputProof));
}
const enc = await fhevm
.createEncryptedInput(await token.getAddress(), owner.address)
.add64(250)
.encrypt();
await token.confidentialBurn(holder.address, enc.handles[0], enc.inputProof);
If you want the owner to be able to view the total supply (useful for administrative purposes):
function _update(address from, address to, euint64 amount) internal virtual override returns (euint64 transferred) {
transferred = super._update(from, to, amount);
FHE.allow(confidentialTotalSupply(), owner());
}
owner permission to decrypt the latest total supply handle after every state-changing update.confidentialTotalSupply() and use their off-chain key material to decrypt the returned handle.Ownable2Step, this function will automatically allow the current owner().