Tutorial
This tutorial explains how to build a sealed-bid NFT auction using Fully Homomorphic Encryption (FHE). In this system, participants submit encrypted bids for a single NFT. Bids remain confidential during the auction, and only the winner’s information is revealed at the end.
By following this guide, you will learn how to:
Accept and process encrypted bids
Compare bids securely without revealing their values
Reveal the winner after the auction concludes
Design an auction that is private, fair, and transparent
Why FHE
In most onchain auctions, bids are fully public. Anyone can inspect the blockchain or monitor pending transactions to see how much each participant has bid. This breaks fairness as all it takes to win is to send a new bid with just one wei higher than the current highest.
Existing solutions like commit-reveal schemes attempt to hide bids during a preliminary commit phase. However, they come with several drawbacks: increased transaction overhead, poor user experience (e.g., requiring users to send funds to EOAs via CREATE2
), and delays caused by the need for multiple auction phases.
Fully Homomorphic Encryption (FHE) to enable participants to submit encrypted bids directly to a smart contract in a single step, eliminating multi-phase complexity, improving user experience, and preserving bid secrecy without ever revealing or decrypting them.
Project setup
First, you need to install a new project by cloning the Zama Hardhat template repository:
git clone https://github.com/zama-ai/fhevm-hardhat-template
Then install the dependencies:
npm install
Create the smart contracts
Let’s now create a new contract called BlindAuction.sol
in the ./contracts/
folder. To enable FHE operations in our contract, we will need to inherit our contract from SepoliaConfig
. This configuration provides the necessary parameters and network-specific settings required to interact with Zama’s FHEVM.
Let’s also create some state variable that is going to be used in our auction. For the payment, we will rely on a ConfidentialERC20
. Indeed, we cannot use traditional ERC20, because even if the state in our auction is private, anyone can still monitor blockchain transactions and guess the bid value. By using a ConfidentialERC20
we ensure the amount stays hidden. This ConfidentialERC20
can be used with any ERC20, you will only need to wrap your token to hide future transfers.
Our contract will also include an ERC721
token representing the NFT being auctioned and the address of the auction’s beneficiary. Finally, we’ll define some time-related parameters to control the auction’s duration.
// SPDX-License-Identifier: BSD-3-Clause-Clear
pragma solidity ^0.8.24;
import { FHE, externalEuint64, euint64, ebool } from "@fhevm/solidity/lib/FHE.sol";
import { SepoliaConfig } from "@fhevm/solidity/config/ZamaConfig.sol";
// ...
contract BlindAuction is SepoliaConfig {
/// @notice The recipient of the highest bid once the auction ends
address public beneficiary;
/// @notice Confidenctial Payment Token
ConfidentialERC20 public confidentialERC20;
/// @notice Token for the auction
IERC721 public nftContract;
uint256 public tokenId;
/// @notice Auction duration
uint256 public auctionStartTime;
uint256 public auctionEndTime;
// ...
constructor(
address _nftContractAddress,
address _confidentialERC20Address,
uint256 _tokenId,
uint256 _auctionStartTime,
uint256 _auctionEndTime
) {
beneficiary = msg.sender;
confidentialERC20 = ConfidentialERC20(_confidentialERC20Address);
nftContract = IERC721(_nftContractAddress);
// Transfer the NFT to the contract for the auction
nftContract.safeTransferFrom(msg.sender, address(this), _tokenId);
require(_auctionStartTime < _auctionEndTime, "INVALID_TIME");
auctionStartTime = _auctionStartTime;
auctionEndTime = _auctionEndTime;
}
// ...
}
Now, we need a way to store the highest bid and the potential winner. To store that information privately, we will use some tools provided by the FHE library. For storing an encrypted address, we can use eaddress
type and for the highest bid, we can store the amount with euint64
. Additionally, we can create a mapping to track the user bids.
/// @notice Encrypted auction info
euint64 private highestBid;
eaddress private winningAddress;
/// @notice Mapping from bidder to their bid value
mapping(address account => euint64 bidAmount) private bids;
Create our bid function
Let’s now create our bid function, where the user will transfer a confidential amount and send it to the auction smart contract. Since we want bids to remain private, users must first encrypt their bid amount locally. This encrypted value will then be used to securely transfer funds from the ConfidentialERC20
token that we’ve set as the payment method. We can create our function as follows:
function bid(
externalEuint64 encryptedAmount,
bytes calldata inputProof
) public onlyDuringAuction nonReentrant {
// Get and verify the amount from the user
euint64 amount = FHE.fromExternal(encryptedAmount, inputProof);
// ...
Here, we accept two parameters:
Encrypted Amount: The user’s bid amount, encrypted using FHE.
Input Proof: A Zero-Knowledge Proof ensuring the validity of the encrypted data.
We can verify those parameters by using our helper function FHE.fromExternal()
which gives us the reference to our encrypted amount.
Then, we need to transfer the confidential token to the contract.
euint64 balanceBefore = confidentialERC20.balanceOf(address(this));
confidentialERC20.transferFrom(msg.sender, address(this), amount);
euint64 balanceAfter = confidentialERC20.balanceOf(address(this));
euint64 sentBalance = FHE.sub(balanceAfter, balanceBefore);
Notice that here, we are not using the amount provided by the user as a source of trust. Indeed, in case the user has not enough funds, when calling the transferFrom()
, the transaction will not be reverted, but instead transfer silently a 0
value. This design choice protects eventual leaks as reverted transactions can unintentionally reveal some information on the data.
Note: To dive deeper into how FHE works, each FHE operation done on chain will emit an event used to construct a computation graph. This graph is then executed by the Zama fhEVM. Thus, the FHE operation is not directly done on the smart contract side, but rather follows the source graph generated by it.
Once the payment is done, we need to update the bid balance of the user. Notice here that the user can increase his previous bid if he wants:
euint64 previousBid = bids[msg.sender];
if (FHE.isInitialized(previousBid)) { // The user increase his bid
euint64 newBid = FHE.add(previousBid, sentBalance);
bids[msg.sender] = newBid;
} else {
// First bid for the user
bids[msg.sender] = sentBalance;
}
And finally we can check if we need to update the encrypted winner:
// Compare the total value of the user from the highest bid
euint64 currentBid = bids[msg.sender];
FHE.allowThis(currentBid);
FHE.allow(currentBid, msg.sender);
if (FHE.isInitialized(highestBid)) {
ebool isNewWinner = FHE.lt(highestBid, currentBid);
highestBid = FHE.select(isNewWinner, currentBid, highestBid);
winningAddress = FHE.select(isNewWinner, FHE.asEaddress(msg.sender), winningAddress);
} else {
highestBid = currentBid;
winningAddress = FHE.asEaddress(msg.sender);
}
FHE.allowThis(highestBid);
FHE.allowThis(winningAddress);
As you can see here, we are using some FHE functions. Let’s talk a bit about the FHE.allow()
and FHE.allowThis()
. Each encrypted value has a restriction on who can read this value. To be able to access this value or even do some computation on it, we need to explicitly request an access. This is the reason why we need to explicitly request the access. Here for instance, we want the contract and the user to have access to the bid value. However, only the contract can have access to the highest bid value and winner address that will be reveal at the end of the auction.
Another point that we want to mention is the FHE.select()
function. As mentioned previously, when using FHE, we do not want transactions to be reverted. Instead, when building our graph of FHE operation, we want to create two paths depending on an encrypted value. This is the reason we are using branching allowing us to define the type of process we want. Here for instance, if the bid value of the user is higher than the current one, we are going to change the amount and the address. However, if it is not the case, we are keeping the old one. This branching method is particularly useful, as on chain you cannot have access directly to encrypted data but you still want to adapt your contract logic based on them.
Alright, it seems our bidding function is ready. Here is the full code we have seen so far:
function bid(externalEuint64 encryptedAmount, bytes calldata inputProof) public onlyDuringAuction nonReentrant {
// Get and verify the amount from the user
euint64 amount = FHE.fromExternal(encryptedAmount, inputProof);
// Transfer the confidential token as payment
euint64 balanceBefore = confidentialERC20.balanceOf(address(this));
FHE.allowTransient(amount, address(confidentialERC20));
confidentialERC20.transferFrom(msg.sender, address(this), amount);
euint64 balanceAfter = confidentialERC20.balanceOf(address(this));
euint64 sentBalance = FHE.sub(balanceAfter, balanceBefore);
// Need to update the bid balance
euint64 previousBid = bids[msg.sender];
if (FHE.isInitialized(previousBid)) {
// The user increase his bid
euint64 newBid = FHE.add(previousBid, sentBalance);
bids[msg.sender] = newBid;
} else {
// First bid for the user
bids[msg.sender] = sentBalance;
}
// Compare the total value of the user from the highest bid
euint64 currentBid = bids[msg.sender];
FHE.allowThis(currentBid);
FHE.allow(currentBid, msg.sender);
if (FHE.isInitialized(highestBid)) {
ebool isNewWinner = FHE.lt(highestBid, currentBid);
highestBid = FHE.select(isNewWinner, currentBid, highestBid);
winningAddress = FHE.select(isNewWinner, FHE.asEaddress(msg.sender), winningAddress);
} else {
highestBid = currentBid;
winningAddress = FHE.asEaddress(msg.sender);
}
FHE.allowThis(highestBid);
FHE.allowThis(winningAddress);
}
Auction resolution phase
Once all participants have placed their bids, it’s time to move to the resolution phase, where we will need to reveal the winner address. First, we will need to decrypt the winner’s address as it is currently encrypted. To do so, we can use the DecryptionOracle
provided by Zama. This oracle will be in charge of handling securely the decryption of an encrypted value and will return the result via a callback. To implement this, let create a function that will call the DecryptionOracle
:
function decryptWinningAddress() public onlyAfterEnd {
bytes32[] memory cts = new bytes32[](1);
cts[0] = FHE.toBytes32(winningAddress);
_latestRequestId = FHE.requestDecryption(cts, this.resolveAuctionCallback.selector);
}
Here, we are requesting to decrypt a single parameter for the winningAddress
. However, you can request multiple ones by increasing the cts
array and adding other parameters.
Notice also that when calling the FHE.requestDecryption()
, we are passing a selector in the parameter. This selector will be the one called back by the oracle.
Notice also that we have restricted this function to be called only when the auction has ended. We must not be able to call it while the auction is still running, else it will leak some information.
We can now write our resolveAuctionCallback
callback function:
function resolveAuctionCallback(uint256 requestId, address resultWinnerAddress, bytes[] memory signatures) public {
require(requestId == _latestRequestId, "Invalid requestId");
FHE.checkSignatures(requestId, signatures);
winnerAddress = resultWinnerAddress;
}
To ensure that it is the expected data we are waiting for, we need to verify the requestId
parameter and the signatures, which verify the computation logic done. Once verified, we can update the winner’s address.
Claiming rewards & refunds
Alright, once the winner is revealed, we can now allow the winner to claim his reward and the other one to get refunded.
function winnerClaimPrize() public onlyAfterWinnerRevealed {
require(winnerAddress == msg.sender, "Only winner can claim item");
require(!isNftClaimed, "NFT has already been claimed");
isNftClaimed = true;
// Reset bid value
bids[msg.sender] = FHE.asEuint64(0);
FHE.allowThis(bids[msg.sender]);
FHE.allow(bids[msg.sender], msg.sender);
// Transfer the highest bid to the beneficiary
FHE.allowTransient(highestBid, address(confidentialERC20));
confidentialERC20.transfer(beneficiary, highestBid);
// Send the NFT to the winner
nftContract.safeTransferFrom(address(this), msg.sender, tokenId);
}
function withdraw(address bidder) public onlyAfterWinnerRevealed {
if (bidder == winnerAddress) revert TooLateError(auctionEndTime);
// Get the user bid value
euint64 amount = bids[bidder];
FHE.allowTransient(amount, address(confidentialERC20));
// Reset user bid value
euint64 newBid = FHE.asEuint64(0);
bids[bidder] = newBid;
FHE.allowThis(newBid);
FHE.allow(newBid, bidder);
// Refund the user with his bid amount
confidentialERC20.transfer(bidder, amount);
}
Conclusion
In this guide, we have walked through how to build a sealed-bid NFT auction using Fully Homomorphic Encryption (FHE) onchain.
We demonstrated how FHE can be used to design a private and fair auction mechanism, keeping all bids encrypted and only revealing information when necessary.
Now it’s your turn. Feel free to build on this code, extend it with more complex logic, or create your own decentralized application powered by FHE.
Last updated