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..24e5d0a38f9e 100644 --- a/yarn-project/slasher/src/tally_slasher_client.test.ts +++ b/yarn-project/slasher/src/tally_slasher_client.test.ts @@ -519,6 +519,99 @@ describe('TallySlasherClient', () => { expect(actions).toHaveLength(1); expect(actions[0].type).toBe('vote-offenses'); }); + + 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); + + // slashMaxPayloadSize has no effect on gatherOffensesForRound; truncation happens later + // in getSlashConsensusVotesFromOffenses after always-slash offenses are merged in. + tallySlasherClient.updateConfig({ slashMaxPayloadSize: 2 }); + + 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 offenses = await tallySlasherClient.gatherOffensesForRound(currentRound); + + expect(offenses).toHaveLength(3); + }); + + it('should return all offenses for the round', async () => { + const currentRound = 5n; + const targetRound = 3n; + const baseSlot = targetRound * BigInt(roundSize); + + 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 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 }); + + 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, 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 + }); }); describe('getSlashPayloads', () => { diff --git a/yarn-project/slasher/src/tally_slasher_client.ts b/yarn-project/slasher/src/tally_slasher_client.ts index 70ef6fdfeeb6..d3dd4321fe53 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 @@ -362,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, 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. */