Learn how to test and debug FHEVM contracts using the encrypt-act-decrypt-assert pattern. This module covers the unique challenges of testing encrypted operations, handling silent failures, and verifying ACL permissions.
Testing smart contracts that use Fully Homomorphic Encryption (FHE) is fundamentally different from testing standard Solidity contracts. In a normal contract, you can:
Call a view function and immediately read a balance as a number
Use require() to enforce conditions and test that they revert on failure
Use console.log() in Hardhat to print intermediate values during execution
Assert exact values returned from functions
With FHE contracts, none of these work in the obvious way:
View functions return encrypted handles, not numbers
require() cannot evaluate encrypted booleans — ebool is not a bool
console.log() will print a meaningless handle identifier, not the underlying value
Function return values are opaque ciphertexts until decrypted
This creates a paradigm shift in how you write tests. Instead of “call and compare,” you follow a cycle of encrypt, act, decrypt, and assert.
import "@nomicfoundation/hardhat-ethers";import "@fhevm/hardhat-plugin";import type { HardhatUserConfig } from "hardhat/config";const config: HardhatUserConfig = { defaultNetwork: "hardhat", networks: { hardhat: { accounts: { mnemonic: "test test test test test test test test test test test junk", }, chainId: 31337, }, }, solidity: { version: "0.8.27", settings: { optimizer: { enabled: true, runs: 800 }, evmVersion: "cancun", }, },};export default config;
Every FHE contract function that accepts encrypted input requires two parameters: the encrypted handle and a proof. In the test environment, you create these using fhevm.createEncryptedInput().
// 1. Create the input builderconst encryptedInput = await fhevm.createEncryptedInput( contractAddress, // The contract receiving the input signer.address // The user sending the transaction);// 2. Add values (type-specific methods)encryptedInput.add64(1000); // Add a uint64 value// 3. Encrypt and get the resultconst enc = await encryptedInput.encrypt();// 4. Use in a contract callawait contract.someFunction(enc.handles[0], enc.inputProof);
You can add multiple values in a single encrypted input:
const enc = await fhevm .createEncryptedInput(contractAddress, signer.address) .add64(amount) .add64(limit) .encrypt();// First value: enc.handles[0]// Second value: enc.handles[1]// Shared proof: enc.inputProof
Common Mistake: Wrong Address ParametersThe encrypted input is bound to a specific contract address and signer. If you mismatch, the proof verification will fail.
// WRONG: Using alice's address when bob is calling the contractconst enc = await fhevm .createEncryptedInput(contractAddress, alice.address) // alice .add64(100) .encrypt();await contract.connect(bob).deposit(enc.handles[0], enc.inputProof); // bob calls!// This will fail!// CORRECT: Match the signerconst enc = await fhevm .createEncryptedInput(contractAddress, bob.address) // bob .add64(100) .encrypt();await contract.connect(bob).deposit(enc.handles[0], enc.inputProof); // bob calls
import { FhevmType } from "@fhevm/hardhat-plugin";// Get the encrypted handle from the contractconst handle = await contract.connect(alice).getBalance();// Decrypt it in the testconst clearValue = await fhevm.userDecryptEuint( FhevmType.euint64, // The type of the encrypted value handle, // The handle returned by the contract contractAddress, // The contract that owns the value alice // The signer who has ACL permission);// Assertexpect(clearValue).to.equal(1000n); // Always use BigInt (n suffix)
You cannot test revert conditions on encrypted values. If a withdraw fails because the encrypted balance is too low, the contract does not revert — it withdraws 0 instead.
In a standard ERC-20, if you try to transfer more tokens than you have:
// Standard ERC-20require(balanceOf[msg.sender] >= amount, "Insufficient balance");
The transaction reverts. Your test can check for the revert.In an FHE contract, you cannot use require on encrypted values because ebool is not a native bool. Instead, the contract uses the select pattern:
If the user does not have enough balance, actualAmount becomes 0, and the subtraction is balance - 0 = balance. The transaction succeeds but transfers nothing.
// WRONG: Not waiting for the transactionawait contract.connect(alice).deposit(enc.handles[0], enc.inputProof);// Immediately tries to read balance before tx is mined// CORRECT: Wait for transaction receiptawait (await contract.connect(alice).deposit( enc.handles[0], enc.inputProof)).wait();
// WRONG: JavaScript number comparisonexpect(clear).to.equal(42);// This may fail because clear is a BigInt// CORRECT: BigInt comparisonexpect(clear).to.equal(42n);