@aztec/node-keystore 3.0.0-canary.a9708bd → 3.0.0-devnet.2-patch.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,10 +3,11 @@
3
3
  *
4
4
  * Manages keystore configuration and delegates signing operations to appropriate signers.
5
5
  */
6
- import type { EthSigner } from '@aztec/ethereum';
6
+ import type { EthSigner } from '@aztec/ethereum/eth-signer';
7
7
  import { Buffer32 } from '@aztec/foundation/buffer';
8
8
  import { EthAddress } from '@aztec/foundation/eth-address';
9
9
  import type { Signature } from '@aztec/foundation/eth-signature';
10
+ import type { AztecAddress } from '@aztec/stdlib/aztec-address';
10
11
 
11
12
  import { Wallet } from '@ethersproject/wallet';
12
13
  import { readFileSync, readdirSync, statSync } from 'fs';
@@ -14,16 +15,17 @@ import { extname, join } from 'path';
14
15
  import type { TypedDataDefinition } from 'viem';
15
16
  import { mnemonicToAccount } from 'viem/accounts';
16
17
 
18
+ import { ethPrivateKeySchema } from './schemas.js';
17
19
  import { LocalSigner, RemoteSigner } from './signer.js';
18
20
  import type {
21
+ AttesterAccounts,
22
+ EncryptedKeyFileConfig,
19
23
  EthAccount,
20
24
  EthAccounts,
21
- EthJsonKeyFileV3Config,
22
- EthMnemonicConfig,
23
- EthPrivateKey,
24
25
  EthRemoteSignerAccount,
25
26
  EthRemoteSignerConfig,
26
27
  KeyStore,
28
+ MnemonicConfig,
27
29
  ProverKeyStore,
28
30
  ValidatorKeyStore as ValidatorKeystoreConfig,
29
31
  } from './types.js';
@@ -57,6 +59,82 @@ export class KeystoreManager {
57
59
  this.validateUniqueAttesterAddresses();
58
60
  }
59
61
 
62
+ /**
63
+ * Validates all remote signers in the keystore are accessible and have the required addresses.
64
+ * Should be called after construction if validation is needed.
65
+ */
66
+ async validateSigners(): Promise<void> {
67
+ // Collect all remote signers with their addresses grouped by URL
68
+ const remoteSignersByUrl = new Map<string, Set<string>>();
69
+
70
+ // Helper to extract remote signer URL from config
71
+ const getUrl = (config: EthRemoteSignerConfig): string => {
72
+ return typeof config === 'string' ? config : config.remoteSignerUrl;
73
+ };
74
+
75
+ // Helper to collect remote signers from accounts
76
+ const collectRemoteSigners = (accounts: EthAccounts, defaultRemoteSigner?: EthRemoteSignerConfig): void => {
77
+ const processAccount = (account: EthAccount): void => {
78
+ if (typeof account === 'object' && !('path' in account) && !('mnemonic' in (account as any))) {
79
+ // This is a remote signer account
80
+ const remoteSigner = account as EthRemoteSignerAccount;
81
+ const address = 'address' in remoteSigner ? remoteSigner.address : remoteSigner;
82
+
83
+ let url: string;
84
+ if ('remoteSignerUrl' in remoteSigner && remoteSigner.remoteSignerUrl) {
85
+ url = remoteSigner.remoteSignerUrl;
86
+ } else if (defaultRemoteSigner) {
87
+ url = getUrl(defaultRemoteSigner);
88
+ } else {
89
+ return; // No remote signer URL available
90
+ }
91
+
92
+ if (!remoteSignersByUrl.has(url)) {
93
+ remoteSignersByUrl.set(url, new Set());
94
+ }
95
+ remoteSignersByUrl.get(url)!.add(address.toString());
96
+ }
97
+ };
98
+
99
+ if (Array.isArray(accounts)) {
100
+ accounts.forEach(account => collectRemoteSigners(account, defaultRemoteSigner));
101
+ } else if (typeof accounts === 'object' && 'mnemonic' in accounts) {
102
+ // Skip mnemonic configs
103
+ } else {
104
+ processAccount(accounts as EthAccount);
105
+ }
106
+ };
107
+
108
+ // Collect from validators
109
+ const validatorCount = this.getValidatorCount();
110
+ for (let i = 0; i < validatorCount; i++) {
111
+ const validator = this.getValidator(i);
112
+ const remoteSigner = validator.remoteSigner || this.keystore.remoteSigner;
113
+
114
+ collectRemoteSigners(this.extractEthAccountsFromAttester(validator.attester), remoteSigner);
115
+ if (validator.publisher) {
116
+ collectRemoteSigners(validator.publisher, remoteSigner);
117
+ }
118
+ }
119
+
120
+ // Collect from slasher
121
+ if (this.keystore.slasher) {
122
+ collectRemoteSigners(this.keystore.slasher, this.keystore.remoteSigner);
123
+ }
124
+
125
+ // Collect from prover
126
+ if (this.keystore.prover && typeof this.keystore.prover === 'object' && 'publisher' in this.keystore.prover) {
127
+ collectRemoteSigners(this.keystore.prover.publisher, this.keystore.remoteSigner);
128
+ }
129
+
130
+ // Validate each remote signer URL with all its addresses
131
+ for (const [url, addresses] of remoteSignersByUrl.entries()) {
132
+ if (addresses.size > 0) {
133
+ await RemoteSigner.validateAccess(url, Array.from(addresses));
134
+ }
135
+ }
136
+ }
137
+
60
138
  /**
61
139
  * Validates that attester addresses are unique across all validators
62
140
  * Only checks simple private key attesters, not JSON-V3 or mnemonic attesters,
@@ -85,67 +163,57 @@ export class KeystoreManager {
85
163
  * Best-effort address extraction that avoids decryption/derivation (no JSON-V3 or mnemonic processing).
86
164
  * This is used at construction time to check for obvious duplicates without throwing for invalid inputs.
87
165
  */
88
- private extractAddressesWithoutSensitiveOperations(accounts: EthAccounts): EthAddress[] {
166
+ private extractAddressesWithoutSensitiveOperations(accounts: AttesterAccounts): EthAddress[] {
167
+ const ethAccounts = this.extractEthAccountsFromAttester(accounts);
168
+ return this.extractAddressesFromEthAccountsNonSensitive(ethAccounts);
169
+ }
170
+
171
+ /**
172
+ * Extract addresses from EthAccounts without sensitive operations (no decryption/derivation).
173
+ */
174
+ private extractAddressesFromEthAccountsNonSensitive(accounts: EthAccounts): EthAddress[] {
89
175
  const results: EthAddress[] = [];
90
176
 
91
177
  const handleAccount = (account: EthAccount): void => {
92
- // String cases: private key or address or remote signer address
93
178
  if (typeof account === 'string') {
94
179
  if (account.startsWith('0x') && account.length === 66) {
95
- // Private key -> derive address locally without external deps
96
180
  try {
97
- const signer = new LocalSigner(Buffer32.fromString(account as EthPrivateKey));
181
+ const signer = new LocalSigner(Buffer32.fromString(ethPrivateKeySchema.parse(account)));
98
182
  results.push(signer.address);
99
183
  } catch {
100
- // Ignore invalid private key at construction time
101
- }
102
- return;
103
- }
104
-
105
- if (account.startsWith('0x') && account.length === 42) {
106
- // Address string
107
- try {
108
- results.push(EthAddress.fromString(account));
109
- } catch {
110
- // Ignore invalid address format at construction time
184
+ // ignore invalid private key at construction time
111
185
  }
112
- return;
113
186
  }
114
-
115
- // Any other string cannot be confidently resolved here
116
187
  return;
117
188
  }
118
189
 
119
- // JSON V3 keystore: skip (requires decryption)
120
190
  if ('path' in account) {
121
191
  return;
122
192
  }
123
193
 
124
- // Mnemonic: skip (requires derivation and may throw on invalid mnemonics)
125
194
  if ('mnemonic' in (account as any)) {
126
195
  return;
127
196
  }
128
197
 
129
- // Remote signer account (object form)
130
- const remoteSigner = account as EthRemoteSignerAccount;
131
- const address = typeof remoteSigner === 'string' ? remoteSigner : remoteSigner.address;
132
- if (address) {
133
- try {
134
- results.push(EthAddress.fromString(address));
135
- } catch {
136
- // Ignore invalid address format at construction time
137
- }
198
+ const remoteSigner: EthRemoteSignerAccount = account;
199
+ if ('address' in remoteSigner) {
200
+ results.push(remoteSigner.address);
201
+ return;
138
202
  }
203
+ results.push(remoteSigner);
139
204
  };
140
205
 
141
206
  if (Array.isArray(accounts)) {
142
207
  for (const account of accounts) {
143
- const subResults = this.extractAddressesWithoutSensitiveOperations(account);
144
- results.push(...subResults);
208
+ handleAccount(account as EthAccount);
145
209
  }
146
210
  return results;
147
211
  }
148
212
 
213
+ if (typeof accounts === 'object' && accounts !== null && 'mnemonic' in (accounts as any)) {
214
+ return results;
215
+ }
216
+
149
217
  handleAccount(accounts as EthAccount);
150
218
  return results;
151
219
  }
@@ -155,7 +223,8 @@ export class KeystoreManager {
155
223
  */
156
224
  createAttesterSigners(validatorIndex: number): EthSigner[] {
157
225
  const validator = this.getValidator(validatorIndex);
158
- return this.createSignersFromEthAccounts(validator.attester, validator.remoteSigner || this.keystore.remoteSigner);
226
+ const ethAccounts = this.extractEthAccountsFromAttester(validator.attester);
227
+ return this.createSignersFromEthAccounts(ethAccounts, validator.remoteSigner || this.keystore.remoteSigner);
159
228
  }
160
229
 
161
230
  /**
@@ -205,7 +274,7 @@ export class KeystoreManager {
205
274
  return undefined;
206
275
  }
207
276
 
208
- // Handle simple prover case (just a private key)
277
+ // Handle prover being a private key, JSON key store or remote signer with nested address
209
278
  if (
210
279
  typeof this.keystore.prover === 'string' ||
211
280
  'path' in this.keystore.prover ||
@@ -218,10 +287,19 @@ export class KeystoreManager {
218
287
  };
219
288
  }
220
289
 
221
- const id = EthAddress.fromString(this.keystore.prover.id);
222
- const signers = this.createSignersFromEthAccounts(this.keystore.prover.publisher, this.keystore.remoteSigner);
290
+ // Handle prover as Id and specified publishers
291
+ if ('id' in this.keystore.prover) {
292
+ const id = this.keystore.prover.id;
293
+ const signers = this.createSignersFromEthAccounts(this.keystore.prover.publisher, this.keystore.remoteSigner);
294
+ return { id, signers };
295
+ }
223
296
 
224
- return { id, signers };
297
+ // Here, prover is just an EthAddress for a remote signer
298
+ const signers = this.createSignersFromEthAccounts(this.keystore.prover, this.keystore.remoteSigner);
299
+ return {
300
+ id: undefined,
301
+ signers,
302
+ };
225
303
  }
226
304
 
227
305
  /**
@@ -242,28 +320,23 @@ export class KeystoreManager {
242
320
  }
243
321
 
244
322
  /**
245
- * Get coinbase address for validator (falls back to first attester address)
323
+ * Get coinbase address for validator (falls back to the specific attester address)
246
324
  */
247
- getCoinbaseAddress(validatorIndex: number): EthAddress {
325
+ getCoinbaseAddress(validatorIndex: number, attesterAddress: EthAddress): EthAddress {
248
326
  const validator = this.getValidator(validatorIndex);
249
327
 
250
328
  if (validator.coinbase) {
251
- return EthAddress.fromString(validator.coinbase);
329
+ return validator.coinbase;
252
330
  }
253
331
 
254
- // Fall back to first attester address
255
- const attesterSigners = this.createAttesterSigners(validatorIndex);
256
- if (attesterSigners.length === 0) {
257
- throw new KeystoreError(`No attester signers found for validator ${validatorIndex}`);
258
- }
259
-
260
- return attesterSigners[0].address;
332
+ // Fall back to the specific attester address
333
+ return attesterAddress;
261
334
  }
262
335
 
263
336
  /**
264
337
  * Get fee recipient for validator
265
338
  */
266
- getFeeRecipient(validatorIndex: number): string {
339
+ getFeeRecipient(validatorIndex: number): AztecAddress {
267
340
  const validator = this.getValidator(validatorIndex);
268
341
  return validator.feeRecipient;
269
342
  }
@@ -294,7 +367,7 @@ export class KeystoreManager {
294
367
  for (let validatorIndex = 0; validatorIndex < validatorCount; validatorIndex++) {
295
368
  const validator = this.getValidator(validatorIndex);
296
369
  const signers = this.createSignersFromEthAccounts(
297
- validator.attester,
370
+ this.extractEthAccountsFromAttester(validator.attester),
298
371
  validator.remoteSigner || this.keystore.remoteSigner,
299
372
  );
300
373
  for (const signer of signers) {
@@ -351,13 +424,9 @@ export class KeystoreManager {
351
424
  if (typeof account === 'string') {
352
425
  if (account.startsWith('0x') && account.length === 66) {
353
426
  // Private key
354
- return new LocalSigner(Buffer32.fromString(account as EthPrivateKey));
427
+ return new LocalSigner(Buffer32.fromString(ethPrivateKeySchema.parse(account)));
355
428
  } else {
356
- // Remote signer address only - use default remote signer config
357
- if (!defaultRemoteSigner) {
358
- throw new KeystoreError(`No remote signer configuration found for address ${account}`);
359
- }
360
- return new RemoteSigner(EthAddress.fromString(account), defaultRemoteSigner);
429
+ throw new Error(`Invalid private key`);
361
430
  }
362
431
  }
363
432
 
@@ -368,35 +437,35 @@ export class KeystoreManager {
368
437
  }
369
438
 
370
439
  // Remote signer account
371
- const remoteSigner = account as EthRemoteSignerAccount;
372
- if (typeof remoteSigner === 'string') {
373
- // Just an address - use default config
374
- if (!defaultRemoteSigner) {
375
- throw new KeystoreError(`No remote signer configuration found for address ${remoteSigner}`);
440
+ const remoteSigner: EthRemoteSignerAccount = account;
441
+
442
+ if ('address' in remoteSigner) {
443
+ // Remote signer with config
444
+ const config = remoteSigner.remoteSignerUrl
445
+ ? {
446
+ remoteSignerUrl: remoteSigner.remoteSignerUrl,
447
+ certPath: remoteSigner.certPath,
448
+ certPass: remoteSigner.certPass,
449
+ }
450
+ : defaultRemoteSigner;
451
+ if (!config) {
452
+ throw new KeystoreError(`No remote signer configuration found for address ${remoteSigner.address}`);
376
453
  }
377
- return new RemoteSigner(EthAddress.fromString(remoteSigner), defaultRemoteSigner);
378
- }
379
454
 
380
- // Remote signer with config
381
- const config = remoteSigner.remoteSignerUrl
382
- ? {
383
- remoteSignerUrl: remoteSigner.remoteSignerUrl,
384
- certPath: remoteSigner.certPath,
385
- certPass: remoteSigner.certPass,
386
- }
387
- : defaultRemoteSigner;
388
-
389
- if (!config) {
390
- throw new KeystoreError(`No remote signer configuration found for address ${remoteSigner.address}`);
455
+ return new RemoteSigner(remoteSigner.address, config);
391
456
  }
392
457
 
393
- return new RemoteSigner(EthAddress.fromString(remoteSigner.address), config);
458
+ // Just an address - use default config
459
+ if (!defaultRemoteSigner) {
460
+ throw new KeystoreError(`No remote signer configuration found for address ${remoteSigner}`);
461
+ }
462
+ return new RemoteSigner(remoteSigner, defaultRemoteSigner);
394
463
  }
395
464
 
396
465
  /**
397
466
  * Create signer from JSON V3 keystore file or directory
398
467
  */
399
- private createSignerFromJsonV3(config: EthJsonKeyFileV3Config): EthSigner[] {
468
+ private createSignerFromJsonV3(config: EncryptedKeyFileConfig): EthSigner[] {
400
469
  try {
401
470
  const stats = statSync(config.path);
402
471
 
@@ -476,7 +545,7 @@ export class KeystoreManager {
476
545
  /**
477
546
  * Create signers from mnemonic configuration using BIP44 derivation
478
547
  */
479
- private createSignersFromMnemonic(config: EthMnemonicConfig): EthSigner[] {
548
+ private createSignersFromMnemonic(config: MnemonicConfig): EthSigner[] {
480
549
  const { mnemonic, addressIndex = 0, accountIndex = 0, addressCount = 1, accountCount = 1 } = config;
481
550
  const signers: EthSigner[] = [];
482
551
 
@@ -531,25 +600,18 @@ export class KeystoreManager {
531
600
  const validator = this.getValidator(validatorIndex);
532
601
 
533
602
  // Helper to get address from an account configuration
534
- const getAddressFromAccount = (account: EthAccount): EthAddress | EthAddress[] | null => {
603
+ const getAddressFromAccount = (account: EthAccount): EthAddress | EthAddress[] | undefined => {
535
604
  if (typeof account === 'string') {
536
605
  if (account.startsWith('0x') && account.length === 66) {
537
606
  // This is a private key - derive the address
538
607
  try {
539
- const signer = new LocalSigner(Buffer32.fromString(account as EthPrivateKey));
608
+ const signer = new LocalSigner(Buffer32.fromString(ethPrivateKeySchema.parse(account)));
540
609
  return signer.address;
541
610
  } catch {
542
- return null;
543
- }
544
- } else if (account.startsWith('0x') && account.length === 42) {
545
- // This is an address
546
- try {
547
- return EthAddress.fromString(account);
548
- } catch {
549
- return null;
611
+ return undefined;
550
612
  }
551
613
  }
552
- return null;
614
+ return undefined;
553
615
  }
554
616
 
555
617
  // JSON V3 keystore
@@ -558,18 +620,16 @@ export class KeystoreManager {
558
620
  const signers = this.createSignerFromJsonV3(account);
559
621
  return signers.map(s => s.address);
560
622
  } catch {
561
- return null;
623
+ return undefined;
562
624
  }
563
625
  }
564
626
 
565
- // Remote signer account
566
- const remoteSigner = account as EthRemoteSignerAccount;
567
- const address = typeof remoteSigner === 'string' ? remoteSigner : remoteSigner.address;
568
- try {
569
- return EthAddress.fromString(address);
570
- } catch {
571
- return null;
627
+ // Remote signer account, either it is an address or the address is nested
628
+ const remoteSigner: EthRemoteSignerAccount = account;
629
+ if ('address' in remoteSigner) {
630
+ return remoteSigner.address;
572
631
  }
632
+ return remoteSigner;
573
633
  };
574
634
 
575
635
  // Helper to check if account matches and get its remote signer config
@@ -588,13 +648,7 @@ export class KeystoreManager {
588
648
 
589
649
  // Found a match - determine the config to return
590
650
  if (typeof account === 'string') {
591
- if (account.startsWith('0x') && account.length === 66) {
592
- // Private key - local signer, no remote config
593
- return undefined;
594
- } else {
595
- // Address only - use defaults
596
- return validator.remoteSigner || this.keystore.remoteSigner;
597
- }
651
+ return undefined;
598
652
  }
599
653
 
600
654
  // JSON V3 - local signer, no remote config
@@ -603,57 +657,75 @@ export class KeystoreManager {
603
657
  }
604
658
 
605
659
  // Remote signer account with potential override
606
- const remoteSigner = account as EthRemoteSignerAccount;
607
- if (typeof remoteSigner === 'string') {
608
- // Just an address - use defaults
609
- return validator.remoteSigner || this.keystore.remoteSigner;
660
+ const remoteSigner: EthRemoteSignerAccount = account;
661
+
662
+ if ('address' in remoteSigner) {
663
+ // Has inline config
664
+ if (remoteSigner.remoteSignerUrl) {
665
+ return {
666
+ remoteSignerUrl: remoteSigner.remoteSignerUrl,
667
+ certPath: remoteSigner.certPath,
668
+ certPass: remoteSigner.certPass,
669
+ };
670
+ } else {
671
+ // No URL specified, use defaults
672
+ return validator.remoteSigner || this.keystore.remoteSigner;
673
+ }
610
674
  }
675
+ // Just an address, use defaults
676
+ return validator.remoteSigner || this.keystore.remoteSigner;
677
+ };
611
678
 
612
- // Has inline config
613
- if (remoteSigner.remoteSignerUrl) {
614
- return {
615
- remoteSignerUrl: remoteSigner.remoteSignerUrl,
616
- certPath: remoteSigner.certPath,
617
- certPass: remoteSigner.certPass,
618
- };
619
- } else {
620
- // No URL specified, use defaults
621
- return validator.remoteSigner || this.keystore.remoteSigner;
679
+ // Normalize attester to EthAccounts and search
680
+ const normalized = this.extractEthAccountsFromAttester(validator.attester);
681
+
682
+ const findInEthAccounts = (accs: EthAccounts): EthRemoteSignerConfig | undefined => {
683
+ if (typeof accs === 'string') {
684
+ return checkAccount(accs);
622
685
  }
686
+ if (Array.isArray(accs)) {
687
+ for (const a of accs as EthAccount[]) {
688
+ const res = checkAccount(a);
689
+ if (res !== undefined) {
690
+ return res;
691
+ }
692
+ }
693
+ return undefined;
694
+ }
695
+ if (typeof accs === 'object' && accs !== null && 'mnemonic' in accs) {
696
+ // mnemonic-derived keys are local signers; no remote signer config
697
+ return undefined;
698
+ }
699
+ return checkAccount(accs as EthAccount);
623
700
  };
624
701
 
625
- // Check the attester configuration
626
- const { attester } = validator;
702
+ return findInEthAccounts(normalized);
703
+ }
627
704
 
705
+ /** Extract ETH accounts from AttesterAccounts */
706
+ private extractEthAccountsFromAttester(attester: AttesterAccounts): EthAccounts {
628
707
  if (typeof attester === 'string') {
629
- const result = checkAccount(attester);
630
- return result === undefined ? undefined : result;
708
+ return attester;
631
709
  }
632
-
633
710
  if (Array.isArray(attester)) {
634
- for (const account of attester) {
635
- const result = checkAccount(account);
636
- if (result !== undefined) {
637
- return result;
711
+ const out: EthAccount[] = [];
712
+ for (const item of attester) {
713
+ if (typeof item === 'string') {
714
+ out.push(item);
715
+ } else if ('eth' in (item as any)) {
716
+ out.push((item as any).eth as EthAccount);
717
+ } else if (!('mnemonic' in (item as any))) {
718
+ out.push(item as EthAccount);
638
719
  }
639
720
  }
640
- return undefined;
721
+ return out;
641
722
  }
642
-
643
- // Mnemonic configuration
644
- if ('mnemonic' in attester) {
645
- try {
646
- const signers = this.createSignersFromMnemonic(attester);
647
- const matches = signers.some(s => s.address.equals(attesterAddress));
648
- // Mnemonic-derived keys are local signers
649
- return matches ? undefined : undefined;
650
- } catch {
651
- return undefined;
652
- }
723
+ if ('mnemonic' in (attester as any)) {
724
+ return attester as any;
653
725
  }
654
-
655
- // Single account object
656
- const result = checkAccount(attester);
657
- return result === undefined ? undefined : result;
726
+ if ('eth' in (attester as any)) {
727
+ return (attester as any).eth as EthAccount;
728
+ }
729
+ return attester as any;
658
730
  }
659
731
  }
package/src/loader.ts CHANGED
@@ -3,10 +3,13 @@
3
3
  *
4
4
  * Handles loading and parsing keystore configuration files.
5
5
  */
6
+ import { EthAddress } from '@aztec/foundation/eth-address';
6
7
  import { createLogger } from '@aztec/foundation/log';
8
+ import type { Hex } from '@aztec/foundation/string';
7
9
 
8
10
  import { readFileSync, readdirSync, statSync } from 'fs';
9
11
  import { extname, join } from 'path';
12
+ import { privateKeyToAddress } from 'viem/accounts';
10
13
 
11
14
  import { keystoreSchema } from './schemas.js';
12
15
  import type { EthAccounts, KeyStore } from './types.js';
@@ -39,7 +42,7 @@ export function loadKeystoreFile(filePath: string): KeyStore {
39
42
  const content = readFileSync(filePath, 'utf-8');
40
43
 
41
44
  // Parse JSON and validate with Zod schema (following Aztec patterns)
42
- return keystoreSchema.parse(JSON.parse(content)) as KeyStore;
45
+ return keystoreSchema.parse(JSON.parse(content));
43
46
  } catch (error) {
44
47
  if (error instanceof SyntaxError) {
45
48
  throw new KeyStoreLoadError('Invalid JSON format', filePath, error);
@@ -220,8 +223,9 @@ export function mergeKeystores(keystores: KeyStore[]): KeyStore {
220
223
  if (keystore.validators) {
221
224
  for (const validator of keystore.validators) {
222
225
  // Check for duplicate attester addresses
223
- const attesterKeys = extractAttesterKeys(validator.attester);
224
- for (const key of attesterKeys) {
226
+ const attesterKeys = extractAttesterAddresses(validator.attester);
227
+ for (let key of attesterKeys) {
228
+ key = key.toLowerCase();
225
229
  if (attesterAddresses.has(key)) {
226
230
  throw new KeyStoreLoadError(
227
231
  `Duplicate attester address ${key} found across keystore files`,
@@ -284,18 +288,43 @@ export function mergeKeystores(keystores: KeyStore[]): KeyStore {
284
288
  * @param attester The attester configuration in any supported shape.
285
289
  * @returns Array of string keys used to detect duplicates.
286
290
  */
287
- function extractAttesterKeys(attester: unknown): string[] {
291
+ function extractAttesterAddresses(attester: unknown): string[] {
292
+ // String forms (private key or other) - return as-is for coarse uniqueness
288
293
  if (typeof attester === 'string') {
289
- return [attester];
294
+ if (attester.length === 66) {
295
+ return [privateKeyToAddress(attester as Hex<32>)];
296
+ } else {
297
+ return [attester];
298
+ }
290
299
  }
291
300
 
301
+ // Arrays of attester items
292
302
  if (Array.isArray(attester)) {
293
- return attester.map(a => (typeof a === 'string' ? a : JSON.stringify(a)));
303
+ const keys: string[] = [];
304
+ for (const item of attester) {
305
+ keys.push(...extractAttesterAddresses(item));
306
+ }
307
+ return keys;
294
308
  }
295
309
 
296
- if (attester && typeof attester === 'object' && 'address' in attester) {
297
- return [(attester as { address: string }).address];
310
+ if (attester && typeof attester === 'object') {
311
+ if (attester instanceof EthAddress) {
312
+ return [attester.toString()];
313
+ }
314
+
315
+ const obj = attester as Record<string, unknown>;
316
+
317
+ // New shape: { eth: EthAccount, bls?: BLSAccount }
318
+ if ('eth' in obj) {
319
+ return extractAttesterAddresses(obj.eth);
320
+ }
321
+
322
+ // Remote signer account object shape: { address, remoteSignerUrl?, ... }
323
+ if ('address' in obj) {
324
+ return [String((obj as any).address)];
325
+ }
298
326
  }
299
327
 
300
- return [JSON.stringify(attester)];
328
+ // mnemonic, encrypted file just disable early duplicates checking
329
+ return [];
301
330
  }