@obolnetwork/obol-sdk 2.5.0 → 2.6.0

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.
Files changed (59) hide show
  1. package/dist/cjs/package.json +3 -3
  2. package/dist/cjs/src/constants.js +13 -1
  3. package/dist/cjs/src/exits/ethUtils.js +57 -0
  4. package/dist/cjs/src/exits/exit.js +420 -0
  5. package/dist/cjs/src/exits/verificationHelpers.js +86 -0
  6. package/dist/cjs/src/{incentiveHelpers.js → incentives/incentiveHelpers.js} +1 -1
  7. package/dist/cjs/src/{incentives.js → incentives/incentives.js} +3 -3
  8. package/dist/cjs/src/index.js +22 -4
  9. package/dist/cjs/src/schema.js +1 -1
  10. package/dist/cjs/src/splits/splitHelpers.js +327 -0
  11. package/dist/cjs/src/types.js +1 -0
  12. package/dist/cjs/test/exit/ethUtils.spec.js +83 -0
  13. package/dist/cjs/test/exit/exit.spec.js +328 -0
  14. package/dist/cjs/test/exit/verificationHelpers.spec.js +68 -0
  15. package/dist/cjs/test/{incentives.test.js → incentives/incentives.spec.js} +4 -4
  16. package/dist/esm/package.json +3 -3
  17. package/dist/esm/src/constants.js +12 -0
  18. package/dist/esm/src/exits/ethUtils.js +52 -0
  19. package/dist/esm/src/exits/exit.js +393 -0
  20. package/dist/esm/src/exits/verificationHelpers.js +81 -0
  21. package/dist/esm/src/{incentiveHelpers.js → incentives/incentiveHelpers.js} +1 -1
  22. package/dist/esm/src/{incentives.js → incentives/incentives.js} +3 -3
  23. package/dist/esm/src/index.js +20 -3
  24. package/dist/esm/src/schema.js +1 -1
  25. package/dist/esm/src/splits/splitHelpers.js +317 -0
  26. package/dist/esm/src/types.js +1 -0
  27. package/dist/esm/test/exit/ethUtils.spec.js +81 -0
  28. package/dist/esm/test/exit/exit.spec.js +303 -0
  29. package/dist/esm/test/exit/verificationHelpers.spec.js +66 -0
  30. package/dist/esm/test/{incentives.test.js → incentives/incentives.spec.js} +4 -4
  31. package/dist/types/src/constants.d.ts +5 -0
  32. package/dist/types/src/exits/ethUtils.d.ts +13 -0
  33. package/dist/types/src/exits/exit.d.ts +180 -0
  34. package/dist/types/src/exits/verificationHelpers.d.ts +20 -0
  35. package/dist/types/src/{incentiveHelpers.d.ts → incentives/incentiveHelpers.d.ts} +1 -1
  36. package/dist/types/src/{incentives.d.ts → incentives/incentives.d.ts} +1 -1
  37. package/dist/types/src/index.d.ts +16 -2
  38. package/dist/types/src/{splitHelpers.d.ts → splits/splitHelpers.d.ts} +1 -1
  39. package/dist/types/src/types.d.ts +98 -0
  40. package/dist/types/test/exit/verificationHelpers.spec.d.ts +1 -0
  41. package/dist/types/test/incentives/incentives.spec.d.ts +1 -0
  42. package/package.json +3 -3
  43. package/src/constants.ts +13 -0
  44. package/src/exits/ethUtils.ts +49 -0
  45. package/src/exits/exit.ts +564 -0
  46. package/src/exits/verificationHelpers.ts +110 -0
  47. package/src/{incentiveHelpers.ts → incentives/incentiveHelpers.ts} +2 -2
  48. package/src/{incentives.ts → incentives/incentives.ts} +3 -3
  49. package/src/index.ts +29 -3
  50. package/src/schema.ts +1 -1
  51. package/src/splits/splitHelpers.ts +557 -0
  52. package/src/types.ts +123 -0
  53. package/dist/cjs/src/splitHelpers.js +0 -187
  54. package/dist/cjs/test/methods.test.js +0 -418
  55. package/dist/esm/src/splitHelpers.js +0 -177
  56. package/dist/esm/test/methods.test.js +0 -393
  57. package/src/splitHelpers.ts +0 -355
  58. /package/dist/types/test/{incentives.test.d.ts → exit/ethUtils.spec.d.ts} +0 -0
  59. /package/dist/types/test/{methods.test.d.ts → exit/exit.spec.d.ts} +0 -0
@@ -0,0 +1,564 @@
1
+ import { ENR } from '@chainsafe/discv5';
2
+ import * as elliptic from 'elliptic';
3
+ import { init, verify } from '@chainsafe/bls';
4
+ import {
5
+ ByteVectorType,
6
+ ContainerType,
7
+ fromHexString,
8
+ ListCompositeType,
9
+ UintNumberType,
10
+ } from '@chainsafe/ssz';
11
+ import type {
12
+ ProviderType,
13
+ ExitClusterConfig,
14
+ ExitValidationPayload,
15
+ ExitValidationBlob,
16
+ ExitValidationMessage,
17
+ SignedExitValidationMessage,
18
+ ExistingExitValidationBlobData,
19
+ } from '../types';
20
+ import { getCapellaFork, getGenesisValidatorsRoot } from './ethUtils';
21
+ import { computeDomain, signingRoot } from './verificationHelpers';
22
+
23
+ // Constants from obol-api/src/verification/exit.ts (assuming these might be needed or were in the original context)
24
+ const DOMAIN_VOLUNTARY_EXIT = '0x04000000';
25
+
26
+ // SSZ Type Definitions (adapted from obol-api/src/verification/exit.ts)
27
+ const SSZExitMessageType = new ContainerType({
28
+ epoch: new UintNumberType(8),
29
+ validator_index: new UintNumberType(8),
30
+ });
31
+
32
+ const SSZPartialExitsPayloadType = new ContainerType({
33
+ partial_exits: new ListCompositeType(
34
+ new ContainerType({
35
+ public_key: new ByteVectorType(48),
36
+ signed_exit_message: new ContainerType({
37
+ message: new ContainerType({
38
+ epoch: new UintNumberType(8),
39
+ validator_index: new UintNumberType(8),
40
+ }),
41
+ signature: new ByteVectorType(96),
42
+ }),
43
+ }),
44
+ 65536,
45
+ ),
46
+ share_idx: new UintNumberType(8),
47
+ });
48
+
49
+ /**
50
+ * Exit validation and verification class for Obol distributed validators.
51
+ *
52
+ * This class provides functionality to validate and verify voluntary exit signatures
53
+ * for distributed validators in an Obol cluster. It handles both partial exit signatures
54
+ * from individual operators and payload signatures that authorize exit operations.
55
+ *
56
+ * The class supports:
57
+ * - Verification of BLS signatures for partial exit messages
58
+ * - Verification of ECDSA signatures for exit payload authorization
59
+ * - Validation of exit blobs against cluster configuration
60
+ * - Duplicate detection and epoch validation
61
+ *
62
+ * @example
63
+ * ```typescript
64
+ * const exit = new Exit(1, provider); // Mainnet with provider
65
+ *
66
+ * // Verify a partial exit signature
67
+ * const isValid = await exit.verifyPartialExitSignature(
68
+ * publicShareKey,
69
+ * signedExitMessage,
70
+ * forkVersion,
71
+ * genesisValidatorsRoot
72
+ * );
73
+ *
74
+ * // Validate exit blobs for a cluster
75
+ * const validBlobs = await exit.validateExitBlobs(
76
+ * clusterConfig,
77
+ * exitsPayload,
78
+ * beaconNodeApiUrl,
79
+ * existingBlobData
80
+ * );
81
+ * ```
82
+ */
83
+ export class Exit {
84
+ public readonly chainId: number;
85
+ public readonly provider: ProviderType | undefined | null;
86
+
87
+ /**
88
+ * Creates a new Exit instance for validator exit operations.
89
+ *
90
+ * @param chainId - The Ethereum chain ID (e.g., 1 for mainnet, 5 for goerli)
91
+ * @param provider - Optional Ethereum provider for blockchain interactions
92
+ *
93
+ * @example
94
+ * ```typescript
95
+ * // For mainnet with a provider
96
+ * const exit = new Exit(1, provider);
97
+ *
98
+ * // For goerli testnet without provider
99
+ * const exit = new Exit(5, null);
100
+ * ```
101
+ */
102
+ constructor(chainId: number, provider: ProviderType | undefined | null) {
103
+ this.chainId = chainId;
104
+ this.provider = provider;
105
+ }
106
+
107
+ /**
108
+ * Safely parse a string integer to number, using BigInt to avoid precision loss
109
+ * for large values beyond JavaScript's safe integer limit (2^53 - 1).
110
+ * Throws an error if the value exceeds the safe range for a 64-bit unsigned integer.
111
+ */
112
+ private static safeParseInt(value: string): number {
113
+ const bigIntValue = BigInt(value);
114
+
115
+ // Check if value is within the range of a 64-bit unsigned integer
116
+ const MAX_UINT64 = BigInt('0xFFFFFFFFFFFFFFFF');
117
+ if (bigIntValue < 0 || bigIntValue > MAX_UINT64) {
118
+ throw new Error(
119
+ `Value ${value} is outside the valid range for a 64-bit unsigned integer`,
120
+ );
121
+ }
122
+
123
+ // Convert to number - SSZ library should handle values even if they exceed JS safe integer limits
124
+ return Number(bigIntValue);
125
+ }
126
+
127
+ private static computePartialExitMessageRoot(
128
+ msg: ExitValidationMessage,
129
+ ): Buffer {
130
+ const sszValue = SSZExitMessageType.defaultValue();
131
+
132
+ sszValue.epoch = Exit.safeParseInt(msg.epoch);
133
+ sszValue.validator_index = Exit.safeParseInt(msg.validator_index);
134
+ return Buffer.from(SSZExitMessageType.hashTreeRoot(sszValue).buffer);
135
+ }
136
+
137
+ private static computeExitPayloadRoot(exits: ExitValidationPayload): string {
138
+ // Remove sorting since SSZ list ordering guarantees order
139
+ // This eliminates the O(n log n) sort and improves performance
140
+ const sszValue = SSZPartialExitsPayloadType.defaultValue();
141
+ sszValue.partial_exits = exits.partial_exits.map(pe => ({
142
+ public_key: fromHexString(pe.public_key),
143
+ signed_exit_message: {
144
+ message: {
145
+ epoch: Exit.safeParseInt(pe.signed_exit_message.message.epoch),
146
+ validator_index: Exit.safeParseInt(
147
+ pe.signed_exit_message.message.validator_index,
148
+ ),
149
+ },
150
+ signature: fromHexString(pe.signed_exit_message.signature),
151
+ },
152
+ }));
153
+ sszValue.share_idx = exits.share_idx;
154
+
155
+ return Buffer.from(
156
+ SSZPartialExitsPayloadType.hashTreeRoot(sszValue).buffer,
157
+ ).toString('hex');
158
+ }
159
+
160
+ /**
161
+ * Verifies a partial exit signature from a distributed validator operator.
162
+ *
163
+ * This method validates that a partial exit signature was correctly signed by the
164
+ * operator's share of the distributed validator's private key. It performs BLS
165
+ * signature verification using the appropriate fork version and genesis validators root.
166
+ *
167
+ * @param publicShareKey - The operator's public share key (BLS public key, hex string with or without 0x prefix)
168
+ * @param signedExitMessage - The signed exit message containing the exit details and signature
169
+ * @param forkVersion - The Ethereum fork version (e.g., "0x00000000" for mainnet)
170
+ * @param genesisValidatorsRootString - The genesis validators root for the network (hex string)
171
+ *
172
+ * @returns Promise resolving to true if the signature is valid, false otherwise
173
+ *
174
+ * @throws {Error} When unable to determine the Capella fork version for the given network
175
+ * @throws {Error} When BLS library initialization or verification fails
176
+ *
177
+ * @example
178
+ * ```typescript
179
+ * const isValid = await exit.verifyPartialExitSignature(
180
+ * "0x1234...abcd", // operator's public share key
181
+ * {
182
+ * message: { epoch: "12345", validator_index: "67890" },
183
+ * signature: "0xabcd...1234"
184
+ * },
185
+ * "0x00000000", // mainnet fork version
186
+ * "0x4b363db94e286120d76eb905340fdd4e54bfe9f06bf33ff6cf5ad27f511bfe95"
187
+ * );
188
+ * ```
189
+ */
190
+ async verifyPartialExitSignature(
191
+ publicShareKey: string,
192
+ signedExitMessage: SignedExitValidationMessage,
193
+ forkVersion: string,
194
+ genesisValidatorsRootString: string,
195
+ ): Promise<boolean> {
196
+ await init('herumi');
197
+
198
+ const capellaForkVersionString = await getCapellaFork(forkVersion);
199
+ if (!capellaForkVersionString) {
200
+ throw new Error(
201
+ `Could not determine Capella fork version for base fork: ${forkVersion}`,
202
+ );
203
+ }
204
+
205
+ const partialExitMessageBuffer = Exit.computePartialExitMessageRoot(
206
+ signedExitMessage.message,
207
+ );
208
+
209
+ const exitDomain = computeDomain(
210
+ fromHexString(DOMAIN_VOLUNTARY_EXIT),
211
+ capellaForkVersionString,
212
+ fromHexString(genesisValidatorsRootString),
213
+ );
214
+
215
+ const messageSigningRoot = signingRoot(
216
+ exitDomain,
217
+ partialExitMessageBuffer,
218
+ );
219
+
220
+ return verify(
221
+ fromHexString(publicShareKey),
222
+ messageSigningRoot,
223
+ fromHexString(signedExitMessage.signature),
224
+ );
225
+ }
226
+
227
+ /**
228
+ * Verifies the exit payload signature using the operator's ENR.
229
+ *
230
+ * This method validates that an exit payload was signed by the correct operator
231
+ * using ECDSA signature verification. The signature is verified against the
232
+ * operator's public key extracted from their ENR (Ethereum Node Record).
233
+ *
234
+ * @param enrString - The operator's ENR string containing their identity and public key
235
+ * @param exitsPayload - The exit validation payload containing partial exits and operator signature
236
+ *
237
+ * @returns Promise resolving to true if the payload signature is valid, false otherwise
238
+ *
239
+ * @throws {Error} When the ENR string is invalid or cannot be decoded
240
+ * @throws {Error} When the signature format is invalid (must be 130 hex characters)
241
+ * @throws {Error} When signature verification encounters an error
242
+ *
243
+ * @example
244
+ * ```typescript
245
+ * const isValid = await exit.verifyExitPayloadSignature(
246
+ * "enr:-LK4QFo_n0dUm4PKejSOXf8JkSWq5EINV0XhG1zY00d...", // operator ENR
247
+ * {
248
+ * partial_exits: [exitBlob1, exitBlob2],
249
+ * share_idx: 1,
250
+ * signature: "0x1234...abcd" // ECDSA signature (130 hex chars)
251
+ * }
252
+ * );
253
+ * ```
254
+ */
255
+ async verifyExitPayloadSignature(
256
+ enrString: string,
257
+ exitsPayload: ExitValidationPayload,
258
+ ): Promise<boolean> {
259
+ const partialExitsDtoHashRoot = Exit.computeExitPayloadRoot(exitsPayload);
260
+ const ec = new elliptic.ec('secp256k1');
261
+
262
+ let pubKeyHex;
263
+ try {
264
+ pubKeyHex = ENR.decodeTxt(enrString).publicKey.toString('hex');
265
+ } catch (e: any) {
266
+ throw new Error(
267
+ `Invalid ENR string: ${enrString}. Error: ${e.message ?? String(e)}`,
268
+ );
269
+ }
270
+
271
+ const sigHex = exitsPayload.signature.startsWith('0x')
272
+ ? exitsPayload.signature.substring(2)
273
+ : exitsPayload.signature;
274
+
275
+ if (sigHex.length !== 130) {
276
+ throw new Error(
277
+ `Invalid signature length. Expected 130 hex chars (r + s), got ${sigHex.length}`,
278
+ );
279
+ }
280
+
281
+ const r = sigHex.slice(0, 64);
282
+ const s = sigHex.slice(64, 128);
283
+
284
+ const enrSignature = { r, s };
285
+
286
+ try {
287
+ return ec
288
+ .keyFromPublic(pubKeyHex, 'hex')
289
+ .verify(
290
+ partialExitsDtoHashRoot,
291
+ enrSignature as elliptic.ec.SignatureOptions,
292
+ );
293
+ } catch (e: any) {
294
+ throw new Error(
295
+ `Signature verification failed: ${e.message ?? String(e)}`,
296
+ );
297
+ }
298
+ }
299
+
300
+ private async validateOperatorAndPayload(
301
+ clusterConfig: ExitClusterConfig,
302
+ exitsPayload: ExitValidationPayload,
303
+ ): Promise<string> {
304
+ const operatorIndex = exitsPayload.share_idx - 1;
305
+ if (
306
+ operatorIndex < 0 ||
307
+ operatorIndex >= clusterConfig.definition.operators.length
308
+ ) {
309
+ throw new Error(
310
+ `Invalid share_idx ${exitsPayload.share_idx} for ${clusterConfig.definition.operators.length} operators.`,
311
+ );
312
+ }
313
+ const operatorEnr = clusterConfig.definition.operators[operatorIndex].enr;
314
+
315
+ const isPayloadSignatureValid = await this.verifyExitPayloadSignature(
316
+ operatorEnr,
317
+ exitsPayload,
318
+ );
319
+ if (!isPayloadSignatureValid) {
320
+ throw new Error('Incorrect payload signature for partial exits.');
321
+ }
322
+
323
+ return operatorEnr;
324
+ }
325
+
326
+ private async getNetworkParameters(
327
+ forkVersion: string,
328
+ beaconNodeApiUrl: string,
329
+ ): Promise<{ genesisValidatorsRoot: string; capellaForkVersion: string }> {
330
+ const genesisValidatorsRootString =
331
+ await getGenesisValidatorsRoot(beaconNodeApiUrl);
332
+ if (!genesisValidatorsRootString) {
333
+ throw new Error('Could not retrieve genesis validators root.');
334
+ }
335
+
336
+ const capellaForkVersionString = await getCapellaFork(forkVersion);
337
+ if (!capellaForkVersionString) {
338
+ throw new Error(
339
+ `Unsupported network: Could not determine Capella fork for ${forkVersion}`,
340
+ );
341
+ }
342
+
343
+ return {
344
+ genesisValidatorsRoot: genesisValidatorsRootString,
345
+ capellaForkVersion: capellaForkVersionString,
346
+ };
347
+ }
348
+
349
+ private findValidatorInCluster(
350
+ clusterConfig: ExitClusterConfig,
351
+ publicKey: string,
352
+ operatorIndex: number,
353
+ ): { validator: any; publicShare: string } {
354
+ const validatorInCluster = clusterConfig.distributed_validators.find(
355
+ dv =>
356
+ (dv.distributed_public_key.startsWith('0x')
357
+ ? dv.distributed_public_key
358
+ : '0x' + dv.distributed_public_key
359
+ ).toLowerCase() ===
360
+ (publicKey.startsWith('0x')
361
+ ? publicKey
362
+ : '0x' + publicKey
363
+ ).toLowerCase(),
364
+ );
365
+
366
+ if (!validatorInCluster) {
367
+ throw new Error(
368
+ `Public key ${publicKey} not found in the cluster's distributed validators.`,
369
+ );
370
+ }
371
+
372
+ const publicShareForOperator =
373
+ validatorInCluster.public_shares[operatorIndex];
374
+ if (!publicShareForOperator) {
375
+ throw new Error(
376
+ `Public share for operator index ${operatorIndex} not found for validator ${publicKey}`,
377
+ );
378
+ }
379
+
380
+ return {
381
+ validator: validatorInCluster,
382
+ publicShare: publicShareForOperator,
383
+ };
384
+ }
385
+
386
+ private async validateExistingBlobData(
387
+ exitBlob: ExitValidationBlob,
388
+ existingBlob: ExistingExitValidationBlobData | null,
389
+ operatorIndex: number,
390
+ ): Promise<boolean> {
391
+ if (!existingBlob) {
392
+ return false;
393
+ }
394
+
395
+ // Check if existing blob data is for this public key
396
+ const normalizeKey = (key: string): string =>
397
+ (key.startsWith('0x') ? key : '0x' + key).toLowerCase();
398
+
399
+ if (
400
+ normalizeKey(existingBlob.public_key) !==
401
+ normalizeKey(exitBlob.public_key)
402
+ ) {
403
+ return false; // Existing blob data is for a different validator
404
+ }
405
+
406
+ if (
407
+ existingBlob.validator_index !==
408
+ exitBlob.signed_exit_message.message.validator_index
409
+ ) {
410
+ throw new Error(
411
+ `Validator index mismatch for already processed exit for public key ${exitBlob.public_key}. Expected ${existingBlob.validator_index}, got ${exitBlob.signed_exit_message.message.validator_index}.`,
412
+ );
413
+ }
414
+
415
+ const currentEpoch = Exit.safeParseInt(
416
+ exitBlob.signed_exit_message.message.epoch,
417
+ );
418
+ const existingEpoch = Exit.safeParseInt(existingBlob.epoch);
419
+
420
+ if (currentEpoch < existingEpoch) {
421
+ throw new Error(
422
+ `New exit epoch ${currentEpoch} is not greater than existing exit epoch ${existingEpoch} for validator ${exitBlob.public_key}.`,
423
+ );
424
+ } else if (currentEpoch === existingEpoch) {
425
+ const operatorShareIndexString = String(operatorIndex);
426
+ if (
427
+ existingBlob.shares_exit_data?.[0]?.[operatorShareIndexString]
428
+ ?.partial_exit_signature &&
429
+ existingBlob.shares_exit_data[0][operatorShareIndexString]
430
+ .partial_exit_signature !== exitBlob.signed_exit_message.signature
431
+ ) {
432
+ throw new Error(
433
+ `Signature mismatch for validator ${exitBlob.public_key}, operator index ${operatorIndex} at epoch ${currentEpoch}. Received different signature than existing.`,
434
+ );
435
+ }
436
+ return true; // Already processed
437
+ }
438
+
439
+ return false;
440
+ }
441
+
442
+ private async processExitBlob(
443
+ exitBlob: ExitValidationBlob,
444
+ clusterConfig: ExitClusterConfig,
445
+ operatorIndex: number,
446
+ genesisValidatorsRoot: string,
447
+ existingBlobData: ExistingExitValidationBlobData | null,
448
+ ): Promise<ExitValidationBlob | null> {
449
+ const { publicShare } = this.findValidatorInCluster(
450
+ clusterConfig,
451
+ exitBlob.public_key,
452
+ operatorIndex,
453
+ );
454
+
455
+ const alreadyProcessed = await this.validateExistingBlobData(
456
+ exitBlob,
457
+ existingBlobData,
458
+ operatorIndex,
459
+ );
460
+
461
+ if (alreadyProcessed) {
462
+ return null;
463
+ }
464
+
465
+ const isPartialSignatureValid = await this.verifyPartialExitSignature(
466
+ publicShare,
467
+ exitBlob.signed_exit_message,
468
+ clusterConfig.definition.fork_version,
469
+ genesisValidatorsRoot,
470
+ );
471
+
472
+ if (!isPartialSignatureValid) {
473
+ throw new Error(
474
+ `Invalid partial exit signature for validator ${exitBlob.public_key} by operator index ${operatorIndex}.`,
475
+ );
476
+ }
477
+
478
+ return exitBlob;
479
+ }
480
+
481
+ /**
482
+ * Validates exit blobs against cluster configuration and existing data.
483
+ *
484
+ * This method performs comprehensive validation of exit blobs including:
485
+ * - Operator authorization and payload signature verification
486
+ * - Network parameter validation (genesis root, fork version)
487
+ * - Public key validation against cluster configuration
488
+ * - Partial signature verification for each exit blob
489
+ * - Duplicate detection and epoch progression validation
490
+ * - Signature consistency checks for existing exits
491
+ *
492
+ * @param clusterConfig - The cluster configuration containing operators and distributed validators
493
+ * @param exitsPayload - The exit validation payload with partial exits and operator info
494
+ * @param beaconNodeApiUrl - The beacon node API URL for network parameter retrieval
495
+ * @param existingBlobData - Existing exit blob data for duplicate detection, or null if none exists
496
+ *
497
+ * @returns Promise resolving to an array of validated, non-duplicate exit blobs
498
+ *
499
+ * @throws {Error} When share_idx is invalid or out of bounds for the cluster operators
500
+ * @throws {Error} When payload signature verification fails
501
+ * @throws {Error} When network parameters cannot be retrieved or are invalid
502
+ * @throws {Error} When a public key is not found in the cluster's distributed validators
503
+ * @throws {Error} When a partial exit signature is invalid
504
+ * @throws {Error} When exit epoch validation fails (new epoch not greater than existing)
505
+ * @throws {Error} When validator index mismatches with existing data
506
+ * @throws {Error} When signature mismatches for the same epoch and operator
507
+ *
508
+ * @example
509
+ * ```typescript
510
+ * const validExitBlobs = await exit.validateExitBlobs(
511
+ * {
512
+ * definition: {
513
+ * operators: [{ enr: "enr:-LK4Q..." }],
514
+ * fork_version: "0x00000000",
515
+ * threshold: 1
516
+ * },
517
+ * distributed_validators: [{
518
+ * distributed_public_key: "0x1234...abcd",
519
+ * public_shares: ["0x5678...efgh"]
520
+ * }]
521
+ * },
522
+ * {
523
+ * partial_exits: [exitBlob],
524
+ * share_idx: 1,
525
+ * signature: "0x1234...abcd"
526
+ * },
527
+ * "http://localhost:5052",
528
+ * existingBlobData // or null for new exits
529
+ * );
530
+ * ```
531
+ */
532
+ async validateExitBlobs(
533
+ clusterConfig: ExitClusterConfig,
534
+ exitsPayload: ExitValidationPayload,
535
+ beaconNodeApiUrl: string,
536
+ existingBlobData: ExistingExitValidationBlobData | null,
537
+ ): Promise<ExitValidationBlob[]> {
538
+ await this.validateOperatorAndPayload(clusterConfig, exitsPayload);
539
+
540
+ const { genesisValidatorsRoot } = await this.getNetworkParameters(
541
+ clusterConfig.definition.fork_version,
542
+ beaconNodeApiUrl,
543
+ );
544
+
545
+ const operatorIndex = exitsPayload.share_idx - 1;
546
+ const validNonDuplicateBlobs: ExitValidationBlob[] = [];
547
+
548
+ for (const currentExitBlob of exitsPayload.partial_exits) {
549
+ const processedBlob = await this.processExitBlob(
550
+ currentExitBlob,
551
+ clusterConfig,
552
+ operatorIndex,
553
+ genesisValidatorsRoot,
554
+ existingBlobData,
555
+ );
556
+
557
+ if (processedBlob) {
558
+ validNonDuplicateBlobs.push(processedBlob);
559
+ }
560
+ }
561
+
562
+ return validNonDuplicateBlobs;
563
+ }
564
+ }
@@ -0,0 +1,110 @@
1
+ import { ContainerType, ByteVectorType, fromHexString } from '@chainsafe/ssz';
2
+
3
+ // From obol-api/src/verification/common.ts
4
+ export const GENESIS_VALIDATOR_ROOT_HEX_STRING =
5
+ '0x0000000000000000000000000000000000000000000000000000000000000000'; // Added 0x prefix
6
+
7
+ const ForkDataType = new ContainerType({
8
+ currentVersion: new ByteVectorType(4),
9
+ genesisValidatorsRoot: new ByteVectorType(32),
10
+ });
11
+
12
+ const SigningRootType = new ContainerType({
13
+ objectRoot: new ByteVectorType(32),
14
+ domain: new ByteVectorType(32),
15
+ });
16
+
17
+ /**
18
+ * https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/beacon-chain.md#compute_fork_data_root
19
+ * @param currentVersion - Uint8Array of the current fork version.
20
+ * @param genesisValidatorsRoot - Uint8Array of the genesis validators root.
21
+ * @returns Uint8Array - The 32-byte fork data root.
22
+ */
23
+ function computeForkDataRoot(
24
+ currentVersion: Uint8Array,
25
+ genesisValidatorsRoot: Uint8Array,
26
+ ): Uint8Array {
27
+ const forkDataVal = ForkDataType.defaultValue();
28
+ forkDataVal.currentVersion = currentVersion;
29
+ forkDataVal.genesisValidatorsRoot = genesisValidatorsRoot;
30
+ return Buffer.from(ForkDataType.hashTreeRoot(forkDataVal).buffer);
31
+ }
32
+
33
+ /**
34
+ * Computes the domain for a given domain type, fork version, and genesis state.
35
+ * https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/beacon-chain.md#compute_domain
36
+ * @param domainType - Uint8Array representing the domain type (e.g., DOMAIN_VOLUNTARY_EXIT).
37
+ * @param forkVersionString - Hex string of the fork version (e.g., Capella fork version).
38
+ * @param genesisValidatorsRootOverride - Optional Uint8Array to override the default genesis validators root.
39
+ * @returns Uint8Array - The 32-byte domain.
40
+ */
41
+ export function computeDomain(
42
+ domainType: Uint8Array, // Should be 4 bytes
43
+ forkVersionString: string, // Hex string, e.g., '0x03000000'
44
+ genesisValidatorsRootOverride?: Uint8Array, // 32 bytes
45
+ ): Uint8Array {
46
+ const forkVersionBytes = fromHexString(
47
+ forkVersionString.substring(2, forkVersionString.length),
48
+ );
49
+
50
+ if (forkVersionBytes.length !== 4) {
51
+ throw new Error('Fork version must be 4 bytes');
52
+ }
53
+ if (domainType.length !== 4) {
54
+ throw new Error('Domain type must be 4 bytes');
55
+ }
56
+
57
+ const actualGenesisValidatorsRoot =
58
+ genesisValidatorsRootOverride ??
59
+ fromHexString(GENESIS_VALIDATOR_ROOT_HEX_STRING.substring(2));
60
+
61
+ if (actualGenesisValidatorsRoot.length !== 32) {
62
+ throw new Error('genesisValidatorsRoot must be 32 bytes');
63
+ }
64
+ const forkDataRoot = computeForkDataRoot(
65
+ forkVersionBytes,
66
+ actualGenesisValidatorsRoot,
67
+ );
68
+ const domain = new Uint8Array(32);
69
+ domain.set(domainType);
70
+ domain.set(forkDataRoot.subarray(0, 28), 4); // Set the remaining 28 bytes from forkDataRoot
71
+ return domain;
72
+ }
73
+
74
+ /**
75
+ * Computes the signing root for an SSZ object and a domain.
76
+ * https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/beacon-chain.md#compute_signing_root
77
+ * @param sszObjectRoot - Uint8Array of the hash tree root of the SSZ object.
78
+ * @param domain - Uint8Array of the domain.
79
+ * @returns Uint8Array - The 32-byte signing root.
80
+ */
81
+ function computeSigningRoot(
82
+ sszObjectRoot: Uint8Array, // Should be 32 bytes
83
+ domain: Uint8Array, // Should be 32 bytes
84
+ ): Uint8Array {
85
+ if (sszObjectRoot.length !== 32) {
86
+ throw new Error('SSZ object root must be 32 bytes');
87
+ }
88
+ if (domain.length !== 32) {
89
+ throw new Error('Domain must be 32 bytes');
90
+ }
91
+
92
+ const val = SigningRootType.defaultValue();
93
+ val.objectRoot = sszObjectRoot;
94
+ val.domain = domain;
95
+ return Buffer.from(SigningRootType.hashTreeRoot(val).buffer);
96
+ }
97
+
98
+ /**
99
+ * Convenience wrapper for computeSigningRoot.
100
+ * @param domain - Uint8Array of the domain.
101
+ * @param messageBuffer - Buffer of the message (SSZ object root).
102
+ * @returns Uint8Array - The signing root.
103
+ */
104
+ export function signingRoot(
105
+ domain: Uint8Array,
106
+ messageBuffer: Buffer, // Assumed to be the 32-byte sszObjectRoot
107
+ ): Uint8Array {
108
+ const sszObjectRootU8 = Uint8Array.from(messageBuffer);
109
+ return computeSigningRoot(sszObjectRootU8, domain);
110
+ }
@@ -1,6 +1,6 @@
1
- import { type ProviderType, type SignerType, type ETH_ADDRESS } from './types';
1
+ import { type ProviderType, type SignerType, type ETH_ADDRESS } from '../types';
2
2
  import { Contract } from 'ethers';
3
- import { MerkleDistributorABI } from './abi/MerkleDistributorWithDeadline';
3
+ import { MerkleDistributorABI } from '../abi/MerkleDistributorWithDeadline';
4
4
 
5
5
  export const claimIncentivesFromMerkleDistributor = async (incentivesData: {
6
6
  signer: SignerType;
@@ -1,4 +1,4 @@
1
- import { isContractAvailable } from './utils';
1
+ import { isContractAvailable } from '../utils';
2
2
  import {
3
3
  type ClaimableIncentives,
4
4
  type ETH_ADDRESS,
@@ -6,12 +6,12 @@ import {
6
6
  type ProviderType,
7
7
  type SignerType,
8
8
  type ClaimIncentivesResponse,
9
- } from './types';
9
+ } from '../types';
10
10
  import {
11
11
  claimIncentivesFromMerkleDistributor,
12
12
  isClaimedFromMerkleDistributor,
13
13
  } from './incentiveHelpers';
14
- import { DEFAULT_BASE_VERSION } from './constants';
14
+ import { DEFAULT_BASE_VERSION } from '../constants';
15
15
 
16
16
  /**
17
17
  * Incentives can be used for fetching and claiming Obol incentives.