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. 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.
- `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: 2 additions & 7 deletions yarn-project/slasher/src/tally_slasher_client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -362,18 +362,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, truncatedCount } = getSlashConsensusVotesFromOffenses(
const votes = getSlashConsensusVotesFromOffenses(
offensesToSlash,
committees,
epochsForCommittees.map(e => BigInt(e)),
{ ...this.settings, maxSlashedValidators: slashMaxPayloadSize },
this.log,
);
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,
Expand Down
68 changes: 47 additions & 21 deletions yarn-project/stdlib/src/slashing/tally.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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([]);
});
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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);
Expand Down Expand Up @@ -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
Expand All @@ -460,7 +460,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, 2, 0, 0]); // validator2 in first committee (20n = 2 units)
Expand All @@ -477,12 +477,11 @@ describe('TallySlashingHelpers', () => {
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, {
const votes = 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)
Expand Down Expand Up @@ -510,12 +509,11 @@ describe('TallySlashingHelpers', () => {
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, {
const votes = 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)
Expand All @@ -527,6 +525,34 @@ describe('TallySlashingHelpers', () => {
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 All @@ -545,7 +571,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
Expand Down
26 changes: 22 additions & 4 deletions 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 @@ -13,7 +14,8 @@ import type { Offense, ValidatorSlashVote } from './types.js';
* @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.
* 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 @@ -26,7 +28,8 @@ export function getSlashConsensusVotesFromOffenses(
targetCommitteeSize: number;
maxSlashedValidators?: number;
},
): { votes: ValidatorSlashVote[]; truncatedCount: number } {
logger: Logger = createLogger('slasher:tally'),
): ValidatorSlashVote[] {
const { slashingAmounts, targetCommitteeSize, maxSlashedValidators } = settings;

if (committees.length !== epochsForCommittees.length) {
Expand Down Expand Up @@ -58,7 +61,7 @@ export function getSlashConsensusVotesFromOffenses(

// 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 };
return votes;
}

const nonZeroByDescendingVote = [...votes.entries()].filter(([, vote]) => vote > 0).sort(([, a], [, b]) => b - a);
Expand All @@ -68,7 +71,22 @@ export function getSlashConsensusVotesFromOffenses(
votes[idx] = 0;
}

return { votes, truncatedCount: toTruncate.length };
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;
}

/** Returns the slash vote for the given amount to slash. */
Expand Down
Loading