Skip to main content

Overview

Learn how to implement a sealed-bid auction using FHEVM. Bids remain encrypted throughout the auction, eliminating front-running and enabling fair price discovery without a reveal phase.
Level: Advanced
Duration: 4 hours
Prerequisites: Modules 01-12

Learning Objectives

By the end of this module, you will be able to:
  1. Design a sealed-bid auction contract with encrypted bids
  2. Track the highest bid using encrypted comparisons (FHE.gt())
  3. Update the winning bid atomically with FHE.select()
  4. Implement time-bounded bidding with reveal/claim phases
  5. Handle bid deposits and refunds securely
  6. Understand the privacy advantages over traditional auction designs

The Problem with Public Auctions

Auctions on public blockchains suffer from a fundamental problem: all bids are visible. This enables:
  • Front-running: MEV bots can see a bid in the mempool and outbid it
  • Bid sniping: Waiting until the last second to bid just above the current highest
  • Collusion: Bidders can coordinate based on visible bid history
A sealed-bid auction using FHEVM solves these problems by keeping all bids encrypted until the auction ends.

Auction Design

Auction Lifecycle:
1. Owner creates auction (item, duration, reserve price) via createAuction()
2. Bidding phase: bidders submit encrypted bids + ETH deposit
3. Each bid is compared against current highest (encrypted)
4. Bidding phase ends (deadline passes)
5. Owner calls endAuction() — uses FHE.makePubliclyDecryptable()
6. Winner and winning bid are revealed on-chain
7. Losers call withdrawDeposit() to reclaim ETH

Key Design Decisions

Multi-Auction Support

The contract supports multiple auctions via an auctionId system. Each auction has its own state:
struct Auction {
    string item;
    uint256 deadline;
    uint64 reservePrice;
    bool ended;
    bool finalized;
    address[] bidders;
}

mapping(uint256 => Auction) public auctions;
mapping(uint256 => euint64) internal _highestBid;
mapping(uint256 => eaddress) internal _highestBidder;

ETH Deposits

Bidders must deposit ETH along with their encrypted bid. This ensures the winner can actually pay.
Privacy consideration: The deposit amount is plaintext (ETH transfers are visible). For maximum privacy, all bidders should deposit the same fixed amount.

Encrypted Highest Bid Tracking

We maintain an euint64 for the current highest bid per auction. On each new bid, we compare and update:
ebool isHigher = FHE.gt(newBid, _highestBid[auctionId]);
_highestBid[auctionId] = FHE.select(isHigher, newBid, _highestBid[auctionId]);

Winner Tracking with eaddress

We track the highest bidder’s address in encrypted form using eaddress:
_highestBidder[auctionId] = FHE.select(
    isHigher,
    FHE.asEaddress(msg.sender),
    _highestBidder[auctionId]
);
This is a key pattern: FHE.asEaddress(msg.sender) converts a plaintext address into an encrypted eaddress, and FHE.select() conditionally picks between encrypted addresses.

Complete SealedBidAuction Contract

SealedBidAuction.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {FHE, euint64, externalEuint64, ebool, eaddress} from "@fhevm/solidity/lib/FHE.sol";
import {ZamaEthereumConfig} from "@fhevm/solidity/config/ZamaConfig.sol";

contract SealedBidAuction is ZamaEthereumConfig {
    struct Auction {
        string item;
        uint256 deadline;
        uint64 reservePrice;
        bool ended;
        bool finalized;
        address[] bidders;
    }

    mapping(uint256 => Auction) public auctions;
    mapping(uint256 => mapping(address => euint64)) internal _bids;
    mapping(uint256 => mapping(address => bool)) public hasBid;
    mapping(uint256 => mapping(address => uint256)) public deposits;
    mapping(uint256 => euint64) internal _highestBid;
    mapping(uint256 => eaddress) internal _highestBidder;

    // Finalization results (public after reveal)
    mapping(uint256 => address) public winner;
    mapping(uint256 => uint64) public winningBidAmount;

    uint256 public auctionCount;
    address public owner;

    event AuctionCreated(uint256 indexed auctionId, string item, uint256 deadline, uint64 reservePrice);
    event BidPlaced(uint256 indexed auctionId, address indexed bidder);
    event AuctionEnded(uint256 indexed auctionId);
    event AuctionFinalized(uint256 indexed auctionId, address winner, uint64 winningBid);
    event DepositWithdrawn(uint256 indexed auctionId, address indexed bidder, uint256 amount);

    modifier onlyOwner() {
        require(msg.sender == owner, "Not the owner");
        _;
    }

    constructor() {
        owner = msg.sender;
    }

    function createAuction(string calldata item, uint256 duration, uint64 reservePrice) external onlyOwner {
        uint256 id = auctionCount++;
        auctions[id].item = item;
        auctions[id].deadline = block.timestamp + duration;
        auctions[id].reservePrice = reservePrice;
        _highestBid[id] = FHE.asEuint64(0);
        FHE.allowThis(_highestBid[id]);

        _highestBidder[id] = FHE.asEaddress(address(0));
        FHE.allowThis(_highestBidder[id]);

        emit AuctionCreated(id, item, auctions[id].deadline, reservePrice);
    }

    function bid(uint256 auctionId, externalEuint64 encBid, bytes calldata inputProof) external payable {
        require(auctionId < auctionCount, "Invalid auction");
        require(block.timestamp <= auctions[auctionId].deadline, "Bidding ended");
        require(!auctions[auctionId].ended, "Auction ended");
        require(!hasBid[auctionId][msg.sender], "Already bid");
        require(msg.value > 0, "Must deposit ETH");

        euint64 newBid = FHE.fromExternal(encBid, inputProof);

        // Store the bid
        _bids[auctionId][msg.sender] = newBid;
        FHE.allowThis(_bids[auctionId][msg.sender]);
        FHE.allow(_bids[auctionId][msg.sender], msg.sender);

        // Track ETH deposit
        deposits[auctionId][msg.sender] = msg.value;

        // Update highest bid using encrypted select
        ebool isHigher = FHE.gt(newBid, _highestBid[auctionId]);
        _highestBid[auctionId] = FHE.select(isHigher, newBid, _highestBid[auctionId]);
        FHE.allowThis(_highestBid[auctionId]);

        // Update highest bidder using eaddress select
        _highestBidder[auctionId] = FHE.select(isHigher, FHE.asEaddress(msg.sender), _highestBidder[auctionId]);
        FHE.allowThis(_highestBidder[auctionId]);

        hasBid[auctionId][msg.sender] = true;
        auctions[auctionId].bidders.push(msg.sender);

        emit BidPlaced(auctionId, msg.sender);
    }

    function endAuction(uint256 auctionId) external onlyOwner {
        require(auctionId < auctionCount, "Invalid auction");
        require(block.timestamp > auctions[auctionId].deadline, "Not yet ended");
        require(!auctions[auctionId].ended, "Already ended");

        auctions[auctionId].ended = true;

        // Make highest bid publicly decryptable for result reveal
        FHE.makePubliclyDecryptable(_highestBid[auctionId]);
        FHE.makePubliclyDecryptable(_highestBidder[auctionId]);

        emit AuctionEnded(auctionId);
    }

    function finalizeAuction(uint256 auctionId, address winnerAddress, uint64 winningBid) external onlyOwner {
        require(auctionId < auctionCount, "Invalid auction");
        require(auctions[auctionId].ended, "Auction not ended");
        require(!auctions[auctionId].finalized, "Already finalized");

        auctions[auctionId].finalized = true;
        winner[auctionId] = winnerAddress;
        winningBidAmount[auctionId] = winningBid;

        emit AuctionFinalized(auctionId, winnerAddress, winningBid);
    }

    function withdrawDeposit(uint256 auctionId) external {
        require(auctions[auctionId].finalized, "Auction not finalized");
        require(msg.sender != winner[auctionId], "Winner cannot withdraw");
        uint256 amount = deposits[auctionId][msg.sender];
        require(amount > 0, "No deposit");

        deposits[auctionId][msg.sender] = 0;
        (bool sent, ) = payable(msg.sender).call{ value: amount }("");
        require(sent, "Transfer failed");
        emit DepositWithdrawn(auctionId, msg.sender, amount);
    }

    function getHighestBid(uint256 auctionId) external view returns (euint64) {
        return _highestBid[auctionId];
    }

    function getMyBid(uint256 auctionId) external view returns (euint64) {
        return _bids[auctionId][msg.sender];
    }

    function getBidderCount(uint256 auctionId) external view returns (uint256) {
        return auctions[auctionId].bidders.length;
    }

    function getHighestBidder(uint256 auctionId) external view returns (eaddress) {
        return _highestBidder[auctionId];
    }
}

The createAuction Function

The owner creates auctions dynamically:
function createAuction(
    string calldata item,
    uint256 duration,
    uint64 reservePrice
) external onlyOwner {
    uint256 id = auctionCount++;
    auctions[id].item = item;
    auctions[id].deadline = block.timestamp + duration;
    auctions[id].reservePrice = reservePrice;

    _highestBid[id] = FHE.asEuint64(0);
    FHE.allowThis(_highestBid[id]);

    _highestBidder[id] = FHE.asEaddress(address(0));
    FHE.allowThis(_highestBidder[id]);

    emit AuctionCreated(id, item, auctions[id].deadline, reservePrice);
}
Key points:
  • duration is relative (seconds from now), converted to an absolute deadline
  • reservePrice is stored as plaintext uint64 — the minimum acceptable bid
  • Both _highestBid and _highestBidder are initialized to encrypted zero/null values
  • FHE.allowThis() grants the contract permission to operate on these encrypted values

The bid Function in Detail

The core logic of the auction:
function bid(uint256 auctionId, externalEuint64 encBid, bytes calldata inputProof) external payable {
    require(msg.value > 0, "Must deposit ETH");
    require(!hasBid[auctionId][msg.sender], "Already bid");

    euint64 newBid = FHE.fromExternal(encBid, inputProof);

    // Compare: is this bid higher than the current highest?
    ebool isHigher = FHE.gt(newBid, _highestBid[auctionId]);

    // Update highest bid: pick the larger one
    _highestBid[auctionId] = FHE.select(isHigher, newBid, _highestBid[auctionId]);

    // Update highest bidder: pick the corresponding address
    _highestBidder[auctionId] = FHE.select(
        isHigher,
        FHE.asEaddress(msg.sender),
        _highestBidder[auctionId]
    );
}

Key Observations

  • FHE.fromExternal(encBid, inputProof) takes exactly 2 parameters (the encrypted handle and the proof)
  • The comparison and selection happen entirely on encrypted data
  • Neither the bidder nor observers know if their bid is currently the highest
  • Both _highestBid and _highestBidder are updated atomically
  • One bid per user is enforced with hasBid mapping

Encrypted Address (eaddress)

This contract uses eaddress — an encrypted Ethereum address:
eaddress private _highestBidder;

// Create from plaintext
_highestBidder[id] = FHE.asEaddress(msg.sender);

// Select between two encrypted addresses
_highestBidder[id] = FHE.select(isHigher,
    FHE.asEaddress(msg.sender), _highestBidder[id]);
The eaddress pattern:
  1. FHE.asEaddress(msg.sender) wraps a plaintext address into an encrypted value
  2. FHE.select(condition, addrA, addrB) picks one of two encrypted addresses based on an encrypted boolean
  3. The winner’s identity stays encrypted until endAuction() is called

Deposit Handling

Bidders must send ETH with their bid:
require(msg.value > 0, "Must deposit ETH");
deposits[auctionId][msg.sender] = msg.value;
After the auction ends:
  • Losers: Can call withdrawDeposit(auctionId) to reclaim their ETH
  • Winner: Their deposit covers the winning bid (or partial settlement)
function withdrawDeposit(uint256 auctionId) external {
    require(auctions[auctionId].finalized, "Auction not finalized");
    require(msg.sender != winner[auctionId], "Winner cannot withdraw");
    uint256 amount = deposits[auctionId][msg.sender];
    require(amount > 0, "No deposit");

    deposits[auctionId][msg.sender] = 0;
    payable(msg.sender).transfer(amount);
    emit DepositWithdrawn(auctionId, msg.sender, amount);
}

Ending the Auction: FHE.makePubliclyDecryptable()

Instead of using a Gateway for decryption, this contract uses FHE.makePubliclyDecryptable():
function endAuction(uint256 auctionId) external onlyOwner {
    require(block.timestamp > auctions[auctionId].deadline, "Not yet ended");
    require(!auctions[auctionId].ended, "Already ended");

    auctions[auctionId].ended = true;

    // Make results publicly readable
    FHE.makePubliclyDecryptable(_highestBid[auctionId]);
    FHE.makePubliclyDecryptable(_highestBidder[auctionId]);

    emit AuctionEnded(auctionId);
}
fhEVM v0.9+ decryption flow (Gateway was discontinued):
  1. makePubliclyDecryptable() marks values for public decryption
  2. Off-chain: admin calls publicDecrypt() via relayer SDK to retrieve plaintext
  3. On-chain: finalizeAuction() submits decrypted winner and bid amount
  4. Use FHE.checkSignatures() to verify decrypted results if needed

Frontend: Placing a Bid

const instance = await initFhevm();
const input = instance.createEncryptedInput(
  auctionAddress, userAddress
);
input.add64(myBidAmount);
const encrypted = await input.encrypt();

const tx = await contract.bid(
  auctionId,
  encrypted.handles[0],
  encrypted.inputProof,
  { value: ethers.parseEther("1.0") } // ETH deposit
);
await tx.wait();
Note the three contract parameters: auctionId, the encrypted handle, and the proof, plus the ETH value sent with the transaction.

Privacy Advantages Over Traditional Auctions

AspectOpen AuctionCommit-RevealFHE Sealed-Bid
Bids visible during auctionYesNo (committed hash)No (encrypted)
Front-running possibleYesPartiallyNo
Requires reveal phaseNoYesNo
Bidder can refuse to revealN/AYes (griefing)N/A
On-chain comparisonPlaintextAfter revealEncrypted
The FHE approach eliminates the reveal phase entirely. There is no “commit-reveal” — bids are compared on-chain while still encrypted.

Handling Ties

What if two bids are equal? With FHE.gt(), equal bids return false, so the earlier bidder keeps the lead:
ebool isHigher = FHE.gt(newBid, _highestBid[auctionId]);
// If equal, isHigher is false -> previous highest stays
If you want ties to go to the later bidder, use FHE.ge():
ebool isHigherOrEqual = FHE.ge(newBid, _highestBid[auctionId]);

Security Considerations

Bid Validity

The encrypted bid could be any value. A malicious bidder could bid type(uint64).max without having the funds. The deposit requirement mitigates this, but for a production system, you would need more sophisticated settlement logic.

One Bid Per Address

require(!hasBid[auctionId][msg.sender], "Already bid");
Allowing bid updates would reveal that the bidder changed their mind (a timing side-channel). One bid per address is simpler and more private.

Reserve Price

The reservePrice is stored per auction. It can be checked against the decrypted winning bid after the auction ends to determine if the auction met its minimum.

Summary

  • Sealed-bid auctions with FHE eliminate front-running and bid sniping
  • createAuction() sets up item, duration, and reserve price; supports multiple auctions
  • bid() requires an ETH deposit and enforces one-bid-per-user
  • FHE.fromExternal(encBid, inputProof) converts external encrypted input (2 parameters)
  • FHE.gt() compares bids without revealing values
  • FHE.select() updates the highest bid and bidder atomically
  • eaddress keeps the winner’s identity encrypted: FHE.select(isHigher, FHE.asEaddress(msg.sender), _highestBidder[auctionId])
  • endAuction() uses FHE.makePubliclyDecryptable() instead of Gateway-based decryption
  • withdrawDeposit(auctionId) lets losers reclaim ETH after the auction ends
  • No reveal phase needed (unlike commit-reveal schemes)

Next Steps

Module 14: Testing & Debugging

Master the unique challenges of testing and debugging contracts where you cannot see the values being computed.

Build docs developers (and LLMs) love