Skip to content
Open
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
100 changes: 100 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,106 @@ describe('TallySlasherClient', () => {
expect(actions).toHaveLength(1);
expect(actions[0].type).toBe('vote-offenses');
});

it('should truncate to slashMaxPayloadSize when offenses exceed cap', async () => {
const currentRound = 5n;
const targetRound = 3n; // currentRound - offset(2)
const baseSlot = targetRound * BigInt(roundSize);

// Set cap to 2 so we keep only the top 2 offenses by priority (uncontroversial first, then amount desc)
tallySlasherClient.updateConfig({ slashMaxPayloadSize: 2 });

// Add 3 offenses for target round: different amounts so sort order is clear (high amount first)
await addPendingOffense({
validator: committee[0],
epochOrSlot: baseSlot,
amount: settings.slashingAmounts[0], // 1 unit - lowest priority
offenseType: OffenseType.PROPOSED_INSUFFICIENT_ATTESTATIONS,
});
await addPendingOffense({
validator: committee[1],
epochOrSlot: baseSlot,
amount: settings.slashingAmounts[2], // 3 units - highest priority
offenseType: OffenseType.PROPOSED_INSUFFICIENT_ATTESTATIONS,
});
await addPendingOffense({
validator: committee[2],
epochOrSlot: baseSlot,
amount: settings.slashingAmounts[1], // 2 units - middle
offenseType: OffenseType.PROPOSED_INSUFFICIENT_ATTESTATIONS,
});

const offenses = await tallySlasherClient.gatherOffensesForRound(currentRound);

expect(offenses).toHaveLength(2);
// First should be committee[1] (3 units), second committee[2] (2 units); committee[0] (1 unit) truncated
expect(offenses[0].validator.equals(committee[1])).toBe(true);
expect(offenses[0].amount).toEqual(settings.slashingAmounts[2]);
expect(offenses[1].validator.equals(committee[2])).toBe(true);
expect(offenses[1].amount).toEqual(settings.slashingAmounts[1]);
});

it('should not truncate when offenses are within cap', async () => {
const currentRound = 5n;
const targetRound = 3n;
const baseSlot = targetRound * BigInt(roundSize);

tallySlasherClient.updateConfig({ slashMaxPayloadSize: 10 });

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 with truncated offenses', async () => {
const currentRound = 5n;
const targetRound = 3n;
const baseSlot = targetRound * BigInt(roundSize);

tallySlasherClient.updateConfig({ slashMaxPayloadSize: 1 });

// Add 3 offenses, only the highest-amount one should survive truncation
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) should have a non-zero vote
expect(action!.votes[0]).toBe(0); // committee[0] truncated
expect(action!.votes[1]).toBe(3); // committee[1] kept (highest amount)
expect(action!.votes[2]).toBe(0); // committee[2] truncated
});
});

describe('getSlashPayloads', () => {
Expand Down
19 changes: 16 additions & 3 deletions yarn-project/slasher/src/tally_slasher_client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
type SlashPayloadRound,
getEpochsForRound,
getSlashConsensusVotesFromOffenses,
offenseDataComparator,
} from '@aztec/stdlib/slashing';

import type { Hex } from 'viem';
Expand Down Expand Up @@ -46,7 +47,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 @@ -415,16 +419,25 @@ export class TallySlasherClient implements ProposerSlashActionProvider, SlasherC
/**
* Gather offenses to be slashed on a given round.
* In tally slashing, round N slashes validators from round N - slashOffsetInRounds.
* Offenses are sorted by priority (uncontroversial first, then amount, then age) and truncated to
* slashMaxPayloadSize so that execution payload stays within gas limits.
* @param round - The round to get offenses for, defaults to current round
* @returns Array of pending offenses for the round with offset applied
* @returns Array of pending offenses for the round with offset applied, truncated to max payload size
*/
public async gatherOffensesForRound(round?: bigint): Promise<Offense[]> {
const targetRound = this.getSlashedRound(round);
if (targetRound < 0n) {
return [];
}

return await this.offensesStore.getOffensesForRound(targetRound);
const raw = await this.offensesStore.getOffensesForRound(targetRound);
const sorted = [...raw].sort(offenseDataComparator);
const { slashMaxPayloadSize } = this.config;
const selected = sorted.slice(0, slashMaxPayloadSize);
if (selected.length !== sorted.length) {
this.log.warn(`Offense list of ${sorted.length} truncated to max size of ${slashMaxPayloadSize}`);
}
return selected;
}

/** Returns all pending offenses stored */
Expand Down
Loading