@@ -45,9 +45,13 @@ describe('sentinel', () => {
4545 let epoch : bigint ;
4646 let ts : bigint ;
4747 let l1Constants : L1RollupConstants ;
48- const config : Pick < SlasherConfig , 'slashInactivityTargetPercentage' | 'slashInactivityPenalty' > = {
48+ const config : Pick <
49+ SlasherConfig ,
50+ 'slashInactivityTargetPercentage' | 'slashInactivityPenalty' | 'slashInactivityConsecutiveEpochThreshold'
51+ > = {
4952 slashInactivityPenalty : 100n ,
5053 slashInactivityTargetPercentage : 0.8 ,
54+ slashInactivityConsecutiveEpochThreshold : 1 ,
5155 } ;
5256
5357 beforeEach ( async ( ) => {
@@ -444,14 +448,184 @@ describe('sentinel', () => {
444448 fromSlot : headerSlots [ 0 ] ,
445449 toSlot : headerSlots [ headerSlots . length - 1 ] ,
446450 } ) ;
447- expect ( emitSpy ) . toHaveBeenCalledWith ( WANT_TO_SLASH_EVENT , [
448- {
449- validator : validator2 ,
450- amount : config . slashInactivityPenalty ,
451- offenseType : OffenseType . INACTIVITY ,
452- epochOrSlot : 1n ,
453- } ,
454- ] satisfies WantToSlashArgs [ ] ) ;
451+
452+ expect ( emitSpy ) . toHaveBeenCalledTimes ( 1 ) ;
453+ expect ( emitSpy ) . toHaveBeenCalledWith (
454+ WANT_TO_SLASH_EVENT ,
455+ expect . arrayContaining ( [
456+ expect . objectContaining ( {
457+ validator : validator2 ,
458+ amount : config . slashInactivityPenalty ,
459+ offenseType : OffenseType . INACTIVITY ,
460+ epochOrSlot : epochNumber ,
461+ } ) ,
462+ ] ) ,
463+ ) ;
464+ } ) ;
465+ } ) ;
466+
467+ describe ( 'consecutive epoch inactivity' , ( ) => {
468+ let validator1 : EthAddress ;
469+ let validator2 : EthAddress ;
470+
471+ beforeEach ( ( ) => {
472+ validator1 = EthAddress . random ( ) ;
473+ validator2 = EthAddress . random ( ) ;
474+ } ) ;
475+
476+ describe ( 'checkConsecutiveInactivity' , ( ) => {
477+ it ( 'should return true when validator has required consecutive epochs of inactivity' , async ( ) => {
478+ // Mock performance data: validator inactive for 3 consecutive epochs
479+ const mockPerformance = [
480+ { epoch : 5n , missed : 8 , total : 10 } , // 80% missed (inactive)
481+ { epoch : 4n , missed : 9 , total : 10 } , // 90% missed (inactive)
482+ { epoch : 3n , missed : 8 , total : 10 } , // 80% missed (inactive)
483+ { epoch : 2n , missed : 5 , total : 10 } , // 50% missed (active)
484+ ] ;
485+
486+ jest . spyOn ( store , 'getProvenPerformance' ) . mockResolvedValue ( mockPerformance ) ;
487+
488+ const result = await sentinel . checkConsecutiveInactivity ( validator1 , 6n , 3 ) ;
489+
490+ expect ( result ) . toBe ( true ) ;
491+ } ) ;
492+
493+ it ( 'should return false when validator has not been inactive for required consecutive epochs' , async ( ) => {
494+ // Mock performance data: validator active in middle epoch
495+ const mockPerformance = [
496+ { epoch : 5n , missed : 8 , total : 10 } , // 80% missed (inactive)
497+ { epoch : 4n , missed : 5 , total : 10 } , // 50% missed (active)
498+ { epoch : 3n , missed : 8 , total : 10 } , // 80% missed (inactive)
499+ { epoch : 2n , missed : 5 , total : 10 } , // 50% missed (active)
500+ ] ;
501+
502+ jest . spyOn ( store , 'getProvenPerformance' ) . mockResolvedValue ( mockPerformance ) ;
503+
504+ const result = await sentinel . checkConsecutiveInactivity ( validator1 , 6n , 3 ) ;
505+
506+ expect ( result ) . toBe ( false ) ;
507+ } ) ;
508+
509+ it ( 'should return false when insufficient historical data' , async ( ) => {
510+ // Mock performance data: only 2 epochs available, but need 3
511+ const mockPerformance = [
512+ { epoch : 5n , missed : 8 , total : 10 } , // 80% missed (inactive)
513+ { epoch : 4n , missed : 9 , total : 10 } , // 90% missed (inactive)
514+ ] ;
515+
516+ jest . spyOn ( store , 'getProvenPerformance' ) . mockResolvedValue ( mockPerformance ) ;
517+
518+ const result = await sentinel . checkConsecutiveInactivity ( validator1 , 6n , 3 ) ;
519+
520+ expect ( result ) . toBe ( false ) ;
521+ } ) ;
522+
523+ it ( 'should return true when there is a gap in epochs since validators are not chosen for every committee' , async ( ) => {
524+ // Mock performance data: gap in epoch 4
525+ const mockPerformance = [
526+ { epoch : 5n , missed : 8 , total : 10 } , // 80% missed (inactive)
527+ { epoch : 3n , missed : 8 , total : 10 } , // 80% missed (inactive) - missing epoch 4
528+ { epoch : 2n , missed : 8 , total : 10 } , // 80% missed (inactive)
529+ ] ;
530+
531+ jest . spyOn ( store , 'getProvenPerformance' ) . mockResolvedValue ( mockPerformance ) ;
532+
533+ const result = await sentinel . checkConsecutiveInactivity ( validator1 , 6n , 3 ) ;
534+
535+ expect ( result ) . toBe ( true ) ;
536+ } ) ;
537+
538+ it ( 'should work with threshold of 0 used when there are no past epochs to inspect' , async ( ) => {
539+ jest . spyOn ( store , 'getProvenPerformance' ) . mockResolvedValue ( [ ] ) ;
540+ const result = await sentinel . checkConsecutiveInactivity ( validator1 , 6n , 0 ) ;
541+ expect ( result ) . toBe ( true ) ;
542+ } ) ;
543+
544+ it ( 'should only consider past epochs' , async ( ) => {
545+ // Mock performance data: validator inactive for 3 consecutive epochs
546+ const mockPerformance = [
547+ { epoch : 5n , missed : 8 , total : 10 } , // 80% missed (inactive)
548+ { epoch : 4n , missed : 9 , total : 10 } , // 90% missed (inactive)
549+ { epoch : 3n , missed : 8 , total : 10 } , // 80% missed (inactive)
550+ { epoch : 2n , missed : 5 , total : 10 } , // 50% missed (active)
551+ ] ;
552+
553+ jest . spyOn ( store , 'getProvenPerformance' ) . mockResolvedValue ( mockPerformance ) ;
554+
555+ // Query on epoch 5, so we only consider past ones and don't get to threshold
556+ const result = await sentinel . checkConsecutiveInactivity ( validator1 , 5n , 3 ) ;
557+
558+ expect ( result ) . toBe ( false ) ;
559+ } ) ;
560+ } ) ;
561+
562+ describe ( 'handleProvenPerformance with consecutive epochs' , ( ) => {
563+ it ( 'should slash validators only after consecutive epoch failures' , async ( ) => {
564+ // Update config to require 2 consecutive epochs
565+ sentinel . updateConfig ( { slashInactivityConsecutiveEpochThreshold : 2 } ) ;
566+
567+ // Mock performance data for two validators
568+ jest . spyOn ( store , 'getProvenPerformance' ) . mockImplementation ( validator => {
569+ if ( validator . equals ( validator1 ) ) {
570+ // Validator1: inactive for 2+ consecutive epochs - should be slashed
571+ return Promise . resolve ( [
572+ { epoch : 5n , missed : 8 , total : 10 } , // 80% missed (inactive)
573+ { epoch : 4n , missed : 9 , total : 10 } , // 90% missed (inactive)
574+ { epoch : 3n , missed : 5 , total : 10 } , // 50% missed (active)
575+ ] ) ;
576+ } else {
577+ // Validator2: inactive only in current epoch - should NOT be slashed
578+ return Promise . resolve ( [
579+ { epoch : 5n , missed : 8 , total : 10 } , // 80% missed (inactive)
580+ { epoch : 4n , missed : 5 , total : 10 } , // 50% missed (active)
581+ { epoch : 3n , missed : 5 , total : 10 } , // 50% missed (active)
582+ ] ) ;
583+ }
584+ } ) ;
585+
586+ const emitSpy = jest . spyOn ( sentinel , 'emit' ) ;
587+
588+ // Current epoch performance: both validators are inactive
589+ const currentEpochPerformance = {
590+ [ validator1 . toString ( ) ] : { missed : 8 , total : 10 } , // 80% missed
591+ [ validator2 . toString ( ) ] : { missed : 8 , total : 10 } , // 80% missed
592+ } ;
593+
594+ await sentinel . handleProvenPerformance ( 5n , currentEpochPerformance ) ;
595+
596+ // Should only slash validator1 (2 consecutive epochs), not validator2 (1 epoch)
597+ expect ( emitSpy ) . toHaveBeenCalledWith ( WANT_TO_SLASH_EVENT , [
598+ {
599+ validator : validator1 ,
600+ amount : config . slashInactivityPenalty ,
601+ offenseType : OffenseType . INACTIVITY ,
602+ epochOrSlot : 5n ,
603+ } ,
604+ ] ) ;
605+ } ) ;
606+
607+ it ( 'should not slash when no validators meet consecutive threshold' , async ( ) => {
608+ // Update config to require 3 consecutive epochs
609+ sentinel . updateConfig ( { slashInactivityConsecutiveEpochThreshold : 3 } ) ;
610+
611+ // Mock performance data: validators only inactive for 2 epochs
612+ jest . spyOn ( store , 'getProvenPerformance' ) . mockResolvedValue ( [
613+ { epoch : 5n , missed : 8 , total : 10 } , // 80% missed (inactive)
614+ { epoch : 4n , missed : 9 , total : 10 } , // 90% missed (inactive)
615+ { epoch : 3n , missed : 5 , total : 10 } , // 50% missed (active)
616+ ] ) ;
617+
618+ const emitSpy = jest . spyOn ( sentinel , 'emit' ) ;
619+
620+ const currentEpochPerformance = {
621+ [ validator1 . toString ( ) ] : { missed : 8 , total : 10 } , // 80% missed
622+ } ;
623+
624+ await sentinel . handleProvenPerformance ( 5n , currentEpochPerformance ) ;
625+
626+ // Should not emit any slash events
627+ expect ( emitSpy ) . not . toHaveBeenCalledWith ( WANT_TO_SLASH_EVENT , expect . anything ( ) ) ;
628+ } ) ;
455629 } ) ;
456630 } ) ;
457631} ) ;
@@ -462,7 +636,10 @@ class TestSentinel extends Sentinel {
462636 archiver : L2BlockSource ,
463637 p2p : P2PClient ,
464638 store : SentinelStore ,
465- config : Pick < SlasherConfig , 'slashInactivityTargetPercentage' | 'slashInactivityPenalty' > ,
639+ config : Pick <
640+ SlasherConfig ,
641+ 'slashInactivityTargetPercentage' | 'slashInactivityPenalty' | 'slashInactivityConsecutiveEpochThreshold'
642+ > ,
466643 protected override blockStream : L2BlockStream ,
467644 ) {
468645 super ( epochCache , archiver , p2p , store , config ) ;
@@ -512,4 +689,12 @@ class TestSentinel extends Sentinel {
512689 public getInitialSlot ( ) {
513690 return this . initialSlot ;
514691 }
692+
693+ public override checkConsecutiveInactivity (
694+ validator : EthAddress ,
695+ currentEpoch : bigint ,
696+ requiredConsecutiveEpochs : number ,
697+ ) {
698+ return super . checkConsecutiveInactivity ( validator , currentEpoch , requiredConsecutiveEpochs ) ;
699+ }
515700}
0 commit comments