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