@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.
- package/dist/cjs/package.json +1 -1
- package/dist/cjs/src/exits/exit.js +82 -0
- package/dist/cjs/test/exit/exit.spec.js +74 -3
- package/dist/esm/package.json +1 -1
- package/dist/esm/src/exits/exit.js +83 -1
- package/dist/esm/test/exit/exit.spec.js +74 -3
- package/dist/types/src/exits/exit.d.ts +21 -1
- package/dist/types/src/types.d.ts +39 -4
- package/package.json +1 -1
- package/src/exits/exit.ts +98 -1
- package/src/types.ts +42 -7
package/dist/cjs/package.json
CHANGED
|
@@ -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
|
-
|
|
327
|
-
|
|
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
|
});
|
package/dist/esm/package.json
CHANGED
|
@@ -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
|
-
|
|
302
|
-
|
|
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
|
-
/**
|
|
335
|
+
/**
|
|
336
|
+
* The BLS public key of the validator in hex format
|
|
337
|
+
*/
|
|
336
338
|
public_key: string;
|
|
337
|
-
/**
|
|
339
|
+
/**
|
|
340
|
+
* The epoch number when the exit is scheduled to occur
|
|
341
|
+
*/
|
|
338
342
|
epoch: string;
|
|
339
|
-
/**
|
|
343
|
+
/**
|
|
344
|
+
* The unique index of the validator in the beacon chain
|
|
345
|
+
*/
|
|
340
346
|
validator_index: string;
|
|
341
|
-
/**
|
|
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
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
|
-
/**
|
|
451
|
+
/**
|
|
452
|
+
* The BLS public key of the validator in hex format
|
|
453
|
+
*/
|
|
452
454
|
public_key: string;
|
|
453
|
-
|
|
454
|
-
|
|
455
|
+
/**
|
|
456
|
+
* The epoch number when the exit is scheduled to occur
|
|
457
|
+
*/
|
|
455
458
|
epoch: string;
|
|
456
|
-
|
|
457
|
-
|
|
459
|
+
/**
|
|
460
|
+
* The unique index of the validator in the beacon chain
|
|
461
|
+
*/
|
|
458
462
|
validator_index: string;
|
|
459
|
-
|
|
460
|
-
|
|
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:
|