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
15 changes: 12 additions & 3 deletions l1-contracts/src/core/slashing/TallySlashingProposer.sol
Original file line number Diff line number Diff line change
Expand Up @@ -883,8 +883,17 @@ contract TallySlashingProposer is EIP712 {

unchecked {
for (uint256 i; i < totalValidators; ++i) {
uint256 epochIndex = i / COMMITTEE_SIZE;

// Skip validators that belong to escape-hatch epochs
if (escapeHatchEpochs[i / COMMITTEE_SIZE]) {
if (escapeHatchEpochs[epochIndex]) {
continue;
}

// Skip validators for epochs without a valid committee (e.g. early epochs
// before the validator set was sampled). Without this check, indexing into
// an empty committee array would revert and block execution of the round.
if (_committees[epochIndex].length != COMMITTEE_SIZE) {
continue;
}

Expand Down Expand Up @@ -918,11 +927,11 @@ contract TallySlashingProposer is EIP712 {

// Record the slashing action
actions[actionCount] =
SlashAction({validator: _committees[i / COMMITTEE_SIZE][i % COMMITTEE_SIZE], slashAmount: slashAmount});
SlashAction({validator: _committees[epochIndex][i % COMMITTEE_SIZE], slashAmount: slashAmount});
++actionCount;

// Mark this committee as having at least one slashed validator
committeesWithSlashes[i / COMMITTEE_SIZE] = true;
committeesWithSlashes[epochIndex] = true;

// Only slash each validator once at the highest amount that reached quorum
break;
Expand Down
45 changes: 45 additions & 0 deletions l1-contracts/test/slashing/TallySlashingProposer.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -870,6 +870,51 @@ contract TallySlashingProposerTest is TestBase {
}
}

function test_executeRoundWithEmptyCommittee() public {
// Round FIRST_SLASH_ROUND targets epochs 0 and 1, which have no committees
// because they precede the validator set sampling lag.
//
// Before the fix, casting votes that reach quorum for validator slots in these
// committee-less epochs would cause executeRound (and getTally) to revert with
// an array out-of-bounds access when indexing _committees[epochIndex][validatorIndex].
_jumpToSlashRound(FIRST_SLASH_ROUND);
SlashRound targetRound = slashingProposer.getCurrentRound();

// Cast QUORUM votes with max slash for ALL validator slots, including those
// corresponding to epochs without valid committees.
uint8[] memory slashAmounts = new uint8[](COMMITTEE_SIZE * ROUND_SIZE_IN_EPOCHS);
for (uint256 i = 0; i < slashAmounts.length; i++) {
slashAmounts[i] = 3;
}

for (uint256 i = 0; i < QUORUM; i++) {
_castVote(slashAmounts);
if (i < QUORUM - 1) {
timeCheater.cheat__progressSlot();
}
}

// Jump past execution delay
uint256 targetSlot = (SlashRound.unwrap(targetRound) + EXECUTION_DELAY_IN_ROUNDS + 1) * ROUND_SIZE;
timeCheater.cheat__jumpToSlot(targetSlot);

// Verify that both targeted epochs have empty committees
address[][] memory committees = slashingProposer.getSlashTargetCommittees(targetRound);
assertEq(committees[0].length, 0, "Epoch 0 should have empty committee");
assertEq(committees[1].length, 0, "Epoch 1 should have empty committee");

// getTally should not revert and should return 0 actions
TallySlashingProposer.SlashAction[] memory actions = slashingProposer.getTally(targetRound, committees);
assertEq(actions.length, 0, "Should have no slash actions for empty committees");

// executeRound should also succeed
slashingProposer.executeRound(targetRound, committees);

// Verify round is marked as executed
(bool isExecuted,) = slashingProposer.getRound(targetRound);
assertTrue(isExecuted, "Round should be marked as executed");
}

function test_getSlashTargetCommitteesEarlyEpochs() public {
// Test that getSlashTargetCommittees handles epochs 0 and 1 without throwing
// when ValidatorSelection__InsufficientValidatorSetSize is thrown
Expand Down
Loading