From ca8fd51cb2907e6cbc3e140178dcaca4f496195e Mon Sep 17 00:00:00 2001 From: grapebaba Date: Thu, 6 Nov 2025 16:02:31 +0800 Subject: [PATCH 1/2] feat: init single merkle proof Signed-off-by: grapebaba --- src/persistent_merkle_tree/proof.zig | 113 +++++++++++++++++++ src/persistent_merkle_tree/proof_test.zig | 129 ++++++++++++++++++++++ src/persistent_merkle_tree/root.zig | 2 + 3 files changed, 244 insertions(+) create mode 100644 src/persistent_merkle_tree/proof.zig create mode 100644 src/persistent_merkle_tree/proof_test.zig diff --git a/src/persistent_merkle_tree/proof.zig b/src/persistent_merkle_tree/proof.zig new file mode 100644 index 0000000..a9fef8c --- /dev/null +++ b/src/persistent_merkle_tree/proof.zig @@ -0,0 +1,113 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const hashOne = @import("hashing").hashOne; +const GindexUint = @import("hashing").GindexUint; +const Node = @import("Node.zig"); +const Gindex = @import("gindex.zig").Gindex; + +const root_gindex_value: GindexUint = 1; + +pub const Error = error{ + /// Allocator or pool could not reserve enough memory. + OutOfMemory, + /// Provided generalized index is not part of the binary tree (must be >= 1). + InvalidGindex, + /// Navigation toward the requested index hit a leaf before reaching the expected depth. + InvalidNavigation, + /// Witness list length does not match the gindex path length. + InvalidWitnessLength, +}; + +pub const SingleProof = struct { + leaf: [32]u8, + witnesses: [][32]u8, + + pub fn deinit(self: *SingleProof, allocator: Allocator) void { + allocator.free(self.witnesses); + self.* = undefined; + } +}; + +/// Produces a single Merkle proof for the node at `gindex`. +pub fn createSingleProof( + allocator: Allocator, + pool: *Node.Pool, + root: Node.Id, + gindex: Gindex, +) (Node.Error || Error)!SingleProof { + if (@intFromEnum(gindex) < root_gindex_value) { + return error.InvalidGindex; + } + + const path_len = gindex.pathLen(); + var witnesses = try allocator.alloc([32]u8, path_len); + errdefer allocator.free(witnesses); + + if (path_len == 0) { + return SingleProof{ + .leaf = root.getRoot(pool).*, + .witnesses = witnesses, + }; + } + + var node_id = root; + var path = gindex.toPath(); + + const witness_leaf_offset: usize = 1; + + for (0..path_len) |depth_idx| { + const witness_index = path_len - witness_leaf_offset - depth_idx; + + if (path.left()) { + const right_id = try node_id.getRight(pool); + witnesses[witness_index] = right_id.getRoot(pool).*; + node_id = try node_id.getLeft(pool); + } else { + const left_id = try node_id.getLeft(pool); + witnesses[witness_index] = left_id.getRoot(pool).*; + node_id = try node_id.getRight(pool); + } + + path.next(); + } + + return SingleProof{ + .leaf = node_id.getRoot(pool).*, + .witnesses = witnesses, + }; +} + +/// Build a fresh node tree from a single Merkle proof. +pub fn createNodeFromSingleProof( + pool: *Node.Pool, + gindex: Gindex, + leaf: [32]u8, + witnesses: []const [32]u8, +) (Node.Error || Error)!Node.Id { + if (@intFromEnum(gindex) < root_gindex_value) { + return error.InvalidGindex; + } + + const path_len = gindex.pathLen(); + if (witnesses.len != path_len) { + return error.InvalidWitnessLength; + } + + var node_id = try pool.createLeaf(&leaf, false); + var index_value: GindexUint = @intFromEnum(gindex); + + for (witnesses) |witness| { + const sibling_id = try pool.createLeaf(&witness, false); + + node_id = try if ((index_value & 1) == 0) + pool.createBranch(node_id, sibling_id, false) + else + pool.createBranch(sibling_id, node_id, false); + index_value >>= 1; + } + + // Raise the reference count so callers own the result. + try pool.ref(node_id); + return node_id; +} diff --git a/src/persistent_merkle_tree/proof_test.zig b/src/persistent_merkle_tree/proof_test.zig new file mode 100644 index 0000000..aa037c1 --- /dev/null +++ b/src/persistent_merkle_tree/proof_test.zig @@ -0,0 +1,129 @@ +const std = @import("std"); +const testing = std.testing; + +const Node = @import("Node.zig"); +const Gindex = @import("gindex.zig").Gindex; +const proof = @import("proof.zig"); +const Depth = @import("hashing").Depth; + +fn makeLeaf(value: u8) [32]u8 { + var out: [32]u8 = [_]u8{0} ** 32; + out[0] = value; + return out; +} + +fn buildFullTree(pool: *Node.Pool, depth: usize, next_value: *u8) Node.Error!Node.Id { + if (depth == 0) { + const leaf_hash = makeLeaf(next_value.*); + next_value.* +%= 1; + return pool.createLeaf(&leaf_hash, false); + } + + const left = try buildFullTree(pool, depth - 1, next_value); + const right = try buildFullTree(pool, depth - 1, next_value); + return pool.createBranch(left, right, false); +} + +// Verifies a proof for gindex 6 (depth 2, index 2) reconstructs the original root. +test "single proof roundtrip" { + var pool = try Node.Pool.init(testing.allocator, 128); + defer pool.deinit(); + + const leaf_hashes = [_][32]u8{ + makeLeaf(1), + makeLeaf(2), + makeLeaf(3), + makeLeaf(4), + }; + + const leaf0 = try pool.createLeaf(&leaf_hashes[0], false); + const leaf1 = try pool.createLeaf(&leaf_hashes[1], false); + const leaf2 = try pool.createLeaf(&leaf_hashes[2], false); + const leaf3 = try pool.createLeaf(&leaf_hashes[3], false); + + const left = try pool.createBranch(leaf0, leaf1, false); + const right = try pool.createBranch(leaf2, leaf3, false); + const root = try pool.createBranch(left, right, true); + defer pool.unref(root); + + const gindex = Gindex.fromDepth(2, 2); + + var single_proof = try proof.createSingleProof(testing.allocator, &pool, root, gindex); + defer single_proof.deinit(testing.allocator); + + try testing.expectEqualSlices(u8, &leaf_hashes[2], &single_proof.leaf); + try testing.expectEqual(@as(usize, 2), single_proof.witnesses.len); + + const root_hash = root.getRoot(&pool).*; + + var pool2 = try Node.Pool.init(testing.allocator, 128); + defer pool2.deinit(); + + const reconstructed = try proof.createNodeFromSingleProof(&pool2, gindex, single_proof.leaf, single_proof.witnesses); + defer pool2.unref(reconstructed); + + const reconstructed_hash = reconstructed.getRoot(&pool2).*; + try testing.expectEqualSlices(u8, &root_hash, &reconstructed_hash); +} + +// Checks every leaf in a depth-4 tree produces the same root after reconstruction. +test "single proof root matches across leaves" { + const build_depth: usize = 4; + const pool_capacity: u32 = @intCast((@as(usize, 1) << (build_depth + 1))); + + var pool = try Node.Pool.init(testing.allocator, pool_capacity); + defer pool.deinit(); + + var next_value: u8 = 1; + const raw_root = try buildFullTree(&pool, build_depth, &next_value); + try pool.ref(raw_root); + defer pool.unref(raw_root); + + const expected_root = raw_root.getRoot(&pool).*; + const leaf_depth: Depth = @intCast(build_depth); + const leaf_count = @as(usize, 1) << build_depth; + + for (0..leaf_count) |leaf_index| { + const gindex = Gindex.fromDepth(leaf_depth, leaf_index); + var single_proof = try proof.createSingleProof(testing.allocator, &pool, raw_root, gindex); + defer single_proof.deinit(testing.allocator); + + var temp_pool = try Node.Pool.init(testing.allocator, 64); + defer temp_pool.deinit(); + + const rebuilt = try proof.createNodeFromSingleProof(&temp_pool, gindex, single_proof.leaf, single_proof.witnesses); + defer temp_pool.unref(rebuilt); + + const rebuilt_root = rebuilt.getRoot(&temp_pool).*; + try testing.expectEqualSlices(u8, &expected_root, &rebuilt_root); + } +} + +// Attempting to prove beyond the tree height should bubble up Node.InvalidNode. +test "single proof invalid navigation" { + var pool = try Node.Pool.init(testing.allocator, 64); + defer pool.deinit(); + + const leaf_hash = makeLeaf(42); + const root = try pool.createLeaf(&leaf_hash, true); + defer pool.unref(root); + + const gindex = Gindex.fromDepth(3, 0); + try testing.expectError(Node.Error.InvalidNode, proof.createSingleProof(testing.allocator, &pool, root, gindex)); +} + +// Zero gindex must be rejected by both proof creation and reconstruction entry points. +test "single proof invalid gindex" { + var pool = try Node.Pool.init(testing.allocator, 8); + defer pool.deinit(); + + const leaf_hash = makeLeaf(9); + const root = try pool.createLeaf(&leaf_hash, true); + defer pool.unref(root); + + const zero_gindex: Gindex = @enumFromInt(0); + try testing.expectError(proof.Error.InvalidGindex, proof.createSingleProof(testing.allocator, &pool, root, zero_gindex)); + + const empty_witnesses: []const [32]u8 = &[_][32]u8{}; + try testing.expectError(proof.Error.InvalidGindex, proof.createNodeFromSingleProof(&pool, zero_gindex, leaf_hash, empty_witnesses)); +} diff --git a/src/persistent_merkle_tree/root.zig b/src/persistent_merkle_tree/root.zig index 1628cbf..78eef88 100644 --- a/src/persistent_merkle_tree/root.zig +++ b/src/persistent_merkle_tree/root.zig @@ -8,8 +8,10 @@ pub const max_depth = @import("hashing").max_depth; pub const Gindex = @import("gindex.zig").Gindex; pub const Node = @import("Node.zig"); pub const View = @import("View.zig"); +pub const proof = @import("proof.zig"); test { testing.refAllDeclsRecursive(@This()); testing.refAllDecls(@import("node_test.zig")); + testing.refAllDecls(@import("proof_test.zig")); } From ae25c2fac27d66ba6e459ee71e8a50e5118131fe Mon Sep 17 00:00:00 2001 From: grapebaba Date: Fri, 7 Nov 2025 15:09:23 +0800 Subject: [PATCH 2/2] fix: fix review comment and add unref when error Signed-off-by: grapebaba --- src/persistent_merkle_tree/proof.zig | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/persistent_merkle_tree/proof.zig b/src/persistent_merkle_tree/proof.zig index a9fef8c..77a081c 100644 --- a/src/persistent_merkle_tree/proof.zig +++ b/src/persistent_merkle_tree/proof.zig @@ -1,7 +1,6 @@ const std = @import("std"); const Allocator = std.mem.Allocator; -const hashOne = @import("hashing").hashOne; const GindexUint = @import("hashing").GindexUint; const Node = @import("Node.zig"); const Gindex = @import("gindex.zig").Gindex; @@ -13,8 +12,6 @@ pub const Error = error{ OutOfMemory, /// Provided generalized index is not part of the binary tree (must be >= 1). InvalidGindex, - /// Navigation toward the requested index hit a leaf before reaching the expected depth. - InvalidNavigation, /// Witness list length does not match the gindex path length. InvalidWitnessLength, }; @@ -54,10 +51,8 @@ pub fn createSingleProof( var node_id = root; var path = gindex.toPath(); - const witness_leaf_offset: usize = 1; - for (0..path_len) |depth_idx| { - const witness_index = path_len - witness_leaf_offset - depth_idx; + const witness_index = path_len - 1 - depth_idx; if (path.left()) { const right_id = try node_id.getRight(pool); @@ -95,15 +90,18 @@ pub fn createNodeFromSingleProof( } var node_id = try pool.createLeaf(&leaf, false); + errdefer pool.unref(node_id); var index_value: GindexUint = @intFromEnum(gindex); for (witnesses) |witness| { const sibling_id = try pool.createLeaf(&witness, false); + errdefer pool.unref(sibling_id); node_id = try if ((index_value & 1) == 0) pool.createBranch(node_id, sibling_id, false) else pool.createBranch(sibling_id, node_id, false); + index_value >>= 1; }