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
2 changes: 1 addition & 1 deletion yarn-project/slasher/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ These settings are configured locally on each validator node:
- `slashProposeInvalidAttestationsPenalty`: Penalty for PROPOSED_INSUFFICIENT_ATTESTATIONS and PROPOSED_INCORRECT_ATTESTATIONS
- `slashAttestDescendantOfInvalidPenalty`: Penalty for ATTESTED_DESCENDANT_OF_INVALID
- `slashUnknownPenalty`: Default penalty for unknown offense types
- `slashMaxPayloadSize`: Maximum size of slash payloads (empire model)
- `slashMaxPayloadSize`: Maximum size of slash payloads. In the empire model this limits offenses per payload. In the tally model it limits the number of **unique validators** (across all committees and epochs in a round) that receive non-zero votes. When this cap is hit, the lowest-severity validator-epoch pairs are zeroed out first, so the most severe slashes are always preserved. Note that multiple offenses for the same validator in the same epoch are summed and counted as a single validator entry against this limit.
- `slashMinPenaltyPercentage`: Agree to slashes if they are at least this percentage of the configured penalty (empire model)
- `slashMaxPenaltyPercentage`: Agree to slashes if they are at most this percentage of the configured penalty (empire model)

Expand Down
9 changes: 7 additions & 2 deletions yarn-project/slasher/src/tally_slasher_client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,10 @@ export type TallySlasherSettings = Prettify<
>;

export type TallySlasherClientConfig = SlashOffensesCollectorConfig &
Pick<SlasherConfig, 'slashValidatorsAlways' | 'slashValidatorsNever' | 'slashExecuteRoundsLookBack'>;
Pick<
SlasherConfig,
'slashValidatorsAlways' | 'slashValidatorsNever' | 'slashExecuteRoundsLookBack' | 'slashMaxPayloadSize'
>;

/**
* The Tally Slasher client is responsible for managing slashable offenses using
Expand Down Expand Up @@ -358,11 +361,13 @@ export class TallySlasherClient implements ProposerSlashActionProvider, SlasherC

const committees = await this.collectCommitteesActiveDuringRound(slashedRound);
const epochsForCommittees = getEpochsForRound(slashedRound, this.settings);
const { slashMaxPayloadSize } = this.config;
const votes = getSlashConsensusVotesFromOffenses(
offensesToSlash,
committees,
epochsForCommittees.map(e => BigInt(e)),
this.settings,
{ ...this.settings, maxSlashedValidators: slashMaxPayloadSize },
this.log,
);
if (votes.every(v => v === 0)) {
this.log.warn(`Computed votes for offenses are all zero. Skipping vote.`, {
Expand Down
86 changes: 86 additions & 0 deletions yarn-project/stdlib/src/slashing/tally.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,92 @@ describe('TallySlashingHelpers', () => {
expect(votes.slice(4, 8)).toEqual([0, 0, 0, 0]); // Padded empty committee
});

it('truncates to maxSlashedValidators unique (validator, epoch) pairs', () => {
const offenses: Offense[] = [
{ validator: mockValidator1, amount: 30n, offenseType: OffenseType.INACTIVITY, epochOrSlot: 5n },
{ validator: mockValidator2, amount: 20n, offenseType: OffenseType.INACTIVITY, epochOrSlot: 5n },
{ validator: mockValidator3, amount: 10n, offenseType: OffenseType.INACTIVITY, epochOrSlot: 5n },
];

const committees = [[mockValidator1, mockValidator2, mockValidator3, mockValidator4]];
const epochsForCommittees = [5n];
// Only 2 slashed validators allowed; validator3 should be zeroed out
const votes = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, {
...settings,
maxSlashedValidators: 2,
});

expect(votes).toHaveLength(4);
expect(votes[0]).toEqual(3); // validator1: included (1st)
expect(votes[1]).toEqual(2); // validator2: included (2nd)
expect(votes[2]).toEqual(0); // validator3: zeroed out (limit reached)
expect(votes[3]).toEqual(0); // validator4: no offenses
});

it('counts the same validator in multiple epochs as separate slashed pairs', () => {
// An always-slash validator appears once per epoch committee, each generating a slash() call
const offenses = [
{
validator: mockValidator1,
amount: 30n,
offenseType: OffenseType.UNKNOWN,
epochOrSlot: undefined, // always-slash
},
{ validator: mockValidator2, amount: 20n, offenseType: OffenseType.INACTIVITY, epochOrSlot: 5n },
{ validator: mockValidator3, amount: 10n, offenseType: OffenseType.INACTIVITY, epochOrSlot: 6n },
];

const committees = [
[mockValidator1, mockValidator2],
[mockValidator1, mockValidator3],
];
const epochsForCommittees = [5n, 6n];
// Limit of 3: validator1@epoch5, validator2@epoch5, validator1@epoch6 are included;
// validator3@epoch6 is zeroed out
const votes = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, {
...settings,
maxSlashedValidators: 3,
});

expect(votes).toHaveLength(8); // 2 committees × 4 targetCommitteeSize
expect(votes[0]).toEqual(3); // validator1 @ epoch5: included (1st)
expect(votes[1]).toEqual(2); // validator2 @ epoch5: included (2nd)
expect(votes[2]).toEqual(0); // padded
expect(votes[3]).toEqual(0); // padded
expect(votes[4]).toEqual(3); // validator1 @ epoch6: included (3rd)
expect(votes[5]).toEqual(0); // validator3 @ epoch6: zeroed out (limit reached)
expect(votes[6]).toEqual(0); // padded
expect(votes[7]).toEqual(0); // padded
});

it('truncates based on validator count, not offense count', () => {
// 3 offenses for validator1, 2 for validator2, 1 for validator3 — but only 2 validators allowed.
// Truncation must cut one validator (not one offense record).
const offenses: Offense[] = [
{ validator: mockValidator1, amount: 15n, offenseType: OffenseType.INACTIVITY, epochOrSlot: 5n },
{ validator: mockValidator1, amount: 8n, offenseType: OffenseType.DATA_WITHHOLDING, epochOrSlot: 5n },
{ validator: mockValidator1, amount: 5n, offenseType: OffenseType.VALID_EPOCH_PRUNED, epochOrSlot: 5n },
{ validator: mockValidator2, amount: 20n, offenseType: OffenseType.INACTIVITY, epochOrSlot: 5n },
{ validator: mockValidator2, amount: 5n, offenseType: OffenseType.DATA_WITHHOLDING, epochOrSlot: 5n },
{ validator: mockValidator3, amount: 10n, offenseType: OffenseType.INACTIVITY, epochOrSlot: 5n },
];

const committees = [[mockValidator1, mockValidator2, mockValidator3, mockValidator4]];
const epochsForCommittees = [5n];
// validator1: 15n+8n+5n=28n → 2 units, validator2: 20n+5n=25n → 2 units, validator3: 10n → 1 unit
// Limit of 2 validators: validator3 (lowest vote) is zeroed out
const votes = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, {
...settings,
maxSlashedValidators: 2,
});

expect(votes).toHaveLength(4);
expect(votes[0]).toEqual(2); // validator1: 28n → 2 units, included
expect(votes[1]).toEqual(2); // validator2: 25n → 2 units, included
expect(votes[2]).toEqual(0); // validator3: 10n → 1 unit, zeroed out (only 2 validators allowed)
expect(votes[3]).toEqual(0); // validator4: no offenses
});

it('handles multiple consecutive empty committees', () => {
const offenses: Offense[] = [
{
Expand Down
35 changes: 34 additions & 1 deletion yarn-project/stdlib/src/slashing/tally.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { sumBigint } from '@aztec/foundation/bigint';
import { padArrayEnd } from '@aztec/foundation/collection';
import { EthAddress } from '@aztec/foundation/eth-address';
import { type Logger, createLogger } from '@aztec/foundation/log';
import type { PartialBy } from '@aztec/foundation/types';

import { getEpochForOffense } from './helpers.js';
Expand All @@ -12,6 +13,9 @@ import type { Offense, ValidatorSlashVote } from './types.js';
* @param committees - Array of committees (each containing array of validator addresses)
* @param epochsForCommittees - Array of epochs corresponding to each committee
* @param settings - Settings including slashingAmounts and optional validator override lists
* @param settings.maxSlashedValidators - If set, limits the total number of [validator, epoch] pairs
* with non-zero votes. The lowest-vote pairs are zeroed out to stay within the limit.
* @param logger - Logger, logs which validators were dropped.
* @returns Array of ValidatorSlashVote, where each vote is how many slash units the validator in that position should be slashed
*/
export function getSlashConsensusVotesFromOffenses(
Expand All @@ -22,9 +26,11 @@ export function getSlashConsensusVotesFromOffenses(
slashingAmounts: [bigint, bigint, bigint];
epochDuration: number;
targetCommitteeSize: number;
maxSlashedValidators?: number;
},
logger: Logger = createLogger('slasher:tally'),
): ValidatorSlashVote[] {
const { slashingAmounts, targetCommitteeSize } = settings;
const { slashingAmounts, targetCommitteeSize, maxSlashedValidators } = settings;

if (committees.length !== epochsForCommittees.length) {
throw new Error('committees and epochsForCommittees must have the same length');
Expand Down Expand Up @@ -53,6 +59,33 @@ export function getSlashConsensusVotesFromOffenses(
return padArrayEnd(votes, 0, targetCommitteeSize);
});

// if a cap is set, zero out the lowest-vote [validator, epoch] pairs so that the most severe slashes stay.
if (maxSlashedValidators === undefined) {
return votes;
}

const nonZeroByDescendingVote = [...votes.entries()].filter(([, vote]) => vote > 0).sort(([, a], [, b]) => b - a);

const toTruncate = nonZeroByDescendingVote.slice(maxSlashedValidators);
for (const [idx] of toTruncate) {
votes[idx] = 0;
}

if (toTruncate.length > 0) {
const truncated = toTruncate.map(([idx]) => {
const committeeIndex = Math.floor(idx / targetCommitteeSize);
const positionInCommittee = idx % targetCommitteeSize;
return {
validator: committees[committeeIndex][positionInCommittee].toString(),
epoch: epochsForCommittees[committeeIndex],
};
});
logger.warn(
`Truncated ${toTruncate.length} validator-epoch pairs to stay within limit of ${maxSlashedValidators}`,
{ truncated },
);
}

return votes;
}

Expand Down
Loading