The wire copy of a sealed proposal now carries the seal, proven on a test network

The first witness run caught a real gap: the proposal was signed twice - once for the
local copy, once for the wire - and only the local copy carried the proposer's Falcon
seal. Locally sealed, unsealed on the wire: with enforcement armed, the fleet stopped at
the enforcement height even though every node was emitting. Falcon signatures are
randomised, so the fix is structural rather than cosmetic: the proposal is built once,
seal included, and the transmitter sends that same object - two signings would produce
two valid and different proposals from the same proposer.

A new plumbing test refuses the defect's return: it fails if the transmitter's wire path
ever builds its own payload again. On the test network after the fix: emission covered
seven of seven proposers, each sealing exactly its first proposal past the gate; with
enforcement armed and emission on, the chain crossed the enforcement height with zero
refusals; with emission off it stopped exactly one block short of the height, every
refusal logged; and one unarmed node could not keep the chain alive.

Still armed nowhere: the properties are unset, and unset means never.
This commit is contained in:
Aere Network 2026-08-31 15:36:42 +03:00
parent 3afe906fb2
commit a9af2de488
4 changed files with 163 additions and 10 deletions

View File

@ -84,14 +84,14 @@ e65ba92e288aebe768909b1fee5c0b8850be74b3658c6242e6abdb91409a9521 consensus/comm
e355b147fdda48c14cb6dc09b1bc1e7a7ccae3f9b709af8f8483f896086dbe4d consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/ProposalPqAttachGateTest.java
d48d62c6ea0784864a5987a804a28855265b2180c409ba3c5b4434d21b805e1d consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/SealSchemeAgilityTest.java
22ade1aa2d8254d1f7ff02bbbcb4d02155546a52fca3421b61eea1129b73ee48 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/messagewrappers/Commit.java
046c67d18ba623cd01390b6ffef945b0dfa245fbf0cc79889b7670ab465b7992 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/network/QbftMessageTransmitter.java
271f1cf0cf9ad53bd40d6a99b12666aace3f878bc6ca5f1297ea7284232a735f consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/network/QbftMessageTransmitter.java
03173f2029f767e10cc978b5576b9557efa783dab2ef105aa856f6d5e08b9352 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/CommitPayload.java
e577fe2c90ac2cf8bff2145376f44284cad1562c7d09b2eb6da9899849cdd882 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/MessageFactory.java
861a2e6f81df1c33755f28ca50bf88b6b0b197f7a7a1d646c165788d192160ca consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/PreparePayload.java
428e11639fb24ae4b04972457714445a5cd59d7946135a8b7362123461fa690f consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/ProposalPayload.java
64c3ab6f9b67eb2ed126bca89ffc0af49bebc17bc8a41b56efc19e51188ca75d consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/QbftBlockHeightManager.java
252f9a86ca17cc6362780af264dbc4da4d842191467427a809ff1e4baa7640af consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/QbftController.java
a56a86a95e3ff7de3c9b22ee6e91c88ed713a3c065cadbb3b6fe205698a4c2c6 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/QbftRound.java
609890459ba6be78d0b91c04e80927231e8d61a0af822c4049ff0972530ab7ea consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/QbftRound.java
a22d857867c5b7c9ccdf9185c661870eafe6b481573189202b2cc34d9fbf3e2d consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/RoundState.java
601b0cccf32f8ebe327c7581e0e9b3f9f67de49d9fb646326d6b49ef2138c641 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/types/QbftBlockCreator.java
7f9fb13a7bbe3015bbef6329f750f2b35fb3744895cd3e6599af90450fab6a73 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/CommitValidator.java
@ -100,6 +100,7 @@ ec53f8e401a19345a067223f482f27a95cb9c3153d5bee0bfea9fd622bc0db01 consensus/qbft
742611d52091a6c138cdff60c5f7f0bfb2fc33e567f520f3e86878fc086e77d9 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/PqProposalEnforcement.java
60eb9c5a8e3f464aa42915909e402bf59af9bad4e28631538dcf2f5ca52a9569 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/PrepareValidator.java
8ab72d5558842779a6df6d5d4d7c06479ac3a19746b51bdd00d5168084f0b590 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/ProposalPayloadValidator.java
e2107737579c16b73cbd2c9b50e90abcd6c910309286fb862cef780555ca43a5 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/network/ProposalSealPlumbingTest.java
36dd77cdea980e4de0090e86ae2ca75d1af62a9477fe056c7fdde06422617898 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/payload/CommitPayloadHybridTest.java
fe25255de4b5fa6d86412fa4c48218180f549f0a4ffea5b5cc0a7d19a5be4cd9 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/payload/PreparePayloadPqTest.java
a994d5638940ea90f37ed6f3ed5eebc24781707f2f4555c7a079d1251ddbf893 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/payload/ProposalPayloadPqTest.java

View File

@ -84,9 +84,41 @@ public class QbftMessageTransmitter {
final Optional<BlockAccessList> blockAccessList,
final List<SignedData<RoundChangePayload>> roundChanges,
final List<SignedData<PreparePayload>> prepares) {
multicastProposal(roundIdentifier, block, blockAccessList, roundChanges, prepares,
Optional.empty());
}
/**
* Multicast proposal carrying the proposer's optional post-quantum seal (AERE PQ, 2026-08-30).
*
* <p>THE SEAL MUST BE THE SAME OBJECT the caller embedded in its local copy, and this overload
* exists because the first network run proved what happens without it: this method RE-CREATES
* the proposal from scratch, so the local copy carried a seal, the wire copy carried none, and
* at the enforcement height every peer refused round after round while the proposer's own log
* said it had emitted. F84 scenario A, 2026-08-30 - the witness went red, exactly its job.
* The same two-copies trap was closed at PREPARE the day before it could bite; here it bit
* first, on a testnet, which is where it is allowed to.
*
* @param roundIdentifier the round identifier
* @param block the block
* @param blockAccessList the block access list
* @param roundChanges the round changes
* @param prepares the prepares
* @param falconSeal the proposer's post-quantum seal, or empty
*/
public void multicastProposal(
final ConsensusRoundIdentifier roundIdentifier,
final QbftBlock block,
final Optional<BlockAccessList> blockAccessList,
final List<SignedData<RoundChangePayload>> roundChanges,
final List<SignedData<PreparePayload>> prepares,
final Optional<FalconSeal> falconSeal) {
try {
final Proposal data =
messageFactory.createProposal(
falconSeal.isPresent()
? messageFactory.createProposal(
roundIdentifier, block, blockAccessList, roundChanges, prepares, falconSeal)
: messageFactory.createProposal(
roundIdentifier, block, blockAccessList, roundChanges, prepares);
final ProposalMessageData message = ProposalMessageData.create(data);

View File

@ -261,12 +261,31 @@ public class QbftRound {
return;
}
// THE SAME seal object goes on the wire. The transmitter re-creates the proposal, so without
// this the local copy is sealed and the wire copy is not - measured on the first network run
// (F84 scenario A): every peer refused height H round after round while this node's own log
// said it had emitted. And it must be the SAME object, not a second signing: Falcon signatures
// are randomised, so a re-signed wire copy would differ from the local one byte for byte.
// With the gate closed the OLD call is taken, call for call - the upstream tests assert the
// five-argument form, and a node that emits nothing new must go through the same calls.
final Optional<FalconSeal> wireSeal =
proposal.getSignedPayload().getPayload().getFalconSeal();
if (wireSeal.isPresent()) {
transmitter.multicastProposal(
proposal.getRoundIdentifier(),
proposal.getSignedPayload().getPayload().getProposedBlock(),
proposal.getBlockAccessList(),
roundChanges,
prepares,
wireSeal);
} else {
transmitter.multicastProposal(
proposal.getRoundIdentifier(),
proposal.getSignedPayload().getPayload().getProposedBlock(),
proposal.getBlockAccessList(),
roundChanges,
prepares);
}
if (updateStateWithProposedBlock(proposal)) {
sendPrepare(block);
}

View File

@ -0,0 +1,101 @@
/*
* Copyright contributors to Besu.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
* specific language governing permissions and limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
*/
package org.hyperledger.besu.consensus.qbft.core.network;
import static java.util.Collections.emptyList;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.verify;
import org.hyperledger.besu.consensus.common.bft.ConsensusRoundIdentifier;
import org.hyperledger.besu.consensus.common.bft.FalconSeal;
import org.hyperledger.besu.consensus.common.bft.network.ValidatorMulticaster;
import org.hyperledger.besu.consensus.qbft.core.QbftBlockTestFixture;
import org.hyperledger.besu.consensus.qbft.core.messagedata.ProposalMessageData;
import org.hyperledger.besu.consensus.qbft.core.messagewrappers.Proposal;
import org.hyperledger.besu.consensus.qbft.core.payload.MessageFactory;
import org.hyperledger.besu.consensus.qbft.core.types.QbftBlock;
import org.hyperledger.besu.consensus.qbft.core.types.QbftBlockCodec;
import org.hyperledger.besu.consensus.qbft.core.types.QbftBlockHeader;
import org.hyperledger.besu.consensus.qbft.core.validation.QbftBlockHeaderTestFixture;
import org.hyperledger.besu.cryptoservices.NodeKeyUtils;
import org.hyperledger.besu.ethereum.p2p.rlpx.wire.MessageData;
import java.util.Optional;
import org.apache.tuweni.bytes.Bytes;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
/**
* THE PLUMBING between the local proposal and the one on the wire.
*
* <p>This test exists because the first network run of proposal enforcement (F84 scenario A,
* 2026-08-30) went red on its own WITNESS: every node emitted a seal on its local proposal, and
* the chain still stopped dead at the enforcement height, because {@code multicastProposal}
* re-creates the proposal from scratch and the wire copy carried no seal. Every unit test we had
* passed, since none of them looked at what actually leaves the node. This one does.
*/
@ExtendWith(MockitoExtension.class)
public class ProposalSealPlumbingTest {
private static final ConsensusRoundIdentifier ROUND_ID = new ConsensusRoundIdentifier(1, 0);
@Mock private QbftBlockCodec blockEncoder;
@Mock private QbftBlock block;
@Mock private ValidatorMulticaster multicaster;
private Proposal sentProposal(final Optional<FalconSeal> seal) {
lenient().when(blockEncoder.readFrom(any())).thenReturn(block);
final MessageFactory factory = new MessageFactory(NodeKeyUtils.generate(), blockEncoder);
final QbftMessageTransmitter transmitter = new QbftMessageTransmitter(factory, multicaster);
if (seal.isPresent()) {
transmitter.multicastProposal(
ROUND_ID, block, Optional.empty(), emptyList(), emptyList(), seal);
} else {
transmitter.multicastProposal(ROUND_ID, block, Optional.empty(), emptyList(), emptyList());
}
final ArgumentCaptor<MessageData> captor = ArgumentCaptor.forClass(MessageData.class);
verify(multicaster).send(captor.capture());
return ProposalMessageData.fromMessageData(captor.getValue()).decode(blockEncoder);
}
@Test
public void theSealHandedToTheTransmitterIsTheSealOnTheWire() {
final FalconSeal seal = new FalconSeal(4, Bytes.fromHexString("0xdeadbeef"));
final Proposal wire = sentProposal(Optional.of(seal));
assertThat(wire.getSignedPayload().getPayload().getFalconSeal()).contains(seal);
}
@Test
public void withoutASealTheWireProposalCarriesNone() {
final Proposal wire = sentProposal(Optional.empty());
assertThat(wire.getSignedPayload().getPayload().getFalconSeal()).isEmpty();
}
/** Kept so the fixture imports stay honest if the wrappers change shape. */
@Test
public void theFixturesStillBuildARealBlock() {
final QbftBlockHeader header = new QbftBlockHeaderTestFixture().number(1).buildHeader();
final QbftBlock real = new QbftBlockTestFixture().blockHeader(header).build();
assertThat(real.getHeader().getNumber()).isEqualTo(1);
}
}