From b2f073d38c22372c724d2b5d2ef8ae3341b74a90 Mon Sep 17 00:00:00 2001 From: Chetany Bhardwaj Date: Tue, 14 Oct 2025 02:18:47 +0530 Subject: [PATCH 1/9] feat: add Poseidon2 hasher to build system with validation --- build.zig | 54 +++++++++++++++++++++++++++++++++++++++++++++++++++ build.zig.zon | 7 +++++++ 2 files changed, 61 insertions(+) diff --git a/build.zig b/build.zig index 448a6a1..d4d4c4e 100644 --- a/build.zig +++ b/build.zig @@ -1,3 +1,4 @@ +const std = @import("std"); const Builder = @import("std").Build; pub fn build(b: *Builder) void { @@ -35,4 +36,57 @@ pub fn build(b: *Builder) void { const test_step = b.step("test", "Run library tests"); test_step.dependOn(&run_main_tests.step); test_step.dependOn(&run_tests_tests.step); + + // Poseidon hasher build options + const poseidon_enabled = b.option(bool, "poseidon", "Enable Poseidon2 hash support") orelse false; + const poseidon_field = b.option([]const u8, "poseidon-field", "Poseidon2 field variant (babybear|koalabear)") orelse "koalabear"; + + // Validate poseidon fields + if (poseidon_enabled) { + const valid_fields = [_][]const u8{ "babybear", "koalabear" }; + var field_valid = false; + for (valid_fields) |valid_field| { + if (std.mem.eql(u8, poseidon_field, valid_field)) { + field_valid = true; + break; + } + } + if (!field_valid) { + std.log.err("Invalid Poseidon2 field configuration: '{s}'", .{poseidon_field}); + std.log.err("Valid field options are:\n1) 'koalabear'\n2) 'babybear'", .{}); + std.log.err("Usage examples:", .{}); + std.log.err("zig build -Dposeidon=true -Dposeidon-field=koalabear", .{}); + std.log.err("zig build -Dposeidon=true -Dposeidon-field=koalabear", .{}); + std.log.err("If no field is specified 'koalabear' will be used as the default.", .{}); + } + + std.log.info("Poseidon2 enabled with field: '{s}'", .{poseidon_field}); + } + + // Create build options + const options = b.addOptions(); + options.addOption(bool, "poseidon_enabled", poseidon_enabled); + options.addOption([]const u8, "poseidon_field", poseidon_field); + + // Get poseidon dependency once if enabled + const poseidon_module = if (poseidon_enabled) blk: { + const poseidon_dep = b.dependency("poseidon", .{ + .target = target, + .optimize = optimize, + }); + break :blk poseidon_dep.module("poseidon"); + } else null; + + // Add build options and poseidon import to all artifacts + mod.addOptions("build_options", options); + if (poseidon_module) |pm| mod.addImport("poseidon", pm); + + lib.root_module.addOptions("build_options", options); + if (poseidon_module) |pm| lib.root_module.addImport("poseidon", pm); + + main_tests.root_module.addOptions("build_options", options); + if (poseidon_module) |pm| main_tests.root_module.addImport("poseidon", pm); + + tests_tests.root_module.addOptions("build_options", options); + if (poseidon_module) |pm| tests_tests.root_module.addImport("poseidon", pm); } diff --git a/build.zig.zon b/build.zig.zon index b3a5565..cf52a6c 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -3,4 +3,11 @@ .fingerprint = 0x1d34bd0ceb1dfc2d, .version = "0.0.8", .paths = .{""}, + .dependencies = .{ + .poseidon = .{ + .url = "https://github.com/blockblaz/zig-poseidon/archive/refs/tags/v0.2.0.tar.gz", + .hash = "zig_poseidon-0.2.0-CHeW2H-SAAC83l4JGZOODgmgfEFpBa_KokE9oO3ilcf1", + }, + + }, } From 44db3929f935403cc26badf0eb6d8441e9c32a11 Mon Sep 17 00:00:00 2001 From: Chetany Bhardwaj Date: Fri, 17 Oct 2025 18:52:19 +0530 Subject: [PATCH 2/9] feature: add poseidon hasher support --- src/lib.zig | 49 ++++++++++------ src/poseidon_wrapper.zig | 122 +++++++++++++++++++++++++++++++++++++++ src/utils.zig | 8 +-- 3 files changed, 158 insertions(+), 21 deletions(-) create mode 100644 src/poseidon_wrapper.zig diff --git a/src/lib.zig b/src/lib.zig index 381f648..a74c826 100644 --- a/src/lib.zig +++ b/src/lib.zig @@ -6,7 +6,22 @@ pub const utils = @import("./utils.zig"); pub const zeros = @import("./zeros.zig"); const ArrayList = std.ArrayList; const builtin = std.builtin; +const build_options = @import("build_options"); const sha256 = std.crypto.hash.sha2.Sha256; + +// Configure the hasher based on build options +pub const Hasher = if (build_options.poseidon_enabled) blk: { + const poseidon = @import("poseidon"); + const poseidon_wrapper = @import("./poseidon_wrapper.zig"); + // Select the appropriate Poseidon2 hasher based on field configuration + const Poseidon2Type = if (std.mem.eql(u8, build_options.poseidon_field, "babybear")) + poseidon.Poseidon2BabyBear + else + poseidon.Poseidon2KoalaBear16; + // Wrap with SHA256-compatible API + break :blk poseidon_wrapper.PoseidonHasher(Poseidon2Type); +} else sha256; + const hashes_of_zero = zeros.hashes_of_zero; const Allocator = std.mem.Allocator; @@ -505,7 +520,7 @@ pub fn deserialize(comptime T: type, serialized: []const u8, out: *T, allocator: } pub fn mixInLength2(root: [32]u8, length: usize, out: *[32]u8) void { - var hasher = sha256.init(sha256.Options{}); + var hasher = Hasher.init(Hasher.Options{}); hasher.update(root[0..]); var tmp = [_]u8{0} ** 32; @@ -515,7 +530,7 @@ pub fn mixInLength2(root: [32]u8, length: usize, out: *[32]u8) void { } fn mixInLength(root: [32]u8, length: [32]u8, out: *[32]u8) void { - var hasher = sha256.init(sha256.Options{}); + var hasher = Hasher.init(Hasher.Options{}); hasher.update(root[0..]); hasher.update(length[0..]); hasher.final(out[0..]); @@ -535,7 +550,7 @@ test "mixInLength" { } fn mixInSelector(root: [32]u8, comptime selector: usize, out: *[32]u8) void { - var hasher = sha256.init(sha256.Options{}); + var hasher = Hasher.init(Hasher.Options{}); hasher.update(root[0..]); var tmp = [_]u8{0} ** 32; std.mem.writeInt(@TypeOf(selector), tmp[0..@sizeOf(@TypeOf(selector))], selector, std.builtin.Endian.little); @@ -671,7 +686,7 @@ test "merkleize an empty slice" { defer list.deinit(); const chunks = &[0][32]u8{}; var out: [32]u8 = undefined; - try merkleize(sha256, chunks, null, &out); + try merkleize(Hasher, chunks, null, &out); try std.testing.expect(std.mem.eql(u8, out[0..], zero_chunk[0..])); } @@ -680,22 +695,22 @@ test "merkleize a string" { defer list.deinit(); const chunks = try pack([]const u8, "a" ** 100, &list); var out: [32]u8 = undefined; - try merkleize(sha256, chunks, null, &out); + try merkleize(Hasher, chunks, null, &out); // Build the expected tree const leaf1 = [_]u8{0x61} ** 32; // "0xaaaaa....aa" 32 times var leaf2: [32]u8 = [_]u8{0x61} ** 4 ++ [_]u8{0} ** 28; var root: [32]u8 = undefined; var internal_left: [32]u8 = undefined; var internal_right: [32]u8 = undefined; - var hasher = sha256.init(sha256.Options{}); + var hasher = Hasher.init(Hasher.Options{}); hasher.update(leaf1[0..]); hasher.update(leaf1[0..]); hasher.final(&internal_left); - hasher = sha256.init(sha256.Options{}); + hasher = Hasher.init(Hasher.Options{}); hasher.update(leaf1[0..]); hasher.update(leaf2[0..]); hasher.final(&internal_right); - hasher = sha256.init(sha256.Options{}); + hasher = Hasher.init(Hasher.Options{}); hasher.update(internal_left[0..]); hasher.update(internal_right[0..]); hasher.final(&root); @@ -710,7 +725,7 @@ test "merkleize a boolean" { var chunks = try pack(bool, false, &list); var expected = [_]u8{0} ** BYTES_PER_CHUNK; var out: [BYTES_PER_CHUNK]u8 = undefined; - try merkleize(sha256, chunks, null, &out); + try merkleize(Hasher, chunks, null, &out); try std.testing.expect(std.mem.eql(u8, out[0..], expected[0..])); @@ -719,7 +734,7 @@ test "merkleize a boolean" { chunks = try pack(bool, true, &list2); expected[0] = 1; - try merkleize(sha256, chunks, null, &out); + try merkleize(Hasher, chunks, null, &out); try std.testing.expect(std.mem.eql(u8, out[0..], expected[0..])); } @@ -764,7 +779,7 @@ pub fn hashTreeRoot(comptime T: type, value: T, out: *[32]u8, allctr: Allocator) var list = ArrayList(u8).init(allctr); defer list.deinit(); const chunks = try pack(T, value, &list); - try merkleize(sha256, chunks, null, out); + try merkleize(Hasher, chunks, null, out); }, .array => |a| { // Check if the child is a basic type. If so, return @@ -776,13 +791,13 @@ pub fn hashTreeRoot(comptime T: type, value: T, out: *[32]u8, allctr: Allocator) var list = ArrayList(u8).init(allctr); defer list.deinit(); const chunks = try pack(T, value, &list); - try merkleize(sha256, chunks, null, out); + try merkleize(Hasher, chunks, null, out); }, .bool => { var list = ArrayList(u8).init(allctr); defer list.deinit(); const chunks = try packBits(value[0..], &list); - try merkleize(sha256, chunks, chunkCount(T), out); + try merkleize(Hasher, chunks, chunkCount(T), out); }, .array => { var chunks = ArrayList(chunk).init(allctr); @@ -792,7 +807,7 @@ pub fn hashTreeRoot(comptime T: type, value: T, out: *[32]u8, allctr: Allocator) try hashTreeRoot(@TypeOf(item), item, &tmp, allctr); try chunks.append(tmp); } - try merkleize(sha256, chunks.items, null, out); + try merkleize(Hasher, chunks.items, null, out); }, else => return error.NotSupported, } @@ -807,7 +822,7 @@ pub fn hashTreeRoot(comptime T: type, value: T, out: *[32]u8, allctr: Allocator) defer list.deinit(); const chunks = try pack(T, value, &list); var tmp: chunk = undefined; - try merkleize(sha256, chunks, null, &tmp); + try merkleize(Hasher, chunks, null, &tmp); mixInLength2(tmp, value.len, out); }, // use bitlist @@ -821,7 +836,7 @@ pub fn hashTreeRoot(comptime T: type, value: T, out: *[32]u8, allctr: Allocator) try hashTreeRoot(@TypeOf(item), item, &tmp, allctr); try chunks.append(tmp); } - try merkleize(sha256, chunks.items, null, &tmp); + try merkleize(Hasher, chunks.items, null, &tmp); mixInLength2(tmp, chunks.items.len, out); }, } @@ -837,7 +852,7 @@ pub fn hashTreeRoot(comptime T: type, value: T, out: *[32]u8, allctr: Allocator) try hashTreeRoot(f.type, @field(value, f.name), &tmp, allctr); try chunks.append(tmp); } - try merkleize(sha256, chunks.items, null, out); + try merkleize(Hasher, chunks.items, null, out); }, // An optional is a union with `None` as first value. .optional => |opt| if (value != null) { diff --git a/src/poseidon_wrapper.zig b/src/poseidon_wrapper.zig new file mode 100644 index 0000000..9e42a76 --- /dev/null +++ b/src/poseidon_wrapper.zig @@ -0,0 +1,122 @@ +//! Provides a SHA256-compatible API wrapper for Poseidon2 hash function. +//! This allows Poseidon2 to be used as a drop-in replacement for SHA256 +//! in merkleization and hash tree root operations. +//! +//! IMPORTANT: This is a specialized wrapper for SSZ merkleization, which always +//! provides exactly 64 bytes (two 32-byte hashes). It is NOT a general-purpose +//! hash function and will produce collisions for variable-length inputs due to +//! simple zero-padding (e.g., "abc" and "abc\x00" would hash identically). + +const std = @import("std"); + +/// Creates a hasher type that wraps a Poseidon2 instance with SHA256-like API +pub fn PoseidonHasher(comptime Poseidon2Type: type) type { + const WIDTH = 16; // Poseidon2 width (16 field elements) + const FIELD_ELEM_SIZE = 4; // u32 = 4 bytes + const BUFFER_SIZE = WIDTH * FIELD_ELEM_SIZE; // 64 bytes + const OUTPUT_FIELD_ELEMS = 8; // 8 u32s = 32 bytes output + + return struct { + const Self = @This(); + + // Accumulated input bytes + buffer: [BUFFER_SIZE]u8, + buffer_len: usize, + + /// Options struct for compatibility with std.crypto.hash API + pub const Options = struct {}; + + /// Initialize a new hasher instance + pub fn init(_: Options) Self { + return .{ + .buffer = undefined, + .buffer_len = 0, + }; + } + + /// Update the hasher with new data + /// Note: This accumulates data. Poseidon2 requires exactly 64 bytes, + /// so we buffer until we have enough data. + pub fn update(self: *Self, data: []const u8) void { + // Enforce the 64-byte limit explicitly + std.debug.assert(self.buffer_len + data.len <= BUFFER_SIZE); + + // Copy data into buffer + const space_left = BUFFER_SIZE - self.buffer_len; + const copy_len = @min(data.len, space_left); + + @memcpy(self.buffer[self.buffer_len..][0..copy_len], data[0..copy_len]); + self.buffer_len += copy_len; + } + + /// Finalize the hash and write the result to out + pub fn final(self: *Self, out: *[32]u8) void { + // Pad buffer to 64 bytes if needed + if (self.buffer_len < BUFFER_SIZE) { + @memset(self.buffer[self.buffer_len..BUFFER_SIZE], 0); + } + + // Convert bytes to field elements (u32s) using little-endian encoding + var input: [WIDTH]u32 = undefined; + for (0..WIDTH) |i| { + input[i] = std.mem.readInt(u32, self.buffer[i * FIELD_ELEM_SIZE ..][0..FIELD_ELEM_SIZE], .little) % Poseidon2Type.Field.MODULUS; + } + + // Hash with Poseidon2 compress function + // Output 8 field elements (32 bytes total) + const output = Poseidon2Type.compress(OUTPUT_FIELD_ELEMS, input); + + // Convert field elements back to bytes using little-endian encoding + for (0..OUTPUT_FIELD_ELEMS) |i| { + std.mem.writeInt(u32, out[i * FIELD_ELEM_SIZE ..][0..FIELD_ELEM_SIZE], output[i], .little); + } + + // Reset buffer for potential reuse + self.buffer_len = 0; + } + }; +} + +test "PoseidonHasher basic API" { + // This test just verifies the API compiles and runs + // Actual hash correctness should be verified against known test vectors + const poseidon = @import("poseidon"); + const Hasher = PoseidonHasher(poseidon.Poseidon2KoalaBear16); + + var hasher = Hasher.init(.{}); + const data = "test data for hashing"; + hasher.update(data); + + var output: [32]u8 = undefined; + hasher.final(&output); + + // Just verify we got some output (not all zeros) + var has_nonzero = false; + for (output) |byte| { + if (byte != 0) { + has_nonzero = true; + break; + } + } + try std.testing.expect(has_nonzero); +} + +test "PoseidonHasher deterministic" { + // Verify same input produces same output + const poseidon = @import("poseidon"); + const Hasher = PoseidonHasher(poseidon.Poseidon2KoalaBear16); + + var hasher1 = Hasher.init(.{}); + var hasher2 = Hasher.init(.{}); + + const data = "deterministic test data"; + hasher1.update(data); + hasher2.update(data); + + var output1: [32]u8 = undefined; + var output2: [32]u8 = undefined; + hasher1.final(&output1); + hasher2.final(&output2); + + try std.testing.expectEqualSlices(u8, &output1, &output2); +} diff --git a/src/utils.zig b/src/utils.zig index e24074c..73d277e 100644 --- a/src/utils.zig +++ b/src/utils.zig @@ -7,7 +7,7 @@ const deserialize = lib.deserialize; const isFixedSizeObject = lib.isFixedSizeObject; const ArrayList = std.ArrayList; const Allocator = std.mem.Allocator; -const sha256 = std.crypto.hash.sha2.Sha256; +const Hasher = lib.Hasher; const hashes_of_zero = @import("./zeros.zig").hashes_of_zero; // SSZ specification constants @@ -155,7 +155,7 @@ pub fn List(comptime T: type, comptime N: usize) type { const items_per_chunk = BYTES_PER_CHUNK / bytes_per_item; const chunks_for_max_capacity = (N + items_per_chunk - 1) / items_per_chunk; var tmp: chunk = undefined; - try lib.merkleize(sha256, chunks, chunks_for_max_capacity, &tmp); + try lib.merkleize(Hasher, chunks, chunks_for_max_capacity, &tmp); lib.mixInLength2(tmp, items.len, out); }, else => { @@ -168,7 +168,7 @@ pub fn List(comptime T: type, comptime N: usize) type { } // Always use N (max capacity) for merkleization, even when empty // This ensures proper tree depth according to SSZ specification - try lib.merkleize(sha256, chunks.items, N, &tmp); + try lib.merkleize(Hasher, chunks.items, N, &tmp); lib.mixInLength2(tmp, items.len, out); }, } @@ -337,7 +337,7 @@ pub fn Bitlist(comptime N: usize) type { // Use chunk_count limit as per SSZ specification const chunk_count_limit = (N + 255) / 256; - try lib.merkleize(sha256, chunks, chunk_count_limit, &tmp); + try lib.merkleize(Hasher, chunks, chunk_count_limit, &tmp); lib.mixInLength2(tmp, bit_length, out); } From fbb1efe0914fed0ca0dd43e31072fed5524622ec Mon Sep 17 00:00:00 2001 From: Chetany Bhardwaj Date: Tue, 21 Oct 2025 19:27:34 +0530 Subject: [PATCH 3/9] point to fixed remote of zig-poseidon --- build.zig.zon | 5 ++--- src/poseidon_wrapper.zig | 24 ++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index cf52a6c..f6ac4d2 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -5,9 +5,8 @@ .paths = .{""}, .dependencies = .{ .poseidon = .{ - .url = "https://github.com/blockblaz/zig-poseidon/archive/refs/tags/v0.2.0.tar.gz", - .hash = "zig_poseidon-0.2.0-CHeW2H-SAAC83l4JGZOODgmgfEFpBa_KokE9oO3ilcf1", + .url = "https://github.com/blockblaz/zig-poseidon/archive/c2281f863a4c51c5f7e2957e266744e158ded6ca.tar.gz", + .hash = "zig_poseidon-0.2.0-CHeW2LS9AAB5ltpBgNJTHcTczmyAJjp7i_dwubSACNOR", }, - }, } diff --git a/src/poseidon_wrapper.zig b/src/poseidon_wrapper.zig index 9e42a76..a9fb714 100644 --- a/src/poseidon_wrapper.zig +++ b/src/poseidon_wrapper.zig @@ -120,3 +120,27 @@ test "PoseidonHasher deterministic" { try std.testing.expectEqualSlices(u8, &output1, &output2); } + +test "PoseidonHasher different inputs produce different outputs" { + // Verify different inputs produce different outputs + const poseidon = @import("poseidon"); + const Hasher = PoseidonHasher(poseidon.Poseidon2KoalaBear16); + + var hasher1 = Hasher.init(.{}); + var hasher2 = Hasher.init(.{}); + + const data1 = "first test data"; + const data2 = "second test data"; + + hasher1.update(data1); + hasher2.update(data2); + + var output1: [32]u8 = undefined; + var output2: [32]u8 = undefined; + hasher1.final(&output1); + hasher2.final(&output2); + + // Verify outputs are different + const are_equal = std.mem.eql(u8, &output1, &output2); + try std.testing.expect(!are_equal); +} From 13fd4afe4083332744b13223a4c00ceb08e64a4f Mon Sep 17 00:00:00 2001 From: Chetany Bhardwaj Date: Sat, 15 Nov 2025 22:55:37 +0530 Subject: [PATCH 4/9] chore: update zig-poseidon dep --- build.zig.zon | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index f6ac4d2..a9936af 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -5,8 +5,8 @@ .paths = .{""}, .dependencies = .{ .poseidon = .{ - .url = "https://github.com/blockblaz/zig-poseidon/archive/c2281f863a4c51c5f7e2957e266744e158ded6ca.tar.gz", - .hash = "zig_poseidon-0.2.0-CHeW2LS9AAB5ltpBgNJTHcTczmyAJjp7i_dwubSACNOR", + .url = "https://github.com/blockblaz/zig-poseidon/archive/5c68c7e79361abb92ecfed3ff95ef795437452b3.tar.gz", + .hash = "zig_poseidon-0.2.0-CHeW2CRPAQBERdItC_QZGgNHZ4_Zcz1z_r1kIpZhq4a4", }, }, } From 7a53dfec54a22ac245bc424960e4f60c7d9dc599 Mon Sep 17 00:00:00 2001 From: Chetany Bhardwaj Date: Fri, 19 Dec 2025 16:29:15 +0530 Subject: [PATCH 5/9] chore: point to latest hash-zig with width check support --- build.zig | 39 +++++++++------------------------------ build.zig.zon | 6 +++--- 2 files changed, 12 insertions(+), 33 deletions(-) diff --git a/build.zig b/build.zig index d4d4c4e..66d848f 100644 --- a/build.zig +++ b/build.zig @@ -39,54 +39,33 @@ pub fn build(b: *Builder) void { // Poseidon hasher build options const poseidon_enabled = b.option(bool, "poseidon", "Enable Poseidon2 hash support") orelse false; - const poseidon_field = b.option([]const u8, "poseidon-field", "Poseidon2 field variant (babybear|koalabear)") orelse "koalabear"; - - // Validate poseidon fields if (poseidon_enabled) { - const valid_fields = [_][]const u8{ "babybear", "koalabear" }; - var field_valid = false; - for (valid_fields) |valid_field| { - if (std.mem.eql(u8, poseidon_field, valid_field)) { - field_valid = true; - break; - } - } - if (!field_valid) { - std.log.err("Invalid Poseidon2 field configuration: '{s}'", .{poseidon_field}); - std.log.err("Valid field options are:\n1) 'koalabear'\n2) 'babybear'", .{}); - std.log.err("Usage examples:", .{}); - std.log.err("zig build -Dposeidon=true -Dposeidon-field=koalabear", .{}); - std.log.err("zig build -Dposeidon=true -Dposeidon-field=koalabear", .{}); - std.log.err("If no field is specified 'koalabear' will be used as the default.", .{}); - } - - std.log.info("Poseidon2 enabled with field: '{s}'", .{poseidon_field}); + std.log.info("Poseidon2 enabled (koalabear, Poseidon2-24 Plonky3)", .{}); } // Create build options const options = b.addOptions(); options.addOption(bool, "poseidon_enabled", poseidon_enabled); - options.addOption([]const u8, "poseidon_field", poseidon_field); - // Get poseidon dependency once if enabled - const poseidon_module = if (poseidon_enabled) blk: { - const poseidon_dep = b.dependency("poseidon", .{ + // Poseidon2 implementation via hash-zig dependency + const hashzig_module = if (poseidon_enabled) blk: { + const hashzig_dep = b.dependency("hash_zig", .{ .target = target, .optimize = optimize, }); - break :blk poseidon_dep.module("poseidon"); + break :blk hashzig_dep.module("hash-zig"); } else null; // Add build options and poseidon import to all artifacts mod.addOptions("build_options", options); - if (poseidon_module) |pm| mod.addImport("poseidon", pm); + if (hashzig_module) |pm| mod.addImport("hash_zig", pm); lib.root_module.addOptions("build_options", options); - if (poseidon_module) |pm| lib.root_module.addImport("poseidon", pm); + if (hashzig_module) |pm| lib.root_module.addImport("hash_zig", pm); main_tests.root_module.addOptions("build_options", options); - if (poseidon_module) |pm| main_tests.root_module.addImport("poseidon", pm); + if (hashzig_module) |pm| main_tests.root_module.addImport("hash_zig", pm); tests_tests.root_module.addOptions("build_options", options); - if (poseidon_module) |pm| tests_tests.root_module.addImport("poseidon", pm); + if (hashzig_module) |pm| tests_tests.root_module.addImport("hash_zig", pm); } diff --git a/build.zig.zon b/build.zig.zon index a9936af..783190e 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -4,9 +4,9 @@ .version = "0.0.8", .paths = .{""}, .dependencies = .{ - .poseidon = .{ - .url = "https://github.com/blockblaz/zig-poseidon/archive/5c68c7e79361abb92ecfed3ff95ef795437452b3.tar.gz", - .hash = "zig_poseidon-0.2.0-CHeW2CRPAQBERdItC_QZGgNHZ4_Zcz1z_r1kIpZhq4a4", + .hash_zig = .{ + .url = "https://github.com/blockblaz/hash-zig/archive/2bca6541a933a2bbe4630f41c29f61b3f84ad7e0.tar.gz", + .hash = "hash_zig-1.1.3-POmurOPmCgCkbtpq_c62mZqLuHzVeaLZ2X23fxsoHVrI", }, }, } From 4806f29049f9867abc8f53cfdc8a86360d6f6964 Mon Sep 17 00:00:00 2001 From: Chetany Bhardwaj Date: Fri, 19 Dec 2025 17:15:45 +0530 Subject: [PATCH 6/9] chore: use hash-zig instead of zig-poseidon --- src/lib.zig | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/lib.zig b/src/lib.zig index a74c826..a367395 100644 --- a/src/lib.zig +++ b/src/lib.zig @@ -11,18 +11,15 @@ const sha256 = std.crypto.hash.sha2.Sha256; // Configure the hasher based on build options pub const Hasher = if (build_options.poseidon_enabled) blk: { - const poseidon = @import("poseidon"); + const hash_zig = @import("hash_zig"); + const poseidon2 = hash_zig.poseidon2; const poseidon_wrapper = @import("./poseidon_wrapper.zig"); - // Select the appropriate Poseidon2 hasher based on field configuration - const Poseidon2Type = if (std.mem.eql(u8, build_options.poseidon_field, "babybear")) - poseidon.Poseidon2BabyBear - else - poseidon.Poseidon2KoalaBear16; + const Poseidon2Type = poseidon2.Poseidon2KoalaBear24Plonky3; // Wrap with SHA256-compatible API break :blk poseidon_wrapper.PoseidonHasher(Poseidon2Type); } else sha256; -const hashes_of_zero = zeros.hashes_of_zero; +const hashes_of_zero = zeros.buildZeroHashes(Hasher, 32, 256); const Allocator = std.mem.Allocator; /// Number of bytes per chunk. @@ -537,6 +534,7 @@ fn mixInLength(root: [32]u8, length: [32]u8, out: *[32]u8) void { } test "mixInLength" { + if (build_options.poseidon_enabled) return; var root: [32]u8 = undefined; var length: [32]u8 = undefined; var expected: [32]u8 = undefined; @@ -559,6 +557,7 @@ fn mixInSelector(root: [32]u8, comptime selector: usize, out: *[32]u8) void { } test "mixInSelector" { + if (build_options.poseidon_enabled) return; var root: [32]u8 = undefined; var expected: [32]u8 = undefined; var mixin: [32]u8 = undefined; From 0240c456d1292fda494b03f5ba15b9e322e47b5b Mon Sep 17 00:00:00 2001 From: Chetany Bhardwaj Date: Sat, 20 Dec 2025 20:28:21 +0530 Subject: [PATCH 7/9] feat: use 24 bit data legs for ssz bytes -> field transformation and poseidon2 operations --- src/beacon_tests.zig | 18 +++++ src/poseidon_wrapper.zig | 140 +++++++++++++++++++++++++-------------- src/tests.zig | 28 ++++++++ 3 files changed, 138 insertions(+), 48 deletions(-) diff --git a/src/beacon_tests.zig b/src/beacon_tests.zig index 5a60c27..66260ca 100644 --- a/src/beacon_tests.zig +++ b/src/beacon_tests.zig @@ -4,6 +4,7 @@ const serialize = libssz.serialize; const deserialize = libssz.deserialize; const hashTreeRoot = libssz.hashTreeRoot; const std = @import("std"); +const build_options = @import("build_options"); const ArrayList = std.ArrayList; const expect = std.testing.expect; @@ -53,6 +54,7 @@ test "Validator struct serialization" { } test "Validator struct hash tree root" { + if (build_options.poseidon_enabled) return; const validator = Validator{ .pubkey = [_]u8{0x01} ** 48, .withdrawal_credentials = [_]u8{0x02} ** 32, @@ -116,6 +118,7 @@ test "Individual Validator serialization and hash" { try expect(std.mem.eql(u8, list.items, &expected_validator_bytes)); // Test hash tree root + if (build_options.poseidon_enabled) return; var hash: [32]u8 = undefined; try hashTreeRoot(Validator, validator, &hash, std.testing.allocator); @@ -190,6 +193,7 @@ test "List[Validator] serialization and hash tree root" { } // Test hash tree root + if (build_options.poseidon_enabled) return; var hash1: [32]u8 = undefined; try hashTreeRoot(ValidatorList, validator_list, &hash1, std.testing.allocator); @@ -279,6 +283,8 @@ test "BeamBlockBody with validator array - full cycle" { try expect(orig.withdrawable_epoch == deser.withdrawable_epoch); } + if (build_options.poseidon_enabled) return; + // Test hash tree root consistency var hash_original: [32]u8 = undefined; try hashTreeRoot(BeamBlockBody, beam_block_body, &hash_original, std.testing.allocator); @@ -377,6 +383,8 @@ test "Zeam-style List/Bitlist usage with tree root stability" { try expect(std.mem.eql(u8, state_serialized.items, &expected_zeam_state_bytes)); + if (build_options.poseidon_enabled) return; + // Test hash tree root determinism and validate against expected hashes var body_hash1: [32]u8 = undefined; var body_hash2: [32]u8 = undefined; @@ -450,6 +458,8 @@ test "BeamState with historical roots - comprehensive test" { const expected_comprehensive_beam_state_bytes = [_]u8{ 0x39, 0x30, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x2A, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x9C, 0x00, 0x00, 0x00, 0xE8, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xBB, 0xBB, 0xBB, 0xBB, 0xBB, 0xBB, 0xBB, 0xBB, 0xBB, 0xBB, 0xBB, 0xBB, 0xBB, 0xBB, 0xBB, 0xBB, 0xBB, 0xBB, 0xBB, 0xBB, 0xBB, 0xBB, 0xBB, 0xBB, 0xBB, 0xBB, 0xBB, 0xBB, 0xBB, 0xBB, 0xBB, 0xBB, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0xEE, 0xEE, 0xEE, 0xEE, 0xEE, 0xEE, 0xEE, 0xEE, 0xEE, 0xEE, 0xEE, 0xEE, 0xEE, 0xEE, 0xEE, 0xEE, 0xEE, 0xEE, 0xEE, 0xEE, 0xEE, 0xEE, 0xEE, 0xEE, 0xEE, 0xEE, 0xEE, 0xEE, 0xEE, 0xEE, 0xEE, 0xEE, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }; try expect(std.mem.eql(u8, serialized_data.items, &expected_comprehensive_beam_state_bytes)); + if (build_options.poseidon_enabled) return; + // Test hash tree root calculation var original_hash: [32]u8 = undefined; try hashTreeRoot(BeamState, beam_state, &original_hash, std.testing.allocator); @@ -524,6 +534,8 @@ test "BeamState with empty historical roots" { const expected_empty_beam_state_bytes = [_]u8{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x14, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }; try expect(std.mem.eql(u8, serialized_data.items, &expected_empty_beam_state_bytes)); + if (build_options.poseidon_enabled) return; + // Test hash tree root calculation var original_hash: [32]u8 = undefined; try hashTreeRoot(SimpleBeamState, beam_state, &original_hash, std.testing.allocator); @@ -591,6 +603,8 @@ test "BeamState with maximum historical roots" { const expected_max_beam_state_bytes_start = [_]u8{ 0x3F, 0x42, 0x0F, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0C, 0x00, 0x00, 0x00 }; try expect(std.mem.eql(u8, serialized_data.items[0..12], &expected_max_beam_state_bytes_start)); + if (build_options.poseidon_enabled) return; + // Test hash tree root calculation var original_hash: [32]u8 = undefined; try hashTreeRoot(MaxBeamState, beam_state, &original_hash, std.testing.allocator); @@ -671,6 +685,8 @@ test "BeamState historical roots access and comparison" { const expected_access_beam_state_bytes = [_]u8{ 0x31, 0xD4, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x14, 0x00, 0x00, 0x00, 0xEF, 0xBE, 0xAD, 0xDE, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x12, 0x12, 0x12, 0x12, 0x12, 0x12, 0x12, 0x12, 0x12, 0x12, 0x12, 0x12, 0x12, 0x12, 0x12, 0x12, 0x12, 0x12, 0x12, 0x12, 0x12, 0x12, 0x12, 0x12, 0x12, 0x12, 0x12, 0x12, 0x12, 0x12, 0x12, 0x12, 0x34, 0x34, 0x34, 0x34, 0x34, 0x34, 0x34, 0x34, 0x34, 0x34, 0x34, 0x34, 0x34, 0x34, 0x34, 0x34, 0x34, 0x34, 0x34, 0x34, 0x34, 0x34, 0x34, 0x34, 0x34, 0x34, 0x34, 0x34, 0x34, 0x34, 0x34, 0x34, 0x56, 0x56, 0x56, 0x56, 0x56, 0x56, 0x56, 0x56, 0x56, 0x56, 0x56, 0x56, 0x56, 0x56, 0x56, 0x56, 0x56, 0x56, 0x56, 0x56, 0x56, 0x56, 0x56, 0x56, 0x56, 0x56, 0x56, 0x56, 0x56, 0x56, 0x56, 0x56, 0x78, 0x78, 0x78, 0x78, 0x78, 0x78, 0x78, 0x78, 0x78, 0x78, 0x78, 0x78, 0x78, 0x78, 0x78, 0x78, 0x78, 0x78, 0x78, 0x78, 0x78, 0x78, 0x78, 0x78, 0x78, 0x78, 0x78, 0x78, 0x78, 0x78, 0x78, 0x78, 0x9A, 0x9A, 0x9A, 0x9A, 0x9A, 0x9A, 0x9A, 0x9A, 0x9A, 0x9A, 0x9A, 0x9A, 0x9A, 0x9A, 0x9A, 0x9A, 0x9A, 0x9A, 0x9A, 0x9A, 0x9A, 0x9A, 0x9A, 0x9A, 0x9A, 0x9A, 0x9A, 0x9A, 0x9A, 0x9A, 0x9A, 0x9A, 0xBC, 0xBC, 0xBC, 0xBC, 0xBC, 0xBC, 0xBC, 0xBC, 0xBC, 0xBC, 0xBC, 0xBC, 0xBC, 0xBC, 0xBC, 0xBC, 0xBC, 0xBC, 0xBC, 0xBC, 0xBC, 0xBC, 0xBC, 0xBC, 0xBC, 0xBC, 0xBC, 0xBC, 0xBC, 0xBC, 0xBC, 0xBC }; try expect(std.mem.eql(u8, serialized_data.items, &expected_access_beam_state_bytes)); + if (build_options.poseidon_enabled) return; + // Test hash tree root calculation var original_hash: [32]u8 = undefined; try hashTreeRoot(AccessBeamState, beam_state, &original_hash, std.testing.allocator); @@ -741,6 +757,8 @@ test "SimpleBeamState with empty historical roots" { const expected_simple_beam_state_bytes = [_]u8{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x14, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }; try expect(std.mem.eql(u8, serialized_data.items, &expected_simple_beam_state_bytes)); + if (build_options.poseidon_enabled) return; + // Test hash tree root calculation var original_hash: [32]u8 = undefined; try hashTreeRoot(SimpleBeamState, beam_state, &original_hash, std.testing.allocator); diff --git a/src/poseidon_wrapper.zig b/src/poseidon_wrapper.zig index a9fb714..bd5dd7c 100644 --- a/src/poseidon_wrapper.zig +++ b/src/poseidon_wrapper.zig @@ -3,18 +3,48 @@ //! in merkleization and hash tree root operations. //! //! IMPORTANT: This is a specialized wrapper for SSZ merkleization, which always -//! provides exactly 64 bytes (two 32-byte hashes). It is NOT a general-purpose -//! hash function and will produce collisions for variable-length inputs due to -//! simple zero-padding (e.g., "abc" and "abc\x00" would hash identically). +//! provides exactly 64 bytes (two 32-byte nodes). It is NOT a general-purpose +//! hash function: it enforces the fixed 64-byte input length and intentionally +//! does not implement any padding scheme. const std = @import("std"); /// Creates a hasher type that wraps a Poseidon2 instance with SHA256-like API pub fn PoseidonHasher(comptime Poseidon2Type: type) type { - const WIDTH = 16; // Poseidon2 width (16 field elements) + // SSZ compression in this codebase is always: + // H: {0,1}^512 -> {0,1}^256 + // i.e. exactly 64 bytes in, 32 bytes out. + const BUFFER_SIZE = 64; + + // Poseidon2-24 state width. + const WIDTH = 24; + + // Compile-time safety: verify Poseidon2Type has the required interface + comptime { + if (!@hasDecl(Poseidon2Type, "Field")) { + @compileError("Poseidon2Type must have a 'Field' declaration"); + } + if (!@hasDecl(Poseidon2Type, "permutation")) { + @compileError("Poseidon2Type must have a 'permutation' function"); + } + if (!@hasDecl(Poseidon2Type, "WIDTH")) { + @compileError("Poseidon2Type must expose a WIDTH constant"); + } + if (Poseidon2Type.WIDTH != WIDTH) { + @compileError(std.fmt.comptimePrint( + "PoseidonHasher requires width-{d} Poseidon2, got width-{d}", + .{ WIDTH, Poseidon2Type.WIDTH }, + )); + } + } + + // We encode 64 bytes as 22 limbs of 24 bits each (little-endian within each limb), + // which are always < 2^24 < p (KoalaBear prime), avoiding lossy modular reduction: + // 64 bytes = 21*3 + 1 => 22 limbs, fits in a single width-24 permutation. + const LIMBS = 22; + const FIELD_ELEM_SIZE = 4; // u32 = 4 bytes - const BUFFER_SIZE = WIDTH * FIELD_ELEM_SIZE; // 64 bytes - const OUTPUT_FIELD_ELEMS = 8; // 8 u32s = 32 bytes output + const OUTPUT_FIELD_ELEMS = 8; // 8 u32s = 32 bytes return struct { const Self = @This(); @@ -35,60 +65,74 @@ pub fn PoseidonHasher(comptime Poseidon2Type: type) type { } /// Update the hasher with new data - /// Note: This accumulates data. Poseidon2 requires exactly 64 bytes, + /// Note: This accumulates data. SSZ compression requires exactly 64 bytes, /// so we buffer until we have enough data. pub fn update(self: *Self, data: []const u8) void { // Enforce the 64-byte limit explicitly std.debug.assert(self.buffer_len + data.len <= BUFFER_SIZE); // Copy data into buffer - const space_left = BUFFER_SIZE - self.buffer_len; - const copy_len = @min(data.len, space_left); - - @memcpy(self.buffer[self.buffer_len..][0..copy_len], data[0..copy_len]); - self.buffer_len += copy_len; + @memcpy(self.buffer[self.buffer_len..][0..data.len], data); + self.buffer_len += data.len; } /// Finalize the hash and write the result to out - pub fn final(self: *Self, out: *[32]u8) void { - // Pad buffer to 64 bytes if needed - if (self.buffer_len < BUFFER_SIZE) { - @memset(self.buffer[self.buffer_len..BUFFER_SIZE], 0); + pub fn final(self: *Self, out: []u8) void { + std.debug.assert(out.len == 32); + // Enforce exact length: SSZ internal nodes and mix-in-length always pass 64 bytes. + std.debug.assert(self.buffer_len == BUFFER_SIZE); + + // Byte -> 24-bit limb packing (injective for fixed 64-byte inputs). + var limbs: [LIMBS]u32 = undefined; + for (0..(LIMBS - 1)) |i| { + const j = i * 3; + limbs[i] = @as(u32, self.buffer[j]) | + (@as(u32, self.buffer[j + 1]) << 8) | + (@as(u32, self.buffer[j + 2]) << 16); } + limbs[LIMBS - 1] = @as(u32, self.buffer[63]); - // Convert bytes to field elements (u32s) using little-endian encoding - var input: [WIDTH]u32 = undefined; - for (0..WIDTH) |i| { - input[i] = std.mem.readInt(u32, self.buffer[i * FIELD_ELEM_SIZE ..][0..FIELD_ELEM_SIZE], .little) % Poseidon2Type.Field.MODULUS; + // Build Poseidon2 state: 22 limbs + 2 zero lanes. + var state: [WIDTH]Poseidon2Type.Field = undefined; + for (0..LIMBS) |i| { + state[i] = Poseidon2Type.Field.fromU32(limbs[i]); } + state[22] = Poseidon2Type.Field.zero; + state[23] = Poseidon2Type.Field.zero; - // Hash with Poseidon2 compress function - // Output 8 field elements (32 bytes total) - const output = Poseidon2Type.compress(OUTPUT_FIELD_ELEMS, input); + // TruncatedPermutation semantics (no feed-forward): permute, then squeeze. + Poseidon2Type.permutation(state[0..]); - // Convert field elements back to bytes using little-endian encoding + // Squeeze first 8 lanes as 32 bytes, little-endian u32 per lane. for (0..OUTPUT_FIELD_ELEMS) |i| { - std.mem.writeInt(u32, out[i * FIELD_ELEM_SIZE ..][0..FIELD_ELEM_SIZE], output[i], .little); + const v = state[i].toU32(); + std.mem.writeInt(u32, out[i * FIELD_ELEM_SIZE ..][0..FIELD_ELEM_SIZE], v, .little); } - // Reset buffer for potential reuse + // Reset buffer for potential reuse. self.buffer_len = 0; } + + /// Convenience helper used by some generic code (e.g. zero-hash builders). + pub fn finalResult(self: *Self) [32]u8 { + var out: [32]u8 = undefined; + self.final(out[0..]); + return out; + } }; } test "PoseidonHasher basic API" { - // This test just verifies the API compiles and runs - // Actual hash correctness should be verified against known test vectors - const poseidon = @import("poseidon"); - const Hasher = PoseidonHasher(poseidon.Poseidon2KoalaBear16); + // This test just verifies the API compiles and runs. + const hash_zig = @import("hash_zig"); + const Hasher = PoseidonHasher(hash_zig.poseidon2.Poseidon2KoalaBear24Plonky3); var hasher = Hasher.init(.{}); - const data = "test data for hashing"; - hasher.update(data); + const data = [_]u8{0x01} ** 64; + hasher.update(data[0..]); var output: [32]u8 = undefined; - hasher.final(&output); + hasher.final(output[0..]); // Just verify we got some output (not all zeros) var has_nonzero = false; @@ -103,42 +147,42 @@ test "PoseidonHasher basic API" { test "PoseidonHasher deterministic" { // Verify same input produces same output - const poseidon = @import("poseidon"); - const Hasher = PoseidonHasher(poseidon.Poseidon2KoalaBear16); + const hash_zig = @import("hash_zig"); + const Hasher = PoseidonHasher(hash_zig.poseidon2.Poseidon2KoalaBear24Plonky3); var hasher1 = Hasher.init(.{}); var hasher2 = Hasher.init(.{}); - const data = "deterministic test data"; - hasher1.update(data); - hasher2.update(data); + const data = [_]u8{0x42} ** 64; + hasher1.update(data[0..]); + hasher2.update(data[0..]); var output1: [32]u8 = undefined; var output2: [32]u8 = undefined; - hasher1.final(&output1); - hasher2.final(&output2); + hasher1.final(output1[0..]); + hasher2.final(output2[0..]); try std.testing.expectEqualSlices(u8, &output1, &output2); } test "PoseidonHasher different inputs produce different outputs" { // Verify different inputs produce different outputs - const poseidon = @import("poseidon"); - const Hasher = PoseidonHasher(poseidon.Poseidon2KoalaBear16); + const hash_zig = @import("hash_zig"); + const Hasher = PoseidonHasher(hash_zig.poseidon2.Poseidon2KoalaBear24Plonky3); var hasher1 = Hasher.init(.{}); var hasher2 = Hasher.init(.{}); - const data1 = "first test data"; - const data2 = "second test data"; + const data1 = [_]u8{0x01} ** 64; + const data2 = [_]u8{0x02} ** 64; - hasher1.update(data1); - hasher2.update(data2); + hasher1.update(data1[0..]); + hasher2.update(data2[0..]); var output1: [32]u8 = undefined; var output2: [32]u8 = undefined; - hasher1.final(&output1); - hasher2.final(&output2); + hasher1.final(output1[0..]); + hasher2.final(output2[0..]); // Verify outputs are different const are_equal = std.mem.eql(u8, &output1, &output2); diff --git a/src/tests.zig b/src/tests.zig index d743a0a..5386a14 100644 --- a/src/tests.zig +++ b/src/tests.zig @@ -7,6 +7,7 @@ const chunkCount = libssz.chunkCount; const hashTreeRoot = libssz.hashTreeRoot; const isFixedSizeObject = libssz.isFixedSizeObject; const std = @import("std"); +const build_options = @import("build_options"); const ArrayList = std.ArrayList; const expect = std.testing.expect; const expectError = std.testing.expectError; @@ -529,6 +530,8 @@ const d_bits = bytesToBits(16, d_bytes); const e_bits = bytesToBits(16, e_bytes); test "calculate the root hash of a boolean" { + // SHA-specific expected vectors; skip when Poseidon is enabled. + if (build_options.poseidon_enabled) return; var expected = [_]u8{1} ++ [_]u8{0} ** 31; var hashed: [32]u8 = undefined; try hashTreeRoot(bool, true, &hashed, std.testing.allocator); @@ -540,6 +543,7 @@ test "calculate the root hash of a boolean" { } test "calculate root hash of an array of two Bitvector[128]" { + if (build_options.poseidon_enabled) return; const deserialized: [2][128]bool = [2][128]bool{ a_bits, b_bits }; var hashed: [32]u8 = undefined; try hashTreeRoot(@TypeOf(deserialized), deserialized, &hashed, std.testing.allocator); @@ -559,6 +563,7 @@ test "calculate the root hash of an array of integers" { } test "calculate root hash of an array of three Bitvector[128]" { + if (build_options.poseidon_enabled) return; const deserialized: [3][128]bool = [3][128]bool{ a_bits, b_bits, c_bits }; var hashed: [32]u8 = undefined; try hashTreeRoot(@TypeOf(deserialized), deserialized, &hashed, std.testing.allocator); @@ -578,6 +583,7 @@ test "calculate root hash of an array of three Bitvector[128]" { } test "calculate the root hash of an array of five Bitvector[128]" { + if (build_options.poseidon_enabled) return; const deserialized = [5][128]bool{ a_bits, b_bits, c_bits, d_bits, e_bits }; var hashed: [32]u8 = undefined; try hashTreeRoot(@TypeOf(deserialized), deserialized, &hashed, std.testing.allocator); @@ -616,6 +622,7 @@ const Fork = struct { }; test "calculate the root hash of a structure" { + if (build_options.poseidon_enabled) return; var hashed: [32]u8 = undefined; const fork = Fork{ .previous_version = [_]u8{ 0x9c, 0xe2, 0x5d, 0x26 }, @@ -629,6 +636,7 @@ test "calculate the root hash of a structure" { } test "calculate the root hash of an Optional" { + if (build_options.poseidon_enabled) return; var hashed: [32]u8 = undefined; var payload: [64]u8 = undefined; const v: ?u32 = null; @@ -647,6 +655,7 @@ test "calculate the root hash of an Optional" { } test "calculate the root hash of an union" { + if (build_options.poseidon_enabled) return; const Payload = union(enum) { int: u64, boolean: bool, @@ -919,6 +928,7 @@ test "structs with nested fixed/variable size u8 array" { } test "slice hashtree root composite type" { + if (build_options.poseidon_enabled) return; const Root = [32]u8; const RootsList = []Root; const test_root = [_]u8{23} ** 32; @@ -938,6 +948,7 @@ test "slice hashtree root composite type" { } test "slice hashtree root simple type" { + if (build_options.poseidon_enabled) return; const DynamicRoot = []u8; // merkelizes as List[u8,33] as dynamic data length is mixed in as bounded type var test_root = [_]u8{23} ** 33; @@ -955,6 +966,7 @@ test "slice hashtree root simple type" { } test "List tree root calculation" { + if (build_options.poseidon_enabled) return; const ListU64 = utils.List(u64, 1024); var empty_list = try ListU64.init(std.testing.allocator); @@ -1275,6 +1287,7 @@ test "serialize max/min integer values" { } test "Empty List hash tree root" { + if (build_options.poseidon_enabled) return; const ListU32 = utils.List(u32, 100); var empty_list = try ListU32.init(std.testing.allocator); defer empty_list.deinit(); @@ -1293,6 +1306,7 @@ test "Empty List hash tree root" { } test "Empty BitList(<=256) hash tree root" { + if (build_options.poseidon_enabled) return; const BitListLen100 = utils.Bitlist(100); var empty_list = try BitListLen100.init(std.testing.allocator); defer empty_list.deinit(); @@ -1310,6 +1324,7 @@ test "Empty BitList(<=256) hash tree root" { } test "Empty BitList (>256) hash tree root" { + if (build_options.poseidon_enabled) return; const BitListLen100 = utils.Bitlist(2570); var empty_list = try BitListLen100.init(std.testing.allocator); defer empty_list.deinit(); @@ -1327,6 +1342,7 @@ test "Empty BitList (>256) hash tree root" { } test "List at maximum capacity" { + if (build_options.poseidon_enabled) return; const ListU8 = utils.List(u8, 4); var full_list = try ListU8.init(std.testing.allocator); defer full_list.deinit(); @@ -1355,6 +1371,8 @@ test "List at maximum capacity" { } test "Array hash tree root" { + // SHA-specific expected vectors; skip when Poseidon is enabled. + if (build_options.poseidon_enabled) return; const data: [4]u32 = .{ 1, 2, 3, 4 }; var hash: [32]u8 = undefined; @@ -1394,6 +1412,8 @@ test "Large Bitvector serialization and hash" { try expect(list.items[32] & 0x01 == 0x01); // bit 256 -> LSB of byte 32 try expect(list.items[63] & 0x80 == 0x80); // bit 511 -> MSB of byte 63 + if (build_options.poseidon_enabled) return; + // Test hash tree root var hash: [32]u8 = undefined; try hashTreeRoot(LargeBitvec, data, &hash, std.testing.allocator); @@ -1407,6 +1427,7 @@ test "Large Bitvector serialization and hash" { } test "Bitlist edge cases" { + if (build_options.poseidon_enabled) return; const TestBitlist = utils.Bitlist(100); // All false @@ -1448,6 +1469,7 @@ test "Bitlist edge cases" { } test "Bitlist trailing zeros optimization" { + if (build_options.poseidon_enabled) return; const TestBitlist = utils.Bitlist(256); // Test case 1: 8 false bits - should result in one 0x00 byte after pack_bits @@ -1497,6 +1519,8 @@ test "Bitlist trailing zeros optimization" { } test "uint256 hash tree root" { + // SHA-specific expected vectors; skip when Poseidon is enabled. + if (build_options.poseidon_enabled) return; const data: u256 = 0x0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF; var hash: [32]u8 = undefined; @@ -1511,6 +1535,7 @@ test "uint256 hash tree root" { } test "Single element List" { + if (build_options.poseidon_enabled) return; const ListU64 = utils.List(u64, 10); var single = try ListU64.init(std.testing.allocator); defer single.deinit(); @@ -1529,6 +1554,7 @@ test "Single element List" { } test "Nested structure hash tree root" { + if (build_options.poseidon_enabled) return; const Inner = struct { a: u32, b: u64, @@ -1577,6 +1603,8 @@ test "serialize negative i8 and i16" { } test "Zero-length array" { + // SHA-specific expected vectors; skip when Poseidon is enabled. + if (build_options.poseidon_enabled) return; const empty: [0]u32 = .{}; var list = ArrayList(u8).init(std.testing.allocator); From df00de4c721513217d84cad6a3b25f1467b0edf4 Mon Sep 17 00:00:00 2001 From: Chetany Bhardwaj Date: Sat, 20 Dec 2025 20:28:21 +0530 Subject: [PATCH 8/9] test: add Plonky3 generated data based cross-testing --- build.zig | 21 ++++- src/poseidon_plonky3_validation.zig | 116 ++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+), 4 deletions(-) create mode 100644 src/poseidon_plonky3_validation.zig diff --git a/build.zig b/build.zig index 66d848f..3bc87fc 100644 --- a/build.zig +++ b/build.zig @@ -33,10 +33,6 @@ pub fn build(b: *Builder) void { tests_tests.root_module.addImport("ssz.zig", mod); const run_tests_tests = b.addRunArtifact(tests_tests); - const test_step = b.step("test", "Run library tests"); - test_step.dependOn(&run_main_tests.step); - test_step.dependOn(&run_tests_tests.step); - // Poseidon hasher build options const poseidon_enabled = b.option(bool, "poseidon", "Enable Poseidon2 hash support") orelse false; if (poseidon_enabled) { @@ -68,4 +64,21 @@ pub fn build(b: *Builder) void { tests_tests.root_module.addOptions("build_options", options); if (hashzig_module) |pm| tests_tests.root_module.addImport("hash_zig", pm); + + const test_step = b.step("test", "Run library tests"); + test_step.dependOn(&run_main_tests.step); + test_step.dependOn(&run_tests_tests.step); + // Optional Poseidon validation suite (only when Poseidon is enabled) + if (poseidon_enabled) { + const plonky3_validation_tests = b.addTest(.{ + .root_source_file = .{ .cwd_relative = "src/poseidon_plonky3_validation.zig" }, + .optimize = optimize, + .target = target, + }); + plonky3_validation_tests.root_module.addOptions("build_options", options); + if (hashzig_module) |pm| plonky3_validation_tests.root_module.addImport("hash_zig", pm); + const run_plonky3_validation_tests = b.addRunArtifact(plonky3_validation_tests); + test_step.dependOn(&run_plonky3_validation_tests.step); + } + } diff --git a/src/poseidon_plonky3_validation.zig b/src/poseidon_plonky3_validation.zig new file mode 100644 index 0000000..e3e4064 --- /dev/null +++ b/src/poseidon_plonky3_validation.zig @@ -0,0 +1,116 @@ +//! Cross-validation: SSZ Poseidon2-24 wrapper vs Plonky3 reference outputs +//! +//! This test verifies that the SSZ Poseidon2 wrapper produces IDENTICAL outputs +//! to Plonky3's reference implementation for the same 64-byte inputs. + +const std = @import("std"); +const build_options = @import("build_options"); + +test "SSZ Poseidon2-24 matches Plonky3 reference outputs" { + if (!build_options.poseidon_enabled) return; + + const hash_zig = @import("hash_zig"); + const poseidon_wrapper = @import("./poseidon_wrapper.zig"); + const Hasher = poseidon_wrapper.PoseidonHasher(hash_zig.poseidon2.Poseidon2KoalaBear24Plonky3); + + // Test 1: All zeros (64 bytes) + { + var hasher = Hasher.init(.{}); + const input = [_]u8{0x00} ** 64; + hasher.update(&input); + + var output: [32]u8 = undefined; + hasher.final(&output); + + const expected = [_]u8{ 0xe4, 0xcb, 0xc9, 0x51, 0xcc, 0xd0, 0xf9, 0x07, 0xe1, 0xca, 0x89, 0x29, 0xc0, 0xa8, 0x70, 0x76, 0xf7, 0x8d, 0x75, 0x7a, 0xda, 0x87, 0xd4, 0x35, 0xd3, 0x86, 0xcc, 0x62, 0xd0, 0x64, 0x5a, 0x13 }; + try std.testing.expectEqualSlices(u8, &expected, &output); + } + + // Test 2: All 0x01 bytes + { + var hasher = Hasher.init(.{}); + const input = [_]u8{0x01} ** 64; + hasher.update(&input); + + var output: [32]u8 = undefined; + hasher.final(&output); + + const expected = [_]u8{ 0xb3, 0x16, 0xc9, 0x34, 0x81, 0x0a, 0x37, 0x73, 0x93, 0x89, 0x61, 0x7a, 0x5e, 0x9d, 0xc8, 0x6f, 0x75, 0x28, 0xd4, 0x27, 0x22, 0x8f, 0xf3, 0x57, 0x9d, 0xfb, 0xff, 0x5c, 0xef, 0x08, 0x1f, 0x00 }; + try std.testing.expectEqualSlices(u8, &expected, &output); + } + + // Test 3: All 0x42 bytes + { + var hasher = Hasher.init(.{}); + const input = [_]u8{0x42} ** 64; + hasher.update(&input); + + var output: [32]u8 = undefined; + hasher.final(&output); + + const expected = [_]u8{ 0x78, 0xae, 0xf5, 0x68, 0xa5, 0x4c, 0xf6, 0x59, 0x2f, 0x82, 0x6d, 0x1e, 0x5f, 0x8f, 0x5e, 0x68, 0x95, 0x94, 0xc6, 0x09, 0x25, 0x87, 0xce, 0x6d, 0x16, 0xd2, 0xb2, 0x21, 0xdb, 0x21, 0x3c, 0x1c }; + try std.testing.expectEqualSlices(u8, &expected, &output); + } + + // Test 4: Sequential bytes (0..63) + { + var hasher = Hasher.init(.{}); + var input: [64]u8 = undefined; + for (0..64) |i| { + input[i] = @intCast(i); + } + hasher.update(&input); + + var output: [32]u8 = undefined; + hasher.final(&output); + + const expected = [_]u8{ 0x29, 0x43, 0x5f, 0x44, 0xc0, 0xab, 0xbb, 0x1e, 0x3b, 0x42, 0x73, 0x2c, 0xfb, 0xac, 0x95, 0x67, 0xb1, 0xa6, 0x4b, 0x6d, 0xb9, 0x51, 0x6a, 0x23, 0xdd, 0x01, 0x03, 0x1d, 0x15, 0xf4, 0x3a, 0x63 }; + try std.testing.expectEqualSlices(u8, &expected, &output); + } + + // Test 5: SSZ pattern - hash two 32-byte nodes (0xAA || 0xBB) + { + var hasher = Hasher.init(.{}); + const left_node = [_]u8{0xAA} ** 32; + const right_node = [_]u8{0xBB} ** 32; + + hasher.update(&left_node); + hasher.update(&right_node); + + var output: [32]u8 = undefined; + hasher.final(&output); + + const expected = [_]u8{ 0xec, 0x3e, 0x77, 0x40, 0x7c, 0x50, 0xf7, 0x7a, 0x63, 0x98, 0xdb, 0x56, 0x94, 0x82, 0x6e, 0x21, 0xfb, 0xb8, 0x7f, 0x29, 0x92, 0x59, 0x3e, 0x59, 0x6c, 0xc9, 0x37, 0x7a, 0x50, 0x54, 0xdf, 0x56 }; + try std.testing.expectEqualSlices(u8, &expected, &output); + } + + // Test 6: Last byte boundary (63 bytes 0xFF, 1 byte 0x01) + { + var hasher = Hasher.init(.{}); + var input: [64]u8 = undefined; + @memset(input[0..63], 0xFF); + input[63] = 0x01; + hasher.update(&input); + + var output: [32]u8 = undefined; + hasher.final(&output); + + const expected = [_]u8{ 0xd2, 0xe5, 0x8c, 0x51, 0x39, 0xb5, 0x91, 0x64, 0xd2, 0xdb, 0x26, 0x49, 0x32, 0x50, 0x7d, 0x4e, 0x6d, 0xac, 0xef, 0x30, 0x76, 0x83, 0x12, 0x67, 0x4a, 0x9c, 0x70, 0x35, 0x87, 0xdf, 0xa9, 0x64 }; + try std.testing.expectEqualSlices(u8, &expected, &output); + } + + // Test 7: Last byte boundary variant (63 bytes 0xFF, 1 byte 0x02) + { + var hasher = Hasher.init(.{}); + var input: [64]u8 = undefined; + @memset(input[0..63], 0xFF); + input[63] = 0x02; + hasher.update(&input); + + var output: [32]u8 = undefined; + hasher.final(&output); + + const expected = [_]u8{ 0xc7, 0xed, 0x40, 0x1c, 0x2c, 0x03, 0x7e, 0x29, 0x3d, 0xb7, 0x76, 0x3f, 0xf2, 0xa7, 0x49, 0x39, 0xec, 0x47, 0x52, 0x3e, 0x5c, 0xeb, 0xad, 0x34, 0xe7, 0x4b, 0x00, 0x74, 0xf5, 0x01, 0xd4, 0x43 }; + try std.testing.expectEqualSlices(u8, &expected, &output); + } +} From 784aca0136da26b1dd2b5a78d759a572d49aa330 Mon Sep 17 00:00:00 2001 From: Chetany Bhardwaj Date: Mon, 22 Dec 2025 21:27:09 +0530 Subject: [PATCH 9/9] chore: lint fix --- build.zig | 1 - 1 file changed, 1 deletion(-) diff --git a/build.zig b/build.zig index 3bc87fc..66e1f7f 100644 --- a/build.zig +++ b/build.zig @@ -80,5 +80,4 @@ pub fn build(b: *Builder) void { const run_plonky3_validation_tests = b.addRunArtifact(plonky3_validation_tests); test_step.dependOn(&run_plonky3_validation_tests.step); } - }