使用 fhEVM 构建密封式 NFT 盲拍:全同态加密链上保密拍卖实战指南
2026/9/12 13:00:40 网站建设 项目流程

使用 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 原语(fromExternalallowselectmakePubliclyDecryptablecheckSignatures等)的底层行为。读完本文你将掌握:加密出价的接收与校验、无需解密即可完成的出价比较、基于公开解密与 KMS 证明的赢家揭示,以及一套可直接运行的 Hardhat 测试流程。

为什么拍卖需要 FHE

在大多数链上拍卖中,出价是完全公开的。任何人既可以扫描链上历史数据,也可以监控内存池中待确认的交易,从而知道每位参与者出价多少。这直接破坏了拍卖的公平性——赢家只需要比当前最高出价多出 1 wei 即可,出价行为退化为"盯梢竞拍"。

现有的 commit-reveal(提交-揭示)方案试图在提交阶段隐藏出价,但它有几大缺点:

  • 额外的交易开销:需要提交与揭示两个阶段的多次交易;
  • 糟糕的用户体验:例如要求用户通过CREATE2预先向 EOA 转入资金;
  • 多阶段延迟:完整的拍卖被拆成若干阶段,流程冗长。

FHE 则允许参与者一步完成加密出价:出价以密文形式直接提交给智能合约,合约在密文上完成比较与状态更新,全程无需解密,也没有多阶段复杂度,既保住了出价机密性,又显著改善了用户体验。这正是 fhEVM 相对传统方案的核心理由。

项目准备

开始之前,需要完成以下三步准备:

  1. 安装 FHEVM Hardhat 模板;
  2. 配置 OpenZeppelin confidential contracts(机密合约)库;
  3. 部署一个机密代币(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/Anvil31337

在其他链上部署会直接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 中uint64uint256差别不大不同,在 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 加密后的出价金额encryptedAmountexternalEuint64),以及证明密文合法性的零知识证明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)在密文条件下选择ab:若当前出价高于历史最高价,则更新金额与地址;否则保留旧值。这种分支方法尤其重要——链上无法直接读取密文,但业务逻辑必须依据密文自适应。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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询