在 Hardhat 中编写 FHEVM 测试:使用 FHEVM Hardhat Plugin 实现加密输入与用户解密
【免费下载链接】fhevmFHEVM, a full-stack framework for integrating Fully Homomorphic Encryption (FHE) with blockchain applications项目地址: https://gitcode.com/GitHub_Trending/fh/fhevm
本篇指南聚焦于在 Hardhat 测试工程中,借助FHEVM Hardhat Plugin为全同态加密智能合约编写测试的核心技能:如何在 TypeScript 测试代码中启用插件、通过fhevm运行时模块对输入值做本地加密、构造externalEuintXX密文句柄与零知识证明,再调用合约方法并最终用userDecryptEuint系列 API 解密链上密文进行断言。读完本文,你将能够在 fhevm 仓库所描述的 FHEVM 开发体系中,从零写出可运行、可验证的加密合约测试,并理解句柄(handle)、输入证明(inputProof)与 FHE 权限在其中的底层作用。
前置条件:启用 FHEVM Hardhat Plugin
FHEVM 的测试能力由一个独立的 Hardhat 插件提供,其作用与任何普通 Hardhat 插件一致:在加载阶段被引入,从而把 FHEVM 相关能力注入 Hardhat 运行时环境(Hardhat Runtime Environment,简称 HRE)。要启用它,只需在hardhat.config.ts中添加一行 import:
import "@fhevm/hardhat-plugin";⚠️注意:如果没有这行 import,Hardhat 运行时环境中将不会存在 FHEVM API,后续所有加密、解密调用都会因找不到
fhevm模块而失败。因此这行 import 是编写一切 FHEVM 测试的前提。
关于整个开发环境的初始化(例如 Node.js 版本要求——建议使用偶数版本如v18.x、v20.x,以及 FHEVM Hardhat 模板仓库的创建与npm install步骤),可以参阅 Hardhat 环境搭建指南;插件能力的整体介绍见 Hardhat 插件开发指南。
访问 Hardhat FHEVM API
插件在启用后,会向标准 Hardhat Runtime Environment 扩展一个新的fhevm模块。在测试代码中有两种等价的方式拿到它:
import { fhevm } from "hardhat";或
import * as hre from "hardhat"; // Then access: hre.fhevm两种写法都指向同一个运行时单例。后续所有加密与解密操作,例如fhevm.createEncryptedInput(...)、fhevm.userDecryptEuint(...),都从该模块发起。这也是整个 FHEVM Hardhat 测试 API 的入口。
加密输入:在测试中构造并提交密文
FHEVM 的核心使用场景是:测试方(例如用户 Alice)把明文值在本地加密成密文,提交给链上合约处理。链上合约处理的是密文本身,因此明文在链上任何环节都不会出现。
Solidity 侧的函数签名
假设被测合约有一个名为foo的函数,接收一个加密的uint32。按 FHEVM 规范,Solidity 侧应这样声明:
function foo(externalEuint32 value, bytes calldata inputProof);其中:
externalEuint32 value:一个bytes32,表示加密后的uint32,即加密输入的句柄(handle)。externalEuint32是 FHEVM 对外部加密输入的专用类型,与合约内部使用的euint32相区分——它表明该密文来自链下用户,必须经过完整性验证后才能进入合约计算。bytes calldata inputProof:bytes数组,保存验证该加密有效性的零知识证明(Zero-Knowledge Proof of Knowledge,ZKPoK)。
关于externalEuintXX/externalEbool/externalEaddress类型与bytes inputProof参数的完整设计说明,可进一步阅读 加密输入(Encrypted Inputs)文档。
TypeScript 侧的加密四步流程
在 TypeScript 测试中,计算这两个参数需要准备两样东西:
- 目标合约的地址(
contractAddress) - 签名者的地址(即发送交易的账户,如
signers.alice.address)
随后按如下四步完成加密并调用:
第 1 步:创建一个新的加密输入对象
// use the `fhevm` API module from the Hardhat Runtime Environment const input = fhevm.createEncryptedInput(contractAddress, signers.alice.address);createEncryptedInput返回一个加密输入构造器,它把密文与「合约地址 + 用户地址」双重绑定:生成的密文只能由该用户在指定合约中使用。
第 2 步:添加要加密的值
input.add32(12345);add32对应加密一个uint32。FHEVM 还提供add8、add16、add64、addBool、addAddress等按位宽区分的追加方法,用于在同一输入对象上打包多个不同类型的加密值。
第 3 步:执行本地加密
const encryptedInputs = await input.encrypt();encrypt()是异步操作:它在本地完成 FHE 公钥加密,并生成用于链上验证的零知识证明。
第 4 步:调用 Solidity 函数
const externalUint32Value = encryptedInputs.handles[0]; const inputProof = encryptedInputs.inputProof; const tx = await input.foo(externalUint32Value, inputProof); await tx.wait();encryptedInputs.handles是一个数组,按add32等方法的调用顺序保存各个加密值的bytes32句柄;encryptedInputs.inputProof则是对应整个打包结果的 ZKPoK。二者分别填入函数的两个参数即可。
💡 补充说明:多个加密值可以打包进同一个输入对象。例如 加密输入文档 中的例子依次调用
addBool(...)、add64(...)、add8(...),加密结果通过handles[0]、handles[1]、handles[2]按添加顺序取出,再分别传给 Solidity 函数的多个externalEbool/externalEuint64/externalEuint8参数。TypeScript 侧的添加顺序与 Solidity 函数参数的声明顺序没有强制对应关系,开发时可以自由组织。
仓库中的真实用法佐证
仓库中大量真实测试都遵循上述模式。以 EncryptedERC20 测试 为例,transfer测试先用createEncryptedInput为 Alice 构造加密转账金额,再取出句柄与证明调用合约:
const input = this.instances.alice.createEncryptedInput(this.contractAddress, this.signers.alice.address); input.add64(1337); const encryptedTransferAmount = await input.encrypt(); const tx = await this.erc20'transfer(address,bytes32,bytes)';同一测试文件中,还对句柄的字节结构做了校验(见 EncryptedERC20.ts 的 mint 测试):句柄的字节 21 被置为0xff,字节 22~29 编码链 ID(chainId),字节 30 编码 FHE 类型(如euint64对应05),字节 31 为句柄版本。这说明一个bytes32句柄并非随机数,而是携带了类型、链与版本信息的 FHEVM 内部引用。
更多加密示例
仓库文档目录还提供了从简单到完整的加密示例,可与本节对照学习:
- 单值加密示例
- 多值加密示例
- FHECounter 完整示例
解密:用 userDecryptEuint 系列 API 读取明文
加密值进入合约参与计算后,测试方需要把它读出来并解密成明文做断言。以用户Alice解密合约中一个euint32值为例,合约需暴露如下view函数:
function getEncryptedUint32Value() public view returns (euint32) { returns _encryptedUint32Value; }⚠️权限前提:为简化说明,这里假设 Alice 的账户与目标合约都已经具备解密该值所需的 FHE 权限。FHE 权限的具体工作机制(
allow/ ACL)请参阅 ACL 文档 以及 用户解密委托说明。如果目标合约或用户任一方没有 FHE 权限,解密调用将直接失败。
解密分两步进行:
第 1 步:从合约读取加密值(一个bytes32句柄)
const encryptedUint32Value = await contract.getEncryptedUint32Value();第 2 步:调用 FHEVM API 执行解密
const clearUint32Value = await fhevm.userDecryptEuint( FhevmType.euint32, // Encrypted type (must match the Solidity type) encryptedUint32Value, // bytes32 handle Alice wants to decrypt contractAddress, // Target contract address signers.alice, // Alice’s wallet );userDecryptEuint的四个参数含义分别为:
FhevmType:加密值的整数类型,必须与 Solidity 侧类型严格一致(例如euint32对应FhevmType.euint32)。类型不匹配会导致解密失败。- 加密句柄:要解密的
bytes32句柄。 - 合约地址:持有该句柄访问权限的目标合约地址。
- 用户签名者:拥有该句柄访问权限的用户钱包(如
signers.alice)。
支持的解密类型
FHEVM 为每种加密类型提供了对应的解密函数,使用时按下表选择:
| 类型 | 函数 |
|---|---|
euintXXX | fhevm.userDecryptEuint(...) |
ebool | fhevm.userDecryptEbool(...) |
eaddress | fhevm.userDecryptEaddress(...) |
FHEVM 支持的加密类型全集(ebool、euint8至euint256、eaddress等)及各自的位宽与支持算子,见 支持的加密类型文档。
权限失败的真实表现
权限约束在真实测试中是可以被验证的。EncryptedERC20.ts 专门断言了「Bob 无法解密密文」这一负向场景:当 Bob 尝试对 Alice 的余额句柄执行解密/重加密时,会抛出User is not authorized to reencrypt this handle!异常。这印证了文档中「权限缺失导致解密失败」的警告——测试编写者可以把这类断言纳入自己的测试,以覆盖安全边界。
更多解密示例
- 单值用户解密示例
- 多值用户解密示例
从零搭建一个完整的 FHEVM 测试文件
把加密与解密串起来,一个完整的 FHEVM 测试文件骨架大致如下。以仓库文档 Test the FHEVM contract 中的FHECounter为例:
import { FHECounter, FHECounter__factory } from "../types"; import { FhevmType } from "@fhevm/hardhat-plugin"; import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; import { expect } from "chai"; import { ethers, fhevm } from "hardhat"; type Signers = { deployer: HardhatEthersSigner; alice: HardhatEthersSigner; bob: HardhatEthersSigner; }; async function deployFixture() { const factory = (await ethers.getContractFactory("FHECounter")) as FHECounter__factory; const fheCounterContract = (await factory.deploy()) as FHECounter; const fheCounterContractAddress = await fheCounterContract.getAddress(); return { fheCounterContract, fheCounterContractAddress }; } describe("FHECounter", function () { let signers: Signers; let fheCounterContract: FHECounter; let fheCounterContractAddress: string; before(async function () { const ethSigners: HardhatEthersSigner[] = await ethers.getSigners(); signers = { deployer: ethSigners[0], alice: ethSigners[1], bob: ethSigners[2] }; }); beforeEach(async () => { ({ fheCounterContract, fheCounterContractAddress } = await deployFixture()); }); it("encrypted count should be uninitialized after deployment", async function () { const encryptedCount = await fheCounterContract.getCount(); // 部署后初始加密计数应为 bytes32(0),表示尚未初始化 expect(encryptedCount).to.eq(ethers.ZeroHash); }); it("increment the counter by 1", async function () { const encryptedCountBeforeInc = await fheCounterContract.getCount(); expect(encryptedCountBeforeInc).to.eq(ethers.ZeroHash); const clearCountBeforeInc = 0; // 本地加密常量 1 为 euint32 const clearOne = 1; const encryptedOne = await fhevm .createEncryptedInput(fheCounterContractAddress, signers.alice.address) .add32(clearOne) .encrypt(); // 以加密参数调用 increment const tx = await fheCounterContract.connect(signers.alice).increment(encryptedOne.handles[0], encryptedOne.inputProof); await tx.wait(); const encryptedCountAfterInc = await fheCounterContract.getCount(); const clearCountAfterInc = await fhevm.userDecryptEuint( FhevmType.euint32, encryptedCountAfterInc, fheCounterContractAddress, signers.alice, ); expect(clearCountAfterInc).to.eq(clearCountBeforeInc + clearOne); }); });这段代码体现了与普通 Hardhat 测试的几个关键差异,值得逐一理解:
- 句柄而非数值:
getCount()返回的不再是 TypeScriptnumber,而是一个十六进制bytes32字符串(FHEVM 句柄),指向类型为euint32的加密原语。未初始化时它等于0x0000...0000(即ethers.ZeroHash),不引用任何加密值。 - 加密输入绑定上下文:
fhevm.createEncryptedInput(contractAddress, signers.alice.address)生成的密文同时绑定合约地址与用户地址,只能由 Alice 在该合约中使用,不能被其他用户或其他合约复用,从而保证数据机密性与上下文绑定。 - 入参数量变化:
increment()由普通合约的单参数变为increment(encryptedOne.handles[0], encryptedOne.inputProof)双参数。这是因为 FHEVM 除密文句柄外,还需要附带 ZKPoK 来证明该加密输入与调用者(Alice)以及目标合约绑定,防止密文在异构上下文被重放。 - 解密需要类型与权限双重匹配:
userDecryptEuint的FhevmType.euint32必须与 Solidity 类型一致;句柄的访问权限由链上FHE.allow()等机制授权。
在三种运行时模式下执行测试
FHEVM Hardhat Plugin 提供三种运行时模式,对应合约开发与测试的不同阶段,在速度、加密强度与持久性之间取舍:
| 模式 | 加密方式 | 持久化 | 链环境 | 速度 | 适用场景 |
|---|---|---|---|---|---|
| Hardhat(默认) | 模拟加密 | 否 | 内存网络 | 非常快 | 常规测试、CI 覆盖率、早期合约开发的快速反馈 |
| Hardhat Node | 模拟加密 | 是 | 本地服务器 | 快 | 前端联调、模拟用户流程、本地持久化测试 |
| Sepolia 测试网 | 真实加密 | 是 | 链上服务器 | 慢 | 全栈验证,唯一使用真实加密值的模式 |
默认内存模式下,直接执行:
npx hardhat test --network hardhat本地节点、Sepolia 部署与交互的完整操作步骤(包括npx hardhat node、npx hardhat deploy --network localhost、npx hardhat fhevm check-fhevm-compatibility以及task:decrypt-count、task:increment等任务用法),参见 部署合约并运行测试。
补充提示:如果你的测试逻辑需要在自定义 Hardhat Task(而非
test/compile内置任务)中使用 FHEVM API,必须在任务开头显式调用fhevm.initializeCLIApi(),因为自定义任务不会像内置任务那样自动初始化 FHEVM 运行时环境。具体写法参见 编写 FHEVM Hardhat 任务。
实战要点与常见陷阱
- 忘记 import 插件:
hardhat.config.ts缺少import "@fhevm/hardhat-plugin";时,hre.fhevm不存在,所有加密解密调用都会抛错。这是最高频的入门错误。 - 类型不匹配:
FhevmType.euint32与 Solidity 的euint32必须一一对应;句柄的编码(字节 30)本身就携带类型信息,类型错配时解密会失败或产生错误结果。 - 权限缺失:合约与用户都必须拥有句柄的 FHE 权限(通过
FHE.allow()等链上机制授予),否则解密抛错。可参考仓库测试 EncryptedERC20.ts 的负向断言写法。 - 未初始化句柄为 ZeroHash:合约中尚未赋值的
euint变量其句柄为全零bytes32,在测试中应先用ethers.ZeroHash断言,再进入加密计算流程。 - 句柄与证明的配对:
handles[i]与inputProof来自同一次encrypt()调用,跨输入对象混用会导致链上零知识验证失败。 - 覆盖权限与非法调用等负向路径:FHEVM 的安全边界(如其他用户越权解密、密文跨合约复用)本身就是重要测试点,仓库测试中已有成熟范例可参考。
通过以上步骤,你就掌握了 FHEVM 合约测试从「启用插件 → 构造加密输入 → 调用合约 → 解密断言」的完整闭环。进一步深入学习可参阅 FHEVM Solidity 指南总目录、加密输入文档 与 FHEVM API 参考。
【免费下载链接】fhevmFHEVM, a full-stack framework for integrating Fully Homomorphic Encryption (FHE) with blockchain applications项目地址: https://gitcode.com/GitHub_Trending/fh/fhevm
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考