Skip to main content

Learning Objectives

By the end of this module, you will:
  1. Design a confidential lending protocol with encrypted collateral and borrowing
  2. Implement encrypted order books for private trading
  3. Apply FHE to real-world DeFi use cases
  4. Handle complex multi-party encrypted interactions
  5. Understand the privacy vs. functionality trade-offs in DeFi

Why Confidential DeFi?

Decentralized finance has transformed how we think about financial services. Lending, trading, and asset management can now happen without intermediaries. But there’s a fundamental problem: everything is public. On a standard EVM blockchain, every transaction is visible to everyone:
The Public DeFi Problem:
  1. Front-running: MEV bots see your trade and execute ahead of you
  2. Sandwich attacks: Bots place orders before AND after yours, extracting value
  3. Liquidation hunting: Observers see when you’re close to liquidation
  4. Information asymmetry: Whales can see and exploit smaller traders
  5. Strategic copying: Competitors can copy your trading strategy
In traditional finance, trade privacy is the default. When you place a limit order at a brokerage, other participants don’t see your order until it’s executed. When you take out a loan at a bank, other customers don’t know your loan balance. FHE (Fully Homomorphic Encryption) brings this privacy to on-chain DeFi. With FHEVM, we can build lending protocols where collateral and borrow amounts are encrypted, and order books where prices and quantities are hidden until execution.

Confidential Lending Protocol

Architecture Overview

A lending protocol has a straightforward lifecycle:
1

Deposit

User deposits collateral (encrypted amount)
2

Borrow

User borrows against collateral (encrypted amount)
3

Check Collateralization

Protocol checks collateralization ratio (FHE comparison)
4

Interest Accrual

Interest accrues over time (FHE arithmetic on encrypted balance)
5

Repay

User repays borrow (FHE subtraction)
6

Withdraw

User withdraws collateral (with sufficiency check)
In our simplified model:
  • Collateral is tracked per user as euint64
  • Borrow balance is tracked per user as euint64
  • LTV (Loan-to-Value) is 50%: you can borrow up to half your collateral
  • Interest is 10% per accrual period (simplified)

Encrypted Collateral Management

Each user has two encrypted balances stored in mappings:
mapping(address => euint64) private _collateral;
mapping(address => euint64) private _borrowBalance;
mapping(address => bool) private _initialized;
Both are initialized to encrypted zero when a user first interacts with the protocol:
function _initUser(address user) internal {
    if (!_initialized[user]) {
        _collateral[user] = FHE.asEuint64(0);
        FHE.allowThis(_collateral[user]);
        FHE.allow(_collateral[user], user);

        _borrowBalance[user] = FHE.asEuint64(0);
        FHE.allowThis(_borrowBalance[user]);
        FHE.allow(_borrowBalance[user], user);

        _initialized[user] = true;
    }
}
Key points:
  • FHE.allowThis() grants the contract permission to operate on the encrypted value
  • FHE.allow(handle, user) grants the user permission to decrypt their own balance
  • The _initialized flag prevents re-initialization (which would reset balances)

Depositing Collateral

Depositing adds encrypted collateral to the user’s balance:
function deposit(externalEuint64 encAmount, bytes calldata inputProof) external {
    _initUser(msg.sender);

    euint64 amount = FHE.fromExternal(encAmount, inputProof);

    _collateral[msg.sender] = FHE.add(_collateral[msg.sender], amount);
    FHE.allowThis(_collateral[msg.sender]);
    FHE.allow(_collateral[msg.sender], msg.sender);

    emit Deposited(msg.sender);
}
The deposit amount is encrypted end-to-end:
  1. The user encrypts the amount client-side using fhevm.createEncryptedInput()
  2. The encrypted value and proof are sent to the contract
  3. FHE.fromExternal() converts the external input to an internal euint64
  4. FHE.add() adds it to the existing collateral (both encrypted)
  5. ACL is updated for the new handle (every FHE operation produces a new handle)
Important: After every FHE operation that produces a new handle, you must call FHE.allowThis() and FHE.allow() again. The ACL is per-handle, not per-slot.

The Collateralization Check with FHE

The core innovation is the on-chain collateralization check using encrypted values:
function borrow(externalEuint64 encAmount, bytes calldata inputProof) external {
    _initUser(msg.sender);

    euint64 borrowAmount = FHE.fromExternal(encAmount, inputProof);

    // Total borrow after this request
    euint64 newBorrowBalance = FHE.add(_borrowBalance[msg.sender], borrowAmount);

    // 50% LTV check: newBorrowBalance <= collateral / 2
    euint64 maxBorrow = FHE.div(_collateral[msg.sender], 2);
    ebool withinLimit = FHE.le(newBorrowBalance, maxBorrow);

    // If within limit, apply the borrow; otherwise keep existing balance
    _borrowBalance[msg.sender] = FHE.select(
        withinLimit,
        newBorrowBalance,
        _borrowBalance[msg.sender]
    );
    FHE.allowThis(_borrowBalance[msg.sender]);
    FHE.allow(_borrowBalance[msg.sender], msg.sender);

    emit Borrowed(msg.sender);
}
Let’s trace through the logic:
  1. borrowAmount is the encrypted amount the user wants to borrow
  2. newBorrowBalance adds this to any existing borrow (also encrypted)
  3. maxBorrow = collateral / 2 computes the maximum allowed borrow (encrypted division)
  4. withinLimit = FHE.le(newBorrowBalance, maxBorrow) checks the LTV constraint — this returns an ebool (encrypted boolean)
  5. FHE.select(withinLimit, newBorrowBalance, _borrowBalance[msg.sender]) conditionally updates the borrow balance
The critical insight: we cannot use if/else on encrypted booleans. The result of FHE.le() is encrypted — we don’t know if it’s true or false. Instead, we use FHE.select() to pick between two encrypted values based on the encrypted condition.

The LastError Pattern

In traditional Solidity, a failed borrow would revert. But reverting leaks information:
Problem with revert:
- User tries to borrow X
- Transaction reverts with "Insufficient collateral"
- Observer now knows: user's collateral < 2X
- This narrows down the collateral range
Solution: silent failure
- User tries to borrow X
- Borrow balance stays the same (FHE.select picks the old value)
- No revert, no information leaked
- User checks their own balance to see if it changed
This is the LastError pattern: instead of reverting on failure, the contract silently does nothing. The user can decrypt their own balance to check whether the operation succeeded. In our implementation, the Borrowed event is always emitted regardless of success. An observer only sees that a borrow attempt was made, but not whether it succeeded or how much was borrowed.

Interest Accrual on Encrypted Balances

Interest is calculated using FHE arithmetic:
function accrueInterest(address user) external onlyOwner {
    _initUser(user);

    euint64 interest = FHE.div(_borrowBalance[user], 10);
    _borrowBalance[user] = FHE.add(_borrowBalance[user], interest);
    FHE.allowThis(_borrowBalance[user]);
    FHE.allow(_borrowBalance[user], user);

    emit InterestAccrued(user);
}
This adds 10% interest: interest = borrowBalance / 10, then borrowBalance += interest. Key observations:
  • The interest amount is never revealed — it’s computed entirely on encrypted data
  • FHE.div() performs integer division on encrypted values
  • The owner (or a keeper) triggers interest accrual per user
  • In production, you would batch this across all users or use a per-block accrual model

Withdrawal with Collateral Sufficiency Check

Withdrawing collateral requires checking that the remaining collateral still covers the borrow:
function withdraw(externalEuint64 encAmount, bytes calldata inputProof) external {
    _initUser(msg.sender);

    euint64 withdrawAmount = FHE.fromExternal(encAmount, inputProof);

    // Remaining collateral after withdrawal
    euint64 remaining = FHE.sub(_collateral[msg.sender], withdrawAmount);

    // Check: remaining >= 2 * borrowBalance
    euint64 requiredCollateral = FHE.mul(_borrowBalance[msg.sender], 2);
    ebool isSafe = FHE.ge(remaining, requiredCollateral);

    // Also check withdrawAmount <= collateral (prevent underflow)
    ebool hasEnough = FHE.ge(_collateral[msg.sender], withdrawAmount);
    ebool canWithdraw = FHE.and(isSafe, hasEnough);

    _collateral[msg.sender] = FHE.select(
        canWithdraw,
        remaining,
        _collateral[msg.sender]
    );
    FHE.allowThis(_collateral[msg.sender]);
    FHE.allow(_collateral[msg.sender], msg.sender);

    emit Withdrawn(msg.sender);
}
Two checks are combined with FHE.and():
  1. Safety check: remaining >= 2 * borrowBalance ensures 50% LTV is maintained
  2. Underflow check: withdrawAmount <= collateral prevents FHE subtraction underflow
If either check fails, the collateral remains unchanged (LastError pattern).

Repayment

Repayment reduces the borrow balance:
function repay(externalEuint64 encAmount, bytes calldata inputProof) external {
    _initUser(msg.sender);

    euint64 repayAmount = FHE.fromExternal(encAmount, inputProof);

    // Cap repayment to the current borrow balance
    euint64 actualRepay = FHE.min(repayAmount, _borrowBalance[msg.sender]);

    _borrowBalance[msg.sender] = FHE.sub(_borrowBalance[msg.sender], actualRepay);
    FHE.allowThis(_borrowBalance[msg.sender]);
    FHE.allow(_borrowBalance[msg.sender], msg.sender);

    emit Repaid(msg.sender);
}
FHE.min() caps the repayment to the borrow balance. If the user tries to repay more than they owe, only the owed amount is subtracted. This prevents underflow without revealing the borrow balance.

Encrypted Order Book

Why Encrypted Order Books Matter

Traditional on-chain order books (like on Serum or dYdX v3) expose every order to everyone:
Traditional Order Book (public):
  BUY  100 units @ $50  (Alice)
  BUY   50 units @ $48  (Bob)
  SELL  75 units @ $52  (Carol)
  SELL 200 units @ $55  (Dave)

Problems:
- MEV bots see Alice's large buy order and front-run it
- Traders see the order book depth and manipulate prices
- Market makers' strategies are fully visible
- Dark pools exist in TradFi precisely to avoid this
Encrypted Order Book:
  BUY  [encrypted] units @ [encrypted]  (Alice)
  BUY  [encrypted] units @ [encrypted]  (Bob)
  SELL [encrypted] units @ [encrypted]  (Carol)
  SELL [encrypted] units @ [encrypted]  (Dave)

What is public: order exists, trader address, buy/sell direction
What is private: price, amount, fill amount

Order Structure

Each order stores encrypted price and amount alongside public metadata:
struct Order {
    address trader;
    euint64 price;
    euint64 amount;
    bool isBuy;
    bool isActive;
}

mapping(uint256 => Order) private _orders;
uint256 public orderCount;
uint256 public activeOrderCount;
uint256 public constant MAX_ACTIVE_ORDERS = 50;
Design decisions:
  • trader is public: Addresses are always visible on-chain (transaction sender)
  • isBuy is public: The direction (buy vs sell) must be known to match orders
  • isActive is public: Needed for validation without FHE overhead
  • price and amount are encrypted: These are the sensitive values
  • MAX_ACTIVE_ORDERS = 50: Prevents DoS from unbounded order storage

Order Submission

Buy and sell order submission follow the same pattern:
function submitBuyOrder(
    externalEuint64 encPrice,
    bytes calldata priceProof,
    externalEuint64 encAmount,
    bytes calldata amountProof
) external {
    require(activeOrderCount < MAX_ACTIVE_ORDERS, "Too many active orders");

    euint64 price = FHE.fromExternal(encPrice, priceProof);
    euint64 amount = FHE.fromExternal(encAmount, amountProof);

    uint256 orderId = orderCount++;
    _orders[orderId].trader = msg.sender;
    _orders[orderId].price = price;
    _orders[orderId].amount = amount;
    _orders[orderId].isBuy = true;
    _orders[orderId].isActive = true;

    FHE.allowThis(_orders[orderId].price);
    FHE.allow(_orders[orderId].price, msg.sender);
    FHE.allowThis(_orders[orderId].amount);
    FHE.allow(_orders[orderId].amount, msg.sender);

    activeOrderCount++;

    emit OrderSubmitted(orderId, msg.sender, true);
}
Note the dual encrypted inputs: both price and amount are encrypted separately, each with its own proof. The function takes four parameters for the encrypted data: two handles and two proofs. Both receive ACL grants for the contract and the trader.

Order Matching Logic

The matching function is the heart of the order book. It compares a buy order against a sell order:
function matchOrders(uint256 buyOrderId, uint256 sellOrderId) external onlyOwner {
    require(_orders[buyOrderId].isActive, "Buy order not active");
    require(_orders[sellOrderId].isActive, "Sell order not active");
    require(_orders[buyOrderId].isBuy, "Not a buy order");
    require(!_orders[sellOrderId].isBuy, "Not a sell order");

    // Check if buy price >= sell price
    ebool canMatch = FHE.ge(
        _orders[buyOrderId].price,
        _orders[sellOrderId].price
    );

    // Fill amount = min of both order amounts
    euint64 fillAmount = FHE.min(
        _orders[buyOrderId].amount,
        _orders[sellOrderId].amount
    );

    // If prices are incompatible, fill becomes 0
    euint64 actualFill = FHE.select(canMatch, fillAmount, FHE.asEuint64(0));

    // Update remaining amounts
    _orders[buyOrderId].amount = FHE.sub(_orders[buyOrderId].amount, actualFill);
    _orders[sellOrderId].amount = FHE.sub(_orders[sellOrderId].amount, actualFill);

    // Update ACL for new handles
    FHE.allowThis(_orders[buyOrderId].amount);
    FHE.allow(_orders[buyOrderId].amount, _orders[buyOrderId].trader);
    FHE.allowThis(_orders[sellOrderId].amount);
    FHE.allow(_orders[sellOrderId].amount, _orders[sellOrderId].trader);

    emit OrderMatched(buyOrderId, sellOrderId);
}
Step-by-step breakdown:
1

Price comparison

FHE.ge(buyPrice, sellPrice) checks if the buy price is at least as high as the sell price. This returns an ebool — we don’t know the result.
2

Fill calculation

FHE.min(buyAmount, sellAmount) determines how much can be filled. If one order is for 100 units and the other for 60, the fill is 60.
3

Conditional fill

FHE.select(canMatch, fillAmount, 0) makes the fill 0 if prices are incompatible. This is the key privacy mechanism — the fill either happens or it doesn’t, and nobody can tell which.
4

Amount update

FHE.sub(amount, actualFill) reduces both orders by the fill amount. If actualFill is 0 (incompatible prices), the subtraction has no effect.
5

ACL refresh

New handles from FHE.sub() need fresh ACL grants.

What Is Public vs. Private

This is a critical distinction for any confidential DeFi protocol:
InformationVisibilityWhy
Order existsPublicTransaction is on-chain
Trader addressPublicmsg.sender is always visible
Buy/sell directionPublicStored as plaintext bool for matching efficiency
Order pricePrivateEncrypted euint64
Order amountPrivateEncrypted euint64
Fill amountPrivateComputed with FHE.min and FHE.select
Whether match succeededPrivateactualFill could be 0 or non-zero; only traders know
Order cancellationPublicChanges isActive flag
The OrderMatched event is emitted regardless of whether the match actually filled. An observer sees that a match was attempted between two order IDs, but not whether it succeeded or how much was filled.

Order Cancellation

Traders can cancel their own orders:
function cancelOrder(uint256 orderId) external {
    require(orderId < orderCount, "Invalid order");
    require(_orders[orderId].isActive, "Order not active");
    require(_orders[orderId].trader == msg.sender, "Not your order");

    _orders[orderId].isActive = false;
    activeOrderCount--;

    emit OrderCancelled(orderId, msg.sender);
}
Cancellation is a plaintext operation — it only flips a boolean. The encrypted price and amount remain in storage but are no longer matchable.

Privacy Trade-offs in DeFi

What You CAN Keep Private

With FHE, the following can remain encrypted:
  • Amounts: Collateral deposits, borrow amounts, order quantities, fill sizes
  • Prices: Limit order prices, liquidation thresholds, interest amounts
  • Balances: User balances, collateral ratios, debt levels
  • Comparisons: Whether a borrow is within LTV, whether orders matched

What You CANNOT Hide

Even with FHE, some information is inherently public on any blockchain:
  • Addresses: msg.sender is always visible. Everyone knows who is interacting with the protocol.
  • Function calls: Which function was called and when. An observer knows you called borrow() even if they don’t know the amount.
  • Timing: When you placed an order, when you repaid, how frequently you interact.
  • Gas usage: Different FHE operations use different gas amounts, which can sometimes leak information about the code path taken.
  • Transaction count: How many orders a trader has placed.

Hybrid Approaches

In practice, confidential DeFi protocols use a hybrid approach:
Hybrid Privacy Model:
- Encrypted: amounts, prices, balances (the "what")
- Public: addresses, function calls, timing (the "who" and "when")
- Semi-public: aggregated metrics (total TVL, order count)
This is analogous to traditional finance:
  • Your bank knows your balance (encrypted on-chain)
  • The public knows you have a bank account (address interaction)
  • Aggregate statistics are published (total deposits)

Gas Costs and Optimization

FHE operations are significantly more expensive than plaintext operations:
Approximate FHE Gas Costs (relative):
- FHE.add()     ~50,000 gas
- FHE.sub()     ~50,000 gas
- FHE.mul()     ~100,000 gas
- FHE.div()     ~150,000 gas
- FHE.le()      ~50,000 gas
- FHE.select()  ~50,000 gas
- FHE.min()     ~100,000 gas

A single borrow() call in ConfidentialLending:
- FHE.fromExternal()  ~100,000
- FHE.add()           ~50,000
- FHE.div()           ~150,000
- FHE.le()            ~50,000
- FHE.select()        ~50,000
- ACL operations       ~50,000
Total:                ~450,000 gas

Compare to a plaintext lending protocol:
- SSTORE + arithmetic  ~30,000 gas
This 10-15x gas overhead is the cost of privacy. Optimization strategies include:
  • Batching operations (e.g., accrue interest for multiple users in one transaction)
  • Reducing FHE operations per function call
  • Using smaller encrypted types (euint8, euint16) where the value range allows
  • Caching intermediate FHE results across function calls

Production Considerations

Compliance

A common concern with privacy protocols is regulatory compliance. FHE-based DeFi can support compliance through:
  1. KYC gates: Require address whitelisting before interaction (plaintext check before encrypted operations)
  2. Auditor access: Grant specific addresses ACL access to encrypted balances via FHE.allow(handle, auditor)
  3. Threshold reporting: Use FHE comparison to flag large transactions without revealing exact amounts
  4. Selective disclosure: Users can choose to make their own balances publicly decryptable

Liquidation Challenges

Liquidation in confidential lending is fundamentally harder:
Traditional Lending:
- Anyone can check if a position is undercollateralized
- Liquidation bots monitor all positions in real-time
- Immediate liquidation when health factor < 1
Confidential Lending:
- Nobody can see collateral or borrow amounts
- Cannot run off-chain health checks
- Liquidation must be triggered differently

Possible approaches:
1. Self-reporting: Users call a function that checks their own health factor using FHE
2. Keeper incentives: A keeper calls checkHealth(user) which does the FHE check on-chain
3. Time-based checks: Interest accrual also checks health factor
4. Threshold decryption: After a certain condition, the health factor is made publicly decryptable

Summary

Confidential lending

Encrypts collateral and borrow balances, enforcing LTV with FHE.le() and FHE.select()

Encrypted order books

Hide prices and amounts while allowing on-chain matching with FHE.ge() and FHE.min()

LastError pattern

Avoids information leakage by using FHE.select() instead of revert

Privacy trade-offs

Amounts and prices can be private; addresses, timing, and function calls cannot

Gas costs

10-15x higher than plaintext DeFi — optimization matters

Real-world DeFi

Eliminating front-running, MEV extraction, and information asymmetry
Production Example: Zaiffer Protocol (a Zama + PyratzLabs joint venture) is building ZaifferSwaps (MEV-protected trading with encrypted order amounts) and ZaifferYields (confidential yield vaults) using the exact patterns covered in this module. Zama’s 2026 roadmap includes cUSDT/cUSDC/cETH yield integration with AAVE and Morpho — demonstrating real market demand for confidential DeFi. The ERC-7984 standard (co-developed by Zama and OpenZeppelin) formalizes the confidential token interface that underpins these products.

Reference Contracts

  • ConfidentialLending.sol (contracts/18-confidential-defi/ConfidentialLending.sol:1) — Privacy-preserving lending protocol
  • EncryptedOrderBook.sol (contracts/18-confidential-defi/EncryptedOrderBook.sol:1) — Order book with encrypted prices and amounts

Next Steps

Next: Module 19: Capstone — Confidential DAO →

Build docs developers (and LLMs) love