Skip to content

Commit 41a6cf5

Browse files
authored
fix: Do not aggregate validator offenses across epochs in a vote (#16695)
When a validator shows up in multiple committees within a round, we used to aggregate all offenses and vote for them only once. This had side effects such as that, if the slash amount for each epoch was the max slashable amount, they would get slashed only once. This commit changes it so each appearance of a validator in a slashing round is voted for independently, based on the offenses of that validator for that specific round. Fixes https://linear.app/aztec-labs/issue/TMNT-233/more-clearly-define-expected-behaviour
2 parents 3a5f63b + 86b0694 commit 41a6cf5

File tree

5 files changed

+263
-33
lines changed

5 files changed

+263
-33
lines changed

yarn-project/slasher/src/tally_slasher_client.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,7 @@ describe('TallySlasherClient', () => {
245245
const targetRound = 3n;
246246

247247
await addPendingOffense({
248-
epochOrSlot: targetRound * BigInt(roundSize),
248+
epochOrSlot: targetRound * BigInt(roundSize) + BigInt(epochDuration),
249249
offenseType: OffenseType.PROPOSED_INSUFFICIENT_ATTESTATIONS, // slot-based
250250
});
251251

yarn-project/slasher/src/tally_slasher_client.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type { Prettify } from '@aztec/foundation/types';
99
import type { SlasherConfig } from '@aztec/stdlib/interfaces/server';
1010
import {
1111
type Offense,
12+
OffenseType,
1213
type ProposerSlashAction,
1314
type ProposerSlashActionProvider,
1415
type SlashPayloadRound,
@@ -253,6 +254,7 @@ export class TallySlasherClient implements ProposerSlashActionProvider, SlasherC
253254
const offensesFromAlwaysSlash = (this.config.slashValidatorsAlways ?? []).map(validator => ({
254255
validator,
255256
amount: this.settings.slashingAmounts[2],
257+
offenseType: OffenseType.UNKNOWN,
256258
}));
257259
const [offensesToForgive, offensesToSlash] = partition([...offensesForRound, ...offensesFromAlwaysSlash], offense =>
258260
this.config.slashValidatorsNever?.some(v => v.equals(offense.validator)),
@@ -291,7 +293,8 @@ export class TallySlasherClient implements ProposerSlashActionProvider, SlasherC
291293
});
292294

293295
const committees = await this.collectCommitteesActiveDuringRound(slashedRound);
294-
const votes = getSlashConsensusVotesFromOffenses(offensesToSlash, committees, this.settings);
296+
const epochsForCommittees = getEpochsForRound(slashedRound, this.settings);
297+
const votes = getSlashConsensusVotesFromOffenses(offensesToSlash, committees, epochsForCommittees, this.settings);
295298
if (votes.every(v => v === 0)) {
296299
this.log.warn(`Computed votes for offenses are all zero. Skipping vote.`, {
297300
slotNumber,

yarn-project/stdlib/src/slashing/helpers.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,12 +106,23 @@ export function getSlotForOffense(
106106
return getTimeUnitForOffense(offenseType) === 'epoch' ? epochOrSlot * BigInt(constants.epochDuration) : epochOrSlot;
107107
}
108108

109-
/** Returns the epoch for a given offense. */
109+
/** Returns the epoch for a given offense. If the offense type or epoch is not defined, returns undefined. */
110110
export function getEpochForOffense(
111111
offense: Pick<Offense, 'epochOrSlot' | 'offenseType'>,
112112
constants: Pick<L1RollupConstants, 'epochDuration'>,
113-
): bigint {
113+
): bigint;
114+
export function getEpochForOffense(
115+
offense: Partial<Pick<Offense, 'epochOrSlot' | 'offenseType'>>,
116+
constants: Pick<L1RollupConstants, 'epochDuration'>,
117+
): bigint | undefined;
118+
export function getEpochForOffense(
119+
offense: Partial<Pick<Offense, 'epochOrSlot' | 'offenseType'>>,
120+
constants: Pick<L1RollupConstants, 'epochDuration'>,
121+
): bigint | undefined {
114122
const { epochOrSlot, offenseType } = offense;
123+
if (epochOrSlot === undefined || offenseType === undefined) {
124+
return undefined;
125+
}
115126
return getTimeUnitForOffense(offenseType) === 'epoch' ? epochOrSlot : epochOrSlot / BigInt(constants.epochDuration);
116127
}
117128

yarn-project/stdlib/src/slashing/tally.test.ts

Lines changed: 222 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@ describe('TallySlashingHelpers', () => {
1010
const mockValidator4 = EthAddress.fromString('0x4567890123456789012345678901234567890123');
1111

1212
describe('getSlashConsensusVotesFromOffenses', () => {
13-
const settings = { slashingAmounts: [10n, 20n, 30n] as [bigint, bigint, bigint] };
13+
const settings = {
14+
slashingAmounts: [10n, 20n, 30n] as [bigint, bigint, bigint],
15+
epochDuration: 32,
16+
};
1417

1518
it('creates votes based on offenses and committees', () => {
1619
const offenses: Offense[] = [
@@ -35,12 +38,13 @@ describe('TallySlashingHelpers', () => {
3538
];
3639

3740
const committees = [[mockValidator1, mockValidator2, mockValidator3]];
38-
const votes = getSlashConsensusVotesFromOffenses(offenses, committees, settings);
41+
const epochsForCommittees = [5n]; // Committee for epoch 5
42+
const votes = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings);
3943

4044
expect(votes).toHaveLength(3);
41-
expect(votes[0]).toEqual(3); // 30 / 10 = 3 slash units for validator1
42-
expect(votes[1]).toEqual(0); // 5 / 10 = 0 slash units for validator2
43-
expect(votes[2]).toEqual(0); // 0 / 10 = 0 slash units for validator3
45+
expect(votes[0]).toEqual(2); // Only 25n from epoch 5 offense for validator1
46+
expect(votes[1]).toEqual(0); // Offense is in slot 10, which is epoch 0, not 5
47+
expect(votes[2]).toEqual(0); // No offenses for validator3
4448
});
4549

4650
it('caps slash units at maximum per validator', () => {
@@ -54,7 +58,8 @@ describe('TallySlashingHelpers', () => {
5458
];
5559

5660
const committees = [[mockValidator1]];
57-
const votes = getSlashConsensusVotesFromOffenses(offenses, committees, settings);
61+
const epochsForCommittees = [5n];
62+
const votes = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings);
5863

5964
expect(votes).toHaveLength(1);
6065
expect(votes[0]).toEqual(3); // Capped at MAX_SLASH_UNITS_PER_VALIDATOR
@@ -81,7 +86,8 @@ describe('TallySlashingHelpers', () => {
8186
[mockValidator3, mockValidator4],
8287
];
8388

84-
const votes = getSlashConsensusVotesFromOffenses(offenses, committees, settings);
89+
const epochsForCommittees = [5n, 6n]; // Committees for epochs 5 and 6
90+
const votes = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings);
8591

8692
expect(votes).toHaveLength(4);
8793
expect(votes[0]).toEqual(2); // validator1 in committee1
@@ -90,7 +96,7 @@ describe('TallySlashingHelpers', () => {
9096
expect(votes[3]).toEqual(3); // validator4 in committee2
9197
});
9298

93-
it('does not repeat slashes for the same validator in different committees', () => {
99+
it('correctly handles validators appearing in multiple committees with different epochs', () => {
94100
const offenses: Offense[] = [
95101
{
96102
validator: mockValidator1,
@@ -110,13 +116,14 @@ describe('TallySlashingHelpers', () => {
110116
[mockValidator1, mockValidator2],
111117
[mockValidator1, mockValidator3],
112118
];
113-
const votes = getSlashConsensusVotesFromOffenses(offenses, committees, settings);
119+
const epochsForCommittees = [5n, 6n];
120+
const votes = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings);
114121

115122
expect(votes).toHaveLength(4);
116-
expect(votes[0]).toEqual(3); // validator1 in committee1
117-
expect(votes[1]).toEqual(0); // validator2 in committee1
118-
expect(votes[2]).toEqual(0); // validator1 in committee2
119-
expect(votes[3]).toEqual(0); // validator3 in committee2
123+
expect(votes[0]).toEqual(2); // validator1 in committee1, epoch 5 offense (20n)
124+
expect(votes[1]).toEqual(0); // validator2 in committee1, no offenses
125+
expect(votes[2]).toEqual(1); // validator1 in committee2, epoch 6 offense (10n)
126+
expect(votes[3]).toEqual(0); // validator3 in committee2, no offenses
120127
});
121128

122129
it('returns empty votes for empty committees', () => {
@@ -130,7 +137,8 @@ describe('TallySlashingHelpers', () => {
130137
];
131138

132139
const committees: EthAddress[][] = [];
133-
const votes = getSlashConsensusVotesFromOffenses(offenses, committees, settings);
140+
const epochsForCommittees: bigint[] = [];
141+
const votes = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings);
134142

135143
expect(votes).toEqual([]);
136144
});
@@ -146,12 +154,211 @@ describe('TallySlashingHelpers', () => {
146154
];
147155

148156
const committees = [[mockValidator2, mockValidator3]];
149-
const votes = getSlashConsensusVotesFromOffenses(offenses, committees, settings);
157+
const epochsForCommittees = [5n];
158+
const votes = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings);
150159

151160
expect(votes).toHaveLength(2);
152161
expect(votes[0]).toEqual(0); // validator2 has no offenses
153162
expect(votes[1]).toEqual(0); // validator3 has no offenses
154163
});
164+
165+
it('handles offenses without epochOrSlot (slashValidatorsAlways)', () => {
166+
const offenses = [
167+
{
168+
validator: mockValidator1,
169+
amount: 30n,
170+
offenseType: OffenseType.UNKNOWN,
171+
epochOrSlot: undefined, // No epoch/slot for always-slash validators
172+
},
173+
{
174+
validator: mockValidator2,
175+
amount: 10n,
176+
offenseType: OffenseType.INACTIVITY,
177+
epochOrSlot: 5n,
178+
},
179+
];
180+
181+
const committees = [
182+
[mockValidator1, mockValidator2],
183+
[mockValidator1, mockValidator3],
184+
];
185+
const epochsForCommittees = [5n, 6n];
186+
const votes = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings);
187+
188+
expect(votes).toHaveLength(4);
189+
expect(votes[0]).toEqual(3); // validator1 in committee1, always-slash (30n)
190+
expect(votes[1]).toEqual(1); // validator2 in committee1, epoch 5 offense (10n)
191+
expect(votes[2]).toEqual(3); // validator1 in committee2, always-slash (30n)
192+
expect(votes[3]).toEqual(0); // validator3 in committee2, no offenses
193+
});
194+
195+
it('correctly converts slot-based offenses to epochs', () => {
196+
const offenses: Offense[] = [
197+
{
198+
validator: mockValidator1,
199+
amount: 15n,
200+
offenseType: OffenseType.PROPOSED_INSUFFICIENT_ATTESTATIONS, // slot-based
201+
epochOrSlot: 64n, // slot 64 = epoch 2 (64/32)
202+
},
203+
{
204+
validator: mockValidator2,
205+
amount: 20n,
206+
offenseType: OffenseType.PROPOSED_INSUFFICIENT_ATTESTATIONS, // slot-based
207+
epochOrSlot: 95n, // slot 95 = epoch 2 (95/32 = 2.96... -> 2)
208+
},
209+
];
210+
211+
const committees = [[mockValidator1, mockValidator2, mockValidator3]];
212+
const epochsForCommittees = [2n]; // Committee for epoch 2
213+
const votes = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings);
214+
215+
expect(votes).toHaveLength(3);
216+
expect(votes[0]).toEqual(1); // validator1: 15n offense maps to epoch 2
217+
expect(votes[1]).toEqual(2); // validator2: 20n offense maps to epoch 2
218+
expect(votes[2]).toEqual(0); // validator3: no offenses
219+
});
220+
221+
it('handles mixed epoch and slot-based offenses resolving to same epoch', () => {
222+
const offenses: Offense[] = [
223+
{
224+
validator: mockValidator1,
225+
amount: 10n,
226+
offenseType: OffenseType.INACTIVITY, // epoch-based
227+
epochOrSlot: 2n, // epoch 2
228+
},
229+
{
230+
validator: mockValidator1,
231+
amount: 15n,
232+
offenseType: OffenseType.PROPOSED_INSUFFICIENT_ATTESTATIONS, // slot-based
233+
epochOrSlot: 75n, // slot 75 = epoch 2 (75/32 = 2.34... -> 2)
234+
},
235+
];
236+
237+
const committees = [[mockValidator1, mockValidator2]];
238+
const epochsForCommittees = [2n];
239+
const votes = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings);
240+
241+
expect(votes).toHaveLength(2);
242+
expect(votes[0]).toEqual(2); // validator1: 10n + 15n = 25n total for epoch 2
243+
expect(votes[1]).toEqual(0); // validator2: no offenses
244+
});
245+
246+
it('sums multiple offenses for same validator in same epoch', () => {
247+
const offenses: Offense[] = [
248+
{
249+
validator: mockValidator1,
250+
amount: 8n,
251+
offenseType: OffenseType.INACTIVITY,
252+
epochOrSlot: 3n,
253+
},
254+
{
255+
validator: mockValidator1,
256+
amount: 7n,
257+
offenseType: OffenseType.DATA_WITHHOLDING,
258+
epochOrSlot: 3n,
259+
},
260+
{
261+
validator: mockValidator1,
262+
amount: 5n,
263+
offenseType: OffenseType.VALID_EPOCH_PRUNED,
264+
epochOrSlot: 3n,
265+
},
266+
];
267+
268+
const committees = [[mockValidator1, mockValidator2]];
269+
const epochsForCommittees = [3n];
270+
const votes = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings);
271+
272+
expect(votes).toHaveLength(2);
273+
expect(votes[0]).toEqual(2); // validator1: 8n + 7n + 5n = 20n total
274+
expect(votes[1]).toEqual(0); // validator2: no offenses
275+
});
276+
277+
it('handles always-slash validator with additional epoch-specific offenses', () => {
278+
const offenses = [
279+
{
280+
validator: mockValidator1,
281+
amount: 20n, // always-slash
282+
offenseType: OffenseType.UNKNOWN,
283+
epochOrSlot: undefined,
284+
},
285+
{
286+
validator: mockValidator1,
287+
amount: 15n, // epoch-specific
288+
offenseType: OffenseType.INACTIVITY,
289+
epochOrSlot: 5n,
290+
},
291+
];
292+
293+
const committees = [
294+
[mockValidator1, mockValidator2],
295+
[mockValidator1, mockValidator3],
296+
];
297+
const epochsForCommittees = [5n, 6n];
298+
const votes = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings);
299+
300+
expect(votes).toHaveLength(4);
301+
expect(votes[0]).toEqual(3); // validator1 committee1: 20n(always) + 15n(epoch5) = 35n
302+
expect(votes[1]).toEqual(0); // validator2: no offenses
303+
expect(votes[2]).toEqual(2); // validator1 committee2: 20n(always) only
304+
expect(votes[3]).toEqual(0); // validator3: no offenses
305+
});
306+
307+
it('handles epoch boundary conditions', () => {
308+
const offenses: Offense[] = [
309+
{
310+
validator: mockValidator1,
311+
amount: 15n,
312+
offenseType: OffenseType.PROPOSED_INSUFFICIENT_ATTESTATIONS, // slot-based
313+
epochOrSlot: 31n, // slot 31 = epoch 0 (31/32 = 0.96... -> 0)
314+
},
315+
{
316+
validator: mockValidator2,
317+
amount: 20n,
318+
offenseType: OffenseType.PROPOSED_INSUFFICIENT_ATTESTATIONS, // slot-based
319+
epochOrSlot: 32n, // slot 32 = epoch 1 (32/32 = 1)
320+
},
321+
];
322+
323+
const committees = [
324+
[mockValidator1, mockValidator2],
325+
[mockValidator1, mockValidator2],
326+
];
327+
const epochsForCommittees = [0n, 1n];
328+
const votes = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings);
329+
330+
expect(votes).toHaveLength(4);
331+
expect(votes[0]).toEqual(1); // validator1 epoch0: 15n offense
332+
expect(votes[1]).toEqual(0); // validator2 epoch0: no matching offenses
333+
expect(votes[2]).toEqual(0); // validator1 epoch1: no matching offenses
334+
expect(votes[3]).toEqual(2); // validator2 epoch1: 20n offense
335+
});
336+
337+
it('handles zero amount offenses', () => {
338+
const offenses: Offense[] = [
339+
{
340+
validator: mockValidator1,
341+
amount: 0n,
342+
offenseType: OffenseType.INACTIVITY,
343+
epochOrSlot: 5n,
344+
},
345+
{
346+
validator: mockValidator2,
347+
amount: 15n,
348+
offenseType: OffenseType.INACTIVITY,
349+
epochOrSlot: 5n,
350+
},
351+
];
352+
353+
const committees = [[mockValidator1, mockValidator2, mockValidator3]];
354+
const epochsForCommittees = [5n];
355+
const votes = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings);
356+
357+
expect(votes).toHaveLength(3);
358+
expect(votes[0]).toEqual(0); // validator1: 0n amount = 0 slash units
359+
expect(votes[1]).toEqual(1); // validator2: 15n amount = 1 slash unit
360+
expect(votes[2]).toEqual(0); // validator3: no offenses
361+
});
155362
});
156363

157364
describe('encodeSlashConsensusVotes', () => {

0 commit comments

Comments
 (0)