Skip to content

Commit b873565

Browse files
committed
fix: Do not aggregate validator offenses across epochs in a vote
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
1 parent 49bd6da commit b873565

File tree

3 files changed

+249
-30
lines changed

3 files changed

+249
-30
lines changed

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/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', () => {

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

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,52 @@
11
import { sumBigint } from '@aztec/foundation/bigint';
22
import { EthAddress } from '@aztec/foundation/eth-address';
3+
import type { PartialBy } from '@aztec/foundation/types';
34

5+
import { getEpochForOffense } from './helpers.js';
46
import type { Offense, ValidatorSlashVote } from './types.js';
57

68
/**
79
* Creates a consensus-slash vote for a given set of committees based on a set of Offenses
810
* @param offenses - Array of offenses to consider
911
* @param committees - Array of committees (each containing array of validator addresses)
12+
* @param epochsForCommittees - Array of epochs corresponding to each committee
1013
* @param settings - Settings including slashingAmounts and optional validator override lists
1114
* @returns Array of ValidatorSlashVote, where each vote is how many slash units the validator in that position should be slashed
1215
*/
1316
export function getSlashConsensusVotesFromOffenses(
14-
offenses: Pick<Offense, 'validator' | 'amount'>[],
17+
offenses: PartialBy<Offense, 'epochOrSlot'>[],
1518
committees: EthAddress[][],
19+
epochsForCommittees: bigint[],
1620
settings: {
1721
slashingAmounts: [bigint, bigint, bigint];
22+
epochDuration: number;
1823
},
1924
): ValidatorSlashVote[] {
2025
const { slashingAmounts } = settings;
2126

22-
const slashedSet: Set<string> = new Set();
27+
if (committees.length !== epochsForCommittees.length) {
28+
throw new Error('committees and epochsForCommittees must have the same length');
29+
}
2330

24-
const votes = committees.flatMap(committee =>
25-
committee.map(validator => {
26-
const validatorStr = validator.toString();
31+
const votes = committees.flatMap((committee, committeeIndex) => {
32+
const committeeEpoch = epochsForCommittees[committeeIndex];
2733

28-
// If already voted for slashing this validator, skip
29-
if (slashedSet.has(validatorStr)) {
30-
return 0;
31-
}
34+
return committee.map(validator => {
35+
// Find offenses for this validator in this specific epoch.
36+
// If an offense has no epoch, it is considered for all epochs due to a slashAlways setting.
37+
const validatorOffenses = offenses.filter(
38+
o =>
39+
o.validator.equals(validator) &&
40+
(o.epochOrSlot === undefined || getEpochForOffense(o as Offense, settings) === committeeEpoch),
41+
);
3242

33-
// Normal offense-based slashing logic
34-
const validatorOffenses = offenses.filter(o => o.validator.equals(validator));
43+
// Sum up the penalties for this validator in this epoch
3544
const slashAmount = sumBigint(validatorOffenses.map(o => o.amount));
3645
const slashUnits = getSlashUnitsForAmount(slashAmount, slashingAmounts);
37-
slashedSet.add(validatorStr);
3846
return Number(slashUnits);
39-
}),
40-
);
47+
});
48+
});
49+
4150
return votes;
4251
}
4352

0 commit comments

Comments
 (0)