From 2a3210dc15f08da9af9edb3150512984aaa1ceb9 Mon Sep 17 00:00:00 2001 From: spypsy <6403450+spypsy@users.noreply.github.com> Date: Fri, 20 Feb 2026 13:12:46 +0000 Subject: [PATCH 1/2] fix: limit offenses when voting in tally slashing mode by slashMaxPayloadSize Fixes [A-507](https://linear.app/aztec-labs/issue/A-507/tally-slasher-execution-can-run-out-of-gas) --- spartan/environments/network-defaults.yml | 2 +- yarn-project/slasher/README.md | 2 +- .../slasher/src/tally_slasher_client.test.ts | 100 ++++++++++++++++++ .../slasher/src/tally_slasher_client.ts | 19 +++- 4 files changed, 118 insertions(+), 5 deletions(-) diff --git a/spartan/environments/network-defaults.yml b/spartan/environments/network-defaults.yml index 9291bc82795c..a270e8a7612f 100644 --- a/spartan/environments/network-defaults.yml +++ b/spartan/environments/network-defaults.yml @@ -120,7 +120,7 @@ slasher: &slasher # Rounds after which an offense expires. SLASH_OFFENSE_EXPIRATION_ROUNDS: 4 # Maximum size of slashing payload. - SLASH_MAX_PAYLOAD_SIZE: 50 + SLASH_MAX_PAYLOAD_SIZE: 80 # Rounds to look back when executing slashes. SLASH_EXECUTE_ROUNDS_LOOK_BACK: 4 # Penalty for slashing validators of a valid pruned epoch. diff --git a/yarn-project/slasher/README.md b/yarn-project/slasher/README.md index dbd4454cf1eb..b5270720fa9a 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 offenses considered when building the vote for a round (same prioritization: uncontroversial first, then by amount and age), so that execution payload stays within gas limits. - `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.test.ts b/yarn-project/slasher/src/tally_slasher_client.test.ts index 9ca14eaa7a44..95bab349286a 100644 --- a/yarn-project/slasher/src/tally_slasher_client.test.ts +++ b/yarn-project/slasher/src/tally_slasher_client.test.ts @@ -519,6 +519,106 @@ describe('TallySlasherClient', () => { expect(actions).toHaveLength(1); expect(actions[0].type).toBe('vote-offenses'); }); + + it('should truncate to slashMaxPayloadSize when offenses exceed cap', async () => { + const currentRound = 5n; + const targetRound = 3n; // currentRound - offset(2) + const baseSlot = targetRound * BigInt(roundSize); + + // Set cap to 2 so we keep only the top 2 offenses by priority (uncontroversial first, then amount desc) + tallySlasherClient.updateConfig({ slashMaxPayloadSize: 2 }); + + // Add 3 offenses for target round: different amounts so sort order is clear (high amount first) + await addPendingOffense({ + validator: committee[0], + epochOrSlot: baseSlot, + amount: settings.slashingAmounts[0], // 1 unit - lowest priority + offenseType: OffenseType.PROPOSED_INSUFFICIENT_ATTESTATIONS, + }); + await addPendingOffense({ + validator: committee[1], + epochOrSlot: baseSlot, + amount: settings.slashingAmounts[2], // 3 units - highest priority + offenseType: OffenseType.PROPOSED_INSUFFICIENT_ATTESTATIONS, + }); + await addPendingOffense({ + validator: committee[2], + epochOrSlot: baseSlot, + amount: settings.slashingAmounts[1], // 2 units - middle + offenseType: OffenseType.PROPOSED_INSUFFICIENT_ATTESTATIONS, + }); + + const offenses = await tallySlasherClient.gatherOffensesForRound(currentRound); + + expect(offenses).toHaveLength(2); + // First should be committee[1] (3 units), second committee[2] (2 units); committee[0] (1 unit) truncated + expect(offenses[0].validator.equals(committee[1])).toBe(true); + expect(offenses[0].amount).toEqual(settings.slashingAmounts[2]); + expect(offenses[1].validator.equals(committee[2])).toBe(true); + expect(offenses[1].amount).toEqual(settings.slashingAmounts[1]); + }); + + it('should not truncate when offenses are within cap', async () => { + const currentRound = 5n; + const targetRound = 3n; + const baseSlot = targetRound * BigInt(roundSize); + + tallySlasherClient.updateConfig({ slashMaxPayloadSize: 10 }); + + await addPendingOffense({ + validator: committee[0], + epochOrSlot: baseSlot, + amount: slashingUnit, + offenseType: OffenseType.PROPOSED_INSUFFICIENT_ATTESTATIONS, + }); + await addPendingOffense({ + validator: committee[1], + epochOrSlot: baseSlot, + amount: slashingUnit * 2n, + offenseType: OffenseType.PROPOSED_INSUFFICIENT_ATTESTATIONS, + }); + + const offenses = await tallySlasherClient.gatherOffensesForRound(currentRound); + expect(offenses).toHaveLength(2); + }); + + it('should produce a valid vote action with truncated offenses', async () => { + const currentRound = 5n; + const targetRound = 3n; + const baseSlot = targetRound * BigInt(roundSize); + + tallySlasherClient.updateConfig({ slashMaxPayloadSize: 1 }); + + // Add 3 offenses, only the highest-amount one should survive truncation + await addPendingOffense({ + validator: committee[0], + epochOrSlot: baseSlot, + amount: settings.slashingAmounts[0], + offenseType: OffenseType.PROPOSED_INSUFFICIENT_ATTESTATIONS, + }); + await addPendingOffense({ + validator: committee[1], + epochOrSlot: baseSlot, + amount: settings.slashingAmounts[2], + offenseType: OffenseType.PROPOSED_INSUFFICIENT_ATTESTATIONS, + }); + await addPendingOffense({ + validator: committee[2], + epochOrSlot: baseSlot, + amount: settings.slashingAmounts[1], + offenseType: OffenseType.PROPOSED_INSUFFICIENT_ATTESTATIONS, + }); + + const currentSlot = currentRound * BigInt(roundSize); + const action = await tallySlasherClient.getVoteOffensesAction(SlotNumber.fromBigInt(currentSlot)); + + expect(action).toBeDefined(); + assert(action!.type === 'vote-offenses'); + // Only committee[1] (3 units) should have a non-zero vote + expect(action!.votes[0]).toBe(0); // committee[0] truncated + expect(action!.votes[1]).toBe(3); // committee[1] kept (highest amount) + expect(action!.votes[2]).toBe(0); // committee[2] truncated + }); }); describe('getSlashPayloads', () => { diff --git a/yarn-project/slasher/src/tally_slasher_client.ts b/yarn-project/slasher/src/tally_slasher_client.ts index 70ef6fdfeeb6..c5533cc6858f 100644 --- a/yarn-project/slasher/src/tally_slasher_client.ts +++ b/yarn-project/slasher/src/tally_slasher_client.ts @@ -16,6 +16,7 @@ import { type SlashPayloadRound, getEpochsForRound, getSlashConsensusVotesFromOffenses, + offenseDataComparator, } from '@aztec/stdlib/slashing'; import type { Hex } from 'viem'; @@ -46,7 +47,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 @@ -415,8 +419,10 @@ export class TallySlasherClient implements ProposerSlashActionProvider, SlasherC /** * Gather offenses to be slashed on a given round. * In tally slashing, round N slashes validators from round N - slashOffsetInRounds. + * Offenses are sorted by priority (uncontroversial first, then amount, then age) and truncated to + * slashMaxPayloadSize so that execution payload stays within gas limits. * @param round - The round to get offenses for, defaults to current round - * @returns Array of pending offenses for the round with offset applied + * @returns Array of pending offenses for the round with offset applied, truncated to max payload size */ public async gatherOffensesForRound(round?: bigint): Promise { const targetRound = this.getSlashedRound(round); @@ -424,7 +430,14 @@ export class TallySlasherClient implements ProposerSlashActionProvider, SlasherC return []; } - return await this.offensesStore.getOffensesForRound(targetRound); + const raw = await this.offensesStore.getOffensesForRound(targetRound); + const sorted = [...raw].sort(offenseDataComparator); + const { slashMaxPayloadSize } = this.config; + const selected = sorted.slice(0, slashMaxPayloadSize); + if (selected.length !== sorted.length) { + this.log.warn(`Offense list of ${sorted.length} truncated to max size of ${slashMaxPayloadSize}`); + } + return selected; } /** Returns all pending offenses stored */ From b559bb2c10e0341132b7ec77dd6a05de4570e811 Mon Sep 17 00:00:00 2001 From: spypsy Date: Fri, 27 Feb 2026 12:31:12 +0000 Subject: [PATCH 2/2] truncate offenders, not offenses --- .../slasher/src/tally_slasher_client.test.ts | 35 +++---- .../slasher/src/tally_slasher_client.ts | 25 +++-- .../stdlib/src/slashing/tally.test.ts | 94 +++++++++++++++---- yarn-project/stdlib/src/slashing/tally.ts | 21 ++++- 4 files changed, 120 insertions(+), 55 deletions(-) diff --git a/yarn-project/slasher/src/tally_slasher_client.test.ts b/yarn-project/slasher/src/tally_slasher_client.test.ts index 95bab349286a..24e5d0a38f9e 100644 --- a/yarn-project/slasher/src/tally_slasher_client.test.ts +++ b/yarn-project/slasher/src/tally_slasher_client.test.ts @@ -520,51 +520,44 @@ describe('TallySlasherClient', () => { expect(actions[0].type).toBe('vote-offenses'); }); - it('should truncate to slashMaxPayloadSize when offenses exceed cap', async () => { + it('should return all offenses for the round regardless of slashMaxPayloadSize', async () => { const currentRound = 5n; const targetRound = 3n; // currentRound - offset(2) const baseSlot = targetRound * BigInt(roundSize); - // Set cap to 2 so we keep only the top 2 offenses by priority (uncontroversial first, then amount desc) + // slashMaxPayloadSize has no effect on gatherOffensesForRound; truncation happens later + // in getSlashConsensusVotesFromOffenses after always-slash offenses are merged in. tallySlasherClient.updateConfig({ slashMaxPayloadSize: 2 }); - // Add 3 offenses for target round: different amounts so sort order is clear (high amount first) await addPendingOffense({ validator: committee[0], epochOrSlot: baseSlot, - amount: settings.slashingAmounts[0], // 1 unit - lowest priority + amount: settings.slashingAmounts[0], offenseType: OffenseType.PROPOSED_INSUFFICIENT_ATTESTATIONS, }); await addPendingOffense({ validator: committee[1], epochOrSlot: baseSlot, - amount: settings.slashingAmounts[2], // 3 units - highest priority + amount: settings.slashingAmounts[2], offenseType: OffenseType.PROPOSED_INSUFFICIENT_ATTESTATIONS, }); await addPendingOffense({ validator: committee[2], epochOrSlot: baseSlot, - amount: settings.slashingAmounts[1], // 2 units - middle + amount: settings.slashingAmounts[1], offenseType: OffenseType.PROPOSED_INSUFFICIENT_ATTESTATIONS, }); const offenses = await tallySlasherClient.gatherOffensesForRound(currentRound); - expect(offenses).toHaveLength(2); - // First should be committee[1] (3 units), second committee[2] (2 units); committee[0] (1 unit) truncated - expect(offenses[0].validator.equals(committee[1])).toBe(true); - expect(offenses[0].amount).toEqual(settings.slashingAmounts[2]); - expect(offenses[1].validator.equals(committee[2])).toBe(true); - expect(offenses[1].amount).toEqual(settings.slashingAmounts[1]); + expect(offenses).toHaveLength(3); }); - it('should not truncate when offenses are within cap', async () => { + it('should return all offenses for the round', async () => { const currentRound = 5n; const targetRound = 3n; const baseSlot = targetRound * BigInt(roundSize); - tallySlasherClient.updateConfig({ slashMaxPayloadSize: 10 }); - await addPendingOffense({ validator: committee[0], epochOrSlot: baseSlot, @@ -582,14 +575,14 @@ describe('TallySlasherClient', () => { expect(offenses).toHaveLength(2); }); - it('should produce a valid vote action with truncated offenses', async () => { + it('should produce a valid vote action respecting slashMaxPayloadSize', async () => { const currentRound = 5n; const targetRound = 3n; const baseSlot = targetRound * BigInt(roundSize); + // Cap at 1 slashed validator-epoch pair; the highest-amount validator should survive tallySlasherClient.updateConfig({ slashMaxPayloadSize: 1 }); - // Add 3 offenses, only the highest-amount one should survive truncation await addPendingOffense({ validator: committee[0], epochOrSlot: baseSlot, @@ -614,10 +607,10 @@ describe('TallySlasherClient', () => { expect(action).toBeDefined(); assert(action!.type === 'vote-offenses'); - // Only committee[1] (3 units) should have a non-zero vote - expect(action!.votes[0]).toBe(0); // committee[0] truncated - expect(action!.votes[1]).toBe(3); // committee[1] kept (highest amount) - expect(action!.votes[2]).toBe(0); // committee[2] truncated + // Only committee[1] (3 units, highest) survives; the others are zeroed out + expect(action!.votes[0]).toBe(0); // committee[0]: 1 unit, dropped + expect(action!.votes[1]).toBe(3); // committee[1]: 3 units, kept + expect(action!.votes[2]).toBe(0); // committee[2]: 2 units, dropped }); }); diff --git a/yarn-project/slasher/src/tally_slasher_client.ts b/yarn-project/slasher/src/tally_slasher_client.ts index c5533cc6858f..d3dd4321fe53 100644 --- a/yarn-project/slasher/src/tally_slasher_client.ts +++ b/yarn-project/slasher/src/tally_slasher_client.ts @@ -16,7 +16,6 @@ import { type SlashPayloadRound, getEpochsForRound, getSlashConsensusVotesFromOffenses, - offenseDataComparator, } from '@aztec/stdlib/slashing'; import type { Hex } from 'viem'; @@ -366,12 +365,19 @@ export class TallySlasherClient implements ProposerSlashActionProvider, SlasherC const committees = await this.collectCommitteesActiveDuringRound(slashedRound); const epochsForCommittees = getEpochsForRound(slashedRound, this.settings); - const votes = getSlashConsensusVotesFromOffenses( + const { slashMaxPayloadSize } = this.config; + const { votes, truncatedCount } = getSlashConsensusVotesFromOffenses( offensesToSlash, committees, epochsForCommittees.map(e => BigInt(e)), - this.settings, + { ...this.settings, maxSlashedValidators: slashMaxPayloadSize }, ); + if (truncatedCount > 0) { + this.log.warn( + `Vote truncated: ${truncatedCount} validator-epoch pairs dropped to stay within gas limit of ${slashMaxPayloadSize}`, + { slotNumber, currentRound, slashedRound }, + ); + } if (votes.every(v => v === 0)) { this.log.warn(`Computed votes for offenses are all zero. Skipping vote.`, { slotNumber, @@ -419,10 +425,8 @@ export class TallySlasherClient implements ProposerSlashActionProvider, SlasherC /** * Gather offenses to be slashed on a given round. * In tally slashing, round N slashes validators from round N - slashOffsetInRounds. - * Offenses are sorted by priority (uncontroversial first, then amount, then age) and truncated to - * slashMaxPayloadSize so that execution payload stays within gas limits. * @param round - The round to get offenses for, defaults to current round - * @returns Array of pending offenses for the round with offset applied, truncated to max payload size + * @returns Array of pending offenses for the round with offset applied */ public async gatherOffensesForRound(round?: bigint): Promise { const targetRound = this.getSlashedRound(round); @@ -430,14 +434,7 @@ export class TallySlasherClient implements ProposerSlashActionProvider, SlasherC return []; } - const raw = await this.offensesStore.getOffensesForRound(targetRound); - const sorted = [...raw].sort(offenseDataComparator); - const { slashMaxPayloadSize } = this.config; - const selected = sorted.slice(0, slashMaxPayloadSize); - if (selected.length !== sorted.length) { - this.log.warn(`Offense list of ${sorted.length} truncated to max size of ${slashMaxPayloadSize}`); - } - return selected; + return await this.offensesStore.getOffensesForRound(targetRound); } /** Returns all pending offenses stored */ diff --git a/yarn-project/stdlib/src/slashing/tally.test.ts b/yarn-project/stdlib/src/slashing/tally.test.ts index d79a9dcb280e..4c0fafc58087 100644 --- a/yarn-project/stdlib/src/slashing/tally.test.ts +++ b/yarn-project/stdlib/src/slashing/tally.test.ts @@ -41,7 +41,7 @@ describe('TallySlashingHelpers', () => { const committees = [[mockValidator1, mockValidator2, mockValidator3]]; const epochsForCommittees = [5n]; // Committee for epoch 5 - const votes = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); + const { votes } = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); expect(votes).toHaveLength(4); // Padded to targetCommitteeSize expect(votes[0]).toEqual(2); // Only 25n from epoch 5 offense for validator1 @@ -62,7 +62,7 @@ describe('TallySlashingHelpers', () => { const committees = [[mockValidator1]]; const epochsForCommittees = [5n]; - const votes = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); + const { votes } = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); expect(votes).toHaveLength(4); // Padded to targetCommitteeSize expect(votes[0]).toEqual(3); // Capped at MAX_SLASH_UNITS_PER_VALIDATOR @@ -91,7 +91,7 @@ describe('TallySlashingHelpers', () => { ]; const epochsForCommittees = [5n, 6n]; // Committees for epochs 5 and 6 - const votes = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); + const { votes } = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); expect(votes).toHaveLength(8); // 2 committees × 4 targetCommitteeSize expect(votes[0]).toEqual(2); // validator1 in committee1 @@ -125,7 +125,7 @@ describe('TallySlashingHelpers', () => { [mockValidator1, mockValidator3], ]; const epochsForCommittees = [5n, 6n]; - const votes = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); + const { votes } = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); expect(votes).toHaveLength(8); // 2 committees × 4 targetCommitteeSize expect(votes[0]).toEqual(2); // validator1 in committee1, epoch 5 offense (20n) @@ -150,7 +150,7 @@ describe('TallySlashingHelpers', () => { const committees: EthAddress[][] = []; const epochsForCommittees: bigint[] = []; - const votes = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); + const { votes } = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); expect(votes).toEqual([]); }); @@ -167,7 +167,7 @@ describe('TallySlashingHelpers', () => { const committees = [[mockValidator2, mockValidator3]]; const epochsForCommittees = [5n]; - const votes = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); + const { votes } = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); expect(votes).toHaveLength(4); // Padded to targetCommitteeSize expect(votes[0]).toEqual(0); // validator2 has no offenses @@ -197,7 +197,7 @@ describe('TallySlashingHelpers', () => { [mockValidator1, mockValidator3], ]; const epochsForCommittees = [5n, 6n]; - const votes = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); + const { votes } = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); expect(votes).toHaveLength(8); // 2 committees × 4 targetCommitteeSize expect(votes[0]).toEqual(3); // validator1 in committee1, always-slash (30n) @@ -228,7 +228,7 @@ describe('TallySlashingHelpers', () => { const committees = [[mockValidator1, mockValidator2, mockValidator3]]; const epochsForCommittees = [2n]; // Committee for epoch 2 - const votes = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); + const { votes } = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); expect(votes).toHaveLength(4); // Padded to targetCommitteeSize expect(votes[0]).toEqual(BlockNumber(1)); // validator1: 15n offense maps to epoch 2 @@ -255,7 +255,7 @@ describe('TallySlashingHelpers', () => { const committees = [[mockValidator1, mockValidator2]]; const epochsForCommittees = [2n]; - const votes = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); + const { votes } = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); expect(votes).toHaveLength(4); // Padded to targetCommitteeSize expect(votes[0]).toEqual(2); // validator1: 10n + 15n = 25n total for epoch 2 @@ -288,7 +288,7 @@ describe('TallySlashingHelpers', () => { const committees = [[mockValidator1, mockValidator2]]; const epochsForCommittees = [3n]; - const votes = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); + const { votes } = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); expect(votes).toHaveLength(4); // Padded to targetCommitteeSize expect(votes[0]).toEqual(2); // validator1: 8n + 7n + 5n = 20n total @@ -318,7 +318,7 @@ describe('TallySlashingHelpers', () => { [mockValidator1, mockValidator3], ]; const epochsForCommittees = [5n, 6n]; - const votes = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); + const { votes } = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); expect(votes).toHaveLength(8); // 2 committees × 4 targetCommitteeSize expect(votes[0]).toEqual(3); // validator1 committee1: 20n(always) + 15n(epoch5) = 35n @@ -352,7 +352,7 @@ describe('TallySlashingHelpers', () => { [mockValidator1, mockValidator2], ]; const epochsForCommittees = [0n, 1n]; - const votes = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); + const { votes } = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); expect(votes).toHaveLength(8); // 2 committees × 4 targetCommitteeSize expect(votes[0]).toEqual(BlockNumber(1)); // validator1 epoch0: 15n offense @@ -383,7 +383,7 @@ describe('TallySlashingHelpers', () => { const committees = [[mockValidator1, mockValidator2, mockValidator3]]; const epochsForCommittees = [5n]; - const votes = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); + const { votes } = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); expect(votes).toHaveLength(4); // Padded to targetCommitteeSize expect(votes[0]).toEqual(0); // validator1: 0n amount = 0 slash units @@ -409,7 +409,7 @@ describe('TallySlashingHelpers', () => { ]; const epochsForCommittees = [5n, 6n, 7n]; - const votes = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); + const { votes } = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); // Should be 12 elements (4 per committee), not 8 expect(votes).toHaveLength(12); @@ -437,7 +437,7 @@ describe('TallySlashingHelpers', () => { ]; const epochsForCommittees = [5n, 6n]; - const votes = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); + const { votes } = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); expect(votes).toHaveLength(8); expect(votes.slice(0, 4)).toEqual([0, 0, 0, 0]); // Padded empty committee @@ -460,13 +460,73 @@ describe('TallySlashingHelpers', () => { ]; const epochsForCommittees = [5n, 6n]; - const votes = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); + const { votes } = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); expect(votes).toHaveLength(8); expect(votes.slice(0, 4)).toEqual([0, 2, 0, 0]); // validator2 in first committee (20n = 2 units) 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, truncatedCount } = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, { + ...settings, + maxSlashedValidators: 2, + }); + + expect(truncatedCount).toBe(1); + 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, truncatedCount } = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, { + ...settings, + maxSlashedValidators: 3, + }); + + expect(truncatedCount).toBe(1); + 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('handles multiple consecutive empty committees', () => { const offenses: Offense[] = [ { @@ -485,7 +545,7 @@ describe('TallySlashingHelpers', () => { ]; const epochsForCommittees = [5n, 6n, 7n, 8n]; - const votes = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); + const { votes } = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); expect(votes).toHaveLength(16); // 4 committees × 4 targetCommitteeSize expect(votes.slice(0, 4)).toEqual([0, 0, 0, 0]); // Committee 0: no matching offenses diff --git a/yarn-project/stdlib/src/slashing/tally.ts b/yarn-project/stdlib/src/slashing/tally.ts index 91bed7e4ff35..63ffe7fb1c28 100644 --- a/yarn-project/stdlib/src/slashing/tally.ts +++ b/yarn-project/stdlib/src/slashing/tally.ts @@ -12,6 +12,8 @@ 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. * @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 +24,10 @@ export function getSlashConsensusVotesFromOffenses( slashingAmounts: [bigint, bigint, bigint]; epochDuration: number; targetCommitteeSize: number; + maxSlashedValidators?: number; }, -): ValidatorSlashVote[] { - const { slashingAmounts, targetCommitteeSize } = settings; +): { votes: ValidatorSlashVote[]; truncatedCount: number } { + const { slashingAmounts, targetCommitteeSize, maxSlashedValidators } = settings; if (committees.length !== epochsForCommittees.length) { throw new Error('committees and epochsForCommittees must have the same length'); @@ -53,7 +56,19 @@ export function getSlashConsensusVotesFromOffenses( return padArrayEnd(votes, 0, targetCommitteeSize); }); - return votes; + // 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, truncatedCount: 0 }; + } + + 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; + } + + return { votes, truncatedCount: toTruncate.length }; } /** Returns the slash vote for the given amount to slash. */