Skip to content

Lesson 1: Encrypted Access Control — The Vault

Duration: ~60 minutes | Prerequisites: Week 1 completed | Contract: src/EncryptedVault.sol


Learning Objectives

By the end of this lesson, you will:

  • Implement per-user encrypted state with mapping(address => euint64)
  • Master granular access control with FHE.allow, FHE.allowThis, and FHE.allowTransient
  • Understand why every FHE operation produces a new handle that must be re-authorized
  • Know the difference between contract-level and user-level permissions
  • Be able to build deposit functionality with proper ACL grants

1. The Problem

In Week 1, we built a single counter that anyone could modify. Real applications need per-user private state — each user should have their own encrypted balance that only they can see.

Consider a vault contract:

  • Users deposit encrypted amounts
  • Each user's balance is private (only they can decrypt it)
  • The contract owner can see the aggregate total (but not individual balances)
  • Withdrawals must check balances without revealing them

This requires mastering the FHEVM access control system.

2. The Contract: EncryptedVault.sol

Open src/EncryptedVault.sol. Here's the interface:

solidity
contract EncryptedVault is ZamaEthereumConfig {
    address public owner;
    mapping(address => euint64) private _balances;
    euint64 private _totalDeposits;

    function deposit(externalEuint64 encAmount, bytes calldata inputProof) external { ... }
    function withdraw(externalEuint64 encAmount, bytes calldata inputProof) external { ... }
    function getBalance() external view returns (euint64) { ... }
    function getTotalDeposits() external view returns (euint64) { ... }
}

Compared to FHECounter from Week 1, this contract introduces:

  • Per-user state — a mapping instead of a single variable
  • Multiple permission grants — different users get access to different values
  • Encrypted conditionals — the withdraw function uses FHE.le and FHE.select (covered in Lesson 2)

Let's walk through the key patterns.

3. Per-User Encrypted State

solidity
mapping(address => euint64) private _balances;

This is the core pattern. Each address maps to its own encrypted 64-bit unsigned integer. On-chain, these are bytes32 handles. Nobody can see the plaintext values by reading storage — the handles are meaningless without decryption permission.

Compare this to Week 1's single state variable:

Week 1 (Counter)Week 2 (Vault)
euint32 private _countmapping(address => euint64) private _balances
One value, shared by allOne value per user, each private
Any caller gets allowOnly the depositor gets allow

The deposit Function

solidity
function deposit(externalEuint64 encAmount, bytes calldata inputProof) external {
    euint64 amount = FHE.fromExternal(encAmount, inputProof);

    // Add to user's encrypted balance
    _balances[msg.sender] = FHE.add(_balances[msg.sender], amount);

    // Update aggregate total
    _totalDeposits = FHE.add(_totalDeposits, amount);

    // Grant permissions
    FHE.allowThis(_balances[msg.sender]);        // Contract can use it
    FHE.allow(_balances[msg.sender], msg.sender); // User can decrypt
    FHE.allowThis(_totalDeposits);                // Contract can use it
    FHE.allow(_totalDeposits, owner);             // Owner can decrypt aggregate
}

This function follows the same verify → compute → authorize pattern from Week 1, but with two crucial additions:

  1. Two separate state updates — the user's balance and the aggregate total are both encrypted and updated independently
  2. Targeted permissions — the user can only decrypt their own balance; only the owner can decrypt the total

Key insight: Every FHE.add() produces a new handle. The old handle's permissions don't transfer. You must re-authorize after every operation.

Why Re-Authorization Matters

Consider what happens without it:

solidity
// Transaction 1: Alice deposits 100
_balances[alice] = FHE.add(_balances[alice], amount);  // New handle = H1
FHE.allowThis(H1);  // Contract can use H1
FHE.allow(H1, alice);  // Alice can decrypt H1

// Transaction 2: Alice deposits 200
// FHE.add needs to READ _balances[alice] (which is H1)
// The contract must have permission on H1 — that's why allowThis was critical!
_balances[alice] = FHE.add(_balances[alice], amount);  // New handle = H2
// H2 is a DIFFERENT handle than H1
// Nobody has permission on H2 yet!
FHE.allowThis(H2);  // Now contract can use H2 next time
FHE.allow(H2, alice);  // Now Alice can decrypt H2

If you forget allowThis in Transaction 1, Transaction 2 reverts because the contract can't read its own state.

4. Deep Dive: Access Control

The ACL is the most important concept in FHEVM. Here's the full picture:

FHE.allowThis(value)

Grants the contract itself permission to use the encrypted value in future transactions. Without this:

// Transaction 1: deposit
_balances[msg.sender] = FHE.add(_balances[msg.sender], amount);
FHE.allowThis(_balances[msg.sender]); // ← MUST do this

// Transaction 2: another deposit
// FHE.add reads _balances[msg.sender] — this requires the contract to have permission!
_balances[msg.sender] = FHE.add(_balances[msg.sender], amount);

If you forget allowThis, the second transaction reverts because the contract can't read its own state.

FHE.allow(value, account)

Grants a specific address permission to decrypt the value off-chain. This is how you control who can see what:

solidity
FHE.allow(_balances[msg.sender], msg.sender); // Only the depositor can see their balance
FHE.allow(_totalDeposits, owner);              // Only the owner can see the total

This is where FHEVM's privacy model shines. In the vault:

  • Alice deposits 1000 → only Alice can decrypt her balance
  • Bob deposits 2000 → only Bob can decrypt his balance
  • The owner can decrypt the aggregate (3000) but cannot see individual balances
  • Nobody else can see anything

FHE.allowTransient(value, account)

Grants temporary permission that expires at the end of the current transaction. Useful for cross-contract calls where you need to pass an encrypted value to another contract, but don't want to leave a permanent permission.

Permission Model Summary

  ┌─────────────────────────────────────────────┐
  │             After FHE.add(a, b)              │
  │                                             │
  │  New handle = X                             │
  │                                             │
  │  Who can use X?                              │
  │  ├── Nobody (by default!)                    │
  │  │                                          │
  │  ├── FHE.allowThis(X)                       │
  │  │   → Contract can use X in future ops     │
  │  │                                          │
  │  ├── FHE.allow(X, alice)                    │
  │  │   → Alice can decrypt X off-chain        │
  │  │                                          │
  │  └── FHE.allowTransient(X, addr)            │
  │      → Temporary: only within this tx       │
  └─────────────────────────────────────────────┘

When to Use Each

FunctionUse CaseLifetime
FHE.allowThis(X)Contract needs to read/operate on X in a future transactionPermanent (until overwritten)
FHE.allow(X, addr)A user needs to decrypt X off-chainPermanent (until overwritten)
FHE.allowTransient(X, addr)Passing X to another contract within the same transactionCurrent transaction only

The ACL Checklist

Every time you write a function that produces a new encrypted value, ask yourself:

  1. Will the contract use this value later? → Call FHE.allowThis(value)
  2. Should a user be able to decrypt this? → Call FHE.allow(value, user)
  3. Am I passing this to another contract in the same tx? → Call FHE.allowTransient(value, contract)

5. Reading Encrypted State

The vault provides two view functions:

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

function getTotalDeposits() external view returns (euint64) {
    return _totalDeposits;
}

Both return encrypted handles, not plaintext values. The caller receives a bytes32 handle that they can only decrypt if they have been granted permission via FHE.allow.

  • When Alice calls getBalance(), she gets her handle. She was granted allow during deposit, so she can decrypt it.
  • When Bob calls getBalance(), he gets his own handle. He cannot decrypt Alice's.
  • When anyone calls getTotalDeposits(), they get the aggregate handle. Only the owner was granted allow, so only the owner can decrypt it.

6. Visualizing the Permission Graph

After Alice deposits 1000 and Bob deposits 2000:

  ┌─────────────────────────────────────────────────────────────┐
  │                     EncryptedVault                          │
  │                                                             │
  │  _balances[alice] = Handle_A                                │
  │    ├── allowThis → Vault contract can use Handle_A          │
  │    └── allow(alice) → Alice can decrypt Handle_A            │
  │                                                             │
  │  _balances[bob] = Handle_B                                  │
  │    ├── allowThis → Vault contract can use Handle_B          │
  │    └── allow(bob) → Bob can decrypt Handle_B                │
  │                                                             │
  │  _totalDeposits = Handle_T                                  │
  │    ├── allowThis → Vault contract can use Handle_T          │
  │    └── allow(owner) → Owner can decrypt Handle_T            │
  │                                                             │
  │  Alice CANNOT decrypt Handle_B or Handle_T                  │
  │  Bob CANNOT decrypt Handle_A or Handle_T                    │
  │  Owner CANNOT decrypt Handle_A or Handle_B                  │
  └─────────────────────────────────────────────────────────────┘

7. Common Mistakes

1. Forgetting allowThis After an Operation

solidity
// ❌ BROKEN — contract can't use balance next time
_balances[msg.sender] = FHE.add(_balances[msg.sender], amount);
// Missing: FHE.allowThis(_balances[msg.sender]);

Next transaction that reads _balances[msg.sender] will revert.

2. Granting Too-Broad Permissions

solidity
// ❌ BAD — anyone can decrypt the user's balance
FHE.allow(_balances[msg.sender], someOtherAddress);

Only grant decrypt permission to the user who owns the data.

3. Forgetting to Re-Authorize After Each Operation

solidity
// ❌ BROKEN — only authorizes after the last operation
_balances[msg.sender] = FHE.add(_balances[msg.sender], amount);
_totalDeposits = FHE.add(_totalDeposits, amount);
FHE.allowThis(_balances[msg.sender]);  // ✅
// Missing: FHE.allowThis(_totalDeposits);  ← OOPS

Each state variable that gets a new handle needs its own allowThis.


Key Concepts Introduced

ConceptWhat It Does
mapping(address => euint64)Per-user encrypted state
FHE.allowThis(value)Contract can reuse value in future transactions
FHE.allow(value, addr)Specific address can decrypt value off-chain
FHE.allowTransient(value, addr)Temporary permission within one transaction
New-handle ruleEvery FHE operation produces a new handle — must re-authorize

Key Takeaways

  1. Per-user encrypted state uses mapping(address => euint64) — each user gets their own private value
  2. Every FHE operation produces a new handle — permissions from the old handle don't carry over
  3. Always call allowThis so the contract can use the value in future transactions
  4. Use allow to let specific users decrypt — this is how you control who sees what
  5. The ACL is the privacy enforcement layer — without it, encrypted values are useless handles

Next: Lesson 2: FHE Patterns — Comparisons, Conditionals & Silent Failure — Learn FHE.select(), the full comparison table, and why FHE contracts don't revert.

Built for the Zama Developer Program — Bounty Track