From 0f53b807f41247f4e7daaacaebf7523ce319e33a Mon Sep 17 00:00:00 2001 From: Cayman Date: Wed, 12 Aug 2026 16:54:08 -0400 Subject: [PATCH 01/12] perf(bls): add cache-aware signature verifier --- bindings/napi/bls_verifier.zig | 254 ++++++++++++++++++ bindings/napi/blst.zig | 35 +-- bindings/napi/blst_verifier.zig | 105 ++++++++ bindings/napi/root.zig | 1 + bindings/src/bls-verifier.d.ts | 50 ++++ bindings/src/bls-verifier.js | 14 + bindings/test/bls-verifier.test.ts | 188 +++++++++++++ package.json | 4 + src/state_transition/cache/pubkey_cache.zig | 19 ++ .../cache/pubkey_cache_test.zig | 18 +- 10 files changed, 657 insertions(+), 31 deletions(-) create mode 100644 bindings/napi/bls_verifier.zig create mode 100644 bindings/napi/blst_verifier.zig create mode 100644 bindings/src/bls-verifier.d.ts create mode 100644 bindings/src/bls-verifier.js create mode 100644 bindings/test/bls-verifier.test.ts diff --git a/bindings/napi/bls_verifier.zig b/bindings/napi/bls_verifier.zig new file mode 100644 index 000000000..5acdaa53b --- /dev/null +++ b/bindings/napi/bls_verifier.zig @@ -0,0 +1,254 @@ +const std = @import("std"); +const zapi = @import("zapi:zapi"); +const js = zapi.js; +const napi = zapi.napi; +const bls = @import("bls"); +const preset = @import("preset").preset; +const blst_bindings = @import("./blst.zig"); +const blst_verifier = @import("./blst_verifier.zig"); +const pubkeys = @import("./pubkeys.zig"); + +const NativePublicKey = bls.PublicKey; +const NativeSignature = bls.Signature; +const BatchVerifyItem = bls.BatchVerifyItem; +const DST = bls.DST; + +const max_verify_sets = 256; +const max_same_message_sets = bls.MAX_AGGREGATE_PER_JOB; +const max_indices_per_set = preset.MAX_VALIDATORS_PER_COMMITTEE * preset.MAX_COMMITTEES_PER_SLOT; + +const SetType = enum(u32) { + indexed = 0, + aggregate = 1, + single = 2, +}; + +fn uint8Slice(value: napi.Value) ![]u8 { + return blst_verifier.uint8Slice(value); +} + +fn uint32Slice(value: napi.Value) ![]u32 { + if (!(try value.isTypedarray())) return error.TypeMismatch; + const info = try value.getTypedarrayInfo(); + if (info.array_type != .uint32) return error.TypeMismatch; + + const byte_ptr: [*]u8 = info.data.ptr; + const typed_ptr: [*]u32 = @ptrCast(@alignCast(byte_ptr)); + return typed_ptr[0..info.length]; +} + +fn uint32(value: napi.Value) !u32 { + const number = try value.getValueDouble(); + const max_u32: f64 = @floatFromInt(std.math.maxInt(u32)); + if (!std.math.isFinite(number) or number < 0 or number > max_u32 or @floor(number) != number) { + return error.InvalidUint32; + } + return @intFromFloat(number); +} + +fn parseMessage(set: napi.Value) ![32]u8 { + const message = try uint8Slice(try set.getNamedProperty("message")); + if (message.len != 32) return error.InvalidMessageLength; + return message[0..32].*; +} + +fn parseSignature(set: napi.Value) !?NativeSignature { + const bytes = try uint8Slice(try set.getNamedProperty("signature")); + return blst_verifier.parseSignature(bytes); +} + +fn parsePublicKey(set: napi.Value) !?NativePublicKey { + const bytes = try uint8Slice(try set.getNamedProperty("pubkey")); + if (bytes.len != NativePublicKey.COMPRESS_SIZE and + bytes.len != NativePublicKey.SERIALIZE_SIZE) + { + return null; + } + var public_key = NativePublicKey.deserialize(bytes) catch return null; + public_key.validate() catch return null; + return public_key; +} + +fn resolvePublicKey(set: napi.Value, set_type: SetType) !?NativePublicKey { + const io = js.io(); + return switch (set_type) { + .indexed => blk: { + if (!pubkeys.state.initialized) return error.PubkeyIndexNotInitialized; + const index = try uint32(try set.getNamedProperty("index")); + break :blk pubkeys.state.cache.getPubkey(io, index) orelse + return error.PubkeyIndexNotFound; + }, + .aggregate => blk: { + if (!pubkeys.state.initialized) return error.PubkeyIndexNotInitialized; + const indices = try uint32Slice(try set.getNamedProperty("indices")); + if (indices.len == 0) return error.EmptyIndices; + if (indices.len > max_indices_per_set) return error.TooManyIndices; + + break :blk pubkeys.state.cache.aggregateU32(io, indices) catch |err| switch (err) { + error.InvalidIndex => return error.PubkeyIndexNotFound, + error.InvalidLength => return error.EmptyIndices, + }; + }, + .single => try parsePublicKey(set), + }; +} + +/// Verify indexed, aggregate, and raw-pubkey signature sets synchronously. +/// +/// Invalid cryptographic inputs return false. Cache misses and malformed +/// interface inputs throw so callers do not classify operational failures as +/// invalid signatures. +pub fn verifySignatureSets(sets: js.Array) !js.Boolean { + const count = try sets.length(); + if (count == 0) return js.Boolean.from(false); + if (count > max_verify_sets) return error.TooManySets; + + var messages: [max_verify_sets][32]u8 = undefined; + var public_keys: [max_verify_sets]NativePublicKey = undefined; + var signatures: [max_verify_sets]NativeSignature = undefined; + var items: [max_verify_sets]BatchVerifyItem = undefined; + + for (0..count) |i| { + const set = (try sets.get(@intCast(i))).toValue(); + const set_type: SetType = switch (try uint32(try set.getNamedProperty("type"))) { + @intFromEnum(SetType.indexed) => .indexed, + @intFromEnum(SetType.aggregate) => .aggregate, + @intFromEnum(SetType.single) => .single, + else => return error.InvalidSetType, + }; + + messages[i] = try parseMessage(set); + + public_keys[i] = (try resolvePublicKey(set, set_type)) orelse + return js.Boolean.from(false); + + signatures[i] = (try parseSignature(set)) orelse + return js.Boolean.from(false); + + items[i] = .{ + .message = messages[i], + .public_key = &public_keys[i], + .signature = &signatures[i], + .randomness = undefined, + }; + } + + if (count == 1) { + signatures[0].verify( + false, + &messages[0], + DST, + null, + &public_keys[0], + false, + ) catch return js.Boolean.from(false); + return js.Boolean.from(true); + } + + const pool = blst_bindings.state.thread_pool orelse + return error.ThreadPoolNotInitialized; + const result = try blst_verifier.verifySignatureSets( + js.io(), + pool, + items[0..count], + false, + false, + true, + ); + + return js.Boolean.from(result); +} + +/// Randomly aggregate and verify indexed signatures over the same message. +/// Returns one result per input, falling back to individual checks only when +/// the aggregate check fails. +pub fn verifySignatureSetsSameMessage(sets: js.Array, message: js.Uint8Array) !js.Array { + const count = try sets.length(); + if (count > max_same_message_sets) return error.TooManySets; + + const results = js.Array.createWithLength(count); + if (count == 0) return results; + + const message_slice = try message.toSlice(); + if (message_slice.len != 32) return error.InvalidMessageLength; + const exact_message = message_slice[0..32].*; + + if (!pubkeys.state.initialized) return error.PubkeyIndexNotInitialized; + + var public_keys: [max_same_message_sets]NativePublicKey = undefined; + var public_key_refs: [max_same_message_sets]*const NativePublicKey = undefined; + var signatures: [max_same_message_sets]NativeSignature = undefined; + var signature_refs: [max_same_message_sets]*const NativeSignature = undefined; + var signature_valid: [max_same_message_sets]bool = undefined; + var can_aggregate = true; + + const io = js.io(); + for (0..count) |i| { + const set = (try sets.get(@intCast(i))).toValue(); + const index = try uint32(try set.getNamedProperty("index")); + public_keys[i] = pubkeys.state.cache.getPubkey(io, index) orelse + return error.PubkeyIndexNotFound; + public_key_refs[i] = &public_keys[i]; + + if (try parseSignature(set)) |signature| { + signatures[i] = signature; + signature_refs[i] = &signatures[i]; + signature_valid[i] = true; + } else { + signature_valid[i] = false; + can_aggregate = false; + } + } + + if (can_aggregate) { + const pool = blst_bindings.state.thread_pool orelse + return error.ThreadPoolNotInitialized; + if (try blst_verifier.verifySameMessage( + io, + pool, + public_key_refs[0..count], + signature_refs[0..count], + &exact_message, + )) { + for (0..count) |i| try results.set(@intCast(i), js.Boolean.from(true)); + return results; + } + } + + for (0..count) |i| { + const is_valid = signature_valid[i] and blk: { + signatures[i].verify( + false, + &exact_message, + DST, + null, + &public_keys[i], + false, + ) catch break :blk false; + break :blk true; + }; + try results.set(@intCast(i), js.Boolean.from(is_valid)); + } + + return results; +} + +pub fn indexedSetType() js.Number { + return js.Number.from(@intFromEnum(SetType.indexed)); +} + +pub fn aggregateSetType() js.Number { + return js.Number.from(@intFromEnum(SetType.aggregate)); +} + +pub fn singleSetType() js.Number { + return js.Number.from(@intFromEnum(SetType.single)); +} + +pub fn maxBatchSize() js.Number { + return js.Number.from(max_verify_sets); +} + +pub fn maxSameMessageBatchSize() js.Number { + return js.Number.from(max_same_message_sets); +} diff --git a/bindings/napi/blst.zig b/bindings/napi/blst.zig index d8cfec9b3..2ba98e5fe 100644 --- a/bindings/napi/blst.zig +++ b/bindings/napi/blst.zig @@ -15,6 +15,7 @@ const zapi = @import("zapi:zapi"); const js = zapi.js; const napi = zapi.napi; const bls = @import("bls"); +const blst_verifier = @import("./blst_verifier.zig"); const NativePublicKey = bls.PublicKey; const NativeSignature = bls.Signature; @@ -36,9 +37,6 @@ const MAX_AGGREGATE_PER_JOB = bls.MAX_AGGREGATE_PER_JOB; /// See: packages/beacon-node/src/chain/bls/multithread/worker.ts const BATCH_VERIFY_SIZE = 32; -/// A broken random source must fail instead of retrying forever. -const RANDOM_SCALAR_RETRIES_MAX = 8; - /// Native-only thread pool state, reached from `root.zig` through the /// pub `state` var so it is not part of the JS module surface. const State = struct { @@ -74,17 +72,6 @@ const allocator = if (builtin.mode == .Debug) else std.heap.c_allocator; -/// A zero coefficient would omit its input from the random linear combination. -fn ensureNonzeroRandomScalar(io: std.Io, scalar: *[8]u8) !void { - if (!std.mem.allEqual(u8, scalar, 0)) return; - - for (0..RANDOM_SCALAR_RETRIES_MAX) |_| { - io.random(scalar); - if (!std.mem.allEqual(u8, scalar, 0)) return; - } - return error.RandomScalarGenerationFailed; -} - fn boolOrDefault(value: ?js.Boolean, default: bool) !bool { return if (value) |v| try v.toBool() else default; } @@ -111,11 +98,7 @@ fn unwrapClass(comptime T: type, value: js.Value) !*T { /// (it calls a non-existent `expectType` method). Instead we narrow via /// the underlying `napi.Value` directly. fn uint8SliceFromValue(value: js.Value) ![]u8 { - const raw = value.toValue(); - if (!(try raw.isTypedarray())) return error.TypeMismatch; - const info = try raw.getTypedarrayInfo(); - if (info.array_type != .uint8) return error.TypeMismatch; - return info.data; + return blst_verifier.uint8Slice(value.toValue()); } pub const PublicKey = struct { @@ -480,7 +463,6 @@ pub fn verifyMultipleAggregateSignatures(sets: js.Array, pks_validate: ?js.Boole break :blk buf; }; - const io = js.io(); for (0..n_elems) |i| { const set = (try sets.get(@intCast(i))).toValue(); @@ -498,18 +480,17 @@ pub fn verifyMultipleAggregateSignatures(sets: js.Array, pks_validate: ?js.Boole .signature = &wrapped_sig.raw, .randomness = undefined, }; - io.random(&items[i].randomness); - try ensureNonzeroRandomScalar(io, items[i].randomness[0..8]); } const pool = state.thread_pool orelse return error.ThreadPoolNotInitialized; - const result = pool.verifyMultipleAggregateSignatures( + const result = try blst_verifier.verifySignatureSets( js.io(), + pool, items, - DST, try boolOrDefault(pks_validate, false), try boolOrDefault(sigs_groupcheck, false), - ) catch return js.Boolean.from(false); + false, + ); return js.Boolean.from(result); } @@ -629,7 +610,7 @@ pub fn aggregateWithRandomness(sets: js.Array) !js.Value { sig_ptrs[i] = &sigs[i]; const scalar = scalars[i * nbytes ..][0..nbytes]; - try ensureNonzeroRandomScalar(io, scalar); + try blst_verifier.ensureNonzeroRandomScalar(io, scalar); sca_ptrs[i] = &scalars[i * nbytes]; } @@ -815,7 +796,7 @@ pub fn asyncAggregateWithRandomness(sets: js.Array) !js.Value { io.random(data.randomness[0 .. n * 32]); for (0..n) |i| { const scalar = data.randomness[i * 32 ..][0..8]; - try ensureNonzeroRandomScalar(io, scalar); + try blst_verifier.ensureNonzeroRandomScalar(io, scalar); } for (0..n) |i| { diff --git a/bindings/napi/blst_verifier.zig b/bindings/napi/blst_verifier.zig new file mode 100644 index 000000000..65e48214d --- /dev/null +++ b/bindings/napi/blst_verifier.zig @@ -0,0 +1,105 @@ +const std = @import("std"); +const zapi = @import("zapi:zapi"); +const napi = zapi.napi; +const bls = @import("bls"); + +const NativePublicKey = bls.PublicKey; +const NativeSignature = bls.Signature; +const SigningRoot = bls.SigningRoot; +const Pairing = bls.Pairing; +const ThreadPool = bls.ThreadPool; +const BatchVerifyItem = bls.BatchVerifyItem; +const DST = bls.DST; +const MAX_AGGREGATE_PER_JOB = bls.MAX_AGGREGATE_PER_JOB; + +const random_scalar_retries_max = 8; + +pub fn uint8Slice(value: napi.Value) ![]u8 { + if (!(try value.isTypedarray())) return error.TypeMismatch; + const info = try value.getTypedarrayInfo(); + if (info.array_type != .uint8) return error.TypeMismatch; + return info.data; +} + +pub fn parseSignature(bytes: []const u8) ?NativeSignature { + if (bytes.len != NativeSignature.COMPRESS_SIZE and + bytes.len != NativeSignature.SERIALIZE_SIZE) + { + return null; + } + + var signature = NativeSignature.deserialize(bytes) catch return null; + signature.validate(true) catch return null; + return signature; +} + +pub fn ensureNonzeroRandomScalar(io: std.Io, scalar: *[8]u8) !void { + if (!std.mem.allEqual(u8, scalar, 0)) return; + + for (0..random_scalar_retries_max) |_| { + io.random(scalar); + if (!std.mem.allEqual(u8, scalar, 0)) return; + } + return error.RandomScalarGenerationFailed; +} + +pub fn verifySignatureSets( + io: std.Io, + pool: *ThreadPool, + items: []BatchVerifyItem, + pks_validate: bool, + sigs_groupcheck: bool, + propagate_pool_shutdown: bool, +) !bool { + for (items) |*item| { + io.random(&item.randomness); + try ensureNonzeroRandomScalar(io, item.randomness[0..8]); + } + + return pool.verifyMultipleAggregateSignatures( + io, + items, + DST, + pks_validate, + sigs_groupcheck, + ) catch |err| switch (err) { + error.ShuttingDown, error.Canceled => if (propagate_pool_shutdown) return err else false, + else => false, + }; +} + +pub fn verifySameMessage( + io: std.Io, + pool: *ThreadPool, + public_keys: []*const NativePublicKey, + signatures: []*const NativeSignature, + message: *const SigningRoot, +) !bool { + var randomness: [MAX_AGGREGATE_PER_JOB * 32]u8 = undefined; + io.random(randomness[0 .. public_keys.len * 32]); + for (0..public_keys.len) |i| { + try ensureNonzeroRandomScalar(io, randomness[i * 32 ..][0..8]); + } + + var aggregate_public_key: NativePublicKey = undefined; + var aggregate_signature: NativeSignature = undefined; + try pool.aggregateWithRandomness( + io, + public_keys, + signatures, + randomness[0 .. public_keys.len * 32], + false, + false, + &aggregate_public_key, + &aggregate_signature, + ); + + var pairing_buffer: [Pairing.sizeOf()]u8 align(Pairing.buf_align) = undefined; + return aggregate_signature.fastAggregateVerifyPreAggregated( + false, + &pairing_buffer, + message, + DST, + &aggregate_public_key, + ) catch false; +} diff --git a/bindings/napi/root.zig b/bindings/napi/root.zig index 9d405985b..b50f2a478 100644 --- a/bindings/napi/root.zig +++ b/bindings/napi/root.zig @@ -8,6 +8,7 @@ pub const metrics = @import("./metrics.zig"); pub const stateTransition = @import("./stateTransition.zig"); pub const BeaconStateView = @import("./BeaconStateView.zig"); pub const blst = @import("./blst.zig"); +pub const blsVerifier = @import("./bls_verifier.zig"); pub const pubkeys = @import("./pubkeys.zig"); const options = @import("bls_options"); diff --git a/bindings/src/bls-verifier.d.ts b/bindings/src/bls-verifier.d.ts new file mode 100644 index 000000000..6cff02f44 --- /dev/null +++ b/bindings/src/bls-verifier.d.ts @@ -0,0 +1,50 @@ +export declare const BLS_VERIFIER_SET_TYPE: { + readonly indexed: 0; + readonly aggregate: 1; + readonly single: 2; +}; + +export declare const BLS_VERIFIER_MAX_BATCH_SIZE: 256; +export declare const BLS_VERIFIER_MAX_SAME_MESSAGE_BATCH_SIZE: 128; + +export type BlsVerifierSetTypeValue = (typeof BLS_VERIFIER_SET_TYPE)[keyof typeof BLS_VERIFIER_SET_TYPE]; + +export type IndexedSignatureSet = { + type: typeof BLS_VERIFIER_SET_TYPE.indexed; + index: number; + message: Uint8Array; + signature: Uint8Array; +}; + +export type AggregateSignatureSet = { + type: typeof BLS_VERIFIER_SET_TYPE.aggregate; + indices: Uint32Array; + message: Uint8Array; + signature: Uint8Array; +}; + +export type SingleSignatureSet = { + type: typeof BLS_VERIFIER_SET_TYPE.single; + pubkey: Uint8Array; + message: Uint8Array; + signature: Uint8Array; +}; + +export type BlsSignatureSet = IndexedSignatureSet | AggregateSignatureSet | SingleSignatureSet; + +export type SameMessageSignatureSet = { + index: number; + signature: Uint8Array; +}; + +/** + * Verify signature sets using cached pubkeys for indexed and aggregate sets. + * Invalid cryptographic inputs return false; invalid interface state throws. + */ +export declare function verifySignatureSets(sets: BlsSignatureSet[]): boolean; + +/** + * Randomly aggregate and verify cached pubkeys and signatures over one message. + * Returns one result per input; invalid interface state throws. + */ +export declare function verifySignatureSetsSameMessage(sets: SameMessageSignatureSet[], message: Uint8Array): boolean[]; diff --git a/bindings/src/bls-verifier.js b/bindings/src/bls-verifier.js new file mode 100644 index 000000000..3d9004047 --- /dev/null +++ b/bindings/src/bls-verifier.js @@ -0,0 +1,14 @@ +import bindings from "./bindings.js"; + +/** @type {typeof import("./bls-verifier.d.ts").BLS_VERIFIER_SET_TYPE} */ +export const BLS_VERIFIER_SET_TYPE = { + indexed: bindings.blsVerifier.indexedSetType(), + aggregate: bindings.blsVerifier.aggregateSetType(), + single: bindings.blsVerifier.singleSetType(), +}; + +export const BLS_VERIFIER_MAX_BATCH_SIZE = bindings.blsVerifier.maxBatchSize(); +export const BLS_VERIFIER_MAX_SAME_MESSAGE_BATCH_SIZE = bindings.blsVerifier.maxSameMessageBatchSize(); + +export const verifySignatureSets = bindings.blsVerifier.verifySignatureSets; +export const verifySignatureSetsSameMessage = bindings.blsVerifier.verifySignatureSetsSameMessage; diff --git a/bindings/test/bls-verifier.test.ts b/bindings/test/bls-verifier.test.ts new file mode 100644 index 000000000..8abf342b4 --- /dev/null +++ b/bindings/test/bls-verifier.test.ts @@ -0,0 +1,188 @@ +import {beforeEach, describe, expect, it} from "vitest"; +import { + BLS_VERIFIER_MAX_BATCH_SIZE, + BLS_VERIFIER_MAX_SAME_MESSAGE_BATCH_SIZE, + BLS_VERIFIER_SET_TYPE, + type BlsSignatureSet, + verifySignatureSets, + verifySignatureSetsSameMessage, +} from "../src/bls-verifier.js"; +import {SecretKey, aggregateSignatures} from "../src/blst.js"; +import {pubkeyCache} from "../src/pubkeys.js"; + +const keys = Array.from({length: 4}, (_, i) => { + const ikm = new Uint8Array(32); + ikm[0] = i + 1; + return SecretKey.fromKeygen(ikm); +}); + +function message(value: number): Uint8Array { + return new Uint8Array(32).fill(value); +} + +describe("bls verifier", () => { + beforeEach(() => { + pubkeyCache.reset(); + for (const [index, key] of keys.entries()) { + pubkeyCache.append(index, key.toPublicKey().toBytes()); + } + }); + + it("verifies mixed indexed, aggregate, and raw-pubkey sets", () => { + const indexedMessage = message(1); + const aggregateMessage = message(2); + const singleMessage = message(3); + const aggregateSignature = aggregateSignatures([keys[1].sign(aggregateMessage), keys[2].sign(aggregateMessage)]); + + const sets: BlsSignatureSet[] = [ + { + index: 0, + message: indexedMessage, + signature: keys[0].sign(indexedMessage).toBytes(), + type: BLS_VERIFIER_SET_TYPE.indexed, + }, + { + indices: Uint32Array.from([1, 2]), + message: aggregateMessage, + signature: aggregateSignature.toBytes(), + type: BLS_VERIFIER_SET_TYPE.aggregate, + }, + { + message: singleMessage, + pubkey: keys[3].toPublicKey().toBytes(), + signature: keys[3].sign(singleMessage).toBytes(), + type: BLS_VERIFIER_SET_TYPE.single, + }, + ]; + + expect(verifySignatureSets(sets)).toBe(true); + }); + + it("returns false for invalid cryptographic input", () => { + expect( + verifySignatureSets([ + { + index: 0, + message: message(1), + signature: new Uint8Array(96), + type: BLS_VERIFIER_SET_TYPE.indexed, + }, + ]) + ).toBe(false); + }); + + it("throws for a missing cached validator index", () => { + const signingRoot = message(1); + expect(() => + verifySignatureSets([ + { + index: keys.length, + message: signingRoot, + signature: keys[0].sign(signingRoot).toBytes(), + type: BLS_VERIFIER_SET_TYPE.indexed, + }, + ]) + ).toThrow("PubkeyIndexNotFound"); + + expect(() => + verifySignatureSetsSameMessage( + [{index: keys.length, signature: keys[0].sign(signingRoot).toBytes()}], + signingRoot + ) + ).toThrow("PubkeyIndexNotFound"); + }); + + it("randomly aggregates indexed signatures over the same message", () => { + const signingRoot = message(4); + const sets = keys.map((key, index) => ({ + index, + signature: key.sign(signingRoot).toBytes(), + })); + + expect(verifySignatureSetsSameMessage(sets, signingRoot)).toEqual([true, true, true, true]); + + sets[1] = {...sets[1], signature: keys[1].sign(message(5)).toBytes()}; + expect(verifySignatureSetsSameMessage(sets, signingRoot)).toEqual([true, false, true, true]); + + sets[1] = {...sets[1], signature: new Uint8Array(96)}; + expect(verifySignatureSetsSameMessage(sets, signingRoot)).toEqual([true, false, true, true]); + }); + + it("rejects empty and oversized batches", () => { + expect(verifySignatureSets([])).toBe(false); + expect(verifySignatureSetsSameMessage([], message(1))).toEqual([]); + + const signingRoot = message(1); + const indexedSet = { + index: 0, + message: signingRoot, + signature: keys[0].sign(signingRoot).toBytes(), + type: BLS_VERIFIER_SET_TYPE.indexed, + } as const; + expect(verifySignatureSets(Array.from({length: BLS_VERIFIER_MAX_BATCH_SIZE}, () => indexedSet))).toBe(true); + expect(() => verifySignatureSets(Array.from({length: BLS_VERIFIER_MAX_BATCH_SIZE + 1}, () => indexedSet))).toThrow( + "TooManySets" + ); + + const sameMessageSet = {index: 0, signature: indexedSet.signature}; + expect( + verifySignatureSetsSameMessage( + Array.from({length: BLS_VERIFIER_MAX_SAME_MESSAGE_BATCH_SIZE}, () => sameMessageSet), + signingRoot + ) + ).toEqual(Array.from({length: BLS_VERIFIER_MAX_SAME_MESSAGE_BATCH_SIZE}, () => true)); + expect(() => + verifySignatureSetsSameMessage( + Array.from({length: BLS_VERIFIER_MAX_SAME_MESSAGE_BATCH_SIZE + 1}, () => sameMessageSet), + signingRoot + ) + ).toThrow("TooManySets"); + }); + + it("rejects invalid numeric indices", () => { + const signingRoot = message(1); + const signature = keys[0].sign(signingRoot).toBytes(); + for (const index of [-1, 0.5, 2 ** 32]) { + expect(() => + verifySignatureSets([ + { + index, + message: signingRoot, + signature, + type: BLS_VERIFIER_SET_TYPE.indexed, + }, + ]) + ).toThrow("InvalidUint32"); + expect(() => verifySignatureSetsSameMessage([{index, signature}], signingRoot)).toThrow("InvalidUint32"); + } + }); + + it("rejects malformed messages and returns false for invalid raw pubkeys", () => { + const signingRoot = message(1); + expect(() => + verifySignatureSets([ + { + index: 0, + message: new Uint8Array(31), + signature: keys[0].sign(signingRoot).toBytes(), + type: BLS_VERIFIER_SET_TYPE.indexed, + }, + ]) + ).toThrow("InvalidMessageLength"); + + expect(() => + verifySignatureSetsSameMessage([{index: 0, signature: new Uint8Array(96)}], new Uint8Array(31)) + ).toThrow("InvalidMessageLength"); + + expect( + verifySignatureSets([ + { + message: signingRoot, + pubkey: new Uint8Array(48), + signature: keys[0].sign(signingRoot).toBytes(), + type: BLS_VERIFIER_SET_TYPE.single, + }, + ]) + ).toBe(false); + }); +}); diff --git a/package.json b/package.json index 518a97f22..8d4436f0c 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,10 @@ "import": "./bindings/src/blst.js", "types": "./bindings/src/blst.d.ts" }, + "./bls-verifier": { + "import": "./bindings/src/bls-verifier.js", + "types": "./bindings/src/bls-verifier.d.ts" + }, "./pubkeys": { "import": "./bindings/src/pubkeys.js", "types": "./bindings/src/pubkeys.d.ts" diff --git a/src/state_transition/cache/pubkey_cache.zig b/src/state_transition/cache/pubkey_cache.zig index 3d4b51282..5806f3f1c 100644 --- a/src/state_transition/cache/pubkey_cache.zig +++ b/src/state_transition/cache/pubkey_cache.zig @@ -211,6 +211,25 @@ pub const PubkeyCache = struct { self: *const PubkeyCache, io: std.Io, indices: []const u64, + ) !bls.PublicKey { + return self.aggregateIndices(u64, io, indices); + } + + /// Aggregate 32-bit validator indices without widening or allocating a + /// temporary buffer. + pub fn aggregateU32( + self: *const PubkeyCache, + io: std.Io, + indices: []const u32, + ) !bls.PublicKey { + return self.aggregateIndices(u32, io, indices); + } + + fn aggregateIndices( + self: *const PubkeyCache, + comptime Index: type, + io: std.Io, + indices: []const Index, ) !bls.PublicKey { if (indices.len == 0) return error.InvalidLength; diff --git a/src/state_transition/cache/pubkey_cache_test.zig b/src/state_transition/cache/pubkey_cache_test.zig index bb288bb54..b9e03571f 100644 --- a/src/state_transition/cache/pubkey_cache_test.zig +++ b/src/state_transition/cache/pubkey_cache_test.zig @@ -190,16 +190,26 @@ test "explicit capacity is exact, non-shrinking, and guarded" { ); } -test "aggregate rejects empty and out-of-range indices" { - var pubkeys: [1]types.primitive.BLSPubkey.Type = undefined; +test "aggregate supports u64 and u32 indices and rejects invalid input" { + var pubkeys: [2]types.primitive.BLSPubkey.Type = undefined; try interop.interopPubkeysCached(pubkeys.len, &pubkeys); var cache = PubkeyCache.init(testing.allocator, testing.io); defer cache.deinit(); - try cache.append(testing.io, pubkeys[0], 0); + for (pubkeys, 0..) |pubkey, index| { + try cache.append(testing.io, pubkey, index); + } + + const aggregate_u64 = try cache.aggregate(testing.io, &.{ 0, 1 }); + const aggregate_u32 = try cache.aggregateU32(testing.io, &.{ 0, 1 }); + const compressed_u64 = aggregate_u64.compress(); + const compressed_u32 = aggregate_u32.compress(); + try testing.expectEqualSlices(u8, &compressed_u64, &compressed_u32); try testing.expectError(error.InvalidLength, cache.aggregate(testing.io, &.{})); - try testing.expectError(error.InvalidIndex, cache.aggregate(testing.io, &.{1})); + try testing.expectError(error.InvalidLength, cache.aggregateU32(testing.io, &.{})); + try testing.expectError(error.InvalidIndex, cache.aggregate(testing.io, &.{2})); + try testing.expectError(error.InvalidIndex, cache.aggregateU32(testing.io, &.{2})); } test "batch lookups preserve order and validate inputs" { From 9ec52ec336a902684d23a07116a74ff6e19f1ac2 Mon Sep 17 00:00:00 2001 From: Cayman Date: Fri, 14 Aug 2026 11:45:25 -0400 Subject: [PATCH 02/12] refactor(bls): address verifier review feedback --- bindings/napi/bls_verifier.zig | 68 +++++++++++++----------------- bindings/napi/blst.zig | 30 ++++++------- bindings/napi/blst_verifier.zig | 37 +++++----------- bindings/src/bls-verifier.d.ts | 4 +- bindings/test/bls-verifier.test.ts | 19 +++++++++ 5 files changed, 74 insertions(+), 84 deletions(-) diff --git a/bindings/napi/bls_verifier.zig b/bindings/napi/bls_verifier.zig index 5acdaa53b..ec3107d84 100644 --- a/bindings/napi/bls_verifier.zig +++ b/bindings/napi/bls_verifier.zig @@ -13,6 +13,9 @@ const NativeSignature = bls.Signature; const BatchVerifyItem = bls.BatchVerifyItem; const DST = bls.DST; +// Bound synchronous NAPI work and the fixed stack buffers below. Lodestar's +// worker jobs normally contain at most 128 sets, so 256 provides headroom while +// requiring unusually large direct callers to chunk explicitly. const max_verify_sets = 256; const max_same_message_sets = bls.MAX_AGGREGATE_PER_JOB; const max_indices_per_set = preset.MAX_VALIDATORS_PER_COMMITTEE * preset.MAX_COMMITTEES_PER_SLOT; @@ -23,18 +26,9 @@ const SetType = enum(u32) { single = 2, }; -fn uint8Slice(value: napi.Value) ![]u8 { - return blst_verifier.uint8Slice(value); -} - fn uint32Slice(value: napi.Value) ![]u32 { - if (!(try value.isTypedarray())) return error.TypeMismatch; - const info = try value.getTypedarrayInfo(); - if (info.array_type != .uint32) return error.TypeMismatch; - - const byte_ptr: [*]u8 = info.data.ptr; - const typed_ptr: [*]u32 = @ptrCast(@alignCast(byte_ptr)); - return typed_ptr[0..info.length]; + try js.Uint32Array.validateArg(value); + return (js.Uint32Array{ .val = value }).toSlice(); } fn uint32(value: napi.Value) !u32 { @@ -46,27 +40,12 @@ fn uint32(value: napi.Value) !u32 { return @intFromFloat(number); } -fn parseMessage(set: napi.Value) ![32]u8 { - const message = try uint8Slice(try set.getNamedProperty("message")); - if (message.len != 32) return error.InvalidMessageLength; - return message[0..32].*; -} - fn parseSignature(set: napi.Value) !?NativeSignature { - const bytes = try uint8Slice(try set.getNamedProperty("signature")); - return blst_verifier.parseSignature(bytes); -} - -fn parsePublicKey(set: napi.Value) !?NativePublicKey { - const bytes = try uint8Slice(try set.getNamedProperty("pubkey")); - if (bytes.len != NativePublicKey.COMPRESS_SIZE and - bytes.len != NativePublicKey.SERIALIZE_SIZE) - { - return null; - } - var public_key = NativePublicKey.deserialize(bytes) catch return null; - public_key.validate() catch return null; - return public_key; + const value = js.Value{ .val = try set.getNamedProperty("signature") }; + const bytes = try (try value.asUint8Array()).toSlice(); + var signature = NativeSignature.deserialize(bytes) catch return null; + signature.validate(true) catch return null; + return signature; } fn resolvePublicKey(set: napi.Value, set_type: SetType) !?NativePublicKey { @@ -89,15 +68,21 @@ fn resolvePublicKey(set: napi.Value, set_type: SetType) !?NativePublicKey { error.InvalidLength => return error.EmptyIndices, }; }, - .single => try parsePublicKey(set), + .single => blk: { + const value = js.Value{ .val = try set.getNamedProperty("pubkey") }; + const bytes = try (try value.asUint8Array()).toSlice(); + var public_key = NativePublicKey.deserialize(bytes) catch break :blk null; + public_key.validate() catch break :blk null; + break :blk public_key; + }, }; } /// Verify indexed, aggregate, and raw-pubkey signature sets synchronously. /// -/// Invalid cryptographic inputs return false. Cache misses and malformed -/// interface inputs throw so callers do not classify operational failures as -/// invalid signatures. +/// Returns false as soon as a cryptographically invalid set is encountered. +/// Evaluation short-circuits, so later sets are not inspected. Cache and +/// interface errors throw only when encountered before the result is known. pub fn verifySignatureSets(sets: js.Array) !js.Boolean { const count = try sets.length(); if (count == 0) return js.Boolean.from(false); @@ -117,7 +102,10 @@ pub fn verifySignatureSets(sets: js.Array) !js.Boolean { else => return error.InvalidSetType, }; - messages[i] = try parseMessage(set); + const message_value = js.Value{ .val = try set.getNamedProperty("message") }; + const message = try (try message_value.asUint8Array()).toSlice(); + if (message.len != 32) return error.InvalidMessageLength; + messages[i] = message[0..32].*; public_keys[i] = (try resolvePublicKey(set, set_type)) orelse return js.Boolean.from(false); @@ -151,9 +139,11 @@ pub fn verifySignatureSets(sets: js.Array) !js.Boolean { js.io(), pool, items[0..count], - false, - false, - true, + .{ + .pks_validate = false, + .sigs_groupcheck = false, + .propagate_pool_shutdown = true, + }, ); return js.Boolean.from(result); diff --git a/bindings/napi/blst.zig b/bindings/napi/blst.zig index 2ba98e5fe..bab4cc37b 100644 --- a/bindings/napi/blst.zig +++ b/bindings/napi/blst.zig @@ -92,15 +92,6 @@ fn unwrapClass(comptime T: type, value: js.Value) !*T { return js.convertArg(*T, raw.value, raw.env); } -/// Reads a Uint8Array slice from a generic `js.Value`. -/// -/// Workaround: `js.Value.asUint8Array` is currently broken in zapi 2.0.0 -/// (it calls a non-existent `expectType` method). Instead we narrow via -/// the underlying `napi.Value` directly. -fn uint8SliceFromValue(value: js.Value) ![]u8 { - return blst_verifier.uint8Slice(value.toValue()); -} - pub const PublicKey = struct { pub const js_meta = js.class(.{}); @@ -304,7 +295,7 @@ pub const SecretKey = struct { const key_info_slice: ?[]const u8 = if (key_info) |value| blk: { if (value.isUndefined() or value.isNull()) break :blk null; - break :blk try uint8SliceFromValue(value); + break :blk try (try value.asUint8Array()).toSlice(); } else null; const sk = NativeSecretKey.keyGen(seed_slice, key_info_slice) catch return error.KeyGenFailed; @@ -381,7 +372,7 @@ pub fn aggregateVerify(msgs: js.Array, pks: js.Array, sig: Signature, pks_valida for (0..msgs_len) |i| { const msg_value = try msgs.get(@intCast(i)); - const msg_bytes = try uint8SliceFromValue(msg_value); + const msg_bytes = try (try msg_value.asUint8Array()).toSlice(); if (msg_bytes.len != @sizeOf(SigningRoot)) return error.InvalidMessageLength; msg_bufs[i] = msg_bytes[0..@sizeOf(SigningRoot)].*; @@ -467,7 +458,7 @@ pub fn verifyMultipleAggregateSignatures(sets: js.Array, pks_validate: ?js.Boole const set = (try sets.get(@intCast(i))).toValue(); const msg_napi = try set.getNamedProperty("msg"); - const msg_bytes = try uint8SliceFromValue(.{ .val = msg_napi }); + const msg_bytes = try (try (js.Value{ .val = msg_napi }).asUint8Array()).toSlice(); if (msg_bytes.len != @sizeOf(SigningRoot)) return error.InvalidMessageLength; const pk_napi = try set.getNamedProperty("pk"); const wrapped_pk = try unwrapClass(PublicKey, .{ .val = pk_napi }); @@ -487,9 +478,11 @@ pub fn verifyMultipleAggregateSignatures(sets: js.Array, pks_validate: ?js.Boole js.io(), pool, items, - try boolOrDefault(pks_validate, false), - try boolOrDefault(sigs_groupcheck, false), - false, + .{ + .pks_validate = try boolOrDefault(pks_validate, false), + .sigs_groupcheck = try boolOrDefault(sigs_groupcheck, false), + .propagate_pool_shutdown = false, + }, ); return js.Boolean.from(result); @@ -555,7 +548,8 @@ pub fn aggregateSerializedPublicKeys(serialized_public_keys: js.Array, pks_valid defer allocator.free(native_pks); for (0..pks_len) |i| { - const bytes = try uint8SliceFromValue(try serialized_public_keys.get(@intCast(i))); + const value = try serialized_public_keys.get(@intCast(i)); + const bytes = try (try value.asUint8Array()).toSlice(); native_pks[i] = NativePublicKey.deserialize(bytes) catch return error.DeserializationFailed; } @@ -604,7 +598,7 @@ pub fn aggregateWithRandomness(sets: js.Array) !js.Value { pk_ptrs[i] = &wrapped_pk.raw; const sig_napi = try set.getNamedProperty("sig"); - const sig_bytes = try uint8SliceFromValue(.{ .val = sig_napi }); + const sig_bytes = try (try (js.Value{ .val = sig_napi }).asUint8Array()).toSlice(); sigs[i] = NativeSignature.deserialize(sig_bytes[0..]) catch return error.DeserializationFailed; sigs[i].validate(true) catch return error.InvalidSignature; sig_ptrs[i] = &sigs[i]; @@ -808,7 +802,7 @@ pub fn asyncAggregateWithRandomness(sets: js.Array) !js.Value { data.pk_ptrs[i] = &data.pks[i]; const sig_napi = try set.getNamedProperty("sig"); - const sig_bytes = try uint8SliceFromValue(.{ .val = sig_napi }); + const sig_bytes = try (try (js.Value{ .val = sig_napi }).asUint8Array()).toSlice(); data.sigs[i] = NativeSignature.deserialize(sig_bytes[0..]) catch return error.DeserializationFailed; data.sig_ptrs[i] = &data.sigs[i]; } diff --git a/bindings/napi/blst_verifier.zig b/bindings/napi/blst_verifier.zig index 65e48214d..942cb8ffc 100644 --- a/bindings/napi/blst_verifier.zig +++ b/bindings/napi/blst_verifier.zig @@ -1,6 +1,4 @@ const std = @import("std"); -const zapi = @import("zapi:zapi"); -const napi = zapi.napi; const bls = @import("bls"); const NativePublicKey = bls.PublicKey; @@ -12,26 +10,15 @@ const BatchVerifyItem = bls.BatchVerifyItem; const DST = bls.DST; const MAX_AGGREGATE_PER_JOB = bls.MAX_AGGREGATE_PER_JOB; +// A correct RNG produces an all-zero 64-bit scalar with probability 2^-64. +// Keep retries bounded so a broken RNG fails instead of looping forever. const random_scalar_retries_max = 8; -pub fn uint8Slice(value: napi.Value) ![]u8 { - if (!(try value.isTypedarray())) return error.TypeMismatch; - const info = try value.getTypedarrayInfo(); - if (info.array_type != .uint8) return error.TypeMismatch; - return info.data; -} - -pub fn parseSignature(bytes: []const u8) ?NativeSignature { - if (bytes.len != NativeSignature.COMPRESS_SIZE and - bytes.len != NativeSignature.SERIALIZE_SIZE) - { - return null; - } - - var signature = NativeSignature.deserialize(bytes) catch return null; - signature.validate(true) catch return null; - return signature; -} +pub const VerifySignatureSetsOptions = struct { + pks_validate: bool = true, + sigs_groupcheck: bool = true, + propagate_pool_shutdown: bool = true, +}; pub fn ensureNonzeroRandomScalar(io: std.Io, scalar: *[8]u8) !void { if (!std.mem.allEqual(u8, scalar, 0)) return; @@ -47,9 +34,7 @@ pub fn verifySignatureSets( io: std.Io, pool: *ThreadPool, items: []BatchVerifyItem, - pks_validate: bool, - sigs_groupcheck: bool, - propagate_pool_shutdown: bool, + options: VerifySignatureSetsOptions, ) !bool { for (items) |*item| { io.random(&item.randomness); @@ -60,10 +45,10 @@ pub fn verifySignatureSets( io, items, DST, - pks_validate, - sigs_groupcheck, + options.pks_validate, + options.sigs_groupcheck, ) catch |err| switch (err) { - error.ShuttingDown, error.Canceled => if (propagate_pool_shutdown) return err else false, + error.ShuttingDown, error.Canceled => if (options.propagate_pool_shutdown) return err else false, else => false, }; } diff --git a/bindings/src/bls-verifier.d.ts b/bindings/src/bls-verifier.d.ts index 6cff02f44..d46cf1aaf 100644 --- a/bindings/src/bls-verifier.d.ts +++ b/bindings/src/bls-verifier.d.ts @@ -39,7 +39,9 @@ export type SameMessageSignatureSet = { /** * Verify signature sets using cached pubkeys for indexed and aggregate sets. - * Invalid cryptographic inputs return false; invalid interface state throws. + * Returns false as soon as a cryptographically invalid set is encountered. + * Evaluation short-circuits, so later sets are not inspected. Cache and + * interface errors throw only when encountered before the result is known. */ export declare function verifySignatureSets(sets: BlsSignatureSet[]): boolean; diff --git a/bindings/test/bls-verifier.test.ts b/bindings/test/bls-verifier.test.ts index 8abf342b4..b99269d57 100644 --- a/bindings/test/bls-verifier.test.ts +++ b/bindings/test/bls-verifier.test.ts @@ -92,6 +92,25 @@ describe("bls verifier", () => { ).toThrow("PubkeyIndexNotFound"); }); + it("short-circuits after an invalid cryptographic input", () => { + const signingRoot = message(1); + const invalidSet: BlsSignatureSet = { + index: 0, + message: signingRoot, + signature: new Uint8Array(96), + type: BLS_VERIFIER_SET_TYPE.indexed, + }; + const missingCacheSet: BlsSignatureSet = { + index: keys.length, + message: signingRoot, + signature: keys[0].sign(signingRoot).toBytes(), + type: BLS_VERIFIER_SET_TYPE.indexed, + }; + + expect(verifySignatureSets([invalidSet, missingCacheSet])).toBe(false); + expect(() => verifySignatureSets([missingCacheSet, invalidSet])).toThrow("PubkeyIndexNotFound"); + }); + it("randomly aggregates indexed signatures over the same message", () => { const signingRoot = message(4); const sets = keys.map((key, index) => ({ From 748bf8c76a32c3b879f9f1989a16eeb5b708adbf Mon Sep 17 00:00:00 2001 From: Cayman Date: Fri, 14 Aug 2026 12:00:47 -0400 Subject: [PATCH 03/12] refactor(bls): move verification into native modules --- bindings/napi/bls_verifier.zig | 176 +++++------------ bindings/napi/blst.zig | 9 +- bindings/napi/blst_verifier.zig | 90 --------- src/bls/Signature.zig | 24 +++ src/bls/ThreadPool.zig | 11 +- src/bls/root.zig | 2 + src/bls/verifier.zig | 167 ++++++++++++++++ src/state_transition/root.zig | 1 + .../signature_sets/verifier.zig | 187 ++++++++++++++++++ 9 files changed, 440 insertions(+), 227 deletions(-) delete mode 100644 bindings/napi/blst_verifier.zig create mode 100644 src/bls/verifier.zig create mode 100644 src/state_transition/signature_sets/verifier.zig diff --git a/bindings/napi/bls_verifier.zig b/bindings/napi/bls_verifier.zig index ec3107d84..e5dac7e25 100644 --- a/bindings/napi/bls_verifier.zig +++ b/bindings/napi/bls_verifier.zig @@ -4,14 +4,12 @@ const js = zapi.js; const napi = zapi.napi; const bls = @import("bls"); const preset = @import("preset").preset; +const state_transition = @import("state_transition"); const blst_bindings = @import("./blst.zig"); -const blst_verifier = @import("./blst_verifier.zig"); const pubkeys = @import("./pubkeys.zig"); const NativePublicKey = bls.PublicKey; -const NativeSignature = bls.Signature; -const BatchVerifyItem = bls.BatchVerifyItem; -const DST = bls.DST; +const signature_set_verifier = state_transition.signature_set_verifier; // Bound synchronous NAPI work and the fixed stack buffers below. Lodestar's // worker jobs normally contain at most 128 sets, so 256 provides headroom while @@ -19,6 +17,8 @@ const DST = bls.DST; const max_verify_sets = 256; const max_same_message_sets = bls.MAX_AGGREGATE_PER_JOB; const max_indices_per_set = preset.MAX_VALIDATORS_PER_COMMITTEE * preset.MAX_COMMITTEES_PER_SLOT; +const SignatureSetBatch = signature_set_verifier.SignatureSetBatch(max_verify_sets); +const SameMessageSignatureSetBatch = signature_set_verifier.SameMessageSignatureSetBatch(max_same_message_sets); const SetType = enum(u32) { indexed = 0, @@ -40,44 +40,6 @@ fn uint32(value: napi.Value) !u32 { return @intFromFloat(number); } -fn parseSignature(set: napi.Value) !?NativeSignature { - const value = js.Value{ .val = try set.getNamedProperty("signature") }; - const bytes = try (try value.asUint8Array()).toSlice(); - var signature = NativeSignature.deserialize(bytes) catch return null; - signature.validate(true) catch return null; - return signature; -} - -fn resolvePublicKey(set: napi.Value, set_type: SetType) !?NativePublicKey { - const io = js.io(); - return switch (set_type) { - .indexed => blk: { - if (!pubkeys.state.initialized) return error.PubkeyIndexNotInitialized; - const index = try uint32(try set.getNamedProperty("index")); - break :blk pubkeys.state.cache.getPubkey(io, index) orelse - return error.PubkeyIndexNotFound; - }, - .aggregate => blk: { - if (!pubkeys.state.initialized) return error.PubkeyIndexNotInitialized; - const indices = try uint32Slice(try set.getNamedProperty("indices")); - if (indices.len == 0) return error.EmptyIndices; - if (indices.len > max_indices_per_set) return error.TooManyIndices; - - break :blk pubkeys.state.cache.aggregateU32(io, indices) catch |err| switch (err) { - error.InvalidIndex => return error.PubkeyIndexNotFound, - error.InvalidLength => return error.EmptyIndices, - }; - }, - .single => blk: { - const value = js.Value{ .val = try set.getNamedProperty("pubkey") }; - const bytes = try (try value.asUint8Array()).toSlice(); - var public_key = NativePublicKey.deserialize(bytes) catch break :blk null; - public_key.validate() catch break :blk null; - break :blk public_key; - }, - }; -} - /// Verify indexed, aggregate, and raw-pubkey signature sets synchronously. /// /// Returns false as soon as a cryptographically invalid set is encountered. @@ -88,10 +50,8 @@ pub fn verifySignatureSets(sets: js.Array) !js.Boolean { if (count == 0) return js.Boolean.from(false); if (count > max_verify_sets) return error.TooManySets; - var messages: [max_verify_sets][32]u8 = undefined; - var public_keys: [max_verify_sets]NativePublicKey = undefined; - var signatures: [max_verify_sets]NativeSignature = undefined; - var items: [max_verify_sets]BatchVerifyItem = undefined; + var batch: SignatureSetBatch = .{}; + const io = js.io(); for (0..count) |i| { const set = (try sets.get(@intCast(i))).toValue(); @@ -105,48 +65,38 @@ pub fn verifySignatureSets(sets: js.Array) !js.Boolean { const message_value = js.Value{ .val = try set.getNamedProperty("message") }; const message = try (try message_value.asUint8Array()).toSlice(); if (message.len != 32) return error.InvalidMessageLength; - messages[i] = message[0..32].*; - - public_keys[i] = (try resolvePublicKey(set, set_type)) orelse - return js.Boolean.from(false); - - signatures[i] = (try parseSignature(set)) orelse - return js.Boolean.from(false); - - items[i] = .{ - .message = messages[i], - .public_key = &public_keys[i], - .signature = &signatures[i], - .randomness = undefined, + const exact_message = message[0..32].*; + + const public_key: NativePublicKey = switch (set_type) { + .indexed => blk: { + if (!pubkeys.state.initialized) return error.PubkeyIndexNotInitialized; + const index = try uint32(try set.getNamedProperty("index")); + break :blk pubkeys.state.cache.getPubkey(io, index) orelse + return error.PubkeyIndexNotFound; + }, + .aggregate => blk: { + if (!pubkeys.state.initialized) return error.PubkeyIndexNotInitialized; + const indices = try uint32Slice(try set.getNamedProperty("indices")); + if (indices.len > max_indices_per_set) return error.TooManyIndices; + break :blk pubkeys.state.cache.aggregateU32(io, indices) catch |err| switch (err) { + error.InvalidIndex => return error.PubkeyIndexNotFound, + error.InvalidLength => return error.EmptyIndices, + }; + }, + .single => blk: { + const value = js.Value{ .val = try set.getNamedProperty("pubkey") }; + const bytes = try (try value.asUint8Array()).toSlice(); + break :blk NativePublicKey.keyValidate(bytes) catch return js.Boolean.from(false); + }, }; - } - if (count == 1) { - signatures[0].verify( - false, - &messages[0], - DST, - null, - &public_keys[0], - false, - ) catch return js.Boolean.from(false); - return js.Boolean.from(true); + const signature_value = js.Value{ .val = try set.getNamedProperty("signature") }; + const signature = try (try signature_value.asUint8Array()).toSlice(); + if (!batch.append(&public_key, &exact_message, signature)) return js.Boolean.from(false); } - const pool = blst_bindings.state.thread_pool orelse - return error.ThreadPoolNotInitialized; - const result = try blst_verifier.verifySignatureSets( - js.io(), - pool, - items[0..count], - .{ - .pks_validate = false, - .sigs_groupcheck = false, - .propagate_pool_shutdown = true, - }, - ); - - return js.Boolean.from(result); + const pool = blst_bindings.state.thread_pool orelse return error.ThreadPoolNotInitialized; + return js.Boolean.from(try batch.verify(io, pool)); } /// Randomly aggregate and verify indexed signatures over the same message. @@ -165,59 +115,31 @@ pub fn verifySignatureSetsSameMessage(sets: js.Array, message: js.Uint8Array) !j if (!pubkeys.state.initialized) return error.PubkeyIndexNotInitialized; - var public_keys: [max_same_message_sets]NativePublicKey = undefined; - var public_key_refs: [max_same_message_sets]*const NativePublicKey = undefined; - var signatures: [max_same_message_sets]NativeSignature = undefined; - var signature_refs: [max_same_message_sets]*const NativeSignature = undefined; - var signature_valid: [max_same_message_sets]bool = undefined; - var can_aggregate = true; + var batch: SameMessageSignatureSetBatch = .{}; const io = js.io(); for (0..count) |i| { const set = (try sets.get(@intCast(i))).toValue(); const index = try uint32(try set.getNamedProperty("index")); - public_keys[i] = pubkeys.state.cache.getPubkey(io, index) orelse + const public_key = pubkeys.state.cache.getPubkey(io, index) orelse return error.PubkeyIndexNotFound; - public_key_refs[i] = &public_keys[i]; - - if (try parseSignature(set)) |signature| { - signatures[i] = signature; - signature_refs[i] = &signatures[i]; - signature_valid[i] = true; - } else { - signature_valid[i] = false; - can_aggregate = false; - } - } - if (can_aggregate) { - const pool = blst_bindings.state.thread_pool orelse - return error.ThreadPoolNotInitialized; - if (try blst_verifier.verifySameMessage( - io, - pool, - public_key_refs[0..count], - signature_refs[0..count], - &exact_message, - )) { - for (0..count) |i| try results.set(@intCast(i), js.Boolean.from(true)); - return results; - } + const signature_value = js.Value{ .val = try set.getNamedProperty("signature") }; + const signature = try (try signature_value.asUint8Array()).toSlice(); + batch.append(&public_key, signature); } + var verification_results: [max_same_message_sets]bool = undefined; + const pool = blst_bindings.state.thread_pool orelse return error.ThreadPoolNotInitialized; + try batch.verify( + io, + pool, + &exact_message, + verification_results[0..count], + ); + for (0..count) |i| { - const is_valid = signature_valid[i] and blk: { - signatures[i].verify( - false, - &exact_message, - DST, - null, - &public_keys[i], - false, - ) catch break :blk false; - break :blk true; - }; - try results.set(@intCast(i), js.Boolean.from(is_valid)); + try results.set(@intCast(i), js.Boolean.from(verification_results[i])); } return results; diff --git a/bindings/napi/blst.zig b/bindings/napi/blst.zig index bab4cc37b..2d478fe08 100644 --- a/bindings/napi/blst.zig +++ b/bindings/napi/blst.zig @@ -15,7 +15,7 @@ const zapi = @import("zapi:zapi"); const js = zapi.js; const napi = zapi.napi; const bls = @import("bls"); -const blst_verifier = @import("./blst_verifier.zig"); +const bls_verifier = bls.verifier; const NativePublicKey = bls.PublicKey; const NativeSignature = bls.Signature; @@ -474,14 +474,13 @@ pub fn verifyMultipleAggregateSignatures(sets: js.Array, pks_validate: ?js.Boole } const pool = state.thread_pool orelse return error.ThreadPoolNotInitialized; - const result = try blst_verifier.verifySignatureSets( + const result = try bls_verifier.verifySignatureSets( js.io(), pool, items, .{ .pks_validate = try boolOrDefault(pks_validate, false), .sigs_groupcheck = try boolOrDefault(sigs_groupcheck, false), - .propagate_pool_shutdown = false, }, ); @@ -604,7 +603,7 @@ pub fn aggregateWithRandomness(sets: js.Array) !js.Value { sig_ptrs[i] = &sigs[i]; const scalar = scalars[i * nbytes ..][0..nbytes]; - try blst_verifier.ensureNonzeroRandomScalar(io, scalar); + try bls_verifier.ensureNonzeroRandomScalar(io, scalar); sca_ptrs[i] = &scalars[i * nbytes]; } @@ -790,7 +789,7 @@ pub fn asyncAggregateWithRandomness(sets: js.Array) !js.Value { io.random(data.randomness[0 .. n * 32]); for (0..n) |i| { const scalar = data.randomness[i * 32 ..][0..8]; - try blst_verifier.ensureNonzeroRandomScalar(io, scalar); + try bls_verifier.ensureNonzeroRandomScalar(io, scalar); } for (0..n) |i| { diff --git a/bindings/napi/blst_verifier.zig b/bindings/napi/blst_verifier.zig deleted file mode 100644 index 942cb8ffc..000000000 --- a/bindings/napi/blst_verifier.zig +++ /dev/null @@ -1,90 +0,0 @@ -const std = @import("std"); -const bls = @import("bls"); - -const NativePublicKey = bls.PublicKey; -const NativeSignature = bls.Signature; -const SigningRoot = bls.SigningRoot; -const Pairing = bls.Pairing; -const ThreadPool = bls.ThreadPool; -const BatchVerifyItem = bls.BatchVerifyItem; -const DST = bls.DST; -const MAX_AGGREGATE_PER_JOB = bls.MAX_AGGREGATE_PER_JOB; - -// A correct RNG produces an all-zero 64-bit scalar with probability 2^-64. -// Keep retries bounded so a broken RNG fails instead of looping forever. -const random_scalar_retries_max = 8; - -pub const VerifySignatureSetsOptions = struct { - pks_validate: bool = true, - sigs_groupcheck: bool = true, - propagate_pool_shutdown: bool = true, -}; - -pub fn ensureNonzeroRandomScalar(io: std.Io, scalar: *[8]u8) !void { - if (!std.mem.allEqual(u8, scalar, 0)) return; - - for (0..random_scalar_retries_max) |_| { - io.random(scalar); - if (!std.mem.allEqual(u8, scalar, 0)) return; - } - return error.RandomScalarGenerationFailed; -} - -pub fn verifySignatureSets( - io: std.Io, - pool: *ThreadPool, - items: []BatchVerifyItem, - options: VerifySignatureSetsOptions, -) !bool { - for (items) |*item| { - io.random(&item.randomness); - try ensureNonzeroRandomScalar(io, item.randomness[0..8]); - } - - return pool.verifyMultipleAggregateSignatures( - io, - items, - DST, - options.pks_validate, - options.sigs_groupcheck, - ) catch |err| switch (err) { - error.ShuttingDown, error.Canceled => if (options.propagate_pool_shutdown) return err else false, - else => false, - }; -} - -pub fn verifySameMessage( - io: std.Io, - pool: *ThreadPool, - public_keys: []*const NativePublicKey, - signatures: []*const NativeSignature, - message: *const SigningRoot, -) !bool { - var randomness: [MAX_AGGREGATE_PER_JOB * 32]u8 = undefined; - io.random(randomness[0 .. public_keys.len * 32]); - for (0..public_keys.len) |i| { - try ensureNonzeroRandomScalar(io, randomness[i * 32 ..][0..8]); - } - - var aggregate_public_key: NativePublicKey = undefined; - var aggregate_signature: NativeSignature = undefined; - try pool.aggregateWithRandomness( - io, - public_keys, - signatures, - randomness[0 .. public_keys.len * 32], - false, - false, - &aggregate_public_key, - &aggregate_signature, - ); - - var pairing_buffer: [Pairing.sizeOf()]u8 align(Pairing.buf_align) = undefined; - return aggregate_signature.fastAggregateVerifyPreAggregated( - false, - &pairing_buffer, - message, - DST, - &aggregate_public_key, - ) catch false; -} diff --git a/src/bls/Signature.zig b/src/bls/Signature.zig index 57a6147ed..d69d19251 100644 --- a/src/bls/Signature.zig +++ b/src/bls/Signature.zig @@ -21,6 +21,15 @@ pub fn validate(self: *const Self, sig_infcheck: bool) BlstError!void { if (!c.blst_p2_affine_in_g2(&self.point)) return BlstError.PointNotInGroup; } +/// Validate a serialized signature. +/// +/// Returns the `Signature` on success, `BlstError` on failure. +pub fn sigValidate(signature: []const u8, sig_infcheck: bool) BlstError!Self { + const sig = try Self.deserialize(signature); + try sig.validate(sig_infcheck); + return sig; +} + /// Verify the `Signature` against a `PublicKey` and message. /// /// Returns `BlstError` if verification fails. @@ -230,6 +239,21 @@ const ikm: [32]u8 = [_]u8{ 0x48, 0x99, }; +test sigValidate { + const sk = try SecretKey.keyGen(&ikm, null); + const signing_root = [_]u8{0x42} ** 32; + const sig = sk.sign(&signing_root, DST, null); + const sig_comp = sig.compress(); + + const validated = try sigValidate(&sig_comp, true); + try std.testing.expect(sig.isEqual(&validated)); + + var infinity = [_]u8{0} ** COMPRESS_SIZE; + infinity[0] = 0xc0; + try std.testing.expectError(BlstError.PkIsInfinity, sigValidate(&infinity, true)); + try std.testing.expect((try sigValidate(&infinity, false)).isInfinity()); +} + test uncompress { const sk = try SecretKey.keyGen(&ikm, null); const signing_root = [_]u8{0x42} ** 32; diff --git a/src/bls/ThreadPool.zig b/src/bls/ThreadPool.zig index 0de5f8eed..fff30908d 100644 --- a/src/bls/ThreadPool.zig +++ b/src/bls/ThreadPool.zig @@ -254,6 +254,7 @@ const VerifyMultiWorkItem = struct { /// This is the multi-threaded version of the same function in `fast_verify.zig`. /// Multiple callers may invoke this concurrently — each call owns its own /// pairing buffers and job state, workers pull from a shared queue. +/// Invalid cryptographic inputs return false; pool lifecycle errors propagate. pub fn verifyMultipleAggregateSignatures( pool: *ThreadPool, io: std.Io, @@ -261,9 +262,9 @@ pub fn verifyMultipleAggregateSignatures( dst: []const u8, pks_validate: bool, sigs_groupcheck: bool, -) (BlstError || PoolError || std.Io.Cancelable)!bool { +) (PoolError || std.Io.Cancelable)!bool { const n_elems = items.len; - if (n_elems == 0) return BlstError.VerifyFail; + if (n_elems == 0) return false; if (n_elems <= 2 or pool.n_workers <= 1) { var pairing_buf: PairingBuf = .{}; @@ -273,7 +274,7 @@ pub fn verifyMultipleAggregateSignatures( dst, pks_validate, sigs_groupcheck, - ); + ) catch false; } const n_active = @min(pool.n_workers, n_elems); @@ -304,9 +305,9 @@ pub fn verifyMultipleAggregateSignatures( try pool.submitAndWait(io, item_ptrs[0..n_active]); - if (job.err_flag.load(.acquire)) return BlstError.VerifyFail; + if (job.err_flag.load(.acquire)) return false; - return mergeAndVerify(&result_bufs, n_active, null); + return mergeAndVerify(&result_bufs, n_active, null) catch false; } const AggVerifyJob = struct { diff --git a/src/bls/root.zig b/src/bls/root.zig index 0cd25e0d5..a50591285 100644 --- a/src/bls/root.zig +++ b/src/bls/root.zig @@ -20,6 +20,7 @@ pub const BatchVerifyItem = @import("fast_verify.zig").BatchVerifyItem; pub const verifyMultipleAggregateSignatures = @import("fast_verify.zig").verifyMultipleAggregateSignatures; pub const ThreadPool = @import("ThreadPool.zig"); pub const pippenger = @import("pippenger.zig"); +pub const verifier = @import("verifier.zig"); /// Maximum number of signatures that can be aggregated in a single job. pub const MAX_AGGREGATE_PER_JOB: usize = 128; @@ -39,4 +40,5 @@ test { testing.refAllDecls(AggregateSignature); testing.refAllDecls(ThreadPool); testing.refAllDecls(pippenger); + testing.refAllDecls(verifier); } diff --git a/src/bls/verifier.zig b/src/bls/verifier.zig new file mode 100644 index 000000000..2878ee02f --- /dev/null +++ b/src/bls/verifier.zig @@ -0,0 +1,167 @@ +const std = @import("std"); +const bls = @import("root.zig"); + +const PublicKey = bls.PublicKey; +const Signature = bls.Signature; +const SigningRoot = bls.SigningRoot; +const Pairing = bls.Pairing; +const ThreadPool = bls.ThreadPool; +const BatchVerifyItem = bls.BatchVerifyItem; +const DST = bls.DST; +const MAX_AGGREGATE_PER_JOB = bls.MAX_AGGREGATE_PER_JOB; + +// A correct RNG produces an all-zero 64-bit scalar with probability 2^-64. +// Keep retries bounded so a broken RNG fails instead of looping forever. +const random_scalar_retries_max = 8; + +pub const VerifySignatureSetsOptions = struct { + pks_validate: bool = true, + sigs_groupcheck: bool = true, +}; + +pub fn ensureNonzeroRandomScalar(io: std.Io, scalar: *[8]u8) !void { + if (!std.mem.allEqual(u8, scalar, 0)) return; + + for (0..random_scalar_retries_max) |_| { + io.random(scalar); + if (!std.mem.allEqual(u8, scalar, 0)) return; + } + return error.RandomScalarGenerationFailed; +} + +pub fn verifySignatureSets( + io: std.Io, + pool: *ThreadPool, + items: []BatchVerifyItem, + options: VerifySignatureSetsOptions, +) !bool { + if (items.len == 0) return false; + if (items.len == 1) { + const item = &items[0]; + item.signature.verify( + options.sigs_groupcheck, + &item.message, + DST, + null, + item.public_key, + options.pks_validate, + ) catch return false; + return true; + } + + for (items) |*item| { + io.random(&item.randomness); + try ensureNonzeroRandomScalar(io, item.randomness[0..8]); + } + + return pool.verifyMultipleAggregateSignatures( + io, + items, + DST, + options.pks_validate, + options.sigs_groupcheck, + ); +} + +test "single signature set verification" { + const ikm = [_]u8{1} ** 32; + const message = [_]u8{2} ** 32; + const secret_key = try bls.SecretKey.keyGen(&ikm, null); + const public_key = secret_key.toPublicKey(); + const signature = secret_key.sign(&message, DST, null); + var items = [_]BatchVerifyItem{.{ + .message = message, + .public_key = &public_key, + .signature = &signature, + .randomness = undefined, + }}; + + const pool = try ThreadPool.init(std.testing.allocator, std.testing.io, .{ .n_workers = 1 }); + defer pool.deinit(std.testing.io); + + try std.testing.expect(try verifySignatureSets( + std.testing.io, + pool, + &items, + .{ .pks_validate = true, .sigs_groupcheck = true }, + )); + + items[0].message = [_]u8{3} ** 32; + try std.testing.expect(!try verifySignatureSets( + std.testing.io, + pool, + &items, + .{ .pks_validate = true, .sigs_groupcheck = true }, + )); +} + +pub fn verifySameMessage( + io: std.Io, + pool: *ThreadPool, + public_keys: []*const PublicKey, + signatures: []*const Signature, + message: *const SigningRoot, +) !bool { + if (public_keys.len == 0 or public_keys.len != signatures.len) return error.InvalidLength; + if (public_keys.len > MAX_AGGREGATE_PER_JOB) return error.TooManySignatureSets; + + var randomness: [MAX_AGGREGATE_PER_JOB * 32]u8 = undefined; + io.random(randomness[0 .. public_keys.len * 32]); + for (0..public_keys.len) |i| { + try ensureNonzeroRandomScalar(io, randomness[i * 32 ..][0..8]); + } + + var aggregate_public_key: PublicKey = undefined; + var aggregate_signature: Signature = undefined; + try pool.aggregateWithRandomness( + io, + public_keys, + signatures, + randomness[0 .. public_keys.len * 32], + false, + false, + &aggregate_public_key, + &aggregate_signature, + ); + + var pairing_buffer: [Pairing.sizeOf()]u8 align(Pairing.buf_align) = undefined; + return aggregate_signature.fastAggregateVerifyPreAggregated( + false, + &pairing_buffer, + message, + DST, + &aggregate_public_key, + ) catch false; +} + +test "same-message verification rejects invalid set counts" { + var empty_public_keys = [_]*const PublicKey{}; + var empty_signatures = [_]*const Signature{}; + const message = [_]u8{0} ** 32; + const pool: *ThreadPool = undefined; + + try std.testing.expectError( + error.InvalidLength, + verifySameMessage( + std.testing.io, + pool, + &empty_public_keys, + &empty_signatures, + &message, + ), + ); + + const too_many = MAX_AGGREGATE_PER_JOB + 1; + var public_keys: [too_many]*const PublicKey = undefined; + var signatures: [too_many]*const Signature = undefined; + try std.testing.expectError( + error.TooManySignatureSets, + verifySameMessage( + std.testing.io, + pool, + &public_keys, + &signatures, + &message, + ), + ); +} diff --git a/src/state_transition/root.zig b/src/state_transition/root.zig index a11a55250..c1192eb2a 100644 --- a/src/state_transition/root.zig +++ b/src/state_transition/root.zig @@ -21,6 +21,7 @@ pub const EpochCache = @import("./cache/epoch_cache.zig").EpochCache; pub const committee_indices = @import("./utils/committee_indices.zig"); pub const PubkeyCache = @import("./cache/pubkey_cache.zig").PubkeyCache; pub const pkix = @import("./cache/pkix.zig"); +pub const signature_set_verifier = @import("./signature_sets/verifier.zig"); pub const EpochTransitionCache = @import("./cache/epoch_transition_cache.zig").EpochTransitionCache; pub const processEpoch = @import("./epoch/process_epoch.zig").processEpoch; diff --git a/src/state_transition/signature_sets/verifier.zig b/src/state_transition/signature_sets/verifier.zig new file mode 100644 index 000000000..4ecfc1a5d --- /dev/null +++ b/src/state_transition/signature_sets/verifier.zig @@ -0,0 +1,187 @@ +const std = @import("std"); +const bls = @import("bls"); + +const PublicKey = bls.PublicKey; +const Signature = bls.Signature; +const SigningRoot = bls.SigningRoot; +const ThreadPool = bls.ThreadPool; +const BatchVerifyItem = bls.BatchVerifyItem; +const DST = bls.DST; + +/// Bounded, allocation-free preparation and verification of signature sets. +/// The caller chooses the capacity appropriate for its input boundary. +pub fn SignatureSetBatch(comptime capacity: usize) type { + if (capacity == 0) @compileError("signature set batch capacity must be positive"); + + return struct { + const Self = @This(); + + count: usize = 0, + messages: [capacity]SigningRoot = undefined, + public_keys: [capacity]PublicKey = undefined, + signatures: [capacity]Signature = undefined, + + /// Add a resolved public key and validate its signature. A false + /// result is a cryptographic failure. + pub fn append( + self: *Self, + public_key: *const PublicKey, + message: *const SigningRoot, + signature_bytes: []const u8, + ) bool { + std.debug.assert(self.count < capacity); + + const signature = Signature.sigValidate(signature_bytes, true) catch return false; + + self.messages[self.count] = message.*; + self.public_keys[self.count] = public_key.*; + self.signatures[self.count] = signature; + self.count += 1; + return true; + } + + pub fn verify(self: *Self, io: std.Io, pool: *ThreadPool) !bool { + std.debug.assert(self.count > 0); + std.debug.assert(self.count <= capacity); + + var items: [capacity]BatchVerifyItem = undefined; + for (0..self.count) |i| { + items[i] = .{ + .message = self.messages[i], + .public_key = &self.public_keys[i], + .signature = &self.signatures[i], + .randomness = undefined, + }; + } + + return bls.verifier.verifySignatureSets( + io, + pool, + items[0..self.count], + .{ + .pks_validate = false, + .sigs_groupcheck = false, + }, + ); + } + }; +} + +/// Bounded, allocation-free verification for signatures sharing one message. +/// An aggregate check handles the common valid case; individual verification +/// identifies invalid signatures only when aggregation fails. +pub fn SameMessageSignatureSetBatch(comptime capacity: usize) type { + if (capacity == 0) @compileError("same-message batch capacity must be positive"); + if (capacity > bls.MAX_AGGREGATE_PER_JOB) { + @compileError("same-message batch capacity exceeds BLS aggregation limit"); + } + + return struct { + const Self = @This(); + + count: usize = 0, + public_keys: [capacity]PublicKey = undefined, + signatures: [capacity]Signature = undefined, + signature_valid: [capacity]bool = undefined, + can_aggregate: bool = true, + + pub fn append( + self: *Self, + public_key: *const PublicKey, + signature_bytes: []const u8, + ) void { + std.debug.assert(self.count < capacity); + + self.public_keys[self.count] = public_key.*; + + if (Signature.sigValidate(signature_bytes, true)) |signature| { + self.signatures[self.count] = signature; + self.signature_valid[self.count] = true; + } else |_| { + self.signature_valid[self.count] = false; + self.can_aggregate = false; + } + + self.count += 1; + } + + pub fn verify( + self: *Self, + io: std.Io, + pool: *ThreadPool, + message: *const SigningRoot, + results: []bool, + ) !void { + std.debug.assert(self.count > 0); + std.debug.assert(self.count <= capacity); + std.debug.assert(results.len == self.count); + + if (self.can_aggregate) { + var public_key_refs: [capacity]*const PublicKey = undefined; + var signature_refs: [capacity]*const Signature = undefined; + for (0..self.count) |i| { + public_key_refs[i] = &self.public_keys[i]; + signature_refs[i] = &self.signatures[i]; + } + + if (try bls.verifier.verifySameMessage( + io, + pool, + public_key_refs[0..self.count], + signature_refs[0..self.count], + message, + )) { + @memset(results, true); + return; + } + } + + for (0..self.count) |i| { + results[i] = self.signature_valid[i] and blk: { + self.signatures[i].verify( + false, + message, + DST, + null, + &self.public_keys[i], + false, + ) catch break :blk false; + break :blk true; + }; + } + } + }; +} + +test "single signature set verification" { + const ikm = [_]u8{2} ** 32; + const message = [_]u8{3} ** 32; + const secret_key = try bls.SecretKey.keyGen(&ikm, null); + const public_key = secret_key.toPublicKey(); + const signature = secret_key.sign(&message, DST, null).compress(); + const pool = try ThreadPool.init(std.testing.allocator, std.testing.io, .{ .n_workers = 1 }); + defer pool.deinit(std.testing.io); + + var batch: SignatureSetBatch(1) = .{}; + try std.testing.expect(batch.append(&public_key, &message, &signature)); + try std.testing.expect(try batch.verify(std.testing.io, pool)); +} + +test "same-message verification falls back around malformed signatures" { + const ikm = [_]u8{4} ** 32; + const message = [_]u8{5} ** 32; + const secret_key = try bls.SecretKey.keyGen(&ikm, null); + const public_key = secret_key.toPublicKey(); + const signature = secret_key.sign(&message, DST, null).compress(); + const malformed_signature = [_]u8{0} ** Signature.COMPRESS_SIZE; + const pool = try ThreadPool.init(std.testing.allocator, std.testing.io, .{ .n_workers = 1 }); + defer pool.deinit(std.testing.io); + + var batch: SameMessageSignatureSetBatch(2) = .{}; + batch.append(&public_key, &signature); + batch.append(&public_key, &malformed_signature); + + var results: [2]bool = undefined; + try batch.verify(std.testing.io, pool, &message, &results); + try std.testing.expectEqualSlices(bool, &.{ true, false }, &results); +} From d8013f61390bf4b42abaf63af6fd5c51dc758ea2 Mon Sep 17 00:00:00 2001 From: Cayman Date: Fri, 14 Aug 2026 13:12:49 -0400 Subject: [PATCH 04/12] refactor(bls): borrow batch verification inputs --- bindings/napi/bls_verifier.zig | 3 +-- bindings/napi/blst.zig | 2 +- src/bls/ThreadPool.zig | 4 ++-- src/bls/fast_verify.zig | 7 ++++--- src/bls/verifier.zig | 8 ++++---- src/state_transition/signature_sets/verifier.zig | 2 +- 6 files changed, 13 insertions(+), 13 deletions(-) diff --git a/bindings/napi/bls_verifier.zig b/bindings/napi/bls_verifier.zig index e5dac7e25..3284ae226 100644 --- a/bindings/napi/bls_verifier.zig +++ b/bindings/napi/bls_verifier.zig @@ -65,7 +65,6 @@ pub fn verifySignatureSets(sets: js.Array) !js.Boolean { const message_value = js.Value{ .val = try set.getNamedProperty("message") }; const message = try (try message_value.asUint8Array()).toSlice(); if (message.len != 32) return error.InvalidMessageLength; - const exact_message = message[0..32].*; const public_key: NativePublicKey = switch (set_type) { .indexed => blk: { @@ -92,7 +91,7 @@ pub fn verifySignatureSets(sets: js.Array) !js.Boolean { const signature_value = js.Value{ .val = try set.getNamedProperty("signature") }; const signature = try (try signature_value.asUint8Array()).toSlice(); - if (!batch.append(&public_key, &exact_message, signature)) return js.Boolean.from(false); + if (!batch.append(&public_key, message[0..32], signature)) return js.Boolean.from(false); } const pool = blst_bindings.state.thread_pool orelse return error.ThreadPoolNotInitialized; diff --git a/bindings/napi/blst.zig b/bindings/napi/blst.zig index 2d478fe08..965fc965f 100644 --- a/bindings/napi/blst.zig +++ b/bindings/napi/blst.zig @@ -466,7 +466,7 @@ pub fn verifyMultipleAggregateSignatures(sets: js.Array, pks_validate: ?js.Boole const sig_napi = try set.getNamedProperty("sig"); const wrapped_sig = try unwrapClass(Signature, .{ .val = sig_napi }); items[i] = .{ - .message = msg_bytes[0..@sizeOf(SigningRoot)].*, + .message = msg_bytes[0..@sizeOf(SigningRoot)], .public_key = &wrapped_pk.raw, .signature = &wrapped_sig.raw, .randomness = undefined, diff --git a/src/bls/ThreadPool.zig b/src/bls/ThreadPool.zig index fff30908d..652de5a53 100644 --- a/src/bls/ThreadPool.zig +++ b/src/bls/ThreadPool.zig @@ -238,7 +238,7 @@ const VerifyMultiWorkItem = struct { job.sigs_groupcheck, &item.randomness, RAND_BITS, - &item.message, + item.message, ) catch { job.err_flag.store(true, .release); break; @@ -520,7 +520,7 @@ test "verifyMultipleAggregateSignatures multi-threaded" { pks[i] = sk.toPublicKey(); sigs[i] = sk.sign(&msgs[i], blst.DST, null); items[i] = .{ - .message = msgs[i], + .message = &msgs[i], .public_key = &pks[i], .signature = &sigs[i], .randomness = undefined, diff --git a/src/bls/fast_verify.zig b/src/bls/fast_verify.zig index 665957767..895c2d304 100644 --- a/src/bls/fast_verify.zig +++ b/src/bls/fast_verify.zig @@ -4,9 +4,10 @@ const RAND_BYTES = 8; /// Number of random bits used for verification. const RAND_BITS = 8 * RAND_BYTES; -/// One signature set and its random coefficient for batch verification. +/// Borrowed signature-set inputs and an owned random coefficient for batch verification. +/// Input pointers must remain valid until verification completes. pub const BatchVerifyItem = struct { - message: SigningRoot, + message: *const SigningRoot, public_key: *const PublicKey, signature: *const Signature, randomness: [32]u8, @@ -42,7 +43,7 @@ pub fn verifyMultipleAggregateSignatures( sigs_groupcheck, &item.randomness, RAND_BITS, - &item.message, + item.message, ); } diff --git a/src/bls/verifier.zig b/src/bls/verifier.zig index 2878ee02f..f2c117526 100644 --- a/src/bls/verifier.zig +++ b/src/bls/verifier.zig @@ -40,7 +40,7 @@ pub fn verifySignatureSets( const item = &items[0]; item.signature.verify( options.sigs_groupcheck, - &item.message, + item.message, DST, null, item.public_key, @@ -65,12 +65,12 @@ pub fn verifySignatureSets( test "single signature set verification" { const ikm = [_]u8{1} ** 32; - const message = [_]u8{2} ** 32; + var message = [_]u8{2} ** 32; const secret_key = try bls.SecretKey.keyGen(&ikm, null); const public_key = secret_key.toPublicKey(); const signature = secret_key.sign(&message, DST, null); var items = [_]BatchVerifyItem{.{ - .message = message, + .message = &message, .public_key = &public_key, .signature = &signature, .randomness = undefined, @@ -86,7 +86,7 @@ test "single signature set verification" { .{ .pks_validate = true, .sigs_groupcheck = true }, )); - items[0].message = [_]u8{3} ** 32; + message = [_]u8{3} ** 32; try std.testing.expect(!try verifySignatureSets( std.testing.io, pool, diff --git a/src/state_transition/signature_sets/verifier.zig b/src/state_transition/signature_sets/verifier.zig index 4ecfc1a5d..2504ca44a 100644 --- a/src/state_transition/signature_sets/verifier.zig +++ b/src/state_transition/signature_sets/verifier.zig @@ -47,7 +47,7 @@ pub fn SignatureSetBatch(comptime capacity: usize) type { var items: [capacity]BatchVerifyItem = undefined; for (0..self.count) |i| { items[i] = .{ - .message = self.messages[i], + .message = &self.messages[i], .public_key = &self.public_keys[i], .signature = &self.signatures[i], .randomness = undefined, From f76824f9ce4202412432ca10395f35bbeda0afda Mon Sep 17 00:00:00 2001 From: Cayman Date: Fri, 14 Aug 2026 13:26:32 -0400 Subject: [PATCH 05/12] refactor(bls): address follow-up verifier review --- bindings/napi/bls_verifier.zig | 13 +++++++------ src/state_transition/cache/pubkey_cache.zig | 15 +++------------ src/state_transition/cache/pubkey_cache_test.zig | 6 +++--- 3 files changed, 13 insertions(+), 21 deletions(-) diff --git a/bindings/napi/bls_verifier.zig b/bindings/napi/bls_verifier.zig index 3284ae226..856f3b15b 100644 --- a/bindings/napi/bls_verifier.zig +++ b/bindings/napi/bls_verifier.zig @@ -42,9 +42,8 @@ fn uint32(value: napi.Value) !u32 { /// Verify indexed, aggregate, and raw-pubkey signature sets synchronously. /// -/// Returns false as soon as a cryptographically invalid set is encountered. -/// Evaluation short-circuits, so later sets are not inspected. Cache and -/// interface errors throw only when encountered before the result is known. +/// Returns false on cryptographic failure. Throws for malformed inputs and +/// cache misses encountered before a result is known. pub fn verifySignatureSets(sets: js.Array) !js.Boolean { const count = try sets.length(); if (count == 0) return js.Boolean.from(false); @@ -77,7 +76,7 @@ pub fn verifySignatureSets(sets: js.Array) !js.Boolean { if (!pubkeys.state.initialized) return error.PubkeyIndexNotInitialized; const indices = try uint32Slice(try set.getNamedProperty("indices")); if (indices.len > max_indices_per_set) return error.TooManyIndices; - break :blk pubkeys.state.cache.aggregateU32(io, indices) catch |err| switch (err) { + break :blk pubkeys.state.cache.aggregateIndices(io, u32, indices) catch |err| switch (err) { error.InvalidIndex => return error.PubkeyIndexNotFound, error.InvalidLength => return error.EmptyIndices, }; @@ -99,8 +98,10 @@ pub fn verifySignatureSets(sets: js.Array) !js.Boolean { } /// Randomly aggregate and verify indexed signatures over the same message. -/// Returns one result per input, falling back to individual checks only when -/// the aggregate check fails. +/// +/// Returns one validity result per input, preserving order. Uses aggregate +/// verification with individual fallback. Throws on invalid input, cache +/// errors, or pool unavailability. pub fn verifySignatureSetsSameMessage(sets: js.Array, message: js.Uint8Array) !js.Array { const count = try sets.length(); if (count > max_same_message_sets) return error.TooManySets; diff --git a/src/state_transition/cache/pubkey_cache.zig b/src/state_transition/cache/pubkey_cache.zig index 8cae529c0..3dcc0802d 100644 --- a/src/state_transition/cache/pubkey_cache.zig +++ b/src/state_transition/cache/pubkey_cache.zig @@ -226,23 +226,14 @@ pub const PubkeyCache = struct { io: std.Io, indices: []const u64, ) !bls.PublicKey { - return self.aggregateIndices(u64, io, indices); + return self.aggregateIndices(io, u64, indices); } - /// Aggregate 32-bit validator indices without widening or allocating a - /// temporary buffer. - pub fn aggregateU32( + /// Aggregate the pubkeys at indices of the requested integer type. + pub fn aggregateIndices( self: *const PubkeyCache, io: std.Io, - indices: []const u32, - ) !bls.PublicKey { - return self.aggregateIndices(u32, io, indices); - } - - fn aggregateIndices( - self: *const PubkeyCache, comptime Index: type, - io: std.Io, indices: []const Index, ) !bls.PublicKey { if (indices.len == 0) return error.InvalidLength; diff --git a/src/state_transition/cache/pubkey_cache_test.zig b/src/state_transition/cache/pubkey_cache_test.zig index b9e03571f..b4ff10aa1 100644 --- a/src/state_transition/cache/pubkey_cache_test.zig +++ b/src/state_transition/cache/pubkey_cache_test.zig @@ -201,15 +201,15 @@ test "aggregate supports u64 and u32 indices and rejects invalid input" { } const aggregate_u64 = try cache.aggregate(testing.io, &.{ 0, 1 }); - const aggregate_u32 = try cache.aggregateU32(testing.io, &.{ 0, 1 }); + const aggregate_u32 = try cache.aggregateIndices(testing.io, u32, &.{ 0, 1 }); const compressed_u64 = aggregate_u64.compress(); const compressed_u32 = aggregate_u32.compress(); try testing.expectEqualSlices(u8, &compressed_u64, &compressed_u32); try testing.expectError(error.InvalidLength, cache.aggregate(testing.io, &.{})); - try testing.expectError(error.InvalidLength, cache.aggregateU32(testing.io, &.{})); + try testing.expectError(error.InvalidLength, cache.aggregateIndices(testing.io, u32, &.{})); try testing.expectError(error.InvalidIndex, cache.aggregate(testing.io, &.{2})); - try testing.expectError(error.InvalidIndex, cache.aggregateU32(testing.io, &.{2})); + try testing.expectError(error.InvalidIndex, cache.aggregateIndices(testing.io, u32, &.{2})); } test "batch lookups preserve order and validate inputs" { From ab1bf3dcc1b8a3dd8e763849d2f969200afb6da6 Mon Sep 17 00:00:00 2001 From: Cayman Date: Fri, 14 Aug 2026 14:42:00 -0400 Subject: [PATCH 06/12] refactor(bls): use typed verifier input views --- bindings/napi/bls_verifier.zig | 56 +++++++++++++++++++--------------- 1 file changed, 32 insertions(+), 24 deletions(-) diff --git a/bindings/napi/bls_verifier.zig b/bindings/napi/bls_verifier.zig index 856f3b15b..43c2e49a3 100644 --- a/bindings/napi/bls_verifier.zig +++ b/bindings/napi/bls_verifier.zig @@ -1,7 +1,6 @@ const std = @import("std"); const zapi = @import("zapi:zapi"); const js = zapi.js; -const napi = zapi.napi; const bls = @import("bls"); const preset = @import("preset").preset; const state_transition = @import("state_transition"); @@ -26,13 +25,23 @@ const SetType = enum(u32) { single = 2, }; -fn uint32Slice(value: napi.Value) ![]u32 { - try js.Uint32Array.validateArg(value); - return (js.Uint32Array{ .val = value }).toSlice(); -} +const CommonSet = struct { + type: js.Number, + message: js.Uint8Array, + signature: js.Uint8Array, +}; + +const IndexedSet = struct { index: js.Number }; +const AggregateSet = struct { indices: js.Uint32Array }; +const SingleSet = struct { pubkey: js.Uint8Array }; + +const SameMessageSet = struct { + index: js.Number, + signature: js.Uint8Array, +}; -fn uint32(value: napi.Value) !u32 { - const number = try value.getValueDouble(); +fn uint32(value: js.Number) !u32 { + const number = try value.toF64(); const max_u32: f64 = @floatFromInt(std.math.maxInt(u32)); if (!std.math.isFinite(number) or number < 0 or number > max_u32 or @floor(number) != number) { return error.InvalidUint32; @@ -53,28 +62,30 @@ pub fn verifySignatureSets(sets: js.Array) !js.Boolean { const io = js.io(); for (0..count) |i| { - const set = (try sets.get(@intCast(i))).toValue(); - const set_type: SetType = switch (try uint32(try set.getNamedProperty("type"))) { + const value = try sets.get(@intCast(i)); + const set = try (try value.asObject(CommonSet)).get(); + const set_type: SetType = switch (try uint32(set.type)) { @intFromEnum(SetType.indexed) => .indexed, @intFromEnum(SetType.aggregate) => .aggregate, @intFromEnum(SetType.single) => .single, else => return error.InvalidSetType, }; - const message_value = js.Value{ .val = try set.getNamedProperty("message") }; - const message = try (try message_value.asUint8Array()).toSlice(); + const message = try set.message.toSlice(); if (message.len != 32) return error.InvalidMessageLength; const public_key: NativePublicKey = switch (set_type) { .indexed => blk: { if (!pubkeys.state.initialized) return error.PubkeyIndexNotInitialized; - const index = try uint32(try set.getNamedProperty("index")); + const indexed = try (try value.asObject(IndexedSet)).get(); + const index = try uint32(indexed.index); break :blk pubkeys.state.cache.getPubkey(io, index) orelse return error.PubkeyIndexNotFound; }, .aggregate => blk: { if (!pubkeys.state.initialized) return error.PubkeyIndexNotInitialized; - const indices = try uint32Slice(try set.getNamedProperty("indices")); + const aggregate = try (try value.asObject(AggregateSet)).get(); + const indices = try aggregate.indices.toSlice(); if (indices.len > max_indices_per_set) return error.TooManyIndices; break :blk pubkeys.state.cache.aggregateIndices(io, u32, indices) catch |err| switch (err) { error.InvalidIndex => return error.PubkeyIndexNotFound, @@ -82,14 +93,13 @@ pub fn verifySignatureSets(sets: js.Array) !js.Boolean { }; }, .single => blk: { - const value = js.Value{ .val = try set.getNamedProperty("pubkey") }; - const bytes = try (try value.asUint8Array()).toSlice(); + const single = try (try value.asObject(SingleSet)).get(); + const bytes = try single.pubkey.toSlice(); break :blk NativePublicKey.keyValidate(bytes) catch return js.Boolean.from(false); }, }; - const signature_value = js.Value{ .val = try set.getNamedProperty("signature") }; - const signature = try (try signature_value.asUint8Array()).toSlice(); + const signature = try set.signature.toSlice(); if (!batch.append(&public_key, message[0..32], signature)) return js.Boolean.from(false); } @@ -111,7 +121,6 @@ pub fn verifySignatureSetsSameMessage(sets: js.Array, message: js.Uint8Array) !j const message_slice = try message.toSlice(); if (message_slice.len != 32) return error.InvalidMessageLength; - const exact_message = message_slice[0..32].*; if (!pubkeys.state.initialized) return error.PubkeyIndexNotInitialized; @@ -119,14 +128,13 @@ pub fn verifySignatureSetsSameMessage(sets: js.Array, message: js.Uint8Array) !j const io = js.io(); for (0..count) |i| { - const set = (try sets.get(@intCast(i))).toValue(); - const index = try uint32(try set.getNamedProperty("index")); + const value = try sets.get(@intCast(i)); + const set = try (try value.asObject(SameMessageSet)).get(); + const index = try uint32(set.index); const public_key = pubkeys.state.cache.getPubkey(io, index) orelse return error.PubkeyIndexNotFound; - const signature_value = js.Value{ .val = try set.getNamedProperty("signature") }; - const signature = try (try signature_value.asUint8Array()).toSlice(); - batch.append(&public_key, signature); + batch.append(&public_key, try set.signature.toSlice()); } var verification_results: [max_same_message_sets]bool = undefined; @@ -134,7 +142,7 @@ pub fn verifySignatureSetsSameMessage(sets: js.Array, message: js.Uint8Array) !j try batch.verify( io, pool, - &exact_message, + message_slice[0..32], verification_results[0..count], ); From 7f86f6d0c4bc02c85a4abcb2cb45cc07659d9089 Mon Sep 17 00:00:00 2001 From: Cayman Date: Fri, 14 Aug 2026 14:43:32 -0400 Subject: [PATCH 07/12] refactor(bindings): alias BLS verifier namespace --- bindings/src/bls-verifier.js | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/bindings/src/bls-verifier.js b/bindings/src/bls-verifier.js index 3d9004047..30948d8cb 100644 --- a/bindings/src/bls-verifier.js +++ b/bindings/src/bls-verifier.js @@ -1,14 +1,16 @@ import bindings from "./bindings.js"; +const blsVerifier = bindings.blsVerifier; + /** @type {typeof import("./bls-verifier.d.ts").BLS_VERIFIER_SET_TYPE} */ export const BLS_VERIFIER_SET_TYPE = { - indexed: bindings.blsVerifier.indexedSetType(), - aggregate: bindings.blsVerifier.aggregateSetType(), - single: bindings.blsVerifier.singleSetType(), + indexed: blsVerifier.indexedSetType(), + aggregate: blsVerifier.aggregateSetType(), + single: blsVerifier.singleSetType(), }; -export const BLS_VERIFIER_MAX_BATCH_SIZE = bindings.blsVerifier.maxBatchSize(); -export const BLS_VERIFIER_MAX_SAME_MESSAGE_BATCH_SIZE = bindings.blsVerifier.maxSameMessageBatchSize(); +export const BLS_VERIFIER_MAX_BATCH_SIZE = blsVerifier.maxBatchSize(); +export const BLS_VERIFIER_MAX_SAME_MESSAGE_BATCH_SIZE = blsVerifier.maxSameMessageBatchSize(); -export const verifySignatureSets = bindings.blsVerifier.verifySignatureSets; -export const verifySignatureSetsSameMessage = bindings.blsVerifier.verifySignatureSetsSameMessage; +export const verifySignatureSets = blsVerifier.verifySignatureSets; +export const verifySignatureSetsSameMessage = blsVerifier.verifySignatureSetsSameMessage; From 1b3f9ad198dbc8fc34b938f44e6b504598fbff94 Mon Sep 17 00:00:00 2001 From: Cayman Date: Fri, 14 Aug 2026 16:33:12 -0400 Subject: [PATCH 08/12] refactor(bls): simplify verifier binding declarations --- bindings/napi/bls_verifier.zig | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/bindings/napi/bls_verifier.zig b/bindings/napi/bls_verifier.zig index 43c2e49a3..a54f7b0a8 100644 --- a/bindings/napi/bls_verifier.zig +++ b/bindings/napi/bls_verifier.zig @@ -3,19 +3,18 @@ const zapi = @import("zapi:zapi"); const js = zapi.js; const bls = @import("bls"); const preset = @import("preset").preset; -const state_transition = @import("state_transition"); +const signature_set_verifier = @import("state_transition").signature_set_verifier; + const blst_bindings = @import("./blst.zig"); const pubkeys = @import("./pubkeys.zig"); -const NativePublicKey = bls.PublicKey; -const signature_set_verifier = state_transition.signature_set_verifier; - // Bound synchronous NAPI work and the fixed stack buffers below. Lodestar's // worker jobs normally contain at most 128 sets, so 256 provides headroom while // requiring unusually large direct callers to chunk explicitly. const max_verify_sets = 256; const max_same_message_sets = bls.MAX_AGGREGATE_PER_JOB; const max_indices_per_set = preset.MAX_VALIDATORS_PER_COMMITTEE * preset.MAX_COMMITTEES_PER_SLOT; + const SignatureSetBatch = signature_set_verifier.SignatureSetBatch(max_verify_sets); const SameMessageSignatureSetBatch = signature_set_verifier.SameMessageSignatureSetBatch(max_same_message_sets); @@ -40,6 +39,7 @@ const SameMessageSet = struct { signature: js.Uint8Array, }; +// TODO(zapi): Replace with value.toU32Exact() after next zapi release: see https://github.com/ChainSafe/zapi/pull/71 fn uint32(value: js.Number) !u32 { const number = try value.toF64(); const max_u32: f64 = @floatFromInt(std.math.maxInt(u32)); @@ -74,7 +74,7 @@ pub fn verifySignatureSets(sets: js.Array) !js.Boolean { const message = try set.message.toSlice(); if (message.len != 32) return error.InvalidMessageLength; - const public_key: NativePublicKey = switch (set_type) { + const public_key: bls.PublicKey = switch (set_type) { .indexed => blk: { if (!pubkeys.state.initialized) return error.PubkeyIndexNotInitialized; const indexed = try (try value.asObject(IndexedSet)).get(); @@ -95,7 +95,7 @@ pub fn verifySignatureSets(sets: js.Array) !js.Boolean { .single => blk: { const single = try (try value.asObject(SingleSet)).get(); const bytes = try single.pubkey.toSlice(); - break :blk NativePublicKey.keyValidate(bytes) catch return js.Boolean.from(false); + break :blk bls.PublicKey.keyValidate(bytes) catch return js.Boolean.from(false); }, }; From b9e339b335919e86a90ed8699a280c8647976cd1 Mon Sep 17 00:00:00 2001 From: Cayman Date: Fri, 14 Aug 2026 17:05:31 -0400 Subject: [PATCH 09/12] refactor(bindings): use typed blst input views --- bindings/napi/blst.zig | 61 ++++++++++++++++++++++-------------------- 1 file changed, 32 insertions(+), 29 deletions(-) diff --git a/bindings/napi/blst.zig b/bindings/napi/blst.zig index 965fc965f..1669b9727 100644 --- a/bindings/napi/blst.zig +++ b/bindings/napi/blst.zig @@ -37,6 +37,17 @@ const MAX_AGGREGATE_PER_JOB = bls.MAX_AGGREGATE_PER_JOB; /// See: packages/beacon-node/src/chain/bls/multithread/worker.ts const BATCH_VERIFY_SIZE = 32; +const SignatureSetInput = struct { + msg: js.Uint8Array, + pk: js.Value, + sig: js.Value, +}; + +const RandomizedAggregationInput = struct { + pk: js.Value, + sig: js.Uint8Array, +}; + /// Native-only thread pool state, reached from `root.zig` through the /// pub `state` var so it is not part of the JS module surface. const State = struct { @@ -455,20 +466,16 @@ pub fn verifyMultipleAggregateSignatures(sets: js.Array, pks_validate: ?js.Boole }; for (0..n_elems) |i| { - const set = (try sets.get(@intCast(i))).toValue(); - - const msg_napi = try set.getNamedProperty("msg"); - const msg_bytes = try (try (js.Value{ .val = msg_napi }).asUint8Array()).toSlice(); - if (msg_bytes.len != @sizeOf(SigningRoot)) return error.InvalidMessageLength; - const pk_napi = try set.getNamedProperty("pk"); - const wrapped_pk = try unwrapClass(PublicKey, .{ .val = pk_napi }); - - const sig_napi = try set.getNamedProperty("sig"); - const wrapped_sig = try unwrapClass(Signature, .{ .val = sig_napi }); + const set_value = try sets.get(@intCast(i)); + const set = try (try set_value.asObject(SignatureSetInput)).get(); + const message_bytes = try set.msg.toSlice(); + if (message_bytes.len != @sizeOf(SigningRoot)) return error.InvalidMessageLength; + const public_key = try unwrapClass(PublicKey, set.pk); + const signature = try unwrapClass(Signature, set.sig); items[i] = .{ - .message = msg_bytes[0..@sizeOf(SigningRoot)], - .public_key = &wrapped_pk.raw, - .signature = &wrapped_sig.raw, + .message = message_bytes[0..@sizeOf(SigningRoot)], + .public_key = &public_key.raw, + .signature = &signature.raw, .randomness = undefined, }; } @@ -590,15 +597,13 @@ pub fn aggregateWithRandomness(sets: js.Array) !js.Value { const env = js.env(); for (0..n) |i| { - const set = (try sets.get(@intCast(i))).toValue(); + const set_value = try sets.get(@intCast(i)); + const set = try (try set_value.asObject(RandomizedAggregationInput)).get(); + const public_key = try unwrapClass(PublicKey, set.pk); + pk_ptrs[i] = &public_key.raw; - const pk_napi = try set.getNamedProperty("pk"); - const wrapped_pk = try unwrapClass(PublicKey, .{ .val = pk_napi }); - pk_ptrs[i] = &wrapped_pk.raw; - - const sig_napi = try set.getNamedProperty("sig"); - const sig_bytes = try (try (js.Value{ .val = sig_napi }).asUint8Array()).toSlice(); - sigs[i] = NativeSignature.deserialize(sig_bytes[0..]) catch return error.DeserializationFailed; + const signature_bytes = try set.sig.toSlice(); + sigs[i] = NativeSignature.deserialize(signature_bytes[0..]) catch return error.DeserializationFailed; sigs[i].validate(true) catch return error.InvalidSignature; sig_ptrs[i] = &sigs[i]; @@ -793,16 +798,14 @@ pub fn asyncAggregateWithRandomness(sets: js.Array) !js.Value { } for (0..n) |i| { - const set = (try sets.get(@intCast(i))).toValue(); - - const pk_napi = try set.getNamedProperty("pk"); - const wrapped_pk = try unwrapClass(PublicKey, .{ .val = pk_napi }); - data.pks[i] = wrapped_pk.raw; + const set_value = try sets.get(@intCast(i)); + const set = try (try set_value.asObject(RandomizedAggregationInput)).get(); + const public_key = try unwrapClass(PublicKey, set.pk); + data.pks[i] = public_key.raw; data.pk_ptrs[i] = &data.pks[i]; - const sig_napi = try set.getNamedProperty("sig"); - const sig_bytes = try (try (js.Value{ .val = sig_napi }).asUint8Array()).toSlice(); - data.sigs[i] = NativeSignature.deserialize(sig_bytes[0..]) catch return error.DeserializationFailed; + const signature_bytes = try set.sig.toSlice(); + data.sigs[i] = NativeSignature.deserialize(signature_bytes[0..]) catch return error.DeserializationFailed; data.sig_ptrs[i] = &data.sigs[i]; } From f3985dd48ca1838b7108fa1f855d372c001851a8 Mon Sep 17 00:00:00 2001 From: bing Date: Mon, 17 Aug 2026 14:32:56 +0800 Subject: [PATCH 10/12] fix: doc comments --- bindings/napi/bls_verifier.zig | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bindings/napi/bls_verifier.zig b/bindings/napi/bls_verifier.zig index a54f7b0a8..51d901a69 100644 --- a/bindings/napi/bls_verifier.zig +++ b/bindings/napi/bls_verifier.zig @@ -8,9 +8,9 @@ const signature_set_verifier = @import("state_transition").signature_set_verifie const blst_bindings = @import("./blst.zig"); const pubkeys = @import("./pubkeys.zig"); -// Bound synchronous NAPI work and the fixed stack buffers below. Lodestar's -// worker jobs normally contain at most 128 sets, so 256 provides headroom while -// requiring unusually large direct callers to chunk explicitly. +/// Bound synchronous NAPI work and the fixed stack buffers below. Lodestar's +/// worker jobs normally contain at most 128 sets, so 256 provides headroom while +/// requiring unusually large direct callers to chunk explicitly. const max_verify_sets = 256; const max_same_message_sets = bls.MAX_AGGREGATE_PER_JOB; const max_indices_per_set = preset.MAX_VALIDATORS_PER_COMMITTEE * preset.MAX_COMMITTEES_PER_SLOT; From 345ba3ce9aefe17ba249209b148435899421faf0 Mon Sep 17 00:00:00 2001 From: bing Date: Mon, 17 Aug 2026 14:33:41 +0800 Subject: [PATCH 11/12] fix: document result first then reason --- src/bls/ThreadPool.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bls/ThreadPool.zig b/src/bls/ThreadPool.zig index 652de5a53..d273aa273 100644 --- a/src/bls/ThreadPool.zig +++ b/src/bls/ThreadPool.zig @@ -254,7 +254,7 @@ const VerifyMultiWorkItem = struct { /// This is the multi-threaded version of the same function in `fast_verify.zig`. /// Multiple callers may invoke this concurrently — each call owns its own /// pairing buffers and job state, workers pull from a shared queue. -/// Invalid cryptographic inputs return false; pool lifecycle errors propagate. +/// Returns false for invalid cryptographic inputs. Propagates pool lifecycle errors. pub fn verifyMultipleAggregateSignatures( pool: *ThreadPool, io: std.Io, From 7cc2b0db590d30ea0e1f964bf1f63e10a92878e8 Mon Sep 17 00:00:00 2001 From: bing Date: Mon, 17 Aug 2026 14:36:26 +0800 Subject: [PATCH 12/12] Update src/bls/verifier.zig --- src/bls/verifier.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/bls/verifier.zig b/src/bls/verifier.zig index f2c117526..246a08c6a 100644 --- a/src/bls/verifier.zig +++ b/src/bls/verifier.zig @@ -10,8 +10,8 @@ const BatchVerifyItem = bls.BatchVerifyItem; const DST = bls.DST; const MAX_AGGREGATE_PER_JOB = bls.MAX_AGGREGATE_PER_JOB; -// A correct RNG produces an all-zero 64-bit scalar with probability 2^-64. -// Keep retries bounded so a broken RNG fails instead of looping forever. +/// A correct RNG produces an all-zero 64-bit scalar with probability 2^-64. +/// Keep retries bounded so a broken RNG fails instead of looping forever. const random_scalar_retries_max = 8; pub const VerifySignatureSetsOptions = struct {