Skip to content

Commit 2d40eee

Browse files
committed
perf: speed up valkeys generation (A-1361)
1 parent b512406 commit 2d40eee

3 files changed

Lines changed: 124 additions & 87 deletions

File tree

yarn-project/cli/src/cmds/validator_keys/shared.ts

Lines changed: 64 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { prettyPrintJSON } from '@aztec/cli/utils';
22
import { deriveBlsPrivateKey } from '@aztec/foundation/crypto/bls';
3-
import { createBn254Keystore } from '@aztec/foundation/crypto/bls/bn254_keystore';
3+
import { createBn254KeystoreAsync } from '@aztec/foundation/crypto/bls/bn254_keystore';
44
import { computeBn254G1PublicKeyCompressed } from '@aztec/foundation/crypto/bn254';
55
import type { EthAddress } from '@aztec/foundation/eth-address';
66
import type { LogFn } from '@aztec/foundation/log';
@@ -91,18 +91,19 @@ export async function buildValidatorEntries(input: BuildValidatorsInput) {
9191
remoteSigner,
9292
} = input;
9393

94-
const summaries: ValidatorSummary[] = [];
95-
96-
const validators = await Promise.all(
94+
const entries = await Promise.all(
9795
Array.from({ length: validatorCount }, async (_unused, i) => {
9896
const addressIndex = baseAddressIndex + i;
9997
const basePath = blsPath ?? defaultBlsPath;
10098
const perValidatorPath = withValidatorIndex(basePath, accountIndex, addressIndex);
10199

102100
const blsPrivKey = ikm || mnemonic ? deriveBlsPrivateKey(mnemonic, ikm, perValidatorPath) : undefined;
103101
const blsPubCompressed = blsPrivKey ? await computeBlsPublicKeyCompressed(blsPrivKey) : undefined;
104-
105-
const ethAttester = deriveEthAttester(mnemonic, accountIndex, addressIndex, remoteSigner);
102+
const attesterAccount = mnemonicToAccount(mnemonic, { accountIndex, addressIndex });
103+
const attesterEthAddress = attesterAccount.address as unknown as string;
104+
const ethAttester = remoteSigner
105+
? ({ address: attesterAccount.address as unknown as EthAddress, remoteSignerUrl: remoteSigner } as EthAccount)
106+
: (('0x' + Buffer.from(attesterAccount.getHdKey().privateKey!).toString('hex')) as EthPrivateKey);
106107
const attester = blsPrivKey ? { eth: ethAttester, bls: blsPrivKey } : ethAttester;
107108

108109
let publisherField: EthAccount | EthPrivateKey | (EthAccount | EthPrivateKey)[] | undefined;
@@ -126,27 +127,23 @@ export async function buildValidatorEntries(input: BuildValidatorsInput) {
126127
publisherField = publisherCount === 1 ? publisherAccounts[0] : publisherAccounts;
127128
}
128129

129-
const acct = mnemonicToAccount(mnemonic, {
130-
accountIndex,
131-
addressIndex,
132-
});
133-
const attesterEthAddress = acct.address as unknown as string;
134-
summaries.push({
135-
attesterEth: attesterEthAddress,
136-
attesterBls: blsPubCompressed,
137-
publisherEth: publisherAddresses.length > 0 ? publisherAddresses : undefined,
138-
});
139-
140130
return {
141-
attester,
142-
...(publisherField !== undefined ? { publisher: publisherField } : {}),
143-
feeRecipient,
144-
coinbase: coinbase ?? attesterEthAddress,
145-
} as ValidatorKeyStore;
131+
validator: {
132+
attester,
133+
...(publisherField !== undefined ? { publisher: publisherField } : {}),
134+
feeRecipient,
135+
coinbase: coinbase ?? attesterEthAddress,
136+
} as ValidatorKeyStore,
137+
summary: {
138+
attesterEth: attesterEthAddress,
139+
attesterBls: blsPubCompressed,
140+
publisherEth: publisherAddresses.length > 0 ? publisherAddresses : undefined,
141+
} as ValidatorSummary,
142+
};
146143
}),
147144
);
148145

149-
return { validators, summaries };
146+
return { validators: entries.map(entry => entry.validator), summaries: entries.map(entry => entry.summary) };
150147
}
151148

152149
export async function resolveKeystoreOutputPath(dataDir?: string, file?: string) {
@@ -228,7 +225,7 @@ export async function writeBn254BlsKeystore(
228225
): Promise<string> {
229226
mkdirSync(outDir, { recursive: true });
230227

231-
const keystore = createBn254Keystore(password, privateKeyHex, pubkeyHex, derivationPath);
228+
const keystore = await createBn254KeystoreAsync(password, privateKeyHex, pubkeyHex, derivationPath);
232229

233230
const safeBase = fileNameBase.replace(/[^a-zA-Z0-9_-]/g, '_');
234231
const outPath = join(outDir, `keystore-${safeBase}.json`);
@@ -241,28 +238,29 @@ export async function writeBlsBn254ToFile(
241238
validators: ValidatorKeyStore[],
242239
options: { outDir: string; password: string; blsPath?: string },
243240
): Promise<void> {
244-
for (let i = 0; i < validators.length; i++) {
245-
const v = validators[i];
246-
if (!v || typeof v !== 'object' || !('attester' in v)) {
247-
continue;
248-
}
249-
const att = (v as any).attester;
241+
await Promise.all(
242+
validators.map(async (v, i) => {
243+
if (!v || typeof v !== 'object' || !('attester' in v)) {
244+
return;
245+
}
246+
const att = (v as any).attester;
250247

251-
// Shapes: { bls: <hex> } or { eth: <ethAccount>, bls?: <hex> } or plain EthAccount
252-
const blsKey: string | undefined = typeof att === 'object' && 'bls' in att ? (att as any).bls : undefined;
253-
if (!blsKey || typeof blsKey !== 'string') {
254-
continue;
255-
}
248+
// Shapes: { bls: <hex> } or { eth: <ethAccount>, bls?: <hex> } or plain EthAccount
249+
const blsKey: string | undefined = typeof att === 'object' && 'bls' in att ? (att as any).bls : undefined;
250+
if (!blsKey || typeof blsKey !== 'string') {
251+
return;
252+
}
256253

257-
const pub = await computeBlsPublicKeyCompressed(blsKey);
258-
const path = options.blsPath ?? defaultBlsPath;
259-
const fileBase = `${String(i + 1)}_${pub.slice(2, 18)}`;
260-
const keystorePath = await writeBn254BlsKeystore(options.outDir, fileBase, options.password, blsKey, pub, path);
254+
const pub = await computeBlsPublicKeyCompressed(blsKey);
255+
const path = options.blsPath ?? defaultBlsPath;
256+
const fileBase = `${String(i + 1)}_${pub.slice(2, 18)}`;
257+
const keystorePath = await writeBn254BlsKeystore(options.outDir, fileBase, options.password, blsKey, pub, path);
261258

262-
if (typeof att === 'object') {
263-
(att as any).bls = { path: keystorePath, password: options.password };
264-
}
265-
}
259+
if (typeof att === 'object') {
260+
(att as any).bls = { path: keystorePath, password: options.password };
261+
}
262+
}),
263+
);
266264
}
267265

268266
/** Writes an Ethereum JSON V3 keystore using ethers, returns absolute path */
@@ -295,32 +293,31 @@ export async function writeEthJsonV3ToFile(
295293
return account;
296294
};
297295

298-
for (let i = 0; i < validators.length; i++) {
299-
const v = validators[i];
300-
if (!v || typeof v !== 'object') {
301-
continue;
302-
}
296+
await Promise.all(
297+
validators.map(async (v, i) => {
298+
if (!v || typeof v !== 'object') {
299+
return;
300+
}
303301

304-
// attester may be string (eth), object with eth, or remote signer
305-
const att = (v as any).attester;
306-
if (typeof att === 'string') {
307-
(v as any).attester = await maybeEncryptEth(att, `attester_${i + 1}`);
308-
} else if (att && typeof att === 'object' && 'eth' in att) {
309-
(att as any).eth = await maybeEncryptEth((att as any).eth, `attester_${i + 1}`);
310-
}
302+
// attester may be string (eth), object with eth, or remote signer
303+
const att = (v as any).attester;
304+
if (typeof att === 'string') {
305+
(v as any).attester = await maybeEncryptEth(att, `attester_${i + 1}`);
306+
} else if (att && typeof att === 'object' && 'eth' in att) {
307+
(att as any).eth = await maybeEncryptEth((att as any).eth, `attester_${i + 1}`);
308+
}
311309

312-
// publisher can be single or array
313-
if ('publisher' in v) {
314-
const pub = (v as any).publisher;
315-
if (Array.isArray(pub)) {
316-
const out: any[] = [];
317-
for (let j = 0; j < pub.length; j++) {
318-
out.push(await maybeEncryptEth(pub[j], `publisher_${i + 1}_${j + 1}`));
310+
// publisher can be single or array
311+
if ('publisher' in v) {
312+
const pub = (v as any).publisher;
313+
if (Array.isArray(pub)) {
314+
(v as any).publisher = await Promise.all(
315+
pub.map((account, j) => maybeEncryptEth(account, `publisher_${i + 1}_${j + 1}`)),
316+
);
317+
} else if (pub !== undefined) {
318+
(v as any).publisher = await maybeEncryptEth(pub, `publisher_${i + 1}`);
319319
}
320-
(v as any).publisher = out;
321-
} else if (pub !== undefined) {
322-
(v as any).publisher = await maybeEncryptEth(pub, `publisher_${i + 1}`);
323320
}
324-
}
325-
}
321+
}),
322+
);
326323
}

yarn-project/cli/src/cmds/validator_keys/valkeys.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,21 @@ describe('validator keys utilities', () => {
181181
expect(summaries[0].publisherEth!.length).toBe(3);
182182
});
183183

184+
it('preserves summary order for multiple validators', async () => {
185+
const { summaries } = await buildValidatorEntries({
186+
validatorCount: 3,
187+
publisherCount: 0,
188+
accountIndex: 0,
189+
baseAddressIndex: 0,
190+
mnemonic: TEST_MNEMONIC,
191+
feeRecipient: feeRecipient,
192+
});
193+
194+
expect(summaries.map(summary => summary.attesterEth)).toEqual(
195+
[0, 1, 2].map(addressIndex => mnemonicToAccount(TEST_MNEMONIC, { accountIndex: 0, addressIndex }).address),
196+
);
197+
});
198+
184199
it('derives different BLS and ETH keys when account index changes', async () => {
185200
// Build with account index 0
186201
const { validators: v1, summaries: s1 } = await buildValidatorEntries({

yarn-project/foundation/src/crypto/bls/bn254_keystore.ts

Lines changed: 45 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { randomBytes } from '@aztec/foundation/crypto/random';
22

3-
import { createCipheriv, createDecipheriv, createHash, pbkdf2Sync, randomUUID } from 'crypto';
3+
import { createCipheriv, createDecipheriv, createHash, pbkdf2, pbkdf2Sync, randomUUID } from 'crypto';
44
import { readFileSync } from 'fs';
5+
import { promisify } from 'util';
56
import { z } from 'zod';
67

78
/**
@@ -100,36 +101,23 @@ export interface Bn254KeystoreInterface {
100101
version: number;
101102
}
102103

103-
/**
104-
* Creates a BN254 keystore object for a BN254 BLS private key.
105-
*
106-
* Uses PBKDF2 with SHA-256 for key derivation and AES-128-CTR for encryption,
107-
* following the EIP-2335 specification format.
108-
*
109-
* @param password - Password for encrypting the private key
110-
* @param privateKeyHex - Private key as 0x-prefixed hex string (32 bytes)
111-
* @param pubkeyHex - Public key as hex string (compressed or uncompressed)
112-
* @param derivationPath - BIP-44 style derivation path (e.g., "m/12381/3600/0/0/0")
113-
* @returns BN254 keystore object ready to be serialized to JSON
114-
* @throws Error if private key is not 32-byte hex
115-
*/
116-
export function createBn254Keystore(
117-
password: string,
104+
const pbkdf2Async = promisify(pbkdf2);
105+
106+
function createBn254KeystoreFromDerivedKey(
118107
privateKeyHex: string,
119108
pubkeyHex: string,
120109
derivationPath: string,
110+
salt: Buffer,
111+
iv: Buffer,
112+
dk: Buffer,
121113
): Bn254Keystore {
122114
const ensureHex = (hex: string) => hex.replace(/^0x/i, '');
123115
const privHex = ensureHex(privateKeyHex);
124116
if (!/^[0-9a-fA-F]{64}$/.test(privHex)) {
125117
throw new Error('BLS private key must be 32-byte hex');
126118
}
127119

128-
const salt = randomBytes(32);
129-
const iv = randomBytes(16);
130-
const dk = pbkdf2Sync(Buffer.from(password.normalize('NFKD'), 'utf8'), salt, 262144, 32, 'sha256');
131120
const cipherKey = dk.subarray(0, 16);
132-
133121
const cipher = createCipheriv('aes-128-ctr', cipherKey, iv);
134122
const plaintext = Buffer.from(privHex, 'hex');
135123
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
@@ -166,6 +154,43 @@ export function createBn254Keystore(
166154
};
167155
}
168156

157+
/**
158+
* Creates a BN254 keystore object for a BN254 BLS private key.
159+
*
160+
* Uses PBKDF2 with SHA-256 for key derivation and AES-128-CTR for encryption,
161+
* following the EIP-2335 specification format.
162+
*
163+
* @param password - Password for encrypting the private key
164+
* @param privateKeyHex - Private key as 0x-prefixed hex string (32 bytes)
165+
* @param pubkeyHex - Public key as hex string (compressed or uncompressed)
166+
* @param derivationPath - BIP-44 style derivation path (e.g., "m/12381/3600/0/0/0")
167+
* @returns BN254 keystore object ready to be serialized to JSON
168+
* @throws Error if private key is not 32-byte hex
169+
*/
170+
export function createBn254Keystore(
171+
password: string,
172+
privateKeyHex: string,
173+
pubkeyHex: string,
174+
derivationPath: string,
175+
): Bn254Keystore {
176+
const salt = randomBytes(32);
177+
const iv = randomBytes(16);
178+
const dk = pbkdf2Sync(Buffer.from(password.normalize('NFKD'), 'utf8'), salt, 262144, 32, 'sha256');
179+
return createBn254KeystoreFromDerivedKey(privateKeyHex, pubkeyHex, derivationPath, salt, iv, dk);
180+
}
181+
182+
export async function createBn254KeystoreAsync(
183+
password: string,
184+
privateKeyHex: string,
185+
pubkeyHex: string,
186+
derivationPath: string,
187+
): Promise<Bn254Keystore> {
188+
const salt = randomBytes(32);
189+
const iv = randomBytes(16);
190+
const dk = await pbkdf2Async(Buffer.from(password.normalize('NFKD'), 'utf8'), salt, 262144, 32, 'sha256');
191+
return createBn254KeystoreFromDerivedKey(privateKeyHex, pubkeyHex, derivationPath, salt, iv, dk);
192+
}
193+
169194
/**
170195
* Loads and validates a BN254 keystore file.
171196
*

0 commit comments

Comments
 (0)