使用 fhEVM 构建密封式 NFT 盲拍:全同态加密链上保密拍卖实战指南
【免费下载链接】fhevmFHEVM, a full-stack framework for integrating Fully Homomorphic Encryption (FHE) with blockchain applications项目地址: https://gitcode.com/GitHub_Trending/fh/fhevm
密封式拍卖(Sealed-Bid Auction)要求所有参与者在拍卖结束前互相隐瞒出价,而 fhEVM 通过全同态加密(FHE)让智能合约可以直接对密文出价进行安全比较,全程不泄露任何一方的出价金额,仅在结算时揭示赢家。本文以BlindAuction.sol为例,完整讲解如何在 FHEVM 上实现"提交加密出价、密文比较、公开解密揭示赢家、领奖与退款"的整套保密拍卖流程,并结合本仓库源码(FHE.sol、ZamaConfig.sol)剖析每个 FHE 原语(fromExternal、allow、select、makePubliclyDecryptable、checkSignatures等)的底层行为。读完本文你将掌握:加密出价的接收与校验、无需解密即可完成的出价比较、基于公开解密与 KMS 证明的赢家揭示,以及一套可直接运行的 Hardhat 测试流程。
为什么拍卖需要 FHE
在大多数链上拍卖中,出价是完全公开的。任何人既可以扫描链上历史数据,也可以监控内存池中待确认的交易,从而知道每位参与者出价多少。这直接破坏了拍卖的公平性——赢家只需要比当前最高出价多出 1 wei 即可,出价行为退化为"盯梢竞拍"。
现有的 commit-reveal(提交-揭示)方案试图在提交阶段隐藏出价,但它有几大缺点:
- 额外的交易开销:需要提交与揭示两个阶段的多次交易;
- 糟糕的用户体验:例如要求用户通过
CREATE2预先向 EOA 转入资金; - 多阶段延迟:完整的拍卖被拆成若干阶段,流程冗长。
FHE 则允许参与者一步完成加密出价:出价以密文形式直接提交给智能合约,合约在密文上完成比较与状态更新,全程无需解密,也没有多阶段复杂度,既保住了出价机密性,又显著改善了用户体验。这正是 fhEVM 相对传统方案的核心理由。
项目准备
开始之前,需要完成以下三步准备:
- 安装 FHEVM Hardhat 模板;
- 配置 OpenZeppelin confidential contracts(机密合约)库;
- 部署一个机密代币(Confidential Token)。
环境要求(Node.js >= 20、Hardhat ^2.24、可访问 FHEVM 网络与 Zama 网关/中继器)与具体的依赖安装命令,可参考 设置 OpenZeppelin confidential contracts 教程;机密代币的完整实现与测试(含ERC7984Example、机密铸造/销毁、总供应量可见性等扩展)见 部署 Confidential Token 教程。
在本文的拍卖中,支付手段选用的是机密 ERC7984 代币而不是普通 ERC20。原因在于:即使拍卖合约的内部状态是加密的,任何人仍可通过监控代币转账交易来猜测出价金额;而 ERC7984 让余额与转账金额全程保持密文。任何 ERC20 都可以通过 ERC7984ERC20Wrapper 包装成 ERC7984 来隐藏后续转账。
合约继承的ZamaEthereumConfig提供了与 Zama Protocol 交互所需的网络参数。从源码看(ZamaConfig.sol),getCoprocessorConfig()会按block.chainid自动路由到对应网络的 ACL、Coprocessor 与 KMSVerifier 地址:
| 网络 | chainId |
|---|---|
| Ethereum 主网 | 1 |
| Polygon 主网 | 137 |
| Ethereum Sepolia 测试网 | 11155111 |
| Polygon Amoy 测试网 | 80002 |
| 本地 Hardhat/Anvil | 31337 |
在其他链上部署会直接revert ZamaProtocolUnsupported()。
创建拍卖合约
在./contracts/目录新建BlindAuction.sol。为了启用 FHE 操作,合约需要继承ZamaEthereumConfig,同时继承ReentrancyGuard(防重入)与IERC721Receiver(接收作为奖品的 NFT):
// SPDX-License-Identifier: BSD-3-Clause-Clear pragma solidity ^0.8.27; import { FHE, externalEuint64, euint64, eaddress, ebool } from "@fhevm/solidity/lib/FHE.sol"; import { ZamaEthereumConfig } from "@fhevm/solidity/config/ZamaConfig.sol"; import { IERC721 } from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; import { IERC721Receiver } from "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol"; import { ReentrancyGuard } from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; import { IERC7984 } from "@openzeppelin/confidential-contracts/interfaces/IERC7984.sol"; contract BlindAuction is ZamaEthereumConfig, ReentrancyGuard, IERC721Receiver { /// @notice The recipient of the highest bid once the auction ends address public beneficiary; /// @notice Confidential payment token (ERC7984) IERC7984 public confidentialToken; /// @notice NFT prize for the auction IERC721 public nftContract; uint256 public tokenId; /// @notice Auction duration uint256 public auctionStartTime; uint256 public auctionEndTime; constructor( address _nftContractAddress, address _confidentialTokenAddress, uint256 _tokenId, uint256 _auctionStartTime, uint256 _auctionEndTime ) { beneficiary = msg.sender; confidentialToken = IERC7984(_confidentialTokenAddress); nftContract = IERC721(_nftContractAddress); tokenId = _tokenId; // 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; } }构造函数完成三件事:记录受益人(部署者)、登记机密支付代币与 NFT 奖品、并把 NFT 通过safeTransferFrom托管到合约中(因此合约必须实现onERC721Received以接收 ERC721 转账)。
用加密状态保存最高出价与赢家
为了私密地保存最高出价与潜在赢家,使用 FHE 库提供的加密类型:eaddress存储加密地址,euint64存储最高出价金额,再用一个 mapping 记录每个出价者自己的出价:
/// @notice Encrypted auction state euint64 private highestBid; eaddress private winningAddress; /// @notice Mapping from bidder to their bid value mapping(address account => euint64 bidAmount) private bids;关于
euint64的性能提示:与标准 Solidity 中uint64与uint256差别不大不同,在 FHE 中数据的位宽对性能有显著影响——表示越大,计算越昂贵。因此应基于实际场景谨慎选择表示位宽。本案例中,euint64足以处理代币余额,是"够用且省"的选择。
除了上述加密状态,完整示例(见 sealed-bid-auction.md)还补充了错误定义、事件、时间修饰器与查询视图,这些构成了拍卖流程的骨架:
error TooEarlyError(uint256 time); error TooLateError(uint256 time); error WinnerNotYetRevealed(); event AuctionDecryptionRequested(eaddress encryptedWinningAddress); modifier onlyDuringAuction() { if (block.timestamp < auctionStartTime) revert TooEarlyError(auctionStartTime); if (block.timestamp >= auctionEndTime) revert TooLateError(auctionEndTime); _; } modifier onlyAfterEnd() { if (block.timestamp < auctionEndTime) revert TooEarlyError(auctionEndTime); _; } modifier onlyAfterWinnerRevealed() { if (winnerAddress == address(0)) revert WinnerNotYetRevealed(); _; } function getEncryptedBid(address account) external view returns (euint64) { return bids[account]; } function getEncryptedWinningAddress() external view returns (eaddress) { return winningAddress; } function getWinnerAddress() external view returns (address) { require(winnerAddress != address(0), "Winning address has not been decided yet"); return winnerAddress; }实现出价函数
出价函数接收两个参数:用户本地用 FHE 加密后的出价金额encryptedAmount(externalEuint64),以及证明密文合法性的零知识证明inputProof。通过FHE.fromExternal()校验并拿到加密金额的引用:
function bid( externalEuint64 encryptedAmount, bytes calldata inputProof ) public onlyDuringAuction nonReentrant { // Get and verify the amount from the user euint64 amount = FHE.fromExternal(encryptedAmount, inputProof); // ... }从源码看(FHE.sol),fromExternal有两种路径:当inputProof非空时调用Impl.verify()验证密文与证明;当 proof 为空时,若句柄为 0 则视为金额 0,否则要求调用者已被allow(否则抛出SenderNotAllowedToUseHandle)。也就是说,出价金额在进入合约逻辑前已经被严格校验。
用余额差计算实际到账金额,而非信任用户输入
接下来把机密代币转入合约。这里有一个关键设计:不能直接信任用户传入的amount。如果用户余额不足,confidentialTransferFrom()不会回滚,而是静默转移 0 值。这个设计刻意避免回滚——回滚交易会无意中泄露关于数据的某些信息(例如"这笔钱不够"本身就是一种信号)。
euint64 balanceBefore = confidentialToken.confidentialBalanceOf(address(this)); FHE.allowTransient(amount, address(confidentialToken)); confidentialToken.confidentialTransferFrom(msg.sender, address(this), amount); euint64 balanceAfter = confidentialToken.confidentialBalanceOf(address(this)); euint64 sentBalance = FHE.sub(balanceAfter, balanceBefore);因此合约通过"转账前后合约余额的密文差"FHE.sub(balanceAfter, balanceBefore)来推导实际到账金额,彻底规避对用户输入的信任。
FHE 工作方式说明:链上执行的每个 FHE 操作都会发出一个事件,用于构建计算图(computation graph),该图随后由 Zama Protocol 执行。也就是说,FHE 运算并非在智能合约侧直接完成,而是遵循合约生成的源图在链下/协处理器侧完成计算,最终结果再回到链上。
支持累计加价的出价记录
用户可以追加自己的出价。若bids[msg.sender]已初始化,则用FHE.add(previousBid, sentBalance)累加;否则记录为首次出价:
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; }FHE.isInitialized(FHE.sol)本质上检查加密句柄是否为 0,用来区分"尚未出价"与"已有出价"两种状态。
密文比较与分支选择
最后,判断是否需要更新加密赢家。核心是FHE.lt()(小于比较,返回密文布尔值)与FHE.select()(按密文条件二选一):
// 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);这里涉及两个必须理解的概念:
访问控制(FHE.allow/FHE.allowThis/FHE.allowTransient):每个加密值都带有一个"谁能读取"的访问限制。要对某个密文做读取或计算,必须显式申请访问权。本例中:
FHE.allowThis(currentBid)/FHE.allow(currentBid, msg.sender):让合约自身与出价者都能访问其出价值;FHE.allowThis(highestBid)/FHE.allowThis(winningAddress):最高出价与赢家地址只允许合约自身访问,直到结算时被公开解密。
从源码看(FHE.sol),allow/allowThis调用Impl.allow()持久授权,而allowTransient调用Impl.allowTransient()(Impl.sol)仅在本交易内授权——后者正是把密文句柄传给代币合约做转账时的标准做法,避免留下持久权限。
分支(branching /FHE.select):如前所述,FHE 场景下应避免交易回滚。构建 FHE 计算图时,我们希望根据一个密文值走出两条路径。FHE.select(control, a, b)(FHE.sol)在密文条件下选择a或b:若当前出价高于历史最高价,则更新金额与地址;否则保留旧值。这种分支方法尤其重要——链上无法直接读取密文,但业务逻辑必须依据密文自适应。FHE.asEaddress(msg.sender)(FHE.sol)则是把明文地址"平凡加密"(Impl.trivialEncrypt,Impl.sol)为加密地址类型。
完整的 bid 函数
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 = confidentialToken.confidentialBalanceOf(address(this)); FHE.allowTransient(amount, address(confidentialToken)); confidentialToken.confidentialTransferFrom(msg.sender, address(this), amount); euint64 balanceAfter = confidentialToken.confidentialBalanceOf(address(this)); euint64 sentBalance = FHE.sub(balanceAfter, balanceBefore); // Update the bid balance (supports incremental bids) euint64 previousBid = bids[msg.sender]; if (FHE.isInitialized(previousBid)) { euint64 newBid = FHE.add(previousBid, sentBalance); bids[msg.sender] = newBid; } else { bids[msg.sender] = sentBalance; } // Compare the total value of the user against 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); }结算阶段:公开解密揭示赢家
所有参与者出价完毕后进入结算阶段,需要把加密的赢家地址解密出来。这里采用**公开解密(public decryption)**的两步流程:先标记该值为可公开解密,再在链上验证解密证明。
第一步:请求解密
function decryptWinningAddress() public onlyAfterEnd { require(!decryptionRequested, "Decryption already requested"); decryptionRequested = true; FHE.makePubliclyDecryptable(winningAddress); emit AuctionDecryptionRequested(winningAddress); }FHE.makePubliclyDecryptable()(FHE.sol)将加密的赢家地址标记为可公开解密;随后发出的事件携带加密句柄,供链下服务(如 Zama Relayer)计算解密并生成证明。关于该 API 与验证完整工作流,可参考 公开解密 SDK 指南。
注意该函数被onlyAfterEnd限制——拍卖进行中绝不能调用,否则会泄露信息(把"当前领先者"提前暴露给所有人)。
第二步:提交解密结果并链上验证
链下解密完成后,任何人都可以提交结果与证明,在链上完成验证:
function resolveAuction(bytes memory abiEncodedClearResult, bytes memory decryptionProof) public { require(decryptionRequested, "Decryption not requested"); require(winnerAddress == address(0), "Winner already resolved"); bytes32[] memory cts = new bytes32[](1); cts[0] = FHE.toBytes32(winningAddress); FHE.checkSignatures(cts, abiEncodedClearResult, decryptionProof); address resultWinnerAddress = abi.decode(abiEncodedClearResult, (address)); winnerAddress = resultWinnerAddress; }abiEncodedClearResult:ABI 编码后的明文赢家地址;decryptionProof:KMS 签名证明,验证解密结果的真实性;FHE.toBytes32()(FHE.sol)把加密地址句柄转为bytes32列表;FHE.checkSignatures()(FHE.sol)验证所提供的明文值确实是存储密文的真实解密结果——证明无效则交易回滚。
双重require保证:解密必须已被请求,且赢家尚未被解析(防止重复提交覆盖结果)。
赢家领奖与败者退款
赢家被揭示后,赢家可以领取奖品,其余人可退回资金。
赢家领取 NFT
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(confidentialToken)); confidentialToken.confidentialTransfer(beneficiary, highestBid); // Send the NFT to the winner nftContract.safeTransferFrom(address(this), msg.sender, tokenId); }流程为:校验调用者确为赢家且 NFT 未被领取 → 将赢家出价清零(FHE.asEuint64(0)平凡加密 0)→ 通过allowTransient临时授权代币合约,用confidentialTransfer把最高出价(密文)转给受益人 → 最后把 NFT 转给赢家。整个过程中最高出价金额始终以密文形式流动,受益人拿到的是加密余额。
败者退款
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(confidentialToken)); // Reset user bid value euint64 newBid = FHE.asEuint64(0); bids[bidder] = newBid; FHE.allowThis(newBid); FHE.allow(newBid, bidder); // Refund the user with their bid amount confidentialToken.confidentialTransfer(bidder, amount); }非赢家(包括从未赢过的人)可以将自己的出价全额取回。withdraw拒绝赢家调用(revert TooLateError),且退款后同样把出价记录清零并同步访问权限,防止重复取款。
用 Hardhat 测试验证整条拍卖链路
完整示例(sealed-bid-auction.md)附带了一套 TypeScript 测试,覆盖从铸造机密代币到赢家领奖、败者退款的全流程。运行前请把.sol文件放入<项目根目录>/contracts/,.ts文件放入<项目根目录>/test/,确保 Hardhat 能正确编译与测试。
测试中的关键辅助函数展示了 SDK 侧的标准用法:
// 用 fhevm.createEncryptedInput 在本地加密出价 async function encryptBid(targetContract: string, userAddress: string, amount: number) { const bidInput = hre.fhevm.createEncryptedInput(targetContract, userAddress); bidInput.add64(amount); return await bidInput.encrypt(); } // 授权拍卖合约作为代币 operator(机密代币的"授权"机制) async function approve(signer: HardhatEthersSigner) { const approveTx = await USDCc.connect(signer).setOperator( blindAuctionAddress, Math.floor(Date.now() / 1000) + 60 * 60, ); await approveTx.wait(); } // 提交加密出价:handles[0] 是密文句柄,inputProof 是零知识证明 async function placeBid(signer: HardhatEthersSigner, amount: number) { const encryptedBid = await encryptBid(blindAuctionAddress, signer.address, amount); const bidTx = await blindAuction.connect(signer).bid(encryptedBid.handles[0], encryptedBid.inputProof); await bidTx.wait(); }注意bid()的前置条件是调用者必须先对机密代币调用setOperator(auctionAddress, deadline)授权拍卖合约——这与 ERC7984 的机密转账机制一致,属于必须在文档/测试中显式处理的链上前置步骤。
结算阶段则演示了公开解密的标准调用顺序(请求解密 → 解析事件拿到加密句柄 → 调用 Relayer 的publicDecrypt→ 把明文与证明提交回链上):
async function resolveAuctionViaPublicDecrypt() { const tx = await blindAuction.decryptWinningAddress(); const receipt = await tx.wait(); // 从 AuctionDecryptionRequested 事件中解析出加密句柄 let encryptedWinningAddress: string | undefined; for (const log of receipt!.logs) { const parsed = blindAuction.interface.parseLog(log); if (parsed && parsed.name === "AuctionDecryptionRequested") { encryptedWinningAddress = parsed.args.encryptedWinningAddress; break; } } expect(encryptedWinningAddress).to.not.be.undefined; // 调用 Zama Relayer 计算解密结果 const publicDecryptResults = await fhevm.publicDecrypt([encryptedWinningAddress!]); // 将解密结果提交回合约进行链上验证 await blindAuction.resolveAuction( publicDecryptResults.abiEncodedClearValues, publicDecryptResults.decryptionProof, ); }核心场景测试 "bob should win auction" 完整验证了:Alice 出价 10,000、Bob 出价 15,000 → 模拟时间推进 1 小时使拍卖结束 → 公开解密揭示 Bob 为赢家 → Bob 领取 NFT 并转移 15,000 给受益人 → Alice 取回 10,000 退款 → Bob 作为赢家无法调用withdraw(必须回滚)。这一测试同时印证了本教程中所有关键设计(密文比较、分支选择、余额差到账、公开解密验证)在实际运行中的正确性。
小结
本教程演示了如何在链上构建一个使用全同态加密的密封式 NFT 拍卖:
- 全部出价全程加密:出价在本地加密,
FHE.fromExternal配零知识证明校验后进入合约; - 密文上完成比较:
FHE.lt+FHE.select在不揭示任何金额的前提下维护最高出价与领先者; - 按需揭示:拍卖结束后通过
makePubliclyDecryptable请求公开解密,checkSignatures链上验证 KMS 证明,仅公开赢家信息; - 隐私友好的资金流:机密 ERC7984 代币承载支付,用余额差而非用户输入确定到账金额,静默零转账设计避免信息泄露。
可以在此基础上继续扩展:更复杂的评标逻辑(如次高价拍卖)、多轮拍卖、与机密投票/机密质押等 FHE 原语组合,乃至构建完整的 FHE 去中心化应用。完整合约与测试代码见 sealed-bid-auction.md,配套教程即本文所讲解的 sealed-bid-auction-tutorial.md。
【免费下载链接】fhevmFHEVM, a full-stack framework for integrating Fully Homomorphic Encryption (FHE) with blockchain applications项目地址: https://gitcode.com/GitHub_Trending/fh/fhevm
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考