diff --git a/README.md b/README.md index f1455bd..05ebc76 100644 --- a/README.md +++ b/README.md @@ -332,6 +332,22 @@ zig build When contributing changes that may affect portability, ensure that `zig build` succeeds on your target platforms, and use the benchmark script on at least one platform to confirm cross-language compatibility. +## SSZ Integration + +hash-zig provides an SSZ-compatible Poseidon2 hasher that matches the SHA256-style API expected by SSZ merkleization libraries. + +```zig +const ssz = @import("ssz.zig"); +const hash_zig = @import("hash-zig"); + +const PoseidonHasher = hash_zig.ssz.SszHasher; + +var root: [32]u8 = undefined; +try ssz.hashTreeRoot(PoseidonHasher, MyType, value, &root, allocator); +``` + +The SSZ wrapper lives under `src/ssz/` and is exported as `hash_zig.ssz`. + ### Repository Layout ``` @@ -400,4 +416,3 @@ Licensed under the Apache License 2.0 – see [LICENSE](LICENSE). - [leanSig](https://github.com/leanEthereum/leanSig) — original Rust implementation and reference tests - [zig-poseidon](https://github.com/blockblaz/zig-poseidon) — Poseidon2 over the KoalaBear field - [Generalized XMSS (ePrint 2025/055)](https://eprint.iacr.org/2025/055.pdf) — scheme specification -- [Rust ↔ Zig compatibility investigation](analysis/rust_zig_compatibility_investigation.md) \ No newline at end of file diff --git a/benchmark/zig_benchmark/build.zig b/benchmark/zig_benchmark/build.zig index 70837ef..656781a 100644 --- a/benchmark/zig_benchmark/build.zig +++ b/benchmark/zig_benchmark/build.zig @@ -38,5 +38,4 @@ pub fn build(b: *std.Build) void { const run_step = b.step("run", "Run the benchmark"); run_step.dependOn(&run_cmd.step); - } diff --git a/benchmark/zig_benchmark/src/remote_hash_tool.zig b/benchmark/zig_benchmark/src/remote_hash_tool.zig index 4aba6cd..b739570 100644 --- a/benchmark/zig_benchmark/src/remote_hash_tool.zig +++ b/benchmark/zig_benchmark/src/remote_hash_tool.zig @@ -132,17 +132,17 @@ fn readPublicKeyFromJson(path: []const u8, allocator: std.mem.Allocator) !hash_z /// 5. hashes: for each hash: /// - array_len: u64 = 8 (little endian) /// - 8 u32 values in canonical form -/// +/// /// This function writes the signature in bincode format matching Rust's serialization. /// The output is then padded to exactly 3116 bytes to comply with leanSpec: /// https://github.com/leanEthereum/leanSpec/blob/main/src/lean_spec/subspecs/containers/signature.py -/// +/// /// The leanSpec requires: /// 1. Signature container: exactly 3116 bytes (Bytes3116) /// 2. Signature data: bincode format at the beginning /// 3. Can be sliced to scheme.config.SIGNATURE_LEN_BYTES if needed /// 4. Format: XmssSignature.from_bytes (bincode deserialization) -/// +/// /// Note: Rust's bincode serializes field elements in canonical form, not Montgomery pub fn writeSignatureBincode(path: []const u8, signature: *const hash_zig.GeneralizedXMSSSignature, rand_len: usize, hash_len: usize) !void { var file = try std.fs.cwd().createFile(path, .{ .truncate = true }); @@ -150,10 +150,10 @@ pub fn writeSignatureBincode(path: []const u8, signature: *const hash_zig.Genera const writer = file.writer(); const path_nodes = signature.getPath().getNodes(); - + // Write path_len (u64) - Vec length try writeLength(writer, path_nodes.len); - + // Write path nodes (each as: 8 u32 values in canonical form, NO length prefix for fixed arrays) // Rust's bincode serializes Vec<[T; N]> as: Vec length + elements directly (no per-array length) for (path_nodes) |node| { @@ -181,7 +181,7 @@ pub fn writeSignatureBincode(path: []const u8, signature: *const hash_zig.Genera // Write hashes_len (u64) - Vec length const hashes = signature.getHashes(); try writeLength(writer, hashes.len); - + // Write hashes (each as: 8 u32 values in canonical form, NO length prefix for fixed arrays) // Rust's bincode serializes Vec<[T; N]> as: Vec length + elements directly (no per-array length) // NOTE: Rust's bincode serializes field elements in CANONICAL form @@ -214,7 +214,7 @@ pub fn readSignatureBincode(path: []const u8, allocator: std.mem.Allocator, rand // Read path_len (u64) - Vec length const path_len = try readLength(reader); if (path_len == 0 or path_len > max_path_len) return BincodeError.InvalidPathLength; - + // Read path nodes (each has: HASH_LEN u32 values in CANONICAL form, NO length prefix for fixed arrays) // Rust's bincode serializes Vec> as: Vec length + elements directly (no per-array length) // Rust writes FieldArray which serializes exactly HASH_LEN elements @@ -265,7 +265,7 @@ pub fn readSignatureBincode(path: []const u8, allocator: std.mem.Allocator, rand path_ptr.deinit(); return BincodeError.InvalidHashesLength; } - + // Read hashes (each has: HASH_LEN u32 values in CANONICAL form, NO length prefix for fixed arrays) // Rust's bincode serializes Vec> as: Vec length + elements directly (no per-array length) // Rust writes FieldArray which serializes exactly HASH_LEN elements @@ -300,7 +300,7 @@ pub fn readSignatureBincode(path: []const u8, allocator: std.mem.Allocator, rand return err; }; allocator.free(hashes_tmp); - + // Debug: print rho values from signature after creation (for Zig→Zig debugging) const rho_from_sig = signature_ptr.getRho(); stderr.print("ZIG_READ_DEBUG: rho from signature.getRho() (Montgomery): ", .{}) catch {}; @@ -308,7 +308,7 @@ pub fn readSignatureBincode(path: []const u8, allocator: std.mem.Allocator, rand stderr.print("0x{x:0>8} ", .{rho_from_sig[i].toMontgomery()}) catch {}; } stderr.print("\n", .{}) catch {}; - + return signature_ptr; } @@ -390,7 +390,7 @@ fn verifyCommand( defer signature_ptr.deinit(); const msg_bytes = messageToBytes(message); - + // Debug: Print signature rho values const rho = signature_ptr.getRho(); log.emit("ZIG_REMOTE_VERIFY_DEBUG: Signature rho (first {}): ", .{scheme.lifetime_params.rand_len_fe}); @@ -398,7 +398,7 @@ fn verifyCommand( log.emit("0x{x:0>8} ", .{rho[i].toCanonical()}); } log.emit("\n", .{}); - + const ok = try scheme.verify(&pk, epoch, msg_bytes, signature_ptr); log.emit("VERIFY_RESULT:{}\n", .{ok}); } diff --git a/build.zig b/build.zig index 9dc00ea..05b644e 100644 --- a/build.zig +++ b/build.zig @@ -7,20 +7,20 @@ pub fn build(b: *std.Build) void { const enable_debug_logs = b.option(bool, "debug-logs", "Enable verbose std.debug logging") orelse false; const enable_profile_keygen = b.option(bool, "enable-profile-keygen", "Enable detailed keygen profiling logs") orelse false; const enable_sanitize = b.option(bool, "sanitize", "Enable AddressSanitizer (default: false)") orelse false; - + // Auto-detect SIMD width based on target CPU features // If user explicitly sets simd-width, use that; otherwise auto-detect const explicit_simd_width = b.option(u32, "simd-width", "SIMD width (4 or 8, default: auto-detect)"); const simd_width: u32 = if (explicit_simd_width) |width| width else blk: { // Auto-detect based on target architecture and CPU features const target_info = target.result; - + // Only x86_64 can support AVX-512 (8-wide SIMD) if (target_info.cpu.arch == .x86_64) { // Check if AVX-512F feature is enabled in the target const avx512f_feature = @intFromEnum(std.Target.x86.Feature.avx512f); const has_avx512_feature = target_info.cpu.features.isEnabled(avx512f_feature); - + if (has_avx512_feature) { std.debug.print("Build: Auto-detected AVX-512 support, using 8-wide SIMD\n", .{}); break :blk 8; diff --git a/scripts/benchmark_hash_function.zig b/scripts/benchmark_hash_function.zig index d567cad..cac3266 100644 --- a/scripts/benchmark_hash_function.zig +++ b/scripts/benchmark_hash_function.zig @@ -33,7 +33,7 @@ pub fn main() !void { // Benchmark: compress16SIMD with SIMD-packed input var packed_input: [16]poseidon2_simd.PackedF = undefined; - + // Initialize with test data for (0..16) |i| { var values: [SIMD_WIDTH]u32 = undefined; @@ -77,4 +77,3 @@ pub fn main() !void { std.debug.print(" Actual keygen time: ~868 seconds\n", .{}); std.debug.print(" Overhead factor: {d:.2}x\n\n", .{868.0 / estimated_time_s}); } - diff --git a/scripts/benchmark_verify.zig b/scripts/benchmark_verify.zig index 6860b3e..840c1f0 100644 --- a/scripts/benchmark_verify.zig +++ b/scripts/benchmark_verify.zig @@ -76,4 +76,3 @@ pub fn main() !void { std.debug.print(" Throughput: {d:.0} verifications/sec\n", .{ops_per_sec}); std.debug.print(" Lifetime: 2^{d}\n", .{lifetime_power}); } - diff --git a/scripts/profile_keygen.zig b/scripts/profile_keygen.zig index be5a053..1f64252 100644 --- a/scripts/profile_keygen.zig +++ b/scripts/profile_keygen.zig @@ -35,23 +35,22 @@ pub fn main() !void { // Generate seed var seed: [32]u8 = undefined; std.crypto.random.bytes(&seed); - + // Generate keypair with timing print("\nGenerating keypair...\n", .{}); const keygen_start = timer.lap(); - + const keypair = try scheme.keyGen(seed); defer keypair.deinit(allocator); - + const keygen_total = timer.lap(); const keygen_time = @as(f64, @floatFromInt(keygen_total)) / 1_000_000_000.0; - + print("\n✅ Key generation completed!\n", .{}); print("⏱️ Total Time: {d:.3}s\n\n", .{keygen_time}); - + print("Breakdown:\n", .{}); print(" - Initialization: {d:.3}s\n", .{@as(f64, @floatFromInt(init_time)) / 1_000_000_000.0}); print(" - Key Generation: {d:.3}s\n", .{keygen_time}); print(" - Total: {d:.3}s\n", .{(@as(f64, @floatFromInt(init_time)) + keygen_time) / 1_000_000_000.0}); } - diff --git a/scripts/profile_keygen_detailed.zig b/scripts/profile_keygen_detailed.zig index e4986ac..21692fb 100644 --- a/scripts/profile_keygen_detailed.zig +++ b/scripts/profile_keygen_detailed.zig @@ -36,32 +36,25 @@ pub fn main() !void { // Generate keypair with timing print("\nGenerating keypair...\n", .{}); timer.reset(); - + const activation_epoch: usize = 0; const keypair = try scheme.keyGen(activation_epoch, num_active_epochs); // KeyGenResult contains public_key and secret_key which are managed by the scheme _ = keypair; - + const keygen_time = @as(f64, @floatFromInt(timer.read())) / 1_000_000_000.0; - + print("\n✅ Key generation completed!\n", .{}); print("⏱️ Total Time: {d:.3}s\n\n", .{keygen_time}); - + const init_time_sec = @as(f64, @floatFromInt(init_time)) / 1_000_000_000.0; const total_time = init_time_sec + keygen_time; - + print("Performance Breakdown:\n", .{}); - print(" - Initialization: {d:.3}s ({d:.1}%)\n", .{ - init_time_sec, - if (total_time > 0) (init_time_sec / total_time) * 100.0 else 0.0 - }); - print(" - Key Generation: {d:.3}s ({d:.1}%)\n", .{ - keygen_time, - if (total_time > 0) (keygen_time / total_time) * 100.0 else 0.0 - }); + print(" - Initialization: {d:.3}s ({d:.1}%)\n", .{ init_time_sec, if (total_time > 0) (init_time_sec / total_time) * 100.0 else 0.0 }); + print(" - Key Generation: {d:.3}s ({d:.1}%)\n", .{ keygen_time, if (total_time > 0) (keygen_time / total_time) * 100.0 else 0.0 }); print(" - Total: {d:.3}s\n", .{total_time}); - + print("\nNote: For detailed breakdown of chain computation vs tree hashing,\n", .{}); print(" instrumentation needs to be added to the key generation code.\n", .{}); } - diff --git a/src/poseidon2/plonky3_field.zig b/src/poseidon2/plonky3_field.zig index e645630..130e5b3 100644 --- a/src/poseidon2/plonky3_field.zig +++ b/src/poseidon2/plonky3_field.zig @@ -61,8 +61,10 @@ pub const KoalaBearField = struct { // Field inverse (exact from Plonky3) pub fn inverse(self: KoalaBearField) KoalaBearField { - const inv = modInverse(self.value, KOALABEAR_PRIME); - return KoalaBearField{ .value = inv }; + // Compute inverse in normal form, then convert back to Montgomery. + const normal = fromMonty(self.value); + const inv_normal = modInverse(normal, KOALABEAR_PRIME); + return KoalaBearField{ .value = toMonty(inv_normal) }; } // Double operation (exact from Plonky3) diff --git a/src/root.zig b/src/root.zig index 463d594..f495136 100644 --- a/src/root.zig +++ b/src/root.zig @@ -14,6 +14,7 @@ pub const merkle = @import("merkle/mod.zig"); pub const signature = @import("signature/mod.zig"); pub const utils = @import("utils/mod.zig"); pub const poseidon2 = @import("poseidon2/root.zig"); +pub const ssz = @import("ssz/root.zig"); // Note: SIMD implementations (simd_signature, simd_winternitz, etc.) are available // as separate modules in build.zig. Access them via: @@ -64,3 +65,17 @@ test "hash-zig root loads" { // Smoke test to ensure the root module compiles. try @import("std").testing.expect(true); } + +// Import all sub-modules to run their tests +test { + _ = core; + _ = hash; + _ = prf; + _ = encoding; + _ = wots; + _ = merkle; + _ = signature; + _ = utils; + _ = poseidon2; + _ = ssz; +} diff --git a/src/ssz/poseidon_plonky3_validation.zig b/src/ssz/poseidon_plonky3_validation.zig new file mode 100644 index 0000000..d1e9bf8 --- /dev/null +++ b/src/ssz/poseidon_plonky3_validation.zig @@ -0,0 +1,114 @@ +//! 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"); + +test "SSZ Poseidon2-24 matches Plonky3 reference outputs" { + const poseidon_wrapper = @import("./poseidon_wrapper.zig"); + const poseidon2 = @import("../poseidon2/root.zig"); + const Poseidon2 = poseidon2.Poseidon2KoalaBear24Plonky3; + const Hasher = poseidon_wrapper.PoseidonHasher(Poseidon2); + + // 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); + } +} diff --git a/src/ssz/poseidon_wrapper.zig b/src/ssz/poseidon_wrapper.zig new file mode 100644 index 0000000..2a90443 --- /dev/null +++ b/src/ssz/poseidon_wrapper.zig @@ -0,0 +1,199 @@ +//! 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 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 { + // 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 OUTPUT_FIELD_ELEMS = 8; // 8 u32s = 32 bytes + + 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. 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 + if (self.buffer_len >= BUFFER_SIZE or data.len > BUFFER_SIZE - self.buffer_len) { + @panic("Input exceeds 64-byte SSZ compression limit"); + } + + // Copy data into buffer + @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: []u8) void { + if (out.len != 32) { + @panic("Output buffer must be 32 bytes"); + } + // Enforce exact length: SSZ internal nodes and mix-in-length always pass 64 bytes. + if (self.buffer_len != BUFFER_SIZE) { + @panic("SSZ compression requires exactly 64 bytes of input"); + } + + // 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]); + + // 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; + + // TruncatedPermutation semantics (no feed-forward): permute, then squeeze. + Poseidon2Type.permutation(state[0..]); + + // Squeeze first 8 lanes as 32 bytes, little-endian u32 per lane. + for (0..OUTPUT_FIELD_ELEMS) |i| { + 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. + 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. + const poseidon2 = @import("../poseidon2/root.zig"); + const Poseidon2 = poseidon2.Poseidon2KoalaBear24Plonky3; + const Hasher = PoseidonHasher(Poseidon2); + + var hasher = Hasher.init(.{}); + const data = [_]u8{0x01} ** 64; + hasher.update(data[0..]); + + var output: [32]u8 = undefined; + hasher.final(output[0..]); + + // 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 poseidon2 = @import("../poseidon2/root.zig"); + const Poseidon2 = poseidon2.Poseidon2KoalaBear24Plonky3; + const Hasher = PoseidonHasher(Poseidon2); + + var hasher1 = Hasher.init(.{}); + var hasher2 = Hasher.init(.{}); + + 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[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 poseidon2 = @import("../poseidon2/root.zig"); + const Poseidon2 = poseidon2.Poseidon2KoalaBear24Plonky3; + const Hasher = PoseidonHasher(Poseidon2); + + var hasher1 = Hasher.init(.{}); + var hasher2 = Hasher.init(.{}); + + const data1 = [_]u8{0x01} ** 64; + const data2 = [_]u8{0x02} ** 64; + + hasher1.update(data1[0..]); + hasher2.update(data2[0..]); + + var output1: [32]u8 = undefined; + var output2: [32]u8 = undefined; + hasher1.final(output1[0..]); + hasher2.final(output2[0..]); + + // Verify outputs are different + const are_equal = std.mem.eql(u8, &output1, &output2); + try std.testing.expect(!are_equal); +} diff --git a/src/ssz/root.zig b/src/ssz/root.zig new file mode 100644 index 0000000..6c5b157 --- /dev/null +++ b/src/ssz/root.zig @@ -0,0 +1,16 @@ +//! SSZ-compatible wrappers for Poseidon2 hash functions +//! Provides SHA256-like API for use with SSZ merkleization + +const poseidon2 = @import("../poseidon2/root.zig"); + +pub const PoseidonHasher = @import("poseidon_wrapper.zig").PoseidonHasher; + +// Pre-configured SSZ hasher using Poseidon2 KoalaBear24 Plonky3 (DEFAULT) +// Uses 24-bit limb packing: 64 bytes → 22 limbs, single permutation +pub const SszHasher = PoseidonHasher(poseidon2.Poseidon2KoalaBear24Plonky3); + +// Import test modules to ensure they run +test { + _ = @import("poseidon_wrapper.zig"); + _ = @import("poseidon_plonky3_validation.zig"); +}