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