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).
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().
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.”
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); }}
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.
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.
FHE.ge() returns ebool, not bool, so require() will not compile
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
// WRONG: euint32 cannot be used as an external function parameter for user inputfunction setSecret(euint32 encryptedValue) external { secretNumber = encryptedValue; FHE.allowThis(secretNumber);}
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.
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.
euint8 small = FHE.asEuint8(10);euint32 big = FHE.asEuint32(1000);// WRONG: Cannot operate on mismatched typeseuint32 result = FHE.add(small, big); // Compilation error
euint8 small = FHE.asEuint8(10);euint32 big = FHE.asEuint32(1000);// Cast to matching type firsteuint32 smallAsUint32 = FHE.asEuint32(small);euint32 result = FHE.add(smallAsUint32, big);FHE.allowThis(result);
uint256 public revealedBalance; // PUBLIC storage!function revealBalance(euint64 encBalance) external { FHE.makePubliclyDecryptable(encBalance); // Now anyone can decrypt it forever}
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.
// Option A: Return the encrypted handle -- client decrypts off-chainfunction 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 storagemapping(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).
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.
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]);}
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.
// Using euint256 for a value that will never exceed 100euint256 private score;function setScore(externalEuint256 encScore, bytes calldata inputProof) external { score = FHE.fromExternal(encScore, inputProof); FHE.allowThis(score);}
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.
// Use the smallest type that fits the data rangeeuint8 private score; // Scores 0-255 are plenty for a value up to 100function 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.
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.
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.
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}