diff --git a/l1-contracts/src/core/slashing/TallySlashingProposer.sol b/l1-contracts/src/core/slashing/TallySlashingProposer.sol index ec9b7e91e5e2..2b944d0ed78c 100644 --- a/l1-contracts/src/core/slashing/TallySlashingProposer.sol +++ b/l1-contracts/src/core/slashing/TallySlashingProposer.sol @@ -594,13 +594,16 @@ contract TallySlashingProposer is EIP712 { function getVotes(SlashRound _round, uint256 _index) external view returns (bytes memory) { uint256 expectedLength = COMMITTEE_SIZE * ROUND_SIZE_IN_EPOCHS / 4; - // _getRoundVotes reverts if _round is out of the roundabout range. - // But within-range the storage slot may still hold stale data from a - // previous round that mapped to the same circular index. Guard against - // that by checking the authoritative round metadata first. + // _getRoundData reverts if _round is out of the roundabout range and + // returns empty metadata if this circular slot still contains round data + // from an older round number. SlashRound currentRound = getCurrentRound(); RoundData memory roundData = _getRoundData(_round, currentRound); - if (roundData.voteCount == 0) { + + // Vote storage is not cleared when a circular slot is reused. If this + // round has fewer votes than a previous one that shared the same slot, + // indices >= voteCount would otherwise return stale vote bytes. + if (_index >= roundData.voteCount) { return new bytes(expectedLength); } diff --git a/l1-contracts/test/slashing/TallySlashingProposer.t.sol b/l1-contracts/test/slashing/TallySlashingProposer.t.sol index 7f23ed2cbb87..38330fab1245 100644 --- a/l1-contracts/test/slashing/TallySlashingProposer.t.sol +++ b/l1-contracts/test/slashing/TallySlashingProposer.t.sol @@ -605,9 +605,8 @@ contract TallySlashingProposerTest is TestBase { function test_getVotesRevertsForOutOfRangeRound() public { // Test that getVotes reverts for rounds outside the valid roundabout range. - // Before the range check was added to _getRoundVotes, getVotes would silently - // return whatever stale data was at the storage slot - potentially data from - // a completely different round that mapped to the same circular index. + // getVotes routes through _getRoundData for range validation before loading + // vote slots, so out-of-range round reads should always revert. _jumpToSlashRound(10); SlashRound baseRound = slashingProposer.getCurrentRound(); @@ -670,6 +669,47 @@ contract TallySlashingProposerTest is TestBase { assertEq(result, emptyVote, "getVotes should return empty bytes for stale round"); } + function test_getVotesReturnsEmptyForUnwrittenIndexInCurrentRound() public { + // Populate two vote indices in a base round. + _jumpToSlashRound(10); + SlashRound baseRound = slashingProposer.getCurrentRound(); + + uint8[] memory firstVote = new uint8[](COMMITTEE_SIZE * ROUND_SIZE_IN_EPOCHS); + firstVote[0] = 3; + _castVote(firstVote); + + timeCheater.cheat__progressSlot(); + + uint8[] memory secondVote = new uint8[](COMMITTEE_SIZE * ROUND_SIZE_IN_EPOCHS); + secondVote[1] = 2; + secondVote[2] = 1; + _castVote(secondVote); + + bytes memory secondVoteData = _createVoteData(secondVote); + assertEq(slashingProposer.getVotes(baseRound, 1), secondVoteData, "Base round index 1 should be populated"); + + // Jump to a round that maps to the same circular slot and cast only one vote. + uint256 roundaboutSize = slashingProposer.ROUNDABOUT_SIZE(); + SlashRound overlappingRound = SlashRound.wrap(SlashRound.unwrap(baseRound) + roundaboutSize); + _jumpToSlashRound(SlashRound.unwrap(overlappingRound)); + + uint8[] memory overlappingVote = new uint8[](COMMITTEE_SIZE * ROUND_SIZE_IN_EPOCHS); + overlappingVote[0] = 1; + _castVote(overlappingVote); + + (, uint256 voteCount) = slashingProposer.getRound(overlappingRound); + assertEq(voteCount, 1, "Overlapping round should have exactly one vote"); + + // Reading index 1 must return empty bytes, not stale bytes from baseRound index 1. + uint256 expectedLength = COMMITTEE_SIZE * ROUND_SIZE_IN_EPOCHS / 4; + bytes memory emptyVote = new bytes(expectedLength); + assertEq( + slashingProposer.getVotes(overlappingRound, 1), + emptyVote, + "getVotes should return empty bytes for unwritten vote index" + ); + } + function test_getVotesRevertsForFutureRound() public { // getVotes should revert when asked about a round in the future _jumpToSlashRound(10);