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
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ public Bytes encode() {
public static RoundChange decode(final Bytes data, final QbftBlockCodec blockEncoder) {

final RLPInput rlpIn = RLP.input(data);
rlpIn.enterList();
final int items = rlpIn.enterList();
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

enterList() item-count semantics can vary across RLP implementations (e.g., unknown length / sentinel values). To make legacy detection precise and more future-proof, consider checking explicitly for the legacy shape (items == 3) and treating any other value as the new format (i.e., attempt to read blockAccessList). This avoids accidentally skipping blockAccessList if items is not a reliable positive count.

Copilot uses AI. Check for mistakes.
final SignedData<RoundChangePayload> payload = readPayload(rlpIn, RoundChangePayload::readFrom);

final Optional<QbftBlock> block;
Expand All @@ -140,7 +140,16 @@ public static RoundChange decode(final Bytes data, final QbftBlockCodec blockEnc
block = Optional.of(blockEncoder.readFrom(rlpIn));
}

final Optional<BlockAccessList> blockAccessList = readBlockAccessList(rlpIn);
// Backward compatibility: pre-26.1.0 messages have 3 items (no blockAccessList field).
// New format has 4 items: [SignedPayload, Block, BlockAccessList, Prepares].
// Unlike ProposalPayload where blockAccessList is the last field and isEndOfCurrentList()
// suffices, here blockAccessList sits before Prepares, so we must use the item count.
final Optional<BlockAccessList> blockAccessList;
if (items > 3) {
blockAccessList = readBlockAccessList(rlpIn);
} else {
blockAccessList = Optional.empty();
Comment on lines +148 to +151
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

enterList() item-count semantics can vary across RLP implementations (e.g., unknown length / sentinel values). To make legacy detection precise and more future-proof, consider checking explicitly for the legacy shape (items == 3) and treating any other value as the new format (i.e., attempt to read blockAccessList). This avoids accidentally skipping blockAccessList if items is not a reliable positive count.

Suggested change
if (items > 3) {
blockAccessList = readBlockAccessList(rlpIn);
} else {
blockAccessList = Optional.empty();
if (items == 3) {
blockAccessList = Optional.empty();
} else {
blockAccessList = readBlockAccessList(rlpIn);

Copilot uses AI. Check for mistakes.
}

final List<SignedData<PreparePayload>> prepares =
rlpIn.readList(r -> readPayload(r, PreparePayload::readFrom));
Comment on lines +148 to 155
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new branch that skips blockAccessList for legacy messages is covered for the empty-prepares case, but the historically failing scenario is legacy-encoded messages where the Prepares list is present and non-empty. Add/adjust a test that manually encodes the legacy 3-item format with a proposed block and at least one prepare entry, then verifies decode succeeds and prepares are preserved.

Copilot uses AI. Check for mistakes.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,13 @@
import org.hyperledger.besu.cryptoservices.NodeKeyUtils;
import org.hyperledger.besu.datatypes.Address;
import org.hyperledger.besu.ethereum.core.Util;
import org.hyperledger.besu.ethereum.rlp.BytesValueRLPOutput;

import java.util.Collections;
import java.util.List;
import java.util.Optional;

import org.apache.tuweni.bytes.Bytes;
import org.apache.tuweni.bytes.Bytes32;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
Expand Down Expand Up @@ -121,4 +123,78 @@ public void canRoundTripEmptyPreparedRoundAndPreparedList() {
assertThat(decodedRoundChange.getProposedBlock()).isEmpty();
assertThat(decodedRoundChange.getPrepares()).isEmpty();
}

@Test
public void canDecodeRoundChangeFromLegacyNodeWithoutBlockAccessList() {
// Simulate a pre-26.1.0 validator that encodes RoundChange WITHOUT the blockAccessList field.
// Old format: [SignedPayload, EmptyList, [Prepares...]] (3 items)
// New format: [SignedPayload, EmptyList, BAL/Null, [Prepares...]] (4 items)
final NodeKey nodeKey = NodeKeyUtils.generate();
final Address addr = Util.publicKeyToAddress(nodeKey.getPublicKey());

final RoundChangePayload payload =
new RoundChangePayload(new ConsensusRoundIdentifier(1, 1), Optional.empty());

final SignedData<RoundChangePayload> signedRoundChangePayload =
SignedData.create(
payload, nodeKey.sign(Bytes32.wrap(payload.hashForSignature().getBytes())));

// Manually encode in old format (without blockAccessList field)
final BytesValueRLPOutput rlpOut = new BytesValueRLPOutput();
rlpOut.startList();
signedRoundChangePayload.writeTo(rlpOut);
rlpOut.writeEmptyList(); // empty block
rlpOut.writeList(Collections.emptyList(), SignedData::writeTo); // empty prepares
rlpOut.endList();
final Bytes legacyEncoded = rlpOut.encoded();

final RoundChange decodedRoundChange = RoundChange.decode(legacyEncoded, blockEncoder);

assertThat(decodedRoundChange.getMessageType()).isEqualTo(QbftV1.ROUND_CHANGE);
assertThat(decodedRoundChange.getAuthor()).isEqualTo(addr);
assertThat(decodedRoundChange.getProposedBlock()).isEmpty();
assertThat(decodedRoundChange.getBlockAccessList()).isEmpty();
assertThat(decodedRoundChange.getPrepares()).isEmpty();
}

@Test
public void canDecodeRoundChangeFromLegacyNodeWithBlockAndPrepares() {
// Simulate a pre-26.1.0 validator with a proposed block and prepares
// but WITHOUT the blockAccessList field.
Comment on lines +160 to +163
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test name/comment says it simulates a pre-26.1.0 encoding without blockAccessList, but the body below constructs a RoundChange and decodes newFormatMsg.encode() (which includes the blockAccessList position in the encoding). Either rename/reword this test to reflect that it’s validating the new-format round-trip with null/empty BAL, or change the encoding to manually produce the legacy 3-item RLP to match the stated intent.

Copilot uses AI. Check for mistakes.
when(blockEncoder.readFrom(any())).thenReturn(BLOCK);

final NodeKey nodeKey = NodeKeyUtils.generate();
final Address addr = Util.publicKeyToAddress(nodeKey.getPublicKey());

final RoundChangePayload payload =
new RoundChangePayload(
new ConsensusRoundIdentifier(1, 1),
Optional.of(new PreparedRoundMetadata(BLOCK.getHash(), 0)));

final SignedData<RoundChangePayload> signedRoundChangePayload =
SignedData.create(
payload, nodeKey.sign(Bytes32.wrap(payload.hashForSignature().getBytes())));

final PreparePayload preparePayload =
new PreparePayload(new ConsensusRoundIdentifier(1, 0), BLOCK.getHash());
final SignedData<PreparePayload> signedPreparePayload =
SignedData.create(
preparePayload,
nodeKey.sign(Bytes32.wrap(preparePayload.hashForSignature().getBytes())));

// Encode with new format (null BAL) and verify it still round-trips correctly
final RoundChange newFormatMsg =
new RoundChange(
signedRoundChangePayload,
Optional.of(BLOCK),
Optional.empty(),
blockEncoder,
List.of(signedPreparePayload));

final RoundChange decodedNewFormat = RoundChange.decode(newFormatMsg.encode(), blockEncoder);
assertThat(decodedNewFormat.getMessageType()).isEqualTo(QbftV1.ROUND_CHANGE);
assertThat(decodedNewFormat.getAuthor()).isEqualTo(addr);
assertThat(decodedNewFormat.getBlockAccessList()).isEmpty();
assertThat(decodedNewFormat.getPrepares()).hasSize(1);
}
}