Overview
Learn how to implement an ERC-20 token where balances and transfer amounts are completely private. This module covers the critical “no-revert” pattern required to prevent information leakage in confidential token systems.
Level: Advanced
Duration: 4 hours
Prerequisites: Modules 01-10
Learning Objectives
By the end of this module, you will be able to:
Implement an ERC-20 token with encrypted balances using euint64
Use FHE.select() to perform privacy-preserving transfers (no information leakage on failure)
Apply the ACL pattern for per-user balance access
Handle encrypted allowances for delegated transfers
Understand why failed transfers must send 0 instead of reverting
Connect the confidential ERC-20 to a frontend for encrypted operations
The Privacy Problem with Standard ERC-20
In a standard ERC-20:
balanceOf(address) returns a plaintext uint256 — anyone can query any address
Transfer events log the exact amount — block explorers show everything
Failed transfers revert with an error — an attacker can binary-search balances
With a confidential ERC-20:
Balances are stored as euint64 — only the owner can decrypt
Transfer amounts are encrypted — observers see nothing
Failed transfers do not revert — they silently transfer 0
The No-Revert Pattern
This is the most critical privacy pattern in FHEVM token design.
Why Can We Not Revert on Insufficient Balance?
If transfer(to, amount) reverts when balance < amount, an attacker can:
Try transferring 1000 — reverts (balance < 1000)
Try transferring 500 — succeeds (balance >= 500)
Try transferring 750 — reverts (balance < 750)
Binary search to find the exact balance
Solution: Always Succeed, but Transfer 0 on Failure
function _transfer ( address from , address to , euint64 amount ) internal {
// Check if sender has enough
ebool hasEnough = FHE. ge (_balances[from], amount);
// If has enough, transfer `amount`. If not, transfer 0.
euint64 transferAmount = FHE. select (hasEnough, amount, FHE. asEuint64 ( 0 ));
// Update balances
_balances[from] = FHE. sub (_balances[from], transferAmount);
_balances[to] = FHE. add (_balances[to], transferAmount);
// Update ACL
FHE. allowThis (_balances[from]);
FHE. allow (_balances[from], from);
FHE. allowThis (_balances[to]);
FHE. allow (_balances[to], to);
}
From the outside, the transaction always succeeds. Nobody can tell if the actual transfer was the requested amount or 0.
Complete ConfidentialERC20 Contract
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24 ;
import { FHE , euint64 , externalEuint64 , ebool } from "@fhevm/solidity/lib/FHE.sol" ;
import { ZamaEthereumConfig } from "@fhevm/solidity/config/ZamaConfig.sol" ;
contract ConfidentialERC20 is ZamaEthereumConfig {
string public name;
string public symbol;
uint8 public decimals;
uint64 public totalSupply;
address public owner;
mapping ( address => euint64) internal _balances;
mapping ( address => mapping ( address => euint64)) internal _allowances;
event Transfer ( address indexed from , address indexed to );
event Approval ( address indexed owner , address indexed spender );
event Mint ( address indexed to , uint64 amount );
modifier onlyOwner () {
require ( msg.sender == owner, "Not the owner" );
_ ;
}
constructor ( string memory _name , string memory _symbol ) {
name = _name;
symbol = _symbol;
decimals = 6 ;
owner = msg.sender ;
}
/// @notice Mint tokens to an address (plaintext amount, only owner)
function mint ( address to , uint64 amount ) external onlyOwner {
totalSupply += amount;
_balances[to] = FHE. add (_balances[to], amount);
FHE. allowThis (_balances[to]);
FHE. allow (_balances[to], to);
emit Mint (to, amount);
}
/// @notice Transfer encrypted amount to recipient
/// @dev If balance < amount, transfers 0 instead of reverting (privacy!)
function transfer ( externalEuint64 encAmount , bytes calldata inputProof , address to ) external {
euint64 amount = FHE. fromExternal (encAmount, inputProof);
_transfer ( msg.sender , to, amount);
}
/// @notice Approve spender for encrypted amount
function approve ( externalEuint64 encAmount , bytes calldata inputProof , address spender ) external {
euint64 amount = FHE. fromExternal (encAmount, inputProof);
_allowances[ msg.sender ][spender] = amount;
FHE. allowThis (_allowances[ msg.sender ][spender]);
FHE. allow (_allowances[ msg.sender ][spender], msg.sender );
FHE. allow (_allowances[ msg.sender ][spender], spender);
emit Approval ( msg.sender , spender);
}
/// @notice TransferFrom with allowance check
function transferFrom ( address from , externalEuint64 encAmount , bytes calldata inputProof , address to ) external {
euint64 amount = FHE. fromExternal (encAmount, inputProof);
// Check allowance
ebool hasAllowance = FHE. ge (_allowances[from][ msg.sender ], amount);
euint64 transferAmount = FHE. select (hasAllowance, amount, FHE. asEuint64 ( 0 ));
// Deduct allowance
_allowances[from][ msg.sender ] = FHE. sub (_allowances[from][ msg.sender ], transferAmount);
FHE. allowThis (_allowances[from][ msg.sender ]);
FHE. allow (_allowances[from][ msg.sender ], from);
FHE. allow (_allowances[from][ msg.sender ], msg.sender );
_transfer (from, to, transferAmount);
}
/// @notice Get encrypted balance handle (ACL protected)
function balanceOf ( address account ) external view returns ( euint64 ) {
return _balances[account];
}
/// @notice Get encrypted allowance handle
function allowance ( address _owner , address spender ) external view returns ( euint64 ) {
return _allowances[_owner][spender];
}
/// @dev Internal transfer with balance check (transfers 0 on insufficient balance)
function _transfer ( address from , address to , euint64 amount ) internal {
// Check balance >= amount
ebool hasBalance = FHE. ge (_balances[from], amount);
euint64 actualAmount = FHE. select (hasBalance, amount, FHE. asEuint64 ( 0 ));
// Update balances
_balances[from] = FHE. sub (_balances[from], actualAmount);
_balances[to] = FHE. add (_balances[to], actualAmount);
// Set ACL permissions
FHE. allowThis (_balances[from]);
FHE. allow (_balances[from], from);
FHE. allowThis (_balances[to]);
FHE. allow (_balances[to], to);
emit Transfer (from, to);
}
}
Key Design Decisions
balanceOf(address) Returns an Encrypted Handle
Unlike a standard ERC-20 where balanceOf(address) returns a plaintext uint256 anyone can read, the confidential version returns an euint64 encrypted handle. The function takes an address account parameter, but only the account owner (who has ACL access) can decrypt the returned value.
Events Without Amounts
event Transfer ( address indexed from , address indexed to );
// Note : no amount field!
Standard ERC-20 events include the amount. We omit it because the amount is encrypted and should not be leaked.
totalSupply is Public
The total supply is plaintext. This is a design choice — you could encrypt it too, but most tokens benefit from a publicly verifiable supply.
Why uint64 Instead of uint256?
FHE operations on larger types are more expensive. euint64 supports up to ~18.4 quintillion, which is sufficient for most token designs (especially with 6 decimals).
The Transfer Flow Step by Step
User calls transfer(encryptedAmount, proof, to)
FHE.fromExternal() converts to euint64
FHE.ge(balance, amount) → ebool hasEnough
FHE.select(hasEnough, amount, 0) → transferAmount
balance[from] = balance[from] - transferAmount
balance[to] = balance[to] + transferAmount
ACL updated for both from and to
Transaction succeeds regardless of outcome
An observer sees:
That a transaction occurred between from and to
That it succeeded
Nothing about the amount or whether it was a “real” transfer or a 0-transfer
Allowance Pattern
The allowance system works similarly to standard ERC-20 but with encrypted amounts:
// Owner approves spender for encrypted amount
approve (encryptedAmount, proof, spender)
// Spender can check their allowance (only they can decrypt)
allowance (owner, spender) -> euint64
// Spender transfers from owner's balance
transferFrom (from, encryptedAmount, proof, to)
The transferFrom checks both:
allowance >= amount (encrypted comparison)
balance >= amount (encrypted comparison)
Both must pass (using FHE.and()), or the transfer sends 0.
Minting Pattern
For initial distribution or minting:
function mint ( address to , uint64 amount ) public onlyOwner {
_balances[to] = FHE. add (_balances[to], FHE. asEuint64 (amount));
FHE. allowThis (_balances[to]);
FHE. allow (_balances[to], to);
totalSupply += amount;
}
amount is plaintext here (the owner knows how much they are minting). If you want private minting, accept an externalEuint64 instead.
Frontend Integration
On the frontend, transfers look like:
async function transfer ( to : string , amount : number ) {
const instance = await initFhevm ();
const input = instance . createEncryptedInput ( tokenAddress , userAddress );
input . add64 ( amount );
const encrypted = await input . encrypt ();
const tx = await contract . transfer ( encrypted . handles [ 0 ], encrypted . inputProof , to );
await tx . wait ();
}
Reading balance:
async function getBalance ( account : string ) : Promise < number > {
const handle = await contract . balanceOf ( account );
// ... EIP-712 signature + reencrypt flow
return Number ( decryptedValue );
}
ERC-20 Compatibility Tradeoffs
Our Confidential ERC-20 intentionally breaks standard ERC-20 compatibility:
Feature Standard ERC-20 Confidential ERC-20 balanceOf returnuint256euint64transfer amount paramuint256externalEuint64 + proofTransfer events Include amount No amount (would leak data) Failed transfer Reverts Returns silently (0 transfer) totalSupplyPublic Can be public or encrypted
These changes are necessary for privacy but mean the contract cannot be used with existing ERC-20 tooling (DEX routers, block explorers, etc.) without adaptation.
Industry Standard: ERC-7984
ERC-7984: Confidential Fungible Tokens The confidential ERC-20 pattern taught in this module is formalized as ERC-7984 — a standard co-developed by Zama and OpenZeppelin for confidential fungible tokens. Real-world use: Zaiffer Protocol (a Zama + PyratzLabs joint venture, €2M backing) uses this exact pattern in production to convert standard ERC-20 tokens into confidential cTokens with encrypted balances. The OpenZeppelin audit was completed in November 2025.
Summary
Confidential ERC-20 stores balances as euint64 in encrypted mappings
The no-revert pattern is essential: failed transfers send 0 instead of reverting
FHE.select() is the core primitive for conditional transfer logic
Events omit amounts to prevent information leakage
balanceOf(address) returns an encrypted handle — only the account owner with ACL access can decrypt it
Allowances are also encrypted, with ACL granted to both owner and spender
FHE.and() combines multiple conditions (balance check + allowance check)
Next Steps
Module 12: Confidential Voting Build a private voting system where votes are encrypted and tallies remain hidden until the election ends.