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
58 changes: 23 additions & 35 deletions yarn-project/aztec-node/src/aztec-node/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -743,34 +743,30 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb

if (!proverOnly) {
validatorsSentinel = await createSentinel(epochCache, archiver, p2pClient, reexecutionTracker, config);
if (validatorsSentinel && config.slashInactivityPenalty > 0n) {
if (validatorsSentinel) {
watchers.push(validatorsSentinel);
}

if (config.slashDataWithholdingPenalty > 0n) {
dataWithholdingWatcher = new DataWithholdingWatcher(
epochCache,
archiver,
p2pClient.getTxProvider(),
p2pClient,
reexecutionTracker,
{ chainId: config.l1ChainId, rollupAddress: config.rollupAddress },
config,
);
watchers.push(dataWithholdingWatcher);
}
dataWithholdingWatcher = new DataWithholdingWatcher(
epochCache,
archiver,
p2pClient.getTxProvider(),
p2pClient,
reexecutionTracker,
{ chainId: config.l1ChainId, rollupAddress: config.rollupAddress },
config,
);
watchers.push(dataWithholdingWatcher);

if (config.slashBroadcastedInvalidCheckpointProposalPenalty > 0n) {
broadcastedInvalidCheckpointProposalWatcher = new BroadcastedInvalidCheckpointProposalWatcher(
p2pClient,
archiver,
epochCache,
config,
);
watchers.push(broadcastedInvalidCheckpointProposalWatcher);
}
broadcastedInvalidCheckpointProposalWatcher = new BroadcastedInvalidCheckpointProposalWatcher(
p2pClient,
archiver,
epochCache,
config,
);
watchers.push(broadcastedInvalidCheckpointProposalWatcher);

if (validatorClient && config.slashAttestInvalidCheckpointProposalPenalty > 0n) {
if (validatorClient) {
attestedInvalidProposalWatcher = new AttestedInvalidProposalWatcher(
p2pClient,
validatorClient,
Expand All @@ -782,19 +778,11 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb
watchers.push(attestedInvalidProposalWatcher);
}

if (config.slashDuplicateProposalPenalty > 0n) {
checkpointEquivocationWatcher = new CheckpointEquivocationWatcher(archiver, epochCache, config);
watchers.push(checkpointEquivocationWatcher);
}
checkpointEquivocationWatcher = new CheckpointEquivocationWatcher(archiver, epochCache, config);
watchers.push(checkpointEquivocationWatcher);

// We assume we want to slash for invalid attestations unless all max penalties are set to 0
if (
config.slashProposeInvalidAttestationsPenalty > 0n ||
config.slashProposeDescendantOfCheckpointWithInvalidAttestationsPenalty > 0n
) {
attestationsBlockWatcher = new AttestationsBlockWatcher(archiver, epochCache, config, log.getBindings());
watchers.push(attestationsBlockWatcher);
}
attestationsBlockWatcher = new AttestationsBlockWatcher(archiver, epochCache, config, log.getBindings());
watchers.push(attestationsBlockWatcher);
}

const watchersToStart = compactArray([
Expand Down
18 changes: 18 additions & 0 deletions yarn-project/aztec-node/src/sentinel/sentinel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -986,6 +986,24 @@ describe('sentinel', () => {
]);
});

it('emits zero-amount inactivity offenses when the penalty is zero', async () => {
sentinel.updateConfig({ slashInactivityPenalty: 0n, slashInactivityConsecutiveEpochThreshold: 1 });
const emitSpy = jest.spyOn(sentinel, 'emit');

await sentinel.handleEpochPerformance(EpochNumber(5), {
[validator1.toString()]: { missed: 8, total: 10 },
});

expect(emitSpy).toHaveBeenCalledWith(WANT_TO_SLASH_EVENT, [
{
validator: validator1,
amount: 0n,
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 });
Expand Down
8 changes: 2 additions & 6 deletions yarn-project/aztec-node/src/sentinel/sentinel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,10 +335,6 @@ export class Sentinel extends (EventEmitter as new () => WatcherEmitter) impleme
}

protected async handleEpochPerformance(epoch: EpochNumber, performance: ValidatorsEpochPerformance) {
if (this.config.slashInactivityPenalty === 0n) {
return;
}

const inactiveValidators = getEntries(performance)
.filter(([_, { missed, total }]) => total > 0 && missed / total >= this.config.slashInactivityTargetPercentage)
.map(([address]) => address);
Expand All @@ -363,8 +359,8 @@ export class Sentinel extends (EventEmitter as new () => WatcherEmitter) impleme

if (criminals.length > 0) {
this.logger.verbose(
`Identified ${criminals.length} validators to slash due to inactivity in at least ${epochThreshold} consecutive epochs`,
{ ...args, epochThreshold },
`Identified ${criminals.length} inactivity offenses in at least ${epochThreshold} consecutive epochs`,
{ offenses: args, epochThreshold },
);
this.emit(WANT_TO_SLASH_EVENT, args);
}
Expand Down
13 changes: 6 additions & 7 deletions yarn-project/slasher/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export const slasherConfigMappings: ConfigMappingsType<SlasherConfig> = {
},
slashDataWithholdingPenalty: {
env: 'SLASH_DATA_WITHHOLDING_PENALTY',
description: 'Penalty amount for slashing validators for data withholding (set to 0 to disable).',
description: 'Penalty for data withholding (0 records offenses without slash votes).',
...bigintConfigHelper(DefaultSlasherConfig.slashDataWithholdingPenalty),
},
slashDataWithholdingToleranceSlots: {
Expand Down Expand Up @@ -125,29 +125,28 @@ export const slasherConfigMappings: ConfigMappingsType<SlasherConfig> = {
},
slashInactivityPenalty: {
env: 'SLASH_INACTIVITY_PENALTY',
description: 'Penalty amount for slashing an inactive validator (set to 0 to disable).',
description: 'Penalty for an inactive validator (0 records offenses without slash votes).',
...bigintConfigHelper(DefaultSlasherConfig.slashInactivityPenalty),
},
slashProposeInvalidAttestationsPenalty: {
env: 'SLASH_PROPOSE_INVALID_ATTESTATIONS_PENALTY',
description: 'Penalty amount for slashing a proposer that proposed invalid attestations (set to 0 to disable).',
description: 'Penalty for proposing invalid attestations (0 records offenses without slash votes).',
...bigintConfigHelper(DefaultSlasherConfig.slashProposeInvalidAttestationsPenalty),
},
slashProposeDescendantOfCheckpointWithInvalidAttestationsPenalty: {
env: 'SLASH_PROPOSE_DESCENDANT_OF_CHECKPOINT_WITH_INVALID_ATTESTATIONS_PENALTY',
description:
'Penalty amount for slashing a proposer that published a checkpoint building on an invalid checkpoint (set to 0 to disable).',
'Penalty for publishing a checkpoint building on an invalid checkpoint (0 records offenses without slash votes).',
...bigintConfigHelper(DefaultSlasherConfig.slashProposeDescendantOfCheckpointWithInvalidAttestationsPenalty),
},
slashAttestInvalidCheckpointProposalPenalty: {
env: 'SLASH_ATTEST_INVALID_CHECKPOINT_PROPOSAL_PENALTY',
description:
'Penalty amount for slashing a validator that attested to an invalid checkpoint proposal (set to 0 to disable).',
description: 'Penalty for attesting to an invalid checkpoint proposal (0 records offenses without slash votes).',
...bigintConfigHelper(DefaultSlasherConfig.slashAttestInvalidCheckpointProposalPenalty),
},
slashUnknownPenalty: {
env: 'SLASH_UNKNOWN_PENALTY',
description: 'Penalty amount for slashing a validator for an unknown offense (set to 0 to disable).',
description: 'Penalty for an unknown offense (0 records offenses without slash votes).',
...bigintConfigHelper(DefaultSlasherConfig.slashUnknownPenalty),
},
slashOffenseExpirationRounds: {
Expand Down
18 changes: 18 additions & 0 deletions yarn-project/slasher/src/slasher_client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,24 @@ describe('SlasherClient', () => {
const actions = await slasherClient.getProposerActions(SlotNumber.fromBigInt(currentSlot));
expect(actions).toHaveLength(0);
});

it('should not return any action for zero-amount offenses', async () => {
const currentRound = 5n;
const currentSlot = currentRound * BigInt(roundSize);
const targetRound = 3n;

await offensesStore.addOffense(
createOffense({
validator: committee[0],
epochOrSlot: targetRound * BigInt(roundSize),
offenseType: OffenseType.PROPOSED_INSUFFICIENT_ATTESTATIONS,
amount: 0n,
}),
);

const actions = await slasherClient.getProposerActions(SlotNumber.fromBigInt(currentSlot));
expect(actions).toHaveLength(0);
});
});

describe('execute-slash', () => {
Expand Down
10 changes: 9 additions & 1 deletion yarn-project/slasher/src/slasher_client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -343,7 +343,7 @@ export class SlasherClient implements ProposerSlashActionProvider, SlasherClient
return undefined;
}

this.log.info(`Voting to slash ${offensesToSlash.length} offenses`, {
this.log.debug(`Computing slash votes for ${offensesToSlash.length} offenses`, {
slotNumber,
currentRound,
slashedRound,
Expand Down Expand Up @@ -371,6 +371,14 @@ export class SlasherClient implements ProposerSlashActionProvider, SlasherClient
return undefined;
}

this.log.info(`Voting to slash ${offensesToSlash.length} offenses`, {
slotNumber,
slashedRound,
currentRound,
votes,
offensesToSlash,
});

this.log.debug(`Computed votes for slashing ${offensesToSlash.length} offenses`, {
slashedRound,
currentRound,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,51 @@ describe('AttestedInvalidProposalWatcher', () => {
]);
});

it('emits zero-amount offenses when the penalty is zero', async () => {
const slot = SlotNumber(10);
const attesterSigner = Secp256k1Signer.random();
invalidProposalSlots.add(slot);
watcher.updateConfig({ slashAttestInvalidCheckpointProposalPenalty: 0n });
p2pClient.getCheckpointAttestationsForSlot.mockResolvedValue([await makeAttestation(slot, attesterSigner)]);

await watcher.scanSlot(slot);

expect(handler).toHaveBeenCalledWith([
{
validator: attesterSigner.address,
amount: 0n,
offenseType: OffenseType.ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL,
epochOrSlot: 10n,
},
]);
});

it('deduplicates repeated scans for the same attester and slot', async () => {
const slot = SlotNumber(10);
invalidProposalSlots.add(slot);
const attestation = await makeAttestation(slot);
p2pClient.getCheckpointAttestationsForSlot.mockResolvedValue([attestation]);

await watcher.scanSlot(slot);
await watcher.scanSlot(slot);

expect(handler).toHaveBeenCalledTimes(1);
});

it('deduplicates repeated scans for the same offense when the penalty changes', async () => {
const slot = SlotNumber(10);
invalidProposalSlots.add(slot);
const attestation = await makeAttestation(slot);
p2pClient.getCheckpointAttestationsForSlot.mockResolvedValue([attestation]);

watcher.updateConfig({ slashAttestInvalidCheckpointProposalPenalty: 0n });
await watcher.scanSlot(slot);
watcher.updateConfig({ slashAttestInvalidCheckpointProposalPenalty: 13n });
await watcher.scanSlot(slot);

expect(handler).toHaveBeenCalledTimes(1);
});

it('scans only marked invalid proposal slots once they are past the scan lag', async () => {
watcher = new AttestedInvalidProposalWatcher(
p2pClient,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { EpochCacheInterface } from '@aztec/epoch-cache';
import { SlotNumber } from '@aztec/foundation/branded-types';
import { merge, pick } from '@aztec/foundation/collection';
import type { EthAddress } from '@aztec/foundation/eth-address';
import { FifoSet } from '@aztec/foundation/fifo-set';
import { type Logger, createLogger } from '@aztec/foundation/log';
import { RunningPromise } from '@aztec/foundation/running-promise';
import type { L2BlockSource } from '@aztec/stdlib/block';
Expand All @@ -17,6 +18,7 @@ const AttestedInvalidProposalWatcherConfigKeys = ['slashAttestInvalidCheckpointP

const SCAN_SLOT_LAG = 1;
const DEFAULT_SCAN_SLOT_LOOKBACK = 4;
const MAX_TRACKED_BAD_ATTESTATIONS = 10_000;

type AttestedInvalidProposalWatcherConfig = Pick<
SlasherConfig,
Expand All @@ -38,6 +40,7 @@ export type InvalidProposalSlotSource = {
export class AttestedInvalidProposalWatcher extends (EventEmitter as new () => WatcherEmitter) implements Watcher {
private readonly log: Logger;
private readonly runningPromise: RunningPromise;
private readonly emittedOffenses = FifoSet.withLimit<string>(MAX_TRACKED_BAD_ATTESTATIONS);
private readonly scanSlotLookback: number;
private config: AttestedInvalidProposalWatcherConfig;
private lastScannedSlot: SlotNumber | undefined;
Expand Down Expand Up @@ -76,10 +79,6 @@ export class AttestedInvalidProposalWatcher extends (EventEmitter as new () => W
}

public async scan(): Promise<void> {
if (this.config.slashAttestInvalidCheckpointProposalPenalty <= 0n) {
return;
}

const currentSlot = (await this.l2BlockSource.getSyncedL2SlotNumber()) ?? this.epochCache.getSlotNow();
// genesis
if (currentSlot <= SlotNumber(SCAN_SLOT_LAG)) {
Expand All @@ -105,7 +104,6 @@ export class AttestedInvalidProposalWatcher extends (EventEmitter as new () => W
/** Scans a single invalid-proposal slot. */
public async scanSlot(slot: SlotNumber): Promise<void> {
if (
this.config.slashAttestInvalidCheckpointProposalPenalty <= 0n ||
this.invalidProposalSlotSource.hasProposalEquivocation(slot) ||
!this.invalidProposalSlotSource.hasInvalidProposals(slot)
) {
Expand All @@ -122,15 +120,21 @@ export class AttestedInvalidProposalWatcher extends (EventEmitter as new () => W

const slashArgs = attestations
.map(attestation => this.getSlashArgs(slot, attestation))
.filter((args): args is WantToSlashArgs => args !== undefined);
.filter((args): args is WantToSlashArgs => args !== undefined)
.filter(args => this.markAsNewOffense(args));

if (slashArgs.length === 0) {
return;
}

this.log.warn('Slashing attesters for attesting to invalid checkpoint proposal', {
this.log.warn('Detected attestations to invalid checkpoint proposal', {
slot,
attesters: slashArgs.map(args => args.validator.toString()),
offenses: slashArgs.map(args => ({
validator: args.validator.toString(),
amount: args.amount,
offenseType: args.offenseType,
epochOrSlot: args.epochOrSlot,
})),
});
this.emit(WANT_TO_SLASH_EVENT, slashArgs);
}
Expand All @@ -156,4 +160,9 @@ export class AttestedInvalidProposalWatcher extends (EventEmitter as new () => W
epochOrSlot: BigInt(slot),
};
}

private markAsNewOffense(args: WantToSlashArgs): boolean {
const key = `${args.validator.toString()}-${args.offenseType}-${args.epochOrSlot}`;
return this.emittedOffenses.addIfAbsent(key);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,26 @@ describe('BroadcastedInvalidCheckpointProposalWatcher', () => {
expect(handler).not.toHaveBeenCalled();
});

it('emits zero-amount offenses when the penalty is zero', async () => {
const signer = Secp256k1Signer.random();
const slot = SlotNumber(10);
const blocks = await makeBlocks(signer, slot, 4);
const checkpoint = await makeCheckpointCore(signer, slot, blocks[1]);
watcher.updateConfig({ slashBroadcastedInvalidCheckpointProposalPenalty: 0n });
mockProposals(slot, blocks, [checkpoint]);

await watcher.scanSlot(slot);

expect(handler).toHaveBeenCalledWith([
{
validator: signer.address,
amount: 0n,
offenseType: OffenseType.BROADCASTED_INVALID_CHECKPOINT_PROPOSAL,
epochOrSlot: 10n,
},
]);
});

it('does not emit duplicate offenses on repeated scans', async () => {
const signer = Secp256k1Signer.random();
const slot = SlotNumber(10);
Expand All @@ -208,6 +228,21 @@ describe('BroadcastedInvalidCheckpointProposalWatcher', () => {
expect(handler).toHaveBeenCalledTimes(1);
});

it('deduplicates repeated scans for the same offense when the penalty changes', async () => {
const signer = Secp256k1Signer.random();
const slot = SlotNumber(10);
const blocks = await makeBlocks(signer, slot, 4);
const checkpoint = await makeCheckpointCore(signer, slot, blocks[1]);
mockProposals(slot, blocks, [checkpoint]);

watcher.updateConfig({ slashBroadcastedInvalidCheckpointProposalPenalty: 0n });
await watcher.scanSlot(slot);
watcher.updateConfig({ slashBroadcastedInvalidCheckpointProposalPenalty: 11n });
await watcher.scanSlot(slot);

expect(handler).toHaveBeenCalledTimes(1);
});

it('scans a lookback of closed slots', async () => {
const signer = Secp256k1Signer.random();
const slot = SlotNumber(10);
Expand Down
Loading
Loading