diff --git a/anchor/MANIFEST-sha256.txt b/anchor/MANIFEST-sha256.txt index 59fedfe..9057423 100644 --- a/anchor/MANIFEST-sha256.txt +++ b/anchor/MANIFEST-sha256.txt @@ -13,11 +13,11 @@ a3b93f2602e9755d91358c9b3f473235f4d40ae23d912f797cdb03ed7e1348f9 consensus/comm e32a03de9f1452bd7444a33b084516ae3399d7a5ae7430088714f5656d3a22ea consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/BftExtraData.java 3ab425b5b5f7d7c2199065a3905623d5c1c4c5922d892c6c4275415b0c69a2b4 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/FalconSeal.java db1e80115ce59c8281fbf046cb37e153a49fe017c1675afd200e8125d3f6b538 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/FalconSealScheme.java -4b6de4abba87246b18c26fb18a65dfc68831aa981dffefe15eeb2c075fdfcf0f consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/FalconSealSupport.java +789db234a90f118cdf881653ee2bb7ad4a229a685315b04822147ffffe6a3c13 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/FalconSealSupport.java e8c8111a343cc993b950ac4e5b3992f16b8ee3d34c8827f00dd80d017884335b consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/HybridSealProducer.java 03043d2360c35de3b6f27d029b7ee83cb6c964f4b827a504799607b8bfe1cdcc consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/HybridSealSupport.java 65e33e639c58adb30045e88c770460757305acab31ac884e4c6b36f1c410ef12 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/HybridSignerRegistry.java -afe13adaa495c4d0a9ada14df555afcbc6c8d46d8883730ab1ce201499eebc5f consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchor.java +ecd0e865aa48e461618048c13c46d9b54095dfa2e14180750f49b90faf49302a consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchor.java 852113d420f2c6b82945423a18591c3b2bd1018febe1a52e8b4e42a996c85114 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorConfig.java c6285ca43331781e1d350a07d58256d02b8b3a74b96b6469c0dca33ecfb6c8cd consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorLapse.java f27952a25bad02d2939a356f1bac8800ff8fb08023c6bdfb1c6d0e51b5a206da consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorNotReadyException.java @@ -82,14 +82,16 @@ e0bc7a465dbb2dd7efa5f1830211a1a45c6554c300b2088dd70bb9677b0ac217 consensus/comm e65ba92e288aebe768909b1fee5c0b8850be74b3658c6242e6abdb91409a9521 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqV2Fixture.java 56426ba6bb10df29f5ef5f5959b1646dda5a6d9e7db9904ceb7ad1070ad750b8 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PreparePqAttachGateTest.java e355b147fdda48c14cb6dc09b1bc1e7a7ccae3f9b709af8f8483f896086dbe4d consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/ProposalPqAttachGateTest.java +45a881d122a7ffa5ff4d2db38a2750e3918055593c51e1b1241342905a30565d consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/RoundChangePqAttachGateTest.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 -271f1cf0cf9ad53bd40d6a99b12666aace3f878bc6ca5f1297ea7284232a735f consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/network/QbftMessageTransmitter.java +597d96d64c9394c79d5084a1ab5df884b2266efe315c0f2d563bfc17b7421480 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 +d365bad33f3aee4597c630071ca1f727ddd8bb6ab6515fccbce0a07842037adf 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 +2e133e92dabb6b7add09820f9239050bd8d3b9ad63c16ed7f6e725a9694f2232 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/RoundChangePayload.java +51eddf3cb8e37489abd35e6f4c36294a13ec76337bbbbfd54c07584085dceaac 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 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 @@ -98,12 +100,16 @@ a22d857867c5b7c9ccdf9185c661870eafe6b481573189202b2cc34d9fbf3e2d consensus/qbft ec53f8e401a19345a067223f482f27a95cb9c3153d5bee0bfea9fd622bc0db01 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/PqCommitEnforcement.java 5ab52433f770537e838b15e23abfc7b24eaa733ea26577451d5e32226d617879 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/PqPrepareEnforcement.java 742611d52091a6c138cdff60c5f7f0bfb2fc33e567f520f3e86878fc086e77d9 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/PqProposalEnforcement.java +afb487f8c1dd87e323fdc1153ecfdf8ec6cb5a10e0726783d4214c07a3fa9a95 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/PqRoundChangeEnforcement.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 +54c657bb4759623faf2159417ec0255187e1919215e43f7e7cefb71e4514c8cb consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/validation/RoundChangePayloadValidator.java e2107737579c16b73cbd2c9b50e90abcd6c910309286fb862cef780555ca43a5 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/network/ProposalSealPlumbingTest.java +6da7d76a2ddc3dcab2a6d54d60615bb54182594f230e858cfd0c13b2626b76e0 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/network/RoundChangeSealPlumbingTest.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 +df5d7d2119cf0864a8c2dce50f4f69bd8b64804cfda7f7366c013b304af0a6e7 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/payload/RoundChangePayloadPqTest.java 0bb2c1e31a7f29e9b07fb7030567746fc6d79460ed4739d5ecafac44501f7bb5 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/statemachine/PqLateSealSalvageTest.java 8c40173c7b248edec7c29ee9e049f9e269f4725b637bceb8734735fc73eb9ca2 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/CommitValidatorPqEnforcementTest.java 401c25f63abb248dfe60b69676a34ea255bb669b4bc58e45916f6eecea3cb870 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqCommitEnforcementTest.java @@ -112,9 +118,11 @@ c0853ed97c53d54951e25ad6d0b70c0299dedd859cc7c44da64e3751d0e0de33 consensus/qbft 5a47d247d7bcda77b57f5c906c3cee1af826785416012fafe9cfa49d63671388 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqPrepareAgilityTest.java 1939e33cc8ea81782e5e17d68e2b59c001e4207ed8ef07acd1d2a166a047dcb3 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqPrepareEnforcementTest.java afbd5e820e5dabfbaa924be2730019cc133fcbd03095f9a8a4b783d8b5e79c90 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqProposalEnforcementTest.java +53a4e0246a55c239ed44a61271e6d38c7901e1101dfa53ebd44d7a7f5bcb74ac consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqRoundChangeEnforcementTest.java dc9f9e862a11f0135d26976176a1d1adac3e84fd5f1e06572d8727262cb3374f consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PrepareValidatorPqWiringTest.java 2c612868dfdd61d86cadc4ca9b34faacf4e43aca88878feb72ac140e1321ef5d consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/ProposalPayloadValidatorPqWiringTest.java 83dd971f66ed63103d09db5283240556bf1db1e1925c2a07808c7f59d47433ad consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/RoundChangeJustificationPqTest.java +8f6abb4297df964e934cfeabe51f365478e917b73cd6909f621c9e201e280aa5 consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/RoundChangePayloadValidatorPqWiringTest.java 79dea5e85b968af696bf57d51d0de175a4c025537200402e3594a4a8290d1a29 consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/QbftBlockHeaderValidationRulesetFactory.java 7d0a75818ac64b601b79c32b8289eedda8e2300dbb0f5e9186084f4e01b04d26 consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/QbftExtraDataCodec.java 47fbdd8639c464bf19bac3a0ba540af4d99f79e9df02c2ad586a19a2aebd037d consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/adaptor/QbftBlockCreatorAdaptor.java diff --git a/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/FalconSealSupport.java b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/FalconSealSupport.java index 65fa080..7049b84 100644 --- a/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/FalconSealSupport.java +++ b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/FalconSealSupport.java @@ -144,6 +144,10 @@ public final class FalconSealSupport { private final java.util.concurrent.atomic.AtomicLong proposalsSealed = new java.util.concurrent.atomic.AtomicLong(); + /** How many seals this node emitted on its own ROUND-CHANGEs since startup. */ + private final java.util.concurrent.atomic.AtomicLong roundChangesSealed = + new java.util.concurrent.atomic.AtomicLong(); + private final boolean signingEnabled; private final int localIndex; private final FalconPrivateKeyParameters localPrivateKey; @@ -3681,6 +3685,105 @@ public final class FalconSealSupport { return proposalsSealed.get(); } + /** + * The property that opens the ROUND-CHANGE attachment gate: from this height on, this node's own + * round-changes carry a Falcon seal. Emission only - enforcement is {@code + * aere.pq.roundChangePq.forkBlock}, deliberately separate so the fleet can emit long before any + * node refuses, exactly as at PREPARE and PROPOSAL. + */ + public static final String ROUNDCHANGE_ATTACH_PROPERTY = "aere.pq.roundChangePq.attachBlock"; + + /** Environment fallback for {@link #ROUNDCHANGE_ATTACH_PROPERTY}. */ + public static final String ROUNDCHANGE_ATTACH_ENV = "AERE_PQ_ROUNDCHANGEPQ_ATTACHBLOCK"; + + /** + * The configured ROUND-CHANGE attachment height, read fresh on every call. + * + *
Absent = {@link Long#MAX_VALUE}, i.e. never. A value that is PRESENT but unreadable REFUSES + * loudly instead of disarming - the anchor loader's lesson, once per gate, every gate. + * + * @return the height, or Long.MAX_VALUE when unset + */ + public static long roundChangeAttachBlock() { + final String raw = resolve(ROUNDCHANGE_ATTACH_PROPERTY, ROUNDCHANGE_ATTACH_ENV); + if (raw == null || raw.isBlank()) { + return Long.MAX_VALUE; + } + try { + final long v = Long.parseLong(raw.trim()); + if (v < 0) { + throw new NumberFormatException("negative"); + } + return v; + } catch (final NumberFormatException e) { + throw new ActivationConfigException( + ActivationConfigException.Kind.SYNTAX, + "AERE-PQC-ROUNDCHANGE-CONF-01", + "AERE PQ ROUNDCHANGE: " + + ROUNDCHANGE_ATTACH_PROPERTY + + " is set to '" + + raw + + "', which is not a non-negative block height. A node must REFUSE to start rather " + + "than silently run with ROUND-CHANGE attachment disarmed: a disarmed node looks " + + "exactly like a correctly configured one until the day it matters."); + } + } + + /** + * Sign this node's own ROUND-CHANGE, when the ROUND-CHANGE attachment gate is open at this + * height. + * + *
Same three requirements as {@link #signPrepare} and {@link #signProposal}, for the same
+ * three reasons: a key, the commit-layer coverage conditions, and its own height. NEVER throws
+ * except for the strict configuration case: a signing failure is a log line and an empty value,
+ * because the ECDSA path must not be disturbed.
+ *
+ * @param blockNumber the height the round-change targets
+ * @param message the domain-separated ROUND-CHANGE message (see PqAnchor.roundChangeMessage)
+ * @return the seal, or empty when any gate is shut
+ */
+ public Optional SEPARATE FROM ALL THREE OTHERS, for the same non-transferability reason. A ROUND-CHANGE is
+ * the message that STEERS rounds: a quorum of them opens a new round, and one that claims a
+ * prepared block decides WHICH block gets re-proposed. A seal given honestly over a vote or a
+ * proposal must not be pasteable onto a round-change, and a round-change seal must not count as
+ * either. One changed string in the preimage enforces that cryptographically.
+ */
+ public static final String ROUNDCHANGE_DOMAIN = "AERE-PQ-ROUNDCHANGE-1";
+
+ /** The round-change domain label as raw bytes. */
+ public static final Bytes ROUNDCHANGE_DOMAIN_BYTES =
+ Bytes.wrap(ROUNDCHANGE_DOMAIN.getBytes(StandardCharsets.US_ASCII));
+
/** Orders Falcon seals by their registry index, ascending. */
public static final Comparator The preimage writes an explicit presence flag of 0 and empty placeholders, so a bare
+ * round-change and one prepared at round 0 can never collide: the flag, not the emptiness of a
+ * field, is what says whether metadata exists.
+ *
+ * @param chainId the chain id
+ * @param blockNumber the height the round-change targets (its sequence number)
+ * @param targetRound the round the sender wants to move to
+ * @return the 32-byte message to sign
+ */
+ public static Bytes32 roundChangeMessage(
+ final long chainId, final long blockNumber, final int targetRound) {
+ return roundChangePreimage(chainId, blockNumber, targetRound, false, 0, Bytes.EMPTY);
+ }
+
+ /**
+ * The 32-byte message a validator's post-quantum ROUND-CHANGE seal signs, for a round-change
+ * that CLAIMS a prepared block.
+ *
+ * The prepared metadata is IN the preimage on purpose: a round-change that claims a prepared
+ * block decides which block gets re-proposed, so a seal from a bare round-change pasted onto one
+ * with metadata (or onto one with different metadata) must not verify. Same shape as {@link
+ * #proposalMessage}, its own domain.
+ *
+ * @param chainId the chain id
+ * @param blockNumber the height the round-change targets (its sequence number)
+ * @param targetRound the round the sender wants to move to
+ * @param preparedRound the round the claimed prepared block was prepared in
+ * @param preparedDigest the 32-byte digest of the claimed prepared block
+ * @return the 32-byte message to sign
+ */
+ public static Bytes32 roundChangeMessage(
+ final long chainId,
+ final long blockNumber,
+ final int targetRound,
+ final int preparedRound,
+ final Bytes preparedDigest) {
+ if (preparedRound < 0) {
+ throw new IllegalArgumentException(
+ "AERE PQ ROUNDCHANGE: preparedRound must not be negative (got " + preparedRound + ")");
+ }
+ if (preparedDigest == null || preparedDigest.size() != 32) {
+ throw new IllegalArgumentException(
+ "AERE PQ ROUNDCHANGE: preparedDigest must be 32 bytes (got "
+ + (preparedDigest == null ? "null" : preparedDigest.size() + " bytes")
+ + ")");
+ }
+ return roundChangePreimage(chainId, blockNumber, targetRound, true, preparedRound, preparedDigest);
+ }
+
+ private static Bytes32 roundChangePreimage(
+ final long chainId,
+ final long blockNumber,
+ final int targetRound,
+ final boolean hasPrepared,
+ final int preparedRound,
+ final Bytes preparedDigest) {
+ if (blockNumber < 0) {
+ throw new IllegalArgumentException(
+ "AERE PQ ROUNDCHANGE: blockNumber must not be negative (got " + blockNumber + ")");
+ }
+ if (targetRound < 0) {
+ throw new IllegalArgumentException(
+ "AERE PQ ROUNDCHANGE: targetRound must not be negative (got " + targetRound + ")");
+ }
+ final BytesValueRLPOutput out = new BytesValueRLPOutput();
+ out.startList();
+ out.writeBytes(ROUNDCHANGE_DOMAIN_BYTES);
+ out.writeLongScalar(chainId);
+ out.writeLongScalar(blockNumber);
+ out.writeLongScalar(targetRound);
+ out.writeLongScalar(hasPrepared ? 1 : 0);
+ out.writeLongScalar(preparedRound);
+ out.writeBytes(preparedDigest);
+ out.endList();
+ return Hash.keccak256(out.encoded());
+ }
+
/**
* Whether the certificate's validator indices are STRICTLY increasing.
*
diff --git a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/RoundChangePqAttachGateTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/RoundChangePqAttachGateTest.java
new file mode 100644
index 0000000..19c1055
--- /dev/null
+++ b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/RoundChangePqAttachGateTest.java
@@ -0,0 +1,108 @@
+/*
+ * 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.common.bft;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import org.apache.tuweni.bytes.Bytes32;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * THE GATE that emits a seal on the ROUND-CHANGE (AERE PQ, 2026-08-31). Twin of the PREPARE and
+ * PROPOSAL gates, with the same shape on purpose.
+ *
+ * What is measured here is the CONFIGURATION SURFACE, which is exactly the part that gets typed
+ * by hand and therefore mistyped: absent means never, a good value means from that height onwards,
+ * and a MISTYPED value refuses loudly instead of booting the node disarmed. The lesson paid for in
+ * the anchor loader is that a stray character must never disarm silently, because then nobody finds
+ * out.
+ *
+ * What is NOT measured here, and it is said plainly: that an ARMED node actually produces a
+ * seal. That needs a Falcon key, a registry bound to addresses, and a FAILED round to provoke the
+ * message at all, which means a network; it is measured at the coverage step, on a testnet. What is
+ * proven here is that the gate is closed by default and cannot be opened by accident.
+ */
+class RoundChangePqAttachGateTest {
+
+ @AfterEach
+ void clearTheProperty() {
+ System.clearProperty(FalconSealSupport.ROUNDCHANGE_ATTACH_PROPERTY);
+ }
+
+ @Test
+ void withoutThePropertyTheGateIsClosedForever() {
+ assertThat(FalconSealSupport.roundChangeAttachBlock()).isEqualTo(Long.MAX_VALUE);
+ }
+
+ @Test
+ void aGoodValueIsReadAsGiven() {
+ System.setProperty(FalconSealSupport.ROUNDCHANGE_ATTACH_PROPERTY, "16500000");
+ assertThat(FalconSealSupport.roundChangeAttachBlock()).isEqualTo(16_500_000L);
+ }
+
+ @Test
+ void zeroIsALEGALValue() {
+ // A threshold of zero means "from genesis", and that is a legitimate configuration on a
+ // testnet. Treated as "unset", a correctly configured testnet would run disarmed in silence.
+ System.setProperty(FalconSealSupport.ROUNDCHANGE_ATTACH_PROPERTY, "0");
+ assertThat(FalconSealSupport.roundChangeAttachBlock()).isZero();
+ }
+
+ @Test
+ void aMISTYPEDValueRefusesLoudly() {
+ for (final String bad : new String[] {"nu-e-numar", "16_500_000", "1e6", "-1", " "}) {
+ System.setProperty(FalconSealSupport.ROUNDCHANGE_ATTACH_PROPERTY, bad);
+ if (bad.isBlank()) {
+ // whitespace is "unset", not a mistyped value: an empty field in a configuration file
+ // must not stop a node
+ assertThat(FalconSealSupport.roundChangeAttachBlock()).isEqualTo(Long.MAX_VALUE);
+ continue;
+ }
+ assertThatThrownBy(FalconSealSupport::roundChangeAttachBlock)
+ .as("the value '%s'", bad)
+ .isInstanceOf(FalconSealSupport.ActivationConfigException.class)
+ .hasMessageContaining("AERE-PQC-ROUNDCHANGE-CONF-01");
+ }
+ }
+
+ @Test
+ void withNoKeyNothingIsSignedEvenWithTheGateOpen() {
+ // The gate is open from genesis and still nothing comes out: the node has no Falcon key. That
+ // is precisely the condition that makes the binary safe to roll onto the fleet before any
+ // decision is taken.
+ System.setProperty(FalconSealSupport.ROUNDCHANGE_ATTACH_PROPERTY, "0");
+ assertThat(FalconSealSupport.instance().signRoundChange(1L, Bytes32.ZERO)).isEmpty();
+ }
+
+ @Test
+ void theROUNDCHANGEGateIsNoneOfItsThreeSiblings() {
+ // Four layers, four switches. If any two shared one, the day one is turned on would become a
+ // flag day for the other - and commit emission is already on since block 13,889,296 on 2800.
+ assertThat(FalconSealSupport.ROUNDCHANGE_ATTACH_PROPERTY)
+ .isNotEqualTo("aere.falcon.attachBlock");
+ assertThat(FalconSealSupport.ROUNDCHANGE_ATTACH_PROPERTY)
+ .isNotEqualTo(FalconSealSupport.PREPARE_ATTACH_PROPERTY);
+ assertThat(FalconSealSupport.ROUNDCHANGE_ATTACH_PROPERTY)
+ .isNotEqualTo(FalconSealSupport.PROPOSAL_ATTACH_PROPERTY);
+ System.setProperty(FalconSealSupport.ROUNDCHANGE_ATTACH_PROPERTY, "16500000");
+ assertThat(FalconSealSupport.roundChangeAttachBlock()).isEqualTo(16_500_000L);
+ // the commit, PREPARE and PROPOSAL properties stay untouched by the ROUND-CHANGE one
+ assertThat(System.getProperty("aere.falcon.attachBlock")).isNull();
+ assertThat(System.getProperty(FalconSealSupport.PREPARE_ATTACH_PROPERTY)).isNull();
+ assertThat(System.getProperty(FalconSealSupport.PROPOSAL_ATTACH_PROPERTY)).isNull();
+ }
+}
diff --git a/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/network/QbftMessageTransmitter.java b/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/network/QbftMessageTransmitter.java
index f9a92ab..18437e3 100644
--- a/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/network/QbftMessageTransmitter.java
+++ b/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/network/QbftMessageTransmitter.java
@@ -244,4 +244,18 @@ public class QbftMessageTransmitter {
LOG.warn("Failed to generate signature for RoundChange (not sent): {} ", e.getMessage());
}
}
+
+ /**
+ * Multicast an ALREADY BUILT round change, without re-creating it. AERE PQ (2026-08-31): the
+ * height manager signs its round-change ONCE - Falcon signatures are randomised, so re-creating
+ * the message here would put a DIFFERENT object on the wire than the one handled locally, the
+ * exact defect F84 measured on the PROPOSAL (local copy sealed, wire copy not). The seal-less
+ * path keeps using the overload above, call for call as upstream.
+ *
+ * @param roundChange the round change to send, exactly as built
+ */
+ public void multicastRoundChange(final RoundChange roundChange) {
+ final RoundChangeMessageData message = RoundChangeMessageData.create(roundChange);
+ multicaster.send(message);
+ }
}
diff --git a/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/MessageFactory.java b/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/MessageFactory.java
index 5994ab2..11426d6 100644
--- a/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/MessageFactory.java
+++ b/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/MessageFactory.java
@@ -226,6 +226,25 @@ public class MessageFactory {
public RoundChange createRoundChange(
final ConsensusRoundIdentifier roundIdentifier,
final Optional AERE PQ (2026-08-31), the LAST hot-path message: a ROUND-CHANGE MAY carry an OPTIONAL
+ * Falcon-512 seal from its author, appended after the prepared-metadata list, exactly as {@code
+ * ProposalPayload} appends its seal. A round-change without a seal encodes byte for byte as
+ * upstream, which is the property that lets the binary be rolled onto a live fleet without a
+ * flag day.
+ *
+ * NOTHING EMITS SUCH A ROUND-CHANGE YET. This file only makes the wire capable of
+ * carrying one and of refusing a malformed one. Emission has its own gate
+ * ({@code aere.pq.roundChangePq.attachBlock}); enforcement has its own height
+ * ({@code aere.pq.roundChangePq.forkBlock}); both are absent on every node today, and absent means
+ * never.
+ *
+ * What the seal signs is NOT this file's business: its own domain
+ * {@code AERE-PQ-ROUNDCHANGE-1} over (chainId, height, targetRound, prepared metadata) - see
+ * {@code PqAnchor.roundChangeMessage}. A round-change STEERS rounds: a quorum of them opens a new
+ * round, and one claiming a prepared block decides which block gets re-proposed. Its seal must be
+ * transferable neither onto a vote nor onto a proposal, nor between a bare round-change and one
+ * with metadata.
+ *
+ * Why the trailing element is parsed strictly: the payload is AUTHENTICATED - the author
+ * is recovered from the signature over the encoded payload. A decoder that silently ignored an
+ * unknown trailing element would re-encode without it, so two different byte strings would
+ * authenticate to the same author. When a fourth element is present it must be exactly a seal, and
+ * it must be the last element; otherwise the message is refused. Messages of the upstream shape
+ * (three elements) are read exactly as upstream reads them.
+ */
+public class RoundChangePayload extends QbftPayload {
+ private static final int TYPE = QbftV1.ROUND_CHANGE;
+ private final ConsensusRoundIdentifier roundChangeIdentifier;
+ private final Optional From the armed height onwards, a ROUND-CHANGE is not accepted without a valid post-quantum
+ * seal from its OWN author. The structure copies {@link PqProposalEnforcement} line for line on
+ * purpose: a second rendering of the same idea, written differently, diverges eventually.
+ *
+ * WHAT THIS LAYER BUYS, said precisely. With PREPARE armed, an adversary who breaks
+ * ECDSA cannot finalize; with PROPOSAL armed, it cannot open rounds. What it can still do is FORCE
+ * round changes: a forged quorum of round-changes moves every honest node to a round of the
+ * adversary's choosing, over and over - the remaining liveness lever, and, because a round-change
+ * may claim a prepared block, a lever over WHICH block gets re-proposed. This layer closes it: a
+ * round-change whose author cannot produce a Falcon seal does not count towards a round change on
+ * an armed node.
+ *
+ * WHAT THE SEAL SIGNS. Its own domain, {@code AERE-PQ-ROUNDCHANGE-1}, over (chainId,
+ * height, targetRound, prepared metadata). The metadata is in the preimage so a seal from a bare
+ * round-change cannot be pasted onto one that claims a prepared block, and vice versa.
+ *
+ * DISARMED BY DEFAULT. Without the property, {@link #fromSystemConfig()} returns null and
+ * the validator behaves exactly as upstream. A value that is PRESENT but unreadable REFUSES loudly:
+ * a node that boots disarmed because of a mistyped character looks exactly like a correctly
+ * configured one, right up to the day it matters.
+ */
+public final class PqRoundChangeEnforcement {
+
+ /** The height nothing ever reaches: disarmed. */
+ public static final long DISARMED = Long.MAX_VALUE;
+
+ /** The property that arms ROUND-CHANGE enforcement. */
+ public static final String PROPERTY_FORK_BLOCK = "aere.pq.roundChangePq.forkBlock";
+
+ /** The equivalent environment variable. */
+ public static final String ENV_FORK_BLOCK = "AERE_PQ_ROUNDCHANGEPQ_FORKBLOCK";
+
+ private final long armedFromBlock;
+ private final PqSignerRegistry registry;
+ private final long chainId;
+
+ /**
+ * @param armedFromBlock first height (inclusive) at which enforcement applies; {@link #DISARMED}
+ * for today's behaviour
+ * @param registry the signer registry, injected, never a singleton
+ * @param chainId the chain that goes into the signed message
+ */
+ public PqRoundChangeEnforcement(
+ final long armedFromBlock, final PqSignerRegistry registry, final long chainId) {
+ this.armedFromBlock = armedFromBlock;
+ this.registry = registry;
+ this.chainId = chainId;
+ }
+
+ /**
+ * The same enforcement, with the chain id taken from the anchor configuration.
+ *
+ * THE CHAIN ID IS AN ARGUMENT, NOT A GLOBAL - the PROPOSAL enforcement's own test caught the
+ * version that read it mid-decision. The factories fetch the value once, at construction, where
+ * it is visible.
+ *
+ * @param armedFromBlock first height at which enforcement applies
+ * @param registry the signer registry
+ */
+ public PqRoundChangeEnforcement(final long armedFromBlock, final PqSignerRegistry registry) {
+ this(armedFromBlock, registry, PqAnchorProducer.config().chainId());
+ }
+
+ /**
+ * The configured enforcement, read FRESH on every call.
+ *
+ * @return the armed enforcement, or null when the property is set nowhere
+ * @throws IllegalStateException AERE-PQC-ROUNDCHANGE-ENF-01 when the value is present but is not
+ * a non-negative decimal height
+ */
+ public static PqRoundChangeEnforcement fromSystemConfig() {
+ String raw = System.getProperty(PROPERTY_FORK_BLOCK);
+ if (raw == null) {
+ raw = System.getenv(ENV_FORK_BLOCK);
+ }
+ if (raw == null || raw.isBlank()) {
+ return null;
+ }
+ final long armedFrom;
+ try {
+ armedFrom = Long.parseLong(raw.trim());
+ if (armedFrom < 0) {
+ throw new NumberFormatException("negative");
+ }
+ } catch (final NumberFormatException e) {
+ throw new IllegalStateException(
+ "AERE-PQC-ROUNDCHANGE-ENF-01: "
+ + PROPERTY_FORK_BLOCK
+ + " is set but not a non-negative block height: '"
+ + raw
+ + "'. A mistyped value must refuse, never silently disarm.");
+ }
+ return new PqRoundChangeEnforcement(armedFrom, PqSignerRegistry.falconSealSupport());
+ }
+
+ /** Whether enforcement is active at this height. */
+ public boolean armedAt(final long height) {
+ return height >= armedFromBlock;
+ }
+
+ /**
+ * Decides whether a ROUND-CHANGE may be accepted.
+ *
+ * @param height the height the round-change targets (the round's sequence number)
+ * @param targetRound the round the author wants to move to; it is part of the signed message
+ * @param author the RECOVERED author of the signed message (from its ECDSA signature)
+ * @param preparedRoundMetadata the prepared-round claim carried by the payload, if any; it is
+ * part of the signed message
+ * @param seal the post-quantum seal carried by the payload, if any
+ * @return empty when the round-change is acceptable; otherwise the refusal, with names and
+ * numbers
+ */
+ public Optional AERE (2026-08-31): an OPTIONAL post-quantum enforcement hook, gated on height (see {@link
+ * PqRoundChangeEnforcement}). When no enforcement is supplied, behaviour is byte for byte the
+ * upstream one - and that is the configuration of every node today. Same pattern as the PREPARE,
+ * commit and PROPOSAL validators, deliberately: a second rendering of the same idea, written
+ * differently, diverges eventually.
+ *
+ * The 2-argument constructor - the one production code calls, from both {@code
+ * MessageValidatorFactory} and {@code ProposalValidator} (the justification path) - wires the
+ * enforcement itself from the system configuration. That self-wiring is the security property: an
+ * enforcement that had to be passed in explicitly could be dropped by any refactor without a
+ * single test failing. It also means round-changes are judged in BOTH places they arrive:
+ * standalone, and inside a proposal's round-change certificate.
+ */
+public class RoundChangePayloadValidator {
+
+ private static final String ERROR_PREFIX = "Invalid RoundChange Payload";
+ private static final Logger LOG = LoggerFactory.getLogger(RoundChangePayloadValidator.class);
+
+ private final Collection This test exists because of what F84 measured on the PROPOSAL (2026-08-30): the transmitter
+ * re-created the message from scratch, so the local copy carried a seal and the wire copy did not,
+ * and every unit test passed because none looked at what actually leaves the node. The round-change
+ * path has the same shape - the height manager builds a local round-change AND the transmitter used
+ * to build its own - so it gets the same test: the sealed overload must put the EXACT object it was
+ * handed on the wire.
+ */
+@ExtendWith(MockitoExtension.class)
+public class RoundChangeSealPlumbingTest {
+
+ // A round-change targets a positive round.
+ private static final ConsensusRoundIdentifier ROUND_ID = new ConsensusRoundIdentifier(1, 1);
+
+ @Mock private QbftBlockCodec blockEncoder;
+ @Mock private ValidatorMulticaster multicaster;
+
+ private RoundChange sentRoundChange(final Optional The test that matters most is the first one: a round-change without a seal encodes EXACTLY as
+ * upstream does, compared against a hand-built expected form instead of against a run of the code
+ * under test.
+ *
+ * The strictness tests close the malleability door the payload's javadoc names: an
+ * authenticated payload whose decoder silently ignored trailing elements would let two different
+ * byte strings authenticate to the same author.
+ */
+public class RoundChangePayloadPqTest {
+
+ private static final ConsensusRoundIdentifier ROUND_ID = new ConsensusRoundIdentifier(7, 3);
+ private static final Hash PREPARED_HASH = Hash.hash(Bytes.of(1, 2, 3));
+
+ /** The old bare form: RLP[ sequence, round, [] ]. Built by hand. */
+ private static Bytes oldBareForm() {
+ final BytesValueRLPOutput out = new BytesValueRLPOutput();
+ out.startList();
+ out.writeLongScalar(ROUND_ID.getSequenceNumber());
+ out.writeIntScalar(ROUND_ID.getRoundNumber());
+ out.startList();
+ out.endList();
+ out.endList();
+ return out.encoded();
+ }
+
+ @Test
+ public void aRoundChangeWithoutASealEncodesEXACTLYAsBefore() {
+ final RoundChangePayload p = new RoundChangePayload(ROUND_ID, Optional.empty());
+ assertThat(p.encoded()).isEqualTo(oldBareForm());
+ // and the signature hash, which is precisely what binds the author to the message
+ assertThat(p.hashForSignature())
+ .isEqualTo(
+ new RoundChangePayload(ROUND_ID, Optional.empty(), Optional.empty()).hashForSignature());
+ }
+
+ @Test
+ public void aRoundChangeWithPreparedMetadataAndNoSealReadsBackIdentical() {
+ final RoundChangePayload p =
+ new RoundChangePayload(
+ ROUND_ID, Optional.of(new PreparedRoundMetadata(PREPARED_HASH, 1)));
+ final Bytes encoded = p.encoded();
+ final RoundChangePayload read =
+ RoundChangePayload.readFrom(new BytesValueRLPInput(encoded, false));
+ assertThat(read.getFalconSeal()).isEmpty();
+ assertThat(read.getPreparedRoundMetadata()).isPresent();
+ assertThat(read.getPreparedRoundMetadata().get().getPreparedRound()).isEqualTo(1);
+ assertThat(read.getPreparedRoundMetadata().get().getPreparedBlockHash())
+ .isEqualTo(PREPARED_HASH);
+ assertThat(read.encoded()).isEqualTo(encoded);
+ }
+
+ @Test
+ public void aRoundChangeWithASealReadsBackIdentical() {
+ final FalconSeal seal = new FalconSeal(4, Bytes.fromHexString("0xdeadbeef"));
+ final RoundChangePayload p =
+ new RoundChangePayload(ROUND_ID, Optional.empty(), Optional.of(seal));
+ final Bytes encoded = p.encoded();
+
+ // it is longer than the old form, and CONTAINS it as a prefix of the content
+ assertThat(encoded.size()).isGreaterThan(oldBareForm().size());
+
+ final RoundChangePayload read =
+ RoundChangePayload.readFrom(new BytesValueRLPInput(encoded, false));
+ assertThat(read.getFalconSeal()).isPresent();
+ assertThat(read.getFalconSeal().get().getValidatorIndex()).isEqualTo(4);
+ assertThat(read.getFalconSeal().get().getSignature())
+ .isEqualTo(Bytes.fromHexString("0xdeadbeef"));
+ assertThat(read.encoded()).isEqualTo(encoded);
+ }
+
+ @Test
+ public void aSealedRoundChangeWithPreparedMetadataReadsBackIdentical() {
+ final FalconSeal seal = new FalconSeal(2, Bytes.fromHexString("0xfeedface"));
+ final RoundChangePayload p =
+ new RoundChangePayload(
+ ROUND_ID, Optional.of(new PreparedRoundMetadata(PREPARED_HASH, 2)), Optional.of(seal));
+ final Bytes encoded = p.encoded();
+ final RoundChangePayload read =
+ RoundChangePayload.readFrom(new BytesValueRLPInput(encoded, false));
+ assertThat(read.getFalconSeal()).isPresent();
+ assertThat(read.getPreparedRoundMetadata()).isPresent();
+ assertThat(read.encoded()).isEqualTo(encoded);
+ }
+
+ @Test
+ public void theGoldenBytesMatchTheSecondClient() {
+ // The SAME two hex strings are asserted by the second client (AereQbftPrepareSealWireProofTests,
+ // AurRcFaraSigiliu / AurRcCuSigiliu) for the same sequence/round/seal. If either implementation
+ // moves, one of the two tests goes red - the cross-implementation link. A round trip through our
+ // own encoder would only prove that we agree with ourselves.
+ assertThat(new RoundChangePayload(ROUND_ID, Optional.empty()).encoded().toUnprefixedHexString())
+ .isEqualTo("c30703c0");
+ assertThat(
+ new RoundChangePayload(
+ ROUND_ID,
+ Optional.empty(),
+ Optional.of(new FalconSeal(4, Bytes.fromHexString("0xdeadbeef"))))
+ .encoded()
+ .toUnprefixedHexString())
+ .isEqualTo("ca0703c0c60484deadbeef");
+ }
+
+ @Test
+ public void aSealChangesTheSignatureHash() {
+ // If it did not change it, the author's ECDSA signature would not cover the seal, and anyone
+ // could paste a foreign index onto an otherwise valid round-change.
+ final RoundChangePayload without = new RoundChangePayload(ROUND_ID, Optional.empty());
+ final RoundChangePayload with =
+ new RoundChangePayload(
+ ROUND_ID,
+ Optional.empty(),
+ Optional.of(new FalconSeal(4, Bytes.fromHexString("0xdeadbeef"))));
+ assertThat(with.hashForSignature()).isNotEqualTo(without.hashForSignature());
+ }
+
+ @Test
+ public void anElementAfterTheSealIsRefused() {
+ // A decoder that ignored it would re-encode without it: two different byte strings would
+ // authenticate to the same author. It is refused.
+ final BytesValueRLPOutput out = new BytesValueRLPOutput();
+ out.startList();
+ out.writeLongScalar(ROUND_ID.getSequenceNumber());
+ out.writeIntScalar(ROUND_ID.getRoundNumber());
+ out.startList();
+ out.endList();
+ out.startList();
+ out.writeIntScalar(4);
+ out.writeBytes(Bytes.fromHexString("0xdeadbeef"));
+ out.endList();
+ out.writeIntScalar(1); // the trailing element nothing accounts for
+ out.endList();
+
+ assertThatThrownBy(
+ () -> RoundChangePayload.readFrom(new BytesValueRLPInput(out.encoded(), false)))
+ .isInstanceOf(RLPException.class);
+ }
+
+ @Test
+ public void aFourthElementThatIsNotASealIsRefused() {
+ final BytesValueRLPOutput out = new BytesValueRLPOutput();
+ out.startList();
+ out.writeLongScalar(ROUND_ID.getSequenceNumber());
+ out.writeIntScalar(ROUND_ID.getRoundNumber());
+ out.startList();
+ out.endList();
+ out.writeBytes(Bytes.fromHexString("0x01")); // not a [index, signature] list
+ out.endList();
+
+ assertThatThrownBy(
+ () -> RoundChangePayload.readFrom(new BytesValueRLPInput(out.encoded(), false)))
+ .isInstanceOf(RLPException.class);
+ }
+
+ // ---- domain separation: the security part of the design --------------------------------------
+
+ private static final Bytes32 DIGEST =
+ Bytes32.fromHexString("0x000000000000000000000000000000000000000000000000000000000000002a");
+
+ @Test
+ public void theRoundChangeMessageIsNoneOfItsThreeSiblings() {
+ // If any two were the same, a seal given HONESTLY in one role could be replayed in the other:
+ // "move on" counted as a vote, an offer, or the other way around.
+ final Bytes32 rc = PqAnchor.roundChangeMessage(2800L, 100L, 3);
+ assertThat(rc).isNotEqualTo(PqAnchor.prepareMessage(2800L, 100L, 3, DIGEST));
+ assertThat(rc).isNotEqualTo(PqAnchor.proposalMessage(2800L, 100L, 3, DIGEST));
+ assertThat(rc).isNotEqualTo(PqAnchor.commitMessage(2800L, 100L, DIGEST));
+ }
+
+ @Test
+ public void theRoundChangeMessageDependsOnTargetRoundChainAndHeight() {
+ final Bytes32 base = PqAnchor.roundChangeMessage(2800L, 100L, 3);
+ assertThat(PqAnchor.roundChangeMessage(2800L, 100L, 4)).isNotEqualTo(base);
+ assertThat(PqAnchor.roundChangeMessage(2801L, 100L, 3)).isNotEqualTo(base);
+ assertThat(PqAnchor.roundChangeMessage(2800L, 101L, 3)).isNotEqualTo(base);
+ }
+
+ @Test
+ public void aBareAndAPreparedRoundChangeMessageDiffer() {
+ // The metadata is in the preimage: "move on" and "move on and re-propose THIS block" are two
+ // different assertions, and their seals must not be interchangeable.
+ final Bytes32 bare = PqAnchor.roundChangeMessage(2800L, 100L, 3);
+ final Bytes32 prepared = PqAnchor.roundChangeMessage(2800L, 100L, 3, 1, DIGEST);
+ assertThat(prepared).isNotEqualTo(bare);
+ assertThat(PqAnchor.roundChangeMessage(2800L, 100L, 3, 2, DIGEST)).isNotEqualTo(prepared);
+ }
+
+ @Test
+ public void theRoundChangeMessageRefusesImpossibleInputs() {
+ assertThatThrownBy(() -> PqAnchor.roundChangeMessage(2800L, -1L, 3))
+ .isInstanceOf(IllegalArgumentException.class);
+ assertThatThrownBy(() -> PqAnchor.roundChangeMessage(2800L, 100L, -1))
+ .isInstanceOf(IllegalArgumentException.class);
+ assertThatThrownBy(() -> PqAnchor.roundChangeMessage(2800L, 100L, 3, -1, DIGEST))
+ .isInstanceOf(IllegalArgumentException.class);
+ assertThatThrownBy(
+ () -> PqAnchor.roundChangeMessage(2800L, 100L, 3, 1, Bytes.fromHexString("0x00")))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+}
diff --git a/anchor/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqRoundChangeEnforcementTest.java b/anchor/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqRoundChangeEnforcementTest.java
new file mode 100644
index 0000000..5e6b486
--- /dev/null
+++ b/anchor/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqRoundChangeEnforcementTest.java
@@ -0,0 +1,422 @@
+/*
+ * 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.validation;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import org.hyperledger.besu.consensus.common.bft.FalconSeal;
+import org.hyperledger.besu.consensus.common.bft.PqAnchor;
+import org.hyperledger.besu.consensus.common.bft.PqSignerRegistry;
+import org.hyperledger.besu.consensus.common.bft.SealScheme;
+import org.hyperledger.besu.consensus.common.bft.SealSchemes;
+import org.hyperledger.besu.consensus.qbft.core.payload.PreparedRoundMetadata;
+import org.hyperledger.besu.crypto.SecureRandomProvider;
+import org.hyperledger.besu.datatypes.Address;
+import org.hyperledger.besu.datatypes.Hash;
+
+import java.security.SecureRandom;
+import java.util.Map;
+import java.util.Optional;
+
+import org.apache.tuweni.bytes.Bytes;
+import org.apache.tuweni.bytes.Bytes32;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * ROUND-CHANGE ENFORCEMENT. The structure follows PqProposalEnforcementTest deliberately - the
+ * same ways of being wrong, each with the test that closes it, plus the one that is round-change
+ * specific: the PREPARED METADATA is in the preimage, so a seal from a bare round-change cannot be
+ * replayed onto one that claims a prepared block, in either direction.
+ *
+ * The keys are REAL Falcon keys, generated in-process, and verification goes through the real
+ * scheme. A test with fake signatures would prove that we can compare strings, not that the
+ * enforcement enforces.
+ */
+class PqRoundChangeEnforcementTest {
+
+ private static final long ARMED_FROM = 1_000_000L;
+ private static final int TARGET_ROUND = 3;
+ private static final Address AUTHOR_0 = Address.fromHexString("0x" + "aa".repeat(20));
+ private static final Address VALIDATOR_1 = Address.fromHexString("0x" + "bb".repeat(20));
+ private static final Hash PREPARED_DIGEST = Hash.hash(Bytes.of(8, 8, 8));
+ private static final long CHAIN_ID = 2800L;
+
+ private final SecureRandom random = SecureRandomProvider.createSecureRandom();
+
+ @AfterEach
+ void clearProperty() {
+ System.clearProperty(PqRoundChangeEnforcement.PROPERTY_FORK_BLOCK);
+ }
+
+ /** Test registry: programmed index-to-address bindings + verification through the REAL scheme. */
+ private static final class TestRegistry implements PqSignerRegistry {
+ final Map Same reason the PREPARE and PROPOSAL twins exist: a class present in the binary, environment
+ * variables visible to the process, and code that never runs is exactly the failure paid for on
+ * 2026-08-28. The registry here REFUSES everything, so this does not measure cryptography (that has
+ * its own test), only whether the decision passes through the hook. The pair is all it takes: the
+ * same signed round-change, once with enforcement and once without.
+ */
+@ExtendWith(MockitoExtension.class)
+public class RoundChangePayloadValidatorPqWiringTest {
+
+ private static final long HEIGHT = 1L;
+
+ // A round-change must target a POSITIVE round, so the identifier moves to round 1.
+ private final ConsensusRoundIdentifier targetRound = new ConsensusRoundIdentifier((int) HEIGHT, 1);
+ @Mock private QbftBlockCodec blockEncoder;
+
+ private final NodeKey nodeKey = NodeKeyUtils.generate();
+ private final Address author = Util.publicKeyToAddress(nodeKey.getPublicKey());
+ private MessageFactory messageFactory;
+
+ @BeforeEach
+ public void setup() {
+ messageFactory = new MessageFactory(nodeKey, blockEncoder);
+ }
+
+ /** A registry that binds no index and verifies nothing. */
+ private static final class EmptyRegistry implements PqSignerRegistry {
+ @Override
+ public Address addressForIndexAtHistoric(final long blockNumber, final int validatorIndex) {
+ return null;
+ }
+
+ @Override
+ public Address addressForIndexAtOwnHead(final long blockNumber, final int validatorIndex) {
+ return null;
+ }
+
+ @Override
+ public boolean verifyAtHistoric(
+ final long blockNumber, final int validatorIndex, final Bytes message, final Bytes signature) {
+ return false;
+ }
+
+ @Override
+ public boolean verifyAtOwnHead(
+ final long blockNumber, final int validatorIndex, final Bytes message, final Bytes signature) {
+ return false;
+ }
+ }
+
+ private RoundChange unsealedRoundChange() {
+ return messageFactory.createRoundChange(targetRound, Optional.empty());
+ }
+
+ @Test
+ public void withoutEnforcementAnUnsealedRoundChangeISValid() {
+ final RoundChangePayloadValidator validator =
+ new RoundChangePayloadValidator(List.of(author), HEIGHT, null);
+ assertThat(validator.validate(unsealedRoundChange().getSignedPayload())).isTrue();
+ }
+
+ @Test
+ public void withTheEnforcementARMEDTheSameRoundChangeISRefused() {
+ // THE SAME message as above. The only difference is the hook, so a different result means it
+ // really is called. If this still returned true, the enforcement would be dead code.
+ final RoundChangePayloadValidator validator =
+ new RoundChangePayloadValidator(
+ List.of(author), HEIGHT, new PqRoundChangeEnforcement(HEIGHT, new EmptyRegistry(), 2800L));
+ assertThat(validator.validate(unsealedRoundChange().getSignedPayload())).isFalse();
+ }
+
+ @Test
+ public void withTheEnforcementBELOWItsHeightTheSameRoundChangeISValid() {
+ // The third state, closing the last way of being wrong: a hook that refused regardless of
+ // height would make the binary impossible to deploy. Here the enforcement exists but does not
+ // apply yet.
+ final RoundChangePayloadValidator validator =
+ new RoundChangePayloadValidator(
+ List.of(author),
+ HEIGHT,
+ new PqRoundChangeEnforcement(HEIGHT + 1, new EmptyRegistry(), 2800L));
+ assertThat(validator.validate(unsealedRoundChange().getSignedPayload())).isTrue();
+ }
+}
+ *
+ *
+ *