Skip to main content

Overview

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.
Level: Advanced
Duration: 3 hours
Prerequisites: Modules 00-13

Learning Objectives

By the end of this module, you will be able to:
  1. Set up and configure the fhEVM mock testing environment
  2. Write comprehensive tests for encrypted operations
  3. Debug encrypted contracts without seeing intermediate values
  4. Handle FHE-specific testing challenges
  5. Use events and return values as debugging signals
  6. Test ACL permissions and multi-user scenarios

The Testing Challenge

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.

Setting Up the Test Environment

The @fhevm/hardhat-plugin

The fhEVM testing stack centers on the @fhevm/hardhat-plugin. This plugin:
  1. Provides a mock FHE environment that runs locally on Hardhat Network
  2. Exposes a global fhevm object in your test files
  3. Handles encrypted input creation and decryption for test assertions
  4. Simulates the FHE computation pipeline using plaintext under the hood
The mock environment is not running real FHE. It stores plaintext values behind encrypted handles, which makes tests fast and deterministic.

hardhat.config.ts Setup

Your Hardhat config must import the plugin:
hardhat.config.ts
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;

The fhevm Object

In every test file, you import fhevm from "hardhat":
import { ethers, fhevm } from "hardhat";
import { FhevmType } from "@fhevm/hardhat-plugin";
The fhevm object provides three critical methods:
MethodPurpose
fhevm.createEncryptedInput(contractAddress, signerAddress)Create encrypted inputs for contract calls
fhevm.userDecryptEuint(FhevmType, handle, contractAddress, signer)Decrypt a numeric encrypted value
fhevm.userDecryptEbool(handle, contractAddress, signer)Decrypt an encrypted boolean

Creating Encrypted Inputs in Tests

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().

Step-by-Step

// 1. Create the input builder
const 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 result
const enc = await encryptedInput.encrypt();

// 4. Use in a contract call
await contract.someFunction(enc.handles[0], enc.inputProof);

Adding Different Types

The input builder supports all FHE numeric types:
encryptedInput.addBool(true);   // ebool
encryptedInput.add8(255);       // euint8  (max: 255)
encryptedInput.add16(65535);    // euint16 (max: 65535)
encryptedInput.add32(1000000);  // euint32
encryptedInput.add64(1000000);  // euint64

Multiple Values in One Input

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 contract
const 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 signer
const enc = await fhevm
  .createEncryptedInput(contractAddress, bob.address)  // bob
  .add64(100)
  .encrypt();
await contract.connect(bob).deposit(enc.handles[0], enc.inputProof);  // bob calls

Decrypting Values in Tests

After a contract operation, you need to read the encrypted result and verify it. This is the “decrypt” step in the encrypt-act-decrypt-assert cycle.

Decrypting Numeric Types

import { FhevmType } from "@fhevm/hardhat-plugin";

// Get the encrypted handle from the contract
const handle = await contract.connect(alice).getBalance();

// Decrypt it in the test
const 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
);

// Assert
expect(clearValue).to.equal(1000n);  // Always use BigInt (n suffix)

Decrypting Booleans

const handle = await contract.connect(alice).getIsActive();
const clearBool = await fhevm.userDecryptEbool(
  handle,
  contractAddress,
  alice
);
expect(clearBool).to.equal(true);

The FhevmType Enum

You must pass the correct type to userDecryptEuint:
Contract return typeFhevmType parameter
euint8FhevmType.euint8
euint16FhevmType.euint16
euint32FhevmType.euint32
euint64FhevmType.euint64
eboolUse userDecryptEbool() instead

Testing Patterns

Pattern 1: Encrypt — Act — Decrypt — Assert

This is the fundamental FHE testing pattern:
it("should deposit and verify balance", async function () {
  // ENCRYPT: Create encrypted input
  const enc = await fhevm
    .createEncryptedInput(contractAddress, alice.address)
    .add64(1000)
    .encrypt();

  // ACT: Call the contract
  await (await contract.connect(alice).deposit(
    enc.handles[0], enc.inputProof
  )).wait();

  // DECRYPT: Read the encrypted result
  const handle = await contract.connect(alice).getBalance();
  const clear = await fhevm.userDecryptEuint(
    FhevmType.euint64, handle, contractAddress, alice
  );

  // ASSERT: Verify the decrypted value
  expect(clear).to.equal(1000n);
});

Pattern 2: Multi-User Scenarios

FHE contracts often involve multiple users whose data must be isolated:
it("should isolate user balances", async function () {
  const [_, alice, bob] = await ethers.getSigners();

  // Alice deposits 1000
  const encAlice = await fhevm
    .createEncryptedInput(contractAddress, alice.address)
    .add64(1000)
    .encrypt();
  await (await contract.connect(alice).deposit(
    encAlice.handles[0], encAlice.inputProof
  )).wait();

  // Bob deposits 2000
  const encBob = await fhevm
    .createEncryptedInput(contractAddress, bob.address)
    .add64(2000)
    .encrypt();
  await (await contract.connect(bob).deposit(
    encBob.handles[0], encBob.inputProof
  )).wait();

  // Verify each user's balance independently
  const handleA = await contract.connect(alice).getBalance();
  const clearA = await fhevm.userDecryptEuint(
    FhevmType.euint64, handleA, contractAddress, alice
  );
  expect(clearA).to.equal(1000n);

  const handleB = await contract.connect(bob).getBalance();
  const clearB = await fhevm.userDecryptEuint(
    FhevmType.euint64, handleB, contractAddress, bob
  );
  expect(clearB).to.equal(2000n);
});

Pattern 3: Event Verification

Events are your primary debugging tool:
it("should emit Deposited event", async function () {
  const enc = await fhevm
    .createEncryptedInput(contractAddress, alice.address)
    .add64(500)
    .encrypt();

  const tx = await contract.connect(alice).deposit(
    enc.handles[0], enc.inputProof
  );
  const receipt = await tx.wait();

  // Find the event in the receipt logs
  const event = receipt.logs.find(
    (log: any) => log.fragment?.name === "Deposited"
  );
  expect(event).to.not.be.undefined;
  expect(event.args[0]).to.equal(alice.address); // indexed user
});
Events are especially useful for tracking:
  • Which code path was taken
  • Operation counters and indices
  • Addresses and plaintext metadata

Pattern 4: Error Handling with try/catch

Custom errors and require() reverts work for plaintext conditions:
it("should revert for non-owner", async function () {
  const enc = await fhevm
    .createEncryptedInput(contractAddress, alice.address)
    .add64(500)
    .encrypt();

  try {
    await contract.connect(alice).setWithdrawalLimit(
      enc.handles[0], enc.inputProof
    );
    expect.fail("Should have reverted");
  } catch (error: any) {
    expect(error.message).to.include("NotOwner");
  }
});
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.

The Silent Failure Problem

This is the single most important concept in FHE testing.

The Problem

In a standard ERC-20, if you try to transfer more tokens than you have:
// Standard ERC-20
require(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:
// FHE contract
ebool hasEnough = FHE.ge(_balances[msg.sender], amount);
euint64 actualAmount = FHE.select(hasEnough, amount, FHE.asEuint64(0));
_balances[msg.sender] = FHE.sub(_balances[msg.sender], actualAmount);
If the user does not have enough balance, actualAmount becomes 0, and the subtraction is balance - 0 = balance. The transaction succeeds but transfers nothing.

Testing Silent Failures

You cannot test for reverts. Instead, verify that state did not change:
it("should silently fail on overdraft (balance unchanged)", async function () {
  // Setup: Alice has 100
  // ...

  // Try to withdraw 9999
  const encW = await fhevm
    .createEncryptedInput(contractAddress, alice.address)
    .add64(9999)
    .encrypt();
  await (await contract.connect(alice).withdraw(
    encW.handles[0], encW.inputProof
  )).wait();

  // Verify: balance is STILL 100 (the withdrawal silently withdrew 0)
  const handle = await contract.connect(alice).getBalance();
  const clear = await fhevm.userDecryptEuint(
    FhevmType.euint64, handle, contractAddress, alice
  );
  expect(clear).to.equal(100n); // unchanged!
});

Implications for Test Design

  1. Always verify state after “failed” operations. The tx will succeed (no revert), but the state should be unchanged.
  2. Use events to confirm the operation ran. The event will fire even on silent failures.
  3. Test both paths: a withdrawal that succeeds (balance decreases) AND a withdrawal that silently fails (balance unchanged).
  4. Never use revertedWith for encrypted condition failures.

Debugging Techniques

Events as Debug Output

Since you cannot console.log() encrypted values, events serve as your primary debugging mechanism:
// GOOD: Events provide debugging signals
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);
    depositCount++;
    emit Deposited(msg.sender, depositCount);
}

Public State Variables as Checkpoints

Add plaintext counters and status flags:
uint256 public depositCount;
uint256 public withdrawalCount;
bool public isPaused;
These are instantly readable in tests:
expect(await vault.depositCount()).to.equal(3n);

Step-by-Step Operation Verification

When a test fails, break the operation into the smallest possible steps:
it("debug: step 1 - deposit", async function () {
  const enc = await fhevm
    .createEncryptedInput(contractAddress, alice.address)
    .add64(1000)
    .encrypt();
  await (await contract.connect(alice).deposit(
    enc.handles[0], enc.inputProof
  )).wait();

  const handle = await contract.connect(alice).getBalance();
  const clear = await fhevm.userDecryptEuint(
    FhevmType.euint64, handle, contractAddress, alice
  );
  console.log("After deposit:", clear.toString());
  // If this prints "0" instead of "1000", the deposit logic is broken
});

Common Testing Mistakes

Mistake 1: Forgetting .wait()

// WRONG: Not waiting for the transaction
await contract.connect(alice).deposit(enc.handles[0], enc.inputProof);
// Immediately tries to read balance before tx is mined

// CORRECT: Wait for transaction receipt
await (await contract.connect(alice).deposit(
  enc.handles[0], enc.inputProof
)).wait();

Mistake 2: Using equal(42) Instead of equal(42n)

// WRONG: JavaScript number comparison
expect(clear).to.equal(42);
// This may fail because clear is a BigInt

// CORRECT: BigInt comparison
expect(clear).to.equal(42n);

Mistake 3: Not Granting ACL Permissions

If you forget FHE.allow() in the contract, the test decryption will fail:
// Contract -- WRONG: No ACL
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() and FHE.allow()
}

// Contract -- CORRECT: With ACL
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]);           // Contract can operate on it
    FHE.allow(_balances[msg.sender], msg.sender);   // User can decrypt it
}

TestableVault Contract

The companion contract for this module demonstrates all testing patterns:
TestableVault.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 TestableVault is ZamaEthereumConfig {
    address public owner;
    euint64 internal _withdrawalLimit;
    mapping(address => euint64) internal _balances;
    uint256 public depositCount;
    uint256 public withdrawalCount;

    event Deposited(address indexed user, uint256 depositIndex);
    event Withdrawn(address indexed user, uint256 withdrawalIndex);
    event WithdrawalLimitSet(address indexed setter);

    error NotOwner(address caller);

    modifier onlyOwner() {
        if (msg.sender != owner) revert NotOwner(msg.sender);
        _;
    }

    constructor() {
        owner = msg.sender;
        _withdrawalLimit = FHE.asEuint64(type(uint64).max);
        FHE.allowThis(_withdrawalLimit);
        FHE.allow(_withdrawalLimit, msg.sender);
    }

    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);
        depositCount++;
        emit Deposited(msg.sender, depositCount);
    }

    function withdraw(externalEuint64 encAmount, bytes calldata inputProof) external {
        euint64 amount = FHE.fromExternal(encAmount, inputProof);
        ebool hasEnough = FHE.ge(_balances[msg.sender], amount);
        ebool withinLimit = FHE.le(amount, _withdrawalLimit);
        ebool canWithdraw = FHE.and(hasEnough, withinLimit);
        euint64 withdrawAmount = FHE.select(canWithdraw, amount, FHE.asEuint64(0));
        _balances[msg.sender] = FHE.sub(_balances[msg.sender], withdrawAmount);
        FHE.allowThis(_balances[msg.sender]);
        FHE.allow(_balances[msg.sender], msg.sender);
        withdrawalCount++;
        emit Withdrawn(msg.sender, withdrawalCount);
    }

    function setWithdrawalLimit(externalEuint64 encLimit, bytes calldata inputProof) external onlyOwner {
        _withdrawalLimit = FHE.fromExternal(encLimit, inputProof);
        FHE.allowThis(_withdrawalLimit);
        FHE.allow(_withdrawalLimit, msg.sender);
        emit WithdrawalLimitSet(msg.sender);
    }

    function getBalance() external view returns (euint64) {
        return _balances[msg.sender];
    }

    function getWithdrawalLimit() external view returns (euint64) {
        return _withdrawalLimit;
    }
}

Key Design for Testability

  1. depositCount and withdrawalCount — plaintext counters that tests can read without decryption
  2. Events with indices — every operation emits an event with a sequential index
  3. Custom errorsNotOwner(address) is testable with error.message.includes("NotOwner")
  4. Dual-condition withdrawal — tests both balance check and withdrawal limit in a single operation
  5. getBalance() uses msg.sender — enforces that only the balance owner can retrieve the handle

Best Practices Checklist

Use this checklist when writing tests for any FHE contract:

Test Structure

  • Use beforeEach to deploy a fresh contract for each test (isolation)
  • Get multiple signers (owner, alice, bob, etc.) for multi-user tests
  • Follow the encrypt-act-decrypt-assert pattern consistently
  • Name tests descriptively: “should withdraw 0 when amount exceeds balance”

Encrypted Inputs

  • Always match createEncryptedInput signer with contract.connect() signer
  • Use the correct add method for the type (add64 for euint64, etc.)
  • Access enc.handles[0] and enc.inputProof from the encrypted result

Decryption

  • Use the correct FhevmType enum value matching the contract’s return type
  • Always use BigInt comparisons (42n, not 42)
  • Handle decryption errors gracefully in ACL boundary tests

Coverage

  • Test deployment/initial state
  • Test every public function’s success path
  • Test “failure” paths by verifying state is unchanged
  • Test with multiple users (balance isolation, permission isolation)
  • Test owner-only functions from both owner and non-owner accounts
  • Test sequential operations (deposit + deposit + withdraw)
  • Test edge cases: zero amounts, max values, empty balances
  • Verify events for every state-changing function

Summary

  • FHE testing follows the encrypt — act — decrypt — assert cycle
  • Use fhevm.createEncryptedInput() to create encrypted inputs; match the signer address carefully
  • Use fhevm.userDecryptEuint(FhevmType.euintXX, handle, contractAddress, signer) to decrypt results
  • Always use BigInt comparisons (42n, not 42) with Chai expectations
  • FHE operations on invalid conditions do not revert — they silently select the fallback value (usually 0)
  • Test “failure” paths by verifying state is unchanged, not by expecting reverts
  • Use events and plaintext counters as your primary debugging tools
  • Test ACL boundaries by attempting decryption from unauthorized signers
  • Always test with multiple signers to verify user isolation

Next Steps

Module 15: Gas Optimization for FHE

Learn advanced techniques to reduce gas costs in FHEVM contracts.

Build docs developers (and LLMs) love