diff --git a/anchor/MANIFEST-sha256.txt b/anchor/MANIFEST-sha256.txt index 2842c3c..1f78fe4 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 -167af0d87b017e1e15ecd2105426b009b6bdaeec5b52f7c1f710d212b0330765 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/FalconSealSupport.java +4b6de4abba87246b18c26fb18a65dfc68831aa981dffefe15eeb2c075fdfcf0f 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 -e9b30713dea69601dd29b3e460d81585a21f5c48286d18f4d3bc5d4c4ec83c19 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchor.java +afe13adaa495c4d0a9ada14df555afcbc6c8d46d8883730ab1ce201499eebc5f 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 @@ -81,23 +81,28 @@ e0bc7a465dbb2dd7efa5f1830211a1a45c6554c300b2088dd70bb9677b0ac217 consensus/comm 9a371ec367cda846640c6da18c8bf7d921a6882cfc024dc0c86d4f965cae0c63 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqStartupHistoryTest.java 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 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 03173f2029f767e10cc978b5576b9557efa783dab2ef105aa856f6d5e08b9352 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/CommitPayload.java -37bda0f126c1333cf854a33c64cc98cedcdc8c0c2ec4cc3ba1f6736e7302ddff consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/MessageFactory.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 -07834160f12c4f28c567959c358176708b17742cc7cc4f49542b165efef5f1c8 consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/QbftRound.java +a56a86a95e3ff7de3c9b22ee6e91c88ed713a3c065cadbb3b6fe205698a4c2c6 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 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 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 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 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 @@ -105,7 +110,9 @@ c0853ed97c53d54951e25ad6d0b70c0299dedd859cc7c44da64e3751d0e0de33 consensus/qbft 5e0bb0ecc77ffb06f232e1aa81cca6870e5455af7c2846ae54d5f477f1bbb88b consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqHybridEnforcementTest.java 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 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 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 diff --git a/anchor/README.md b/anchor/README.md index 2783810..03f9b26 100644 --- a/anchor/README.md +++ b/anchor/README.md @@ -147,6 +147,8 @@ This matters more than the file list, so it is stated before it. | Legacy per-block Falcon rule (`aere.falcon.forkBlock`) | yes | **no** — retired at the anchor block | | Post-quantum seal on PREPARE, emission | yes | **no** — no node sets the property | | Post-quantum seal on PREPARE, enforcement | yes | **no** — no node sets the property | +| Post-quantum seal on the PROPOSAL, emission | yes | **no** — no node sets the property | +| Post-quantum seal on the PROPOSAL, enforcement | yes | **no** — no node sets the property | | Hybrid Falcon + SLH-DSA certificate | yes | **no** — needs new keys, not generated | Everything in the "no" rows is **disarmed by absence, not by a flag**: the properties that arm them @@ -160,6 +162,16 @@ unsealed PREPAREs never reaches the prepared state, so it never sends COMMIT at it has no safety net during an activation, which is why it is not armed anywhere and why its activation height is a decision that has not been taken. +The PROPOSAL layer (added 2026-08-30) closes the remaining hot-path gap, and its claim is stated +precisely because the imprecise version oversells: an adversary who breaks ECDSA cannot finalize +anything while the PREPARE layer is armed, since "prepared" needs a full quorum of PREPAREs. What +forged proposals could still do is OPEN rounds and waste them. With proposal enforcement armed, a +proposal without a valid Falcon seal from its own proposer does not open a round. The seal signs +its own domain over (chainId, height, round, digest), so an honest proposal seal cannot be replayed +as a vote nor a vote seal as a proposal - both directions are tested. What remains classical: the +ROUND-CHANGE message itself (its embedded justifications are already coupled to PREPARE +enforcement) and node-level devp2p authentication. + ### The files - `consensus/common/.../bft/` — the anchor itself: configuration, the digest, the seal cache and 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 6456c42..65fa080 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 @@ -136,10 +136,14 @@ public final class FalconSealSupport { private static final String ANCHOR_SLOT = "0000000000000000000000000000000000000000000000000000000000000000"; - /** Cate sigilii a emis acest nod pe PREPARE. Vezi {@link #preparesSealed()}. */ + /** How many seals this node emitted on its own PREPAREs. See {@link #preparesSealed()}. */ private final java.util.concurrent.atomic.AtomicLong preparesSealed = new java.util.concurrent.atomic.AtomicLong(); + /** How many seals this node emitted on its own PROPOSALs since startup. */ + private final java.util.concurrent.atomic.AtomicLong proposalsSealed = + new java.util.concurrent.atomic.AtomicLong(); + private final boolean signingEnabled; private final int localIndex; private final FalconPrivateKeyParameters localPrivateKey; @@ -3465,9 +3469,10 @@ public final class FalconSealSupport { * Property naming the first height at which this node ATTACHES a post-quantum seal to its own * PREPARE messages. Absent = never, which is the configuration of every node today. * - *
SEPARAT de {@code aere.falcon.attachBlock}, si separarea e obligatorie: daca emiterea pe - * PREPARE ar porni odata cu cea pe commit, ridicarea binarului pe flota ar deveni o zi de flag. - * Asa, binarul poate sta luni de zile pe toate nodurile inainte ca vreunul sa emita ceva nou. + *
SEPARATE from {@code aere.falcon.attachBlock}, and the separation is mandatory: if PREPARE + * emission started together with commit emission, rolling the binary onto the fleet would become + * a flag day. This way the binary can sit on every node for months before any of them emits + * anything new. */ public static final String PREPARE_ATTACH_PROPERTY = "aere.pq.preparePq.attachBlock"; @@ -3576,6 +3581,106 @@ public final class FalconSealSupport { return preparesSealed.get(); } + /** + * Property naming the first height at which this node ATTACHES a post-quantum seal to its own + * PROPOSAL messages. Absent = never, which is the configuration of every node today. + * + *
SEPARATE from both the commit and the PREPARE gates, same reason: three layers that arm on + * three independent decisions must not share a switch, or the day one is turned on becomes a flag + * day for the others. + */ + public static final String PROPOSAL_ATTACH_PROPERTY = "aere.pq.proposalPq.attachBlock"; + + /** Environment fallback for {@link #PROPOSAL_ATTACH_PROPERTY}. */ + public static final String PROPOSAL_ATTACH_ENV = "AERE_PQ_PROPOSALPQ_ATTACHBLOCK"; + + /** + * The configured PROPOSAL 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 proposalAttachBlock() { + final String raw = resolve(PROPOSAL_ATTACH_PROPERTY, PROPOSAL_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-PROPOSAL-CONF-01", + "AERE PQ PROPOSAL: " + + PROPOSAL_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 PROPOSAL attachment disarmed: a disarmed node looks exactly " + + "like a correctly configured one until the day it matters."); + } + } + + /** + * Sign this node's own PROPOSAL, when the PROPOSAL attachment gate is open at this height. + * + *
Same three requirements as {@link #signPrepare}, 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 being proposed
+ * @param message the domain-separated PROPOSAL message (see PqAnchor.proposalMessage)
+ * @return the seal, or empty when any gate is shut
+ */
+ public Optional SEPARATE FROM COMMIT, and the separation is a security requirement, not a matter of style.
- * If a PREPARE seal signed the same bytes as a commit seal, an adversary could take the PREPARE
- * dat CINSTIT de un validator si sa il lipeasca pe un COMMIT falsificat: semnatura ar verifica,
- * si chiar regula pusa sa apere commitul ar fi ocolita. Un singur sir schimbat in preimagine face
- * cele doua semnaturi netransferabile.
+ * If a PREPARE seal signed the same bytes as a commit seal, an adversary could take a PREPARE
+ * seal given HONESTLY by a validator and paste it onto a forged COMMIT: the signature would
+ * verify, and the very rule meant to defend the commit would be bypassed. One changed string in
+ * the preimage makes the two signatures non-transferable.
*/
public static final String PREPARE_DOMAIN = "AERE-PQ-PREPARE-1";
@@ -113,6 +113,21 @@ public final class PqAnchor {
public static final Bytes PREPARE_DOMAIN_BYTES =
Bytes.wrap(PREPARE_DOMAIN.getBytes(StandardCharsets.US_ASCII));
+ /**
+ * The PROPOSAL domain label. AERE PQ (2026-08-30), the next hot-path step after PREPARE.
+ *
+ * SEPARATE FROM BOTH PREPARE AND COMMIT, for the same non-transferability reason: a seal a
+ * proposer gives HONESTLY over its own proposal must not be usable as a vote. A proposal is an
+ * OFFER, not a vote - the design note of 2026-08-28 measures that "prepared" needs a full quorum
+ * of PREPAREs, proposer included - so its seal must never count as one. One changed string in the
+ * preimage is what enforces that at the cryptographic layer instead of by convention.
+ */
+ public static final String PROPOSAL_DOMAIN = "AERE-PQ-PROPOSAL-1";
+
+ /** The proposal domain label as raw bytes. */
+ public static final Bytes PROPOSAL_DOMAIN_BYTES =
+ Bytes.wrap(PROPOSAL_DOMAIN.getBytes(StandardCharsets.US_ASCII));
+
/** Orders Falcon seals by their registry index, ascending. */
public static final Comparator RUNDA E IN PREIMAGINE, spre deosebire de commit, si asta e al doilea lucru care nu se sare:
* doua PREPARE-uri ale aceluiasi bloc in runde diferite sunt doua afirmatii diferite, iar un
- * a seal given in one round must not be movable into another. Without the round, a seal from
- * PREPARE dintr-o runda esuata ar putea fi refolosit ca sa justifice o alta.
+ * a seal given in one round must not be movable into another. Without the round, a seal from a
+ * PREPARE of a failed round could be reused to justify another one.
*
* @param chainId the chain id
* @param blockNumber the height being prepared
@@ -273,6 +288,46 @@ public final class PqAnchor {
return Hash.keccak256(out.encoded());
}
+ /**
+ * The 32-byte message a proposer's post-quantum PROPOSAL seal signs.
+ *
+ * Same shape as {@link #prepareMessage}, different domain, and the ROUND is in the preimage
+ * for the same reason: a proposal for the same block in a different round is a different
+ * assertion, and a seal from a failed round must not open another one.
+ *
+ * @param chainId the chain id
+ * @param blockNumber the height being proposed
+ * @param round the round number of the proposal
+ * @param digest the digest of the proposed block
+ * @return the 32-byte message to sign
+ */
+ public static Bytes32 proposalMessage(
+ final long chainId, final long blockNumber, final int round, final Bytes digest) {
+ if (blockNumber < 0) {
+ throw new IllegalArgumentException(
+ "AERE PQ PROPOSAL: blockNumber must not be negative (got " + blockNumber + ")");
+ }
+ if (round < 0) {
+ throw new IllegalArgumentException(
+ "AERE PQ PROPOSAL: round must not be negative (got " + round + ")");
+ }
+ if (digest == null || digest.size() != 32) {
+ throw new IllegalArgumentException(
+ "AERE PQ PROPOSAL: digest must be 32 bytes (got "
+ + (digest == null ? "null" : digest.size() + " bytes")
+ + ")");
+ }
+ final BytesValueRLPOutput out = new BytesValueRLPOutput();
+ out.startList();
+ out.writeBytes(PROPOSAL_DOMAIN_BYTES);
+ out.writeLongScalar(chainId);
+ out.writeLongScalar(blockNumber);
+ out.writeLongScalar(round);
+ out.writeBytes(digest);
+ 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/ProposalPqAttachGateTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/ProposalPqAttachGateTest.java
new file mode 100644
index 0000000..00c8eac
--- /dev/null
+++ b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/ProposalPqAttachGateTest.java
@@ -0,0 +1,104 @@
+/*
+ * 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 PROPOSAL (AERE PQ, 2026-08-30). Twin of the PREPARE gate,
+ * 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 and a registry bound to addresses, 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 ProposalPqAttachGateTest {
+
+ @AfterEach
+ void clearTheProperty() {
+ System.clearProperty(FalconSealSupport.PROPOSAL_ATTACH_PROPERTY);
+ }
+
+ @Test
+ void withoutThePropertyTheGateIsClosedForever() {
+ assertThat(FalconSealSupport.proposalAttachBlock()).isEqualTo(Long.MAX_VALUE);
+ }
+
+ @Test
+ void aGoodValueIsReadAsGiven() {
+ System.setProperty(FalconSealSupport.PROPOSAL_ATTACH_PROPERTY, "16500000");
+ assertThat(FalconSealSupport.proposalAttachBlock()).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.PROPOSAL_ATTACH_PROPERTY, "0");
+ assertThat(FalconSealSupport.proposalAttachBlock()).isZero();
+ }
+
+ @Test
+ void aMISTYPEDValueRefusesLoudly() {
+ for (final String bad : new String[] {"nu-e-numar", "16_500_000", "1e6", "-1", " "}) {
+ System.setProperty(FalconSealSupport.PROPOSAL_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.proposalAttachBlock()).isEqualTo(Long.MAX_VALUE);
+ continue;
+ }
+ assertThatThrownBy(FalconSealSupport::proposalAttachBlock)
+ .as("the value '%s'", bad)
+ .isInstanceOf(FalconSealSupport.ActivationConfigException.class)
+ .hasMessageContaining("AERE-PQC-PROPOSAL-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.PROPOSAL_ATTACH_PROPERTY, "0");
+ assertThat(FalconSealSupport.instance().signProposal(1L, Bytes32.ZERO)).isEmpty();
+ }
+
+ @Test
+ void thePROPOSALGateIsNotThePREPAREOrCOMMITGate() {
+ // Three layers, three 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.PROPOSAL_ATTACH_PROPERTY).isNotEqualTo("aere.falcon.attachBlock");
+ assertThat(FalconSealSupport.PROPOSAL_ATTACH_PROPERTY)
+ .isNotEqualTo(FalconSealSupport.PREPARE_ATTACH_PROPERTY);
+ System.setProperty(FalconSealSupport.PROPOSAL_ATTACH_PROPERTY, "16500000");
+ assertThat(FalconSealSupport.proposalAttachBlock()).isEqualTo(16_500_000L);
+ // the commit and PREPARE properties stay untouched by the PROPOSAL one
+ assertThat(System.getProperty("aere.falcon.attachBlock")).isNull();
+ assertThat(System.getProperty(FalconSealSupport.PREPARE_ATTACH_PROPERTY)).isNull();
+ }
+}
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 dddb933..5994ab2 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
@@ -82,6 +82,35 @@ public class MessageFactory {
return new Proposal(createSignedMessage(payload), roundChanges, prepares);
}
+ /**
+ * Create proposal carrying the proposer's optional post-quantum seal (AERE PQ, 2026-08-30).
+ *
+ * The seal rides INSIDE the signed payload, so the proposer's ECDSA signature covers it and
+ * nobody can strip or replace it without failing authorship recovery. With the seal absent this
+ * overload produces byte-for-byte what the upstream overload produces.
+ *
+ * @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
+ * @return the proposal
+ */
+ public Proposal createProposal(
+ final ConsensusRoundIdentifier roundIdentifier,
+ final QbftBlock block,
+ final Optional AERE PQ (2026-08-30), the hot-path step after PREPARE: a PROPOSAL MAY carry an OPTIONAL
+ * Falcon-512 seal from its proposer, appended after the block-access-list element, exactly as
+ * {@code PreparePayload} appends its seal. A proposal 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 PROPOSAL 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.proposalPq.attachBlock}); enforcement has its own height
+ * ({@code aere.pq.proposalPq.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-PROPOSAL-1} over (chainId, height, ROUND, digest) - see
+ * {@code PqAnchor.proposalMessage}. A proposal is an offer, not a vote: with the commit or PREPARE
+ * domain, a proposal seal given honestly could be pasted onto a forged vote and counted.
+ *
+ * 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 proposer. When a fifth 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
+ * shapes (three or four elements) are read exactly as upstream reads them.
+ */
+public class ProposalPayload extends QbftPayload {
+
+ private static final int TYPE = QbftV1.PROPOSAL;
+ private final ConsensusRoundIdentifier roundIdentifier;
+ private final QbftBlock proposedBlock;
+ private final QbftBlockCodec blockEncoder;
+ private final Optional Mesajul semnat are DOMENIUL LUI si contine RUNDA - vezi PqAnchor.prepareMessage si
- * proiectarea din 2026-08-28. Cu domeniul commitului, un sigiliu de PREPARE dat cinstit ar putea
- * fi lipit pe un COMMIT falsificat.
+ * The signed message has ITS OWN DOMAIN and contains the ROUND - see PqAnchor.prepareMessage
+ * and the 2026-08-28 design note. Under the commit domain, a PREPARE seal given honestly could
+ * be pasted onto a forged COMMIT.
*/
private Optional Its own domain again - see PqAnchor.proposalMessage. A proposal is an offer, not a vote:
+ * under the PREPARE or commit domain, a proposal seal given honestly could be pasted onto a
+ * forged vote and counted.
+ */
+ private Optional From the armed height onwards, a PROPOSAL is not accepted without a valid post-quantum seal
+ * from its OWN proposer. 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. A proposal is an OFFER, not a vote: the design
+ * note of 2026-08-28 measures that "prepared" needs a full quorum of PREPAREs, proposer included,
+ * so an adversary who breaks ECDSA and forges proposals still cannot finalize anything while the
+ * PREPARE layer is armed. What forged proposals CAN do is start rounds and waste them - steering
+ * which honest proposals get considered and degrading liveness. This layer closes that: a proposal
+ * whose proposer cannot produce a Falcon seal does not even open a round on an armed node.
+ *
+ * WHAT THE SEAL SIGNS. Its own domain, {@code AERE-PQ-PROPOSAL-1}, over (chainId,
+ * height, ROUND, digest). Under the PREPARE or commit domain, a proposal seal given honestly could
+ * be pasted onto a forged vote and counted; one changed string in the preimage makes the
+ * signatures non-transferable in both directions.
+ *
+ * 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 PqProposalEnforcement {
+
+ /** The height nothing ever reaches: disarmed. */
+ public static final long DISARMED = Long.MAX_VALUE;
+
+ /** The property that arms PROPOSAL enforcement. */
+ public static final String PROPERTY_FORK_BLOCK = "aere.pq.proposalPq.forkBlock";
+
+ /** The equivalent environment variable. */
+ public static final String ENV_FORK_BLOCK = "AERE_PQ_PROPOSALPQ_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 PqProposalEnforcement(
+ 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, and its own test caught that: the first version
+ * read it from {@code PqAnchorProducer.config()} in the middle of a consensus decision, so the
+ * test signed over 2800 while the enforcement verified over whatever the process configuration
+ * happened to be. A consensus decision that depends on global state cannot be tested honestly,
+ * and cannot be read either. The factories below fetch the value once, at construction, where it
+ * is visible.
+ *
+ * @param armedFromBlock first height at which enforcement applies
+ * @param registry the signer registry
+ */
+ public PqProposalEnforcement(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-PROPOSAL-ENF-01 when the value is present but is not a
+ * non-negative decimal height
+ */
+ public static PqProposalEnforcement 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-PROPOSAL-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 PqProposalEnforcement(armedFrom, PqSignerRegistry.falconSealSupport());
+ }
+
+ /** Whether enforcement is active at this height. */
+ public boolean armedAt(final long height) {
+ return height >= armedFromBlock;
+ }
+
+ /**
+ * Decides whether a PROPOSAL may be accepted.
+ *
+ * @param height the height the PROPOSAL targets (the round's sequence number)
+ * @param round the PROPOSAL's round; it is part of the signed message
+ * @param author the RECOVERED author of the signed message (from its ECDSA signature)
+ * @param digest the digest of the proposed block
+ * @param seal the post-quantum seal carried by the payload, if any
+ * @return empty when the PROPOSAL is acceptable; otherwise the refusal, with names and numbers
+ */
+ public Optional AERE (2026-08-30): an OPTIONAL post-quantum enforcement hook, gated on height (see {@link
+ * PqProposalEnforcement}). 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 and commit
+ * validators, deliberately: a second rendering of the same idea, written differently, diverges
+ * eventually.
+ *
+ * The 3-argument constructor - the one production code calls - wires the enforcement itself
+ * from the system configuration, exactly as {@link PrepareValidator} does. 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.
+ */
+public class ProposalPayloadValidator {
+
+ private static final String ERROR_PREFIX = "Invalid Proposal Payload";
+
+ private static final Logger LOG = LoggerFactory.getLogger(ProposalPayloadValidator.class);
+ private final Address expectedProposer;
+ private final ConsensusRoundIdentifier targetRound;
+ private final QbftBlockValidator blockValidator;
+ private final PqProposalEnforcement pqEnforcement;
+
+ /**
+ * Instantiates a new Proposal payload validator, with the post-quantum enforcement wired from
+ * the system configuration. This is the constructor production code calls.
+ *
+ * @param expectedProposer the expected proposer
+ * @param targetRound the target round
+ * @param blockValidator the block validator
+ */
+ @VisibleForTesting
+ public ProposalPayloadValidator(
+ final Address expectedProposer,
+ final ConsensusRoundIdentifier targetRound,
+ final QbftBlockValidator blockValidator) {
+ this(expectedProposer, targetRound, blockValidator, PqProposalEnforcement.fromSystemConfig());
+ }
+
+ /**
+ * Instantiates a new Proposal payload validator with an explicit post-quantum enforcement. Test
+ * seam; production goes through the 3-argument constructor above.
+ *
+ * @param expectedProposer the expected proposer
+ * @param targetRound the target round
+ * @param blockValidator the block validator
+ * @param pqEnforcement the post-quantum enforcement, or null for upstream behaviour
+ */
+ @VisibleForTesting
+ public ProposalPayloadValidator(
+ final Address expectedProposer,
+ final ConsensusRoundIdentifier targetRound,
+ final QbftBlockValidator blockValidator,
+ final PqProposalEnforcement pqEnforcement) {
+ this.expectedProposer = expectedProposer;
+ this.targetRound = targetRound;
+ this.blockValidator = blockValidator;
+ this.pqEnforcement = pqEnforcement;
+ }
+
+ /**
+ * Validate.
+ *
+ * @param signedPayload the signed Proposal payload
+ * @return the boolean
+ */
+ public boolean validate(final SignedData The test that matters most is the first one: a proposal without a seal encodes EXACTLY as
+ * upstream does. The block codec is mocked and contributes zero bytes on both sides of the round
+ * trip, which is what lets the surrounding list structure be compared against a hand-built
+ * expected form instead of against a run of the code under test.
+ *
+ * The strictness tests close the malleability door this file's javadoc names: an authenticated
+ * payload whose decoder silently ignored trailing elements would let two different byte strings
+ * authenticate to the same proposer.
+ */
+@ExtendWith(MockitoExtension.class)
+public class ProposalPayloadPqTest {
+
+ private static final ConsensusRoundIdentifier ROUND_ID = new ConsensusRoundIdentifier(7, 3);
+
+ @Mock private QbftBlockCodec blockEncoder;
+ @Mock private QbftBlock block;
+
+ @BeforeEach
+ void wireCodec() {
+ // The mock codec writes nothing and reads nothing: the block contributes zero elements on both
+ // sides, so the tests compare the STRUCTURE around it, which is what this file changed.
+ lenient().when(blockEncoder.readFrom(any())).thenReturn(block);
+ }
+
+ /** The old form with the mocked block: RLP[ sequence, round, null-BAL ]. Built by hand. */
+ private static Bytes oldForm() {
+ final BytesValueRLPOutput out = new BytesValueRLPOutput();
+ out.startList();
+ out.writeLongScalar(ROUND_ID.getSequenceNumber());
+ out.writeIntScalar(ROUND_ID.getRoundNumber());
+ out.writeNull();
+ out.endList();
+ return out.encoded();
+ }
+
+ @Test
+ public void aProposalWithoutASealEncodesEXACTLYAsBefore() {
+ final ProposalPayload p = new ProposalPayload(ROUND_ID, block, blockEncoder, Optional.empty());
+ assertThat(p.encoded()).isEqualTo(oldForm());
+ // and the signature hash, which is precisely what binds the proposer to the message
+ assertThat(p.hashForSignature())
+ .isEqualTo(
+ new ProposalPayload(ROUND_ID, block, blockEncoder, Optional.empty(), Optional.empty())
+ .hashForSignature());
+ }
+
+ @Test
+ public void aProposalWithoutASealReadsBackIdentical() {
+ final Bytes encoded = new ProposalPayload(ROUND_ID, block, blockEncoder, Optional.empty()).encoded();
+ final ProposalPayload read =
+ ProposalPayload.readFrom(new BytesValueRLPInput(encoded, false), blockEncoder);
+ assertThat(read.getFalconSeal()).isEmpty();
+ assertThat(read.getRoundIdentifier()).isEqualTo(ROUND_ID);
+ assertThat(read.encoded()).isEqualTo(encoded);
+ }
+
+ @Test
+ public void aProposalWithASealReadsBackIdentical() {
+ final FalconSeal seal = new FalconSeal(4, Bytes.fromHexString("0xdeadbeef"));
+ final ProposalPayload p =
+ new ProposalPayload(ROUND_ID, block, blockEncoder, 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(oldForm().size());
+
+ final ProposalPayload read =
+ ProposalPayload.readFrom(new BytesValueRLPInput(encoded, false), blockEncoder);
+ 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 aSealChangesTheSignatureHash() {
+ // If it did not change it, the proposer's ECDSA signature would not cover the seal, and anyone
+ // could paste a foreign index onto an otherwise valid proposal.
+ final ProposalPayload without = new ProposalPayload(ROUND_ID, block, blockEncoder, Optional.empty());
+ final ProposalPayload with =
+ new ProposalPayload(
+ ROUND_ID,
+ block,
+ blockEncoder,
+ 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 proposer. It is refused.
+ final BytesValueRLPOutput out = new BytesValueRLPOutput();
+ out.startList();
+ out.writeLongScalar(ROUND_ID.getSequenceNumber());
+ out.writeIntScalar(ROUND_ID.getRoundNumber());
+ out.writeNull();
+ out.startList();
+ out.writeIntScalar(4);
+ out.writeBytes(Bytes.fromHexString("0xdeadbeef"));
+ out.endList();
+ out.writeIntScalar(1); // the trailing element nothing accounts for
+ out.endList();
+
+ assertThatThrownBy(
+ () -> ProposalPayload.readFrom(new BytesValueRLPInput(out.encoded(), false), blockEncoder))
+ .isInstanceOf(RLPException.class);
+ }
+
+ @Test
+ public void aFifthElementThatIsNotASealIsRefused() {
+ final BytesValueRLPOutput out = new BytesValueRLPOutput();
+ out.startList();
+ out.writeLongScalar(ROUND_ID.getSequenceNumber());
+ out.writeIntScalar(ROUND_ID.getRoundNumber());
+ out.writeNull();
+ out.writeBytes(Bytes.fromHexString("0x01")); // not a [index, signature] list
+ out.endList();
+
+ assertThatThrownBy(
+ () -> ProposalPayload.readFrom(new BytesValueRLPInput(out.encoded(), false), blockEncoder))
+ .isInstanceOf(RLPException.class);
+ }
+
+ // ---- domain separation: the security part of the design --------------------------------------
+
+ private static final Bytes32 DIGEST =
+ Bytes32.fromHexString("0x000000000000000000000000000000000000000000000000000000000000002a");
+
+ @Test
+ public void theProposalMessageIsNeitherThePrepareNorTheCommitMessage() {
+ // If any two were the same, a seal given HONESTLY in one role could be replayed in the other:
+ // an offer counted as a vote, or a vote replayed as an offer.
+ final Bytes32 proposal = PqAnchor.proposalMessage(2800L, 100L, 3, DIGEST);
+ assertThat(proposal).isNotEqualTo(PqAnchor.prepareMessage(2800L, 100L, 3, DIGEST));
+ assertThat(proposal).isNotEqualTo(PqAnchor.commitMessage(2800L, 100L, DIGEST));
+ }
+
+ @Test
+ public void theProposalMessageDependsOnROUNDChainAndHeight() {
+ final Bytes32 base = PqAnchor.proposalMessage(2800L, 100L, 3, DIGEST);
+ assertThat(PqAnchor.proposalMessage(2800L, 100L, 4, DIGEST)).isNotEqualTo(base);
+ assertThat(PqAnchor.proposalMessage(2801L, 100L, 3, DIGEST)).isNotEqualTo(base);
+ assertThat(PqAnchor.proposalMessage(2800L, 101L, 3, DIGEST)).isNotEqualTo(base);
+ }
+
+ @Test
+ public void theProposalMessageRefusesImpossibleInputs() {
+ assertThatThrownBy(() -> PqAnchor.proposalMessage(2800L, -1L, 3, DIGEST))
+ .isInstanceOf(IllegalArgumentException.class);
+ assertThatThrownBy(() -> PqAnchor.proposalMessage(2800L, 100L, -1, DIGEST))
+ .isInstanceOf(IllegalArgumentException.class);
+ assertThatThrownBy(() -> PqAnchor.proposalMessage(2800L, 100L, 3, Bytes.fromHexString("0x00")))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+}
diff --git a/anchor/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqProposalEnforcementTest.java b/anchor/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqProposalEnforcementTest.java
new file mode 100644
index 0000000..c4853f6
--- /dev/null
+++ b/anchor/consensus/qbft-core/src/test/java/org/hyperledger/besu/consensus/qbft/core/validation/PqProposalEnforcementTest.java
@@ -0,0 +1,297 @@
+/*
+ * 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.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;
+
+/**
+ * PROPOSAL ENFORCEMENT. The structure follows PqPrepareEnforcementTest deliberately - the same
+ * eight ways of being wrong, each with the test that closes it.
+ *
+ * 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 PqProposalEnforcementTest {
+
+ private static final long ARMED_FROM = 1_000_000L;
+ private static final int ROUND = 3;
+ private static final Address PROPOSER_0 = Address.fromHexString("0x" + "aa".repeat(20));
+ private static final Address VALIDATOR_1 = Address.fromHexString("0x" + "bb".repeat(20));
+ private static final Hash 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(PqProposalEnforcement.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 twin exists: 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 proposal, once with enforcement and once without.
+ */
+@ExtendWith(MockitoExtension.class)
+public class ProposalPayloadValidatorPqWiringTest {
+
+ private static final long HEIGHT = 1L;
+
+ private final ConsensusRoundIdentifier round = new ConsensusRoundIdentifier((int) HEIGHT, 0);
+ @Mock private QbftBlockCodec blockEncoder;
+
+ private final NodeKey nodeKey = NodeKeyUtils.generate();
+ private final Address proposer = 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 Proposal unsealedProposal() {
+ final QbftBlockHeader header =
+ new QbftBlockHeaderTestFixture().number(round.getSequenceNumber()).buildHeader();
+ final QbftBlock block = new QbftBlockTestFixture().blockHeader(header).build();
+ return messageFactory.createProposal(round, block, Optional.empty(), emptyList(), emptyList());
+ }
+
+ @Test
+ public void withoutEnforcementAnUnsealedProposalISValid() {
+ final ProposalPayloadValidator validator =
+ new ProposalPayloadValidator(proposer, round, null, null);
+ assertThat(validator.validateWithoutBlockValidation(unsealedProposal().getSignedPayload()))
+ .isTrue();
+ }
+
+ @Test
+ public void withTheEnforcementARMEDTheSameProposalISRefused() {
+ // 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 ProposalPayloadValidator validator =
+ new ProposalPayloadValidator(
+ proposer, round, null, new PqProposalEnforcement(HEIGHT, new EmptyRegistry(), 2800L));
+ assertThat(validator.validateWithoutBlockValidation(unsealedProposal().getSignedPayload()))
+ .isFalse();
+ }
+
+ @Test
+ public void withTheEnforcementBELOWItsHeightTheSameProposalISValid() {
+ // 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 ProposalPayloadValidator validator =
+ new ProposalPayloadValidator(
+ proposer,
+ round,
+ null,
+ new PqProposalEnforcement(HEIGHT + 1, new EmptyRegistry(), 2800L));
+ assertThat(validator.validateWithoutBlockValidation(unsealedProposal().getSignedPayload()))
+ .isTrue();
+ }
+}
+ *
+ *
+ *