Skip to content

Commit 358bae7

Browse files
committed
feat: Slash after N consecutive epochs of inactivity
Instead of slashing on inactivity immediately, the sentinel now waits until the last N epochs for which it has data for the offender also match the inactivity threshold. Defaults to 1, so the behaviour is the same as the current one.
1 parent 331ad6c commit 358bae7

File tree

7 files changed

+273
-16
lines changed

7 files changed

+273
-16
lines changed

yarn-project/aztec-node/src/sentinel/sentinel.test.ts

Lines changed: 195 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -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
}

yarn-project/aztec-node/src/sentinel/sentinel.ts

Lines changed: 58 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { EpochCache } from '@aztec/epoch-cache';
2-
import { countWhile } from '@aztec/foundation/collection';
2+
import { countWhile, filterAsync } from '@aztec/foundation/collection';
33
import { EthAddress } from '@aztec/foundation/eth-address';
44
import { createLogger } from '@aztec/foundation/log';
55
import { RunningPromise } from '@aztec/foundation/running-promise';
@@ -44,7 +44,10 @@ export class Sentinel extends (EventEmitter as new () => WatcherEmitter) impleme
4444
protected archiver: L2BlockSource,
4545
protected p2p: P2PClient,
4646
protected store: SentinelStore,
47-
protected config: Pick<SlasherConfig, 'slashInactivityTargetPercentage' | 'slashInactivityPenalty'>,
47+
protected config: Pick<
48+
SlasherConfig,
49+
'slashInactivityTargetPercentage' | 'slashInactivityPenalty' | 'slashInactivityConsecutiveEpochThreshold'
50+
>,
4851
protected logger = createLogger('node:sentinel'),
4952
) {
5053
super();
@@ -118,7 +121,7 @@ export class Sentinel extends (EventEmitter as new () => WatcherEmitter) impleme
118121
this.logger.info(`Computed proven performance for epoch ${epoch}`, performance);
119122

120123
await this.updateProvenPerformance(epoch, performance);
121-
this.handleProvenPerformance(epoch, performance);
124+
await this.handleProvenPerformance(epoch, performance);
122125
}
123126

124127
protected async computeProvenPerformance(epoch: bigint) {
@@ -161,13 +164,59 @@ export class Sentinel extends (EventEmitter as new () => WatcherEmitter) impleme
161164
return this.store.updateProvenPerformance(epoch, performance);
162165
}
163166

164-
protected handleProvenPerformance(epoch: bigint, performance: ValidatorsEpochPerformance) {
165-
const criminals = Object.entries(performance)
167+
/**
168+
* Checks if a validator has been inactive for the specified number of consecutive epochs we have data on it.
169+
* @param validator The validator address to check
170+
* @param currentEpoch The current epoch being evaluated
171+
* @param requiredConsecutiveEpochs Number of consecutive epochs required for slashing
172+
* @returns true if the validator has been inactive for the required consecutive epochs
173+
*/
174+
protected async checkConsecutiveInactivity(
175+
validator: EthAddress,
176+
currentEpoch: bigint,
177+
requiredConsecutiveEpochs: number,
178+
): Promise<boolean> {
179+
if (requiredConsecutiveEpochs === 0) {
180+
return true;
181+
}
182+
183+
// Get all historical performance for this validator
184+
const allPerformance = await this.store.getProvenPerformance(validator);
185+
186+
// If we don't have enough historical data, don't slash
187+
if (allPerformance.length < requiredConsecutiveEpochs) {
188+
this.logger.debug(
189+
`Not enough historical data for slashing ${validator} for inactivity (${allPerformance.length} epochs < ${requiredConsecutiveEpochs} required)`,
190+
);
191+
return false;
192+
}
193+
194+
// Sort by epoch descending to get most recent first, keep only epochs strictly before the current one, and get the first N
195+
return allPerformance
196+
.sort((a, b) => Number(b.epoch - a.epoch))
197+
.filter(p => p.epoch < currentEpoch)
198+
.slice(0, requiredConsecutiveEpochs)
199+
.every(p => p.missed / p.total >= this.config.slashInactivityTargetPercentage);
200+
}
201+
202+
protected async handleProvenPerformance(epoch: bigint, performance: ValidatorsEpochPerformance) {
203+
const inactiveValidators = Object.entries(performance)
166204
.filter(([_, { missed, total }]) => {
167205
return missed / total >= this.config.slashInactivityTargetPercentage;
168206
})
169207
.map(([address]) => address as `0x${string}`);
170208

209+
this.logger.debug(`Found ${inactiveValidators.length} inactive validators in epoch ${epoch}`, {
210+
inactiveValidators,
211+
epoch,
212+
inactivityTargetPercentage: this.config.slashInactivityTargetPercentage,
213+
});
214+
215+
const epochThreshold = this.config.slashInactivityConsecutiveEpochThreshold;
216+
const criminals: string[] = await filterAsync(inactiveValidators, address =>
217+
this.checkConsecutiveInactivity(EthAddress.fromString(address), epoch, epochThreshold - 1),
218+
);
219+
171220
const args = criminals.map(address => ({
172221
validator: EthAddress.fromString(address),
173222
amount: this.config.slashInactivityPenalty,
@@ -176,7 +225,10 @@ export class Sentinel extends (EventEmitter as new () => WatcherEmitter) impleme
176225
}));
177226

178227
if (criminals.length > 0) {
179-
this.logger.info(`Identified ${criminals.length} validators to slash due to inactivity`, { args });
228+
this.logger.info(
229+
`Identified ${criminals.length} validators to slash due to inactivity in at least ${epochThreshold} consecutive epochs`,
230+
{ ...args, epochThreshold },
231+
);
180232
this.emit(WANT_TO_SLASH_EVENT, args);
181233
}
182234
}

yarn-project/aztec/src/cli/chain_l2_config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ export const testnetIgnitionL2ChainConfig: L2ChainConfig = {
7575
slashMinPenaltyPercentage: 0.5,
7676
slashMaxPenaltyPercentage: 200,
7777
slashInactivityTargetPercentage: 0,
78+
slashInactivityConsecutiveEpochThreshold: 1,
7879
slashInactivityPenalty: 0n,
7980
slashPrunePenalty: 0n,
8081
slashDataWithholdingPenalty: 0n,
@@ -152,6 +153,7 @@ export const alphaTestnetL2ChainConfig: L2ChainConfig = {
152153
slashMinPenaltyPercentage: 0.5,
153154
slashMaxPenaltyPercentage: 2.0,
154155
slashInactivityTargetPercentage: 0.7,
156+
slashInactivityConsecutiveEpochThreshold: 1,
155157
slashInactivityPenalty: DefaultL1ContractsConfig.slashAmountSmall,
156158
slashPrunePenalty: DefaultL1ContractsConfig.slashAmountSmall,
157159
slashDataWithholdingPenalty: DefaultL1ContractsConfig.slashAmountSmall,
@@ -326,6 +328,7 @@ export async function enrichEnvironmentWithChainConfig(networkName: NetworkNames
326328
enrichVar('SLASH_PRUNE_PENALTY', config.slashPrunePenalty.toString());
327329
enrichVar('SLASH_DATA_WITHHOLDING_PENALTY', config.slashDataWithholdingPenalty.toString());
328330
enrichVar('SLASH_INACTIVITY_TARGET_PERCENTAGE', config.slashInactivityTargetPercentage.toString());
331+
enrichVar('SLASH_INACTIVITY_CONSECUTIVE_EPOCH_THRESHOLD', config.slashInactivityConsecutiveEpochThreshold.toString());
329332
enrichVar('SLASH_INACTIVITY_PENALTY', config.slashInactivityPenalty.toString());
330333
enrichVar('SLASH_PROPOSE_INVALID_ATTESTATIONS_PENALTY', config.slashProposeInvalidAttestationsPenalty.toString());
331334
enrichVar('SLASH_ATTEST_DESCENDANT_OF_INVALID_PENALTY', config.slashAttestDescendantOfInvalidPenalty.toString());

yarn-project/foundation/src/config/env_var.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,7 @@ export type EnvVar =
207207
| 'SLASH_DATA_WITHHOLDING_PENALTY'
208208
| 'SLASH_INACTIVITY_PENALTY'
209209
| 'SLASH_INACTIVITY_TARGET_PERCENTAGE'
210+
| 'SLASH_INACTIVITY_CONSECUTIVE_EPOCH_THRESHOLD'
210211
| 'SLASH_INVALID_BLOCK_PENALTY'
211212
| 'SLASH_OVERRIDE_PAYLOAD'
212213
| 'SLASH_PROPOSE_INVALID_ATTESTATIONS_PENALTY'

yarn-project/slasher/src/config.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export const DefaultSlasherConfig: SlasherConfig = {
2020
slashPrunePenalty: DefaultL1ContractsConfig.slashAmountSmall,
2121
slashDataWithholdingPenalty: DefaultL1ContractsConfig.slashAmountSmall,
2222
slashInactivityTargetPercentage: 0.9,
23+
slashInactivityConsecutiveEpochThreshold: 1, // Default to 1 for backward compatibility
2324
slashBroadcastedInvalidBlockPenalty: DefaultL1ContractsConfig.slashAmountSmall,
2425
slashInactivityPenalty: DefaultL1ContractsConfig.slashAmountSmall,
2526
slashProposeInvalidAttestationsPenalty: DefaultL1ContractsConfig.slashAmountSmall,
@@ -95,6 +96,18 @@ export const slasherConfigMappings: ConfigMappingsType<SlasherConfig> = {
9596
}
9697
}),
9798
},
99+
slashInactivityConsecutiveEpochThreshold: {
100+
env: 'SLASH_INACTIVITY_CONSECUTIVE_EPOCH_THRESHOLD',
101+
description: 'Number of consecutive epochs a validator must be inactive before slashing (minimum 1).',
102+
...numberConfigHelper(DefaultSlasherConfig.slashInactivityConsecutiveEpochThreshold),
103+
parseEnv: (val: string) => {
104+
const parsed = parseInt(val, 10);
105+
if (parsed < 1) {
106+
throw new RangeError(`SLASH_INACTIVITY_CONSECUTIVE_EPOCH_THRESHOLD must be at least 1 (got ${parsed})`);
107+
}
108+
return parsed;
109+
},
110+
},
98111
slashInactivityPenalty: {
99112
env: 'SLASH_INACTIVITY_PENALTY',
100113
description: 'Penalty amount for slashing an inactive validator (set to 0 to disable).',

0 commit comments

Comments
 (0)