Skip to content

Fix QBFT/IBFT RLPException when decoding RoundChange from pre-26.1.0 nodes#10204

Open
ghostant-1017 wants to merge 1 commit intobesu-eth:mainfrom
ghostant-1017:fix/qbft-ibft-roundchange-backward-compat
Open

Fix QBFT/IBFT RLPException when decoding RoundChange from pre-26.1.0 nodes#10204
ghostant-1017 wants to merge 1 commit intobesu-eth:mainfrom
ghostant-1017:fix/qbft-ibft-roundchange-backward-compat

Conversation

@ghostant-1017
Copy link
Copy Markdown

Summary

Detail

The problem

Pre-26.1.0 QBFT RoundChange wire format (3 items):
```
[SignedPayload, Block/EmptyList, [Prepares...]]
```

Post-26.1.0 format (4 items):
```
[SignedPayload, Block/EmptyList, BlockAccessList/Null, [Prepares...]]
```

When decoding old-format messages, readBlockAccessList() checks isEndOfCurrentList() which returns false (because the Prepares list is still there), then incorrectly consumes the Prepares data as a BlockAccessList. The subsequent readList() for Prepares then fails because the RLP input is fully consumed.

The fix

For QBFT RoundChange: use the return value of enterList() (which gives the number of top-level items) to detect the format version — 3 items means pre-26.1.0 (skip BAL), >3 means new format (read BAL normally).

For IBFT RoundChange and Proposal: blockAccessList is the last field, so isEndOfCurrentList() correctly detects its absence — just add the missing check (same approach as #9977).

Test plan

  • Add canDecodeRoundChangeFromLegacyNodeWithoutBlockAccessList — manually encodes a 3-item (old format) RoundChange and verifies successful decode
  • Add canDecodeRoundChangeFromLegacyNodeWithBlockAndPrepares — verifies new format (4-item with null BAL) still round-trips correctly
  • Run ./gradlew :consensus:qbft-core:test --tests "*.RoundChangeTest"
  • Run ./gradlew :consensus:ibft:test --tests "*.RoundChangeMessageTest"
  • Run ./gradlew :consensus:ibft:test --tests "*.ProposalMessageTest"

Copilot AI review requested due to automatic review settings April 9, 2026 08:52
…nodes

PR besu-eth#9629 added blockAccessList to QBFT/IBFT consensus messages but
broke backward compatibility with older nodes that do not include this
field.

PR besu-eth#9977 partially fixed this for QBFT ProposalPayload (where
blockAccessList is the last field, so isEndOfCurrentList() suffices),
but missed QBFT RoundChange where blockAccessList sits *before* the
Prepares list. In that position isEndOfCurrentList() returns false
(because Prepares is still there), causing the decoder to consume
the Prepares list as a BlockAccessList and then fail with:

  RLPException: Cannot enter a lists, input is fully consumed

Fix: use the enterList() item count to detect old (3-item) vs new
(4-item) format in QBFT RoundChange.decode().

Also add the missing isEndOfCurrentList() guard to IBFT RoundChange
and IBFT Proposal (where blockAccessList is the last field).

Signed-off-by: qinglin <qinglin@example.com>

🤖 Generated with [Qoder][https://qoder.com]
@ghostant-1017 ghostant-1017 force-pushed the fix/qbft-ibft-roundchange-backward-compat branch from bee1fa5 to b467d18 Compare April 9, 2026 08:54
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

Fixes backward-compatibility RLP decoding for QBFT/IBFT RoundChange (and related IBFT messages) when receiving messages from pre-26.1.0 nodes that don’t include blockAccessList, preventing RLPException crashes.

Changes:

  • QBFT RoundChange.decode(): use top-level RLP list item count to distinguish legacy (3-item) vs new (4-item) encoding.
  • QBFT tests: add coverage for decoding legacy-format RoundChange and ensure new-format round-trips.
  • (Per description) IBFT: add isEndOfCurrentList() guard for blockAccessList where it’s the last field.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.

File Description
consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/messagewrappers/RoundChange.java Uses RLP list item count to safely skip blockAccessList for legacy messages.
consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/messagewrappers/RoundChangeTest.java Adds tests for legacy decode and new-format round-trip to prevent regressions.


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.
Comment on lines +148 to +151
if (items > 3) {
blockAccessList = readBlockAccessList(rlpIn);
} else {
blockAccessList = Optional.empty();
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.
Comment on lines +160 to +163
@Test
public void canDecodeRoundChangeFromLegacyNodeWithBlockAndPrepares() {
// Simulate a pre-26.1.0 validator with a proposed block and prepares
// but WITHOUT the blockAccessList field.
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.
Comment on lines +148 to 155
if (items > 3) {
blockAccessList = readBlockAccessList(rlpIn);
} else {
blockAccessList = Optional.empty();
}

final List<SignedData<PreparePayload>> prepares =
rlpIn.readList(r -> readPayload(r, PreparePayload::readFrom));
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.
@fab-10
Copy link
Copy Markdown
Contributor

fab-10 commented Apr 9, 2026

FYI: @matthew1001 @jframe

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants