Skip to content

Commit f78d0bc

Browse files
spalladinoludamad
authored andcommitted
fix: Do not aggregate validator offenses across epochs in a vote (#16695)
1 parent 276edb5 commit f78d0bc

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)