| name | fhevm-antipatterns |
| description | Used when reviewing, debugging, auditing, or pre-commit-checking FHEVM Solidity or client code. Catalogues the v0.11-era breaking renames (TFHE→FHE, SepoliaZamaFHEVMConfig→ZamaEthereumConfig, ConfidentialFungibleToken→ERC7984, TFHESafeMath→FHESafeMath, tokenURI→contractURI, isUserAllowed→canTransact), the removed FHE.requestDecryption API, deprecated fhevmjs, and high-frequency footguns (missing FHE.allowThis after state writes, view functions returning encrypted types, .env-based Hardhat secrets). Triggers on FHEVM code review, anti-pattern, audit, migration from pre-v0.9/v0.11, pre-commit check, ACLNotAllowed, EmptyDecryptionProof, or DeclarationError. |
fhevm-antipatterns
A review and audit checklist. Every entry has: the wrong pattern, why it fails, the correct pattern, and a verification step (compile check, grep, runtime assertion, or doc cross-reference). Run through this list before opening a PR that touches FHEVM code.
Essential Principles
1. Compile does not mean correct
Many FHEVM antipatterns compile cleanly. TFHE.* against a v0.11 @fhevm/solidity gives a DeclarationError, yes — but missing FHE.allowThis(handle) compiles and deploys fine, then reverts with ACLNotAllowed() on the second transaction. Never treat "it compiles" as validation.
2. fhevmjs is dead — if you see it, it is wrong
Any code or doc snippet that imports from fhevmjs is at least a year out of date. Migrate to @zama-fhe/sdk@3.0.0 (the primary, provider-agnostic client SDK — works with viem, ethers, or any EVM library). @zama-fhe/relayer-sdk@~0.4.x is the legacy low-level transport that v3 wraps as a transitive dep; do not install it directly for new code. Even import paths in README examples need fixing.
3. .env is forbidden in Hardhat secrets
This skill set treats .env-based secret loading in Hardhat configs as a forbidden pattern — hardhat vars is the single source of truth. See fhevm-setup for the full workflow.
v0.11 breaking-rename catalogue
The eight breaking renames between v0.9 and v0.11 (TFHE → FHE, SepoliaZamaFHEVMConfig → ZamaEthereumConfig, removed FHE.requestDecryption, fhevmjs → @zama-fhe/*, ConfidentialFungibleToken → ERC7984, TFHESafeMath → FHESafeMath, tokenURI → contractURI, isUserAllowed → canTransact) live in references/v0.11-migration.md as AP-1 through AP-8. Each entry has the wrong form, why it fails, the correct v0.11 form, and a grep to verify a clean codebase. Read that file first if you are migrating an older codebase or reviewing a fresh-cloned repo for stale imports.
The behavioral antipatterns below — missing ACL grants, view-returning encrypted types, .env secrets, Sepolia-only failure modes — are AP-9 through AP-12. They survive even on a freshly migrated v0.11 codebase, and they tend to compile cleanly while reverting at runtime.
Behavioral antipatterns
9. Missing FHE.allowThis after a state write
Wrong:
function increment(externalEuint64 enc, bytes calldata proof) external {
euint64 delta = FHE.fromExternal(enc, proof);
_count = FHE.add(_count, delta);
// NO FHE.allowThis(_count) — contract cannot reuse the handle next tx
}
Why wrong: Every new handle produced by an FHE.* op carries no ACL grants from its inputs. On the next transaction, the contract will read _count and immediately fail with ACLNotAllowed() — because the contract's own stored handle has no permission to be read by the contract's own code. The handle permission resets every time a new op creates a new handle.
Correct:
function increment(externalEuint64 enc, bytes calldata proof) external {
euint64 delta = FHE.fromExternal(enc, proof);
_count = FHE.add(_count, delta);
FHE.allowThis(_count); // contract can reuse next tx
FHE.allow(_count, msg.sender); // user can decrypt
}
Verify: grep -A 2 "FHE\\.add\\|FHE\\.sub\\|FHE\\.mul\\|FHE\\.select\\|FHE\\.min\\|FHE\\.max" contracts/ — every result stored in state should be followed (within the same function) by an FHE.allowThis on that handle. If the caller needs to decrypt, also an FHE.allow(handle, msg.sender).
10. View functions returning encrypted types
Wrong:
// REVERTS — view cannot emit the symbolic events FHE needs
function balanceOf(address u) external view returns (euint64) {
return _balances[u];
}
Why wrong: Two failure modes, depending on the body:
- Body performs
FHE.* ops — reverts. FHE.* calls emit symbolic events to FHEVMExecutor; view cannot emit events.
- Body only reads storage — does not revert (handles are
bytes32 under the wrapper), but is misleading: the euint64 return type implies a plaintext value the caller can't actually consume without an ACL grant + off-chain userDecrypt.
The non-view rule is different — function _update(...) returns (euint64 transferred) and similar non-view returns of encrypted handles are permitted and routine (see ERC-7984 confidentialTransfer overloads). The problem is specifically view + the encrypted-type return shape.
ERC-7984 caveat: The OpenZeppelin IERC7984 interface declares confidentialBalanceOf and confidentialTotalSupply as external view returns (euint64) because the implementation reads storage only. These work mechanically and you must keep the signature to conform to the standard. For non-standard accessors in your own contracts, prefer the bytes32 form below.
Correct:
// Return the handle as bytes32 — the caller must hold an ACL grant
// (granted via FHE.allow on a prior state-mutating call).
function balanceHandleOf(address u) external view returns (bytes32) {
return euint64.unwrap(_balances[u]);
}
Then, on the client, use instance.userDecrypt(handle, signer) — the decryption happens off-chain. See fhevm-decryption.
Verify: grep -En "function.*\\bview\\b.*returns \\(euint|function.*\\bview\\b.*returns \\(ebool|function.*\\bview\\b.*returns \\(eaddress" contracts/ — no hits.
11. .env for Hardhat secrets instead of hardhat vars
Wrong:
import "dotenv/config";
const config: HardhatUserConfig = {
networks: {
sepolia: {
url: `https://eth-sepolia.g.alchemy.com/v2/${process.env.RPC_KEY}`,
accounts: [process.env.PRIVATE_KEY!],
},
},
};
Why wrong: .env files land in git one copy-paste mistake away from leaking a mnemonic. They also do not survive CI cleanly (.env not present → undefined → silent "" URL). The repo convention (see CLAUDE.md) is hardhat vars exclusively.
Correct:
import { HardhatUserConfig, vars } from "hardhat/config";
const MNEMONIC = vars.get("MNEMONIC");
const RPC_KEY = vars.get("RPC_KEY");
const config: HardhatUserConfig = {
networks: {
sepolia: {
url: `https://eth-sepolia.g.alchemy.com/v2/${RPC_KEY}`,
accounts: { mnemonic: MNEMONIC },
},
},
};
Set values via npx hardhat vars set MNEMONIC etc. For CI, export HARDHAT_VAR_MNEMONIC=... — no .env file created.
Verify: grep -rn "dotenv\\|process\\.env\\.\\(MNEMONIC\\|PRIVATE_KEY\\|RPC_KEY\\|ETHERSCAN_API_KEY\\)" . — no hits. See fhevm-setup.
12. Sepolia-only failure modes (mock passes, Sepolia fails)
The mock coprocessor on the hardhat network masks several Sepolia-only
failure modes. Mock tests pass; deployment to Sepolia silently rejects the
transaction. Three known sub-modes — all warrant pre-deployment audit and a
Sepolia smoke-test in CI.
12a. transaction gas limit too high (cap: 16777216) on Sepolia
Wrong (frontend write call without explicit gas):
await writeContractAsync({
address, abi, functionName: "deposit",
args: [encrypted, proof],
});
Why wrong: Sepolia enforces a per-tx gas cap of 2^24 = 16_777_216. wagmi
/ viem's default gas estimation for FHE-heavy calls (FHE.fromExternal,
confidentialTransferFrom, FHE.gt / FHE.select over euint64, multiple
FHE.allowThis) typically estimates ~5M actual but adds a buffer that pushes
tx.gas over the cap. The relayer rejects with
transaction gas limit too high (cap: 16777216, tx: <N>). Mock mode does not
enforce this cap, so the call passes locally.
Correct:
await writeContractAsync({
address, abi, functionName: "deposit",
args: [encrypted, proof],
gas: 15_000_000n,
});
Verify: grep -rEn "useWriteContract|writeContractAsync\\(" frontend/ — every
call to a function whose ABI takes encrypted-input markers (bytes32/bytes)
must include gas: 15_000_000n. See fhevm-frontend/workflows/debug-errors.md
Phase 6 for the full diagnostic.
12b. INVALID_ARGUMENT — invalid object key - customData on npx hardhat deploy --network sepolia
Wrong (hardhat-deploy in the prescribed stack):
import "hardhat-deploy";
const { deployments, getNamedAccounts } = hre;
const { deployer } = await getNamedAccounts();
await deployments.deploy("Counter", { from: deployer });
Why wrong: hardhat-deploy@0.14.x (and 0.11.x) transitively depends on
@ethersproject/properties@5.x (ethers v5), which validates tx keys against a
hardcoded allowlist. @fhevm/hardhat-plugin@0.4.2 injects a customData key
on every tx (for relayer routing); the v5 validator rejects it. ethers v6
(via @nomicfoundation/hardhat-ethers) accepts customData cleanly.
Mock-mode "deployments" don't broadcast, so the conflict is invisible until
the first Sepolia attempt.
Correct:
import hre from "hardhat";
async function main() {
const Counter = await hre.ethers.getContractFactory("Counter");
const counter = await Counter.deploy();
await counter.waitForDeployment();
console.log("Counter deployed to:", await counter.getAddress());
}
main().catch((e) => { console.error(e); process.exitCode = 1; });
Then deploy via npx hardhat run scripts/deploy.ts --network sepolia — the tx
routes through @nomicfoundation/hardhat-ethers (ethers v6) which accepts the
customData key.
Verify: npm ls hardhat-deploy returns (empty) after npm uninstall hardhat-deploy. grep -rn 'import "hardhat-deploy"' . — no hits. See fhevm-setup for the updated bootstrap sequence.
12c. Invalid @zama-fhe/relayer-sdk version — exact-pin requirement
Wrong:
{
"devDependencies": {
"@zama-fhe/relayer-sdk": "^0.4.1"
}
}
Why wrong: @fhevm/hardhat-plugin@0.4.2 declares an exact-version peer
dependency on @zama-fhe/relayer-sdk@0.4.1 (verified in
node_modules/@fhevm/hardhat-plugin/package.json line 84:
"@zama-fhe/relayer-sdk": "0.4.1" — no caret). At runtime, the plugin imports
@zama-fhe/relayer-sdk/node and validates the resolved version; any other
version (including 0.4.2, currently the latest published) raises
Invalid @zama-fhe/relayer-sdk version. A caret pin lets npm resolve to
0.4.2 on a fresh install → broken Hardhat.
Correct:
{
"devDependencies": {
"@zama-fhe/relayer-sdk": "0.4.1"
}
}
Verify: npm ls @zama-fhe/relayer-sdk must show exactly 0.4.1 (not
0.4.2 or higher). When @fhevm/hardhat-plugin bumps and relaxes this peer
dep (or pins to a newer exact version), the project pin can move with it.
Cluster summary
All three sub-modes share a property: the mock coprocessor on the hardhat
network does not exercise them. Catching them requires either (a) a Sepolia
smoke-test in CI or (b) eyes-on-Sepolia review before declaring a deployment
"working." The
"mock passes" signal is necessary but not sufficient for any
encrypted-write code path. Drop hardhat-deploy first — it removes 12b
entirely, which would otherwise block 12a from being observable (the tx never
broadcasts).
Cross-cutting references
- Full pitfall catalogue with symptoms, causes, and diagnostic commands →
references/debug-checklist.md.
- Protocol-level explanation of why ACL grants behave this way →
fhevm-overview.
- Correct setup workflow (Hardhat vars, pins, Node versions, no
hardhat-deploy) → fhevm-setup.
- Sepolia-only failure modes — see AP-12 above for the cluster (gas cap,
customData reject, relayer-sdk exact pin).
When to Use
- Reviewing a PR that touches FHEVM code.
- Auditing a codebase that compiled but reverts at runtime.
- Diagnosing
ACLNotAllowed(), EmptyDecryptionProof(), or DeclarationError at compile.
- Migrating a codebase from pre-v0.9 or pre-v0.11 Zama packages.
- Checking a client codebase still importing
fhevmjs.
When NOT to Use
- Writing a new contract from scratch →
fhevm-contracts.
- Learning the protocol architecture →
fhevm-overview.
- Setting up a new workspace →
fhevm-setup.
- Writing tests →
fhevm-testing.
- Building a frontend →
fhevm-frontend.
Routing
Which symptom or question does the user's prompt match?
- Compile error, missing import, or
DeclarationError → references/v0.11-migration.md AP-1, AP-2, AP-5, AP-6.
- "Transaction reverts with ACLNotAllowed / EmptyDecryptionProof" → AP-9 +
references/debug-checklist.md § ACL, § Mock KMS.
- Client code still uses
fhevmjs → references/v0.11-migration.md AP-4.
- Code compiles but transfers / state changes don't take effect →
references/debug-checklist.md § Silent Transfer Returns Zero.
- Agent suggests
.env for secrets → AP-11.
- Agent writes
function foo() external view returns (euint64) → AP-10.
- Migrating pre-v0.9 code with
FHE.requestDecryption → references/v0.11-migration.md AP-3 + v0.9 migration guide.
- "Mock tests pass but Sepolia broadcast fails" → AP-12 cluster (12a gas cap / 12b customData reject / 12c relayer-sdk pin).
transaction gas limit too high (cap: 16777216, tx: <N>) → AP-12a.
INVALID_ARGUMENT — invalid object key - customData on hardhat deploy → AP-12b. Drop hardhat-deploy.
Invalid @zama-fhe/relayer-sdk version → AP-12c. Pin exact 0.4.1.
- General FHEVM pre-commit / audit checklist → scan AP-9 through AP-12 here + the migration catalogue in
references/v0.11-migration.md, then references/debug-checklist.md.
External references
See fhevm-overview for the canonical list. The OZ Confidential Contracts CHANGELOG is the most useful single page for the rename catalogue this skill covers.