Lesson 3: Hello FHE — Your First Encrypted Contract
Duration: ~60 minutes | Prerequisites: Lesson 2: Environment Setup | Contract: src/FHECounter.sol
Learning Objectives
By the end of this lesson, you will:
- Understand the
FHECountercontract line by line - Know how encrypted types (
euint32,externalEuint32) work in practice - Master the encrypt → compute → authorize → decrypt flow
- Write Forge tests for FHE contracts using mock helpers
- Be able to add new FHE operations to an existing contract
1. The Contract
Open src/FHECounter.sol. This is identical to the Hardhat template's FHECounter.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {FHE, euint32, externalEuint32} from "@fhevm/solidity/lib/FHE.sol";
import {ZamaEthereumConfig} from "@fhevm/solidity/config/ZamaConfig.sol";
contract FHECounter is ZamaEthereumConfig {
euint32 private _count;
function getCount() external view returns (euint32) {
return _count;
}
function increment(externalEuint32 inputEuint32, bytes calldata inputProof) external {
euint32 encryptedEuint32 = FHE.fromExternal(inputEuint32, inputProof);
_count = FHE.add(_count, encryptedEuint32);
FHE.allowThis(_count);
FHE.allow(_count, msg.sender);
}
function decrement(externalEuint32 inputEuint32, bytes calldata inputProof) external {
euint32 encryptedEuint32 = FHE.fromExternal(inputEuint32, inputProof);
_count = FHE.sub(_count, encryptedEuint32);
FHE.allowThis(_count);
FHE.allow(_count, msg.sender);
}
}Let's break it down.
2. Line-by-Line Walkthrough
Imports
import {FHE, euint32, externalEuint32} from "@fhevm/solidity/lib/FHE.sol";FHE— The main library. All encrypted operations go throughFHE.add(),FHE.sub(),FHE.allow(), etc.euint32— An encrypted unsigned 32-bit integer. Stored asbytes32on-chain (a handle pointing to the ciphertext in the coprocessor).externalEuint32— An "unverified" encrypted input from a user. Must be validated withFHE.fromExternal().
import {ZamaEthereumConfig} from "@fhevm/solidity/config/ZamaConfig.sol";ZamaEthereumConfig— Abstract contract whose constructor callsFHE.setCoprocessor()with the correct addresses for the current chain (mainnet, Sepolia, or local).
State Variable
euint32 private _count;This is the encrypted counter. It's stored as bytes32(0) initially (uninitialized). The FHE library handles uninitialized values gracefully — FHE.add(uninitialized, x) treats uninitialized as zero.
Reading the Count
function getCount() external view returns (euint32) {
return _count;
}This returns the encrypted count. The caller gets a bytes32 handle, not a plaintext number. To see the actual value, the caller must decrypt it off-chain (and must have been granted permission via FHE.allow).
Increment
function increment(externalEuint32 inputEuint32, bytes calldata inputProof) external {
// Step 1: Verify the encrypted input
euint32 encryptedEuint32 = FHE.fromExternal(inputEuint32, inputProof);
// Step 2: Add the encrypted value to the counter
_count = FHE.add(_count, encryptedEuint32);
// Step 3: Grant permissions on the new encrypted result
FHE.allowThis(_count); // Contract can use _count in future operations
FHE.allow(_count, msg.sender); // Caller can decrypt _count
}Why three steps? Every FHE operation produces a new encrypted handle. The old _count handle and the new one are different values. Permissions don't carry over — you must re-authorize after each operation.
Decrement
function decrement(externalEuint32 inputEuint32, bytes calldata inputProof) external {
euint32 encryptedEuint32 = FHE.fromExternal(inputEuint32, inputProof);
_count = FHE.sub(_count, encryptedEuint32);
FHE.allowThis(_count);
FHE.allow(_count, msg.sender);
}Same pattern as increment, but uses FHE.sub.
3. The Test
Open test/solutions/FHECounter.ts:
describe("FHECounter", function () {
it("increments by one", async function () {
const encryptedOne = await fhevm
.createEncryptedInput(counterAddress, signers.alice.address)
.add32(1)
.encrypt();
await counter.connect(signers.alice).increment(encryptedOne.handles[0], encryptedOne.inputProof);
});
});address public alice;
beforeEach(async function () { const factory = await ethers.getContractFactory("FHECounter"); counter = await factory.deploy(); counterAddress = await counter.getAddress(); });
it("increments by one", async function () { const encryptedOne = await fhevm .createEncryptedInput(counterAddress, signers.alice.address) .add32(1) .encrypt();
await counter.connect(signers.alice).increment(encryptedOne.handles[0], encryptedOne.inputProof);
const clearCount = await fhevm.userDecryptEuint(
FhevmType.euint32,
await counter.getCount(),
counterAddress,
signers.alice,
);
expect(clearCount).to.equal(1n);
}); });
### The Test Pattern
This is the core encrypted test loop you now run in Hardhat:
| Step | Hardhat |
|------|---------|
| Encrypt | `fhevm.createEncryptedInput(addr, user).add32(1).encrypt()` |
| Call | `contract.connect(alice).increment(handles[0], inputProof)` |
| Decrypt | `fhevm.userDecryptEuint(euint32, ct, addr, signer)` |
### What happens under the hood in mock mode
When you call `createEncryptedInput(...).add32(1).encrypt()`:
- Hardhat creates an encrypted input payload bound to a specific contract and signer
- The result contains a handle plus an input proof
When the contract calls `FHE.fromExternal(handle, proof)`:
- The handle is verified and converted into an internal encrypted value
When the contract calls `FHE.add(_count, encryptedValue)`:
- The coprocessor path performs the encrypted addition and returns a new encrypted handle
When you call `fhevm.userDecryptEuint(...)`:
- The runtime checks permissions and decrypts the value for the authorized signer
## 4. Run the Tests
```bash
npm run test:week1All 5 tests should pass:
test_initialCountIsZero— Counter starts at bytes32(0)test_incrementByOne— Increment by 1, decrypt to verifytest_decrementByOne— Increment then decrement, verify returns to 0test_multipleIncrements— 5 + 3 = 8test_differentUsersCanIncrement— Alice adds 10, Bob adds 20, total is 30
5. Exercise: Add a Multiply Function
Now it's your turn. Try adding a multiply function to FHECounter.sol:
function multiply(externalEuint32 inputEuint32, bytes calldata inputProof) external {
euint32 encryptedEuint32 = FHE.fromExternal(inputEuint32, inputProof);
_count = FHE.mul(_count, encryptedEuint32);
FHE.allowThis(_count);
FHE.allow(_count, msg.sender);
}Then write a test:
it("multiplies the count", async function () {
const five = await fhevm.createEncryptedInput(counterAddress, signers.alice.address).add32(5).encrypt();
await counter.connect(signers.alice).increment(five.handles[0], five.inputProof);
const three = await fhevm.createEncryptedInput(counterAddress, signers.alice.address).add32(3).encrypt();
await counter.connect(signers.alice).multiply(three.handles[0], three.inputProof);
const clearCount = await fhevm.userDecryptEuint(
FhevmType.euint32,
await counter.getCount(),
counterAddress,
signers.alice,
);
expect(clearCount).to.equal(15n);
});Notice how the pattern is always the same: encrypt → call → decrypt → assert. This is the rhythm of FHE testing.
6. Common Mistakes
Forgetting
FHE.allowThis()— If you don't callallowThisafter an operation, the contract cannot use the result in future operations. The next call that reads_countwill revert.Forgetting
FHE.allow()— If you don't allow the caller, they cannot decrypt the result off-chain.Using
externalEuint32withoutfromExternal— External types are unverified. Always validate withFHE.fromExternal(handle, proof).Expecting plaintext returns —
getCount()returns an encrypted handle, not a number. You must decrypt off-chain.
Key Concepts Introduced
| Concept | What It Does |
|---|---|
euint32 | Encrypted unsigned 32-bit integer (stored as bytes32 handle) |
externalEuint32 | Unverified encrypted input from user |
FHE.fromExternal() | Verify and convert external input to internal type |
FHE.add() / FHE.sub() | Encrypted arithmetic |
FHE.allowThis() | Grant contract permission to use a value |
FHE.allow() | Grant a user permission to decrypt a value |
ZamaEthereumConfig | Auto-configures coprocessor addresses for current chain |
Key Takeaways
- The FHE pattern is always: verify input → compute on ciphertext → authorize → decrypt off-chain
- Every FHE operation produces a new handle — you must re-authorize after each operation
- The test pattern mirrors the contract pattern: encrypt → call → decrypt → assert
- Mock mode makes all of this fast and deterministic — no real cryptography during testing
Next: Homework: EncryptedPoll — Build an encrypted voting contract from scratch.