Skip to main content

Common Pitfalls in fhEVM Development

This guide covers the most common mistakes developers make when building with fhEVM, along with explanations and correct solutions. All examples use the new FHEVM API (FHE library).

Pitfall 1: Using the Old API (TFHE instead of FHE)

Wrong

import "fhevm/lib/TFHE.sol";

contract OldAPI {
    euint32 private value;

    function setValue(einput encryptedInput, bytes calldata inputProof) external {
        value = TFHE.asEuint32(encryptedInput, proof);
        TFHE.allow(value, address(this));
    }
}

Why It Is Wrong

The TFHE library, einput type, and TFHE.asEuint32(einput, proof) pattern are from the old API. The new API uses the FHE library, externalEuint32 type, and FHE.fromExternal().

Correct

import { FHE, euint32 } from "@fhevm/solidity/lib/FHE.sol";
import { ZamaEthereumConfig } from "@fhevm/solidity/config/ZamaConfig.sol";

contract NewAPI is ZamaEthereumConfig {
    euint32 private value;

    function setValue(externalEuint32 encryptedInput, bytes calldata inputProof) external {
        value = FHE.fromExternal(encryptedInput, inputProof);
        FHE.allowThis(value);
    }
}

Pitfall 2: Forgetting FHE.allowThis() After Storing a Value

Wrong

function deposit(externalEuint64 encAmount, bytes calldata inputProof) external {
    euint64 amount = FHE.fromExternal(encAmount, inputProof);
    balances[msg.sender] = FHE.add(balances[msg.sender], amount);
    // Missing: FHE.allowThis(balances[msg.sender]);
}

Why It Is Wrong

Every FHE operation produces a new ciphertext. The new ciphertext has an empty ACL. If the contract does not call FHE.allowThis(), it cannot read its own stored value in the next transaction. The next call to balances[msg.sender] will fail with “Unauthorized access to ciphertext.”

Correct

function deposit(externalEuint64 encAmount, bytes calldata inputProof) external {
    euint64 amount = FHE.fromExternal(encAmount, inputProof);
    balances[msg.sender] = FHE.add(balances[msg.sender], amount);
    FHE.allowThis(balances[msg.sender]);
    FHE.allow(balances[msg.sender], msg.sender);
}
Always call both:
  • FHE.allowThis(value) - So the contract can operate on it
  • FHE.allow(value, user) - So the user can decrypt it

Pitfall 3: Branching on Encrypted Values with if/else

Wrong

function transfer(address to, externalEuint64 encAmount, bytes calldata inputProof) external {
    euint64 amount = FHE.fromExternal(encAmount, inputProof);

    // WRONG: Cannot use encrypted value in an if statement
    if (FHE.ge(balances[msg.sender], amount)) {
        balances[msg.sender] = FHE.sub(balances[msg.sender], amount);
        balances[to] = FHE.add(balances[to], amount);
    }
}

Why It Is Wrong

FHE.ge() returns an ebool (encrypted boolean), not a Solidity bool. You cannot use an encrypted boolean in an if statement because its value is not known at execution time — it is a ciphertext. This will cause a compilation error.

Correct

function transfer(address to, externalEuint64 encAmount, bytes calldata inputProof) external {
    euint64 amount = FHE.fromExternal(encAmount, inputProof);
    ebool hasEnough = FHE.ge(balances[msg.sender], amount);

    // Compute both outcomes, select the right one
    balances[msg.sender] = FHE.select(
        hasEnough,
        FHE.sub(balances[msg.sender], amount),
        balances[msg.sender]
    );
    balances[to] = FHE.select(
        hasEnough,
        FHE.add(balances[to], amount),
        balances[to]
    );

    FHE.allowThis(balances[msg.sender]);
    FHE.allow(balances[msg.sender], msg.sender);
    FHE.allowThis(balances[to]);
    FHE.allow(balances[to], to);
}
The FHE.select() Pattern:FHE.select(condition, ifTrue, ifFalse) always computes both branches and returns the encrypted result based on the encrypted condition. This maintains constant-time execution and prevents information leakage.

Pitfall 4: Using require() on Encrypted Conditions

Wrong

function withdraw(externalEuint64 encAmount, bytes calldata inputProof) external {
    euint64 amount = FHE.fromExternal(encAmount, inputProof);

    // WRONG: Cannot require() on encrypted comparison
    require(FHE.ge(balances[msg.sender], amount), "Insufficient balance");

    balances[msg.sender] = FHE.sub(balances[msg.sender], amount);
}

Why It Is Wrong

Two problems:
  1. FHE.ge() returns ebool, not bool, so require() will not compile
  2. Even if it could work, reverting on a balance check leaks information — an attacker could binary-search the victim’s balance by observing which amounts cause reverts

Correct

function withdraw(externalEuint64 encAmount, bytes calldata inputProof) external {
    euint64 amount = FHE.fromExternal(encAmount, inputProof);
    ebool hasEnough = FHE.ge(balances[msg.sender], amount);

    // Silent fail: if insufficient, balance stays the same
    balances[msg.sender] = FHE.select(
        hasEnough,
        FHE.sub(balances[msg.sender], amount),
        balances[msg.sender]
    );

    FHE.allowThis(balances[msg.sender]);
    FHE.allow(balances[msg.sender], msg.sender);
}

Pitfall 5: Using euint32 as a Function Parameter

Wrong

// WRONG: euint32 cannot be used as an external function parameter for user input
function setSecret(euint32 encryptedValue) external {
    secretNumber = encryptedValue;
    FHE.allowThis(secretNumber);
}

Why It Is Wrong

External functions that accept encrypted data from users must use the externalEuintXX type. The euint32 type is an internal ciphertext handle and cannot be directly passed by external callers.

Correct

function setSecret(externalEuint32 encryptedValue, bytes calldata inputProof) external {
    secretNumber = FHE.fromExternal(encryptedValue, inputProof);
    FHE.allowThis(secretNumber);
}
externalEuint8, externalEuint16, externalEuint32, externalEuint64

// Always paired with:
bytes calldata inputProof

Pitfall 6: Forgetting to Allow the User to View Their Own Data

Wrong

function deposit(externalEuint64 encAmount, bytes calldata inputProof) external {
    euint64 amount = FHE.fromExternal(encAmount, inputProof);
    balances[msg.sender] = FHE.add(balances[msg.sender], amount);
    FHE.allowThis(balances[msg.sender]);
    // Missing: FHE.allow(balances[msg.sender], msg.sender);
}

Why It Is Wrong

The contract can use the balance (because of allowThis), but the user cannot decrypt or re-encrypt their own balance. When the user calls a view function to see their balance, the re-encryption will fail because msg.sender is not in the ACL.

Correct

function deposit(externalEuint64 encAmount, bytes calldata inputProof) external {
    euint64 amount = FHE.fromExternal(encAmount, inputProof);
    balances[msg.sender] = FHE.add(balances[msg.sender], amount);
    FHE.allowThis(balances[msg.sender]);
    FHE.allow(balances[msg.sender], msg.sender);  // User can view their balance
}

Pitfall 7: Mixing Encrypted Types Without Casting

Wrong

euint8 small = FHE.asEuint8(10);
euint32 big = FHE.asEuint32(1000);

// WRONG: Cannot operate on mismatched types
euint32 result = FHE.add(small, big);  // Compilation error

Why It Is Wrong

All FHE operations require operands of the same encrypted type. You cannot add an euint8 to a euint32 directly.

Correct

euint8 small = FHE.asEuint8(10);
euint32 big = FHE.asEuint32(1000);

// Cast to matching type first
euint32 smallAsUint32 = FHE.asEuint32(small);
euint32 result = FHE.add(smallAsUint32, big);
FHE.allowThis(result);

Pitfall 8: Storing Decrypted Values in Public Storage

Wrong

uint256 public revealedBalance;  // PUBLIC storage!

function revealBalance(euint64 encBalance) external {
    FHE.makePubliclyDecryptable(encBalance);  // Now anyone can decrypt it forever
}

Why It Is Wrong

Storing a decrypted value in public storage permanently exposes it. Anyone can read public storage variables. If the goal was temporary access, this defeats the purpose of encryption.

Correct

// Option A: Return the encrypted handle -- client decrypts off-chain
function getBalance(address user) external view returns (euint64) {
    require(FHE.isSenderAllowed(balances[user]), "Not authorized");
    return balances[user]; // Client decrypts via instance.userDecrypt()
}

// Option B: If on-chain decryption is needed, use private storage
mapping(address => uint64) private revealedValues;

function revealMyBalance() external {
    require(FHE.isSenderAllowed(balances[msg.sender]), "Not authorized");
    FHE.makePubliclyDecryptable(balances[msg.sender]);
    // Value is now decryptable but stored privately
}
Best practice: Use ACL grants + client-side re-encryption (instance.userDecrypt) so the value never appears in plaintext on-chain. Only use FHE.makePubliclyDecryptable() for values that genuinely need to be public (e.g., final auction results, game outcomes).

Pitfall 9: Emitting Events with Encrypted Values

Wrong

event Transfer(address indexed from, address indexed to, uint256 amount);

function transfer(address to, externalEuint64 encAmount, bytes calldata inputProof) external {
    euint64 amount = FHE.fromExternal(encAmount, inputProof);
    // ... transfer logic ...

    // WRONG: Cannot emit encrypted type in a standard event
    emit Transfer(msg.sender, to, amount);  // Compilation error (euint64 != uint256)
}

Why It Is Wrong

Events expect plaintext types. You cannot pass an euint64 where a uint256 is expected. Even if you could cast it, emitting the plaintext amount would leak the transfer amount to everyone.

Correct

// Option A: Emit event without the amount
event Transfer(address indexed from, address indexed to);

function transfer(address to, externalEuint64 encAmount, bytes calldata inputProof) external {
    euint64 amount = FHE.fromExternal(encAmount, inputProof);
    // ... transfer logic ...
    emit Transfer(msg.sender, to);  // Amount is confidential
}

// Option B: Emit a placeholder or counter
event TransferOccurred(address indexed from, address indexed to, uint256 indexed txNonce);

function transfer(address to, externalEuint64 encAmount, bytes calldata inputProof) external {
    euint64 amount = FHE.fromExternal(encAmount, inputProof);
    // ... transfer logic ...
    transferNonce++;
    emit TransferOccurred(msg.sender, to, transferNonce);
}

Pitfall 10: Not Handling Zero/Uninitialized Encrypted Values

Wrong

mapping(address => euint64) private balances;

function transfer(address to, externalEuint64 encAmount, bytes calldata inputProof) external {
    euint64 amount = FHE.fromExternal(encAmount, inputProof);

    // If balances[to] has never been set, it is an uninitialized handle
    balances[to] = FHE.add(balances[to], amount);
    FHE.allowThis(balances[to]);
}

Why It Is Wrong

Uninitialized euint64 values in mappings may behave as zero ciphertexts, but depending on the implementation, operations on uninitialized handles can fail or produce unexpected behavior.

Correct

mapping(address => euint64) private balances;
mapping(address => bool) private initialized;

function _ensureInitialized(address account) internal {
    if (!initialized[account]) {
        balances[account] = FHE.asEuint64(0);
        FHE.allowThis(balances[account]);
        initialized[account] = true;
    }
}

function transfer(address to, externalEuint64 encAmount, bytes calldata inputProof) external {
    _ensureInitialized(msg.sender);
    _ensureInitialized(to);

    euint64 amount = FHE.fromExternal(encAmount, inputProof);
    // Now safe to operate on initialized values
}

Pitfall 11: Using euint256 for Everything

Wrong

// Using euint256 for a value that will never exceed 100
euint256 private score;

function setScore(externalEuint256 encScore, bytes calldata inputProof) external {
    score = FHE.fromExternal(encScore, inputProof);
    FHE.allowThis(score);
}

Why It Is Wrong

FHE gas costs scale with bit width. Operations on euint256 are significantly more expensive than operations on euint8 or euint32. Using a 256-bit encrypted integer for a value that fits in 8 bits wastes gas.

Correct

// Use the smallest type that fits the data range
euint8 private score;  // Scores 0-255 are plenty for a value up to 100

function setScore(externalEuint8 encScore, bytes calldata inputProof) external {
    score = FHE.fromExternal(encScore, inputProof);
    FHE.allowThis(score);
}
Type Size Guide:
  • euint8 - Values 0-255
  • euint16 - Values 0-65,535
  • euint32 - Values 0-4,294,967,295
  • euint64 - Values 0-18,446,744,073,709,551,615
Always use the smallest type that fits your data range.

Pitfall 12: Leaking Information Through Gas Differences

Wrong

function processPayment(externalEuint64 encAmount, bytes calldata inputProof) external {
    euint64 amount = FHE.fromExternal(encAmount, inputProof);
    ebool isLargePayment = FHE.gt(amount, FHE.asEuint64(10000));

    // Different code paths = different gas = information leakage
    balances[msg.sender] = FHE.sub(balances[msg.sender], amount);
}

Why It Is Wrong

If different logical paths consume different amounts of gas, an observer can infer information about encrypted values by watching gas usage. All execution paths should perform the same operations.

Correct

function processPayment(externalEuint64 encAmount, bytes calldata inputProof) external {
    euint64 amount = FHE.fromExternal(encAmount, inputProof);

    // Always perform the same operations regardless of amount
    ebool hasEnough = FHE.ge(balances[msg.sender], amount);

    // Both branches of select are always computed
    balances[msg.sender] = FHE.select(
        hasEnough,
        FHE.sub(balances[msg.sender], amount),
        balances[msg.sender]
    );

    FHE.allowThis(balances[msg.sender]);
    FHE.allow(balances[msg.sender], msg.sender);
}

Pitfall 13: Forgetting ACL in Multi-Contract Interactions

Wrong

contract TokenA {
    mapping(address => euint64) private balances;

    function getBalance(address user) external view returns (euint64) {
        return balances[user];
        // The calling contract (TokenB) has no ACL permission!
    }
}

contract TokenB {
    TokenA public tokenA;

    function doSomething(address user) external {
        euint64 bal = tokenA.getBalance(user);
        // FAILS: TokenB is not in the ACL
        euint64 doubled = FHE.mul(bal, FHE.asEuint64(2));
    }
}

Why It Is Wrong

When one contract returns an encrypted value to another, the calling contract is not automatically in the ACL. The returning contract must explicitly grant permission.

Correct

contract TokenA {
    mapping(address => euint64) private balances;

    function getBalanceFor(address user, address caller) external returns (euint64) {
        euint64 bal = balances[user];
        FHE.allowTransient(bal, caller);  // Grant temporary access
        return bal;
    }
}

contract TokenB {
    TokenA public tokenA;

    function doSomething(address user) external {
        euint64 bal = tokenA.getBalanceFor(user, address(this));
        // Now TokenB has transient permission
        euint64 doubled = FHE.mul(bal, FHE.asEuint64(2));
        FHE.allowThis(doubled);
    }
}

Pitfall 14: No Error Feedback from Silent Fails

Wrong

function transfer(address to, euint64 amount) external {
    ebool hasEnough = FHE.ge(balances[msg.sender], amount);
    balances[msg.sender] = FHE.select(hasEnough, FHE.sub(balances[msg.sender], amount), balances[msg.sender]);
    balances[to] = FHE.select(hasEnough, FHE.add(balances[to], amount), balances[to]);
    // User has no way to know if the transfer succeeded or failed
}

Why It Is Wrong

The silent fail pattern is correct for preventing information leakage, but provides zero feedback to the caller.

Correct — LastError Pattern

// Encrypted error code: 0 = no error, non-zero = error occurred
mapping(address => euint8) private _lastError;

function transfer(address to, euint64 amount) external {
    ebool hasEnough = FHE.ge(balances[msg.sender], amount);

    balances[msg.sender] = FHE.select(hasEnough, FHE.sub(balances[msg.sender], amount), balances[msg.sender]);
    balances[to] = FHE.select(hasEnough, FHE.add(balances[to], amount), balances[to]);

    // Store encrypted error code: 0 if success, 1 if insufficient balance
    _lastError[msg.sender] = FHE.select(hasEnough, FHE.asEuint8(0), FHE.asEuint8(1));
    FHE.allowThis(_lastError[msg.sender]);
    FHE.allow(_lastError[msg.sender], msg.sender);
}

function getLastError() external view returns (euint8) {
    return _lastError[msg.sender];
}
Key insight: The LastError pattern stores an encrypted error code, preserving privacy while giving the user feedback they can decrypt client-side.

Quick Reference Table

#PitfallImpactFix
1Old API (TFHE)Does not compileUse FHE library + externalEuintXX
2Missing allowThisContract loses accessAlways FHE.allowThis() after storing
3if/else on encryptedDoes not compileUse FHE.select()
4require() on encryptedCompilation error + info leakUse FHE.select() (silent fail)
5euint32 as parameterDoes not compileUse externalEuint32 + proof
6Missing user ACLUser cannot viewAdd FHE.allow(value, user)
7Type mismatchDoes not compileCast with FHE.asEuintXX()
8Public decrypted storagePermanent leakUse private storage or re-encryption
9Encrypted in eventsDoes not compileOmit amount or emit placeholder
10Uninitialized valuesPotential failuresInitialize with FHE.asEuintXX(0)
11Oversized typesWasted gasUse smallest sufficient type
12Gas leakageSide-channel attackUniform execution paths
13Missing cross-contract ACLUnauthorized accessFHE.allowTransient() before return
14No error feedbackUser confusionUse encrypted LastError pattern

Best Practices Summary

1

Always use the new FHE API

Import from @fhevm/solidity/lib/FHE.sol and inherit ZamaEthereumConfig
2

Grant ACL permissions after every FHE operation

FHE.allowThis(value);        // Contract can use it
FHE.allow(value, user);      // User can decrypt it
3

Use FHE.select() instead of if/else

Always compute both branches and select encrypted result
4

Never use require() on encrypted conditions

Silent fails prevent information leakage
5

Choose the smallest encrypted type

Minimize gas costs by using the narrowest bit width
6

Initialize encrypted storage

Explicitly set values to FHE.asEuintXX(0) before first use
7

Provide encrypted error feedback

Use the LastError pattern for user-facing error codes

Next Steps

Testing FHE Contracts

Learn how to test these patterns with Hardhat

Frontend Integration

Connect React frontends to your contracts

Build docs developers (and LLMs) love