大白话解析:多证明验证(Merkle Multi-Proof)
P.S.关于之前发布的文章【大白话解析】 OpenZeppelin 的 MerkleProof 库:Solidity 默克尔证明验证工具全指南(附源代码)-CSDN博客,有朋友反馈希望重点讲解【多数据批量验证】的部分。今天我们就来深入解析这个功能,帮助大家更好地理解与应用!
一. 默克尔树是啥?
想象一棵倒过来的树,叶子节点(最底层) 存放的是数据的哈希(比如 Data0
→ L0 = hash(Data0)
),非叶子节点 是它下面两个子节点的哈希组合(比如 H0 = hash(L0 + L1)
)。
根哈希(Root) 就是整棵树的唯一指纹,只要数据有改动,根哈希就会变。
我们的树长这样:
Root (H01 + H23)/ \H01 (H0 + H1) H23 (H2 + H3)/ \ / \H0 H1 H2 H3/ \ / \ / \ / \L0 L1 L2 L3 ...(具体数据)
-
L1 和 L2 是我们要验证的两个数据块(比如某笔交易或文件片段)。
-
Root 是整棵树的根哈希(相当于“总证书”)。
二、我们要干啥?
目标:不拿出整棵树,只用 少量关键信息(L1, L2 + 几个辅助节点),证明 L1
和 L2
确实属于这棵树,并且它们的根哈希是对的。
为什么有用?
-
节省存储/带宽:不用传整棵树,只传必要的证明节点。
-
快速验证:计算几步哈希,就能确认数据是否合法。
多证明验证(Merkle Multi-Proof)的意义与价值
多证明验证(Merkle Multi-Proof)是区块链和分布式系统中一种高效的数据完整性验证技术,它的核心意义在于用最小的计算和存储开销,证明多个数据块属于某个更大的数据集合(如默克尔树)。以下是其关键意义和实际价值:
1. 传统默克尔证明(单证明)的问题
单证明(Single Proof):只能验证单个叶子节点(比如
L1
),需要提供从该叶子到根的完整路径(比如L1
的兄弟L0
、父节点H0
、祖父节点H01
等)。缺点:如果要验证多个数据(比如
L1
和L2
),必须分别提供各自的证明路径,导致:
冗余数据:多个证明可能包含重复的中间节点(比如
H01
可能被多次计算)。计算开销大:需要多次哈希计算,效率较低。
2. 多证明(Multi-Proof)的优化
一次性验证多个叶子(比如
L1
和L2
),只需提供:
这些叶子本身(
L1
,L2
)。必要的“缺失节点”(比如
L0
,L3
,H23
),帮助计算路径。操作指令(proofFlags),指导程序如何组合这些节点。
优势:
减少冗余:共享中间节点(比如
H01
只需计算一次)。- 降低计算量:合并多个验证步骤,减少哈希计算次数。
- 节省带宽/存储:不需要传输整棵树,只需关键节点。
三、我们需要哪些材料?
材料 | 是啥 | 例子 |
---|---|---|
要验证的叶子(leaves) | 我们要证明的数据(比如 |
|
辅助证明节点(proof) | 帮助我们计算路径的“中间节点”(比如 |
|
操作指令(proofFlags) | 告诉程序怎么组合这些节点( |
|
关键点:
-
proofFlags
控制计算顺序:告诉程序每一步该用哪个节点(比如先算L1 + L0
,再算L2 + L3
,最后合并到根)。 -
proof
提供“缺失的拼图”:比如L0
是L1
的兄弟节点,H23
是更高层的哈希。
四、先看完整代码
// 函数功能:根据给定的一组叶子节点(leaves)、证明节点(proof)、以及证明标识(flags),
// 按顺序两两组合计算哈希,最终构建并返回 Merkle Tree 的根哈希(merkleRoot)
// 这是一个内部纯函数(internal pure),不修改状态也不依赖区块链数据
function processMultiProof(bytes32[] memory proof, // 辅助证明节点数组,例如一些中间哈希,用来补全 Merkle Tree 分支bool[] memory proofFlags, // 证明标识数组,长度与需要组合的次数一致(通常为层数)// proofFlags[i] == true 表示当前组合中的第2个操作数 b 应该从 leaves 或 hashes 中取// proofFlags[i] == false 表示第2个操作数 b 应该从 proof 数组中取bytes32[] memory leaves // 原始的叶子节点数组,例如一个个数据的哈希值,通常是待打包上链的数据的 hash
) internal pure returns (bytes32 merkleRoot) {// ======================// 1. 获取输入数组的长度// ======================uint256 leavesLen = leaves.length; // 叶子节点的数量,比如 2 个数据 => 2 个叶子uint256 proofFlagsLen = proofFlags.length; // 证明标识数组的长度,表示要进行多少次两两哈希组合,比如 4 次// ======================// 2. 安全检查:参数数量必须匹配,否则无法正确构建 Merkle Tree// ======================// 规则:leaves 的数量 + proof 中辅助节点的数量,应该刚好等于 proofFlags 的数量 + 1// 解释:每执行一次 proofFlags(即一次两两组合),就会减少一个独立节点,最终合并成一个根// 所以:leaves + proof 的总自由节点数 == proofFlags 的组合次数 + 1(最终根)if (leavesLen + proof.length != proofFlagsLen + 1) {revert MerkleProofInvalidMultiproof(); // 如果不匹配,说明参数传错了,直接报错}// ======================// 3. 创建一个数组,用于存储每一层计算出来的中间哈希结果// ======================bytes32[] memory hashes = new bytes32[](proofFlagsLen);// 这个数组将保存每次组合后的哈希,比如 hash(L1,L2), hash(H1,H2), ..., 最后一个是根哈希// 数组长度 == 证明标识的数量 == 组合的次数 == proofFlagsLen// ======================// 4. 初始化指针,用于跟踪当前处理到哪里了// ======================uint256 leafPos = 0; // 指向 leaves 数组中下一个待处理的叶子节点位置,从第 0 个开始uint256 hashPos = 0; // 指向 hashes 数组中下一个待使用的中间哈希位置,从第 0 个开始uint256 proofPos = 0; // 指向 proof 数组中下一个待使用的辅助证明节点位置,从第 0 个开始// ======================// 5. 开始核心逻辑:循环 proofFlagsLen 次,每次处理一次两两组合// ======================for (uint256 i = 0; i < proofFlagsLen; i++) {// 每一次循环,我们要组合两个节点 a 和 b,然后计算 hash(a, b),存入 hashes[i]// --------------------------------------------------// 第一步:选择第一个操作数 a// --------------------------------------------------bytes32 a;if (leafPos < leavesLen) {// 如果还有未使用的叶子节点,就从 leaves 中取第 leafPos 个元素作为 aa = leaves[leafPos];leafPos++; // 取完后,指针后移,指向下一个叶子} else {// 如果所有叶子都用完了(leafPos >= leavesLen),那就从之前算好的中间哈希中取a = hashes[hashPos];hashPos++; // 取完后,指针后移,指向下一个中间哈希}// --------------------------------------------------// 第二步:选择第二个操作数 b// --------------------------------------------------bytes32 b;if (proofFlags[i]) {// ✅ 如果 proofFlags[i] == true,表示 b 应该从 leaves 或 hashes 中取(不是从 proof!)if (leafPos < leavesLen) {// 如果还有未使用的叶子,就取 leaves[leafPos] 作为 bb = leaves[leafPos];leafPos++;} else {// 如果叶子用完了,就从之前计算的中间哈希中取 hashes[hashPos] 作为 bb = hashes[hashPos];hashPos++;}} else {// ❌ 如果 proofFlags[i] == false,表示 b 应该从 proof 数组中取(辅助证明节点)if (proofPos < proof.length) {// 如果 proof 还有数据,取 proof[proofPos] 作为 bb = proof[proofPos];proofPos++;} else {// 如果 proof 也用完了(理论上不应该发生,前提是参数校验过了),那就从 hashes 中取b = hashes[hashPos];hashPos++;}}// --------------------------------------------------// 第三步:计算 a 和 b 的组合哈希,并存入结果数组// --------------------------------------------------// 将 a 和 b 拼接后,计算 keccak256 哈希,这就是当前层的中间哈希hashes[i] = keccak256(abi.encodePacked(a, b));// 该哈希会被用于下一层的组合(如果有),或最终成为根哈希}// ======================// 6. 校验:确保所有的 proof 节点都已经被使用(没有多余的遗留)// ======================if (proofPos != proof.length) {revert MerkleProofInvalidMultiproof(); // 如果 proof 还有剩余,说明参数不匹配,证明无效}// ======================// 7. 返回最终的 Merkle Root(根哈希)// ======================// 根哈希就是最后一次计算得到的哈希,也就是 hashes 数组中的最后一个元素// 即:hashes[proofFlagsLen - 1]unchecked {return hashes[proofFlagsLen - 1]; // 返回根哈希}
}
五、逐步解析计算过程(一步步怎么算?)
初始状态:
输入参数:leaves: [L1, L2] // 要验证的叶子节点(目标数据)proof: [L0, L3, H23] // 辅助证明节点(兄弟节点/中间哈希)proofFlags: [false, false, true, false] // 操作指令,控制如何选择第二个操作数初始化指针和存储:leafPos = 0 // 指向leaves数组的当前位置(初始为0,即L1)hashPos = 0 // 指向hashes数组的当前位置(初始为0,尚未使用)proofPos = 0 // 指向proof数组的当前位置(初始为0,即L0)hashes: [空, 空, 空, 空] // 用于存储中间计算结果的哈希数组,共4个位置(对应proofFlags的长度)
参数验证:
// leavesLen + proof.length = proofFlagsLen + 1
// 2 + 3 = 4 + 1 → 5 = 5 ✓ 验证通过
计算步骤:
-
第一步:算
H0
(L1 + L0)// 初始状态变量设置: proofFlags[0] = false // 表示当前要处理的操作数 b(第二个操作数)不是来自 leaves,而是来自 proof(辅助证明节点) leafPos = 0 // 当前要处理的叶子节点索引,初始为 0,表示从第 1 个叶子开始 hashPos = 0 // 当前要填充的哈希结果数组索引,初始为 0,表示将第一个计算的哈希存入 hashes[0] proofPos = 0 // 当前要读取的 proof(辅助节点)数组索引,初始为 0// =========================== // 步骤 1:选择第一个操作数 a // =========================== // 判断是否还有未处理的叶子节点:leafPos (0) < leavesLen (2) → 还有叶子未处理 // 所以:从 leaves 数组中取出第 leafPos (0) 个元素,即第一个叶子节点 a = leaves[0] = L1 // 第一个操作数 a 是叶子节点 L1 // 处理完该叶子后,移动指针到下一个叶子 leafPos 更新为 1 // 下一步将处理第 2 个叶子(如果有的话),即 L2// =========================== // 步骤 2:选择第二个操作数 b // =========================== // 根据 proofFlags[0] 的值来决定 b 的来源: // proofFlags[0] = false → 表示 b 不是来自 leaves,而是来自 proof(即辅助证明节点) // 所以:从 proof 数组中按 proofPos 指定的位置取出 b // 当前 proofPos = 0 < proof.length = 3 → 还有可用的 proof 节点 b = proof[0] = L0 // 第二个操作数 b 是辅助证明节点中的第 1 个元素,即 L0 // 处理完该 proof 节点后,移动 proof 指针到下一个 proofPos 更新为 1 // 下一步将从 proof[1] 取数据(即 L3)// =========================== // 步骤 3:计算当前层的哈希值 // =========================== // 将两个操作数 a 和 b 拼接后做 keccak256 哈希运算 // 这里是将叶子 L1 和辅助节点 L0 拼接后计算哈希 hashes[0] = keccak256(abi.encodePacked(L1, L0)) = H0 // 即:H0 = hash(L1 || L0)// =========================== // 步骤 4:更新所有状态变量 // =========================== // leafPos = 1 // 已处理第 1 个叶子,接下来处理第 2 个叶子(L2) // hashPos = 0 // 当前哈希存入了 hashes[0],hashPos 暂未移动(如果后续有新哈希会递增) // proofPos = 1 // 已使用 proof[0],接下来将使用 proof[1] // hashes: [H0, 空, 空, 空] // 当前哈希数组中,第 0 个位置已经存好第一个中间哈希 H0,其余待填充// 总结当前阶段已完成: // - 从 leaves 取出 L1 作为 a // - 从 proof 取出 L0 作为 b(因为 proofFlags[0] == false) // - 计算 H0 = hash(L1 || L0),存入 hashes[0] // - 各指针已正确前移,准备进入下一轮计算(如处理 L2 与 L3 等)
-
第二步:算
H1
(L2 + L3)// 当前状态变量: proofFlags[1] = false // 表示当前这一层的第二个操作数 b 不是来自 leaves,而是来自 proof(辅助证明节点) leafPos = 1 // 当前处理的叶子节点索引为 1,即第二个叶子 L2 hashPos = 0 // 初始化为 0,但目前 leaves 还没用完,暂时不会从 hashes 中取数据。// 它的含义是:如果未来 leafPos >= leaves.length,就从 hashes[hashPos] 取操作数 proofPos = 1 // 当前从 proof 数组中读取的位置为 1,即下一个辅助节点是 proof[1]// ================================ // 步骤 1:选择第一个操作数 a // ================================ // 判断是否还有未使用的叶子节点:leafPos (1) < leaves.length (2) → 是,还有第 2 个叶子 // 操作:从 leaves 数组中取出第 1 个元素(索引从 0 开始,所以这是第 2 个叶子) a = leaves[1] = L2 // 第一个操作数 a 是第 2 个叶子节点 L2 // 处理完该叶子后,移动 leafPos 指针到下一个位置 leafPos 更新为 2 // 已经处理完全部 2 个叶子(L1 和 L2),没有更多叶子了// ================================ // 步骤 2:选择第二个操作数 b // ================================ // 根据 proofFlags[1] 的值判断 b 的来源: // proofFlags[1] = false → 表示 b 不是来自 leaves,而是来自 proof(辅助节点数组) // 检查:proofPos (1) < proof.length (3) → 是,proof 中还有可用的节点 // 操作:从 proof 数组中取出第 proofPos (1) 个元素,即第 2 个辅助节点 b = proof[1] = L3 // 第二个操作数 b 是辅助证明中的第 2 个节点 L3 // 处理完该 proof 节点后,将 proofPos 指针向后移动一位 proofPos 更新为 2 // 接下来将使用 proof[2](可能是 H23 或其它上层节点)// ================================ // 步骤 3:计算当前层的哈希值 // ================================ // 将两个操作数 a 和 b 拼接后,计算其 keccak256 哈希值 // 即:将叶子节点 L2 和辅助节点 L3 拼接后做哈希 hashes[1] = keccak256(abi.encodePacked(L2, L3)) = H1 // 即:H1 = hash(L2 || L3)// 此哈希将被存入 hashes 数组的第 1 个位置(即 hashes[1]) // 说明我们正在逐层构建 Merkle Tree 的中间哈希// ================================ // 步骤 4:更新所有状态变量 // ================================ // leafPos = 2 // 已处理完全部叶子节点(L1 和 L2),没有更多叶子了 // hashPos = 0 // 仍然为 0,因为目前还未从 hashes 中取任何数据// 它的含义是:如果未来 leafPos >= leaves.length,就从 hashes[hashPos] 取数据 // proofPos = 2 // 已使用 proof[1],接下来将使用 proof[2] // hashes: [H0, H1, 空, 空] // 现在 hashes 数组中:// - hashes[0] = H0 (来自 L1 和 L0)// - hashes[1] = H1 (来自 L2 和 L3)// - hashes[2] 和 hashes[3] 尚未使用/计算// ✅ 当前阶段已完成: // - 从 leaves 中取出了第 2 个叶子 L2 作为 a // - 从 proof 中取出了第 2 个辅助节点 L3 作为 b(因为 proofFlags[1] == false) // - 计算出第二个中间哈希 H1 = hash(L2 || L3),并将其存入 hashes[1] // - 各状态指针已正确更新,准备进入下一阶段(如处理上层哈希,或生成最终根)
-
第三步:算
H01
(H1 + H2)// 当前状态变量: proofFlags[2] = true // 表示当前这一层的两个操作数 a 和 b 都不是来自 leaves 或 proof,// 而是来自之前已经计算并存储在 hashes 数组中的中间哈希值 leafPos = 2 // 当前叶子索引为 2,等于 leaves.length(2),说明所有叶子节点已处理完毕,// 接下来如果需要操作数,只能从 hashes 中获取 hashPos = 0 // 当前从 hashes 数组中取数据的起始位置,初始为 0// 用于按顺序获取之前计算好的中间哈希(如 H0, H1, ...) proofPos = 2 // 当前 proof 数组读取位置为 2,但由于 proofFlags[2] = true,// 所以不会使用 proof,proofPos 为下一阶段备用// ================================ // 步骤 1:选择第一个操作数 a // ================================ // 判断是否还有未使用的叶子节点: // leafPos (2) >= leaves.length (2) → 是的,所有叶子已用完 // 所以:不能从 leaves 中取 a,而需要从之前计算好的中间哈希中获取// 根据规则,从 hashes 数组中按 hashPos 指定的位置取出操作数 // 当前 hashPos = 0 → 可用,hashes[0] 存在 a = hashes[0] = H0 // 第一个操作数 a 是之前计算得到的第 1 个中间哈希 H0 // 该哈希来自 hash(L1 || L2)// 处理完该 hashes 数据后,将 hashPos 指针向后移动一位 hashPos 更新为 1 // 下一步将尝试从 hashes[1] 获取下一个操作数// ================================ // 步骤 2:选择第二个操作数 b // ================================ // 根据 proofFlags[2] 的值: // proofFlags[2] = true → 表示 b 也不是来自 leaves 或 proof,而是来自 hashes// 同样地,从 hashes 数组中按当前 hashPos 指定的位置获取操作数 // 当前 hashPos = 1 → 可用,hashes[1] 存在 b = hashes[1] = H1 // 第二个操作数 b 是之前计算得到的第 2 个中间哈希 H1 // 该哈希来自 hash(L3 || L4)// 处理完该 hashes 数据后,将 hashPos 指针向后移动一位 hashPos 更新为 2 // 下一步若还有操作数,将从 hashes[2] 获取// ================================ // 步骤 3:计算当前层的哈希值 // ================================ // 将两个操作数 a 和 b 拼接后,计算它们的 keccak256 哈希值 // 即:将上一层的两个中间哈希 H0 和 H1 拼接后做哈希运算 hashes[2] = keccak256(abi.encodePacked(H0, H1)) = H01 // 即:H01 = hash(H0 || H1)// 此哈希将被存入 hashes 数组的第 2 个位置(即 hashes[2]) // 表示这是当前层(或当前步骤)所计算出的第 3 个中间哈希(0, 1, 2)// ================================ // 步骤 4:更新所有状态变量 // ================================ // leafPos = 2 // 所有叶子节点已处理完毕,保持为 2 // hashPos = 2 // 已使用 hashes[0] 和 hashes[1],下一个可用中间哈希是 hashes[2] // proofPos = 3 // 虽然未使用 proof,但 proofPos 自增到 3,为后续可能使用做准备 // hashes: [H0, H1, H01, 空] // 当前 hashes 数组内容为:// - hashes[0] = H0 (例如:hash(L1 || L0))// - hashes[1] = H1 (例如:hash(L2 || L3))// - hashes[2] = H01 (当前计算:hash(H0 || H1))// - hashes[3] = 空 (尚未使用/计算)// ✅ 当前阶段已完成: // - 由于所有叶子已用完(leafPos >= leaves.length),所以两个操作数 a 和 b 都是从 hashes 中获取 // - a = hashes[0] = H0,b = hashes[1] = H1 // - 计算得到新的中间哈希 H01 = hash(H0 || H1),存入 hashes[2] // - 各状态指针已正确更新: // - hashPos 移动到了 2,指向下一个可能的中间哈希(hashes[2]) // - proofPos 更新为 3(即使未使用,也递增以备后用) // - hashes 数组现在保存了三层中间结果:[H0, H1, H01, 空] // - 准备进入下一阶段(如继续向上构建 Merkle Tree,直到生成根哈希)
-
第四步:算最终 Root
// 当前状态变量: proofFlags[3] = false // 表示当前这一层的第二个操作数 b 不是来自 hashes,也不是来自 leaves,// 而是来自 proof(辅助证明节点数组) leafPos = 2 // 当前叶子索引为 2,等于 leaves.length(2),说明所有叶子节点已处理完毕,// 如果需要操作数,只能从 hashes 或 proof 中获取 hashPos = 2 // 当前从 hashes 数组中取数据的起始位置为 2// 之前已经使用了 hashes[0] 和 hashes[1],现在轮到 hashes[2] proofPos = 2 // 当前从 proof 数组中读取的位置为 2,// 因为 proofFlags[3] = false,所以会从中取出辅助证明节点// ================================ // 步骤 1:选择第一个操作数 a // ================================ // 判断是否还有未使用的叶子节点: // leafPos (2) >= leaves.length (2) → 是的,所有叶子已用完 // 所以:不能从 leaves 中取 a,而需要从之前计算好的中间哈希中获取// 根据规则,从 hashes 数组中按 hashPos 指定的位置取出操作数 // 当前 hashPos = 2 → 可用,hashes[2] 存在 a = hashes[2] = H01 // 第一个操作数 a 是之前计算出的第 3 个中间哈希 H01// 该哈希可能是上一层组合 hash(H0 || H1) 的结果// 处理完该 hashes 数据后,将 hashPos 指针向后移动一位 hashPos 更新为 3 // 下一步若还有操作数来自 hashes,将尝试从 hashes[3] 获取// ================================ // 步骤 2:选择第二个操作数 b // ================================ // 根据 proofFlags[3] 的值: // proofFlags[3] = false → 表示 b 不是来自 hashes,而是来自 proof(辅助节点数组)// 检查是否还有可用的 proof 节点: // proofPos = 2 < proof.length (3) → 是的,proof 中还有第 3 个元素可用(即 proof[2]) // 所以:从 proof 数组中取出第 proofPos (2) 个元素 b = proof[2] = H23 // 第二个操作数 b 是辅助证明中的第 3 个节点 H23// 该节点可能是上一层另一个分支的中间哈希// 处理完该 proof 节点后,将 proofPos 指针向后移动一位 proofPos 更新为 3 // 下一步将超出当前 proof 数组范围(如果还有后续步骤)// ================================ // 步骤 3:计算当前层的哈希值 // ================================ // 将两个操作数 a 和 b 拼接后,计算它们的 keccak256 哈希值 // 即:将上一层的两个中间哈希 H01(来自 hashes)和 H23(来自 proof)拼接后做哈希运算 hashes[3] = keccak256(abi.encodePacked(H01, H23)) = Root // 即:Root = hash(H01 || H23)// 此哈希将被存入 hashes 数组的第 3 个位置(即 hashes[3]) // 表示这是当前层(通常是最后一层)所计算出的最终根哈希// ================================ // 步骤 4:更新所有状态变量 // ================================ // leafPos = 2 // 所有叶子节点已处理完毕,保持为 2 // hashPos = 3 // 已使用 hashes[2],下一个可用中间哈希是 hashes[3](即Root) // proofPos = 3 // 已使用 proof[2],已到达或超出当前 proof 数组范围 // hashes: [H0, H1, H01, Root] // 当前 hashes 数组内容为:// - hashes[0] = H0 (例如:hash(L1 || L0))// - hashes[1] = H1 (例如:hash(L2 || L3))// - hashes[2] = H01 (例如:hash(H0 || H1))// - hashes[3] = Root (最终根哈希:hash(H01 || H23))// ✅ 当前阶段已完成: // - 由于所有叶子已用完(leafPos >= leaves.length),第一个操作数 a 从 hashes[2] = H01 获取 // - 由于 proofFlags[3] = false,第二个操作数 b 从 proof[2] = H23 获取 // - 将 a 和 b 拼接后计算出最终的根哈希:Root = hash(H01 || H23),存入 hashes[3] // - 各状态指针已正确更新: // - hashPos 移动到了 3(即Root) // - proofPos 移动到了 3(表示已使用最后一个辅助证明节点) // - hashes 数组完整保存了从叶子到根的完整中间过程:[H0, H1, H01, Root] // - 至此,Merkle Tree 的根哈希(Root)已成功计算并存储在 hashes[3]
最终:如果算出来的 Root
和区块链上的根哈希一致,就证明 L1
和 L2
是合法的!
六、总结
✅ 多证明验证的核心:
-
只用少量数据(L1, L2 + 几个辅助节点),就能证明它们属于整棵树。
-
不需要暴露所有数据,节省存储和计算。
✅ 为什么安全?
-
每一步哈希都是不可逆的,篡改任何一个叶子,根哈希都会变。
-
proofFlags 控制计算路径,确保计算顺序正确。
✅ 适用场景:
-
区块链验证(比如证明某个交易在区块里)
-
文件完整性检查(比如证明某个文件片段没被篡改)
-
去中心化存储(比如证明你存的数据是正确的)