From b7605403131acbe86dbbc97be1e741f733c61f5e Mon Sep 17 00:00:00 2001 From: Lasse Herskind <16536249+LHerskind@users.noreply.github.com> Date: Mon, 9 Feb 2026 14:05:59 +0100 Subject: [PATCH] fix: skip empty epochs in tally slasher --- .../core/slashing/TallySlashingProposer.sol | 15 +++++-- .../test/slashing/TallySlashingProposer.t.sol | 45 +++++++++++++++++++ 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/l1-contracts/src/core/slashing/TallySlashingProposer.sol b/l1-contracts/src/core/slashing/TallySlashingProposer.sol index 0b5656e1345f..14c2a8c14127 100644 --- a/l1-contracts/src/core/slashing/TallySlashingProposer.sol +++ b/l1-contracts/src/core/slashing/TallySlashingProposer.sol @@ -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; } @@ -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; diff --git a/l1-contracts/test/slashing/TallySlashingProposer.t.sol b/l1-contracts/test/slashing/TallySlashingProposer.t.sol index 81f1b2b692dd..e474fa34c359 100644 --- a/l1-contracts/test/slashing/TallySlashingProposer.t.sol +++ b/l1-contracts/test/slashing/TallySlashingProposer.t.sol @@ -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