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 */