| name | writing-fhevm-contracts |
| description | Use when writing, modifying, or reviewing any Solidity contract that uses Zama fhEVM encrypted types (euint, ebool, eaddress) — confidential DeFi, private voting, sealed-bid auctions, encrypted ERC20 tokens, encrypted balances, or any application where on-chain data must remain encrypted under fully homomorphic encryption. Symptoms in code: imports from @fhevm/solidity, uses externalEuintXX or FHE.fromExternal, calls FHE.add/sub/mul/select, FHE.allowThis, FHE.makePubliclyDecryptable, ZamaEthereumConfig, fhevm.createEncryptedInput. Use also when frontend integrates relayer-sdk for client-side encryption or async decryption. |
fhEVM Confidential Smart Contract Development
Build Solidity contracts where on-chain data stays encrypted using Fully Homomorphic Encryption.
Validation: This skill has been A/B tested against 15 prompts (7 demo + 8 adversarial traps), with one live verification run on 2026-05-09. Without this skill, vanilla LLM output triggers 47 distinct anti-pattern occurrences across 14 of the 20 patterns in the catalog (predicted), plus 9 anti-pattern occurrences in a single live run (verified — full transcript at validation/transcripts/2026-05-09-vanilla-erc20.md); only 1/15 vanilla outputs both compile and have no privacy leak. With this skill loaded, all of these move to 0 and 15/15 respectively. Full report and reproducibility protocol: validation/agent-effectiveness.md.
When to Use
Use this skill when any of these apply:
- Writing or modifying a Solidity contract that imports
@fhevm/solidity
- Adding encrypted state (
euint, ebool, eaddress) to an existing contract
- Reviewing fhEVM code (your own or AI-generated) for correctness or privacy leaks
- Building or modifying a frontend that uses
@zama-fhe/relayer-sdk for client-side encryption or async decryption
- Migrating a regular Solidity contract to fhEVM
- Writing or debugging Hardhat tests that use
fhevm.createEncryptedInput
- Designing data flow for any application where on-chain values must stay encrypted under threshold MPC
Symptoms in chat or code that should trigger this skill:
- "encrypted balance / score / vote / bid"
- API references:
FHE.add, FHE.select, FHE.fromExternal, FHE.allowThis, FHE.makePubliclyDecryptable, ZamaEthereumConfig, externalEuintXX
- File patterns:
@fhevm/solidity imports, *.sol files declaring euint* state, test files importing fhevm from hardhat, relayer-sdk in frontend deps
- Error patterns: ACL access denied, "ebool cannot be evaluated",
FHE.decrypt is not a function, decryption returning garbage
When NOT to Use
This skill is overkill or off-topic for:
- Regular Solidity contracts with no encrypted state — vanilla Solidity guidance is enough; loading this skill adds noise.
- ZK-SNARK / ZK-STARK circuits that aren't fhEVM-related — different paradigm (zero-knowledge ≠ fully homomorphic).
- Concrete ML / TFHE-rs Rust applications — those use Python/Rust APIs and a different deployment model; this skill is Solidity-only.
- Generic Ethereum security audits where FHE is not present — use general security review skills, not this one.
- Contracts that only call fhEVM contracts but hold no encrypted state themselves — usually only the
references/access-control.md cross-contract section is needed; full skill is unnecessary.
Development Philosophy
Think in ciphertexts, not plaintexts. This is the fundamental shift from regular Solidity.
You are not writing "if this, then that" logic anymore. You are writing "compute both branches, select the correct result homomorphically." Every if/else becomes FHE.select. Every require becomes a guard that silently clamps to zero on failure. Your contract always succeeds at the EVM level — the question is what the encrypted result contains.
Four-step approach to every fhEVM task:
- Understand the goal — What data must stay private? What can be public? Define the privacy boundary before writing code.
- Choose the right pattern — Use the decision tables below. Don't branch on encrypted values. Don't try to decrypt synchronously. Don't forget ACL.
- Verify at each step — After every FHE operation, ask: "Did I re-grant ACL on the new ciphertext? Did I use
FHE.select instead of if? Did I validate the input with FHE.fromExternal?"
- Test with mock mode — Run
npx hardhat test locally. Mock FHE simulates all operations without a real coprocessor. Verify the logic works before deploying.
How fhEVM Works (Architecture)
Understanding the architecture prevents entire classes of bugs:
User encrypts value (client-side, ZKPoK generated)
↓
Transaction carries encrypted handle (bytes32) + proof
↓
Contract calls FHE.fromExternal() → validates ZKPoK
↓
FHE.add(a, b) → FHE.sol → Impl.sol → FHEVMExecutor (on-chain)
↓
FHEVMExecutor does SYMBOLIC execution only:
- Generates new handle via keccak256(op, operands, chainId, blockhash...)
- Grants transient ACL permission for result
- Emits event (e.g., FheAdd)
- Meters HCU cost
↓
Off-chain coprocessor watches events, performs REAL TFHE computation
↓
Result ciphertext stored off-chain, indexed by the same handle
Key insight: The on-chain system is entirely symbolic. Handles (bytes32) are NOT ciphertexts — they are deterministic identifiers computed via keccak256. The actual encrypted data lives in 5 off-chain coprocessor nodes. Decryption requires 9 of 13 KMS MPC nodes (run by Etherscan, Fireblocks, Ledger, OpenZeppelin, etc. in AWS Nitro Enclaves). This is why:
- Every operation creates a new handle → FHE math produces a new ciphertext (different noise/randomness), so you must re-grant ACL permissions on the new handle
- Handles cannot be evaluated as booleans → no node has the secret key; the EVM literally cannot evaluate
if (eboolHandle)
- Decryption requires the off-chain KMS → 9/13 threshold MPC, always async, no
FHE.decrypt()
- Uninitialized handles are
bytes32(0) → not encrypted zero, just an invalid pointer
- Operations are expensive → each FHE op involves noise management on large polynomial structures; bootstrapping refreshes noise but costs HCU
See references/architecture.md for the full 6-component system, TFHE principles, handle format, and HCU metering details.
Quick Start
git clone https://github.com/zama-ai/fhevm-hardhat-template.git my-fhevm-project
cd my-fhevm-project && npm install
npm test
npm run compile
Minimal contract:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import { FHE, euint64, externalEuint64 } from "@fhevm/solidity/lib/FHE.sol";
import { ZamaEthereumConfig } from "@fhevm/solidity/config/ZamaConfig.sol";
contract EncryptedCounter is ZamaEthereumConfig {
euint64 private _count;
function increment(externalEuint64 encValue, bytes calldata proof) external {
euint64 value = FHE.fromExternal(encValue, proof);
_count = FHE.add(_count, value);
FHE.allowThis(_count);
FHE.allow(_count, msg.sender);
}
}
Decision Tables
When to use which encrypted type
| Need | Type | Why |
|---|
| Token balances, amounts | euint64 | Fits up to ~18.4 quintillion; use 6 decimals not 18 |
| Scores, counters, vote tallies | euint32 | Cheaper ops than euint64, sufficient range |
| Tiers, flags, small enums | euint8 | 2-10x cheaper than euint64 |
| Boolean conditions | ebool | Result of all comparisons |
| Hash values, bitfields | euint256 | No arithmetic — bitwise and equality only |
| Encrypted addresses | eaddress | Only eq, ne, select — very limited |
| Large financial values | euint128 | Expensive (mul costs ~1,686K HCU) — use sparingly |
Solidity pattern → fhEVM equivalent
| Regular Solidity | fhEVM Equivalent |
|---|
if (a > b) { x = a; } else { x = b; } | x = FHE.select(FHE.gt(a, b), a, b); |
require(balance >= amount) | ebool ok = FHE.ge(balance, amount); + silent failure |
balance -= amount | balance = FHE.sub(balance, amount); FHE.allowThis(balance); |
return balance | Return handle; user decrypts off-chain |
| Revert on error | Silent failure: transfer 0, bid 0 |
a / b (both variables) | Impossible if both encrypted; restructure math |
mapping(addr => uint) | mapping(address => euint64) + ACL on every update |
When to load reference files
| Situation | Load |
|---|
| Need full type details, casting, initialization | references/encrypted-types.md |
| Need exact operation signatures or HCU costs | references/fhe-operations.md |
| Writing ACL logic, cross-contract permissions, AA bundles | references/access-control.md |
| Handling user-submitted encrypted inputs | references/input-validation.md |
| Implementing reveal/result publication, decryption callbacks | references/decryption-patterns.md |
| Setting up or writing Hardhat tests | references/testing-guide.md |
| Building frontend encryption/decryption | references/frontend-integration.md |
| Reviewing contract for common mistakes (20 patterns) | anti-patterns/ANTI-PATTERNS.md |
| Writing/grading agent-generated code against trap cases | validation/prompts.md |
| Reviewing the validation evidence | validation/agent-effectiveness.md |
Encrypted Types (Summary)
| Type | Arithmetic | Comparison | Bitwise | Select |
|---|
ebool | No | eq, ne | and, or, xor, not | Yes |
euint8–euint128 | Full | Full | Full | Yes |
euint256 | No | eq, ne only | Full | Yes |
eaddress | No | eq, ne only | No | Yes |
Import only what you use: import { FHE, euint64, ebool, externalEuint64 } from "@fhevm/solidity/lib/FHE.sol";
FHE Operations (Summary)
Arithmetic (euint8–128): FHE.add, FHE.sub, FHE.mul, FHE.div*, FHE.rem*, FHE.min, FHE.max, FHE.neg
*div/rem require plaintext right operand — encrypted divisor is NOT supported.
Comparison (→ ebool): FHE.eq, FHE.ne, FHE.ge, FHE.gt, FHE.le, FHE.lt
Branching: FHE.select(ebool, ifTrue, ifFalse) — the ONLY way to do conditional logic.
Random: FHE.randEuint8() – FHE.randEuint256(). Bounded: FHE.randEuint16(upperBound) (power-of-2). Transactions only, not view functions.
Type casting: FHE.asEuint32(plaintext) to encrypt. FHE.asEuint64(euint32Value) to upcast.
Scalar operations (one plaintext operand) are significantly cheaper. Prefer FHE.add(enc, 5) over FHE.add(enc1, enc2) when possible.
Operator overloading: With using FHE for *, you can write a + b instead of FHE.add(a, b). Supports +, -, *, &, |, ^, ~.
Access Control (ACL) — The #1 Source of Bugs
Every FHE operation creates a NEW ciphertext handle with ZERO permissions. Re-grant after EVERY operation:
euint64 newBalance = FHE.add(balance, amount);
_balances[to] = newBalance;
FHE.allowThis(newBalance); // Contract can use it in future txs
FHE.allow(newBalance, to); // Owner can request decryption
| Function | Scope | When |
|---|
FHE.allowThis(ct) | Persistent | Always — contract must access its own state |
FHE.allow(ct, addr) | Persistent | User/protocol needs long-term decrypt access |
FHE.allowTransient(ct, addr) | Single tx | Helper contract, cheaper gas |
FHE.makePubliclyDecryptable(ct) | Anyone, forever | Publishing results (vote tallies) |
FHE.isSenderAllowed(ct) | Check | Verify caller permission |
FHE.cleanTransientStorage() | Cleanup | Between AA bundled ops (prevent cross-op leaks) |
Input Validation
Users encrypt values client-side. Contracts validate via externalEuintXX + FHE.fromExternal:
function deposit(externalEuint64 encAmount, bytes calldata inputProof) external {
euint64 amount = FHE.fromExternal(encAmount, inputProof); // Validates ZKPoK
_balances[msg.sender] = FHE.add(_balances[msg.sender], amount);
FHE.allowThis(_balances[msg.sender]);
FHE.allow(_balances[msg.sender], msg.sender);
}
Never accept raw euintXX as function parameters — always use externalEuintXX + proof.
Decryption (Always Async)
There is no FHE.decrypt(). Decryption goes through the off-chain Gateway → KMS threshold MPC:
- On-chain:
FHE.makePubliclyDecryptable(ct) — mark for decryption
- Off-chain:
relayer-sdk.publicDecrypt(handles) — get cleartext + proof
- On-chain (optional):
FHE.checkSignatures(handles, clearValues, proof) — verify
Frontend SDK: fhevmjs is renamed to @zama-fhe/relayer-sdk
If you are reading older Zama tutorials, blog posts, or third-party guides, you will see the package name fhevmjs. This is the previous name for the official frontend SDK. The current package is @zama-fhe/relayer-sdk (sometimes called "relayer-sdk"). Same intent, refreshed API, native ESM, and handles client-side encryption + async decryption end-to-end.
| Old (deprecated) | New (use this) |
|---|
npm install fhevmjs | npm install @zama-fhe/relayer-sdk |
import { initFhevm, createInstance } from "fhevmjs" | import { initSDK, createInstance, SepoliaConfig } from "@zama-fhe/relayer-sdk" |
instance.createEncryptedInput(addr, user) | fhevm.createEncryptedInput(addr, user) (same shape) |
instance.reencrypt(...) (legacy) | fhevm.userDecrypt([handles], eip712Signature) |
instance.publicDecrypt(...) (legacy) | fhevm.publicDecrypt([handles]) |
If a search engine sends an agent to a fhevmjs snippet, mentally rename it to @zama-fhe/relayer-sdk and use the API table in references/frontend-integration.md. Don't npm install fhevmjs on a new project — the package is no longer maintained for current Zama Protocol releases.
Configuration
import { ZamaEthereumConfig } from "@fhevm/solidity/config/ZamaConfig.sol";
contract MyContract is ZamaEthereumConfig { ... } // Auto-detects chainId
Hardhat: must set evmVersion: "cancun" in solidity settings.
Testing (Hardhat Mock)
import { ethers, fhevm } from "hardhat";
it("should work", async function () {
if (!fhevm.isMock) { this.skip(); }
const enc = await fhevm.createEncryptedInput(contractAddr, alice.address)
.add64(1000).encrypt();
await contract.connect(alice).deposit(enc.handles[0], enc.inputProof);
});
Input methods: .addBool, .add8, .add16, .add32, .add64, .add128, .add256, .addAddress
Foundry alternative: forge-fhevm (early-stage) supports pure Solidity tests with native fuzz testing. See references/testing-foundry.md.
Confidential Token Design (ERC-7984)
When building encrypted ERC20 tokens, these rules prevent critical bugs:
OpenZeppelin Confidential Contracts
For production-grade ERC-7984 tokens, prefer the audited base contracts from @openzeppelin/confidential-contracts (verified against 0.4.0) over rolling your own:
npm install @openzeppelin/confidential-contracts
Note: The earlier @openzeppelin/contracts-confidential package is deprecated and renamed. Use @openzeppelin/confidential-contracts for any new project.
import { ERC7984 } from "@openzeppelin/confidential-contracts/token/ERC7984/ERC7984.sol";
import { ZamaEthereumConfig } from "@fhevm/solidity/config/ZamaConfig.sol";
contract MyConfidentialToken is ERC7984, ZamaEthereumConfig {
constructor() ERC7984("My Token", "MTK", "https://example.com/uri") {}
}
(Constructor signature: ERC7984(string name_, string symbol_, string contractURI_).)
What ERC7984 already gives you — so you don't reinvent it:
- Encrypted
balanceOf(address) with proper ACL re-grants on every transfer
confidentialTransfer(to, externalEuint64, proof) + confidentialTransferFrom(...) with FHE.and(hasBalance, hasAllowance) checks
- Silent-fail-on-insufficient-balance pattern (returns the actually-transferred amount as
euint64)
Transfer / Approval events that emit type(uint256).max as the value placeholder (anti-pattern #16 prevention)
Override only domain-specific logic (mint/burn rules, transfer hooks). Do not re-implement ACL re-grants by hand — the base contract already does it correctly.
Standard extensions in @openzeppelin/confidential-contracts/token/ERC7984/extensions/ (v0.4.0):
ERC7984ERC20Wrapper — bidirectional wrap/unwrap with a plaintext ERC-20 (see next section)
ERC7984Votes — votes-style governance hooks for confidential supply
ERC7984Freezable — admin-controlled per-account freezes
ERC7984Restricted — allowlist / denylist gating
ERC7984Omnibus — multi-account omnibus accounting
ERC7984Rwa — real-world-asset hooks (admin-pause, force-transfer)
ERC7984ObserverAccess — third-party observer ACL grants
Our examples/ConfidentialERC20.sol is a from-scratch reference implementation for didactic purposes; for new dApps subclass ERC7984 instead.
Wrap / Unwrap: ERC-7984 ↔ ERC-20
ERC-7984 tokens commonly need to bridge in/out of plaintext ERC-20 liquidity. The pattern:
- Wrap — user deposits N plaintext ERC-20, receives encrypted balance of N in the ERC-7984 wrapper. The deposit amount is public (visible in the ERC-20
Transfer event) — privacy starts after wrapping.
- Unwrap — user burns an encrypted ERC-7984 balance, an async decryption resolves the cleartext, and the underlying ERC-20 is released to the recipient. The unwrap amount becomes public on settlement (it has to, to release ERC-20).
@openzeppelin/confidential-contracts ships ERC7984ERC20Wrapper (abstract — you subclass it):
import { ERC7984 } from "@openzeppelin/confidential-contracts/token/ERC7984/ERC7984.sol";
import { ERC7984ERC20Wrapper } from "@openzeppelin/confidential-contracts/token/ERC7984/extensions/ERC7984ERC20Wrapper.sol";
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { ZamaEthereumConfig } from "@fhevm/solidity/config/ZamaConfig.sol";
contract WrappedUSDC is ERC7984ERC20Wrapper, ZamaEthereumConfig {
constructor(IERC20 underlying)
ERC7984ERC20Wrapper(underlying)
ERC7984("Confidential USDC", "cUSDC", "")
{}
}
Real wrapper API (v0.4.0, verified):
wrap(address to, uint256 amount) returns (euint64) — pulls plaintext ERC-20 via safeTransferFrom, mints encrypted token; returns the encrypted amount actually wrapped (after rate division).
unwrap(address from, address to, euint64 amount) returns (bytes32 requestId) — burns encrypted balance and triggers async KMS decryption to release plaintext ERC-20 to to.
unwrap(address from, address to, externalEuint64 encryptedAmount, bytes calldata inputProof) returns (bytes32 requestId) — variant that takes a fresh externalEuint64 + ZK proof.
unwrapAmount(bytes32 requestId) returns (euint64) — view to read the encrypted amount handle for a pending unwrap.
- ERC-1363
onTransferReceived is supported, so ERC-1363 tokens can transferAndCall directly into the wrapper (one-tx wrap).
Frontend flow (relayer-sdk):
await usdc.approve(wrapper.address, 1_000_000n);
const wrappedHandle = await wrapper.wrap(user.address, 1_000_000n);
const handle = await wrapper.balanceOf(user.address);
const cleartext = await fhevm.userDecrypt([handle], sig);
const enc = fhevm.createEncryptedInput(wrapper.address, user.address);
enc.add64(50_000n);
const { handles, inputProof } = await enc.encrypt();
await wrapper.confidentialTransfer(recipient, handles[0], inputProof);
const requestId = await wrapper.unwrap(user.address, recipient, handles[0], inputProof);
Privacy boundary reminder: wrap/unwrap amounts are public by necessity (they cross a plaintext boundary). What stays private is balances and intra-system transfers between wraps. Document this in your dApp UI so users don't assume wrapping is fully confidential.
Anti-Patterns — STOP and Read Before Writing Code
20 anti-patterns are catalogued in anti-patterns/ANTI-PATTERNS.md, organized in three layers:
- Logic correctness (#1–9) — won't run / won't compile / undefined behavior.
- Operational correctness (#10–14) — works in isolation, breaks under realistic conditions.
- Privacy boundary (#15–20) — works and tests pass, but state still leaks via off-chain side channels (calldata, events, ordering, mempool, AA bundles).
Top 12 quick reference (full list + production-grade #15–20 in the catalog):
| # | Anti-Pattern | Why It's Wrong | Fix |
|---|
| 1 | if (FHE.lt(a,b)) | ebool is bytes32, not bool | FHE.select(FHE.lt(a,b), x, y) |
| 2 | Missing ACL after op | New handle has 0 permissions | FHE.allowThis() + FHE.allow() |
| 3 | FHE.decrypt(x) | Function doesn't exist | FHE.makePubliclyDecryptable(x) + async |
| 4 | FHE.div(enc, enc) | Encrypted divisor unsupported | FHE.div(enc, plaintext) |
| 5 | FHE.add on euint256 | No arithmetic for 256-bit | Use euint128 or smaller |
| 6 | Uninitialized euint64 | Default is invalid, not zero | FHE.asEuint64(0) + allowThis |
| 7 | fn(euint64 amount) | Skips ZKPoK validation | fn(externalEuint64, bytes proof) |
| 8 | Assume tx success = transfer success | Silent failure transfers 0 | Check effective result |
| 15 | FHE.asEuint(plaintextParam) | Trivial encryption — value is in calldata | externalEuintXX + fromExternal |
| 16 | emit Transfer(from, to, amount) | Events are public — leaks encrypted value | emit Transfer(from, to, type(uint256).max) |
| 17 | Cross-contract pass without allowTransient | Callee has no ACL | allowTransient(ct, callee) before call |
| 18 | Trust callback cleartext directly | Callback is public — anyone forges values | FHE.checkSignatures(handles, vals, sigs) |
Gas Budget (HCU)
Transaction limit: 20M HCU total, 5M HCU depth. Key costs (non-scalar, euint64):
add/sub: 162K — max ~123/tx
mul: 596K — max ~33/tx
select: 55K (constant) — cheap
trivialEncrypt/cast: 32 — free
Rule: use the smallest type that fits. Prefer scalar ops. Avoid rem.
Examples
| Contract | Key Patterns | File |
|---|
| EncryptedCounter | Input validation, ACL, basic ops | examples/EncryptedCounter.sol |
| ConfidentialERC20 | Silent failure, allowances, ERC-7984 | examples/ConfidentialERC20.sol |
| ConfidentialVoting | Encrypted booleans, homomorphic tally | examples/ConfidentialVoting.sol |
| BlindAuction | FHE.select for max, async reveal | examples/BlindAuction.sol |
| AgentRegistry | Multi-input, threshold checks | examples/AgentRegistry.sol |
| ConfidentialTreasury | Full lifecycle, guardian voting, balance check | examples/ConfidentialTreasury.sol |
examples/ConfidentialTreasury.test.ts — Complete test file (15 tests, verified passing) demonstrating all SKILL.md testing patterns.
Pre-Deployment Checklist
File Index
| Path | Load when |
|---|
references/encrypted-types.md | Need type details, casting, ranges |
references/fhe-operations.md | Need exact signatures or HCU costs |
references/access-control.md | Writing ACL, cross-contract, security, AA bundles |
references/input-validation.md | Handling user encrypted inputs |
references/decryption-patterns.md | Implementing reveal, callbacks, checkSignatures |
references/testing-guide.md | Setting up or writing Hardhat tests |
references/testing-foundry.md | Foundry (forge-fhevm) testing |
references/frontend-integration.md | Building frontend encryption/decryption |
anti-patterns/ANTI-PATTERNS.md | 20 anti-patterns: logic / operational / privacy boundary |
templates/contract-template.sol | Starting a new contract |
templates/hardhat.config.ts | Setting up Hardhat |
templates/test-template.ts | Starting a new test file |
examples/*.sol | Working reference implementations (6 contracts) |
examples/ConfidentialTreasury.test.ts | E2E-verified test pattern (15/15 passing) |
validation/prompts.md | 7 demo prompts + 8 adversarial trap prompts for testing agent error-prevention |
validation/agent-effectiveness.md | A/B validation report: 47 anti-patterns prevented, reproducibility protocol |
../../scripts/fhevm-lint.js (from repo root) | Static checker (12 rules, 0 deps) — run node scripts/fhevm-lint.js path/to/Contract.sol from the repo root. Catches the layer-3 privacy patterns Slither/Solhint/Mythril cannot. |