Skip to main content

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:
  1. Implement an ERC-20 token with encrypted balances using euint64
  2. Use FHE.select() to perform privacy-preserving transfers (no information leakage on failure)
  3. Apply the ACL pattern for per-user balance access
  4. Handle encrypted allowances for delegated transfers
  5. Understand why failed transfers must send 0 instead of reverting
  6. 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:
  1. Try transferring 1000 — reverts (balance < 1000)
  2. Try transferring 500 — succeeds (balance >= 500)
  3. Try transferring 750 — reverts (balance < 750)
  4. 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

ConfidentialERC20.sol
// 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

  1. User calls transfer(encryptedAmount, proof, to)
  2. FHE.fromExternal() converts to euint64
  3. FHE.ge(balance, amount)ebool hasEnough
  4. FHE.select(hasEnough, amount, 0)transferAmount
  5. balance[from] = balance[from] - transferAmount
  6. balance[to] = balance[to] + transferAmount
  7. ACL updated for both from and to
  8. 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:
  1. allowance >= amount (encrypted comparison)
  2. 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:
FeatureStandard ERC-20Confidential ERC-20
balanceOf returnuint256euint64
transfer amount paramuint256externalEuint64 + proof
Transfer eventsInclude amountNo amount (would leak data)
Failed transferRevertsReturns silently (0 transfer)
totalSupplyPublicCan 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.

Build docs developers (and LLMs) love