Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
204 changes: 192 additions & 12 deletions yarn-project/aztec-node/src/sentinel/sentinel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,7 @@ import { Secp256k1Signer } from '@aztec/foundation/crypto';
import { EthAddress } from '@aztec/foundation/eth-address';
import { AztecLMDBStoreV2, openTmpStore } from '@aztec/kv-store/lmdb-v2';
import type { P2PClient } from '@aztec/p2p';
import { OffenseType } from '@aztec/slasher';
import { WANT_TO_SLASH_EVENT, type WantToSlashArgs } from '@aztec/slasher';
import { OffenseType, WANT_TO_SLASH_EVENT } from '@aztec/slasher';
import type { SlasherConfig } from '@aztec/slasher/config';
import {
type L2BlockSource,
Expand Down Expand Up @@ -45,9 +44,13 @@ describe('sentinel', () => {
let epoch: bigint;
let ts: bigint;
let l1Constants: L1RollupConstants;
const config: Pick<SlasherConfig, 'slashInactivityTargetPercentage' | 'slashInactivityPenalty'> = {
const config: Pick<
SlasherConfig,
'slashInactivityTargetPercentage' | 'slashInactivityPenalty' | 'slashInactivityConsecutiveEpochThreshold'
> = {
slashInactivityPenalty: 100n,
slashInactivityTargetPercentage: 0.8,
slashInactivityConsecutiveEpochThreshold: 1,
};

beforeEach(async () => {
Expand Down Expand Up @@ -444,14 +447,184 @@ describe('sentinel', () => {
fromSlot: headerSlots[0],
toSlot: headerSlots[headerSlots.length - 1],
});
expect(emitSpy).toHaveBeenCalledWith(WANT_TO_SLASH_EVENT, [
{
validator: validator2,
amount: config.slashInactivityPenalty,
offenseType: OffenseType.INACTIVITY,
epochOrSlot: 1n,
},
] satisfies WantToSlashArgs[]);

expect(emitSpy).toHaveBeenCalledTimes(1);
expect(emitSpy).toHaveBeenCalledWith(
WANT_TO_SLASH_EVENT,
expect.arrayContaining([
expect.objectContaining({
validator: validator2,
amount: config.slashInactivityPenalty,
offenseType: OffenseType.INACTIVITY,
epochOrSlot: epochNumber,
}),
]),
);
});
});

describe('consecutive epoch inactivity', () => {
let validator1: EthAddress;
let validator2: EthAddress;

beforeEach(() => {
validator1 = EthAddress.random();
validator2 = EthAddress.random();
});

describe('checkConsecutiveInactivity', () => {
it('should return true when validator has required consecutive epochs of inactivity', async () => {
// Mock performance data: validator inactive for 3 consecutive epochs
const mockPerformance = [
{ epoch: 5n, missed: 8, total: 10 }, // 80% missed (inactive)
{ epoch: 4n, missed: 9, total: 10 }, // 90% missed (inactive)
{ epoch: 3n, missed: 8, total: 10 }, // 80% missed (inactive)
{ epoch: 2n, missed: 5, total: 10 }, // 50% missed (active)
];

jest.spyOn(store, 'getProvenPerformance').mockResolvedValue(mockPerformance);

const result = await sentinel.checkPastInactivity(validator1, 6n, 3);

expect(result).toBe(true);
});

it('should return false when validator has not been inactive for required consecutive epochs', async () => {
// Mock performance data: validator active in middle epoch
const mockPerformance = [
{ epoch: 5n, missed: 8, total: 10 }, // 80% missed (inactive)
{ epoch: 4n, missed: 5, total: 10 }, // 50% missed (active)
{ epoch: 3n, missed: 8, total: 10 }, // 80% missed (inactive)
{ epoch: 2n, missed: 5, total: 10 }, // 50% missed (active)
];

jest.spyOn(store, 'getProvenPerformance').mockResolvedValue(mockPerformance);

const result = await sentinel.checkPastInactivity(validator1, 6n, 3);

expect(result).toBe(false);
});

it('should return false when insufficient historical data', async () => {
// Mock performance data: only 2 epochs available, but need 3
const mockPerformance = [
{ epoch: 5n, missed: 8, total: 10 }, // 80% missed (inactive)
{ epoch: 4n, missed: 9, total: 10 }, // 90% missed (inactive)
];

jest.spyOn(store, 'getProvenPerformance').mockResolvedValue(mockPerformance);

const result = await sentinel.checkPastInactivity(validator1, 6n, 3);

expect(result).toBe(false);
});

it('should return true when there is a gap in epochs since validators are not chosen for every committee', async () => {
// Mock performance data: gap in epoch 4
const mockPerformance = [
{ epoch: 5n, missed: 8, total: 10 }, // 80% missed (inactive)
{ epoch: 3n, missed: 8, total: 10 }, // 80% missed (inactive) - missing epoch 4
{ epoch: 2n, missed: 8, total: 10 }, // 80% missed (inactive)
];

jest.spyOn(store, 'getProvenPerformance').mockResolvedValue(mockPerformance);

const result = await sentinel.checkPastInactivity(validator1, 6n, 3);

expect(result).toBe(true);
});

it('should work with threshold of 0 used when there are no past epochs to inspect', async () => {
jest.spyOn(store, 'getProvenPerformance').mockResolvedValue([]);
const result = await sentinel.checkPastInactivity(validator1, 6n, 0);
expect(result).toBe(true);
});

it('should only consider past epochs', async () => {
// Mock performance data: validator inactive for 3 consecutive epochs
const mockPerformance = [
{ epoch: 5n, missed: 8, total: 10 }, // 80% missed (inactive)
{ epoch: 4n, missed: 9, total: 10 }, // 90% missed (inactive)
{ epoch: 3n, missed: 8, total: 10 }, // 80% missed (inactive)
{ epoch: 2n, missed: 5, total: 10 }, // 50% missed (active)
];

jest.spyOn(store, 'getProvenPerformance').mockResolvedValue(mockPerformance);

// Query on epoch 5, so we only consider past ones and don't get to threshold
const result = await sentinel.checkPastInactivity(validator1, 5n, 3);

expect(result).toBe(false);
});
});

describe('handleProvenPerformance with consecutive epochs', () => {
it('should slash validators only after consecutive epoch failures', async () => {
// Update config to require 2 consecutive epochs
sentinel.updateConfig({ slashInactivityConsecutiveEpochThreshold: 2 });

// Mock performance data for two validators
jest.spyOn(store, 'getProvenPerformance').mockImplementation(validator => {
if (validator.equals(validator1)) {
// Validator1: inactive for 2+ consecutive epochs - should be slashed
return Promise.resolve([
{ epoch: 5n, missed: 8, total: 10 }, // 80% missed (inactive)
{ epoch: 4n, missed: 9, total: 10 }, // 90% missed (inactive)
{ epoch: 3n, missed: 5, total: 10 }, // 50% missed (active)
]);
} else {
// Validator2: inactive only in current epoch - should NOT be slashed
return Promise.resolve([
{ epoch: 5n, missed: 8, total: 10 }, // 80% missed (inactive)
{ epoch: 4n, missed: 5, total: 10 }, // 50% missed (active)
{ epoch: 3n, missed: 5, total: 10 }, // 50% missed (active)
]);
}
});

const emitSpy = jest.spyOn(sentinel, 'emit');

// Current epoch performance: both validators are inactive
const currentEpochPerformance = {
[validator1.toString()]: { missed: 8, total: 10 }, // 80% missed
[validator2.toString()]: { missed: 8, total: 10 }, // 80% missed
};

await sentinel.handleProvenPerformance(5n, currentEpochPerformance);

// Should only slash validator1 (2 consecutive epochs), not validator2 (1 epoch)
expect(emitSpy).toHaveBeenCalledWith(WANT_TO_SLASH_EVENT, [
{
validator: validator1,
amount: config.slashInactivityPenalty,
offenseType: OffenseType.INACTIVITY,
epochOrSlot: 5n,
},
]);
});

it('should not slash when no validators meet consecutive threshold', async () => {
// Update config to require 3 consecutive epochs
sentinel.updateConfig({ slashInactivityConsecutiveEpochThreshold: 3 });

// Mock performance data: validators only inactive for 2 epochs
jest.spyOn(store, 'getProvenPerformance').mockResolvedValue([
{ epoch: 5n, missed: 8, total: 10 }, // 80% missed (inactive)
{ epoch: 4n, missed: 9, total: 10 }, // 90% missed (inactive)
{ epoch: 3n, missed: 5, total: 10 }, // 50% missed (active)
]);

const emitSpy = jest.spyOn(sentinel, 'emit');

const currentEpochPerformance = {
[validator1.toString()]: { missed: 8, total: 10 }, // 80% missed
};

await sentinel.handleProvenPerformance(5n, currentEpochPerformance);

// Should not emit any slash events
expect(emitSpy).not.toHaveBeenCalledWith(WANT_TO_SLASH_EVENT, expect.anything());
});
});
});
});
Expand All @@ -462,7 +635,10 @@ class TestSentinel extends Sentinel {
archiver: L2BlockSource,
p2p: P2PClient,
store: SentinelStore,
config: Pick<SlasherConfig, 'slashInactivityTargetPercentage' | 'slashInactivityPenalty'>,
config: Pick<
SlasherConfig,
'slashInactivityTargetPercentage' | 'slashInactivityPenalty' | 'slashInactivityConsecutiveEpochThreshold'
>,
protected override blockStream: L2BlockStream,
) {
super(epochCache, archiver, p2p, store, config);
Expand Down Expand Up @@ -512,4 +688,8 @@ class TestSentinel extends Sentinel {
public getInitialSlot() {
return this.initialSlot;
}

public override checkPastInactivity(validator: EthAddress, currentEpoch: bigint, requiredConsecutiveEpochs: number) {
return super.checkPastInactivity(validator, currentEpoch, requiredConsecutiveEpochs);
}
}
63 changes: 57 additions & 6 deletions yarn-project/aztec-node/src/sentinel/sentinel.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { EpochCache } from '@aztec/epoch-cache';
import { countWhile } from '@aztec/foundation/collection';
import { countWhile, filterAsync } from '@aztec/foundation/collection';
import { EthAddress } from '@aztec/foundation/eth-address';
import { createLogger } from '@aztec/foundation/log';
import { RunningPromise } from '@aztec/foundation/running-promise';
Expand Down Expand Up @@ -44,7 +44,10 @@ export class Sentinel extends (EventEmitter as new () => WatcherEmitter) impleme
protected archiver: L2BlockSource,
protected p2p: P2PClient,
protected store: SentinelStore,
protected config: Pick<SlasherConfig, 'slashInactivityTargetPercentage' | 'slashInactivityPenalty'>,
protected config: Pick<
SlasherConfig,
'slashInactivityTargetPercentage' | 'slashInactivityPenalty' | 'slashInactivityConsecutiveEpochThreshold'
>,
protected logger = createLogger('node:sentinel'),
) {
super();
Expand Down Expand Up @@ -118,7 +121,7 @@ export class Sentinel extends (EventEmitter as new () => WatcherEmitter) impleme
this.logger.info(`Computed proven performance for epoch ${epoch}`, performance);

await this.updateProvenPerformance(epoch, performance);
this.handleProvenPerformance(epoch, performance);
await this.handleProvenPerformance(epoch, performance);
}

protected async computeProvenPerformance(epoch: bigint) {
Expand Down Expand Up @@ -161,13 +164,58 @@ export class Sentinel extends (EventEmitter as new () => WatcherEmitter) impleme
return this.store.updateProvenPerformance(epoch, performance);
}

protected handleProvenPerformance(epoch: bigint, performance: ValidatorsEpochPerformance) {
const criminals = Object.entries(performance)
/**
* Checks if a validator has been inactive for the specified number of consecutive epochs for which we have data on it.
* @param validator The validator address to check
* @param currentEpoch Epochs strictly before the current one are evaluated only
* @param requiredConsecutiveEpochs Number of consecutive epochs required for slashing
*/
protected async checkPastInactivity(
validator: EthAddress,
currentEpoch: bigint,
requiredConsecutiveEpochs: number,
): Promise<boolean> {
if (requiredConsecutiveEpochs === 0) {
return true;
}

// Get all historical performance for this validator
const allPerformance = await this.store.getProvenPerformance(validator);

// If we don't have enough historical data, don't slash
if (allPerformance.length < requiredConsecutiveEpochs) {
this.logger.debug(
`Not enough historical data for slashing ${validator} for inactivity (${allPerformance.length} epochs < ${requiredConsecutiveEpochs} required)`,
);
return false;
}

// Sort by epoch descending to get most recent first, keep only epochs strictly before the current one, and get the first N
return allPerformance
.sort((a, b) => Number(b.epoch - a.epoch))
.filter(p => p.epoch < currentEpoch)
.slice(0, requiredConsecutiveEpochs)
.every(p => p.missed / p.total >= this.config.slashInactivityTargetPercentage);
}

protected async handleProvenPerformance(epoch: bigint, performance: ValidatorsEpochPerformance) {
const inactiveValidators = Object.entries(performance)
.filter(([_, { missed, total }]) => {
return missed / total >= this.config.slashInactivityTargetPercentage;
})
.map(([address]) => address as `0x${string}`);

this.logger.debug(`Found ${inactiveValidators.length} inactive validators in epoch ${epoch}`, {
inactiveValidators,
epoch,
inactivityTargetPercentage: this.config.slashInactivityTargetPercentage,
});

const epochThreshold = this.config.slashInactivityConsecutiveEpochThreshold;
const criminals: string[] = await filterAsync(inactiveValidators, address =>
this.checkPastInactivity(EthAddress.fromString(address), epoch, epochThreshold - 1),
);

const args = criminals.map(address => ({
validator: EthAddress.fromString(address),
amount: this.config.slashInactivityPenalty,
Expand All @@ -176,7 +224,10 @@ export class Sentinel extends (EventEmitter as new () => WatcherEmitter) impleme
}));

if (criminals.length > 0) {
this.logger.info(`Identified ${criminals.length} validators to slash due to inactivity`, { args });
this.logger.info(
`Identified ${criminals.length} validators to slash due to inactivity in at least ${epochThreshold} consecutive epochs`,
{ ...args, epochThreshold },
);
this.emit(WANT_TO_SLASH_EVENT, args);
}
}
Expand Down
3 changes: 3 additions & 0 deletions yarn-project/aztec/src/cli/chain_l2_config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export const testnetIgnitionL2ChainConfig: L2ChainConfig = {
slashMinPenaltyPercentage: 0.5,
slashMaxPenaltyPercentage: 200,
slashInactivityTargetPercentage: 0,
slashInactivityConsecutiveEpochThreshold: 1,
slashInactivityPenalty: 0n,
slashPrunePenalty: 0n,
slashDataWithholdingPenalty: 0n,
Expand Down Expand Up @@ -152,6 +153,7 @@ export const alphaTestnetL2ChainConfig: L2ChainConfig = {
slashMinPenaltyPercentage: 0.5,
slashMaxPenaltyPercentage: 2.0,
slashInactivityTargetPercentage: 0.7,
slashInactivityConsecutiveEpochThreshold: 1,
slashInactivityPenalty: DefaultL1ContractsConfig.slashAmountSmall,
slashPrunePenalty: DefaultL1ContractsConfig.slashAmountSmall,
slashDataWithholdingPenalty: DefaultL1ContractsConfig.slashAmountSmall,
Expand Down Expand Up @@ -326,6 +328,7 @@ export async function enrichEnvironmentWithChainConfig(networkName: NetworkNames
enrichVar('SLASH_PRUNE_PENALTY', config.slashPrunePenalty.toString());
enrichVar('SLASH_DATA_WITHHOLDING_PENALTY', config.slashDataWithholdingPenalty.toString());
enrichVar('SLASH_INACTIVITY_TARGET_PERCENTAGE', config.slashInactivityTargetPercentage.toString());
enrichVar('SLASH_INACTIVITY_CONSECUTIVE_EPOCH_THRESHOLD', config.slashInactivityConsecutiveEpochThreshold.toString());
enrichVar('SLASH_INACTIVITY_PENALTY', config.slashInactivityPenalty.toString());
enrichVar('SLASH_PROPOSE_INVALID_ATTESTATIONS_PENALTY', config.slashProposeInvalidAttestationsPenalty.toString());
enrichVar('SLASH_ATTEST_DESCENDANT_OF_INVALID_PENALTY', config.slashAttestDescendantOfInvalidPenalty.toString());
Expand Down
1 change: 1 addition & 0 deletions yarn-project/foundation/src/config/env_var.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@ export type EnvVar =
| 'SLASH_DATA_WITHHOLDING_PENALTY'
| 'SLASH_INACTIVITY_PENALTY'
| 'SLASH_INACTIVITY_TARGET_PERCENTAGE'
| 'SLASH_INACTIVITY_CONSECUTIVE_EPOCH_THRESHOLD'
| 'SLASH_INVALID_BLOCK_PENALTY'
| 'SLASH_OVERRIDE_PAYLOAD'
| 'SLASH_PROPOSE_INVALID_ATTESTATIONS_PENALTY'
Expand Down
13 changes: 13 additions & 0 deletions yarn-project/slasher/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export const DefaultSlasherConfig: SlasherConfig = {
slashPrunePenalty: DefaultL1ContractsConfig.slashAmountSmall,
slashDataWithholdingPenalty: DefaultL1ContractsConfig.slashAmountSmall,
slashInactivityTargetPercentage: 0.9,
slashInactivityConsecutiveEpochThreshold: 1, // Default to 1 for backward compatibility
slashBroadcastedInvalidBlockPenalty: DefaultL1ContractsConfig.slashAmountSmall,
slashInactivityPenalty: DefaultL1ContractsConfig.slashAmountSmall,
slashProposeInvalidAttestationsPenalty: DefaultL1ContractsConfig.slashAmountSmall,
Expand Down Expand Up @@ -95,6 +96,18 @@ export const slasherConfigMappings: ConfigMappingsType<SlasherConfig> = {
}
}),
},
slashInactivityConsecutiveEpochThreshold: {
env: 'SLASH_INACTIVITY_CONSECUTIVE_EPOCH_THRESHOLD',
description: 'Number of consecutive epochs a validator must be inactive before slashing (minimum 1).',
...numberConfigHelper(DefaultSlasherConfig.slashInactivityConsecutiveEpochThreshold),
parseEnv: (val: string) => {
const parsed = parseInt(val, 10);
if (parsed < 1) {
throw new RangeError(`SLASH_INACTIVITY_CONSECUTIVE_EPOCH_THRESHOLD must be at least 1 (got ${parsed})`);
}
return parsed;
},
},
slashInactivityPenalty: {
env: 'SLASH_INACTIVITY_PENALTY',
description: 'Penalty amount for slashing an inactive validator (set to 0 to disable).',
Expand Down
Loading
Loading