Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions bindings/napi/blst.zig
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const builtin = @import("builtin");
const zapi = @import("zapi:zapi");
const js = zapi.js;
const napi = zapi.napi;
const pubkeys = @import("./pubkeys.zig");
const bls = @import("bls");

const NativePublicKey = bls.PublicKey;
Expand Down Expand Up @@ -718,7 +719,7 @@ fn asyncAggRand_execute(_: napi.Env, data: *AsyncAggRandData) void {
data.pk_ptrs[0..data.n],
data.sig_ptrs[0..data.n],
data.randomness[0 .. data.n * 32],
false, // pks already validated implicitly by being deserialized PublicKey instances
false, // cache keys were group-checked at deposit processing
true, // sigs were deserialized but not group-checked on the JS thread
&data.pk_out,
&data.sig_out,
Expand Down Expand Up @@ -788,8 +789,14 @@ fn rejectWithError(env: napi.Env, deferred: napi.Deferred, where: []const u8, co
///
/// See: https://github.com/supranational/blst/blob/dece82ea537b422890888bacde4034ca5b5a44d8/bindings/rust/src/pippenger.rs
///
/// Public keys are resolved from the process-wide pubkey cache by validator
/// index on the JS thread during setup (copied by value into the async
/// context), so callers skip the per-set getOrThrow and PublicKey object
/// crossing. Unknown indices throw PubkeyIndexNotFound synchronously. For
/// arbitrary non-registry keys, use the synchronous `aggregateWithRandomness`.
///
/// Arguments:
/// 1) sets: Array of {pk: PublicKey, sig: Uint8Array}
/// 1) sets: Array of {index: number, sig: Uint8Array}
///
/// Returns: Promise<{pk: PublicKey, sig: Signature}>
pub fn asyncAggregateWithRandomness(sets: js.Array) !js.Value {
Expand All @@ -798,6 +805,7 @@ pub fn asyncAggregateWithRandomness(sets: js.Array) !js.Value {
if (n == 0) return error.EmptyArray;
if (n > MAX_AGGREGATE_PER_JOB) return error.TooManySets;
if (state.thread_pool == null) return error.PoolNotInitialized;
if (!pubkeys.state.initialized) return error.PubkeyIndexNotInitialized;

const env = js.env();

Expand All @@ -821,9 +829,8 @@ 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 index = try (js.Number{ .val = try set.getNamedProperty("index") }).toU32();
data.pks[i] = pubkeys.state.cache.getPubkey(io, index) orelse return error.PubkeyIndexNotFound;
data.pk_ptrs[i] = &data.pks[i];

const sig_napi = try set.getNamedProperty("sig");
Expand Down
9 changes: 8 additions & 1 deletion bindings/src/blst.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,14 @@ export declare function aggregateWithRandomness(sets: PkAndSerializedSig[]): PkA
* Same as `aggregateWithRandomness`, but the multi-scalar multiplications run on the
* native thread pool and the call returns a Promise instead of blocking the JS thread.
*/
export declare function asyncAggregateWithRandomness(sets: PkAndSerializedSig[]): Promise<PkAndSig>;
/**
* Aggregate (pubkey, signature) pairs with randomness, resolving each signer
* from the process-wide pubkey cache by validator index (populate via
* pubkeyCache.syncPubkeys/append before use; reset() is test-only). Throws
* synchronously "PubkeyIndexNotFound" for an unknown index. For arbitrary
* non-registry keys, use the synchronous `aggregateWithRandomness`.
*/
export declare function asyncAggregateWithRandomness(sets: {index: number; sig: Uint8Array}[]): Promise<PkAndSig>;

/**
* Aggregate multiple signatures into a single signature.
Expand Down
60 changes: 60 additions & 0 deletions bindings/test/aggregate-by-index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import {afterAll, beforeAll, describe, expect, it} from "vitest";
import {SecretKey, aggregateWithRandomness, asyncAggregateWithRandomness, verify} from "../src/blst.js";
import {pubkeyCache} from "../src/pubkeys.js";

const keypairs = Array.from({length: 8}, (_, i) => {
const ikm = new Uint8Array(32);
ikm[0] = i + 1;
const sk = SecretKey.fromKeygen(ikm);
return {pk: sk.toPublicKey(), sk};
});

const msg = new Uint8Array(32).fill(13);
let base = 0;

beforeAll(() => {
// Append after any keys other suites left behind; the cache is append-only.
base = pubkeyCache.size;
pubkeyCache.ensureCapacity(base + keypairs.length);
for (const [i, {pk}] of keypairs.entries()) {
pubkeyCache.append(base + i, pk.toBytes());
}
});

afterAll(() => pubkeyCache.reset());

describe("asyncAggregateWithRandomness", () => {
it("produces a verifying aggregate from validator indices", async () => {
const sets = keypairs.map(({sk}, i) => ({index: base + i, sig: sk.sign(msg).toBytes()}));
const {pk, sig} = await asyncAggregateWithRandomness(sets);
expect(verify(msg, pk, sig)).toBe(true);
});

it("matches the synchronous by-object variant's verification result", async () => {
const byIndex = await asyncAggregateWithRandomness(
keypairs.map(({sk}, i) => ({index: base + i, sig: sk.sign(msg).toBytes()}))
);
const byObject = aggregateWithRandomness(keypairs.map(({pk, sk}) => ({pk, sig: sk.sign(msg).toBytes()})));
// Randomness differs per call, so aggregates differ — but both must verify.
expect(verify(msg, byIndex.pk, byIndex.sig)).toBe(true);
expect(verify(msg, byObject.pk, byObject.sig)).toBe(true);
});

it("rejects an aggregate containing a wrong signature", async () => {
const sets = keypairs.map(({sk}, i) => ({index: base + i, sig: sk.sign(msg).toBytes()}));
sets[3] = {index: base + 3, sig: keypairs[4].sk.sign(msg).toBytes()};
const {pk, sig} = await asyncAggregateWithRandomness(sets);
expect(verify(msg, pk, sig)).toBe(false);
});

it("throws synchronously for bad input, like the by-object variant", () => {
// Setup-phase failures throw before a Promise exists.
expect(() => asyncAggregateWithRandomness([{index: 99_999_999, sig: keypairs[0].sk.sign(msg).toBytes()}])).toThrow(
"PubkeyIndexNotFound"
);
expect(() => asyncAggregateWithRandomness([])).toThrow("EmptyArray");
expect(() => asyncAggregateWithRandomness([{index: base, sig: new Uint8Array(96).fill(0xaa)}])).toThrow(
"DeserializationFailed"
);
});
});
58 changes: 44 additions & 14 deletions bindings/test/blst.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import crypto from "node:crypto";
import {beforeEach, describe, expect, it} from "vitest";
import {afterAll, beforeEach, describe, expect, it} from "vitest";
import {
PublicKey,
SecretKey,
Expand All @@ -13,6 +13,7 @@ import {
verify,
verifyMultipleAggregateSignatures,
} from "../src/blst.js";
import {pubkeyCache} from "../src/pubkeys.js";

describe("blst", () => {
describe("PublicKey", () => {
Expand Down Expand Up @@ -463,21 +464,42 @@ describe("blst", () => {
});

describe("asyncAggregateWithRandomness", () => {
/**
* Resolve or append sets' pubkeys in the process-wide cache, returning
* their indices. Idempotent: the test helper returns the same
* deterministic keys across calls, and the cache rejects duplicates.
*/
function seedIndices(sets: {pk: PublicKey}[]): number[] {
pubkeyCache.ensureCapacity(pubkeyCache.size + sets.length);
return sets.map((s) => {
const bytes = s.pk.toBytes();
const existing = pubkeyCache.getIndex(bytes);
if (existing !== null) return existing;
const index = pubkeyCache.size;
pubkeyCache.append(index, bytes);
return index;
});
}

afterAll(() => pubkeyCache.reset());

it("should be exported as a function", () => {
expect(typeof asyncAggregateWithRandomness).toBe("function");
});

it("should return a Promise", () => {
const {sets} = getTestSetsSameMessage(2);
const input = sets.map((s) => ({pk: s.pk, sig: s.sig.toBytes()}));
const indices = seedIndices(sets);
const input = sets.map((s, i) => ({index: indices[i], sig: s.sig.toBytes()}));
const result = asyncAggregateWithRandomness(input);
expect(result).toBeInstanceOf(Promise);
return result;
});

it("should resolve with aggregated pk and sig instances", async () => {
const {sets} = getTestSetsSameMessage(8);
const input = sets.map((s) => ({pk: s.pk, sig: s.sig.toBytes()}));
const indices = seedIndices(sets);
const input = sets.map((s, i) => ({index: indices[i], sig: s.sig.toBytes()}));
const result = await asyncAggregateWithRandomness(input);
expect(result).toHaveProperty("pk");
expect(result).toHaveProperty("sig");
Expand All @@ -487,38 +509,44 @@ describe("blst", () => {

it("should produce a valid aggregated signature - small MSM", async () => {
const {msg, sets} = getTestSetsSameMessage(8);
const input = sets.map((s) => ({pk: s.pk, sig: s.sig.toBytes()}));
const indices = seedIndices(sets);
const input = sets.map((s, i) => ({index: indices[i], sig: s.sig.toBytes()}));
const {pk, sig} = await asyncAggregateWithRandomness(input);
expect(verify(msg, pk, sig, false, false)).toBe(true);
});

it("should produce a valid aggregated signature - tiled MSM", async () => {
const {msg, sets} = getTestSetsSameMessage(33);
const input = sets.map((s) => ({pk: s.pk, sig: s.sig.toBytes()}));
const indices = seedIndices(sets);
const input = sets.map((s, i) => ({index: indices[i], sig: s.sig.toBytes()}));
const {pk, sig} = await asyncAggregateWithRandomness(input);
expect(verify(msg, pk, sig, false, false)).toBe(true);
});

it("should work with a single set", async () => {
const {msg, sets} = getTestSetsSameMessage(1);
const input = sets.map((s) => ({pk: s.pk, sig: s.sig.toBytes()}));
const indices = seedIndices(sets);
const input = sets.map((s, i) => ({index: indices[i], sig: s.sig.toBytes()}));
const {pk, sig} = await asyncAggregateWithRandomness(input);
expect(verify(msg, pk, sig, false, false)).toBe(true);
});

it("should fail verification against a different message", async () => {
const {sets} = getTestSetsSameMessage(4);
const input = sets.map((s) => ({pk: s.pk, sig: s.sig.toBytes()}));
const indices = seedIndices(sets);
const input = sets.map((s, i) => ({index: indices[i], sig: s.sig.toBytes()}));
const {pk, sig} = await asyncAggregateWithRandomness(input);
const wrongMessage = new Uint8Array(32).fill(0);
expect(verify(wrongMessage, pk, sig, false, false)).toBe(false);
});

it("should match the synchronous aggregateWithRandomness verification result", async () => {
const {msg, sets} = getTestSetsSameMessage(6);
const input = sets.map((s) => ({pk: s.pk, sig: s.sig.toBytes()}));
const syncResult = aggregateWithRandomness(input);
const asyncResult = await asyncAggregateWithRandomness(input);
const indices = seedIndices(sets);
const syncResult = aggregateWithRandomness(sets.map((s) => ({pk: s.pk, sig: s.sig.toBytes()})));
const asyncResult = await asyncAggregateWithRandomness(
sets.map((s, i) => ({index: indices[i], sig: s.sig.toBytes()}))
);
// Randomness differs between calls so signatures aren't byte-equal,
// but both must verify against the shared message.
expect(verify(msg, syncResult.pk, syncResult.sig, false, false)).toBe(true);
Expand All @@ -536,15 +564,17 @@ describe("blst", () => {
await expect(Promise.resolve().then(() => asyncAggregateWithRandomness(input))).rejects.toThrow();
});

it("should reject objects of the wrong class", async () => {
it("should reject sets without a valid index", async () => {
const {sets} = getTestSetsSameMessage(1);
const input = [{pk: sets[0].sk as unknown as PublicKey, sig: sets[0].sig.toBytes()}];
await expect(Promise.resolve().then(() => asyncAggregateWithRandomness(input))).rejects.toThrow("TypeMismatch");
// Old by-object shape: no `index` property.
const input = [{pk: sets[0].pk, sig: sets[0].sig.toBytes()} as unknown as {index: number; sig: Uint8Array}];
await expect(Promise.resolve().then(() => asyncAggregateWithRandomness(input))).rejects.toThrow();
});

it("should resolve concurrent invocations correctly", async () => {
const {msg, sets} = getTestSetsSameMessage(8);
const input = sets.map((s) => ({pk: s.pk, sig: s.sig.toBytes()}));
const indices = seedIndices(sets);
const input = sets.map((s, i) => ({index: indices[i], sig: s.sig.toBytes()}));
const results = await Promise.all([
asyncAggregateWithRandomness(input),
asyncAggregateWithRandomness(input),
Expand Down
Loading