Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
4924e83
feat: impl sig spec test
GrapeBaBa Dec 22, 2025
0233f7d
feat: add signature spec test
GrapeBaBa Dec 27, 2025
a27a789
fix: fix signature test
GrapeBaBa Jan 3, 2026
5e56317
fix: bootstrap spectest:run when missing generated index
Jan 14, 2026
ff26975
fix(spectest): support verify_signatures attestations + proofs
Jan 15, 2026
71c50b4
chore: fix zig fmt step and rust lint
Jan 15, 2026
164aca4
Apply suggestion from @Copilot
GrapeBaBa Jan 15, 2026
ffe61b6
fix(spectest): stop using ctx allocator in signature parsing
Jan 16, 2026
c45ec58
Merge branch 'main' of github.com:blockblaz/zeam into sig_spec_test
Jan 22, 2026
ebb40d1
refactor: align latest spec test
Jan 22, 2026
f6abf9c
Merge branch 'main' into sig_spec_test
GrapeBaBa Jan 22, 2026
9fe4b7b
refactor: hashsig test scheme
Jan 23, 2026
cf0df8f
fix: fix test
Jan 23, 2026
1a33798
fix: fix lint
Jan 23, 2026
9b24b2b
fix: fix review comments
Jan 23, 2026
961ee03
fix: fix review comments
Jan 23, 2026
fc8eb66
Merge remote-tracking branch 'origin/main' into sig_spec_test
Jan 23, 2026
4e7046f
Merge branch 'main' into sig_spec_test
GrapeBaBa Jan 23, 2026
51f2ff4
feat: add API endpoint versioning (#514)
ch4r10t33r Jan 24, 2026
074c1c6
refactor: rename justified endpoint to /lean/v0/checkpoints/justified…
ch4r10t33r Jan 24, 2026
bdb711a
fix snappy frame decoding boundaries for req/resp (#515)
shariqnaiyer Jan 25, 2026
5c6b6f1
add OCI labels for git commit and branch to docker images (#493)
KatyaRyazantseva Jan 25, 2026
97e2760
update to use SSZ hasher agnostic hashTreeRoot API (#474)
chetanyb Jan 25, 2026
28f4bca
Merge remote-tracking branch 'origin/main' into sig_spec_test
Jan 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,7 @@ pub fn build(b: *Builder) !void {
zeam_spectests.addImport("build_options", build_options_module);
zeam_spectests.addImport("@zeam/state-transition", zeam_state_transition);
zeam_spectests.addImport("@zeam/node", zeam_beam_node);
zeam_spectests.addImport("@zeam/xmss", zeam_xmss);

// Add the cli executable
const cli_exe = b.addExecutable(.{
Expand Down Expand Up @@ -572,6 +573,10 @@ pub fn build(b: *Builder) !void {
spectests.root_module.addImport("@zeam/metrics", zeam_metrics);
spectests.root_module.addImport("@zeam/state-transition", zeam_state_transition);
spectests.root_module.addImport("ssz", ssz);
spectests.root_module.addImport("@zeam/xmss", zeam_xmss);

spectests.step.dependOn(&build_rust_lib_steps.step);
addRustGlueLib(b, spectests, target, prover);

manager_tests.step.dependOn(&build_rust_lib_steps.step);

Expand Down
207 changes: 193 additions & 14 deletions pkgs/key-manager/src/lib.zig
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,105 @@ const types = @import("@zeam/types");
const zeam_utils = @import("@zeam/utils");
const zeam_metrics = @import("@zeam/metrics");
const Allocator = std.mem.Allocator;
const JsonValue = std.json.Value;

pub const XmssTestConfig = struct {
scheme: xmss.HashSigScheme,
signature_ssz_len: usize,
allow_placeholder_aggregated_proof: bool,

pub fn fromLeanEnv(lean_env: ?[]const u8) XmssTestConfig {
const scheme = schemeFromLeanEnv(lean_env);
return .{
.scheme = scheme,
.signature_ssz_len = xmss.signatureSszLenForScheme(scheme),
.allow_placeholder_aggregated_proof = scheme == .@"test",
};
}
};

pub const FixtureKeyError = error{
DuplicateKeyIndex,
InvalidKeyFile,
InvalidKeyIndex,
InvalidPublicKey,
NoKeysFound,
};

fn schemeFromLeanEnv(lean_env: ?[]const u8) xmss.HashSigScheme {
const env = lean_env orelse return .prod;
if (std.ascii.eqlIgnoreCase(env, "test")) return .@"test";
return .prod;
}

fn parseKeyIndex(file_name: []const u8) !usize {
if (!std.mem.endsWith(u8, file_name, ".json")) {
return FixtureKeyError.InvalidKeyIndex;
}
const stem = file_name[0 .. file_name.len - ".json".len];
if (stem.len == 0) {
return FixtureKeyError.InvalidKeyIndex;
}
return std.fmt.parseInt(usize, stem, 10) catch FixtureKeyError.InvalidKeyIndex;
}
Comment on lines +38 to +47
Copy link

Copilot AI Jan 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function lacks documentation. Add a doc comment explaining that it extracts a validator index from a JSON filename (e.g., '5.json' returns 5).

Copilot uses AI. Check for mistakes.

const fixture_key_file_max_bytes: usize = 2 * 1024 * 1024;

fn readPublicKeyFromJson(
allocator: Allocator,
dir: std.fs.Dir,
file_name: []const u8,
) !types.Bytes52 {
const payload = dir.readFileAlloc(allocator, file_name, fixture_key_file_max_bytes) catch {
return FixtureKeyError.InvalidKeyFile;
};
defer allocator.free(payload);

var parsed = std.json.parseFromSlice(JsonValue, allocator, payload, .{ .ignore_unknown_fields = true }) catch {
return FixtureKeyError.InvalidKeyFile;
};
defer parsed.deinit();

const obj = switch (parsed.value) {
.object => |map| map,
else => return FixtureKeyError.InvalidKeyFile,
};
const pub_val = obj.get("public") orelse return FixtureKeyError.InvalidKeyFile;
const pub_hex = switch (pub_val) {
.string => |s| s,
else => return FixtureKeyError.InvalidKeyFile,
};

return parsePublicKeyHex(pub_hex);
}

fn parsePublicKeyHex(input: []const u8) !types.Bytes52 {
const public_key_hex_len: usize = 2 * @sizeOf(types.Bytes52);
const hex_str = if (std.mem.startsWith(u8, input, "0x")) input[2..] else input;
if (hex_str.len != public_key_hex_len) {
return FixtureKeyError.InvalidPublicKey;
}
var bytes: types.Bytes52 = undefined;
_ = std.fmt.hexToBytes(&bytes, hex_str) catch {
return FixtureKeyError.InvalidPublicKey;
};
return bytes;
}

const KeyManagerError = error{
ValidatorKeyNotFound,
PrivateKeyMissing,
PublicKeyBufferTooSmall,
};

const PublicKeyEntry = struct {
bytes: types.Bytes52,
public_key: xmss.PublicKey,
};

const KeyEntry = union(enum) {
keypair: xmss.KeyPair,
public_key: PublicKeyEntry,
};

const CachedKeyPair = struct {
Expand Down Expand Up @@ -53,32 +149,80 @@ fn getOrCreateCachedKeyPair(
}

pub const KeyManager = struct {
keys: std.AutoHashMap(usize, xmss.KeyPair),
keys: std.AutoHashMap(usize, KeyEntry),
allocator: Allocator,
owns_keypairs: bool,

const Self = @This();

pub fn init(allocator: Allocator) Self {
return Self{
.keys = std.AutoHashMap(usize, xmss.KeyPair).init(allocator),
.keys = std.AutoHashMap(usize, KeyEntry).init(allocator),
.allocator = allocator,
.owns_keypairs = true,
};
}

pub fn deinit(self: *Self) void {
if (self.owns_keypairs) {
var it = self.keys.iterator();
while (it.next()) |entry| {
entry.value_ptr.deinit();
}
}
self.clearEntries();
self.keys.deinit();
}
Comment on lines 166 to 169
Copy link

Copilot AI Jan 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The clearEntries method is now called in deinit, which properly cleans up entries. However, the comment at line 324 in clearEntries should clarify that this method is idempotent and safe to call multiple times if needed.

Copilot uses AI. Check for mistakes.

pub fn addKeypair(self: *Self, validator_id: usize, keypair: xmss.KeyPair) !void {
try self.keys.put(validator_id, keypair);
if (self.keys.getPtr(validator_id)) |entry| {
self.deinitEntry(entry);
entry.* = .{ .keypair = keypair };
return;
}
try self.keys.put(validator_id, .{ .keypair = keypair });
}

pub fn addPublicKey(self: *Self, validator_id: usize, pubkey_bytes: types.Bytes52) !void {
var public_key = try xmss.PublicKey.fromBytes(pubkey_bytes[0..]);
errdefer public_key.deinit();

const entry_value = PublicKeyEntry{ .bytes = pubkey_bytes, .public_key = public_key };
if (self.keys.getPtr(validator_id)) |entry| {
self.deinitEntry(entry);
entry.* = .{ .public_key = entry_value };
return;
}
try self.keys.put(validator_id, .{ .public_key = entry_value });
}

pub fn loadLeanSpecKeys(self: *Self, keys_root: []const u8, lean_env: ?[]const u8) !void {
const scheme_dir_name = switch (schemeFromLeanEnv(lean_env)) {
.@"test" => "test_scheme",
.prod => "prod_scheme",
};
const scheme_dir_path = try std.fs.path.join(self.allocator, &.{ keys_root, scheme_dir_name });
defer self.allocator.free(scheme_dir_path);
try self.loadKeysFromDir(scheme_dir_path);
}

pub fn loadKeysFromDir(self: *Self, keys_dir_path: []const u8) !void {
var dir = try std.fs.cwd().openDir(keys_dir_path, .{ .iterate = true });
defer dir.close();

self.clearEntries();

var it = dir.iterate();
while (try it.next()) |entry| {
if (entry.kind != .file) continue;
const index = parseKeyIndex(entry.name) catch continue;
const pubkey = try readPublicKeyFromJson(self.allocator, dir, entry.name);
if (self.keys.get(index) != null) {
return FixtureKeyError.DuplicateKeyIndex;
}
self.addPublicKey(index, pubkey) catch |err| switch (err) {
error.OutOfMemory => return err,
else => return FixtureKeyError.InvalidPublicKey,
};
}

if (self.keys.count() == 0) {
return FixtureKeyError.NoKeysFound;
}
}

pub fn loadFromKeypairDir(_: *Self, _: []const u8) !void {
Expand Down Expand Up @@ -109,8 +253,17 @@ pub const KeyManager = struct {
validator_index: usize,
buffer: []u8,
) !usize {
const keypair = self.keys.get(validator_index) orelse return KeyManagerError.ValidatorKeyNotFound;
return try keypair.pubkeyToBytes(buffer);
const entry = self.keys.getPtr(validator_index) orelse return KeyManagerError.ValidatorKeyNotFound;
return switch (entry.*) {
.keypair => |keypair| try keypair.pubkeyToBytes(buffer),
.public_key => |pubkey| blk: {
if (buffer.len < pubkey.bytes.len) {
return KeyManagerError.PublicKeyBufferTooSmall;
}
@memcpy(buffer[0..pubkey.bytes.len], pubkey.bytes[0..]);
break :blk pubkey.bytes.len;
},
};
}

/// Extract all validator public keys into an array
Expand All @@ -136,8 +289,11 @@ pub const KeyManager = struct {
self: *const Self,
validator_index: usize,
) !*const xmss.HashSigPublicKey {
const keypair = self.keys.get(validator_index) orelse return KeyManagerError.ValidatorKeyNotFound;
return keypair.public_key;
const entry = self.keys.getPtr(validator_index) orelse return KeyManagerError.ValidatorKeyNotFound;
return switch (entry.*) {
.keypair => |keypair| keypair.public_key,
.public_key => |pubkey| pubkey.public_key.handle,
};
}

/// Sign an attestation and return the raw signature handle (for aggregation)
Expand All @@ -148,7 +304,11 @@ pub const KeyManager = struct {
allocator: Allocator,
) !xmss.Signature {
const validator_index: usize = @intCast(attestation.validator_id);
const keypair = self.keys.get(validator_index) orelse return KeyManagerError.ValidatorKeyNotFound;
const entry = self.keys.getPtr(validator_index) orelse return KeyManagerError.ValidatorKeyNotFound;
const keypair = switch (entry.*) {
.keypair => |*kp| kp,
.public_key => return KeyManagerError.PrivateKeyMissing,
};

const signing_timer = zeam_metrics.lean_pq_signature_attestation_signing_time_seconds.start();
var message: [32]u8 = undefined;
Expand All @@ -160,6 +320,25 @@ pub const KeyManager = struct {

return signature;
}

fn clearEntries(self: *Self) void {
var it = self.keys.iterator();
while (it.next()) |entry| {
self.deinitEntry(entry.value_ptr);
}
self.keys.clearRetainingCapacity();
}

fn deinitEntry(self: *Self, entry: *KeyEntry) void {
switch (entry.*) {
.keypair => |*keypair| {
if (self.owns_keypairs) {
keypair.deinit();
}
},
.public_key => |*pubkey| pubkey.public_key.deinit(),
}
}
};

pub fn getTestKeyManager(
Expand Down
5 changes: 4 additions & 1 deletion pkgs/spectest/src/fixture_kind.zig
Original file line number Diff line number Diff line change
@@ -1,20 +1,23 @@
pub const FixtureKind = enum {
state_transition,
fork_choice,
verify_signatures,

pub fn runnerModule(self: FixtureKind) []const u8 {
return switch (self) {
.state_transition => "state_transition",
.fork_choice => "fork_choice",
.verify_signatures => "verify_signatures",
};
}

pub fn handlerSubdir(self: FixtureKind) []const u8 {
return switch (self) {
.state_transition => "state_transition",
.fork_choice => "fc",
.verify_signatures => "verify_signatures",
};
}
};

pub const all = [_]FixtureKind{ .state_transition, .fork_choice };
pub const all = [_]FixtureKind{ .state_transition, .fork_choice, .verify_signatures };
17 changes: 17 additions & 0 deletions pkgs/spectest/src/json_expect.zig
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,23 @@ pub fn expectArrayValue(
};
}

pub fn expectArrayField(
comptime FixtureError: type,
obj: std.json.ObjectMap,
field_names: []const []const u8,
context: Context,
label: []const u8,
) FixtureError!std.json.Array {
const value = getField(obj, field_names) orelse {
std.debug.print(
"fixture {s} case {s}{}: missing field {s}\n",
.{ context.fixture_label, context.case_name, context.formatStep(), label },
);
return FixtureError.InvalidFixture;
};
return expectArrayValue(FixtureError, value, context, label);
}

pub fn appendBytesDataField(
comptime FixtureError: type,
comptime T: type,
Expand Down
5 changes: 4 additions & 1 deletion pkgs/spectest/src/runner/fork_choice_runner.zig
Original file line number Diff line number Diff line change
Expand Up @@ -633,7 +633,10 @@ fn processBlockStep(
}
try types.sszClone(ctx.allocator, types.BeamState, parent_state_ptr.*, new_state_ptr);

state_transition.apply_transition(ctx.allocator, new_state_ptr, block, .{ .logger = ctx.fork_logger, .validateResult = false }) catch |err| {
state_transition.apply_transition(ctx.allocator, new_state_ptr, block, .{
.logger = ctx.fork_logger,
.validateResult = false,
}) catch |err| {
std.debug.print(
"fixture {s} case {s}{}: state transition failed {s}\n",
.{ fixture_path, case_name, formatStep(step_index), @errorName(err) },
Expand Down
6 changes: 5 additions & 1 deletion pkgs/spectest/src/runner/state_transition_runner.zig
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,11 @@ fn runCase(
}
}

state_transition.apply_transition(allocator, &pre_state, block, .{ .logger = logger }) catch |err| {
const validate_result = expect_exception != null;
state_transition.apply_transition(allocator, &pre_state, block, .{
.logger = logger,
.validateResult = validate_result,
}) catch |err| {
encountered_error = true;
if (expect_exception == null) {
std.debug.print(
Expand Down
Loading
Loading