Testing Fully Homomorphic Encryption (FHE) contracts requires a different approach than standard Solidity testing. This guide shows you how to test fhEVM contracts using the Hardhat plugin and the encrypt-act-decrypt-assert pattern.
import "@fhevm/hardhat-plugin";import "@nomicfoundation/hardhat-ethers";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;
2
Import the test utilities
In your test file, import the necessary modules:
import { expect } from "chai";import { ethers, fhevm } from "hardhat";import { FhevmType } from "@fhevm/hardhat-plugin";
The fhevm object provides:
createEncryptedInput() - Create encrypted inputs for contract calls
const enc = await fhevm .createEncryptedInput(vaultAddress, alice.address) .add64(1000) // Add a 64-bit encrypted value .encrypt();
Available input methods:
addBool(value) - Encrypt a boolean
add8(value) - Encrypt a uint8 (max: 255)
add16(value) - Encrypt a uint16 (max: 65535)
add32(value) - Encrypt a uint32
add64(value) - Encrypt a uint64
2
Act - Call the contract with encrypted inputs
await (await vault.connect(alice).deposit( enc.handles[0], // The encrypted handle enc.inputProof // The proof)).wait();
Always use the double-await pattern: await (await contract.function()).wait()The first await sends the transaction, the second await waits for it to be mined.
3
Decrypt - Read the encrypted result
const handle = await vault.connect(alice).getBalance();const clear = await fhevm.userDecryptEuint( FhevmType.euint64, // The type of the encrypted value handle, // The handle returned by the contract vaultAddress, // The contract address alice // The signer with ACL permission);
4
Assert - Verify the decrypted value
expect(clear).to.equal(1000n); // Always use BigInt (n suffix)
All decrypted values are BigInts. Always use the n suffix for expected values.
FHE contracts cannot use require() on encrypted values, so failed operations don’t revert. Instead, they “silently fail” by performing a no-op (usually subtracting/adding 0).
Never test for reverts on encrypted condition failures. The transaction will succeed, but state will be unchanged.
it("should prevent user B from decrypting user A's balance", async function () { // Alice deposits const enc = await fhevm .createEncryptedInput(vaultAddress, alice.address) .add64(1000) .encrypt(); await (await vault.connect(alice).deposit( enc.handles[0], enc.inputProof )).wait(); // Alice can decrypt her own balance const handle = await vault.connect(alice).getBalance(); const clear = await fhevm.userDecryptEuint( FhevmType.euint64, handle, vaultAddress, alice ); expect(clear).to.equal(1000n); // Bob CANNOT decrypt Alice's balance handle try { await fhevm.userDecryptEuint( FhevmType.euint64, handle, vaultAddress, bob ); expect.fail("Bob should not be able to decrypt Alice's balance"); } catch (error) { // Expected failure: Bob lacks ACL permission }});
// 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();
Mistake 2: Using regular numbers instead of BigInt
// WRONG: JavaScript number comparisonexpect(clear).to.equal(42);// CORRECT: BigInt comparisonexpect(clear).to.equal(42n);
Mistake 3: Mismatched signer in createEncryptedInput
// WRONG: Encrypted for alice, sent by bobconst enc = await fhevm .createEncryptedInput(contractAddress, alice.address) .add64(100) .encrypt();await contract.connect(bob).deposit(enc.handles[0], enc.inputProof);// Proof validation fails!// CORRECT: Match signerconst enc = await fhevm .createEncryptedInput(contractAddress, bob.address) .add64(100) .encrypt();await contract.connect(bob).deposit(enc.handles[0], enc.inputProof);
Tests fail with “Unauthorized access to ciphertext”
Cause: The contract forgot to call FHE.allow() or FHE.allowThis() after creating an encrypted value.Solution: Ensure every function that stores encrypted values includes:
FHE.allowThis(encryptedValue); // Contract can operate on itFHE.allow(encryptedValue, msg.sender); // User can decrypt it
# Run all testsnpx hardhat test# Run specific test filenpx hardhat test test/TestableVault.test.ts# Run with gas reportingREPORT_GAS=true npx hardhat test