diff --git a/yarn-project/slasher/README.md b/yarn-project/slasher/README.md index dbd4454cf1eb..adb32be23305 100644 --- a/yarn-project/slasher/README.md +++ b/yarn-project/slasher/README.md @@ -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) diff --git a/yarn-project/slasher/src/tally_slasher_client.ts b/yarn-project/slasher/src/tally_slasher_client.ts index d95d565746a8..862525addf63 100644 --- a/yarn-project/slasher/src/tally_slasher_client.ts +++ b/yarn-project/slasher/src/tally_slasher_client.ts @@ -46,7 +46,10 @@ export type TallySlasherSettings = Prettify< >; export type TallySlasherClientConfig = SlashOffensesCollectorConfig & - Pick; + Pick< + SlasherConfig, + 'slashValidatorsAlways' | 'slashValidatorsNever' | 'slashExecuteRoundsLookBack' | 'slashMaxPayloadSize' + >; /** * The Tally Slasher client is responsible for managing slashable offenses using @@ -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.`, { diff --git a/yarn-project/stdlib/src/slashing/tally.test.ts b/yarn-project/stdlib/src/slashing/tally.test.ts index d79a9dcb280e..b0682890d6a5 100644 --- a/yarn-project/stdlib/src/slashing/tally.test.ts +++ b/yarn-project/stdlib/src/slashing/tally.test.ts @@ -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[] = [ { diff --git a/yarn-project/stdlib/src/slashing/tally.ts b/yarn-project/stdlib/src/slashing/tally.ts index 91bed7e4ff35..c22e76fe992c 100644 --- a/yarn-project/stdlib/src/slashing/tally.ts +++ b/yarn-project/stdlib/src/slashing/tally.ts @@ -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'; @@ -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( @@ -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'); @@ -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; }