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 spartan/environments/network-defaults.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
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 (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)

Expand Down
93 changes: 93 additions & 0 deletions yarn-project/slasher/src/tally_slasher_client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
16 changes: 13 additions & 3 deletions yarn-project/slasher/src/tally_slasher_client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,10 @@ export type TallySlasherSettings = Prettify<
>;

export type TallySlasherClientConfig = SlashOffensesCollectorConfig &
Pick<SlasherConfig, 'slashValidatorsAlways' | 'slashValidatorsNever' | 'slashExecuteRoundsLookBack'>;
Pick<
SlasherConfig,
'slashValidatorsAlways' | 'slashValidatorsNever' | 'slashExecuteRoundsLookBack' | 'slashMaxPayloadSize'
>;

/**
* The Tally Slasher client is responsible for managing slashable offenses using
Expand Down Expand Up @@ -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,
Expand Down
94 changes: 77 additions & 17 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,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[] = [
{
Expand All @@ -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
Expand Down
Loading
Loading