Skip to content

Commit 8ee262b

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 8ee262b

File tree

7 files changed

+269
-18
lines changed

7 files changed

+269
-18
lines changed

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

Lines changed: 192 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,7 @@ import { Secp256k1Signer } from '@aztec/foundation/crypto';
44
import { EthAddress } from '@aztec/foundation/eth-address';
55
import { AztecLMDBStoreV2, openTmpStore } from '@aztec/kv-store/lmdb-v2';
66
import 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';
98
import type { SlasherConfig } from '@aztec/slasher/config';
109
import {
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
}

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

Lines changed: 57 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,58 @@ 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 for which we have data on it.
169+
* @param validator The validator address to check
170+
* @param currentEpoch Epochs strictly before the current one are evaluated only
171+
* @param requiredConsecutiveEpochs Number of consecutive epochs required for slashing
172+
*/
173+
protected async checkPastInactivity(
174+
validator: EthAddress,
175+
currentEpoch: bigint,
176+
requiredConsecutiveEpochs: number,
177+
): Promise<boolean> {
178+
if (requiredConsecutiveEpochs === 0) {
179+
return true;
180+
}
181+
182+
// Get all historical performance for this validator
183+
const allPerformance = await this.store.getProvenPerformance(validator);
184+
185+
// If we don't have enough historical data, don't slash
186+
if (allPerformance.length < requiredConsecutiveEpochs) {
187+
this.logger.debug(
188+
`Not enough historical data for slashing ${validator} for inactivity (${allPerformance.length} epochs < ${requiredConsecutiveEpochs} required)`,
189+
);
190+
return false;
191+
}
192+
193+
// Sort by epoch descending to get most recent first, keep only epochs strictly before the current one, and get the first N
194+
return allPerformance
195+
.sort((a, b) => Number(b.epoch - a.epoch))
196+
.filter(p => p.epoch < currentEpoch)
197+
.slice(0, requiredConsecutiveEpochs)
198+
.every(p => p.missed / p.total >= this.config.slashInactivityTargetPercentage);
199+
}
200+
201+
protected async handleProvenPerformance(epoch: bigint, performance: ValidatorsEpochPerformance) {
202+
const inactiveValidators = Object.entries(performance)
166203
.filter(([_, { missed, total }]) => {
167204
return missed / total >= this.config.slashInactivityTargetPercentage;
168205
})
169206
.map(([address]) => address as `0x${string}`);
170207

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

178226
if (criminals.length > 0) {
179-
this.logger.info(`Identified ${criminals.length} validators to slash due to inactivity`, { args });
227+
this.logger.info(
228+
`Identified ${criminals.length} validators to slash due to inactivity in at least ${epochThreshold} consecutive epochs`,
229+
{ ...args, epochThreshold },
230+
);
180231
this.emit(WANT_TO_SLASH_EVENT, args);
181232
}
182233
}

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)