@aztec/node-keystore 0.0.1-commit.03f7ef2

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.
package/dest/loader.js ADDED
@@ -0,0 +1,330 @@
1
+ /**
2
+ * Keystore File Loader
3
+ *
4
+ * Handles loading and parsing keystore configuration files.
5
+ */ import { EthAddress } from '@aztec/foundation/eth-address';
6
+ import { createLogger } from '@aztec/foundation/log';
7
+ import { readFileSync, readdirSync, statSync } from 'fs';
8
+ import { extname, join } from 'path';
9
+ import { privateKeyToAddress } from 'viem/accounts';
10
+ import { keystoreSchema } from './schemas.js';
11
+ const logger = createLogger('node-keystore:loader');
12
+ /**
13
+ * Error thrown when keystore loading fails
14
+ */ export class KeyStoreLoadError extends Error {
15
+ filePath;
16
+ cause;
17
+ constructor(message, filePath, cause){
18
+ super(`Failed to load keystore from ${filePath}: ${message}`), this.filePath = filePath, this.cause = cause;
19
+ this.name = 'KeyStoreLoadError';
20
+ }
21
+ }
22
+ /**
23
+ * Loads and validates a single keystore JSON file.
24
+ *
25
+ * @param filePath Absolute or relative path to a keystore JSON file.
26
+ * @returns Parsed keystore object adhering to the schema.
27
+ * @throws KeyStoreLoadError When JSON is invalid, schema validation fails, or other IO/parse errors occur.
28
+ */ export function loadKeystoreFile(filePath) {
29
+ try {
30
+ const content = readFileSync(filePath, 'utf-8');
31
+ // Parse JSON and validate with Zod schema (following Aztec patterns)
32
+ return keystoreSchema.parse(JSON.parse(content));
33
+ } catch (error) {
34
+ if (error instanceof SyntaxError) {
35
+ throw new KeyStoreLoadError('Invalid JSON format', filePath, error);
36
+ }
37
+ if (error && typeof error === 'object' && 'issues' in error) {
38
+ const issues = error.issues ?? [];
39
+ const message = issues.map((e)=>{
40
+ const path = Array.isArray(e.path) ? e.path.join('.') : String(e.path ?? 'root');
41
+ return `${e.message} (${path})`;
42
+ }).join('. ') || 'Schema validation error';
43
+ throw new KeyStoreLoadError(`Schema validation failed: ${message}`, filePath, error);
44
+ }
45
+ throw new KeyStoreLoadError(`Unexpected error: ${String(error)}`, filePath, error);
46
+ }
47
+ }
48
+ /**
49
+ * Loads keystore files from a directory (only .json files).
50
+ *
51
+ * @param dirPath Absolute or relative path to a directory containing keystore files.
52
+ * @returns Array of parsed keystores loaded from all .json files in the directory.
53
+ * @throws KeyStoreLoadError When the directory can't be read or contains no valid keystore files.
54
+ */ export function loadKeystoreDirectory(dirPath) {
55
+ try {
56
+ const files = readdirSync(dirPath);
57
+ const keystores = [];
58
+ for (const file of files){
59
+ // Only process .json files
60
+ if (extname(file).toLowerCase() !== '.json') {
61
+ continue;
62
+ }
63
+ const filePath = join(dirPath, file);
64
+ try {
65
+ const keystore = loadKeystoreFile(filePath);
66
+ keystores.push(keystore);
67
+ } catch (error) {
68
+ // Re-throw with directory context
69
+ if (error instanceof KeyStoreLoadError) {
70
+ throw error;
71
+ }
72
+ throw new KeyStoreLoadError(`Failed to load file ${file}`, filePath, error);
73
+ }
74
+ }
75
+ if (keystores.length === 0) {
76
+ throw new KeyStoreLoadError('No valid keystore files found', dirPath);
77
+ }
78
+ return keystores;
79
+ } catch (error) {
80
+ if (error instanceof KeyStoreLoadError) {
81
+ throw error;
82
+ }
83
+ throw new KeyStoreLoadError(`Failed to read directory`, dirPath, error);
84
+ }
85
+ }
86
+ /**
87
+ * Loads keystore(s) from a path (file or directory).
88
+ *
89
+ * If a file is provided, loads a single keystore. If a directory is provided,
90
+ * loads all keystore files within that directory.
91
+ *
92
+ * @param path File or directory path.
93
+ * @returns Array of parsed keystores.
94
+ * @throws KeyStoreLoadError When the path is invalid or cannot be accessed.
95
+ */ export function loadKeystores(path) {
96
+ try {
97
+ const stats = statSync(path);
98
+ if (stats.isFile()) {
99
+ return [
100
+ loadKeystoreFile(path)
101
+ ];
102
+ } else if (stats.isDirectory()) {
103
+ return loadKeystoreDirectory(path);
104
+ } else {
105
+ throw new KeyStoreLoadError('Path is neither a file nor directory', path);
106
+ }
107
+ } catch (error) {
108
+ if (error instanceof KeyStoreLoadError) {
109
+ throw error;
110
+ }
111
+ const err = error;
112
+ if (err?.code === 'ENOENT') {
113
+ throw new KeyStoreLoadError('File or directory not found', path, error);
114
+ }
115
+ throw new KeyStoreLoadError(`Failed to access path: ${err?.code ?? 'UNKNOWN'}`, path, error);
116
+ }
117
+ }
118
+ /**
119
+ * Loads keystore(s) from multiple paths (comma-separated string or array).
120
+ *
121
+ * @param paths Comma-separated string or array of file/directory paths.
122
+ * @returns Flattened array of all parsed keystores from all paths.
123
+ * @throws KeyStoreLoadError When any path fails to load; includes context for which path list was used.
124
+ */ export function loadMultipleKeystores(paths) {
125
+ const pathArray = typeof paths === 'string' ? paths.split(',').map((p)=>p.trim()) : paths;
126
+ const allKeystores = [];
127
+ for (const path of pathArray){
128
+ if (!path) {
129
+ continue;
130
+ } // Skip empty paths
131
+ try {
132
+ const keystores = loadKeystores(path);
133
+ allKeystores.push(...keystores);
134
+ } catch (error) {
135
+ // Add context about which path failed
136
+ if (error instanceof KeyStoreLoadError) {
137
+ throw new KeyStoreLoadError(`${error.message} (from path list: ${pathArray.join(', ')})`, error.filePath, error.cause);
138
+ }
139
+ throw error;
140
+ }
141
+ }
142
+ if (allKeystores.length === 0) {
143
+ throw new KeyStoreLoadError('No keystore files found in any of the provided paths', pathArray.join(', '));
144
+ }
145
+ return allKeystores;
146
+ }
147
+ /**
148
+ * Merges multiple keystores into a single configuration.
149
+ *
150
+ * - Concatenates validator arrays and enforces unique attester addresses by simple structural keys
151
+ * - Accumulates all slasher accounts across inputs
152
+ * - Applies last-one-wins semantics for file-level remote signer defaults
153
+ * - Requires at most one prover configuration across inputs
154
+ *
155
+ * Note: Full duplicate detection (e.g., after resolving JSON V3 or mnemonics) is
156
+ * performed downstream by the validator client.
157
+ *
158
+ * @param keystores Array of keystores to merge.
159
+ * @returns A merged keystore object.
160
+ * @throws Error When keystore list is empty.
161
+ * @throws KeyStoreLoadError When duplicate attester keys are found or multiple prover configs exist.
162
+ */ export function mergeKeystores(keystores) {
163
+ if (keystores.length === 0) {
164
+ throw new Error('Cannot merge empty keystore list');
165
+ }
166
+ if (keystores.length === 1) {
167
+ return keystores[0];
168
+ }
169
+ // Track attester addresses to prevent duplicates
170
+ const attesterAddresses = new Set();
171
+ // Determine schema version: use v2 if any input is v2
172
+ const schemaVersion = keystores.some((ks)=>ks.schemaVersion === 2) ? 2 : 1;
173
+ const merged = {
174
+ schemaVersion,
175
+ validators: [],
176
+ slasher: undefined,
177
+ remoteSigner: undefined,
178
+ prover: undefined,
179
+ publisher: undefined,
180
+ coinbase: undefined,
181
+ feeRecipient: undefined
182
+ };
183
+ for(let i = 0; i < keystores.length; i++){
184
+ const keystore = keystores[i];
185
+ // Merge validators
186
+ if (keystore.validators) {
187
+ for (const validator of keystore.validators){
188
+ // Check for duplicate attester addresses
189
+ const attesterKeys = extractAttesterAddresses(validator.attester);
190
+ for (let key of attesterKeys){
191
+ key = key.toLowerCase();
192
+ if (attesterAddresses.has(key)) {
193
+ throw new KeyStoreLoadError(`Duplicate attester address ${key} found across keystore files`, `keystores[${i}].validators`);
194
+ }
195
+ attesterAddresses.add(key);
196
+ }
197
+ // When merging v1 validators into a v2+ result, preserve original fallback behavior
198
+ // by explicitly setting publisher/coinbase/feeRecipient if they're missing
199
+ if (keystore.schemaVersion !== schemaVersion) {
200
+ throw new KeyStoreLoadError(`Cannot merge keystores with different schema versions: ${keystore.schemaVersion} and ${schemaVersion}`, `keystores[${i}].schemaVersion`);
201
+ } else {
202
+ merged.validators.push(validator);
203
+ }
204
+ }
205
+ }
206
+ // Merge slasher (accumulate all)
207
+ if (keystore.slasher) {
208
+ if (!merged.slasher) {
209
+ merged.slasher = keystore.slasher;
210
+ } else {
211
+ const toArray = (accounts)=>Array.isArray(accounts) ? accounts : [
212
+ accounts
213
+ ];
214
+ const combined = [
215
+ ...toArray(merged.slasher),
216
+ ...toArray(keystore.slasher)
217
+ ];
218
+ // Cast is safe at runtime: consumer handles arrays with mixed account configs
219
+ merged.slasher = combined;
220
+ }
221
+ }
222
+ // Merge remote signer (last one wins, but warn about conflicts)
223
+ if (keystore.remoteSigner) {
224
+ if (merged.remoteSigner) {
225
+ logger.warn('Multiple default remote signer configurations found, using the last one');
226
+ }
227
+ merged.remoteSigner = keystore.remoteSigner;
228
+ }
229
+ // Merge prover (error if multiple)
230
+ if (keystore.prover) {
231
+ if (merged.prover) {
232
+ throw new KeyStoreLoadError('Multiple prover configurations found across keystore files. Only one prover configuration is allowed.', `keystores[${i}].prover`);
233
+ }
234
+ merged.prover = keystore.prover;
235
+ }
236
+ // Merge top-level publisher (accumulate all, unless conflicting MnemonicConfigs)
237
+ if (keystore.publisher) {
238
+ if (!merged.publisher) {
239
+ merged.publisher = keystore.publisher;
240
+ } else {
241
+ const isMnemonic = (accounts)=>typeof accounts === 'object' && accounts !== null && 'mnemonic' in accounts;
242
+ // If either is a mnemonic, warn and use last one (can't merge mnemonics)
243
+ if (isMnemonic(merged.publisher) || isMnemonic(keystore.publisher)) {
244
+ logger.warn('Multiple default publisher configurations found with mnemonic, using the last one (cannot merge mnemonics)');
245
+ merged.publisher = keystore.publisher;
246
+ } else {
247
+ // Both are non-mnemonic, accumulate them
248
+ const toArray = (accounts)=>Array.isArray(accounts) ? accounts : [
249
+ accounts
250
+ ];
251
+ const combined = [
252
+ ...toArray(merged.publisher),
253
+ ...toArray(keystore.publisher)
254
+ ];
255
+ merged.publisher = combined;
256
+ }
257
+ }
258
+ }
259
+ // Merge top-level coinbase (last one wins, but warn about conflicts)
260
+ if (keystore.coinbase) {
261
+ if (merged.coinbase) {
262
+ logger.warn('Multiple default coinbase addresses found, using the last one');
263
+ }
264
+ merged.coinbase = keystore.coinbase;
265
+ }
266
+ // Merge top-level feeRecipient (last one wins, but warn about conflicts)
267
+ if (keystore.feeRecipient) {
268
+ if (merged.feeRecipient) {
269
+ logger.warn('Multiple default feeRecipient addresses found, using the last one');
270
+ }
271
+ merged.feeRecipient = keystore.feeRecipient;
272
+ }
273
+ }
274
+ // Clean up empty arrays
275
+ if (merged.validators.length === 0) {
276
+ delete merged.validators;
277
+ }
278
+ return merged;
279
+ }
280
+ /**
281
+ * Extracts attester addresses/keys for coarse duplicate checking during merge.
282
+ *
283
+ * This avoids expensive resolution/decryption and is intended as a best-effort
284
+ * guard only. Full duplicate detection is done in the validator client after
285
+ * accounts are fully resolved.
286
+ *
287
+ * @param attester The attester configuration in any supported shape.
288
+ * @returns Array of string keys used to detect duplicates.
289
+ */ function extractAttesterAddresses(attester) {
290
+ // String forms (private key or other) - return as-is for coarse uniqueness
291
+ if (typeof attester === 'string') {
292
+ if (attester.length === 66) {
293
+ return [
294
+ privateKeyToAddress(attester)
295
+ ];
296
+ } else {
297
+ return [
298
+ attester
299
+ ];
300
+ }
301
+ }
302
+ // Arrays of attester items
303
+ if (Array.isArray(attester)) {
304
+ const keys = [];
305
+ for (const item of attester){
306
+ keys.push(...extractAttesterAddresses(item));
307
+ }
308
+ return keys;
309
+ }
310
+ if (attester && typeof attester === 'object') {
311
+ if (attester instanceof EthAddress) {
312
+ return [
313
+ attester.toString()
314
+ ];
315
+ }
316
+ const obj = attester;
317
+ // New shape: { eth: EthAccount, bls?: BLSAccount }
318
+ if ('eth' in obj) {
319
+ return extractAttesterAddresses(obj.eth);
320
+ }
321
+ // Remote signer account object shape: { address, remoteSignerUrl?, ... }
322
+ if ('address' in obj) {
323
+ return [
324
+ String(obj.address)
325
+ ];
326
+ }
327
+ }
328
+ // mnemonic, encrypted file just disable early duplicates checking
329
+ return [];
330
+ }