fhEVM 前端加密实战:使用 fhevm 的encryptValues/encryptValue在客户端安全加密链上输入
【免费下载链接】fhevmFHEVM, a full-stack framework for integrating Fully Homomorphic Encryption (FHE) with blockchain applications项目地址: https://gitcode.com/GitHub_Trending/fh/fhevm
导读
本文围绕 fhevm(fhEVM 全栈框架)JS SDK 的加密能力展开,系统讲解如何把明文数值在客户端本地加密为链上可验证的密文句柄(handle)与输入证明(input proof),并正确提交给 FHEVM 合约消费。你将掌握encryptValues与encryptValue两种加密 API 的使用姿势、Solidity 值类型与 FHE 类型(externalEuintXX/externalEbool/externalEaddress)的映射关系、批量加密与进度控制,以及加密背后“ZK 证明生成 → Relayer 签名换证”的两步式底层原理。
加密是什么:明文永不离开客户端
加密(Encryption)将明文值变成不透明的加密值,同时产出一份你的合约可以验证的证明。整个过程全部发生在客户端侧——明文永远不会离开你的应用,不会出现在任何网络请求、RPC 调用或区块数据中。
从代码结构看,加密能力通过模块装饰器挂在客户端实例上,同时支持两种客户端形态:
createFhevmClientcreateFhevmEncryptClient
装饰器的挂载逻辑位于 sdk/js-sdk/src/core/clients/decorators/encrypt.ts,它把encryptValue与encryptValues两个动作(action)暴露为客户端的方法,并注册对应的加密运行时模块。也就是说,只要你的客户端带有 encrypt 能力(WithEncrypt),即可直接调用下述两个方法。
两个加密方法
encryptValues:一次加密一批值,共享一份证明
encryptValues用于批量加密:一次调用加密多个值,并为整个批次生成一份共享的输入证明(input proof)。只要一次合约调用携带多个加密参数,就应该用它——一份证明即可覆盖整批值,链上只需验证一次。
const encrypted = await client.encryptValues({ contractAddress: '0xYourContract…', userAddress: '0xYourWallet…', values: [ { type: 'uint32', value: 42 }, { type: 'bool', value: true }, ], }); encrypted.encryptedValues; // readonly EncryptedValue[] —— 每个输入一个,顺序一一对应 encrypted.inputProof; // BytesHex —— 整批共用的证明encryptValue:只加密一个值
encryptValue是单参数场景的便捷封装,内部逻辑与encryptValues完全一致,只是入参从values数组变为单个value:
const encrypted = await client.encryptValue({ contractAddress: '0xYourContract…', userAddress: '0xYourWallet…', value: { type: 'uint64', value: 1000n }, }); encrypted.encryptedValue; // 单个 EncryptedValue encrypted.inputProof; // BytesHex从源码看,两者最终汇入同一个加密流水线:encryptValue内部先通过toArray把单个值包装成数组,再与encryptValues一样依次执行地址校验(assertIsAddress)、类型解析(resolveRawValueTypeName)、createTypedValue规范化,最后调用底层的encrypt协处理器函数(见 sdk/js-sdk/src/core/actions/encrypt/encryptValues.ts 与 sdk/js-sdk/src/core/actions/encrypt/encryptValue.ts)。返回结构上,encryptValues返回encryptedValues数组,encryptValue返回单个encryptedValue,二者的inputProof类型都是BytesHex。
绑定:合约地址与用户地址缺一不可
contractAddress与userAddress两个参数都是必填的,并且都会被密码学地绑定进证明:
contractAddress—— 消费这批加密值的合约地址。生成的证明只对该地址有效。userAddress—— 将提交这笔交易的用户地址。证明只在该用户发起交易时有效。
如果在提交时两者中的任何一个与加密时不一致,链上验证就会失败。因此必须用与真实交易完全相同的值、合约和发送者进行加密。
从实现看,两个地址在进入流水线前会被强制转换为校验和格式(addressToChecksummedAddress),并在createInputProofFromInputHandles阶段以signedHandleAccess(含userAddress、contractAddress)的形式参与输入证明的构造,从而与证明密码学绑定(见 sdk/js-sdk/src/core/coprocessor/InputProof-p.ts)。
警告:
contractAddress和userAddress都被密码学绑定进证明。只要二者之一发生变化就必须重新加密——为一个发送者/合约生成的证明,对另一个发送者/合约毫无价值。
支持的输入类型
type字段使用Solidity 值类型名,而不是 FHE 类型名。每个类型映射到链上的externalEuintXX/externalEbool/externalEaddress:
type | 可接受的 JS 值 | 映射到链上 |
|---|---|---|
'bool' | boolean/number/bigint | externalEbool |
'uint8' | number/bigint | externalEuint8 |
'uint16' | number/bigint | externalEuint16 |
'uint32' | number/bigint | externalEuint32 |
'uint64' | number/bigint | externalEuint64 |
'uint128' | number/bigint | externalEuint128 |
'uint256' | number/bigint | externalEuint256 |
'address' | string(十六进制地址) | externalEaddress |
需要注意的边界:
- 没有
uint160类型—— 加密的以太坊地址使用'address'。 - 没有加密的
bytes类型。 euint4已被移除。
对于大整数(uint64及以上),建议使用bigint,以避免 JavaScriptNumber.MAX_SAFE_INTEGER(2^53 - 1)的精度上限:
values: [ { type: 'uint256', value: 123456789012345678901234567890n }, { type: 'address', value: '0xAbC0000000000000000000000000000000000001' }, ];在 SDK 的类型系统中,加密时输入比严格的TypedValue略微宽松(uint32接受number或bigint,bool接受boolean、number或bigint),SDK 会负责校验并规范化;而解密时你拿到的永远是严格的TypedValue形态(详见 sdk/js-sdk/docs/types.md)。
在合约调用中使用加密结果
encryptedValues中的每一项按顺序传给合约中对应的externalEuintXX参数,共享的inputProof则是 FHEVM 合约期望的末尾bytes参数。
ethers.js
const encrypted = await client.encryptValues({ contractAddress, userAddress, values: [{ type: 'uint32', value: 42 }], }); await contract.increment( encrypted.encryptedValues[0], // externalEuint32 encrypted.inputProof, // bytes );viem
const encrypted = await client.encryptValues({ contractAddress, userAddress, values: [{ type: 'uint32', value: 42 }], }); await walletClient.writeContract({ address: contractAddress, abi, functionName: 'increment', args: [encrypted.encryptedValues[0], encrypted.inputProof], });在链上,合约通过FHE.fromExternal(externalValue, inputProof)验证每个输入,并将其转换为可参与运算的euintXX之后再执行计算。也就是说,客户端产出的“外部句柄 + 证明”只是入口,真正的同态运算发生在链上把外部值转换成语义安全的内部句柄之后。
批量加密:一份证明 + 原子绑定
相比逐个加密,批量加密有两个明确收益:
- 一份证明—— 一个批次只产生一个
inputProof,验证成本远低于多份独立证明。 - 原子性—— 批内所有值共享同一份对合约与用户的绑定。
容量上限:单个输入密文(input ciphertext)最多可打包256 个加密变量,超出会抛出TooManyHandlesError。
提示:单个输入密文最多打包 256 个加密变量。更大的批次请拆分成多次
encryptValues调用。
这个上限可以从源码中印证:在 sdk/js-sdk/src/core/coprocessor/InputProof-p.ts 中,createInputProofFromInputHandles会检查numberOfHandles > MAX_UINT8(单字节可表示的最大句柄数)并抛出TooManyHandlesError({ numberOfHandles });句柄数量与签名数量均以单字节长度字段编码进证明结构,因此单个证明能携带的句柄数量受 8 位长度字段约束。TooManyHandlesError的完整类型定义与构造见 sdk/js-sdk/src/core/errors/InputProofError.ts。
请求选项与进度回调
每次加密调用都接受可选的options对象,用于控制向 Relayer 请求已验证证明的 HTTP 行为:
const encrypted = await client.encryptValues({ contractAddress, userAddress, values, options: { timeout: 60_000, signal: abortController.signal, onProgress: (args) => console.log(args.type), // 'queued' | 'throttled' | 'succeeded' | 'timeout' | 'abort' | 'failed' }, });常用字段:
| 字段 | 类型 | 说明 |
|---|---|---|
timeout | number | 单次请求超时时间(毫秒) |
signal | AbortSignal | 用于中途取消请求 |
headers | Record<string, string> | 附加 HTTP 请求头 |
fetchRetries | number | 请求失败后的重试次数 |
fetchRetryDelayInMilliseconds | number | 重试之间的延迟(毫秒) |
onProgress | 回调函数 | 上报'queued' / 'throttled' / 'succeeded' / 'timeout' / 'abort' / 'failed'六种进度状态 |
从 sdk/js-sdk/src/core/types/relayer.ts 的类型定义看,RelayerInputProofOptions由RelayerCommonOptions(auth、headers、debug、fetchRetries、fetchRetryDelayInMilliseconds、signal、timeout)扩展而来,并追加加密专用的onProgress。而进度回调的参数对象携带url、method(POST/GET)、operation(如'INPUT_PROOF')、jobId、retryCount、totalSteps、step等字段;queued状态对应 HTTP 202 并带retryAfterMs与requestId,throttled对应 429 并携带relayerApiError,succeeded对应 200 并携带result(即handles、signatures、extraData)。完整选项集请参考 sdk/js-sdk/docs/api-reference.md。
底层发生了什么:两步流水线
encryptValues/encryptValue底层运行着一个你通常看不到的两步流水线(见 sdk/js-sdk/src/core/coprocessor/encrypt.ts 的实现:先createZkProof,再fetchVerifiedInputProof):
- 本地生成 ZK 证明(WASM/TFHE)—— 在客户端通过编译为 WASM 的 TFHE 库生成零知识证明:证明你在 FHE 公钥下正确加密了你的明文,同时不泄露明文内容。FHE 公钥首次使用时从 Relayer 获取并缓存。
- 换取已验证的输入证明—— Relayer 的协处理器(coprocessor)验证这份 ZK 证明并对其签名,产出你的合约信任的
inputProof。
第二步在 sdk/js-sdk/src/core/coprocessor/fetchVerifiedInputProof.ts 中还有更细的 4 个子步骤:
- 从 ZK 证明中提取外部句柄(
inputHandles),若为空则抛InputProofError; - 将 ZK 证明提交给 Relayer,请求协处理器签名(
fetchCoprocessorSignatures); - 用
assertHandleArrayEquals校验返回的句柄与本地句柄一致——这一检查“理论上并非必需”,但 SDK 选择执行它,因为不信任 Relayer,用于检测 Relayer 是否恶意; - 用协处理器 EIP-712 签名、输入句柄、
extraData与signedHandleAccess(用户地址 + 合约地址)组装最终输入证明,并在本地做一次验证后返回。
分离执行:如果你需要把这两步分开运行(例如离线生成证明、稍后再提交),可以使用独立的 action:generateZkProof(来自@fhevm/sdk/actions/encrypt)和fetchEncryptedValues(来自@fhevm/sdk/actions/base),详见 sdk/js-sdk/docs/actions.md。
加密结果的形态:句柄而非密文本体
加密返回的EncryptedValue是一个bytes32句柄:它是对协处理器持有的密文的不透明、确定性引用,而不是密文本体。它正是你的合约存储与返回的东西。SDK 还提供按类型品牌化的别名(Ebool、Euint8、Euint32、Euint64、Euint128、Euint256、Eaddress)以及EncryptedValueLike(Uint8Array | string | { bytes32Hex })宽松输入形态和isEncryptedValue/asEncryptedValue工具函数,便于校验与转换(详见 sdk/js-sdk/docs/types.md)。
错误处理速览
加密链路涉及三类典型错误(详见 sdk/js-sdk/docs/error-handling.md):
EncryptionError—— 加密动作层面的错误;ZkProofError—— 本地 ZK 证明生成阶段的错误;TooManyHandlesError—— 单次批次超过 256 个句柄上限(构造参数携带numberOfHandles)。
关联阅读
- 解密(Decryption) —— 把加密值读回明文;
- 类型系统(Types) —— 加密值句柄与类型化值的完整类型体系;
- Actions —— 独立的
generateZkProof(加密)/fetchEncryptedValues(基础)函数; - 错误处理(Error handling) ——
EncryptionError、ZkProofError、TooManyHandlesError的完整说明; - API 参考 —— 全部导出类型与选项的权威清单。
【免费下载链接】fhevmFHEVM, a full-stack framework for integrating Fully Homomorphic Encryption (FHE) with blockchain applications项目地址: https://gitcode.com/GitHub_Trending/fh/fhevm
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考