@obolnetwork/obol-sdk 2.6.0 → 2.7.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@obolnetwork/obol-sdk",
3
- "version": "2.6.0",
3
+ "version": "2.7.0",
4
4
  "description": "A package for creating Distributed Validators using the Obol API.",
5
5
  "bugs": {
6
6
  "url": "https://github.com/obolnetwork/obol-sdk/issues"
@@ -416,5 +416,87 @@ class Exit {
416
416
  return validNonDuplicateBlobs;
417
417
  });
418
418
  }
419
+ /**
420
+ * Recombines exit blobs into a single exit blob.
421
+ *
422
+ * This method aggregates partial exit signatures from multiple operators into a single exit blob.
423
+ * It ensures that the signatures are properly ordered and aggregated according to the operator indices.
424
+ *
425
+ * @param exitBlob - The existing exit blob data containing partial exit signatures
426
+ *
427
+ * @returns Promise resolving to a single exit blob with aggregated signatures
428
+ *
429
+ * @throws {Error} When no valid signatures are found for aggregation
430
+ * @throws {Error} When signature length is invalid
431
+ * @throws {Error} When signature parsing fails
432
+ *
433
+ * @example
434
+ * ```typescript
435
+ * const aggregatedExitBlob = await exit.recombineExitBlobs(existingBlobData);
436
+ * ```
437
+ */
438
+ recombineExitBlobs(exitBlob) {
439
+ return __awaiter(this, void 0, void 0, function* () {
440
+ yield (0, bls_1.init)('herumi');
441
+ // Map to store signatures by their share index (matching Go's map[int]tbls.Signature)
442
+ const signaturesByIndex = new Map();
443
+ // Extract signatures from shares_exit_data (equivalent to er.Signatures in Go)
444
+ if (!exitBlob.shares_exit_data || exitBlob.shares_exit_data.length === 0) {
445
+ throw new Error('No shares exit data available for aggregation');
446
+ }
447
+ const signaturesMap = exitBlob.shares_exit_data[0] || {};
448
+ for (const [sigIdxStr, sigData] of Object.entries(signaturesMap)) {
449
+ const sigStr = sigData.partial_exit_signature;
450
+ if (!sigStr || sigStr.length === 0) {
451
+ // ignore, the associated share index didn't push a partial signature yet
452
+ continue;
453
+ }
454
+ if (sigStr.length < 2) {
455
+ throw new Error(`Signature string has invalid size: ${sigStr.length}`);
456
+ }
457
+ // Remove 0x prefix and ensure it's 96 bytes (192 hex chars)
458
+ const cleanSigStr = sigStr.startsWith('0x')
459
+ ? sigStr.substring(2)
460
+ : sigStr;
461
+ if (cleanSigStr.length !== 192) {
462
+ throw new Error(`Invalid signature length. Expected 192 hex chars (96 bytes), got ${cleanSigStr.length}`);
463
+ }
464
+ try {
465
+ const sigBytes = (0, ssz_1.fromHexString)(cleanSigStr);
466
+ // Convert string index to number and add 1 (matching Go's sigIdx+1)
467
+ const sigIdx = parseInt(sigIdxStr, 10);
468
+ signaturesByIndex.set(sigIdx + 1, sigBytes);
469
+ }
470
+ catch (err) {
471
+ throw new Error(`Invalid partial signature: ${String(err)}`);
472
+ }
473
+ }
474
+ if (signaturesByIndex.size === 0) {
475
+ throw new Error('No valid signatures found for aggregation');
476
+ }
477
+ // Sort by index and extract signatures in correct order
478
+ const sortedIndices = Array.from(signaturesByIndex.keys()).sort((a, b) => a - b);
479
+ const rawSignatures = sortedIndices.map(idx => {
480
+ const signature = signaturesByIndex.get(idx);
481
+ if (signature === undefined) {
482
+ throw new Error(`Missing signature for index ${idx}`);
483
+ }
484
+ return signature;
485
+ });
486
+ // Aggregate signatures (equivalent to tbls.ThresholdAggregate in Go)
487
+ // Note: @chainsafe/bls doesn't have explicit threshold aggregation, but ordering should be preserved
488
+ const fullSig = (0, bls_1.aggregateSignatures)(rawSignatures);
489
+ return {
490
+ public_key: exitBlob.public_key,
491
+ signed_exit_message: {
492
+ message: {
493
+ epoch: exitBlob.epoch,
494
+ validator_index: exitBlob.validator_index,
495
+ },
496
+ signature: '0x' + Buffer.from(fullSig).toString('hex'),
497
+ },
498
+ };
499
+ });
500
+ }
419
501
  }
420
502
  exports.Exit = Exit;
@@ -44,7 +44,7 @@ const verificationHelpers = __importStar(require("../../src/exits/verificationHe
44
44
  jest.mock('../../src/exits/ethUtils');
45
45
  jest.mock('@chainsafe/bls', () => {
46
46
  const actual = jest.requireActual('@chainsafe/bls');
47
- return Object.assign(Object.assign({ __esModule: true }, actual), { init: jest.fn(), verify: jest.fn() });
47
+ return Object.assign(Object.assign({ __esModule: true }, actual), { init: jest.fn(), verify: jest.fn(), aggregateSignatures: jest.fn() });
48
48
  });
49
49
  // ENR and elliptic will be spied on, not fully mocked at module level for more flexibility
50
50
  const mockedEthUtils = ethUtils;
@@ -323,6 +323,77 @@ describe('exit', () => {
323
323
  yield expect(exit.validateExitBlobs(clusterConfigWithMissingShare, mockExitPayload, MOCK_BEACON_API_URL, mockExistingBlobData)).rejects.toThrow('Public share for operator index 0 not found for validator 0xcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd');
324
324
  }));
325
325
  });
326
- // Add other tests for different scenarios: epoch mismatch, already exited, etc.
327
- // Consider testing the case where getExistingBlobData throws an error
326
+ describe('recombineExitBlobs', () => {
327
+ it('should successfully recombine signatures into a single exit blob', () => __awaiter(void 0, void 0, void 0, function* () {
328
+ const mockExistingBlob = {
329
+ public_key: '0x' + '1234'.repeat(24),
330
+ epoch: '100',
331
+ validator_index: '200',
332
+ shares_exit_data: [
333
+ {
334
+ 0: { partial_exit_signature: '0x' + 'a0'.repeat(96) },
335
+ 2: { partial_exit_signature: '0x' + 'a2'.repeat(96) }, // Note non-sequential indices
336
+ 1: { partial_exit_signature: '0x' + 'a1'.repeat(96) },
337
+ },
338
+ ],
339
+ };
340
+ const expectedAggregatedSig = '0x' + 'ff'.repeat(96);
341
+ const expectedAggregatedSigBytes = (0, ssz_1.fromHexString)(expectedAggregatedSig);
342
+ mockedBls.aggregateSignatures.mockReturnValue(expectedAggregatedSigBytes);
343
+ const result = yield exit.recombineExitBlobs(mockExistingBlob);
344
+ expect(mockedBls.init).toHaveBeenCalledWith('herumi');
345
+ expect(mockedBls.aggregateSignatures).toHaveBeenCalledTimes(1);
346
+ const calls = mockedBls.aggregateSignatures.mock.calls;
347
+ const rawSignatures = calls[0][0];
348
+ // Check if signatures were passed in sorted order of operator index
349
+ expect(rawSignatures[0]).toEqual((0, ssz_1.fromHexString)('a0'.repeat(96)));
350
+ expect(rawSignatures[1]).toEqual((0, ssz_1.fromHexString)('a1'.repeat(96)));
351
+ expect(rawSignatures[2]).toEqual((0, ssz_1.fromHexString)('a2'.repeat(96)));
352
+ expect(result).toEqual({
353
+ public_key: mockExistingBlob.public_key,
354
+ signed_exit_message: {
355
+ message: {
356
+ epoch: mockExistingBlob.epoch,
357
+ validator_index: mockExistingBlob.validator_index,
358
+ },
359
+ signature: expectedAggregatedSig,
360
+ },
361
+ });
362
+ }));
363
+ it('should throw if no valid signatures are found', () => __awaiter(void 0, void 0, void 0, function* () {
364
+ const mockExistingBlob = {
365
+ public_key: '0x' + '1234'.repeat(24),
366
+ epoch: '100',
367
+ validator_index: '200',
368
+ shares_exit_data: [
369
+ {
370
+ 0: { partial_exit_signature: '' }, // empty sig
371
+ },
372
+ ],
373
+ };
374
+ yield expect(exit.recombineExitBlobs(mockExistingBlob)).rejects.toThrow('No valid signatures found for aggregation');
375
+ }));
376
+ it('should throw on invalid signature length', () => __awaiter(void 0, void 0, void 0, function* () {
377
+ const mockExistingBlob = {
378
+ public_key: '0x' + '1234'.repeat(24),
379
+ epoch: '100',
380
+ validator_index: '200',
381
+ shares_exit_data: [
382
+ {
383
+ 0: { partial_exit_signature: '0x1234' },
384
+ },
385
+ ],
386
+ };
387
+ yield expect(exit.recombineExitBlobs(mockExistingBlob)).rejects.toThrow('Invalid signature length');
388
+ }));
389
+ it('should handle empty shares_exit_data', () => __awaiter(void 0, void 0, void 0, function* () {
390
+ const mockExistingBlob = {
391
+ public_key: '0x' + '1234'.repeat(24),
392
+ epoch: '100',
393
+ validator_index: '200',
394
+ shares_exit_data: [],
395
+ };
396
+ yield expect(exit.recombineExitBlobs(mockExistingBlob)).rejects.toThrow('No shares exit data available for aggregation');
397
+ }));
398
+ });
328
399
  });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@obolnetwork/obol-sdk",
3
- "version": "2.6.0",
3
+ "version": "2.7.0",
4
4
  "description": "A package for creating Distributed Validators using the Obol API.",
5
5
  "bugs": {
6
6
  "url": "https://github.com/obolnetwork/obol-sdk/issues"
@@ -9,7 +9,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
9
9
  };
10
10
  import { ENR } from '@chainsafe/discv5';
11
11
  import * as elliptic from 'elliptic';
12
- import { init, verify } from '@chainsafe/bls';
12
+ import { init, verify, aggregateSignatures } from '@chainsafe/bls';
13
13
  import { ByteVectorType, ContainerType, fromHexString, ListCompositeType, UintNumberType, } from '@chainsafe/ssz';
14
14
  import { getCapellaFork, getGenesisValidatorsRoot } from './ethUtils';
15
15
  import { computeDomain, signingRoot } from './verificationHelpers';
@@ -390,4 +390,86 @@ export class Exit {
390
390
  return validNonDuplicateBlobs;
391
391
  });
392
392
  }
393
+ /**
394
+ * Recombines exit blobs into a single exit blob.
395
+ *
396
+ * This method aggregates partial exit signatures from multiple operators into a single exit blob.
397
+ * It ensures that the signatures are properly ordered and aggregated according to the operator indices.
398
+ *
399
+ * @param exitBlob - The existing exit blob data containing partial exit signatures
400
+ *
401
+ * @returns Promise resolving to a single exit blob with aggregated signatures
402
+ *
403
+ * @throws {Error} When no valid signatures are found for aggregation
404
+ * @throws {Error} When signature length is invalid
405
+ * @throws {Error} When signature parsing fails
406
+ *
407
+ * @example
408
+ * ```typescript
409
+ * const aggregatedExitBlob = await exit.recombineExitBlobs(existingBlobData);
410
+ * ```
411
+ */
412
+ recombineExitBlobs(exitBlob) {
413
+ return __awaiter(this, void 0, void 0, function* () {
414
+ yield init('herumi');
415
+ // Map to store signatures by their share index (matching Go's map[int]tbls.Signature)
416
+ const signaturesByIndex = new Map();
417
+ // Extract signatures from shares_exit_data (equivalent to er.Signatures in Go)
418
+ if (!exitBlob.shares_exit_data || exitBlob.shares_exit_data.length === 0) {
419
+ throw new Error('No shares exit data available for aggregation');
420
+ }
421
+ const signaturesMap = exitBlob.shares_exit_data[0] || {};
422
+ for (const [sigIdxStr, sigData] of Object.entries(signaturesMap)) {
423
+ const sigStr = sigData.partial_exit_signature;
424
+ if (!sigStr || sigStr.length === 0) {
425
+ // ignore, the associated share index didn't push a partial signature yet
426
+ continue;
427
+ }
428
+ if (sigStr.length < 2) {
429
+ throw new Error(`Signature string has invalid size: ${sigStr.length}`);
430
+ }
431
+ // Remove 0x prefix and ensure it's 96 bytes (192 hex chars)
432
+ const cleanSigStr = sigStr.startsWith('0x')
433
+ ? sigStr.substring(2)
434
+ : sigStr;
435
+ if (cleanSigStr.length !== 192) {
436
+ throw new Error(`Invalid signature length. Expected 192 hex chars (96 bytes), got ${cleanSigStr.length}`);
437
+ }
438
+ try {
439
+ const sigBytes = fromHexString(cleanSigStr);
440
+ // Convert string index to number and add 1 (matching Go's sigIdx+1)
441
+ const sigIdx = parseInt(sigIdxStr, 10);
442
+ signaturesByIndex.set(sigIdx + 1, sigBytes);
443
+ }
444
+ catch (err) {
445
+ throw new Error(`Invalid partial signature: ${String(err)}`);
446
+ }
447
+ }
448
+ if (signaturesByIndex.size === 0) {
449
+ throw new Error('No valid signatures found for aggregation');
450
+ }
451
+ // Sort by index and extract signatures in correct order
452
+ const sortedIndices = Array.from(signaturesByIndex.keys()).sort((a, b) => a - b);
453
+ const rawSignatures = sortedIndices.map(idx => {
454
+ const signature = signaturesByIndex.get(idx);
455
+ if (signature === undefined) {
456
+ throw new Error(`Missing signature for index ${idx}`);
457
+ }
458
+ return signature;
459
+ });
460
+ // Aggregate signatures (equivalent to tbls.ThresholdAggregate in Go)
461
+ // Note: @chainsafe/bls doesn't have explicit threshold aggregation, but ordering should be preserved
462
+ const fullSig = aggregateSignatures(rawSignatures);
463
+ return {
464
+ public_key: exitBlob.public_key,
465
+ signed_exit_message: {
466
+ message: {
467
+ epoch: exitBlob.epoch,
468
+ validator_index: exitBlob.validator_index,
469
+ },
470
+ signature: '0x' + Buffer.from(fullSig).toString('hex'),
471
+ },
472
+ };
473
+ });
474
+ }
393
475
  }
@@ -19,7 +19,7 @@ import * as verificationHelpers from '../../src/exits/verificationHelpers';
19
19
  jest.mock('../../src/exits/ethUtils');
20
20
  jest.mock('@chainsafe/bls', () => {
21
21
  const actual = jest.requireActual('@chainsafe/bls');
22
- return Object.assign(Object.assign({ __esModule: true }, actual), { init: jest.fn(), verify: jest.fn() });
22
+ return Object.assign(Object.assign({ __esModule: true }, actual), { init: jest.fn(), verify: jest.fn(), aggregateSignatures: jest.fn() });
23
23
  });
24
24
  // ENR and elliptic will be spied on, not fully mocked at module level for more flexibility
25
25
  const mockedEthUtils = ethUtils;
@@ -298,6 +298,77 @@ describe('exit', () => {
298
298
  yield expect(exit.validateExitBlobs(clusterConfigWithMissingShare, mockExitPayload, MOCK_BEACON_API_URL, mockExistingBlobData)).rejects.toThrow('Public share for operator index 0 not found for validator 0xcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd');
299
299
  }));
300
300
  });
301
- // Add other tests for different scenarios: epoch mismatch, already exited, etc.
302
- // Consider testing the case where getExistingBlobData throws an error
301
+ describe('recombineExitBlobs', () => {
302
+ it('should successfully recombine signatures into a single exit blob', () => __awaiter(void 0, void 0, void 0, function* () {
303
+ const mockExistingBlob = {
304
+ public_key: '0x' + '1234'.repeat(24),
305
+ epoch: '100',
306
+ validator_index: '200',
307
+ shares_exit_data: [
308
+ {
309
+ 0: { partial_exit_signature: '0x' + 'a0'.repeat(96) },
310
+ 2: { partial_exit_signature: '0x' + 'a2'.repeat(96) }, // Note non-sequential indices
311
+ 1: { partial_exit_signature: '0x' + 'a1'.repeat(96) },
312
+ },
313
+ ],
314
+ };
315
+ const expectedAggregatedSig = '0x' + 'ff'.repeat(96);
316
+ const expectedAggregatedSigBytes = fromHexString(expectedAggregatedSig);
317
+ mockedBls.aggregateSignatures.mockReturnValue(expectedAggregatedSigBytes);
318
+ const result = yield exit.recombineExitBlobs(mockExistingBlob);
319
+ expect(mockedBls.init).toHaveBeenCalledWith('herumi');
320
+ expect(mockedBls.aggregateSignatures).toHaveBeenCalledTimes(1);
321
+ const calls = mockedBls.aggregateSignatures.mock.calls;
322
+ const rawSignatures = calls[0][0];
323
+ // Check if signatures were passed in sorted order of operator index
324
+ expect(rawSignatures[0]).toEqual(fromHexString('a0'.repeat(96)));
325
+ expect(rawSignatures[1]).toEqual(fromHexString('a1'.repeat(96)));
326
+ expect(rawSignatures[2]).toEqual(fromHexString('a2'.repeat(96)));
327
+ expect(result).toEqual({
328
+ public_key: mockExistingBlob.public_key,
329
+ signed_exit_message: {
330
+ message: {
331
+ epoch: mockExistingBlob.epoch,
332
+ validator_index: mockExistingBlob.validator_index,
333
+ },
334
+ signature: expectedAggregatedSig,
335
+ },
336
+ });
337
+ }));
338
+ it('should throw if no valid signatures are found', () => __awaiter(void 0, void 0, void 0, function* () {
339
+ const mockExistingBlob = {
340
+ public_key: '0x' + '1234'.repeat(24),
341
+ epoch: '100',
342
+ validator_index: '200',
343
+ shares_exit_data: [
344
+ {
345
+ 0: { partial_exit_signature: '' }, // empty sig
346
+ },
347
+ ],
348
+ };
349
+ yield expect(exit.recombineExitBlobs(mockExistingBlob)).rejects.toThrow('No valid signatures found for aggregation');
350
+ }));
351
+ it('should throw on invalid signature length', () => __awaiter(void 0, void 0, void 0, function* () {
352
+ const mockExistingBlob = {
353
+ public_key: '0x' + '1234'.repeat(24),
354
+ epoch: '100',
355
+ validator_index: '200',
356
+ shares_exit_data: [
357
+ {
358
+ 0: { partial_exit_signature: '0x1234' },
359
+ },
360
+ ],
361
+ };
362
+ yield expect(exit.recombineExitBlobs(mockExistingBlob)).rejects.toThrow('Invalid signature length');
363
+ }));
364
+ it('should handle empty shares_exit_data', () => __awaiter(void 0, void 0, void 0, function* () {
365
+ const mockExistingBlob = {
366
+ public_key: '0x' + '1234'.repeat(24),
367
+ epoch: '100',
368
+ validator_index: '200',
369
+ shares_exit_data: [],
370
+ };
371
+ yield expect(exit.recombineExitBlobs(mockExistingBlob)).rejects.toThrow('No shares exit data available for aggregation');
372
+ }));
373
+ });
303
374
  });
@@ -1,4 +1,4 @@
1
- import type { ProviderType, ExitClusterConfig, ExitValidationPayload, ExitValidationBlob, SignedExitValidationMessage, ExistingExitValidationBlobData } from '../types';
1
+ import type { ProviderType, ExitClusterConfig, ExitValidationPayload, ExitValidationBlob, SignedExitValidationMessage, ExistingExitValidationBlobData, FullExitBlob } from '../types';
2
2
  /**
3
3
  * Exit validation and verification class for Obol distributed validators.
4
4
  *
@@ -177,4 +177,24 @@ export declare class Exit {
177
177
  * ```
178
178
  */
179
179
  validateExitBlobs(clusterConfig: ExitClusterConfig, exitsPayload: ExitValidationPayload, beaconNodeApiUrl: string, existingBlobData: ExistingExitValidationBlobData | null): Promise<ExitValidationBlob[]>;
180
+ /**
181
+ * Recombines exit blobs into a single exit blob.
182
+ *
183
+ * This method aggregates partial exit signatures from multiple operators into a single exit blob.
184
+ * It ensures that the signatures are properly ordered and aggregated according to the operator indices.
185
+ *
186
+ * @param exitBlob - The existing exit blob data containing partial exit signatures
187
+ *
188
+ * @returns Promise resolving to a single exit blob with aggregated signatures
189
+ *
190
+ * @throws {Error} When no valid signatures are found for aggregation
191
+ * @throws {Error} When signature length is invalid
192
+ * @throws {Error} When signature parsing fails
193
+ *
194
+ * @example
195
+ * ```typescript
196
+ * const aggregatedExitBlob = await exit.recombineExitBlobs(existingBlobData);
197
+ * ```
198
+ */
199
+ recombineExitBlobs(exitBlob: ExistingExitValidationBlobData): Promise<FullExitBlob>;
180
200
  }
@@ -332,17 +332,52 @@ export interface ExitValidationPayload {
332
332
  * Represents the data structure for an already existing exit blob for exit validation.
333
333
  */
334
334
  export interface ExistingExitValidationBlobData {
335
- /** The public key of the validator to exit. */
335
+ /**
336
+ * The BLS public key of the validator in hex format
337
+ */
336
338
  public_key: string;
337
- /** The epoch at which the validator wishes to exit. */
339
+ /**
340
+ * The epoch number when the exit is scheduled to occur
341
+ */
338
342
  epoch: string;
339
- /** The index of the validator in the beacon chain. */
343
+ /**
344
+ * The unique index of the validator in the beacon chain
345
+ */
340
346
  validator_index: string;
341
- /** Collection of partial exit signatures from different shares. */
347
+ /**
348
+ * Array of distributed validator shares exit data, where each share contains
349
+ * the partial exit signature from each operator in the cluster
350
+ */
342
351
  shares_exit_data: Array<Record<string, {
343
352
  partial_exit_signature: string;
344
353
  }>>;
345
354
  }
355
+ export interface FullExitBlob {
356
+ /**
357
+ * The signed voluntary exit message containing the exit details and signature
358
+ */
359
+ signed_exit_message: {
360
+ /**
361
+ * The voluntary exit message details
362
+ */
363
+ message: {
364
+ /**
365
+ * The epoch number when the validator exit will be processed
366
+ */
367
+ epoch: string;
368
+ /**
369
+ * The unique index of the validator requesting to exit
370
+ */
371
+ validator_index: string;
372
+ };
373
+ /**
374
+ * The BLS12-381 hex-encoded signature of the exit message
375
+ */
376
+ signature: string;
377
+ };
378
+ /** The public key of the validator to exit. */
379
+ public_key: string;
380
+ }
346
381
  /**
347
382
  * Generic HTTP request function type.
348
383
  * Args:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@obolnetwork/obol-sdk",
3
- "version": "2.6.0",
3
+ "version": "2.7.0",
4
4
  "description": "A package for creating Distributed Validators using the Obol API.",
5
5
  "bugs": {
6
6
  "url": "https://github.com/obolnetwork/obol-sdk/issues"
package/src/exits/exit.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { ENR } from '@chainsafe/discv5';
2
2
  import * as elliptic from 'elliptic';
3
- import { init, verify } from '@chainsafe/bls';
3
+ import { init, verify, aggregateSignatures } from '@chainsafe/bls';
4
4
  import {
5
5
  ByteVectorType,
6
6
  ContainerType,
@@ -16,6 +16,7 @@ import type {
16
16
  ExitValidationMessage,
17
17
  SignedExitValidationMessage,
18
18
  ExistingExitValidationBlobData,
19
+ FullExitBlob,
19
20
  } from '../types';
20
21
  import { getCapellaFork, getGenesisValidatorsRoot } from './ethUtils';
21
22
  import { computeDomain, signingRoot } from './verificationHelpers';
@@ -561,4 +562,100 @@ export class Exit {
561
562
 
562
563
  return validNonDuplicateBlobs;
563
564
  }
565
+
566
+ /**
567
+ * Recombines exit blobs into a single exit blob.
568
+ *
569
+ * This method aggregates partial exit signatures from multiple operators into a single exit blob.
570
+ * It ensures that the signatures are properly ordered and aggregated according to the operator indices.
571
+ *
572
+ * @param exitBlob - The existing exit blob data containing partial exit signatures
573
+ *
574
+ * @returns Promise resolving to a single exit blob with aggregated signatures
575
+ *
576
+ * @throws {Error} When no valid signatures are found for aggregation
577
+ * @throws {Error} When signature length is invalid
578
+ * @throws {Error} When signature parsing fails
579
+ *
580
+ * @example
581
+ * ```typescript
582
+ * const aggregatedExitBlob = await exit.recombineExitBlobs(existingBlobData);
583
+ * ```
584
+ */
585
+ async recombineExitBlobs(
586
+ exitBlob: ExistingExitValidationBlobData,
587
+ ): Promise<FullExitBlob> {
588
+ await init('herumi');
589
+
590
+ // Map to store signatures by their share index (matching Go's map[int]tbls.Signature)
591
+ const signaturesByIndex = new Map<number, Uint8Array>();
592
+
593
+ // Extract signatures from shares_exit_data (equivalent to er.Signatures in Go)
594
+ if (!exitBlob.shares_exit_data || exitBlob.shares_exit_data.length === 0) {
595
+ throw new Error('No shares exit data available for aggregation');
596
+ }
597
+ const signaturesMap = exitBlob.shares_exit_data[0] || {};
598
+ for (const [sigIdxStr, sigData] of Object.entries(signaturesMap)) {
599
+ const sigStr = sigData.partial_exit_signature;
600
+
601
+ if (!sigStr || sigStr.length === 0) {
602
+ // ignore, the associated share index didn't push a partial signature yet
603
+ continue;
604
+ }
605
+
606
+ if (sigStr.length < 2) {
607
+ throw new Error(`Signature string has invalid size: ${sigStr.length}`);
608
+ }
609
+
610
+ // Remove 0x prefix and ensure it's 96 bytes (192 hex chars)
611
+ const cleanSigStr = sigStr.startsWith('0x')
612
+ ? sigStr.substring(2)
613
+ : sigStr;
614
+ if (cleanSigStr.length !== 192) {
615
+ throw new Error(
616
+ `Invalid signature length. Expected 192 hex chars (96 bytes), got ${cleanSigStr.length}`,
617
+ );
618
+ }
619
+
620
+ try {
621
+ const sigBytes = fromHexString(cleanSigStr);
622
+ // Convert string index to number and add 1 (matching Go's sigIdx+1)
623
+ const sigIdx = parseInt(sigIdxStr, 10);
624
+ signaturesByIndex.set(sigIdx + 1, sigBytes);
625
+ } catch (err) {
626
+ throw new Error(`Invalid partial signature: ${String(err)}`);
627
+ }
628
+ }
629
+
630
+ if (signaturesByIndex.size === 0) {
631
+ throw new Error('No valid signatures found for aggregation');
632
+ }
633
+
634
+ // Sort by index and extract signatures in correct order
635
+ const sortedIndices = Array.from(signaturesByIndex.keys()).sort(
636
+ (a, b) => a - b,
637
+ );
638
+ const rawSignatures = sortedIndices.map(idx => {
639
+ const signature = signaturesByIndex.get(idx);
640
+ if (signature === undefined) {
641
+ throw new Error(`Missing signature for index ${idx}`);
642
+ }
643
+ return signature;
644
+ });
645
+
646
+ // Aggregate signatures (equivalent to tbls.ThresholdAggregate in Go)
647
+ // Note: @chainsafe/bls doesn't have explicit threshold aggregation, but ordering should be preserved
648
+ const fullSig = aggregateSignatures(rawSignatures);
649
+
650
+ return {
651
+ public_key: exitBlob.public_key,
652
+ signed_exit_message: {
653
+ message: {
654
+ epoch: exitBlob.epoch,
655
+ validator_index: exitBlob.validator_index,
656
+ },
657
+ signature: '0x' + Buffer.from(fullSig).toString('hex'),
658
+ },
659
+ };
660
+ }
564
661
  }
package/src/types.ts CHANGED
@@ -448,19 +448,54 @@ export interface ExitValidationPayload {
448
448
  * Represents the data structure for an already existing exit blob for exit validation.
449
449
  */
450
450
  export interface ExistingExitValidationBlobData {
451
- /** The public key of the validator to exit. */
451
+ /**
452
+ * The BLS public key of the validator in hex format
453
+ */
452
454
  public_key: string;
453
-
454
- /** The epoch at which the validator wishes to exit. */
455
+ /**
456
+ * The epoch number when the exit is scheduled to occur
457
+ */
455
458
  epoch: string;
456
-
457
- /** The index of the validator in the beacon chain. */
459
+ /**
460
+ * The unique index of the validator in the beacon chain
461
+ */
458
462
  validator_index: string;
459
-
460
- /** Collection of partial exit signatures from different shares. */
463
+ /**
464
+ * Array of distributed validator shares exit data, where each share contains
465
+ * the partial exit signature from each operator in the cluster
466
+ */
461
467
  shares_exit_data: Array<Record<string, { partial_exit_signature: string }>>;
462
468
  }
463
469
 
470
+ // ExitBlob is an exit message alongside its BLS12-381 hex-encoded signature.
471
+ export interface FullExitBlob {
472
+ /**
473
+ * The signed voluntary exit message containing the exit details and signature
474
+ */
475
+ signed_exit_message: {
476
+ /**
477
+ * The voluntary exit message details
478
+ */
479
+ message: {
480
+ /**
481
+ * The epoch number when the validator exit will be processed
482
+ */
483
+ epoch: string;
484
+ /**
485
+ * The unique index of the validator requesting to exit
486
+ */
487
+ validator_index: string;
488
+ };
489
+ /**
490
+ * The BLS12-381 hex-encoded signature of the exit message
491
+ */
492
+ signature: string;
493
+ };
494
+
495
+ /** The public key of the validator to exit. */
496
+ public_key: string;
497
+ }
498
+
464
499
  /**
465
500
  * Generic HTTP request function type.
466
501
  * Args: