aere-node/patches/0003-aere-pq-anchor.patch
Aere Network 67e3c26e1c Patch 0003 regenerated with the schedule-boundary fix: a package-built node now crosses the first real anchor
The published patch carried a one-block boundary defect: the seal rule asks the
index-to-address map at the parent height (H-1) while the registry schedule
binds inclusively from H, so at the very first anchor the schedule was empty and
the lookup fell back to a head map that is empty on a node synced from genesis.
A valid certificate was rejected. Fixed in both lookup paths: when the schedule
has an entry at exactly blockNumber+1, answer from that entry's verified bound
registry. The boundary stays exactly one block.

Proven by import, not by assertion: the package-built node was stuck at
13,013,999 with 6,646 rejections; with the fix it crossed 13,014,000 in 42
seconds and imported 253,729 further blocks through the anchor era with zero
rejections. Tests: new PqParentHeightAlignmentTest 3/3, consensus:common 391/0,
consensus:qbft 217/0. Negative control measured: disarm the condition and the
measuring test goes red; restore it and it is green.

Patch verified to apply cleanly on a pristine upstream checkout, alone and in
series with 0001/0004/0005; file list is the previous 75 plus the new test, and
every untouched section is byte-identical to the old patch. The anchor/ source
copy in this package was brought to the same state so the two cannot diverge.

Honest limit, recorded in IMPORT-PROOF-STARE: further along, at 13,267,729, the
node stops again for a DIFFERENT reason. Blocks in a band there carry an
attached certificate whose vanityData is the ordinary client string rather than
the digest, at heights a single static interval treats as anchor heights. The
best-supported reading is that the fleet ran a different anchoring configuration
in that window (these values are not consensus-bound), so the package needs a
HISTORICAL anchoring schedule, not one value. Stated rather than implied.
2026-08-16 00:03:20 +03:00

25725 lines
1.2 MiB

From ce832eb0614c6029d1aaa503b3717a83b6d02734 Mon Sep 17 00:00:00 2001
From: Aere Network <node@aere.network>
Date: Sat, 15 Aug 2026 23:43:50 +0300
Subject: [PATCH] Aere Network: post-quantum certificate anchor for QBFT
Puts a 32-byte digest of the validator certificate into vanityData, which is
already covered by the block hash, so that the hash commits to the certificate.
The certificate itself still rides outside the hash and is pinned by the digest.
Apache License 2.0, section 4(b): the files this patch modifies are the work of
Hyperledger Besu and carry its copyright notice. This patch changes them. The
modifications are by contributors to the Aere Network and are offered under the
same Apache License 2.0; the upstream copyright headers are left as found.
Consensus on chain 2800 remains classical secp256k1 ECDSA. This binds a
post-quantum certificate to the block hash; it does not make consensus
post-quantum.
Updated 2026-08-15: repairs the schedule boundary at the first anchor height.
The seals rule resolves the certificate carried by the block at height H
against the registry at the parent height H-1, and the height-to-registry
schedule binds inclusively from H, so at H-1 both height-resolved lookups fell
back to the head registry, which is empty on a node syncing from genesis, and
a valid certificate was refused one block below the first anchor. Both lookups
now answer that one question from the schedule entry bound exactly one block
above, using the verified registry; two or more blocks below the schedule
nothing changes. Covered by the new PqParentHeightAlignmentTest, and measured
on a node synced from genesis, which now crosses the first anchor height
instead of stopping one block below it.
---
.../org/hyperledger/besu/cli/BesuCommand.java | 24 +
.../cli/options/AerePqEmergencyOptions.java | 245 ++
.../controller/QbftBesuControllerBuilder.java | 273 ++
.../options/AerePqEmergencyOptionsTest.java | 136 +
.../besu/config/JsonGenesisConfigOptions.java | 23 +
consensus/common/build.gradle | 7 +
.../common/bft/BftBlockInterface.java | 22 +-
.../consensus/common/bft/BftExtraData.java | 50 +-
.../besu/consensus/common/bft/FalconSeal.java | 88 +
.../common/bft/FalconSealSupport.java | 3749 +++++++++++++++++
.../besu/consensus/common/bft/PqAnchor.java | 335 ++
.../consensus/common/bft/PqAnchorConfig.java | 1320 ++++++
.../common/bft/PqAnchorNotReadyException.java | 132 +
.../common/bft/PqAnchorSyncModeGuard.java | 130 +
.../common/bft/PqAnchorThresholdGuard.java | 252 ++
.../common/bft/PqRegistryBinding.java | 497 +++
.../consensus/common/bft/PqRegistryHash.java | 2341 ++++++++++
.../common/bft/PqRegistryHashTool.java | 482 +++
.../consensus/common/bft/PqSealCache.java | 351 ++
.../consensus/common/bft/PqSealStore.java | 384 ++
.../common/bft/PqSignerRegistry.java | 172 +
.../blockcreation/BftBlockCreatorFactory.java | 25 +-
.../bft/blockcreation/PqAnchorProducer.java | 347 ++
.../common/bft/tools/PqRegistryHashTool.java | 231 +
.../common/bft/FalconAttachIntervalTest.java | 232 +
.../common/bft/PqAnchorConfigTest.java | 691 +++
.../bft/PqAnchorEmergencyConfigTest.java | 120 +
.../common/bft/PqAnchorIntervalTest.java | 186 +
.../common/bft/PqAnchorMinSealsFloorTest.java | 153 +
.../common/bft/PqAnchorProducerCostTest.java | 251 ++
.../common/bft/PqAnchorSealCapTest.java | 150 +
.../consensus/common/bft/PqAnchorTest.java | 301 ++
.../bft/PqAnchorThresholdGuardTest.java | 215 +
.../common/bft/PqArmingGateTest.java | 388 ++
.../common/bft/PqCallerIntentTest.java | 386 ++
.../common/bft/PqFleetRestartArmingTest.java | 390 ++
.../common/bft/PqForkArmingTest.java | 368 ++
.../bft/PqForkThresholdReachabilityTest.java | 355 ++
.../bft/PqForkValidatorSetChangeTest.java | 427 ++
.../common/bft/PqInertBinaryTest.java | 475 +++
.../bft/PqParentHeightAlignmentTest.java | 229 +
.../common/bft/PqRegistryBindingTest.java | 595 +++
.../bft/PqRegistryHeightRefusalTest.java | 328 ++
.../common/bft/PqRegistryRotationTest.java | 476 +++
.../common/bft/PqSealPersistenceTest.java | 621 +++
.../common/bft/PqSignedHeightTest.java | 403 ++
.../common/bft/PqStartupHistoryTest.java | 325 ++
.../consensus/common/bft/PqV2Fixture.java | 233 +
.../qbft/core/messagewrappers/Commit.java | 18 +
.../core/network/QbftMessageTransmitter.java | 25 +-
.../qbft/core/payload/CommitPayload.java | 147 +-
.../qbft/core/payload/MessageFactory.java | 28 +-
.../statemachine/QbftBlockHeightManager.java | 29 +-
.../qbft/core/statemachine/QbftRound.java | 191 +-
.../qbft/core/statemachine/RoundState.java | 25 +
.../qbft/core/types/QbftBlockCreator.java | 37 +
...ftBlockHeaderValidationRulesetFactory.java | 110 +-
.../consensus/qbft/QbftExtraDataCodec.java | 92 +-
.../qbft/adaptor/QbftBlockCreatorAdaptor.java | 214 +-
.../QbftBlockCreatorFactory.java | 35 +-
.../FalconSealValidationRule.java | 501 +++
.../PqAnchorDigestAttachedRule.java | 115 +
.../PqAnchorDigestRule.java | 207 +
.../PqAnchorSealsRule.java | 329 ++
.../PqEmergencyShoutRule.java | 134 +
.../PqRegistryBindingRule.java | 91 +
.../qbft/QbftAnchorRuleWiringTest.java | 194 +
.../FalconSealLogThrottleTest.java | 182 +
...alconSealValidationRuleRetirementTest.java | 85 +
.../PqAnchorDigestRuleTest.java | 359 ++
.../PqAnchorSealsRuleTest.java | 690 +++
.../PqAnchorTestSupport.java | 144 +
.../PqArmedWithoutRegistryTest.java | 185 +
.../PqEmergencyShoutRuleTest.java | 78 +
.../PqForkGateFeedTest.java | 189 +
.../eth/sync/DownloadHeadersStep.java | 103 +-
76 files changed, 24406 insertions(+), 45 deletions(-)
create mode 100755 app/src/main/java/org/hyperledger/besu/cli/options/AerePqEmergencyOptions.java
create mode 100755 app/src/test/java/org/hyperledger/besu/cli/options/AerePqEmergencyOptionsTest.java
create mode 100755 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/FalconSeal.java
create mode 100755 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/FalconSealSupport.java
create mode 100755 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchor.java
create mode 100755 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorConfig.java
create mode 100755 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorNotReadyException.java
create mode 100755 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorSyncModeGuard.java
create mode 100755 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorThresholdGuard.java
create mode 100755 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqRegistryBinding.java
create mode 100755 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqRegistryHash.java
create mode 100755 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqRegistryHashTool.java
create mode 100755 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqSealCache.java
create mode 100755 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqSealStore.java
create mode 100755 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqSignerRegistry.java
create mode 100755 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/blockcreation/PqAnchorProducer.java
create mode 100755 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/tools/PqRegistryHashTool.java
create mode 100755 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/FalconAttachIntervalTest.java
create mode 100755 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorConfigTest.java
create mode 100755 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorEmergencyConfigTest.java
create mode 100755 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorIntervalTest.java
create mode 100755 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorMinSealsFloorTest.java
create mode 100755 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorProducerCostTest.java
create mode 100755 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorSealCapTest.java
create mode 100755 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorTest.java
create mode 100755 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorThresholdGuardTest.java
create mode 100755 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqArmingGateTest.java
create mode 100755 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqCallerIntentTest.java
create mode 100755 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqFleetRestartArmingTest.java
create mode 100755 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqForkArmingTest.java
create mode 100755 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqForkThresholdReachabilityTest.java
create mode 100755 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqForkValidatorSetChangeTest.java
create mode 100755 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqInertBinaryTest.java
create mode 100644 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqParentHeightAlignmentTest.java
create mode 100755 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqRegistryBindingTest.java
create mode 100755 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqRegistryHeightRefusalTest.java
create mode 100755 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqRegistryRotationTest.java
create mode 100755 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqSealPersistenceTest.java
create mode 100755 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqSignedHeightTest.java
create mode 100755 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqStartupHistoryTest.java
create mode 100755 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqV2Fixture.java
create mode 100755 consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/FalconSealValidationRule.java
create mode 100755 consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorDigestAttachedRule.java
create mode 100755 consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorDigestRule.java
create mode 100755 consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorSealsRule.java
create mode 100755 consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqEmergencyShoutRule.java
create mode 100755 consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqRegistryBindingRule.java
create mode 100755 consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/QbftAnchorRuleWiringTest.java
create mode 100755 consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/FalconSealLogThrottleTest.java
create mode 100755 consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/FalconSealValidationRuleRetirementTest.java
create mode 100755 consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorDigestRuleTest.java
create mode 100755 consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorSealsRuleTest.java
create mode 100755 consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorTestSupport.java
create mode 100755 consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqArmedWithoutRegistryTest.java
create mode 100755 consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqEmergencyShoutRuleTest.java
create mode 100755 consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqForkGateFeedTest.java
diff --git a/app/src/main/java/org/hyperledger/besu/cli/BesuCommand.java b/app/src/main/java/org/hyperledger/besu/cli/BesuCommand.java
index 2767db2eb..df19c1dd2 100644
--- a/app/src/main/java/org/hyperledger/besu/cli/BesuCommand.java
+++ b/app/src/main/java/org/hyperledger/besu/cli/BesuCommand.java
@@ -11,6 +11,12 @@
* specific language governing permissions and limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
+ *
+ * Modifications Copyright contributors to the Aere Network.
+ *
+ * This file was modified by contributors to the Aere Network, as required by section 4(b) of the
+ * Apache License 2.0. The copyright header above is the upstream one and is left exactly as it was
+ * found, as section 4(c) requires. The change itself is in patches/0003-aere-pq-anchor.patch.
*/
package org.hyperledger.besu.cli;
@@ -41,6 +47,7 @@ import org.hyperledger.besu.cli.config.ProfilesCompletionCandidates;
import org.hyperledger.besu.cli.custom.JsonRPCAllowlistHostsProperty;
import org.hyperledger.besu.cli.error.BesuExecutionExceptionHandler;
import org.hyperledger.besu.cli.error.BesuParameterExceptionHandler;
+import org.hyperledger.besu.cli.options.AerePqEmergencyOptions;
import org.hyperledger.besu.cli.options.ApiConfigurationOptions;
import org.hyperledger.besu.cli.options.BalConfigurationOptions;
import org.hyperledger.besu.cli.options.ChainPruningOptions;
@@ -299,6 +306,13 @@ public class BesuCommand implements DefaultCommandValues, Runnable {
private final ChainPruningOptions unstableChainPruningOptions = ChainPruningOptions.create();
private final QBFTOptions unstableQbftOptions = QBFTOptions.create();
+ /**
+ * AERE OPTIUNI-URGENTA (2026-08-02): the three post-quantum emergency controls, so that standing
+ * the anchor down is a RESTART and not a rebuild. See {@link AerePqEmergencyOptions}.
+ */
+ private final AerePqEmergencyOptions unstableAerePqEmergencyOptions =
+ AerePqEmergencyOptions.create();
+
// stable CLI options
final DataStorageOptions dataStorageOptions = DataStorageOptions.create();
private final EthstatsOptions ethstatsOptions = EthstatsOptions.create();
@@ -950,6 +964,13 @@ public class BesuCommand implements DefaultCommandValues, Runnable {
logger.info("Starting Besu");
+ // AERE OPTIUNI-URGENTA (2026-08-02): apply the post-quantum emergency controls FIRST, before
+ // anything that reads them. FalconSealSupport runs its guards in its constructor and
+ // PqAnchorConfig is resolved while the protocol schedule is assembled, both of which happen
+ // inside buildController() further down. This is also the earliest point at which logging is
+ // configured, so the banner lands where an operator will see it.
+ unstableAerePqEmergencyOptions.applyAndAnnounce();
+
// set merge config on the basis of genesis config
setMergeConfigOptions();
@@ -1243,6 +1264,9 @@ public class BesuCommand implements DefaultCommandValues, Runnable {
.put("IPC Options", unstableIpcOptions)
.put("Chain Data Pruning Options", unstableChainPruningOptions)
.put("QBFT Options", unstableQbftOptions)
+ // AERE OPTIUNI-URGENTA: registered here so `besu --Xhelp` LISTS them. An emergency
+ // control nobody can find at three in the morning is not a control.
+ .put("AERE post-quantum emergency controls", unstableAerePqEmergencyOptions)
.build();
UnstableOptionsSubCommand.createUnstableOptions(commandLine, unstableOptions);
diff --git a/app/src/main/java/org/hyperledger/besu/cli/options/AerePqEmergencyOptions.java b/app/src/main/java/org/hyperledger/besu/cli/options/AerePqEmergencyOptions.java
new file mode 100755
index 000000000..0d685347a
--- /dev/null
+++ b/app/src/main/java/org/hyperledger/besu/cli/options/AerePqEmergencyOptions.java
@@ -0,0 +1,245 @@
+/*
+ * Copyright contributors to Besu / Aere Network.
+ *
+ * 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.cli.options;
+
+import org.hyperledger.besu.consensus.common.bft.FalconSealSupport;
+import org.hyperledger.besu.consensus.common.bft.PqAnchorConfig;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import picocli.CommandLine;
+
+/**
+ * AERE OPTIUNI-URGENTA (2026-08-02): the three emergency controls for the post-quantum certificate
+ * anchor, as COMMAND-LINE OPTIONS.
+ *
+ * <p><b>The requirement, in one sentence: there has to be a way back that does not need a build.</b>
+ * If the anchor misbehaves on the live chain at three in the morning, the person on the other end of
+ * the page has to be able to stand it down with a RESTART. Until this class existed the controls
+ * were real but reachable only as system properties and environment variables, which in practice
+ * means editing a service unit or a wrapper script on every node of the fleet under time pressure,
+ * in a file whose syntax nobody remembers, with no {@code --help} to check against. Two of these
+ * options were already named in the javadoc of {@code PqAnchorConfig} as though they existed. They
+ * did not.
+ *
+ * <p><b>The three controls, and why exactly these three.</b>
+ *
+ * <ul>
+ * <li>{@code --Xaere-pq-anchor-disarm} switches both anchor rules off on this node. This is the
+ * blunt instrument, and it is the one to reach for when the anchor itself is rejecting
+ * headers the fleet agrees are good.
+ * <li>{@code --Xaere-pq-anchor-min-seals-max} caps the seal threshold K. It can only ever LOWER
+ * it: the effective threshold is {@code min(scheduled, ceiling)}. A control that could raise K
+ * would be a way to halt a chain, not a way to restart one, so it is not offered. This is the
+ * one to reach for when the chain has stalled because proposers cannot gather K seals - too
+ * many validators down, a key rotation half-done - and the rest of the scheme is fine.
+ * <li>{@code --Xaere-pq-registry-mismatch-allow} lets a node START and KEEP RUNNING with a Falcon
+ * registry that does not satisfy what genesis requires. This is the way back from the
+ * REGISTRY BINDING guard, which is a CONFIGURATION guard: one wrong byte in a registry file
+ * pushed to the fleet stops every node it reached, for a reason that has nothing to do with
+ * whether the blocks are valid.
+ * </ul>
+ *
+ * <p><b>Every one of them shouts.</b> A quiet way out is worse than no way out, because it will be
+ * set once in an emergency and found six months later on a fleet everybody believed was enforcing
+ * something. So each control that is IN FORCE costs one complete ERROR line at EVERY height, for as
+ * long as the node runs: see {@code PqEmergencyShoutRule} and {@code
+ * FalconSealSupport.shoutRegistryOverrideUnsafe}. Nothing is emitted when nothing is overridden, so
+ * a clean log is a measurable statement and not an absence of instrumentation.
+ *
+ * <p><b>How they take effect, and why through the properties.</b> Each option writes the SAME system
+ * property the control has always read, before anything reads it. That is deliberate: it leaves
+ * exactly one place where each decision is made, so the command line cannot mean something subtly
+ * different from the environment variable, and the code that was measured under the registry
+ * binding work and under the anchor work is the code still doing the deciding. Precedence is
+ * command line, then system property, then environment variable; which source won is written into
+ * the log so an operator never has to guess whether the flag took.
+ *
+ * <p><b>Deliberately LOCAL, not on-chain.</b> A halted chain cannot deliver a height-scheduled
+ * configuration change. The only control that works when the chain is ALREADY STOPPED is one that
+ * lives in the node's own start-up and takes effect on restart, and restarting a quorum of nodes is
+ * the right price for a change of this weight.
+ */
+public class AerePqEmergencyOptions {
+
+ private static final Logger LOG = LoggerFactory.getLogger(AerePqEmergencyOptions.class);
+
+ /** The stable log code for the startup banner listing every override in force. */
+ public static final String BANNER_CODE = "AERE-PQC-EMG-BANNER-01";
+
+ /** Default constructor. */
+ private AerePqEmergencyOptions() {}
+
+ /**
+ * Create a new instance.
+ *
+ * @return a new instance
+ */
+ public static AerePqEmergencyOptions create() {
+ return new AerePqEmergencyOptions();
+ }
+
+ @CommandLine.Option(
+ names = {"--Xaere-pq-anchor-disarm"},
+ description =
+ "AERE EMERGENCY. Switch the post-quantum certificate anchor rules OFF on this node. Takes "
+ + "effect on restart, needs no rebuild, and makes this node log one ERROR line per "
+ + "block for as long as it is set. Recovery setting only. (default: ${DEFAULT-VALUE})",
+ arity = "0..1",
+ fallbackValue = "true",
+ paramLabel = "<true|false>",
+ hidden = true)
+ private Boolean anchorDisarm = null;
+
+ @CommandLine.Option(
+ names = {"--Xaere-pq-anchor-min-seals-max"},
+ description =
+ "AERE EMERGENCY. Cap the post-quantum seal threshold K at this value. It can only LOWER "
+ + "the scheduled threshold, never raise it. Use when the chain has stalled because "
+ + "proposers cannot gather K seals. Logs one ERROR line per block while it is "
+ + "actually lowering something. (default: ${DEFAULT-VALUE})",
+ paramLabel = "<integer>",
+ hidden = true)
+ private Integer minSealsMax = null;
+
+ @CommandLine.Option(
+ names = {"--Xaere-pq-registry-mismatch-allow"},
+ description =
+ "AERE EMERGENCY. Start and keep running even though this node's Falcon registry does not "
+ + "satisfy the binding genesis requires. The node then does NOT verify post-quantum "
+ + "certificates and says so at every height. Recovery setting only. (default: "
+ + "${DEFAULT-VALUE})",
+ arity = "0..1",
+ fallbackValue = "true",
+ paramLabel = "<true|false>",
+ hidden = true)
+ private Boolean registryMismatchAllow = null;
+
+ /**
+ * Apply whatever was given on the command line onto the system properties the controls read, and
+ * announce the resulting state.
+ *
+ * <p>Call this ONCE, early in startup and before the controller is built, because {@code
+ * PqAnchorConfig.fromSystemConfiguration()} and the {@code FalconSealSupport} constructor both
+ * read these properties while the protocol schedule is being assembled.
+ *
+ * <p>An out-of-range ceiling REFUSES TO START rather than being ignored. An emergency control that
+ * silently declines to do what the operator typed is the worst possible behaviour for a control
+ * used under pressure: the operator would believe the chain had been given a way out and would go
+ * looking somewhere else for why it had not moved.
+ *
+ * @throws IllegalArgumentException when an option value is out of range
+ */
+ public void applyAndAnnounce() {
+ final List<String> inForce = new ArrayList<>();
+
+ if (minSealsMax != null && minSealsMax < 0) {
+ throw new IllegalArgumentException(
+ "--Xaere-pq-anchor-min-seals-max must not be negative (got "
+ + minSealsMax
+ + "). Use 0 to require no post-quantum seals at all.");
+ }
+
+ if (anchorDisarm != null) {
+ System.setProperty(PqAnchorConfig.PROPERTY_DISABLE, anchorDisarm.toString());
+ }
+ if (minSealsMax != null) {
+ System.setProperty(PqAnchorConfig.PROPERTY_MIN_SEALS_CEILING, minSealsMax.toString());
+ }
+ if (registryMismatchAllow != null) {
+ System.setProperty(
+ FalconSealSupport.PROPERTY_REGISTRY_MISMATCH_ALLOW, registryMismatchAllow.toString());
+ }
+
+ // Read back from the properties, NOT from the fields. What is announced has to be what the
+ // controls will actually see, including the case where the operator set the environment
+ // variable and nothing on the command line.
+ if (Boolean.parseBoolean(
+ effective(PqAnchorConfig.PROPERTY_DISABLE, "AERE_PQ_ANCHOR_DISABLE"))) {
+ inForce.add(
+ "ANCHOR DISARMED (both certificate-anchor rules off) via "
+ + origin(anchorDisarm, PqAnchorConfig.PROPERTY_DISABLE, "AERE_PQ_ANCHOR_DISABLE"));
+ }
+ final String ceiling =
+ effective(PqAnchorConfig.PROPERTY_MIN_SEALS_CEILING, "AERE_PQ_ANCHOR_MIN_SEALS_MAX");
+ if (ceiling != null) {
+ inForce.add(
+ "SEAL THRESHOLD CAPPED at K<="
+ + ceiling
+ + " (lowering only, never raising) via "
+ + origin(
+ minSealsMax,
+ PqAnchorConfig.PROPERTY_MIN_SEALS_CEILING,
+ "AERE_PQ_ANCHOR_MIN_SEALS_MAX"));
+ }
+ if (Boolean.parseBoolean(
+ effective(
+ FalconSealSupport.PROPERTY_REGISTRY_MISMATCH_ALLOW,
+ FalconSealSupport.ENV_REGISTRY_MISMATCH_ALLOW))) {
+ inForce.add(
+ "REGISTRY BINDING BYPASSED (this node may run without verifying post-quantum "
+ + "certificates) via "
+ + origin(
+ registryMismatchAllow,
+ FalconSealSupport.PROPERTY_REGISTRY_MISMATCH_ALLOW,
+ FalconSealSupport.ENV_REGISTRY_MISMATCH_ALLOW));
+ }
+
+ if (inForce.isEmpty()) {
+ // Say NOTHING. The silence is a measurement: a node with no AERE-PQC-EMG line in its log is
+ // running with no emergency control in force, and that has to be checkable by grep.
+ return;
+ }
+
+ LOG.error(
+ "AERE PQC EMERGENCY [{}]: this node is starting with {} POST-QUANTUM SAFETY CONTROL(S) "
+ + "OVERRIDDEN: {}. These are RECOVERY settings. They reduce what this node checks, they "
+ + "are local to this node and bound to nothing, and a fleet running on them is not "
+ + "running the scheme it is documented as running. Each one in force writes a line at "
+ + "every block, on purpose. Remove them and restart as soon as the cause is fixed.",
+ BANNER_CODE,
+ inForce.size(),
+ String.join(" | ", inForce));
+ }
+
+ /** The value a control will actually see: system property first, then environment variable. */
+ private static String effective(final String property, final String environmentVariable) {
+ final String fromProperty = System.getProperty(property);
+ if (fromProperty != null && !fromProperty.isBlank()) {
+ return fromProperty.trim();
+ }
+ final String fromEnvironment = System.getenv(environmentVariable);
+ if (fromEnvironment != null && !fromEnvironment.isBlank()) {
+ return fromEnvironment.trim();
+ }
+ return null;
+ }
+
+ /** Where the value in force came from, so nobody has to guess whether the flag took. */
+ private static String origin(
+ final Object fromCommandLine, final String property, final String environmentVariable) {
+ if (fromCommandLine != null) {
+ return "the COMMAND LINE";
+ }
+ final String fromProperty = System.getProperty(property);
+ if (fromProperty != null && !fromProperty.isBlank()) {
+ return "system property " + property;
+ }
+ return "environment variable " + environmentVariable;
+ }
+}
diff --git a/app/src/main/java/org/hyperledger/besu/controller/QbftBesuControllerBuilder.java b/app/src/main/java/org/hyperledger/besu/controller/QbftBesuControllerBuilder.java
index 7fbf58d0b..c245c06d2 100644
--- a/app/src/main/java/org/hyperledger/besu/controller/QbftBesuControllerBuilder.java
+++ b/app/src/main/java/org/hyperledger/besu/controller/QbftBesuControllerBuilder.java
@@ -11,6 +11,12 @@
* specific language governing permissions and limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
+ *
+ * Modifications Copyright contributors to the Aere Network.
+ *
+ * This file was modified by contributors to the Aere Network, as required by section 4(b) of the
+ * Apache License 2.0. The copyright header above is the upstream one and is left exactly as it was
+ * found, as section 4(c) requires. The change itself is in patches/0003-aere-pq-anchor.patch.
*/
package org.hyperledger.besu.controller;
@@ -18,6 +24,7 @@ import static com.google.common.base.Preconditions.checkNotNull;
import org.hyperledger.besu.config.BftConfigOptions;
import org.hyperledger.besu.config.BftFork;
+import org.hyperledger.besu.config.JsonGenesisConfigOptions;
import org.hyperledger.besu.config.QbftConfigOptions;
import org.hyperledger.besu.config.QbftFork;
import org.hyperledger.besu.consensus.common.BftValidatorOverrides;
@@ -33,7 +40,14 @@ import org.hyperledger.besu.consensus.common.bft.BftRoundExpiryTimeCalculator;
import org.hyperledger.besu.consensus.common.bft.BlockTimer;
import org.hyperledger.besu.consensus.common.bft.EthSynchronizerUpdater;
import org.hyperledger.besu.consensus.common.bft.EventMultiplexer;
+import org.hyperledger.besu.consensus.common.bft.FalconSealSupport;
import org.hyperledger.besu.consensus.common.bft.MessageTracker;
+import org.hyperledger.besu.consensus.common.bft.PqAnchorConfig;
+import org.hyperledger.besu.consensus.common.bft.PqAnchorSyncModeGuard;
+import org.hyperledger.besu.consensus.common.bft.PqAnchorThresholdGuard;
+import org.hyperledger.besu.consensus.common.bft.PqRegistryHash;
+import org.hyperledger.besu.consensus.common.bft.PqSealCache;
+import org.hyperledger.besu.consensus.common.bft.PqSignerRegistry;
import org.hyperledger.besu.consensus.common.bft.RoundTimer;
import org.hyperledger.besu.consensus.common.bft.UniqueMessageMulticaster;
import org.hyperledger.besu.consensus.common.bft.blockcreation.BftMiningCoordinator;
@@ -197,6 +211,212 @@ public class QbftBesuControllerBuilder extends BesuControllerBuilder {
final SyncState syncState,
final EthProtocolManager ethProtocolManager) {
final MutableBlockchain blockchain = protocolContext.getBlockchain();
+
+ // AERE GARDA-ACTIVARE (2026-08-01): the CHAIN-RELATIVE half of the Falcon seal-attachment guard.
+ //
+ // FalconSealSupport is constructed eagerly while the protocol schedule is built, which is early
+ // enough to validate the configuration against ITSELF but too early to see a chain. That left a
+ // measured hole: attachmentArmed() only ever required blockNumber >= attachBlock, so a validator
+ // restarted with an activation height the chain had ALREADY PASSED started with no complaint and
+ // armed on its first commit, emitting Falcon-carrying Commits into a fleet that had not armed.
+ //
+ // This is the first point in startup where a chain head exists, and it is still before the
+ // network and the QBFT state machine are started, so an abort here is a clean refusal to start
+ // rather than a mid-flight halt. The predicate answers "does the header at this height carry at
+ // least one Falcon seal", which is the on-chain proof that the fleet legitimately armed and is
+ // what lets a crashed validator restart into a live activation instead of being locked out.
+ FalconSealSupport.instance()
+ .verifyAttachHeightAgainstChainHeadOrAbort(
+ blockchain.getChainHeadBlockNumber(),
+ n ->
+ blockchain
+ .getBlockHeader(n)
+ .map(h -> !qbftExtraDataCodec.decode(h).getFalconSeals().isEmpty())
+ .orElse(false),
+ dataDirectory);
+
+ // AERE GARDA-CHEIE-MASINA (2026-08-07): the Falcon key loaded must be THIS machine's own.
+ //
+ // Measured on seven test nodes with a single variable changed: node 0 was handed index 1's key.
+ // It started cleanly, logged index 1 -- the very same line the node that REALLY is index 1
+ // writes -- and the chain crossed the K=3 step without a pause, with zero rejected blocks and
+ // zero divergence, because the seals VERIFY. What was lost in silence: index 0 disappeared from
+ // the signer set and index 1 was counted twice.
+ //
+ // This is the first point in startup where the node knows its own validator address, and it is
+ // still before the network and the QBFT state machine, so a refusal here is a clean refusal to
+ // start. The guard is INERT for as long as there is no aere.falcon.key and no address-bound
+ // registry, so on 2800 as it stands today it cannot fire.
+ FalconSealSupport.instance()
+ .verifyLocalKeyBelongsToThisNodeOrAbort(Util.publicKeyToAddress(nodeKey.getPublicKey()));
+
+ // AERE BLOCAJ-REPORNIRE (2026-08-05): activate the late anchor FROM THE CHAIN HEAD STATE, at
+ // startup, and not only when a block is imported.
+ //
+ // WHY, and this is a measured chain death, not a supposition. The full activation rehearsal, on
+ // a seven-node test network, found a CIRCULAR DEADLOCK nobody had been looking for:
+ // activateLateAnchor() was called ONLY from FalconSealValidationRule, that is only while
+ // validating an IMPORTED block. After a simultaneous restart of the whole fleet no block is
+ // imported, because nobody proposes. So lateActivated stays false, attachmentArmed() answers
+ // false, no seal is attached, no certificate reaches K, and nobody can propose. Seals come
+ // from Commits, Commits come from proposals, and proposals need seals.
+ // The node's exact message, verbatim from the run: "refusing to propose ... holds 0 valid
+ // eligible Falcon seal(s) ... threshold is 3", over "Attachment stays OFF (fail-safe)".
+ // With K=0 the chain heals itself. With K>0 it NEVER heals.
+ //
+ // A simultaneous restart of the seven is not an exotic scenario: it is a power cut, a scheduled
+ // kernel update, or any procedure that starts the fleet all at once.
+ //
+ // The repair invents nothing and weakens no check: it does here, once, exactly what the import
+ // path already did, reading the same slot 0 of the same contract, out of the chain head state.
+ // activateLateAnchor stays idempotent and fail-closed: on a hash mismatch the registry stays
+ // EMPTY and the path is terminally failed, exactly as before. If the head state cannot be read,
+ // nothing changes and the import path remains the second chance.
+ //
+ // Deliberately placed before the registry guard and before the QBFT machine starts, so that a
+ // refusal here is still a clean refusal to start.
+ if (FalconSealSupport.instance().anchorAddress() != null) {
+ try {
+ final BlockHeader aereCap = blockchain.getChainHeadHeader();
+ protocolContext
+ .getWorldStateArchive()
+ .get(aereCap.getStateRoot(), aereCap.getHash())
+ .ifPresent(
+ ws -> {
+ final var cont = ws.get(Address.fromHexString(FalconSealSupport.instance().anchorAddress()));
+ if (cont == null) {
+ return;
+ }
+ final var slot0 = cont.getStorageValue(org.apache.tuweni.units.bigints.UInt256.ZERO);
+ if (slot0 == null || slot0.isZero()) {
+ return;
+ }
+ final boolean armat =
+ FalconSealSupport.instance()
+ .activateLateAnchor(slot0.toBytes().toUnprefixedHexString());
+ LOG.info(
+ "AERE PQC: anchor activation at STARTUP from the chain head state {}: {}. "
+ + "Without it, a simultaneous fleet restart with K>0 deadlocks the chain "
+ + "for good (measured on a seven-node test network, 2026-08-05).",
+ aereCap.getNumber(),
+ armat ? "SUCCEEDED" : "failed, the import path remains the second chance");
+ });
+ } catch (final Exception e) {
+ LOG.warn(
+ "AERE PQC: the anchor activation at startup could not be done ({}); the import path "
+ + "remains the second chance, but after a fleet restart with K>0 that path never "
+ + "opens again.",
+ e.toString());
+ }
+ }
+
+ // AERE REGISTRY-BINDING (2026-08-01): bind the Falcon registry to consensus.
+ //
+ // Deliberately placed immediately after the attachment guard and before BftExecutors, for the
+ // same reason: a chain head exists here, and the network and the QBFT state machine have not
+ // been started, so a refusal is a clean refusal to start.
+ //
+ // Deliberately placed BEFORE the attachment guard would ever let a seal be emitted: a node whose
+ // registry disagrees with its peers must not reach the point of signing or verifying anything.
+ // The two guards answer different questions - "is the activation height sane relative to this
+ // chain" and "is this the registry this chain requires" - and both have to be true.
+ //
+ // AERE REGISTRY-BINDING (2026-08-02): the SCHEDULE comes from the genesis configuration BESU
+ // BOOTED WITH, not from a genesis file re-opened by path from a system property. Re-reading a
+ // file would have reproduced the defect one level up: the enforced binding would again depend
+ // on a local file a node can be pointed at wrongly, and a node reading a stale copy would
+ // enforce a stale schedule, or none, in silence. Read from GenesisConfigOptions there is no
+ // second file: the value enforced comes out of the same object that produced this node's
+ // genesis hash, so a node that disagrees about the schedule already disagrees about the chain.
+ //
+ // MEASURED, and it is the input that decides the hash: the live chain 2800 genesis carries
+ // config.chainId = 2800, so getChainId() is PRESENT and the 0L fallback below is not the value
+ // in play. That had been recorded as unmeasured, and any registry hash pinned into genesis has
+ // to be computed under the same chain id the guard will use.
+ final PqRegistryHash.Schedule pqRegistrySchedule =
+ PqRegistryHash.parseSchedule(
+ aereGenesisConfigNode("pqRegistryHash"), "booted genesis config.pqRegistryHash");
+ FalconSealSupport.instance()
+ .verifyRegistryBindingOrAbort(
+ blockchain.getChainHeadBlockNumber(),
+ genesisConfigOptions
+ .getChainId()
+ .map(java.math.BigInteger::longValue)
+ .orElse(0L),
+ pqRegistrySchedule);
+
+ // AERE SINCRONIZARE (2026-08-02): the anchor is only a defence if it applies to the history this
+ // node ACCEPTS, and not only to the blocks it is offered as proposals. Outside FULL sync the
+ // header import path never instantiates a header validator at all, so both anchor rules would be
+ // skipped for the entire span between checkpoint and pivot. Refuse to start rather than run a
+ // node that believes it is protected and is not.
+ //
+ // Placed with the other two startup guards and for the same reason: a chain head exists here, the
+ // network is not up and the QBFT state machine has not been created, so this is a clean refusal
+ // to start. Inert when aere.pq.anchorBlock is unset, which is chain 2800 as it stands today.
+ // AERE GARDA-PRAG (2026-08-02): read the anchor configuration ONCE, here, and hand the SAME
+ // object to every guard that needs it. Two calls to fromSystemConfiguration would print the
+ // ARMED banner twice and, worse, would give two guards two independent chances to disagree
+ // about what is actually configured on this node.
+ final PqAnchorConfig aereAnchorConfig = PqAnchorConfig.fromSystemConfiguration();
+
+ PqAnchorSyncModeGuard.verifySyncModeOrAbort(
+ syncConfig == null || syncConfig.getSyncMode() == null
+ ? null
+ : syncConfig.getSyncMode().name(),
+ aereAnchorConfig);
+
+ // AERE PERSISTENTA-SIGILII (2026-08-05): THE SECOND HALF of the chain death on a whole-fleet
+ // restart. The first half is repaired above (BLOCAJ-REPORNIRE): the registry now arms at startup
+ // from the chain head state, and all seven nodes reported SUCCEEDED. The chain died anyway, the
+ // refusal only changing shape: "registry address-bound=TRUE ... Heard 0 seal(s)", frozen 150 s
+ // on the first restart and 298 s on the second.
+ //
+ // WHY. Falcon seals over M(head) travel ONLY on the Commit messages of the head block. Those
+ // messages are never repeated after a restart, and they exist nowhere else: the head block
+ // carries in element 6 the certificate over its PARENT, not over itself. So every node started
+ // with zero seals, nobody reached K, nobody could propose, and so nobody sent another Commit.
+ // The same circular deadlock, one level down.
+ //
+ // WHY THIS IS NOT A REGISTRY-TRUSTED-FROM-A-FILE IN NEW CLOTHES, and this is the whole security
+ // argument: the seal is SELF-VERIFYING. Every seal read from the file is cryptographically
+ // verified again against the anchored registry, over an M rebuilt from the head header this very
+ // process has just loaded, exactly as the producer does at selection time. A forged file cannot
+ // inject a seal without forging a Falcon-512 signature; all it can obtain is the empty cache an
+ // absent file already gives. The defect back then was a REGISTRY of keys trusted because it sat
+ // in a file.
+ //
+ // Deliberately here: the registry is already armed by the block above (otherwise no seal could
+ // resolve and the restore would have gone quiet for nothing), the chain head exists, and the
+ // network and the QBFT machine have not started. Inert when the anchor is not armed, so chain
+ // 2800 as it stands today sees nothing.
+ if (aereAnchorConfig.everActive()) {
+ PqSealCache.instance().enablePersistence(dataDirectory, aereAnchorConfig.chainId());
+ try {
+ final BlockHeader aereCapSigilii = blockchain.getChainHeadHeader();
+ final int aereSigiliiRestaurate =
+ PqSealCache.instance()
+ .restoreFromDisk(
+ aereCapSigilii.getNumber(),
+ aereCapSigilii.getHash(),
+ PqSignerRegistry.falconSealSupport());
+ LOG.info(
+ "AERE PERSISTENTA-SIGILII: {} VERIFIED seal(s) restored for chain head {} ({}). The "
+ + "threshold demanded at the next height is K={}. Zero means this node cannot "
+ + "propose until it hears a Commit, which after a fleet restart with K>0 never "
+ + "happens again on its own.",
+ aereSigiliiRestaurate,
+ aereCapSigilii.getNumber(),
+ aereCapSigilii.getHash(),
+ aereAnchorConfig.minSealsAt(aereCapSigilii.getNumber() + 1L));
+ } catch (final Exception e) {
+ LOG.warn(
+ "AERE PERSISTENTA-SIGILII: restoring the chain head seals could not be done ({}); the "
+ + "node starts with an empty cache, exactly as before this repair.",
+ e.toString());
+ }
+ }
+
final BftExecutors bftExecutors =
BftExecutors.create(metricsSystem, BftExecutors.ConsensusType.QBFT);
final QbftBlockCodec blockEncoder = new QbftBlockCodecAdaptor(qbftExtraDataCodec);
@@ -226,6 +446,22 @@ public class QbftBesuControllerBuilder extends BesuControllerBuilder {
validatorProvider =
protocolContext.getConsensusContext(BftContext.class).getValidatorProvider();
}
+
+ // AERE GARDA-PRAG (2026-08-02): refuse to start on a seal threshold that STOPS the chain.
+ //
+ // Measured before this line was written: nothing anywhere refused a threshold value. Searched
+ // the whole fork tree for any occurrence of minSeals near quorum, throw, refuse, abort or
+ // Illegal, and there were ZERO. PqAnchorConfig rejects a negative K and a step scheduled below
+ // the activation height, and nothing else; the only other check, PqAnchorProducer's
+ // warnIfActivationHeightCanRefuse, writes one LOG.error and returns. So a value at or above the
+ // QBFT quorum was a configuration a node accepted in silence and then halted on.
+ //
+ // Placed here rather than beside the other three startup guards because this is the FIRST point
+ // at which the validator set is resolved, and the bound is a function of its size. It is still
+ // before the network exists (ValidatorPeers is built below) and before the QBFT state machine is
+ // created, so a refusal here is a clean refusal to start rather than a mid-flight halt.
+ PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort(
+ aereAnchorConfig, validatorProvider.getValidatorsAtHead().size());
final QbftValidatorProvider qbftValidatorProvider =
new QbftValidatorProviderAdaptor(validatorProvider);
@@ -447,6 +683,43 @@ public class QbftBesuControllerBuilder extends BesuControllerBuilder {
return new BftValidatorOverrides(result);
}
+ /**
+ * AERE REGISTRY-BINDING: read a genesis {@code config.*} value that Besu itself does not model,
+ * out of the genesis configuration THIS NODE BOOTED WITH.
+ *
+ * <p>Besu's {@code GenesisConfigOptions.asMap()} cannot be used for this: it is an allow-list of
+ * the keys Besu knows about, so a key of ours is simply absent from it and the guard would read
+ * "no schedule" on a genesis that plainly carries one. That failure would be silent and would
+ * disarm the binding on exactly the fleet that had configured it, which is worse than not having
+ * written it.
+ *
+ * <p>MEASURED, and it is why the lookup is done twice: Besu normalises every genesis config key
+ * to lower case in {@code GenesisReader} via {@code JsonUtil.normalizeKeys}, so {@code
+ * pqRegistryHash} as written by an operator arrives here as {@code pqregistryhash}. Looking up
+ * only the camel-case spelling would find nothing on a real genesis file. Both spellings are
+ * tried so the guard cannot be switched off by a rebase that changes the normalisation.
+ *
+ * <p>Returns null when the key is absent, which {@code PqRegistryHash.parseSchedule} turns into
+ * the empty schedule, i.e. nothing is enforced and the existing chain is untouched.
+ */
+ private com.fasterxml.jackson.databind.JsonNode aereGenesisConfigNode(final String key) {
+ if (!(genesisConfigOptions instanceof JsonGenesisConfigOptions)) {
+ LOG.warn(
+ "AERE PQC REGISTRY-BINDING: the genesis configuration is a {}, not the JSON-backed "
+ + "implementation, so config.{} cannot be read and the Falcon registry binding is "
+ + "NOT ENFORCED on this node. A binding everybody believes is on and is not is worse "
+ + "than no binding.",
+ genesisConfigOptions.getClass().getName(),
+ key);
+ return null;
+ }
+ final JsonGenesisConfigOptions json = (JsonGenesisConfigOptions) genesisConfigOptions;
+ final com.fasterxml.jackson.databind.JsonNode exact = json.aereRawConfigValue(key);
+ return exact != null
+ ? exact
+ : json.aereRawConfigValue(key.toLowerCase(java.util.Locale.ROOT));
+ }
+
private static MinedBlockObserver blockLogger(
final TransactionPool transactionPool, final Address localAddress) {
return block ->
diff --git a/app/src/test/java/org/hyperledger/besu/cli/options/AerePqEmergencyOptionsTest.java b/app/src/test/java/org/hyperledger/besu/cli/options/AerePqEmergencyOptionsTest.java
new file mode 100755
index 000000000..e74459f24
--- /dev/null
+++ b/app/src/test/java/org/hyperledger/besu/cli/options/AerePqEmergencyOptionsTest.java
@@ -0,0 +1,136 @@
+/*
+ * Copyright contributors to Besu / Aere Network.
+ *
+ * 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.cli.options;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import org.hyperledger.besu.consensus.common.bft.FalconSealSupport;
+import org.hyperledger.besu.consensus.common.bft.PqAnchorConfig;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import picocli.CommandLine;
+
+/**
+ * AERE OPTIUNI-URGENTA: the command line really reaches the controls.
+ *
+ * <p>These options exist for exactly one reason - that standing the post-quantum anchor down must be
+ * a RESTART and never a rebuild - so the thing worth asserting is the hand-off: a flag typed on the
+ * command line ends up in the same system property the control has always read, and it wins over a
+ * property already set, so an operator who types the flag does not have to know what a wrapper
+ * script did earlier.
+ */
+class AerePqEmergencyOptionsTest {
+
+ private static final String[] PROPERTIES = {
+ PqAnchorConfig.PROPERTY_DISABLE,
+ PqAnchorConfig.PROPERTY_MIN_SEALS_CEILING,
+ FalconSealSupport.PROPERTY_REGISTRY_MISMATCH_ALLOW
+ };
+
+ @BeforeEach
+ @AfterEach
+ void clearProperties() {
+ for (final String property : PROPERTIES) {
+ System.clearProperty(property);
+ }
+ }
+
+ private AerePqEmergencyOptions parse(final String... args) {
+ final AerePqEmergencyOptions options = AerePqEmergencyOptions.create();
+ new CommandLine(options).parseArgs(args);
+ return options;
+ }
+
+ @Test
+ void nothingOnTheCommandLineTouchesNothing() {
+ parse().applyAndAnnounce();
+ for (final String property : PROPERTIES) {
+ assertThat(System.getProperty(property)).isNull();
+ }
+ }
+
+ @Test
+ void bareDisarmFlagArmsTheDisarm() {
+ parse("--Xaere-pq-anchor-disarm").applyAndAnnounce();
+ assertThat(System.getProperty(PqAnchorConfig.PROPERTY_DISABLE)).isEqualTo("true");
+ }
+
+ @Test
+ void disarmCanBeSpelledOutAndCanBeSpelledFalse() {
+ parse("--Xaere-pq-anchor-disarm=false").applyAndAnnounce();
+ assertThat(System.getProperty(PqAnchorConfig.PROPERTY_DISABLE)).isEqualTo("false");
+ }
+
+ @Test
+ void theCommandLineBeatsAPropertySetEarlier() {
+ // The case this protects: a wrapper script or a unit file left the disarm on, and the operator
+ // now wants it off. Typing the flag has to be enough.
+ System.setProperty(PqAnchorConfig.PROPERTY_DISABLE, "true");
+ parse("--Xaere-pq-anchor-disarm=false").applyAndAnnounce();
+ assertThat(System.getProperty(PqAnchorConfig.PROPERTY_DISABLE)).isEqualTo("false");
+ }
+
+ @Test
+ void ceilingReachesTheProperty() {
+ parse("--Xaere-pq-anchor-min-seals-max=2").applyAndAnnounce();
+ assertThat(System.getProperty(PqAnchorConfig.PROPERTY_MIN_SEALS_CEILING)).isEqualTo("2");
+ }
+
+ @Test
+ void aCeilingOfZeroIsAReadableValueAndNotAnAbsentOne() {
+ parse("--Xaere-pq-anchor-min-seals-max=0").applyAndAnnounce();
+ assertThat(System.getProperty(PqAnchorConfig.PROPERTY_MIN_SEALS_CEILING)).isEqualTo("0");
+ }
+
+ @Test
+ void aNegativeCeilingRefusesToStartRatherThanBeingIgnored() {
+ assertThatThrownBy(() -> parse("--Xaere-pq-anchor-min-seals-max=-1").applyAndAnnounce())
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("must not be negative");
+ assertThat(System.getProperty(PqAnchorConfig.PROPERTY_MIN_SEALS_CEILING)).isNull();
+ }
+
+ @Test
+ void registryBypassReachesTheProperty() {
+ parse("--Xaere-pq-registry-mismatch-allow").applyAndAnnounce();
+ assertThat(System.getProperty(FalconSealSupport.PROPERTY_REGISTRY_MISMATCH_ALLOW))
+ .isEqualTo("true");
+ }
+
+ @Test
+ void allThreeCanBeGivenTogether() {
+ parse(
+ "--Xaere-pq-anchor-disarm",
+ "--Xaere-pq-anchor-min-seals-max=0",
+ "--Xaere-pq-registry-mismatch-allow")
+ .applyAndAnnounce();
+ assertThat(System.getProperty(PqAnchorConfig.PROPERTY_DISABLE)).isEqualTo("true");
+ assertThat(System.getProperty(PqAnchorConfig.PROPERTY_MIN_SEALS_CEILING)).isEqualTo("0");
+ assertThat(System.getProperty(FalconSealSupport.PROPERTY_REGISTRY_MISMATCH_ALLOW))
+ .isEqualTo("true");
+ }
+
+ @Test
+ void whatTheCommandLineWritesIsWhatPqAnchorConfigThenReads() {
+ // The hand-off end to end, through the real reader rather than a re-implementation of it.
+ parse("--Xaere-pq-anchor-disarm", "--Xaere-pq-anchor-min-seals-max=1").applyAndAnnounce();
+ final PqAnchorConfig read = PqAnchorConfig.fromSystemConfiguration();
+ assertThat(read.disabled()).isTrue();
+ assertThat(read.minSealsCeiling()).hasValue(1);
+ }
+}
diff --git a/config/src/main/java/org/hyperledger/besu/config/JsonGenesisConfigOptions.java b/config/src/main/java/org/hyperledger/besu/config/JsonGenesisConfigOptions.java
index eeaa4ac3e..e0d6a6025 100644
--- a/config/src/main/java/org/hyperledger/besu/config/JsonGenesisConfigOptions.java
+++ b/config/src/main/java/org/hyperledger/besu/config/JsonGenesisConfigOptions.java
@@ -11,6 +11,12 @@
* specific language governing permissions and limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
+ *
+ * Modifications Copyright contributors to the Aere Network.
+ *
+ * This file was modified by contributors to the Aere Network, as required by section 4(b) of the
+ * Apache License 2.0. The copyright header above is the upstream one and is left exactly as it was
+ * found, as section 4(c) requires. The change itself is in patches/0003-aere-pq-anchor.patch.
*/
package org.hyperledger.besu.config;
@@ -639,4 +645,21 @@ public class JsonGenesisConfigOptions implements GenesisConfigOptions {
public int hashCode() {
return Objects.hash(configRoot, configOverrides);
}
+
+ /**
+ * AERE REGISTRY-BINDING: the raw genesis {@code config.*} value for a key Besu does not model, or
+ * null.
+ *
+ * <p>WHY THIS EXISTS. {@link #asMap()} is an allow-list of the keys Besu knows, so a key of ours
+ * is absent from it, and a guard reading it would conclude "no schedule" on a genesis that
+ * plainly carries one. Silently disarming a binding on exactly the fleet that configured it is
+ * worse than never having written the binding. This accessor is additive and read-only: it
+ * changes no Besu behaviour and returns the node exactly as the genesis reader normalised it.
+ *
+ * @param key the config key, as normalised by the genesis reader (lower case)
+ * @return the value node, or null when absent
+ */
+ public com.fasterxml.jackson.databind.JsonNode aereRawConfigValue(final String key) {
+ return configRoot.get(key);
+ }
}
diff --git a/consensus/common/build.gradle b/consensus/common/build.gradle
index 499c974f1..a6f5276f2 100644
--- a/consensus/common/build.gradle
+++ b/consensus/common/build.gradle
@@ -11,6 +11,12 @@
* specific language governing permissions and limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
+ *
+ * Modifications Copyright contributors to the Aere Network.
+ *
+ * This file was modified by contributors to the Aere Network, as required by section 4(b) of the
+ * Apache License 2.0. The copyright header above is the upstream one and is left exactly as it was
+ * found, as section 4(c) requires. The change itself is in patches/0003-aere-pq-anchor.patch.
*/
apply plugin: 'java-library'
@@ -47,6 +53,7 @@ dependencies {
implementation 'com.fasterxml.jackson.core:jackson-databind'
implementation 'com.google.guava:guava'
+ implementation 'org.bouncycastle:bcprov-jdk18on'
implementation 'io.consensys.tuweni:tuweni-bytes'
testImplementation project(':config')
diff --git a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/BftBlockInterface.java b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/BftBlockInterface.java
index b7a886c29..da9a3e3ae 100644
--- a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/BftBlockInterface.java
+++ b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/BftBlockInterface.java
@@ -11,6 +11,12 @@
* specific language governing permissions and limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
+ *
+ * Modifications Copyright contributors to the Aere Network.
+ *
+ * This file was modified by contributors to the Aere Network, as required by section 4(b) of the
+ * Apache License 2.0. The copyright header above is the upstream one and is left exactly as it was
+ * found, as section 4(c) requires. The change itself is in patches/0003-aere-pq-anchor.patch.
*/
package org.hyperledger.besu.consensus.common.bft;
@@ -79,6 +85,19 @@ public class BftBlockInterface implements BlockInterface {
/**
* Replace round in block.
*
+ * <p>AERE ANCHOR V2, MEASURED DEFECT, FIXED HERE. This method used the five argument {@link
+ * BftExtraData} constructor, which defaults the Falcon seal collection to EMPTY, so every round
+ * replacement silently DELETED element 6. That is harmless while element 6 only carries a
+ * log-only attestation, and fatal under V2: the round change path
+ * ({@code QbftRound.startRoundWith} to {@code replaceRoundAndProposerForProposalBlock}) re-uses a
+ * prepared block, and it would have re-proposed it with vanityData still carrying the digest D of
+ * a certificate that had just been stripped out. The digest rule would then reject every single
+ * re-proposed block, on a chain where rounds above zero do occur (measured 1 in 307).
+ *
+ * <p>Carrying the seals across is safe for every existing user of this method, because the Falcon
+ * list is excluded from BOTH hash pre-images: neither the committed-seal hash nor the on-chain
+ * block hash changes. IBFT, which shares this class, always has an empty list here.
+ *
* @param block the block
* @param round the round
* @param blockHeaderFunctions the block header functions
@@ -93,7 +112,8 @@ public class BftBlockInterface implements BlockInterface {
prevExtraData.getSeals(),
prevExtraData.getVote(),
round,
- prevExtraData.getValidators());
+ prevExtraData.getValidators(),
+ prevExtraData.getFalconSeals());
final BlockHeaderBuilder headerBuilder = BlockHeaderBuilder.fromHeader(block.getHeader());
headerBuilder
diff --git a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/BftExtraData.java b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/BftExtraData.java
index a24c33a93..913f68f0a 100644
--- a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/BftExtraData.java
+++ b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/BftExtraData.java
@@ -11,6 +11,12 @@
* specific language governing permissions and limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
+ *
+ * Modifications Copyright contributors to the Aere Network.
+ *
+ * This file was modified by contributors to the Aere Network, as required by section 4(b) of the
+ * Apache License 2.0. The copyright header above is the upstream one and is left exactly as it was
+ * found, as section 4(c) requires. The change itself is in patches/0003-aere-pq-anchor.patch.
*/
package org.hyperledger.besu.consensus.common.bft;
@@ -21,6 +27,7 @@ import org.hyperledger.besu.datatypes.Address;
import org.hyperledger.besu.ethereum.core.ParsedExtraData;
import java.util.Collection;
+import java.util.Collections;
import java.util.Optional;
import org.apache.tuweni.bytes.Bytes;
@@ -34,7 +41,15 @@ public class BftExtraData implements ParsedExtraData {
private final int round;
/**
- * Instantiates a new Bft extra data.
+ * Parallel, ADDITIVE post-quantum (Falcon-512) seals. These sit alongside the decisive ECDSA
+ * {@link #seals} and are excluded from every signed pre-image, so they never disturb the ECDSA
+ * committed seals or the block/header hash.
+ */
+ private final Collection<FalconSeal> falconSeals;
+
+ /**
+ * Instantiates a new Bft extra data (no Falcon seals). Retained for callers, notably the IBFT
+ * codec, that never carry a parallel post-quantum seal.
*
* @param vanityData the vanity data
* @param seals the seals
@@ -48,14 +63,36 @@ public class BftExtraData implements ParsedExtraData {
final Optional<Vote> vote,
final int round,
final Collection<Address> validators) {
+ this(vanityData, seals, vote, round, validators, Collections.emptyList());
+ }
+
+ /**
+ * Instantiates a new Bft extra data with a parallel Falcon-512 seal list.
+ *
+ * @param vanityData the vanity data
+ * @param seals the ECDSA committed seals (decisive)
+ * @param vote the vote
+ * @param round the round
+ * @param validators the validators
+ * @param falconSeals the parallel post-quantum Falcon seals (additive, log-only)
+ */
+ public BftExtraData(
+ final Bytes vanityData,
+ final Collection<SECPSignature> seals,
+ final Optional<Vote> vote,
+ final int round,
+ final Collection<Address> validators,
+ final Collection<FalconSeal> falconSeals) {
checkNotNull(vanityData);
checkNotNull(seals);
checkNotNull(validators);
+ checkNotNull(falconSeals);
this.vanityData = vanityData;
this.seals = seals;
this.validators = validators;
this.vote = vote;
this.round = round;
+ this.falconSeals = falconSeals;
}
/**
@@ -103,6 +140,15 @@ public class BftExtraData implements ParsedExtraData {
return round;
}
+ /**
+ * Gets the parallel post-quantum Falcon seals.
+ *
+ * @return the Falcon seals (possibly empty, never null)
+ */
+ public Collection<FalconSeal> getFalconSeals() {
+ return falconSeals;
+ }
+
@Override
public String toString() {
return "BftExtraData{"
@@ -116,6 +162,8 @@ public class BftExtraData implements ParsedExtraData {
+ vote
+ ", round="
+ round
+ + ", falconSeals="
+ + falconSeals
+ '}';
}
}
diff --git a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/FalconSeal.java b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/FalconSeal.java
new file mode 100755
index 000000000..578b6f5aa
--- /dev/null
+++ b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/FalconSeal.java
@@ -0,0 +1,88 @@
+/*
+ * Copyright contributors to Besu / Aere Network.
+ *
+ * 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 java.util.Objects;
+
+import org.apache.tuweni.bytes.Bytes;
+
+/**
+ * A parallel post-quantum (Falcon-512) seal attached to a BFT block header, ALONGSIDE (never
+ * replacing) the decisive ECDSA committed seals.
+ *
+ * <p>Because Falcon signatures are not public-key-recoverable, each seal carries an explicit {@code
+ * validatorIndex} so a verifier can look up the signer's Falcon public key in the validator to
+ * Falcon-pubkey registry. This is an additive, log-only attestation for the hybrid PQC demo; ECDSA
+ * committed seals remain the liveness-critical seal that decides block acceptance.
+ */
+public class FalconSeal {
+ private final int validatorIndex;
+ private final Bytes signature;
+
+ /**
+ * Instantiates a new Falcon seal.
+ *
+ * @param validatorIndex the index of the signing validator in the Falcon registry
+ * @param signature the Falcon-512 signature over the block commit hash
+ */
+ public FalconSeal(final int validatorIndex, final Bytes signature) {
+ this.validatorIndex = validatorIndex;
+ this.signature = signature;
+ }
+
+ /**
+ * Gets the validator index.
+ *
+ * @return the validator index used to look up the Falcon public key
+ */
+ public int getValidatorIndex() {
+ return validatorIndex;
+ }
+
+ /**
+ * Gets the Falcon signature bytes.
+ *
+ * @return the Falcon-512 signature
+ */
+ public Bytes getSignature() {
+ return signature;
+ }
+
+ @Override
+ public boolean equals(final Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ final FalconSeal that = (FalconSeal) o;
+ return validatorIndex == that.validatorIndex && Objects.equals(signature, that.signature);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(validatorIndex, signature);
+ }
+
+ @Override
+ public String toString() {
+ return "FalconSeal{validatorIndex="
+ + validatorIndex
+ + ", sigLen="
+ + (signature == null ? 0 : signature.size())
+ + '}';
+ }
+}
diff --git a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/FalconSealSupport.java b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/FalconSealSupport.java
new file mode 100755
index 000000000..af694dcf9
--- /dev/null
+++ b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/FalconSealSupport.java
@@ -0,0 +1,3749 @@
+/*
+ * 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 java.io.FileInputStream;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Optional;
+import java.util.OptionalInt;
+import java.util.Properties;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.function.LongPredicate;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.tuweni.bytes.Bytes;
+import org.apache.tuweni.bytes.Bytes32;
+import org.bouncycastle.crypto.digests.KeccakDigest;
+import org.bouncycastle.pqc.crypto.falcon.FalconParameters;
+import org.bouncycastle.pqc.crypto.falcon.FalconPrivateKeyParameters;
+import org.bouncycastle.pqc.crypto.falcon.FalconPublicKeyParameters;
+import org.bouncycastle.pqc.crypto.falcon.FalconSigner;
+import org.hyperledger.besu.datatypes.Address;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * BFT-local, PARALLEL Falcon-512 signer + verifier + registry for the Falcon consensus SEAL: a
+ * post-quantum signature carried ALONGSIDE the classical seals.
+ *
+ * <p>NAMING, and this is not pedantry. This line used to read "for the hybrid post-quantum
+ * consensus seal". AERE DOES NOT HAVE POST-QUANTUM CONSENSUS and must never be described as having
+ * it: proposer selection and finality are classical secp256k1 ECDSA QBFT, and the post-quantum
+ * layer is signature, precompile and account level. Our audit scope tells reviewers to FLAG that
+ * phrase wherever it appears in code comments. It appeared here.
+ *
+ * <p>This deliberately does NOT ride the shared secp256k1 {@code NodeKey} / {@code SecurityModule}
+ * singleton (that interface returns ECDSA R,S and is used chain-wide for transactions and devp2p).
+ * The Falcon key here is a second, consensus-only key.
+ *
+ * <p>AERE audit fix (AUD-CONSENSUS-1 / AUD-CONSENSUS-2, 2026-07-18): the registry now binds each
+ * Falcon-512 public key to a validator ADDRESS, not merely to an index. This is what makes the
+ * "eligible signer" set {@code currentValidators INTERSECT registry} well defined, so the blocking
+ * rule can compute BOTH the Falcon quorum AND the counted-seal set over the SAME set of validator
+ * addresses (see {@code FalconSealValidationRule}). Without the address binding the registry is only
+ * a bag of indexed keys with no tie to the live validator set, which is exactly the decoupling that
+ * let a routine add-validator vote raise the ECDSA-sized quorum above the fixed key count and halt a
+ * post-fork chain, and let a removed validator's key keep counting.
+ *
+ * <p>Configuration (system property takes precedence over environment variable):
+ *
+ * <ul>
+ * <li>{@code aere.falcon.key} / {@code AERE_FALCON_KEY}: path to this node's Falcon private key
+ * .properties file with keys {@code index, f, g, F, pk} (hex, low-level BouncyCastle Falcon
+ * components). This is a per-node local SECRET and never appears in any registry.
+ * <li>{@code aere.falcon.genesis} / {@code AERE_FALCON_GENESIS}: path to the chain's genesis file
+ * carrying a GENESIS-ANCHORED validator-to-Falcon-pubkey manifest. Tamper-evident and
+ * immutable from genesis. Highest precedence.
+ * <li>{@code aere.falcon.manifest} / {@code AERE_FALCON_MANIFEST} together with {@code
+ * aere.falcon.anchor.address} / {@code AERE_FALCON_ANCHOR_ADDRESS}: the STAGE-2
+ * LATE-ANCHOR (contract-anchored) registry source for chains that launched WITHOUT a genesis
+ * manifest (i.e. an already-live chain). The manifest file carries the full
+ * index-to-{addr,pk} JSON object; the anchor contract's storage slot 0 must hold
+ * {@code keccak256(canonical manifest)}. The registry stays EMPTY (pending) until consensus
+ * code observes the anchor contract in the chain's world state and calls {@link
+ * #activateLateAnchor}; activation is fail-closed on any hash mismatch. This removes the
+ * re-genesis requirement for activating the hybrid PQC certificate on an existing chain.
+ * <li>{@code aere.falcon.registry} / {@code AERE_FALCON_REGISTRY}: (LEGACY, mutable-file
+ * fallback) path to the validator to Falcon-pubkey registry .properties file. Lowest
+ * precedence; retained for backward compatibility and the upstream unit tests. This legacy
+ * source carries pubkeys ONLY (no validator address), so a registry loaded from it is NOT
+ * address-bound and is NOT eligible to arm blocking (see {@link #addressBound()}).
+ * <li>{@code aere.falcon.forkBlock} / {@code AERE_FALCON_FORKBLOCK}: block number at and after
+ * which the Falcon quorum certificate is BLOCKING. Unset means never blocking (log-only).
+ * </ul>
+ *
+ * <p>Every method is defensive: any load, parse, sign or verify failure is swallowed and logged, so
+ * a Falcon fault never THROWS on the consensus path.
+ *
+ * <p>THAT IS NO LONGER THE SAME THING AS "IT CANNOT HALT THE CHAIN", and the sentence that used to
+ * claim so has been removed rather than softened. Not throwing only means the failure is returned
+ * as a verdict, and two consumers now turn that verdict into a REJECT: {@code
+ * FalconSealValidationRule} once {@code aere.falcon.forkBlock} is reached with an active registry,
+ * and {@code PqAnchorSealsRule} (V2 R2) at and above {@code pqAnchorBlock}, which refuses a header
+ * whenever {@link #addressForIndex} returns null or {@link #verify} returns false. A node whose
+ * registry file fails to load therefore rejects every header carrying a certificate, i.e. it halts
+ * itself, and two nodes with DIFFERENT registry files disagree about which headers are valid. That
+ * is the unbound-registry defect, and it is why the registry has to be bound to genesis by {@code pqRegistryHash}
+ * before any of this is armed. While both of those gates are unset - which is the state of chain
+ * 2800 today - the subsystem is log-only and the old sentence holds; it is not a property of this
+ * file, it is a property of the configuration.
+ */
+public final class FalconSealSupport {
+
+ private static final Logger LOG = LoggerFactory.getLogger(FalconSealSupport.class);
+
+ private static volatile FalconSealSupport instance;
+
+ /**
+ * System anchor address whose storage slot 0 holds keccak256 of the canonical Falcon manifest.
+ * Because genesis {@code alloc} storage is committed into the genesis stateRoot (and hence the
+ * genesis block hash), the manifest hash is bound to the chain identity itself: two nodes that
+ * agree on the chain necessarily agree on the manifest hash, and it is independently queryable
+ * via {@code eth_getStorageAt(0x..0fa1, 0x0)}. 40-hex, lower-case, no 0x prefix.
+ */
+ private static final String ANCHOR_ADDRESS = "0000000000000000000000000000000000000fa1";
+
+ /** Storage slot (0) at the anchor address holding the manifest hash. 64-hex, no 0x prefix. */
+ private static final String ANCHOR_SLOT =
+ "0000000000000000000000000000000000000000000000000000000000000000";
+
+ private final boolean signingEnabled;
+ private final int localIndex;
+ private final FalconPrivateKeyParameters localPrivateKey;
+ private final Map<Integer, FalconPublicKeyParameters> registry;
+
+ /**
+ * Validator ADDRESS bound to each registry index (AUD-CONSENSUS-1 fix). Populated in lockstep
+ * with {@link #registry} from an address-bound manifest; empty for the legacy pubkey-only source.
+ * This is the map that makes {@code currentValidators INTERSECT registry} computable.
+ */
+ private final Map<Integer, Address> indexToAddress;
+
+ private final boolean genesisAnchored;
+
+ /**
+ * AERE GENESIS BINDING: the registry file this node ACTUALLY used, and which of the three sources it came from.
+ * Recorded so the consensus-binding guard can re-read exactly that file and hash it, rather than
+ * hashing something adjacent to it. Null when no registry is configured at all.
+ */
+ private final String registrySourcePath;
+
+ private final PqRegistryHash.SourceKind registrySourceKind;
+
+ /** Outcome of the genesis-binding guard; NOT_CHECKED equivalent is null until it has run. */
+ private volatile PqRegistryHash.GateState registryBindingState;
+
+ /**
+ * AERE GENESIS BINDING (per-block half): the schedule the startup guard actually enforced, the registry object
+ * it hashed, and the chain id it hashed under. Held so that {@link #registryBindingSatisfiedAt}
+ * can answer the SAME question on the per-block consensus path without re-reading a file 61
+ * million times a year, and without a second, drifting copy of the decision.
+ *
+ * <p>Null until the guard has run. Null means the per-block rule is INERT, which is the only safe
+ * default: a node whose controller never called the guard (a non-QBFT path, or a unit test) must
+ * not start rejecting headers because of a binding nobody configured.
+ */
+ private volatile PqRegistryHash.Schedule registryBindingSchedule;
+
+ private volatile PqRegistryHash.Registry registryBindingLoaded;
+
+ /**
+ * HEIGHT SCHEDULE: every registry this node holds, bound to the schedule entry each one satisfies.
+ *
+ * <p>{@link #registryBindingLoaded} above is the registry for the HEAD, and it is what the node
+ * signs with. It is kept because every diagnostic message names it. This field is the whole
+ * scheduled history, and it is what the per-block binding question and height-resolved
+ * verification are answered from. With no history configured the set holds exactly the one
+ * registry above, so both answers are bit-for-bit what they were before the height schedule existed.
+ */
+ private volatile PqRegistryHash.RegistrySet registryBindingSet;
+
+ /** HEIGHT SCHEDULE: per (schedule entry, index) Falcon public keys, built on demand from the set. */
+ private final Map<String, FalconPublicKeyParameters> historicalKeys = new ConcurrentHashMap<>();
+
+ private volatile long registryBindingChainId;
+
+ /** Highest height at which the per-block binding rule has already shouted, to bound the log. */
+ private final java.util.concurrent.atomic.AtomicLong registryBindingLastShout =
+ new java.util.concurrent.atomic.AtomicLong(-1L);
+
+ /**
+ * AERE OPTIUNI-URGENTA (2026-08-02): the operator has EXPLICITLY asked this node to run even
+ * though its Falcon registry does not satisfy the binding genesis requires.
+ *
+ * <p>WHY THIS EXISTS AT ALL. The genesis-binding guard is a CONFIGURATION guard: it refuses to start a node
+ * whose registry file is not the one the chain names. That refusal is correct and it is
+ * fail-closed, and it also means a single bad registry file pushed to the fleet takes the fleet
+ * down and the only documented way back was to rebuild or to hand-edit a service unit at whatever
+ * hour it happened. This flag is the way back. It is read ONCE, in the constructor, from {@value
+ * #PROPERTY_REGISTRY_MISMATCH_ALLOW} or its environment form, which is where the command-line
+ * option {@code --Xaere-pq-registry-mismatch-allow} writes.
+ *
+ * <p>WHY IT IS NOT A QUIET ONE. A silent bypass is worse than no bypass, because a fleet can run
+ * for months on it and nobody finds out until the property it was supposed to guarantee is needed.
+ * So: a full-width banner at startup, and one complete ERROR line at EVERY height at which the
+ * bypass actually carries weight, for as long as the node runs.
+ */
+ private final boolean registryMismatchAllow;
+
+ /**
+ * True once the bypass has ACTUALLY suppressed a refusal, at startup or on the block path. Distinct
+ * from {@link #registryMismatchAllow}, which only says the operator armed it: a node whose registry
+ * is fine must not announce that it is running unverified.
+ */
+ private volatile boolean registryOverrideEngaged;
+
+ /** Highest height at which the unsafe-override announcement has already been made. */
+ private final java.util.concurrent.atomic.AtomicLong registryOverrideLastShout =
+ new java.util.concurrent.atomic.AtomicLong(-1L);
+
+ private volatile String manifestHash;
+
+ // ---- STAGE-2 LATE-ANCHOR (contract-anchored) state ----
+ /** Parsed-but-not-yet-activated manifest pubkeys (validator index -> Falcon-512 pubkey). */
+ private final Map<Integer, FalconPublicKeyParameters> pendingLate;
+ /** Parsed-but-not-yet-activated validator addresses (validator index -> address). */
+ private final Map<Integer, Address> pendingLateAddresses;
+ /** keccak256(canonical pending manifest), 64-hex no 0x; null when no late manifest configured. */
+ private final String pendingLateHash;
+ /** The on-chain anchor CONTRACT address (0x-hex) whose slot 0 must equal pendingLateHash. */
+ private final String anchorContractAddress;
+
+ private volatile boolean lateActivated;
+ private volatile boolean lateFailed;
+
+ // ---- AERE FIX-OPRIRE-CONSENS: SEAL-ATTACHMENT GATE ----
+ //
+ // The halting bug this state exists to prevent: seal ATTACHMENT used to be gated on nothing but
+ // key presence. The moment an operator dropped a Falcon key onto one validator and restarted it,
+ // that node began emitting Falcon-carrying Commit messages. Every peer still running the stock
+ // binary decodes Commit with a STRICT rlpInput.leaveList(), threw MalformedRLPInputException, and
+ // DISCARDED the entire Commit - including the decisive ECDSA seal it carried. A rolling
+ // key-by-key restart therefore walks the fleet straight through a state where the stock nodes
+ // cannot see enough ECDSA commit seals to reach quorum.
+ //
+ // The gate makes that order impossible: attachment is OFF until an explicitly configured
+ // ACTIVATION HEIGHT is reached AND the anchored registry covers every current validator. Both are
+ // fleet-wide facts, identical on every node, so all nodes flip together at the same block. Key
+ // presence alone now does nothing at all.
+
+ /** Minimum gap required between the attachment height and the blocking fork height. */
+ private static final long DEFAULT_MIN_ATTACH_LEAD = 256L;
+
+ /**
+ * Minimum validator-set size for which BLOCKING Falcon quorum may be armed. At N=7 the Falcon
+ * quorum (5 of 7) equals the ECDSA quorum, so the Falcon layer has ZERO extra fault margin: a
+ * single keyed validator whose Falcon key is faulty or whose process is down becomes a new
+ * liveness dependency and stops the chain. N&gt;=9 restores a one-fault Falcon margin.
+ */
+ private static final int MIN_BLOCKING_VALIDATORS = 9;
+
+ // ---- AERE ARMING GUARD (2026-08-01): the CHAIN-RELATIVE half of the attachment guard ----
+ //
+ // The constructor-time guards below can only check the configuration against ITSELF. They have no
+ // chain, so they cannot answer the one question that matters most: is the configured attachment
+ // height still AHEAD of us? Measured hole (run P1-PAST-ATTACH-RESTART): a fleet configured exactly
+ // per the documented activation order, with aere.falcon.attachBlock UNSET everywhere, emitted
+ // nothing for 60 s. One validator was then restarted with aere.falcon.attachBlock=5 while the
+ // chain head was 19. It started with no complaint and ARMED at block 21, and each of the four
+ // stock peers immediately began discarding its Commit messages. A procedure is not a guard.
+ //
+ // Two things must be true for the fleet to flip together at one block:
+ // (i) the height has not already gone by; and
+ // (ii) every node is already PARTICIPATING in consensus before that height arrives, otherwise a
+ // node that is still starting up arms at the first block it commits, which is a different
+ // block from the rest of the fleet. Measured on the scratch fleet: 45.2 s elapse between
+ // the startup config guard and the first commit this node takes part in.
+ // The margin below is what makes (ii) true, which is why it is a margin and not a mere > head.
+
+ /**
+ * Default number of blocks by which {@code aere.falcon.attachBlock} must LEAD this node's local
+ * chain head at startup. Sized from the measured startup-to-first-commit window (45.2 s, i.e. ~46
+ * blocks on the 1 s scratch fleet) with a wide safety factor, so that a node which is still
+ * opening its database, syncing and joining the network cannot have the activation height arrive
+ * underneath it. Chains with a sub-second block period must RAISE this in proportion: the
+ * margin has to cover the same wall-clock startup window at the faster block rate.
+ */
+ private static final long DEFAULT_MIN_ATTACH_FUTURE_MARGIN = 1024L;
+
+ /**
+ * How many blocks back from the chain head the startup guard looks for positive proof that the
+ * fleet has ALREADY armed at the configured height (a header carrying at least one Falcon seal).
+ * Once the fleet arms, every subsequent block carries seals, so the evidence is at the head; the
+ * lookback only absorbs a short tail.
+ */
+ private static final int ACTIVATION_EVIDENCE_LOOKBACK = 128;
+
+ /**
+ * File written inside the node's data directory when the startup guard ADMITS an attachment height
+ * that was safely in the future. Its only job is to tell a later restart "this node was already
+ * admitted with this exact activation height, on this exact registry", so that a crash during an
+ * activation window is not turned into a permanent refusal to start.
+ */
+ private static final String ADMISSION_RECEIPT_FILE = "aere-falcon-activation.receipt";
+
+ /**
+ * HEIGHT SCHEDULE: the registries this node holds for SCHEDULED HEIGHTS IT IS NO LONGER AT, as a
+ * comma-separated list of files. Env: {@code AERE_FALCON_REGISTRY_HISTORY}.
+ *
+ * <p>WHY A SECOND PROPERTY AND NOT A LIST IN THE FIRST. {@code aere.falcon.registry} names the
+ * registry this node SIGNS with, and there can only ever be one of those. This names the ones it
+ * must still be able to VERIFY against, and there is one per rotation the chain has ever done.
+ * Keeping them apart means a rotation adds a file to this list and changes the first property, and
+ * neither operation can be confused with the other.
+ *
+ * <p>The list is never pruned. Cosmos ADR-016 may bound its rotation history by the unbonding
+ * period; chain 2800 has no unbonding period and a node syncing from genesis verifies every block
+ * ever produced, so every registry ever scheduled has to stay here forever.
+ */
+ public static final String PROPERTY_REGISTRY_HISTORY = "aere.falcon.registry.history";
+
+ /**
+ * AERE OPTIUNI-URGENTA: system property behind the command-line option {@code
+ * --Xaere-pq-registry-mismatch-allow}. Env: {@code AERE_PQ_REGISTRY_MISMATCH_ALLOW}. Setting it
+ * lets this node start and keep running with a Falcon registry that does NOT satisfy the binding
+ * genesis requires, at the price of one full ERROR line at every height where that matters.
+ */
+ public static final String PROPERTY_REGISTRY_MISMATCH_ALLOW = "aere.pq.registry.mismatchAllow";
+
+ /** Environment-variable form of {@link #PROPERTY_REGISTRY_MISMATCH_ALLOW}. */
+ public static final String ENV_REGISTRY_MISMATCH_ALLOW = "AERE_PQ_REGISTRY_MISMATCH_ALLOW";
+
+ /**
+ * AERE LATE-ANCHOR HEIGHT: the DECLARED height at or after which the on-chain late-anchor registry contract is
+ * expected to be observable. Mandatory whenever a blocking fork height is armed over a late-anchor
+ * registry that is still pending; see {@link #validateAnchorObservationHeightOrAbort}.
+ */
+ public static final String PROPERTY_ANCHOR_OBSERVE_BLOCK = "aere.falcon.anchor.block";
+
+ /** Environment-variable form of {@link #PROPERTY_ANCHOR_OBSERVE_BLOCK}. */
+ public static final String ENV_ANCHOR_OBSERVE_BLOCK = "AERE_FALCON_ANCHOR_BLOCK";
+
+ /** Activation height for seal ATTACHMENT; Long.MAX_VALUE means NEVER attach. */
+ private final long attachBlock;
+
+ /**
+ * AERE LATE-ANCHOR HEIGHT: the BLOCKING height, resolved and validated exactly ONCE, at construction, and
+ * owned thereafter. Long.MAX_VALUE means never blocking.
+ *
+ * <p>It used to be a property read on every call to {@link #forkBlock()}, with a
+ * NumberFormatException swallowed into "never blocking, log-only" plus one WARN line. The startup
+ * guard added for AUD-CONSENSUS-3 validated the property but did not TAKE it, so the one value the
+ * whole PQC layer is gated on could still turn itself off after the guard had passed, at any call,
+ * on any input the guard never saw. A value that is checked at the boundary and re-parsed at every
+ * use is not checked. It is parsed here, once, or the node does not start.
+ */
+ private final long forkBlock;
+
+ /**
+ * AERE LATE-ANCHOR HEIGHT: the height at and after which the on-chain LATE-ANCHOR registry contract is expected
+ * to be observable, as DECLARED by the operator. Long.MAX_VALUE when undeclared.
+ *
+ * <p>This value does not activate anything. It exists because without it the ordering that the
+ * whole late-anchor activation depends on cannot be stated, and therefore cannot be checked: the
+ * registry becomes usable at a height that only the chain knows, while blocking arms at a height
+ * only the configuration knows. Declaring it is what lets the two be compared before the node
+ * joins the network.
+ */
+ private final long anchorObserveBlock;
+
+ /**
+ * AERE LATE-ANCHOR HEIGHT: first height at which this node validated a header at or after the blocking height
+ * while the anchored registry was NOT active, or -1 if that has never happened.
+ *
+ * <p>This is the residual the configuration guard cannot close: an operator may declare the
+ * observation height correctly and the anchor transaction may still fail to land. The header rule
+ * deliberately stays LOG-ONLY there rather than halt the chain (see AUD-CONSENSUS-4), which is the
+ * right trade and is also exactly how the condition used to disappear: log-only, one line, nothing
+ * a command could ever read. Now it leaves a mark.
+ */
+ private final AtomicLong blockingArmedWithoutRegistrySince = new AtomicLong(-1L);
+
+ /** Outcome of the chain-relative startup guard; NOT_CHECKED until it has run. */
+ private volatile ChainRelativeGateState chainRelativeState = ChainRelativeGateState.NOT_CHECKED;
+
+ /** Height at which activation evidence was found on chain, or -1. */
+ private volatile long armedEvidenceHeight = -1L;
+
+ /** Latest validator set observed from the header-validation path, or null if never observed. */
+ private volatile Set<Address> observedValidators;
+
+ /** Height at which {@link #observedValidators} was captured. */
+ private volatile long observedValidatorsHeight = -1L;
+
+ /** One-shot latches so the gate diagnostics do not spam one line per block. */
+ private final AtomicBoolean loggedAttachmentOff = new AtomicBoolean(false);
+
+ private final AtomicBoolean loggedAttachmentOn = new AtomicBoolean(false);
+
+ private final AtomicBoolean loggedCoverageBlocked = new AtomicBoolean(false);
+
+ /**
+ * LOOKUP HARDENING (b): the once-only latch for the refusal to resolve keys at an armed height with no
+ * height-to-registry binding. One line per block at a 523 ms period is a hazard, not a diagnostic.
+ */
+ private final AtomicBoolean loggedUnboundArmedHeight = new AtomicBoolean(false);
+
+ /**
+ * AERE COVERAGE MARGIN (2026-08-02): the last coverage situation this node reported, as "N/R", so the
+ * coverage line is emitted exactly when the situation CHANGES and not once per block. Null means
+ * nothing has been reported yet.
+ */
+ private volatile String lastCoverageSignature;
+
+ private FalconSealSupport() {
+ int idx = -1;
+ FalconPrivateKeyParameters priv = null;
+ final Map<Integer, FalconPublicKeyParameters> reg = new ConcurrentHashMap<>();
+ final Map<Integer, Address> regAddr = new ConcurrentHashMap<>();
+ String mHash = null;
+ boolean anchored = false;
+ Map<Integer, FalconPublicKeyParameters> late = null;
+ Map<Integer, Address> lateAddr = null;
+ String lateHash = null;
+ String anchorAddr = null;
+
+ // Registry source resolution order:
+ // 1) GENESIS-ANCHORED manifest (aere.falcon.genesis) - tamper-evident, immutable, preferred.
+ // 2) LATE-ANCHOR manifest + contract (aere.falcon.manifest + aere.falcon.anchor.address) -
+ // Stage-2 activation path for an ALREADY-LIVE chain: pending until the anchor contract is
+ // observed on-chain and the manifest hash matches (fail-closed).
+ // 3) LEGACY shared .properties file (aere.falcon.registry) - backward-compatible fallback.
+ final String genesisPath = resolve("aere.falcon.genesis", "AERE_FALCON_GENESIS");
+ final String manifestPath = resolve("aere.falcon.manifest", "AERE_FALCON_MANIFEST");
+ final String anchorProp = resolve("aere.falcon.anchor.address", "AERE_FALCON_ANCHOR_ADDRESS");
+ final String registryPath = resolve("aere.falcon.registry", "AERE_FALCON_REGISTRY");
+ if (genesisPath != null) {
+ mHash = loadGenesisAnchoredRegistry(genesisPath, reg, regAddr);
+ anchored = mHash != null;
+ } else if (manifestPath != null && anchorProp != null) {
+ final Map<Integer, FalconPublicKeyParameters> pend = new HashMap<>();
+ final Map<Integer, Address> pendAddr = new HashMap<>();
+ lateHash = loadPendingLateManifest(manifestPath, pend, pendAddr);
+ if (lateHash != null) {
+ late = pend;
+ lateAddr = pendAddr;
+ anchorAddr = anchorProp.trim();
+ LOG.info(
+ "AERE PQC: LATE-ANCHOR manifest loaded from {} ({} entries, address-bound={}), "
+ + "keccak256(manifest)=0x{}; registry PENDING until the on-chain anchor contract "
+ + "{} (slot 0) is observed to match. Fail-closed on mismatch.",
+ manifestPath,
+ pend.size(),
+ !pendAddr.isEmpty() && pendAddr.size() == pend.size(),
+ lateHash,
+ anchorAddr);
+ } else {
+ LOG.error(
+ "AERE PQC: failed to load LATE-ANCHOR manifest from {}; registry EMPTY (fail-closed).",
+ manifestPath);
+ }
+ } else if (registryPath != null) {
+ // AERE GENESIS BINDING REPAIR (2026-08-02): the properties source the design prescribed was measured to
+ // HALT EVERY NODE at the activation height. Not by being mutable, and not by disagreeing with
+ // a hash: by carrying public keys and NO ADDRESSES. addressForIndex() then returns null for
+ // every index, and the V2 seals rule (R2) refuses every header that carries a certificate.
+ // Measured on three observers with the data directory wiped, on the SAME honest chain:
+ // genesis-anchored address-bound registry reached head 374 with R1=0 R2=0, while this source
+ // stopped at head 19 with R2=4 rejections, indistinguishable from having no registry at all.
+ //
+ // The repair is in two halves and both are needed. FIRST, this loader now reads the optional
+ // "<i>.addr=0x<20 bytes>" row, exactly the shape PqRegistryHash.loadPropertiesRegistry
+ // already accepts, so the prescribed source CAN be address-bound and CAN carry the fleet past
+ // activation. SECOND, armingReadinessDiagnostic() below no longer merely logs when an
+ // activation is configured over a registry that is still pubkey-only: it refuses to start.
+ // A loud refusal at startup and a silent fleet-wide halt at the activation height are not the
+ // same event, and only one of them is survivable.
+ try (InputStream in = new FileInputStream(registryPath)) {
+ final Properties p = new Properties();
+ p.load(in);
+ final Map<Integer, Address> propAddr = new java.util.TreeMap<>();
+ for (final String name : p.stringPropertyNames()) {
+ if ("count".equals(name)) {
+ continue;
+ }
+ try {
+ final String key = name.trim();
+ if (key.endsWith(".addr")) {
+ final int i = Integer.parseInt(key.substring(0, key.length() - ".addr".length()));
+ final byte[] a = Bytes.fromHexStringLenient(p.getProperty(name).trim()).toArray();
+ if (a.length != 20) {
+ throw new IllegalArgumentException(
+ "address is " + a.length + " bytes, expected exactly 20");
+ }
+ // Built through the canonical hex of the VALIDATED 20 bytes, not from the raw string
+ // and not through Address.wrap: the lenient hex parser above accepts shapes the
+ // strict one does not, so re-rendering the checked bytes is the only spelling that
+ // cannot disagree with the length check that just passed.
+ propAddr.put(i, Address.fromHexString(Bytes.wrap(a).toHexString()));
+ } else {
+ final int i = Integer.parseInt(key);
+ final byte[] h = Bytes.fromHexStringLenient(p.getProperty(name).trim()).toArray();
+ reg.put(i, new FalconPublicKeyParameters(FalconParameters.falcon_512, h));
+ }
+ } catch (final RuntimeException e) {
+ LOG.warn("Skipping malformed Falcon registry entry '{}': {}", name, e.toString());
+ }
+ }
+ // Address binding is ALL-OR-NOTHING. A half-bound registry is worse than an unbound one:
+ // some indices would resolve and some would not, so the fleet would reject a header that
+ // one operator can verify and another cannot, which is the same defect wearing a different hat.
+ final boolean fullyBound = !reg.isEmpty() && propAddr.keySet().equals(reg.keySet());
+ if (fullyBound) {
+ regAddr.putAll(propAddr);
+ } else if (!propAddr.isEmpty()) {
+ LOG.error(
+ "AERE PQC GENESIS-BINDING: LEGACY registry file {} carries {} '<i>.addr' rows for {} keys. A "
+ + "PARTIALLY address-bound registry is refused as a binding source (all-or-"
+ + "nothing): some indices would resolve to a validator address and some would "
+ + "not, so two nodes would disagree about which headers are valid. Treating this "
+ + "registry as pubkey-only.",
+ registryPath,
+ propAddr.size(),
+ reg.size());
+ }
+ LOG.info(
+ "AERE PQC: loaded Falcon validator registry ({} entries, address-bound={}) from LEGACY "
+ + "file {} (mutable; superseded by aere.falcon.genesis / aere.falcon.manifest). "
+ + "{}",
+ reg.size(),
+ fullyBound,
+ registryPath,
+ fullyBound
+ ? "Address-bound, so the eligible-signer intersection is computable and this "
+ + "registry CAN carry the fleet past an activation height; it is still bound to "
+ + "consensus only if config.pqRegistryHash names its hash."
+ : "PUBKEY-ONLY, so addressForIndex() returns null for every index and EVERY header "
+ + "carrying a certificate would be rejected at the activation height. NOT "
+ + "eligible to arm blocking.");
+ } catch (final Exception e) {
+ LOG.warn(
+ "AERE PQC: failed to load Falcon registry from {}: {}", registryPath, e.toString());
+ }
+ } else {
+ LOG.info(
+ "AERE PQC: no Falcon registry configured (aere.falcon.genesis/aere.falcon.manifest/"
+ + "aere.falcon.registry); hybrid seal verification will be a no-op.");
+ }
+
+ // Load this node's Falcon private key (only present on validators that should sign).
+ final String keyPath = resolve("aere.falcon.key", "AERE_FALCON_KEY");
+ if (keyPath != null) {
+ try (InputStream in = new FileInputStream(keyPath)) {
+ final Properties p = new Properties();
+ p.load(in);
+ idx = Integer.parseInt(p.getProperty("index").trim());
+ final byte[] f = Bytes.fromHexStringLenient(p.getProperty("f").trim()).toArray();
+ final byte[] g = Bytes.fromHexStringLenient(p.getProperty("g").trim()).toArray();
+ final byte[] bigF = Bytes.fromHexStringLenient(p.getProperty("F").trim()).toArray();
+ final byte[] pk = Bytes.fromHexStringLenient(p.getProperty("pk").trim()).toArray();
+ priv = new FalconPrivateKeyParameters(FalconParameters.falcon_512, f, g, bigF, pk);
+ LOG.info(
+ "AERE PQC: loaded local Falcon signing key for validator index {} from {}",
+ idx,
+ keyPath);
+ } catch (final Exception e) {
+ LOG.warn("AERE PQC: failed to load Falcon signing key from {}: {}", keyPath, e.toString());
+ idx = -1;
+ priv = null;
+ }
+ }
+
+ this.registry = reg;
+ this.indexToAddress = regAddr;
+ this.localIndex = idx;
+ this.localPrivateKey = priv;
+ this.signingEnabled = priv != null && idx >= 0;
+ this.manifestHash = mHash;
+ this.genesisAnchored = anchored;
+ this.pendingLate = late;
+ this.pendingLateAddresses = lateAddr;
+ this.pendingLateHash = lateHash;
+ this.anchorContractAddress = anchorAddr;
+
+ // AERE GENESIS BINDING: remember WHICH file the registry came from, so the consensus-binding guard hashes
+ // the file this node is really running on and not a different one that happens to be nearby.
+ if (genesisPath != null) {
+ this.registrySourcePath = genesisPath;
+ this.registrySourceKind = PqRegistryHash.SourceKind.GENESIS_MANIFEST;
+ } else if (manifestPath != null && anchorProp != null) {
+ this.registrySourcePath = manifestPath;
+ this.registrySourceKind = PqRegistryHash.SourceKind.MANIFEST_JSON;
+ } else if (registryPath != null) {
+ this.registrySourcePath = registryPath;
+ this.registrySourceKind = PqRegistryHash.SourceKind.LEGACY_PROPERTIES;
+ } else {
+ this.registrySourcePath = null;
+ this.registrySourceKind = PqRegistryHash.SourceKind.NONE;
+ }
+
+ // AERE OPTIUNI-URGENTA (2026-08-02): read the registry bypass ONCE, here, so that every later
+ // decision reads one immutable field rather than re-resolving a property that could have been
+ // changed underneath it. Announced immediately, whether or not it ends up carrying weight,
+ // because "somebody armed the bypass on this node" is itself something an operator must be able
+ // to see in the first screen of the log.
+ this.registryMismatchAllow =
+ Boolean.parseBoolean(
+ resolve(PROPERTY_REGISTRY_MISMATCH_ALLOW, ENV_REGISTRY_MISMATCH_ALLOW));
+ if (this.registryMismatchAllow) {
+ LOG.warn(
+ "AERE PQC EMERGENCY [AERE-PQC-EMG-REGALLOW-01]: the Falcon registry binding bypass is "
+ + "ARMED on this node ({}). If the registry this node holds does NOT satisfy what "
+ + "genesis requires, this node will START ANYWAY and will keep importing headers "
+ + "whose certificates it cannot correctly verify, and it will say so at every height "
+ + "where that is true. This is a RECOVERY setting. It is not a configuration to leave "
+ + "in place: remove it as soon as the correct registry is installed.",
+ PROPERTY_REGISTRY_MISMATCH_ALLOW);
+ }
+
+ // AERE audit fix (AUD-CONSENSUS-3): fail-closed on a MALFORMED fork-block config at startup,
+ // before anything else. A present-but-unparseable aere.falcon.forkBlock must not silently
+ // degrade to never-blocking (log-only); it aborts node init here (config time, NOT the per-block
+ // consensus path).
+ // AERE LATE-ANCHOR HEIGHT: the result is TAKEN, not merely checked. Nothing re-reads the property afterwards.
+ this.forkBlock = validateForkBlockConfigOrAbort();
+
+ // AERE FIX-OPRIRE-CONSENS (b): resolve and validate the ATTACHMENT gate. Every inconsistent
+ // combination aborts here, at config time, before the node joins the network - never as a
+ // surprise mid-chain.
+ this.attachBlock = validateAndResolveAttachBlockOrAbort(this.forkBlock);
+
+ // AERE LATE-ANCHOR HEIGHT: the ORDER between the blocking height and the height at which the registry that
+ // backs it can become active. Aborts here, at config time, for the same reason as everything
+ // above it: after the node has joined, the same error is a silent degradation to log-only.
+ this.anchorObserveBlock =
+ validateAnchorObservationHeightOrAbort(this.forkBlock, this.attachBlock);
+
+ // AERE audit fix (AUD-CONSENSUS-1): fail-closed ARMING diagnostic at config/startup time.
+ // If a fork block is configured (blocking is intended) but the manifest that WILL back it is
+ // not address-bound, the eligible-signer intersection cannot be formed and every post-fork
+ // block will fail closed. Surface that LOUDLY here, at arm/config time, rather than as a silent
+ // block-production halt later. (Late-anchor: the pending manifest is checked; the live registry
+ // is still empty until activation, which is expected.)
+ armingReadinessDiagnostic();
+
+ // AERE ROW BINDING (2026-08-06): the SECOND arming question, and the one AERE-PQC-REG-ARM-01 cannot
+ // answer. Address-bound says every index has SOME address next to it. It does not say that the
+ // validator at that address ever held the Falcon key filed under it. Measured on the real
+ // verification path on 2026-08-06: a registry with two rows' public keys swapped - no duplicate
+ // key, no duplicate address, so no uniqueness check would have seen it - produced an ACCEPTED
+ // header, and one key placed at two indices satisfied a threshold of two on its own.
+ requireRegistryBindingProofsOrAbort();
+
+ // AERE COVERAGE MARGIN (2026-08-03): the arm-time comparison the repair above NAMED and did not make.
+ // armingReadinessDiagnostic() answers "is the manifest address-bound"; it never asks whether the
+ // threshold the fleet is about to arm is one the fleet can be guaranteed to MEET.
+ validateThresholdReachabilityOrAbort();
+ }
+
+ /**
+ * AERE audit fix (AUD-CONSENSUS-3): distinguish an UNSET fork block from a MALFORMED configured
+ * one. {@link #forkBlock()} on the per-block path deliberately never throws (it falls back to
+ * {@link Long#MAX_VALUE}); on its own that means a typo'd fork block fails OPEN to never-blocking,
+ * asymmetric with the manifest / anchor loaders that all fail CLOSED. This startup guard closes
+ * that gap: if the property/env IS present but does not parse as a NON-NEGATIVE block number, we
+ * ABORT node init (fail-closed). This runs at construction (node init), not during block
+ * validation, so it never throws on the consensus path. An UNSET value keeps the safe
+ * never-blocking log-only default.
+ *
+ * <p>AERE LATE-ANCHOR HEIGHT (2026-08-03): it now RETURNS the validated height and the constructor keeps it in
+ * a final field. Validating a value and then re-reading its source on every use leaves the
+ * original defect intact one level down, which is exactly where it was found.
+ *
+ * @return the validated blocking height, or {@link Long#MAX_VALUE} when unset
+ */
+ private long validateForkBlockConfigOrAbort() {
+ final String v = resolve("aere.falcon.forkBlock", "AERE_FALCON_FORKBLOCK");
+ if (v == null) {
+ return Long.MAX_VALUE; // unset: safe never-blocking default, nothing to validate
+ }
+ final long parsed;
+ try {
+ parsed = Long.parseLong(v.trim());
+ } catch (final NumberFormatException e) {
+ throw new ActivationConfigException(
+ ActivationConfigException.Kind.SYNTAX,
+ "AERE-PQC-CFG-SYNTAX-03",
+ "AERE PQC: aere.falcon.forkBlock is set to a MALFORMED value '"
+ + v
+ + "' (not an integer block number). Refusing to start (fail-closed): a malformed fork "
+ + "block must not silently degrade to never-blocking / log-only. Set a valid "
+ + "non-negative block number, or UNSET the property to run the additive log-only "
+ + "baseline.");
+ }
+ if (parsed < 0) {
+ throw new ActivationConfigException(
+ ActivationConfigException.Kind.SYNTAX,
+ "AERE-PQC-CFG-SYNTAX-04",
+ "AERE PQC: aere.falcon.forkBlock '"
+ + v
+ + "' is negative. Refusing to start (fail-closed): set a valid non-negative block "
+ + "number, or UNSET the property to run the additive log-only baseline.");
+ }
+ return parsed;
+ }
+
+ /**
+ * AERE LATE-ANCHOR HEIGHT: the ordering guard between the BLOCKING height and the height at which the registry
+ * that backs it can first be active.
+ *
+ * <p>THE HOLE THIS CLOSES, in the words of the code that documented it and did nothing about it.
+ * {@code FalconSealValidationRule} says: "If forkBlock is armed at or before the late-anchor
+ * observation height, the registry is not yet active when the fork block is validated". Its answer
+ * is to stay LOG-ONLY, which is the correct answer for a header rule - blocking over an empty
+ * registry provides zero safety and only a halt. But it means the misconfiguration has no
+ * consequence a node can be stopped by and no consequence a command can read: the operator asked
+ * for a post-quantum quorum, the node said yes, and the quorum is never enforced.
+ *
+ * <p>The check cannot be made against the chain, because the height at which an anchor contract
+ * lands is not knowable at config time. It is therefore made against a DECLARED height, {@code
+ * aere.falcon.anchor.block}, in the same spirit as EIP-7910's {@code eth_config}: state the fork
+ * parameters explicitly so they can be compared, instead of inferring them and finding out at the
+ * boundary. Declaring the height is mandatory whenever blocking is armed over a registry that is
+ * still pending, because a value nobody stated is a value nobody can check.
+ *
+ * <p>Only two rules are needed, and the second one implies what the finding is named after:
+ *
+ * <ol>
+ * <li>a blocking height over a PENDING late anchor with no declared observation height aborts;
+ * <li>{@code attachBlock < anchorObserveBlock} aborts. Because {@code forkBlock - attachBlock >=
+ * minAttachLead} is already enforced above, this makes {@code forkBlock > anchorObserveBlock
+ * + minAttachLead} a THEOREM rather than a third rule that could drift out of step with the
+ * other two.
+ * </ol>
+ *
+ * <p>Genesis-anchored and legacy-file registries are exempt on purpose: both are active from block
+ * 0, so there is no observation height to compare against and demanding one would break the
+ * deployment path that {@link #armingReadinessDiagnostic()} names as the remedy.
+ *
+ * @param fork the validated blocking height
+ * @param attach the validated attachment height
+ * @return the declared observation height, or {@link Long#MAX_VALUE} when undeclared
+ */
+ private long validateAnchorObservationHeightOrAbort(final long fork, final long attach) {
+ final String raw = resolve(PROPERTY_ANCHOR_OBSERVE_BLOCK, ENV_ANCHOR_OBSERVE_BLOCK);
+ final long observe;
+ if (raw == null) {
+ observe = Long.MAX_VALUE;
+ } else {
+ final long parsed;
+ try {
+ parsed = Long.parseLong(raw.trim());
+ } catch (final NumberFormatException e) {
+ throw new ActivationConfigException(
+ ActivationConfigException.Kind.SYNTAX,
+ "AERE-PQC-CFG-SYNTAX-09",
+ "AERE PQC: "
+ + PROPERTY_ANCHOR_OBSERVE_BLOCK
+ + " is set to a MALFORMED value '"
+ + raw
+ + "' (not an integer block number). Refusing to start (fail-closed): every "
+ + "aere.falcon.* height fails closed on a syntax error, uniformly, because a "
+ + "half-fail-closed property set is worse than either extreme - nobody can predict "
+ + "which half they are in.");
+ }
+ if (parsed < 0) {
+ throw new ActivationConfigException(
+ ActivationConfigException.Kind.SYNTAX,
+ "AERE-PQC-CFG-SYNTAX-10",
+ "AERE PQC: "
+ + PROPERTY_ANCHOR_OBSERVE_BLOCK
+ + " '"
+ + raw
+ + "' is negative. Refusing to start (fail-closed).");
+ }
+ observe = parsed;
+ }
+
+ // Nothing blocking is configured, or the registry is already active at block 0. In both cases
+ // there is no ordering to violate.
+ if (fork == Long.MAX_VALUE || genesisAnchored || anchorContractAddress == null) {
+ return observe;
+ }
+
+ if (raw == null) {
+ throw new ActivationConfigException(
+ ActivationConfigException.Kind.UNSAFE,
+ "AERE-PQC-CFG-UNSAFE-06",
+ "AERE PQC: aere.falcon.forkBlock is set to "
+ + fork
+ + " (Falcon quorum BLOCKING) over a LATE-ANCHOR registry that is still PENDING (anchor "
+ + "contract "
+ + anchorContractAddress
+ + "), and "
+ + PROPERTY_ANCHOR_OBSERVE_BLOCK
+ + " is UNDECLARED. Refusing to start (fail-closed). WHY THIS IS REFUSED RATHER THAN "
+ + "WARNED: if the anchor is observed at or after the blocking height, the registry is "
+ + "empty when the fork block is validated, and the header rule then stays LOG-ONLY "
+ + "forever - correctly, because blocking over an empty registry would only halt the "
+ + "chain. The result is a node that was told to enforce a post-quantum quorum, "
+ + "reported no error, and enforces nothing. Nothing on the node can detect that, "
+ + "because the height the registry becomes usable at is known only to the chain and "
+ + "the height blocking arms at is known only to this configuration. Declare "
+ + PROPERTY_ANCHOR_OBSERVE_BLOCK
+ + " as the height at or after which the anchor contract is expected to be observable, "
+ + "and set aere.falcon.attachBlock at or after it.");
+ }
+
+ if (attach < observe) {
+ throw new ActivationConfigException(
+ ActivationConfigException.Kind.UNSAFE,
+ "AERE-PQC-CFG-UNSAFE-07",
+ "AERE PQC: aere.falcon.attachBlock ("
+ + attach
+ + ") is BEFORE "
+ + PROPERTY_ANCHOR_OBSERVE_BLOCK
+ + " ("
+ + observe
+ + "), with aere.falcon.forkBlock="
+ + fork
+ + ". Refusing to start (fail-closed): until the anchor is observed the registry is "
+ + "empty, so no node can emit a Falcon seal, so the LOG-ONLY soak window between "
+ + "attachment and blocking observes nothing and proves nothing. Order the three "
+ + "heights "
+ + PROPERTY_ANCHOR_OBSERVE_BLOCK
+ + " <= aere.falcon.attachBlock, and aere.falcon.forkBlock at least "
+ + minAttachLead()
+ + " blocks after aere.falcon.attachBlock; that ordering is also the only thing that "
+ + "keeps the blocking height strictly AFTER the observation height, which is the "
+ + "configuration this guard exists to refuse.");
+ }
+
+ LOG.info(
+ "AERE PQC: activation ORDER accepted: {}={} <= attachBlock={}, forkBlock={} (lead {} >= "
+ + "{}). Blocking arms {} block(s) after the earliest height at which the anchored "
+ + "registry can be active.",
+ PROPERTY_ANCHOR_OBSERVE_BLOCK,
+ observe,
+ attach,
+ fork,
+ fork - attach,
+ minAttachLead(),
+ fork - observe);
+ return observe;
+ }
+
+ /**
+ * AERE FIX-OPRIRE-CONSENS (b): resolve and validate the seal-ATTACHMENT height. Runs at
+ * construction (node init), never on the consensus path. Every inconsistent combination ABORTS
+ * here, so a fleet can never be started into a configuration that halts it later.
+ *
+ * <p>Rules enforced:
+ *
+ * <ol>
+ * <li>a present but MALFORMED or negative attachBlock aborts (no silent degrade);
+ * <li>configuring BLOCKING ({@code aere.falcon.forkBlock}) without an attachBlock aborts: seals
+ * would never be emitted, so every post-fork block would fail closed and the chain would
+ * stop;
+ * <li>{@code attachBlock > forkBlock} aborts: blocking would arm before any seal exists;
+ * <li>{@code forkBlock - attachBlock} below the minimum lead aborts: the fleet needs a
+ * log-only window in which seals are demonstrably flowing and verifying before the quorum
+ * becomes decisive;
+ * <li>arming BLOCKING with a validator set smaller than {@link #MIN_BLOCKING_VALIDATORS}
+ * aborts, because at N&lt;9 the Falcon quorum has no fault margin over the ECDSA quorum
+ * (see the N=7 zero-margin finding). Isolated test networks opt out explicitly with
+ * {@code aere.falcon.testnetAllowSmallFleet=true}, which logs an ERROR every start.
+ * </ol>
+ *
+ * @param fork the already-validated blocking height (AERE LATE-ANCHOR HEIGHT: passed in rather than re-parsed
+ * from the property, so this method and {@link #forkBlock()} cannot disagree about it)
+ * @return the resolved attachment height, or {@link Long#MAX_VALUE} when unset
+ */
+ private long validateAndResolveAttachBlockOrAbort(final long fork) {
+ final String rawAttach = resolve("aere.falcon.attachBlock", "AERE_FALCON_ATTACHBLOCK");
+
+ if (rawAttach == null) {
+ if (fork == Long.MAX_VALUE && signingEnabled) {
+ // The rolling-restart case: an operator dropped a Falcon key on this node and restarted it.
+ // Say plainly, at startup, that this does NOT arm anything.
+ LOG.warn(
+ "AERE PQC: this node HOLDS a Falcon signing key (index {}) but seal ATTACHMENT is NOT "
+ + "configured (aere.falcon.attachBlock unset), so NO Falcon-carrying Commit will be "
+ + "emitted. This is the safe default: emitting one while any peer still runs a "
+ + "binary with a strict Commit decoder would make that peer DISCARD the whole "
+ + "Commit, ECDSA seal included.",
+ localIndex);
+ }
+ if (fork != Long.MAX_VALUE) {
+ throw new ActivationConfigException(
+ ActivationConfigException.Kind.UNSAFE,
+ "AERE-PQC-CFG-UNSAFE-01",
+ "AERE PQC: aere.falcon.forkBlock is set to "
+ + fork
+ + " (Falcon quorum BLOCKING) but aere.falcon.attachBlock is UNSET. Refusing to "
+ + "start (fail-closed): with attachment off, no node ever emits a Falcon seal, so "
+ + "every block at or after the fork block would be rejected for want of a quorum "
+ + "certificate and the chain would STOP. Set aere.falcon.attachBlock to a height at "
+ + "least "
+ + minAttachLead()
+ + " blocks BEFORE the fork block, and only after the whole fleet runs a binary that "
+ + "can parse a Falcon-carrying Commit.");
+ }
+ return Long.MAX_VALUE;
+ }
+
+ final long attach;
+ try {
+ attach = Long.parseLong(rawAttach.trim());
+ } catch (final NumberFormatException e) {
+ throw new ActivationConfigException(
+ ActivationConfigException.Kind.SYNTAX,
+ "AERE-PQC-CFG-SYNTAX-01",
+ "AERE PQC: aere.falcon.attachBlock is set to a MALFORMED value '"
+ + rawAttach
+ + "' (not an integer block number). Refusing to start (fail-closed).");
+ }
+ if (attach < 0) {
+ throw new ActivationConfigException(
+ ActivationConfigException.Kind.SYNTAX,
+ "AERE-PQC-CFG-SYNTAX-02",
+ "AERE PQC: aere.falcon.attachBlock '" + rawAttach + "' is negative. Refusing to start.");
+ }
+
+ if (fork != Long.MAX_VALUE) {
+ if (attach > fork) {
+ throw new ActivationConfigException(
+ ActivationConfigException.Kind.UNSAFE,
+ "AERE-PQC-CFG-UNSAFE-02",
+ "AERE PQC: aere.falcon.attachBlock ("
+ + attach
+ + ") is AFTER aere.falcon.forkBlock ("
+ + fork
+ + "). Refusing to start (fail-closed): the Falcon quorum would become blocking "
+ + "before any Falcon seal is ever attached, rejecting every block.");
+ }
+ final long lead = fork - attach;
+ if (lead < minAttachLead()) {
+ throw new ActivationConfigException(
+ ActivationConfigException.Kind.UNSAFE,
+ "AERE-PQC-CFG-UNSAFE-03",
+ "AERE PQC: only "
+ + lead
+ + " block(s) between aere.falcon.attachBlock ("
+ + attach
+ + ") and aere.falcon.forkBlock ("
+ + fork
+ + "); the minimum lead is "
+ + minAttachLead()
+ + ". Refusing to start (fail-closed): the fleet needs a LOG-ONLY window in which "
+ + "seals are observably flowing and verifying before the quorum becomes decisive.");
+ }
+ final int fleet = expectedFleetSize();
+ final boolean smallFleetAllowed =
+ Boolean.parseBoolean(
+ String.valueOf(
+ resolve("aere.falcon.testnetAllowSmallFleet", "AERE_FALCON_TESTNET_SMALL_FLEET")));
+ if (fleet < MIN_BLOCKING_VALIDATORS && !smallFleetAllowed) {
+ throw new ActivationConfigException(
+ ActivationConfigException.Kind.UNSAFE,
+ "AERE-PQC-CFG-UNSAFE-04",
+ "AERE PQC: BLOCKING Falcon quorum is configured (forkBlock="
+ + fork
+ + ") for a fleet of "
+ + fleet
+ + " validator(s), below the minimum of "
+ + MIN_BLOCKING_VALIDATORS
+ + ". Refusing to start (fail-closed): at N<"
+ + MIN_BLOCKING_VALIDATORS
+ + " the Falcon quorum equals the ECDSA quorum, so the Falcon layer carries ZERO "
+ + "extra fault margin and any single keyed validator that is down or holds a faulty "
+ + "Falcon key becomes a new liveness dependency that stops the chain. Grow the "
+ + "validator set to at least "
+ + MIN_BLOCKING_VALIDATORS
+ + " first. Isolated test networks may set aere.falcon.testnetAllowSmallFleet=true.");
+ }
+ if (fleet < MIN_BLOCKING_VALIDATORS) {
+ LOG.error(
+ "AERE PQC: aere.falcon.testnetAllowSmallFleet=true - BLOCKING armed on a fleet of {} "
+ + "(< {}). ZERO Falcon fault margin. This setting MUST NOT be used on mainnet.",
+ fleet,
+ MIN_BLOCKING_VALIDATORS);
+ }
+ }
+
+ LOG.info(
+ "AERE PQC: seal-ATTACHMENT gate configured: attachBlock={}, forkBlock={}, minLead={}. "
+ + "Holding a Falcon key does NOT by itself emit a Falcon-carrying Commit.",
+ attach,
+ fork == Long.MAX_VALUE ? "unset" : Long.toString(fork),
+ minAttachLead());
+ return attach;
+ }
+
+ /**
+ * Minimum number of blocks between the attachment height and the blocking fork height.
+ *
+ * <p>AERE ARMING GUARD: this used to swallow a malformed value and silently return the default.
+ * That is the same silent-degrade defect the attachBlock guard exists to prevent, one level down:
+ * an operator who typed the margin wrong would believe a long soak window was enforced when the
+ * default was. Every {@code aere.falcon.*} value now fails closed on a syntax error, uniformly. A
+ * half-fail-closed property set is worse than either extreme, because nobody can predict which
+ * half they are in.
+ */
+ private long minAttachLead() {
+ final String v = resolve("aere.falcon.minAttachLead", "AERE_FALCON_MIN_ATTACH_LEAD");
+ if (v == null) {
+ return DEFAULT_MIN_ATTACH_LEAD;
+ }
+ final long parsed;
+ try {
+ parsed = Long.parseLong(v.trim());
+ } catch (final NumberFormatException e) {
+ throw new ActivationConfigException(
+ ActivationConfigException.Kind.SYNTAX,
+ "AERE-PQC-CFG-SYNTAX-07",
+ "AERE PQC: aere.falcon.minAttachLead is set to a MALFORMED value '"
+ + v
+ + "' (not an integer number of blocks). Refusing to start: a soak-window length that "
+ + "silently reverted to its default would leave the operator believing a longer soak "
+ + "is enforced than actually is.");
+ }
+ if (parsed < 0) {
+ throw new ActivationConfigException(
+ ActivationConfigException.Kind.SYNTAX,
+ "AERE-PQC-CFG-SYNTAX-07",
+ "AERE PQC: aere.falcon.minAttachLead '" + v + "' is negative. Refusing to start.");
+ }
+ return parsed;
+ }
+
+ /**
+ * Best available estimate of the validator-set size at config time, used only for the N&gt;=9
+ * blocking guard. The registry (or the pending late manifest) is address-bound one entry per
+ * validator, so its size is the fleet the operator intends to arm.
+ */
+ private int expectedFleetSize() {
+ final String v = resolve("aere.falcon.validatorCount", "AERE_FALCON_VALIDATOR_COUNT");
+ if (v != null) {
+ try {
+ return Integer.parseInt(v.trim());
+ } catch (final NumberFormatException e) {
+ throw new ActivationConfigException(
+ ActivationConfigException.Kind.SYNTAX,
+ "AERE-PQC-CFG-SYNTAX-08",
+ "AERE PQC: aere.falcon.validatorCount is set to a MALFORMED value '"
+ + v
+ + "' (not an integer). Refusing to start: falling through to the registry-derived "
+ + "size would silently apply the N>=9 blocking guard to a fleet size the operator "
+ + "did not state.");
+ }
+ }
+ if (!registry.isEmpty()) {
+ return registry.size();
+ }
+ return pendingLate == null ? 0 : pendingLate.size();
+ }
+
+ // ---- AERE ARMING GUARD (2026-08-01): CHAIN-RELATIVE STARTUP GUARD ----
+
+ /** Outcome of the chain-relative attachment-height guard. Exposed for tests and diagnostics. */
+ public enum ChainRelativeGateState {
+ /** The guard has not run yet. */
+ NOT_CHECKED,
+ /** No attachment height is configured, so there is nothing to check. */
+ NOT_APPLICABLE,
+ /** The attachment height leads the local chain head by at least the required margin. */
+ FUTURE,
+ /** The height has passed and the chain itself proves the fleet armed at it. */
+ RESUMED_ACTIVATED,
+ /** This node was previously admitted with this exact height while it was still in the future. */
+ RESUMED_ADMITTED,
+ /** An operator named this exact height in an explicit acknowledgement property. */
+ OPERATOR_ACKNOWLEDGED
+ }
+
+ /**
+ * Thrown to ABORT node startup on a Falcon activation configuration that must not be allowed to
+ * run. The {@link Kind} separates the two cases an operator has to treat differently.
+ *
+ * <p>A {@link Kind#SYNTAX} failure is a value this code could not parse at all. It can never be
+ * correct anywhere, it is local to this node, and the fix is to correct the string on this node
+ * and restart. A {@link Kind#UNSAFE} failure is a value that parsed perfectly and describes a
+ * configuration that would damage the fleet. It is usually NOT local: the same value is probably
+ * on the other validators too, and "fixing" it on this node alone can be exactly the wrong move.
+ * Keeping the two apart is the difference between a one-node typo and a fleet-wide decision.
+ */
+ public static final class ActivationConfigException extends IllegalStateException {
+
+ private static final long serialVersionUID = 1L;
+
+ /** Whether the value could not be parsed, or parsed and is dangerous. */
+ public enum Kind {
+ /** The configured value is not parseable. A typo. Local to this node. */
+ SYNTAX,
+ /** The configured value parses but the resulting activation would be unsafe. */
+ UNSAFE
+ }
+
+ private final Kind kind;
+ private final String code;
+
+ ActivationConfigException(final Kind kind, final String code, final String message) {
+ super("[" + code + "] " + message);
+ this.kind = kind;
+ this.code = code;
+ }
+
+ /**
+ * The failure class.
+ *
+ * @return SYNTAX for an unparseable value, UNSAFE for a dangerous one
+ */
+ public Kind kind() {
+ return kind;
+ }
+
+ /**
+ * The stable machine-readable code, e.g. {@code AERE-PQC-CFG-UNSAFE-05}.
+ *
+ * @return the code
+ */
+ public String code() {
+ return code;
+ }
+ }
+
+ /**
+ * CHAIN-RELATIVE half of the seal-attachment guard: refuse to start when {@code
+ * aere.falcon.attachBlock} is not comfortably AHEAD of this node's chain head, unless there is
+ * positive evidence that this is a legitimate resumption rather than a misconfiguration.
+ *
+ * <p>Call this exactly once during node startup, from the first point at which a chain head
+ * exists and BEFORE the network and the consensus state machine are started, so that a refusal is
+ * a clean refusal to start rather than a mid-flight abort.
+ *
+ * <p>The hole this closes, measured: {@link #attachmentArmed(long)} only ever required {@code
+ * blockNumber >= attachBlock}. Nothing required {@code attachBlock} to be in the future, so a
+ * single validator restarted with an already-passed height began emitting Falcon-carrying Commit
+ * messages on its first commit, into a fleet that was otherwise correctly configured.
+ *
+ * <p>The failure mode this guard introduces, and how it is handled: a node that CRASHES and
+ * restarts after the attachment height has legitimately passed must still be able to come back. A
+ * guard that refuses that restart is itself a halt risk, and on the scratch fleet losing one of
+ * seven validators stalled the chain for 61 s. Three independent pieces of evidence therefore
+ * distinguish "this height was configured in the past, which is a misconfiguration" from "this
+ * height passed normally while we were running, which is expected":
+ *
+ * <ol>
+ * <li>ON-CHAIN, strongest and fleet-wide: a header at or after the attachment height carries a
+ * Falcon seal. The fleet demonstrably armed. Nothing local is trusted for this.
+ * <li>LOCAL ADMISSION RECEIPT: this node was already admitted, by this same guard, with this
+ * exact height and this exact registry, at a time when the height was safely in the future.
+ * This is what rescues a crash in the window BEFORE the height arrives, where no on-chain
+ * evidence can exist yet.
+ * <li>EXPLICIT OPERATOR ACKNOWLEDGEMENT naming the exact height. The escape hatch for a node
+ * restored from a backup that predates the activation and lost its receipt.
+ * </ol>
+ *
+ * @param chainHeadNumber this node's local chain head at startup
+ * @param headerCarriesFalconSeal true iff the header at that height carries >= 1 Falcon seal; may
+ * be null, in which case on-chain evidence is treated as unavailable (fail-closed)
+ * @param dataDirectory the node data directory in which the admission receipt lives; may be null
+ * @throws ActivationConfigException with {@link ActivationConfigException.Kind#UNSAFE} when the
+ * configured height is not safely ahead and no evidence of a legitimate activation exists
+ */
+ public void verifyAttachHeightAgainstChainHeadOrAbort(
+ final long chainHeadNumber,
+ final LongPredicate headerCarriesFalconSeal,
+ final Path dataDirectory) {
+
+ if (attachBlock == Long.MAX_VALUE) {
+ chainRelativeState = ChainRelativeGateState.NOT_APPLICABLE;
+ LOG.info(
+ "AERE PQC: chain-relative attachment guard NOT APPLICABLE - aere.falcon.attachBlock is "
+ + "unset, so seal attachment is OFF at every height (safe default).");
+ return;
+ }
+
+ final long margin = minAttachFutureMargin();
+ final long head = Math.max(0L, chainHeadNumber);
+ final long lead = attachBlock - head;
+
+ if (lead >= margin) {
+ chainRelativeState = ChainRelativeGateState.FUTURE;
+ LOG.info(
+ "AERE PQC: attachment-height guard PASSED - aere.falcon.attachBlock={} leads this node's "
+ + "chain head {} by {} block(s); required margin aere.falcon.minAttachFutureMargin={}."
+ + " Recording an admission receipt so that a crash-restart inside the activation "
+ + "window is not refused.",
+ attachBlock,
+ head,
+ lead,
+ margin);
+ writeAdmissionReceipt(dataDirectory, head);
+ return;
+ }
+
+ // The height is at, behind, or uncomfortably close to the head. Refuse UNLESS something
+ // positively proves this is a resumption. Absence of evidence is treated as evidence of
+ // misconfiguration: that direction is the fail-closed one.
+
+ final long evidenceFloor = Math.max(attachBlock, head - ACTIVATION_EVIDENCE_LOOKBACK + 1);
+ if (fleetAlreadyArmed(head, evidenceFloor, headerCarriesFalconSeal)) {
+ chainRelativeState = ChainRelativeGateState.RESUMED_ACTIVATED;
+ LOG.warn(
+ "AERE PQC: attachment height {} is already PASSED at this node's chain head {} (lead={}, "
+ + "margin={}), but block {} carries a Falcon seal, so the fleet demonstrably ARMED at "
+ + "this height and this is a legitimate RESTART, not a misconfiguration. Starting "
+ + "normally; this node will attach seals immediately, which is what its peers already "
+ + "do.",
+ attachBlock,
+ head,
+ lead,
+ margin,
+ armedEvidenceHeight);
+ writeAdmissionReceipt(dataDirectory, head);
+ return;
+ }
+
+ if (admissionReceiptMatches(dataDirectory)) {
+ chainRelativeState = ChainRelativeGateState.RESUMED_ADMITTED;
+ LOG.warn(
+ "AERE PQC: attachment height {} is not {} block(s) ahead of this node's chain head {} "
+ + "(lead={}), and no block at or after {} carries a Falcon seal yet, BUT this node "
+ + "holds an admission receipt for exactly this height and registry, i.e. this same "
+ + "guard already accepted this configuration while the height was safely in the "
+ + "future. This is a RESTART inside the activation window, not a misconfiguration. "
+ + "Starting normally.",
+ attachBlock,
+ margin,
+ head,
+ lead,
+ evidenceFloor);
+ return;
+ }
+
+ if (operatorAcknowledgedPastAttachBlock()) {
+ chainRelativeState = ChainRelativeGateState.OPERATOR_ACKNOWLEDGED;
+ LOG.error(
+ "AERE PQC: OPERATOR OVERRIDE - aere.falcon.attachBlockAcknowledgePast={} matches "
+ + "aere.falcon.attachBlock, so the chain-relative guard is being bypassed even though "
+ + "the height is not {} block(s) ahead of the chain head {} (lead={}) and no on-chain "
+ + "activation evidence was found. This node WILL attach Falcon seals as soon as it "
+ + "commits. Use this ONLY to restore a validator into an activation that has genuinely "
+ + "already happened. REMOVE the property after the node is back.",
+ attachBlock,
+ margin,
+ head,
+ lead);
+ writeAdmissionReceipt(dataDirectory, head);
+ return;
+ }
+
+ throw new ActivationConfigException(
+ ActivationConfigException.Kind.UNSAFE,
+ "AERE-PQC-CFG-UNSAFE-05",
+ "AERE PQC: aere.falcon.attachBlock="
+ + attachBlock
+ + " is NOT safely ahead of this node's chain head "
+ + head
+ + " (lead="
+ + lead
+ + " block(s); required aere.falcon.minAttachFutureMargin="
+ + margin
+ + "). Refusing to start (fail-closed). An attachment height that this chain has already "
+ + "passed - or that will pass while this node is still starting up and not yet taking "
+ + "part in consensus - makes THIS node begin emitting Falcon-carrying Commit messages at "
+ + "a block the rest of the fleet did not arm at. Every peer whose Commit decoder is "
+ + "strict then discards the whole Commit, decisive ECDSA seal included, and the "
+ + "committing set silently shrinks. No evidence of a legitimate activation was found: no "
+ + "header in ["
+ + evidenceFloor
+ + ","
+ + head
+ + "] carries a Falcon seal, and this node holds no admission receipt for attachBlock="
+ + attachBlock
+ + ". REMEDY: if the fleet HAS legitimately activated at this height and this node lost "
+ + "its data directory, restart once with "
+ + "-Daere.falcon.attachBlockAcknowledgePast="
+ + attachBlock
+ + " (the value must equal aere.falcon.attachBlock exactly). Otherwise set "
+ + "aere.falcon.attachBlock to a height at least "
+ + margin
+ + " blocks ahead of the fleet's head, ON EVERY VALIDATOR, and restart the fleet.");
+ }
+
+ /**
+ * Look for on-chain proof that the fleet has already armed: a header at or after the attachment
+ * height carrying at least one Falcon seal. Fails CLOSED - an unreadable or undecodable header is
+ * not evidence.
+ */
+ private boolean fleetAlreadyArmed(
+ final long head, final long evidenceFloor, final LongPredicate headerCarriesFalconSeal) {
+ if (headerCarriesFalconSeal == null || attachBlock > head) {
+ return false;
+ }
+ for (long n = head; n >= evidenceFloor && n >= 0; n--) {
+ try {
+ if (headerCarriesFalconSeal.test(n)) {
+ armedEvidenceHeight = n;
+ return true;
+ }
+ } catch (final RuntimeException e) {
+ LOG.warn(
+ "AERE PQC: could not inspect header {} for Falcon seals ({}); treating on-chain "
+ + "activation evidence as UNAVAILABLE (fail-closed).",
+ n,
+ e.toString());
+ return false;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Identity of the registry this node is configured against, so an admission receipt written for
+ * one chain or one manifest can never admit a different one.
+ */
+ private String registryIdentity() {
+ if (manifestHash != null) {
+ return manifestHash;
+ }
+ if (pendingLateHash != null) {
+ return pendingLateHash;
+ }
+ return "none";
+ }
+
+ /** Path of the admission receipt inside the node data directory, or null if unavailable. */
+ private Path admissionReceiptPath(final Path dataDirectory) {
+ return dataDirectory == null ? null : dataDirectory.resolve(ADMISSION_RECEIPT_FILE);
+ }
+
+ /**
+ * Record that this guard ADMITTED this attachment height. Deliberately best-effort: a data
+ * directory that cannot be written must not stop a validator from starting. It is logged at ERROR
+ * because the consequence is real - without the receipt, a crash-restart before the activation
+ * height would be refused and would need the acknowledgement property.
+ */
+ private void writeAdmissionReceipt(final Path dataDirectory, final long head) {
+ final Path p = admissionReceiptPath(dataDirectory);
+ if (p == null) {
+ return;
+ }
+ final Properties props = new Properties();
+ props.setProperty("attachBlock", Long.toString(attachBlock));
+ props.setProperty("registryIdentity", registryIdentity());
+ props.setProperty("admittedAtChainHead", Long.toString(head));
+ props.setProperty("admittedAtEpochMillis", Long.toString(System.currentTimeMillis()));
+ try {
+ Files.createDirectories(p.getParent());
+ try (OutputStream out = Files.newOutputStream(p)) {
+ props.store(
+ out,
+ "AERE PQC activation admission receipt. Written by the chain-relative attachment guard "
+ + "when it accepted this attachment height. Presence of this file lets a later "
+ + "restart tell a legitimate resumption from a past-height misconfiguration. It "
+ + "contains no key material.");
+ }
+ } catch (final Exception e) {
+ LOG.error(
+ "AERE PQC: could NOT write the activation admission receipt to {} ({}). Startup "
+ + "continues, but if this node restarts before block {} it will have no local proof "
+ + "that this activation height was already accepted, and the chain-relative guard "
+ + "will refuse to start it until either the fleet has visibly armed on chain or "
+ + "-Daere.falcon.attachBlockAcknowledgePast={} is set.",
+ p,
+ e.toString(),
+ attachBlock,
+ attachBlock);
+ }
+ }
+
+ /** True iff a receipt exists recording an earlier admission of this exact height and registry. */
+ private boolean admissionReceiptMatches(final Path dataDirectory) {
+ final Path p = admissionReceiptPath(dataDirectory);
+ if (p == null || !Files.isRegularFile(p)) {
+ return false;
+ }
+ try (InputStream in = Files.newInputStream(p)) {
+ final Properties props = new Properties();
+ props.load(in);
+ final String a = props.getProperty("attachBlock");
+ final String id = props.getProperty("registryIdentity");
+ final boolean match =
+ a != null
+ && id != null
+ && Long.parseLong(a.trim()) == attachBlock
+ && id.trim().equals(registryIdentity());
+ if (!match) {
+ LOG.warn(
+ "AERE PQC: an activation admission receipt exists at {} but does NOT match the current "
+ + "configuration (receipt attachBlock={}, registryIdentity={}; configured "
+ + "attachBlock={}, registryIdentity={}). It is being IGNORED, which is the "
+ + "fail-closed direction: a receipt for a different activation must never admit "
+ + "this one.",
+ p,
+ a,
+ id,
+ attachBlock,
+ registryIdentity());
+ }
+ return match;
+ } catch (final Exception e) {
+ LOG.warn(
+ "AERE PQC: could not read the activation admission receipt at {} ({}); treating it as "
+ + "ABSENT (fail-closed).",
+ p,
+ e.toString());
+ return false;
+ }
+ }
+
+ /**
+ * The explicit operator escape hatch. The acknowledgement must NAME the exact attachment height,
+ * so it cannot be a blanket "true" that survives across activations and quietly disarms this
+ * guard forever. An acknowledgement naming a DIFFERENT height is itself an abort: it means the
+ * operator is acknowledging an activation this node is not configured for.
+ */
+ private boolean operatorAcknowledgedPastAttachBlock() {
+ final String v =
+ resolve("aere.falcon.attachBlockAcknowledgePast", "AERE_FALCON_ATTACHBLOCK_ACK_PAST");
+ if (v == null) {
+ return false;
+ }
+ final long ack;
+ try {
+ ack = Long.parseLong(v.trim());
+ } catch (final NumberFormatException e) {
+ throw new ActivationConfigException(
+ ActivationConfigException.Kind.SYNTAX,
+ "AERE-PQC-CFG-SYNTAX-05",
+ "AERE PQC: aere.falcon.attachBlockAcknowledgePast is set to a MALFORMED value '"
+ + v
+ + "' (not an integer block number). Refusing to start. This property is not a "
+ + "boolean: it must repeat the exact aere.falcon.attachBlock value being acknowledged "
+ + "("
+ + attachBlock
+ + ").");
+ }
+ if (ack != attachBlock) {
+ throw new ActivationConfigException(
+ ActivationConfigException.Kind.UNSAFE,
+ "AERE-PQC-CFG-UNSAFE-06",
+ "AERE PQC: aere.falcon.attachBlockAcknowledgePast="
+ + ack
+ + " does not match aere.falcon.attachBlock="
+ + attachBlock
+ + ". Refusing to start (fail-closed): an acknowledgement that names a different "
+ + "height is a stale acknowledgement left over from an earlier activation, and it "
+ + "must never silently authorise the current one.");
+ }
+ return true;
+ }
+
+ /**
+ * Highest legal value for {@code aere.falcon.attachInterval}.
+ *
+ * <p>This is NOT a taste limit, it is {@link #ACTIVATION_EVIDENCE_LOOKBACK}. A validator that
+ * loses its data directory and restarts past an attachment height proves the fleet is already
+ * armed by finding a header carrying at least one seal within the last {@value
+ * #ACTIVATION_EVIDENCE_LOOKBACK} blocks; with a wider interval that window can contain no
+ * seal-bearing header at all, the evidence search comes up empty, and the node refuses to start
+ * with AERE-PQC-CFG-UNSAFE-05. The failure would arrive days later, on an unrelated restart, on
+ * whichever node happened to lose its disk. Refusing the interval at configuration time is the
+ * only place the operator can still see the connection.
+ */
+ private static final int MAX_ATTACH_INTERVAL = ACTIVATION_EVIDENCE_LOOKBACK;
+
+ /**
+ * Write the Falcon certificate only on every Nth block, counted from {@code
+ * aere.falcon.attachBlock}. Configurable via {@code aere.falcon.attachInterval} / {@code
+ * AERE_FALCON_ATTACHINTERVAL}.
+ *
+ * <p>WHY THIS EXISTS, measured on chain 2800 on 2026-08-08. Attachment was armed on all seven
+ * validators and the header went from 525 to 3844 bytes, five Falcon seals on EVERY block: more
+ * than seven times the header bytes, on nodes whose free space could not absorb it. The anchor
+ * producer
+ * already has both an interval and a seal cap, but the assembler reached when the anchor is NOT
+ * armed has neither, and that assembler is the one that runs before the activation height. So the
+ * cheap-by-design path was unreachable precisely during the window it was needed.
+ *
+ * <p>Unset means EVERY block, which is exactly today's behaviour. A node that writes fewer
+ * certificates than its neighbours is a behaviour change on a live chain and has to be asked for,
+ * never delivered as a default.
+ *
+ * <p>There is no consensus coupling here and that is deliberate: below the anchor activation
+ * height no rule reads element 6 to refuse anything, so two nodes with different intervals still
+ * agree on exactly which headers are valid. That stops being true at the activation height, where
+ * PqAnchorDigestRule binds the certificate to the vanity digest, and from there the anchor's own
+ * interval governs.
+ *
+ * @return the interval in blocks, or empty for every block
+ */
+ public OptionalInt attachInterval() {
+ final String v = resolve("aere.falcon.attachInterval", "AERE_FALCON_ATTACHINTERVAL");
+ if (v == null) {
+ return OptionalInt.empty();
+ }
+ final int parsed;
+ try {
+ parsed = Integer.parseInt(v.trim());
+ } catch (final NumberFormatException e) {
+ throw new ActivationConfigException(
+ ActivationConfigException.Kind.SYNTAX,
+ "AERE-PQC-CFG-SYNTAX-09",
+ "AERE PQC: aere.falcon.attachInterval is set to a MALFORMED value '"
+ + v
+ + "' (not an integer number of blocks). Refusing to start: an interval that silently "
+ + "reverted to 'every block' would leave the operator believing a disk control is in "
+ + "force when it is not, and the disk would fill anyway.");
+ }
+ if (parsed < 1 || parsed > MAX_ATTACH_INTERVAL) {
+ throw new ActivationConfigException(
+ ActivationConfigException.Kind.UNSAFE,
+ "AERE-PQC-CFG-UNSAFE-09",
+ "AERE PQC: aere.falcon.attachInterval "
+ + parsed
+ + " is outside the legal range 1.."
+ + MAX_ATTACH_INTERVAL
+ + ". Refusing to start. The upper bound is the "
+ + ACTIVATION_EVIDENCE_LOOKBACK
+ + "-block window in which a restarting validator looks for proof that the fleet is "
+ + "already armed; a wider interval can leave that window with no seal-bearing header, "
+ + "and the node would then refuse to start with AERE-PQC-CFG-UNSAFE-05 days later, "
+ + "on whichever node happened to lose its data directory.");
+ }
+ return OptionalInt.of(parsed);
+ }
+
+ /**
+ * Largest number of seals this node writes into a certificate on the pre-activation assembly
+ * path. Configurable via {@code aere.falcon.attachMaxSeals} / {@code
+ * AERE_FALCON_ATTACHMAXSEALS}; unset means no cap, which is today's behaviour.
+ *
+ * <p>This is the twin of {@code aere.pq.anchor.maxSeals}, which caps the anchor producer. K is a
+ * FLOOR, not a ceiling: nodes attach as many seals as reach them in time, not as many as any
+ * threshold asks for. Measured on 2026-08-08, five seals per block arrived with seven validators
+ * attaching.
+ *
+ * <p>Unlike the anchor cap there is no minimum tied to a K schedule, because below the activation
+ * height no rule counts these seals. The only floor is 1: a certificate of zero seals is not a
+ * cheaper certificate, it is an absent one, and the caller should unset attachment instead.
+ *
+ * @return the cap, or empty for no cap
+ */
+ public OptionalInt attachMaxSeals() {
+ final String v = resolve("aere.falcon.attachMaxSeals", "AERE_FALCON_ATTACHMAXSEALS");
+ if (v == null) {
+ return OptionalInt.empty();
+ }
+ final int parsed;
+ try {
+ parsed = Integer.parseInt(v.trim());
+ } catch (final NumberFormatException e) {
+ throw new ActivationConfigException(
+ ActivationConfigException.Kind.SYNTAX,
+ "AERE-PQC-CFG-SYNTAX-10",
+ "AERE PQC: aere.falcon.attachMaxSeals is set to a MALFORMED value '"
+ + v
+ + "' (not an integer). Refusing to start rather than silently carrying no cap.");
+ }
+ if (parsed < 1) {
+ throw new ActivationConfigException(
+ ActivationConfigException.Kind.UNSAFE,
+ "AERE-PQC-CFG-UNSAFE-10",
+ "AERE PQC: aere.falcon.attachMaxSeals "
+ + parsed
+ + " is below 1. Refusing to start: a cap of zero produces an absent certificate, not "
+ + "a cheaper one. To stop writing certificates, unset aere.falcon.attachBlock.");
+ }
+ return OptionalInt.of(parsed);
+ }
+
+ /**
+ * Whether the pre-activation assembler should write a certificate into THIS block's header.
+ *
+ * <p>Counted from {@code attachBlock} so that every node in the fleet picks the same heights
+ * without any agreement protocol: the two inputs are a configured constant and the block number.
+ * Nodes with different intervals disagree about which blocks carry a certificate, and that is
+ * harmless below the activation height for the reason given on {@link #attachInterval()}.
+ *
+ * @param blockNumber the height being sealed
+ * @return true if a certificate should be written at this height
+ */
+ public boolean attachesCertificateAt(final long blockNumber) {
+ return attachmentArmed(blockNumber) && isAttachHeight(blockNumber, attachBlock, attachInterval());
+ }
+
+ /**
+ * The height arithmetic of {@link #attachesCertificateAt}, with no dependency on instance state.
+ *
+ * <p>Kept separate and package-private so it can be measured on its own. The surrounding gate
+ * needs a fully constructed node with a loaded registry, a signing key and an armed attachment
+ * height, which is a great deal of scaffolding around one modulo; a proof that has to build all
+ * of that in order to ask "is 12,900,100 an attachment height" ends up measuring the scaffolding.
+ *
+ * @param blockNumber the height being sealed
+ * @param from the configured attachment height
+ * @param every the configured interval, or empty for every block
+ * @return true if a certificate belongs at this height
+ */
+ static boolean isAttachHeight(final long blockNumber, final long from, final OptionalInt every) {
+ if (blockNumber < from) {
+ return false;
+ }
+ if (every.isEmpty()) {
+ return true;
+ }
+ return (blockNumber - from) % every.getAsInt() == 0;
+ }
+
+ /**
+ * Minimum number of blocks by which the attachment height must lead this node's chain head at
+ * startup. Configurable via {@code aere.falcon.minAttachFutureMargin} /
+ * {@code AERE_FALCON_MIN_ATTACH_FUTURE_MARGIN}; a malformed or negative value aborts startup
+ * rather than silently reverting to the default, so an operator can never believe a margin is in
+ * force when it is not.
+ *
+ * @return the required margin in blocks
+ */
+ public long minAttachFutureMargin() {
+ final String v =
+ resolve("aere.falcon.minAttachFutureMargin", "AERE_FALCON_MIN_ATTACH_FUTURE_MARGIN");
+ if (v == null) {
+ return DEFAULT_MIN_ATTACH_FUTURE_MARGIN;
+ }
+ final long parsed;
+ try {
+ parsed = Long.parseLong(v.trim());
+ } catch (final NumberFormatException e) {
+ throw new ActivationConfigException(
+ ActivationConfigException.Kind.SYNTAX,
+ "AERE-PQC-CFG-SYNTAX-06",
+ "AERE PQC: aere.falcon.minAttachFutureMargin is set to a MALFORMED value '"
+ + v
+ + "' (not an integer number of blocks). Refusing to start: a safety margin that "
+ + "silently reverted to its default would leave the operator believing a margin is in "
+ + "force when it is not.");
+ }
+ if (parsed < 0) {
+ throw new ActivationConfigException(
+ ActivationConfigException.Kind.SYNTAX,
+ "AERE-PQC-CFG-SYNTAX-06",
+ "AERE PQC: aere.falcon.minAttachFutureMargin '"
+ + v
+ + "' is negative. Refusing to start.");
+ }
+ return parsed;
+ }
+
+ /**
+ * Outcome of the chain-relative startup guard.
+ *
+ * @return the gate state, NOT_CHECKED until the guard has run
+ */
+ public ChainRelativeGateState chainRelativeState() {
+ return chainRelativeState;
+ }
+
+ // ---- AERE GENESIS BINDING (2026-08-01): BIND THE REGISTRY TO CONSENSUS ----
+
+ /**
+ * GENESIS BINDING GUARD: refuse to start when the Falcon registry this node loaded is not the one the chain
+ * requires at this height.
+ *
+ * <p>THE DEFECT, read out of the constructor above and not guessed at. The registry that answers
+ * "which Falcon public key is validator index i" can come from {@code aere.falcon.registry}, a
+ * plain local properties file with no binding to anything. Two nodes handed two different files
+ * disagree about who index i is, so one and the same certificate verifies on one node and fails on
+ * the other. That splits the network with no attacker in it. The two anchored sources do check a
+ * hash, but their fail-closed action is to leave the registry EMPTY and log; the process still
+ * STARTS, so a node can run for days as a validator that can verify nothing and report itself
+ * healthy while doing it.
+ *
+ * <p>WHAT THIS GUARD DOES. It re-reads the registry file this node actually used, computes its
+ * canonical hash, and compares it against {@code config.pqRegistryHash} in genesis, which is a
+ * height-indexed schedule. Below the first scheduled height nothing is enforced, so the roughly
+ * 11.8 million blocks already on chain 2800 are untouched. At and above it, a mismatch throws and
+ * the node does not start; the refusal names the hash expected, the hash found, the file, the
+ * source kind and a fingerprint per registry row.
+ *
+ * <p>Call this from the same place as {@link #verifyAttachHeightAgainstChainHeadOrAbort}: after a
+ * chain head exists and before the network and the QBFT state machine are started, so a refusal is
+ * a clean refusal to start rather than a mid-flight abort.
+ *
+ * <p>The genesis file is located from {@code aere.pq.genesis} / {@code AERE_PQ_GENESIS}, falling
+ * back to {@code aere.falcon.genesis} / {@code AERE_FALCON_GENESIS}. When neither is set there is
+ * no schedule to read, nothing is enforced, and that fact is logged at WARN rather than passed
+ * over: a binding everybody believes is on and is not is worse than no binding at all.
+ *
+ * @param chainHeadNumber this node's local chain head at startup
+ * @param chainId the chain id, which is part of the canonical pre-image
+ * @return the gate state
+ * @throws PqRegistryHash.RegistryConfigException when a binding is active and is not satisfied
+ */
+ public PqRegistryHash.GateState verifyRegistryBindingOrAbort(
+ final long chainHeadNumber, final long chainId) {
+
+ final String genesisForSchedule =
+ resolve("aere.pq.genesis", "AERE_PQ_GENESIS") != null
+ ? resolve("aere.pq.genesis", "AERE_PQ_GENESIS")
+ : resolve("aere.falcon.genesis", "AERE_FALCON_GENESIS");
+
+ final PqRegistryHash.Schedule schedule;
+ if (genesisForSchedule == null) {
+ LOG.warn(
+ "AERE PQC GENESIS-BINDING: no genesis file is reachable to read config.pqRegistryHash from (neither "
+ + "aere.pq.genesis nor aere.falcon.genesis is set), so the Falcon registry on this "
+ + "node is NOT bound to consensus. The registry in use is {} ({}). Two nodes holding "
+ + "different registry files would disagree about which public key validator index i "
+ + "has, and the same certificate would verify on one and fail on the other.",
+ registrySourcePath == null ? "(none)" : registrySourcePath,
+ registrySourceKind);
+ schedule = PqRegistryHash.emptySchedule();
+ } else {
+ schedule = PqRegistryHash.loadScheduleFromGenesis(Paths.get(genesisForSchedule));
+ }
+
+ return verifyRegistryBindingOrAbort(chainHeadNumber, chainId, schedule);
+ }
+
+ /**
+ * AERE GENESIS BINDING, the form the caller should actually use: verify the registry binding against a
+ * schedule that came from the genesis configuration BESU ITSELF PARSED AND BOOTED WITH.
+ *
+ * <p>WHY THIS OVERLOAD EXISTS, and it is not tidiness. The single-argument form above reads
+ * {@code config.pqRegistryHash} out of a genesis FILE whose path comes from a system property.
+ * That is a local file named by local configuration, which is the exact shape of the defect this
+ * guard was written to close. A node pointed at a stale copy of genesis would enforce a stale
+ * binding, or none, and would say nothing. Handing the schedule down from {@code
+ * GenesisConfigOptions} removes the second file entirely: the value enforced is read out of the
+ * same genesis object that produced this node's genesis hash, so a node that disagrees about the
+ * schedule already disagrees about the chain and is not on it.
+ *
+ * @param chainHeadNumber this node's local chain head at startup
+ * @param chainId the chain id, which is part of the canonical pre-image
+ * @param schedule the schedule parsed from the booted genesis configuration
+ * @return the gate state
+ * @throws PqRegistryHash.RegistryConfigException when a binding is active and is not satisfied
+ */
+ public PqRegistryHash.GateState verifyRegistryBindingOrAbort(
+ final long chainHeadNumber, final long chainId, final PqRegistryHash.Schedule schedule) {
+
+ PqRegistryHash.Registry loaded = null;
+ if (registrySourcePath != null) {
+ loaded = PqRegistryHash.loadAuto(Paths.get(registrySourcePath));
+ }
+
+ // HEIGHT SCHEDULE: build the height-resolved set BEFORE anything is published, from the registry this
+ // node signs with plus every registry named in the history list. With no history configured the
+ // set holds exactly the one registry and every answer below is what it was before the height schedule existed.
+ //
+ // AERE HELD-SET SCOPE (2026-08-06). THIS BLOCK USED TO SIT 41 LINES LOWER, AND THAT WAS THE DEFECT. The
+ // startup guard below was handed the single primary registry and threw
+ // AERE-PQC-REG-MISMATCH-01 before this code ever ran, so a node holding exactly the right files
+ // - the post-rotation registry as its own, the pre-rotation one as history - refused to start
+ // with a correct configuration. Re-confirmed 2026-08-06 with head at 2554. Consequence: after
+ // the first rotation, no node could be restarted without hand intervention.
+ //
+ // MOVING IT UP ADDS NO WORK ON AN UNCONFIGURED NODE: resolve() returns null, and
+ // parseRegistryPaths(null) returns an empty list, so nothing is opened that was not opened
+ // before. On chain 2800 the schedule is empty, buildSet's loop has zero iterations, and this
+ // whole block costs one map lookup.
+ final List<PqRegistryHash.Registry> held = new ArrayList<>();
+ held.add(loaded);
+ final String history = resolve(PROPERTY_REGISTRY_HISTORY, "AERE_FALCON_REGISTRY_HISTORY");
+ for (final java.nio.file.Path p : PqRegistryHash.parseRegistryPaths(history)) {
+ held.add(PqRegistryHash.loadAuto(p));
+ }
+ final PqRegistryHash.RegistrySet set = PqRegistryHash.buildSet(schedule, held, chainId);
+
+ PqRegistryHash.GateState state;
+ try {
+ state = PqRegistryHash.verifyOrAbort(schedule, set, chainHeadNumber, chainId);
+ requireAnchorHeightIsASignedHeightOrAbort(schedule, set);
+ } catch (final PqRegistryHash.RegistryConfigException e) {
+ // AERE OPTIUNI-URGENTA (2026-08-02): THE WAY BACK, and the only one that does not need a
+ // build. Without it, a registry file that is wrong in one byte takes down every node it was
+ // pushed to, and the documented recovery was to edit a unit file or rebuild. With it, the
+ // recovery is a restart with one flag - and the node spends the whole time it runs saying, at
+ // every height, that it is running unverified.
+ //
+ // NOT bypassed here, deliberately: AERE-PQC-REG-ARM-01, the refusal to arm over a registry
+ // with no validator addresses. That refusal is not a configuration mismatch, it is a
+ // guarantee that the fleet is about to halt at the activation height, and starting anyway
+ // would only move the halt a few blocks later. The way out of THAT one is to disarm the
+ // anchor, which is a different option and is why there are three and not one.
+ if (!registryMismatchAllow) {
+ throw e;
+ }
+ registryOverrideEngaged = true;
+ LOG.error(
+ "AERE PQC EMERGENCY [AERE-PQC-REG-UNSAFE-01]: STARTING ANYWAY WITH AN UNVERIFIED FALCON "
+ + "REGISTRY. The genesis-binding guard REFUSED this node ({}), and {} is set, so "
+ + "the refusal has been overridden BY EXPLICIT OPERATOR REQUEST. What that means, "
+ + "stated plainly: from the first height at which genesis requires a registry hash, "
+ + "this node cannot correctly decide whether a header's Falcon certificate is valid, "
+ + "and it will import those headers anyway. It is NOT a validating node for the "
+ + "post-quantum layer while this is set. WHAT TO DO: install the registry whose hash "
+ + "genesis names and restart WITHOUT this option. The refusal that was overridden "
+ + "follows in full.\n{}",
+ e.code(),
+ PROPERTY_REGISTRY_MISMATCH_ALLOW,
+ e.getMessage());
+ state = PqRegistryHash.GateState.OVERRIDDEN_UNSAFE;
+ }
+ if (schedule.enforced() && !set.coversWholeSchedule()) {
+ // NOT a refusal here, and the reason is measured, not aesthetic. This method is also the
+ // path a node takes when it is being started BELOW the first scheduled height, where nothing
+ // is enforced yet and an operator legitimately holds only one file. The per-block rule
+ // refuses at exactly the heights that are uncovered, which is the narrowest fail-closed
+ // action that still names the problem. What must never happen is silence.
+ LOG.error(
+ "AERE PQC HEIGHT-SCHEDULE: this node holds {} registry file(s) and the chain's pqRegistryHash "
+ + "schedule has {} entr(ies), of which the heights {} are covered by NOTHING this "
+ + "node holds. Every header at or after such a height will be REFUSED, and a node "
+ + "syncing from genesis will stop there. Name the missing registry file(s) in {} "
+ + "(comma-separated). The list is never pruned: one file per rotation the chain has "
+ + "ever performed, kept forever, because a node acquiring history has to verify every "
+ + "block that was ever produced.",
+ set.count(),
+ schedule.entries().size(),
+ set.uncoveredEntryBlocks(),
+ PROPERTY_REGISTRY_HISTORY);
+ }
+ this.registryBindingState = state;
+ // Publish the inputs the per-block half needs. Order matters: the schedule is written LAST, and
+ // registryBindingSatisfiedAt() reads it FIRST and returns true when it is null, so a rule that
+ // runs while this is being assembled sees "inert" and never sees a half-built gate.
+ this.registryBindingLoaded = loaded;
+ this.registryBindingSet = set;
+ this.historicalKeys.clear();
+ this.registryBindingChainId = chainId;
+ this.registryBindingSchedule = schedule;
+ return state;
+ }
+
+ /**
+ * AERE SIGNED-HEIGHT CHECK (2026-08-06), THE LOCAL HALF. The height this node ARMS at must be a height the fleet
+ * actually signed a registry for.
+ *
+ * <p>WHY THIS CANNOT BE THE SAME KIND OF GUARD AS THE ONE ABOVE, and the difference is the whole
+ * honest limitation of that check. {@code aere.pq.anchorBlock} is a SYSTEM PROPERTY, set per node through
+ * {@code BESU_OPTS}. It is in no genesis. Nothing in the document the seven validators hold
+ * byte-identically constrains it. So the agreement of seven nodes on an arming height CANNOT BE
+ * ENFORCED by any code that runs on one node - it can only be DETECTED locally, and that is what
+ * this does. Enforcement would require the height to move into genesis, which is a change to the
+ * chain's configuration and not to this class. That is written here as a limitation, not as a
+ * to-do, because a reader must not mistake this for a consensus rule.
+ *
+ * <p>WHAT IT COMPARES. The arming height A against the {@code block} values of
+ * {@code config.pqRegistryHash}. A registry row's possession proof and validator claim both sign
+ * the bind height; the schedule entry that puts a registry in force names the same number (that is
+ * {@code AERE-PQC-REG-BIND-08}, enforced above). If A is not one of those heights, the moment at
+ * which certificates begin to carry weight is a number no validator ever put a signature on.
+ * Measured 2026-08-06 on a network of seven with A moved from 40 to 70: seven of seven started,
+ * head reached 95, agreement 7 of 7, zero refusals.
+ *
+ * <p>WHY IT CANNOT FIRE ON CHAIN 2800 OR ON ANY NODE RUNNING TODAY. Three independent gates, any
+ * one of which returns first: the anchor is not armed ({@code aere.pq.anchorBlock} unset, so
+ * {@code anchorArmedFrom()} is {@link Long#MAX_VALUE}); {@code config.pqRegistryHash} is in no
+ * genesis this fleet runs, so the schedule is not enforced; and the held registry is v1, carrying
+ * no signatures over any height at all.
+ *
+ * @param schedule the schedule parsed from the booted genesis
+ * @param set the registries this node holds
+ * @throws PqRegistryHash.RegistryConfigException with {@code AERE-PQC-REG-BIND-09}
+ */
+ private void requireAnchorHeightIsASignedHeightOrAbort(
+ final PqRegistryHash.Schedule schedule, final PqRegistryHash.RegistrySet set) {
+ final long armedFrom = anchorArmedFrom();
+ if (armedFrom == Long.MAX_VALUE || !schedule.enforced()) {
+ return;
+ }
+ final PqRegistryHash.Registry primary = set.primary();
+ if (primary == null || !primary.proofBound()) {
+ // Nothing here signed a height. A v1 registry on an armed node is already refused, louder and
+ // for a better reason, by AERE-PQC-REG-ARM-02 in the constructor; this must not take that
+ // message over.
+ return;
+ }
+ for (final PqRegistryHash.ScheduleEntry e : schedule.entries()) {
+ if (e.block() == armedFrom) {
+ return;
+ }
+ }
+ final StringBuilder heights = new StringBuilder();
+ for (final PqRegistryHash.ScheduleEntry e : schedule.entries()) {
+ if (heights.length() > 0) {
+ heights.append(", ");
+ }
+ heights.append(e.block());
+ }
+ throw new PqRegistryHash.RegistryConfigException(
+ "AERE-PQC-REG-BIND-09",
+ "AERE PQC SIGNED-HEIGHT: REFUSING TO START - this node arms at a height no validator signed for.\n"
+ + " FIELD: aere.pq.anchorBlock (system property, per node, read through "
+ + "PqAnchorConfig) versus the 'block' values of config.pqRegistryHash in genesis ("
+ + schedule.source()
+ + ").\n"
+ + " READ: aere.pq.anchorBlock = "
+ + armedFrom
+ + "\n"
+ + " config.pqRegistryHash heights = ["
+ + heights
+ + "]\n"
+ + " EXPECTED: the arming height is ONE of those heights. Every row of the registry "
+ + "carries a Falcon possession proof and an ECDSA claim by that validator's own "
+ + "consensus key, and both signatures cover a single bind height. From the arming "
+ + "height a header's Falcon certificate carries consensus weight; if that height is not "
+ + "one the fleet signed, the day the chain starts enforcing post-quantum seals is a "
+ + "number one operator typed.\n"
+ + " WHY NOTHING ELSE CATCHES IT: aere.pq.anchorBlock is not in genesis. It is not in "
+ + "the document all seven nodes hold identically, so no node can hold another node to "
+ + "it. This refusal is DETECTION on this node only. Two nodes with different "
+ + "aere.pq.anchorBlock still do not disagree about a header until the lower of the two "
+ + "heights, and this guard cannot see the other node's value.\n"
+ + " WHAT TO DO: EITHER set aere.pq.anchorBlock to one of the heights above, on all "
+ + "seven nodes and in the same change - a node armed alone validates differently from "
+ + "the rest. OR, if the activation day genuinely moved, re-run the key ceremony for the "
+ + "new height, put the re-signed registry's NEW hash in config.pqRegistryHash at that "
+ + "height, and roll it to the whole fleet. Moving the day is 14 signatures. It is "
+ + "supposed to be.\n"
+ + " CHECK THE WHOLE FLEET BEFORE RESTARTING ANYTHING: this property has to read "
+ + "the same on every node, and a split state is a fault, "
+ + "not a warning.");
+ }
+
+ /**
+ * AERE GENESIS BINDING, the PER-BLOCK half: does the registry this node is running on satisfy the binding the
+ * chain requires AT THIS HEIGHT? Never throws.
+ *
+ * <p>WHY A STARTUP GUARD IS NOT ENOUGH, measured as a gap and not assumed. The startup guard
+ * answers the question once, against the chain head that existed at startup. A node that is
+ * already running when a ROTATION height in the schedule passes underneath it is never asked
+ * again: it keeps validating with a registry the chain has moved off, and it keeps reporting
+ * itself healthy while doing it. That is the same failure mode the genesis binding was opened for, arriving by a
+ * different door. With a schedule of one entry the two guards are equivalent; with two or more,
+ * only this one covers the interval after the second entry.
+ *
+ * <p>WHAT IT DOES ON MISMATCH. It returns false, and the header rule that calls it refuses the
+ * header. That is deliberate and it is fail-closed: a node whose registry no longer matches what
+ * the chain requires cannot verify the certificates in the headers it is being offered, so
+ * importing them would mean asserting a check it did not perform. It stops instead, loudly, at
+ * the first header past the rotation. It does NOT touch proposer selection or finality.
+ *
+ * @param blockNumber the height of the header being validated
+ * @return true when no binding is active at that height, or the registry satisfies it
+ */
+ public boolean registryBindingSatisfiedAt(final long blockNumber) {
+ final PqRegistryHash.Schedule schedule = this.registryBindingSchedule;
+ if (schedule == null) {
+ return true; // guard never ran on this node: inert, exactly as before this change
+ }
+ final Optional<PqRegistryHash.ScheduleEntry> required =
+ PqRegistryHash.requiredHashAt(schedule, blockNumber);
+ if (required.isEmpty()) {
+ return true; // below the first scheduled height: the 11.8 million existing blocks are untouched
+ }
+ final PqRegistryHash.Registry loaded = this.registryBindingLoaded;
+ // HEIGHT SCHEDULE: ask the whole scheduled history, not only the entry in force at the head. Before this
+ // change a node held one registry, so one rotation left NO configuration that satisfied both
+ // the pre-rotation interval and the head, and the chain became permanently unjoinable.
+ final boolean ok =
+ PqRegistryHash.matchesAt(
+ schedule, this.registryBindingSet, blockNumber, this.registryBindingChainId);
+ if (!ok) {
+ // AERE OPTIUNI-URGENTA: the per-block half of the bypass. It has to consult the ARMED flag
+ // and not only the ENGAGED one, because the case this rule exists for is precisely the one
+ // the startup guard never saw: a rotation height passing underneath a node that started
+ // clean. In that case nothing was overridden at startup and there is nothing engaged yet.
+ if (registryMismatchAllow) {
+ registryOverrideEngaged = true;
+ shoutRegistryOverrideUnsafe(blockNumber, required.get(), loaded);
+ return true;
+ }
+ shoutRegistryBindingMismatch(blockNumber, required.get(), loaded);
+ }
+ return ok;
+ }
+
+ /**
+ * AERE OPTIUNI-URGENTA: is this node ACTUALLY running on a suppressed registry refusal?
+ *
+ * @return true once the bypass has suppressed a real refusal, at startup or on the block path
+ */
+ public boolean registryOverrideEngaged() {
+ return registryOverrideEngaged;
+ }
+
+ /**
+ * AERE OPTIUNI-URGENTA: has an operator armed the registry bypass on this node?
+ *
+ * @return true iff the bypass property or environment variable is set
+ */
+ public boolean registryMismatchAllowArmed() {
+ return registryMismatchAllow;
+ }
+
+ /**
+ * One complete ERROR line per height, for as long as the bypass carries weight. Bounded to one per
+ * height for the same reason the refusal is: at a 0.5 s block period an unbounded line would bury
+ * the first and most useful occurrence. Bounded per height is NOT bounded once: the mission for
+ * this option was that the node shouts at EVERY block, and one line per height is what "every
+ * block" means on a path that is asked about a height more than once.
+ */
+ private void shoutRegistryOverrideUnsafe(
+ final long blockNumber,
+ final PqRegistryHash.ScheduleEntry required,
+ final PqRegistryHash.Registry loaded) {
+ if (registryOverrideLastShout.getAndSet(blockNumber) == blockNumber) {
+ return;
+ }
+ LOG.error(
+ "AERE PQC EMERGENCY [AERE-PQC-REG-UNSAFE-02]: height {} ACCEPTED UNVERIFIED. This chain "
+ + "requires registry hash {} from height {}, the registry this node holds hashes to {}, "
+ + "and the header was imported anyway because {} is set. This node is NOT checking the "
+ + "post-quantum certificates in the headers it accepts. Registry in use: {} ({}, {} "
+ + "entries, address-bound={}). WHAT TO DO: install the registry whose hash is {} and "
+ + "restart WITHOUT the bypass. THIS LINE REPEATS AT EVERY HEIGHT, BY DESIGN.",
+ blockNumber,
+ required.hash(),
+ required.block(),
+ // GENESIS BINDING: this used to print hashV1 next to `required.hash()`, which is computed
+ // with hashFor. Two numbers set side by side to be compared, but computed differently: for
+ // a registry that carries proofs the two can NEVER match, and the message sends the
+ // operator hunting for the defect where it is not. Measured on a fleet of seven on
+ // 2026-08-06.
+ loaded == null
+ ? "(no registry loaded)"
+ : PqRegistryHash.hashFor(loaded, registryBindingChainId),
+ PROPERTY_REGISTRY_MISMATCH_ALLOW,
+ loaded == null ? "(none)" : loaded.sourcePath(),
+ loaded == null ? "(none)" : loaded.kind(),
+ loaded == null ? 0 : loaded.count(),
+ loaded != null && loaded.addressBound(),
+ required.hash());
+ }
+
+ /**
+ * One loud, complete line per height, at most. Bounded because this sits on the header path and a
+ * mismatched node is offered a header roughly twice a second; an unbounded log would bury the
+ * first and most useful occurrence.
+ */
+ private void shoutRegistryBindingMismatch(
+ final long blockNumber,
+ final PqRegistryHash.ScheduleEntry required,
+ final PqRegistryHash.Registry loaded) {
+ if (registryBindingLastShout.getAndSet(blockNumber) == blockNumber) {
+ return;
+ }
+ LOG.error(
+ "AERE PQC GENESIS-BINDING [AERE-PQC-REG-BLOCK-01]: REFUSING header at height {} (fail-closed). The "
+ + "chain requires registry hash {} from height {} (schedule source {}), and the "
+ + "registry this node is running has hash {}. Registry in use: {} ({}), {} entries, "
+ + "address-bound={}. WHY THIS FIRES NOW AND NOT AT STARTUP: this node started under an "
+ + "earlier entry of the same schedule and a ROTATION height has passed underneath it. "
+ + "The startup guard cannot see that; this rule can. WHAT TO DO: install the registry "
+ + "whose hash is {} and restart. Per-row fingerprints of the registry in use: {}",
+ blockNumber,
+ required.hash(),
+ required.block(),
+ this.registryBindingSchedule == null ? "(none)" : this.registryBindingSchedule.source(),
+ // GENESIS BINDING, same reason as above: this is compared against required.hash(), which
+ // is computed with hashFor.
+ loaded == null ? "(no registry loaded)" : PqRegistryHash.hashFor(loaded, registryBindingChainId),
+ loaded == null ? "(none)" : loaded.sourcePath(),
+ loaded == null ? "(none)" : loaded.kind(),
+ loaded == null ? 0 : loaded.count(),
+ loaded != null && loaded.addressBound(),
+ required.hash(),
+ rowFingerprints(loaded));
+ }
+
+ private String rowFingerprints(final PqRegistryHash.Registry loaded) {
+ if (loaded == null) {
+ return "(none)";
+ }
+ final StringBuilder sb = new StringBuilder();
+ for (int i = 0; i < loaded.count(); i++) {
+ if (i > 0) {
+ sb.append(' ');
+ }
+ sb.append('[').append(i).append(']').append(PqRegistryHash.fingerprint(loaded, i));
+ }
+ return sb.toString();
+ }
+
+ /**
+ * Outcome of the genesis-binding guard.
+ *
+ * @return the gate state, or null when the guard has not run
+ */
+ public PqRegistryHash.GateState registryBindingState() {
+ return registryBindingState;
+ }
+
+ /**
+ * Parse a Falcon manifest JSON node ({@code count} plus {@code 0..N-1} entries) into {@code
+ * outPub} / {@code outAddr} and return the keccak256 of the canonical pre-image, or {@code null}
+ * on any failure.
+ *
+ * <p>Each entry is EITHER an address-bound object {@code {"addr": "0x..", "pk": "hex"}} (the
+ * audit-fixed form that binds the Falcon key to a validator address) OR a bare pubkey hex string
+ * (legacy, pubkey-only). A manifest must be uniformly one or the other; a mixed manifest is
+ * rejected (fail-closed). The canonical pre-image is the concatenation, in ascending
+ * validator-index order, of {@code addr(20) || pk} when address-bound, or {@code pk} when legacy.
+ */
+ private static String parseManifest(
+ final JsonNode cfg, final Map<Integer, byte[]> outPub, final Map<Integer, Address> outAddr) {
+ if (cfg == null || cfg.isMissingNode() || !cfg.has("count")) {
+ return null;
+ }
+ final int count = cfg.get("count").asInt();
+ if (count <= 0) {
+ return null;
+ }
+ Boolean addressBoundMode = null; // decided by the first entry, then enforced uniformly
+ for (int i = 0; i < count; i++) {
+ final JsonNode e = cfg.get(Integer.toString(i));
+ if (e == null) {
+ return null;
+ }
+ final boolean bound = e.isObject();
+ if (addressBoundMode == null) {
+ addressBoundMode = bound;
+ } else if (addressBoundMode != bound) {
+ return null; // mixed manifest -> fail-closed
+ }
+ if (bound) {
+ final JsonNode addrNode = e.get("addr");
+ final JsonNode pkNode = e.get("pk");
+ if (addrNode == null || !addrNode.isTextual() || pkNode == null || !pkNode.isTextual()) {
+ return null;
+ }
+ final Address addr;
+ try {
+ addr = Address.fromHexString(addrNode.asText().trim());
+ } catch (final RuntimeException ex) {
+ return null;
+ }
+ if (addr == null) {
+ return null;
+ }
+ outAddr.put(i, addr);
+ outPub.put(i, Bytes.fromHexStringLenient(pkNode.asText().trim()).toArray());
+ } else {
+ if (!e.isTextual()) {
+ return null;
+ }
+ outPub.put(i, Bytes.fromHexStringLenient(e.asText().trim()).toArray());
+ }
+ }
+ final KeccakDigest kd = new KeccakDigest(256);
+ for (int i = 0; i < count; i++) {
+ if (Boolean.TRUE.equals(addressBoundMode)) {
+ // Base Besu d2032017: Address extends BytesHolder (not a Tuweni Bytes), so obtain the raw
+ // 20 address bytes via getBytes().toArray(). Byte-identical to the intended pre-image
+ // (addr20 || pk). Reconciled during PQ-CONSENSUS-BUILDTEST-2026-07-18 (compiles clean).
+ final byte[] a = outAddr.get(i).getBytes().toArray();
+ kd.update(a, 0, a.length);
+ }
+ final byte[] h = outPub.get(i);
+ kd.update(h, 0, h.length);
+ }
+ final byte[] digest = new byte[32];
+ kd.doFinal(digest, 0);
+ return Bytes.wrap(digest).toUnprefixedHexString();
+ }
+
+ /**
+ * Load the STAGE-2 late-anchor manifest file (a bare JSON object {@code {"count": N, "0": {...},
+ * ...}}) into a PENDING pubkey + address map. The registry is NOT populated here; activation
+ * happens only via {@link #activateLateAnchor} once the on-chain anchor contract has been
+ * observed.
+ *
+ * @param manifestPath path to the manifest JSON file
+ * @param pend out-param pending pubkey map
+ * @param pendAddr out-param pending validator-address map
+ * @return keccak256(canonical manifest) 64-hex no 0x, or null on any failure
+ */
+ private static String loadPendingLateManifest(
+ final String manifestPath,
+ final Map<Integer, FalconPublicKeyParameters> pend,
+ final Map<Integer, Address> pendAddr) {
+ try {
+ final byte[] raw = Files.readAllBytes(Paths.get(manifestPath));
+ final JsonNode root = new ObjectMapper().readTree(raw);
+ final Map<Integer, byte[]> pub = new HashMap<>();
+ final Map<Integer, Address> addr = new HashMap<>();
+ final String computed = parseManifest(root, pub, addr);
+ if (computed == null) {
+ return null;
+ }
+ for (final Map.Entry<Integer, byte[]> e : pub.entrySet()) {
+ pend.put(
+ e.getKey(), new FalconPublicKeyParameters(FalconParameters.falcon_512, e.getValue()));
+ }
+ pendAddr.putAll(addr);
+ return computed;
+ } catch (final Exception e) {
+ LOG.error(
+ "AERE PQC: failed to parse late-anchor manifest {}: {} (fail-closed).",
+ manifestPath,
+ e.toString());
+ return null;
+ }
+ }
+
+ /**
+ * Load the validator-to-Falcon-pubkey registry from a GENESIS-ANCHORED manifest and verify it
+ * against the on-chain-committed manifest hash.
+ *
+ * <p>Fail-CLOSED: if the manifest is absent, malformed, or its keccak256 does not equal the
+ * on-chain anchor (i.e. a tampered pubkey or address), the registry is left EMPTY and {@code
+ * null} is returned. With an empty registry every Falcon seal fails to verify, so a post-fork
+ * BLOCKING chain rejects the block - a manifest tamper is therefore detected and cannot be
+ * silently accepted.
+ *
+ * @param genesisPath path to the genesis file
+ * @param reg out-param registry map to populate on success
+ * @param regAddr out-param validator-address map to populate on success
+ * @return the verified manifest hash (64-hex, no 0x) on success, or {@code null} on any failure
+ */
+ private static String loadGenesisAnchoredRegistry(
+ final String genesisPath,
+ final Map<Integer, FalconPublicKeyParameters> reg,
+ final Map<Integer, Address> regAddr) {
+ try {
+ final byte[] raw = Files.readAllBytes(Paths.get(genesisPath));
+ final JsonNode root = new ObjectMapper().readTree(raw);
+ final JsonNode cfg = root.path("config").path("aereFalconRegistry");
+ final Map<Integer, byte[]> pub = new HashMap<>();
+ final Map<Integer, Address> addr = new HashMap<>();
+ final String computed = parseManifest(cfg, pub, addr);
+ if (computed == null) {
+ LOG.error(
+ "AERE PQC: genesis {} has no valid config.aereFalconRegistry manifest; registry EMPTY "
+ + "(fail-closed).",
+ genesisPath);
+ return null;
+ }
+
+ final String anchored = readAnchoredHash(root);
+ if (anchored == null) {
+ LOG.error(
+ "AERE PQC: genesis {} has no on-chain anchored manifest hash at alloc 0x{} slot 0; "
+ + "registry EMPTY (fail-closed).",
+ genesisPath,
+ ANCHOR_ADDRESS);
+ return null;
+ }
+ if (!computed.equalsIgnoreCase(anchored)) {
+ LOG.error(
+ "AERE PQC: GENESIS MANIFEST TAMPER DETECTED - keccak256(manifest)=0x{} != "
+ + "genesis-anchored hash 0x{} (alloc 0x{} slot 0). Registry REJECTED / EMPTY "
+ + "(fail-closed); post-fork blocks will not verify.",
+ computed,
+ anchored,
+ ANCHOR_ADDRESS);
+ return null;
+ }
+
+ for (final Map.Entry<Integer, byte[]> e : pub.entrySet()) {
+ reg.put(
+ e.getKey(), new FalconPublicKeyParameters(FalconParameters.falcon_512, e.getValue()));
+ }
+ regAddr.putAll(addr);
+ LOG.info(
+ "AERE PQC: loaded GENESIS-ANCHORED Falcon registry ({} entries, address-bound={}) from "
+ + "{}; manifestHash=0x{} MATCHES on-chain anchor (alloc 0x{} slot 0, committed in the "
+ + "genesis stateRoot / block hash). Registry is immutable from genesis.",
+ reg.size(),
+ !regAddr.isEmpty() && regAddr.size() == reg.size(),
+ genesisPath,
+ computed,
+ ANCHOR_ADDRESS);
+ return computed;
+ } catch (final Exception e) {
+ LOG.error(
+ "AERE PQC: failed to load genesis-anchored Falcon registry from {}: {} "
+ + "(registry EMPTY, fail-closed).",
+ genesisPath,
+ e.toString());
+ return null;
+ }
+ }
+
+ /** Read the anchored manifest hash from genesis alloc[ANCHOR].storage[slot0]; null if absent. */
+ private static String readAnchoredHash(final JsonNode root) {
+ final JsonNode alloc = root.path("alloc");
+ if (alloc.isMissingNode()) {
+ return null;
+ }
+ final Iterator<Map.Entry<String, JsonNode>> it = alloc.fields();
+ while (it.hasNext()) {
+ final Map.Entry<String, JsonNode> en = it.next();
+ if (!norm(en.getKey(), 40).equals(ANCHOR_ADDRESS)) {
+ continue;
+ }
+ final JsonNode storage = en.getValue().path("storage");
+ if (storage.isMissingNode()) {
+ return null;
+ }
+ final Iterator<Map.Entry<String, JsonNode>> sit = storage.fields();
+ while (sit.hasNext()) {
+ final Map.Entry<String, JsonNode> se = sit.next();
+ if (norm(se.getKey(), 64).equals(ANCHOR_SLOT)) {
+ return norm(se.getValue().asText(), 64);
+ }
+ }
+ }
+ return null;
+ }
+
+ /** Normalise a hex string: strip 0x, lower-case, left-pad with zeros to {@code width} chars. */
+ private static String norm(final String hex, final int width) {
+ String s = hex == null ? "" : hex.trim();
+ if (s.startsWith("0x") || s.startsWith("0X")) {
+ s = s.substring(2);
+ }
+ s = s.toLowerCase(Locale.ROOT);
+ final StringBuilder b = new StringBuilder();
+ for (int i = s.length(); i < width; i++) {
+ b.append('0');
+ }
+ b.append(s);
+ return b.toString();
+ }
+
+ /**
+ * Process-wide lazy singleton.
+ *
+ * @return the shared instance
+ */
+ public static FalconSealSupport instance() {
+ if (instance == null) {
+ synchronized (FalconSealSupport.class) {
+ if (instance == null) {
+ instance = new FalconSealSupport();
+ }
+ }
+ }
+ return instance;
+ }
+
+ private static String resolve(final String sysProp, final String envVar) {
+ final String v = System.getProperty(sysProp);
+ if (v != null && !v.isBlank()) {
+ return v;
+ }
+ final String e = System.getenv(envVar);
+ if (e != null && !e.isBlank()) {
+ return e;
+ }
+ return null;
+ }
+
+ // ---- STAGE-2 LATE-ANCHOR API ----
+
+ /**
+ * Whether a late-anchor manifest is configured, parsed, and still awaiting on-chain activation.
+ * Returns false once activated, terminally failed, or when late-anchor mode is not configured.
+ *
+ * @return true iff consensus code should keep looking for the on-chain anchor contract
+ */
+ public boolean lateAnchorPending() {
+ return anchorContractAddress != null && !lateActivated && !lateFailed;
+ }
+
+ /**
+ * The configured on-chain anchor contract address for the late-anchor registry source.
+ *
+ * @return the 0x-hex contract address, or null when late-anchor mode is not configured
+ */
+ public String anchorAddress() {
+ return anchorContractAddress;
+ }
+
+ /**
+ * Whether the registry has been activated from the late-anchor (contract-anchored) source.
+ *
+ * @return true iff late-anchor activation succeeded
+ */
+ public boolean lateAnchored() {
+ return lateActivated;
+ }
+
+ /**
+ * Whether the late anchor is TERMINALLY FAILED, i.e. an on-chain anchor was observed and its hash
+ * did NOT match the local manifest (tamper, or a wrong manifest shipped to this node).
+ *
+ * <p>AERE FIX-OPRIRE-CONSENS (c): this is the state the old code had no way to express. {@link
+ * #lateAnchorPending()} returns false both while a legitimate anchor has simply not landed yet AND
+ * after a tampered anchor was rejected, and the validation rule keyed BLOCKING off {@code
+ * genesisAnchored() || lateAnchored()} alone. A tampered anchor therefore silently degraded the
+ * whole PQC layer to LOG-ONLY: it failed OPEN. PENDING and terminally FAILED are now distinct, and
+ * the rule fails CLOSED on FAILED.
+ *
+ * @return true iff a manifest/anchor mismatch was detected and the registry is permanently empty
+ */
+ public boolean lateAnchorFailed() {
+ return lateFailed;
+ }
+
+ /**
+ * STAGE-2: activate the pending late-anchor registry against the manifest hash observed in the
+ * anchor contract's storage slot 0 in the chain's world state.
+ *
+ * <p>Idempotent and fail-closed: on a hash match the pending manifest becomes the live registry;
+ * on a mismatch the registry stays EMPTY and the late-anchor path is terminally failed (so a
+ * post-fork BLOCKING chain rejects blocks rather than accepting an unverified manifest).
+ *
+ * @param onChainHash the 32-byte hash read from the anchor contract's slot 0 (hex, 0x optional)
+ * @return true iff the registry is (now) activated from the late anchor
+ */
+ public synchronized boolean activateLateAnchor(final String onChainHash) {
+ if (lateActivated) {
+ return true;
+ }
+ if (lateFailed || anchorContractAddress == null || pendingLateHash == null) {
+ return false;
+ }
+ final String observed = norm(onChainHash, 64);
+ if (!pendingLateHash.equalsIgnoreCase(observed)) {
+ LOG.error(
+ "AERE PQC: LATE-ANCHOR MANIFEST TAMPER DETECTED - keccak256(local manifest)=0x{} != "
+ + "on-chain anchor 0x{} at contract {} slot 0. Registry REJECTED / EMPTY "
+ + "(fail-closed); post-fork blocks will not verify.",
+ pendingLateHash,
+ observed,
+ anchorContractAddress);
+ lateFailed = true;
+ return false;
+ }
+ registry.putAll(pendingLate);
+ if (pendingLateAddresses != null) {
+ indexToAddress.putAll(pendingLateAddresses);
+ }
+ manifestHash = pendingLateHash;
+ lateActivated = true;
+ LOG.info(
+ "AERE PQC: LATE-ANCHOR ACTIVATED - Falcon registry ({} entries, address-bound={}) is now "
+ + "live; keccak256(manifest)=0x{} MATCHES the on-chain anchor at contract {} slot 0 "
+ + "(readable via eth_getStorageAt). Hybrid PQC certificate can now be verified on an "
+ + "already-live chain with NO re-genesis.",
+ registry.size(),
+ addressBound(),
+ pendingLateHash,
+ anchorContractAddress);
+ return true;
+ }
+
+ /**
+ * Whether this node holds a Falcon signing key and should attach a parallel seal.
+ *
+ * @return true if signing is enabled
+ */
+ public boolean signingEnabled() {
+ return signingEnabled;
+ }
+
+ /**
+ * This node's validator index in the Falcon registry.
+ *
+ * @return the local validator index, or -1 if signing is disabled
+ */
+ public int localIndex() {
+ return localIndex;
+ }
+
+ /**
+ * Number of validators in the Falcon registry.
+ *
+ * @return the registry size
+ */
+ public int registrySize() {
+ return registry.size();
+ }
+
+ // ---- AERE audit fix (AUD-CONSENSUS-1 / -2): address binding + eligible-signer set ----
+
+ /**
+ * Whether the active registry binds every one of its Falcon keys to a validator ADDRESS. Only an
+ * address-bound registry can produce a well-defined {@code currentValidators INTERSECT registry}
+ * eligible-signer set, so ONLY an address-bound registry may arm blocking. The legacy pubkey-only
+ * {@code aere.falcon.registry} file and an empty (pending / unconfigured) registry both return
+ * false, so a post-fork block over such a registry fails closed.
+ *
+ * @return true iff the registry is non-empty and every registry index has a bound validator
+ * address
+ */
+ public boolean addressBound() {
+ return !registry.isEmpty() && indexToAddress.size() == registry.size();
+ }
+
+ /**
+ * The validator ADDRESS bound to a registry index, or {@code null} if the index is unregistered
+ * or the registry is not address-bound.
+ *
+ * @param validatorIndex the registry index carried by a Falcon seal
+ * @return the bound validator address, or null
+ */
+ public Address addressForIndex(final int validatorIndex) {
+ return indexToAddress.get(validatorIndex);
+ }
+
+ /**
+ * AERE 2026-08-07. Refuses to start when the Falcon key this node loaded belongs to a DIFFERENT
+ * validator, i.e. when the registry binds the key's index to an address that is not this node's.
+ *
+ * <p>WHY THIS EXISTS, and it is a measurement not a worry. Seven test nodes, one variable changed:
+ * node 0 was handed index 1's key file. It started clean and logged {@code loaded local Falcon
+ * signing key for validator index 1} - the very same line the node that really is index 1 logs.
+ * Zero refusals, zero mismatch lines. The chain then crossed the K=3 threshold height without a
+ * pause, to height 86, with zero rejected blocks on the healthy nodes and zero divergence, because
+ * the seals VERIFY: it is genuinely index 1's key. What was silently lost is that index 0
+ * disappeared from the signer set and index 1 was duplicated. The N=7, K=3, f=2 margin is computed
+ * over SEVEN DISTINCT signers; you have six and nothing tells you.
+ *
+ * <p>Nothing else catches it. Not startup, not the log, not the block rhythm, not rejections, not
+ * the divergence detector - every node agrees, because the certificate is valid. Until today the
+ * only guard was a PROCEDURE step run by hand on each target, and a procedure step can be skipped.
+ * The written procedure claimed it "shows up as R2 rejections at the anchor height"; measured, it does not.
+ *
+ * <p>WHY IT IS SAFE TO REFUSE HERE. Same argument as the other startup guards: this runs before
+ * the network and the QBFT state machine start, so it is a clean refusal to start rather than a
+ * mid-flight halt. It is INERT unless a Falcon key is configured AND the registry is address-bound,
+ * so wherever no {@code aere.falcon.key} is configured, it cannot fire.
+ *
+ * <p>A null bound address is NOT treated as a failure: that means a pubkey-only registry, which is
+ * already refused elsewhere for the paths that matter, and turning it into a second refusal here
+ * would move the operator's attention to the wrong place.
+ *
+ * @param thisNodeAddress this node's own validator address, derived from its node key
+ */
+ public void verifyLocalKeyBelongsToThisNodeOrAbort(final Address thisNodeAddress) {
+ if (localIndex < 0 || localPrivateKey == null) {
+ return; // no local Falcon key: nothing to bind
+ }
+ if (thisNodeAddress == null) {
+ LOG.warn(
+ "AERE PQC KEY-ADDR: this node holds Falcon key index {} but its own validator address "
+ + "could not be derived, so the key-to-machine binding was NOT checked. This is the "
+ + "one failure mode that stays invisible afterwards.",
+ localIndex);
+ return;
+ }
+ final Address bound = indexToAddress.get(localIndex);
+ if (bound == null) {
+ LOG.warn(
+ "AERE PQC KEY-ADDR: this node holds Falcon key index {} and the registry binds NO address "
+ + "to that index (pubkey-only registry), so the key-to-machine binding cannot be "
+ + "checked. A key placed on the wrong machine would not be detectable here.",
+ localIndex);
+ return;
+ }
+ if (bound.equals(thisNodeAddress)) {
+ LOG.info(
+ "AERE PQC KEY-ADDR: local Falcon key index {} is bound by the registry to {}, which is "
+ + "this node's own validator address. Key and machine agree.",
+ localIndex,
+ bound);
+ return;
+ }
+ throw new ActivationConfigException(
+ ActivationConfigException.Kind.UNSAFE,
+ "AERE-PQC-KEY-ADDR-01",
+ "AERE PQC: REFUSING TO START (fail-closed). The local Falcon key index does not map to "
+ + "this node.\n"
+ + " READ: aere.falcon.key carries index "
+ + localIndex
+ + ", and the registry binds that index to validator "
+ + bound
+ + ".\n"
+ + " BUT: this node's own validator address is "
+ + thisNodeAddress
+ + ".\n"
+ + " MEANING: this machine was handed ANOTHER validator's Falcon key. If it started, it "
+ + "would sign as "
+ + bound
+ + ", so that index would be produced by two machines while this node's own index would "
+ + "never appear in any certificate. The quorum would be silently one signer short: the "
+ + "N/K/f margin is computed over DISTINCT signers.\n"
+ + " WHY YOU WOULD NOT HAVE NOTICED: measured 2026-08-07 on seven nodes, this exact "
+ + "mistake produces a chain that crosses the threshold height with no pause, no rejected "
+ + "blocks, no divergence and no error line anywhere.\n"
+ + " FIX: put the key whose index the registry binds to "
+ + thisNodeAddress
+ + " on this machine, and run the on-target check "
+ + "(VerificaPotrivireaCheilor) before starting.");
+ }
+
+ /**
+ * LOOKUP HARDENING (a). THIS NODE's own signing identity, which is the one place in the stack that
+ * legitimately has no height.
+ *
+ * <p>WHY IT IS A SEPARATE METHOD AND NOT {@code addressForIndex(localIndex())}. The adversarial
+ * review of 2026-08-02 found the verification path asking the registry questions with no
+ * height. Deleting the height-less pair from {@link PqSignerRegistry} left exactly one honest
+ * caller behind: {@code QbftBlockCreatorAdaptor} asking "am I, right now, an eligible signer",
+ * before signing with the single private key this process holds. There is no historical question
+ * in that, and inventing a height for it would be a lie dressed as rigour. Giving it its own name
+ * means the two uses can no longer be confused by a future reader, which is how the defect arrived in the
+ * first place.
+ *
+ * @return this node's registry-bound address, or null when it holds no key or the registry is not
+ * address-bound
+ */
+ public Address localSigningAddress() {
+ return indexToAddress.get(localIndex);
+ }
+
+ /**
+ * LOOKUP HARDENING (b). The height at and above which this node is ARMED, i.e. from which a header's
+ * Falcon certificate carries consensus weight.
+ *
+ * <p>WHY THE HEIGHT-RESOLVED LOOKUPS NEED IT. Before this, {@code keyAt} and {@code
+ * addressForIndexAt} fell back to the HEAD registry whenever no {@code config.pqRegistryHash}
+ * entry was in force - and {@code pqRegistryHash} is in no genesis this fleet runs (measured
+ * 2026-08-05: a search for {@code pqRegistryHash} across the deployment configuration returns
+ * nothing). So the whole height-schedule machinery was inert and the answer above the arming
+ * height was still "verify this year-old header against today's keys", which IS the height-less
+ * lookup defect, unrepaired. Falling back is correct BELOW the arming height, where no certificate is being judged; at and above it,
+ * the honest answer to "which keys were in force here" is a refusal, not a guess.
+ *
+ * <p>Read through {@link PqAnchorProducer#config()} so the value is the same one the producer and
+ * the R1/R2 rules use, memoised there. Never throws: a broken anchor configuration already fails
+ * closed at construction, and this method must not be the thing that turns a diagnostic into an
+ * exception on the per-block path.
+ *
+ * <p>INERT ON CHAIN 2800 AS IT STANDS. {@code aere.pq.anchorBlock} is unset on the live fleet, so
+ * {@code everActive()} is false, this returns {@link Long#MAX_VALUE}, no height is at or above it,
+ * and every answer below is byte-for-byte what it was before. This code cannot stop a validator
+ * that is running today.
+ *
+ * @return the arming height, or {@link Long#MAX_VALUE} when the anchor is not configured
+ */
+ private long anchorArmedFrom() {
+ try {
+ final PqAnchorConfig cfg =
+ org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer.config();
+ return cfg.everActive() ? cfg.anchorBlock() : Long.MAX_VALUE;
+ } catch (final RuntimeException e) {
+ // An unreadable anchor configuration is already a refusal to start elsewhere. Here, the
+ // conservative answer is "armed from nowhere", because claiming armed would refuse headers on
+ // a node whose only fault is a diagnostic that threw.
+ return Long.MAX_VALUE;
+ }
+ }
+
+ /**
+ * The set of validator addresses that have a Falcon key in the registry. This is the "signer
+ * registry" side of the eligible-signer intersection {@code currentValidators INTERSECT
+ * registry}.
+ *
+ * @return an immutable snapshot of the registered validator addresses (possibly empty)
+ */
+ public Set<Address> registeredValidatorAddresses() {
+ return Set.copyOf(indexToAddress.values());
+ }
+
+ /**
+ * Whether the registry covers the given (current) validator set, i.e. every current validator has
+ * a Falcon key in the registry. This is the ARMING INVARIANT (AUD-CONSENSUS-1): blocking should be
+ * armed only when coverage is complete, so the eligible-signer set equals the full validator set
+ * and the Falcon quorum matches the ECDSA quorum. When coverage is incomplete the blocking rule
+ * stays LIVE on the intersection (it never silently halts) but warns that the margin is reduced
+ * and a registry re-anchor is required.
+ *
+ * @param validators the current QBFT validator set
+ * @return true iff the registry is address-bound and contains every current validator
+ */
+ public boolean registryCoversValidators(final Collection<Address> validators) {
+ if (!addressBound()) {
+ return false;
+ }
+ final Set<Address> registered = registeredValidatorAddresses();
+ return registered.containsAll(validators);
+ }
+
+ /**
+ * Whether the registry was resolved from a verified GENESIS-ANCHORED manifest (as opposed to the
+ * late-anchor path, the legacy mutable file, or no registry at all).
+ *
+ * @return true iff the registry is genesis-anchored and its hash matched the on-chain anchor
+ */
+ public boolean genesisAnchored() {
+ return genesisAnchored;
+ }
+
+ /**
+ * The verified keccak256 of the canonical anchored manifest (genesis-anchored or late-anchored),
+ * or {@code null} when no anchored registry is active.
+ *
+ * @return the manifest hash as 64-hex (no 0x prefix), or null
+ */
+ public String registryManifestHash() {
+ return manifestHash;
+ }
+
+ /**
+ * The block number at and after which the Falcon quorum certificate becomes BLOCKING (a valid
+ * &gt;= 2f+1 Falcon quorum is required for a block to be accepted). Before this block the Falcon
+ * seals are additive / log-only. Configured via {@code aere.falcon.forkBlock} / {@code
+ * AERE_FALCON_FORKBLOCK}; defaults to {@link Long#MAX_VALUE} (never blocking, pure log-only) so
+ * an unconfigured node behaves exactly like the additive baseline.
+ *
+ * <p>AERE LATE-ANCHOR HEIGHT (2026-08-03): this used to re-read the property on every call and swallow a
+ * NumberFormatException into {@link Long#MAX_VALUE} with a WARN line. That is the finding, in one
+ * method: the single value the entire post-quantum enforcement layer is gated on could silently
+ * become "never blocking", and the only trace was a log line nothing reads. The value is now
+ * resolved once, in the constructor, by {@link #validateForkBlockConfigOrAbort()}, which ABORTS on
+ * anything it cannot parse. There is no longer any input to this method and therefore no longer
+ * any way for it to fail open.
+ *
+ * @return the fork block, or {@link Long#MAX_VALUE} if unconfigured
+ */
+ public long forkBlock() {
+ return forkBlock;
+ }
+
+ /**
+ * AERE LATE-ANCHOR HEIGHT: the DECLARED height at or after which the on-chain late-anchor registry contract is
+ * expected to be observable, or {@link Long#MAX_VALUE} when undeclared.
+ *
+ * @return the declared anchor observation height
+ */
+ public long anchorObserveBlock() {
+ return anchorObserveBlock;
+ }
+
+ /**
+ * AERE LATE-ANCHOR HEIGHT: the first height at which this node validated a header at or after the blocking
+ * height while the anchored registry was NOT active, or -1 if that has never happened.
+ *
+ * <p>The configuration guard refuses the misconfiguration that CAUSES this. It cannot refuse the
+ * accident: a correctly declared observation height whose anchor transaction never lands. From
+ * that height on, the node runs log-only while its operator believes the Falcon quorum is
+ * enforced. This counter is the difference between that situation being visible and it being a
+ * warning in a file.
+ *
+ * @return the first such height, or -1
+ */
+ public long blockingArmedWithoutRegistrySince() {
+ return blockingArmedWithoutRegistrySince.get();
+ }
+
+ /**
+ * Record that the blocking height has been reached with no active anchored registry. Never throws;
+ * called from the header-validation path.
+ *
+ * @param blockNumber the height at which the condition was observed
+ * @return true iff this is the FIRST height at which it was observed, so the caller can log once
+ * instead of once per block (a per-block line at a sub-second block period is a disk hazard,
+ * measured on this fleet in a different form on 2026-08-02)
+ */
+ public boolean noteBlockingArmedWithoutActiveRegistry(final long blockNumber) {
+ final boolean first = blockingArmedWithoutRegistrySince.compareAndSet(-1L, blockNumber);
+ if (first) {
+ LOG.error(
+ "AERE PQC [AERE-PQC-ARM-INERT-01]: block {} is at or after the Falcon BLOCKING height {} "
+ + "and the anchored registry is NOT active, so the Falcon quorum is NOT being "
+ + "enforced and will not be until the anchor lands. Declared observation height was "
+ + "{}. This node is running the additive LOG-ONLY baseline while configured to "
+ + "enforce a post-quantum quorum. Deploy or re-observe the anchor contract {}.",
+ blockNumber,
+ forkBlock,
+ anchorObserveBlock == Long.MAX_VALUE ? "undeclared" : Long.toString(anchorObserveBlock),
+ anchorContractAddress == null ? "(none configured)" : anchorContractAddress);
+ }
+ return first;
+ }
+
+ /**
+ * AERE audit fix (AUD-CONSENSUS-1) fail-closed ARMING diagnostic, emitted once at construction.
+ * If a fork block is configured (blocking is intended) but the manifest that will back it is not
+ * address-bound, the {@code currentValidators INTERSECT registry} eligible-signer set cannot be
+ * formed and post-fork blocks will fail closed. We log this LOUDLY here (arm/config time) rather
+ * than let it surface as a silent block-production halt later. We deliberately do NOT throw: this
+ * class must never throw on the consensus path, so the guarantee is loud-diagnostic-plus-fail-
+ * closed-reject (in the rule), not process abort.
+ */
+ private void armingReadinessDiagnostic() {
+ final long fork = forkBlock();
+ if (fork == Long.MAX_VALUE) {
+ return; // blocking not configured; nothing to arm
+ }
+ final boolean pendingAddressBound =
+ pendingLate != null
+ && pendingLateAddresses != null
+ && !pendingLate.isEmpty()
+ && pendingLateAddresses.size() == pendingLate.size();
+ if (addressBound() || (anchorContractAddress != null && pendingAddressBound)) {
+ LOG.info(
+ "AERE PQC: blocking configured (forkBlock={}); backing manifest is ADDRESS-BOUND, so the "
+ + "eligible-signer set (currentValidators INTERSECT registry) is well defined.",
+ fork);
+ return;
+ }
+ // AERE GENESIS BINDING REPAIR (2026-08-02). This used to be LOG.error and nothing else, and the comment
+ // above still says "diagnostic only". That was measured to be the wrong trade. A node that
+ // starts here does not stay harmless: it joins the fleet, reaches the activation height, and
+ // from that height rejects every header that carries a certificate. Measured on three
+ // observers: head 19 with R2=4 rejections, against head 374 with R1=0 R2=0 for the
+ // address-bound source on the same honest chain. A whole fleet configured this way stops, and
+ // stops in the shape of a consensus failure rather than a configuration error, which is the
+ // most expensive shape a configuration error can take.
+ //
+ // So it now REFUSES TO START. The reasons this is safe to do HERE, and only here: this is the
+ // constructor, i.e. config time, on the same path as validateForkBlockConfigOrAbort() and
+ // validateAndResolveAttachBlockOrAbort(), which already abort; the network is not up; the QBFT
+ // state machine has not been created; and no header has been offered to anybody. The promise
+ // that this class never throws is a promise about the PER-BLOCK path, and it is kept: nothing
+ // below this constructor throws.
+ //
+ // The refusal is inert wherever forkBlock is unset, because
+ // this code cannot stop a live validator that is running now.
+ throw new ActivationConfigException(
+ ActivationConfigException.Kind.UNSAFE,
+ "AERE-PQC-REG-ARM-01",
+ "AERE PQC GENESIS-BINDING: REFUSING TO START (fail-closed). Falcon blocking is "
+ + "configured at forkBlock="
+ + fork
+ + " but the active Falcon registry is NOT ADDRESS-BOUND"
+ + " (registrySize="
+ + registry.size()
+ + ", indexToAddress="
+ + indexToAddress.size()
+ + ", source="
+ + (registrySourcePath == null ? "(none)" : registrySourcePath)
+ + " ["
+ + registrySourceKind
+ + "], late-anchor pending="
+ + (anchorContractAddress != null)
+ + "). WHAT THIS MEANS: addressForIndex() returns null for every index, so the "
+ + "eligible-signer set (currentValidators INTERSECT registry) is empty and EVERY block "
+ + "at or above the activation height would be rejected by the seals rule. Measured, "
+ + "this is not a degradation, it is a fleet-wide halt: three observers replaying the "
+ + "same honest chain reached head 374 with an address-bound registry and stopped at "
+ + "head 19 with this one. WHAT TO DO: re-anchor the registry in an ADDRESS-BOUND form "
+ + "- a genesis manifest at config.aereFalconRegistry with {\"addr\",\"pk\"} entries, or "
+ + "a properties file carrying a '<i>.addr=0x<20 bytes>' row for every index - and set "
+ + "config.pqRegistryHash to its hash, before configuring aere.falcon.forkBlock. To "
+ + "start this node unchanged for diagnosis, unset aere.falcon.forkBlock; the node is "
+ + "then log-only and cannot halt anything.");
+ }
+
+ /**
+ * AERE ROW BINDING (2026-08-06). REFUSE TO START when this node is ARMED and the registry it will be
+ * held to carries no binding proofs.
+ *
+ * <p>WHAT IT ADDS OVER {@code AERE-PQC-REG-ARM-01}. That guard asks whether the registry is
+ * ADDRESS-BOUND, i.e. whether index {@code i} has an address at all. This one asks the question
+ * that decides who a seal is CREDITED to: does row {@code i} carry a Falcon possession proof over
+ * its own {@code (chainId, bindHeight, count, i, address, publicKey)} pre-image, and an ECDSA
+ * claim over the same bytes signed by that validator's own consensus key. Without both, whoever
+ * writes the registry file decides attribution, and the threshold itself becomes fiction: two
+ * indices carrying the same key satisfy K=2 with a single private key.
+ *
+ * <p>WHY UNIQUENESS IS NOT ENOUGH, and this is measured rather than argued. Swapping the public
+ * keys of two rows leaves four distinct keys and four distinct addresses, so every uniqueness
+ * check passes and the forged header is ACCEPTED. Uniqueness repairs the THRESHOLD. Only a
+ * signature by the validator repairs ATTRIBUTION.
+ *
+ * <p>WHEN IT FIRES. Only when this node is armed: {@code aere.falcon.forkBlock} is configured, or
+ * the certificate anchor is active per {@link #anchorArmedFrom()}. Both are unset as it
+ * stands, so this refusal is INERT on the live fleet exactly as AERE-PQC-REG-ARM-01 is, and it
+ * cannot stop a validator that is running today. That inertness is not asserted here, it is
+ * measured by {@code PqInertBinaryTest}, which drives a node with no {@code aere.pq.*}
+ * and no {@code aere.falcon.forkBlock} property at all through this constructor.
+ *
+ * <p>WHAT IT DOES NOT JUDGE. A node armed with NO registry file at all returns without a word.
+ * Row binding is about a row that credits a seal to the wrong validator, and a registry with no rows
+ * credits nobody; that condition belongs to AERE-PQC-REG-ARM-01 and AERE-PQC-CFG-UNSAFE-08, which
+ * own it and name the numbers. See the comment at the return itself for what was measured when
+ * this guard tried to own it too.
+ *
+ * <p>WHY IT IS NOT BEHIND THE EMERGENCY BYPASS. {@code aere.falcon.registry.mismatch.allow}
+ * overrides the genesis hash gate, which answers "is this the file genesis named" - a question about
+ * configuration drift, recoverable by installing the right file. This one answers "can this
+ * registry be trusted to say who signed", and the anchor contract is IMMUTABLE once written: a
+ * fleet armed over unbound rows carries that defect for the life of the chain. The way out of THIS one
+ * is the same as for AERE-PQC-REG-ARM-01 - do not arm, or disarm: unset {@code
+ * aere.falcon.forkBlock}, or set {@code aere.pq.anchor.disable=true}, which makes {@link
+ * #anchorArmedFrom()} return {@link Long#MAX_VALUE} and leaves {@code PqEmergencyShoutRule}
+ * shouting at every height.
+ *
+ * <p>WHY IT MAY THROW HERE. Same path, same reasoning and same precedent as
+ * AERE-PQC-REG-ARM-01 and AERE-PQC-CFG-UNSAFE-04 directly around it: this is the constructor, the
+ * network is not up, the QBFT state machine does not exist and no header has been offered to
+ * anybody. The promise that this class never throws is a promise about the PER-BLOCK path, and it
+ * is kept.
+ *
+ * <p>NOT MEASURED, and written rather than hidden: when the anchor configuration itself cannot be
+ * read, {@link #anchorArmedFrom()} answers {@link Long#MAX_VALUE} and this guard therefore does
+ * not fire. That case is owned by the anchor loader, which refuses to start on its own account
+ * (AERE-PQC-ANCHOR-CONF-01); that the refusal always reaches the operator BEFORE this constructor
+ * runs has not been measured here.
+ *
+ * @throws PqRegistryHash.RegistryConfigException with code {@code AERE-PQC-REG-ARM-02} when this
+ * node is armed over a registry with no binding proofs
+ */
+ private void requireRegistryBindingProofsOrAbort() {
+ if (forkBlock() == Long.MAX_VALUE && anchorArmedFrom() == Long.MAX_VALUE) {
+ return; // nothing armed on this node; inert
+ }
+ if (registrySourcePath == null) {
+ // AN ARMED NODE WITH NO REGISTRY AT ALL IS NOT A ROW-BINDING DEFECT, and this return is the
+ // difference between a guard and a blanket. Row binding is about mis-ATTRIBUTION: a row that credits a
+ // seal to a validator who never held the key on it. That requires ROWS. With no registry
+ // there are no rows, indexToAddress is empty, the eligible-signer set is empty, and nobody
+ // can be credited with anything - there is nothing for a forged binding to say.
+ //
+ // The "armed over nothing" condition is real and it is already owned, twice, by guards that
+ // say more than this one could: AERE-PQC-REG-ARM-01 immediately above (when
+ // aere.falcon.forkBlock is set) and AERE-PQC-CFG-UNSAFE-08 immediately below, which refuses
+ // a positive threshold that the anchored key count cannot guarantee and names the two
+ // numbers. MEASURED 2026-08-06: refusing here instead took over both of those messages -
+ // PqForkThresholdReachabilityTest#aPositiveThresholdWithNoAnchoredKeysMustRefuseToStart got
+ // AERE-PQC-REG-ARM-02 where it must get AERE-PQC-CFG-UNSAFE-08 - and additionally refused
+ // the K=0 warm-up regime, which is the regime the fleet is meant to arm INTO. A guard that
+ // breaks the intended activation procedure is not fail-closed, it is just closed.
+ //
+ // KNOWN AND NOT CLOSED HERE: aere.falcon.testnetAllowSmallFleet waives
+ // AERE-PQC-CFG-UNSAFE-08, so on a fleet that sets it, armed + no registry + K>0 still
+ // starts. That is an explicitly named isolated-network waiver and it is deliberately not
+ // re-litigated by this guard.
+ return;
+ }
+ // Deliberately re-read from the source file rather than from the parsed maps: the proofs are
+ // per-ROW and the in-memory index-to-key / index-to-address maps have already thrown them away.
+ // A guard that judged the maps would be judging what survived the loader, not what was written.
+ PqRegistryHash.requireBindingsOrThrow(PqRegistryHash.loadAuto(Paths.get(registrySourcePath)));
+ }
+
+ /**
+ * AERE COVERAGE MARGIN (2026-08-03). REFUSE TO START when the armed Falcon threshold K is above the number of
+ * anchored-key holders this fleet is GUARANTEED to have among a block's ECDSA committers.
+ *
+ * <p>WHY THIS EXISTS. The 2026-08-02 repair correctly removed the fleet-wide coverage question from
+ * the per-commit attachment gate, because answering it with "stop attaching" is what stopped the
+ * chain. In its place the javadoc of {@link #attachmentArmed} promised that the same protection was
+ * still there, one level up: "an arm-time decision, and it is made at arm time by
+ * armingReadinessDiagnostic() and by the operator". Measured on 2026-08-03: {@code
+ * armingReadinessDiagnostic()} tests ADDRESS-BOUNDNESS and nothing else. It does not read the fleet
+ * size, the keyed count, or K. The protection named there did not exist. This method is it.
+ *
+ * <p>WHAT IT COMPARES. K is the highest threshold the staged schedule ever reaches, because that is
+ * the one the chain will eventually be held to; the guarantee is {@link #worstCaseKeyedSigners}
+ * over the DECLARED fleet size and the anchored key count. Both numbers are known at config time,
+ * on every node, without a chain and without a vote. The emergency ceiling is deliberately ignored:
+ * it can only LOWER K later, and a guard must not be satisfied by a lever an operator may not pull.
+ *
+ * <p>WHY IT MAY THROW HERE. Same reasoning, same path and same precedent as AERE-PQC-CFG-UNSAFE-04
+ * and the genesis-binding refusal directly above: this is the constructor, the network is not up, the QBFT state
+ * machine does not exist, and no header has been offered to anyone. The promise that this class
+ * never throws is a promise about the PER-BLOCK path, and it is kept.
+ *
+ * <p>WHY IT IS NOT A RUNTIME CHECK. A per-block version of this comparison would be exactly the
+ * defect rebuilt: a fleet-wide fact, false on every node at the same height after an
+ * ordinary vote, answered with a halting action. The runtime half stays a REPORT, in {@link
+ * #reportRegistryCoverage}, and decides nothing.
+ *
+ * <p>INERT WHERE {@code aere.pq.anchorBlock} IS UNSET: there is no
+ * threshold and no comparison. This code cannot stop a validator that is running now.
+ */
+ private void validateThresholdReachabilityOrAbort() {
+ final PqAnchorConfig anchor;
+ try {
+ anchor = PqAnchorConfig.fromSystemConfiguration();
+ } catch (final RuntimeException e) {
+ // The anchor loader already fails closed on its own account and logs why. A guard must not be
+ // the thing that turns an anchor-config fault into an unexplained startup failure here.
+ LOG.warn(
+ "AERE PQC: threshold-reachability guard SKIPPED - the anchor configuration could not be "
+ + "read ({}). This is NOT a statement that the threshold is reachable.",
+ e.toString());
+ return;
+ }
+ if (!anchor.everActive()) {
+ return;
+ }
+ int k = 0;
+ for (final Integer step : anchor.minSealsSchedule().values()) {
+ if (step != null && step > k) {
+ k = step;
+ }
+ }
+ if (k <= 0) {
+ return;
+ }
+
+ final int keyed = anchoredKeyCount();
+ final int fleet = Math.max(expectedFleetSize(), keyed);
+ final int guaranteed = worstCaseKeyedSigners(fleet, keyed);
+ final int deficit = thresholdDeficit(fleet, keyed, k);
+ if (deficit == 0) {
+ LOG.info(
+ "AERE PQC: Falcon threshold K={} is REACHABLE - a fleet of {} with {} anchored key "
+ + "holder(s) guarantees {} keyed signer(s) among a block's committers.",
+ k,
+ fleet,
+ keyed,
+ guaranteed);
+ return;
+ }
+
+ final boolean waived =
+ Boolean.parseBoolean(
+ String.valueOf(
+ resolve("aere.falcon.testnetAllowSmallFleet", "AERE_FALCON_TESTNET_SMALL_FLEET")));
+ if (waived) {
+ LOG.error(
+ "AERE PQC: aere.falcon.testnetAllowSmallFleet=true - arming Falcon threshold K={} on a "
+ + "fleet of {} with only {} anchored key holder(s), which guarantees {}. A proposer "
+ + "can legitimately fail to assemble a certificate. This setting MUST NOT be used on "
+ + "mainnet.",
+ k,
+ fleet,
+ keyed,
+ guaranteed);
+ return;
+ }
+
+ throw new ActivationConfigException(
+ ActivationConfigException.Kind.UNSAFE,
+ "AERE-PQC-CFG-UNSAFE-08",
+ "AERE PQC COVERAGE: REFUSING TO START (fail-closed). The Falcon anchor is armed at height "
+ + anchor.anchorBlock()
+ + " with a staged threshold that reaches K="
+ + k
+ + ", but this fleet is guaranteed to produce only "
+ + guaranteed
+ + " Falcon seal(s) per block: "
+ + fleet
+ + " validator(s), of which "
+ + keyed
+ + " hold an anchored Falcon key, need ceil(2N/3)="
+ + BftHelpers.calculateRequiredValidatorQuorum(fleet)
+ + " ECDSA committers, and the unluckiest committer set takes every UNKEYED validator "
+ + "first. Short by "
+ + deficit
+ + ". WHAT THIS MEANS: with every node honest and every node up, a proposer can fail to "
+ + "assemble a certificate, and it fails on every node at once because the validator set "
+ + "is consensus state - the chain stops in a state where the re-anchoring transaction "
+ + "that would repair it can no longer be carried by any block. That is an unreachable threshold. WHAT TO "
+ + "DO: re-anchor the manifest so that every validator holds a key (this is what makes "
+ + "the standing 'grow to N>=9 before arming' order safe - the manifest has to grow WITH "
+ + "the set, not after it), or lower the "
+ + PqAnchorConfig.PROPERTY_MIN_SEALS
+ + " schedule to at most "
+ + guaranteed
+ + ", or declare the true fleet size in aere.falcon.validatorCount if it is wrong here. "
+ + "Isolated test networks may set aere.falcon.testnetAllowSmallFleet=true.");
+ }
+
+ /**
+ * AERE COVERAGE MARGIN: how many validators hold an anchored Falcon key, counting a late anchor that has not
+ * landed yet the same way {@link #expectedFleetSize()} does, so the two numbers being compared are
+ * read at the same moment from the same manifest.
+ *
+ * @return the anchored key count
+ */
+ private int anchoredKeyCount() {
+ if (!registry.isEmpty()) {
+ return registry.size();
+ }
+ return pendingLate == null ? 0 : pendingLate.size();
+ }
+
+ // ---- AERE FIX-OPRIRE-CONSENS (b): SEAL-ATTACHMENT GATE ----
+
+ /**
+ * The block number at and after which this node may ATTACH a Falcon seal to its Commit messages.
+ * Configured via {@code aere.falcon.attachBlock} / {@code AERE_FALCON_ATTACHBLOCK}; defaults to
+ * {@link Long#MAX_VALUE}, i.e. NEVER attach.
+ *
+ * <p>This is deliberately a separate, EARLIER height than {@link #forkBlock()}. The safe ordering
+ * is: every validator binary upgraded (keyless) -&gt; fleet confirmed on the new binary -&gt;
+ * attachment height reached, seals start flowing and are verified LOG-ONLY -&gt; only then the fork
+ * height, where the Falcon quorum becomes blocking.
+ *
+ * @return the attachment height, or {@link Long#MAX_VALUE} when attachment is not configured
+ */
+ public long attachBlock() {
+ return attachBlock;
+ }
+
+ /**
+ * Record the validator set seen by the header-validation path. This is the "does the anchored
+ * registry cover the WHOLE fleet" input to the attachment gate, captured from the one place that
+ * already has an authoritative validator set for a given height.
+ *
+ * @param blockNumber the height the set was read at
+ * @param validators the validator set at that height
+ */
+ public void observeValidators(final long blockNumber, final Collection<Address> validators) {
+ if (validators == null || validators.isEmpty() || blockNumber < observedValidatorsHeight) {
+ return;
+ }
+ this.observedValidators = new LinkedHashSet<>(validators);
+ this.observedValidatorsHeight = blockNumber;
+ }
+
+ /**
+ * AERE COVERAGE MARGIN: the height at which a validator set was last observed, or {@code -1} when none ever
+ * was. Diagnostic, and the only way a test can tell "the gate is being fed" from "the gate happens
+ * to say yes anyway", which is the difference the coverage repair turns on.
+ *
+ * @return the observation height, or -1 if no validator set has been observed
+ */
+ public long observedValidatorsHeight() {
+ return observedValidators == null ? -1L : observedValidatorsHeight;
+ }
+
+ /**
+ * Whether this node may ATTACH a Falcon seal to a Commit for the given block. Never throws.
+ *
+ * <p>All three conditions are fleet-wide facts, so every correct node flips at the same block:
+ *
+ * <ol>
+ * <li>an ATTACHMENT HEIGHT is configured and reached;
+ * <li>the anchored Falcon registry is ACTIVE (genesis-anchored, or a late anchor already
+ * observed on-chain) and address-bound;
+ * <li>that registry binds THIS node's own Falcon index to a validator address.
+ * </ol>
+ *
+ * <p><b>AERE COVERAGE MARGIN (2026-08-02): condition 3 used to be "that registry COVERS every validator in
+ * the last observed validator set", and that is the sentence that stopped the chain.</b> Coverage
+ * is a property of the validator set, which is consensus state, so an ordinary add-validator vote
+ * falsifies it on every node at the same height; the old answer to that was to switch attachment
+ * OFF, which is fleet-wide, which starves every proposer of seals, which stops the chain in a
+ * state where the repairing transaction can no longer be included. Measured in {@code
+ * PqForkValidatorSetChangeTest}. Condition 3 is now a LOCAL fact that no membership change can
+ * falsify; coverage is REPORTED by {@link #reportRegistryCoverage} with the margin number it
+ * costs, and decides nothing.
+ *
+ * <p>WHICH CONDITION PROVIDES WHICH PROTECTION (corrected 2026-08-01; the earlier claim here was
+ * wrong and is restated so nobody relies on protection that is not there):
+ *
+ * <ul>
+ * <li>CONDITION 1 is what makes a one-by-one KEY rollout harmless. {@code attachBlock} defaults
+ * to {@link Long#MAX_VALUE}, so a node holding a Falcon key attaches nothing until an
+ * operator names an activation height. That default, and nothing else, is why step 3 of the
+ * activation order (distribute keys, restart one at a time) cannot emit a single seal.
+ * <li>CONDITION 2 stops attachment when the registry backing it is absent or not address-bound,
+ * so a seal is never emitted against a registry that cannot be checked.
+ * <li>CONDITION 3 checks that the anchored registry binds THIS node's own index, so this node
+ * never emits a seal no verifier can attribute. It stops one node, never the fleet, and
+ * nothing outside this node's own configuration can change its answer. It does NOT and
+ * cannot check peer BINARY VERSION, and it is emphatically NOT what makes the halting order
+ * impossible. Until 2026-08-02 it asked the FLEET question instead - does the registry cover
+ * every observed validator - and switched attachment off when the answer was no; see the
+ * coverage note on the method below for why that was a chain stop rather than a safeguard.
+ * <li>The CHAIN-RELATIVE guard, {@link #verifyAttachHeightAgainstChainHeadOrAbort}, is what
+ * closes that last gap. It refuses to START a node whose activation height is not safely
+ * ahead of the chain head, so the height cannot be one that the fleet has already walked
+ * past without arming.
+ * </ul>
+ *
+ * <p>None of these check peer binary version. That remains procedural: see step 2 of the
+ * activation order and the "what this fix does NOT do" section of FIX-OPRIRE-CONSENS.
+ *
+ * @param blockNumber the block whose commit is being sealed
+ * @return true iff attaching a Falcon seal is safe for the whole fleet
+ */
+ public boolean attachmentArmed(final long blockNumber) {
+ if (attachBlock == Long.MAX_VALUE) {
+ if (signingEnabled && loggedAttachmentOff.compareAndSet(false, true)) {
+ LOG.warn(
+ "AERE PQC: this node HOLDS a Falcon signing key (index {}) but seal ATTACHMENT is NOT "
+ + "configured (aere.falcon.attachBlock unset), so NO Falcon-carrying Commit will be "
+ + "emitted. This is the safe default: emitting one while any peer still runs a "
+ + "binary with a strict Commit decoder would make that peer DISCARD the whole "
+ + "Commit, ECDSA seal included. Set aere.falcon.attachBlock only after the ENTIRE "
+ + "validator fleet runs a binary that can parse a Falcon-carrying Commit.",
+ localIndex);
+ }
+ return false;
+ }
+ if (blockNumber < attachBlock) {
+ return false;
+ }
+ if (!(genesisAnchored || lateActivated) || !addressBound()) {
+ if (loggedCoverageBlocked.compareAndSet(false, true)) {
+ LOG.error(
+ "AERE PQC: attachment height {} reached at block {} but the anchored registry is NOT "
+ + "active/address-bound (genesisAnchored={}, lateAnchored={}, lateAnchorFailed={}). "
+ + "Attachment stays OFF (fail-safe for liveness).",
+ attachBlock,
+ blockNumber,
+ genesisAnchored,
+ lateActivated,
+ lateFailed);
+ }
+ return false;
+ }
+ // AERE COVERAGE MARGIN (2026-08-02), CONDITION 3, REPLACED. What stands here now is a LOCAL fact: does the
+ // anchored registry bind MY OWN index to an address, so that every verifier can resolve my seal
+ // to a signer. It is a property of this node and the anchored manifest, and nothing that happens
+ // to the validator set can falsify it.
+ //
+ // WHAT USED TO STAND HERE AND WHY IT HAD TO GO. The old condition 3 asked a FLEET question -
+ // "does the anchored registry cover every validator I have observed" - and answered NO by
+ // switching seal attachment OFF. Both halves are wrong, and the second is the halt:
+ //
+ // * an ordinary add-validator vote admits a node that, by construction, is not in a manifest
+ // anchored before it existed. Coverage therefore goes false on EVERY node at the SAME
+ // height, because the validator set is consensus state. Every node stops attaching, so no
+ // proposer can gather K seals, so PqAnchorProducer refuses for every proposer in turn and
+ // the chain stops - and it stops in a state where the re-anchoring transaction that would
+ // repair it can no longer be carried by any block. That is the same halt, measured in
+ // PqForkValidatorSetChangeTest.
+ // * the input it read is fed by observeValidators, whose only caller stands down at the anchor
+ // height. Above that height the answer was either FROZEN at a set from below it, or - on any
+ // node whose process started above it - never set at all, which the old code also answered
+ // with "stop signing". A node restart is an ordinary operation; it must not be able to
+ // silence this node's post-quantum seal for good.
+ //
+ // "I could not measure the fleet" and "the fleet is unsafe" are different states, and only one of
+ // them justifies the halting action. Coverage is now REPORTED, with the liveness margin it
+ // actually costs, and it decides nothing. What coverage genuinely protects - that blocking is
+ // not ARMED over a partial manifest - is an arm-time decision, not one taken once per commit by
+ // every node at once.
+ //
+ // CORRECTED 2026-08-03. This comment used to name armingReadinessDiagnostic() as the place that
+ // arm-time decision was made. It was not: that method tests ADDRESS-BOUNDNESS and nothing else,
+ // and it never reads the fleet size, the keyed count, or K. So for one day the compensating
+ // control for this repair was a sentence in a comment. It is now validateThresholdReachabilityOrAbort(),
+ // which refuses to start a node whose armed threshold is above what the fleet is guaranteed to
+ // produce (AERE-PQC-CFG-UNSAFE-08), and it is measured in PqForkThresholdReachabilityTest.
+ if (localIndex < 0 || indexToAddress.get(localIndex) == null) {
+ if (loggedCoverageBlocked.compareAndSet(false, true)) {
+ LOG.error(
+ "AERE PQC: attachment height {} reached at block {} but the anchored registry does not "
+ + "bind THIS node's Falcon index {} to a validator address ({} bound entries). A "
+ + "seal from an index no verifier can resolve is refused by the seals rule, so "
+ + "emitting one would only add bytes. Attachment stays OFF for this node alone; the "
+ + "rest of the fleet is unaffected. Re-anchor a manifest that contains this index.",
+ attachBlock,
+ blockNumber,
+ localIndex,
+ indexToAddress.size());
+ }
+ return false;
+ }
+ reportRegistryCoverage(blockNumber);
+ if (loggedAttachmentOn.compareAndSet(false, true)) {
+ LOG.info(
+ "AERE PQC: seal ATTACHMENT ARMED at block {} (attachBlock={}, anchored address-bound "
+ + "registry of {} entries binds this node's index {}). This node will now attach a "
+ + "Falcon seal to its Commit messages. Falcon quorum becomes BLOCKING at forkBlock={}.",
+ blockNumber,
+ attachBlock,
+ indexToAddress.size(),
+ localIndex,
+ forkBlock() == Long.MAX_VALUE ? "unset" : Long.toString(forkBlock()));
+ }
+ return true;
+ }
+
+ /**
+ * AERE COVERAGE MARGIN (2026-08-02). REPORT what the anchored registry covers, and what that costs in
+ * liveness margin. This decides nothing: it is called after the attachment gate has already said
+ * yes, and its only effect is a log line.
+ *
+ * <p>It exists because removing coverage from the gate must not also remove the operator's ability
+ * to see coverage. What it prints is not "incomplete" but a NUMBER, {@link
+ * #worstCaseKeyedSigners}, because "the registry does not cover the set" is compatible with both a
+ * chain that is completely fine and a chain that is one proposer turn from stopping, and only the
+ * number tells them apart.
+ *
+ * <p>Emitted when the situation CHANGES, not once per block: the interesting event is the height
+ * where the validator set moved, and a line per block would bury it.
+ *
+ * @param blockNumber the height being sealed
+ */
+ private void reportRegistryCoverage(final long blockNumber) {
+ final Set<Address> validators = observedValidators;
+ if (validators == null) {
+ if (!"unobserved".equals(lastCoverageSignature)) {
+ lastCoverageSignature = "unobserved";
+ LOG.warn(
+ "AERE PQC: attaching Falcon seals at block {} without having observed a validator set, "
+ + "so registry COVERAGE is NOT MEASURED on this node. This is not a safety "
+ + "statement in either direction: attachment is gated on this node's own registry "
+ + "binding, which is satisfied. It is normal for a process that started above the "
+ + "anchor height. Coverage is reported as soon as a validator set is observed.",
+ blockNumber);
+ }
+ return;
+ }
+ final int n = validators.size();
+ final int keyed = (int) validators.stream().filter(registeredValidatorAddresses()::contains).count();
+ final String signature = n + "/" + keyed;
+ if (signature.equals(lastCoverageSignature)) {
+ return;
+ }
+ lastCoverageSignature = signature;
+ if (keyed == n) {
+ LOG.info(
+ "AERE PQC: anchored registry COVERS the validator set at block {} ({} of {} validators "
+ + "keyed). Falcon and ECDSA quorums coincide.",
+ blockNumber,
+ keyed,
+ n);
+ return;
+ }
+ LOG.warn(
+ "AERE PQC: the anchored registry does NOT cover the validator set at block {}: {} of {} "
+ + "validators hold an anchored Falcon key. Seal attachment CONTINUES - switching it off "
+ + "here is what stops a chain - but the post-quantum margin has moved: in the "
+ + "worst case only {} keyed validator(s) are among the {} ECDSA committers of a block. "
+ + "Compare that with the Falcon threshold K in force: if it is above that number, a "
+ + "proposer can legitimately fail to assemble a certificate. RE-ANCHOR the manifest for "
+ + "the full validator set, or lower K, before relying on the post-quantum seal.",
+ blockNumber,
+ keyed,
+ n,
+ worstCaseKeyedSigners(n, keyed),
+ BftHelpers.calculateRequiredValidatorQuorum(n));
+ }
+
+ /**
+ * AERE COVERAGE MARGIN. The number of anchored-key holders a block's ECDSA committer set is GUARANTEED to
+ * contain, in the worst case, when {@code validators} validators are in the set and {@code keyed}
+ * of them hold an anchored Falcon key.
+ *
+ * <p>A block needs {@code ceil(2N/3)} ECDSA committed seals. An adversarial (or merely unlucky)
+ * choice of committers takes every unkeyed validator first, so the guaranteed keyed count is
+ * {@code quorum - (N - keyed)}, floored at zero. A Falcon threshold K above this number is a
+ * threshold the chain is not guaranteed to be able to meet, which is exactly the shape of that halt:
+ * at N=7 with all 7 keyed and K=5 the margin is exactly zero, one added unkeyed validator holds it
+ * at zero, and a second takes it negative.
+ *
+ * @param validators the size of the current validator set
+ * @param keyed how many of them hold an anchored Falcon key
+ * @return the guaranteed number of keyed signers among a block's committers, never negative
+ */
+ public static int worstCaseKeyedSigners(final int validators, final int keyed) {
+ if (validators <= 0 || keyed <= 0) {
+ return 0;
+ }
+ final int quorum = BftHelpers.calculateRequiredValidatorQuorum(validators);
+ return Math.max(0, quorum - (validators - Math.min(keyed, validators)));
+ }
+
+ /**
+ * AERE COVERAGE MARGIN (2026-08-03). By how much a Falcon threshold {@code k} exceeds what the fleet is
+ * GUARANTEED to be able to produce. Zero means reachable; any positive number is a threshold a
+ * proposer can legitimately fail to meet with every node honest and every node up.
+ *
+ * <p>Zero is REACHABLE, not comfortable: at N=7 fully keyed with K=5 the deficit is 0 and the
+ * margin is also 0, which is why {@link #MIN_BLOCKING_VALIDATORS} exists as a separate rule. The
+ * two say different things and neither implies the other.
+ *
+ * @param validators the size of the validator set
+ * @param keyed how many of them hold an anchored Falcon key
+ * @param k the Falcon threshold in force
+ * @return {@code k} minus the guaranteed keyed signer count, floored at zero
+ */
+ public static int thresholdDeficit(final int validators, final int keyed, final int k) {
+ if (k <= 0) {
+ return 0;
+ }
+ return Math.max(0, k - worstCaseKeyedSigners(validators, keyed));
+ }
+
+ /**
+ * Falcon-sign a commit hash with this node's Falcon key, producing a parallel seal. Never throws.
+ *
+ * <p>AERE FIX-OPRIRE-CONSENS (b): the block number is MANDATORY and the attachment gate is
+ * checked here, at the single point where a seal can be produced. There is deliberately no
+ * height-less overload, so no call site can attach a seal without passing the gate.
+ *
+ * @param blockNumber the number of the block whose commit is being sealed
+ * @param commitHash the same 32-byte commit hash the ECDSA committed seal signs
+ * @return the Falcon seal, or empty if signing is disabled, gated off, or failed
+ */
+ public Optional<FalconSeal> sign(final long blockNumber, final Bytes32 commitHash) {
+ if (!signingEnabled) {
+ return Optional.empty();
+ }
+ if (!attachmentArmed(blockNumber)) {
+ return Optional.empty();
+ }
+ try {
+ final FalconSigner signer = new FalconSigner();
+ signer.init(true, localPrivateKey);
+ final byte[] sig = signer.generateSignature(commitHash.toArray());
+ return Optional.of(new FalconSeal(localIndex, Bytes.wrap(sig)));
+ } catch (final RuntimeException e) {
+ LOG.warn("AERE PQC: Falcon signing failed (ECDSA seal unaffected): {}", e.toString());
+ return Optional.empty();
+ }
+ }
+
+ /**
+ * Verify a Falcon seal against the registry public key for its validator index. Never throws.
+ *
+ * @param validatorIndex the signer index
+ * @param commitHash the commit hash that was signed
+ * @param signature the Falcon signature bytes
+ * @return true iff a registry public key exists for the index and the signature verifies
+ */
+ public boolean verify(final int validatorIndex, final Bytes commitHash, final Bytes signature) {
+ return verifyWithKey(registry.get(validatorIndex), validatorIndex, commitHash, signature);
+ }
+
+ /**
+ * HEIGHT SCHEDULE. The Falcon public key registered for an index AT A HEIGHT: the one carried by the
+ * registry the chain's schedule makes active there.
+ *
+ * <p>WHY A HEIGHT IS NEEDED AT ALL. A certificate inside a block at height h was produced under
+ * the key set the chain required at h. After a rotation the node's own head registry is a
+ * different key set, so checking an old block against it fails for a reason that has nothing to do
+ * with the block. Cosmos ADR-016 states the same requirement for the same reason and keeps a
+ * height-to-key mapping; the only difference here is that ours is never pruned, because this chain
+ * has no unbonding period and a node syncing from genesis verifies every block ever produced.
+ *
+ * <p>Returns the head registry's key when no binding is active at that height, which is every
+ * height below the schedule's first entry and every height on a chain with no schedule at all. So
+ * a node with a single registry gets exactly what it got before the height schedule existed.
+ */
+ private FalconPublicKeyParameters keyAt(
+ final long blockNumber, final int validatorIndex, final boolean historic) {
+ final PqRegistryHash.Schedule schedule = this.registryBindingSchedule;
+ final PqRegistryHash.RegistrySet set = this.registryBindingSet;
+ if (schedule == null || set == null) {
+ return headRegistryKeyOrRefuse(
+ blockNumber, validatorIndex, "no schedule was ever loaded", historic);
+ }
+ final Optional<PqRegistryHash.ScheduleEntry> required =
+ PqRegistryHash.requiredHashAt(schedule, blockNumber);
+ if (required.isEmpty()) {
+ // SCHEDULE BOUNDARY (2026-08-15). The only historical question ever asked one block
+ // below the schedule's first entry is about the certificate carried by the block at
+ // blockNumber+1, whose governing registry is the one bound EXACTLY at blockNumber+1.
+ // Answer it from that entry's VERIFIED bound registry, never from the unverified head
+ // file. Exactly one block: at blockNumber+2 below the schedule nothing changes.
+ final Optional<PqRegistryHash.ScheduleEntry> nextEntry =
+ PqRegistryHash.requiredHashAt(schedule, blockNumber + 1);
+ if (nextEntry.isPresent() && nextEntry.get().block() == blockNumber + 1) {
+ final Optional<PqRegistryHash.Registry> boundNext =
+ PqRegistryHash.registryAt(schedule, set, blockNumber + 1);
+ if (boundNext.isPresent()) {
+ for (final PqRegistryHash.Entry e : boundNext.get().entries()) {
+ if (e.index() == validatorIndex) {
+ return new FalconPublicKeyParameters(FalconParameters.falcon_512, e.publicKey());
+ }
+ }
+ return null;
+ }
+ }
+ return headRegistryKeyOrRefuse(
+ blockNumber, validatorIndex, "the schedule binds no registry at this height", historic);
+ }
+ final String cacheKey = required.get().block() + ":" + validatorIndex;
+ final FalconPublicKeyParameters cached = historicalKeys.get(cacheKey);
+ if (cached != null) {
+ return cached;
+ }
+ final Optional<PqRegistryHash.Registry> active =
+ PqRegistryHash.registryAt(schedule, set, blockNumber);
+ if (active.isEmpty()) {
+ // The entry active here is covered by nothing this node holds. Fail closed: no key, so no
+ // seal verifies, and the per-block binding rule has already refused the header for the same
+ // reason. Silence here would be the node deciding a question it cannot answer.
+ return null;
+ }
+ for (final PqRegistryHash.Entry e : active.get().entries()) {
+ if (e.index() == validatorIndex) {
+ final FalconPublicKeyParameters pub =
+ new FalconPublicKeyParameters(FalconParameters.falcon_512, e.publicKey());
+ historicalKeys.put(cacheKey, pub);
+ return pub;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * HEIGHT SCHEDULE. The validator ADDRESS bound to a registry index at a height.
+ *
+ * <p>LOOKUP HARDENING (b-v2): PRIVATE, with the caller's motive as an argument. The two public
+ * doors are {@link #addressForIndexAtHistoric} and {@link #addressForIndexAtOwnHead}, each of
+ * which passes a constant. Nothing outside this file can choose the value, so the one shared
+ * resolution body cannot drift between the two paths and no caller can pick the wrong flag.
+ *
+ * @param blockNumber the height of the header being validated
+ * @param validatorIndex the registry index carried by a Falcon seal
+ * @param historic true when the subject is a header this node received, false when it is this
+ * node's own head or the block it is building
+ * @return the bound address, or null when the index is unregistered at that height or the
+ * registry active there is not address-bound
+ */
+ private Address addressAt(
+ final long blockNumber, final int validatorIndex, final boolean historic) {
+ final PqRegistryHash.Schedule schedule = this.registryBindingSchedule;
+ final PqRegistryHash.RegistrySet set = this.registryBindingSet;
+ if (schedule == null || set == null) {
+ return headRegistryAddressOrRefuse(
+ blockNumber, validatorIndex, "no schedule was ever loaded", historic);
+ }
+ if (PqRegistryHash.requiredHashAt(schedule, blockNumber).isEmpty()) {
+ // SCHEDULE BOUNDARY (2026-08-15). Same alignment as in keyAt: one block below the
+ // schedule's first entry the subject is the certificate of the block at blockNumber+1,
+ // governed by the registry bound EXACTLY there, so the answer comes from that entry's
+ // VERIFIED bound registry and mirrors what this method answers at blockNumber+1 itself.
+ final Optional<PqRegistryHash.ScheduleEntry> nextEntry =
+ PqRegistryHash.requiredHashAt(schedule, blockNumber + 1);
+ if (nextEntry.isPresent() && nextEntry.get().block() == blockNumber + 1) {
+ final Optional<PqRegistryHash.Registry> boundNext =
+ PqRegistryHash.registryAt(schedule, set, blockNumber + 1);
+ if (boundNext.isPresent()) {
+ if (!boundNext.get().addressBound()) {
+ return null;
+ }
+ for (final PqRegistryHash.Entry e : boundNext.get().entries()) {
+ if (e.index() == validatorIndex && e.address() != null) {
+ return Address.wrap(Bytes.wrap(e.address()));
+ }
+ }
+ return null;
+ }
+ }
+ return headRegistryAddressOrRefuse(
+ blockNumber, validatorIndex, "the schedule binds no registry at this height", historic);
+ }
+ final Optional<PqRegistryHash.Registry> active =
+ PqRegistryHash.registryAt(schedule, set, blockNumber);
+ if (active.isEmpty() || !active.get().addressBound()) {
+ return null;
+ }
+ for (final PqRegistryHash.Entry e : active.get().entries()) {
+ if (e.index() == validatorIndex && e.address() != null) {
+ return Address.wrap(Bytes.wrap(e.address()));
+ }
+ }
+ return null;
+ }
+
+ /**
+ * HEIGHT SCHEDULE / LOOKUP HARDENING (b-v2). THE HISTORY DOOR. Verify a Falcon seal carried by a header this
+ * node RECEIVED, against the key set the chain required at that height.
+ *
+ * <p>WHY THE MOTIVE IS IN THE NAME AND NOT IN THE HEIGHT, measured on 2026-08-06. The first shape
+ * of hardening (b) refused whenever no height binding existed at or above the arming height, and
+ * decided that from the block number alone. That stopped the two paths that work on THIS NODE'S
+ * OWN head: restoring seals from disk after a restart, and proposing. Six tests went red, five in
+ * {@code PqSealPersistenceTest} and one in {@code PqForkValidatorSetChangeTest}, and the refusal
+ * message says the consequence outright - the node refuses to propose, so it stops producing
+ * blocks. In every one of the six the number presented to the guard was 1030 against an arming
+ * height of 1000, identical to what a real historical question would present in the same process
+ * in the same second. So the distinction moved into the question.
+ *
+ * <p>Reaching THIS method means the node is judging somebody else's claim about a height it did
+ * not build. "Which keys were in force here" then has an answer that is not this node's current
+ * registry, and answering from the head registry anyway is that defect verbatim: a header produced under
+ * one key set checked against another, with success reported.
+ *
+ * @param blockNumber the height of the header carrying the seal
+ * @param validatorIndex the signer index
+ * @param commitHash the message that was signed
+ * @param signature the Falcon signature bytes
+ * @return true iff a key was bound for that index AT THAT HEIGHT and the signature verifies
+ */
+ public boolean verifyAtHistoric(
+ final long blockNumber,
+ final int validatorIndex,
+ final Bytes commitHash,
+ final Bytes signature) {
+ return verifyWithKey(
+ keyAt(blockNumber, validatorIndex, true), validatorIndex, commitHash, signature);
+ }
+
+ /**
+ * LOOKUP HARDENING (b-v2). THE OWN-HEAD DOOR. Verify a Falcon seal over a block this node holds as its
+ * own head, or is building right now.
+ *
+ * <p>It does not refuse for a missing height binding, because at this node's own head the head
+ * registry IS the answer by construction. A wrong local acceptance cannot manufacture history: the
+ * certificate is re-checked by the other six validators through {@code PqAnchorSealsRule}, which
+ * goes through {@link #verifyAtHistoric} at the parent's height. The cost of a local mistake is
+ * one lost proposer round, not a branch.
+ *
+ * <p>What it still refuses: a height the schedule DOES cover with a registry this node does not
+ * hold. That is not a missing binding, it is a node running a registry the chain has moved off,
+ * and it fails closed on both doors - see the {@code registryAt} branch of {@link #keyAt}.
+ *
+ * @param blockNumber this node's own head, or the block it is sealing
+ * @param validatorIndex the signer index
+ * @param commitHash the message that was signed
+ * @param signature the Falcon signature bytes
+ * @return true iff a key exists for the index there and the signature verifies
+ */
+ public boolean verifyAtOwnHead(
+ final long blockNumber,
+ final int validatorIndex,
+ final Bytes commitHash,
+ final Bytes signature) {
+ return verifyWithKey(
+ keyAt(blockNumber, validatorIndex, false), validatorIndex, commitHash, signature);
+ }
+
+ /**
+ * HEIGHT SCHEDULE / LOOKUP HARDENING (b-v2). THE HISTORY DOOR, address half. See {@link #verifyAtHistoric}.
+ *
+ * @param blockNumber the height of the header being validated
+ * @param validatorIndex the registry index carried by a Falcon seal
+ * @return the bound address, or null when nothing binds that index at that height
+ */
+ public Address addressForIndexAtHistoric(final long blockNumber, final int validatorIndex) {
+ return addressAt(blockNumber, validatorIndex, true);
+ }
+
+ /**
+ * LOOKUP HARDENING (b-v2). THE OWN-HEAD DOOR, address half. See {@link #verifyAtOwnHead}.
+ *
+ * @param blockNumber this node's own head, or the block it is building
+ * @param validatorIndex the registry index
+ * @return the bound address, or null
+ */
+ public Address addressForIndexAtOwnHead(final long blockNumber, final int validatorIndex) {
+ return addressAt(blockNumber, validatorIndex, false);
+ }
+
+ /**
+ * LOOKUP HARDENING (b). The one decision the whole hardening turns on, isolated so that removing it is
+ * a one-line edit and the negative control can prove the hardening test goes red without it.
+ *
+ * <p>THE MEASURED DEFECT. Both height-resolved lookups used to answer a height they had no binding
+ * for by returning the HEAD registry - the key set in force right now. Below the arming height
+ * that is correct and costs nothing: no certificate is being judged there. At and above it, it is
+ * the height-less lookup defect verbatim: "an armed node verifies a year-old header against the
+ * keys it holds today", so one rotation makes every block between the arming height and the rotation unverifiable, and the
+ * node reports success while doing it. Refusing is the only answer that does not assert a check
+ * that was not performed.
+ *
+ * <p>WHAT A REFUSAL COSTS AND WHY IT IS THE CHEAPER FAILURE. A null key makes every seal fail, so
+ * {@code PqAnchorSealsRule} refuses the header and the node stops at the first block at or above
+ * the arming height. It stops; it does not fork. A node that cannot say which keys were in force
+ * cannot forge a competing history either, and the repair is to configure {@code
+ * config.pqRegistryHash} plus {@code aere.falcon.registry.history} and restart. Compare the
+ * silence this replaces, where the same node imports the whole chain and calls it verified.
+ *
+ * <p>LOOKUP HARDENING (b-v2), 2026-08-06. The condition gained ONE term, {@code historic}, and
+ * that term is the whole of the second repair. The first shape refused on height alone; the
+ * six tests it turned red were all asking about this node's OWN head at height 1030 with an
+ * arming height of 1000, which is the same pair of numbers a genuinely historical question
+ * presents. The block number cannot tell them apart. The caller can, and now says so.
+ *
+ * @param blockNumber the height being asked about
+ * @param validatorIndex the registry index
+ * @param why the reason no height binding was found, for the log
+ * @param historic true when the subject is a received header, false for this node's own head
+ * @return the head registry's key when not historic or below the arming height, else null
+ */
+ private FalconPublicKeyParameters headRegistryKeyOrRefuse(
+ final long blockNumber,
+ final int validatorIndex,
+ final String why,
+ final boolean historic) {
+ if (!historic || blockNumber < anchorArmedFrom()) {
+ return registry.get(validatorIndex);
+ }
+ shoutUnboundHeight(blockNumber, why);
+ return null;
+ }
+
+ /**
+ * LOOKUP HARDENING (b). The address half of {@link #headRegistryKeyOrRefuse}, with the same rule and
+ * for the same reason: at and above the arming height an unbound height has no answer, and {@code
+ * PqAnchorSealsRule} refuses an index it cannot bind to an address rather than skipping it.
+ *
+ * <p>LOOKUP HARDENING (b-v2): same one added term as {@link #headRegistryKeyOrRefuse}.
+ *
+ * @param blockNumber the height being asked about
+ * @param validatorIndex the registry index
+ * @param why the reason no height binding was found, for the log
+ * @param historic true when the subject is a received header, false for this node's own head
+ * @return the head registry's bound address when not historic or below the arming height, else
+ * null
+ */
+ private Address headRegistryAddressOrRefuse(
+ final long blockNumber,
+ final int validatorIndex,
+ final String why,
+ final boolean historic) {
+ if (!historic || blockNumber < anchorArmedFrom()) {
+ return addressForIndex(validatorIndex);
+ }
+ shoutUnboundHeight(blockNumber, why);
+ return null;
+ }
+
+ /**
+ * LOOKUP HARDENING (b). Say it once per configuration change, not once per block: at a 523 ms block
+ * period a per-block ERROR is itself a hazard on this fleet, and the refusal is already visible as
+ * a stopped node.
+ *
+ * @param blockNumber the height that has no registry binding
+ * @param why the reason
+ */
+ private void shoutUnboundHeight(final long blockNumber, final String why) {
+ if (loggedUnboundArmedHeight.compareAndSet(false, true)) {
+ LOG.error(
+ "AERE PQC LOOKUP-HARDENING: REFUSING to resolve Falcon keys at height {} - this node is ARMED from {} "
+ + "and {}. Until 2026-08-06 this fell back to the registry in force at the HEAD, "
+ + "which is the height-less lookup defect: a header produced under one key set was checked against "
+ + "another, and one key rotation would have made every block above the arming height "
+ + "unverifiable while the node reported success. Every header at or above the arming "
+ + "height will now be REFUSED until the height-to-registry binding exists. WHAT TO DO: "
+ + "put config.pqRegistryHash in the genesis this node booted (first entry exactly at "
+ + "the arming height) and name every registry file the schedule covers in {}.",
+ blockNumber,
+ anchorArmedFrom(),
+ why,
+ PROPERTY_REGISTRY_HISTORY);
+ }
+ }
+
+ private boolean verifyWithKey(
+ final FalconPublicKeyParameters pub,
+ final int validatorIndex,
+ final Bytes commitHash,
+ final Bytes signature) {
+ if (pub == null || commitHash == null || signature == null) {
+ return false;
+ }
+ try {
+ final FalconSigner verifier = new FalconSigner();
+ verifier.init(false, pub);
+ return verifier.verifySignature(commitHash.toArray(), signature.toArray());
+ } catch (final RuntimeException e) {
+ LOG.debug("AERE PQC: Falcon verify threw for index {}: {}", validatorIndex, e.toString());
+ return false;
+ }
+ }
+}
diff --git a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchor.java b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchor.java
new file mode 100755
index 000000000..a383bcf1c
--- /dev/null
+++ b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchor.java
@@ -0,0 +1,335 @@
+/*
+ * Copyright contributors to Besu / Aere Network.
+ *
+ * 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 org.hyperledger.besu.crypto.Hash;
+import org.hyperledger.besu.ethereum.rlp.BytesValueRLPOutput;
+import org.hyperledger.besu.ethereum.rlp.RLPOutput;
+
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Comparator;
+import java.util.List;
+
+import org.apache.tuweni.bytes.Bytes;
+import org.apache.tuweni.bytes.Bytes32;
+
+/**
+ * CERTIFICATE ANCHOR, schema V2: "IT IS CARRIED, NOT REFERENCED".
+ *
+ * <p>Canonical encoding, anchor digest D and signed message M for the AERE post-quantum certificate
+ * anchor. This class is a PURE function library: no singletons, no configuration, no I/O, no state.
+ * Everything it computes is a function of its arguments only, which is what makes {@code
+ * PqAnchorDigestRule} usable in LIGHT validation.
+ *
+ * <p><b>Why V2 exists.</b> Today the QBFT block hash covers no proof of any post-quantum seal: the
+ * Falcon seal list sits in {@code EncodingType.ALL} only, and both hash pre-images exclude it. You
+ * can strip the entire post-quantum attestation from a header and its block hash is unchanged. The
+ * earlier design (Option V) tried to fix that by putting the digest of the PARENT's certificate,
+ * read out of the PARENT's header, into the child's vanityData. That died on a measurement: over 200
+ * consecutive headers read simultaneously from two of our own RPC nodes, with the block hash
+ * identical 200/200, the seal ORDER differed on 68 headers and the seal SET differed on 11. The
+ * cause is structural, not a bug to patch: {@code RoundState} holds commits in a {@code
+ * LinkedHashMap} keyed on author, {@code values()} yields arrival order, and the assembler does not
+ * reorder. Every node writes what IT heard. Sorting fixes the order and does NOT fix the set, and no
+ * deterministic function of a local set can produce the same output from two different sets.
+ *
+ * <p><b>What V2 does instead.</b> The certificate over block N-1 is not read out of N-1's header.
+ * It is CARRIED in the extraData of block N, chosen by N's proposer and ratified by N's round, and
+ * N's vanityData carries its digest over all 32 bytes. The digest therefore depends only on what the
+ * PROPOSER WROTE, and those bytes reach every node identically inside the same proposal. A validator
+ * never compares the certificate with what it heard: it checks only the threshold, strictly
+ * increasing indices, index eligibility in the PARENT's validator set, and k Falcon verifications. A
+ * node that heard 7 accepts the proposer's certificate of 5.
+ *
+ * <p><b>The two pre-images.</b>
+ *
+ * <pre>
+ * D = keccak256( RLP[ "AERE-PQ-ANCHOR-1", chainId, parentNumber, parentHash, C ] )
+ * M = keccak256( RLP[ "AERE-PQ-COMMIT-1", chainId, blockNumber, blockHash ] )
+ * C = RLP[ [idx, sig], [idx, sig], ... ] indices STRICTLY INCREASING
+ * </pre>
+ *
+ * <p>D occupies all 32 bytes of vanityData: no magic, no version byte, no padding. The budget is 32
+ * bytes and cannot grow, because two LIVE length checks require exactly 32 (AereQbftFinalityVerifier
+ * .sol line 194 and the second client's decoder). Versioning is by ACTIVATION HEIGHT, not by an
+ * in-band version byte, because any format change needs a governance-decided height anyway.
+ *
+ * <p>M is deliberately NOT the ECDSA commit hash. The commit hash includes the ROUND, which is not
+ * in the block-hash pre-image, and rounds above zero do occur on this chain (measured 1 in 307).
+ * Defining M over the block HASH makes the message reconstructible by any node holding the parent
+ * header: no state, no round, no local memory.
+ *
+ * <p>The domain labels are what stops a Falcon signature produced for another subsystem (the
+ * AerePQCTxAccount rail, ML-KEM intents, any application message) from being reinterpreted as a
+ * consensus seal, and the chainId is what stops a certificate produced on the 442807 proving chain,
+ * which runs the SAME binaries and may run the same Falcon keys, from being material here.
+ *
+ * <p>CONSENSUS IS STILL CLASSICAL secp256k1 ECDSA. This class does not make consensus post-quantum
+ * and must never be described as doing so. The post-quantum layer is signature, precompile and
+ * account level.
+ */
+public final class PqAnchor {
+
+ /** Domain label for the anchor digest D. Exactly 16 ASCII bytes. */
+ public static final String ANCHOR_DOMAIN = "AERE-PQ-ANCHOR-1";
+
+ /** Domain label for the signed commit message M. Exactly 16 ASCII bytes. */
+ public static final String COMMIT_DOMAIN = "AERE-PQ-COMMIT-1";
+
+ /** The anchor domain label as raw bytes. */
+ public static final Bytes ANCHOR_DOMAIN_BYTES =
+ Bytes.wrap(ANCHOR_DOMAIN.getBytes(StandardCharsets.US_ASCII));
+
+ /** The commit domain label as raw bytes. */
+ public static final Bytes COMMIT_DOMAIN_BYTES =
+ Bytes.wrap(COMMIT_DOMAIN.getBytes(StandardCharsets.US_ASCII));
+
+ /** Orders Falcon seals by their registry index, ascending. */
+ public static final Comparator<FalconSeal> BY_INDEX =
+ Comparator.comparingInt(FalconSeal::getValidatorIndex);
+
+ private PqAnchor() {}
+
+ /**
+ * Encode the certificate C in its canonical wire form. This is byte-for-byte the shape {@code
+ * QbftExtraDataCodec} already writes for the Falcon seal element, so the certificate that is
+ * hashed is the certificate that is carried: one logical object, one accepted byte string.
+ *
+ * @param output the RLP sink to write the certificate list into
+ * @param certificate the seals, in the order they are to be encoded
+ */
+ public static void writeCertificate(
+ final RLPOutput output, final Collection<FalconSeal> certificate) {
+ output.writeList(
+ certificate,
+ (seal, rlp) -> {
+ rlp.startList();
+ rlp.writeIntScalar(seal.getValidatorIndex());
+ rlp.writeBytes(seal.getSignature());
+ rlp.endList();
+ });
+ }
+
+ /**
+ * The canonical RLP encoding of the certificate C on its own. Used by tests and diagnostics; the
+ * digest path writes the certificate directly into the outer list rather than nesting an encoded
+ * blob.
+ *
+ * @param certificate the seals, in the order they are to be encoded
+ * @return the canonical RLP bytes of C
+ */
+ public static Bytes encodeCertificate(final Collection<FalconSeal> certificate) {
+ final BytesValueRLPOutput out = new BytesValueRLPOutput();
+ writeCertificate(out, certificate);
+ return out.encoded();
+ }
+
+ /**
+ * Compute the anchor digest D that block N's vanityData must carry, over the certificate block N
+ * carries for its PARENT.
+ *
+ * <p>The certificate is hashed IN THE ORDER GIVEN. It is deliberately not sorted here: sorting
+ * inside the digest would let a header with a reordered certificate still match D, which would
+ * make the digest rule (cheap, runs in light validation) disagree with the seals rule (expensive,
+ * runs only where state exists). Instead BOTH rules require {@link
+ * #hasStrictlyIncreasingIndices(Collection)}, so for every ACCEPTED header "the order given" and
+ * "sorted" are the same list, and reordering is rejected in light validation too. Producers call
+ * {@link #sortedByIndex(Collection)} before writing.
+ *
+ * @param chainId the chain id, in the pre-image so a certificate from another chain running the
+ * same binaries and possibly the same Falcon keys is not material here
+ * @param parentNumber the number of the parent block the certificate attests to
+ * @param parentHash the hash of the parent block the certificate attests to
+ * @param certificate the carried certificate C, possibly empty (at the activation height itself)
+ * @return the 32-byte anchor digest D
+ */
+ public static Bytes32 anchorDigest(
+ final long chainId,
+ final long parentNumber,
+ final Bytes parentHash,
+ final Collection<FalconSeal> certificate) {
+ if (parentNumber < 0) {
+ throw new IllegalArgumentException(
+ "AERE PQ ANCHOR: parentNumber must not be negative (got " + parentNumber + ")");
+ }
+ if (parentHash == null || parentHash.size() != 32) {
+ throw new IllegalArgumentException(
+ "AERE PQ ANCHOR: parentHash must be 32 bytes (got "
+ + (parentHash == null ? "null" : parentHash.size() + " bytes")
+ + ")");
+ }
+ final BytesValueRLPOutput out = new BytesValueRLPOutput();
+ out.startList();
+ out.writeBytes(ANCHOR_DOMAIN_BYTES);
+ out.writeLongScalar(chainId);
+ out.writeLongScalar(parentNumber);
+ out.writeBytes(parentHash);
+ writeCertificate(out, certificate);
+ out.endList();
+ return Hash.keccak256(out.encoded());
+ }
+
+ /**
+ * Compute the message M that a validator's Falcon key signs when it seals a block.
+ *
+ * <p>M is a function of the block HASH, never of the commit hash, so it can be recomputed from the
+ * header alone: no round number, no local state, no memory of the round that produced it.
+ *
+ * @param chainId the chain id
+ * @param blockNumber the number of the block being sealed
+ * @param blockHash the hash of the block being sealed
+ * @return the 32-byte message M that Falcon signs
+ */
+ public static Bytes32 commitMessage(
+ final long chainId, final long blockNumber, final Bytes blockHash) {
+ if (blockNumber < 0) {
+ throw new IllegalArgumentException(
+ "AERE PQ ANCHOR: blockNumber must not be negative (got " + blockNumber + ")");
+ }
+ if (blockHash == null || blockHash.size() != 32) {
+ throw new IllegalArgumentException(
+ "AERE PQ ANCHOR: blockHash must be 32 bytes (got "
+ + (blockHash == null ? "null" : blockHash.size() + " bytes")
+ + ")");
+ }
+ final BytesValueRLPOutput out = new BytesValueRLPOutput();
+ out.startList();
+ out.writeBytes(COMMIT_DOMAIN_BYTES);
+ out.writeLongScalar(chainId);
+ out.writeLongScalar(blockNumber);
+ out.writeBytes(blockHash);
+ out.endList();
+ return Hash.keccak256(out.encoded());
+ }
+
+ /**
+ * Whether the certificate's validator indices are STRICTLY increasing.
+ *
+ * <p>Strictly increasing does three jobs at once and all three are structural rather than
+ * defensive: it fixes ONE accepted order for a given set (closing seal reordering), it makes
+ * duplicate indices unrepresentable (closing the "inflate k by repeating a seal" attack in the
+ * grammar of the format rather than with a HashSet applied afterwards), and it rejects negative
+ * indices when combined with the non-negative check below.
+ *
+ * @param certificate the carried certificate
+ * @return true iff every index is non-negative and each is strictly greater than its predecessor
+ */
+ public static boolean hasStrictlyIncreasingIndices(final Collection<FalconSeal> certificate) {
+ long previous = Long.MIN_VALUE;
+ for (final FalconSeal seal : certificate) {
+ if (seal == null || seal.getSignature() == null) {
+ return false;
+ }
+ final int index = seal.getValidatorIndex();
+ if (index < 0 || index <= previous) {
+ return false;
+ }
+ previous = index;
+ }
+ return true;
+ }
+
+ /**
+ * First anchor height of the one historical window on chain 2800 whose certificates carry indices
+ * that are distinct but NOT sorted. Measured 2026-08-11 by decoding every anchor header in
+ * [13,266,800, 13,269,400] straight off the public endpoint: 82 anchor headers, 46 sorted, 36 not,
+ * none without a certificate. The 36 are CONTIGUOUS at the 32-block anchor spacing and nothing
+ * outside the window is affected, so the range below is the measured extent, not a guess.
+ */
+ public static final long UNORDERED_WINDOW_FIRST = 13_267_824L;
+
+ /** Last anchor height of that window. Its neighbours 13,267,792 and 13,268,976 are both sorted. */
+ public static final long UNORDERED_WINDOW_LAST = 13_268_944L;
+
+ /**
+ * Whether the certificate's indices are non-negative and pairwise DISTINCT, in any order.
+ *
+ * <p>This is the part of {@link #hasStrictlyIncreasingIndices(Collection)} that is load bearing
+ * against an attacker rather than against ambiguity. Sortedness fixes one accepted order for a
+ * given set; distinctness is what makes "inflate k by repeating one seal" unrepresentable in the
+ * grammar of the format. A historical exception may relax the first WITHOUT relaxing the second,
+ * and this method exists so that the exception cannot accidentally relax both.
+ *
+ * @param certificate the carried certificate
+ * @return true iff every index is non-negative and no index appears twice
+ */
+ public static boolean hasDistinctNonNegativeIndices(final Collection<FalconSeal> certificate) {
+ final java.util.Set<Integer> seen = new java.util.HashSet<>();
+ for (final FalconSeal seal : certificate) {
+ if (seal == null || seal.getSignature() == null) {
+ return false;
+ }
+ final int index = seal.getValidatorIndex();
+ if (index < 0 || !seen.add(index)) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Whether a header at {@code blockNumber} may carry this certificate's index ordering.
+ *
+ * <p><b>Why this exists.</b> During a production interruption on 2026-08-10 some proposers wrote
+ * the certificate in the order the commits ARRIVED instead of sorted. {@code RoundState} holds
+ * commits in a {@code LinkedHashMap} keyed on author and {@code values()} yields arrival order, so
+ * a producer that skips {@link #sortedByIndex(Collection)} writes an unsorted certificate and
+ * nothing downstream notices: the anchor digest is taken IN THE ORDER GIVEN, so those headers'
+ * digests are perfectly consistent with their own bytes. 36 such headers are canonical today.
+ *
+ * <p>Nodes already holding the chain never revalidate them, which is exactly why this was invisible
+ * for a day. A node syncing from genesis under the plain rule would reject header 13,267,824 and
+ * stop there forever. That is the same shape as the base-fee floor episode: the history is what it
+ * is, and the exception belongs at VALIDATION, where a header is accepted as written, not in any
+ * recomputation. A patch anywhere else moves the error instead of ending it.
+ *
+ * <p><b>What the exception does NOT relax.</b> Inside the window the certificate must still have
+ * non-negative and pairwise distinct indices. Measured 2026-08-11 on all 36 headers: distinct in
+ * every one, no negative index in any. So this accepts exactly the history that exists and nothing
+ * weaker, and the "repeat one seal to inflate k" attack stays unrepresentable at every height.
+ *
+ * <p><b>Producers must not call this.</b> {@code PqAnchorProducer} keeps requiring strict
+ * sortedness, so no new unsorted header can ever be written, at any height. The window is closed by
+ * construction: it is bounded above by a measured constant, so it cannot grow.
+ *
+ * @param certificate the carried certificate
+ * @param blockNumber the number of the header carrying it
+ * @return true iff the ordering is acceptable for a header at that height
+ */
+ public static boolean hasAcceptableIndices(
+ final Collection<FalconSeal> certificate, final long blockNumber) {
+ if (hasStrictlyIncreasingIndices(certificate)) {
+ return true;
+ }
+ final boolean inHistoricalWindow =
+ blockNumber >= UNORDERED_WINDOW_FIRST && blockNumber <= UNORDERED_WINDOW_LAST;
+ return inHistoricalWindow && hasDistinctNonNegativeIndices(certificate);
+ }
+
+ /**
+ * Return the certificate sorted by validator index, ascending. Producers use this before writing
+ * the certificate into extraData; validators never sort, they require sortedness.
+ *
+ * @param certificate the seals in any order
+ * @return a new list sorted by validator index
+ */
+ public static List<FalconSeal> sortedByIndex(final Collection<FalconSeal> certificate) {
+ final List<FalconSeal> sorted = new ArrayList<>(certificate);
+ sorted.sort(BY_INDEX);
+ return sorted;
+ }
+}
diff --git a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorConfig.java b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorConfig.java
new file mode 100755
index 000000000..ce9f081b5
--- /dev/null
+++ b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorConfig.java
@@ -0,0 +1,1320 @@
+/*
+ * Copyright contributors to Besu / Aere Network.
+ *
+ * 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 java.util.Collections;
+import java.util.Locale;
+import java.util.Map;
+import java.util.NavigableMap;
+import java.util.OptionalInt;
+import java.util.TreeMap;
+
+import com.google.common.base.Splitter;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Height-indexed activation configuration for the V2 certificate anchor: {@code pqAnchorBlock} (H)
+ * and the staged {@code pqAnchorMinSeals} threshold schedule K(height), plus the two LOCAL emergency
+ * de-arm controls.
+ *
+ * <p><b>The gate is on the BLOCK NUMBER.</b> Below H no new rule applies at all: {@link
+ * #activeAt(long)} returns false and both new rules return true as their first statement, before
+ * they decode anything. The existing chain history is therefore untouched, with the same hashes,
+ * because neither the hash formula, nor the codec, nor the existing rule set changes for it. That
+ * property is cheap precisely because the gate is on the number and not on the content, and it is
+ * the condition under which a binary built from this code can be warmed on a live node without risk.
+ *
+ * <p><b>Emergency de-arm.</b> Two controls exist and are deliberately LOCAL to the node, not
+ * on-chain: a halted chain cannot deliver a height-scheduled configuration change, so the only
+ * control that works when the chain is ALREADY STOPPED is one that lives in the node's own
+ * configuration and takes effect on restart.
+ *
+ * <ul>
+ * <li>{@value #PROPERTY_MIN_SEALS_CEILING} lowers K, and can NEVER raise it: the effective
+ * threshold is {@code min(scheduled, ceiling)}. A control that could raise the threshold would
+ * be a way to halt the chain, not a way to recover it.
+ * <li>{@value #PROPERTY_DISABLE} switches both rules off entirely on this node.
+ * </ul>
+ *
+ * <p>Both take at least a quorum of nodes to restart before block production resumes, which is
+ * exactly the right threshold for a change of this weight.
+ *
+ * <p><b>AERE OPTIUNI-URGENTA (2026-08-02): both are now COMMAND-LINE OPTIONS.</b> Until this date
+ * they existed only as system properties and environment variables, which meant the documented way
+ * back from a bad activation was to edit a service unit or a wrapper script, and the javadoc above
+ * named two command-line options that did not exist. They now do: {@code
+ * --Xaere-pq-anchor-disarm} and {@code --Xaere-pq-anchor-min-seals-max}, defined in {@code
+ * org.hyperledger.besu.cli.options.AerePqEmergencyOptions} and applied onto these same property
+ * names before anything reads them, so there is exactly ONE place that decides what is in force. The
+ * property and environment forms keep working unchanged; the command line wins over both, and which
+ * source won is logged.
+ *
+ * <p><b>AERE CONFIGURATIE-STRICTA (2026-08-06): {@link #fromSystemConfiguration()} is FAIL-CLOSED on
+ * PRESENCE.</b> Until this date every reading fault in this loader ended in a value rather than in a
+ * refusal: an unparseable step DISCARDED the whole schedule and left K at 0 with the node ARMED; an
+ * unparseable ceiling was IGNORED while the command-line banner announced it as being in force; and
+ * an {@link IllegalArgumentException} from the constructor returned {@link #never(long)}, so the node
+ * ran with the anchor completely inert. None of the three stops a chain, which is exactly what makes
+ * them dangerous: the fleet keeps producing blocks, every head matches, every graph is green, and
+ * the post-quantum property we state in public is simply not being enforced. That is the Holesky
+ * shape - one wrong configuration field, silently defaulted, nodes that started perfectly and ran
+ * for months.
+ *
+ * <p>The gate is on PRESENCE, not on value, and that is what keeps the compatibility property
+ * intact:
+ *
+ * <ul>
+ * <li><b>No {@code aere.pq.*} name set at all</b> - not as a system property, not as an
+ * environment variable - and this method returns {@link #never(long)} at its first statement.
+ * No decode, no allocation, no log, bit for bit today's behaviour. Absence is not a
+ * misconfiguration, it is the absence of a configuration, and a binary built from this code
+ * can still be warmed on a live node that has none of these names set.
+ * <li><b>At least one name present</b>, even set to the empty string, and every field is read
+ * strictly: anything unparseable, an empty value, a discarded schedule, a threshold schedule
+ * with no step at H, an activation height without an explicit chain id, or a constructor that
+ * rejects the result, is a {@link FalconSealSupport.ActivationConfigException} carrying {@link
+ * #REFUSAL_CODE}, i.e. a refusal to start. An operator who typed {@code
+ * -Daere.pq.anchorBlock=} wanted an anchor and had a shell variable go empty on them; that is
+ * a mistake, not an absence.
+ * </ul>
+ *
+ * <p>The one deliberate exception is {@value #PROPERTY_DISABLE}. It is the brake, it is the only
+ * control that works when the chain is ALREADY STOPPED, and a brake that can itself be refused is
+ * not a brake. When it resolves to true the node STARTS: the rest of the configuration is still
+ * parsed, so that a valid one keeps {@link #anchorConfigured()} true and the per-block disarm
+ * announcement keeps shouting, and an invalid one is reported at ERROR and the node starts fully
+ * inert rather than not at all.
+ *
+ * <p>Refusing at STARTUP is not the class of failure that CrowdStrike and Cloudflare are cautionary
+ * tales about. Those were fail-closed at RUNTIME, delivered to an entire fleet at once. A refusal
+ * here happens before the node has joined the quorum, is visible in {@code systemctl status} in
+ * second zero, and is repaired with one line and one restart, at a pace the operator controls. The
+ * risk that IS real is a bad shared template plus a parallel fleet restart: at quorum 5 of 7 that is
+ * not degradation, it is a dead chain. The net for it is the operating rule that goes with this
+ * configuration - restart one at a time, never in parallel - and a preflight that computes its
+ * verdict from THIS code path rather than from a second reading of the same strings.
+ *
+ * <p><b>HONEST LIMITATION, stated in code because it is the same defect class as an unbound
+ * registry.</b> The values here are read from LOCAL system properties or environment variables,
+ * exactly like {@code aere.falcon.registry} is today. They are NOT yet read from the genesis {@code
+ * config.qbft} / {@code config.transitions.qbft}, and there is NO consensus binding on them: two
+ * nodes configured with different H or different K schedules will disagree about which headers are
+ * valid. Wiring these to genesis, and refusing to start when the Falcon registry does not match the
+ * genesis {@code pqRegistryHash}, is a PRECONDITION of arming and is tracked as the fork-activation
+ * and registry work items. Until that lands, a non-default value here is a laboratory setting, not
+ * a deployment.
+ */
+public final class PqAnchorConfig {
+
+ private static final Logger LOG = LoggerFactory.getLogger(PqAnchorConfig.class);
+
+ /** System property naming the activation height H. Env: {@code AERE_PQ_ANCHOR_BLOCK}. */
+ public static final String PROPERTY_ANCHOR_BLOCK = "aere.pq.anchorBlock";
+
+ /**
+ * System property naming the staged threshold schedule, as {@code block:minSeals} pairs separated
+ * by commas, e.g. {@code 12000000:0,12007200:1,12014400:3,12021600:5}. Env: {@code
+ * AERE_PQ_ANCHOR_MINSEALS}.
+ */
+ public static final String PROPERTY_MIN_SEALS = "aere.pq.anchorMinSeals";
+
+ /** System property naming the chain id used in the D and M pre-images. Env: {@code AERE_PQ_CHAINID}. */
+ public static final String PROPERTY_CHAIN_ID = "aere.pq.chainId";
+
+ /**
+ * EMERGENCY: an upper bound on K. Lowers the effective threshold, never raises it. This is the
+ * property behind the command-line option {@code --Xaere-pq-anchor-min-seals-max}. Env: {@code
+ * AERE_PQ_ANCHOR_MIN_SEALS_MAX}.
+ */
+ public static final String PROPERTY_MIN_SEALS_CEILING = "aere.pq.anchor.minSealsCeiling";
+
+ /**
+ * EMERGENCY: switch both anchor rules off on this node. This is the property behind the
+ * command-line option {@code --Xaere-pq-anchor-disarm}. Env: {@code AERE_PQ_ANCHOR_DISABLE}.
+ */
+ public static final String PROPERTY_DISABLE = "aere.pq.anchor.disable";
+
+ /**
+ * COST: the largest number of seals a proposer of THIS node writes into a certificate. K is a
+ * FLOOR, not a cap: a proposer includes every eligible seal it happened to hear in time, so at N=7
+ * a K=3 chain carries four, five, six or seven seals in practice, not three.
+ *
+ * <p>MEASURED 2026-08-07, and this is why the property exists. A Falcon-512 seal is 666 bytes. The
+ * seal counts observed on a live seven-node run with the threshold at 4 were: 42 blocks with 4, 36
+ * with 5, 5 with 6. The rehearsal's median header of 3838 bytes is {@code (3838-525)/666 = 4.97}
+ * seals. Header bytes therefore scale with the seals actually attached: five seals is about 1.7
+ * times what the same chain writes capped at K=3, and about seven times a seal-less header. The
+ * figure used before that day had been computed for a SINGLE seal, so it understated the cost by
+ * about 4.4x.
+ *
+ * <p>Setting this to K therefore removes ~40% of the anchor's disk cost and takes nothing from the
+ * quorum margin, because the margin is decided by the THRESHOLD a verifier requires, not by how
+ * many seals a proposer volunteers above it.
+ *
+ * <p>UNSET MEANS TODAY'S BEHAVIOUR: no cap. That is deliberate. A cap is a live-chain behaviour
+ * change and must be asked for, never arrive as a default.
+ *
+ * <p>FAIL-CLOSED: a cap below the highest K the schedule ever reaches would make every proposer
+ * assemble a certificate its own fleet rejects, so the loader REFUSES to start. See {@link
+ * #withMaxSealsCarried(OptionalInt)}. Env: {@code AERE_PQ_ANCHOR_MAX_SEALS}.
+ */
+ public static final String PROPERTY_MAX_SEALS = "aere.pq.anchor.maxSeals";
+
+ /** Environment variable twin of {@link #PROPERTY_MAX_SEALS}. */
+ public static final String ENV_MAX_SEALS = "AERE_PQ_ANCHOR_MAX_SEALS";
+
+ /**
+ * COST, and the big lever: carry a certificate every N-th block instead of every block.
+ *
+ * <p>WHY IT IS ALLOWED TO BE SAFE, and this is the whole argument. Block hashes chain: block N+1
+ * commits to block N's hash. So an anchor at height A, whose vanityData binds a Falcon certificate
+ * over A-1, transitively protects EVERYTHING below A. Rewriting any block under A changes A's
+ * parent hash, which forces A to be reproduced, which needs a Falcon quorum the adversary does not
+ * have. The only thing an adversary with every classical validator key can still rewrite is the
+ * TAIL: the blocks produced since the last anchor. The property is therefore
+ *
+ * <pre>fork depth &lt;= anchor interval</pre>
+ *
+ * and it is a knob, not an accident.
+ *
+ * <p>MEASURED 2026-08-07, at K=3 capped, 666 bytes a seal: the certificate cost falls in exact
+ * proportion to the interval, so every 10th block costs a tenth of the every-block figure, every
+ * 100th a hundredth, every 256th about a 250th. Against a ~523 ms block, an interval of 100 buys
+ * that hundredfold saving for a rewritable tail that grows from about half a second to about
+ * fifty-two seconds. Algorand ships the same shape at 1 in 256.
+ *
+ * <p>UNSET MEANS EVERY BLOCK, which is today's design and the strongest setting. As with the seal
+ * cap, a weakening never arrives as a default; it has to be asked for.
+ *
+ * <p>ANCHOR HEIGHTS ARE COUNTED FROM H, not from zero: {@code (n - anchorBlock) % interval == 0}.
+ * H itself is therefore always an anchor height, which matters because H is the one height the
+ * whole fleet coordinates on. Env: {@code AERE_PQ_ANCHOR_INTERVAL}.
+ */
+ public static final String PROPERTY_ANCHOR_INTERVAL = "aere.pq.anchorInterval";
+
+ /** Environment variable twin of {@link #PROPERTY_ANCHOR_INTERVAL}. */
+ public static final String ENV_ANCHOR_INTERVAL = "AERE_PQ_ANCHOR_INTERVAL";
+
+ /** Environment variable twin of {@link #PROPERTY_ANCHOR_BLOCK}. */
+ public static final String ENV_ANCHOR_BLOCK = "AERE_PQ_ANCHOR_BLOCK";
+
+ /** Environment variable twin of {@link #PROPERTY_MIN_SEALS}. */
+ public static final String ENV_MIN_SEALS = "AERE_PQ_ANCHOR_MINSEALS";
+
+ /** Environment variable twin of {@link #PROPERTY_CHAIN_ID}. */
+ public static final String ENV_CHAIN_ID = "AERE_PQ_CHAINID";
+
+ /** Environment variable twin of {@link #PROPERTY_MIN_SEALS_CEILING}. */
+ public static final String ENV_MIN_SEALS_CEILING = "AERE_PQ_ANCHOR_MIN_SEALS_MAX";
+
+ /** Environment variable twin of {@link #PROPERTY_DISABLE}. */
+ public static final String ENV_DISABLE = "AERE_PQ_ANCHOR_DISABLE";
+
+ /**
+ * The stable, greppable code carried by every startup refusal raised while reading this
+ * configuration, in the shape of the registry-binding refusal {@code AERE-PQC-REG-MISMATCH-01}.
+ */
+ public static final String REFUSAL_CODE = "AERE-PQC-ANCHOR-CONF-01";
+
+ /**
+ * MIN-SEALS FLOOR: the greppable name of the guard that refuses an armed anchor whose schedule
+ * never demands a single signature. Named, and not just a message, so that a check can ask whether
+ * the guard EXISTS rather than whether some prose happens to be present.
+ */
+ public static final String REFUSAL_MIN_SEALS_FLOOR = REFUSAL_CODE + "/minSealsFloor";
+
+ /** Sentinel meaning "the anchor never activates". */
+ public static final long NEVER = Long.MAX_VALUE;
+
+ /**
+ * Every name this loader reads, property first and environment twin second. Presence of ANY of
+ * them is what switches the loader from "this node has no anchor configuration" to "this node has
+ * an anchor configuration and every field of it must be readable".
+ */
+ private static final String[][] ANCHOR_NAMES = {
+ {PROPERTY_ANCHOR_BLOCK, ENV_ANCHOR_BLOCK},
+ {PROPERTY_MIN_SEALS, ENV_MIN_SEALS},
+ {PROPERTY_CHAIN_ID, ENV_CHAIN_ID},
+ {PROPERTY_MIN_SEALS_CEILING, ENV_MIN_SEALS_CEILING},
+ {PROPERTY_MAX_SEALS, ENV_MAX_SEALS},
+ {PROPERTY_ANCHOR_INTERVAL, ENV_ANCHOR_INTERVAL},
+ {PROPERTY_DISABLE, ENV_DISABLE}
+ };
+
+ private static final String SOURCE_PROPERTY = "system property (BESU_OPTS)";
+
+ private final long chainId;
+ private final long anchorBlock;
+ private final NavigableMap<Long, Integer> minSealsSchedule;
+ private final OptionalInt minSealsCeiling;
+ private final boolean disabled;
+ private final OptionalInt maxSealsCarried;
+ private final OptionalInt anchorInterval;
+
+ /**
+ * Build an explicit configuration.
+ *
+ * @param chainId the chain id used in the D and M pre-images
+ * @param anchorBlock the activation height H, or {@link #NEVER}; must be at least 1 when set,
+ * because block H needs a parent
+ * @param minSealsSchedule the staged threshold, keyed on the height each step takes effect at; may
+ * be empty, in which case K is 0 everywhere
+ * @param minSealsCeiling an emergency upper bound on K, or empty
+ * @param disabled true to switch the anchor rules off entirely
+ */
+ public PqAnchorConfig(
+ final long chainId,
+ final long anchorBlock,
+ final Map<Long, Integer> minSealsSchedule,
+ final OptionalInt minSealsCeiling,
+ final boolean disabled) {
+ this(chainId, anchorBlock, minSealsSchedule, minSealsCeiling, disabled, OptionalInt.empty());
+ }
+
+ /**
+ * The full constructor, with the proposer-side seal cap.
+ *
+ * @param chainId the chain id used in the D and M pre-images
+ * @param anchorBlock the activation height H, or {@link #NEVER}
+ * @param minSealsSchedule the staged threshold, keyed on the height each step takes effect at
+ * @param minSealsCeiling an emergency upper bound on K, or empty
+ * @param disabled true to switch the anchor rules off entirely
+ * @param maxSealsCarried the largest certificate this node's proposer writes, or empty for no cap
+ */
+ public PqAnchorConfig(
+ final long chainId,
+ final long anchorBlock,
+ final Map<Long, Integer> minSealsSchedule,
+ final OptionalInt minSealsCeiling,
+ final boolean disabled,
+ final OptionalInt maxSealsCarried) {
+ this(chainId, anchorBlock, minSealsSchedule, minSealsCeiling, disabled, maxSealsCarried, OptionalInt.empty());
+ }
+
+ /**
+ * The full constructor, with both cost controls.
+ *
+ * @param chainId the chain id used in the D and M pre-images
+ * @param anchorBlock the activation height H, or {@link #NEVER}
+ * @param minSealsSchedule the staged threshold, keyed on the height each step takes effect at
+ * @param minSealsCeiling an emergency upper bound on K, or empty
+ * @param disabled true to switch the anchor rules off entirely
+ * @param maxSealsCarried the largest certificate this node's proposer writes, or empty for no cap
+ * @param anchorInterval carry a certificate every N-th block from H, or empty for every block
+ */
+ public PqAnchorConfig(
+ final long chainId,
+ final long anchorBlock,
+ final Map<Long, Integer> minSealsSchedule,
+ final OptionalInt minSealsCeiling,
+ final boolean disabled,
+ final OptionalInt maxSealsCarried,
+ final OptionalInt anchorInterval) {
+ this.anchorInterval = anchorInterval;
+ if (anchorInterval.isPresent() && anchorInterval.getAsInt() < 1) {
+ throw new IllegalArgumentException(
+ "AERE PQ ANCHOR: "
+ + PROPERTY_ANCHOR_INTERVAL
+ + " must be at least 1, got "
+ + anchorInterval.getAsInt()
+ + "; 1 means every block, which is the strongest setting, and there is nothing below it");
+ }
+ this.maxSealsCarried = maxSealsCarried;
+ if (maxSealsCarried.isPresent() && maxSealsCarried.getAsInt() < 1) {
+ throw new IllegalArgumentException(
+ "AERE PQ ANCHOR: "
+ + PROPERTY_MAX_SEALS
+ + " must be at least 1, got "
+ + maxSealsCarried.getAsInt()
+ + "; a cap of 0 would make every proposer write an empty certificate");
+ }
+ if (anchorBlock != NEVER && anchorBlock < 1) {
+ throw new IllegalArgumentException(
+ "AERE PQ ANCHOR: pqAnchorBlock must be at least 1 (block H needs a parent), got "
+ + anchorBlock);
+ }
+ final NavigableMap<Long, Integer> schedule = new TreeMap<>();
+ for (final Map.Entry<Long, Integer> step : minSealsSchedule.entrySet()) {
+ final Long at = step.getKey();
+ final Integer k = step.getValue();
+ if (at == null || k == null) {
+ throw new IllegalArgumentException("AERE PQ ANCHOR: null entry in pqAnchorMinSeals");
+ }
+ if (k < 0) {
+ throw new IllegalArgumentException(
+ "AERE PQ ANCHOR: pqAnchorMinSeals step at " + at + " has a negative threshold " + k);
+ }
+ if (anchorBlock != NEVER && at < anchorBlock) {
+ throw new IllegalArgumentException(
+ "AERE PQ ANCHOR: pqAnchorMinSeals step at "
+ + at
+ + " is below pqAnchorBlock "
+ + anchorBlock
+ + "; a threshold cannot take effect before the rules do");
+ }
+ schedule.put(at, k);
+ }
+ if (minSealsCeiling.isPresent() && minSealsCeiling.getAsInt() < 0) {
+ throw new IllegalArgumentException(
+ "AERE PQ ANCHOR: emergency min-seals ceiling must not be negative");
+ }
+ this.chainId = chainId;
+ this.anchorBlock = anchorBlock;
+ this.minSealsSchedule = Collections.unmodifiableNavigableMap(schedule);
+ this.minSealsCeiling = minSealsCeiling;
+ this.disabled = disabled;
+ }
+
+ /**
+ * The configuration that behaves exactly like today: the anchor never activates, so both new rules
+ * are inert at every height.
+ *
+ * @param chainId the chain id (unused while inactive, carried for diagnostics)
+ * @return an inactive configuration
+ */
+ public static PqAnchorConfig never(final long chainId) {
+ return new PqAnchorConfig(chainId, NEVER, Collections.emptyMap(), OptionalInt.empty(), false);
+ }
+
+ // -----------------------------------------------------------------------------------------------
+ // Reading the names. ONE seam, used by both controls of the proof: a recording reader shows that
+ // the unconfigured path touches nothing, and a reader whose presence probe always answers "absent"
+ // is the negative control that makes every refusal below disappear.
+ // -----------------------------------------------------------------------------------------------
+
+ /** How this class reaches system properties and environment variables. */
+ interface NameReader {
+
+ /**
+ * The raw system property, or null when the name is not set.
+ *
+ * @param name the system property name
+ * @return the value as set, possibly empty, or null when absent
+ */
+ String property(String name);
+
+ /**
+ * The raw environment variable, or null when the name is not set.
+ *
+ * @param name the environment variable name
+ * @return the value as set, possibly empty, or null when absent
+ */
+ String environment(String name);
+ }
+
+ private static final NameReader REAL_NAMES =
+ new NameReader() {
+ @Override
+ public String property(final String name) {
+ return System.getProperty(name);
+ }
+
+ @Override
+ public String environment(final String name) {
+ return System.getenv(name);
+ }
+ };
+
+ private static volatile NameReader names = REAL_NAMES;
+
+ /**
+ * Replace the reader, for the positive and negative controls of the configuration proof.
+ *
+ * @param replacement the reader to use, or null to restore the real one
+ */
+ static void useNameReaderForTesting(final NameReader replacement) {
+ names = replacement == null ? REAL_NAMES : replacement;
+ }
+
+ /**
+ * The real reader, so a recording test reader can delegate to it instead of re-implementing it.
+ *
+ * @return the reader that reads actual system properties and environment variables
+ */
+ static NameReader realNameReader() {
+ return REAL_NAMES;
+ }
+
+ /**
+ * One name as this loader saw it.
+ *
+ * @param property the system property name
+ * @param environment the environment variable name
+ * @param present whether the name was set at all, INCLUDING being set to the empty string
+ * @param source human-readable origin, for the refusal message
+ * @param raw the trimmed value, possibly empty, or null when absent
+ */
+ private record Read(
+ String property, String environment, boolean present, String source, String raw) {
+
+ boolean hasValue() {
+ return present && !raw.isEmpty();
+ }
+ }
+
+ private static Read read(final String property, final String environment) {
+ final NameReader reader = names;
+ final String fromProperty = reader.property(property);
+ if (fromProperty != null) {
+ return new Read(property, environment, true, SOURCE_PROPERTY, fromProperty.trim());
+ }
+ final String fromEnvironment = reader.environment(environment);
+ if (fromEnvironment != null) {
+ return new Read(
+ property,
+ environment,
+ true,
+ "environment variable " + environment,
+ fromEnvironment.trim());
+ }
+ return new Read(property, environment, false, null, null);
+ }
+
+ private static boolean anyAnchorNamePresent() {
+ for (final String[] pair : ANCHOR_NAMES) {
+ if (read(pair[0], pair[1]).present()) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ // -----------------------------------------------------------------------------------------------
+ // The loader.
+ // -----------------------------------------------------------------------------------------------
+
+ /**
+ * Read the configuration from system properties, falling back to environment variables, and apply
+ * the emergency overrides.
+ *
+ * <p>A node with NONE of the {@code aere.pq.*} names set gets {@link #never(long)} at the first
+ * statement, i.e. exactly today's behaviour with no decode, no allocation and no log. A node with
+ * at least one of them set is read strictly and REFUSES TO START on any fault, except through the
+ * {@value #PROPERTY_DISABLE} brake. See the class javadoc for why the gate is on presence.
+ *
+ * @return the configuration this node will run with
+ * @throws FalconSealSupport.ActivationConfigException when an anchor name is present and the
+ * configuration cannot be read exactly as written
+ */
+ public static PqAnchorConfig fromSystemConfiguration() {
+ if (!anyAnchorNamePresent()) {
+ return never(0L);
+ }
+
+ final Read block = read(PROPERTY_ANCHOR_BLOCK, ENV_ANCHOR_BLOCK);
+ final Read seals = read(PROPERTY_MIN_SEALS, ENV_MIN_SEALS);
+ final Read chain = read(PROPERTY_CHAIN_ID, ENV_CHAIN_ID);
+ final Read ceiling = read(PROPERTY_MIN_SEALS_CEILING, ENV_MIN_SEALS_CEILING);
+ final Read disable = read(PROPERTY_DISABLE, ENV_DISABLE);
+
+ final boolean disabled = readDisable(disable);
+
+ if (disabled) {
+ // THE BRAKE IS ABSOLUTE. The whole reason this control is local to the node is that it has to
+ // work when the chain is already stopped; a brake that can refuse to engage is not a brake.
+ // The rest is still parsed, because a configuration that reads cleanly keeps
+ // anchorConfigured() true and therefore keeps the per-block disarm announcement shouting.
+ try {
+ final PqAnchorConfig braked = strict(block, seals, chain, ceiling, true);
+ if (braked.anchorConfigured()) {
+ LOG.warn(
+ "AERE PQ ANCHOR: DISABLED by emergency override {} even though {}={} is configured. "
+ + "Both anchor rules are inert on this node.",
+ PROPERTY_DISABLE,
+ PROPERTY_ANCHOR_BLOCK,
+ braked.anchorBlock);
+ }
+ return braked;
+ } catch (final RuntimeException e) {
+ LOG.error(
+ "AERE PQ ANCHOR: the emergency disarm {} is in force, so this node STARTS, but the rest "
+ + "of the anchor configuration cannot be read and is therefore NOT carried: {}. The "
+ + "node runs fully inert and the per-block disarm announcement is LOST, because "
+ + "there is no readable activation height to announce. Fix the configuration before "
+ + "removing the disarm.",
+ PROPERTY_DISABLE,
+ e.getMessage());
+ return never(0L);
+ }
+ }
+
+ final PqAnchorConfig bare = strict(block, seals, chain, ceiling, false);
+ final PqAnchorConfig config =
+ bare.withMaxSealsCarried(readMaxSeals()).withAnchorInterval(readAnchorInterval());
+ requireMinSealsFloorOrRefuse(config);
+ if (config.anchorInterval().isPresent() && config.everActive()) {
+ final int iv = config.anchorInterval().getAsInt();
+ LOG.warn(
+ "AERE PQ ANCHOR: certificate carried every {} block(s) from H={}, not every block. The "
+ + "hash chain makes each anchor protect everything BELOW it, so what stays rewritable "
+ + "by an adversary holding every classical validator key is the TAIL since the last "
+ + "anchor: fork depth <= {} blocks. This is a DELIBERATE weakening bought for header "
+ + "size: the certificate cost falls to roughly 1/{} of the every-block figure.",
+ iv,
+ config.anchorBlock,
+ iv,
+ iv);
+ }
+ if (config.maxSealsCarried().isPresent() && config.everActive()) {
+ LOG.warn(
+ "AERE PQ ANCHOR: proposer-side seal CAP in force - this node writes at most {} seal(s) "
+ + "per certificate, against a highest scheduled threshold of K={}. This lowers the "
+ + "header cost and takes NOTHING from the quorum margin, which is decided by the "
+ + "threshold a verifier demands, not by how many seals a proposer volunteers above "
+ + "it. Measured 2026-08-07: uncapped, a K=3 chain at N=7 carries about five seals, "
+ + "which is about 1.7 times the header bytes it writes capped at K.",
+ config.maxSealsCarried().getAsInt(),
+ config.highestEffectiveMinSeals());
+ }
+ if (config.everActive()) {
+ LOG.warn(
+ "AERE PQ ANCHOR: ARMED from LOCAL configuration - pqAnchorBlock={}, chainId={}, "
+ + "minSeals schedule={}, emergency ceiling={}. These values are NOT bound to genesis "
+ + "and NOT bound to consensus: two nodes with different values disagree about which "
+ + "headers are valid. Bind them to genesis before any deployment.",
+ config.anchorBlock,
+ config.chainId,
+ config.minSealsSchedule,
+ config.minSealsCeiling.isPresent() ? config.minSealsCeiling.getAsInt() : "none");
+ }
+ return config;
+ }
+
+ /**
+ * Every field read exactly as written, or a refusal. Nothing here falls back to a default: a
+ * default that silently replaces a value the operator typed is the defect this method exists to
+ * remove.
+ */
+ private static PqAnchorConfig strict(
+ final Read block,
+ final Read seals,
+ final Read chain,
+ final Read ceiling,
+ final boolean disabled) {
+
+ final OptionalInt ceilingValue =
+ ceiling.present()
+ ? OptionalInt.of(
+ readCeiling(ceiling))
+ : OptionalInt.empty();
+
+ final long chainId = chain.present() ? readChainId(chain) : 0L;
+
+ if (!block.present()) {
+ if (seals.present()) {
+ throw refuse(
+ seals,
+ FalconSealSupport.ActivationConfigException.Kind.UNSAFE,
+ "a threshold schedule is configured but "
+ + PROPERTY_ANCHOR_BLOCK
+ + " is not set anywhere, so the schedule can never apply and the anchor is dead "
+ + "while looking configured",
+ "either set "
+ + PROPERTY_ANCHOR_BLOCK
+ + " as well, or remove "
+ + PROPERTY_MIN_SEALS
+ + " entirely");
+ }
+ return new PqAnchorConfig(
+ chainId, NEVER, Collections.emptyMap(), ceilingValue, disabled);
+ }
+
+ final long anchorBlock = readAnchorBlock(block);
+
+ if (!chain.present()) {
+ // The Holesky field, by name. There, depositContractAddress was absent on three clients, the
+ // default zero took its place, every node started, and the chain lost finality for two weeks
+ // at the height where the value finally mattered. An absent field must not be indistinguishable
+ // from a zero field, so this one is required and explicit, exactly as go-ethereum made the
+ // deposit contract address required per network afterwards.
+ throw refuse(
+ chain,
+ FalconSealSupport.ActivationConfigException.Kind.UNSAFE,
+ PROPERTY_ANCHOR_BLOCK
+ + "="
+ + anchorBlock
+ + " is set but "
+ + PROPERTY_CHAIN_ID
+ + " is not set anywhere; the silent default 0 would enter the D and M pre-images and "
+ + "would remove domain separation without showing anywhere",
+ "an explicit chain id, for example " + PROPERTY_CHAIN_ID + "=2800");
+ }
+ if (chainId < 1) {
+ throw refuse(
+ chain,
+ FalconSealSupport.ActivationConfigException.Kind.UNSAFE,
+ "chain id " + chainId + "; zero and negative values separate no domain at all",
+ "an explicit chain id >= 1, for example " + PROPERTY_CHAIN_ID + "=2800");
+ }
+
+ if (!seals.hasValue()) {
+ throw refuse(
+ seals,
+ FalconSealSupport.ActivationConfigException.Kind.UNSAFE,
+ PROPERTY_ANCHOR_BLOCK
+ + "="
+ + anchorBlock
+ + " is set but the threshold schedule is missing or empty, so K would be 0 at every "
+ + "height and an ARMED node would accept empty certificates",
+ scheduleExpectation(anchorBlock));
+ }
+
+ final NavigableMap<Long, Integer> schedule = readSchedule(seals, anchorBlock);
+ if (schedule.floorEntry(anchorBlock) == null) {
+ throw refuse(
+ seals,
+ FalconSealSupport.ActivationConfigException.Kind.UNSAFE,
+ "the schedule has no step at the activation height "
+ + anchorBlock
+ + ", so K stays 0 from activation until the first step, and an ARMED node with K=0 "
+ + "accepts an empty certificate",
+ scheduleExpectation(anchorBlock));
+ }
+
+ try {
+ return new PqAnchorConfig(chainId, anchorBlock, schedule, ceilingValue, disabled);
+ } catch (final IllegalArgumentException e) {
+ throw refuse(
+ block,
+ FalconSealSupport.ActivationConfigException.Kind.UNSAFE,
+ "the configuration reads but is not valid: " + e.getMessage(),
+ scheduleExpectation(anchorBlock));
+ }
+ }
+
+ private static String scheduleExpectation(final long anchorBlock) {
+ return "block:threshold,block:threshold - every block >= "
+ + PROPERTY_ANCHOR_BLOCK
+ + "="
+ + anchorBlock
+ + ", whole threshold >= 0, and a step exactly at "
+ + anchorBlock;
+ }
+
+ /**
+ * Read the proposer-side seal cap. Absent means no cap, which is exactly today's behaviour; an
+ * unreadable value is a refusal, never a silent fallback to "no cap", because a cap that was asked
+ * for and quietly dropped costs disk nobody is watching.
+ */
+ private static OptionalInt readMaxSeals() {
+ final Read cap = read(PROPERTY_MAX_SEALS, ENV_MAX_SEALS);
+ if (!cap.hasValue()) {
+ return OptionalInt.empty();
+ }
+ try {
+ return OptionalInt.of(Integer.parseInt(cap.raw()));
+ } catch (final NumberFormatException e) {
+ throw new IllegalArgumentException(
+ REFUSAL_CODE
+ + ": "
+ + PROPERTY_MAX_SEALS
+ + " is set to '"
+ + cap.raw()
+ + "' from "
+ + cap.source()
+ + ", which is not a whole number. It is NOT ignored: a cap asked for and silently "
+ + "dropped costs disk nobody is watching.");
+ }
+ }
+
+ /**
+ * THE MIN-SEALS FLOOR. A schedule whose effective K is ZERO at every height leaves the anchor
+ * ARMED as a structure and completely toothless, for ever.
+ *
+ * <p>WHAT THE CODE ALREADY GUARDED, and why that was not enough. The loader already refuses a
+ * MISSING schedule, in its own words: {@code "the threshold schedule is missing or empty, so K
+ * would be 0 at every height and an ARMED node would accept empty certificates"}. The author knew
+ * exactly what the danger was. But the guard only fired on an ABSENT schedule. A schedule that is
+ * PRESENT and of the shape {@code <H>:0} reaches the same end state and went through unseen.
+ *
+ * <p>WHY THIS IS NOT THEORETICAL: it is the very shape a staged activation recommends, one that
+ * STARTS at K=0 as a warm-up window and rises to 3 later. If the second half of the line is lost,
+ * to a truncated environment variable or a misplaced quote, what is left is {@code <H>:0}. The
+ * nodes start, every tool comes out green because each of them measures what was ASKED FOR and
+ * the ask is valid, and the threshold stays zero for ever. Not even a tool that compares the
+ * effective threshold against the requested one catches this: here both of them are zero.
+ *
+ * <p>The warm-up window stays perfectly legal: this looks at the HIGHEST K in the whole schedule,
+ * after the emergency ceiling, so {@code H:0,H+165000:3} passes and {@code H:0} on its own does
+ * not.
+ *
+ * <p>THE LIMIT, written down because it is real: the check sits on the LOADING path, not in the
+ * constructor. A test that builds an all-zero-schedule object directly is not stopped. The real
+ * danger is an operator configuration in BESU_OPTS, and that is where the guard is placed.
+ */
+ private static void requireMinSealsFloorOrRefuse(final PqAnchorConfig config) {
+ if (!config.everActive() || config.disabled) {
+ return;
+ }
+ if (config.highestEffectiveMinSeals() >= 1) {
+ return;
+ }
+ throw new IllegalArgumentException(
+ REFUSAL_MIN_SEALS_FLOOR
+ + ": "
+ + PROPERTY_ANCHOR_BLOCK
+ + "="
+ + config.anchorBlock
+ + " is armed, but the highest threshold in the WHOLE schedule "
+ + PROPERTY_MIN_SEALS
+ + "="
+ + config.minSealsSchedule
+ + (config.minSealsCeiling.isPresent()
+ ? " (after the emergency ceiling " + config.minSealsCeiling.getAsInt() + ")"
+ : "")
+ + " is ZERO. A node started like this has the anchor armed and NO signature requirement"
+ + " at all, for ever, and every tool comes out green because it measures what was asked"
+ + " for. A warm-up window with K=0 is legal, but the schedule has to rise somewhere to"
+ + " at least 1: for example "
+ + config.anchorBlock
+ + ":0,"
+ + (config.anchorBlock + 165000L)
+ + ":3");
+ }
+
+ /**
+ * Read the anchor interval. Absent means every block, the strongest setting; an unreadable value
+ * is a refusal, never a silent fallback, because a weakening asked for and quietly dropped and a
+ * weakening quietly APPLIED are both states an operator must never be left guessing between.
+ */
+ private static OptionalInt readAnchorInterval() {
+ final Read iv = read(PROPERTY_ANCHOR_INTERVAL, ENV_ANCHOR_INTERVAL);
+ if (!iv.hasValue()) {
+ return OptionalInt.empty();
+ }
+ try {
+ return OptionalInt.of(Integer.parseInt(iv.raw()));
+ } catch (final NumberFormatException e) {
+ throw new IllegalArgumentException(
+ REFUSAL_CODE
+ + ": "
+ + PROPERTY_ANCHOR_INTERVAL
+ + " is set to '"
+ + iv.raw()
+ + "' from "
+ + iv.source()
+ + ", which is not a whole number. It is NOT ignored: this value decides how much of "
+ + "the chain tip stays rewritable, and a node guessing it differently from its peers "
+ + "disagrees about which headers carry a certificate at all.");
+ }
+ }
+
+ private static boolean readDisable(final Read disable) {
+ if (!disable.present()) {
+ return false;
+ }
+ final String value = disable.raw().toLowerCase(Locale.ROOT);
+ switch (value) {
+ case "true":
+ case "1":
+ case "yes":
+ case "on":
+ return true;
+ case "false":
+ case "0":
+ case "no":
+ case "off":
+ return false;
+ default:
+ // Boolean.parseBoolean turns "1", "yes" and "on" into FALSE without a word. That is a brake
+ // pedal that reports nothing when it does not engage, on the one control an operator reaches
+ // for while the chain is down.
+ throw refuse(
+ disable,
+ FalconSealSupport.ActivationConfigException.Kind.SYNTAX,
+ "the value cannot be read as true or false",
+ "true, false, 1, 0, yes, no, on or off");
+ }
+ }
+
+ private static long readAnchorBlock(final Read block) {
+ if (!block.hasValue()) {
+ throw refuse(
+ block,
+ FalconSealSupport.ActivationConfigException.Kind.SYNTAX,
+ "the name is set but the value is empty; that is what an unexpanded shell variable in "
+ + "BESU_OPTS looks like, and until today this started the node with the anchor fully "
+ + "inert, without a single line in the log",
+ "the activation height H as a decimal number, for example 10141734");
+ }
+ final long value = readLong(block);
+ if (value < 1) {
+ throw refuse(
+ block,
+ FalconSealSupport.ActivationConfigException.Kind.UNSAFE,
+ "activation height " + value + " leaves no room for a parent",
+ "an activation height H >= 1, and in practice a height ahead of the chain head");
+ }
+ return value;
+ }
+
+ private static long readChainId(final Read chain) {
+ if (!chain.hasValue()) {
+ throw refuse(
+ chain,
+ FalconSealSupport.ActivationConfigException.Kind.SYNTAX,
+ "the name is set but the value is empty",
+ "a chain id as a decimal number, for example 2800");
+ }
+ return readLong(chain);
+ }
+
+ private static int readCeiling(final Read ceiling) {
+ if (!ceiling.hasValue()) {
+ throw refuse(
+ ceiling,
+ FalconSealSupport.ActivationConfigException.Kind.SYNTAX,
+ "the name is set but the value is empty",
+ "a whole ceiling >= 0; 0 disarms the threshold completely");
+ }
+ final int value;
+ try {
+ value = Integer.parseInt(ceiling.raw());
+ } catch (final NumberFormatException e) {
+ // Until today this was IGNORED while AerePqEmergencyOptions announced "SEAL THRESHOLD CAPPED"
+ // anyway, so the operator read the banner as proof that the brake had engaged when it had not.
+ throw refuse(
+ ceiling,
+ FalconSealSupport.ActivationConfigException.Kind.SYNTAX,
+ "the value cannot be read as a whole number, and an emergency ceiling silently ignored "
+ + "is worse than no ceiling at all: the startup banner announces it as being in "
+ + "force anyway",
+ "a whole ceiling >= 0; 0 disarms the threshold completely");
+ }
+ if (value < 0) {
+ throw refuse(
+ ceiling,
+ FalconSealSupport.ActivationConfigException.Kind.UNSAFE,
+ "negative ceiling; until today this silently disarmed the WHOLE anchor, while the banner "
+ + "announced no more than a capped threshold",
+ "a whole ceiling >= 0; 0 disarms the threshold completely");
+ }
+ return value;
+ }
+
+ private static long readLong(final Read where) {
+ try {
+ return Long.parseLong(where.raw());
+ } catch (final NumberFormatException e) {
+ throw refuse(
+ where,
+ FalconSealSupport.ActivationConfigException.Kind.SYNTAX,
+ "the value cannot be read as a decimal number",
+ "a decimal number, no separators, no 0x prefix and no suffixes");
+ }
+ }
+
+ private static NavigableMap<Long, Integer> readSchedule(
+ final Read seals, final long anchorBlock) {
+ final NavigableMap<Long, Integer> schedule = new TreeMap<>();
+ for (final String step : Splitter.on(',').split(seals.raw())) {
+ final String trimmed = step.trim();
+ if (trimmed.isEmpty()) {
+ throw refuse(
+ seals,
+ FalconSealSupport.ActivationConfigException.Kind.SYNTAX,
+ "an empty step, that is one comma too many or two commas side by side",
+ scheduleExpectation(anchorBlock));
+ }
+ final int colon = trimmed.indexOf(':');
+ if (colon < 0) {
+ throw refuse(
+ seals,
+ FalconSealSupport.ActivationConfigException.Kind.SYNTAX,
+ "step \"" + trimmed + "\" has no ':'",
+ scheduleExpectation(anchorBlock));
+ }
+ if (colon == 0) {
+ throw refuse(
+ seals,
+ FalconSealSupport.ActivationConfigException.Kind.SYNTAX,
+ "step \"" + trimmed + "\" has no block before the ':'",
+ scheduleExpectation(anchorBlock));
+ }
+ if (colon == trimmed.length() - 1) {
+ throw refuse(
+ seals,
+ FalconSealSupport.ActivationConfigException.Kind.SYNTAX,
+ "step \"" + trimmed + "\" has no threshold after the ':'",
+ scheduleExpectation(anchorBlock));
+ }
+ final long at;
+ final int k;
+ try {
+ at = Long.parseLong(trimmed.substring(0, colon).trim());
+ k = Integer.parseInt(trimmed.substring(colon + 1).trim());
+ } catch (final NumberFormatException e) {
+ throw refuse(
+ seals,
+ FalconSealSupport.ActivationConfigException.Kind.SYNTAX,
+ "step \"" + trimmed + "\" carries a number that cannot be read",
+ scheduleExpectation(anchorBlock));
+ }
+ if (k < 0) {
+ throw refuse(
+ seals,
+ FalconSealSupport.ActivationConfigException.Kind.UNSAFE,
+ "step \"" + trimmed + "\" asks for a negative threshold",
+ scheduleExpectation(anchorBlock));
+ }
+ if (at < anchorBlock) {
+ throw refuse(
+ seals,
+ FalconSealSupport.ActivationConfigException.Kind.UNSAFE,
+ "step \""
+ + trimmed
+ + "\" is below the activation height "
+ + anchorBlock
+ + ", so a threshold would take effect before the rules do",
+ scheduleExpectation(anchorBlock));
+ }
+ final Integer previous = schedule.put(at, k);
+ if (previous != null && previous != k) {
+ throw refuse(
+ seals,
+ FalconSealSupport.ActivationConfigException.Kind.UNSAFE,
+ "height "
+ + at
+ + " appears twice with different thresholds, "
+ + previous
+ + " and "
+ + k
+ + ", so the threshold at that height depends on the order within the string",
+ scheduleExpectation(anchorBlock));
+ }
+ }
+ return schedule;
+ }
+
+ /**
+ * The one shape a refusal takes: the field, where it was read from, what was actually read, what
+ * is wrong with it, what was expected, and the two operating instructions that matter more than
+ * any of it - repair this node, and never restart the fleet in parallel.
+ */
+ private static FalconSealSupport.ActivationConfigException refuse(
+ final Read where,
+ final FalconSealSupport.ActivationConfigException.Kind kind,
+ final String problem,
+ final String expected) {
+ final StringBuilder message = new StringBuilder(768);
+ message
+ .append("AERE PQ ANCHOR: REFUSING TO START\n")
+ .append(" FIELD ")
+ .append(where.property())
+ .append('\n')
+ .append(" SOURCE ")
+ .append(where.present() ? where.source() : "not set anywhere")
+ .append('\n')
+ .append(" READ ")
+ .append(where.present() ? "\"" + where.raw() + "\"" : "(nothing)")
+ .append('\n')
+ .append(" PROBLEM ")
+ .append(problem)
+ .append('\n')
+ .append(" EXPECTED ")
+ .append(expected)
+ .append('\n')
+ .append(" FIX correct BESU_OPTS on THIS node and restart ONLY this node\n")
+ .append(" WARNING if the same value is on all seven: restart one at a time,\n")
+ .append(" never in parallel. At quorum 5 of 7 you lose the chain.\n")
+ .append(" EMERGENCY ")
+ .append(PROPERTY_DISABLE)
+ .append("=true starts the node with the anchor off and shouts at every block");
+ return new FalconSealSupport.ActivationConfigException(kind, REFUSAL_CODE, message.toString());
+ }
+
+ /**
+ * Whether the anchor rules apply to a block at this height.
+ *
+ * @param blockNumber the height being validated
+ * @return true iff the anchor is configured, not emergency-disabled, and the height is at or above
+ * H
+ */
+ public boolean activeAt(final long blockNumber) {
+ return !disabled && anchorBlock != NEVER && blockNumber >= anchorBlock;
+ }
+
+ /**
+ * Whether the anchor can ever apply on this node.
+ *
+ * @return true iff configured and not emergency-disabled
+ */
+ public boolean everActive() {
+ return !disabled && anchorBlock != NEVER;
+ }
+
+ /**
+ * The minimum number of Falcon seals a certificate must carry at this height, after the emergency
+ * ceiling is applied.
+ *
+ * @param blockNumber the height being validated
+ * @return the effective threshold K, never negative
+ */
+ public int minSealsAt(final long blockNumber) {
+ final int scheduled = scheduledMinSealsAt(blockNumber);
+ return minSealsCeiling.isPresent() ? Math.min(scheduled, minSealsCeiling.getAsInt()) : scheduled;
+ }
+
+ /**
+ * The threshold the SCHEDULE asks for at this height, before the emergency ceiling is applied.
+ *
+ * <p>AERE OPTIUNI-URGENTA (2026-08-02). This exists so the per-block emergency announcement can
+ * state both numbers from the SAME object the rules read, rather than re-deriving one of them from
+ * configuration a second time. A message that re-computes its own version of what the enforcement
+ * did can drift away from it, and a shout that says something other than what is actually in force
+ * is worse than no shout.
+ *
+ * @param blockNumber the height being validated
+ * @return the scheduled threshold K, never negative, ignoring any emergency ceiling
+ */
+ public int scheduledMinSealsAt(final long blockNumber) {
+ final Map.Entry<Long, Integer> step = minSealsSchedule.floorEntry(blockNumber);
+ return step == null ? 0 : step.getValue();
+ }
+
+ /**
+ * Whether the emergency ceiling is ACTUALLY lowering the threshold at this height, as opposed to
+ * merely being set.
+ *
+ * <p>A ceiling of 5 over a scheduled threshold of 1 changes nothing, and a node announcing "the
+ * threshold is lowered" in that state would be shouting about a control that is carrying no
+ * weight. Operators stop reading a log that cries wolf, so the announcement is tied to the ceiling
+ * having an effect, not to it being present.
+ *
+ * @param blockNumber the height being validated
+ * @return true iff the anchor is active here and the ceiling is strictly below the scheduled
+ * threshold
+ */
+ public boolean emergencyCeilingLowersAt(final long blockNumber) {
+ return activeAt(blockNumber)
+ && minSealsCeiling.isPresent()
+ && minSealsCeiling.getAsInt() < scheduledMinSealsAt(blockNumber);
+ }
+
+ /**
+ * The emergency ceiling on K, if one is configured.
+ *
+ * @return the ceiling, or empty
+ */
+ public OptionalInt minSealsCeiling() {
+ return minSealsCeiling;
+ }
+
+ /**
+ * The largest certificate this node's proposer writes, or empty for no cap (today's behaviour).
+ *
+ * @return the proposer-side seal cap
+ */
+ public OptionalInt maxSealsCarried() {
+ return maxSealsCarried;
+ }
+
+ /**
+ * The highest EFFECTIVE threshold this configuration ever demands, that is the largest {@code K}
+ * across every step of the schedule after the emergency ceiling has been applied. This is the one
+ * number a proposer-side cap must never fall below.
+ *
+ * @return the highest effective K, or 0 if the schedule is empty
+ */
+ public int highestEffectiveMinSeals() {
+ int highest = 0;
+ for (final Integer k : minSealsSchedule.values()) {
+ final int effective = minSealsCeiling.isPresent() ? Math.min(k, minSealsCeiling.getAsInt()) : k;
+ highest = Math.max(highest, effective);
+ }
+ return highest;
+ }
+
+ /**
+ * Return a copy carrying a proposer-side seal cap, refusing any cap that cannot work.
+ *
+ * <p>THE REFUSAL IS THE POINT. A cap below {@link #highestEffectiveMinSeals()} makes this node's
+ * proposer assemble certificates its own fleet must reject, at every height from the step that
+ * raises K past the cap. The chain would not diverge and would not corrupt: it would simply stop
+ * accepting anything this node proposes, which at N=7 costs a seventh of the proposers and, if the
+ * same cap is deployed fleet-wide, every one of them. That failure is silent in every existing
+ * tool, because each of them measures what was ASKED and the ask is internally consistent. So it
+ * is refused here, at startup, where a refusal is cheap.
+ *
+ * @param cap the cap to carry, or empty for none
+ * @return a copy of this configuration carrying the cap
+ * @throws IllegalArgumentException if the cap is below the highest effective threshold
+ */
+ public PqAnchorConfig withMaxSealsCarried(final OptionalInt cap) {
+ if (cap.isPresent()) {
+ final int highest = highestEffectiveMinSeals();
+ if (cap.getAsInt() < highest) {
+ throw new IllegalArgumentException(
+ REFUSAL_CODE
+ + ": "
+ + PROPERTY_MAX_SEALS
+ + "="
+ + cap.getAsInt()
+ + " is below the highest threshold this schedule ever demands, K="
+ + highest
+ + ". A proposer capped below K writes certificates its own fleet rejects, and every "
+ + "existing check stays GREEN while it happens, because each one measures what was "
+ + "asked. Raise the cap to at least "
+ + highest
+ + ", or lower the schedule.");
+ }
+ }
+ // CAREFUL: the interval is carried across too. A copy method that drops a field along the way
+ // is exactly how a disarm or an interval would vanish silently when the other control is set.
+ return new PqAnchorConfig(
+ chainId, anchorBlock, minSealsSchedule, minSealsCeiling, disabled, cap, anchorInterval);
+ }
+
+ /**
+ * How often a certificate is carried, counted from H. Empty means every block, which is the
+ * strongest setting and today's design.
+ *
+ * @return the anchor interval
+ */
+ public OptionalInt anchorInterval() {
+ return anchorInterval;
+ }
+
+ /**
+ * Is this a height at which a certificate is carried and demanded?
+ *
+ * <p>Counted FROM H, so H itself is always an anchor height. That matters because H is the single
+ * height the whole fleet coordinates on, and a scheme whose first anchor lands somewhere else
+ * would be a scheme nobody can check by hand on activation day.
+ *
+ * @param blockNumber the height to test
+ * @return true if a certificate belongs in this block
+ */
+ public boolean isAnchorHeight(final long blockNumber) {
+ if (anchorInterval.isEmpty()) {
+ return true;
+ }
+ if (anchorBlock == NEVER || blockNumber < anchorBlock) {
+ return false;
+ }
+ return (blockNumber - anchorBlock) % anchorInterval.getAsInt() == 0;
+ }
+
+ /**
+ * The single question both anchor rules ask: do I judge this header at all?
+ *
+ * <p>Two conditions, and they are different in kind. {@link #activeAt(long)} is about WHETHER the
+ * scheme is switched on for this node at this height; {@link #isAnchorHeight(long)} is about
+ * whether this particular height is one that carries a certificate. Folding them into one method
+ * is deliberate: two rules asking the same question two different ways is exactly how R1 and R2
+ * would drift apart, and a header accepted by one and refused by the other is a chain split.
+ *
+ * @param blockNumber the height to test
+ * @return true if the anchor rules judge this header
+ */
+ public boolean anchorAppliesAt(final long blockNumber) {
+ return activeAt(blockNumber) && isAnchorHeight(blockNumber);
+ }
+
+ /**
+ * Return a copy carrying an anchor interval.
+ *
+ * <p>THE SECURITY PROPERTY, stated so it can be argued with: block hashes chain, so an anchor at
+ * height A protects everything below A, because rewriting anything under A changes A's parent hash
+ * and forces A to be reproduced, which needs a Falcon quorum. What an adversary holding every
+ * classical validator key can still rewrite is the TAIL since the last anchor. Therefore
+ * {@code fork depth <= interval}. At ~523 ms a block, an interval of 100 is about 52 seconds of
+ * rewritable tail, against about half a second at interval 1, at a hundredth of the header cost.
+ *
+ * @param interval the interval, or empty for every block
+ * @return a copy of this configuration carrying the interval
+ */
+ public PqAnchorConfig withAnchorInterval(final OptionalInt interval) {
+ return new PqAnchorConfig(
+ chainId, anchorBlock, minSealsSchedule, minSealsCeiling, disabled, maxSealsCarried, interval);
+ }
+
+ /**
+ * Whether the anchor is configured at all, INDEPENDENTLY of whether it has been emergency
+ * disarmed.
+ *
+ * <p>{@link #everActive()} answers "will the rules ever fire", which is false both when nothing is
+ * configured and when a configured anchor has been disarmed. Telling those two states apart is the
+ * whole point of the per-block disarm announcement: a node that never had an anchor has nothing to
+ * shout about, and a node running disarmed over a configured anchor has to shout at every block.
+ *
+ * @return true iff an activation height is configured
+ */
+ public boolean anchorConfigured() {
+ return anchorBlock != NEVER;
+ }
+
+ /**
+ * The activation height H.
+ *
+ * @return H, or {@link #NEVER}
+ */
+ public long anchorBlock() {
+ return anchorBlock;
+ }
+
+ /**
+ * The height at which the legacy log-only {@code FalconSealValidationRule} retires. It is the same
+ * height the new rules take over at, so exactly one of the two regimes is in force at any height.
+ *
+ * @return H, or {@link #NEVER} when the anchor never activates and the legacy rule never retires
+ */
+ public long legacyFalconRuleRetirementBlock() {
+ return everActive() ? anchorBlock : NEVER;
+ }
+
+ /**
+ * The chain id used in the D and M pre-images.
+ *
+ * @return the chain id
+ */
+ public long chainId() {
+ return chainId;
+ }
+
+ /**
+ * The staged threshold schedule.
+ *
+ * @return an unmodifiable view keyed on activation height
+ */
+ public NavigableMap<Long, Integer> minSealsSchedule() {
+ return minSealsSchedule;
+ }
+
+ /**
+ * Whether the emergency disable override is in force.
+ *
+ * @return true iff the anchor rules are switched off on this node
+ */
+ public boolean disabled() {
+ return disabled;
+ }
+
+ @Override
+ public String toString() {
+ return "PqAnchorConfig{chainId="
+ + chainId
+ + ", anchorBlock="
+ + (anchorBlock == NEVER ? "NEVER" : anchorBlock)
+ + ", minSeals="
+ + minSealsSchedule
+ + ", ceiling="
+ + (minSealsCeiling.isPresent() ? minSealsCeiling.getAsInt() : "none")
+ + ", disabled="
+ + disabled
+ + '}';
+ }
+}
diff --git a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorNotReadyException.java b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorNotReadyException.java
new file mode 100755
index 000000000..772560067
--- /dev/null
+++ b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorNotReadyException.java
@@ -0,0 +1,132 @@
+/*
+ * Copyright contributors to Besu / Aere Network.
+ *
+ * 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;
+
+/**
+ * Raised by the block producer when this node cannot back the block it is about to propose with a
+ * certificate that reaches the staged threshold K.
+ *
+ * <p>THIS IS A DECISION, NOT A FAULT. The alternative, proposing anyway, means emitting a header
+ * this node has ALREADY COMPUTED to be invalid under its own rules. In QBFT both choices cost the
+ * same thing, one round timeout, because there is no way for a proposer to yield its turn early:
+ * a proposal everyone rejects and a proposal nobody sends both end at the round timer. So proposing
+ * anyway buys no liveness at all and only puts a header that cannot be imported into gossip. The
+ * refusal is the strictly better of two equal-cost options.
+ *
+ * <p>WHEN IT CAN HAPPEN. Only above the activation height, and only once the operator has raised K
+ * past zero: the first stage is required to be K=0, so activation itself can never refuse. In
+ * steady state the usual cause is a node that restarted and has not yet taken part in a commit, at
+ * most one proposer turn. The other cause, f validators withholding Falcon seals, is a measured
+ * exposure of its own and is the reason the validator set must grow to N&gt;=9 before K is raised to
+ * quorum, because at N=7, f=2 the margin is exactly zero.
+ *
+ * <p>Callers on the consensus path must catch this and simply not propose. It carries the numbers a
+ * human needs to see why, so the refusal is never silent.
+ */
+public class PqAnchorNotReadyException extends RuntimeException {
+
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * Find a refusal anywhere in a throwable's cause chain.
+ *
+ * <p>MEASURED REASON THIS EXISTS, 2026-08-02. {@code AbstractBlockCreator.createBlock} wraps
+ * EVERY exception thrown by the extra-data calculator into {@code IllegalStateException("Block
+ * creation failed unexpectedly. Will restart on next block added to chain.")} at
+ * AbstractBlockCreator.java:366 of commit d2032017, and the producer is called from inside that
+ * same try block, at AbstractBlockCreator.java:333. A plain {@code catch
+ * (PqAnchorNotReadyException)} on the consensus path is therefore DEAD CODE. Measured on an
+ * isolated N=4 network with an unreachable threshold (K=4 from height 30, chain halted at 29):
+ * the refusal WARN was logged 0 times on 4 of 4 nodes, while "Block creation failed unexpectedly"
+ * and "State machine threw exception while processing event" were each logged once per node, and
+ * the escape aborted {@code handleRoundChangePayload} rather than skipping one proposal.
+ *
+ * <p>Callers must rethrow when this returns null: a refusal is the ONLY exception the consensus
+ * path is allowed to swallow, and a broad catch that ate the rest would hide real faults.
+ *
+ * @param thrown the exception caught on the consensus path
+ * @return the refusal carried by that exception, or null when it is not a refusal
+ */
+ public static PqAnchorNotReadyException findIn(final Throwable thrown) {
+ Throwable cursor = thrown;
+ // Bounded so a self-referential or cyclic cause chain cannot spin the consensus thread.
+ for (int hop = 0; cursor != null && hop < 16; hop++) {
+ if (cursor instanceof PqAnchorNotReadyException) {
+ return (PqAnchorNotReadyException) cursor;
+ }
+ final Throwable next = cursor.getCause();
+ if (next == cursor) {
+ return null;
+ }
+ cursor = next;
+ }
+ return null;
+ }
+
+ private final long parentNumber;
+ private final int seals;
+ private final int required;
+
+ /**
+ * Instantiates the refusal.
+ *
+ * @param parentNumber the number of the parent block the certificate would attest
+ * @param seals how many valid, eligible seals this node holds for that parent
+ * @param required the staged threshold K for the block being produced
+ * @param detail a human readable explanation of where the seals came from and what was rejected
+ */
+ public PqAnchorNotReadyException(
+ final long parentNumber, final int seals, final int required, final String detail) {
+ super(
+ "AERE-PQ-ANCHOR-NOT-READY: refusing to propose on top of block "
+ + parentNumber
+ + " because this node holds "
+ + seals
+ + " valid eligible Falcon seal(s) for it and the staged threshold is "
+ + required
+ + ". "
+ + detail);
+ this.parentNumber = parentNumber;
+ this.seals = seals;
+ this.required = required;
+ }
+
+ /**
+ * The parent the certificate would have attested.
+ *
+ * @return the parent block number
+ */
+ public long parentNumber() {
+ return parentNumber;
+ }
+
+ /**
+ * How many valid eligible seals were available.
+ *
+ * @return the seal count
+ */
+ public int seals() {
+ return seals;
+ }
+
+ /**
+ * The staged threshold that was not reached.
+ *
+ * @return the threshold K
+ */
+ public int required() {
+ return required;
+ }
+}
diff --git a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorSyncModeGuard.java b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorSyncModeGuard.java
new file mode 100755
index 000000000..73dd0492b
--- /dev/null
+++ b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorSyncModeGuard.java
@@ -0,0 +1,130 @@
+/*
+ * Copyright contributors to Besu / Aere Network.
+ *
+ * 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 java.util.Locale;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * AERE SINCRONIZARE (2026-08-02). Refuse to start when the V2 certificate anchor is configured and
+ * this node is going to acquire its history through a sync mode whose header import path does not
+ * run the block header validator at all.
+ *
+ * <p><b>The hole this closes, read in the source and not assumed.</b> On the SNAP path the backward
+ * header pipeline ends in {@code
+ * org.hyperledger.besu.ethereum.eth.sync.common.ImportHeadersStep}. That class takes a {@code
+ * MutableBlockchain}, an anchor header and a pivot header, and nothing else: it holds no {@code
+ * ProtocolSchedule}, no {@code ProtocolContext} and no {@code BlockHeaderValidator}. Its {@code
+ * accept(List)} checks one thing, that the first header of the batch hashes to the parent hash the
+ * previously imported child declared, and then calls {@code storeBlockHeaders} directly. There is no
+ * {@code HeaderValidationMode} anywhere in the file. So every header between the checkpoint and the
+ * pivot enters the database having been checked for hash chaining and for nothing else. No QBFT
+ * rule runs on it: not the validator set rule, not the committed seals rule, and not either half of
+ * the certificate anchor. Bodies and receipts below the pivot arrive through the same pipeline
+ * family and are imported without executing the blocks.
+ *
+ * <p><b>Why refusal and not coverage.</b> Covering the SNAP header path properly means giving that
+ * step a header validator and a parent for every header it imports. The backward pipeline does not
+ * have the parent when it imports a header - the parent is downloaded in a later batch - so the
+ * attached half of the anchor cannot run there at all, and the detached half would run against a
+ * parent that is not yet known. Refusing to start is the only repair that is complete: it has no
+ * window, no partial coverage and nothing for an operator to get subtly wrong. What it costs is
+ * stated plainly rather than hidden: with the anchor armed, a new public node has to acquire the
+ * chain by full sync.
+ *
+ * <p><b>Fail closed on the unknown.</b> A null, blank or unrecognised mode name aborts. A guard that
+ * treats "I could not tell" as "probably fine" is the class of defect this whole programme exists to
+ * remove.
+ *
+ * <p><b>Inert when the anchor is not configured.</b> With no {@code aere.pq.anchorBlock} this method
+ * returns before it looks at the sync mode, so a binary carrying it behaves exactly as it did
+ * before on any chain where the anchor is not configured on any node.
+ */
+public final class PqAnchorSyncModeGuard {
+
+ private static final Logger LOG = LoggerFactory.getLogger(PqAnchorSyncModeGuard.class);
+
+ /** The stable machine-readable code emitted when this guard refuses to start a node. */
+ public static final String CODE = "AERE-PQC-SYNC-MODE-01";
+
+ /** The only sync mode whose import path runs the block header validator on every block. */
+ public static final String REQUIRED_MODE = "FULL";
+
+ private PqAnchorSyncModeGuard() {}
+
+ /**
+ * Refuse to start when the anchor is armed and the sync mode bypasses header validation.
+ *
+ * <p>Call this exactly once during node startup, from a point where a chain head already exists
+ * and before the network and the consensus state machine are started, so that a refusal is a clean
+ * refusal to start rather than a mid-flight halt.
+ *
+ * @param syncModeName the configured sync mode, as its enum name; null or blank aborts
+ * @param anchorConfig the height-indexed certificate-anchor configuration; a null or never-active
+ * configuration makes this method a no-op
+ * @throws FalconSealSupport.ActivationConfigException when the anchor is armed and the sync mode
+ * is anything other than {@value #REQUIRED_MODE}
+ */
+ public static void verifySyncModeOrAbort(
+ final String syncModeName, final PqAnchorConfig anchorConfig) {
+
+ if (anchorConfig == null || !anchorConfig.everActive() || anchorConfig.disabled()) {
+ LOG.debug(
+ "AERE PQ ANCHOR: sync-mode guard inert, the certificate anchor is not armed on this node.");
+ return;
+ }
+
+ final String mode =
+ syncModeName == null ? "" : syncModeName.trim().toUpperCase(Locale.ROOT);
+
+ if (REQUIRED_MODE.equals(mode)) {
+ LOG.info(
+ "AERE PQ ANCHOR: sync mode is {} and the certificate anchor is armed at H={}. Every header "
+ + "this node imports goes through the block header validator, so both halves of the "
+ + "anchor apply to synced history as well as to proposed blocks.",
+ mode,
+ anchorConfig.anchorBlock());
+ return;
+ }
+
+ throw new FalconSealSupport.ActivationConfigException(
+ FalconSealSupport.ActivationConfigException.Kind.UNSAFE,
+ CODE,
+ "AERE PQ ANCHOR: REFUSING TO START (fail-closed). The V2 certificate anchor is armed at "
+ + "H="
+ + anchorConfig.anchorBlock()
+ + " but --sync-mode is "
+ + (mode.isEmpty() ? "(null or blank)" : mode)
+ + ", not "
+ + REQUIRED_MODE
+ + ". WHAT THIS MEANS: outside "
+ + REQUIRED_MODE
+ + " sync, history is acquired through the backward header pipeline, which ends in "
+ + "ImportHeadersStep. That step holds no ProtocolSchedule and no BlockHeaderValidator; "
+ + "it checks that each batch of headers hash-chains onto the child already imported and "
+ + "then writes them straight to storage. No HeaderValidationMode appears in that file. "
+ + "So the anchor digest rule and the anchor seals rule would both be skipped for every "
+ + "block between the checkpoint and the pivot, and this node would accept, as its own "
+ + "history, blocks whose certificates were stripped or swapped in flight. A defence that "
+ + "does not apply to a node that is syncing is missing exactly when it matters, because "
+ + "that is the moment a node takes history from strangers. WHAT TO DO: start this node "
+ + "with --sync-mode="
+ + REQUIRED_MODE
+ + ". To start it unchanged for diagnosis, unset aere.pq.anchorBlock; the anchor rules "
+ + "are then inert and this node enforces nothing.");
+ }
+}
diff --git a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorThresholdGuard.java b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorThresholdGuard.java
new file mode 100755
index 000000000..6551df04e
--- /dev/null
+++ b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorThresholdGuard.java
@@ -0,0 +1,252 @@
+/*
+ * Copyright contributors to Besu / Aere Network.
+ *
+ * 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 java.util.Map;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * AERE GARDA-PRAG (2026-08-02). Refuse to start when the staged Falcon seal threshold K is set to a
+ * value that STOPS THE CHAIN, instead of letting the node boot quietly and discover it one block
+ * later.
+ *
+ * <p><b>The hole this closes, measured before it was written.</b> Searched across the whole fork
+ * tree, {@code grep -rn "minSeals" --include=*.java consensus app} filtered for any of {@code
+ * quorum}, {@code throw}, {@code refus}, {@code abort} or {@code Illegal}: ZERO matches. {@link
+ * PqAnchorConfig} refuses a NEGATIVE threshold and a step scheduled below the activation height, and
+ * nothing else. The only guard that looked at the threshold at all was {@code
+ * PqAnchorProducer.warnIfActivationHeightCanRefuse}, and it covers a different question (K at the
+ * activation height itself), reaches only {@code LOG.error}, and returns. A halting threshold was
+ * therefore a configuration a node accepted in silence.
+ *
+ * <p><b>THE BOUND, AND WHERE THE ARITHMETIC COMES FROM.</b> The refusal is {@code K >= quorum(N)},
+ * i.e. the highest value that may be configured is {@code quorum(N) - 1}.
+ *
+ * <ul>
+ * <li>{@code quorum(N)} is Besu's own {@link BftHelpers#calculateRequiredValidatorQuorum(int)},
+ * {@code ceil(2N/3)}. At N=7 that is {@code ceil(14/3) = 5}, so the highest configurable
+ * threshold at that set size is <b>4</b>.
+ * <li>{@code K = quorum} is a GUARANTEED HALT, and that is measured, not argued. A proposer
+ * assembles its certificate out of the Falcon seals it heard on Commit messages, and the block
+ * is IMPORTED at the quorum-th Commit; after the import {@code QbftController.consumeMessage}
+ * discards every message that targets a height not above the chain head, so the Commits that
+ * arrive afterwards never reach the seal cache. A proposer can therefore gather at most {@code
+ * quorum} seals, at ANY N. Measured on an isolated N=4 network (quorum 3): {@code k = 3} on
+ * EVERY header above the activation height, never 4, with all four nodes keyed and healthy.
+ * Requiring K=quorum thus requires that ALL of the first {@code quorum} Commits carry a valid,
+ * eligible Falcon seal: one validator without a key among them, or one seal that does not
+ * verify, and no proposer can ever propose again.
+ * <li><b>{@code K > N - f} is the WRONG bound and this guard deliberately does not use it.</b> At
+ * N=7 the two agree by coincidence, because {@code N - f = 7 - 2 = 5} equals {@code quorum} and
+ * both reject 5. They part company as soon as the set grows: at N=9, {@code quorum = 6} while
+ * {@code N - f = 7}, so {@code K > N - f} would happily accept K=7, which is two rungs above
+ * what a proposer can physically gather. Growing the validator set does not buy quorum margin;
+ * that was measured too, and it is asserted in the test.
+ * </ul>
+ *
+ * <p><b>What is a refusal and what is only a shout.</b> Above {@code quorum - f} the schedule is
+ * still reachable but has no margin against f silent or keyless signers, which is a measured
+ * exposure of its own. That is a deliberate operator choice with a real cost, so it gets a loud
+ * WARN and the node starts. At or above {@code quorum} the schedule is not reachable at all, so it gets a
+ * refusal. A guard that refused both would take the emergency ladder away; a guard that shouted for
+ * both would be the log line this class exists to replace.
+ *
+ * <p><b>The verdict is computed from the SAME code path enforcement reads.</b> Every step is scored
+ * with {@link PqAnchorConfig#minSealsAt(long)}, the exact method the producer calls, not with a
+ * second reading of the raw schedule. That is why the emergency ceiling is honoured here for free: a
+ * ceiling that lowers a fatal schedule below the bound makes the node start, which is precisely what
+ * an operator needs at three in the morning, and a ceiling ABOVE the schedule rescues nothing,
+ * because the ceiling can only ever lower.
+ *
+ * <p><b>Fail closed on the unknown.</b> A validator-set size of zero or less aborts. "I could not
+ * count the validators" is not "the threshold is probably fine".
+ *
+ * <p><b>Inert when the anchor is not armed.</b> With no {@code aere.pq.anchorBlock}, or with the
+ * anchor emergency-disarmed, this method returns before it computes anything, so a binary carrying
+ * it behaves exactly as it did before on any chain where the anchor is armed on no node.
+ */
+public final class PqAnchorThresholdGuard {
+
+ private static final Logger LOG = LoggerFactory.getLogger(PqAnchorThresholdGuard.class);
+
+ /** The stable machine-readable code emitted when this guard refuses to start a node. */
+ public static final String CODE = "AERE-PQC-THRESHOLD-01";
+
+ private PqAnchorThresholdGuard() {}
+
+ /**
+ * The highest seal threshold that may be configured for a validator set of this size.
+ *
+ * <p>One below the QBFT quorum. At N=7 this is 4.
+ *
+ * @param validatorCount the number of validators, at least 1
+ * @return the highest configurable threshold K
+ */
+ public static int maxConfigurableThreshold(final int validatorCount) {
+ return BftHelpers.calculateRequiredValidatorQuorum(validatorCount) - 1;
+ }
+
+ /**
+ * The number of faulty validators QBFT tolerates at this set size, {@code floor((N-1)/3)}.
+ *
+ * @param validatorCount the number of validators, at least 1
+ * @return f
+ */
+ public static int byzantineBudget(final int validatorCount) {
+ return (validatorCount - 1) / 3;
+ }
+
+ /**
+ * Refuse to start when any step of the staged threshold schedule is at or above the QBFT quorum.
+ *
+ * <p>Call this exactly once during node startup, from a point where the validator set is already
+ * known and before the network and the consensus state machine are started, so that a refusal is a
+ * clean refusal to start rather than a mid-flight halt.
+ *
+ * @param anchorConfig the height-indexed certificate-anchor configuration; a null, never-active or
+ * disarmed configuration makes this method a no-op
+ * @param validatorCount the size of the validator set this node is starting into; zero or less
+ * aborts
+ * @throws FalconSealSupport.ActivationConfigException when the anchor is armed and any step of the
+ * effective threshold schedule reaches the quorum, or when the validator set size is unknown
+ */
+ public static void verifyThresholdAgainstQuorumOrAbort(
+ final PqAnchorConfig anchorConfig, final int validatorCount) {
+
+ if (anchorConfig == null || !anchorConfig.everActive()) {
+ LOG.debug(
+ "AERE PQ ANCHOR: threshold guard inert, the certificate anchor is not armed on this node.");
+ return;
+ }
+
+ if (validatorCount < 1) {
+ throw new FalconSealSupport.ActivationConfigException(
+ FalconSealSupport.ActivationConfigException.Kind.UNSAFE,
+ CODE,
+ "AERE PQ ANCHOR: REFUSING TO START (fail-closed). The V2 certificate anchor is armed at H="
+ + anchorConfig.anchorBlock()
+ + " but this node counted "
+ + validatorCount
+ + " validators, so the QBFT quorum cannot be computed and the configured seal "
+ + "threshold cannot be checked against it. A threshold at or above the quorum stops "
+ + "the chain, and a guard that treated an uncountable validator set as safe would be "
+ + "exactly the silence this guard exists to remove. WHAT TO DO: start this node "
+ + "without aere.pq.anchorBlock to diagnose, or fix whatever prevented the validator "
+ + "set from being read.");
+ }
+
+ final int quorum = BftHelpers.calculateRequiredValidatorQuorum(validatorCount);
+ final int maxConfigurable = quorum - 1;
+ final int f = byzantineBudget(validatorCount);
+ final int noMarginAbove = quorum - f;
+
+ long fatalHeight = -1L;
+ int fatalThreshold = -1;
+ long highestAt = anchorConfig.anchorBlock();
+ int highest = anchorConfig.minSealsAt(anchorConfig.anchorBlock());
+
+ for (final Map.Entry<Long, Integer> step : anchorConfig.minSealsSchedule().entrySet()) {
+ final long at = step.getKey();
+ // Scored through minSealsAt, the SAME method the producer calls, so the emergency ceiling is
+ // applied here by exactly the code that will apply it in enforcement. A guard that re-derived
+ // the effective threshold from the raw schedule could drift away from what is in force.
+ final int effective = anchorConfig.minSealsAt(at);
+ if (effective > highest) {
+ highest = effective;
+ highestAt = at;
+ }
+ if (effective >= quorum && fatalHeight < 0L) {
+ fatalHeight = at;
+ fatalThreshold = effective;
+ }
+ }
+
+ if (fatalHeight >= 0L) {
+ throw new FalconSealSupport.ActivationConfigException(
+ FalconSealSupport.ActivationConfigException.Kind.UNSAFE,
+ CODE,
+ "AERE PQ ANCHOR: REFUSING TO START (fail-closed). The staged Falcon seal threshold reaches "
+ + fatalThreshold
+ + " at height "
+ + fatalHeight
+ + ", and the QBFT quorum for the "
+ + validatorCount
+ + " validators this node is starting into is "
+ + quorum
+ + " (ceil(2N/3)). The highest threshold that may be configured at this set size is "
+ + maxConfigurable
+ + ". WHAT THIS MEANS: a proposer builds its certificate out of the Falcon seals it "
+ + "heard on Commit messages, and the block is imported at the quorum-th Commit; every "
+ + "Commit arriving after that import is discarded as targeting a height not above the "
+ + "chain head, so a proposer can gather at most quorum seals at ANY validator-set "
+ + "size. Measured on an isolated N=4 network with quorum 3: k=3 on every header above "
+ + "the activation height, never 4, with all four nodes keyed and healthy. A threshold "
+ + "of "
+ + fatalThreshold
+ + " therefore requires that ALL of the first "
+ + quorum
+ + " Commits carry a valid and eligible Falcon seal; one validator without a key among "
+ + "them, or one seal that does not verify, and no proposer proposes again. That is a "
+ + "halt, not a degradation, and it starts at height "
+ + fatalHeight
+ + ". WHAT TO DO: lower the step to at most "
+ + maxConfigurable
+ + " in aere.pq.anchorMinSeals, or, if the chain is already stopped, restart with "
+ + "--Xaere-pq-anchor-min-seals-max="
+ + maxConfigurable
+ + " which lowers the effective threshold without editing the schedule, or with "
+ + "--Xaere-pq-anchor-disarm to switch the anchor rules off on this node entirely. "
+ + "Schedule as read: "
+ + anchorConfig.minSealsSchedule()
+ + ", emergency ceiling: "
+ + (anchorConfig.minSealsCeiling().isPresent()
+ ? Integer.toString(anchorConfig.minSealsCeiling().getAsInt())
+ : "none")
+ + ".");
+ }
+
+ if (highest > noMarginAbove) {
+ LOG.warn(
+ "AERE PQ ANCHOR: threshold guard PASSED but the schedule has NO MARGIN. K reaches {} at "
+ + "height {}, the quorum for {} validators is {} and f is {}, so the largest threshold "
+ + "that survives f silent or keyless signers is {}. Above that line every one of the "
+ + "first {} committers has to carry a valid Falcon seal minus at most {}. This is "
+ + "allowed because it is an operator's decision, not because it is safe.",
+ highest,
+ highestAt,
+ validatorCount,
+ quorum,
+ f,
+ noMarginAbove,
+ quorum,
+ quorum - highest);
+ return;
+ }
+
+ LOG.info(
+ "AERE PQ ANCHOR: threshold guard passed. K reaches {} at height {}; quorum for {} "
+ + "validators is {}, highest configurable threshold is {}, margin against silent "
+ + "signers is {} of a tolerated f={}.",
+ highest,
+ highestAt,
+ validatorCount,
+ quorum,
+ maxConfigurable,
+ noMarginAbove - highest,
+ f);
+ }
+}
diff --git a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqRegistryBinding.java b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqRegistryBinding.java
new file mode 100755
index 000000000..5e3a83260
--- /dev/null
+++ b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqRegistryBinding.java
@@ -0,0 +1,497 @@
+/*
+ * 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 org.hyperledger.besu.crypto.SECPSignature;
+import org.hyperledger.besu.crypto.SignatureAlgorithmFactory;
+import org.hyperledger.besu.datatypes.Address;
+import org.hyperledger.besu.datatypes.Hash;
+import org.hyperledger.besu.ethereum.core.Util;
+
+import java.io.ByteArrayOutputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+
+import org.apache.tuweni.bytes.Bytes;
+import org.apache.tuweni.bytes.Bytes32;
+import org.bouncycastle.crypto.digests.KeccakDigest;
+import org.bouncycastle.pqc.crypto.falcon.FalconParameters;
+import org.bouncycastle.pqc.crypto.falcon.FalconPublicKeyParameters;
+import org.bouncycastle.pqc.crypto.falcon.FalconSigner;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * AERE REGISTRY BINDING: bind each registry row's FALCON PUBLIC KEY to the VALIDATOR ADDRESS it
+ * sits next to.
+ *
+ * <h2>The defect, measured on the real code path on 2026-08-06</h2>
+ *
+ * <p>{@link PqRegistryHash} makes the registry a chain-committed object: two nodes cannot hold
+ * different files without one of them refusing to start. It says so itself, in its own class
+ * javadoc: "It says nothing about whether any validator actually holds the private key matching its
+ * registered public key." That sentence is the whole of the defect this class closes.
+ *
+ * <p>Concretely, the registry is a table from index i to the pair (ECDSA validator address,
+ * Falcon public key). Seal verification uses the KEY at index i; signer eligibility is checked
+ * against the parent block's VALIDATOR SET, i.e. against the ADDRESS at index i. Nothing whatsoever
+ * connects the two halves of a row, so whoever writes the registry file may place any key under any
+ * address. Four consequences were measured with probes on the real verification path, all with the
+ * startup gate reporting MATCH and the header ACCEPTED:
+ *
+ * <ul>
+ * <li>T3: rebinding one row's address credits a seal made by validator 0's key to validator 1.
+ * <li>T4: putting ONE public key at TWO indices lets a SINGLE key holder produce two seals that
+ * are credited to two different addresses, so the threshold K is satisfied by one signer. The
+ * threshold itself becomes fiction, not merely the attribution.
+ * <li>T6A: SWAPPING two rows' public keys - no duplicate key, no duplicate address - is accepted.
+ * <li>T6B: SWAPPING two rows' addresses - again no duplicate of anything - is accepted, and every
+ * seal is then attributed to the wrong validator.
+ * </ul>
+ *
+ * <p>T6A and T6B are why this class exists and why a uniqueness check alone was not enough. Measured
+ * on 2026-08-06: both permuted registries carry 4 distinct public keys and 4 distinct addresses, so
+ * every uniqueness test passes, and the forged header is still ACCEPTED. Uniqueness restores the
+ * THRESHOLD (K verifying seals at K distinct indices are then necessarily K distinct keys); it can
+ * never restore ATTRIBUTION. Attribution needs a signature.
+ *
+ * <h2>What a row must now carry</h2>
+ *
+ * <p>Two proofs over ONE canonical pre-image, because one signature closes only half the defect:
+ *
+ * <ul>
+ * <li>POSSESSION, a Falcon signature by the row's own key. Proves the row is not an invented or
+ * truncated key and that somebody holds the matching secret. It does NOT close T3, T4 or T6:
+ * at the key ceremony the registry writer holds every Falcon secret, so it can sign a
+ * possession proof for key 0 sitting under validator 1's address. Anyone who claims a Falcon
+ * proof-of-possession repairs this binding defect is wrong, and the probe measures it.
+ * <li>CLAIM, an ECDSA signature by the row's own VALIDATOR key. This is the half that cuts. The
+ * registry writer cannot forge it without validator i's consensus key, so a key cannot be
+ * moved under another validator's address, indices cannot be swapped, and a key cannot appear
+ * under an address whose owner did not ask for it.
+ * </ul>
+ *
+ * <h2>The canonical pre-image</h2>
+ *
+ * <pre>
+ * "AERE-PQ-POP-1" 13 bytes ASCII, at offset 0
+ * uint8(formatVersion) 1
+ * uint64be(chainId) 8
+ * uint64be(bindHeight) 8 the height in pqRegistryHash that makes THIS registry active
+ * uint32be(count) 4
+ * uint32be(index) 4
+ * address 20
+ * uint32be(len(publicKey)) 4
+ * publicKey len
+ * </pre>
+ *
+ * <p>Every field earns its place against a specific, named attack:
+ *
+ * <ul>
+ * <li>THE DOMAIN TAG, at offset 0 and inside the hashed pre-image rather than beside it. The same
+ * Falcon key signs anchor seals over {@code keccak(RLP["AERE-PQ-COMMIT-1", chainId, number,
+ * hash])} - also a 32-byte digest, also handed to the same {@link FalconSigner}. Without a
+ * distinct tag a possession proof would be a valid Falcon signature over a 32-byte digest and
+ * could be replayed as a SEAL, filling the threshold K with a validator that never ran a
+ * block. Ristenpart and Yilek (EUROCRYPT 2007) is the general form of this: a
+ * proof-of-possession that can be reused as a working signature is not a defence, it is a
+ * second door, and the repair is separate hash inputs for the two purposes.
+ * <li>publicKey IS IN THE SIGNED MESSAGE. Falcon is not Dilithium here: Cremers, Duzlu, Fiedler,
+ * Fischlin and Janson (IEEE S&amp;P 2021) measured that Falcon does NOT have exclusive
+ * ownership, because {@code HashToPoint(r || m)} never absorbs the public key. Putting the key
+ * into the message is the Pornin-Stern PS-3 transform applied at the application layer, with
+ * no change to the Falcon implementation and no growth in signature size. NOT MEASURED HERE: a
+ * concrete key-substitution forgery against Falcon-512 at our parameters was not executed;
+ * what is established is the missing property, from the literature.
+ * <li>chainId, from Ethereum's own scar. {@code DOMAIN_DEPOSIT} omits the genesis validators root,
+ * so deposit signatures replay BETWEEN CHAINS; EIP-8205 exists to add the separation. Our
+ * scratch chain 442807 runs the same binaries and can run the same Falcon keys.
+ * <li>index and bindHeight, so a row cannot be lifted to a different index, and a row retired at
+ * one rotation cannot be replayed into a later registry.
+ * <li>count, so a registry cannot be truncated and have its surviving proofs stay valid.
+ * </ul>
+ *
+ * <p>The CLAIM signs {@code keccak256(0x19 || 0x00 || context || preimage)}, the EIP-191 version-0
+ * envelope, with {@code context} the low 20 bytes of {@code keccak256("AERE-PQ-CLAIM-1" ||
+ * uint64be(chainId))}. The leading {@code 0x19} is the point: it cannot begin an RLP-encoded legacy
+ * transaction (those start at {@code 0xc0}) nor a typed one ({@code 0x01}..{@code 0x04}), so a
+ * validator's claim can never be replayed as a transaction it signed. The context byte string keeps
+ * this registry's claims disjoint from any other EIP-191 message on the same chain.
+ *
+ * <h2>Where this runs, and where it deliberately does not</h2>
+ *
+ * <p>AT LOAD, ONCE, from {@code PqRegistryHash.assemble}, which is the single point every loader
+ * funnels through, and fail-closed. The registry is immutable for the whole of an epoch, so the cost
+ * is N Falcon verifications plus N ecrecover per PROCESS START and exactly ZERO per block. At N=7,
+ * against the 0.068 ms median quoted in {@code PqAnchorSealsRule}, that is under a millisecond of
+ * startup.
+ *
+ * <p>NOT per seal. That would add K verifications to every block - about 0.2 ms at K=3 out of a
+ * 523 ms block - to re-establish a fact that cannot change within an epoch, and every line added to
+ * that loop is a permanent reconciliation cost against upstream Besu.
+ *
+ * <h2>What this does NOT defend against, stated plainly</h2>
+ *
+ * <ul>
+ * <li>NOT the holder of the vault. All seven validator ECDSA keys live in one place; whoever has
+ * them signs a perfectly valid claim for any Falcon key they like. This moves the attack from
+ * "whoever can edit a file" to "whoever holds the consensus keys". The answer to "how many
+ * independent people must agree to stop this chain" is unchanged, and is one.
+ * <li>NOT the rewriting of historical epochs. The immutable anchor contract pins ONE hash, the
+ * head registry's at activation; earlier scheduled epochs are pinned by nothing on chain.
+ * <li>NOT key duplication. Possession is not exclusivity: a claim proves validator i asked for
+ * this key, never that nobody else has a copy. T4 is closed by the uniqueness rule in {@code
+ * PqRegistryHash.assemble}, not by either signature.
+ * <li>NOT anything about how a private key is stored or whether it was ever copied.
+ * </ul>
+ */
+public final class PqRegistryBinding {
+
+ private static final Logger LOG = LoggerFactory.getLogger(PqRegistryBinding.class);
+
+ /** Domain tag of the binding pre-image. Distinct from every other Falcon message we sign. */
+ public static final String DOMAIN_POSSESSION = "AERE-PQ-POP-1";
+
+ /** Domain tag mixed into the EIP-191 context of the ECDSA claim. */
+ public static final String DOMAIN_CLAIM = "AERE-PQ-CLAIM-1";
+
+ /** Registry format version that carries binding proofs. Version 1 carries none. */
+ public static final int FORMAT_VERSION = 2;
+
+ /** r(32) || s(32) || recId(1), the encoding {@code decodeSignature} expects. */
+ private static final int CLAIM_SIGNATURE_LENGTH = 65;
+
+ private PqRegistryBinding() {}
+
+ // ===================================================================================
+ // The canonical binding pre-image and its two digests
+ // ===================================================================================
+
+ /**
+ * The canonical binding pre-image for one registry row. Domain-separated, length-prefixed,
+ * chain-bound, height-bound and index-bound. See the class javadoc for why each field is there.
+ *
+ * @param chainId the chain this registry is bound to
+ * @param bindHeight the pqRegistryHash schedule height that makes this registry active
+ * @param count the number of rows in the registry
+ * @param index the row index
+ * @param address the 20-byte validator address of the row
+ * @param publicKey the Falcon public key of the row
+ * @return the pre-image bytes
+ */
+ public static byte[] bindingPreimage(
+ final long chainId,
+ final long bindHeight,
+ final int count,
+ final int index,
+ final byte[] address,
+ final byte[] publicKey) {
+ final ByteArrayOutputStream out = new ByteArrayOutputStream();
+ writeAll(out, DOMAIN_POSSESSION.getBytes(StandardCharsets.US_ASCII));
+ out.write(FORMAT_VERSION);
+ writeAll(out, uint64be(chainId));
+ writeAll(out, uint64be(bindHeight));
+ writeAll(out, uint32be(count));
+ writeAll(out, uint32be(index));
+ writeAll(out, address);
+ writeAll(out, uint32be(publicKey.length));
+ writeAll(out, publicKey);
+ return out.toByteArray();
+ }
+
+ /**
+ * The 32-byte message the row's FALCON key signs.
+ *
+ * @param chainId the chain id
+ * @param bindHeight the activation height of this registry
+ * @param count the registry size
+ * @param index the row index
+ * @param address the row's validator address
+ * @param publicKey the row's Falcon public key
+ * @return the possession digest
+ */
+ public static Bytes32 possessionDigest(
+ final long chainId,
+ final long bindHeight,
+ final int count,
+ final int index,
+ final byte[] address,
+ final byte[] publicKey) {
+ return Bytes32.wrap(
+ keccak(bindingPreimage(chainId, bindHeight, count, index, address, publicKey)));
+ }
+
+ /**
+ * The 32-byte message the row's VALIDATOR ECDSA key signs: the EIP-191 version-0 envelope around
+ * the same pre-image. The leading {@code 0x19} makes the claim unrepresentable as a transaction.
+ *
+ * @param chainId the chain id
+ * @param bindHeight the activation height of this registry
+ * @param count the registry size
+ * @param index the row index
+ * @param address the row's validator address
+ * @param publicKey the row's Falcon public key
+ * @return the claim digest
+ */
+ public static Bytes32 claimDigest(
+ final long chainId,
+ final long bindHeight,
+ final int count,
+ final int index,
+ final byte[] address,
+ final byte[] publicKey) {
+ final ByteArrayOutputStream out = new ByteArrayOutputStream();
+ out.write(0x19);
+ out.write(0x00);
+ writeAll(out, claimContext(chainId));
+ writeAll(out, bindingPreimage(chainId, bindHeight, count, index, address, publicKey));
+ return Bytes32.wrap(keccak(out.toByteArray()));
+ }
+
+ /**
+ * The 20-byte EIP-191 version-0 "intended validator" field for this chain's registry claims.
+ *
+ * @param chainId the chain id
+ * @return 20 context bytes
+ */
+ public static byte[] claimContext(final long chainId) {
+ final ByteArrayOutputStream out = new ByteArrayOutputStream();
+ writeAll(out, DOMAIN_CLAIM.getBytes(StandardCharsets.US_ASCII));
+ writeAll(out, uint64be(chainId));
+ final byte[] d = keccak(out.toByteArray());
+ final byte[] ctx = new byte[20];
+ System.arraycopy(d, 12, ctx, 0, 20);
+ return ctx;
+ }
+
+ // ===================================================================================
+ // Verification of one row
+ // ===================================================================================
+
+ /**
+ * Verify a Falcon possession proof. Never throws.
+ *
+ * @param publicKey the row's raw Falcon-512 public key
+ * @param digest the possession digest
+ * @param signature the Falcon signature bytes
+ * @return true iff the signature verifies under that key
+ */
+ public static boolean verifyPossession(
+ final byte[] publicKey, final Bytes32 digest, final byte[] signature) {
+ if (publicKey == null || digest == null || signature == null || signature.length == 0) {
+ return false;
+ }
+ try {
+ final FalconPublicKeyParameters pub =
+ new FalconPublicKeyParameters(FalconParameters.falcon_512, publicKey);
+ final FalconSigner verifier = new FalconSigner();
+ verifier.init(false, pub);
+ return verifier.verifySignature(digest.toArray(), signature);
+ } catch (final RuntimeException e) {
+ LOG.debug("AERE PQC REGISTRY-BINDING: Falcon possession verify threw: {}", e.toString());
+ return false;
+ }
+ }
+
+ /**
+ * Recover the address that produced an ECDSA claim. Never throws.
+ *
+ * @param digest the claim digest
+ * @param signature 65 bytes, r || s || recId
+ * @return the recovered address, or null when the signature is malformed or unrecoverable
+ */
+ public static Address recoverClaim(final Bytes32 digest, final byte[] signature) {
+ if (digest == null || signature == null || signature.length != CLAIM_SIGNATURE_LENGTH) {
+ return null;
+ }
+ try {
+ final SECPSignature s =
+ SignatureAlgorithmFactory.getInstance().decodeSignature(Bytes.wrap(signature));
+ return Util.signatureToAddress(s, Hash.wrap(digest));
+ } catch (final RuntimeException e) {
+ LOG.debug("AERE PQC REGISTRY-BINDING: claim recovery threw: {}", e.toString());
+ return null;
+ }
+ }
+
+ // ===================================================================================
+ // Verification of a whole registry, fail-closed
+ // ===================================================================================
+
+ /**
+ * Verify every binding proof in a proof-bound registry, and REFUSE on the first row that does not
+ * hold. Called from {@code PqRegistryHash.assemble}, so it runs on every load, on every node, at
+ * every restart, and not merely once at the key ceremony.
+ *
+ * <p>Fail-closed is the whole point and it is not a preference. The neighbouring defect recorded
+ * on 2026-08-06 is a configuration path that fails OPEN: a misplaced comma drops the schedule, the
+ * threshold falls to zero, and the node starts DISARMED with nothing visible anywhere. A binding
+ * check that logged and continued would be the same mistake with a different name.
+ *
+ * @param registry the loaded registry
+ * @throws PqRegistryHash.RegistryConfigException when any row's binding does not hold
+ */
+ public static void verifyOrThrow(final PqRegistryHash.Registry registry) {
+ if (!registry.proofBound()) {
+ return;
+ }
+ final String source = registry.sourcePath();
+ if (!registry.addressBound()) {
+ throw new PqRegistryHash.RegistryConfigException(
+ "AERE-PQC-REG-BIND-06",
+ "AERE PQC REGISTRY-BINDING: registry '"
+ + source
+ + "' carries binding proofs but is NOT address-bound. A proof binds a Falcon key to a "
+ + "validator ADDRESS; with no addresses there is nothing to bind to. Refusing "
+ + "(fail-closed).");
+ }
+ final long chainId = registry.declaredChainId();
+ final long bindHeight = registry.bindHeight();
+ final int count = registry.count();
+ final List<PqRegistryHash.Entry> entries = registry.entries();
+
+ for (final PqRegistryHash.Entry e : entries) {
+ final byte[] addr = e.address();
+ final byte[] pk = e.publicKey();
+ final byte[] pop = e.possessionProof();
+ final byte[] claim = e.claimProof();
+
+ if (pop == null || pop.length == 0) {
+ throw new PqRegistryHash.RegistryConfigException(
+ "AERE-PQC-REG-BIND-01",
+ "AERE PQC REGISTRY-BINDING: registry '"
+ + source
+ + "' index "
+ + e.index()
+ + " has NO possession proof while other rows have one. Binding must be all or "
+ + "nothing: one unproven row is a row whose key nobody has shown they hold, and it "
+ + "counts toward the threshold exactly like a proven one. Refusing (fail-closed).");
+ }
+ if (claim == null || claim.length == 0) {
+ throw new PqRegistryHash.RegistryConfigException(
+ "AERE-PQC-REG-BIND-02",
+ "AERE PQC REGISTRY-BINDING: registry '"
+ + source
+ + "' index "
+ + e.index()
+ + " has NO validator claim. The claim is the half that stops a key being filed "
+ + "under somebody else's address. Refusing (fail-closed).");
+ }
+
+ final Bytes32 popDigest =
+ possessionDigest(chainId, bindHeight, count, e.index(), addr, pk);
+ if (!verifyPossession(pk, popDigest, pop)) {
+ throw new PqRegistryHash.RegistryConfigException(
+ "AERE-PQC-REG-BIND-03",
+ "AERE PQC REGISTRY-BINDING: registry '"
+ + source
+ + "' index "
+ + e.index()
+ + " (address 0x"
+ + hex(addr)
+ + ") carries a possession proof that DOES NOT VERIFY under the public key on the "
+ + "same row, for chainId="
+ + chainId
+ + " bindHeight="
+ + bindHeight
+ + " count="
+ + count
+ + ". Either the key was replaced without re-signing, or the row was lifted from "
+ + "another index, another height or another chain. Refusing (fail-closed).");
+ }
+
+ final Bytes32 clDigest = claimDigest(chainId, bindHeight, count, e.index(), addr, pk);
+ final Address recovered = recoverClaim(clDigest, claim);
+ if (recovered == null) {
+ throw new PqRegistryHash.RegistryConfigException(
+ "AERE-PQC-REG-BIND-05",
+ "AERE PQC REGISTRY-BINDING: registry '"
+ + source
+ + "' index "
+ + e.index()
+ + " carries a claim of "
+ + claim.length
+ + " bytes that cannot be decoded as an ECDSA signature (expected exactly "
+ + CLAIM_SIGNATURE_LENGTH
+ + ": r||s||recId). Refusing (fail-closed).");
+ }
+ final Address bound = Address.wrap(Bytes.wrap(addr));
+ if (!recovered.equals(bound)) {
+ throw new PqRegistryHash.RegistryConfigException(
+ "AERE-PQC-REG-BIND-04",
+ "AERE PQC REGISTRY-BINDING: registry '"
+ + source
+ + "' index "
+ + e.index()
+ + " is bound to validator "
+ + bound
+ + " but its claim was signed by "
+ + recovered
+ + ". THIS IS THE DEFECT THIS CHECK EXISTS FOR: the Falcon key on this row was filed "
+ + "under an address whose owner did not sign for it, so every seal made with that "
+ + "key would be credited to the wrong validator - and if the same key sits at two "
+ + "indices, a single key holder alone satisfies the quorum threshold. Refusing "
+ + "(fail-closed). WHAT TO DO: have validator "
+ + bound
+ + " sign the row with its own consensus key, or put the key back under the address "
+ + "that did sign it.");
+ }
+ }
+ LOG.info(
+ "AERE PQC REGISTRY-BINDING: registry '{}' - all {} rows carry a verified Falcon possession "
+ + "proof and a verified validator claim (chainId={}, bindHeight={}).",
+ source,
+ count,
+ chainId,
+ bindHeight);
+ }
+
+ // ===================================================================================
+ // Local helpers. Same shape as PqRegistryHash's, deliberately, so the two files can be read
+ // against each other without holding two conventions in your head.
+ // ===================================================================================
+
+ private static void writeAll(final ByteArrayOutputStream out, final byte[] b) {
+ out.write(b, 0, b.length);
+ }
+
+ private static byte[] uint32be(final int v) {
+ return new byte[] {(byte) (v >>> 24), (byte) (v >>> 16), (byte) (v >>> 8), (byte) v};
+ }
+
+ private static byte[] uint64be(final long v) {
+ final byte[] b = new byte[8];
+ for (int i = 0; i < 8; i++) {
+ b[i] = (byte) (v >>> (56 - 8 * i));
+ }
+ return b;
+ }
+
+ private static byte[] keccak(final byte[] input) {
+ final KeccakDigest kd = new KeccakDigest(256);
+ kd.update(input, 0, input.length);
+ final byte[] d = new byte[32];
+ kd.doFinal(d, 0);
+ return d;
+ }
+
+ private static String hex(final byte[] b) {
+ final StringBuilder s = new StringBuilder(b.length * 2);
+ for (final byte x : b) {
+ s.append(Character.forDigit((x >> 4) & 0xf, 16)).append(Character.forDigit(x & 0xf, 16));
+ }
+ return s.toString();
+ }
+}
diff --git a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqRegistryHash.java b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqRegistryHash.java
new file mode 100755
index 000000000..6b285723a
--- /dev/null
+++ b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqRegistryHash.java
@@ -0,0 +1,2341 @@
+/*
+ * 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 java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.Optional;
+import java.util.Properties;
+import java.util.TreeMap;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.google.common.base.Splitter;
+import org.bouncycastle.crypto.digests.KeccakDigest;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * AERE GENESIS BINDING: bind the Falcon validator-index-to-public-key REGISTRY to consensus.
+ *
+ * <p>THE DEFECT THIS EXISTS TO CLOSE. Measured by reading {@code FalconSealSupport}: the registry
+ * that answers "which Falcon public key is validator index i" can be loaded from a plain local
+ * properties FILE named by {@code aere.falcon.registry} / {@code AERE_FALCON_REGISTRY}. Nothing ties
+ * that file to the chain. Two nodes handed two different files disagree about who index i is, so the
+ * SAME certificate verifies on one node and fails on the other. That is a network split with no
+ * attacker in it at all: an operator copying the wrong file is enough. The genesis-anchored and
+ * late-anchor sources do check a hash, but their failure mode is to leave the registry EMPTY and log
+ * an error, and the process still STARTS - so a node can run for a long time believing it is a
+ * verifying validator while it can verify nothing.
+ *
+ * <p>THE FIX. A canonical hash of the registry, {@code pqRegistryHash}, is fixed in the genesis
+ * configuration and bound to HEIGHT. At startup a node computes the canonical hash of whatever
+ * registry it actually loaded and compares it with the hash the schedule makes active at its chain
+ * head. On a mismatch the node REFUSES TO START, and the refusal states what it found and what it
+ * expected, entry by entry, so an operator can find the wrong file in one read instead of diffing
+ * seven nodes by hand.
+ *
+ * <p>WHY HEIGHT-BOUND. Chain 2800 has roughly 11.8 million blocks that were produced with no
+ * registry binding at all. A schedule whose first entry is at height H leaves every height below H
+ * completely unenforced, so nothing existing is invalidated, and a later entry expresses a key
+ * rotation. This is the same shape as every other fork gate in this codebase: the gate is on the
+ * block NUMBER, never on content.
+ *
+ * <p>WHAT THIS CLASS DOES NOT DO, stated plainly so nobody relies on absent protection:
+ *
+ * <ul>
+ * <li>The guard runs at STARTUP. It cannot stop a node that is already running when a scheduled
+ * rotation height arrives underneath it. {@link #requiredHashAt(Schedule, long)} and {@link
+ * #matchesAt} are public precisely so a per-block header rule can enforce the same binding on
+ * the consensus path once such a rule exists. No such rule exists today.
+ * <li>It does not check that the registry is the RIGHT one in any sense other than "it hashes to
+ * the value the chain's genesis says it must". The genesis value is a root of trust exactly
+ * like the genesis hash itself.
+ * <li>It says nothing about whether any validator actually holds the private key matching its
+ * registered public key.
+ * </ul>
+ */
+public final class PqRegistryHash {
+
+ private static final Logger LOG = LoggerFactory.getLogger(PqRegistryHash.class);
+
+ /** Domain tag of the canonical v1 pre-image. Any change here is a new format and a new height. */
+ public static final String DOMAIN_V1 = "AERE-PQ-REGISTRY-1";
+
+ /**
+ * Domain tag of the canonical v2 pre-image: the same registry PLUS the row binding proofs. A
+ * separate tag, and not a flag inside v1, so that a v1 file and a v2 file can never hash equal and
+ * a downgrade that strips the proofs cannot satisfy a schedule entry that was written for v2.
+ */
+ public static final String DOMAIN_V2 = "AERE-PQ-REGISTRY-2";
+
+ /**
+ * Expected length of the Falcon-512 public key as THIS registry format stores it: the raw {@code
+ * h} polynomial, 512 coefficients at 14 bits, i.e. 896 bytes. MEASURED: every entry of
+ * {@code opt/aere-pqc/falcon-registry.properties} is 896 bytes. The 897 figure that appears in
+ * some notes is the standard encoding, which prepends a one-byte header; that header is NOT in
+ * these files, and {@code FalconSealSupport} feeds the bytes straight into
+ * {@code new FalconPublicKeyParameters(falcon_512, h)}, which also wants the bare {@code h}.
+ *
+ * <p>Used only for a diagnostic warning. It never rejects, because the canonical form is
+ * length-prefixed and is correct for any length.
+ */
+ private static final int FALCON_512_PK_LENGTH = 896;
+
+ /** Max registry entries printed in a refusal message before it is elided. */
+ private static final int MAX_ENTRIES_IN_REPORT = 64;
+
+ private PqRegistryHash() {}
+
+ // ===================================================================================
+ // Registry model
+ // ===================================================================================
+
+ /** Where a loaded registry came from. Part of every diagnostic, because it decides trust. */
+ public enum SourceKind {
+ /** {@code aere.falcon.registry}: a plain local properties file. No binding of its own. */
+ LEGACY_PROPERTIES,
+ /** {@code aere.falcon.manifest}: the address-bound JSON manifest of the late-anchor path. */
+ MANIFEST_JSON,
+ /** {@code aere.falcon.genesis}: the {@code config.aereFalconRegistry} manifest in genesis. */
+ GENESIS_MANIFEST,
+ /** No registry configured at all. */
+ NONE
+ }
+
+ /** One registry row: a validator index, its Falcon public key, and optionally its address. */
+ public static final class Entry {
+ private final int index;
+ private final byte[] address; // 20 bytes, or null when the source is not address-bound
+ private final byte[] publicKey;
+ private final byte[] possessionProof; // row binding proof, or null in a v1 registry
+ private final byte[] claimProof; // row binding proof, or null in a v1 registry
+
+ Entry(final int index, final byte[] address, final byte[] publicKey) {
+ this(index, address, publicKey, null, null);
+ }
+
+ Entry(
+ final int index,
+ final byte[] address,
+ final byte[] publicKey,
+ final byte[] possessionProof,
+ final byte[] claimProof) {
+ this.index = index;
+ this.address = address;
+ this.publicKey = publicKey;
+ this.possessionProof = possessionProof;
+ this.claimProof = claimProof;
+ }
+
+ /**
+ * The validator index this row binds.
+ *
+ * @return the index
+ */
+ public int index() {
+ return index;
+ }
+
+ /**
+ * The bound validator address, or null when the source carries public keys only.
+ *
+ * @return 20 address bytes, or null
+ */
+ public byte[] address() {
+ return address == null ? null : address.clone();
+ }
+
+ /**
+ * The Falcon-512 public key bytes.
+ *
+ * @return the public key
+ */
+ public byte[] publicKey() {
+ return publicKey.clone();
+ }
+
+ /**
+ * ROW BINDING. The Falcon signature by this row's own key over the binding pre-image, proving somebody
+ * holds the matching secret.
+ *
+ * @return the possession proof, or null in a v1 registry
+ */
+ public byte[] possessionProof() {
+ return possessionProof == null ? null : possessionProof.clone();
+ }
+
+ /**
+ * ROW BINDING. The ECDSA signature by this row's own VALIDATOR key over the binding pre-image.
+ * This is the half a registry writer cannot forge, and therefore the half that closes both
+ * rebinding a row to another validator's address and swapping two rows.
+ *
+ * @return the claim proof, or null in a v1 registry
+ */
+ public byte[] claimProof() {
+ return claimProof == null ? null : claimProof.clone();
+ }
+ }
+
+ /**
+ * A loaded registry in canonical order, with the provenance needed to explain a refusal.
+ *
+ * <p>Loading is STRICT and fail-closed. A registry whose shape is ambiguous has no well defined
+ * hash, and hashing an ambiguous object would produce a number that looks authoritative and means
+ * nothing. Every loader therefore rejects rather than guesses: {@code count} must be present,
+ * indices must be exactly {@code 0..count-1} with no gap and no extra, address binding must be
+ * all-or-nothing, and hex must be even-length and strictly hexadecimal.
+ */
+ public static final class Registry {
+ private final SourceKind kind;
+ private final String sourcePath;
+ private final List<Entry> entries; // ascending index, contiguous from 0
+ private final boolean addressBound;
+ private final boolean proofBound; // every row carries both binding proofs
+ private final long declaredChainId; // the chainId the proofs were signed over
+ private final long bindHeight; // the activation height the proofs were signed over
+
+ Registry(
+ final SourceKind kind,
+ final String sourcePath,
+ final List<Entry> entries,
+ final boolean addressBound) {
+ this(kind, sourcePath, entries, addressBound, false, -1L, -1L);
+ }
+
+ Registry(
+ final SourceKind kind,
+ final String sourcePath,
+ final List<Entry> entries,
+ final boolean addressBound,
+ final boolean proofBound,
+ final long declaredChainId,
+ final long bindHeight) {
+ this.kind = kind;
+ this.sourcePath = sourcePath;
+ this.entries = entries;
+ this.addressBound = addressBound;
+ this.proofBound = proofBound;
+ this.declaredChainId = declaredChainId;
+ this.bindHeight = bindHeight;
+ }
+
+ /**
+ * Where this registry was loaded from.
+ *
+ * @return the source kind
+ */
+ public SourceKind kind() {
+ return kind;
+ }
+
+ /**
+ * The file this registry was read from.
+ *
+ * @return the path as configured, or "(none)"
+ */
+ public String sourcePath() {
+ return sourcePath == null ? "(none)" : sourcePath;
+ }
+
+ /**
+ * Number of registry rows.
+ *
+ * @return the entry count
+ */
+ public int count() {
+ return entries.size();
+ }
+
+ /**
+ * The rows, ascending by index.
+ *
+ * @return an unmodifiable list
+ */
+ public List<Entry> entries() {
+ return List.copyOf(entries);
+ }
+
+ /**
+ * Whether every row carries a validator address.
+ *
+ * @return true iff address-bound
+ */
+ public boolean addressBound() {
+ return addressBound;
+ }
+
+ /**
+ * ROW BINDING. Whether every row carries a verified Falcon possession proof and a verified validator
+ * claim. False for every registry written before 2026-08-06.
+ *
+ * @return true iff proof-bound
+ */
+ public boolean proofBound() {
+ return proofBound;
+ }
+
+ /**
+ * ROW BINDING. The chain id the binding proofs were signed over, as the FILE declares it. Cross-checked
+ * against the node's real chain id by {@link #verifyOrAbort}: a registry lifted from the scratch
+ * chain carries proofs that verify perfectly among themselves and belong to another chain.
+ *
+ * @return the declared chain id, or -1 when not proof-bound
+ */
+ public long declaredChainId() {
+ return declaredChainId;
+ }
+
+ /**
+ * ROW BINDING. The pqRegistryHash schedule height the binding proofs were signed over, so a row
+ * retired at one rotation cannot be replayed into a later registry.
+ *
+ * @return the bind height, or -1 when not proof-bound
+ */
+ public long bindHeight() {
+ return bindHeight;
+ }
+ }
+
+ // ===================================================================================
+ // Canonical encodings
+ // ===================================================================================
+
+ /**
+ * The canonical v1 pre-image: domain-separated, length-prefixed, chain-bound.
+ *
+ * <pre>
+ * "AERE-PQ-REGISTRY-1" 18 bytes, ASCII
+ * uint64be(chainId) 8 bytes
+ * uint32be(count) 4 bytes
+ * for i in 0..count-1:
+ * uint32be(i) 4 bytes
+ * uint8(addressBound?1:0) 1 byte
+ * address 20 bytes, only when address-bound
+ * uint32be(len(publicKey)) 4 bytes
+ * publicKey len bytes
+ * </pre>
+ *
+ * <p>Three properties this buys, and each one is the answer to a specific way the older
+ * concatenation form could be argued with:
+ *
+ * <ul>
+ * <li>LENGTH PREFIXES. The legacy form concatenates raw keys with no separator. It is
+ * unambiguous only because Falcon-512 public keys happen to be a fixed 897 bytes. "Happens
+ * to be" is not a proof, and a future parameter set or a truncated key would silently make
+ * two different registries hash the same. With a length prefix in front of every variable
+ * field that argument cannot be made at all.
+ * <li>THE INDEX IS IN THE PRE-IMAGE. Not just the order. A registry that permutes two rows
+ * hashes differently even under a hash function that ignored order.
+ * <li>chainId IS IN THE PRE-IMAGE. The scratch chain 442807 runs the same binaries and can run
+ * the same Falcon keys; without chainId a registry lifted from there would hash equal here.
+ * </ul>
+ *
+ * @param registry the loaded registry
+ * @param chainId the chain id this registry is being bound to
+ * @return the pre-image bytes
+ */
+ public static byte[] canonicalPreimageV1(final Registry registry, final long chainId) {
+ final java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream();
+ writeAll(out, DOMAIN_V1.getBytes(StandardCharsets.US_ASCII));
+ writeAll(out, uint64be(chainId));
+ writeAll(out, uint32be(registry.count()));
+ for (final Entry e : registry.entries) {
+ writeAll(out, uint32be(e.index));
+ out.write(registry.addressBound ? 1 : 0);
+ if (registry.addressBound) {
+ writeAll(out, e.address);
+ }
+ writeAll(out, uint32be(e.publicKey.length));
+ writeAll(out, e.publicKey);
+ }
+ return out.toByteArray();
+ }
+
+ /**
+ * keccak256 of the canonical v1 pre-image, as 64-hex with no {@code 0x}.
+ *
+ * @param registry the loaded registry
+ * @param chainId the chain id this registry is bound to
+ * @return the canonical hash, lower-case, unprefixed
+ */
+ public static String hashV1(final Registry registry, final long chainId) {
+ return keccakHex(canonicalPreimageV1(registry, chainId));
+ }
+
+ /**
+ * ROW BINDING. The canonical v2 pre-image: everything v1 commits to, plus the activation height and the
+ * two binding proofs of every row.
+ *
+ * <pre>
+ * "AERE-PQ-REGISTRY-2" 18 bytes, ASCII
+ * uint64be(chainId) 8 bytes
+ * uint64be(bindHeight) 8 bytes
+ * uint32be(count) 4 bytes
+ * for i in 0..count-1:
+ * uint32be(i) 4 bytes
+ * uint8(1) 1 byte, address binding is mandatory in v2
+ * address 20 bytes
+ * uint32be(len(publicKey)) 4 bytes
+ * publicKey len bytes
+ * uint32be(len(possession)) 4 bytes
+ * possession len bytes
+ * uint32be(len(claim)) 4 bytes
+ * claim len bytes
+ * </pre>
+ *
+ * <p>WHY THE PROOFS ARE INSIDE THE HASH and not merely checked at load. The hash is the value that
+ * genesis anchors and, at arming, that the immutable anchor contract pins. If the proofs sat
+ * outside it, they would be advisory: a node could be handed the same registry with the proof
+ * fields deleted, it would hash the same, satisfy the schedule, and load without ever verifying
+ * anything. With them inside, stripping a proof is a different registry with a different hash and
+ * the existing genesis-binding guard refuses it. That is also why v2 has its OWN domain tag: a v1 file cannot
+ * collide with a v2 schedule entry, so a format downgrade is refused by machinery that already
+ * exists rather than by a new rule that could be forgotten.
+ *
+ * @param registry the loaded registry, which must be proof-bound
+ * @param chainId the chain id this registry is being bound to
+ * @return the pre-image bytes
+ */
+ public static byte[] canonicalPreimageV2(final Registry registry, final long chainId) {
+ final java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream();
+ writeAll(out, DOMAIN_V2.getBytes(StandardCharsets.US_ASCII));
+ writeAll(out, uint64be(chainId));
+ writeAll(out, uint64be(registry.bindHeight()));
+ writeAll(out, uint32be(registry.count()));
+ for (final Entry e : registry.entries) {
+ writeAll(out, uint32be(e.index));
+ out.write(1);
+ writeAll(out, e.address);
+ writeAll(out, uint32be(e.publicKey.length));
+ writeAll(out, e.publicKey);
+ writeAll(out, uint32be(e.possessionProof.length));
+ writeAll(out, e.possessionProof);
+ writeAll(out, uint32be(e.claimProof.length));
+ writeAll(out, e.claimProof);
+ }
+ return out.toByteArray();
+ }
+
+ /**
+ * ROW BINDING. keccak256 of the canonical v2 pre-image, as 64-hex with no {@code 0x}.
+ *
+ * @param registry the loaded registry, which must be proof-bound
+ * @param chainId the chain id this registry is bound to
+ * @return the canonical v2 hash, lower-case, unprefixed
+ */
+ public static String hashV2(final Registry registry, final long chainId) {
+ return keccakHex(canonicalPreimageV2(registry, chainId));
+ }
+
+ /**
+ * ROW BINDING. The canonical hash OF THIS REGISTRY: v2 when it carries binding proofs, v1 when it does
+ * not. Every comparison against a schedule entry goes through here, so a proof-bound registry is
+ * compared as v2 everywhere and a legacy one keeps exactly the number it had before this change.
+ *
+ * @param registry the loaded registry
+ * @param chainId the chain id this registry is bound to
+ * @return the canonical hash for this registry's format
+ */
+ public static String hashFor(final Registry registry, final long chainId) {
+ return registry.proofBound() ? hashV2(registry, chainId) : hashV1(registry, chainId);
+ }
+
+ /**
+ * ROW BINDING. The ARMING precondition: refuse to arm the anchor over a registry whose rows are not
+ * bound to their validator addresses by signatures.
+ *
+ * <p>The shape is deliberately the same as {@code AERE-PQC-REG-ARM-01}, which already refuses to
+ * arm over a registry with no validator addresses at all, and for the same reason: arming is the
+ * last moment at which the registry format can still be changed. The anchor contract is immutable
+ * once written, so a fleet armed over unbound rows carries that defect for the life of the chain.
+ *
+ * <p>WIRED 2026-08-06. It is called from {@code
+ * FalconSealSupport.requireRegistryBindingProofsOrAbort()}, in the constructor, immediately after
+ * {@code armingReadinessDiagnostic()} raises AERE-PQC-REG-ARM-01, and only when this node is armed
+ * (a configured {@code aere.falcon.forkBlock}, or an active certificate anchor). Until 2026-08-06
+ * this javadoc read "NOT WIRED YET", and while that was true a v1 registry with no proofs loaded
+ * on an armed node exactly as before.
+ *
+ * @param registry the registry the fleet is about to arm over
+ * @throws RegistryConfigException when the registry carries no binding proofs
+ */
+ public static void requireBindingsOrThrow(final Registry registry) {
+ if (registry != null && registry.proofBound()) {
+ return;
+ }
+ throw new RegistryConfigException(
+ "AERE-PQC-REG-ARM-02",
+ "AERE PQC ROW-BINDING: REFUSING TO ARM (fail-closed) over the registry "
+ + (registry == null ? "(none)" : "'" + registry.sourcePath() + "'")
+ + ", which carries NO binding proofs. Nothing in such a registry connects a Falcon "
+ + "public key to the validator address on the same row, so whoever writes the file "
+ + "decides who a seal is credited to, and one key placed at two indices satisfies the "
+ + "quorum threshold on its own. Measured on the real verification path 2026-08-06: a "
+ + "registry with two rows' keys swapped, carrying no duplicate key and no duplicate "
+ + "address, produced an ACCEPTED header. WHAT TO DO: rebuild the registry in the v2 "
+ + "format, where every row carries a Falcon possession proof and an ECDSA claim signed "
+ + "by that validator's own consensus key, and set config.pqRegistryHash to its v2 hash.");
+ }
+
+ /**
+ * keccak256 of the LEGACY pre-image, byte-for-byte what {@code FalconSealSupport.parseManifest}
+ * already computes: the concatenation, in ascending index order, of {@code address||publicKey}
+ * when address-bound and {@code publicKey} alone otherwise.
+ *
+ * <p>Kept so that a {@code pqRegistryHash} can be cross-checked against the manifest hash already
+ * committed in the genesis {@code alloc} anchor slot, and so an operator can tell which of the two
+ * numbers they are holding. It is NOT the value {@code pqRegistryHash} is compared against: it has
+ * no domain tag, no chain binding and no length prefixes.
+ *
+ * @param registry the loaded registry
+ * @return the legacy hash, lower-case, unprefixed
+ */
+ public static String hashV0Legacy(final Registry registry) {
+ final java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream();
+ for (final Entry e : registry.entries) {
+ if (registry.addressBound) {
+ writeAll(out, e.address);
+ }
+ writeAll(out, e.publicKey);
+ }
+ return keccakHex(out.toByteArray());
+ }
+
+ /**
+ * A short, safe fingerprint of one row: the first 8 hex of {@code keccak256(address||publicKey)}.
+ *
+ * <p>This is the field that turns a refusal into a five-second diagnosis. Two operators comparing
+ * refusal messages from two nodes see immediately WHICH index differs, without moving any key
+ * material and without either of them having to trust the other's file.
+ *
+ * @param registry the loaded registry
+ * @param index the row index
+ * @return an 8-hex fingerprint, or "(absent)" when the index is not present
+ */
+ public static String fingerprint(final Registry registry, final int index) {
+ for (final Entry e : registry.entries) {
+ if (e.index == index) {
+ final java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream();
+ if (e.address != null) {
+ writeAll(out, e.address);
+ }
+ writeAll(out, e.publicKey);
+ return keccakHex(out.toByteArray()).substring(0, 8);
+ }
+ }
+ return "(absent)";
+ }
+
+ // ===================================================================================
+ // Loaders. All STRICT, all fail-closed.
+ // ===================================================================================
+
+ /**
+ * Load the LEGACY properties registry: {@code count=N} plus rows {@code i=<pk hex>}, and
+ * optionally {@code i.addr=<20-byte hex>} to make it address-bound.
+ *
+ * <p>Comments, blank lines, line order and surrounding whitespace do not affect the canonical
+ * hash: the hash is computed over the DECODED registry, not the file bytes. That is deliberate.
+ * Hashing the file bytes would make a node refuse to start because somebody added a comment, and a
+ * guard that fires on nothing real is a guard operators learn to switch off.
+ *
+ * @param path the properties file
+ * @return the loaded registry
+ * @throws RegistryConfigException on any ambiguity
+ */
+ public static Registry loadPropertiesRegistry(final Path path) {
+ final Properties p = new Properties();
+ try (InputStream in = Files.newInputStream(path)) {
+ p.load(in);
+ } catch (final IOException e) {
+ throw new RegistryConfigException(
+ "AERE-PQC-REG-LOAD-01",
+ "AERE PQC GENESIS-BINDING: cannot read the Falcon registry file '"
+ + path
+ + "': "
+ + e
+ + ". Refusing to continue (fail-closed): a node that cannot read its registry cannot "
+ + "verify a single Falcon seal, and starting it anyway would put an unverifying node "
+ + "into the validator set while it reports itself healthy.");
+ }
+
+ final String countRaw = p.getProperty("count");
+ if (countRaw == null) {
+ throw new RegistryConfigException(
+ "AERE-PQC-REG-LOAD-02",
+ "AERE PQC GENESIS-BINDING: the Falcon registry '"
+ + path
+ + "' has no 'count' property. Refusing to continue (fail-closed): without a declared "
+ + "count there is no way to tell a complete registry from one that lost its last "
+ + "lines in transit, and both would hash to something that looks valid. Add "
+ + "'count=<number of validators>'.");
+ }
+ final int count = parsePositiveInt(countRaw, "count", path.toString());
+
+ // AERE ROW BINDING. Header fields of the v2 format. A v1 node reading a v2 file does NOT silently
+ // ignore these: parseIndexOrThrow refuses an unrecognised key, so an old binary handed a bound
+ // registry REFUSES rather than loading it with the proofs dropped. That is the correct
+ // direction of failure and it is why the fields are plain top-level names.
+ final long declaredChainId = parseOptionalLong(p.getProperty("chainId"), "chainId", path.toString());
+ final long bindHeight = parseOptionalLong(p.getProperty("bindHeight"), "bindHeight", path.toString());
+ final int formatVersion =
+ (int) parseOptionalLong(p.getProperty("formatVersion"), "formatVersion", path.toString());
+
+ final TreeMap<Integer, byte[]> pks = new TreeMap<>();
+ final TreeMap<Integer, byte[]> addrs = new TreeMap<>();
+ final TreeMap<Integer, byte[]> pops = new TreeMap<>();
+ final TreeMap<Integer, byte[]> claims = new TreeMap<>();
+ for (final String name : p.stringPropertyNames()) {
+ if ("count".equals(name)
+ || "chainId".equals(name)
+ || "bindHeight".equals(name)
+ || "formatVersion".equals(name)) {
+ continue;
+ }
+ final String key = name.trim();
+ if (key.endsWith(".pop")) {
+ final int i =
+ parseIndexOrThrow(key.substring(0, key.length() - ".pop".length()), path.toString());
+ pops.put(i, strictHex(p.getProperty(name).trim(), "entry " + i + ".pop", path.toString()));
+ } else if (key.endsWith(".claim")) {
+ final int i =
+ parseIndexOrThrow(key.substring(0, key.length() - ".claim".length()), path.toString());
+ claims.put(
+ i, strictHex(p.getProperty(name).trim(), "entry " + i + ".claim", path.toString()));
+ } else if (key.endsWith(".addr")) {
+ final int i =
+ parseIndexOrThrow(key.substring(0, key.length() - ".addr".length()), path.toString());
+ final byte[] a = strictHex(p.getProperty(name).trim(), "entry " + i + ".addr", path.toString());
+ if (a.length != 20) {
+ throw new RegistryConfigException(
+ "AERE-PQC-REG-LOAD-03",
+ "AERE PQC GENESIS-BINDING: registry '"
+ + path
+ + "' entry "
+ + i
+ + ".addr is "
+ + a.length
+ + " bytes, expected exactly 20. Refusing to continue (fail-closed).");
+ }
+ addrs.put(i, a);
+ } else {
+ final int i = parseIndexOrThrow(key, path.toString());
+ pks.put(i, strictHex(p.getProperty(name).trim(), "entry " + i, path.toString()));
+ }
+ }
+ return assemble(
+ SourceKind.LEGACY_PROPERTIES,
+ path.toString(),
+ count,
+ pks,
+ addrs,
+ pops,
+ claims,
+ declaredChainId,
+ bindHeight,
+ formatVersion);
+ }
+
+ /**
+ * Load an address-bound JSON manifest: {@code {"count": N, "0": {"addr": "0x..", "pk": ".."},
+ * ...}}, or the legacy bare-string form {@code {"count": N, "0": "<pk hex>", ...}}.
+ *
+ * @param path the manifest file
+ * @return the loaded registry
+ * @throws RegistryConfigException on any ambiguity
+ */
+ public static Registry loadManifestJson(final Path path) {
+ return fromManifestNode(readJson(path), SourceKind.MANIFEST_JSON, path.toString());
+ }
+
+ /**
+ * Load the manifest embedded in a genesis file at {@code config.aereFalconRegistry}.
+ *
+ * @param genesisPath the genesis file
+ * @return the loaded registry
+ * @throws RegistryConfigException on any ambiguity
+ */
+ public static Registry loadGenesisManifest(final Path genesisPath) {
+ final JsonNode cfg = readJson(genesisPath).path("config").path("aereFalconRegistry");
+ if (cfg.isMissingNode()) {
+ throw new RegistryConfigException(
+ "AERE-PQC-REG-LOAD-04",
+ "AERE PQC GENESIS-BINDING: genesis '"
+ + genesisPath
+ + "' has no config.aereFalconRegistry manifest. Refusing to continue (fail-closed).");
+ }
+ return fromManifestNode(cfg, SourceKind.GENESIS_MANIFEST, genesisPath.toString());
+ }
+
+ /**
+ * Sniff the file and load it with the right loader: a genesis file if it has
+ * {@code config.aereFalconRegistry}, a bare manifest if it is JSON, otherwise properties.
+ *
+ * @param path any registry file
+ * @return the loaded registry
+ */
+ public static Registry loadAuto(final Path path) {
+ final byte[] raw;
+ try {
+ raw = Files.readAllBytes(path);
+ } catch (final IOException e) {
+ throw new RegistryConfigException(
+ "AERE-PQC-REG-LOAD-01",
+ "AERE PQC GENESIS-BINDING: cannot read '" + path + "': " + e + ". Refusing to continue (fail-closed).");
+ }
+ int i = 0;
+ while (i < raw.length && Character.isWhitespace((char) (raw[i] & 0xff))) {
+ i++;
+ }
+ if (i < raw.length && raw[i] == '{') {
+ final JsonNode root = readJson(path);
+ if (!root.path("config").path("aereFalconRegistry").isMissingNode()) {
+ return loadGenesisManifest(path);
+ }
+ return fromManifestNode(root, SourceKind.MANIFEST_JSON, path.toString());
+ }
+ return loadPropertiesRegistry(path);
+ }
+
+ private static Registry fromManifestNode(
+ final JsonNode cfg, final SourceKind kind, final String source) {
+ if (cfg == null || cfg.isMissingNode() || !cfg.has("count")) {
+ throw new RegistryConfigException(
+ "AERE-PQC-REG-LOAD-05",
+ "AERE PQC GENESIS-BINDING: manifest '" + source + "' has no 'count'. Refusing (fail-closed).");
+ }
+ final int count = parsePositiveInt(cfg.get("count").asText(), "count", source);
+
+ // Every numeric field present must be inside 0..count-1.
+ //
+ // FOUND BY THE PLANTED-FAILURE HARNESS, case L1, in the first run of this code: without this
+ // check a manifest that declares count=6 while carrying 7 rows loaded happily as a 6-row
+ // registry, because the loop below only ever asks for indices 0..count-1 and never looks at
+ // what else is in the file. That is precisely the failure this whole class exists to prevent -
+ // a registry that is quietly not the file the operator thinks it is - reintroduced one level
+ // down. Two nodes with the same file but different 'count' would have hashed differently.
+ final List<String> strays = new ArrayList<>();
+ final java.util.Iterator<String> names = cfg.fieldNames();
+ while (names.hasNext()) {
+ final String n = names.next();
+ if ("count".equals(n)
+ || "chainId".equals(n)
+ || "bindHeight".equals(n)
+ || "formatVersion".equals(n)) {
+ continue;
+ }
+ final int idx;
+ try {
+ idx = Integer.parseInt(n.trim());
+ } catch (final NumberFormatException e) {
+ throw new RegistryConfigException(
+ "AERE-PQC-REG-LOAD-18",
+ "AERE PQC GENESIS-BINDING: manifest '"
+ + source
+ + "' has the unrecognised field '"
+ + n
+ + "'. Every field must be 'count' or a validator index. Refusing (fail-closed).");
+ }
+ if (idx < 0 || idx >= count) {
+ strays.add(n);
+ }
+ }
+ if (!strays.isEmpty()) {
+ throw new RegistryConfigException(
+ "AERE-PQC-REG-LOAD-09",
+ "AERE PQC GENESIS-BINDING: manifest '"
+ + source
+ + "' declares count="
+ + count
+ + " but also carries entr"
+ + (strays.size() == 1 ? "y " : "ies ")
+ + strays
+ + ", outside 0.."
+ + (count - 1)
+ + ". Refusing (fail-closed): those rows would have been SILENTLY DROPPED and the "
+ + "remainder would still have hashed to a perfectly valid-looking value.");
+ }
+
+ final long declaredChainId =
+ cfg.has("chainId") ? parseOptionalLong(cfg.get("chainId").asText(), "chainId", source) : -1L;
+ final long bindHeight =
+ cfg.has("bindHeight")
+ ? parseOptionalLong(cfg.get("bindHeight").asText(), "bindHeight", source)
+ : -1L;
+ final int formatVersion =
+ cfg.has("formatVersion")
+ ? (int) parseOptionalLong(cfg.get("formatVersion").asText(), "formatVersion", source)
+ : -1;
+
+ final TreeMap<Integer, byte[]> pks = new TreeMap<>();
+ final TreeMap<Integer, byte[]> addrs = new TreeMap<>();
+ final TreeMap<Integer, byte[]> pops = new TreeMap<>();
+ final TreeMap<Integer, byte[]> claims = new TreeMap<>();
+ for (int i = 0; i < count; i++) {
+ final JsonNode e = cfg.get(Integer.toString(i));
+ if (e == null) {
+ throw new RegistryConfigException(
+ "AERE-PQC-REG-LOAD-06",
+ "AERE PQC GENESIS-BINDING: manifest '"
+ + source
+ + "' declares count="
+ + count
+ + " but has no entry '"
+ + i
+ + "'. Refusing (fail-closed).");
+ }
+ if (e.isObject()) {
+ final JsonNode a = e.get("addr");
+ final JsonNode k = e.get("pk");
+ if (a == null || !a.isTextual() || k == null || !k.isTextual()) {
+ throw new RegistryConfigException(
+ "AERE-PQC-REG-LOAD-07",
+ "AERE PQC GENESIS-BINDING: manifest '"
+ + source
+ + "' entry "
+ + i
+ + " is an object but lacks textual 'addr' and 'pk'. Refusing (fail-closed).");
+ }
+ final byte[] addr = strictHex(a.asText().trim(), "entry " + i + ".addr", source);
+ if (addr.length != 20) {
+ throw new RegistryConfigException(
+ "AERE-PQC-REG-LOAD-03",
+ "AERE PQC GENESIS-BINDING: manifest '"
+ + source
+ + "' entry "
+ + i
+ + " addr is "
+ + addr.length
+ + " bytes, expected 20. Refusing (fail-closed).");
+ }
+ addrs.put(i, addr);
+ pks.put(i, strictHex(k.asText().trim(), "entry " + i + ".pk", source));
+ final JsonNode pop = e.get("pop");
+ final JsonNode claim = e.get("claim");
+ if (pop != null && pop.isTextual()) {
+ pops.put(i, strictHex(pop.asText().trim(), "entry " + i + ".pop", source));
+ }
+ if (claim != null && claim.isTextual()) {
+ claims.put(i, strictHex(claim.asText().trim(), "entry " + i + ".claim", source));
+ }
+ } else if (e.isTextual()) {
+ pks.put(i, strictHex(e.asText().trim(), "entry " + i, source));
+ } else {
+ throw new RegistryConfigException(
+ "AERE-PQC-REG-LOAD-08",
+ "AERE PQC GENESIS-BINDING: manifest '"
+ + source
+ + "' entry "
+ + i
+ + " is neither an object nor a hex string. Refusing (fail-closed).");
+ }
+ }
+ return assemble(
+ kind, source, count, pks, addrs, pops, claims, declaredChainId, bindHeight, formatVersion);
+ }
+
+ /**
+ * MEASURED 2026-08-02, on the first build of this class INSIDE Besu. These two parameters were
+ * declared {@code TreeMap}. Besu compiles with Error Prone's {@code NonApiType} check and {@code
+ * -Werror}, so a concrete collection type in a method signature is a build FAILURE, not a style
+ * note. This file had been type-checked standalone with {@code javac -Xlint:all} and reported
+ * clean; that is a weaker statement than it sounds, and the difference is the whole reason the
+ * wiring had to be compiled in the real tree before this could be called closed. {@code
+ * NavigableMap} keeps the guarantee the code actually relies on, which is ascending key order.
+ */
+ private static Registry assemble(
+ final SourceKind kind,
+ final String source,
+ final int count,
+ final java.util.NavigableMap<Integer, byte[]> pks,
+ final java.util.NavigableMap<Integer, byte[]> addrs,
+ final java.util.NavigableMap<Integer, byte[]> pops,
+ final java.util.NavigableMap<Integer, byte[]> claims,
+ final long declaredChainId,
+ final long bindHeight,
+ final int declaredFormatVersion) {
+
+ if (pks.size() != count) {
+ throw new RegistryConfigException(
+ "AERE-PQC-REG-LOAD-09",
+ "AERE PQC GENESIS-BINDING: registry '"
+ + source
+ + "' declares count="
+ + count
+ + " but carries "
+ + pks.size()
+ + " public-key entr"
+ + (pks.size() == 1 ? "y" : "ies")
+ + " (indices present: "
+ + pks.keySet()
+ + "). Refusing (fail-closed): a registry whose declared size and real size disagree "
+ + "has no canonical form, so any hash of it would be meaningless.");
+ }
+ for (int i = 0; i < count; i++) {
+ if (!pks.containsKey(i)) {
+ throw new RegistryConfigException(
+ "AERE-PQC-REG-LOAD-10",
+ "AERE PQC GENESIS-BINDING: registry '"
+ + source
+ + "' is missing index "
+ + i
+ + "; indices must be exactly 0.."
+ + (count - 1)
+ + " with no gap. Indices present: "
+ + pks.keySet()
+ + ". Refusing (fail-closed).");
+ }
+ if (pks.get(i).length == 0) {
+ throw new RegistryConfigException(
+ "AERE-PQC-REG-LOAD-11",
+ "AERE PQC GENESIS-BINDING: registry '" + source + "' index " + i + " has an EMPTY public key. "
+ + "Refusing (fail-closed).");
+ }
+ }
+ final boolean bound;
+ if (addrs.isEmpty()) {
+ bound = false;
+ } else if (addrs.size() == count) {
+ bound = true;
+ for (int i = 0; i < count; i++) {
+ if (!addrs.containsKey(i)) {
+ throw new RegistryConfigException(
+ "AERE-PQC-REG-LOAD-12",
+ "AERE PQC GENESIS-BINDING: registry '"
+ + source
+ + "' binds addresses for "
+ + addrs.keySet()
+ + " but not for index "
+ + i
+ + ". Address binding must be all or nothing. Refusing (fail-closed).");
+ }
+ }
+ } else {
+ throw new RegistryConfigException(
+ "AERE-PQC-REG-LOAD-12",
+ "AERE PQC GENESIS-BINDING: registry '"
+ + source
+ + "' is MIXED: "
+ + addrs.size()
+ + " of "
+ + count
+ + " entries carry a validator address. Address binding must be all or nothing, "
+ + "otherwise the eligible-signer set is not well defined. Refusing (fail-closed).");
+ }
+
+ // ===============================================================================
+ // AERE ROW BINDING (2026-08-06). UNIQUENESS. Nothing here needs a signature, and it is the half that
+ // makes the THRESHOLD real again.
+ //
+ // MEASURED on the real verification path: a registry carrying ONE public key at TWO indices was
+ // loaded without a word, and two seals produced by a SINGLE Falcon private key - distinct
+ // signatures, because Falcon is randomised - were credited to two different validator addresses
+ // and satisfied a threshold of K=2. At K=3 one key holder alone would produce a certificate the
+ // chain calls a quorum. With distinct keys at every index, K verifying seals at K distinct
+ // indices are necessarily K distinct key holders again.
+ //
+ // Duplicate ADDRESSES are refused here too. Today they are already caught one layer up, by the
+ // `counted` set in PqAnchorSealsRule, but that is an availability trap rather than a defence:
+ // one address duplicated by mistake stops the chain instead of falsifying it, and it stops it at
+ // the activation height rather than at the moment the bad file was written. Refusing at load
+ // turns a fleet-wide halt into a node that will not start and says why.
+ final java.util.Map<String, Integer> seenPk = new java.util.HashMap<>();
+ for (int i = 0; i < count; i++) {
+ final String fp = keccakHex(pks.get(i));
+ final Integer first = seenPk.putIfAbsent(fp, i);
+ if (first != null) {
+ throw new RegistryConfigException(
+ "AERE-PQC-REG-LOAD-19",
+ "AERE PQC ROW-BINDING: registry '"
+ + source
+ + "' carries the SAME Falcon public key at index "
+ + first
+ + " and index "
+ + i
+ + " (key digest "
+ + fp.substring(0, 16)
+ + "). Refusing (fail-closed): the anchor threshold counts DISTINCT INDICES, so one "
+ + "key holder sitting at two indices produces two seals that satisfy a threshold of "
+ + "two on their own. The threshold would stop meaning 'K validators attested' and "
+ + "start meaning 'K rows attested', which is not a quorum of anything.");
+ }
+ }
+ if (bound) {
+ final java.util.Map<String, Integer> seenAddr = new java.util.HashMap<>();
+ for (int i = 0; i < count; i++) {
+ final String a = hex(addrs.get(i));
+ final Integer first = seenAddr.putIfAbsent(a, i);
+ if (first != null) {
+ throw new RegistryConfigException(
+ "AERE-PQC-REG-LOAD-20",
+ "AERE PQC ROW-BINDING: registry '"
+ + source
+ + "' binds the SAME validator address 0x"
+ + a
+ + " at index "
+ + first
+ + " and index "
+ + i
+ + ". Refusing (fail-closed): the seals rule counts each address once, so the "
+ + "second index can never contribute and the fleet silently loses a signer - "
+ + "measured as a REJECTED header at threshold K=2 with two honest signers.");
+ }
+ }
+ }
+
+ // AERE ROW BINDING. FORMAT COHERENCE. Binding proofs are all-or-nothing, exactly like address binding,
+ // and for the same reason: one unproven row counts toward the threshold like a proven one.
+ final boolean anyProof = !pops.isEmpty() || !claims.isEmpty();
+ final boolean proofBound;
+ if (anyProof || declaredFormatVersion == PqRegistryBinding.FORMAT_VERSION) {
+ if (pops.size() != count || claims.size() != count) {
+ throw new RegistryConfigException(
+ "AERE-PQC-REG-LOAD-21",
+ "AERE PQC ROW-BINDING: registry '"
+ + source
+ + "' declares count="
+ + count
+ + " but carries "
+ + pops.size()
+ + " possession proof(s) and "
+ + claims.size()
+ + " validator claim(s). Binding must be ALL OR NOTHING. Refusing (fail-closed): a "
+ + "registry that is bound for some rows and unbound for others has exactly the "
+ + "security of its unbound rows, while looking bound.");
+ }
+ if (declaredChainId < 0 || bindHeight < 0) {
+ throw new RegistryConfigException(
+ "AERE-PQC-REG-LOAD-22",
+ "AERE PQC ROW-BINDING: registry '"
+ + source
+ + "' carries binding proofs but does not declare both 'chainId' and 'bindHeight'. "
+ + "Both are inside the signed message, so without them the proofs cannot even be "
+ + "checked, let alone trusted. Refusing (fail-closed).");
+ }
+ proofBound = true;
+ } else {
+ proofBound = false;
+ }
+
+ final List<Entry> entries = new ArrayList<>(count);
+ for (int i = 0; i < count; i++) {
+ entries.add(
+ new Entry(
+ i,
+ bound ? addrs.get(i) : null,
+ pks.get(i),
+ proofBound ? pops.get(i) : null,
+ proofBound ? claims.get(i) : null));
+ if (pks.get(i).length != FALCON_512_PK_LENGTH) {
+ LOG.warn(
+ "AERE PQC GENESIS-BINDING: registry '{}' index {} carries a {}-byte public key; Falcon-512 public "
+ + "keys are {} bytes. The registry is NOT rejected for this (the canonical form is "
+ + "length-prefixed and handles any length), but it is almost certainly the wrong "
+ + "file or a truncated copy.",
+ source,
+ i,
+ pks.get(i).length,
+ FALCON_512_PK_LENGTH);
+ }
+ }
+ final Registry assembled =
+ new Registry(kind, source, entries, bound, proofBound, declaredChainId, bindHeight);
+ // AERE ROW BINDING. THE VERIFICATION ITSELF, on the single path every loader funnels through, so it
+ // runs at every restart on every node and not only once at the ceremony. Fail-closed, and O(N)
+ // per process start with zero cost per block.
+ PqRegistryBinding.verifyOrThrow(assembled);
+ if (!proofBound) {
+ LOG.warn(
+ "AERE PQC ROW-BINDING: registry '{}' ({} rows) carries NO binding proofs. Nothing in it "
+ + "connects a Falcon public key to the validator address on the same row, so whoever "
+ + "wrote this file decided who every seal is credited to. Measured 2026-08-06 on the "
+ + "real verification path: swapping two rows produces an ACCEPTED header with no "
+ + "duplicate key and no duplicate address. This is the row-binding defect, and it "
+ + "is not closed on this node.",
+ source,
+ count);
+ }
+ return assembled;
+ }
+
+ // ===================================================================================
+ // The height-bound schedule
+ // ===================================================================================
+
+ /** One scheduled binding: from {@code block} onwards the registry must hash to {@code hash}. */
+ public static final class ScheduleEntry {
+ private final long block;
+ private final String hash; // 64-hex, lower-case, no 0x
+
+ ScheduleEntry(final long block, final String hash) {
+ this.block = block;
+ this.hash = hash;
+ }
+
+ /**
+ * First height at which this binding applies.
+ *
+ * @return the block number
+ */
+ public long block() {
+ return block;
+ }
+
+ /**
+ * The required canonical registry hash.
+ *
+ * @return 64-hex, lower-case, no 0x prefix
+ */
+ public String hash() {
+ return hash;
+ }
+
+ @Override
+ public String toString() {
+ return "{block=" + block + ", hash=0x" + hash + "}";
+ }
+ }
+
+ /** The parsed {@code config.pqRegistryHash} schedule; possibly empty, meaning NOT ENFORCED. */
+ public static final class Schedule {
+ private final List<ScheduleEntry> entries; // strictly increasing block
+ private final String source;
+
+ Schedule(final List<ScheduleEntry> entries, final String source) {
+ this.entries = entries;
+ this.source = source;
+ }
+
+ /**
+ * Whether any binding is configured at all.
+ *
+ * @return true iff at least one schedule entry exists
+ */
+ public boolean enforced() {
+ return !entries.isEmpty();
+ }
+
+ /**
+ * The schedule entries, ascending by block.
+ *
+ * @return an unmodifiable list
+ */
+ public List<ScheduleEntry> entries() {
+ return List.copyOf(entries);
+ }
+
+ /**
+ * Where the schedule was read from.
+ *
+ * @return a human-readable source description
+ */
+ public String source() {
+ return source;
+ }
+
+ @Override
+ public String toString() {
+ return entries.toString();
+ }
+ }
+
+ /** An empty schedule: nothing is enforced. Preserves today's behaviour exactly. */
+ public static Schedule emptySchedule() {
+ return new Schedule(List.of(), "(none)");
+ }
+
+ /**
+ * Parse {@code config.pqRegistryHash} from a genesis file.
+ *
+ * <p>Two accepted shapes:
+ *
+ * <pre>
+ * "pqRegistryHash": "0x&lt;64 hex&gt;" -- bound from block 0
+ * "pqRegistryHash": [ {"block": H, "hash": "0x.."},
+ * {"block": H2, "hash": "0x.."} ] -- height-bound, with rotation
+ * </pre>
+ *
+ * <p>ABSENT means NOT ENFORCED, which is the only choice that leaves the 11.8 million existing
+ * blocks and every existing test network untouched. Absence is logged loudly, once, because a
+ * guard everybody believes is on and is not is worse than no guard.
+ *
+ * @param genesisPath the genesis file
+ * @return the parsed schedule, possibly empty
+ * @throws RegistryConfigException when the value is present but malformed
+ */
+ public static Schedule loadScheduleFromGenesis(final Path genesisPath) {
+ final JsonNode config = readJson(genesisPath).path("config");
+ return parseSchedule(config.get("pqRegistryHash"), genesisPath + " config.pqRegistryHash");
+ }
+
+ /**
+ * Parse a {@code pqRegistryHash} JSON value into a schedule.
+ *
+ * @param node the value, or null when absent
+ * @param source human-readable provenance for diagnostics
+ * @return the schedule, empty when the node is null or JSON null
+ * @throws RegistryConfigException when present but malformed
+ */
+ public static Schedule parseSchedule(final JsonNode node, final String source) {
+ if (node == null || node.isNull() || node.isMissingNode()) {
+ return new Schedule(List.of(), source + " (ABSENT)");
+ }
+ final List<ScheduleEntry> out = new ArrayList<>();
+ if (node.isTextual()) {
+ out.add(new ScheduleEntry(0L, normHash(node.asText().trim(), source)));
+ } else if (node.isArray()) {
+ if (node.isEmpty()) {
+ throw new RegistryConfigException(
+ "AERE-PQC-REG-SCHED-01",
+ "AERE PQC GENESIS-BINDING: "
+ + source
+ + " is an EMPTY array. Refusing to start (fail-closed): an empty schedule is not "
+ + "the same statement as an absent one, and guessing which was meant is exactly how "
+ + "a guard ends up switched off without anybody deciding to switch it off. Either "
+ + "remove the key entirely, or give it at least one {block, hash} entry.");
+ }
+ long previous = Long.MIN_VALUE;
+ for (final JsonNode e : node) {
+ if (!e.isObject() || !e.has("block") || !e.has("hash")) {
+ throw new RegistryConfigException(
+ "AERE-PQC-REG-SCHED-02",
+ "AERE PQC GENESIS-BINDING: "
+ + source
+ + " entry "
+ + e
+ + " is not an object with 'block' and 'hash'. Refusing to start (fail-closed).");
+ }
+ final long block = e.get("block").asLong(-1L);
+ if (block < 0) {
+ throw new RegistryConfigException(
+ "AERE-PQC-REG-SCHED-03",
+ "AERE PQC GENESIS-BINDING: "
+ + source
+ + " has a negative or unparseable block in "
+ + e
+ + ". Refusing to start (fail-closed).");
+ }
+ if (block <= previous) {
+ throw new RegistryConfigException(
+ "AERE-PQC-REG-SCHED-04",
+ "AERE PQC GENESIS-BINDING: "
+ + source
+ + " blocks are not STRICTLY INCREASING ("
+ + previous
+ + " then "
+ + block
+ + "). Refusing to start (fail-closed): with a repeated or out-of-order height "
+ + "two nodes reading the same file could pick different active entries, which is "
+ + "the very split this binding exists to prevent.");
+ }
+ previous = block;
+ out.add(new ScheduleEntry(block, normHash(e.get("hash").asText(), source)));
+ }
+ } else {
+ throw new RegistryConfigException(
+ "AERE-PQC-REG-SCHED-05",
+ "AERE PQC GENESIS-BINDING: "
+ + source
+ + " must be a 0x-prefixed 32-byte hash or an array of {block, hash}; found "
+ + node.getNodeType()
+ + ". Refusing to start (fail-closed).");
+ }
+ return new Schedule(out, source);
+ }
+
+ /**
+ * The registry hash required at a given height, or empty when no binding is active there.
+ *
+ * <p>Public because the startup guard is not the only place this belongs: a per-block header rule
+ * enforcing the same binding on the consensus path would call exactly this.
+ *
+ * @param schedule the parsed schedule
+ * @param blockNumber the height
+ * @return the active entry, or empty
+ */
+ public static Optional<ScheduleEntry> requiredHashAt(
+ final Schedule schedule, final long blockNumber) {
+ ScheduleEntry active = null;
+ for (final ScheduleEntry e : schedule.entries) {
+ if (e.block <= blockNumber) {
+ active = e;
+ } else {
+ break;
+ }
+ }
+ return Optional.ofNullable(active);
+ }
+
+ /** The first scheduled entry strictly after the given height, or empty. */
+ private static Optional<ScheduleEntry> nextAfter(final Schedule schedule, final long blockNumber) {
+ for (final ScheduleEntry e : schedule.entries) {
+ if (e.block > blockNumber) {
+ return Optional.of(e);
+ }
+ }
+ return Optional.empty();
+ }
+
+ /**
+ * Whether the given registry satisfies the binding active at a height. Never throws.
+ *
+ * @param schedule the parsed schedule
+ * @param registry the loaded registry, may be null
+ * @param blockNumber the height
+ * @param chainId the chain id
+ * @return true iff no binding is active there, or the registry hashes to the required value
+ */
+ public static boolean matchesAt(
+ final Schedule schedule,
+ final Registry registry,
+ final long blockNumber,
+ final long chainId) {
+ final Optional<ScheduleEntry> req = requiredHashAt(schedule, blockNumber);
+ if (req.isEmpty()) {
+ return true;
+ }
+ if (registry == null) {
+ return false;
+ }
+ return hashFor(registry, chainId).equalsIgnoreCase(req.get().hash);
+ }
+
+ // ===================================================================================
+ // HEIGHT SCHEDULE: the whole scheduled history, not only the entry in force at the head
+ // ===================================================================================
+
+ /**
+ * HEIGHT SCHEDULE. The registries a node holds, indexed by the SCHEDULE ENTRY each one satisfies.
+ *
+ * <p>THE DEFECT THIS EXISTS TO CLOSE, measured and not assumed. {@code config.pqRegistryHash} is a
+ * schedule, and a second entry is how a key rotation or a revocation is expressed. Enforcement,
+ * however, took exactly ONE loaded registry: {@link #matchesAt(Schedule, Registry, long, long)}
+ * resolves the entry active at a height and compares it against that single registry. After one
+ * rotation at H2 there are two intervals with two different required hashes, and one file can
+ * satisfy at most one of them. Measured on the real classes in {@code PqRegistryRotationTest}:
+ * a node holding the pre-rotation registry was refused at {12100000, 12100001}, a node holding the
+ * post-rotation registry was refused at {12000000, 12000001, 12099999}, and there was no third
+ * choice.
+ *
+ * <p>That is not confined to the rotation moment. {@code PqRegistryBindingRule} is DETACHED, so it
+ * runs on the header-download path, and {@code PqAnchorSyncModeGuard} refuses to start an armed
+ * node in anything but FULL sync. Every node acquiring history therefore validates the interval
+ * before the rotation AND has to reach the head. Using the rotation mechanism once made the chain
+ * permanently unjoinable, which means the mechanism was expressible and was not usable.
+ *
+ * <p>THE SHAPE OF THE REPAIR, and where it was learned. Cosmos ADR-016 keeps a rotation HISTORY
+ * mapping height to the consensus key in force there, precisely so that blocks signed under an
+ * older key stay verifiable, and bounds that history by the unbonding period. We keep the same
+ * mapping and we do NOT bound it: chain 2800 has no unbonding period and a node syncing from
+ * genesis must verify every block ever produced, so every entry ever scheduled stays loadable
+ * forever. Besu's own answer to the same class of question, the genesis {@code transitions} list,
+ * has the same shape: the value in force at a height is fixed by the height, lives in a document
+ * every node already holds byte-identically, and is never re-read from mutable state.
+ *
+ * <p>A set that does not cover every scheduled entry is NOT silently degraded. {@link
+ * #uncoveredEntryBlocks()} names exactly which heights have no registry, so the startup guard can
+ * refuse with the missing heights in hand instead of the operator discovering them one header at
+ * a time.
+ */
+ public static final class RegistrySet {
+ private final List<Registry> loaded;
+ private final List<Long> entryBlocks; // ascending, one per COVERED schedule entry
+ private final List<Registry> entryRegistries; // parallel to entryBlocks
+ private final List<Long> uncovered; // schedule entry blocks with no matching registry
+ private final List<Misbound> misbound; // signed height did not match the scheduled one
+
+ RegistrySet(
+ final List<Registry> loaded,
+ final List<Long> entryBlocks,
+ final List<Registry> entryRegistries,
+ final List<Long> uncovered) {
+ this(loaded, entryBlocks, entryRegistries, uncovered, List.of());
+ }
+
+ RegistrySet(
+ final List<Registry> loaded,
+ final List<Long> entryBlocks,
+ final List<Registry> entryRegistries,
+ final List<Long> uncovered,
+ final List<Misbound> misbound) {
+ this.loaded = loaded;
+ this.entryBlocks = entryBlocks;
+ this.entryRegistries = entryRegistries;
+ this.uncovered = uncovered;
+ this.misbound = misbound;
+ }
+
+ /**
+ * Every registry this node was given, in the order it was given them.
+ *
+ * @return an unmodifiable list
+ */
+ public List<Registry> loaded() {
+ return List.copyOf(loaded);
+ }
+
+ /**
+ * The registry this node SIGNS with, i.e. the first one it was given. Every other held file is
+ * history: loadable so that old blocks stay verifiable, never the one this node writes under.
+ *
+ * @return the primary registry, or null when this node holds none
+ */
+ public Registry primary() {
+ return loaded.isEmpty() ? null : loaded.get(0);
+ }
+
+ /**
+ * SIGNED-HEIGHT CHECK. The schedule entries a held registry reproduces BY HASH and yet was not signed for.
+ *
+ * @return an unmodifiable list, empty when every covered entry is coherently signed
+ */
+ public List<Misbound> misbound() {
+ return List.copyOf(misbound);
+ }
+
+ /**
+ * The scheduled heights this node CAN answer for.
+ *
+ * @return an unmodifiable ascending list
+ */
+ public List<Long> coveredEntryBlocks() {
+ return List.copyOf(entryBlocks);
+ }
+
+ /**
+ * How many registries this node holds.
+ *
+ * @return the count
+ */
+ public int count() {
+ return loaded.size();
+ }
+
+ /**
+ * The scheduled heights for which this node holds NO registry at all.
+ *
+ * @return an unmodifiable ascending list, empty when the whole schedule is covered
+ */
+ public List<Long> uncoveredEntryBlocks() {
+ return List.copyOf(uncovered);
+ }
+
+ /**
+ * Whether this node can satisfy every entry the schedule contains.
+ *
+ * @return true iff nothing is uncovered
+ */
+ public boolean coversWholeSchedule() {
+ return uncovered.isEmpty();
+ }
+
+ /** The registry bound to a schedule entry, or null when that entry is uncovered. */
+ Registry forEntryBlock(final long entryBlock) {
+ for (int i = 0; i < entryBlocks.size(); i++) {
+ if (entryBlocks.get(i).longValue() == entryBlock) {
+ return entryRegistries.get(i);
+ }
+ }
+ return null;
+ }
+
+ @Override
+ public String toString() {
+ return "RegistrySet{held="
+ + loaded.size()
+ + ", covered="
+ + entryBlocks
+ + ", uncovered="
+ + uncovered
+ + '}';
+ }
+ }
+
+ /**
+ * HEIGHT SCHEDULE. Bind a set of loaded registries to the schedule by canonical hash.
+ *
+ * <p>The binding is by HASH and never by order or by file name: a registry covers the entry whose
+ * required hash it reproduces, and nothing else makes it cover anything. An operator therefore
+ * cannot mis-assign a file, only fail to supply one, and that failure is named.
+ *
+ * @param schedule the parsed schedule
+ * @param registries every registry this node holds; nulls are ignored
+ * @param chainId the chain id, part of the canonical pre-image
+ * @return the height-resolved set
+ */
+ public static RegistrySet buildSet(
+ final Schedule schedule, final List<Registry> registries, final long chainId) {
+ final List<Registry> held = new ArrayList<>();
+ for (final Registry r : registries) {
+ if (r != null) {
+ held.add(r);
+ }
+ }
+ final List<Long> blocks = new ArrayList<>();
+ final List<Registry> mapped = new ArrayList<>();
+ final List<Long> uncovered = new ArrayList<>();
+ final List<Misbound> misbound = new ArrayList<>();
+ for (final ScheduleEntry e : schedule.entries) {
+ Registry match = null;
+ for (final Registry r : held) {
+ if (!hashFor(r, chainId).equalsIgnoreCase(e.hash)) {
+ continue;
+ }
+ // AERE SIGNED-HEIGHT CHECK (2026-08-06). THE LINK THAT WAS NEVER DRAWN. Both numbers have been in this
+ // lexical scope since the height schedule was written and they were never put on the same expression.
+ //
+ // bindHeight is the height every row's possession proof and every row's validator claim
+ // were SIGNED OVER (PqRegistryBinding.bindingPreimage). e.block is the height from which
+ // genesis puts this registry IN FORCE. They are supposed to be the same number, and the
+ // hash cannot notice when they are not: bindHeight is INSIDE the v2 pre-image, so a file
+ // signed for 40 and scheduled from 70 reproduces its own hash perfectly. Measured
+ // 2026-08-06 on a network of seven: all 7 started, the chain ran to head 95, agreement was
+ // 7 of 7, zero errors and zero refusals - and the offline tool, on the same files, said RED.
+ // The node and the tool disagreed and nothing put them face to face.
+ //
+ // WHAT THAT BUYS AN OPERATOR WHO IS NOT SUPPOSED TO HAVE IT: moving the activation day
+ // costs 14 fresh signatures if this is checked, and ZERO if it is not. The seven
+ // validators' agreement on a height is only an agreement if something refuses the heights
+ // they did not sign.
+ if (r.proofBound() && r.bindHeight() != e.block) {
+ misbound.add(new Misbound(e.block, e.hash, r.sourcePath(), r.bindHeight()));
+ continue;
+ }
+ match = r;
+ break;
+ }
+ if (match == null) {
+ uncovered.add(e.block);
+ } else {
+ blocks.add(e.block);
+ mapped.add(match);
+ }
+ }
+ return new RegistrySet(held, blocks, mapped, uncovered, misbound);
+ }
+
+ /**
+ * SIGNED-HEIGHT CHECK. One schedule entry whose required hash a held registry reproduces, and whose height that
+ * registry's binding proofs were not signed over.
+ *
+ * @param entryBlock the height genesis puts the registry in force from
+ * @param entryHash the hash genesis requires there
+ * @param registryPath the file that reproduces that hash
+ * @param signedHeight the height the file's proofs were actually signed over
+ */
+ public record Misbound(
+ long entryBlock, String entryHash, String registryPath, long signedHeight) {}
+
+ /**
+ * HEIGHT SCHEDULE. Load every registry named and bind the result to the schedule.
+ *
+ * @param schedule the parsed schedule
+ * @param paths the registry files this node holds
+ * @param chainId the chain id
+ * @return the height-resolved set
+ * @throws RegistryConfigException when any file is unreadable or ambiguous
+ */
+ public static RegistrySet loadSet(
+ final Schedule schedule, final List<Path> paths, final long chainId) {
+ final List<Registry> registries = new ArrayList<>();
+ for (final Path p : paths) {
+ registries.add(loadAuto(p));
+ }
+ return buildSet(schedule, registries, chainId);
+ }
+
+ /**
+ * HEIGHT SCHEDULE. Split a comma-separated list of registry paths. Blank elements are dropped; a null or
+ * blank list gives an empty result rather than a path named "".
+ *
+ * @param raw the configured value, may be null
+ * @return the paths, in the order written
+ */
+ public static List<Path> parseRegistryPaths(final String raw) {
+ final List<Path> out = new ArrayList<>();
+ if (raw == null || raw.isBlank()) {
+ return out;
+ }
+ for (final String piece : Splitter.on(',').split(raw)) {
+ final String trimmed = piece.trim();
+ if (!trimmed.isEmpty()) {
+ out.add(Path.of(trimmed));
+ }
+ }
+ return out;
+ }
+
+ /**
+ * HEIGHT SCHEDULE. The registry in force at a height: the one bound to the schedule entry active there.
+ *
+ * <p>This is the function Falcon verification needs. A certificate in a block at height h was
+ * produced under the key set the chain required at h, so it must be checked against that key set
+ * and not against whatever the node happens to hold for the head.
+ *
+ * @param schedule the parsed schedule
+ * @param set the registries this node holds, may be null
+ * @param blockNumber the height
+ * @return the registry in force there, or empty when no binding is active or none is held
+ */
+ public static Optional<Registry> registryAt(
+ final Schedule schedule, final RegistrySet set, final long blockNumber) {
+ final Optional<ScheduleEntry> required = requiredHashAt(schedule, blockNumber);
+ if (required.isEmpty() || set == null) {
+ return Optional.empty();
+ }
+ return Optional.ofNullable(set.forEntryBlock(required.get().block));
+ }
+
+ /**
+ * HEIGHT SCHEDULE. Whether the registries this node holds satisfy the binding active at a height.
+ *
+ * <p>The hash is RECOMPUTED here rather than trusted from the binding built earlier, so that this
+ * answer is a positive proof about the bytes the node is holding right now and not a restatement
+ * of an earlier decision.
+ *
+ * @param schedule the parsed schedule
+ * @param set the registries this node holds, may be null
+ * @param blockNumber the height
+ * @param chainId the chain id
+ * @return true iff no binding is active there, or a held registry reproduces the required hash
+ */
+ public static boolean matchesAt(
+ final Schedule schedule,
+ final RegistrySet set,
+ final long blockNumber,
+ final long chainId) {
+ final Optional<ScheduleEntry> required = requiredHashAt(schedule, blockNumber);
+ if (required.isEmpty()) {
+ return true;
+ }
+ if (set == null) {
+ return false;
+ }
+ final Registry r = set.forEntryBlock(required.get().block);
+ return r != null && hashFor(r, chainId).equalsIgnoreCase(required.get().hash);
+ }
+
+ // ===================================================================================
+ // The startup guard
+ // ===================================================================================
+
+ /** Outcome of the startup guard. Exposed so tests and diagnostics can assert on it. */
+ public enum GateState {
+ /** No {@code pqRegistryHash} in genesis: nothing is enforced, exactly as before this change. */
+ NOT_ENFORCED_NO_SCHEDULE,
+ /** A schedule exists but its first height is still ahead of this node's chain head. */
+ NOT_ENFORCED_BELOW_FIRST_HEIGHT,
+ /** A binding is active at this height and the loaded registry satisfies it. */
+ MATCH,
+ /**
+ * AERE OPTIUNI-URGENTA: a binding is active, the registry does NOT satisfy it, and an operator
+ * explicitly overrode the refusal. The node is running and is NOT verifying certificates.
+ */
+ OVERRIDDEN_UNSAFE
+ }
+
+ /**
+ * Refusal to start, or refusal to accept a registry. Carries a stable machine-readable code so
+ * fleet tooling can match on it without parsing prose.
+ */
+ public static final class RegistryConfigException extends IllegalStateException {
+
+ private static final long serialVersionUID = 1L;
+
+ private final String code;
+
+ RegistryConfigException(final String code, final String message) {
+ super("[" + code + "] " + message);
+ this.code = code;
+ }
+
+ /**
+ * The stable code, e.g. {@code AERE-PQC-REG-MISMATCH-01}.
+ *
+ * @return the code
+ */
+ public String code() {
+ return code;
+ }
+ }
+
+ /**
+ * THE GUARD. Verify the registry this node actually loaded against the hash the genesis schedule
+ * makes active at the NEXT height this node will be asked to validate, chainHead + 1, and REFUSE
+ * TO START on a mismatch.
+ *
+ * <p>AERE CONSENS-LENS (2026-08-02): chainHead + 1, and not chainHead. That one block of
+ * difference was a permanent chain stop, measured on real nodes; the measurement and the
+ * reasoning are on the line that computes the height.
+ *
+ * <p>Call once during startup, from a point where a chain head exists and BEFORE the network and
+ * the consensus state machine are started, so that a refusal is a clean refusal to start rather
+ * than a mid-flight abort. The same place {@code verifyAttachHeightAgainstChainHeadOrAbort} is
+ * called from is correct.
+ *
+ * @param schedule the parsed {@code config.pqRegistryHash} schedule
+ * @param registry the registry this node loaded, or null if it loaded none
+ * @param chainHeadNumber this node's local chain head at startup
+ * @param chainId the chain id, which is part of the canonical pre-image
+ * @return the gate state on success
+ * @throws RegistryConfigException when a binding is active and the registry does not satisfy it
+ */
+ public static GateState verifyOrAbort(
+ final Schedule schedule,
+ final Registry registry,
+ final long chainHeadNumber,
+ final long chainId) {
+ return verifyOrAbort(
+ schedule,
+ buildSet(schedule, registry == null ? List.of() : List.of(registry), chainId),
+ chainHeadNumber,
+ chainId);
+ }
+
+ /**
+ * AERE HELD-SET SCOPE (2026-08-06). THE SAME GUARD, ASKED OF EVERY REGISTRY THIS NODE HOLDS.
+ *
+ * <p>WHY THIS OVERLOAD HAD TO EXIST, and it is not tidiness. The height schedule gave a node a HISTORY of
+ * registry files, one per rotation the chain has ever performed, precisely so that a node can
+ * validate blocks produced under a retired key set. The startup guard was never told. It compared
+ * the schedule against ONE registry, the one named by {@code registrySourcePath}, and the history
+ * was read 41 lines further down - after the refusal had already been thrown. So the order of two
+ * blocks of code WAS the defect: after the first rotation, a node whose primary file is the
+ * post-rotation registry and whose history holds the pre-rotation one is correctly configured, and
+ * was refused to start with {@code AERE-PQC-REG-MISMATCH-01}. Re-confirmed 2026-08-06 with head at
+ * 2554. The consequence, stated plainly: after the first key rotation, no node in the fleet could
+ * be restarted with its own correct configuration without hand intervention.
+ *
+ * <p>The question this asks is unchanged - "is the binding the chain requires at the next height I
+ * will validate satisfied by something I hold" - only the word "something" is now honest.
+ *
+ * @param schedule the parsed {@code config.pqRegistryHash} schedule
+ * @param set every registry this node holds, resolved against the schedule
+ * @param chainHeadNumber this node's local chain head at startup
+ * @param chainId the chain id, which is part of the canonical pre-image
+ * @return the gate state on success
+ * @throws RegistryConfigException when a binding is active and nothing held satisfies it
+ */
+ public static GateState verifyOrAbort(
+ final Schedule schedule,
+ final RegistrySet set,
+ final long chainHeadNumber,
+ final long chainId) {
+
+ // AERE ROW BINDING. The proofs are signed over a chainId the FILE declares. A registry lifted from
+ // the scratch chain 442807 carries proofs that verify perfectly among themselves - they are
+ // internally consistent, just for another chain - and would otherwise pass. Fail-closed here,
+ // where the node's real chain id is known and the file's is not yet trusted.
+ //
+ // AERE HELD-SET SCOPE (2026-08-06): over EVERY held file, not only the primary. A history file lifted from
+ // the scratch chain is exactly as dangerous as a primary one - it is the file that answers for
+ // an interval of history - and before this it was never asked.
+ for (final Registry r : set.loaded()) {
+ if (r.proofBound() && r.declaredChainId() != chainId) {
+ throw new RegistryConfigException(
+ "AERE-PQC-REG-BIND-07",
+ "AERE PQC ROW-BINDING: registry '"
+ + r.sourcePath()
+ + "' declares chainId="
+ + r.declaredChainId()
+ + " and its binding proofs are signed over that value, but this node is on chainId="
+ + chainId
+ + ". Refusing to start (fail-closed): the proofs in this file are valid, and they "
+ + "are valid for a different chain.");
+ }
+ }
+
+ final Registry registry = set.primary();
+
+ if (!schedule.enforced()) {
+ LOG.warn(
+ "AERE PQC GENESIS-BINDING: pqRegistryHash is NOT CONFIGURED ({}), so the Falcon registry is NOT bound "
+ + "to consensus on this node. The registry currently loaded is {} ({}, {} entries, "
+ + "address-bound={}), canonical hash 0x{}. Two nodes holding DIFFERENT registry files "
+ + "will disagree about which public key validator index i has, so the same "
+ + "certificate can verify on one and fail on the other. Put that hash into genesis as "
+ + "config.pqRegistryHash to close this.",
+ schedule.source(),
+ registry == null ? "(none)" : registry.sourcePath(),
+ registry == null ? SourceKind.NONE : registry.kind(),
+ registry == null ? 0 : registry.count(),
+ registry != null && registry.addressBound(),
+ // GENESIS BINDING, measured on a fleet of seven on 2026-08-06: this printed hashV1 next
+ // to the text "put this in config.pqRegistryHash", while THE GATE compares hashFor,
+ // which for a registry carrying proofs is hashV2. With the printed value put into
+ // genesis, all seven nodes start, all seven report the registry loaded, and THE CHAIN
+ // STOPS AT H-1. The guard shouts NOT CORRECTLY STAGED, so it is not a silent halt, but
+ // the operator who follows the node's own instruction halts the fleet. A wrong
+ // instruction is more dangerous than no instruction at all.
+ registry == null ? "(no registry)" : hashFor(registry, chainId));
+ return GateState.NOT_ENFORCED_NO_SCHEDULE;
+ }
+
+ // AERE SIGNED-HEIGHT CHECK (2026-08-06). THE SILENT DEFERRAL. Placed HERE, below the not-enforced exit above,
+ // and that position is a rule and not a preference: on chain 2800 config.pqRegistryHash does not
+ // exist, schedule.enforced() is false, and the return above is the first executable statement
+ // this guard reaches. Nothing new is ever put above it.
+ //
+ // WHY REFUSING IS RIGHT AND NOT MERELY SAFE. A misbound entry is not a missing file, which an
+ // operator can legitimately have; it is a file the operator DOES hold, which reproduces exactly
+ // the hash genesis names, and which the fleet signed for a DIFFERENT height. There is no honest
+ // reading of it. Refusing at startup also catches it before the height arrives, which is the
+ // whole point: an activation day moved under signed proofs is invisible until the day itself.
+ if (!set.misbound().isEmpty()) {
+ final StringBuilder rows = new StringBuilder();
+ for (final Misbound m : set.misbound()) {
+ rows.append(" genesis puts registry '")
+ .append(m.registryPath())
+ .append("' in force FROM BLOCK ")
+ .append(m.entryBlock())
+ .append(", and every binding proof in that file is signed over bindHeight=")
+ .append(m.signedHeight())
+ .append(" (entry hash 0x")
+ .append(m.entryHash())
+ .append(")\n");
+ }
+ throw new RegistryConfigException(
+ "AERE-PQC-REG-BIND-08",
+ "AERE PQC SIGNED-HEIGHT: REFUSING TO START - a registry this node holds is scheduled at a height "
+ + "its validators never signed for.\n"
+ + " FIELD: 'bindHeight', inside the registry file, versus 'block' of the matching "
+ + "entry of config.pqRegistryHash in genesis ("
+ + schedule.source()
+ + ").\n"
+ + " READ:\n"
+ + rows
+ + " EXPECTED: bindHeight and the schedule entry's block are THE SAME NUMBER. Every "
+ + "row of a v2 registry carries a Falcon possession proof and an ECDSA claim by that "
+ + "validator's own consensus key, and both signatures cover the bind height. A "
+ + "registry in force from block B is every one of those validators stating that B is "
+ + "the height they agreed to.\n"
+ + " WHY THE HASH DID NOT CATCH THIS: bindHeight is INSIDE the v2 pre-image, so the "
+ + "file reproduces its own hash perfectly no matter which height genesis schedules it "
+ + "at. Hash-against-hash can never see this; only these two numbers side by side can. "
+ + "Measured 2026-08-06 on a network of seven: all seven started, the chain ran, "
+ + "agreement was 7 of 7, zero errors, zero refusals.\n"
+ + " WHAT IT WOULD HAVE COST AN ATTACKER OR A HURRIED OPERATOR: moving the activation "
+ + "day costs 14 fresh validator signatures when this is checked, and zero when it is "
+ + "not.\n"
+ + " REMEDY, and there are exactly two honest ones. EITHER put the schedule entry back "
+ + "to the height the file was signed for, on every node, because genesis is the "
+ + "document all seven hold byte-identically. OR re-run the key ceremony and have every "
+ + "validator re-sign its row for the new height, then set config.pqRegistryHash to the "
+ + "NEW hash of the re-signed file - it will be a different hash, because the height is "
+ + "inside it.\n"
+ + " DO NOT edit bindHeight in the file. The proofs are over it; the file would stop "
+ + "loading at all (AERE-PQC-REG-BIND-03).");
+ }
+
+ // AERE CONSENS-LENS (2026-08-02). THE HEIGHT THIS GUARD ASKS ABOUT IS chainHead + 1, and the
+ // one-block difference was a PERMANENT CHAIN STOP, measured on real nodes before this line was
+ // changed. With a schedule of two entries and a rotation at height X:
+ // - every node runs to X-1 and REFUSES header X (PqRegistryBindingRule,
+ // AERE-PQC-REG-BLOCK-01). Measured: head stopped at 11 with a rotation at 12.
+ // - the operator installs the registry the schedule requires FROM X and restarts. This guard,
+ // asked about the HEAD X-1, demanded the OLD hash and refused to start with
+ // AERE-PQC-REG-MISMATCH-01, exit 2. Measured.
+ // - the operator puts the OLD registry back: the node starts and still cannot pass X.
+ // Measured.
+ // Old registry: starts, cannot advance. New registry: cannot start. There was no third file,
+ // and the only exit measured was to set the emergency bypass on every node, i.e. to switch
+ // the genesis binding off across the whole fleet in order to cross a PLANNED rotation.
+ //
+ // chainHead + 1 is the question the node can actually act on: the only header it will be
+ // offered next is chainHead + 1, and PqRegistryBindingRule judges that header against the entry
+ // active AT THAT HEIGHT. Asking the same question here means the startup guard and the
+ // per-block rule can no longer disagree, which is the property that was missing. A node halted
+ // at X-1 now starts with the registry required from X. A node staged EARLY is refused at
+ // startup, and that is the honest verdict: it could not validate the blocks between its head
+ // and the rotation, and refusing at startup is better than stalling on the first header.
+ final long validationHeight =
+ chainHeadNumber == Long.MAX_VALUE ? chainHeadNumber : chainHeadNumber + 1;
+ final Optional<ScheduleEntry> active = requiredHashAt(schedule, validationHeight);
+
+ if (active.isEmpty()) {
+ final ScheduleEntry first = schedule.entries.get(0);
+ // AERE HELD-SET SCOPE: ask the SET, not only the primary. A node staged for the activation may already
+ // hold the activation registry as history while still signing under the current one.
+ final Registry staged = set.forEntryBlock(first.block());
+ final String computed = registry == null ? null : hashFor(registry, chainId);
+ if (staged != null) {
+ LOG.info(
+ "AERE PQC GENESIS-BINDING: registry binding is scheduled to start at block {} and this node's chain "
+ + "head is {}, so nothing is enforced yet. The registry already loaded ({}, {} "
+ + "entries) ALREADY MATCHES the hash required from block {}: 0x{}. This node is "
+ + "correctly staged for the activation.",
+ first.block(),
+ chainHeadNumber,
+ staged.sourcePath(),
+ staged.count(),
+ first.block(),
+ hashFor(staged, chainId));
+ } else {
+ LOG.error(
+ "AERE PQC GENESIS-BINDING: registry binding starts at block {} and this node's chain head is {}, so "
+ + "nothing is enforced yet AND THIS NODE IS NOT CORRECTLY STAGED. Required from "
+ + "block {}: 0x{}. Loaded here: {}. This node will run normally and will then "
+ + "REFUSE the header at block {} (AERE-PQC-REG-BLOCK-01) and stop there. Install "
+ + "the registry named above AT THAT HALT and restart. Do NOT install it earlier: "
+ + "from the moment it is installed this node can no longer validate the blocks "
+ + "between here and the rotation, and it will be refused at startup. NOTE the "
+ + "honest limitation: this guard runs at STARTUP only, so it will NOT stop this "
+ + "node while it keeps running.",
+ first.block(),
+ chainHeadNumber,
+ first.block(),
+ first.hash(),
+ computed == null ? "NO REGISTRY LOADED AT ALL" : "0x" + computed,
+ first.block());
+ }
+ return GateState.NOT_ENFORCED_BELOW_FIRST_HEIGHT;
+ }
+
+ final ScheduleEntry required = active.get();
+
+ if (set.count() == 0) {
+ throw new RegistryConfigException(
+ "AERE-PQC-REG-MISMATCH-02",
+ "AERE PQC GENESIS-BINDING: REFUSING TO START.\n"
+ + " EXPECTED: a Falcon validator registry whose canonical hash is\n"
+ + " 0x"
+ + required.hash()
+ + "\n"
+ + " required from block "
+ + required.block()
+ + " by genesis config.pqRegistryHash ("
+ + schedule.source()
+ + ").\n"
+ + " FOUND: NO REGISTRY AT ALL. None of the three registry sources is configured.\n"
+ + " Searched, in this order:\n"
+ + " 1) aere.falcon.genesis / AERE_FALCON_GENESIS (genesis manifest)\n"
+ + " 2) aere.falcon.manifest / AERE_FALCON_MANIFEST (late-anchor)\n"
+ + " 3) aere.falcon.registry / AERE_FALCON_REGISTRY (legacy properties)\n"
+ + " CHAIN HEAD: "
+ + chainHeadNumber
+ + " NEXT HEIGHT TO VALIDATE: "
+ + validationHeight
+ + " (the binding above is the one active AT THAT HEIGHT)\n"
+ + " CHAIN ID: "
+ + chainId
+ + "\n"
+ + " WHY THIS IS FATAL: from block "
+ + required.block()
+ + " this chain states which registry every node must hold. A node with no registry "
+ + "verifies no Falcon seal at all, so it would sit in the validator set reporting "
+ + "itself healthy while contributing nothing to the post-quantum layer.\n"
+ + " REMEDY: point one of the three properties above at the registry file whose hash "
+ + "is 0x"
+ + required.hash()
+ + ", then restart. Compute a file's hash with:\n"
+ + " java -cp <besu lib> "
+ + "org.hyperledger.besu.consensus.common.bft.tools.PqRegistryHashTool "
+ + "<file> --chain-id "
+ + chainId);
+ }
+
+ final String computed = hashFor(registry, chainId);
+ // AERE HELD-SET SCOPE (2026-08-06): the binding is satisfied by ANY file this node holds for this entry,
+ // not only by the one it signs with. Before this line the answer came from the primary registry
+ // alone, and after the first rotation the primary is by definition NOT the file that answers for
+ // the interval below the rotation.
+ final Registry bound = set.forEntryBlock(required.block());
+ if (bound != null) {
+ LOG.info(
+ "AERE PQC GENESIS-BINDING: registry binding SATISFIED. Loaded {} ({}, {} entries, address-bound={}); "
+ + "canonical hash 0x{} equals the hash required from block {} by genesis "
+ + "config.pqRegistryHash, read from [{}]. Chain head {}, chainId {}. This node holds "
+ + "{} registry file(s) in total. {}",
+ bound.sourcePath(),
+ bound.kind(),
+ bound.count(),
+ bound.addressBound(),
+ hashFor(bound, chainId),
+ required.block(),
+ // AERE GENESIS BINDING: naming the PROVENANCE of the schedule is not decoration. The whole defect
+ // class is "a value that came from somewhere nobody checked", so a line that says the
+ // binding is satisfied without saying what it was read from asserts more than it knows.
+ schedule.source(),
+ chainHeadNumber,
+ chainId,
+ set.count(),
+ nextAfter(schedule, validationHeight)
+ .map(
+ n ->
+ "NEXT ROTATION: from block "
+ + n.block()
+ + " this node must hold a registry hashing to 0x"
+ + n.hash()
+ + (set.forEntryBlock(n.block()) != null
+ ? " - already satisfied by a file this node holds."
+ : " - NOTHING this node holds satisfies it. This node will run"
+ + " to block "
+ + (n.block() - 1)
+ + ", REFUSE the header at "
+ + n.block()
+ + " (AERE-PQC-REG-BLOCK-01) and stop there. Install the new file"
+ + " and name it in "
+ + "aere.falcon.registry.history, then restart."))
+ .orElse("No further rotation is scheduled."));
+ return GateState.MATCH;
+ }
+
+ throw new RegistryConfigException(
+ "AERE-PQC-REG-MISMATCH-01",
+ "AERE PQC GENESIS-BINDING: REFUSING TO START - the Falcon validator registry on this node is NOT the one "
+ + "this chain requires.\n"
+ + " EXPECTED hash: 0x"
+ + required.hash()
+ + "\n"
+ + " required from block "
+ + required.block()
+ + " by genesis config.pqRegistryHash ("
+ + schedule.source()
+ + ")\n"
+ + " FOUND hash: 0x"
+ + computed
+ + "\n"
+ + " CHAIN HEAD: "
+ + chainHeadNumber
+ + " NEXT HEIGHT TO VALIDATE: "
+ + validationHeight
+ + " (the binding above is the one active AT THAT HEIGHT)\n"
+ + " CHAIN ID: "
+ + chainId
+ + " (chainId is part of the hashed pre-image)\n"
+ + " FULL SCHEDULE: "
+ + schedule
+ + "\n"
+ + describeHeld(set, chainId)
+ + describeRegistry(registry, chainId)
+ + " WHY THIS IS FATAL: this file decides which Falcon public key belongs to validator "
+ + "index i. A node holding a different file disagrees with its peers about who index i "
+ + "is, so the SAME certificate verifies on one node and fails on another and the "
+ + "network splits with no attacker involved. Starting with the wrong file is strictly "
+ + "worse than not starting.\n"
+ + " HOW TO FIND THE WRONG ROW: every node prints the per-index fingerprints above. "
+ + "Compare them with a node that starts cleanly; the first index that differs is the "
+ + "row that was changed. The fingerprints are keccak digests, so they can be pasted "
+ + "into a chat without moving key material.\n"
+ + " REMEDY: this node must HOLD a file whose canonical hash is 0x"
+ + required.hash()
+ + ". It does not have to be the file this node signs with: name it in "
+ + "aere.falcon.registry.history (comma-separated) and every file listed there answers "
+ + "for the interval the schedule gives it. That list is never pruned - one file per "
+ + "rotation the chain has ever performed, kept forever. Then restart. To check a "
+ + "candidate file before restarting:\n"
+ + " java -cp <besu lib> "
+ + "org.hyperledger.besu.consensus.common.bft.tools.PqRegistryHashTool "
+ + "<file> --chain-id "
+ + chainId
+ + "\n"
+ + " DO NOT 'fix' this by editing genesis. The genesis value is what the rest of the "
+ + "fleet agrees on; changing it on this node alone makes this node the odd one out.");
+ }
+
+ /**
+ * AERE HELD-SET SCOPE. Every registry file this node holds and which scheduled interval each one answers for.
+ * Without this an operator reading {@code MISMATCH-01} cannot tell "I gave this node one file and
+ * it is the wrong one" from "I gave it four and none covers this height", which are different
+ * mistakes with different fixes.
+ */
+ private static String describeHeld(final RegistrySet set, final long chainId) {
+ final StringBuilder b = new StringBuilder();
+ b.append(" REGISTRY FILES THIS NODE HOLDS: ").append(set.count()).append('\n');
+ for (final Registry r : set.loaded()) {
+ final String h = hashFor(r, chainId);
+ Long covers = null;
+ for (final Long blk : set.coveredEntryBlocks()) {
+ if (set.forEntryBlock(blk) == r) {
+ covers = blk;
+ break;
+ }
+ }
+ b.append(" ")
+ .append(r.sourcePath())
+ .append(" hash=0x")
+ .append(h)
+ .append(" bindHeight=")
+ .append(r.proofBound() ? Long.toString(r.bindHeight()) : "(v1, unsigned)")
+ .append(" covers=")
+ .append(covers == null ? "NO SCHEDULED ENTRY" : "from block " + covers)
+ .append('\n');
+ }
+ if (!set.uncoveredEntryBlocks().isEmpty()) {
+ b.append(" scheduled heights covered by NOTHING held here: ")
+ .append(set.uncoveredEntryBlocks())
+ .append('\n');
+ }
+ return b.toString();
+ }
+
+ /** The "what I found" block of a refusal: source, shape, both hashes, per-index fingerprints. */
+ private static String describeRegistry(final Registry registry, final long chainId) {
+ final StringBuilder b = new StringBuilder();
+ b.append(" REGISTRY ACTUALLY LOADED:\n");
+ b.append(" source : ").append(registry.sourcePath()).append('\n');
+ b.append(" source kind : ").append(registry.kind()).append('\n');
+ b.append(" entries : ").append(registry.count()).append('\n');
+ b.append(" addressBound: ").append(registry.addressBound()).append('\n');
+ b.append(" format: ")
+ .append(registry.proofBound() ? "v2, binding proofs present" : "v1, NO binding proofs")
+ .append('\n');
+ // GENESIS BINDING: the report printed both v1 and v2 without saying WHICH one goes into
+ // genesis, and whoever took the last value off the screen took v1 and halted the fleet at
+ // H-1. The one that matters is now named explicitly, and it is the very one the gate
+ // compares: hashFor.
+ b.append(" >>> FOR config.pqRegistryHash: 0x")
+ .append(hashFor(registry, chainId))
+ .append(" <<< this one, and only this one\n");
+ if (registry.proofBound()) {
+ b.append(" bindHeight: ").append(registry.bindHeight()).append('\n');
+ b.append(" canonical v2: 0x").append(hashV2(registry, chainId)).append(" (= the one above)\n");
+ }
+ b.append(" canonical v1: 0x").append(hashV1(registry, chainId))
+ .append(registry.proofBound() ? " (NOT this one: the registry carries proofs)\n" : " (= the one above)\n");
+ b.append(" legacy v0 : 0x")
+ .append(hashV0Legacy(registry))
+ .append(" (the older concat form, for cross-checking the genesis alloc anchor only)\n");
+ b.append(" per-index fingerprints, keccak256(addr||pk) first 8 hex:\n");
+ final int shown = Math.min(registry.count(), MAX_ENTRIES_IN_REPORT);
+ for (int i = 0; i < shown; i++) {
+ final Entry e = registry.entries.get(i);
+ b.append(" [")
+ .append(i)
+ .append("] ")
+ .append(fingerprint(registry, i))
+ .append(" pkLen=")
+ .append(e.publicKey.length);
+ if (e.address != null) {
+ b.append(" addr=0x").append(hex(e.address));
+ }
+ b.append('\n');
+ }
+ if (registry.count() > shown) {
+ b.append(" ... ").append(registry.count() - shown).append(" more entries\n");
+ }
+ return b.toString();
+ }
+
+ // ===================================================================================
+ // Small helpers. Deliberately local so this class needs nothing from Besu itself and
+ // can be compiled, hashed and tested standalone.
+ // ===================================================================================
+
+ private static JsonNode readJson(final Path path) {
+ try {
+ return new ObjectMapper().readTree(Files.readAllBytes(path));
+ } catch (final IOException e) {
+ throw new RegistryConfigException(
+ "AERE-PQC-REG-LOAD-13",
+ "AERE PQC GENESIS-BINDING: cannot read or parse JSON at '"
+ + path
+ + "': "
+ + e
+ + ". Refusing (fail-closed).");
+ }
+ }
+
+ private static int parseIndexOrThrow(final String raw, final String source) {
+ try {
+ final int i = Integer.parseInt(raw.trim());
+ if (i < 0) {
+ throw new NumberFormatException("negative");
+ }
+ return i;
+ } catch (final NumberFormatException e) {
+ throw new RegistryConfigException(
+ "AERE-PQC-REG-LOAD-14",
+ "AERE PQC GENESIS-BINDING: registry '"
+ + source
+ + "' has the unrecognised key '"
+ + raw
+ + "'. Every key must be 'count', a non-negative validator index, or '<index>.addr'. "
+ + "Refusing (fail-closed): the old loader SKIPPED keys it did not understand and "
+ + "logged a warning, which means a typo'd index silently produced a smaller registry "
+ + "that still hashed to something.");
+ }
+ }
+
+ /**
+ * AERE ROW BINDING. Parse an OPTIONAL non-negative header field: absent means -1, present means it must
+ * be a well formed non-negative number. Absent-or-garbage is never collapsed into a default,
+ * because a default is how a threshold quietly becomes zero.
+ */
+ private static long parseOptionalLong(
+ final String raw, final String what, final String source) {
+ if (raw == null || raw.trim().isEmpty()) {
+ return -1L;
+ }
+ final long v;
+ try {
+ v = Long.parseLong(raw.trim());
+ } catch (final NumberFormatException e) {
+ throw new RegistryConfigException(
+ "AERE-PQC-REG-LOAD-23",
+ "AERE PQC ROW-BINDING: registry '"
+ + source
+ + "' has "
+ + what
+ + "='"
+ + raw
+ + "', which is not a number. Refusing (fail-closed).");
+ }
+ if (v < 0) {
+ throw new RegistryConfigException(
+ "AERE-PQC-REG-LOAD-23",
+ "AERE PQC ROW-BINDING: registry '"
+ + source
+ + "' has a negative "
+ + what
+ + " ("
+ + v
+ + "). Refusing (fail-closed).");
+ }
+ return v;
+ }
+
+ private static int parsePositiveInt(final String raw, final String what, final String source) {
+ try {
+ final int v = Integer.parseInt(raw.trim());
+ if (v <= 0) {
+ throw new NumberFormatException("not positive");
+ }
+ return v;
+ } catch (final NumberFormatException e) {
+ throw new RegistryConfigException(
+ "AERE-PQC-REG-LOAD-15",
+ "AERE PQC GENESIS-BINDING: registry '"
+ + source
+ + "' has a malformed "
+ + what
+ + " '"
+ + raw
+ + "'. Refusing (fail-closed).");
+ }
+ }
+
+ /**
+ * Strict hex: optional {@code 0x}, then an EVEN number of hex digits and nothing else.
+ *
+ * <p>Deliberately NOT the lenient parser the old loader used. Lenient parsing accepts an odd digit
+ * count and silently left-pads, so a public key that lost one character would load as a DIFFERENT
+ * key with no complaint, and hash to something that looks perfectly valid.
+ */
+ private static byte[] strictHex(final String raw, final String what, final String source) {
+ String s = raw == null ? "" : raw.trim();
+ if (s.startsWith("0x") || s.startsWith("0X")) {
+ s = s.substring(2);
+ }
+ if (s.isEmpty() || (s.length() & 1) == 1) {
+ throw new RegistryConfigException(
+ "AERE-PQC-REG-LOAD-16",
+ "AERE PQC GENESIS-BINDING: registry '"
+ + source
+ + "' "
+ + what
+ + " has "
+ + s.length()
+ + " hex digits, which is "
+ + (s.isEmpty() ? "empty" : "ODD")
+ + ". Refusing (fail-closed): a lenient parser would left-pad this and load a "
+ + "DIFFERENT key without saying anything.");
+ }
+ final byte[] out = new byte[s.length() / 2];
+ for (int i = 0; i < out.length; i++) {
+ final int hi = Character.digit(s.charAt(2 * i), 16);
+ final int lo = Character.digit(s.charAt(2 * i + 1), 16);
+ if (hi < 0 || lo < 0) {
+ throw new RegistryConfigException(
+ "AERE-PQC-REG-LOAD-17",
+ "AERE PQC GENESIS-BINDING: registry '"
+ + source
+ + "' "
+ + what
+ + " contains a non-hex character at position "
+ + (2 * i)
+ + ". Refusing (fail-closed).");
+ }
+ out[i] = (byte) ((hi << 4) | lo);
+ }
+ return out;
+ }
+
+ private static String normHash(final String raw, final String source) {
+ String s = raw == null ? "" : raw.trim();
+ if (s.startsWith("0x") || s.startsWith("0X")) {
+ s = s.substring(2);
+ }
+ s = s.toLowerCase(Locale.ROOT);
+ if (s.length() != 64) {
+ throw new RegistryConfigException(
+ "AERE-PQC-REG-SCHED-06",
+ "AERE PQC GENESIS-BINDING: "
+ + source
+ + " carries the hash '"
+ + raw
+ + "' which is "
+ + s.length()
+ + " hex digits, not 64. Refusing to start (fail-closed).");
+ }
+ for (int i = 0; i < 64; i++) {
+ if (Character.digit(s.charAt(i), 16) < 0) {
+ throw new RegistryConfigException(
+ "AERE-PQC-REG-SCHED-07",
+ "AERE PQC GENESIS-BINDING: " + source + " hash '" + raw + "' is not hexadecimal. Refusing to start.");
+ }
+ }
+ return s;
+ }
+
+ private static void writeAll(final java.io.ByteArrayOutputStream out, final byte[] b) {
+ out.write(b, 0, b.length);
+ }
+
+ private static byte[] uint32be(final int v) {
+ return new byte[] {(byte) (v >>> 24), (byte) (v >>> 16), (byte) (v >>> 8), (byte) v};
+ }
+
+ private static byte[] uint64be(final long v) {
+ final byte[] b = new byte[8];
+ for (int i = 0; i < 8; i++) {
+ b[i] = (byte) (v >>> (56 - 8 * i));
+ }
+ return b;
+ }
+
+ private static String keccakHex(final byte[] input) {
+ final KeccakDigest kd = new KeccakDigest(256);
+ kd.update(input, 0, input.length);
+ final byte[] d = new byte[32];
+ kd.doFinal(d, 0);
+ return hex(d);
+ }
+
+ private static String hex(final byte[] b) {
+ final StringBuilder s = new StringBuilder(b.length * 2);
+ for (final byte x : b) {
+ s.append(Character.forDigit((x >> 4) & 0xf, 16)).append(Character.forDigit(x & 0xf, 16));
+ }
+ return s.toString();
+ }
+}
diff --git a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqRegistryHashTool.java b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqRegistryHashTool.java
new file mode 100755
index 000000000..e5d2675eb
--- /dev/null
+++ b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqRegistryHashTool.java
@@ -0,0 +1,482 @@
+/*
+ * Copyright contributors to Besu / Aere Network.
+ *
+ * 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 java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * REGISTRY SCHEDULE TOOL. The tool that writes and checks the two configuration values without
+ * which the height-indexed registry repair changes nothing.
+ *
+ * <p>WHY IT LIVES IN THE CONSENSUS MODULE AND NOT IN A SCRIPT. The value being computed is a
+ * keccak digest over a domain-separated, length-prefixed, chain-bound pre-image, and the node
+ * refuses to start when what it computes differs by one bit from what genesis names. A second
+ * implementation in another language is a second thing that can drift, and the drift would show up
+ * as seven validators refusing to start. This tool calls the SAME methods the node calls, from the
+ * same jar, so "the tool says the hash is X" and "the node computes X" cannot come apart. Run it
+ * from a node's own distribution:
+ *
+ * <pre>
+ * java -cp 'besu/lib/*' org.hyperledger.besu.consensus.common.bft.PqRegistryHashTool \
+ * verify --chain-id 2800 --genesis &lt;config-dir&gt;/genesis.json \
+ * --history &lt;config-dir&gt;/falcon/registry-epoca-0.properties
+ * </pre>
+ *
+ * <p>THREE VERBS.
+ *
+ * <ul>
+ * <li>{@code hash} prints the canonical hash of every registry file named. This is the value
+ * that goes into genesis.
+ * <li>{@code generate} prints the exact genesis fragment and the exact
+ * {@code aere.falcon.registry.history} line, given an arming height and one registry file per
+ * epoch. It writes nothing by itself: arming is a founder decision, and a tool that edits a
+ * live genesis is a tool that can arm a chain by accident.
+ * <li>{@code verify} recomputes everything from the files on THIS machine and answers one
+ * question: is there a scheduled height this node could not resolve. Exit 0 green, 1 red, 2
+ * not measurable.
+ * </ul>
+ *
+ * <p>THE NEGATIVE CONTROL IS A VERB, not a promise: {@code verify --CONTROL-NEGATIV} changes one
+ * hex digit of the first required hash in memory and requires the verification to go RED. A check
+ * that has never failed cannot be trusted, so the tool proves it can fail every time it is run
+ * that way.
+ */
+public final class PqRegistryHashTool {
+
+ private PqRegistryHashTool() {}
+
+ /**
+ * Entry point.
+ *
+ * @param args the verb and its options
+ */
+ public static void main(final String[] args) {
+ System.exit(run(args));
+ }
+
+ /**
+ * The tool, as a function of its arguments, so a test can drive it without exiting the JVM.
+ *
+ * @param args the verb and its options
+ * @return 0 green, 1 red, 2 not measurable
+ */
+ public static int run(final String[] args) {
+ if (args.length == 0) {
+ utilizare();
+ return 2;
+ }
+ final Map<String, String> o = optiuni(args);
+ final long chainId = Long.parseLong(o.getOrDefault("chain-id", "2800"));
+ try {
+ switch (args[0]) {
+ case "hash":
+ return hash(o, chainId);
+ case "generate":
+ return generate(o, chainId);
+ case "verify":
+ return verify(o, chainId);
+ default:
+ utilizare();
+ return 2;
+ }
+ } catch (final PqRegistryHash.RegistryConfigException e) {
+ System.out.println("RED: " + e.getMessage());
+ return 1;
+ } catch (final RuntimeException e) {
+ System.out.println("NOT MEASURED: " + e);
+ return 2;
+ }
+ }
+
+ // ---------------------------------------------------------------------------------------------
+
+ private static int hash(final Map<String, String> o, final long chainId) {
+ final List<Path> files = files(o, "registry");
+ if (files.isEmpty()) {
+ System.out.println("NOT MEASURED: --registry <file>[,<file>...] is missing");
+ return 2;
+ }
+ System.out.println("chainId=" + chainId + " (goes INTO the pre-image; 2800 and 442807 give different hashes for the same registry)");
+ for (final Path p : files) {
+ final PqRegistryHash.Registry r = PqRegistryHash.loadAuto(p);
+ // AERE CANONICAL FINGERPRINT (2026-08-06): hashFor, not hashV1. For a registry that carries
+ // binding proofs, hashV1 is a number NOTHING in the node ever compares against: the guard
+ // compares hashFor, that is hashV2. The same mistake, in the `generate` verb below, writes
+ // into genesis a hash the node will never recognise, and then all seven start and the chain
+ // stops at H-1. On top of that, hashV1 does NOT tell two rotation epochs of the same fleet
+ // apart, because the bind height does not enter the v1 pre-image; so it cannot serve even as
+ // an epoch identifier for diagnostics.
+ System.out.println(
+ "0x"
+ + PqRegistryHash.hashFor(r, chainId)
+ + " "
+ + p
+ + " (entries="
+ + r.count()
+ + ", address-bound="
+ + r.addressBound()
+ + ", format="
+ + (r.proofBound() ? "v2, bind-height=" + r.bindHeight() : "v1, no proofs")
+ + ")");
+ }
+ return 0;
+ }
+
+ // ---------------------------------------------------------------------------------------------
+
+ private static int generate(final Map<String, String> o, final long chainId) {
+ final String armingHeightRaw = o.get("arming-height");
+ if (armingHeightRaw == null) {
+ System.out.println("NOT MEASURED: --arming-height <H> is missing. The first entry of the");
+ System.out.println(" schedule must be EXACTLY at aere.pq.anchorBlock: a later");
+ System.out.println(" first entry leaves the arming height with no scheduled registry.");
+ return 2;
+ }
+ final long h = Long.parseLong(armingHeightRaw);
+ final List<Path> files = files(o, "registry");
+ if (files.isEmpty()) {
+ System.out.println("NOT MEASURED: --registry is missing");
+ return 2;
+ }
+ // The epochs: the first at the arming height, then one per --rotation <height>=<file>.
+ final Map<Long, Path> epoci = new LinkedHashMap<>();
+ epoci.put(h, files.get(0));
+ for (final String rot : list(o.get("rotation"))) {
+ final int eq = rot.indexOf('=');
+ if (eq < 0) {
+ System.out.println("NOT MEASURED: --rotation is written <height>=<file>, got: " + rot);
+ return 2;
+ }
+ epoci.put(Long.parseLong(rot.substring(0, eq).trim()), Path.of(rot.substring(eq + 1).trim()));
+ }
+
+ final List<Long> inaltimi = new ArrayList<>(epoci.keySet());
+ inaltimi.sort(Long::compare);
+ if (inaltimi.get(0) != h) {
+ System.out.println("RED: the first epoch is at " + inaltimi.get(0) + ", not at the arming height " + h);
+ return 1;
+ }
+
+ // NO COMMENT INSIDE THE FRAGMENT, and that is a measurement, not a matter of taste. The first
+ // form of this method put the file path after every entry, as `// /path/registry.json`.
+ // The fragment LOOKS fine and CANNOT BE USED: genesis is read with Jackson without
+ // ALLOW_COMMENTS, so a node handed one of those refuses to start with
+ // [AERE-PQC-REG-LOAD-13] "Unexpected character ('/')", and it then refuses EVERY header from
+ // the arming height upwards. Measured 2026-08-06, with two controls that reproduce the refusal.
+ // Whatever is explanation is printed outside the JSON, on lines beginning with #.
+ final StringBuilder json = new StringBuilder();
+ json.append(" \"pqRegistryHash\": [\n");
+ final StringBuilder history = new StringBuilder();
+ final StringBuilder harta = new StringBuilder();
+ for (int i = 0; i < inaltimi.size(); i++) {
+ final long b = inaltimi.get(i);
+ final Path p = epoci.get(b);
+ final PqRegistryHash.Registry reg = PqRegistryHash.loadAuto(p);
+ // AERE CANONICAL FINGERPRINT (2026-08-06). THIS IS THE DANGEROUS VERB: what is printed here
+ // gets pasted into genesis, and genesis is the document all seven nodes hold identical.
+ // hashV1 next to a registry that carries proofs writes into genesis a number the node's guard
+ // (hashFor) never produces, so all seven nodes start, all report the registry loaded, and the
+ // chain stops at H-1. A wrong indication is more dangerous than a missing one.
+ final String hash = PqRegistryHash.hashFor(reg, chainId);
+ // AERE SIGNED-HEIGHT CHECK (2026-08-06). The recipe we print has to be the one the NEW code
+ // accepts. Since 2026-08-06 the node refuses to start (AERE-PQC-REG-BIND-08) on a registry
+ // forced in at a height its proofs did not sign. If the tool printed that recipe, it would
+ // manufacture exactly the configuration the node rejects, and it would do so in a file that
+ // reaches all seven at once.
+ if (reg.proofBound() && reg.bindHeight() != b) {
+ System.out.println(
+ "RED: registry "
+ + p
+ + " has bind height "
+ + reg.bindHeight()
+ + ", while here it is scheduled from block "
+ + b
+ + ". The two numbers must be EQUAL: every row of the registry carries a proof of "
+ + "possession and a claim, and both sign the bind height. A registry forced in from "
+ + "another block is an activation day no validator signed, and the node refuses to "
+ + "start on it with AERE-PQC-REG-BIND-08. It is not repaired by editing the "
+ + "bindHeight field: the proofs are over it, and the file would no longer load at "
+ + "all (AERE-PQC-REG-BIND-03). It is repaired either by scheduling the epoch at "
+ + reg.bindHeight()
+ + ", or by re-signing the registry for "
+ + b
+ + ", which means the key ceremony and a NEW hash.");
+ return 1;
+ }
+ json.append(" {\"block\": ").append(b).append(", \"hash\": \"0x").append(hash).append("\"}");
+ if (i < inaltimi.size() - 1) {
+ json.append(',');
+ }
+ json.append('\n');
+ harta.append("# block ").append(b).append(" -> ").append(p.toAbsolutePath()).append('\n');
+ if (history.length() > 0) {
+ history.append(',');
+ }
+ history.append(p.toAbsolutePath());
+ }
+ json.append(" ]");
+
+ System.out.println("# 1. In the genesis of chain " + chainId + ", inside the \"config\" object:");
+ System.out.println("# The fragment below is VALID JSON and is pasted as it stands. No comments");
+ System.out.println("# are added to it: genesis is read WITHOUT ALLOW_COMMENTS and the node refuses to start.");
+ System.out.println(json);
+ System.out.println("# Which registry file stands behind each epoch:");
+ System.out.print(harta);
+ System.out.println();
+ System.out.println("# 2. On EVERY node, the second client included, in BESU_OPTS:");
+ System.out.println("-Daere.falcon.registry.history=" + history);
+ System.out.println();
+ System.out.println("# 3. The order, and it is not negotiable: the registry files are placed on all");
+ System.out.println("# seven AND the hash goes into genesis BEFORE the restart. A node armed");
+ System.out.println("# without a schedule refuses every header from the arming height upwards.");
+ System.out.println("# 4. After they are placed, on each node:");
+ System.out.println("# PqRegistryHashTool verify --chain-id " + chainId + " --genesis <genesis> --history <list>");
+ return 0;
+ }
+
+ // ---------------------------------------------------------------------------------------------
+
+ private static int verify(final Map<String, String> o, final long chainId) {
+ final String genesisRaw = o.get("genesis");
+ if (genesisRaw == null) {
+ System.out.println("NOT MEASURED: --genesis <file> is missing");
+ return 2;
+ }
+ final Path genesis = Path.of(genesisRaw);
+ if (!Files.isReadable(genesis)) {
+ System.out.println("NOT MEASURED: genesis cannot be read: " + genesis);
+ return 2;
+ }
+
+ PqRegistryHash.Schedule schedule = PqRegistryHash.loadScheduleFromGenesis(genesis);
+ if (!schedule.enforced()) {
+ System.out.println(
+ "RED: "
+ + genesis
+ + " does not carry config.pqRegistryHash. The height-indexed registry schedule "
+ + "exists, it is complete, and it is OFF. A node armed without a schedule falls back "
+ + "on TODAY's registry and says ACCEPTED for a header it has bound to no "
+ + "height.");
+ return 1;
+ }
+
+ boolean controlNegativ = o.containsKey("CONTROL-NEGATIV");
+ if (controlNegativ) {
+ schedule = strica(schedule);
+ System.out.println(
+ "NEGATIVE CONTROL: the first required hash was changed by ONE hex character, in memory. "
+ + "The verification below MUST come out RED.");
+ }
+
+ final List<Path> history = files(o, "history");
+ final List<PqRegistryHash.Registry> tinute = new ArrayList<>();
+ for (final Path p : history) {
+ tinute.add(PqRegistryHash.loadAuto(p));
+ }
+ if (tinute.isEmpty()) {
+ System.out.println(
+ "WARNING: --history is empty, so no registry was loaded. On a real node the signing "
+ + "registry is added automatically; here it is asked for explicitly, so that nothing "
+ + "is reported green on the strength of what was not measured.");
+ }
+
+ final PqRegistryHash.RegistrySet set = PqRegistryHash.buildSet(schedule, tinute, chainId);
+
+ System.out.println("chainId = " + chainId);
+ System.out.println("genesis = " + genesis);
+ System.out.println("schedule source = " + schedule.source());
+ System.out.println("epochs scheduled = " + schedule.entries().size());
+ for (final PqRegistryHash.ScheduleEntry e : schedule.entries()) {
+ final boolean acoperita = !set.uncoveredEntryBlocks().contains(e.block());
+ System.out.println(
+ " block " + e.block() + " 0x" + e.hash() + " " + (acoperita ? "COVERED" : "NOT COVERED"));
+ }
+ System.out.println("registries held = " + set.count());
+ for (final Path p : history) {
+ // AERE CANONICAL FINGERPRINT: hashFor. This line sits immediately under the list of scheduled
+ // epochs printed with their hashes; two numbers laid one under the other so that they get
+ // compared by eye, and computed with two different functions, are a comparison that can never
+ // match.
+ final PqRegistryHash.Registry r = PqRegistryHash.loadAuto(p);
+ System.out.println(
+ " "
+ + PqRegistryHash.hashFor(r, chainId)
+ + " "
+ + p
+ + (r.proofBound() ? " (bind-height=" + r.bindHeight() + ")" : " (v1)"));
+ }
+ final String armareRaw = o.get("arming-height");
+ int rc = 0;
+ // AERE SIGNED-HEIGHT CHECK: the registries that reproduce the required hash and did NOT sign
+ // that height. This is exactly what the node now refuses to start on; until 2026-08-06 the node
+ // started and the tool said nothing.
+ for (final PqRegistryHash.Misbound m : set.misbound()) {
+ System.out.println(
+ "RED: "
+ + m.registryPath()
+ + " reproduces the hash required at block "
+ + m.entryBlock()
+ + ", but its proofs sign height "
+ + m.signedHeight()
+ + ". The node refuses to start with AERE-PQC-REG-BIND-08.");
+ rc = 1;
+ }
+ if (armareRaw != null) {
+ final long h = Long.parseLong(armareRaw);
+ final long prima = schedule.entries().get(0).block();
+ if (prima != h) {
+ System.out.println(
+ "RED: the first entry of the schedule is at "
+ + prima
+ + ", while aere.pq.anchorBlock is "
+ + h
+ + ". They must be EQUAL. If the first entry is higher, the heights between H and it "
+ + "are bound to nothing and the node falls back on the head registry exactly where "
+ + "the certificate begins to matter.");
+ rc = 1;
+ } else {
+ System.out.println("OK: the first epoch is exactly at the arming height " + h + ".");
+ }
+ } else {
+ System.out.println(
+ "NOT MEASURED: --arming-height was not given, so it was NOT checked that the first "
+ + "epoch coincides with aere.pq.anchorBlock.");
+ }
+
+ final List<Long> neacoperite = set.uncoveredEntryBlocks();
+ if (!neacoperite.isEmpty()) {
+ System.out.println(
+ "RED: heights "
+ + neacoperite
+ + " are covered by NOTHING this node holds. Every header from such a height upwards "
+ + "will be REFUSED, and a node syncing from genesis stops there. Name the missing "
+ + "file in aere.falcon.registry.history.");
+ rc = 1;
+ } else {
+ System.out.println("OK: every epoch in the schedule is covered by a registry this node holds.");
+ }
+
+ // Positive probe: the registry in force at the height of every epoch resolves right now.
+ for (final PqRegistryHash.ScheduleEntry e : schedule.entries()) {
+ final Optional<PqRegistryHash.Registry> r =
+ PqRegistryHash.registryAt(schedule, set, e.block());
+ if (r.isEmpty()) {
+ System.out.println("RED: registryAt(" + e.block() + ") resolves nothing.");
+ rc = 1;
+ } else if (!PqRegistryHash.matchesAt(schedule, set, e.block(), chainId)) {
+ System.out.println("RED: registryAt(" + e.block() + ") resolves a registry with a different hash.");
+ rc = 1;
+ }
+ }
+
+ if (controlNegativ) {
+ if (rc == 1) {
+ System.out.println("NEGATIVE CONTROL OK: with a single character changed, the verification is RED.");
+ return 0;
+ }
+ System.out.println(
+ "NEGATIVE CONTROL FAILED: a hash was changed and the verification stayed GREEN. The "
+ + "verification cannot be trusted.");
+ return 1;
+ }
+ System.out.println(rc == 0 ? "GREEN" : "RED");
+ return rc;
+ }
+
+ // ---------------------------------------------------------------------------------------------
+
+ /** Change exactly one hex digit of the first required hash, through the real parser. */
+ private static PqRegistryHash.Schedule strica(final PqRegistryHash.Schedule s) {
+ final StringBuilder sb = new StringBuilder("{\"config\":{\"pqRegistryHash\":[");
+ for (int i = 0; i < s.entries().size(); i++) {
+ final PqRegistryHash.ScheduleEntry e = s.entries().get(i);
+ String h = e.hash();
+ if (i == 0) {
+ final char c = h.charAt(h.length() - 1);
+ h = h.substring(0, h.length() - 1) + (c == '0' ? '1' : '0');
+ }
+ if (i > 0) {
+ sb.append(',');
+ }
+ sb.append("{\"block\":").append(e.block()).append(",\"hash\":\"0x").append(h).append("\"}");
+ }
+ sb.append("]}}");
+ try {
+ final Path t = Files.createTempFile("aere-control-negativ-", ".json");
+ t.toFile().deleteOnExit();
+ Files.writeString(t, sb.toString());
+ return PqRegistryHash.loadScheduleFromGenesis(t);
+ } catch (final java.io.IOException io) {
+ throw new IllegalStateException("cannot write the negative-control file: " + io, io);
+ }
+ }
+
+ private static List<Path> files(final Map<String, String> o, final String cheie) {
+ final List<Path> out = new ArrayList<>();
+ for (final String s : list(o.get(cheie))) {
+ out.add(Path.of(s));
+ }
+ return out;
+ }
+
+ private static List<String> list(final String raw) {
+ final List<String> out = new ArrayList<>();
+ if (raw == null || raw.isBlank()) {
+ return out;
+ }
+ for (final String s : com.google.common.base.Splitter.on(',').split(raw)) {
+ if (!s.trim().isEmpty()) {
+ out.add(s.trim());
+ }
+ }
+ return out;
+ }
+
+ private static Map<String, String> optiuni(final String[] args) {
+ final Map<String, String> o = new LinkedHashMap<>();
+ for (int i = 1; i < args.length; i++) {
+ if (!args[i].startsWith("--")) {
+ continue;
+ }
+ final String k = args[i].substring(2);
+ if (i + 1 < args.length && !args[i + 1].startsWith("--")) {
+ final String prev = o.get(k);
+ o.put(k, prev == null ? args[i + 1] : prev + "," + args[i + 1]);
+ i++;
+ } else {
+ o.put(k, "");
+ }
+ }
+ return o;
+ }
+
+ private static void utilizare() {
+ System.out.println("The height-indexed registry schedule: hash, generate, verify.");
+ System.out.println();
+ System.out.println(" hash --chain-id 2800 --registry <f>[,<f>...]");
+ System.out.println(" generate --chain-id 2800 --arming-height <H> --registry <f>");
+ System.out.println(" [--rotation <H2>=<f2> ...]");
+ System.out.println(" verify --chain-id 2800 --genesis <g.json> --history <f>[,<f>...]");
+ System.out.println(" [--arming-height <H>] [--CONTROL-NEGATIV]");
+ System.out.println();
+ System.out.println("Exit code: 0 green, 1 red, 2 not measurable.");
+ }
+}
diff --git a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqSealCache.java b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqSealCache.java
new file mode 100755
index 000000000..e5e61bd90
--- /dev/null
+++ b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqSealCache.java
@@ -0,0 +1,351 @@
+/*
+ * Copyright contributors to Besu / Aere Network.
+ *
+ * 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 org.hyperledger.besu.datatypes.Hash;
+
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * AERE ANCHOR V2: the bounded store of Falcon seals this node HEARD for recent blocks, in memory and
+ * - for the chain head only - on disk.
+ *
+ * <p>WHAT IT IS FOR. The proposer of block N must carry a certificate over block N-1. Under
+ * V2 that certificate is chosen by the proposer out of what it heard, so the seals gossiped on
+ * Commit messages have to survive from the height where they arrive to the height where the next
+ * block is built. That is all this class does.
+ *
+ * <p>KEYED ON THE ON-CHAIN BLOCK HASH, NEVER ON THE COMMIT DIGEST. The digest carried by a Commit
+ * message is the committed-seal hash, which INCLUDES the round number, so it differs between rounds
+ * for one and the same block. The producer looks the certificate up by {@code
+ * parentHeader.getHash()}, which is the round-independent on-chain hash. Storing under anything
+ * else would silently produce empty certificates at every round change.
+ *
+ * <p>AERE PERSISTENTA-SIGILII (2026-08-05): PERSISTENT FOR THE CHAIN HEAD, AND THE OLD REASONING FOR
+ * NOT BEING SO WAS MEASURED WRONG. This class used to say, in this exact place, that losing the map
+ * on restart costs "at most one proposer turn per restart". That reasoning is correct for ONE node
+ * restarted inside a LIVE fleet, which is the case it was written for. For a fleet restarted
+ * ENTIRELY it is false, and the falsehood was measured on a seven-node network: with K&gt;0 every
+ * node came back holding zero seals, no certificate could reach K, nobody could propose, so no
+ * Commit was ever sent, so no node ever heard a seal again. The chain was dead, permanently, and no
+ * amount of waiting healed it. The seals over M(head) exist nowhere else: they travel only on the
+ * Commit messages of the head block, and those are never replayed.
+ *
+ * <p>AND IT IS NOT THE UNBOUND-REGISTRY DEFECT IN ANOTHER COAT. That one was a registry of public
+ * keys read from a file and BELIEVED. Every seal read back here is re-verified, cryptographically,
+ * against the anchored registry over M rebuilt from the header this process just loaded - see
+ * {@link PqSealStore}. A forged file cannot inject a seal without forging a Falcon-512 signature;
+ * the worst it achieves is the empty cache an absent file already gives. Persistence is OFF unless
+ * a caller enables it, and the only caller that does is the QBFT controller builder, only when the
+ * anchor is actually armed.
+ *
+ * <p>WHAT IT DOES NOT DO. The in-memory path verifies nothing. Whether a seal is valid, whether its
+ * index maps to an eligible validator, and whether there are enough of them, are decided at
+ * SELECTION time by the producer, against the message M of the specific parent. A seal that was
+ * signed under the pre-fork message simply fails that check and is dropped, which is what makes the
+ * height switch safe without any coordination beyond the shared activation height.
+ */
+public final class PqSealCache {
+
+ private static final Logger LOG = LoggerFactory.getLogger(PqSealCache.class);
+
+ /** How many distinct block hashes are retained. Several rounds at one height each add one. */
+ private static final int MAX_ENTRIES = 1024;
+
+ /** How far below the highest seen height entries are kept, in blocks. */
+ private static final long HEIGHT_WINDOW = 256L;
+
+ private static final PqSealCache INSTANCE = new PqSealCache();
+
+ private final LinkedHashMap<Hash, Entry> byHash = new LinkedHashMap<>();
+ private long highestSeen = -1L;
+
+ /** Guards the file, never the map, so a slow disk cannot hold up the consensus thread's map. */
+ private final Object writeLock = new Object();
+
+ private volatile Path persistenceFile;
+ private volatile long persistenceChainId;
+
+ private long persistedBlock = -1L;
+ private Hash persistedHash;
+ private int persistedCount = -1;
+
+ private PqSealCache() {}
+
+ /**
+ * The process-wide cache.
+ *
+ * @return the singleton
+ */
+ public static PqSealCache instance() {
+ return INSTANCE;
+ }
+
+ /**
+ * Turn on writing the chain head's seals to the node's data directory.
+ *
+ * <p>The path is DERIVED from the data directory Besu was started with, never configured
+ * separately: a second configurable path is a second thing that can be pointed at the wrong node's
+ * state, and this file is per-node by construction.
+ *
+ * @param dataDirectory the node's data directory, or null to leave persistence off
+ * @param chainId the chain id used in the M pre-image, which must be the SAME value the producer
+ * uses or every restored seal would fail verification
+ */
+ public void enablePersistence(final Path dataDirectory, final long chainId) {
+ if (dataDirectory == null) {
+ LOG.warn(
+ "AERE PERSISTENTA-SIGILII: no data directory was supplied, so heard seals stay in memory "
+ + "only. A simultaneous restart of the whole fleet with K>0 stops the chain.");
+ return;
+ }
+ this.persistenceChainId = chainId;
+ this.persistenceFile = PqSealStore.fileIn(dataDirectory);
+ LOG.info(
+ "AERE PERSISTENTA-SIGILII: heard seals for the chain head are kept in {} (chainId={}). "
+ + "Every seal read back is re-verified against the anchored registry before it is used.",
+ this.persistenceFile,
+ chainId);
+ }
+
+ /**
+ * Turn persistence off again. For tests, and for an operator who wants the previous behaviour.
+ */
+ public void disablePersistence() {
+ this.persistenceFile = null;
+ synchronized (writeLock) {
+ persistedBlock = -1L;
+ persistedHash = null;
+ persistedCount = -1;
+ }
+ }
+
+ /**
+ * The file heard seals are written to, or null when persistence is off.
+ *
+ * @return the seal file, or null
+ */
+ public Path persistenceFile() {
+ return persistenceFile;
+ }
+
+ /**
+ * Refill the cache from disk for a specific block, normally this node's chain head at startup.
+ *
+ * <p>Call this AFTER the Falcon registry has been activated, or every seal will be rejected for
+ * the entirely correct reason that the registry cannot resolve its index yet, and the restore will
+ * silently do nothing.
+ *
+ * @param blockNumber the head block's height
+ * @param onchainBlockHash the head block's ON-CHAIN hash
+ * @param registry the anchored registry every restored seal is re-verified against
+ * @return how many seals survived verification and entered the cache
+ */
+ public int restoreFromDisk(
+ final long blockNumber, final Hash onchainBlockHash, final PqSignerRegistry registry) {
+ final Path file = persistenceFile;
+ if (file == null) {
+ return 0;
+ }
+ final List<FalconSeal> verified;
+ try {
+ verified =
+ PqSealStore.readVerified(
+ file, persistenceChainId, blockNumber, onchainBlockHash, registry);
+ } catch (final RuntimeException e) {
+ // readVerified is written not to throw; this is the belt on top of the braces, because an
+ // exception escaping here would turn a lost cache into a node that refuses to start.
+ LOG.warn(
+ "AERE PERSISTENTA-SIGILII: restoring seals from {} failed unexpectedly ({}); the cache "
+ + "starts empty, which is exactly the behaviour before this file existed.",
+ file,
+ e.toString());
+ return 0;
+ }
+ if (verified.isEmpty()) {
+ return 0;
+ }
+ synchronized (this) {
+ final Entry entry =
+ byHash.computeIfAbsent(onchainBlockHash, unused -> new Entry(blockNumber));
+ for (final FalconSeal seal : verified) {
+ entry.seals.putIfAbsent(seal.getValidatorIndex(), seal);
+ }
+ if (blockNumber > highestSeen) {
+ highestSeen = blockNumber;
+ }
+ prune();
+ }
+ return verified.size();
+ }
+
+ /**
+ * Remember the seals heard for a block. Idempotent per validator index: the first seal seen for
+ * an index wins, so a later duplicate cannot displace it.
+ *
+ * @param blockNumber the height of the block the seals attest
+ * @param onchainBlockHash the ON-CHAIN hash of that block
+ * @param seals the seals heard, possibly empty
+ */
+ public void record(
+ final long blockNumber, final Hash onchainBlockHash, final Collection<FalconSeal> seals) {
+ final List<FalconSeal> toPersist;
+ synchronized (this) {
+ if (onchainBlockHash == null || seals == null || seals.isEmpty()) {
+ return;
+ }
+ final Entry entry =
+ byHash.computeIfAbsent(onchainBlockHash, unused -> new Entry(blockNumber));
+ for (final FalconSeal seal : seals) {
+ if (seal != null && seal.getValidatorIndex() >= 0 && seal.getSignature() != null) {
+ entry.seals.putIfAbsent(seal.getValidatorIndex(), seal);
+ }
+ }
+ if (blockNumber > highestSeen) {
+ highestSeen = blockNumber;
+ }
+ prune();
+ if (persistenceFile == null || blockNumber < highestSeen) {
+ // Only the HIGHEST block is worth a file: the producer of the next block asks for the head's
+ // seals and nothing else, and writing older heights would just churn the disk.
+ return;
+ }
+ toPersist = PqAnchor.sortedByIndex(new ArrayList<>(entry.seals.values()));
+ }
+ // Deliberately OUTSIDE the map lock: a file write must never be able to stall a caller that only
+ // wants to read the cache.
+ persist(blockNumber, onchainBlockHash, toPersist);
+ }
+
+ private void persist(
+ final long blockNumber, final Hash onchainBlockHash, final List<FalconSeal> seals) {
+ final Path file = persistenceFile;
+ if (file == null) {
+ return;
+ }
+ synchronized (writeLock) {
+ if (blockNumber < persistedBlock) {
+ return;
+ }
+ if (blockNumber == persistedBlock
+ && onchainBlockHash.equals(persistedHash)
+ && seals.size() <= persistedCount) {
+ return;
+ }
+ try {
+ PqSealStore.writeAtomically(
+ file,
+ PqSealStore.encode(
+ persistenceChainId, blockNumber, onchainBlockHash.getBytes(), seals));
+ persistedBlock = blockNumber;
+ persistedHash = onchainBlockHash;
+ persistedCount = seals.size();
+ } catch (final Exception e) {
+ // A disk fault must not be able to stop this node from taking part in consensus. It costs
+ // the restart protection, which is what the log line says, and nothing else.
+ LOG.warn(
+ "AERE PERSISTENTA-SIGILII: could not write the {} heard seal(s) for block {} to {} "
+ + "({}). This node keeps working; it just loses its seals if it restarts now.",
+ seals.size(),
+ blockNumber,
+ file,
+ e.toString());
+ }
+ }
+ }
+
+ /**
+ * The seals heard for a block, in canonical order.
+ *
+ * @param blockNumber the height of the block the seals must attest
+ * @param onchainBlockHash the ON-CHAIN hash of that block
+ * @return the seals, sorted by strictly increasing validator index, possibly empty
+ */
+ public synchronized List<FalconSeal> sealsFor(
+ final long blockNumber, final Hash onchainBlockHash) {
+ final Entry entry = byHash.get(onchainBlockHash);
+ if (entry == null || entry.blockNumber != blockNumber) {
+ return List.of();
+ }
+ return PqAnchor.sortedByIndex(new ArrayList<>(entry.seals.values()));
+ }
+
+ /**
+ * How many block hashes are currently retained.
+ *
+ * @return the number of entries
+ */
+ public synchronized int entryCount() {
+ return byHash.size();
+ }
+
+ /**
+ * How many seals are retained for a block hash.
+ *
+ * @param onchainBlockHash the ON-CHAIN hash
+ * @return the seal count, 0 when nothing is retained
+ */
+ public synchronized int sealCount(final Hash onchainBlockHash) {
+ final Entry entry = byHash.get(onchainBlockHash);
+ return entry == null ? 0 : entry.seals.size();
+ }
+
+ /**
+ * Drop everything held in memory. For tests, and for a deliberate operator reset. The file, if
+ * one exists, is deliberately left alone: it is rewritten by the next commit this node hears.
+ */
+ public synchronized void clear() {
+ byHash.clear();
+ highestSeen = -1L;
+ synchronized (writeLock) {
+ persistedBlock = -1L;
+ persistedHash = null;
+ persistedCount = -1;
+ }
+ }
+
+ private void prune() {
+ final Iterator<Map.Entry<Hash, Entry>> it = byHash.entrySet().iterator();
+ while (it.hasNext()) {
+ final Map.Entry<Hash, Entry> e = it.next();
+ if (highestSeen - e.getValue().blockNumber > HEIGHT_WINDOW) {
+ it.remove();
+ }
+ }
+ while (byHash.size() > MAX_ENTRIES) {
+ final Iterator<Hash> oldest = byHash.keySet().iterator();
+ oldest.next();
+ oldest.remove();
+ }
+ }
+
+ private static final class Entry {
+ private final long blockNumber;
+ private final Map<Integer, FalconSeal> seals = new LinkedHashMap<>();
+
+ private Entry(final long blockNumber) {
+ this.blockNumber = blockNumber;
+ }
+ }
+}
diff --git a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqSealStore.java b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqSealStore.java
new file mode 100755
index 000000000..fecb2991b
--- /dev/null
+++ b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqSealStore.java
@@ -0,0 +1,384 @@
+/*
+ * Copyright contributors to Besu / Aere Network.
+ *
+ * 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 org.hyperledger.besu.datatypes.Hash;
+import org.hyperledger.besu.ethereum.rlp.BytesValueRLPInput;
+import org.hyperledger.besu.ethereum.rlp.BytesValueRLPOutput;
+import org.hyperledger.besu.ethereum.rlp.RLPInput;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.channels.FileChannel;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.nio.file.StandardOpenOption;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+
+import org.apache.tuweni.bytes.Bytes;
+import org.apache.tuweni.bytes.Bytes32;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * AERE PERSISTENTA-SIGILII (2026-08-05): the ON-DISK half of {@link PqSealCache}, holding the Falcon
+ * seals this node heard for its CHAIN HEAD so that a restart does not start from zero.
+ *
+ * <p><b>THE SECOND HALF OF A MEASURED CHAIN DEATH.</b> With the anchor armed at K&gt;0, a
+ * SIMULTANEOUS restart of the whole fleet was measured to kill the chain for good. The first half of
+ * the deadlock was the registry, which only ever activated while a block was being imported; that is
+ * repaired (QbftBesuControllerBuilder, marker "AERE BLOCAJ-REPORNIRE") and the seven nodes now report
+ * "anchor activation at STARTUP from head state: SUCCEEDED". The chain still died, with the refusal
+ * changing shape only: "registry address-bound=TRUE ... Heard 0 seal(s)". This class is the second
+ * half. The seals over M(head) travel on nothing but the Commit messages of the head block, which are
+ * never replayed after a restart, so every node came back holding zero seals, nobody could reach K,
+ * and nobody could propose. Seals come from Commits, Commits come from proposals, proposals need
+ * seals - the same circle, one level down.
+ *
+ * <p><b>WHY THIS IS NOT THE UNBOUND-REGISTRY DEFECT IN ANOTHER COAT, and the distinction is the
+ * whole safety argument.</b> That one was a REGISTRY read from a local file and BELIEVED: the
+ * public keys that decide who is a legitimate signer came out of a file a node could be pointed at
+ * wrongly, so the file was authority. Nothing here is believed. A Falcon seal is SELF-AUTHENTICATING: {@link
+ * #readVerified(Path, long, long, Hash, PqSignerRegistry)} re-verifies EVERY seal it reads against
+ * the anchored registry, over the message M rebuilt from the chain-head header this process just
+ * loaded, exactly as the producer does at selection time. A forged, edited or replayed file cannot
+ * inject a seal, because forging one means forging a Falcon-512 signature. The worst a hostile file
+ * can achieve is what an absent file already achieves: an empty cache, which is today's behaviour.
+ *
+ * <p><b>WHAT THE FILE IS BOUND TO.</b> The domain label, the chain id, the block NUMBER and the block
+ * HASH are all in the file and all checked before a single signature is verified. A file from another
+ * chain, another height, or another fork of the same height is discarded whole. That is not a
+ * security property (the signature check is), it is an honesty property: it makes "these are the
+ * seals for THIS head" checkable rather than assumed.
+ *
+ * <p><b>FAILURE IS NEVER FATAL.</b> Missing, truncated, corrupt, oversized, from another height, or
+ * unreadable for any reason at all: the answer is an empty list and a log line, never an exception
+ * that reaches the caller. A node that cannot read its seal file must behave exactly like a node that
+ * has none, because that is precisely the state this whole class exists to make survivable.
+ */
+public final class PqSealStore {
+
+ private static final Logger LOG = LoggerFactory.getLogger(PqSealStore.class);
+
+ /** The file name, inside the node's data directory. Never an absolute configured path. */
+ public static final String FILE_NAME = "aere-pq-seals.rlp";
+
+ /** The temporary file the atomic write goes through before the rename. */
+ public static final String TEMP_FILE_NAME = "aere-pq-seals.rlp.tmp";
+
+ /** Domain label, so a file written for any other purpose cannot be read as a seal set. */
+ public static final String DOMAIN = "AERE-PQ-SEALSTORE-1";
+
+ private static final Bytes DOMAIN_BYTES =
+ Bytes.wrap(DOMAIN.getBytes(StandardCharsets.US_ASCII));
+
+ /**
+ * Refuse to even parse a file larger than this. A Falcon-512 signature is 666 bytes and a fleet is
+ * seven, so a real file is about 5 kB; 1 MiB is four orders of magnitude of headroom and still
+ * bounds what a corrupt or hostile file can make this process allocate.
+ */
+ public static final int MAX_FILE_BYTES = 1024 * 1024;
+
+ /** Refuse to accumulate more seals than this from one file, for the same reason. */
+ public static final int MAX_SEALS = 1024;
+
+ /**
+ * System property forcing an fsync of the temporary file before the rename. Default true: the
+ * whole point of the file is to survive an unplanned restart, and the measured cost is small
+ * against the block interval.
+ */
+ public static final String PROPERTY_FSYNC = "aere.pq.sealStore.fsync";
+
+ private PqSealStore() {}
+
+ /**
+ * The seal file inside a node's data directory.
+ *
+ * @param dataDirectory the node's data directory, as Besu was started with
+ * @return the path of the seal file
+ */
+ public static Path fileIn(final Path dataDirectory) {
+ return dataDirectory.resolve(FILE_NAME);
+ }
+
+ /**
+ * The canonical bytes of a seal set.
+ *
+ * <p>The certificate itself is written by {@link PqAnchor#writeCertificate}, the SAME encoder that
+ * produces the bytes carried in element 6 of a header and hashed into the anchor digest. One
+ * logical object, one byte string, no second encoder to drift.
+ *
+ * @param chainId the chain id, also part of the M pre-image
+ * @param blockNumber the height of the block the seals attest
+ * @param blockHash the ON-CHAIN hash of that block
+ * @param seals the seals to store, in the order given
+ * @return the file payload
+ */
+ public static byte[] encode(
+ final long chainId,
+ final long blockNumber,
+ final Bytes blockHash,
+ final Collection<FalconSeal> seals) {
+ final BytesValueRLPOutput out = new BytesValueRLPOutput();
+ out.startList();
+ out.writeBytes(DOMAIN_BYTES);
+ out.writeLongScalar(chainId);
+ out.writeLongScalar(blockNumber);
+ out.writeBytes(blockHash);
+ PqAnchor.writeCertificate(out, seals);
+ out.endList();
+ return out.encoded().toArrayUnsafe();
+ }
+
+ /**
+ * Write the payload so that no reader can ever observe a half-written file.
+ *
+ * <p>Temporary file, optional fsync, then an ATOMIC_MOVE onto the target. A process killed at any
+ * instant leaves either the previous complete file or the new complete file, never a prefix of
+ * either. The fallback to a non-atomic move exists because ATOMIC_MOVE is not universally
+ * supported; a filesystem that cannot do it is still better served by a rename than by writing in
+ * place.
+ *
+ * @param file the target file
+ * @param payload the bytes to write
+ * @throws IOException if the write or the rename fails
+ */
+ public static void writeAtomically(final Path file, final byte[] payload) throws IOException {
+ final Path directory = file.toAbsolutePath().getParent();
+ if (directory != null) {
+ Files.createDirectories(directory);
+ }
+ final Path temp =
+ directory == null ? Path.of(TEMP_FILE_NAME) : directory.resolve(TEMP_FILE_NAME);
+ try (FileChannel channel =
+ FileChannel.open(
+ temp,
+ StandardOpenOption.CREATE,
+ StandardOpenOption.WRITE,
+ StandardOpenOption.TRUNCATE_EXISTING)) {
+ final ByteBuffer buffer = ByteBuffer.wrap(payload);
+ while (buffer.hasRemaining()) {
+ channel.write(buffer);
+ }
+ if (!"false".equalsIgnoreCase(System.getProperty(PROPERTY_FSYNC))) {
+ channel.force(true);
+ }
+ }
+ try {
+ Files.move(
+ temp, file, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
+ } catch (final UnsupportedOperationException | IOException atomicUnsupported) {
+ Files.move(temp, file, StandardCopyOption.REPLACE_EXISTING);
+ }
+ }
+
+ /**
+ * Read the seals stored for a block and return ONLY those that verify RIGHT NOW against the
+ * anchored registry.
+ *
+ * <p>Every seal is put through the same two questions the producer asks at selection time: does
+ * the index map to a signer in the registry, and does the Falcon signature verify over M(this
+ * block). Nothing is accepted because it is in the file. What is deliberately NOT re-checked here
+ * is validator-set eligibility, and that is not a gap: the producer re-checks it against the
+ * validator set FOR THE PARENT every time it assembles a certificate, so a seal from a former
+ * validator is dropped there whether it came from a Commit message or from this file.
+ *
+ * @param file the seal file
+ * @param chainId the chain id this node runs, part of the M pre-image
+ * @param blockNumber the height the seals must attest, normally this node's chain head
+ * @param blockHash the ON-CHAIN hash the seals must attest
+ * @param registry the anchored Falcon registry to verify against
+ * @return the surviving seals sorted by strictly increasing index, empty on any fault whatsoever
+ */
+ public static List<FalconSeal> readVerified(
+ final Path file,
+ final long chainId,
+ final long blockNumber,
+ final Hash blockHash,
+ final PqSignerRegistry registry) {
+
+ if (file == null || registry == null || blockHash == null) {
+ return List.of();
+ }
+
+ final byte[] raw;
+ try {
+ if (!Files.isRegularFile(file)) {
+ LOG.debug("AERE PERSISTENTA-SIGILII: no seal file at {}; starting with an empty cache.", file);
+ return List.of();
+ }
+ final long size = Files.size(file);
+ if (size <= 0 || size > MAX_FILE_BYTES) {
+ LOG.warn(
+ "AERE PERSISTENTA-SIGILII: seal file {} has an implausible size ({} bytes); IGNORED, "
+ + "the cache starts empty exactly as it did before this file existed.",
+ file,
+ size);
+ return List.of();
+ }
+ raw = Files.readAllBytes(file);
+ } catch (final Exception e) {
+ LOG.warn(
+ "AERE PERSISTENTA-SIGILII: seal file {} could not be read ({}); IGNORED, the cache starts "
+ + "empty. A node that cannot read this file must behave like a node that has none.",
+ file,
+ e.toString());
+ return List.of();
+ }
+
+ final List<FalconSeal> decoded = new ArrayList<>();
+ final long storedNumber;
+ final Bytes storedHash;
+ try {
+ final RLPInput in = new BytesValueRLPInput(Bytes.wrap(raw), false);
+ in.enterList();
+ final Bytes domain = in.readBytes();
+ if (!DOMAIN_BYTES.equals(domain)) {
+ LOG.warn(
+ "AERE PERSISTENTA-SIGILII: seal file {} carries domain {} instead of {}; IGNORED.",
+ file,
+ domain,
+ DOMAIN);
+ return List.of();
+ }
+ final long storedChainId = in.readLongScalar();
+ if (storedChainId != chainId) {
+ LOG.warn(
+ "AERE PERSISTENTA-SIGILII: seal file {} was written for chain {} and this node runs "
+ + "chain {}; IGNORED.",
+ file,
+ storedChainId,
+ chainId);
+ return List.of();
+ }
+ storedNumber = in.readLongScalar();
+ storedHash = in.readBytes();
+ in.enterList();
+ while (!in.isEndOfCurrentList()) {
+ if (decoded.size() >= MAX_SEALS) {
+ LOG.warn(
+ "AERE PERSISTENTA-SIGILII: seal file {} holds more than {} seals; IGNORED.",
+ file,
+ MAX_SEALS);
+ return List.of();
+ }
+ in.enterList();
+ final int index = in.readIntScalar();
+ final Bytes signature = in.readBytes();
+ in.leaveList();
+ decoded.add(new FalconSeal(index, signature));
+ }
+ in.leaveList();
+ in.leaveList();
+ } catch (final RuntimeException e) {
+ LOG.warn(
+ "AERE PERSISTENTA-SIGILII: seal file {} is CORRUPT and did not decode ({}); IGNORED, the "
+ + "cache starts empty. This costs at most one proposer turn; refusing to start would "
+ + "cost the chain.",
+ file,
+ e.toString());
+ return List.of();
+ }
+
+ if (storedNumber != blockNumber || !storedHash.equals(blockHash.getBytes())) {
+ LOG.info(
+ "AERE PERSISTENTA-SIGILII: seal file {} holds seals for block {} ({}), this node's head is "
+ + "{} ({}); IGNORED. Seals over another block are of no use to the proposer of the "
+ + "next one.",
+ file,
+ storedNumber,
+ storedHash,
+ blockNumber,
+ blockHash);
+ return List.of();
+ }
+
+ final Bytes32 message = PqAnchor.commitMessage(chainId, blockNumber, blockHash.getBytes());
+ final List<FalconSeal> verified = new ArrayList<>();
+ final Set<Integer> seen = new LinkedHashSet<>();
+ int rejectedUnknownIndex = 0;
+ int rejectedSignature = 0;
+ for (final FalconSeal seal : PqAnchor.sortedByIndex(decoded)) {
+ if (seal.getValidatorIndex() < 0
+ || seal.getSignature() == null
+ || !seen.add(seal.getValidatorIndex())
+ // HEIGHT-RESOLVED LOOKUP (2026-08-06). These seals are over block `blockNumber`, which the
+ // caller has already matched against the stored header, so the height is known exactly.
+ // On a restart at the head this resolves to the same registry it always did; the point is
+ // that it can no longer resolve to a DIFFERENT one without saying so.
+ // OWN-HEAD DOOR (b-v2): the caller has already refused this file unless the
+ // stored block number and hash equal this node's head, so the subject is this node's
+ // own head by construction.
+ || registry.addressForIndexAtOwnHead(blockNumber, seal.getValidatorIndex()) == null) {
+ rejectedUnknownIndex++;
+ continue;
+ }
+ if (!sealVerifiesAgainstAnchoredRegistry(
+ registry, blockNumber, message, seal.getValidatorIndex(), seal.getSignature())) {
+ rejectedSignature++;
+ continue;
+ }
+ verified.add(seal);
+ }
+
+ LOG.info(
+ "AERE PERSISTENTA-SIGILII: restored {} of {} stored seal(s) for head {} from {}; {} had no "
+ + "registry index, {} FAILED Falcon verification over M(head) and were dropped.",
+ verified.size(),
+ decoded.size(),
+ blockNumber,
+ file,
+ rejectedUnknownIndex,
+ rejectedSignature);
+ return verified;
+ }
+
+ /**
+ * THE LOAD-BEARING LINE OF THIS WHOLE FILE, isolated so that removing it is a one-line edit and
+ * the test suite's negative control can prove that removing it turns the forged-seal test RED.
+ *
+ * <p>This is the same call, on the same registry interface, that {@code PqAnchorProducer.apply}
+ * makes when it decides which heard seals may enter a certificate. Nothing about a seal is trusted
+ * because it was on disk.
+ *
+ * <p>HEIGHT-RESOLVED LOOKUP (2026-08-06): it now carries the HEIGHT the seals belong to. The
+ * adversarial review of
+ * 2026-08-02 measured that every registry question in this stack was height-less, so a restart
+ * after a key rotation re-checked seals over an old block against today's keys and dropped them
+ * all as forged. Here the height is not in doubt: the caller has already refused the file unless
+ * the stored block number and hash equal this node's head.
+ *
+ * @param registry the anchored Falcon registry
+ * @param blockNumber the height of the block the stored seals attest
+ * @param message the message M over the block the seals attest
+ * @param validatorIndex the seal's registry index
+ * @param signature the Falcon-512 signature bytes
+ * @return true iff the signature verifies
+ */
+ private static boolean sealVerifiesAgainstAnchoredRegistry(
+ final PqSignerRegistry registry,
+ final long blockNumber,
+ final Bytes32 message,
+ final int validatorIndex,
+ final Bytes signature) {
+ return registry.verifyAtOwnHead(blockNumber, validatorIndex, message, signature);
+ }
+}
diff --git a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqSignerRegistry.java b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqSignerRegistry.java
new file mode 100755
index 000000000..3646b9a22
--- /dev/null
+++ b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqSignerRegistry.java
@@ -0,0 +1,172 @@
+/*
+ * Copyright contributors to Besu / Aere Network.
+ *
+ * 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 org.hyperledger.besu.datatypes.Address;
+
+import org.apache.tuweni.bytes.Bytes;
+
+/**
+ * The index-to-signer view the anchor seals rule needs: which validator ADDRESS a registry index
+ * belongs to, and whether a Falcon signature by that index verifies over a message.
+ *
+ * <p>This exists as an interface for one reason that matters and one that is convenience. The one
+ * that matters: header validation must be able to state its dependency on the registry explicitly,
+ * so the registry can be bound to consensus (genesis {@code pqRegistryHash}) rather than picked up
+ * implicitly from a process-wide singleton whose contents are a local file. The convenience: a rule
+ * with an injectable registry can be unit-tested without standing up the whole Falcon subsystem.
+ *
+ * <p>The production implementation is {@link #falconSealSupport()}, which resolves {@link
+ * FalconSealSupport#instance()} LAZILY, per call. Lazily matters: constructing a rule must not
+ * trigger the Falcon subsystem's start-up guards, because rules are constructed while the protocol
+ * schedule is being built and the ONE place that deliberately forces those guards early is {@code
+ * QbftBlockHeaderValidationRulesetFactory}, which does it explicitly.
+ */
+public interface PqSignerRegistry {
+
+ /**
+ * HEIGHT-INDEXED REGISTRY, LOOKUP HARDENING (a). The validator address bound to a registry index
+ * AT A HEIGHT.
+ *
+ * <p>WHY THERE IS NO HEIGHT-LESS FORM HERE, and why this is the change and not a nicety. Until
+ * 2026-08-06 this interface carried BOTH {@code addressForIndex(int)} and a {@code
+ * addressForIndexAt(long,int)} whose body was {@code default { return addressForIndex(idx); }}.
+ * That default is precisely the defect an adversarial review of 2026-08-02 measured: a registry
+ * has no height argument, so a header that verified yesterday is refused the moment index 0's
+ * Falcon key is rotated. The default made the defect INVISIBLE TO ITS OWN PROOF - that review's
+ * probe injected a registry that overrides only the height-less pair, inherits the default, and
+ * therefore returns exactly the same verdict on repaired and unrepaired code. A probe that cannot
+ * go red is not a probe.
+ *
+ * <p>So the height-less pair is DELETED rather than deprecated, and both survivors are abstract.
+ * The compiler is now the negative control: any implementation, test double included, that cannot
+ * answer "at which height" fails to compile. The signing side keeps its height-less identity, but
+ * on {@link FalconSealSupport} and under a name that cannot be mistaken for a verification path -
+ * see {@code FalconSealSupport.localSigningAddress()}.
+ *
+ * <p>LOOKUP HARDENING (b-v2), 2026-08-06: this is the HISTORY half of the pair. It is reachable
+ * only from the two header-validation rules, and it REFUSES an unbound height at or above the
+ * arming height. The own-head half is {@link #addressForIndexAtOwnHead}, which carries the
+ * measurement that forced the split.
+ *
+ * @param blockNumber the height of the header being validated
+ * @param validatorIndex the index carried by a Falcon seal
+ * @return the bound address, or null when the index is unregistered at that height, the registry
+ * active there is not address-bound, or no registry is covered there at all
+ */
+ Address addressForIndexAtHistoric(long blockNumber, int validatorIndex);
+
+ /**
+ * LOOKUP HARDENING (b-v2). The address bound to a registry index at a height, asked about THIS
+ * NODE'S
+ * OWN HEAD: a block this node is building, or the head it has just restarted onto.
+ *
+ * <p>WHY THIS SECOND NAME EXISTS, and it is a measurement and not a taste. The first shape of
+ * hardening (b) refused every unbound height at or above the arming height and decided that from
+ * the block NUMBER alone. On 2026-08-06 that turned six tests red - five in {@code
+ * PqSealPersistenceTest}, the restart path, and one in {@code PqForkValidatorSetChangeTest}, the
+ * proposer - and that test's message states the operational consequence in one line: the node
+ * stops producing blocks. In all six the number handed to the guard was 1030 with an arming
+ * height of 1000, which is exactly what a genuinely historical question at the same instant would
+ * hand it.
+ * There is no arithmetic on the height that separates the two. What separates them is WHO SUPPLIES
+ * THE SUBJECT, and that is known at every call site and was being thrown away at the boundary.
+ *
+ * <p>THIS pair does not refuse for a missing height binding, because at this node's own head the
+ * head registry IS the answer by construction, and a wrong local acceptance cannot manufacture
+ * history: the certificate is re-checked by the other six through {@code PqAnchorSealsRule}, on
+ * the HISTORIC pair, at the parent's height. The cost of a mistake here is one lost round, not a
+ * branch. It still returns null when a schedule IS configured and covers this height with a
+ * registry this node does not hold - that is not a missing binding, it is a node running a
+ * registry the chain has moved off, and that must fail closed on every path.
+ *
+ * @param blockNumber this node's own head, or the block it is building
+ * @param validatorIndex the registry index
+ * @return the bound address, or null
+ */
+ Address addressForIndexAtOwnHead(long blockNumber, int validatorIndex);
+
+ /**
+ * HEIGHT-INDEXED REGISTRY, LOOKUP HARDENING (a). Verify a Falcon signature by a registry index AT
+ * A HEIGHT. Must never
+ * throw. Abstract for the reason given on {@link #addressForIndexAtHistoric}.
+ *
+ * @param blockNumber the height of the header carrying the seal
+ * @param validatorIndex the signer's registry index
+ * @param message the 32-byte message M that was signed
+ * @param signature the Falcon-512 signature bytes
+ * @return true iff a public key exists for the index at that height and the signature verifies
+ */
+ boolean verifyAtHistoric(long blockNumber, int validatorIndex, Bytes message, Bytes signature);
+
+ /**
+ * LOOKUP HARDENING (b-v2). Verify a Falcon signature over a block THIS NODE holds as its own head
+ * or
+ * is building right now. Never refuses for a missing height binding; see {@link
+ * #addressForIndexAtOwnHead} for the measurement that forced the split and for what it still does
+ * refuse.
+ *
+ * @param blockNumber this node's own head, or the block it is building
+ * @param validatorIndex the signer's registry index
+ * @param message the 32-byte message M that was signed
+ * @param signature the Falcon-512 signature bytes
+ * @return true iff a key exists for the index there and the signature verifies
+ */
+ boolean verifyAtOwnHead(long blockNumber, int validatorIndex, Bytes message, Bytes signature);
+
+ /**
+ * The production registry, backed by the Falcon subsystem singleton and resolved per call.
+ *
+ * @return a registry view over {@link FalconSealSupport#instance()}
+ */
+ static PqSignerRegistry falconSealSupport() {
+ return new PqSignerRegistry() {
+ @Override
+ public Address addressForIndexAtHistoric(final long blockNumber, final int validatorIndex) {
+ return FalconSealSupport.instance().addressForIndexAtHistoric(blockNumber, validatorIndex);
+ }
+
+ @Override
+ public boolean verifyAtHistoric(
+ final long blockNumber,
+ final int validatorIndex,
+ final Bytes message,
+ final Bytes signature) {
+ return FalconSealSupport.instance()
+ .verifyAtHistoric(blockNumber, validatorIndex, message, signature);
+ }
+
+ @Override
+ public Address addressForIndexAtOwnHead(final long blockNumber, final int validatorIndex) {
+ return FalconSealSupport.instance().addressForIndexAtOwnHead(blockNumber, validatorIndex);
+ }
+
+ @Override
+ public boolean verifyAtOwnHead(
+ final long blockNumber,
+ final int validatorIndex,
+ final Bytes message,
+ final Bytes signature) {
+ return FalconSealSupport.instance()
+ .verifyAtOwnHead(blockNumber, validatorIndex, message, signature);
+ }
+
+ @Override
+ public String toString() {
+ return "FalconSealSupport";
+ }
+ };
+ }
+}
diff --git a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/blockcreation/BftBlockCreatorFactory.java b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/blockcreation/BftBlockCreatorFactory.java
index 20c072202..1ecdcbf34 100644
--- a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/blockcreation/BftBlockCreatorFactory.java
+++ b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/blockcreation/BftBlockCreatorFactory.java
@@ -11,6 +11,12 @@
* specific language governing permissions and limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
+ *
+ * Modifications Copyright contributors to the Aere Network.
+ *
+ * This file was modified by contributors to the Aere Network, as required by section 4(b) of the
+ * Apache License 2.0. The copyright header above is the upstream one and is left exactly as it was
+ * found, as section 4(c) requires. The change itself is in patches/0003-aere-pq-anchor.patch.
*/
package org.hyperledger.besu.consensus.common.bft.blockcreation;
@@ -157,6 +163,23 @@ public class BftBlockCreatorFactory<T extends BftConfigOptions> {
* @return the bytes
*/
public Bytes createExtraData(final int round, final BlockHeader parentHeader) {
+ return bftExtraDataCodec.encode(buildExtraData(round, parentHeader));
+ }
+
+ /**
+ * Build the extra data VALUE for a block on this parent, before it is encoded.
+ *
+ * <p>AERE ANCHOR V2: split out of {@link #createExtraData(int, BlockHeader)} with NO behaviour
+ * change, so a subclass can post-process the value (QBFT rewrites vanityData with the anchor
+ * digest and fills element 6 with the parent's certificate) without decoding and
+ * re-encoding bytes it just produced. IBFT, which shares this class, keeps the old path exactly:
+ * it never overrides this and never sees the anchor.
+ *
+ * @param round the round
+ * @param parentHeader the parent header
+ * @return the extra data value
+ */
+ protected BftExtraData buildExtraData(final int round, final BlockHeader parentHeader) {
final BftContext bftContext = protocolContext.getConsensusContext(BftContext.class);
final ValidatorProvider validatorProvider = bftContext.getValidatorProvider();
Optional<VoteProvider> voteProviderAfterBlock =
@@ -177,7 +200,7 @@ public class BftBlockCreatorFactory<T extends BftConfigOptions> {
round,
validators);
- return bftExtraDataCodec.encode(extraData);
+ return extraData;
}
/**
diff --git a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/blockcreation/PqAnchorProducer.java b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/blockcreation/PqAnchorProducer.java
new file mode 100755
index 000000000..8ff0f9d97
--- /dev/null
+++ b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/blockcreation/PqAnchorProducer.java
@@ -0,0 +1,347 @@
+/*
+ * Copyright contributors to Besu / Aere Network.
+ *
+ * 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.blockcreation;
+
+import org.hyperledger.besu.consensus.common.bft.BftContext;
+import org.hyperledger.besu.consensus.common.bft.BftExtraData;
+import org.hyperledger.besu.consensus.common.bft.FalconSeal;
+import org.hyperledger.besu.consensus.common.bft.FalconSealSupport;
+import org.hyperledger.besu.consensus.common.bft.PqAnchor;
+import org.hyperledger.besu.consensus.common.bft.PqAnchorConfig;
+import org.hyperledger.besu.consensus.common.bft.PqAnchorNotReadyException;
+import org.hyperledger.besu.consensus.common.bft.PqSealCache;
+import org.hyperledger.besu.consensus.common.bft.PqSignerRegistry;
+import org.hyperledger.besu.datatypes.Address;
+import org.hyperledger.besu.ethereum.ProtocolContext;
+import org.hyperledger.besu.ethereum.core.BlockHeader;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import org.apache.tuweni.bytes.Bytes32;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * AERE ANCHOR V2, THE PRODUCER SIDE: the proposer of block N chooses the certificate over
+ * block N-1 out of what IT heard, writes it into element 6 of N's extraData, and writes the anchor
+ * digest D of that certificate into all 32 bytes of N's vanityData.
+ *
+ * <p>WHY THE OBJECT MOVED. Option V summarised the certificate sitting in the PARENT's header.
+ * Measured on the live chain, that object is not unique: over 200 consecutive headers read
+ * simultaneously from two nodes with identical block hashes, seal ORDER differed on 68 and the seal
+ * SET differed on 11, because RoundState keeps commits in a LinkedHashMap keyed on author and each
+ * node writes what IT heard. V2 summarises an object ONE node writes, which reaches everybody as
+ * the same bytes inside the same proposal. This class is that one node's choice.
+ *
+ * <p>THE ONE PLACE THE CERTIFICATE IS CHOSEN. Which heard seals are eligible, which of them
+ * actually verify, whether there are enough of them, and what D is, are all decided here. Keeping
+ * it in one method is not tidiness, it is the correctness argument: the decision "can I propose"
+ * and the bytes that go into the header are computed by the SAME code on the SAME inputs, so a
+ * proposal can never be emitted that this node's own arithmetic already rejected. A verdict
+ * computed by a different path than the run is the exact defect class this project keeps having to
+ * fix.
+ *
+ * <p>WHAT IS SELECTED, in order, and each step is the mirror of a validator-side rule:
+ *
+ * <ol>
+ * <li>every seal heard for the parent, sorted by validator index (rule: strictly increasing);
+ * <li>dropped unless the index maps, through the address-bound Falcon registry, to an address in
+ * the validator set FOR THE PARENT (rule: eligible signer);
+ * <li>dropped unless the Falcon signature verifies over M(parent) (rule: k verifications);
+ * <li>refused entirely if fewer than K(N) survive (rule: k &gt;= K).
+ * </ol>
+ *
+ * <p>SO THE PROPOSER NEVER EMITS A SEAL IT HAS NOT ITSELF VERIFIED. The cost is k Falcon
+ * verifications per proposal, measured median 0.068 ms and p95 0.249 ms each, so five of them are
+ * about 1.3 ms against a measured 517 ms block interval.
+ *
+ * <p>THE PROPOSER NEVER COMPARES ANYTHING WITH WHAT ITS PEERS HEARD, and nothing downstream does
+ * either. That asymmetry is the whole reason V2 works where option V could not: a node that heard
+ * seven seals accepts a five-seal certificate written by a node that heard five, because Falcon
+ * verification is public.
+ */
+public final class PqAnchorProducer {
+
+ private static final Logger LOG = LoggerFactory.getLogger(PqAnchorProducer.class);
+
+ private static final AtomicBoolean LOGGED_ACTIVATION_THRESHOLD = new AtomicBoolean(false);
+
+ private static volatile PqAnchorConfig config;
+
+ private PqAnchorProducer() {}
+
+ /**
+ * The activation configuration this process produces blocks under.
+ *
+ * <p>Read once from system configuration and memoised. It is deliberately a process-wide value
+ * rather than an injected one: the two producer entry points that need it (the QBFT block creator
+ * factory and the sealing path in {@code QbftRound}) sit in different modules with no shared
+ * construction site, and inventing one would mean threading a new argument through the QBFT round
+ * factory for no gain. The honest cost is that this cannot be varied per instance inside one JVM,
+ * which only matters for tests; {@link #useConfigForTesting(PqAnchorConfig)} covers that.
+ *
+ * @return the anchor configuration
+ */
+ public static PqAnchorConfig config() {
+ PqAnchorConfig local = config;
+ if (local == null) {
+ synchronized (PqAnchorProducer.class) {
+ local = config;
+ if (local == null) {
+ local = PqAnchorConfig.fromSystemConfiguration();
+ warnIfActivationHeightCanRefuse(local);
+ config = local;
+ }
+ }
+ }
+ return local;
+ }
+
+ /**
+ * Override the memoised configuration. For tests, and for a controller builder that wants to feed
+ * a genesis-derived configuration in before the first block is produced.
+ *
+ * @param replacement the configuration to use, or null to force a re-read
+ */
+ public static void useConfigForTesting(final PqAnchorConfig replacement) {
+ synchronized (PqAnchorProducer.class) {
+ config = replacement;
+ LOGGED_ACTIVATION_THRESHOLD.set(false);
+ }
+ }
+
+ /**
+ * Whether a Falcon seal produced FOR this block must be signed over the V2 message M rather than
+ * over the legacy ECDSA committed-seal hash.
+ *
+ * <p>ONE BLOCK EARLIER than {@link PqAnchorConfig#activeAt(long)}, and that offset is the whole
+ * bootstrap of the scheme: the certificate carried by block H is made of seals over block H-1, so
+ * H-1 must already be sealed in the new form. Every node flips at the same height because the
+ * height is a pure function of the same configured H.
+ *
+ * @param blockNumber the number of the block being sealed
+ * @return true iff the seal must be over M
+ */
+ public static boolean sealMessageIsAnchorForm(final long blockNumber) {
+ final PqAnchorConfig cfg = config();
+ // DELIBERATELY LEFT UNTOUCHED BY THE INTERVAL, 2026-08-07. With an interval in force,
+ // seals made at heights that feed no anchor reach no certificate at all, so they are WASTE.
+ // The temptation is to stop signing them. It is not done here, and the reason is that this
+ // method decides the FORM of the seal: if the form depended on the interval, two nodes
+ // reading the interval differently would sign different forms and would no longer recognise
+ // each other. The form stays a function of H alone, which is the one value the fleet
+ // coordinates anyway.
+ //
+ // The waste remains, and it is BANDWIDTH, not DISK: the seals still travel on the Commit
+ // messages of every block, they simply no longer reach any header. The disk, which was the
+ // problem, drops by a factor of <interval>. Cutting the pointless signing is done separately,
+ // at the attachment gate, where it cannot change the form.
+ return cfg.everActive() && blockNumber + 1L >= cfg.anchorBlock();
+ }
+
+ /**
+ * Return the extra data the proposer should encode for a block on top of this parent.
+ *
+ * <p>Below the activation height the input is returned UNCHANGED, so pre-fork behaviour is
+ * identical byte for byte and the 11.8 million existing blocks stay untouched. The gate is on the
+ * block NUMBER, never on content.
+ *
+ * @param base the extra data the unmodified Besu path would have produced
+ * @param parentHeader the parent header, whose number and ON-CHAIN hash bind the certificate
+ * @param protocolContext the protocol context, used only to read the validator set for the parent
+ * @return either {@code base} itself, or a copy carrying the certificate and the anchor digest
+ * @throws PqAnchorNotReadyException when the staged threshold cannot be met, meaning this node
+ * must not propose at all this round
+ */
+ public static BftExtraData apply(
+ final BftExtraData base,
+ final BlockHeader parentHeader,
+ final ProtocolContext protocolContext) {
+
+ final PqAnchorConfig cfg = config();
+ final long blockNumber = parentHeader.getNumber() + 1L;
+ // THE SAME QUESTION BOTH RULES ASK, through the same method: anchorAppliesAt.
+ // The producer and the validator have to agree on which heights carry a certificate. If the
+ // producer wrote one where the rule does not ask for it, that would only be waste; if it
+ // skipped one where the rule DOES ask for it, every block it proposes would be rejected. So
+ // the same question is asked, not one that merely resembles it.
+ if (!cfg.anchorAppliesAt(blockNumber)) {
+ return base;
+ }
+
+ final long parentNumber = parentHeader.getNumber();
+ final Bytes32 message =
+ PqAnchor.commitMessage(cfg.chainId(), parentNumber, parentHeader.getHash().getBytes());
+
+ final List<FalconSeal> heard =
+ PqSealCache.instance().sealsFor(parentNumber, parentHeader.getHash());
+
+ final PqSignerRegistry registry = PqSignerRegistry.falconSealSupport();
+ final Set<Address> eligible = validatorsForParent(parentHeader, protocolContext);
+
+ final List<FalconSeal> certificate = new ArrayList<>();
+ final Set<Address> seen = new LinkedHashSet<>();
+ // Integer.MAX_VALUE means "no cap", i.e. exactly what every node did before 2026-08-07.
+ final int sealCap = cfg.maxSealsCarried().orElse(Integer.MAX_VALUE);
+ int rejectedIneligible = 0;
+ int rejectedInvalid = 0;
+ // HEIGHT-RESOLVED LOOKUP (2026-08-06): the proposer resolves keys AT THE PARENT'S HEIGHT, the
+ // same height R2 will use when it re-checks this certificate. Before this the producer asked a
+ // height-less registry while R2 asked a height-resolved one, so at a rotation height the two
+ // could disagree about which key set applies - the proposer would assemble a certificate the
+ // fleet then refuses, and the round would fail for a reason no log named. Building and
+ // validating now read the same question.
+ for (final FalconSeal seal : PqAnchor.sortedByIndex(heard)) {
+ // OWN-HEAD DOOR (b-v2): parentHeader is this node's own head - this method is
+ // reached only from the proposer, building the block on top of it. Refusing here for a
+ // missing schedule is what stopped block production in PqForkValidatorSetChangeTest.
+ final Address signer =
+ registry.addressForIndexAtOwnHead(parentNumber, seal.getValidatorIndex());
+ if (signer == null || !eligible.contains(signer) || !seen.add(signer)) {
+ rejectedIneligible++;
+ continue;
+ }
+ if (!registry.verifyAtOwnHead(
+ parentNumber, seal.getValidatorIndex(), message, seal.getSignature())) {
+ rejectedInvalid++;
+ continue;
+ }
+ certificate.add(seal);
+ // THE COST CAP. K is a FLOOR, not a ceiling: without this break the proposer writes every
+ // eligible seal it happened to hear, so a K=3 chain at N=7 carries four, five, six or seven.
+ // Measured 2026-08-07 on a live seven-node run with the threshold at 4: 42 blocks carried 4
+ // seals, 36 carried 5, 5 carried 6. At 666 bytes a seal that is roughly 1.7 times the header
+ // bytes the same chain would write capped at K, and the extra buys NOTHING: what a verifier
+ // demands is the threshold, not how many seals a proposer volunteers above it.
+ //
+ // The break is safe precisely because it is placed AFTER the eligibility and signature checks:
+ // every seal counted here has already been verified, so stopping at the cap can never leave
+ // the certificate short by including a bad one. And the cap can never be set below the highest
+ // scheduled K, because PqAnchorConfig.withMaxSealsCarried refuses that at startup.
+ //
+ // Unset means no cap, which is exactly the behaviour of every node built before today.
+ if (certificate.size() >= sealCap) {
+ break;
+ }
+ }
+
+ final int required = cfg.minSealsAt(blockNumber);
+ if (certificate.size() < required) {
+ throw new PqAnchorNotReadyException(
+ parentNumber,
+ certificate.size(),
+ required,
+ "Heard "
+ + heard.size()
+ + " seal(s) for parent hash "
+ + parentHeader.getHash()
+ + "; "
+ + rejectedIneligible
+ + " were not eligible signers (registry address-bound="
+ + FalconSealSupport.instance().addressBound()
+ + ", validators for the parent="
+ + eligible.size()
+ + ") and "
+ + rejectedInvalid
+ + " did not verify over M(parent). Schedule="
+ + cfg.minSealsSchedule()
+ + ". A node that has just restarted holds no seals until it takes part in one commit,"
+ + " which costs at most one proposer turn.");
+ }
+
+ // The list is already ascending by index and de-duplicated by signer address; assert the format
+ // invariant here rather than trusting the loop, because this is the exact property the digest
+ // rule and the seals rule both depend on.
+ if (!PqAnchor.hasStrictlyIncreasingIndices(certificate)) {
+ throw new IllegalStateException(
+ "AERE PQ-ANCHOR: assembled a certificate whose indices are not strictly increasing; "
+ + "refusing to write it. This is a bug in the producer, not a configuration fault.");
+ }
+
+ final Bytes32 digest =
+ PqAnchor.anchorDigest(
+ cfg.chainId(), parentNumber, parentHeader.getHash().getBytes(), certificate);
+
+ LOG.debug(
+ "AERE PQ-ANCHOR: block {} carries a {}-seal certificate over parent {} (K={}), vanity D={}",
+ blockNumber,
+ certificate.size(),
+ parentNumber,
+ required,
+ digest);
+
+ return new BftExtraData(
+ digest,
+ base.getSeals(),
+ base.getVote(),
+ base.getRound(),
+ base.getValidators(),
+ certificate);
+ }
+
+ /**
+ * The validator set FOR the parent block: the nodes that were entitled to seal it.
+ *
+ * <p>Taken for the PARENT, not for the block being built, because those are the nodes whose seals
+ * a certificate over the parent may contain. On a validator-set change the two sets differ, and
+ * using the wrong one would either admit a seal the validator side rejects or drop one it
+ * accepts.
+ */
+ private static Set<Address> validatorsForParent(
+ final BlockHeader parentHeader, final ProtocolContext protocolContext) {
+ final Collection<Address> validators =
+ protocolContext
+ .getConsensusContext(BftContext.class)
+ .getValidatorProvider()
+ .getValidatorsForBlock(parentHeader);
+ return new LinkedHashSet<>(validators);
+ }
+
+ /**
+ * The first stage must not be able to block anything. K at the activation height itself has to be
+ * 0, because block H's parent predates the fork and may carry no Falcon seals at all: measured,
+ * the chain has ZERO today, 0 of 46 and 0 of 40 sampled headers, with the RLP element absent
+ * entirely. A schedule that puts a positive threshold at H would make the proposer of H refuse,
+ * and every proposer after it, which is a halt at the activation height. This does not throw, it
+ * shouts: refusing to start on a threshold an operator may have chosen deliberately is not this
+ * class's decision to make.
+ */
+ private static void warnIfActivationHeightCanRefuse(final PqAnchorConfig cfg) {
+ if (!cfg.everActive() || !LOGGED_ACTIVATION_THRESHOLD.compareAndSet(false, true)) {
+ return;
+ }
+ final int atActivation = cfg.minSealsAt(cfg.anchorBlock());
+ if (atActivation > 0) {
+ LOG.error(
+ "AERE PQ-ANCHOR: the staged threshold at the ACTIVATION height {} is {}, not 0. Block H's "
+ + "parent predates the fork and may carry no Falcon seals at all, so every proposer "
+ + "at H would refuse and the chain would stop at exactly the height you armed it. "
+ + "Open the schedule with {}:0 and raise K only at a later step.",
+ cfg.anchorBlock(),
+ atActivation,
+ cfg.anchorBlock());
+ } else {
+ LOG.info(
+ "AERE PQ-ANCHOR: producer armed. H={}, K(H)=0 as required, schedule={}.",
+ cfg.anchorBlock(),
+ cfg.minSealsSchedule());
+ }
+ }
+}
diff --git a/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/tools/PqRegistryHashTool.java b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/tools/PqRegistryHashTool.java
new file mode 100755
index 000000000..d78385d93
--- /dev/null
+++ b/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/tools/PqRegistryHashTool.java
@@ -0,0 +1,231 @@
+/*
+ * 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.tools;
+
+import java.nio.file.Path;
+import java.nio.file.Paths;
+
+import org.hyperledger.besu.consensus.common.bft.PqRegistryHash;
+
+/**
+ * AERE GENESIS BINDING: compute the canonical hash of a Falcon validator registry so it can be put
+ * into genesis as {@code config.pqRegistryHash}.
+ *
+ * <p>This tool calls THE SAME code the startup guard calls. That is the whole point of it existing
+ * as a class inside {@code consensus:common} rather than as a shell script: a tool that computed the
+ * hash its own way would sooner or later disagree with the guard, and the operator would be told a
+ * number that does not let any node start.
+ *
+ * <pre>
+ * java -cp &lt;besu lib&gt; org.hyperledger.besu.consensus.common.bft.tools.PqRegistryHashTool \
+ * &lt;registry-file&gt; [--chain-id N] [--block H] [--quiet]
+ * </pre>
+ *
+ * <p>The registry file may be a legacy {@code .properties} registry, a bare JSON manifest, or a
+ * genesis file carrying {@code config.aereFalconRegistry}; the format is sniffed.
+ */
+public final class PqRegistryHashTool {
+
+ private PqRegistryHashTool() {}
+
+ /**
+ * Entry point.
+ *
+ * @param args registry file, then optional {@code --chain-id N}, {@code --block H}, {@code
+ * --quiet}
+ */
+ public static void main(final String[] args) {
+ String file = null;
+ long chainId = 2800L;
+ long block = 0L;
+ boolean quiet = false;
+ boolean chainIdGiven = false;
+
+ for (int i = 0; i < args.length; i++) {
+ switch (args[i]) {
+ case "--chain-id":
+ chainId = Long.parseLong(require(args, ++i, "--chain-id"));
+ chainIdGiven = true;
+ break;
+ case "--block":
+ block = Long.parseLong(require(args, ++i, "--block"));
+ break;
+ case "--quiet":
+ quiet = true;
+ break;
+ case "-h":
+ case "--help":
+ usage(System.out);
+ return;
+ default:
+ if (args[i].startsWith("--")) {
+ System.err.println("unknown option: " + args[i]);
+ usage(System.err);
+ System.exit(2);
+ }
+ file = args[i];
+ }
+ }
+ if (file == null) {
+ usage(System.err);
+ System.exit(2);
+ return;
+ }
+
+ final Path path = Paths.get(file);
+ final PqRegistryHash.Registry reg;
+ try {
+ reg = PqRegistryHash.loadAuto(path);
+ } catch (final PqRegistryHash.RegistryConfigException e) {
+ System.err.println(e.getMessage());
+ System.exit(3);
+ return;
+ }
+
+ // AERE CANONICAL FINGERPRINT (2026-08-06). hashFor, not hashV1, and this is the line an
+ // operator pastes into genesis. For a proof-bound (v2) registry the node's gate compares
+ // hashV2; printing hashV1 here gives the whole fleet a value NOTHING on a node ever computes.
+ // Measured on a network of seven on 2026-08-06: all seven start, all seven report the registry
+ // loaded, and the chain stops at H-1. A wrong instruction is more dangerous than a missing one.
+ //
+ // hashV1 is also useless as an epoch identifier, which is the other reason it cannot merely be
+ // kept alongside: the bind height is not in the v1 pre-image, so two rotation epochs of the same
+ // fleet produce the SAME v1 number.
+ final String canonical = PqRegistryHash.hashFor(reg, chainId);
+
+ if (quiet) {
+ System.out.println("0x" + canonical);
+ return;
+ }
+
+ System.out.println("AERE PQC GENESIS-BINDING - canonical Falcon registry hash");
+ System.out.println(" file : " + reg.sourcePath());
+ System.out.println(" source kind : " + reg.kind());
+ System.out.println(" entries : " + reg.count());
+ System.out.println(" address-bound : " + reg.addressBound());
+ System.out.println(" chainId : " + chainId + (chainIdGiven ? "" : " (default)"));
+ System.out.println(
+ " format : "
+ + (reg.proofBound()
+ ? "v2, proof-bound, bindHeight=" + reg.bindHeight()
+ : "v1, no binding proofs"));
+ System.out.println();
+ System.out.println(
+ " pqRegistryHash (canonical "
+ + (reg.proofBound() ? "v2" : "v1")
+ + ", THIS is the value for genesis):");
+ System.out.println(" 0x" + canonical);
+ if (reg.proofBound()) {
+ System.out.println();
+ System.out.println(
+ " AERE HEIGHT BINDING: schedule this registry at block "
+ + reg.bindHeight()
+ + " AND NOWHERE ELSE. Every row's possession proof and validator claim sign that");
+ System.out.println(
+ " height; a node handed this file at any other block refuses to start with");
+ System.out.println(" AERE-PQC-REG-BIND-08.");
+ }
+ System.out.println();
+ System.out.println(" legacy v0 concat hash (only for cross-checking the genesis alloc anchor");
+ System.out.println(" slot that FalconSealSupport.parseManifest already computes; NOT the value");
+ System.out.println(" pqRegistryHash is compared against):");
+ System.out.println(" 0x" + PqRegistryHash.hashV0Legacy(reg));
+ System.out.println();
+ System.out.println(" per-index fingerprints, keccak256(addr||pk) first 8 hex.");
+ System.out.println(" Paste these next to another node's to find a differing row; they are");
+ System.out.println(" digests, so they carry no key material:");
+ for (final PqRegistryHash.Entry e : reg.entries()) {
+ final byte[] a = e.address();
+ System.out.println(
+ " ["
+ + e.index()
+ + "] "
+ + PqRegistryHash.fingerprint(reg, e.index())
+ + " pkLen="
+ + e.publicKey().length
+ + (a == null ? "" : " addr=0x" + hex(a)));
+ }
+ System.out.println();
+ System.out.println(" Paste into genesis under \"config\":");
+ System.out.println();
+ // AERE HEIGHT BINDING: the recipe this tool prints has to be the recipe the node accepts.
+ // Since 2026-08-06 a proof-bound registry scheduled at a block other than its bindHeight is
+ // refused at startup, so printing one here would be manufacturing the configuration the node
+ // rejects - in a file that goes to all seven nodes at once.
+ final long at = block == 0L && reg.proofBound() ? reg.bindHeight() : block;
+ if (reg.proofBound() && at != reg.bindHeight()) {
+ System.err.println();
+ System.err.println(
+ "REFUSING TO PRINT A GENESIS FRAGMENT. --block "
+ + block
+ + " does not equal this registry's bindHeight "
+ + reg.bindHeight()
+ + ".");
+ System.err.println(
+ " Every row of this file carries a Falcon possession proof and an ECDSA claim, and both");
+ System.err.println(
+ " sign the bind height. Scheduling it from another block is an activation day no");
+ System.err.println(
+ " validator signed, and a node handed it refuses to start (AERE-PQC-REG-BIND-08).");
+ System.err.println(
+ " Either pass --block " + reg.bindHeight() + ", or re-run the key ceremony for block");
+ System.err.println(
+ " " + block + " - which produces a DIFFERENT hash, because the height is inside it.");
+ System.exit(4);
+ return;
+ }
+ if (at == 0L) {
+ System.out.println(" \"pqRegistryHash\": \"0x" + canonical + "\"");
+ System.out.println();
+ System.out.println(" (that binds from block 0. On a live chain use --block <H> instead, so");
+ System.out.println(" the existing history stays completely unbound.)");
+ } else {
+ System.out.println(" \"pqRegistryHash\": [");
+ System.out.println(" { \"block\": " + at + ", \"hash\": \"0x" + canonical + "\" }");
+ System.out.println(" ]");
+ System.out.println();
+ System.out.println(" Every height below " + at + " stays unbound, so nothing already on");
+ System.out.println(" the chain is affected. A later rotation is a further entry with a");
+ System.out.println(" STRICTLY greater block.");
+ }
+ }
+
+ private static String require(final String[] args, final int i, final String what) {
+ if (i >= args.length) {
+ System.err.println(what + " needs a value");
+ System.exit(2);
+ }
+ return args[i];
+ }
+
+ private static String hex(final byte[] b) {
+ final StringBuilder s = new StringBuilder(b.length * 2);
+ for (final byte x : b) {
+ s.append(Character.forDigit((x >> 4) & 0xf, 16)).append(Character.forDigit(x & 0xf, 16));
+ }
+ return s.toString();
+ }
+
+ private static void usage(final java.io.PrintStream o) {
+ o.println(
+ "usage: PqRegistryHashTool <registry-file> [--chain-id N] [--block H] [--quiet]\n"
+ + " <registry-file> a legacy .properties registry, a JSON manifest, or a genesis\n"
+ + " file carrying config.aereFalconRegistry. Format is sniffed.\n"
+ + " --chain-id N chain id bound into the hashed pre-image (default 2800)\n"
+ + " --block H emit the genesis snippet as a height-bound schedule at H\n"
+ + " --quiet print only 0x<hash>, for scripts\n"
+ + "exit: 0 ok, 2 usage, 3 the registry file was rejected");
+ }
+}
diff --git a/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/FalconAttachIntervalTest.java b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/FalconAttachIntervalTest.java
new file mode 100755
index 000000000..2d593d4e8
--- /dev/null
+++ b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/FalconAttachIntervalTest.java
@@ -0,0 +1,232 @@
+/*
+ * AERE, 2026-08-08. The interval and the cap on the path BEFORE the anchor is activated.
+ *
+ * WHY THIS EXISTS, and the cost that demanded it. On 8 August Falcon seal attachment was switched
+ * on across all seven validators of chain 2800. The header went from 525 to 3844 bytes, that is
+ * FIVE seals on EVERY block: more than seven times the previous header, every ~523 ms. On a chain
+ * with no transactions the headers are close to everything that gets written to disk, so that
+ * multiplier is exactly the multiplier of database growth, and it exceeds any reasonable
+ * provisioning.
+ *
+ * AND NOW THE PART THAT IS THE ACTUAL FINDING. The anchor producer already had both an interval and
+ * a cap, built and proven on 7 August. But the assembler that runs when the anchor is NOT armed had
+ * neither, and that is precisely the assembler that runs up to the activation height. The cost
+ * control existed and was out of reach exactly in the window where it was needed. `anchorInterval`
+ * on its own does not help: without `aere.pq.anchorBlock` it is completely inert, so there is no
+ * middle road.
+ *
+ * WHY IT IS SAFE, and this is the argument that has to hold, not the disk saving. The preimage of a
+ * Falcon seal contains `parentHash`, which covers the parent header, which covers the grandparent,
+ * and so on down to genesis. So ONE seal over block H transitively covers ALL the history below H.
+ * You do not need a signature on every block in order to defend every block. What you give up with
+ * interval N is exactly and only this: an adversary holding every ECDSA key and no Falcon key can
+ * rewrite up to N blocks back from the last anchor, instead of 1. The property is
+ *
+ * fork depth <= interval
+ *
+ * and it is a dial, not an accident. Algorand ships the same shape at 1 in 256 or rarer.
+ *
+ * Same property as in `PqAnchorIntervalTest`, a different code path. Two paths need two proofs:
+ * precisely because we had a proof on one of them only, we paid the full cost on the other.
+ */
+package org.hyperledger.besu.consensus.common.bft;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import java.util.OptionalInt;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+class FalconAttachIntervalTest {
+
+ private static final long ATTACH = 12_900_000L;
+ private static final String P_INTERVAL = "aere.falcon.attachInterval";
+ private static final String P_CAP = "aere.falcon.attachMaxSeals";
+
+ @AfterEach
+ void curata() {
+ System.clearProperty(P_INTERVAL);
+ System.clearProperty(P_CAP);
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // 1. THE COMPATIBILITY BOUNDARY. Unset means EVERY block, that is exactly today's behaviour. A
+ // cap or an interval shipped as a default would be a behaviour change on the live chain
+ // smuggled in through a version bump, and such a change is asked for, not shipped.
+ // ---------------------------------------------------------------------------------------------
+ @Test
+ void withNoIntervalEveryHeightCarriesACertificate() {
+ for (final long n : new long[] {ATTACH, ATTACH + 1, ATTACH + 2, ATTACH + 99, ATTACH + 100_000}) {
+ assertThat(FalconSealSupport.isAttachHeight(n, ATTACH, OptionalInt.empty()))
+ .as("height %d", n)
+ .isTrue();
+ }
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // 2. Below the attach height nothing is written, with or without an interval.
+ // ---------------------------------------------------------------------------------------------
+ @Test
+ void belowTheAttachHeightNothingIsWritten() {
+ for (final OptionalInt every :
+ new OptionalInt[] {OptionalInt.empty(), OptionalInt.of(1), OptionalInt.of(100)}) {
+ assertThat(FalconSealSupport.isAttachHeight(ATTACH - 1, ATTACH, every)).isFalse();
+ assertThat(FalconSealSupport.isAttachHeight(0, ATTACH, every)).isFalse();
+ }
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // 3. An interval of 1 is the same thing written out explicitly, and must behave like "unset".
+ // ---------------------------------------------------------------------------------------------
+ @Test
+ void anIntervalOfOneIsTheSameAsNoInterval() {
+ for (final long n : new long[] {ATTACH, ATTACH + 1, ATTACH + 7, ATTACH + 1000}) {
+ assertThat(FalconSealSupport.isAttachHeight(n, ATTACH, OptionalInt.of(1)))
+ .as("height %d", n)
+ .isEqualTo(FalconSealSupport.isAttachHeight(n, ATTACH, OptionalInt.empty()));
+ }
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // 4. THE NEGATIVE CONTROL OF THE PROOF. At interval 100 exactly one height in a hundred carries a
+ // certificate. We count, we do not sample: a proof that checked three heights I picked myself
+ // would pass even if the arithmetic were wrong between them.
+ // ---------------------------------------------------------------------------------------------
+ @Test
+ void anIntervalOfOneHundredCarriesExactlyOneHeightInOneHundred() {
+ int cu = 0;
+ for (long n = ATTACH; n < ATTACH + 10_000; n++) {
+ if (FalconSealSupport.isAttachHeight(n, ATTACH, OptionalInt.of(100))) {
+ cu++;
+ assertThat((n - ATTACH) % 100).as("height %d is not a multiple", n).isZero();
+ }
+ }
+ assertThat(cu).isEqualTo(100);
+
+ // and the same span of heights WITH NO interval, so the difference we are buying is visible
+ int fara = 0;
+ for (long n = ATTACH; n < ATTACH + 10_000; n++) {
+ if (FalconSealSupport.isAttachHeight(n, ATTACH, OptionalInt.empty())) {
+ fara++;
+ }
+ }
+ assertThat(fara).isEqualTo(10_000);
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // 5. The first attach height always carries a certificate, whatever the interval. If it were
+ // skipped, the arming evidence a restarted node looks for could be missing at the start.
+ // ---------------------------------------------------------------------------------------------
+ @Test
+ void theAttachHeightItselfAlwaysCarriesACertificate() {
+ for (final int every : new int[] {1, 2, 32, 100, 128}) {
+ assertThat(FalconSealSupport.isAttachHeight(ATTACH, ATTACH, OptionalInt.of(every)))
+ .as("interval %d", every)
+ .isTrue();
+ }
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // 6. THE UPPER LIMIT ON THE INTERVAL IS NOT A MATTER OF TASTE. It is the 128 block window in
+ // which a restarted validator looks for its evidence that the fleet is already armed. With a
+ // wider interval that window can contain no sealed header at all, and the node then refuses to
+ // start days later, on some other restart, on the host whose disk has just failed. The only
+ // place where the operator can still see that connection is right here, at configuration time.
+ // ---------------------------------------------------------------------------------------------
+ @Test
+ void anIntervalWiderThanTheRestartEvidenceWindowIsRefused() {
+ System.setProperty(P_INTERVAL, "129");
+ assertThatThrownBy(() -> FalconSealSupport.instance().attachInterval())
+ .isInstanceOf(FalconSealSupport.ActivationConfigException.class)
+ .hasMessageContaining("AERE-PQC-CFG-UNSAFE-09")
+ .hasMessageContaining("128");
+
+ // positive control on the SAME path: exactly 128 has to pass, otherwise the message above could
+ // be coming from anything else and the proof would not be measuring the boundary
+ System.setProperty(P_INTERVAL, "128");
+ assertThat(FalconSealSupport.instance().attachInterval()).hasValue(128);
+ }
+
+ @Test
+ void anIntervalBelowOneIsRefused() {
+ System.setProperty(P_INTERVAL, "0");
+ assertThatThrownBy(() -> FalconSealSupport.instance().attachInterval())
+ .isInstanceOf(FalconSealSupport.ActivationConfigException.class)
+ .hasMessageContaining("AERE-PQC-CFG-UNSAFE-09");
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // 7. A malformed interval STOPS startup. It does not fall back silently to "every block": that
+ // would leave the operator sure a disk control is in force while the disk fills at the old
+ // rate. It is exactly the shape of failure that has cost us the most, a green earned by not
+ // asking.
+ // ---------------------------------------------------------------------------------------------
+ @Test
+ void aMalformedIntervalRefusesToStartRatherThanSilentlyReverting() {
+ System.setProperty(P_INTERVAL, "one hundred");
+ assertThatThrownBy(() -> FalconSealSupport.instance().attachInterval())
+ .isInstanceOf(FalconSealSupport.ActivationConfigException.class)
+ .hasMessageContaining("AERE-PQC-CFG-SYNTAX-09");
+ }
+
+ @Test
+ void anUnsetIntervalMeansEveryBlock() {
+ assertThat(System.getProperty(P_INTERVAL)).isNull();
+ assertThat(FalconSealSupport.instance().attachInterval()).isEmpty();
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // 8. THE SEAL CAP. Unset means no cap. Zero is refused, because a certificate of zero seals is
+ // not a cheaper one, it is an absent one, and the road for that is to not attach at all.
+ // ---------------------------------------------------------------------------------------------
+ @Test
+ void theSealCapDefaultsToNoCapAndRefusesZero() {
+ assertThat(FalconSealSupport.instance().attachMaxSeals()).isEmpty();
+
+ System.setProperty(P_CAP, "3");
+ assertThat(FalconSealSupport.instance().attachMaxSeals()).hasValue(3);
+
+ System.setProperty(P_CAP, "0");
+ assertThatThrownBy(() -> FalconSealSupport.instance().attachMaxSeals())
+ .isInstanceOf(FalconSealSupport.ActivationConfigException.class)
+ .hasMessageContaining("AERE-PQC-CFG-UNSAFE-10");
+
+ System.setProperty(P_CAP, "three");
+ assertThatThrownBy(() -> FalconSealSupport.instance().attachMaxSeals())
+ .isInstanceOf(FalconSealSupport.ActivationConfigException.class)
+ .hasMessageContaining("AERE-PQC-CFG-SYNTAX-10");
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // 9. THE COST, computed from the very arithmetic above and from the measurement on the live
+ // chain. This proof exists so that it breaks if someone changes the arithmetic and believes
+ // they only changed a detail. The figures: 525 bytes for the base header, ~662 per seal
+ // measured in extraData, 60,300,000 blocks per year.
+ // ---------------------------------------------------------------------------------------------
+ @Test
+ void theMeasuredCostOfEachSettingIsWhatWeToldTheFounder() {
+ final long blocuriPeAn = 60_300_000L;
+ final long octetiPeSigiliu = 662L;
+
+ assertThat(gbPeAn(5, 1, octetiPeSigiliu, blocuriPeAn)).isBetween(180L, 210L); // today
+ assertThat(gbPeAn(3, 1, octetiPeSigiliu, blocuriPeAn)).isBetween(105L, 125L); // cap only
+ assertThat(gbPeAn(3, 32, octetiPeSigiliu, blocuriPeAn)).isBetween(3L, 5L); // cap + 32
+ assertThat(gbPeAn(3, 100, octetiPeSigiliu, blocuriPeAn)).isBetween(1L, 2L); // cap + 100
+
+ // and the boundary that matters for any provisioning decision: starting from a fixed space
+ // budget, how many days each setting lasts. The budget below is a parameter of the proof, kept
+ // deliberately small so that the order of magnitude between the settings is visible.
+ assertThat(zile(12L, gbPeAn(5, 1, octetiPeSigiliu, blocuriPeAn))).isLessThan(30L);
+ assertThat(zile(12L, gbPeAn(3, 32, octetiPeSigiliu, blocuriPeAn))).isGreaterThan(700L);
+ }
+
+ private static long gbPeAn(
+ final int sigilii, final int interval, final long octetiPeSigiliu, final long blocuriPeAn) {
+ return (long) sigilii * octetiPeSigiliu * blocuriPeAn / interval / (1024L * 1024L * 1024L);
+ }
+
+ private static long zile(final long gbLiberi, final long gbPeAn) {
+ return gbPeAn == 0 ? Long.MAX_VALUE : gbLiberi * 365L / gbPeAn;
+ }
+}
diff --git a/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorConfigTest.java b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorConfigTest.java
new file mode 100755
index 000000000..0536d2ecb
--- /dev/null
+++ b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorConfigTest.java
@@ -0,0 +1,691 @@
+/*
+ * Copyright contributors to Besu / Aere Network.
+ *
+ * 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.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.OptionalInt;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/** The height gate, the staged threshold, and the two emergency de-arm controls. */
+public class PqAnchorConfigTest {
+
+ /** The activation height used throughout, so H appears as one number in every message. */
+ private static final long H = 10_141_734L;
+
+ /**
+ * A reader over two maps instead of over the JVM and the process environment.
+ *
+ * <p>Two reasons this exists rather than {@code System.setProperty} everywhere. First, hermetic
+ * tests: an operator or a CI image that happens to export {@code AERE_PQ_ANCHOR_BLOCK} would
+ * otherwise change what these tests measure, which is precisely the class of accident under
+ * examination. Second, it records every access, and the count of accesses is what proves that the
+ * unconfigured path decodes nothing.
+ */
+ private static final class RecordingReader implements PqAnchorConfig.NameReader {
+ private final Map<String, String> properties = new HashMap<>();
+ private final Map<String, String> environment = new HashMap<>();
+ private final List<String> accesses = new ArrayList<>();
+
+ @Override
+ public String property(final String name) {
+ accesses.add("property:" + name);
+ return properties.get(name);
+ }
+
+ @Override
+ public String environment(final String name) {
+ accesses.add("environment:" + name);
+ return environment.get(name);
+ }
+ }
+
+ /** The negative control: a reader that reports every name as absent, whatever is really set. */
+ private static final class BlindReader implements PqAnchorConfig.NameReader {
+ @Override
+ public String property(final String name) {
+ return null;
+ }
+
+ @Override
+ public String environment(final String name) {
+ return null;
+ }
+ }
+
+ private RecordingReader reader;
+
+ @BeforeEach
+ public void installReader() {
+ reader = new RecordingReader();
+ PqAnchorConfig.useNameReaderForTesting(reader);
+ }
+
+ @AfterEach
+ public void restoreReader() {
+ PqAnchorConfig.useNameReaderForTesting(null);
+ }
+
+ private void set(final String property, final String value) {
+ reader.properties.put(property, value);
+ }
+
+ private void setEnvironment(final String variable, final String value) {
+ reader.environment.put(variable, value);
+ }
+
+ /** A schedule that is correct: a step exactly at H, then two staged raises. */
+ private void armCorrectly() {
+ set(PqAnchorConfig.PROPERTY_CHAIN_ID, "2800");
+ set(PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, Long.toString(H));
+ set(PqAnchorConfig.PROPERTY_MIN_SEALS, H + ":0," + (H + 7200) + ":1," + (H + 14400) + ":3");
+ }
+
+ private static Map<Long, Integer> schedule() {
+ final Map<Long, Integer> steps = new LinkedHashMap<>();
+ steps.put(1000L, 0);
+ steps.put(2000L, 1);
+ steps.put(3000L, 3);
+ steps.put(4000L, 5);
+ return steps;
+ }
+
+ private static PqAnchorConfig armed() {
+ return new PqAnchorConfig(2800L, 1000L, schedule(), OptionalInt.empty(), false);
+ }
+
+ // ===============================================================================================
+ // 1. The value object. Unchanged behaviour, kept as the floor under everything below.
+ // ===============================================================================================
+
+ @Test
+ public void unconfiguredMeansInertAtEveryHeight() {
+ final PqAnchorConfig config = PqAnchorConfig.never(2800L);
+ assertThat(config.everActive()).isFalse();
+ assertThat(config.activeAt(0L)).isFalse();
+ assertThat(config.activeAt(11_800_000L)).isFalse();
+ assertThat(config.activeAt(Long.MAX_VALUE)).isFalse();
+ // And the legacy Falcon rule never retires, so the whole rule set behaves exactly as today.
+ assertThat(config.legacyFalconRuleRetirementBlock()).isEqualTo(Long.MAX_VALUE);
+ }
+
+ @Test
+ public void theGateIsOnTheBlockNumberAndNothingBelowHIsTouched() {
+ final PqAnchorConfig config = armed();
+ assertThat(config.activeAt(0L)).isFalse();
+ assertThat(config.activeAt(999L)).isFalse();
+ assertThat(config.activeAt(1000L)).isTrue();
+ assertThat(config.activeAt(1001L)).isTrue();
+ }
+
+ @Test
+ public void thresholdFollowsTheStagedScheduleAndIsZeroAtActivation() {
+ final PqAnchorConfig config = armed();
+ assertThat(config.minSealsAt(1000L)).isEqualTo(0);
+ assertThat(config.minSealsAt(1999L)).isEqualTo(0);
+ assertThat(config.minSealsAt(2000L)).isEqualTo(1);
+ assertThat(config.minSealsAt(2999L)).isEqualTo(1);
+ assertThat(config.minSealsAt(3000L)).isEqualTo(3);
+ assertThat(config.minSealsAt(4000L)).isEqualTo(5);
+ assertThat(config.minSealsAt(9_999_999L)).isEqualTo(5);
+ }
+
+ @Test
+ public void emergencyCeilingLowersTheThresholdAndCanNeverRaiseIt() {
+ // THE EMERGENCY CEILING, the only de-arm that works when the chain is ALREADY STOPPED: a halted
+ // chain cannot deliver a height-scheduled configuration change, so the control has to be local
+ // to the node.
+ final PqAnchorConfig lowered =
+ new PqAnchorConfig(2800L, 1000L, schedule(), OptionalInt.of(1), false);
+ assertThat(lowered.minSealsAt(4000L)).isEqualTo(1);
+ assertThat(lowered.minSealsAt(1000L)).isEqualTo(0);
+
+ final PqAnchorConfig raiseAttempt =
+ new PqAnchorConfig(2800L, 1000L, schedule(), OptionalInt.of(99), false);
+ assertThat(raiseAttempt.minSealsAt(2000L)).isEqualTo(1);
+ assertThat(raiseAttempt.minSealsAt(4000L)).isEqualTo(5);
+
+ final PqAnchorConfig floored =
+ new PqAnchorConfig(2800L, 1000L, schedule(), OptionalInt.of(0), false);
+ assertThat(floored.minSealsAt(4000L)).isEqualTo(0);
+ }
+
+ @Test
+ public void emergencyDisableSwitchesBothRulesOff() {
+ final PqAnchorConfig disabled =
+ new PqAnchorConfig(2800L, 1000L, schedule(), OptionalInt.empty(), true);
+ assertThat(disabled.everActive()).isFalse();
+ assertThat(disabled.activeAt(5000L)).isFalse();
+ assertThat(disabled.legacyFalconRuleRetirementBlock()).isEqualTo(Long.MAX_VALUE);
+ }
+
+ @Test
+ public void invalidConfigurationsAreRefusedAtConstruction() {
+ // H must leave room for a parent.
+ assertThatThrownBy(
+ () ->
+ new PqAnchorConfig(
+ 2800L, 0L, Collections.emptyMap(), OptionalInt.empty(), false))
+ .isInstanceOf(IllegalArgumentException.class);
+ // A threshold step cannot take effect before the rules do.
+ assertThatThrownBy(
+ () ->
+ new PqAnchorConfig(
+ 2800L, 1000L, Map.of(500L, 3), OptionalInt.empty(), false))
+ .isInstanceOf(IllegalArgumentException.class);
+ // A negative threshold is meaningless.
+ assertThatThrownBy(
+ () ->
+ new PqAnchorConfig(
+ 2800L, 1000L, Map.of(1000L, -1), OptionalInt.empty(), false))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+
+ // ===============================================================================================
+ // 2. THE COMPATIBILITY PROPERTY. This is the one the whole design is built around: a binary
+ // carrying this code, put on a live node that has none of these names set, must behave exactly
+ // as today. Not approximately. The assertion is on the ACCESS LOG, because "no decode, no
+ // allocation" is a claim about what the loader touched, not about what it returned.
+ // ===============================================================================================
+
+ @Test
+ public void withNoAnchorNameSetAnywhereTheLoaderTouchesNothingAndReturnsTodaysBehaviour() {
+ final PqAnchorConfig config = PqAnchorConfig.fromSystemConfiguration();
+
+ assertThat(config.everActive()).isFalse();
+ assertThat(config.anchorConfigured()).isFalse();
+ assertThat(config.disabled()).isFalse();
+ assertThat(config.chainId()).isEqualTo(0L);
+ assertThat(config.anchorBlock()).isEqualTo(PqAnchorConfig.NEVER);
+ assertThat(config.minSealsSchedule()).isEmpty();
+ assertThat(config.minSealsCeiling()).isEmpty();
+ assertThat(config.legacyFalconRuleRetirementBlock()).isEqualTo(PqAnchorConfig.NEVER);
+ assertThat(config.toString()).isEqualTo(PqAnchorConfig.never(0L).toString());
+ for (final long height : new long[] {0L, 1L, H - 1, H, H + 1, Long.MAX_VALUE}) {
+ assertThat(config.activeAt(height)).isFalse();
+ assertThat(config.minSealsAt(height)).isEqualTo(0);
+ assertThat(config.emergencyCeilingLowersAt(height)).isFalse();
+ }
+
+ // THE LOAD-BEARING ASSERTION of this test. SEVEN names, each probed exactly once as a system
+ // property and once as an environment variable, and then nothing: fourteen accesses, not
+ // fifteen. A loader that went any further and decoded would read the same names a second time
+ // and this number would be 28.
+ //
+ // IT WAS TEN UNTIL 2026-08-07, and it went up TWICE on the same day, for
+ // `aere.pq.anchor.maxSeals` and then for `aere.pq.anchorInterval`. Each time the test failed
+ // and forced somebody to look, which is exactly what it was asked to do. The number is raised
+ // DELIBERATELY, with the reason written next to it; the assertion is never weakened and never
+ // deleted.
+ //
+ // The consequence of adding a name, and it is intended: a node that has ONLY a cost control
+ // set, with no anchor height, is no longer "a node with no anchor configuration"; it becomes
+ // one with an incomplete configuration, and it refuses to start. A cap or an interval with no
+ // anchor means nothing, and it is better for it to shout than to keep quiet.
+ assertThat(reader.accesses).hasSize(14);
+ assertThat(reader.accesses)
+ .containsExactly(
+ "property:" + PqAnchorConfig.PROPERTY_ANCHOR_BLOCK,
+ "environment:" + PqAnchorConfig.ENV_ANCHOR_BLOCK,
+ "property:" + PqAnchorConfig.PROPERTY_MIN_SEALS,
+ "environment:" + PqAnchorConfig.ENV_MIN_SEALS,
+ "property:" + PqAnchorConfig.PROPERTY_CHAIN_ID,
+ "environment:" + PqAnchorConfig.ENV_CHAIN_ID,
+ "property:" + PqAnchorConfig.PROPERTY_MIN_SEALS_CEILING,
+ "environment:" + PqAnchorConfig.ENV_MIN_SEALS_CEILING,
+ // added 2026-08-07 together with the cost cap; the ORDER matters and it is the one in
+ // ANCHOR_NAMES, where maxSeals sits between the emergency ceiling and the disarm
+ "property:" + PqAnchorConfig.PROPERTY_MAX_SEALS,
+ "environment:" + PqAnchorConfig.ENV_MAX_SEALS,
+ "property:" + PqAnchorConfig.PROPERTY_ANCHOR_INTERVAL,
+ "environment:" + PqAnchorConfig.ENV_ANCHOR_INTERVAL,
+ "property:" + PqAnchorConfig.PROPERTY_DISABLE,
+ "environment:" + PqAnchorConfig.ENV_DISABLE);
+ }
+
+ // ===============================================================================================
+ // 3. THE ARMED PATH. A correct configuration still starts, and the threshold in force is the one
+ // that was asked for. Without this, "refuses everything" would score as a pass.
+ // ===============================================================================================
+
+ @Test
+ public void aCorrectConfigurationStartsAndTheThresholdIsTheOneThatWasAskedFor() {
+ armCorrectly();
+ final PqAnchorConfig config = PqAnchorConfig.fromSystemConfiguration();
+
+ assertThat(config.everActive()).isTrue();
+ assertThat(config.anchorConfigured()).isTrue();
+ assertThat(config.chainId()).isEqualTo(2800L);
+ assertThat(config.anchorBlock()).isEqualTo(H);
+ assertThat(config.activeAt(H - 1)).isFalse();
+ assertThat(config.activeAt(H)).isTrue();
+ assertThat(config.minSealsAt(H)).isEqualTo(0);
+ assertThat(config.minSealsAt(H + 7199)).isEqualTo(0);
+ assertThat(config.minSealsAt(H + 7200)).isEqualTo(1);
+ assertThat(config.minSealsAt(H + 14400)).isEqualTo(3);
+ assertThat(config.minSealsAt(H + 10_000_000)).isEqualTo(3);
+ assertThat(config.legacyFalconRuleRetirementBlock()).isEqualTo(H);
+ }
+
+ @Test
+ public void theEnvironmentFormIsReadTheSameWayAsTheProperty() {
+ setEnvironment(PqAnchorConfig.ENV_CHAIN_ID, "2800");
+ setEnvironment(PqAnchorConfig.ENV_ANCHOR_BLOCK, Long.toString(H));
+ setEnvironment(PqAnchorConfig.ENV_MIN_SEALS, H + ":3");
+ final PqAnchorConfig config = PqAnchorConfig.fromSystemConfiguration();
+ assertThat(config.everActive()).isTrue();
+ assertThat(config.minSealsAt(H)).isEqualTo(3);
+ }
+
+ @Test
+ public void aCorrectConfigurationReadThroughTheREALSystemPropertiesAlsoStarts() {
+ // The map reader above is hermetic, which is why it is used everywhere else. This one case goes
+ // through System.getProperty itself, so that the seam cannot be hiding a difference between the
+ // reader the tests use and the reader the node uses.
+ PqAnchorConfig.useNameReaderForTesting(null);
+ final String[] keys = {
+ PqAnchorConfig.PROPERTY_CHAIN_ID,
+ PqAnchorConfig.PROPERTY_ANCHOR_BLOCK,
+ PqAnchorConfig.PROPERTY_MIN_SEALS,
+ PqAnchorConfig.PROPERTY_MIN_SEALS_CEILING,
+ PqAnchorConfig.PROPERTY_DISABLE
+ };
+ final String[] saved = new String[keys.length];
+ for (int i = 0; i < keys.length; i++) {
+ saved[i] = System.getProperty(keys[i]);
+ }
+ try {
+ System.setProperty(PqAnchorConfig.PROPERTY_CHAIN_ID, "2800");
+ System.setProperty(PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, "12000000");
+ System.setProperty(
+ PqAnchorConfig.PROPERTY_MIN_SEALS, "12000000:0,12007200:1,12014400:3,12021600:5");
+ System.clearProperty(PqAnchorConfig.PROPERTY_MIN_SEALS_CEILING);
+ System.clearProperty(PqAnchorConfig.PROPERTY_DISABLE);
+
+ final PqAnchorConfig config = PqAnchorConfig.fromSystemConfiguration();
+ assertThat(config.chainId()).isEqualTo(2800L);
+ assertThat(config.anchorBlock()).isEqualTo(12_000_000L);
+ assertThat(config.activeAt(11_999_999L)).isFalse();
+ assertThat(config.activeAt(12_000_000L)).isTrue();
+ assertThat(config.minSealsAt(12_000_000L)).isEqualTo(0);
+ assertThat(config.minSealsAt(12_021_600L)).isEqualTo(5);
+
+ System.setProperty(PqAnchorConfig.PROPERTY_MIN_SEALS_CEILING, "1");
+ assertThat(PqAnchorConfig.fromSystemConfiguration().minSealsAt(12_021_600L)).isEqualTo(1);
+
+ System.setProperty(PqAnchorConfig.PROPERTY_DISABLE, "true");
+ final PqAnchorConfig off = PqAnchorConfig.fromSystemConfiguration();
+ assertThat(off.everActive()).isFalse();
+ assertThat(off.activeAt(12_021_600L)).isFalse();
+ // The anchor is still CONFIGURED, which is what keeps the per-block disarm shout alive.
+ assertThat(off.anchorConfigured()).isTrue();
+ } finally {
+ for (int i = 0; i < keys.length; i++) {
+ if (saved[i] == null) {
+ System.clearProperty(keys[i]);
+ } else {
+ System.setProperty(keys[i], saved[i]);
+ }
+ }
+ }
+ }
+
+ // ===============================================================================================
+ // 4. THE NINE WAYS THE ANCHOR USED TO GO QUIETLY DISARMED. One case each, every one of them a
+ // startup refusal now, and every assertion names the code and the field so that a message that
+ // stops telling the operator which knob to turn goes red here.
+ // ===============================================================================================
+
+ private void assertRefusesNaming(final String field) {
+ assertThatThrownBy(PqAnchorConfig::fromSystemConfiguration)
+ .isInstanceOf(FalconSealSupport.ActivationConfigException.class)
+ .hasMessageContaining(PqAnchorConfig.REFUSAL_CODE)
+ .hasMessageContaining("REFUSING TO START")
+ .hasMessageContaining("FIELD " + field)
+ .hasMessageContaining("never in parallel");
+ }
+
+ /** P1: the emptied shell variable. The quietest path in the whole file until today. */
+ @Test
+ public void p1_anEmptyAnchorBlockIsAMistakeNotAnAbsence() {
+ set(PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, "");
+ assertRefusesNaming(PqAnchorConfig.PROPERTY_ANCHOR_BLOCK);
+ }
+
+ /** P2: an unreadable H used to log a number nobody reads as "the anchor is off". */
+ @Test
+ public void p2_anUnparseableAnchorBlockRefusesInsteadOfRunningInert() {
+ set(PqAnchorConfig.PROPERTY_CHAIN_ID, "2800");
+ set(PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, "10_141_734");
+ set(PqAnchorConfig.PROPERTY_MIN_SEALS, H + ":3");
+ assertRefusesNaming(PqAnchorConfig.PROPERTY_ANCHOR_BLOCK);
+ }
+
+ /** P3: the discarded schedule. The node armed, and K was 0, and the banner said ARMED. */
+ @Test
+ public void p3_aBrokenStepNoLongerDiscardsTheWholeScheduleAndArmsAtZero() {
+ set(PqAnchorConfig.PROPERTY_CHAIN_ID, "2800");
+ set(PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, Long.toString(H));
+ set(PqAnchorConfig.PROPERTY_MIN_SEALS, H + ":3," + (H + 7200) + ":");
+ assertRefusesNaming(PqAnchorConfig.PROPERTY_MIN_SEALS);
+ assertThatThrownBy(PqAnchorConfig::fromSystemConfiguration)
+ .hasMessageContaining("has no threshold after the ':'");
+ }
+
+ /** P3, the other shape: one comma too many. */
+ @Test
+ public void p3_aStrayCommaIsARefusalNotASkippedStep() {
+ set(PqAnchorConfig.PROPERTY_CHAIN_ID, "2800");
+ set(PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, Long.toString(H));
+ set(PqAnchorConfig.PROPERTY_MIN_SEALS, H + ":3,," + (H + 7200) + ":4");
+ assertRefusesNaming(PqAnchorConfig.PROPERTY_MIN_SEALS);
+ }
+
+ /** P3, the emptiest shape: armed with no schedule at all means armed at K=0. */
+ @Test
+ public void p3_anArmedAnchorWithAnEmptyScheduleIsRefused() {
+ set(PqAnchorConfig.PROPERTY_CHAIN_ID, "2800");
+ set(PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, Long.toString(H));
+ set(PqAnchorConfig.PROPERTY_MIN_SEALS, "");
+ assertRefusesNaming(PqAnchorConfig.PROPERTY_MIN_SEALS);
+ }
+
+ /**
+ * P4: syntactically perfect, semantically wrong. One digit too many in the first height and K
+ * stays 0 for ten million blocks, with no log line and no guard anywhere. The rule that kills it
+ * is a property, not a heuristic: the threshold must be defined AT the activation height.
+ */
+ @Test
+ public void p4_aScheduleThatDoesNotDefineKAtTheActivationHeightIsRefused() {
+ set(PqAnchorConfig.PROPERTY_CHAIN_ID, "2800");
+ set(PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, Long.toString(H));
+ set(PqAnchorConfig.PROPERTY_MIN_SEALS, (H * 10) + ":3");
+ assertRefusesNaming(PqAnchorConfig.PROPERTY_MIN_SEALS);
+ assertThatThrownBy(PqAnchorConfig::fromSystemConfiguration)
+ .hasMessageContaining("no step at the activation height " + H);
+ }
+
+ /**
+ * P5: every value the constructor would have rejected used to be caught, logged once and turned
+ * into {@code never()}, so the node ran with the anchor completely inert and nothing said so
+ * again. All four shapes refuse now, and each one names the field that is actually wrong rather
+ * than the first field the loader happened to be holding.
+ */
+ @Test
+ public void p5_aConfigurationTheConstructorWouldRejectRefusesInsteadOfReturningNever() {
+ // H with no room for a parent.
+ set(PqAnchorConfig.PROPERTY_CHAIN_ID, "2800");
+ set(PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, "0");
+ set(PqAnchorConfig.PROPERTY_MIN_SEALS, "0:3");
+ assertRefusesNaming(PqAnchorConfig.PROPERTY_ANCHOR_BLOCK);
+
+ // A negative threshold.
+ installReader();
+ armCorrectly();
+ set(PqAnchorConfig.PROPERTY_MIN_SEALS, H + ":-1");
+ assertRefusesNaming(PqAnchorConfig.PROPERTY_MIN_SEALS);
+
+ // A step that would take effect before the rules do. This is the common one: H is pushed back
+ // and the schedule is left where it was.
+ installReader();
+ armCorrectly();
+ set(PqAnchorConfig.PROPERTY_MIN_SEALS, (H - 1000) + ":0," + H + ":3");
+ assertRefusesNaming(PqAnchorConfig.PROPERTY_MIN_SEALS);
+ }
+
+ /** P6: a negative ceiling disarmed the WHOLE anchor while the banner said "threshold capped". */
+ @Test
+ public void p6_aNegativeCeilingRefusesInsteadOfSilentlyKillingTheAnchor() {
+ armCorrectly();
+ set(PqAnchorConfig.PROPERTY_MIN_SEALS_CEILING, "-1");
+ assertRefusesNaming(PqAnchorConfig.PROPERTY_MIN_SEALS_CEILING);
+ }
+
+ /** P7: an unreadable ceiling was IGNORED, and announced as being in force anyway. */
+ @Test
+ public void p7_anUnparseableCeilingRefusesInsteadOfBeingIgnored() {
+ armCorrectly();
+ set(PqAnchorConfig.PROPERTY_MIN_SEALS_CEILING, "three");
+ assertRefusesNaming(PqAnchorConfig.PROPERTY_MIN_SEALS_CEILING);
+ }
+
+ /**
+ * P8: the Holesky field, by name. An absent chain id took the default zero, the domain separation
+ * left the D and M pre-images, and nothing anywhere refused. Holesky ran for months on exactly
+ * this before it lost finality for two weeks.
+ */
+ @Test
+ public void p8_anArmedAnchorWithoutAnExplicitChainIdIsRefused() {
+ set(PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, Long.toString(H));
+ set(PqAnchorConfig.PROPERTY_MIN_SEALS, H + ":3");
+ assertRefusesNaming(PqAnchorConfig.PROPERTY_CHAIN_ID);
+ }
+
+ /** P8, the explicit zero: written down, still meaningless. */
+ @Test
+ public void p8_anExplicitChainIdOfZeroIsRefused() {
+ set(PqAnchorConfig.PROPERTY_CHAIN_ID, "0");
+ set(PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, Long.toString(H));
+ set(PqAnchorConfig.PROPERTY_MIN_SEALS, H + ":3");
+ assertRefusesNaming(PqAnchorConfig.PROPERTY_CHAIN_ID);
+ }
+
+ /** A schedule with no activation height can never do anything, and used to say nothing. */
+ @Test
+ public void aScheduleWithoutAnActivationHeightIsRefused() {
+ set(PqAnchorConfig.PROPERTY_CHAIN_ID, "2800");
+ set(PqAnchorConfig.PROPERTY_MIN_SEALS, H + ":3");
+ assertRefusesNaming(PqAnchorConfig.PROPERTY_MIN_SEALS);
+ }
+
+ /** The same height twice with two different thresholds: whichever wins, it is not a decision. */
+ @Test
+ public void aDuplicateHeightWithTwoThresholdsIsRefused() {
+ set(PqAnchorConfig.PROPERTY_CHAIN_ID, "2800");
+ set(PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, Long.toString(H));
+ set(PqAnchorConfig.PROPERTY_MIN_SEALS, H + ":3," + H + ":5");
+ assertRefusesNaming(PqAnchorConfig.PROPERTY_MIN_SEALS);
+ }
+
+ // ===============================================================================================
+ // 5. THE BRAKE. It is the one control that has to work when the chain is already stopped, so it
+ // is the one place where "refuse to start" would be the wrong answer.
+ // ===============================================================================================
+
+ /** P9: Boolean.parseBoolean made "1", "yes" and "on" into a silent FALSE. A pressed brake. */
+ @Test
+ public void p9_theBrakeUnderstandsTheWordsAnOperatorActuallyTypes() {
+ for (final String yes : new String[] {"true", "TRUE", "1", "yes", "YES", "on", "On"}) {
+ installReader();
+ armCorrectly();
+ set(PqAnchorConfig.PROPERTY_DISABLE, yes);
+ final PqAnchorConfig config = PqAnchorConfig.fromSystemConfiguration();
+ assertThat(config.disabled()).as("disarm value %s", yes).isTrue();
+ assertThat(config.everActive()).isFalse();
+ assertThat(config.activeAt(H + 1)).isFalse();
+ // Still CONFIGURED, so PqEmergencyShoutRule keeps shouting at every block.
+ assertThat(config.anchorConfigured()).isTrue();
+ assertThat(config.anchorBlock()).isEqualTo(H);
+ }
+ for (final String no : new String[] {"false", "FALSE", "0", "no", "off"}) {
+ installReader();
+ armCorrectly();
+ set(PqAnchorConfig.PROPERTY_DISABLE, no);
+ final PqAnchorConfig config = PqAnchorConfig.fromSystemConfiguration();
+ assertThat(config.disabled()).as("disarm value %s", no).isFalse();
+ assertThat(config.everActive()).isTrue();
+ }
+ }
+
+ /** A brake value nobody can read is the one thing worse than no brake: refuse. */
+ @Test
+ public void p9_anUnreadableBrakeValueIsRefused() {
+ armCorrectly();
+ set(PqAnchorConfig.PROPERTY_DISABLE, "maybe");
+ assertRefusesNaming(PqAnchorConfig.PROPERTY_DISABLE);
+ }
+
+ /**
+ * THE BRAKE IS ABSOLUTE. A node whose anchor configuration is broken in every other way must
+ * still start when the operator has asked for the anchor to be off, because that request is the
+ * only way back from an activation that stopped the chain.
+ */
+ @Test
+ public void theBrakeStartsTheNodeEvenOverAConfigurationThatWouldOtherwiseBeRefused() {
+ set(PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, "");
+ set(PqAnchorConfig.PROPERTY_MIN_SEALS, "this,is,not,a,schedule");
+ set(PqAnchorConfig.PROPERTY_MIN_SEALS_CEILING, "-9");
+ set(PqAnchorConfig.PROPERTY_DISABLE, "true");
+
+ final PqAnchorConfig config = PqAnchorConfig.fromSystemConfiguration();
+ assertThat(config.everActive()).isFalse();
+ assertThat(config.activeAt(H)).isFalse();
+ // Honest cost, stated here so it is not discovered later: with no readable activation height
+ // there is nothing for the per-block disarm announcement to name, so it is lost in this corner.
+ assertThat(config.anchorConfigured()).isFalse();
+ }
+
+ // ===============================================================================================
+ // 6. THE CONTROLS. A gate that has never been shown to open and close is not a gate.
+ // ===============================================================================================
+
+ /**
+ * NEGATIVE CONTROL 1. Remove the guard: a reader whose presence probe answers "absent" for every
+ * name. Every refusal above must disappear and the loader must fall straight through to today's
+ * behaviour. If any of these still threw, the refusal would be coming from somewhere other than
+ * the presence gate and this whole file would be measuring the wrong thing.
+ */
+ @Test
+ public void negativeControl_withThePresenceGateRemovedEveryRefusalDisappears() {
+ final String[][] brokenConfigurations = {
+ {PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, ""},
+ {PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, "10_141_734"},
+ {PqAnchorConfig.PROPERTY_MIN_SEALS, H + ":3," + (H + 7200) + ":"},
+ {PqAnchorConfig.PROPERTY_MIN_SEALS, H + ":3,," + (H + 7200) + ":4"},
+ {PqAnchorConfig.PROPERTY_MIN_SEALS, (H * 10) + ":3"},
+ {PqAnchorConfig.PROPERTY_MIN_SEALS_CEILING, "-1"},
+ {PqAnchorConfig.PROPERTY_MIN_SEALS_CEILING, "three"},
+ {PqAnchorConfig.PROPERTY_CHAIN_ID, "0"},
+ {PqAnchorConfig.PROPERTY_DISABLE, "maybe"}
+ };
+
+ // First, with the gate in place, prove each one is genuinely red. A negative control over cases
+ // that were never red proves nothing at all.
+ for (final String[] broken : brokenConfigurations) {
+ installReader();
+ set(PqAnchorConfig.PROPERTY_CHAIN_ID, "2800");
+ set(PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, Long.toString(H));
+ set(PqAnchorConfig.PROPERTY_MIN_SEALS, H + ":3");
+ set(broken[0], broken[1]);
+ assertThatThrownBy(PqAnchorConfig::fromSystemConfiguration)
+ .as("with the gate in place: %s=%s", broken[0], broken[1])
+ .isInstanceOf(FalconSealSupport.ActivationConfigException.class);
+ }
+
+ // Now pull the guard out and watch every one of them go quiet.
+ PqAnchorConfig.useNameReaderForTesting(new BlindReader());
+ for (final String[] broken : brokenConfigurations) {
+ assertThatCode(PqAnchorConfig::fromSystemConfiguration)
+ .as("with the gate removed: %s=%s", broken[0], broken[1])
+ .doesNotThrowAnyException();
+ assertThat(PqAnchorConfig.fromSystemConfiguration().everActive())
+ .as("with the gate removed: %s=%s", broken[0], broken[1])
+ .isFalse();
+ }
+ }
+
+ /**
+ * NEGATIVE CONTROL 2, the sharper one. For every refusal above, the SAME configuration with only
+ * the one broken field corrected must start cleanly. Without this, a loader that refused
+ * absolutely everything would score full marks on the nine cases above.
+ */
+ @Test
+ public void negativeControl_correctingOnlyTheBrokenFieldMakesEachCaseStart() {
+ // field, the value that must refuse, the value that must start.
+ final String[][] pairs = {
+ {PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, "", Long.toString(H)},
+ {PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, "10_141_734", Long.toString(H)},
+ {PqAnchorConfig.PROPERTY_MIN_SEALS, H + ":3," + (H + 7200) + ":", H + ":3," + (H + 7200) + ":4"},
+ {PqAnchorConfig.PROPERTY_MIN_SEALS, H + ":3,," + (H + 7200) + ":4", H + ":3," + (H + 7200) + ":4"},
+ {PqAnchorConfig.PROPERTY_MIN_SEALS, (H * 10) + ":3", H + ":3," + (H * 10) + ":4"},
+ {PqAnchorConfig.PROPERTY_MIN_SEALS_CEILING, "-1", "2"},
+ {PqAnchorConfig.PROPERTY_MIN_SEALS_CEILING, "three", "2"},
+ {PqAnchorConfig.PROPERTY_CHAIN_ID, "0", "2800"},
+ {PqAnchorConfig.PROPERTY_DISABLE, "maybe", "false"}
+ };
+ for (final String[] pair : pairs) {
+ installReader();
+ armCorrectly();
+ set(pair[0], pair[1]);
+ assertThatThrownBy(PqAnchorConfig::fromSystemConfiguration)
+ .as("broken: %s=%s", pair[0], pair[1])
+ .isInstanceOf(FalconSealSupport.ActivationConfigException.class);
+
+ installReader();
+ armCorrectly();
+ set(pair[0], pair[2]);
+ final PqAnchorConfig config = PqAnchorConfig.fromSystemConfiguration();
+ assertThat(config.everActive()).as("corrected: %s=%s", pair[0], pair[2]).isTrue();
+ assertThat(config.anchorBlock()).isEqualTo(H);
+ }
+ }
+
+ /**
+ * POSITIVE CONTROL for the refusal message itself. The message is the whole product here: an
+ * operator at three in the morning gets one screen, and it has to name the field, the value read,
+ * the problem, the repair, and the sequencing rule that keeps a fleet restart from killing the
+ * chain at quorum 5 of 7.
+ */
+ @Test
+ public void theRefusalMessageCarriesEverythingAnOperatorNeedsAtThreeInTheMorning() {
+ set(PqAnchorConfig.PROPERTY_CHAIN_ID, "2800");
+ set(PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, Long.toString(H));
+ set(PqAnchorConfig.PROPERTY_MIN_SEALS, H + ":3," + (H + 7200) + ":");
+
+ assertThatThrownBy(PqAnchorConfig::fromSystemConfiguration)
+ .isInstanceOf(FalconSealSupport.ActivationConfigException.class)
+ .hasMessageContaining(PqAnchorConfig.REFUSAL_CODE)
+ .hasMessageContaining("FIELD " + PqAnchorConfig.PROPERTY_MIN_SEALS)
+ .hasMessageContaining("SOURCE system property (BESU_OPTS)")
+ .hasMessageContaining("READ \"" + H + ":3," + (H + 7200) + ":\"")
+ .hasMessageContaining("PROBLEM step \"" + (H + 7200) + ":\" has no threshold after the ':'")
+ .hasMessageContaining("EXPECTED block:threshold,block:threshold")
+ .hasMessageContaining("a step exactly at " + H)
+ .hasMessageContaining("FIX correct BESU_OPTS on THIS node")
+ .hasMessageContaining("restart one at a time")
+ .hasMessageContaining("At quorum 5 of 7 you lose the chain")
+ .hasMessageContaining("EMERGENCY " + PqAnchorConfig.PROPERTY_DISABLE + "=true");
+ }
+
+ /** And the origin is named honestly when the value came from the environment, not the command. */
+ @Test
+ public void theRefusalNamesTheEnvironmentWhenThatIsWhereTheValueCameFrom() {
+ setEnvironment(PqAnchorConfig.ENV_CHAIN_ID, "2800");
+ setEnvironment(PqAnchorConfig.ENV_ANCHOR_BLOCK, "not-a-number");
+ assertThatThrownBy(PqAnchorConfig::fromSystemConfiguration)
+ .hasMessageContaining("SOURCE environment variable " + PqAnchorConfig.ENV_ANCHOR_BLOCK);
+ }
+}
diff --git a/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorEmergencyConfigTest.java b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorEmergencyConfigTest.java
new file mode 100755
index 000000000..222f45b7d
--- /dev/null
+++ b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorEmergencyConfigTest.java
@@ -0,0 +1,120 @@
+/*
+ * Copyright contributors to Besu / Aere Network.
+ *
+ * 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 java.util.Map;
+import java.util.OptionalInt;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * AERE OPTIUNI-URGENTA: the arithmetic the two anchor emergency controls stand on.
+ *
+ * <p>The property that matters is not "the ceiling works". It is that the ceiling can only ever go
+ * ONE WAY. A control an operator reaches for at three in the morning to restart a stalled chain must
+ * not be capable of stopping one, so a ceiling ABOVE the scheduled threshold has to be inert rather
+ * than raising it. That is asserted here in both directions, and the announcement predicate is
+ * asserted alongside it, because a shout that fires when the control is doing nothing trains people
+ * to ignore the log.
+ */
+class PqAnchorEmergencyConfigTest {
+
+ private static final long CHAIN_ID = 2800L;
+ private static final long H = 1_000L;
+
+ private PqAnchorConfig config(final OptionalInt ceiling, final boolean disabled) {
+ return new PqAnchorConfig(
+ CHAIN_ID, H, Map.of(H, 0, H + 100L, 3, H + 200L, 5), ceiling, disabled);
+ }
+
+ @Test
+ void ceilingLowersTheThreshold() {
+ final PqAnchorConfig capped = config(OptionalInt.of(1), false);
+ assertThat(capped.scheduledMinSealsAt(H + 250L)).isEqualTo(5);
+ assertThat(capped.minSealsAt(H + 250L)).isEqualTo(1);
+ }
+
+ @Test
+ void ceilingCanNeverRaiseTheThreshold() {
+ final PqAnchorConfig capped = config(OptionalInt.of(99), false);
+ // Scheduled 0, 3 and 5 at the three steps; a ceiling of 99 must change NONE of them.
+ assertThat(capped.minSealsAt(H)).isEqualTo(0);
+ assertThat(capped.minSealsAt(H + 100L)).isEqualTo(3);
+ assertThat(capped.minSealsAt(H + 250L)).isEqualTo(5);
+ assertThat(capped.minSealsAt(H + 250L)).isEqualTo(capped.scheduledMinSealsAt(H + 250L));
+ }
+
+ @Test
+ void ceilingOfZeroRemovesTheThresholdEntirely() {
+ final PqAnchorConfig capped = config(OptionalInt.of(0), false);
+ assertThat(capped.minSealsAt(H + 250L)).isZero();
+ }
+
+ @Test
+ void theAnnouncementFiresOnlyWhereTheCeilingActuallyLowersSomething() {
+ final PqAnchorConfig capped = config(OptionalInt.of(3), false);
+ // Below H nothing is active at all, so there is nothing to announce.
+ assertThat(capped.emergencyCeilingLowersAt(H - 1L)).isFalse();
+ // At H the schedule asks for 0; a ceiling of 3 is carrying no weight there.
+ assertThat(capped.emergencyCeilingLowersAt(H)).isFalse();
+ // At H+100 the schedule asks for exactly 3; still no weight.
+ assertThat(capped.emergencyCeilingLowersAt(H + 100L)).isFalse();
+ // At H+200 the schedule asks for 5 and the ceiling holds it at 3. THAT is worth a line.
+ assertThat(capped.emergencyCeilingLowersAt(H + 250L)).isTrue();
+ }
+
+ @Test
+ void noCeilingMeansNoAnnouncementAnywhere() {
+ final PqAnchorConfig plain = config(OptionalInt.empty(), false);
+ assertThat(plain.emergencyCeilingLowersAt(H + 250L)).isFalse();
+ assertThat(plain.minSealsCeiling()).isEmpty();
+ }
+
+ @Test
+ void disarmedIsNotTheSameStateAsNeverConfigured() {
+ final PqAnchorConfig disarmed = config(OptionalInt.empty(), true);
+ assertThat(disarmed.disabled()).isTrue();
+ assertThat(disarmed.anchorConfigured()).isTrue();
+ assertThat(disarmed.everActive()).isFalse();
+ assertThat(disarmed.activeAt(H + 500L)).isFalse();
+
+ final PqAnchorConfig never = PqAnchorConfig.never(CHAIN_ID);
+ assertThat(never.disabled()).isFalse();
+ assertThat(never.anchorConfigured()).isFalse();
+ assertThat(never.everActive()).isFalse();
+ }
+
+ @Test
+ void aDisarmedNodeStillEnforcesNothingEvenWhereTheScheduleIsHighest() {
+ final PqAnchorConfig disarmed = config(OptionalInt.empty(), true);
+ assertThat(disarmed.activeAt(H + 250L)).isFalse();
+ // The announcement predicate is gated on activeAt, so a disarmed node announces the DISARM and
+ // not, additionally, a ceiling that is no longer reachable.
+ assertThat(disarmed.emergencyCeilingLowersAt(H + 250L)).isFalse();
+ }
+
+ @Test
+ void aNegativeCeilingIsRefusedRatherThanClampedSilently() {
+ assertThatThrownBy(
+ () ->
+ new PqAnchorConfig(
+ CHAIN_ID, H, Map.of(H, 3), OptionalInt.of(-1), false))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("must not be negative");
+ }
+}
diff --git a/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorIntervalTest.java b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorIntervalTest.java
new file mode 100755
index 000000000..08afdba0a
--- /dev/null
+++ b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorIntervalTest.java
@@ -0,0 +1,186 @@
+/*
+ * AERE, 2026-08-07. The anchor interval: a certificate on every Nth block instead of every block.
+ *
+ * WHY THIS EXISTS. At ~523 ms per block we produce 165,248 blocks per day, 23 times more than
+ * Ethereum. A certificate in EVERY block multiplies the header by almost five even with the cap at
+ * K=3, and on a chain with empty blocks the headers are close to everything that gets written to
+ * disk. So that multiplier is the multiplier of database growth, and the design does not fit on a
+ * reasonably provisioned node. The interval divides it by N.
+ *
+ * WHY IT IS SAFE, and this is the argument that has to hold, not the saving. Block hashes chain:
+ * block N+1 commits to the hash of N. So an anchor at height A, whose vanityData binds a Falcon
+ * certificate over A-1, protects EVERYTHING below A: rewriting any block below A changes the hash
+ * of A's parent, which requires A to be reproduced, which requires a Falcon quorum the adversary
+ * does not have. The only thing that stays rewritable is the TAIL since the last anchor. The
+ * property is therefore
+ *
+ * fork depth <= interval
+ *
+ * and it is a dial, not an accident. At 100 it means ~52 seconds of tail instead of ~0.5 seconds,
+ * and it costs one hundredth as much. Algorand ships the same shape at 1 in 256.
+ */
+package org.hyperledger.besu.consensus.common.bft;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import java.util.Map;
+import java.util.OptionalInt;
+import org.junit.jupiter.api.Test;
+
+class PqAnchorIntervalTest {
+
+ private static final long H = 1000L;
+
+ private static PqAnchorConfig armed() {
+ return new PqAnchorConfig(2800L, H, Map.of(H, 3), OptionalInt.empty(), false);
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // 1. THE COMPATIBILITY BOUNDARY. Unset means every block, that is the design as it stood until
+ // today. If this ever breaks, a node built from the new code no longer agrees with an old one.
+ // ---------------------------------------------------------------------------------------------
+ @Test
+ void withNoIntervalEveryHeightIsAnAnchorHeight() {
+ final PqAnchorConfig cfg = armed();
+ assertThat(cfg.anchorInterval()).isEmpty();
+ for (final long n : new long[] {H, H + 1, H + 2, H + 99, H + 100, H + 12345}) {
+ assertThat(cfg.isAnchorHeight(n)).as("height %d", n).isTrue();
+ assertThat(cfg.anchorAppliesAt(n)).as("height %d", n).isTrue();
+ }
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // 2. An interval of 1 is the same thing written out explicitly, and must behave identically.
+ // ---------------------------------------------------------------------------------------------
+ @Test
+ void anIntervalOfOneIsTheSameAsNoInterval() {
+ final PqAnchorConfig cfg = armed().withAnchorInterval(OptionalInt.of(1));
+ for (final long n : new long[] {H, H + 1, H + 7, H + 1000}) {
+ assertThat(cfg.isAnchorHeight(n)).as("height %d", n).isTrue();
+ }
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // 3. THE HEART. Counting starts at H, not at zero. H is the only height the fleet coordinates on,
+ // and a scheme whose first anchor falls somewhere else is a scheme nobody checks by hand on
+ // activation day.
+ // ---------------------------------------------------------------------------------------------
+ @Test
+ void anchorHeightsAreCountedFromHAndHItselfIsAlwaysOne() {
+ final PqAnchorConfig cfg = armed().withAnchorInterval(OptionalInt.of(100));
+
+ assertThat(cfg.isAnchorHeight(H)).as("H itself").isTrue();
+ assertThat(cfg.isAnchorHeight(H + 100)).isTrue();
+ assertThat(cfg.isAnchorHeight(H + 200)).isTrue();
+ assertThat(cfg.isAnchorHeight(H + 100000)).isTrue();
+
+ for (final long off : new long[] {1, 2, 50, 99, 101, 199}) {
+ assertThat(cfg.isAnchorHeight(H + off)).as("H+%d is not an anchor", off).isFalse();
+ assertThat(cfg.anchorAppliesAt(H + off)).as("H+%d is not judged", off).isFalse();
+ }
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // 4. Below H there is no anchor, however neatly the arithmetic falls. Without this, a height
+ // below H that happens to be a multiple would be judged, and a new binary would no longer
+ // behave like an old one on the history from before activation.
+ // ---------------------------------------------------------------------------------------------
+ @Test
+ void belowHNothingIsAnAnchorHeightHoweverTheArithmeticFalls() {
+ final PqAnchorConfig cfg = armed().withAnchorInterval(OptionalInt.of(100));
+ for (final long n : new long[] {0L, 1L, H - 200, H - 100, H - 1}) {
+ assertThat(cfg.isAnchorHeight(n)).as("height %d", n).isFalse();
+ assertThat(cfg.anchorAppliesAt(n)).as("height %d", n).isFalse();
+ }
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // 5. NEGATIVE CONTROL. Zero and negative are refused. An interval of 0 would give a division by
+ // zero, and a negative one would make the modulo arithmetic answer nonsense.
+ // ---------------------------------------------------------------------------------------------
+ @Test
+ void zeroAndNegativeIntervalsAreRefused() {
+ for (final int bad : new int[] {0, -1, -100}) {
+ assertThatThrownBy(() -> armed().withAnchorInterval(OptionalInt.of(bad)))
+ .as("interval %d", bad)
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining(PqAnchorConfig.PROPERTY_ANCHOR_INTERVAL)
+ .hasMessageContaining("at least 1");
+ }
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // 6. anchorAppliesAt is the AND of the two conditions, and the emergency disarm beats all of it.
+ // A disarm that let the anchor judge at the interval heights would not be a disarm.
+ // ---------------------------------------------------------------------------------------------
+ @Test
+ void theEmergencyDisarmBeatsTheIntervalEverywhere() {
+ final PqAnchorConfig dezarmat =
+ new PqAnchorConfig(2800L, H, Map.of(H, 3), OptionalInt.empty(), true)
+ .withAnchorInterval(OptionalInt.of(100));
+ assertThat(dezarmat.isAnchorHeight(H)).isTrue(); // the arithmetic says yes
+ assertThat(dezarmat.activeAt(H)).isFalse(); // but the scheme is switched off
+ assertThat(dezarmat.anchorAppliesAt(H)).isFalse(); // so nothing is judged
+ assertThat(dezarmat.anchorAppliesAt(H + 100)).isFalse();
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // 7. A node with no anchor at all has no anchor heights either. Without this, a configuration
+ // with no H but with an interval would answer "yes" to anything and would judge headers on a
+ // chain that was never activated.
+ // ---------------------------------------------------------------------------------------------
+ @Test
+ void aNodeWithNoAnchorAtAllHasNoAnchorHeights() {
+ final PqAnchorConfig niciodata = PqAnchorConfig.never(2800L).withAnchorInterval(OptionalInt.of(100));
+ for (final long n : new long[] {0L, 1L, H, H + 100, Long.MAX_VALUE - 1}) {
+ assertThat(niciodata.isAnchorHeight(n)).as("height %d", n).isFalse();
+ assertThat(niciodata.anchorAppliesAt(n)).as("height %d", n).isFalse();
+ }
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // 8. THE TWO COST CONTROLS DO NOT LOSE EACH OTHER. This is the mistake I made a point of
+ // catching: a copy method that forgets a field is how the cap or the interval would silently
+ // disappear when the other one is set, and nobody would notice until the disk bill.
+ // ---------------------------------------------------------------------------------------------
+ @Test
+ void theTwoCostControlsDoNotLoseEachOther() {
+ final PqAnchorConfig amandoua =
+ armed().withMaxSealsCarried(OptionalInt.of(3)).withAnchorInterval(OptionalInt.of(100));
+ assertThat(amandoua.maxSealsCarried()).hasValue(3);
+ assertThat(amandoua.anchorInterval()).hasValue(100);
+
+ // and in the reverse order
+ final PqAnchorConfig invers =
+ armed().withAnchorInterval(OptionalInt.of(100)).withMaxSealsCarried(OptionalInt.of(3));
+ assertThat(invers.maxSealsCarried()).hasValue(3);
+ assertThat(invers.anchorInterval()).hasValue(100);
+
+ // and the rest of the configuration survives both of them
+ assertThat(invers.chainId()).isEqualTo(2800L);
+ assertThat(invers.anchorBlock()).isEqualTo(H);
+ assertThat(invers.minSealsAt(H)).isEqualTo(3);
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // 9. THE PROPERTY, written as a proof and not merely as a sentence in a comment: between two
+ // consecutive anchors there are exactly <interval> heights, of which exactly one is judged.
+ // That IS the maximum fork depth, and we count it instead of asserting it.
+ // ---------------------------------------------------------------------------------------------
+ @Test
+ void betweenTwoAnchorsExactlyOneHeightIsJudged() {
+ final int interval = 100;
+ final PqAnchorConfig cfg = armed().withAnchorInterval(OptionalInt.of(interval));
+ int judecate = 0;
+ for (long n = H; n < H + interval; n++) {
+ if (cfg.anchorAppliesAt(n)) {
+ judecate++;
+ }
+ }
+ assertThat(judecate)
+ .as("exactly one height judged every %d, so the rewritable tail is %d blocks",
+ interval, interval)
+ .isEqualTo(1);
+ }
+}
diff --git a/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorMinSealsFloorTest.java b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorMinSealsFloorTest.java
new file mode 100755
index 000000000..53917eecb
--- /dev/null
+++ b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorMinSealsFloorTest.java
@@ -0,0 +1,153 @@
+/*
+ * AERE, 2026-08-07. THE THRESHOLD FLOOR: a schedule whose effective K is zero everywhere leaves the
+ * anchor armed and completely toothless, forever, and every tool reports GREEN the whole time it is
+ * happening, because they all measure what was ASKED FOR and the request is valid.
+ *
+ * The loader already guarded this consequence in its own words, "K would be 0 at every height
+ * and an ARMED node would accept empty certificates", but only for a MISSING schedule. A schedule
+ * that is PRESENT and of the form "<H>:0" reaches the same state, and it used to pass.
+ *
+ * And it is not theoretical: the recommended activation schedule has the form "<H>:0,<H+165000>:3",
+ * that is, it STARTS at zero, precisely in order to leave a warm-up window. If the second half is
+ * lost to a stray quote or a truncated variable, what remains is exactly the dangerous form, and
+ * that is why the floor looks at the WHOLE schedule.
+ */
+package org.hyperledger.besu.consensus.common.bft;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.api.Assertions.assertThatCode;
+
+import java.util.HashMap;
+import java.util.Map;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+class PqAnchorMinSealsFloorTest {
+
+ private static final long H = 13_793_219L;
+
+ private static final class Reader implements PqAnchorConfig.NameReader {
+ private final Map<String, String> properties = new HashMap<>();
+
+ @Override
+ public String property(final String name) {
+ return properties.get(name);
+ }
+
+ @Override
+ public String environment(final String name) {
+ return null;
+ }
+ }
+
+ private Reader reader;
+
+ @BeforeEach
+ void installReader() {
+ reader = new Reader();
+ PqAnchorConfig.useNameReaderForTesting(reader);
+ set(PqAnchorConfig.PROPERTY_CHAIN_ID, "2800");
+ set(PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, Long.toString(H));
+ }
+
+ @AfterEach
+ void restoreReader() {
+ PqAnchorConfig.useNameReaderForTesting(null);
+ }
+
+ private void set(final String property, final String value) {
+ reader.properties.put(property, value);
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // 1. THE DEFECT ITSELF. A schedule that never demands anything is refused at startup.
+ // ---------------------------------------------------------------------------------------------
+ @Test
+ void anArmedAnchorWhoseScheduleNeverDemandsASignatureIsRefused() {
+ set(PqAnchorConfig.PROPERTY_MIN_SEALS, H + ":0");
+
+ assertThatThrownBy(PqAnchorConfig::fromSystemConfiguration)
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining(PqAnchorConfig.REFUSAL_MIN_SEALS_FLOOR)
+ .hasMessageContaining("ZERO");
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // 2. Nor do several zero steps add up to anything. Whoever writes two zeros believes they have
+ // done something.
+ // ---------------------------------------------------------------------------------------------
+ @Test
+ void severalZeroStepsAreStillZero() {
+ set(PqAnchorConfig.PROPERTY_MIN_SEALS, H + ":0," + (H + 1000) + ":0," + (H + 2000) + ":0");
+
+ assertThatThrownBy(PqAnchorConfig::fromSystemConfiguration)
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining(PqAnchorConfig.REFUSAL_MIN_SEALS_FLOOR);
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // 3. THE CONTROL THAT MATTERS MOST: the warm-up window stays LEGAL. A guard that also refused the
+ // form our own plan recommends would have been worked around within five minutes, and then we
+ // would have had neither the guard nor the window.
+ // ---------------------------------------------------------------------------------------------
+ @Test
+ void theWarmUpWindowRecommendedByOurOwnPlanStaysLegal() {
+ set(PqAnchorConfig.PROPERTY_MIN_SEALS, H + ":0," + (H + 165000) + ":3");
+
+ assertThatCode(PqAnchorConfig::fromSystemConfiguration).doesNotThrowAnyException();
+ final PqAnchorConfig config = PqAnchorConfig.fromSystemConfiguration();
+ assertThat(config.minSealsAt(H)).isZero();
+ assertThat(config.minSealsAt(H + 165000)).isEqualTo(3);
+ assertThat(config.highestEffectiveMinSeals()).isEqualTo(3);
+ }
+
+ @Test
+ void aScheduleThatDemandsFromTheStartIsFine() {
+ set(PqAnchorConfig.PROPERTY_MIN_SEALS, H + ":3");
+ assertThatCode(PqAnchorConfig::fromSystemConfiguration).doesNotThrowAnyException();
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // 4. THE SECOND ROAD TO THE SAME STATE, and it is the one that would have fooled me: an
+ // emergency ceiling of zero drops K to zero at every height, over a schedule that looks
+ // perfect. The floor looks at the EFFECTIVE K, after the ceiling, precisely to catch this too.
+ // ---------------------------------------------------------------------------------------------
+ @Test
+ void anEmergencyCeilingOfZeroReachesTheSameToothlessStateAndIsAlsoRefused() {
+ set(PqAnchorConfig.PROPERTY_MIN_SEALS, H + ":3");
+ set(PqAnchorConfig.PROPERTY_MIN_SEALS_CEILING, "0");
+
+ assertThatThrownBy(PqAnchorConfig::fromSystemConfiguration)
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining(PqAnchorConfig.REFUSAL_MIN_SEALS_FLOOR)
+ .hasMessageContaining("emergency ceiling");
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // 5. THE BRAKE IS ABSOLUTE, and it stays absolute. The emergency disarm has to work when the
+ // chain is ALREADY stopped; a guard that refused to start a DISARMED node would be a brake
+ // that refuses to brake, which is exactly what our own comment says it must never be.
+ // ---------------------------------------------------------------------------------------------
+ @Test
+ void theEmergencyDisarmStillStartsWithAToothlessSchedule() {
+ set(PqAnchorConfig.PROPERTY_MIN_SEALS, H + ":0");
+ set(PqAnchorConfig.PROPERTY_DISABLE, "true");
+
+ assertThatCode(PqAnchorConfig::fromSystemConfiguration).doesNotThrowAnyException();
+ assertThat(PqAnchorConfig.fromSystemConfiguration().activeAt(H)).isFalse();
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // 6. A node with no anchor at all has no floor to violate. Without this, the guard would stop
+ // every node in the fleet that has not been configured yet, which is most of them, from
+ // starting on activation day.
+ // ---------------------------------------------------------------------------------------------
+ @Test
+ void aNodeWithNoAnchorConfiguredIsUntouched() {
+ PqAnchorConfig.useNameReaderForTesting(new Reader());
+ assertThatCode(PqAnchorConfig::fromSystemConfiguration).doesNotThrowAnyException();
+ assertThat(PqAnchorConfig.fromSystemConfiguration().everActive()).isFalse();
+ }
+}
diff --git a/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorProducerCostTest.java b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorProducerCostTest.java
new file mode 100755
index 000000000..252fd3463
--- /dev/null
+++ b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorProducerCostTest.java
@@ -0,0 +1,251 @@
+/*
+ * AERE, 2026-08-07. THE TWO COST CONTROLS, measured on the PRODUCER, not on the configuration.
+ *
+ * WHY THIS EXISTS. On 7 August `aere.pq.anchor.maxSeals` and `aere.pq.anchorInterval` were built,
+ * and their configuration guards were proven the same day. But the cut in the producer, the code
+ * that ACTUALLY stops seals being written past the cap, and that ACTUALLY skips the heights with no
+ * anchor, stayed an ASSERTION: there was no producer harness in the tree, and we had just seen, at
+ * the wiring of the rules into the validation chain, what a piece of code that no proof touches
+ * costs.
+ *
+ * This class touches it. It counts the seals written, it does not assume them.
+ *
+ * THE NEGATIVE CONTROL IS INSIDE, and it is exactly the pair: the same seven seals heard, the same
+ * proposer, the same inputs, one single difference, the cap set or not. Without the half with no
+ * cap, a proof counting 3 would have passed even if the producer had heard only 3.
+ */
+package org.hyperledger.besu.consensus.common.bft;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import static org.mockito.Mockito.withSettings;
+
+import java.io.IOException;
+import java.lang.reflect.Field;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.OptionalInt;
+import org.apache.tuweni.bytes.Bytes;
+import org.apache.tuweni.bytes.Bytes32;
+import org.bouncycastle.crypto.digests.KeccakDigest;
+import org.bouncycastle.pqc.crypto.falcon.FalconPrivateKeyParameters;
+import org.bouncycastle.pqc.crypto.falcon.FalconSigner;
+import org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer;
+import org.hyperledger.besu.consensus.common.validator.ValidatorProvider;
+import org.hyperledger.besu.datatypes.Address;
+import org.hyperledger.besu.ethereum.ProtocolContext;
+import org.hyperledger.besu.ethereum.core.BlockHeader;
+import org.hyperledger.besu.ethereum.core.BlockHeaderTestFixture;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.mockito.quality.Strictness;
+
+class PqAnchorProducerCostTest {
+
+ private static final long CHAIN_ID = 220_879L;
+ private static final long H = 1_000L;
+ private static final long ATTACH = 900L;
+ private static final int N = 7;
+
+ @TempDir private Path tmp;
+
+ private final List<FalconPrivateKeyParameters> privateKeys = new ArrayList<>();
+ private final List<Address> validators = new ArrayList<>();
+
+ @BeforeEach
+ void setUp() throws Exception {
+ final KeccakDigest kd = new KeccakDigest(256);
+ final StringBuilder manifest = new StringBuilder();
+ manifest
+ .append("{\"config\":{\"aereFalconRegistry\":{")
+ .append(PqV2Fixture.manifestHeader(N, CHAIN_ID, H));
+ for (int i = 0; i < N; i++) {
+ privateKeys.add(PqV2Fixture.privateKey(i));
+ validators.add(PqV2Fixture.address(i));
+ final byte[] row = PqV2Fixture.anchorPreimageRow(i);
+ kd.update(row, 0, row.length);
+ manifest.append(',').append(PqV2Fixture.manifestEntry(i, N, CHAIN_ID, H));
+ }
+ manifest.append("}},\"alloc\":{\"0000000000000000000000000000000000000fa1\":{\"storage\":{\"0x")
+ .append("0".repeat(64))
+ .append("\":\"0x");
+ final byte[] anchoredHash = new byte[32];
+ kd.doFinal(anchoredHash, 0);
+ manifest.append(Bytes.wrap(anchoredHash).toUnprefixedHexString()).append("\"}}}}");
+
+ final Path genesis = tmp.resolve("genesis-registry.json");
+ Files.writeString(genesis, manifest.toString());
+ System.setProperty("aere.falcon.genesis", genesis.toAbsolutePath().toString());
+ System.setProperty("aere.falcon.attachBlock", Long.toString(ATTACH));
+
+ resetFalconSingleton();
+ PqSealCache.instance().clear();
+ }
+
+ @AfterEach
+ void tearDown() throws Exception {
+ System.clearProperty("aere.falcon.genesis");
+ System.clearProperty("aere.falcon.attachBlock");
+ resetFalconSingleton();
+ PqSealCache.instance().clear();
+ PqAnchorProducer.useConfigForTesting(null);
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // THE CAP, measured in both directions on the same seven seals heard.
+ // ---------------------------------------------------------------------------------------------
+
+ @Test
+ void withNoCapTheProposerWritesEverySealItHeard() {
+ PqAnchorProducer.useConfigForTesting(armedConfig(OptionalInt.empty(), OptionalInt.empty()));
+ final BlockHeader parent = parentOf(H + 10L);
+ hearAllSevenSealsFor(parent);
+
+ final BftExtraData produced = PqAnchorProducer.apply(base(), parent, contextWith(validators));
+
+ assertThat(produced.getFalconSeals())
+ .describedAs(
+ "K is a FLOOR: with no cap the proposer writes everything it heard, that is seven, not "
+ + "three. This half is what makes the number in the other half mean anything")
+ .hasSize(N);
+ }
+
+ @Test
+ void withTheCapAtThreeTheProposerWritesExactlyThree() {
+ PqAnchorProducer.useConfigForTesting(armedConfig(OptionalInt.of(3), OptionalInt.empty()));
+ final BlockHeader parent = parentOf(H + 10L);
+ hearAllSevenSealsFor(parent);
+
+ final BftExtraData produced = PqAnchorProducer.apply(base(), parent, contextWith(validators));
+
+ assertThat(produced.getFalconSeals())
+ .describedAs("the same seven seals heard, the only difference is the cap")
+ .hasSize(3);
+ }
+
+ /**
+ * A cap larger than what was heard does not invent seals. Without this, a badly chosen cap could
+ * look like it "works" merely because the number asked for happens to equal the number heard.
+ */
+ @Test
+ void aCapAboveWhatWasHeardChangesNothing() {
+ PqAnchorProducer.useConfigForTesting(armedConfig(OptionalInt.of(20), OptionalInt.empty()));
+ final BlockHeader parent = parentOf(H + 10L);
+ hearAllSevenSealsFor(parent);
+
+ assertThat(PqAnchorProducer.apply(base(), parent, contextWith(validators)).getFalconSeals())
+ .hasSize(N);
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // THE INTERVAL, also in both directions, and also on the same seals heard.
+ // ---------------------------------------------------------------------------------------------
+
+ @Test
+ void atAnAnchorHeightTheProposerWritesACertificate() {
+ PqAnchorProducer.useConfigForTesting(armedConfig(OptionalInt.of(3), OptionalInt.of(100)));
+ // the parent is H-1, so the block being built is H itself, and H is always an anchor height
+ final BlockHeader parent = parentOf(H - 1L);
+ hearAllSevenSealsFor(parent);
+
+ assertThat(PqAnchorProducer.apply(base(), parent, contextWith(validators)).getFalconSeals())
+ .describedAs("H itself is always an anchor height, whatever the interval is")
+ .hasSize(3);
+ }
+
+ @Test
+ void betweenAnchorsTheProposerWritesNothingEvenThoughItHeardEverything() {
+ PqAnchorProducer.useConfigForTesting(armedConfig(OptionalInt.of(3), OptionalInt.of(100)));
+ // the parent is H, so the block built is H+1, which is NOT an anchor height at interval 100
+ final BlockHeader parent = parentOf(H);
+ hearAllSevenSealsFor(parent);
+
+ final BftExtraData produced = PqAnchorProducer.apply(base(), parent, contextWith(validators));
+
+ assertThat(produced.getFalconSeals())
+ .describedAs(
+ "seven seals heard and none written, because this height carries no certificate. "
+ + "This is where the hundredfold saving comes from")
+ .isEmpty();
+ assertThat(produced.getVanityData())
+ .describedAs("and the digest is not placed where there is nothing to bind")
+ .isEqualTo(Bytes32.ZERO);
+ }
+
+ /**
+ * With no interval, the SAME height does carry a certificate. This is the pair of the proof
+ * above.
+ */
+ @Test
+ void thatSameHeightDoesCarryACertificateWhenThereIsNoInterval() {
+ PqAnchorProducer.useConfigForTesting(armedConfig(OptionalInt.of(3), OptionalInt.empty()));
+ final BlockHeader parent = parentOf(H);
+ hearAllSevenSealsFor(parent);
+
+ assertThat(PqAnchorProducer.apply(base(), parent, contextWith(validators)).getFalconSeals())
+ .describedAs("the only difference from the proof before is the interval")
+ .hasSize(3);
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // Helpers.
+ // ---------------------------------------------------------------------------------------------
+
+ private static PqAnchorConfig armedConfig(final OptionalInt cap, final OptionalInt interval) {
+ return new PqAnchorConfig(
+ CHAIN_ID, H, Map.of(H, 3), OptionalInt.empty(), false, cap, interval);
+ }
+
+ private static BlockHeader parentOf(final long number) {
+ return new BlockHeaderTestFixture().number(number).buildHeader();
+ }
+
+ private BftExtraData base() {
+ return new BftExtraData(
+ Bytes32.ZERO, Collections.emptyList(), Optional.empty(), 0, validators, Collections.emptyList());
+ }
+
+ private void hearAllSevenSealsFor(final BlockHeader parent) {
+ final Bytes32 m =
+ PqAnchor.commitMessage(CHAIN_ID, parent.getNumber(), parent.getHash().getBytes());
+ final List<FalconSeal> heard = new ArrayList<>();
+ for (int i = 0; i < N; i++) {
+ heard.add(new FalconSeal(i, Bytes.wrap(falconSign(privateKeys.get(i), m))));
+ }
+ PqSealCache.instance().record(parent.getNumber(), parent.getHash(), heard);
+ }
+
+ private static byte[] falconSign(final FalconPrivateKeyParameters key, final Bytes32 m) {
+ final FalconSigner signer = new FalconSigner();
+ signer.init(true, key);
+ return signer.generateSignature(m.toArray());
+ }
+
+ private static ProtocolContext contextWith(final Collection<Address> vs) {
+ final ValidatorProvider validatorProvider =
+ mock(ValidatorProvider.class, withSettings().strictness(Strictness.LENIENT));
+ when(validatorProvider.getValidatorsForBlock(any())).thenReturn(vs);
+ when(validatorProvider.getValidatorsAfterBlock(any())).thenReturn(vs);
+ final BftContext bftContext =
+ mock(BftContext.class, withSettings().strictness(Strictness.LENIENT));
+ when(bftContext.getValidatorProvider()).thenReturn(validatorProvider);
+ when(bftContext.as(any())).thenReturn(bftContext);
+ return new ProtocolContext.Builder().withConsensusContext(bftContext).build();
+ }
+
+ private static void resetFalconSingleton() throws Exception {
+ final Field f = FalconSealSupport.class.getDeclaredField("instance");
+ f.setAccessible(true);
+ f.set(null, null);
+ }
+}
diff --git a/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorSealCapTest.java b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorSealCapTest.java
new file mode 100755
index 000000000..5c61464f9
--- /dev/null
+++ b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorSealCapTest.java
@@ -0,0 +1,150 @@
+/*
+ * AERE, 2026-08-07. The PROPOSER-side seal cap, and its fail-closed guard.
+ *
+ * WHY IT EXISTS. K is a FLOOR, not a cap. Measured on a live ten-node run with the threshold at 4:
+ * 42 blocks carried 4 seals, 36 carried 5, 5 carried 6. The proposer writes every seal it heard and
+ * that is eligible, not as many as the threshold demands. At 666 bytes a seal, that means about two
+ * thirds more header written than the threshold asks for, and the surplus buys nothing: what a
+ * verifier demands is THE THRESHOLD.
+ *
+ * WHAT THIS FILE GUARDS, and this is the dangerous part: a cap set BELOW the highest K in the
+ * schedule makes the proposer write certificates its own fleet rejects, at every height from the
+ * step that raises K above the cap onwards. The failure is SILENT to every existing tool, because
+ * each one measures what was ASKED FOR and the request is consistent with itself. That is why the
+ * node refuses to start.
+ */
+package org.hyperledger.besu.consensus.common.bft;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import java.util.Map;
+import java.util.OptionalInt;
+import org.junit.jupiter.api.Test;
+
+class PqAnchorSealCapTest {
+
+ private static PqAnchorConfig armed(final Map<Long, Integer> schedule, final OptionalInt ceiling) {
+ return new PqAnchorConfig(2800L, 100L, schedule, ceiling, false);
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // 1. No cap means exactly what every node did before today. This is the compatibility boundary,
+ // and it is the first thing to break if anyone ever gives the property a default value.
+ // ---------------------------------------------------------------------------------------------
+ @Test
+ void withNoCapConfiguredTheProposerIsUncapped() {
+ final PqAnchorConfig cfg = armed(Map.of(100L, 3), OptionalInt.empty());
+ assertThat(cfg.maxSealsCarried()).isEmpty();
+ assertThat(cfg.maxSealsCarried().orElse(Integer.MAX_VALUE)).isEqualTo(Integer.MAX_VALUE);
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // 2. THE NEGATIVE CONTROL. A cap below the highest K in the schedule must REFUSE, and the message
+ // must name both numbers, otherwise whoever reads it at 3 in the morning does not know what to
+ // change.
+ // ---------------------------------------------------------------------------------------------
+ @Test
+ void aCapBelowTheHighestScheduledThresholdIsRefused() {
+ final PqAnchorConfig cfg = armed(Map.of(100L, 1, 200L, 3, 300L, 5), OptionalInt.empty());
+ assertThat(cfg.highestEffectiveMinSeals()).isEqualTo(5);
+
+ assertThatThrownBy(() -> cfg.withMaxSealsCarried(OptionalInt.of(4)))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining(PqAnchorConfig.REFUSAL_CODE)
+ .hasMessageContaining(PqAnchorConfig.PROPERTY_MAX_SEALS)
+ .hasMessageContaining("=4")
+ .hasMessageContaining("K=5");
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // 3. And the refusal looks at the WHOLE schedule, not just the step at the arming height. A cap
+ // chosen from the first step is exactly the mistake I expect: our schedule STARTS at a low K.
+ // ---------------------------------------------------------------------------------------------
+ @Test
+ void theRefusalLooksAtTheWholeScheduleNotJustTheFirstStep() {
+ // exactly the shape the activation plan recommends: starts at 0, climbs to 3
+ final PqAnchorConfig cfg = armed(Map.of(100L, 0, 265L, 3), OptionalInt.empty());
+ assertThat(cfg.minSealsAt(100L)).isZero();
+ assertThat(cfg.highestEffectiveMinSeals()).isEqualTo(3);
+
+ // a cap of 1 "looks" right if you only look at the first step
+ assertThatThrownBy(() -> cfg.withMaxSealsCarried(OptionalInt.of(1)))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("K=3");
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // 4. A cap exactly at K is the value we want in production, and it has to be accepted.
+ // ---------------------------------------------------------------------------------------------
+ @Test
+ void aCapEqualToTheHighestThresholdIsAccepted() {
+ final PqAnchorConfig cfg =
+ armed(Map.of(100L, 0, 265L, 3), OptionalInt.empty()).withMaxSealsCarried(OptionalInt.of(3));
+ assertThat(cfg.maxSealsCarried()).hasValue(3);
+ assertThat(cfg.minSealsAt(265L)).isEqualTo(3);
+ assertThat(cfg.anchorBlock()).isEqualTo(100L);
+ assertThat(cfg.chainId()).isEqualTo(2800L);
+ }
+
+ @Test
+ void aCapAboveTheHighestThresholdIsAccepted() {
+ final PqAnchorConfig cfg =
+ armed(Map.of(100L, 3), OptionalInt.empty()).withMaxSealsCarried(OptionalInt.of(7));
+ assertThat(cfg.maxSealsCarried()).hasValue(7);
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // 5. Zero is not a cap, it is a stop. An empty certificate is a state only the SCHEDULE may ask
+ // for, through K=0, and only inside the warm-up window.
+ // ---------------------------------------------------------------------------------------------
+ @Test
+ void aCapOfZeroIsRefusedOutright() {
+ assertThatThrownBy(
+ () -> new PqAnchorConfig(2800L, 100L, Map.of(100L, 0), OptionalInt.empty(), false, OptionalInt.of(0)))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining(PqAnchorConfig.PROPERTY_MAX_SEALS)
+ .hasMessageContaining("at least 1");
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // 6. The emergency ceiling LOWERS K, so it also lowers the threshold the cost cap has to reach.
+ // The two cannot end up arguing with each other.
+ // ---------------------------------------------------------------------------------------------
+ @Test
+ void theEmergencyCeilingLowersWhatTheCostCapMustReach() {
+ final Map<Long, Integer> schedule = Map.of(100L, 3, 200L, 5);
+ assertThat(armed(schedule, OptionalInt.empty()).highestEffectiveMinSeals()).isEqualTo(5);
+ // with the emergency ceiling at 2, the effective K never goes above 2
+ final PqAnchorConfig braked = armed(schedule, OptionalInt.of(2));
+ assertThat(braked.highestEffectiveMinSeals()).isEqualTo(2);
+ // so a cost cap of 2 becomes legal, even though the schedule says 5
+ assertThat(braked.withMaxSealsCarried(OptionalInt.of(2)).maxSealsCarried()).hasValue(2);
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // 7. An empty schedule demands nothing, so any positive cap is legal.
+ // ---------------------------------------------------------------------------------------------
+ @Test
+ void anEmptyScheduleDemandsNothing() {
+ final PqAnchorConfig cfg = armed(Map.of(), OptionalInt.empty());
+ assertThat(cfg.highestEffectiveMinSeals()).isZero();
+ assertThat(cfg.withMaxSealsCarried(OptionalInt.of(1)).maxSealsCarried()).hasValue(1);
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // 8. The rest of the configuration survives the copy. A method that drops a field on the way is
+ // how an emergency disarm would vanish in silence the moment the cap is set.
+ // ---------------------------------------------------------------------------------------------
+ @Test
+ void everyOtherFieldSurvivesTheCopy() {
+ final PqAnchorConfig cfg =
+ new PqAnchorConfig(2800L, 100L, Map.of(100L, 3), OptionalInt.of(3), true)
+ .withMaxSealsCarried(OptionalInt.of(3));
+ assertThat(cfg.chainId()).isEqualTo(2800L);
+ assertThat(cfg.anchorBlock()).isEqualTo(100L);
+ assertThat(cfg.minSealsCeiling()).hasValue(3);
+ assertThat(cfg.activeAt(100L)).isFalse(); // disarmed, and it stayed disarmed
+ assertThat(cfg.maxSealsCarried()).hasValue(3);
+ }
+}
diff --git a/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorTest.java b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorTest.java
new file mode 100755
index 000000000..0e65d7fa4
--- /dev/null
+++ b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorTest.java
@@ -0,0 +1,301 @@
+/*
+ * Copyright contributors to Besu / Aere Network.
+ *
+ * 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 java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import org.apache.tuweni.bytes.Bytes;
+import org.apache.tuweni.bytes.Bytes32;
+import org.junit.jupiter.api.Test;
+
+/** Vectors and invariants for the V2 certificate anchor pre-images. */
+public class PqAnchorTest {
+
+ private static final long CHAIN_ID = 2800L;
+ private static final Bytes32 PARENT_HASH =
+ Bytes32.fromHexString("0x1111111111111111111111111111111111111111111111111111111111111111");
+ private static final Bytes32 OTHER_HASH =
+ Bytes32.fromHexString("0x2222222222222222222222222222222222222222222222222222222222222222");
+
+ /**
+ * A deterministic stand-in for a Falcon-512 signature. Real ones measure 648 to 660 bytes; the
+ * anchor pre-image treats them as opaque length-prefixed byte strings, so the exact length only
+ * has to be representative.
+ */
+ static Bytes signatureFor(final int index, final int variant) {
+ final byte[] bytes = new byte[655];
+ for (int i = 0; i < bytes.length; i++) {
+ bytes[i] = (byte) ((i * 31 + index * 7 + variant * 101) & 0xFF);
+ }
+ return Bytes.wrap(bytes);
+ }
+
+ static FalconSeal seal(final int index) {
+ return new FalconSeal(index, signatureFor(index, 0));
+ }
+
+ @Test
+ public void domainLabelsAreExactlySixteenAsciiBytes() {
+ assertThat(PqAnchor.ANCHOR_DOMAIN.getBytes(StandardCharsets.US_ASCII)).hasSize(16);
+ assertThat(PqAnchor.COMMIT_DOMAIN.getBytes(StandardCharsets.US_ASCII)).hasSize(16);
+ assertThat(PqAnchor.ANCHOR_DOMAIN_BYTES.size()).isEqualTo(16);
+ assertThat(PqAnchor.COMMIT_DOMAIN_BYTES.size()).isEqualTo(16);
+ assertThat(PqAnchor.ANCHOR_DOMAIN).isNotEqualTo(PqAnchor.COMMIT_DOMAIN);
+ }
+
+ @Test
+ public void anchorDigestIsThirtyTwoBytesAndDeterministic() {
+ final List<FalconSeal> certificate = Arrays.asList(seal(0), seal(1), seal(2));
+ final Bytes32 first = PqAnchor.anchorDigest(CHAIN_ID, 10L, PARENT_HASH, certificate);
+ final Bytes32 second = PqAnchor.anchorDigest(CHAIN_ID, 10L, PARENT_HASH, certificate);
+ assertThat(first.size()).isEqualTo(32);
+ assertThat(first).isEqualTo(second);
+ }
+
+ @Test
+ public void anchorDigestChangesWithEveryFieldOfItsPreImage() {
+ final List<FalconSeal> certificate = Arrays.asList(seal(0), seal(1), seal(2));
+ final Bytes32 base = PqAnchor.anchorDigest(CHAIN_ID, 10L, PARENT_HASH, certificate);
+
+ // chainId: this is what stops a certificate produced on the 442807 proving chain, which runs the
+ // same binaries and may run the same Falcon keys, from being material here.
+ assertThat(PqAnchor.anchorDigest(442807L, 10L, PARENT_HASH, certificate)).isNotEqualTo(base);
+ // parent height and parent hash: replay at another height or on another branch.
+ assertThat(PqAnchor.anchorDigest(CHAIN_ID, 11L, PARENT_HASH, certificate)).isNotEqualTo(base);
+ assertThat(PqAnchor.anchorDigest(CHAIN_ID, 10L, OTHER_HASH, certificate)).isNotEqualTo(base);
+ }
+
+ @Test
+ public void anchorDigestDetectsAStrippedCertificate() {
+ // Attack A1: remove element 6 entirely. The canonical round-trip gate cannot see this, because
+ // truncated bytes re-encode to themselves. The digest can.
+ final List<FalconSeal> certificate = Arrays.asList(seal(0), seal(1), seal(2));
+ assertThat(PqAnchor.anchorDigest(CHAIN_ID, 10L, PARENT_HASH, Collections.emptyList()))
+ .isNotEqualTo(PqAnchor.anchorDigest(CHAIN_ID, 10L, PARENT_HASH, certificate));
+ }
+
+ @Test
+ public void anchorDigestDetectsAnEquallyValidDifferentSubset() {
+ // Attack A2: swap quorum subset {0,1,2,3} for {0,1,2,4}, both authentic over the same parent.
+ // The block hash does not change, because the certificate is outside its pre-image. D does.
+ final List<FalconSeal> subsetA = Arrays.asList(seal(0), seal(1), seal(2), seal(3));
+ final List<FalconSeal> subsetB = Arrays.asList(seal(0), seal(1), seal(2), seal(4));
+ assertThat(PqAnchor.anchorDigest(CHAIN_ID, 10L, PARENT_HASH, subsetA))
+ .isNotEqualTo(PqAnchor.anchorDigest(CHAIN_ID, 10L, PARENT_HASH, subsetB));
+ }
+
+ @Test
+ public void anchorDigestDetectsAReorderedCertificate() {
+ // Attack A3, first of the two independent refusals. The second is the strictly increasing index
+ // requirement, which both rules apply.
+ final List<FalconSeal> ordered = Arrays.asList(seal(0), seal(1));
+ final List<FalconSeal> reordered = Arrays.asList(seal(1), seal(0));
+ assertThat(PqAnchor.anchorDigest(CHAIN_ID, 10L, PARENT_HASH, ordered))
+ .isNotEqualTo(PqAnchor.anchorDigest(CHAIN_ID, 10L, PARENT_HASH, reordered));
+ }
+
+ @Test
+ public void anchorDigestDetectsAChangedSignatureUnderTheSameIndex() {
+ final List<FalconSeal> original = List.of(new FalconSeal(3, signatureFor(3, 0)));
+ final List<FalconSeal> substituted = List.of(new FalconSeal(3, signatureFor(3, 1)));
+ assertThat(PqAnchor.anchorDigest(CHAIN_ID, 10L, PARENT_HASH, original))
+ .isNotEqualTo(PqAnchor.anchorDigest(CHAIN_ID, 10L, PARENT_HASH, substituted));
+ }
+
+ @Test
+ public void emptyCertificateHasAWellDefinedDigest() {
+ // This is the block-H case: the parent predates the scheme, K(H) is 0, so C is legitimately
+ // empty and D is the digest over the empty list. "Explicitly empty" and "absent" are the same
+ // object because of the codec's canonical round-trip gate.
+ final Bytes32 digest = PqAnchor.anchorDigest(CHAIN_ID, 0L, PARENT_HASH, Collections.emptyList());
+ assertThat(digest.size()).isEqualTo(32);
+ assertThat(digest)
+ .isEqualTo(PqAnchor.anchorDigest(CHAIN_ID, 0L, PARENT_HASH, Collections.emptyList()));
+ assertThat(PqAnchor.encodeCertificate(Collections.emptyList()))
+ .isEqualTo(Bytes.fromHexString("0xc0"));
+ }
+
+ @Test
+ public void commitMessageIsThirtyTwoBytesAndBoundToItsFields() {
+ final Bytes32 base = PqAnchor.commitMessage(CHAIN_ID, 10L, PARENT_HASH);
+ assertThat(base.size()).isEqualTo(32);
+ assertThat(PqAnchor.commitMessage(CHAIN_ID, 10L, PARENT_HASH)).isEqualTo(base);
+ assertThat(PqAnchor.commitMessage(442807L, 10L, PARENT_HASH)).isNotEqualTo(base);
+ assertThat(PqAnchor.commitMessage(CHAIN_ID, 11L, PARENT_HASH)).isNotEqualTo(base);
+ assertThat(PqAnchor.commitMessage(CHAIN_ID, 10L, OTHER_HASH)).isNotEqualTo(base);
+ }
+
+ @Test
+ public void anchorAndCommitPreImagesAreDomainSeparated() {
+ // Attack A7: a Falcon signature made for another subsystem must not be reinterpretable as a
+ // consensus seal, and an anchor pre-image must not collide with a commit pre-image.
+ assertThat(PqAnchor.commitMessage(CHAIN_ID, 10L, PARENT_HASH))
+ .isNotEqualTo(PqAnchor.anchorDigest(CHAIN_ID, 10L, PARENT_HASH, Collections.emptyList()));
+ }
+
+ @Test
+ public void strictlyIncreasingIndicesAreRequired() {
+ assertThat(PqAnchor.hasStrictlyIncreasingIndices(Collections.emptyList())).isTrue();
+ assertThat(PqAnchor.hasStrictlyIncreasingIndices(List.of(seal(0)))).isTrue();
+ assertThat(PqAnchor.hasStrictlyIncreasingIndices(Arrays.asList(seal(0), seal(1), seal(4))))
+ .isTrue();
+ // Attack A3: reordering.
+ assertThat(PqAnchor.hasStrictlyIncreasingIndices(Arrays.asList(seal(1), seal(0)))).isFalse();
+ // Attack A4: the same index twice to inflate k. Unrepresentable, not filtered afterwards.
+ assertThat(PqAnchor.hasStrictlyIncreasingIndices(Arrays.asList(seal(2), seal(2)))).isFalse();
+ assertThat(
+ PqAnchor.hasStrictlyIncreasingIndices(
+ Arrays.asList(new FalconSeal(2, signatureFor(2, 0)), new FalconSeal(2, signatureFor(2, 1)))))
+ .isFalse();
+ assertThat(PqAnchor.hasStrictlyIncreasingIndices(List.of(new FalconSeal(-1, Bytes.of(1)))))
+ .isFalse();
+ assertThat(PqAnchor.hasStrictlyIncreasingIndices(List.of(new FalconSeal(1, null)))).isFalse();
+ }
+
+ @Test
+ public void historicalUnorderedWindowIsAcceptedAndOnlyThere() {
+ // The 36 canonical headers written unsorted during the 2026-08-10 interruption. Two real index
+ // sequences, decoded off the public endpoint on 2026-08-11: block 13,267,824 lists 2, 6, 1 and
+ // block 13,268,944 lists 5, 6, 1, 0.
+ final List<FalconSeal> real1 = Arrays.asList(seal(2), seal(6), seal(1));
+ final List<FalconSeal> real2 = Arrays.asList(seal(5), seal(6), seal(1), seal(0));
+
+ // Inside the window: accepted, at both edges and in the middle.
+ assertThat(PqAnchor.hasAcceptableIndices(real1, PqAnchor.UNORDERED_WINDOW_FIRST)).isTrue();
+ assertThat(PqAnchor.hasAcceptableIndices(real2, PqAnchor.UNORDERED_WINDOW_LAST)).isTrue();
+ assertThat(PqAnchor.hasAcceptableIndices(real1, PqAnchor.UNORDERED_WINDOW_FIRST + 32L)).isTrue();
+
+ // NEGATIVE CONTROL, and it is the whole point: one block on either side of the window and the
+ // very same certificate is rejected. If these two ever pass, the exception has stopped being an
+ // exception and has become the rule.
+ assertThat(PqAnchor.hasAcceptableIndices(real1, PqAnchor.UNORDERED_WINDOW_FIRST - 1L)).isFalse();
+ assertThat(PqAnchor.hasAcceptableIndices(real1, PqAnchor.UNORDERED_WINDOW_LAST + 1L)).isFalse();
+ assertThat(PqAnchor.hasAcceptableIndices(real2, 1L)).isFalse();
+ assertThat(PqAnchor.hasAcceptableIndices(real2, Long.MAX_VALUE)).isFalse();
+ }
+
+ @Test
+ public void historicalWindowDoesNotRelaxDistinctness() {
+ // Attack A4 inside the window: repeating one seal to inflate k. Sortedness is relaxed there,
+ // distinctness is NOT, at any height. A certificate that repeats an index is still
+ // unrepresentable, which is the only part of the ordering rule that an attacker cares about.
+ final List<FalconSeal> repetat =
+ Arrays.asList(new FalconSeal(2, signatureFor(2, 0)), new FalconSeal(2, signatureFor(2, 1)));
+ assertThat(PqAnchor.hasAcceptableIndices(repetat, PqAnchor.UNORDERED_WINDOW_FIRST)).isFalse();
+ assertThat(PqAnchor.hasAcceptableIndices(repetat, PqAnchor.UNORDERED_WINDOW_LAST)).isFalse();
+
+ // Negative indices and null signatures stay rejected inside the window too.
+ assertThat(
+ PqAnchor.hasAcceptableIndices(
+ List.of(new FalconSeal(-1, Bytes.of(1))), PqAnchor.UNORDERED_WINDOW_FIRST))
+ .isFalse();
+ assertThat(
+ PqAnchor.hasAcceptableIndices(
+ List.of(new FalconSeal(1, null)), PqAnchor.UNORDERED_WINDOW_FIRST))
+ .isFalse();
+
+ // And a sorted certificate is accepted everywhere, window or not: the exception only ever adds.
+ final List<FalconSeal> sortat = Arrays.asList(seal(0), seal(3), seal(6));
+ assertThat(PqAnchor.hasAcceptableIndices(sortat, 1L)).isTrue();
+ assertThat(PqAnchor.hasAcceptableIndices(sortat, PqAnchor.UNORDERED_WINDOW_FIRST)).isTrue();
+ assertThat(PqAnchor.hasAcceptableIndices(sortat, Long.MAX_VALUE)).isTrue();
+ }
+
+ @Test
+ public void historicalWindowIsClosedAndAnchorAligned() {
+ // The window cannot grow: it is bounded above by a measured constant. And both bounds must sit on
+ // the 32-block anchor spacing, because only anchor heights carry certificates. A window whose
+ // bounds are off the spacing would be evidence that the measurement, not the chain, was wrong.
+ assertThat(PqAnchor.UNORDERED_WINDOW_LAST).isGreaterThan(PqAnchor.UNORDERED_WINDOW_FIRST);
+ assertThat((PqAnchor.UNORDERED_WINDOW_LAST - PqAnchor.UNORDERED_WINDOW_FIRST) % 32L).isZero();
+ // 36 anchor headers, measured contiguous on 2026-08-11.
+ assertThat((PqAnchor.UNORDERED_WINDOW_LAST - PqAnchor.UNORDERED_WINDOW_FIRST) / 32L + 1L)
+ .isEqualTo(36L);
+ }
+
+ @Test
+ public void sortedByIndexOrdersAscending() {
+ final List<FalconSeal> sorted = PqAnchor.sortedByIndex(Arrays.asList(seal(5), seal(1), seal(3)));
+ assertThat(sorted.stream().map(FalconSeal::getValidatorIndex)).containsExactly(1, 3, 5);
+ assertThat(PqAnchor.hasStrictlyIncreasingIndices(sorted)).isTrue();
+ }
+
+ @Test
+ public void malformedPreImageInputsAreRefusedRatherThanHashed() {
+ assertThatThrownBy(
+ () -> PqAnchor.anchorDigest(CHAIN_ID, -1L, PARENT_HASH, Collections.emptyList()))
+ .isInstanceOf(IllegalArgumentException.class);
+ assertThatThrownBy(
+ () -> PqAnchor.anchorDigest(CHAIN_ID, 1L, Bytes.of(1, 2, 3), Collections.emptyList()))
+ .isInstanceOf(IllegalArgumentException.class);
+ assertThatThrownBy(() -> PqAnchor.commitMessage(CHAIN_ID, 1L, Bytes.of(1, 2, 3)))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+
+ // -----------------------------------------------------------------------------------------
+ // GOLDEN VECTORS. Added 2026-08-02 after a planted defect proved they were missing.
+ //
+ // Every other assertion in this class is RELATIONAL: it says D(x) differs from D(y). A planted
+ // one-byte corruption applied uniformly to the output of anchorDigest ("plantedD[0] ^= 1")
+ // therefore left all 378 tests of consensus:common and consensus:qbft GREEN, because every
+ // relation it was asked about survived the shift. That is precisely the defect class the whole
+ // scheme exists to remove: two builds that disagree on D do not disagree on any relation, they
+ // disagree on the VALUE, and until this file pinned the value nothing could see it. It also gives
+ // the second client a vector to implement against instead of prose.
+ //
+ // If one of these three fails, the wire format of the anchor pre-image has changed and that is a
+ // consensus fork. It is never correct to update the constant to match the code; the code has to
+ // be brought back to the constant, or a new activation height has to be agreed.
+ // -----------------------------------------------------------------------------------------
+
+ private static final Bytes32 GOLDEN_D_EMPTY =
+ Bytes32.fromHexString("0xbe4e758707704f9a64b2bfd9d67c3072dc5a94ce586006c98301a017cb874935");
+ private static final Bytes32 GOLDEN_D_CERT =
+ Bytes32.fromHexString("0x3d1599ba09480b2938b5d3488f943c04db4ad5a74e3ccdc588aa9ee182205f63");
+ private static final Bytes32 GOLDEN_M =
+ Bytes32.fromHexString("0x1255862357253d98bff5c483601c8cd4a5145027a383b8944be086aa0619a746");
+
+ private static List<FalconSeal> goldenCertificate() {
+ // Three seals, non-contiguous indices, and three DIFFERENT signature lengths inside the
+ // measured Falcon-512 range 648..660, so the vector also pins the length prefixes.
+ return List.of(
+ new FalconSeal(0, Bytes.repeat((byte) 0xa0, 655)),
+ new FalconSeal(2, Bytes.repeat((byte) 0xa2, 648)),
+ new FalconSeal(5, Bytes.repeat((byte) 0xa5, 660)));
+ }
+
+ @Test
+ public void anchorDigestMatchesItsGoldenVectorOverAnEmptyCertificate() {
+ assertThat(PqAnchor.anchorDigest(CHAIN_ID, 11801562L, PARENT_HASH, Collections.emptyList()))
+ .isEqualTo(GOLDEN_D_EMPTY);
+ }
+
+ @Test
+ public void anchorDigestMatchesItsGoldenVectorOverACarriedCertificate() {
+ assertThat(PqAnchor.anchorDigest(CHAIN_ID, 11801562L, PARENT_HASH, goldenCertificate()))
+ .isEqualTo(GOLDEN_D_CERT);
+ }
+
+ @Test
+ public void commitMessageMatchesItsGoldenVector() {
+ assertThat(PqAnchor.commitMessage(CHAIN_ID, 11801562L, PARENT_HASH)).isEqualTo(GOLDEN_M);
+ }
+}
diff --git a/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorThresholdGuardTest.java b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorThresholdGuardTest.java
new file mode 100755
index 000000000..9c7be4419
--- /dev/null
+++ b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorThresholdGuardTest.java
@@ -0,0 +1,215 @@
+/*
+ * Copyright contributors to Besu / Aere Network.
+ *
+ * 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.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import java.util.Map;
+import java.util.OptionalInt;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * AERE GARDA-PRAG: the PLANTED FAILURE for the seal-threshold guard.
+ *
+ * <p>Each refusal test builds, by hand, the exact configuration that stops the chain, and asserts
+ * that the guard sees it. The bound asserted is {@code K <= quorum(N) - 1}, which is 4 at N=7 and 2
+ * at N=4, and {@link #growingTheValidatorSetDoesNotBuyQuorumMargin()} is the test that would go green
+ * under the WRONG bound {@code K > N - f} and red under the right one.
+ *
+ * <p>The negative control for this file does not live in it: it is a second build of the same tree in
+ * which the guard body is replaced by a stub that accepts everything. Every refusal assertion below
+ * has to go RED in that build, otherwise these tests are proving nothing.
+ */
+class PqAnchorThresholdGuardTest {
+
+ private static final long CHAIN_ID = 2800L;
+ private static final long H = 12_000_000L;
+
+ /** Chain 2800 as it stands: seven validators, quorum ceil(14/3) = 5, f = 2. */
+ private static final int N_LIVE = 7;
+
+ private PqAnchorConfig armed(final Map<Long, Integer> schedule, final OptionalInt ceiling) {
+ return new PqAnchorConfig(CHAIN_ID, H, schedule, ceiling, false);
+ }
+
+ @Test
+ void theArithmeticIsTheOneTheChainActuallyUses() {
+ // The bound is not a constant typed into this test: it is Besu's own quorum formula, minus one.
+ assertThat(BftHelpers.calculateRequiredValidatorQuorum(N_LIVE)).isEqualTo(5);
+ assertThat(PqAnchorThresholdGuard.maxConfigurableThreshold(N_LIVE)).isEqualTo(4);
+ assertThat(PqAnchorThresholdGuard.byzantineBudget(N_LIVE)).isEqualTo(2);
+
+ assertThat(BftHelpers.calculateRequiredValidatorQuorum(4)).isEqualTo(3);
+ assertThat(PqAnchorThresholdGuard.maxConfigurableThreshold(4)).isEqualTo(2);
+ }
+
+ @Test
+ void plantedFailureAThresholdEqualToTheQuorumIsRefused() {
+ assertThatThrownBy(
+ () ->
+ PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort(
+ armed(Map.of(H, 0, H + 7_200L, 1, H + 21_600L, 5), OptionalInt.empty()),
+ N_LIVE))
+ .isInstanceOf(FalconSealSupport.ActivationConfigException.class)
+ .hasMessageContaining(PqAnchorThresholdGuard.CODE)
+ .hasMessageContaining("REFUSING TO START")
+ .hasMessageContaining("reaches 5 at height " + (H + 21_600L))
+ .hasMessageContaining("may be configured at this set size is 4");
+ }
+
+ @Test
+ void plantedFailureAnImpossibleThresholdAboveTheSetSizeIsRefused() {
+ assertThatThrownBy(
+ () ->
+ PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort(
+ armed(Map.of(H, 0, H + 100L, 99), OptionalInt.empty()), N_LIVE))
+ .isInstanceOf(FalconSealSupport.ActivationConfigException.class)
+ .hasMessageContaining(PqAnchorThresholdGuard.CODE);
+ }
+
+ @Test
+ void plantedFailureTheVeryFirstStepMayAlsoBeFatal() {
+ // A schedule that opens AT the quorum. The producer's existing log-only warning covers K>0 at H
+ // for a different reason; this asserts the refusal fires on the same step.
+ assertThatThrownBy(
+ () ->
+ PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort(
+ armed(Map.of(H, 5), OptionalInt.empty()), N_LIVE))
+ .isInstanceOf(FalconSealSupport.ActivationConfigException.class)
+ .hasMessageContaining("reaches 5 at height " + H);
+ }
+
+ @Test
+ void theHighestSafeThresholdStarts() {
+ assertThatCode(
+ () ->
+ PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort(
+ armed(Map.of(H, 0, H + 7_200L, 1, H + 21_600L, 4), OptionalInt.empty()),
+ N_LIVE))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ void aScheduleWithFullMarginStarts() {
+ // quorum - f = 3 at N=7, so 3 is the last rung that survives f silent signers.
+ assertThatCode(
+ () ->
+ PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort(
+ armed(Map.of(H, 0, H + 7_200L, 3), OptionalInt.empty()), N_LIVE))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ void growingTheValidatorSetDoesNotBuyQuorumMargin() {
+ // THIS is the test that separates the right bound from the wrong one. At N=9 the quorum is 6
+ // while N-f is 7, so the rule "refuse when K > N - f" would ACCEPT K=7, which is a rung no
+ // proposer can ever reach. Both 6 and 7 must be refused.
+ assertThat(BftHelpers.calculateRequiredValidatorQuorum(9)).isEqualTo(6);
+ assertThat(9 - PqAnchorThresholdGuard.byzantineBudget(9)).isEqualTo(7);
+
+ assertThatThrownBy(
+ () ->
+ PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort(
+ armed(Map.of(H, 0, H + 100L, 6), OptionalInt.empty()), 9))
+ .isInstanceOf(FalconSealSupport.ActivationConfigException.class);
+
+ assertThatThrownBy(
+ () ->
+ PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort(
+ armed(Map.of(H, 0, H + 100L, 7), OptionalInt.empty()), 9))
+ .isInstanceOf(FalconSealSupport.ActivationConfigException.class);
+
+ assertThatCode(
+ () ->
+ PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort(
+ armed(Map.of(H, 0, H + 100L, 5), OptionalInt.empty()), 9))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ void theBoundAtFourValidatorsIsTwo() {
+ assertThatThrownBy(
+ () ->
+ PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort(
+ armed(Map.of(H, 0, H + 30L, 3), OptionalInt.empty()), 4))
+ .isInstanceOf(FalconSealSupport.ActivationConfigException.class)
+ .hasMessageContaining("quorum for the 4 validators");
+
+ assertThatCode(
+ () ->
+ PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort(
+ armed(Map.of(H, 0, H + 30L, 2), OptionalInt.empty()), 4))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ void theEmergencyCeilingRescuesAnOtherwiseFatalSchedule() {
+ // The way back from a bad activation must not be blocked by the guard that caught it.
+ assertThatCode(
+ () ->
+ PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort(
+ armed(Map.of(H, 0, H + 21_600L, 5), OptionalInt.of(1)), N_LIVE))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ void aCeilingAboveTheScheduleRescuesNothing() {
+ // The ceiling can only ever lower. A ceiling of 9 over a fatal 5 leaves the 5 in force.
+ assertThatThrownBy(
+ () ->
+ PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort(
+ armed(Map.of(H, 0, H + 21_600L, 5), OptionalInt.of(9)), N_LIVE))
+ .isInstanceOf(FalconSealSupport.ActivationConfigException.class);
+ }
+
+ @Test
+ void anUncountableValidatorSetIsRefusedRatherThanAssumedSafe() {
+ assertThatThrownBy(
+ () ->
+ PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort(
+ armed(Map.of(H, 0), OptionalInt.empty()), 0))
+ .isInstanceOf(FalconSealSupport.ActivationConfigException.class)
+ .hasMessageContaining("counted 0 validators");
+ }
+
+ @Test
+ void inertWhenTheAnchorWasNeverConfigured() {
+ // Chain 2800 as it stands today: a binary carrying this guard must behave exactly as before.
+ assertThatCode(
+ () ->
+ PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort(
+ PqAnchorConfig.never(CHAIN_ID), 1))
+ .doesNotThrowAnyException();
+ assertThatCode(
+ () -> PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort(null, 7))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ void inertWhenTheAnchorIsEmergencyDisarmed() {
+ // A disarmed node enforces nothing, so a fatal schedule on it cannot stop anything and must not
+ // stop the node from starting either. Refusing here would turn the disarm control into a brick.
+ final PqAnchorConfig disarmed =
+ new PqAnchorConfig(CHAIN_ID, H, Map.of(H, 0, H + 100L, 99), OptionalInt.empty(), true);
+ assertThat(disarmed.anchorConfigured()).isTrue();
+ assertThat(disarmed.everActive()).isFalse();
+ assertThatCode(
+ () -> PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort(disarmed, N_LIVE))
+ .doesNotThrowAnyException();
+ }
+}
diff --git a/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqArmingGateTest.java b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqArmingGateTest.java
new file mode 100755
index 000000000..41222af72
--- /dev/null
+++ b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqArmingGateTest.java
@@ -0,0 +1,388 @@
+/*
+ * Copyright contributors to Besu / Aere Network.
+ *
+ * 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.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import java.lang.reflect.Field;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Properties;
+import java.util.stream.Collectors;
+
+import org.apache.tuweni.bytes.Bytes;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * REGISTRY BINDING AT ARM TIME, THE LINE THAT WAS MISSING. {@code
+ * PqRegistryHash.requireBindingsOrThrow} was delivered with its own tests, and nothing called it.
+ * Its own javadoc said so: "NOT WIRED YET ... the call belongs beside AERE-PQC-REG-ARM-01 in
+ * FalconSealSupport". This class measures the wire.
+ *
+ * <p>WHAT THE WIRE BUYS, stated as the thing that is actually true. Before it, an ARMED node loaded
+ * a v1 registry without a word, and the registry decides who a Falcon seal is credited to. Measured
+ * on the real verification path on the same day: two rows with their public keys swapped - four
+ * distinct keys, four distinct addresses, so no uniqueness check would see anything - produced an
+ * ACCEPTED header; and the same key filed at two indices satisfied a threshold of two with one
+ * private key, which makes the threshold itself fiction.
+ *
+ * <p>WHY THE POSITIVE CONTROLS ARE THE EXPENSIVE HALF. A gate that refuses everything is not a gate,
+ * it is an outage wearing a security message. The tests that cost the most to get right here are the
+ * ones where the node STARTS: over a correct v2 registry, and over the very same v1 file when
+ * nothing is armed.
+ *
+ * <p>WHY THE ANCHOR CASE IS TESTED SEPARATELY FROM THE FORK-BLOCK CASE. They are different triggers
+ * and only one of them was previously guarded at all. {@code armingReadinessDiagnostic()} returns
+ * immediately when {@code aere.falcon.forkBlock} is unset, so AERE-PQC-REG-ARM-01 has never fired on
+ * a node armed through the certificate anchor. This guard fires on both, and {@link
+ * #armedThroughTheANCHORAloneTheNodeAlsoREFUSES} is the half that has no predecessor.
+ *
+ * <p>WHAT IS NOT MEASURED HERE, written rather than implied: nothing is deployed, no node is
+ * started, the fleet of seven is not touched, and every Falcon and ECDSA key below is a PROBE key
+ * generated in this JVM. Whether the refusal behaves the same on the seven real boxes at a
+ * coordinated restart is NOT MEASURED.
+ */
+public class PqArmingGateTest {
+
+ /** The height at which this fixture arms Falcon blocking. */
+ private static final long FORK = 7_000L;
+
+ /** Attachment must lead the fork block; the same shape PqForkArmingTest uses. */
+ private static final long ATTACH = 6_000L;
+
+ /** The chain id the registry is bound to. Not 2800: nothing here may look like the live fleet. */
+ private static final long CHAIN_ID = 220_878L;
+
+ /** The height the binding proofs are signed for. */
+ private static final long BIND_HEIGHT = FORK;
+
+ private static final int N = 4;
+
+ /** Every property this class is allowed to touch. Cleared before AND after every test. */
+ private static final List<String> OWNED_PROPERTIES =
+ List.of(
+ "aere.falcon.registry",
+ "aere.falcon.forkBlock",
+ "aere.falcon.attachBlock",
+ "aere.falcon.validatorCount",
+ "aere.falcon.testnetAllowSmallFleet",
+ PqAnchorConfig.PROPERTY_ANCHOR_BLOCK,
+ PqAnchorConfig.PROPERTY_CHAIN_ID,
+ PqAnchorConfig.PROPERTY_MIN_SEALS);
+
+ @TempDir private Path tmp;
+
+ @BeforeEach
+ public void setUp() throws Exception {
+ clearOwnedProperties();
+ forgetAnchorConfig();
+ resetFalconSingleton();
+ }
+
+ @AfterEach
+ public void tearDown() throws Exception {
+ clearOwnedProperties();
+ forgetAnchorConfig();
+ resetFalconSingleton();
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 1. THE FINDING, on each of the two arming triggers.
+ // -------------------------------------------------------------------------------------------
+
+ /** An armed node over a registry with no binding proofs must refuse to start. */
+ @Test
+ public void armedOverAV1RegistryTheNodeREFUSESToStart() throws Exception {
+ armWithForkBlock(writeRegistry("registry-v1.properties", false, false));
+
+ assertThatThrownBy(FalconSealSupport::instance)
+ .describedAs(
+ "a v1 registry decides attribution by whoever wrote the file; arming over it is "
+ + "permanent, because the anchor contract is immutable once written")
+ .isInstanceOf(PqRegistryHash.RegistryConfigException.class)
+ .hasMessageContaining("AERE-PQC-REG-ARM-02");
+ }
+
+ /**
+ * The same refusal when the node is armed through the CERTIFICATE ANCHOR and {@code
+ * aere.falcon.forkBlock} is not set at all.
+ *
+ * <p>This is the case with no predecessor. AERE-PQC-REG-ARM-01 is raised by {@code
+ * armingReadinessDiagnostic()}, whose first statement is to return when the fork block is unset,
+ * so an anchor-armed node has never been asked ANY question about its registry's shape at startup.
+ */
+ @Test
+ public void armedThroughTheANCHORAloneTheNodeAlsoREFUSES() throws Exception {
+ final Path v1 = writeRegistry("registry-v1.properties", false, false);
+ System.setProperty("aere.falcon.registry", v1.toAbsolutePath().toString());
+ armWithAnchorOnly();
+
+ assertThat(System.getProperty("aere.falcon.forkBlock"))
+ .describedAs("this test is only worth something while the fork block is genuinely unset")
+ .isNull();
+ assertThatThrownBy(FalconSealSupport::instance)
+ .isInstanceOf(PqRegistryHash.RegistryConfigException.class)
+ .hasMessageContaining("AERE-PQC-REG-ARM-02");
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 2. THE POSITIVE CONTROLS. Without these the refusals above could be a load bug.
+ // -------------------------------------------------------------------------------------------
+
+ /**
+ * The same node, the same arming, over a registry whose every row carries a Falcon possession
+ * proof and an ECDSA claim signed by that row's own validator key, STARTS - and loads.
+ */
+ @Test
+ public void armedOverAV2RegistryTheNodeSTARTS() throws Exception {
+ armWithForkBlock(writeRegistry("registry-v2.properties", true, true));
+
+ assertThatCode(FalconSealSupport::instance)
+ .describedAs(
+ "POSITIVE CONTROL: the gate can be green. A refusal that no correct input can pass is "
+ + "an outage wearing a security message")
+ .doesNotThrowAnyException();
+ assertThat(FalconSealSupport.instance().registrySize())
+ .describedAs("and it must really have loaded the file, not merely declined to throw")
+ .isEqualTo(N);
+ }
+
+ /** The same, armed through the anchor alone. */
+ @Test
+ public void armedThroughTheANCHORAloneOverAV2RegistryTheNodeSTARTS() throws Exception {
+ final Path v2 = writeRegistry("registry-v2.properties", true, true);
+ System.setProperty("aere.falcon.registry", v2.toAbsolutePath().toString());
+ armWithAnchorOnly();
+
+ assertThatCode(FalconSealSupport::instance).doesNotThrowAnyException();
+ assertThat(FalconSealSupport.instance().registrySize()).isEqualTo(N);
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 3. THE BOUNDARY. A node that arms NOTHING must be untouched by any of this.
+ // -------------------------------------------------------------------------------------------
+
+ /**
+ * THE GUARANTEE FOR CHAIN 2800 AS IT STANDS: a node with no {@code aere.pq.*} property and no
+ * {@code aere.falcon.forkBlock} starts over the very same v1 file that is refused when armed.
+ *
+ * <p>The assertion that carries the weight is not the "starts" - it is the property sweep. A test
+ * that only asserted "does not throw" would keep passing if a later edit made the guard read some
+ * other property that happened to be set in this JVM. The sweep states the precondition as a
+ * measurement: at the moment the constructor runs, NO system property beginning with {@code
+ * aere.pq.} exists, and neither does the fork block.
+ */
+ @Test
+ public void withNothingArmedTheGateIsInertOverTheSameV1Registry() throws Exception {
+ final Path v1 = writeRegistry("registry-v1.properties", false, false);
+ System.setProperty("aere.falcon.registry", v1.toAbsolutePath().toString());
+
+ assertThat(systemPropertiesStartingWith("aere.pq."))
+ .describedAs("the precondition of this test, measured rather than assumed")
+ .isEmpty();
+ assertThat(System.getProperty("aere.falcon.forkBlock")).isNull();
+
+ assertThatCode(FalconSealSupport::instance)
+ .describedAs(
+ "the same file that is refused when armed is accepted when nothing is armed, so the "
+ + "trigger is ARMING and not the file")
+ .doesNotThrowAnyException();
+ assertThat(FalconSealSupport.instance().registrySize())
+ .describedAs("and an unarmed node's registry is loaded exactly as it was before this guard")
+ .isEqualTo(N);
+ }
+
+ /**
+ * The same boundary with NO registry configured either, which is a node holding nothing at all -
+ * the shape of a fresh box joining the fleet before any key ceremony.
+ */
+ @Test
+ public void aNodeWithNoFalconConfigurationAtAllStarts() {
+ assertThat(systemPropertiesStartingWith("aere.pq.")).isEmpty();
+ assertThat(System.getProperty("aere.falcon.registry")).isNull();
+
+ assertThatCode(FalconSealSupport::instance).doesNotThrowAnyException();
+ }
+
+ /**
+ * An ARMED node with no registry file at all is deliberately NOT this guard's business, and this
+ * test is what stops that from being a silent decision.
+ *
+ * <p>This guard is about mis-ATTRIBUTION, which needs rows; an empty registry credits nobody. The
+ * condition
+ * is owned by AERE-PQC-CFG-UNSAFE-08 when the threshold is positive, and MEASURED here: with a
+ * threshold of zero, which is the warm-up regime the fleet is meant to arm INTO, the node starts.
+ * An earlier revision of this guard refused here, and the cost was exactly that - the intended
+ * activation procedure became unstartable.
+ */
+ @Test
+ public void armedWithNoRegistryAtAllAndAZeroThresholdTheNodeStarts() {
+ System.setProperty(PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, Long.toString(FORK));
+ System.setProperty(PqAnchorConfig.PROPERTY_CHAIN_ID, Long.toString(CHAIN_ID));
+ System.setProperty(PqAnchorConfig.PROPERTY_MIN_SEALS, FORK + ":0");
+ System.setProperty("aere.falcon.validatorCount", Integer.toString(N));
+ System.setProperty("aere.falcon.testnetAllowSmallFleet", "true");
+
+ assertThatCode(FalconSealSupport::instance)
+ .describedAs("K=0 over an empty registry is the warm-up regime, not a mis-attribution defect")
+ .doesNotThrowAnyException();
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 4. HALF A v2 REGISTRY IS NOT A v2 REGISTRY.
+ // -------------------------------------------------------------------------------------------
+
+ /**
+ * A row that carries a Falcon possession proof and no ECDSA claim proves that SOMEBODY holds the
+ * key, and says nothing about which validator asked for it - which is the whole of this guard.
+ *
+ * <p>MEASURED, and the assertion was CHANGED to match the measurement rather than the other way
+ * round. The expectation written first was AERE-PQC-REG-ARM-02. What actually happens is a refusal
+ * one step EARLIER, at load, with AERE-PQC-REG-LOAD-21, because the loader counts proofs against
+ * claims and refuses a half-bound file before the arming gate ever sees it. That is the stronger
+ * of the two refusals - it holds whether or not the node is armed - so this is asserted on the
+ * code that actually fires.
+ */
+ @Test
+ public void possessionWithoutAClaimIsRefusedEarlierStillAtLoad() throws Exception {
+ armWithForkBlock(writeRegistry("registry-possession-only.properties", true, false));
+
+ assertThatThrownBy(FalconSealSupport::instance)
+ .describedAs(
+ "the attacker is the key holder, so a genuine possession proof over a lying row is "
+ + "genuinely produceable; only the validator's own signature closes it")
+ .isInstanceOf(PqRegistryHash.RegistryConfigException.class)
+ .hasMessageContaining("AERE-PQC-REG-LOAD-21");
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // Helpers.
+ // -------------------------------------------------------------------------------------------
+
+ /** Arm through {@code aere.falcon.forkBlock}, the trigger AERE-PQC-REG-ARM-01 also watches. */
+ private void armWithForkBlock(final Path registry) {
+ System.setProperty("aere.falcon.registry", registry.toAbsolutePath().toString());
+ System.setProperty("aere.falcon.forkBlock", Long.toString(FORK));
+ System.setProperty("aere.falcon.attachBlock", Long.toString(ATTACH));
+ System.setProperty("aere.falcon.validatorCount", Integer.toString(N));
+ // N=4 is below the blocking minimum; this fixture is an isolated network and says so with the
+ // switch the codebase already uses for exactly that, rather than by pretending to be seven.
+ System.setProperty("aere.falcon.testnetAllowSmallFleet", "true");
+ }
+
+ /**
+ * Arm through the CERTIFICATE ANCHOR only, leaving {@code aere.falcon.forkBlock} unset. The
+ * threshold is 2, which {@code worstCaseKeyedSigners(4, 4)} = 3 guarantees, so the
+ * threshold-reachability guard next door stays silent and cannot be mistaken for this one.
+ */
+ private void armWithAnchorOnly() {
+ System.setProperty(PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, Long.toString(FORK));
+ System.setProperty(PqAnchorConfig.PROPERTY_CHAIN_ID, Long.toString(CHAIN_ID));
+ System.setProperty(PqAnchorConfig.PROPERTY_MIN_SEALS, FORK + ":0," + (FORK + 10L) + ":2");
+ System.setProperty("aere.falcon.validatorCount", Integer.toString(N));
+ System.setProperty("aere.falcon.testnetAllowSmallFleet", "true");
+ }
+
+ /**
+ * Write a registry in the legacy properties form. {@code withPossession} and {@code withClaim} are
+ * separate so that the half-bound case can be built, which is the one the loader must refuse.
+ */
+ private Path writeRegistry(
+ final String name, final boolean withPossession, final boolean withClaim) throws Exception {
+ final StringBuilder b = new StringBuilder();
+ if (withPossession || withClaim) {
+ b.append("formatVersion=").append(PqRegistryBinding.FORMAT_VERSION).append('\n');
+ b.append("chainId=").append(CHAIN_ID).append('\n');
+ b.append("bindHeight=").append(BIND_HEIGHT).append('\n');
+ }
+ b.append("count=").append(N).append('\n');
+ for (int i = 0; i < N; i++) {
+ b.append(i).append('=').append(unprefixed(PqV2Fixture.publicKey(i))).append('\n');
+ b.append(i)
+ .append(".addr=")
+ .append(unprefixed(PqV2Fixture.address(i).getBytes().toArray()))
+ .append('\n');
+ if (withPossession) {
+ b.append(i)
+ .append(".pop=")
+ .append(strip(PqV2Fixture.popHex(CHAIN_ID, BIND_HEIGHT, N, i)))
+ .append('\n');
+ }
+ if (withClaim) {
+ b.append(i)
+ .append(".claim=")
+ .append(strip(PqV2Fixture.claimHex(CHAIN_ID, BIND_HEIGHT, N, i)))
+ .append('\n');
+ }
+ }
+ final Path f = tmp.resolve(name);
+ Files.writeString(f, b.toString(), StandardCharsets.UTF_8);
+ return f;
+ }
+
+ private static String unprefixed(final byte[] b) {
+ return Bytes.wrap(b).toUnprefixedHexString();
+ }
+
+ private static String strip(final String hex) {
+ return hex.startsWith("0x") ? hex.substring(2) : hex;
+ }
+
+ /** Every system property name with the given prefix, so a precondition can be MEASURED. */
+ private static List<String> systemPropertiesStartingWith(final String prefix) {
+ final Properties p = System.getProperties();
+ return p.stringPropertyNames().stream()
+ .filter(n -> n.startsWith(prefix))
+ .sorted()
+ .collect(Collectors.toList());
+ }
+
+ private static void clearOwnedProperties() {
+ for (final String p : OWNED_PROPERTIES) {
+ System.clearProperty(p);
+ }
+ }
+
+ /**
+ * Force the anchor configuration to be re-read from system properties.
+ *
+ * <p>MEASURED 2026-08-06, and it is the reason this method exists rather than being assumed
+ * unnecessary. {@code PqAnchorProducer.config()} memoises the first configuration it ever builds,
+ * for the life of the JVM. That is CORRECT in production - a node is one JVM with one set of
+ * properties, and a configuration that could change underneath the consensus path would be worse
+ * than one that cannot. In a test JVM shared by every class in this module it means an anchor
+ * armed by an earlier test is still armed here, and {@link
+ * #withNothingArmedTheGateIsInertOverTheSameV1Registry} failed exactly that way before this call
+ * was added: the property sweep found no {@code aere.pq.*} and the node still refused, because
+ * the memo held another class's anchor.
+ */
+ private static void forgetAnchorConfig() {
+ org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer.useConfigForTesting(
+ null);
+ }
+
+ private static void resetFalconSingleton() throws Exception {
+ final Field f = FalconSealSupport.class.getDeclaredField("instance");
+ f.setAccessible(true);
+ f.set(null, null);
+ }
+}
diff --git a/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqCallerIntentTest.java b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqCallerIntentTest.java
new file mode 100755
index 000000000..0af0116a7
--- /dev/null
+++ b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqCallerIntentTest.java
@@ -0,0 +1,386 @@
+/*
+ * Copyright contributors to Besu / Aere Network.
+ *
+ * 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 org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer;
+import org.hyperledger.besu.datatypes.Address;
+
+import java.lang.reflect.Field;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.OptionalInt;
+
+import org.apache.tuweni.bytes.Bytes;
+import org.apache.tuweni.bytes.Bytes32;
+import org.bouncycastle.crypto.digests.KeccakDigest;
+import org.bouncycastle.pqc.crypto.falcon.FalconPrivateKeyParameters;
+import org.bouncycastle.pqc.crypto.falcon.FalconSigner;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * ROTATION HARDENING (b-v2). The repair of the repair: the caller's MOTIVE decides, not the height.
+ *
+ * <p>WHAT THE FIRST SHAPE DID, MEASURED AND NOT ARGUED. On 2026-08-06 hardening (b) refused every
+ * unbound height at or above the arming height, deciding from the block NUMBER alone. Run against
+ * the suite that gave 588 tests and 0 failures on a clean tree, it gave 597 tests and 6 failures:
+ * five in {@code PqSealPersistenceTest} and one in {@code PqForkValidatorSetChangeTest}. Both
+ * classes work on THIS NODE'S OWN head - restarting and re-reading its own seal file, and proposing
+ * on top of its own head - and in all six the number handed to the guard was 1030 with an arming
+ * height of 1000. A genuinely historical question, in the same process in the same second, hands
+ * the guard exactly those numbers too. No arithmetic on the height separates them.
+ *
+ * <p>THE OPERATIONAL CONSEQUENCE, in the words of that failure itself: {@code refusing to
+ * propose on top of block 1030 because this node holds 0 valid eligible Falcon seal(s)}. The first
+ * shape turned a defect that is invisible on a running fleet and fatal only to a node syncing later
+ * into one that stops block production on all seven, in the minute the anchor is armed.
+ *
+ * <p>WHAT SEPARATES THEM IS WHO SUPPLIES THE SUBJECT, and that is known at every call site and was
+ * being discarded at the interface boundary. So {@code PqSignerRegistry} now carries two named
+ * pairs, and the compiler forces every call site to say which question it is asking. This class is
+ * the proof that the two doors answer DIFFERENTLY at the SAME height, that the own-head door is not
+ * a loophole, and that the history door still refuses.
+ *
+ * <p>THIS CLASS CANNOT GO GREEN BY ACCIDENT. Three of its tests fail if the own-head door is made
+ * to refuse (which is the first shape restored), and three fail if the history door is made to
+ * answer (which is the pre-2026-08-06 defect restored). The two plants are run in opposite
+ * directions, and each was measured before this class was allowed to count as evidence.
+ */
+public class PqCallerIntentTest {
+
+ /** Anchor activation height H, matching the fixture the six failures ran under. */
+ private static final long H = 1_000L;
+
+ /** Height from which the staged threshold is non-zero, i.e. the fully armed regime. */
+ private static final long K_AT = H + 10L;
+
+ /**
+ * The height the six failures actually presented to the guard: this node's own head, above the
+ * arming height. Named for what it is, because the whole point is that the NUMBER is innocent.
+ */
+ private static final long OWN_HEAD = 1_030L;
+
+ /** A height far above H, standing in for "a year of history above the arming height". */
+ private static final long DEEP = K_AT + 5_000L;
+
+ private static final int N = 7;
+
+ private static final long CHAIN_ID = 220_878L;
+
+ @TempDir private Path tmp;
+
+ private final List<FalconPrivateKeyParameters> privateKeys = new ArrayList<>();
+ private final List<Address> validators = new ArrayList<>();
+ private Path genesisPath;
+
+ /** A fixed 32-byte message, standing in for M(parent) or a committed-seal hash. */
+ private static final Bytes32 MESSAGE = Bytes32.fromHexString("0x" + "5a".repeat(32));
+
+ private Bytes sealByIndexZero;
+
+ @BeforeEach
+ public void setUp() throws Exception {
+ // AERE REGISTRY BINDING (2026-08-06): a v2, PROOF-BOUND registry. It used to be v1, with
+ // addresses spelled 0xA00+i, which no secp256k1 key can sign for, so this fixture described a
+ // fleet that could never satisfy AERE-PQC-REG-ARM-02 once that guard was wired. The registry is
+ // bound at H, the height this fixture arms the anchor from.
+ final KeccakDigest kd = new KeccakDigest(256);
+ final StringBuilder manifest = new StringBuilder();
+ manifest
+ .append("{\"config\":{\"aereFalconRegistry\":{")
+ .append(PqV2Fixture.manifestHeader(N, CHAIN_ID, H));
+ for (int i = 0; i < N; i++) {
+ privateKeys.add(PqV2Fixture.privateKey(i));
+ validators.add(PqV2Fixture.address(i));
+ final byte[] anchoredRow = PqV2Fixture.anchorPreimageRow(i);
+ kd.update(anchoredRow, 0, anchoredRow.length);
+ manifest.append(',').append(PqV2Fixture.manifestEntry(i, N, CHAIN_ID, H));
+ }
+ manifest
+ .append("}},\"alloc\":{\"0000000000000000000000000000000000000fa1\":{\"storage\":{\"0x")
+ .append("0".repeat(64))
+ .append("\":\"0x");
+ final byte[] anchoredHash = new byte[32];
+ kd.doFinal(anchoredHash, 0);
+ manifest.append(Bytes.wrap(anchoredHash).toUnprefixedHexString()).append("\"}}}}");
+
+ genesisPath = tmp.resolve("genesis-d2v2.json");
+ Files.writeString(genesisPath, manifest.toString());
+ System.setProperty("aere.falcon.genesis", genesisPath.toAbsolutePath().toString());
+
+ final FalconSigner signer = new FalconSigner();
+ signer.init(true, privateKeys.get(0));
+ sealByIndexZero = Bytes.wrap(signer.generateSignature(MESSAGE.toArray()));
+
+ resetFalconSingleton();
+ // Exactly the montage the six failures ran under: armed at 1000, K staged at 1010, own head
+ // 1030, and NO config.pqRegistryHash anywhere - the state of every node on chain 2800 today.
+ PqAnchorProducer.useConfigForTesting(
+ new PqAnchorConfig(CHAIN_ID, H, Map.of(H, 0, K_AT, 3), OptionalInt.empty(), false));
+ }
+
+ @AfterEach
+ public void tearDown() throws Exception {
+ System.clearProperty("aere.falcon.genesis");
+ System.clearProperty("aere.pq.genesis");
+ System.clearProperty(FalconSealSupport.PROPERTY_REGISTRY_HISTORY);
+ resetFalconSingleton();
+ PqAnchorProducer.useConfigForTesting(null);
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 0. The fixture. Without this a green run below could mean the registry never loaded at all.
+ // -------------------------------------------------------------------------------------------
+
+ @Test
+ public void baselineTheFixtureIsGenesisAnchoredAndTheSealIsGENUINE() {
+ final FalconSealSupport pqc = FalconSealSupport.instance();
+ assertThat(pqc.genesisAnchored())
+ .describedAs("the fixture must load a GENESIS-ANCHORED registry, or nothing here means anything")
+ .isTrue();
+ assertThat(pqc.addressBound()).isTrue();
+ assertThat(pqc.verify(0, MESSAGE, sealByIndexZero))
+ .describedAs("the seal must be a REAL Falcon signature under the head registry")
+ .isTrue();
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 1. THE WHOLE REPAIR, IN ONE ASSERTION. Same height, same index, same signature, same instant.
+ // Two answers, because two different questions were asked.
+ // -------------------------------------------------------------------------------------------
+
+ @Test
+ public void theSameHeightGivesTwoAnswersBecauseTheQUESTIONSDIFFER() {
+ final FalconSealSupport pqc = FalconSealSupport.instance();
+
+ assertThat(pqc.verifyAtHistoric(OWN_HEAD, 0, MESSAGE, sealByIndexZero))
+ .describedAs(
+ "HISTORY door at 1030, armed from 1000, no schedule: a node judging somebody else's "
+ + "header cannot say which keys were in force there, so it REFUSES. Answering from "
+ + "the head registry here is the rotation defect verbatim")
+ .isFalse();
+
+ assertThat(pqc.verifyAtOwnHead(OWN_HEAD, 0, MESSAGE, sealByIndexZero))
+ .describedAs(
+ "OWN-HEAD door, SAME height, SAME seal, same instant: this node's own head, where the "
+ + "head registry IS the answer by construction. Refusing here is what stopped the "
+ + "proposer and the restart path on 2026-08-06, and it bought no security: the "
+ + "certificate is re-checked by the other six through the history door")
+ .isTrue();
+
+ assertThat(pqc.addressForIndexAtHistoric(OWN_HEAD, 0))
+ .describedAs("the address halves must split the same way, or R2 and the producer disagree")
+ .isNull();
+ assertThat(pqc.addressForIndexAtOwnHead(OWN_HEAD, 0)).isEqualTo(validators.get(0));
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 2. The restart path, which is five of the six failures. PqSealStore has already forced the
+ // stored block number and hash to equal this node's head before it asks.
+ // -------------------------------------------------------------------------------------------
+
+ @Test
+ public void theRESTARTPathAnswersAtAnArmedHeightWithNoSchedule() {
+ final FalconSealSupport pqc = FalconSealSupport.instance();
+ assertThat(pqc.verifyAtOwnHead(OWN_HEAD, 0, MESSAGE, sealByIndexZero))
+ .describedAs(
+ "PqSealPersistenceTest restored 0 of 3 genuine seals under the first shape. A node "
+ + "that cannot re-read its own seal file after a restart is a node that cannot "
+ + "propose, and the file is the documented way out of that restart deadlock")
+ .isTrue();
+ assertThat(pqc.addressForIndexAtOwnHead(OWN_HEAD, 0))
+ .describedAs("and the index must bind, or every stored seal is dropped as unknown")
+ .isEqualTo(validators.get(0));
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 3. The proposer path, the sixth failure. PqAnchorProducer resolves at the PARENT's height,
+ // and the parent is this node's own head.
+ // -------------------------------------------------------------------------------------------
+
+ @Test
+ public void thePROPOSERPathAnswersAtAnArmedHeightWithNoSchedule() {
+ final FalconSealSupport pqc = FalconSealSupport.instance();
+ for (int i = 0; i < 5; i++) {
+ final FalconSigner s = new FalconSigner();
+ s.init(true, privateKeys.get(i));
+ final Bytes sealI = Bytes.wrap(s.generateSignature(MESSAGE.toArray()));
+ assertThat(pqc.verifyAtOwnHead(OWN_HEAD, i, MESSAGE, sealI))
+ .describedAs(
+ "all K=5 genuine seals must resolve, or PqAnchorProducer throws "
+ + "PqAnchorNotReadyException with '5 were not eligible signers' and the node "
+ + "stops producing blocks - which is exactly what was measured")
+ .isTrue();
+ assertThat(pqc.addressForIndexAtOwnHead(OWN_HEAD, i)).isEqualTo(validators.get(i));
+ }
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 4. THE OWN-HEAD DOOR IS NOT A LOOPHOLE. This is the assertion that has to hold for the form to
+ // be worth anything: a configured epoch this node does NOT hold fails closed on BOTH doors.
+ // -------------------------------------------------------------------------------------------
+
+ @Test
+ public void theOwnHeadDoorIsNOTALoopholeAnUnheldEpochIsRefusedOnBOTHDOORS() throws Exception {
+ final FalconSealSupport pqc = FalconSealSupport.instance();
+ final PqRegistryHash.Registry held = PqRegistryHash.loadAuto(genesisPath);
+ final String hash = PqRegistryHash.hashFor(held, CHAIN_ID);
+ final long rotation = K_AT + 1_000L;
+
+ final Map<Long, String> entries = new LinkedHashMap<>();
+ entries.put(H, hash);
+ entries.put(rotation, "0x" + "cd".repeat(32));
+ pqc.verifyRegistryBindingOrAbort(0L, CHAIN_ID, scheduleFromGenesis(entries));
+
+ assertThat(pqc.verifyAtOwnHead(rotation, 0, MESSAGE, sealByIndexZero))
+ .describedAs(
+ "a rotation the chain HAS scheduled and this node does NOT hold is not a missing "
+ + "binding, it is a node running a registry the chain has moved off. If the "
+ + "own-head door answered here it would be a way to sign blocks under a retired "
+ + "key set, and the split would have bought a liveness fix at the price of the "
+ + "property the whole anchor exists for")
+ .isFalse();
+ assertThat(pqc.addressForIndexAtOwnHead(rotation, 0)).isNull();
+
+ assertThat(pqc.verifyAtHistoric(rotation, 0, MESSAGE, sealByIndexZero))
+ .describedAs("and the history door refuses identically")
+ .isFalse();
+
+ assertThat(pqc.verifyAtOwnHead(rotation - 1L, 0, MESSAGE, sealByIndexZero))
+ .describedAs("positive control: below the rotation this node holds the epoch and answers")
+ .isTrue();
+ assertThat(pqc.verifyAtHistoric(rotation - 1L, 0, MESSAGE, sealByIndexZero)).isTrue();
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 5. Negative control on the split itself: with the binding CONFIGURED, the two doors converge.
+ // If they do not, the own-head door is not a fallback rule, it is a second key set.
+ // -------------------------------------------------------------------------------------------
+
+ @Test
+ public void withTheScheduleConfiguredBOTHDOORSGiveTheSameAnswer() throws Exception {
+ final FalconSealSupport pqc = FalconSealSupport.instance();
+ final PqRegistryHash.Registry held = PqRegistryHash.loadAuto(genesisPath);
+ final Map<Long, String> entries = new LinkedHashMap<>();
+ // AERE REGISTRY BINDING (2026-08-06): hashFor, not hashV1. A schedule entry has to carry the
+ // canonical hash OF THE REGISTRY IT NAMES, and this fixture's registry is now v2, which hashes
+ // under a different domain tag. MEASURED: leaving hashV1 here made the entry name a registry
+ // nobody holds, and the height-resolved lookups fell through to a refusal - a green test
+ // turning red for a reason that had nothing to do with what it measures. This is the same
+ // breakage a real genesis takes: any config.pqRegistryHash computed before the registry was
+ // rebuilt as v2 stops matching the moment it is rebuilt.
+ entries.put(H, PqRegistryHash.hashFor(held, CHAIN_ID));
+ pqc.verifyRegistryBindingOrAbort(0L, CHAIN_ID, scheduleFromGenesis(entries));
+
+ for (final long h : new long[] {H - 1L, H, OWN_HEAD, DEEP}) {
+ assertThat(pqc.verifyAtHistoric(h, 0, MESSAGE, sealByIndexZero))
+ .describedAs(
+ "at height "
+ + h
+ + " with the epoch bound at the arming height, the history door resolves through "
+ + "the SCHEDULE, not through any fallback")
+ .isTrue();
+ assertThat(pqc.verifyAtOwnHead(h, 0, MESSAGE, sealByIndexZero))
+ .describedAs(
+ "and the own-head door gives the SAME answer at height "
+ + h
+ + ". The two doors differ only in what they do when NOTHING binds the height. If "
+ + "they differed with a binding in force, the split would have introduced a "
+ + "second key set rather than a second failure mode")
+ .isTrue();
+ assertThat(pqc.addressForIndexAtHistoric(h, 0)).isEqualTo(pqc.addressForIndexAtOwnHead(h, 0));
+ }
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 6. The live fleet is untouched. aere.pq.anchorBlock is unset on all seven today, so there is
+ // no arming height, and BOTH doors answer exactly as they did before either hardening.
+ // -------------------------------------------------------------------------------------------
+
+ @Test
+ public void whenTheAnchorIsNotArmedBOTHDOORSAnswerAndNOTHINGCHANGES() {
+ PqAnchorProducer.useConfigForTesting(PqAnchorConfig.never(CHAIN_ID));
+ final FalconSealSupport pqc = FalconSealSupport.instance();
+ assertThat(pqc.verifyAtHistoric(DEEP, 0, MESSAGE, sealByIndexZero)).isTrue();
+ assertThat(pqc.verifyAtOwnHead(DEEP, 0, MESSAGE, sealByIndexZero)).isTrue();
+ assertThat(pqc.addressForIndexAtHistoric(DEEP, 0)).isEqualTo(validators.get(0));
+ assertThat(pqc.addressForIndexAtOwnHead(DEEP, 0)).isEqualTo(validators.get(0));
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 7. Below the arming height nothing is being judged, so both doors answer. This is the
+ // assertion that goes red first if anybody makes the history door refuse unconditionally.
+ // -------------------------------------------------------------------------------------------
+
+ @Test
+ public void belowTheArmingHeightBOTHDOORSAnswer() {
+ final FalconSealSupport pqc = FalconSealSupport.instance();
+ assertThat(pqc.verifyAtHistoric(H - 1L, 0, MESSAGE, sealByIndexZero)).isTrue();
+ assertThat(pqc.verifyAtOwnHead(H - 1L, 0, MESSAGE, sealByIndexZero)).isTrue();
+ assertThat(pqc.verifyAtHistoric(0L, 0, MESSAGE, sealByIndexZero)).isTrue();
+ assertThat(pqc.addressForIndexAtHistoric(H - 1L, 0)).isEqualTo(validators.get(0));
+ assertThat(pqc.addressForIndexAtOwnHead(H - 1L, 0)).isEqualTo(validators.get(0));
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // Helpers.
+ // -------------------------------------------------------------------------------------------
+
+ /**
+ * Build a schedule the way a node really gets one: written into a genesis file as {@code
+ * config.pqRegistryHash} and parsed back, so the strictly-increasing rule in the parser is on the
+ * path rather than bypassed.
+ *
+ * @param entries height to 0x-prefixed registry hash, in ascending order of height
+ * @return the parsed schedule
+ * @throws Exception when the temporary genesis cannot be written
+ */
+ private PqRegistryHash.Schedule scheduleFromGenesis(final Map<Long, String> entries)
+ throws Exception {
+ final List<Long> heights = new ArrayList<>(entries.keySet());
+ heights.sort(Long::compare);
+ final StringBuilder sb = new StringBuilder("{\"config\":{\"pqRegistryHash\":[");
+ for (int i = 0; i < heights.size(); i++) {
+ if (i > 0) {
+ sb.append(',');
+ }
+ final long b = heights.get(i);
+ String h = entries.get(b);
+ if (!h.startsWith("0x")) {
+ h = "0x" + h;
+ }
+ sb.append("{\"block\":").append(b).append(",\"hash\":\"").append(h).append("\"}");
+ }
+ sb.append("]}}");
+ final Path p =
+ tmp.resolve("genesis-schedule-v2-" + heights.size() + "-" + heights.get(0) + ".json");
+ Files.writeString(p, sb.toString());
+ return PqRegistryHash.loadScheduleFromGenesis(p);
+ }
+
+ private static void resetFalconSingleton() throws Exception {
+ final Field f = FalconSealSupport.class.getDeclaredField("instance");
+ f.setAccessible(true);
+ f.set(null, null);
+ }
+}
diff --git a/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqFleetRestartArmingTest.java b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqFleetRestartArmingTest.java
new file mode 100755
index 000000000..b784d90e5
--- /dev/null
+++ b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqFleetRestartArmingTest.java
@@ -0,0 +1,390 @@
+/*
+ * Copyright contributors to Besu / Aere Network.
+ *
+ * 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 org.hyperledger.besu.crypto.SecureRandomProvider;
+import org.hyperledger.besu.datatypes.Address;
+
+import java.lang.reflect.Field;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.SecureRandom;
+
+import org.apache.tuweni.bytes.Bytes;
+import org.bouncycastle.crypto.AsymmetricCipherKeyPair;
+import org.bouncycastle.crypto.digests.KeccakDigest;
+import org.bouncycastle.pqc.crypto.falcon.FalconKeyGenerationParameters;
+import org.bouncycastle.pqc.crypto.falcon.FalconKeyPairGenerator;
+import org.bouncycastle.pqc.crypto.falcon.FalconParameters;
+import org.bouncycastle.pqc.crypto.falcon.FalconPrivateKeyParameters;
+import org.bouncycastle.pqc.crypto.falcon.FalconPublicKeyParameters;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * THE FLEET-RESTART DEADLOCK, AND THE STATE MACHINE THE REPAIR MOVES.
+ *
+ * <p>MEASURED FIRST, ON A NETWORK, NOT ASSUMED. A full activation rehearsal on a seven-node test
+ * network found that with the anchor armed at K&gt;0 a SIMULTANEOUS restart of every validator stops
+ * the chain for good. The node said it verbatim: "refusing to
+ * propose ... holds 0 valid eligible Falcon seal(s) ... threshold is 3", over "Attachment stays OFF
+ * (fail-safe)".
+ *
+ * <p>THE CIRCLE. {@code activateLateAnchor()} used to be reachable from exactly one place, {@code
+ * FalconSealValidationRule.tryActivateLateAnchor}, which runs only while a block is being IMPORTED.
+ * After a fleet restart no block is imported, because nobody proposes. So {@code lateActivated}
+ * stays false, {@link FalconSealSupport#attachmentArmed(long)} answers false, no seal is attached,
+ * no certificate reaches K, and nobody can propose. Seals come from Commits, Commits come from
+ * proposals, proposals need seals. With K=0 the chain heals itself. With K&gt;0 it never does.
+ *
+ * <p>WHAT THIS CLASS MEASURES, and it is the state machine the repair moves, not a paraphrase of
+ * it. The repair (QbftBesuControllerBuilder, marker "AERE BLOCAJ-REPORNIRE") adds a SECOND caller of
+ * the SAME method at startup, reading the SAME contract slot 0 out of the chain-head world state.
+ * So the question that decides whether the repair can work is exactly: does calling {@code
+ * activateLateAnchor} with the on-chain hash, with no block imported and no other stimulus, turn
+ * {@code attachmentArmed()} from false to true. Below, it does.
+ *
+ * <ol>
+ * <li>{@link #restartedFleetIsNotArmedAndDoesNotHealWithTime()} - the deadlock state itself. A
+ * node whose late anchor is PENDING is past its attachment height and still refuses to
+ * attach, at that height and at every height after it. Nothing in the process flips it.
+ * <li>{@link #activatingFromTheChainHeadArmsAttachment()} - the repair's mechanism. One call with
+ * the on-chain hash, and attachment is armed. This is the ONLY thing the startup code adds.
+ * <li>{@link #aWrongOnChainHashLeavesAttachmentOffAndIsTerminal()} - THE NEGATIVE CONTROL. If the
+ * hash does not match, activation must FAIL and attachment must stay OFF: the repair must not
+ * have bought liveness by weakening the tamper check. It also stays terminally failed, so a
+ * later correct hash does not resurrect it.
+ * <li>{@link #activationIsIdempotentAcrossRepeatedStartupCalls()} - the scope control. The
+ * startup call and the import-path call can both fire in one process; the second must be a
+ * no-op rather than a second registry load.
+ * <li>{@link #aGenesisAnchoredNodeIsArmedImmediatelyAfterRestart()} - the rehearsal's own
+ * stimulus replayed against THIS tree, and it does not fail the way the network did. Read its
+ * javadoc: the rehearsal binary predates the seal-attachment repair, and the line it logged
+ * came from a condition this tree no longer contains.
+ * </ol>
+ *
+ * <p>NOT MEASURED here, and named so it is not read as covered: that a real Besu process reads slot
+ * 0 out of a real chain-head world state (that is world-state plumbing in the app module, and the
+ * rehearsal network is the instrument for it), and that seven live nodes recover from a real
+ * simultaneous restart with this binary. This class measures the decision the deadlock hinges on.
+ */
+public class PqFleetRestartArmingTest {
+
+ /** Fleet size: seven, the validator count this deployment runs. */
+ private static final int N = 7;
+
+ /** Height at which the anchor contract is expected to be observable. */
+ private static final long OBSERVE = 1_000L;
+
+ /** Seal-attachment height, at or after OBSERVE. */
+ private static final long ATTACH = 1_200L;
+
+ /** A chain head well past the attachment height: this is what a restart comes back to. */
+ private static final long HEAD = 5_000L;
+
+ private static final String ANCHOR_ADDRESS = "0x0000000000000000000000000000000000000fa1";
+
+ @TempDir private Path tmp;
+
+ /** keccak256 over (addr20 || pk) for every index in order: what the anchor contract holds. */
+ private String onChainHash;
+
+ /** The same registry, spelled as a GENESIS-anchored manifest (the rehearsal's own shape). */
+ private Path genesisPath;
+
+ @BeforeEach
+ public void setUp() throws Exception {
+ resetFalconSingleton();
+
+ final SecureRandom rnd = SecureRandomProvider.createSecureRandom();
+ final KeccakDigest kd = new KeccakDigest(256);
+ final StringBuilder manifest = new StringBuilder("{\"count\":").append(N);
+ final StringBuilder genesis =
+ new StringBuilder("{\"config\":{\"aereFalconRegistry\":{\"count\":").append(N);
+ for (int i = 0; i < N; i++) {
+ final FalconKeyPairGenerator gen = new FalconKeyPairGenerator();
+ gen.init(new FalconKeyGenerationParameters(rnd, FalconParameters.falcon_512));
+ final AsymmetricCipherKeyPair kp = gen.generateKeyPair();
+ final FalconPublicKeyParameters pub = (FalconPublicKeyParameters) kp.getPublic();
+ final FalconPrivateKeyParameters priv = (FalconPrivateKeyParameters) kp.getPrivate();
+ final Address addr = Address.fromHexString(String.format("0x%040x", 0xA00 + i));
+
+ // The pre-image is accumulated in lockstep with the manifest text, exactly the way a real
+ // anchoring transaction is built, so the hash below is not copied out of the code under test.
+ final byte[] addrBytes = addr.getBytes().toArray();
+ kd.update(addrBytes, 0, addrBytes.length);
+ kd.update(pub.getH(), 0, pub.getH().length);
+
+ final String entry =
+ ",\""
+ + i
+ + "\":{\"addr\":\""
+ + addr.toHexString()
+ + "\",\"pk\":\""
+ + Bytes.wrap(pub.getH()).toHexString()
+ + "\"}";
+ manifest.append(entry);
+ genesis.append(entry);
+
+ if (i == 0) {
+ // This node is validator 0 and HOLDS a signing key, otherwise attachment is off for a
+ // reason that has nothing to do with the deadlock and the measurement would be vacuous.
+ final Path key0 = tmp.resolve("falcon-key-0.properties");
+ Files.writeString(
+ key0,
+ "index=0\n"
+ + "f="
+ + Bytes.wrap(priv.getSpolyf()).toHexString()
+ + "\n"
+ + "g="
+ + Bytes.wrap(priv.getG()).toHexString()
+ + "\n"
+ + "F="
+ + Bytes.wrap(priv.getSpolyF()).toHexString()
+ + "\n"
+ + "pk="
+ + Bytes.wrap(pub.getH()).toHexString()
+ + "\n");
+ System.setProperty("aere.falcon.key", key0.toAbsolutePath().toString());
+ }
+ }
+ manifest.append("}");
+
+ final byte[] digest = new byte[32];
+ kd.doFinal(digest, 0);
+ onChainHash = Bytes.wrap(digest).toUnprefixedHexString();
+
+ final Path manifestPath = tmp.resolve("falcon-late-manifest.json");
+ Files.writeString(manifestPath, manifest.toString());
+
+ // Same seven entries, anchored the way the rehearsal network anchored them: in genesis, with
+ // the hash committed in the anchor contract's slot 0 through alloc storage.
+ genesis
+ .append("}},\"alloc\":{\"0000000000000000000000000000000000000fa1\":{\"storage\":{\"0x")
+ .append("0".repeat(64))
+ .append("\":\"0x")
+ .append(onChainHash)
+ .append("\"}}}}");
+ genesisPath = tmp.resolve("genesis-registry.json");
+ Files.writeString(genesisPath, genesis.toString());
+
+ System.setProperty("aere.falcon.manifest", manifestPath.toAbsolutePath().toString());
+ System.setProperty("aere.falcon.anchor.address", ANCHOR_ADDRESS);
+ System.setProperty("aere.falcon.anchor.block", Long.toString(OBSERVE));
+ System.setProperty("aere.falcon.attachBlock", Long.toString(ATTACH));
+ System.setProperty("aere.falcon.validatorCount", Integer.toString(N));
+ }
+
+ @AfterEach
+ public void tearDown() throws Exception {
+ for (final String p :
+ new String[] {
+ "aere.falcon.manifest",
+ "aere.falcon.genesis",
+ "aere.falcon.key",
+ "aere.falcon.anchor.address",
+ "aere.falcon.anchor.block",
+ "aere.falcon.attachBlock",
+ "aere.falcon.forkBlock",
+ "aere.falcon.validatorCount"
+ }) {
+ System.clearProperty(p);
+ }
+ resetFalconSingleton();
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 1. The deadlock state, stated as a property.
+ // -------------------------------------------------------------------------------------------
+
+ @Test
+ public void restartedFleetIsNotArmedAndDoesNotHealWithTime() {
+ final FalconSealSupport pqc = FalconSealSupport.instance();
+
+ assertThat(pqc.lateAnchorPending())
+ .describedAs(
+ "fixture control: the late-anchor manifest must LOAD and stay PENDING, or every "
+ + "assertion below is about a registry that was never configured")
+ .isTrue();
+ assertThat(pqc.lateAnchored()).isFalse();
+ assertThat(pqc.lateAnchorFailed()).isFalse();
+ assertThat(pqc.signingEnabled())
+ .describedAs("fixture control: this node holds a Falcon key, so attachment is not off for "
+ + "the trivial reason")
+ .isTrue();
+ assertThat(pqc.attachBlock()).isEqualTo(ATTACH);
+
+ // This IS the post-restart state: the process has just started, the chain head is far past the
+ // attachment height, and no block has been imported because nobody has proposed one.
+ assertThat(pqc.attachmentArmed(HEAD))
+ .describedAs(
+ "the measured deadlock: attachment height long since passed, registry still pending, "
+ + "so no seal is attached and no certificate can ever reach K")
+ .isFalse();
+
+ // And it does not heal. Time, and blocks that are never imported, change nothing.
+ for (long n = HEAD; n <= HEAD + 10_000L; n += 1_000L) {
+ assertThat(pqc.attachmentArmed(n))
+ .describedAs("still not armed at height %s; nothing in the process flips it", n)
+ .isFalse();
+ }
+ assertThat(pqc.registrySize())
+ .describedAs("the registry is EMPTY while pending, which is why a seal cannot verify either")
+ .isZero();
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 2. The repair's mechanism: the SECOND caller, the one startup adds.
+ // -------------------------------------------------------------------------------------------
+
+ @Test
+ public void activatingFromTheChainHeadArmsAttachment() {
+ final FalconSealSupport pqc = FalconSealSupport.instance();
+ assertThat(pqc.attachmentArmed(HEAD)).isFalse();
+
+ // Exactly what the startup repair does: hand over the 32-byte value read from the anchor
+ // contract's slot 0 in the CHAIN-HEAD world state. No block is imported anywhere here.
+ final boolean activated = pqc.activateLateAnchor(onChainHash);
+
+ assertThat(activated).isTrue();
+ assertThat(pqc.lateAnchored()).isTrue();
+ assertThat(pqc.lateAnchorPending()).isFalse();
+ assertThat(pqc.registrySize()).isEqualTo(N);
+ assertThat(pqc.addressBound())
+ .describedAs("the activated registry must bind every index to an address, or a seal cannot "
+ + "be resolved to a signer")
+ .isTrue();
+ assertThat(pqc.attachmentArmed(HEAD))
+ .describedAs(
+ "THE REPAIR: one activation from chain-head state arms attachment, so a restarted "
+ + "validator emits Falcon-carrying Commits again, certificates reach K, and a "
+ + "proposer can propose. This is the edge the deadlock needed and did not have.")
+ .isTrue();
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 3. THE NEGATIVE CONTROL. The repair must not have bought liveness by weakening the check.
+ // -------------------------------------------------------------------------------------------
+
+ @Test
+ public void aWrongOnChainHashLeavesAttachmentOffAndIsTerminal() {
+ final FalconSealSupport pqc = FalconSealSupport.instance();
+
+ // One flipped nibble: a tampered anchor, or a wrong manifest shipped to this node.
+ final char first = onChainHash.charAt(0);
+ final String wrong = (first == '0' ? '1' : '0') + onChainHash.substring(1);
+ assertThat(wrong).isNotEqualTo(onChainHash).hasSize(64);
+
+ assertThat(pqc.activateLateAnchor(wrong))
+ .describedAs("a mismatching anchor must NOT activate the registry")
+ .isFalse();
+ assertThat(pqc.lateAnchored()).isFalse();
+ assertThat(pqc.lateAnchorFailed())
+ .describedAs("and the mismatch must be TERMINAL, not merely 'not yet'")
+ .isTrue();
+ assertThat(pqc.registrySize())
+ .describedAs("the registry stays EMPTY: fail-closed, not fail-open")
+ .isZero();
+ assertThat(pqc.attachmentArmed(HEAD))
+ .describedAs(
+ "attachment stays OFF after a failed activation. If this were true, the startup repair "
+ + "would have turned a tamper detection into an arming path.")
+ .isFalse();
+
+ // And the correct hash afterwards does not resurrect it: a node that has seen a tampered anchor
+ // stays refused, which is the same fail-closed rule the import path already had.
+ assertThat(pqc.activateLateAnchor(onChainHash)).isFalse();
+ assertThat(pqc.attachmentArmed(HEAD)).isFalse();
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 4. Scope control: two callers now exist in one process.
+ // -------------------------------------------------------------------------------------------
+
+ @Test
+ public void activationIsIdempotentAcrossRepeatedStartupCalls() {
+ final FalconSealSupport pqc = FalconSealSupport.instance();
+
+ assertThat(pqc.activateLateAnchor(onChainHash)).isTrue();
+ final int afterFirst = pqc.registrySize();
+
+ // The startup call has fired; the import path fires too, on the first block that arrives.
+ assertThat(pqc.activateLateAnchor(onChainHash)).isTrue();
+ assertThat(pqc.registrySize()).isEqualTo(afterFirst).isEqualTo(N);
+ assertThat(pqc.attachmentArmed(HEAD)).isTrue();
+
+ // Even a garbage hash after activation cannot un-arm it: activation is a one-way latch, so a
+ // second reader with a stale view cannot disarm a fleet that is already sealing.
+ assertThat(pqc.activateLateAnchor("00".repeat(32))).isTrue();
+ assertThat(pqc.lateAnchorFailed()).isFalse();
+ assertThat(pqc.attachmentArmed(HEAD)).isTrue();
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 5. The rehearsal's OWN stimulus, replayed against THIS tree. Read the note before trusting it.
+ // -------------------------------------------------------------------------------------------
+
+ /**
+ * The seven-node rehearsal ran a GENESIS-anchored registry, and the line it logged after the
+ * simultaneous restart was the COVERAGE one: "no validator set has been observed yet, so registry
+ * COVERAGE cannot be proven. Attachment stays OFF (fail-safe)". That condition does not exist in
+ * this tree: {@code grep} for it returns nothing, because the seal-attachment repair took the
+ * fleet-wide question out of the per-commit gate. The rehearsal binary was built before that
+ * repair landed, and still had it.
+ *
+ * <p>So this test states what is true HERE: a genesis-anchored node, freshly constructed, with no
+ * validator set observed and no block imported, IS armed. The rehearsal's measured deadlock is
+ * closed for the genesis-anchored path by a repair that already landed - and NOT by the startup
+ * repair this class is about.
+ *
+ * <p>Which is exactly why the startup repair is still needed: on the LATE-ANCHOR path, the one
+ * the live chain must use because it cannot be re-genesised, {@code lateActivated} is still set
+ * from one place only. Tests 1-3 measure that path.
+ *
+ * <p>NOT MEASURED: that seven live nodes on a genesis-anchored network recover from a
+ * simultaneous restart with a binary built from this tree.
+ */
+ @Test
+ public void aGenesisAnchoredNodeIsArmedImmediatelyAfterRestart() throws Exception {
+ System.clearProperty("aere.falcon.manifest");
+ System.clearProperty("aere.falcon.anchor.address");
+ System.clearProperty("aere.falcon.anchor.block");
+ System.setProperty("aere.falcon.genesis", genesisPath.toAbsolutePath().toString());
+ resetFalconSingleton();
+
+ final FalconSealSupport pqc = FalconSealSupport.instance();
+
+ assertThat(pqc.genesisAnchored())
+ .describedAs("fixture control: the genesis manifest must verify against the anchored hash")
+ .isTrue();
+ assertThat(pqc.registrySize()).isEqualTo(N);
+ assertThat(pqc.addressBound()).isTrue();
+ assertThat(pqc.attachmentArmed(HEAD))
+ .describedAs(
+ "a genesis-anchored node arms with NO validator set observed and NO block imported. "
+ + "The rehearsal's coverage condition is gone from this tree.")
+ .isTrue();
+ }
+
+ private static void resetFalconSingleton() throws Exception {
+ final Field f = FalconSealSupport.class.getDeclaredField("instance");
+ f.setAccessible(true);
+ f.set(null, null);
+ }
+}
diff --git a/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqForkArmingTest.java b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqForkArmingTest.java
new file mode 100755
index 000000000..2f7654372
--- /dev/null
+++ b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqForkArmingTest.java
@@ -0,0 +1,368 @@
+/*
+ * Copyright contributors to Besu / Aere Network.
+ *
+ * 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 java.lang.reflect.Field;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import org.apache.tuweni.bytes.Bytes;
+import org.bouncycastle.crypto.digests.KeccakDigest;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * FORK-HEIGHT ARMING. THE MEASUREMENT THAT DID NOT EXIST.
+ *
+ * <p>The concern, as it was written down: a malformed forkBlock falls OPEN, with only a log line,
+ * and arming it at or before the anchor observation height passes undetected. It stood as an
+ * assertion with no command behind it for months. This file is the command that can fail.
+ *
+ * <p>Both halves of the finding are about the SAME shape of defect, the one the Holesky Pectra
+ * incident of February 2025 made expensive for everybody: a fork-activation parameter that is wrong
+ * or absent does not stop the node, it changes what the node silently believes. Half one is the
+ * value itself. Half two is the ORDER between that value and the height at which the registry the
+ * value depends on becomes active.
+ *
+ * <p>WHAT EACH TEST MEASURES, and how each can fail:
+ *
+ * <ol>
+ * <li>{@link #controlAWellFormedLateAnchorConfigurationStarts()} - the fixture's own negative
+ * control. If the late-anchor manifest did not load, or the singleton were not really being
+ * rebuilt, every refusal below would be a refusal for the wrong reason.
+ * <li>{@link #aMalformedForkBlockRefusesToStart()} and {@link #aNegativeForkBlockRefusesToStart()}
+ * - the config-time half of the finding, at construction.
+ * <li>{@link #theForkBlockIsResolvedOnceAndCannotBeReopenedAfterStartup()} - the RESIDUAL half
+ * one. The startup guard only ever looked at the property once, but {@code forkBlock()}
+ * re-read the property on every call and fell back to "never blocking" with a log line on
+ * anything it could not parse. A guard that validates a value it does not then own is not a
+ * guard; this test drives that exact gap.
+ * <li>{@link #blockingOverAPendingAnchorWithNoDeclaredObservationHeightRefuses()} - half two. A
+ * blocking height is stated over a registry that is not active yet and whose activation
+ * height is nowhere stated, so nothing in the process can compare the two.
+ * <li>{@link #anAttachHeightBeforeTheObservationHeightRefuses()} and {@link
+ * #aForkHeightAtTheObservationHeightRefuses()} - half two on its own stimulus: the ordering
+ * is wrong and the node starts anyway.
+ * <li>{@link #aMalformedObservationHeightRefuses()} and {@link
+ * #aNegativeObservationHeightRefuses()} - the new value must fail closed like every other
+ * {@code aere.falcon.*} value. A half-fail-closed property set is worse than either extreme.
+ * <li>{@link #aGenesisAnchoredRegistryNeedsNoObservationHeight()} and {@link
+ * #anObservationHeightWithoutBlockingIsHarmless()} - the scope controls. A guard that refused
+ * every blocking configuration would pass every test above and be useless.
+ * </ol>
+ *
+ * <p>NOT MEASURED here, deliberately, and named so it is not mistaken for covered: whether a real
+ * Besu node process exits with a non-zero status when this exception is thrown. This class measures
+ * the decision, not the process. The exception is thrown from the constructor, on the same path as
+ * the guards that already abort, and nothing in this tree catches {@code
+ * FalconSealSupport.ActivationConfigException}.
+ */
+public class PqForkArmingTest {
+
+ /** Fleet size; nine, because the blocking guard refuses to arm below nine. */
+ private static final int N = 9;
+
+ /** Height at which the on-chain late-anchor registry contract is expected to be observed. */
+ private static final long OBSERVE = 5_000L;
+
+ /** Seal-attachment height: at or after OBSERVE, so a seal can actually be emitted. */
+ private static final long ATTACH = 6_000L;
+
+ /** Blocking height: at least minAttachLead (256) after ATTACH. */
+ private static final long FORK = 7_000L;
+
+ private static final String ANCHOR_ADDRESS = "0x0000000000000000000000000000000000000fa1";
+
+ /**
+ * REGISTRY BINDING: the chain this fixture's registries are BOUND to. Every proof commits to it,
+ * so it has to be stated rather than defaulted.
+ */
+ private static final long CHAIN_ID = 2_800L;
+
+ @TempDir private Path tmp;
+
+ private Path manifestPath;
+ private Path genesisPath;
+
+ @BeforeEach
+ public void setUp() throws Exception {
+ // REGISTRY BINDING: both registries below are v2 and PROOF-BOUND, bound at FORK, the
+ // height this fixture arms from. They used to carry addresses spelled 0xB00+i, which no
+ // secp256k1 key can sign for, so this whole fixture became unstartable the moment
+ // AERE-PQC-REG-ARM-02 was wired into the constructor.
+
+ // LATE-ANCHOR manifest: the registry is PENDING until the anchor contract is observed on chain.
+ final StringBuilder late = new StringBuilder("{");
+ late.append(PqV2Fixture.manifestHeader(N, CHAIN_ID, FORK));
+ for (int i = 0; i < N; i++) {
+ late.append(',').append(PqV2Fixture.manifestEntry(i, N, CHAIN_ID, FORK));
+ }
+ late.append("}");
+ manifestPath = tmp.resolve("falcon-late-manifest.json");
+ Files.writeString(manifestPath, late.toString());
+
+ // GENESIS-ANCHORED manifest: the registry is ACTIVE from block 0, so no observation height can
+ // exist and none may be demanded. Built exactly the way a real genesis is, hash included.
+ final KeccakDigest kd = new KeccakDigest(256);
+ final StringBuilder gen = new StringBuilder("{\"config\":{\"aereFalconRegistry\":{");
+ gen.append(PqV2Fixture.manifestHeader(N, CHAIN_ID, FORK));
+ for (int i = 0; i < N; i++) {
+ final byte[] anchoredRow = PqV2Fixture.anchorPreimageRow(i);
+ kd.update(anchoredRow, 0, anchoredRow.length);
+ gen.append(',').append(PqV2Fixture.manifestEntry(i, N, CHAIN_ID, FORK));
+ }
+ final byte[] anchoredHash = new byte[32];
+ kd.doFinal(anchoredHash, 0);
+ gen.append("}},\"alloc\":{\"0000000000000000000000000000000000000fa1\":{\"storage\":{\"0x")
+ .append("0".repeat(64))
+ .append("\":\"0x")
+ .append(Bytes.wrap(anchoredHash).toUnprefixedHexString())
+ .append("\"}}}}");
+ genesisPath = tmp.resolve("genesis-registry.json");
+ Files.writeString(genesisPath, gen.toString());
+
+ System.setProperty("aere.falcon.validatorCount", Integer.toString(N));
+ resetFalconSingleton();
+ }
+
+ @AfterEach
+ public void tearDown() throws Exception {
+ for (final String p :
+ new String[] {
+ "aere.falcon.manifest",
+ "aere.falcon.genesis",
+ "aere.falcon.anchor.address",
+ "aere.falcon.anchor.block",
+ "aere.falcon.attachBlock",
+ "aere.falcon.forkBlock",
+ "aere.falcon.validatorCount"
+ }) {
+ System.clearProperty(p);
+ }
+ resetFalconSingleton();
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 1. The fixture's own control.
+ // -------------------------------------------------------------------------------------------
+
+ @Test
+ public void controlAWellFormedLateAnchorConfigurationStarts() {
+ lateAnchor();
+ System.setProperty("aere.falcon.anchor.block", Long.toString(OBSERVE));
+ System.setProperty("aere.falcon.attachBlock", Long.toString(ATTACH));
+ System.setProperty("aere.falcon.forkBlock", Long.toString(FORK));
+
+ final FalconSealSupport pqc = FalconSealSupport.instance();
+ assertThat(pqc.lateAnchorPending())
+ .describedAs(
+ "the late-anchor manifest must load and stay PENDING, or every refusal below is a "
+ + "refusal about a registry that was never there")
+ .isTrue();
+ assertThat(pqc.forkBlock()).isEqualTo(FORK);
+ assertThat(pqc.attachBlock()).isEqualTo(ATTACH);
+ assertThat(pqc.forkBlock())
+ .describedAs(
+ "the ordering the guard exists to enforce, stated as a property: blocking arms strictly "
+ + "AFTER the height at which the registry it depends on can become active")
+ .isGreaterThan(OBSERVE);
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 2-3. Half one at config time.
+ // -------------------------------------------------------------------------------------------
+
+ @Test
+ public void aMalformedForkBlockRefusesToStart() {
+ lateAnchor();
+ System.setProperty("aere.falcon.anchor.block", Long.toString(OBSERVE));
+ System.setProperty("aere.falcon.attachBlock", Long.toString(ATTACH));
+ // The exact typo shape a human makes when copying a height out of a document.
+ System.setProperty("aere.falcon.forkBlock", "9_189_161");
+
+ assertThatThrownBy(FalconSealSupport::instance)
+ .describedAs(
+ "a malformed blocking height must ABORT, never degrade to never-blocking with a log line")
+ .isInstanceOf(FalconSealSupport.ActivationConfigException.class)
+ .hasMessageContaining("MALFORMED");
+ }
+
+ @Test
+ public void aNegativeForkBlockRefusesToStart() {
+ lateAnchor();
+ System.setProperty("aere.falcon.anchor.block", Long.toString(OBSERVE));
+ System.setProperty("aere.falcon.attachBlock", Long.toString(ATTACH));
+ System.setProperty("aere.falcon.forkBlock", "-1");
+
+ assertThatThrownBy(FalconSealSupport::instance)
+ .isInstanceOf(FalconSealSupport.ActivationConfigException.class)
+ .hasMessageContaining("negative");
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 4. Half one where it actually survived: the value was validated but never OWNED.
+ // -------------------------------------------------------------------------------------------
+
+ @Test
+ public void theForkBlockIsResolvedOnceAndCannotBeReopenedAfterStartup() {
+ lateAnchor();
+ System.setProperty("aere.falcon.anchor.block", Long.toString(OBSERVE));
+ System.setProperty("aere.falcon.attachBlock", Long.toString(ATTACH));
+ System.setProperty("aere.falcon.forkBlock", Long.toString(FORK));
+
+ final FalconSealSupport pqc = FalconSealSupport.instance();
+ assertThat(pqc.forkBlock()).isEqualTo(FORK);
+
+ // The startup guard has already run and passed. Nothing will run it again. If the accessor
+ // re-reads the property, then the ONE decision the whole PQC layer is gated on is a value that
+ // can still turn into "never blocking" at any moment, for any reason that leaves the property
+ // unparseable, and the only trace is one WARN line per call.
+ System.setProperty("aere.falcon.forkBlock", "not-a-number");
+ assertThat(pqc.forkBlock())
+ .describedAs(
+ "the blocking height must be resolved ONCE, at the boundary, and owned thereafter. A "
+ + "value that is validated at startup and re-parsed on every use is not validated.")
+ .isEqualTo(FORK);
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 5-7. Half two: the ORDER between the blocking height and the anchor observation height.
+ // -------------------------------------------------------------------------------------------
+
+ @Test
+ public void blockingOverAPendingAnchorWithNoDeclaredObservationHeightRefuses() {
+ lateAnchor();
+ System.setProperty("aere.falcon.attachBlock", Long.toString(ATTACH));
+ System.setProperty("aere.falcon.forkBlock", Long.toString(FORK));
+ // aere.falcon.anchor.block deliberately NOT set.
+
+ assertThatThrownBy(FalconSealSupport::instance)
+ .describedAs(
+ "with the registry still PENDING and no stated activation height, nothing in this "
+ + "process can compare the blocking height against the height at which the registry "
+ + "becomes usable, so the ordering error the finding names cannot be detected at all")
+ .isInstanceOf(FalconSealSupport.ActivationConfigException.class)
+ .hasMessageContaining("AERE-PQC-CFG-UNSAFE-06");
+ }
+
+ @Test
+ public void anAttachHeightBeforeTheObservationHeightRefuses() {
+ lateAnchor();
+ System.setProperty("aere.falcon.anchor.block", Long.toString(ATTACH + 1L));
+ System.setProperty("aere.falcon.attachBlock", Long.toString(ATTACH));
+ System.setProperty("aere.falcon.forkBlock", Long.toString(FORK));
+
+ assertThatThrownBy(FalconSealSupport::instance)
+ .describedAs(
+ "attachment before the registry can be active emits nothing, so the log-only soak "
+ + "window measures nothing and the blocking height arrives over a registry no node "
+ + "has ever produced a seal against")
+ .isInstanceOf(FalconSealSupport.ActivationConfigException.class)
+ .hasMessageContaining("AERE-PQC-CFG-UNSAFE-07");
+ }
+
+ @Test
+ public void aForkHeightAtTheObservationHeightRefuses() {
+ lateAnchor();
+ System.setProperty("aere.falcon.anchor.block", Long.toString(FORK));
+ System.setProperty("aere.falcon.attachBlock", Long.toString(ATTACH));
+ System.setProperty("aere.falcon.forkBlock", Long.toString(FORK));
+
+ assertThatThrownBy(FalconSealSupport::instance)
+ .describedAs("the literal stimulus in the finding: armed AT the anchor observation height")
+ .isInstanceOf(FalconSealSupport.ActivationConfigException.class)
+ .hasMessageContaining("AERE-PQC-CFG-UNSAFE-07");
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 8-9. The new value must fail closed like every other one.
+ // -------------------------------------------------------------------------------------------
+
+ @Test
+ public void aMalformedObservationHeightRefuses() {
+ lateAnchor();
+ System.setProperty("aere.falcon.anchor.block", "1e3");
+ System.setProperty("aere.falcon.attachBlock", Long.toString(ATTACH));
+ System.setProperty("aere.falcon.forkBlock", Long.toString(FORK));
+
+ assertThatThrownBy(FalconSealSupport::instance)
+ .isInstanceOf(FalconSealSupport.ActivationConfigException.class)
+ .hasMessageContaining("AERE-PQC-CFG-SYNTAX-09");
+ }
+
+ @Test
+ public void aNegativeObservationHeightRefuses() {
+ lateAnchor();
+ System.setProperty("aere.falcon.anchor.block", "-5");
+ System.setProperty("aere.falcon.attachBlock", Long.toString(ATTACH));
+ System.setProperty("aere.falcon.forkBlock", Long.toString(FORK));
+
+ assertThatThrownBy(FalconSealSupport::instance)
+ .isInstanceOf(FalconSealSupport.ActivationConfigException.class)
+ .hasMessageContaining("AERE-PQC-CFG-SYNTAX-10");
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 10-11. Scope controls. A guard that refuses everything is not a guard.
+ // -------------------------------------------------------------------------------------------
+
+ @Test
+ public void aGenesisAnchoredRegistryNeedsNoObservationHeight() {
+ System.setProperty("aere.falcon.genesis", genesisPath.toAbsolutePath().toString());
+ System.setProperty("aere.falcon.attachBlock", Long.toString(ATTACH));
+ System.setProperty("aere.falcon.forkBlock", Long.toString(FORK));
+ // aere.falcon.anchor.block deliberately NOT set: a genesis-anchored registry is active from
+ // block 0, so there IS no observation height and demanding one would break the whole
+ // genesis-anchored deployment path.
+
+ final FalconSealSupport pqc = FalconSealSupport.instance();
+ assertThat(pqc.genesisAnchored()).isTrue();
+ assertThat(pqc.addressBound()).isTrue();
+ assertThat(pqc.forkBlock()).isEqualTo(FORK);
+ }
+
+ @Test
+ public void anObservationHeightWithoutBlockingIsHarmless() {
+ lateAnchor();
+ System.setProperty("aere.falcon.anchor.block", Long.toString(OBSERVE));
+ // No forkBlock, no attachBlock: the log-only baseline every node on chain 2800 runs today.
+
+ final FalconSealSupport pqc = FalconSealSupport.instance();
+ assertThat(pqc.forkBlock()).isEqualTo(Long.MAX_VALUE);
+ assertThat(pqc.attachBlock()).isEqualTo(Long.MAX_VALUE);
+ assertThat(pqc.lateAnchorPending()).isTrue();
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // Helpers.
+ // -------------------------------------------------------------------------------------------
+
+ private void lateAnchor() {
+ System.setProperty("aere.falcon.manifest", manifestPath.toAbsolutePath().toString());
+ System.setProperty("aere.falcon.anchor.address", ANCHOR_ADDRESS);
+ }
+
+ private static void resetFalconSingleton() throws Exception {
+ final Field f = FalconSealSupport.class.getDeclaredField("instance");
+ f.setAccessible(true);
+ f.set(null, null);
+ }
+}
diff --git a/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqForkThresholdReachabilityTest.java b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqForkThresholdReachabilityTest.java
new file mode 100755
index 000000000..f9e4f3602
--- /dev/null
+++ b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqForkThresholdReachabilityTest.java
@@ -0,0 +1,355 @@
+/*
+ * Copyright contributors to Besu / Aere Network.
+ *
+ * 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.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+
+import java.lang.reflect.Field;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import org.apache.tuweni.bytes.Bytes;
+import org.bouncycastle.crypto.digests.KeccakDigest;
+import org.bouncycastle.pqc.crypto.falcon.FalconPrivateKeyParameters;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * THRESHOLD REACHABILITY, THE HALF THAT WAS STILL OPEN: is the threshold K one the fleet can be
+ * GUARANTEED to meet?
+ *
+ * <p>The 2026-08-02 repair closed the mechanism that stopped the chain on one add-validator vote: it
+ * took the fleet-wide coverage question out of the per-commit attachment gate and made coverage a
+ * REPORT. That repair is correct and it is measured next door in {@code PqForkValidatorSetChangeTest}.
+ * But it left behind an explicit promise, written in the javadoc of {@code attachmentArmed}:
+ *
+ * <blockquote>
+ *
+ * "What coverage genuinely protects - that blocking is not ARMED over a partial manifest - is an
+ * arm-time decision, and it is made at arm time by armingReadinessDiagnostic() and by the operator".
+ *
+ * </blockquote>
+ *
+ * <p>MEASURED 2026-08-03: {@code armingReadinessDiagnostic()} checks exactly one thing, whether the
+ * manifest is ADDRESS-BOUND. It never reads the fleet size, never reads how many validators hold an
+ * anchored key, and never reads K. The arm-time decision the comment names did not exist, so the
+ * compensating control for the repair was a sentence. This class is what makes it exist.
+ *
+ * <p>THE ARITHMETIC, which is the whole finding and is not an opinion. A block needs {@code
+ * ceil(2N/3)} ECDSA committers, and Falcon seals ride on Commit messages, so the seals a proposer is
+ * GUARANTEED to hear are only those of the keyed validators it cannot avoid: {@code quorum - (N -
+ * keyed)}. The row that matters for this project:
+ *
+ * <pre>
+ * N=7, keyed 7, quorum 5 -&gt; 5 guaranteed K=5 reachable, margin exactly 0
+ * N=9, keyed 7, quorum 6 -&gt; 4 guaranteed K=5 NOT guaranteed
+ * </pre>
+ *
+ * <p>The second row is the standing plan. "Grow to N=9 BEFORE arming" is right, and if the manifest
+ * is not re-anchored on the way there it produces a fleet that arms a threshold no proposer is
+ * guaranteed to meet. Before this guard a node in that state started, joined, armed, and the failure
+ * appeared later as a proposer that could not propose. That is the most expensive shape a
+ * configuration error can take, and it is the same shape the address-binding guard already refused
+ * to allow for a non-address-bound manifest.
+ *
+ * <p>WHY AT CONFIG TIME AND NOWHERE ELSE. The lesson is borrowed, not invented: CometBFT applies a
+ * validator-set change only at H+2 and Ethereum's light-client protocol carries {@code
+ * next_sync_committee} a whole period ahead, both so that the set a cryptographic check runs over is
+ * known and comparable BEFORE the boundary rather than discovered at it. We cannot copy their
+ * mechanism, because at seven nodes under one operator there is no committee to sample. We can copy
+ * the discipline: DECLARE the fleet size, compare it against the threshold at config time, and
+ * refuse to cross the boundary if the comparison fails. The same reasoning already produced
+ * AERE-PQC-CFG-UNSAFE-04 and, for the fork height, AERE-PQC-CFG-UNSAFE-06/07 in the fork-arming
+ * configuration guard.
+ *
+ * <p>NOT MEASURED here, and named so it is not read as covered: what a LIVE fleet does in the rounds
+ * between the vote landing and the first proposer failing. That needs a network. This class measures
+ * the decision, which is the thing a node can be stopped from taking.
+ */
+public class PqForkThresholdReachabilityTest {
+
+ /** Anchor activation height H. */
+ private static final long H = 1_000L;
+
+ /** The height from which the staged threshold is K. */
+ private static final long K_AT = H + 10L;
+
+ /** The threshold this project intends to arm. */
+ private static final int K = 5;
+
+ /**
+ * REGISTRY BINDING: the chain the registries this fixture writes are BOUND to. It is the same
+ * value {@link #armAnchor} states in {@code aere.pq.chainId}: a registry bound to one chain and an
+ * anchor armed on another is a configuration this fixture must never accidentally describe.
+ */
+ private static final long CHAIN_ID = 2_800L;
+
+ @TempDir private Path tmp;
+
+ @BeforeEach
+ public void setUp() throws Exception {
+ resetFalconSingleton();
+ }
+
+ @AfterEach
+ public void tearDown() throws Exception {
+ for (final String p :
+ new String[] {
+ "aere.falcon.genesis",
+ "aere.falcon.key",
+ "aere.falcon.attachBlock",
+ "aere.falcon.validatorCount",
+ "aere.falcon.testnetAllowSmallFleet",
+ PqAnchorConfig.PROPERTY_ANCHOR_BLOCK,
+ PqAnchorConfig.PROPERTY_MIN_SEALS,
+ PqAnchorConfig.PROPERTY_CHAIN_ID
+ }) {
+ System.clearProperty(p);
+ }
+ // Clearing the properties is NOT enough. PqAnchorProducer holds the configuration in a
+ // remembered field, and that field outlives the properties. Measured 2026-08-11: without the
+ // line below, this class leaves the anchor ARMED for whichever class runs next, and four tests
+ // in PqStartupHistoryTest fail on a guard that is firing CORRECTLY. The suite was green because
+ // the ordering of the day hid the leak, not because the leak was absent.
+ PqAnchorProducer.useConfigForTesting(null);
+ resetFalconSingleton();
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 1. THE FINDING. A threshold the fleet is not guaranteed to meet must not start.
+ // -------------------------------------------------------------------------------------------
+
+ @Test
+ public void armingAThresholdTheFleetCannotGuaranteeMustRefuseToStart() throws Exception {
+ // The exact state the standing plan walks through: the set has grown to nine, the anchored
+ // manifest still names the original seven, and the threshold is the one the schedule arms.
+ writeAnchoredRegistry(7);
+ System.setProperty("aere.falcon.validatorCount", "9");
+ armAnchor(K);
+
+ assertThatThrownBy(FalconSealSupport::instance)
+ .describedAs(
+ "N=9 with 7 keyed guarantees only %d Falcon seal(s) among a block's committers, and the "
+ + "armed threshold is K=%d. A node must refuse to start rather than arm a threshold "
+ + "no proposer is guaranteed to be able to meet.",
+ FalconSealSupport.worstCaseKeyedSigners(9, 7), K)
+ .isInstanceOf(FalconSealSupport.ActivationConfigException.class)
+ .hasMessageContaining("AERE-PQC-CFG-UNSAFE-08")
+ // The message has to carry BOTH numbers. "Unsafe" without them sends an operator to read
+ // code; the two numbers are the whole diagnosis and the whole remedy.
+ .hasMessageContaining("K=" + K)
+ .hasMessageContaining("guaranteed");
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 2. NEGATIVE CONTROL. A guard that refuses everything is not a guard.
+ // -------------------------------------------------------------------------------------------
+
+ @Test
+ public void aReachableThresholdMustStillStart() throws Exception {
+ // N=7 fully keyed: quorum 5, guaranteed 5, K=5. Margin is exactly zero, which is a different
+ // statement from "unreachable", and the guard must not confuse the two. This is also the
+ // configuration the fleet runs today, so a guard that refused it would be a self-inflicted halt.
+ writeAnchoredRegistry(7);
+ System.setProperty("aere.falcon.validatorCount", "7");
+ armAnchor(K);
+
+ assertThatCode(FalconSealSupport::instance)
+ .describedAs("N=7 fully keyed guarantees exactly K=%d; zero margin is not unreachable", K)
+ .doesNotThrowAnyException();
+ assertThat(FalconSealSupport.instance().registrySize()).isEqualTo(7);
+ }
+
+ @Test
+ public void growingTheManifestWithTheSetIsWhatMakesNineSafe() throws Exception {
+ // The remedy the refusal names, measured rather than asserted: re-anchor the manifest for the
+ // whole set and the same N=9, same K=5 starts.
+ writeAnchoredRegistry(9);
+ System.setProperty("aere.falcon.validatorCount", "9");
+ armAnchor(K);
+
+ assertThatCode(FalconSealSupport::instance).doesNotThrowAnyException();
+ assertThat(FalconSealSupport.worstCaseKeyedSigners(9, 9))
+ .describedAs("nine keyed of nine guarantees the full ECDSA quorum")
+ .isEqualTo(6);
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 3. INERT WHERE IT MUST BE INERT. Chain 2800 as it stands today.
+ // -------------------------------------------------------------------------------------------
+
+ @Test
+ public void withNoAnchorConfiguredTheGuardIsInert() throws Exception {
+ // With aere.pq.anchorBlock unset there is no anchor, so K does not exist and there is nothing
+ // to compare. A guard that could stop a node in that state would be a new way to lose the fleet,
+ // which is a strictly worse defect than the one it repairs.
+ writeAnchoredRegistry(7);
+ System.setProperty("aere.falcon.validatorCount", "9");
+
+ assertThatCode(FalconSealSupport::instance)
+ .describedAs("no anchor configured: no threshold, no comparison, no refusal")
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ public void aScheduleThatNeverRaisesTheThresholdAboveZeroIsInert() throws Exception {
+ writeAnchoredRegistry(7);
+ System.setProperty("aere.falcon.validatorCount", "9");
+ armAnchor(0);
+
+ assertThatCode(FalconSealSupport::instance)
+ .describedAs("K=0 everywhere is the warm-up regime; nothing can fail to be met")
+ .doesNotThrowAnyException();
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 4. THE CASE WITH NO KEYS AT ALL, which is the same arithmetic at its floor.
+ // -------------------------------------------------------------------------------------------
+
+ @Test
+ public void aPositiveThresholdWithNoAnchoredKeysMustRefuseToStart() throws Exception {
+ // No manifest anywhere and K=5: guaranteed is 0, so every block at or above H would be rejected
+ // for want of a certificate nobody can produce. Distinct from the genesis-binding refusal,
+ // which only fires when aere.falcon.forkBlock is set; the anchor path has its own arming
+ // height.
+ System.setProperty("aere.falcon.validatorCount", "7");
+ armAnchor(K);
+
+ assertThatThrownBy(FalconSealSupport::instance)
+ .isInstanceOf(FalconSealSupport.ActivationConfigException.class)
+ .hasMessageContaining("AERE-PQC-CFG-UNSAFE-08");
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 5. THE WAIVER IS EXPLICIT, NAMED, AND ONLY FOR ISOLATED NETWORKS.
+ // -------------------------------------------------------------------------------------------
+
+ @Test
+ public void anIsolatedTestNetworkCanWaiveTheGuardExplicitly() throws Exception {
+ writeAnchoredRegistry(7);
+ System.setProperty("aere.falcon.validatorCount", "9");
+ System.setProperty("aere.falcon.testnetAllowSmallFleet", "true");
+ armAnchor(K);
+
+ assertThatCode(FalconSealSupport::instance)
+ .describedAs(
+ "the same switch that waives the N>=9 rule waives this one, because both say the same "
+ + "thing: this fleet has no Falcon fault margin and must not be a mainnet")
+ .doesNotThrowAnyException();
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 6. THE ARITHMETIC ITSELF, at the boundary, as a pure function.
+ // -------------------------------------------------------------------------------------------
+
+ @Test
+ public void theDeficitIsTheDistanceBetweenTheThresholdAndTheGuarantee() {
+ assertThat(FalconSealSupport.thresholdDeficit(7, 7, 5))
+ .describedAs("N=7 fully keyed meets K=5 exactly")
+ .isZero();
+ assertThat(FalconSealSupport.thresholdDeficit(8, 7, 5))
+ .describedAs("one unkeyed validator added: still met")
+ .isZero();
+ assertThat(FalconSealSupport.thresholdDeficit(9, 7, 5))
+ .describedAs("two added without re-anchoring: short by one, which is the halt")
+ .isEqualTo(1);
+ assertThat(FalconSealSupport.thresholdDeficit(9, 9, 5)).isZero();
+ assertThat(FalconSealSupport.thresholdDeficit(7, 0, 1))
+ .describedAs("no keys at all: a positive threshold is short by all of it")
+ .isEqualTo(1);
+ assertThat(FalconSealSupport.thresholdDeficit(7, 7, 0))
+ .describedAs("K=0 can never be in deficit")
+ .isZero();
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // Helpers.
+ // -------------------------------------------------------------------------------------------
+
+ /** Arm the V2 anchor from system configuration with a staged threshold that reaches {@code k}. */
+ private static void armAnchor(final int k) {
+ System.setProperty(PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, Long.toString(H));
+ // AERE CONFIGURATIE-STRICTA (2026-08-06): an activation height without an explicit
+ // chain id is now a startup refusal, because a silently defaulted 0 in the D and M
+ // pre-images is the Holesky shape. The fixture states what the fleet states.
+ System.setProperty(PqAnchorConfig.PROPERTY_CHAIN_ID, Long.toString(CHAIN_ID));
+ System.setProperty(
+ PqAnchorConfig.PROPERTY_MIN_SEALS, H + ":0," + K_AT + ":" + k);
+ }
+
+ /**
+ * Write a genesis-anchored, address-bound Falcon manifest for {@code count} validators and point
+ * this node at index 0's key, exactly as {@code PqForkValidatorSetChangeTest} does. The anchored hash
+ * is accumulated in lockstep with the manifest text, so the fixture is anchored the way a real
+ * genesis is rather than by a flag.
+ */
+ private void writeAnchoredRegistry(final int count) throws Exception {
+ // REGISTRY BINDING: v2, proof-bound, bound at H, the height armAnchor() arms from. The
+ // rows come from PqV2Fixture because a v2 claim must be signed by the validator whose address
+ // is on the row, and the 0xA00+i addresses this used to spell have no key behind them.
+ final KeccakDigest kd = new KeccakDigest(256);
+ final StringBuilder manifest = new StringBuilder();
+ manifest
+ .append("{\"config\":{\"aereFalconRegistry\":{")
+ .append(PqV2Fixture.manifestHeader(count, CHAIN_ID, H));
+ for (int i = 0; i < count; i++) {
+ final FalconPrivateKeyParameters priv = PqV2Fixture.privateKey(i);
+ final byte[] anchoredRow = PqV2Fixture.anchorPreimageRow(i);
+ kd.update(anchoredRow, 0, anchoredRow.length);
+ manifest.append(',').append(PqV2Fixture.manifestEntry(i, count, CHAIN_ID, H));
+ if (i == 0) {
+ final Path key0 = tmp.resolve("falcon-key-0.properties");
+ Files.writeString(
+ key0,
+ "index=0\n"
+ + "f="
+ + Bytes.wrap(priv.getSpolyf()).toHexString()
+ + "\n"
+ + "g="
+ + Bytes.wrap(priv.getG()).toHexString()
+ + "\n"
+ + "F="
+ + Bytes.wrap(priv.getSpolyF()).toHexString()
+ + "\n"
+ + "pk="
+ + Bytes.wrap(PqV2Fixture.publicKey(i)).toHexString()
+ + "\n");
+ System.setProperty("aere.falcon.key", key0.toAbsolutePath().toString());
+ }
+ }
+ manifest.append("}},\"alloc\":{\"0000000000000000000000000000000000000fa1\":{\"storage\":{\"0x")
+ .append("0".repeat(64))
+ .append("\":\"0x");
+ final byte[] anchoredHash = new byte[32];
+ kd.doFinal(anchoredHash, 0);
+ manifest.append(Bytes.wrap(anchoredHash).toUnprefixedHexString()).append("\"}}}}");
+
+ final Path genesis = tmp.resolve("genesis-registry.json");
+ Files.writeString(genesis, manifest.toString());
+ System.setProperty("aere.falcon.genesis", genesis.toAbsolutePath().toString());
+ }
+
+ private static void resetFalconSingleton() throws Exception {
+ final Field f = FalconSealSupport.class.getDeclaredField("instance");
+ f.setAccessible(true);
+ f.set(null, null);
+ }
+}
diff --git a/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqForkValidatorSetChangeTest.java b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqForkValidatorSetChangeTest.java
new file mode 100755
index 000000000..113c55748
--- /dev/null
+++ b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqForkValidatorSetChangeTest.java
@@ -0,0 +1,427 @@
+/*
+ * Copyright contributors to Besu / Aere Network.
+ *
+ * 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 static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import static org.mockito.Mockito.withSettings;
+
+import org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer;
+import org.hyperledger.besu.consensus.common.validator.ValidatorProvider;
+import org.hyperledger.besu.datatypes.Address;
+import org.hyperledger.besu.ethereum.ProtocolContext;
+import org.hyperledger.besu.ethereum.core.BlockHeader;
+import org.hyperledger.besu.ethereum.core.BlockHeaderTestFixture;
+
+import java.lang.reflect.Field;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.OptionalInt;
+
+import org.apache.tuweni.bytes.Bytes;
+import org.apache.tuweni.bytes.Bytes32;
+import org.bouncycastle.crypto.digests.KeccakDigest;
+import org.bouncycastle.pqc.crypto.falcon.FalconPrivateKeyParameters;
+import org.bouncycastle.pqc.crypto.falcon.FalconSigner;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.mockito.quality.Strictness;
+
+/**
+ * VALIDATOR-SET CHANGE UNDER AN ARMED ANCHOR. THE MEASUREMENT THAT DID NOT EXIST.
+ *
+ * <p>The concern, as it was written down, was this: if PQC were armed, an ordinary add-validator
+ * vote would stop the chain, because the Falcon blocking quorum follows the dynamic set and cannot
+ * be reached inside the vote window. It was carried as UNMEASURED, on the argument that the direct
+ * measurement would require ARMING PQC on a chain, which is exactly the thing that stops the chain.
+ *
+ * <p>That is true of a whole chain. It is NOT true of the decision that stops it. Every step from
+ * "the validator set changed" to "no block can be proposed" is taken by three objects in this
+ * module, each of which is a pure function of its inputs: {@link FalconSealSupport#attachmentArmed}
+ * decides whether this node emits a Falcon seal at all, {@link PqSealCache} holds what was heard,
+ * and {@link PqAnchorProducer#apply} decides whether this node may propose. This class drives those
+ * three with a REAL address-bound genesis-anchored registry and REAL Falcon-512 keys, and asks the
+ * question that was held to be unaskable.
+ *
+ * <p>WHAT EACH TEST MEASURES, and why each of them can fail:
+ *
+ * <ol>
+ * <li>{@link #baselineTheGateIsArmedWhileTheRegistryCoversTheSet()} - the negative control for
+ * every other test here. If the gate were simply always off, or the registry never loaded,
+ * the three tests below would "pass" for a reason that has nothing to do with the question
+ * under test. This one fails if the fixture is not genuinely armed.
+ * <li>{@link #addingOneValidatorMustNotTurnSealAttachmentOff()} - the concern itself, on the exact
+ * stimulus in the title: one more validator in the set, with no Falcon key.
+ * <li>{@link #aNodeStartedAboveTheAnchorHeightMustStillAttach()} - the SAME halt through a much
+ * more ordinary door than a vote: a restart. Above the anchor height the only caller of
+ * {@code observeValidators} has retired, so a node that starts there never observes a
+ * validator set at all.
+ * <li>{@link #theProposerRefusesWhenNothingWasAttachedAndProposesWhenSomethingWas()} - the causal
+ * link, measured in both directions, so that "attachment off" to "chain stopped" is not an
+ * assertion. Nothing heard: the proposer throws and cannot propose. Five real seals heard:
+ * the proposer produces extraData carrying a five-seal certificate.
+ * </ol>
+ *
+ * <p>NOT MEASURED here, deliberately, and named so it is not mistaken for covered: how many rounds a
+ * live fleet takes to stop once every proposer refuses, and what a syncing node does meanwhile.
+ * Those need a network, and the network run is separate evidence.
+ */
+public class PqForkValidatorSetChangeTest {
+
+ /** Anchor activation height H used throughout. */
+ private static final long H = 1_000L;
+
+ /** Seal-attachment height, comfortably below H. */
+ private static final long ATTACH = 900L;
+
+ /** Height from which the staged threshold K is 5, i.e. the armed regime. */
+ private static final long K_AT = H + 10L;
+
+ private static final int K = 5;
+
+ private static final int N = 7;
+
+ private static final long CHAIN_ID = 220_878L;
+
+ @TempDir private Path tmp;
+
+ private final List<Address> keyedValidators = new ArrayList<>();
+ private final List<FalconPrivateKeyParameters> privateKeys = new ArrayList<>();
+ private Address newcomer;
+ private Path genesisPath;
+ private Path key0Path;
+
+ @BeforeEach
+ public void setUp() throws Exception {
+ // The genesis-anchored path is only satisfied when keccak256(addr20 || pk, indices ascending)
+ // equals the hash stored in the genesis alloc. The digest is accumulated here in lockstep with
+ // the manifest text, so the fixture is anchored the same way a real genesis is.
+ //
+ // REGISTRY BINDING: v2, proof-bound, bound at H. The addresses come from PqV2Fixture and
+ // are DERIVED from real secp256k1 keys, because a claim has to be signed by the validator whose
+ // address is on the row and no key produces the 0xA00+i addresses this used to spell.
+ final KeccakDigest kd = new KeccakDigest(256);
+ final StringBuilder manifest = new StringBuilder();
+ manifest
+ .append("{\"config\":{\"aereFalconRegistry\":{")
+ .append(PqV2Fixture.manifestHeader(N, CHAIN_ID, H));
+ for (int i = 0; i < N; i++) {
+ final FalconPrivateKeyParameters priv = PqV2Fixture.privateKey(i);
+ privateKeys.add(priv);
+ keyedValidators.add(PqV2Fixture.address(i));
+ final byte[] anchoredRow = PqV2Fixture.anchorPreimageRow(i);
+ kd.update(anchoredRow, 0, anchoredRow.length);
+ manifest.append(',').append(PqV2Fixture.manifestEntry(i, N, CHAIN_ID, H));
+ if (i == 0) {
+ key0Path = tmp.resolve("falcon-key-0.properties");
+ Files.writeString(
+ key0Path,
+ "index=0\n"
+ + "f="
+ + Bytes.wrap(priv.getSpolyf()).toHexString()
+ + "\n"
+ + "g="
+ + Bytes.wrap(priv.getG()).toHexString()
+ + "\n"
+ + "F="
+ + Bytes.wrap(priv.getSpolyF()).toHexString()
+ + "\n"
+ + "pk="
+ + Bytes.wrap(PqV2Fixture.publicKey(i)).toHexString()
+ + "\n");
+ System.setProperty("aere.falcon.key", key0Path.toAbsolutePath().toString());
+ }
+ }
+ manifest.append("}},\"alloc\":{\"0000000000000000000000000000000000000fa1\":{\"storage\":{\"0x")
+ .append("0".repeat(64))
+ .append("\":\"0x");
+ final byte[] anchoredHash = new byte[32];
+ kd.doFinal(anchoredHash, 0);
+ manifest.append(Bytes.wrap(anchoredHash).toUnprefixedHexString()).append("\"}}}}");
+ // The eighth validator: a perfectly ordinary node that an ordinary vote admits, and that has no
+ // Falcon key because the manifest that is anchored on chain was written for seven. It is row N
+ // of the same probe pool, so it is a REAL address with a REAL key behind it that simply was not
+ // filed in the registry - which is the situation this test is about.
+ newcomer = PqV2Fixture.address(N);
+
+ genesisPath = tmp.resolve("genesis-registry.json");
+ Files.writeString(genesisPath, manifest.toString());
+ System.setProperty("aere.falcon.genesis", genesisPath.toAbsolutePath().toString());
+ System.setProperty("aere.falcon.attachBlock", Long.toString(ATTACH));
+
+ resetFalconSingleton();
+ PqSealCache.instance().clear();
+ PqAnchorProducer.useConfigForTesting(
+ new PqAnchorConfig(CHAIN_ID, H, Map.of(H, 0, K_AT, K), OptionalInt.empty(), false));
+ }
+
+ @AfterEach
+ public void tearDown() throws Exception {
+ System.clearProperty("aere.falcon.genesis");
+ System.clearProperty("aere.falcon.key");
+ System.clearProperty("aere.falcon.attachBlock");
+ resetFalconSingleton();
+ PqSealCache.instance().clear();
+ PqAnchorProducer.useConfigForTesting(null);
+ }
+
+ // -----------------------------------------------------------------------------------------
+ // 1. Negative control for the fixture itself.
+ // -----------------------------------------------------------------------------------------
+
+ @Test
+ public void baselineTheGateIsArmedWhileTheRegistryCoversTheSet() {
+ final FalconSealSupport pqc = FalconSealSupport.instance();
+ assertThat(pqc.genesisAnchored())
+ .describedAs("the fixture must load a GENESIS-ANCHORED registry, or nothing below means anything")
+ .isTrue();
+ assertThat(pqc.addressBound()).isTrue();
+ assertThat(pqc.registrySize()).isEqualTo(N);
+ assertThat(pqc.signingEnabled()).isTrue();
+
+ pqc.observeValidators(H + 1L, keyedValidators);
+ assertThat(pqc.attachmentArmed(H + 2L))
+ .describedAs("with the registry covering all %d validators the gate must be ARMED", N)
+ .isTrue();
+ final Optional<FalconSeal> seal = pqc.sign(H + 2L, message(H + 1L));
+ assertThat(seal).isPresent();
+ assertThat(pqc.verify(0, message(H + 1L), seal.get().getSignature())).isTrue();
+ }
+
+ // -----------------------------------------------------------------------------------------
+ // 2. The concern on its own stimulus: one validator added.
+ // -----------------------------------------------------------------------------------------
+
+ @Test
+ public void addingOneValidatorMustNotTurnSealAttachmentOff() {
+ final FalconSealSupport pqc = FalconSealSupport.instance();
+
+ pqc.observeValidators(H + 1L, keyedValidators);
+ assertThat(pqc.attachmentArmed(H + 2L))
+ .describedAs("armed before the set changes")
+ .isTrue();
+
+ final List<Address> afterVote = new ArrayList<>(keyedValidators);
+ afterVote.add(newcomer);
+ pqc.observeValidators(H + 2L, afterVote);
+
+ assertThat(pqc.attachmentArmed(H + 3L))
+ .describedAs(
+ "one ordinary add-validator vote must not switch Falcon seal ATTACHMENT off. It is a "
+ + "fleet-wide fact, so it turns off on EVERY node at the same height, and with no "
+ + "node attaching no proposer can gather the K=%d seals an anchored header needs.",
+ K)
+ .isTrue();
+ assertThat(pqc.sign(H + 3L, message(H + 2L)))
+ .describedAs("and the seal must actually be produced, not merely permitted")
+ .isPresent();
+ }
+
+ // -----------------------------------------------------------------------------------------
+ // 3. The same halt through a restart, which needs no vote at all.
+ // -----------------------------------------------------------------------------------------
+
+ @Test
+ public void aNodeStartedAboveTheAnchorHeightMustStillAttach() {
+ // No observeValidators call at all. Above H the only caller of it, FalconSealValidationRule,
+ // returns at its retirement gate before observing, so this is exactly the state of a node whose
+ // chain head is already above H when the process starts.
+ final FalconSealSupport pqc = FalconSealSupport.instance();
+ assertThat(pqc.attachmentArmed(H + 50L))
+ .describedAs(
+ "a node that starts above the anchor height has observed no validator set, and "
+ + "\"I could not measure the set\" must not be answered with \"stop signing\": that "
+ + "answer is the halt. Restarting a node is an ordinary operation.")
+ .isTrue();
+ assertThat(pqc.sign(H + 50L, message(H + 49L))).isPresent();
+ }
+
+ // -----------------------------------------------------------------------------------------
+ // 4. The causal link, measured in BOTH directions.
+ // -----------------------------------------------------------------------------------------
+
+ @Test
+ public void theProposerRefusesWhenNothingWasAttachedAndProposesWhenSomethingWas() {
+ final BlockHeader parent = new BlockHeaderTestFixture().number(K_AT + 20L).buildHeader();
+ final ProtocolContext context = contextWith(keyedValidators);
+ final BftExtraData base =
+ new BftExtraData(
+ Bytes32.ZERO,
+ Collections.emptyList(),
+ Optional.empty(),
+ 0,
+ keyedValidators,
+ Collections.emptyList());
+
+ // (a) nothing heard, because nothing was attached: the proposer cannot propose.
+ assertThatThrownBy(() -> PqAnchorProducer.apply(base, parent, context))
+ .isInstanceOf(PqAnchorNotReadyException.class);
+
+ // (b) five real Falcon seals heard: the same proposer, same inputs, produces a certificate.
+ // Without this half, (a) would be satisfied by a producer that always refuses.
+ final Bytes32 m =
+ PqAnchor.commitMessage(CHAIN_ID, parent.getNumber(), parent.getHash().getBytes());
+ final List<FalconSeal> heard = new ArrayList<>();
+ for (int i = 0; i < K; i++) {
+ heard.add(new FalconSeal(i, Bytes.wrap(falconSign(privateKeys.get(i), m))));
+ }
+ PqSealCache.instance().record(parent.getNumber(), parent.getHash(), heard);
+
+ final BftExtraData produced = PqAnchorProducer.apply(base, parent, context);
+ assertThat(produced.getFalconSeals()).hasSize(K);
+ assertThat(produced.getVanityData())
+ .isEqualTo(
+ PqAnchor.anchorDigest(
+ CHAIN_ID,
+ parent.getNumber(),
+ parent.getHash().getBytes(),
+ PqAnchor.sortedByIndex(heard)));
+ }
+
+ // -----------------------------------------------------------------------------------------
+ // 4b. What an unkeyed validator actually costs, as a number rather than as a worry.
+ // -----------------------------------------------------------------------------------------
+
+ /**
+ * Removing coverage from the attachment gate stops the halt; it does not make adding an unkeyed
+ * validator free. The price is the guaranteed number of anchored-key holders among a block's ECDSA
+ * committers, and it is arithmetic, not opinion: a block needs {@code ceil(2N/3)} committers, and
+ * the unluckiest committer set takes every unkeyed validator first.
+ *
+ * <p>The three rows below are the ones that decide the project's own arming order, so they are
+ * measured here rather than reasoned about in a document:
+ *
+ * <pre>
+ * N=7, keyed 7, quorum 5 -&gt; 5 guaranteed K=5 is met, with EXACTLY zero margin
+ * N=8, keyed 7, quorum 6 -&gt; 5 guaranteed K=5 is still met, still zero margin
+ * N=9, keyed 7, quorum 6 -&gt; 4 guaranteed K=5 is NOT guaranteed any more
+ * </pre>
+ *
+ * <p>Read against the standing rule "grow to N=9 BEFORE arming", that third row is the warning:
+ * growing to nine while the anchored manifest still names seven is exactly the state in which a
+ * proposer can legitimately fail to assemble a certificate. The manifest has to grow with the set.
+ */
+ @Test
+ public void theCostOfAnUnkeyedValidatorIsANumberAndTheNumberIsThis() {
+ assertThat(FalconSealSupport.worstCaseKeyedSigners(7, 7))
+ .describedAs("N=7 fully keyed: K=5 is met with zero margin")
+ .isEqualTo(5);
+ assertThat(FalconSealSupport.worstCaseKeyedSigners(8, 7))
+ .describedAs("one validator added without re-anchoring: K=5 still met, still zero margin")
+ .isEqualTo(5);
+ assertThat(FalconSealSupport.worstCaseKeyedSigners(9, 7))
+ .describedAs(
+ "two added without re-anchoring: below K=5, so a proposer can legitimately fail. This "
+ + "is the row that constrains growing to N=9 before arming.")
+ .isEqualTo(4);
+ assertThat(FalconSealSupport.worstCaseKeyedSigners(7, 0)).isZero();
+ assertThat(FalconSealSupport.worstCaseKeyedSigners(0, 0)).isZero();
+ }
+
+ // -----------------------------------------------------------------------------------------
+ // 5. NEGATIVE CONTROL for this whole file: the gate must still refuse what it must refuse.
+ // -----------------------------------------------------------------------------------------
+
+ /**
+ * Every other test here asserts that the gate says YES. Replace {@code attachmentArmed} with
+ * {@code return true} and all of them still pass, which would make this file a proof that cannot
+ * fail. These four assertions are what makes that substitution impossible: each names a condition
+ * the attachment repair deliberately did NOT touch.
+ *
+ * @throws Exception if the fixture cannot be rebuilt
+ */
+ @Test
+ public void theGateStillRefusesEverythingItMustStillRefuse() throws Exception {
+ // (1) below the configured attachment height.
+ assertThat(FalconSealSupport.instance().attachmentArmed(ATTACH - 1L))
+ .describedAs("below the attachment height nothing may be attached")
+ .isFalse();
+
+ // (2) no attachment height configured at all, which is the default and the state of chain 2800.
+ System.clearProperty("aere.falcon.attachBlock");
+ resetFalconSingleton();
+ assertThat(FalconSealSupport.instance().attachmentArmed(H + 5L))
+ .describedAs("with aere.falcon.attachBlock unset a node holding a key attaches nothing")
+ .isFalse();
+
+ // (3) attachment height reached, but no anchored registry to be checked against.
+ System.setProperty("aere.falcon.attachBlock", Long.toString(ATTACH));
+ System.clearProperty("aere.falcon.genesis");
+ resetFalconSingleton();
+ assertThat(FalconSealSupport.instance().attachmentArmed(H + 5L))
+ .describedAs("a seal is never emitted against a registry that cannot be checked")
+ .isFalse();
+
+ // (4) anchored, address-bound registry, but it does not bind THIS node's index. The seal would
+ // be unattributable, so the seals rule would refuse the whole header carrying it.
+ System.setProperty("aere.falcon.genesis", genesisPath.toAbsolutePath().toString());
+ final Path strayKey = tmp.resolve("falcon-key-stray.properties");
+ Files.writeString(strayKey, Files.readString(key0Path).replace("index=0", "index=42"));
+ System.setProperty("aere.falcon.key", strayKey.toAbsolutePath().toString());
+ resetFalconSingleton();
+ final FalconSealSupport stray = FalconSealSupport.instance();
+ assertThat(stray.genesisAnchored())
+ .describedAs("the registry must still load, or (4) would pass for the wrong reason")
+ .isTrue();
+ assertThat(stray.attachmentArmed(H + 5L))
+ .describedAs("an index the anchored registry does not bind must not attach")
+ .isFalse();
+ assertThat(stray.sign(H + 5L, message(H + 4L))).isEmpty();
+ }
+
+ // -----------------------------------------------------------------------------------------
+ // Helpers.
+ // -----------------------------------------------------------------------------------------
+
+ private static Bytes32 message(final long blockNumber) {
+ return PqAnchor.commitMessage(CHAIN_ID, blockNumber, Bytes32.leftPad(Bytes.of(1)));
+ }
+
+ private static byte[] falconSign(final FalconPrivateKeyParameters key, final Bytes32 m) {
+ final FalconSigner signer = new FalconSigner();
+ signer.init(true, key);
+ return signer.generateSignature(m.toArray());
+ }
+
+ private static ProtocolContext contextWith(final Collection<Address> validators) {
+ final ValidatorProvider validatorProvider =
+ mock(ValidatorProvider.class, withSettings().strictness(Strictness.LENIENT));
+ when(validatorProvider.getValidatorsForBlock(any())).thenReturn(validators);
+ when(validatorProvider.getValidatorsAfterBlock(any())).thenReturn(validators);
+ final BftContext bftContext =
+ mock(BftContext.class, withSettings().strictness(Strictness.LENIENT));
+ when(bftContext.getValidatorProvider()).thenReturn(validatorProvider);
+ when(bftContext.as(any())).thenReturn(bftContext);
+ return new ProtocolContext.Builder().withConsensusContext(bftContext).build();
+ }
+
+ private static void resetFalconSingleton() throws Exception {
+ final Field f = FalconSealSupport.class.getDeclaredField("instance");
+ f.setAccessible(true);
+ f.set(null, null);
+ }
+}
diff --git a/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqInertBinaryTest.java b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqInertBinaryTest.java
new file mode 100755
index 000000000..f8a26fed3
--- /dev/null
+++ b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqInertBinaryTest.java
@@ -0,0 +1,475 @@
+/*
+ * Copyright contributors to Besu / Aere Network.
+ *
+ * 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.assertThatCode;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import static org.mockito.Mockito.withSettings;
+
+import org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer;
+import org.hyperledger.besu.consensus.common.validator.ValidatorProvider;
+import org.hyperledger.besu.datatypes.Address;
+import org.hyperledger.besu.ethereum.ProtocolContext;
+import org.hyperledger.besu.ethereum.core.BlockHeader;
+import org.hyperledger.besu.ethereum.core.BlockHeaderTestFixture;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.InvocationHandler;
+import java.lang.reflect.Method;
+import java.lang.reflect.Proxy;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Locale;
+import java.util.Optional;
+import java.util.Properties;
+import java.util.stream.Collectors;
+
+import org.apache.logging.log4j.Level;
+import org.apache.tuweni.bytes.Bytes32;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.quality.Strictness;
+
+/**
+ * THE COMPATIBILITY PROPERTY, which is the one that decides whether any of this can be shipped.
+ *
+ * <p>The three anchor patches plus the registry-binding arming gate are meant to travel onto the
+ * seven live boxes BEFORE the activation height, so that the fleet is already running the binary
+ * when the height arrives and activation is a restart-free event. That plan is only sound if a node
+ * holding this binary and NO {@code aere.pq.*} configuration is indistinguishable from one holding
+ * the binary it replaces: it must start, it must produce blocks, and it must not say a word about
+ * an anchor that is not armed. If that property is lost, the whole package is unusable regardless
+ * of how correct the anchor logic is, because it could not be staged.
+ *
+ * <p>WHY THE SILENCE IS MEASURED AND NOT ASSUMED. "It returns early, so it cannot log" is a reading
+ * of the code, not a measurement, and the integrated tree has four patches whose log sites nobody
+ * has looked at together. Here the actual Log4j2 pipeline is tapped and the lines are counted.
+ *
+ * <p>WHY {@link #positiveControlTheCaptorSEESTheAnchorWhenItISArmed} is not optional. A captor that
+ * attaches to nothing reports silence forever, and every assertion in {@link
+ * #withNoAerePropertiesTheProposerProducesABlockAndSaysNOTHING} would pass against a broken tap.
+ * The positive control arms the anchor and requires that the SAME captor, in the same JVM, sees the
+ * producer's activation line. Without it this class would be a proof that cannot go red.
+ *
+ * <p>WHY THE CAPTOR IS BUILT BY REFLECTION. {@code log4j-core}, which owns the appender API, is on
+ * this module's RUNTIME test classpath but not its COMPILE one - measured, not assumed. Adding it as
+ * a compile dependency would put a build file into the AERE overlay, which until now is Java only.
+ * Reflection keeps the overlay unchanged, and the positive control is what makes it safe: if any of
+ * the reflective steps silently failed, the captor would see nothing and the positive control would
+ * be the test that fails.
+ *
+ * <p>NOT MEASURED, and written rather than implied: nothing is deployed and no node is started. That
+ * an unarmed node on one of the seven real boxes behaves this way over a real chain, at 523 ms
+ * blocks, alongside a peer that IS armed, is NOT MEASURED and needs the rehearsal network.
+ */
+public class PqInertBinaryTest {
+
+ /**
+ * Loggers that exist ONLY because of the anchor work, so any line from them on an unarmed node is
+ * by itself a finding.
+ *
+ * <p>{@code FalconSealSupport} is deliberately NOT here even though it is the loudest of them.
+ * It predates the anchor and legitimately says one thing at startup; listing it would make the
+ * filter report a four-year-old INFO line as new anchor chatter. Its armed messages are caught by
+ * {@link #ANCHOR_WORDS} instead, which keys on what the line SAYS rather than who said it.
+ */
+ private static final List<String> ANCHOR_LOGGERS =
+ List.of(
+ "org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer",
+ "org.hyperledger.besu.consensus.common.bft.PqAnchorConfig",
+ "org.hyperledger.besu.consensus.common.bft.PqSealStore",
+ "org.hyperledger.besu.consensus.common.bft.PqRegistryBinding");
+
+ /**
+ * Words that name the ANCHOR - the capability these patches add - in a message body, whichever
+ * logger emitted it. Deliberately narrower than "anything mentioning Falcon": the Falcon registry
+ * predates all of this, and a filter that cannot tell the new surface from the old one would call
+ * a pre-existing line a regression.
+ */
+ private static final List<String> ANCHOR_WORDS =
+ List.of("pq-anchor", "anchor", "aere-pqc", "aere pqc d2");
+
+ /**
+ * THE ONE LINE an unarmed node has always written, quoted so that any NEW startup chatter turns
+ * this class red.
+ *
+ * <p>MEASURED 2026-08-06, and it is the reason this constant exists rather than an {@code
+ * isEmpty()} on everything. The first shape of this test asserted total silence and went red on
+ * this line. It is not a regression: {@code git log -S} places it in commit 307fd0d0, the snapshot
+ * of everything built between 14 June and 2 August, so it predates all three anchor patches and
+ * the arming gate. It is {@code LOG.info} and it says the node has no Falcon registry, which is
+ * true and was equally true of the binary being replaced.
+ *
+ * <p>So the property that is actually worth defending is not "says nothing" - that was never true
+ * - but "says nothing NEW, and nothing about the anchor". Pinning the exact text is what makes the
+ * second half enforceable: a fourth patch that adds one more startup line has to come here and
+ * change this constant deliberately.
+ */
+ private static final String THE_ONE_PRE_EXISTING_LINE =
+ "AERE PQC: no Falcon registry configured "
+ + "(aere.falcon.genesis/aere.falcon.manifest/aere.falcon.registry); "
+ + "hybrid seal verification will be a no-op.";
+
+ private static final long CHAIN_ID = 220_878L;
+
+ private static final long H = 4_000L;
+
+ /**
+ * Every property this class may touch. The unarmed test does not rely on this list - it sweeps the
+ * whole property table - but the armed one must put back exactly what it took.
+ */
+ private static final List<String> OWNED_PROPERTIES =
+ List.of(
+ "aere.falcon.registry",
+ "aere.falcon.forkBlock",
+ "aere.falcon.attachBlock",
+ "aere.falcon.validatorCount",
+ "aere.falcon.testnetAllowSmallFleet",
+ PqAnchorConfig.PROPERTY_ANCHOR_BLOCK,
+ PqAnchorConfig.PROPERTY_CHAIN_ID,
+ PqAnchorConfig.PROPERTY_MIN_SEALS);
+
+ @BeforeEach
+ public void setUp() throws Exception {
+ clearOwnedProperties();
+ forgetAnchorConfig();
+ resetFalconSingleton();
+ }
+
+ @AfterEach
+ public void tearDown() throws Exception {
+ clearOwnedProperties();
+ forgetAnchorConfig();
+ resetFalconSingleton();
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // THE PROPERTY.
+ // ---------------------------------------------------------------------------------------------
+
+ /**
+ * A node carrying the integrated binary and no anchor configuration starts, produces a block, and
+ * logs nothing about the anchor.
+ *
+ * <p>The block-production half is asserted on OBJECT IDENTITY, not equality. {@code
+ * PqAnchorProducer.apply} returns its argument unchanged at the first branch when the anchor is
+ * not active; an equal-but-rebuilt {@code BftExtraData} would mean the producer had walked the
+ * certificate path and merely arrived back at the same value, which is a different and much
+ * weaker statement.
+ */
+ @Test
+ public void withNoAerePropertiesTheProposerProducesABlockAndSaysNOTHING() throws Exception {
+ // The precondition is MEASURED over the whole property table rather than trusted to the
+ // teardown of whatever test ran before this one in this JVM.
+ assertThat(systemPropertiesStartingWith("aere."))
+ .describedAs("the precondition of this test, measured rather than assumed")
+ .isEmpty();
+
+ final LogCaptor captor = LogCaptor.attach();
+ final BftExtraData produced;
+ final BftExtraData base = plainExtraData();
+ try {
+ assertThatCode(FalconSealSupport::instance)
+ .describedAs("a box with no key ceremony behind it must still come up")
+ .doesNotThrowAnyException();
+
+ final BlockHeader parent = new BlockHeaderTestFixture().number(H + 500L).buildHeader();
+ produced = PqAnchorProducer.apply(base, parent, contextWith(List.of()));
+ } finally {
+ captor.detach();
+ }
+
+ assertThat(produced)
+ .describedAs(
+ "the unarmed producer must hand back the very object it was given; an equal copy would "
+ + "mean it had walked the certificate path")
+ .isSameAs(base);
+
+ assertThat(PqAnchorProducer.config().everActive())
+ .describedAs("and it must consider itself never-active, not merely inactive right now")
+ .isFalse();
+
+ assertThat(captor.anchorLines())
+ .describedAs(
+ "an operator staging this binary before the height must see NOTHING about the anchor; "
+ + "%d line(s) in total were seen, so the captor was live",
+ captor.total())
+ .isEmpty();
+
+ // And nothing NEW of any kind. This is the half that catches a future patch adding chatter.
+ assertThat(captor.aereLines())
+ .describedAs(
+ "the whole AERE output of an unarmed node, pinned: exactly the one INFO line that "
+ + "predates these patches (commit 307fd0d0). A new line here is a staging "
+ + "regression even when it is harmless, because it changes what the fleet prints "
+ + "on a restart that is supposed to be a no-op.")
+ .containsExactly(THE_ONE_PRE_EXISTING_LINE);
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // THE POSITIVE CONTROL, without which the test above proves nothing.
+ // ---------------------------------------------------------------------------------------------
+
+ /**
+ * The same captor, the same JVM, the same loggers - with the anchor armed. If this does not see a
+ * line, the silence measured above is the silence of a broken tap and means nothing.
+ *
+ * <p>The line chosen is the producer's own activation notice, emitted from {@code
+ * PqAnchorProducer.config()} the first time a configuration is built. Its once-per-JVM latch is
+ * reset by {@code useConfigForTesting(null)}, which is why {@link #forgetAnchorConfig()} runs
+ * before every test in this class.
+ */
+ @Test
+ public void positiveControlTheCaptorSEESTheAnchorWhenItISArmed() throws Exception {
+ System.setProperty(PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, Long.toString(H));
+ System.setProperty(PqAnchorConfig.PROPERTY_CHAIN_ID, Long.toString(CHAIN_ID));
+ // THIS WAS `H + ":0"` UNTIL 2026-08-07, and the threshold floor added the same day refused it.
+ // Refused correctly, and it is worth reading twice: this positive control only needed "an
+ // armed anchor", so it had used the shortest form that got past the loader, and that form
+ // was exactly the TOOTHLESS one, a schedule whose threshold is zero for ever. In other
+ // words our own test property had picked, without meaning to, precisely the dangerous
+ // configuration, and would have passed it on as "this is how the anchor is armed" to anyone
+ // who copied from here.
+ //
+ // The warm-up window is kept, because that is the form the activation plan recommends, and
+ // the step that gives the schedule teeth is added. The positive control measures the same
+ // thing: the captor hears the producer's arming line.
+ System.setProperty(PqAnchorConfig.PROPERTY_MIN_SEALS, H + ":0," + (H + 1000L) + ":3");
+
+ final LogCaptor captor = LogCaptor.attach();
+ try {
+ PqAnchorProducer.config();
+ } finally {
+ captor.detach();
+ }
+
+ assertThat(captor.anchorLines())
+ .describedAs(
+ "the captor must be able to hear the anchor, or the silence next door is worthless")
+ .isNotEmpty();
+ assertThat(String.join("\n", captor.anchorLines())).contains("producer armed");
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // Helpers.
+ // ---------------------------------------------------------------------------------------------
+
+ /** Extra data with no anchor digest, i.e. exactly what a pre-fork proposer builds. */
+ private static BftExtraData plainExtraData() {
+ return new BftExtraData(
+ Bytes32.ZERO,
+ Collections.emptyList(),
+ Optional.empty(),
+ 0,
+ Collections.emptyList(),
+ Collections.emptyList());
+ }
+
+ private static ProtocolContext contextWith(final Collection<Address> validators) {
+ final ValidatorProvider validatorProvider =
+ mock(ValidatorProvider.class, withSettings().strictness(Strictness.LENIENT));
+ when(validatorProvider.getValidatorsForBlock(any())).thenReturn(validators);
+ when(validatorProvider.getValidatorsAfterBlock(any())).thenReturn(validators);
+ final BftContext bftContext =
+ mock(BftContext.class, withSettings().strictness(Strictness.LENIENT));
+ when(bftContext.getValidatorProvider()).thenReturn(validatorProvider);
+ when(bftContext.as(any())).thenReturn(bftContext);
+ return new ProtocolContext.Builder().withConsensusContext(bftContext).build();
+ }
+
+ private static List<String> systemPropertiesStartingWith(final String prefix) {
+ final Properties p = System.getProperties();
+ return p.stringPropertyNames().stream()
+ .filter(n -> n.startsWith(prefix))
+ .sorted()
+ .collect(Collectors.toList());
+ }
+
+ private static void clearOwnedProperties() {
+ for (final String p : OWNED_PROPERTIES) {
+ System.clearProperty(p);
+ }
+ }
+
+ private static void forgetAnchorConfig() {
+ PqAnchorProducer.useConfigForTesting(null);
+ }
+
+ private static void resetFalconSingleton() throws Exception {
+ final Field f = FalconSealSupport.class.getDeclaredField("instance");
+ f.setAccessible(true);
+ f.set(null, null);
+ }
+
+ /**
+ * A Log4j2 appender built as a dynamic proxy and attached to the root logger, so that this module
+ * can read the real logging pipeline without taking a compile dependency on {@code log4j-core}.
+ *
+ * <p>{@link #attach()} throws if any reflective step fails. It does NOT fall back to a silent
+ * captor: a captor that quietly captures nothing is precisely the failure this class is written to
+ * exclude.
+ */
+ private static final class LogCaptor {
+
+ private final List<String> lines = Collections.synchronizedList(new ArrayList<>());
+ private final Object rootLoggerConfig;
+ private final Object loggerContext;
+ private final Level priorLevel;
+
+ private LogCaptor(
+ final Object rootLoggerConfig, final Object loggerContext, final Level priorLevel) {
+ this.rootLoggerConfig = rootLoggerConfig;
+ this.loggerContext = loggerContext;
+ this.priorLevel = priorLevel;
+ }
+
+ static LogCaptor attach() throws Exception {
+ final Class<?> appenderCls = Class.forName("org.apache.logging.log4j.core.Appender");
+ final Class<?> eventCls = Class.forName("org.apache.logging.log4j.core.LogEvent");
+ final Class<?> configCls = Class.forName("org.apache.logging.log4j.core.config.Configuration");
+ final Class<?> loggerConfigCls =
+ Class.forName("org.apache.logging.log4j.core.config.LoggerConfig");
+ final Class<?> filterCls = Class.forName("org.apache.logging.log4j.core.Filter");
+ final Class<?> ctxCls = Class.forName("org.apache.logging.log4j.core.LoggerContext");
+ final Class<?> stateCls = Class.forName("org.apache.logging.log4j.core.LifeCycle$State");
+
+ // LogManager is reached reflectively as well, not out of symmetry but because the build bans
+ // the symbol: [BannedMethod] "Do not use org.apache.logging.log4j.LogManager, use
+ // org.slf4j.LoggerFactory instead", and the ban is right for production code. A test that
+ // needs to inspect the logging pipeline itself is the one place it cannot be honoured, and
+ // going through the SLF4J facade cannot reach the appender list at all.
+ final Class<?> logManagerCls = Class.forName("org.apache.logging.log4j.LogManager");
+ final Object ctx =
+ logManagerCls.getMethod("getContext", boolean.class).invoke(null, Boolean.FALSE);
+ if (!ctxCls.isInstance(ctx)) {
+ throw new IllegalStateException(
+ "the SLF4J binding in this JVM is not log4j-core, so the log cannot be tapped: "
+ + ctx.getClass().getName());
+ }
+ final Object configuration = ctxCls.getMethod("getConfiguration").invoke(ctx);
+ final Object rootLoggerConfig = configCls.getMethod("getRootLogger").invoke(configuration);
+
+ final List<String> sink = Collections.synchronizedList(new ArrayList<>());
+ final Method getMessage = eventCls.getMethod("getMessage");
+ final Method getLoggerName = eventCls.getMethod("getLoggerName");
+ Object startedState = null;
+ for (final Object c : stateCls.getEnumConstants()) {
+ if ("STARTED".equals(((Enum<?>) c).name())) {
+ startedState = c;
+ }
+ }
+ final Object started = startedState;
+
+ final InvocationHandler handler =
+ (proxy, method, args) -> {
+ switch (method.getName()) {
+ case "append":
+ final Object event = args[0];
+ final Object msg = getMessage.invoke(event);
+ final String text =
+ (String) msg.getClass().getMethod("getFormattedMessage").invoke(msg);
+ sink.add(getLoggerName.invoke(event) + " | " + text);
+ return null;
+ case "getName":
+ return "aere-d147-captor";
+ case "isStarted":
+ return Boolean.TRUE;
+ case "isStopped":
+ return Boolean.FALSE;
+ case "getState":
+ return started;
+ case "ignoreExceptions":
+ return Boolean.TRUE;
+ case "equals":
+ return proxy == args[0];
+ case "hashCode":
+ return System.identityHashCode(proxy);
+ case "toString":
+ return "aere-d147-captor";
+ default:
+ return null;
+ }
+ };
+ final Object appender =
+ Proxy.newProxyInstance(
+ PqInertBinaryTest.class.getClassLoader(), new Class<?>[] {appenderCls}, handler);
+
+ final Level prior = (Level) loggerConfigCls.getMethod("getLevel").invoke(rootLoggerConfig);
+ loggerConfigCls
+ .getMethod("addAppender", appenderCls, Level.class, filterCls)
+ .invoke(rootLoggerConfig, appender, Level.ALL, null);
+ loggerConfigCls.getMethod("setLevel", Level.class).invoke(rootLoggerConfig, Level.ALL);
+ ctxCls.getMethod("updateLoggers").invoke(ctx);
+
+ final LogCaptor captor = new LogCaptor(rootLoggerConfig, ctx, prior);
+ captor.bind(sink);
+ return captor;
+ }
+
+ /** The proxy writes into its own list; this keeps a single reading surface. */
+ private List<String> bound;
+
+ private void bind(final List<String> sink) {
+ this.bound = sink;
+ }
+
+ void detach() throws Exception {
+ final Class<?> loggerConfigCls =
+ Class.forName("org.apache.logging.log4j.core.config.LoggerConfig");
+ final Class<?> ctxCls = Class.forName("org.apache.logging.log4j.core.LoggerContext");
+ loggerConfigCls
+ .getMethod("removeAppender", String.class)
+ .invoke(rootLoggerConfig, "aere-d147-captor");
+ loggerConfigCls.getMethod("setLevel", Level.class).invoke(rootLoggerConfig, priorLevel);
+ ctxCls.getMethod("updateLoggers").invoke(loggerContext);
+ lines.addAll(bound);
+ }
+
+ int total() {
+ return lines.size();
+ }
+
+ /** Every captured line that names the ANCHOR, by logger or by wording. */
+ List<String> anchorLines() {
+ return lines.stream()
+ .filter(
+ l -> {
+ final String lower = l.toLowerCase(Locale.ROOT);
+ return ANCHOR_LOGGERS.contains(loggerOf(l))
+ || ANCHOR_WORDS.stream().anyMatch(lower::contains);
+ })
+ .collect(Collectors.toList());
+ }
+
+ /** Every captured message body that AERE code emitted, logger prefix stripped. */
+ List<String> aereLines() {
+ return lines.stream()
+ .filter(l -> loggerOf(l).contains(".bft") || l.contains("AERE"))
+ .map(l -> l.substring(l.indexOf(" | ") + 3))
+ .collect(Collectors.toList());
+ }
+
+ private static String loggerOf(final String line) {
+ final int i = line.indexOf(" | ");
+ return i < 0 ? "" : line.substring(0, i);
+ }
+ }
+}
diff --git a/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqParentHeightAlignmentTest.java b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqParentHeightAlignmentTest.java
new file mode 100644
index 000000000..0067f4b49
--- /dev/null
+++ b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqParentHeightAlignmentTest.java
@@ -0,0 +1,229 @@
+/*
+ * Copyright contributors to Besu / AERE Network.
+ *
+ * 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 org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer;
+import org.hyperledger.besu.datatypes.Address;
+
+import java.lang.reflect.Field;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.OptionalInt;
+
+import org.apache.tuweni.bytes.Bytes;
+import org.apache.tuweni.bytes.Bytes32;
+import org.bouncycastle.crypto.digests.KeccakDigest;
+import org.bouncycastle.pqc.crypto.falcon.FalconPrivateKeyParameters;
+import org.bouncycastle.pqc.crypto.falcon.FalconSigner;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * SCHEDULE BOUNDARY (2026-08-15): the PARENT-HEIGHT question at the first anchor.
+ *
+ * <p>WHAT WAS MEASURED, on a public node synced from genesis on the live chain. {@code
+ * PqAnchorSealsRule} judges the certificate carried by the block at the first anchor height H by
+ * asking the registry at the PARENT's height H-1, because that is the height the seals commit to.
+ * The schedule binds INCLUSIVELY from H, so at H-1 it binds nothing, and both height-resolved
+ * lookups fell back to the HEAD registry - which on a public node synced from genesis is EMPTY. A
+ * perfectly valid certificate was refused with "certificate carries index 0, which the registry
+ * does not bind to any validator address", and the node parked one block below the first anchor
+ * forever (measured: head 13,013,999 = H-1, 6,646 R2 rejections in the log).
+ *
+ * <p>THE REPAIR UNDER TEST. In the branch where {@code requiredHashAt(schedule, blockNumber)} is
+ * empty, before the head fallback: when the schedule has an entry at EXACTLY {@code blockNumber+1},
+ * answer from that entry's VERIFIED bound registry. The only historical question ever asked at H-1
+ * is about the certificate carried by the block at H, whose governing registry is the one bound at
+ * H; the alignment uses the verified v2 registry, not an unverified head file.
+ *
+ * <p>THE FIXTURE IS THE PUBLIC-NODE SHAPE, DELIBERATELY: no {@code aere.falcon.genesis}, so the
+ * head registry is EMPTY, and the registry reaches the node ONLY as history ({@code
+ * aere.falcon.registry.history}) bound by hash to the schedule entry at H. That emptiness is what
+ * makes the negative control possible - with a populated head, the H-1 assertions would pass
+ * through the old head fallback and prove nothing about the repair. {@link
+ * #baselineTheHeadIsEmptyOtherwiseNothingHereMeansAnything()} pins that shape.
+ */
+public class PqParentHeightAlignmentTest {
+
+ /** Anchor activation height H: the schedule's FIRST entry sits exactly here. */
+ private static final long H = 1_000L;
+
+ /** Height from which the staged threshold is non-zero, i.e. the fully armed regime. */
+ private static final long K_AT = H + 10L;
+
+ private static final int N = 7;
+
+ private static final long CHAIN_ID = 220_878L;
+
+ @TempDir private Path tmp;
+
+ private final List<FalconPrivateKeyParameters> privateKeys = new ArrayList<>();
+ private final List<Address> validators = new ArrayList<>();
+ private Path genesisPath;
+
+ /** A fixed 32-byte message, standing in for M(parent) of the first anchor block. */
+ private static final Bytes32 MESSAGE = Bytes32.fromHexString("0x" + "5a".repeat(32));
+
+ private Bytes sealByIndexZero;
+
+ @BeforeEach
+ public void setUp() throws Exception {
+ // The same v2 proof-bound manifest as PqRegistryHeightRefusalTest, bound at H.
+ final KeccakDigest kd = new KeccakDigest(256);
+ final StringBuilder manifest = new StringBuilder();
+ manifest
+ .append("{\"config\":{\"aereFalconRegistry\":{")
+ .append(PqV2Fixture.manifestHeader(N, CHAIN_ID, H));
+ for (int i = 0; i < N; i++) {
+ privateKeys.add(PqV2Fixture.privateKey(i));
+ validators.add(PqV2Fixture.address(i));
+ final byte[] anchoredRow = PqV2Fixture.anchorPreimageRow(i);
+ kd.update(anchoredRow, 0, anchoredRow.length);
+ manifest.append(',').append(PqV2Fixture.manifestEntry(i, N, CHAIN_ID, H));
+ }
+ manifest
+ .append("}},\"alloc\":{\"0000000000000000000000000000000000000fa1\":{\"storage\":{\"0x")
+ .append("0".repeat(64))
+ .append("\":\"0x");
+ final byte[] anchoredHash = new byte[32];
+ kd.doFinal(anchoredHash, 0);
+ manifest.append(Bytes.wrap(anchoredHash).toUnprefixedHexString()).append("\"}}}}");
+
+ genesisPath = tmp.resolve("genesis-d228.json");
+ Files.writeString(genesisPath, manifest.toString());
+ // DELIBERATELY NOT setting aere.falcon.genesis: the head registry stays EMPTY, which is the
+ // public-node shape the defect was measured on.
+
+ // A genuine Falcon-512 signature by index 0 over MESSAGE.
+ final FalconSigner signer = new FalconSigner();
+ signer.init(true, privateKeys.get(0));
+ sealByIndexZero = Bytes.wrap(signer.generateSignature(MESSAGE.toArray()));
+
+ resetFalconSingleton();
+ // ARMED at H, exactly like the live fleet at its first anchor height.
+ PqAnchorProducer.useConfigForTesting(
+ new PqAnchorConfig(CHAIN_ID, H, Map.of(H, 0, K_AT, 3), OptionalInt.empty(), false));
+
+ // The registry reaches this node ONLY as history, exactly like the public package does it.
+ System.setProperty(
+ FalconSealSupport.PROPERTY_REGISTRY_HISTORY, genesisPath.toAbsolutePath().toString());
+ final FalconSealSupport pqc = FalconSealSupport.instance();
+ final PqRegistryHash.Registry held = PqRegistryHash.loadAuto(genesisPath);
+ final PqRegistryHash.Schedule schedule =
+ scheduleFromGenesis(Map.of(H, PqRegistryHash.hashFor(held, CHAIN_ID)));
+ pqc.verifyRegistryBindingOrAbort(0L, CHAIN_ID, schedule);
+ }
+
+ @AfterEach
+ public void tearDown() throws Exception {
+ System.clearProperty("aere.falcon.genesis");
+ System.clearProperty("aere.pq.genesis");
+ System.clearProperty(FalconSealSupport.PROPERTY_REGISTRY_HISTORY);
+ resetFalconSingleton();
+ PqAnchorProducer.useConfigForTesting(null);
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 0. The fixture itself: the head must be EMPTY, or the two tests below pass through the old
+ // head fallback and the negative control cannot go red.
+ // -------------------------------------------------------------------------------------------
+
+ @Test
+ public void baselineTheHeadIsEmptyOtherwiseNothingHereMeansAnything() {
+ final FalconSealSupport pqc = FalconSealSupport.instance();
+ assertThat(pqc.addressForIndex(0))
+ .describedAs("the HEAD registry must be empty: this is the public-node shape")
+ .isNull();
+ assertThat(pqc.verify(0, MESSAGE, sealByIndexZero))
+ .describedAs("a head verify must fail for the same reason")
+ .isFalse();
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 1. THE MEASUREMENT. At H-1, the question is about the certificate of the block at H, and the
+ // registry bound at H answers it.
+ // -------------------------------------------------------------------------------------------
+
+ @Test
+ public void atTheParentOfTheFirstAnchorTheBoundRegistryAnswers() {
+ final FalconSealSupport pqc = FalconSealSupport.instance();
+ assertThat(pqc.addressForIndexAtHistoric(H - 1L, 0))
+ .describedAs(
+ "SCHEDULE BOUNDARY: PqAnchorSealsRule asks at the PARENT height H-1 about the certificate carried "
+ + "by the block at H. The registry governing that certificate is the one bound at "
+ + "H, and it is VERIFIED; refusing here parks a syncing node at H-1 forever")
+ .isEqualTo(validators.get(0));
+ assertThat(pqc.verifyAtHistoric(H - 1L, 0, MESSAGE, sealByIndexZero))
+ .describedAs("and the key half must answer from the same bound registry")
+ .isTrue();
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 2. THE BOUNDARY IS EXACTLY ONE BLOCK. At H-2 and below, nothing changes: the head fallback
+ // still answers, and on this empty head that answer is a refusal, exactly as before.
+ // -------------------------------------------------------------------------------------------
+
+ @Test
+ public void theBoundaryIsExactlyOneBlock() {
+ final FalconSealSupport pqc = FalconSealSupport.instance();
+ assertThat(pqc.addressForIndexAtHistoric(H - 2L, 0))
+ .describedAs("H-2 is not the parent of any anchor-governed height: old behaviour, refusal")
+ .isNull();
+ assertThat(pqc.verifyAtHistoric(H - 2L, 0, MESSAGE, sealByIndexZero)).isFalse();
+ assertThat(pqc.addressForIndexAtHistoric(0L, 0))
+ .describedAs("and deep below, the same")
+ .isNull();
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // Helpers, borrowed from PqRegistryHeightRefusalTest.
+ // -------------------------------------------------------------------------------------------
+
+ private PqRegistryHash.Schedule scheduleFromGenesis(final Map<Long, String> entries)
+ throws Exception {
+ final List<Long> heights = new ArrayList<>(entries.keySet());
+ heights.sort(Long::compare);
+ final StringBuilder sb = new StringBuilder("{\"config\":{\"pqRegistryHash\":[");
+ for (int i = 0; i < heights.size(); i++) {
+ if (i > 0) {
+ sb.append(',');
+ }
+ final long b = heights.get(i);
+ String h = entries.get(b);
+ if (!h.startsWith("0x")) {
+ h = "0x" + h;
+ }
+ sb.append("{\"block\":").append(b).append(",\"hash\":\"").append(h).append("\"}");
+ }
+ sb.append("]}}");
+ final Path p =
+ tmp.resolve("genesis-schedule-" + heights.size() + "-" + heights.get(0) + ".json");
+ Files.writeString(p, sb.toString());
+ return PqRegistryHash.loadScheduleFromGenesis(p);
+ }
+
+ private static void resetFalconSingleton() throws Exception {
+ final Field f = FalconSealSupport.class.getDeclaredField("instance");
+ f.setAccessible(true);
+ f.set(null, null);
+ }
+}
diff --git a/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqRegistryBindingTest.java b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqRegistryBindingTest.java
new file mode 100755
index 000000000..d902552cc
--- /dev/null
+++ b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqRegistryBindingTest.java
@@ -0,0 +1,595 @@
+/*
+ * 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.hyperledger.besu.crypto.KeyPair;
+import org.hyperledger.besu.crypto.SECPSignature;
+import org.hyperledger.besu.crypto.SecureRandomProvider;
+import org.hyperledger.besu.crypto.SignatureAlgorithm;
+import org.hyperledger.besu.crypto.SignatureAlgorithmFactory;
+import org.hyperledger.besu.datatypes.Address;
+import org.hyperledger.besu.ethereum.core.Util;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.SecureRandom;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.apache.tuweni.bytes.Bytes;
+import org.apache.tuweni.bytes.Bytes32;
+import org.bouncycastle.crypto.AsymmetricCipherKeyPair;
+import org.bouncycastle.pqc.crypto.falcon.FalconKeyGenerationParameters;
+import org.bouncycastle.pqc.crypto.falcon.FalconKeyPairGenerator;
+import org.bouncycastle.pqc.crypto.falcon.FalconParameters;
+import org.bouncycastle.pqc.crypto.falcon.FalconPrivateKeyParameters;
+import org.bouncycastle.pqc.crypto.falcon.FalconPublicKeyParameters;
+import org.bouncycastle.pqc.crypto.falcon.FalconSigner;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * AERE REGISTRY BINDING. The registry says validator i has Falcon key k. Nothing said validator i
+ * ever agreed to that, or that anybody holds k's secret.
+ *
+ * <p>WHAT WAS MEASURED BEFORE THIS TEST EXISTED, on the real verification path, with the startup
+ * gate reporting MATCH and the header ACCEPTED every time:
+ *
+ * <ul>
+ * <li>T3, one row's address repointed: a seal made by validator 0's key credited to validator 1.
+ * <li>T4, one key at two indices: two seals from a SINGLE private key satisfy a threshold of 2.
+ * <li>T6A, two rows' keys swapped: accepted, and the registry has NO duplicate key and NO
+ * duplicate address, so no uniqueness rule can ever see it.
+ * <li>T6B, two rows' addresses swapped: accepted, again with nothing duplicated, and every seal
+ * attributed to the wrong validator.
+ * </ul>
+ *
+ * <p>T6A and T6B are the reason this test class needs signatures and not just a set. Uniqueness
+ * makes the THRESHOLD honest again; only a signature by the validator's own consensus key makes the
+ * ATTRIBUTION honest.
+ *
+ * <p>KEYS. Every Falcon and ECDSA key here is generated in memory, used inside one test method, and
+ * never written anywhere but a JUnit temporary directory. Nothing in this file touches the key
+ * ceremony or the controls that stand in front of real key generation.
+ */
+class PqRegistryBindingTest {
+
+ private static final long CHAIN_ID = 2800L;
+ private static final long OTHER_CHAIN_ID = 442807L;
+ private static final long BIND_HEIGHT = 12_000_000L;
+ private static final int N = 4;
+
+ /** One validator's material: a Falcon pair and the ECDSA consensus key that must vouch for it. */
+ private record Holder(
+ byte[] falconPublicKey, FalconPrivateKeyParameters falconPrivate, KeyPair ecdsa) {
+ Address address() {
+ return Util.publicKeyToAddress(ecdsa.getPublicKey());
+ }
+ }
+
+ private static List<Holder> holders;
+ private static SignatureAlgorithm ecdsa;
+
+ @BeforeAll
+ static void generateProbeKeys() {
+ ecdsa = SignatureAlgorithmFactory.getInstance();
+ final SecureRandom rnd = SecureRandomProvider.publicSecureRandom();
+ final FalconKeyPairGenerator gen = new FalconKeyPairGenerator();
+ gen.init(new FalconKeyGenerationParameters(rnd, FalconParameters.falcon_512));
+ holders = new ArrayList<>();
+ for (int i = 0; i < N; i++) {
+ final AsymmetricCipherKeyPair kp = gen.generateKeyPair();
+ holders.add(
+ new Holder(
+ ((FalconPublicKeyParameters) kp.getPublic()).getH(),
+ (FalconPrivateKeyParameters) kp.getPrivate(),
+ ecdsa.generateKeyPair()));
+ }
+ }
+
+ // ===================================================================================
+ // The positive control. Without this, every refusal below proves only that the loader
+ // refuses everything.
+ // ===================================================================================
+
+ @Test
+ void anHonestlySignedRegistryLoadsAndIsProofBound(@TempDir final Path dir) throws IOException {
+ final Path f = writeRegistry(dir, "honest.properties", rows(), CHAIN_ID, BIND_HEIGHT);
+
+ final PqRegistryHash.Registry r = PqRegistryHash.loadAuto(f);
+
+ assertThat(r.count()).isEqualTo(N);
+ assertThat(r.addressBound()).isTrue();
+ assertThat(r.proofBound()).isTrue();
+ assertThat(r.declaredChainId()).isEqualTo(CHAIN_ID);
+ assertThat(r.bindHeight()).isEqualTo(BIND_HEIGHT);
+ for (final PqRegistryHash.Entry e : r.entries()) {
+ assertThat(e.possessionProof()).isNotNull().isNotEmpty();
+ assertThat(e.claimProof()).hasSize(65);
+ }
+ }
+
+ // ===================================================================================
+ // T3 and T6: attribution. These are the cases NO uniqueness rule can catch.
+ // ===================================================================================
+
+ @Test
+ void t6aSwappingTwoRowsPublicKeysIsRefused(@TempDir final Path dir) throws IOException {
+ final List<Row> rows = rows();
+ final byte[] pk0 = rows.get(0).publicKey;
+ rows.get(0).publicKey = rows.get(2).publicKey;
+ rows.get(2).publicKey = pk0;
+ final Path f = writeRegistry(dir, "t6a.properties", rows, CHAIN_ID, BIND_HEIGHT);
+
+ // The registry still has four distinct keys and four distinct addresses. Measured on the real
+ // path on 2026-08-06, exactly this file produced an ACCEPTED header.
+ assertThat(distinct(rows, true)).isEqualTo(N);
+ assertThat(distinct(rows, false)).isEqualTo(N);
+
+ assertThatThrownBy(() -> PqRegistryHash.loadAuto(f))
+ .isInstanceOf(PqRegistryHash.RegistryConfigException.class)
+ .hasMessageContaining("AERE-PQC-REG-BIND-03");
+ }
+
+ @Test
+ void t6bSwappingTwoRowsAddressesIsRefused(@TempDir final Path dir) throws IOException {
+ final List<Row> rows = rows();
+ final Address a0 = rows.get(0).address;
+ rows.get(0).address = rows.get(2).address;
+ rows.get(2).address = a0;
+ final Path f = writeRegistry(dir, "t6b.properties", rows, CHAIN_ID, BIND_HEIGHT);
+
+ assertThat(distinct(rows, true)).isEqualTo(N);
+ assertThat(distinct(rows, false)).isEqualTo(N);
+
+ assertThatThrownBy(() -> PqRegistryHash.loadAuto(f))
+ .isInstanceOf(PqRegistryHash.RegistryConfigException.class)
+ .hasMessageContaining("AERE-PQC-REG-BIND-03");
+ }
+
+ @Test
+ void t3AKeyFiledUnderAnotherValidatorsAddressIsRefused(@TempDir final Path dir)
+ throws IOException {
+ // The exact T3 shape, with the proofs re-signed by the party that WOULD hold them at a key
+ // ceremony: the registry writer, who has every FALCON secret. It signs a perfectly valid
+ // possession proof for key 0 sitting under validator 1's address. Only the ECDSA claim, which
+ // needs validator 1's consensus key, stops it - and that is the whole argument for why a Falcon
+ // proof-of-possession alone does not repair the attribution gap.
+ final List<Row> rows = rows();
+ rows.get(0).address = holders.get(1).address();
+ rows.get(1).address = holders.get(3).address(); // keep addresses distinct
+ rows.get(3).address = holders.get(0).address();
+ resignAsRegistryWriter(rows);
+ final Path f = writeRegistry(dir, "t3.properties", rows, CHAIN_ID, BIND_HEIGHT);
+
+ assertThat(distinct(rows, false)).isEqualTo(N);
+
+ assertThatThrownBy(() -> PqRegistryHash.loadAuto(f))
+ .isInstanceOf(PqRegistryHash.RegistryConfigException.class)
+ .hasMessageContaining("AERE-PQC-REG-BIND-04");
+ }
+
+ @Test
+ void aFalconPossessionProofAloneDoesNotBindTheAddress(@TempDir final Path dir) throws IOException {
+ // Stated as a test so nobody can quietly ship "we added proof of possession" as the repair.
+ // The possession proof below is genuine and verifies; the row is still a lie.
+ final List<Row> rows = rows();
+ rows.get(0).address = holders.get(1).address();
+ rows.get(1).address = holders.get(0).address();
+ resignAsRegistryWriter(rows);
+ final Path f = writeRegistry(dir, "pop-only.properties", rows, CHAIN_ID, BIND_HEIGHT);
+
+ final Row r0 = rows.get(0);
+ final Bytes32 popDigest =
+ PqRegistryBinding.possessionDigest(
+ CHAIN_ID, BIND_HEIGHT, N, 0, r0.address.getBytes().toArray(), r0.publicKey);
+ assertThat(PqRegistryBinding.verifyPossession(r0.publicKey, popDigest, r0.possession))
+ .as("the Falcon possession proof is genuine and verifies")
+ .isTrue();
+
+ assertThatThrownBy(() -> PqRegistryHash.loadAuto(f))
+ .as("and the registry is still refused, by the ECDSA half")
+ .isInstanceOf(PqRegistryHash.RegistryConfigException.class)
+ .hasMessageContaining("AERE-PQC-REG-BIND-04");
+ }
+
+ // ===================================================================================
+ // T4 and T5: the threshold. Uniqueness, no signature involved.
+ // ===================================================================================
+
+ @Test
+ void t4TheSameKeyAtTwoIndicesIsRefused(@TempDir final Path dir) throws IOException {
+ final List<Row> rows = rows();
+ rows.get(2).publicKey = rows.get(0).publicKey;
+ final Path f = writeRegistry(dir, "t4.properties", rows, CHAIN_ID, BIND_HEIGHT);
+
+ assertThatThrownBy(() -> PqRegistryHash.loadAuto(f))
+ .isInstanceOf(PqRegistryHash.RegistryConfigException.class)
+ .hasMessageContaining("AERE-PQC-REG-LOAD-19");
+ }
+
+ @Test
+ void t5TheSameAddressAtTwoIndicesIsRefused(@TempDir final Path dir) throws IOException {
+ final List<Row> rows = rows();
+ rows.get(2).address = rows.get(0).address;
+ final Path f = writeRegistry(dir, "t5.properties", rows, CHAIN_ID, BIND_HEIGHT);
+
+ assertThatThrownBy(() -> PqRegistryHash.loadAuto(f))
+ .isInstanceOf(PqRegistryHash.RegistryConfigException.class)
+ .hasMessageContaining("AERE-PQC-REG-LOAD-20");
+ }
+
+ @Test
+ void t4AndT5AreRefusedInALEGACYRegistryToo(@TempDir final Path dir) throws IOException {
+ // The uniqueness half must not be reachable only through the new format, or a v1 file remains
+ // the way to ship a duplicated key. This is the ONE case where the before/after can be measured
+ // on the SAME bytes by the unpatched binary, and it is: the probe loads this file on the old
+ // tree and refuses it on the new one.
+ final StringBuilder b = new StringBuilder("count=" + N + "\n");
+ for (int i = 0; i < N; i++) {
+ b.append(i).append('=').append(hex(holders.get(i == 2 ? 0 : i).falconPublicKey())).append('\n');
+ b.append(i).append(".addr=").append(hex(holders.get(i).address().getBytes().toArray())).append('\n');
+ }
+ final Path f = dir.resolve("legacy-dup.properties");
+ Files.writeString(f, b.toString(), StandardCharsets.UTF_8);
+
+ assertThatThrownBy(() -> PqRegistryHash.loadAuto(f))
+ .isInstanceOf(PqRegistryHash.RegistryConfigException.class)
+ .hasMessageContaining("AERE-PQC-REG-LOAD-19");
+ }
+
+ // ===================================================================================
+ // Replay: the fields that are inside the signed message earn their place here.
+ // ===================================================================================
+
+ @Test
+ void aProofLiftedToAnotherIndexIsRefused(@TempDir final Path dir) throws IOException {
+ final List<Row> rows = rows();
+ final byte[] pop0 = rows.get(0).possession;
+ rows.get(0).possession = rows.get(1).possession;
+ rows.get(1).possession = pop0;
+ final Path f = writeRegistry(dir, "lift.properties", rows, CHAIN_ID, BIND_HEIGHT);
+
+ assertThatThrownBy(() -> PqRegistryHash.loadAuto(f))
+ .isInstanceOf(PqRegistryHash.RegistryConfigException.class)
+ .hasMessageContaining("AERE-PQC-REG-BIND-03");
+ }
+
+ @Test
+ void aRegistrySignedForAnotherHeightIsRefused(@TempDir final Path dir) throws IOException {
+ final Path f = writeRegistry(dir, "height.properties", rows(), CHAIN_ID, BIND_HEIGHT + 1);
+
+ assertThatThrownBy(() -> PqRegistryHash.loadAuto(f))
+ .isInstanceOf(PqRegistryHash.RegistryConfigException.class)
+ .hasMessageContaining("AERE-PQC-REG-BIND-03");
+ }
+
+ @Test
+ void aRegistryLiftedFromTheScratchChainIsRefusedAtTheGate(@TempDir final Path dir)
+ throws IOException {
+ // Signed for 442807, declared as 442807, internally perfect. It loads - the proofs really do
+ // verify - and the GATE refuses it, because the node is on 2800.
+ final Path f =
+ writeRegistry(dir, "scratch.properties", rows(OTHER_CHAIN_ID), OTHER_CHAIN_ID, BIND_HEIGHT);
+ final PqRegistryHash.Registry r = PqRegistryHash.loadAuto(f);
+ assertThat(r.proofBound()).isTrue();
+
+ assertThatThrownBy(
+ () ->
+ PqRegistryHash.verifyOrAbort(
+ PqRegistryHash.emptySchedule(), r, BIND_HEIGHT, CHAIN_ID))
+ .isInstanceOf(PqRegistryHash.RegistryConfigException.class)
+ .hasMessageContaining("AERE-PQC-REG-BIND-07");
+ }
+
+ @Test
+ void aTruncatedProofIsRefused(@TempDir final Path dir) throws IOException {
+ final List<Row> rows = rows();
+ final byte[] cut = new byte[rows.get(1).possession.length - 8];
+ System.arraycopy(rows.get(1).possession, 0, cut, 0, cut.length);
+ rows.get(1).possession = cut;
+ final Path f = writeRegistry(dir, "cut.properties", rows, CHAIN_ID, BIND_HEIGHT);
+
+ assertThatThrownBy(() -> PqRegistryHash.loadAuto(f))
+ .isInstanceOf(PqRegistryHash.RegistryConfigException.class)
+ .hasMessageContaining("AERE-PQC-REG-BIND-03");
+ }
+
+ @Test
+ void aClaimSignedByTheWrongValidatorIsRefused(@TempDir final Path dir) throws IOException {
+ final List<Row> rows = rows();
+ rows.get(1).claim =
+ signClaim(holders.get(2).ecdsa(), CHAIN_ID, BIND_HEIGHT, 1, rows.get(1));
+ final Path f = writeRegistry(dir, "wrongsigner.properties", rows, CHAIN_ID, BIND_HEIGHT);
+
+ assertThatThrownBy(() -> PqRegistryHash.loadAuto(f))
+ .isInstanceOf(PqRegistryHash.RegistryConfigException.class)
+ .hasMessageContaining("AERE-PQC-REG-BIND-04");
+ }
+
+ @Test
+ void aPartiallyBoundRegistryIsRefused(@TempDir final Path dir) throws IOException {
+ final List<Row> rows = rows();
+ rows.get(3).claim = null;
+ final Path f = writeRegistry(dir, "partial.properties", rows, CHAIN_ID, BIND_HEIGHT);
+
+ assertThatThrownBy(() -> PqRegistryHash.loadAuto(f))
+ .isInstanceOf(PqRegistryHash.RegistryConfigException.class)
+ .hasMessageContaining("AERE-PQC-REG-LOAD-21");
+ }
+
+ @Test
+ void proofsWithoutAChainIdOrBindHeightAreRefused(@TempDir final Path dir) throws IOException {
+ final Path f = writeRegistry(dir, "noheader.properties", rows(), -1L, -1L);
+
+ assertThatThrownBy(() -> PqRegistryHash.loadAuto(f))
+ .isInstanceOf(PqRegistryHash.RegistryConfigException.class)
+ .hasMessageContaining("AERE-PQC-REG-LOAD-22");
+ }
+
+ // ===================================================================================
+ // The hash must COVER the proofs, or they are advisory and can be stripped.
+ // ===================================================================================
+
+ @Test
+ void theCanonicalHashCoversBothProofsOfEveryRow(@TempDir final Path dir) throws IOException {
+ final List<Row> rows = rows();
+ final PqRegistryHash.Registry r =
+ PqRegistryHash.loadAuto(writeRegistry(dir, "cover.properties", rows, CHAIN_ID, BIND_HEIGHT));
+
+ final Bytes pre = Bytes.wrap(PqRegistryHash.canonicalPreimageV2(r, CHAIN_ID));
+ for (final Row row : rows) {
+ assertThat(indexOf(pre, Bytes.wrap(row.possession)))
+ .as("possession proof is inside the hashed pre-image")
+ .isNotNegative();
+ assertThat(indexOf(pre, Bytes.wrap(row.claim)))
+ .as("claim is inside the hashed pre-image")
+ .isNotNegative();
+ }
+ assertThat(PqRegistryHash.hashFor(r, CHAIN_ID)).isEqualTo(PqRegistryHash.hashV2(r, CHAIN_ID));
+ }
+
+ @Test
+ void strippingTheProofsChangesTheHashSoADowngradeCannotSatisfyASchedule(@TempDir final Path dir)
+ throws IOException {
+ final List<Row> rows = rows();
+ final PqRegistryHash.Registry bound =
+ PqRegistryHash.loadAuto(writeRegistry(dir, "bound.properties", rows, CHAIN_ID, BIND_HEIGHT));
+
+ // The same rows with the proof lines deleted: what an attacker writes to make the check
+ // disappear. It must not hash to the value genesis anchored.
+ final StringBuilder b = new StringBuilder("count=" + N + "\n");
+ for (int i = 0; i < N; i++) {
+ b.append(i).append('=').append(hex(rows.get(i).publicKey)).append('\n');
+ b.append(i).append(".addr=").append(hex(rows.get(i).address.getBytes().toArray())).append('\n');
+ }
+ final Path stripped = dir.resolve("stripped.properties");
+ Files.writeString(stripped, b.toString(), StandardCharsets.UTF_8);
+ final PqRegistryHash.Registry v1 = PqRegistryHash.loadAuto(stripped);
+
+ assertThat(v1.proofBound()).isFalse();
+ assertThat(PqRegistryHash.hashFor(v1, CHAIN_ID))
+ .isNotEqualTo(PqRegistryHash.hashFor(bound, CHAIN_ID));
+
+ final PqRegistryHash.Schedule schedule =
+ PqRegistryHash.parseSchedule(
+ scheduleNode(PqRegistryHash.hashFor(bound, CHAIN_ID)), "test");
+ assertThat(PqRegistryHash.matchesAt(schedule, bound, BIND_HEIGHT, CHAIN_ID)).isTrue();
+ assertThat(PqRegistryHash.matchesAt(schedule, v1, BIND_HEIGHT, CHAIN_ID)).isFalse();
+ }
+
+ // ===================================================================================
+ // Domain separation: a possession proof must never be usable as an anchor seal.
+ // ===================================================================================
+
+ @Test
+ void aPossessionProofIsNotASealForTheSameHeight(@TempDir final Path dir) throws IOException {
+ final List<Row> rows = rows();
+ final Row r0 = rows.get(0);
+ final Bytes32 pop =
+ PqRegistryBinding.possessionDigest(
+ CHAIN_ID, BIND_HEIGHT, N, 0, r0.address.getBytes().toArray(), r0.publicKey);
+ final Bytes32 commit =
+ PqAnchor.commitMessage(CHAIN_ID, BIND_HEIGHT, Bytes32.repeat((byte) 0x7e));
+
+ assertThat(pop).isNotEqualTo(commit);
+ // The tag is at offset 0 of the pre-image, so the two message spaces cannot overlap by
+ // construction rather than by luck.
+ final byte[] pre =
+ PqRegistryBinding.bindingPreimage(
+ CHAIN_ID, BIND_HEIGHT, N, 0, r0.address.getBytes().toArray(), r0.publicKey);
+ assertThat(new String(pre, 0, PqRegistryBinding.DOMAIN_POSSESSION.length(), StandardCharsets.US_ASCII))
+ .isEqualTo(PqRegistryBinding.DOMAIN_POSSESSION);
+ // A seal signed over the COMMIT message does not verify as a possession proof.
+ final byte[] sealOverCommit = falconSign(holders.get(0).falconPrivate(), commit);
+ assertThat(PqRegistryBinding.verifyPossession(r0.publicKey, pop, sealOverCommit)).isFalse();
+ // ... and the possession proof does not verify as a seal.
+ assertThat(PqRegistryBinding.verifyPossession(r0.publicKey, commit, r0.possession)).isFalse();
+ assertThat(dir).exists();
+ }
+
+ // ===================================================================================
+ // The arming precondition.
+ // ===================================================================================
+
+ @Test
+ void armingOverAnUnboundRegistryIsRefused(@TempDir final Path dir) throws IOException {
+ final StringBuilder b = new StringBuilder("count=" + N + "\n");
+ for (int i = 0; i < N; i++) {
+ b.append(i).append('=').append(hex(holders.get(i).falconPublicKey())).append('\n');
+ b.append(i).append(".addr=").append(hex(holders.get(i).address().getBytes().toArray())).append('\n');
+ }
+ final Path f = dir.resolve("unbound.properties");
+ Files.writeString(f, b.toString(), StandardCharsets.UTF_8);
+ final PqRegistryHash.Registry v1 = PqRegistryHash.loadAuto(f);
+
+ assertThatThrownBy(() -> PqRegistryHash.requireBindingsOrThrow(v1))
+ .isInstanceOf(PqRegistryHash.RegistryConfigException.class)
+ .hasMessageContaining("AERE-PQC-REG-ARM-02");
+
+ final PqRegistryHash.Registry v2 =
+ PqRegistryHash.loadAuto(writeRegistry(dir, "armed.properties", rows(), CHAIN_ID, BIND_HEIGHT));
+ PqRegistryHash.requireBindingsOrThrow(v2); // must not throw
+ }
+
+ // ===================================================================================
+ // Fixtures
+ // ===================================================================================
+
+ private static final class Row {
+ private byte[] publicKey;
+ private Address address;
+ private byte[] possession;
+ private byte[] claim;
+ }
+
+ /** Honest rows for CHAIN_ID at BIND_HEIGHT, every proof signed by the party that should sign it. */
+ private static List<Row> rows() {
+ return rows(CHAIN_ID);
+ }
+
+ private static List<Row> rows(final long chainId) {
+ final List<Row> out = new ArrayList<>();
+ for (int i = 0; i < N; i++) {
+ final Row r = new Row();
+ r.publicKey = holders.get(i).falconPublicKey();
+ r.address = holders.get(i).address();
+ out.add(r);
+ }
+ for (int i = 0; i < N; i++) {
+ final Row r = out.get(i);
+ r.possession =
+ falconSign(
+ holders.get(i).falconPrivate(),
+ PqRegistryBinding.possessionDigest(
+ chainId, BIND_HEIGHT, N, i, r.address.getBytes().toArray(), r.publicKey));
+ r.claim = signClaim(holders.get(i).ecdsa(), chainId, BIND_HEIGHT, i, r);
+ }
+ return out;
+ }
+
+ /**
+ * Re-sign every row the way THE REGISTRY WRITER would at a key ceremony: it holds every FALCON
+ * secret, so it can always produce a valid possession proof for whatever row it just wrote. The
+ * ECDSA claim it can produce is the one belonging to the holder of the key on that row, never the
+ * one belonging to the address it filed the key under. That gap is the whole of the attribution
+ * problem this class exists for, and it is why the Falcon half alone repairs nothing.
+ */
+ private static void resignAsRegistryWriter(final List<Row> rows) {
+ for (int i = 0; i < rows.size(); i++) {
+ final Row r = rows.get(i);
+ final Holder owner = ownerOfKey(r.publicKey);
+ r.possession =
+ falconSign(
+ owner.falconPrivate(),
+ PqRegistryBinding.possessionDigest(
+ CHAIN_ID, BIND_HEIGHT, N, i, r.address.getBytes().toArray(), r.publicKey));
+ r.claim = signClaim(owner.ecdsa(), CHAIN_ID, BIND_HEIGHT, i, r);
+ }
+ }
+
+ private static Holder ownerOfKey(final byte[] publicKey) {
+ for (final Holder h : holders) {
+ if (java.util.Arrays.equals(h.falconPublicKey(), publicKey)) {
+ return h;
+ }
+ }
+ throw new IllegalStateException("no probe holder owns that key");
+ }
+
+ private static byte[] signClaim(
+ final KeyPair kp, final long chainId, final long height, final int index, final Row r) {
+ final Bytes32 d =
+ PqRegistryBinding.claimDigest(
+ chainId, height, N, index, r.address.getBytes().toArray(), r.publicKey);
+ final SECPSignature s = ecdsa.sign(d, kp);
+ return s.encodedBytes().toArrayUnsafe();
+ }
+
+ private static byte[] falconSign(final FalconPrivateKeyParameters priv, final Bytes32 message) {
+ final FalconSigner signer = new FalconSigner();
+ signer.init(true, priv);
+ return signer.generateSignature(message.toArray());
+ }
+
+ private static Path writeRegistry(
+ final Path dir,
+ final String name,
+ final List<Row> rows,
+ final long chainId,
+ final long bindHeight)
+ throws IOException {
+ final StringBuilder b = new StringBuilder();
+ b.append("formatVersion=").append(PqRegistryBinding.FORMAT_VERSION).append('\n');
+ if (chainId >= 0) {
+ b.append("chainId=").append(chainId).append('\n');
+ }
+ if (bindHeight >= 0) {
+ b.append("bindHeight=").append(bindHeight).append('\n');
+ }
+ b.append("count=").append(rows.size()).append('\n');
+ for (int i = 0; i < rows.size(); i++) {
+ final Row r = rows.get(i);
+ b.append(i).append('=').append(hex(r.publicKey)).append('\n');
+ b.append(i).append(".addr=").append(hex(r.address.getBytes().toArray())).append('\n');
+ if (r.possession != null) {
+ b.append(i).append(".pop=").append(hex(r.possession)).append('\n');
+ }
+ if (r.claim != null) {
+ b.append(i).append(".claim=").append(hex(r.claim)).append('\n');
+ }
+ }
+ final Path f = dir.resolve(name);
+ Files.writeString(f, b.toString(), StandardCharsets.UTF_8);
+ return f;
+ }
+
+ private static long distinct(final List<Row> rows, final boolean keys) {
+ return rows.stream()
+ .map(r -> keys ? hex(r.publicKey) : hex(r.address.getBytes().toArray()))
+ .distinct()
+ .count();
+ }
+
+ private static int indexOf(final Bytes haystack, final Bytes needle) {
+ for (int i = 0; i + needle.size() <= haystack.size(); i++) {
+ if (haystack.slice(i, needle.size()).equals(needle)) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ private static com.fasterxml.jackson.databind.JsonNode scheduleNode(final String hash) {
+ final com.fasterxml.jackson.databind.node.ArrayNode a =
+ new com.fasterxml.jackson.databind.ObjectMapper().createArrayNode();
+ a.addObject().put("block", BIND_HEIGHT).put("hash", "0x" + hash);
+ return a;
+ }
+
+ private static String hex(final byte[] b) {
+ final StringBuilder s = new StringBuilder(b.length * 2);
+ for (final byte x : b) {
+ s.append(Character.forDigit((x >> 4) & 0xf, 16)).append(Character.forDigit(x & 0xf, 16));
+ }
+ return s.toString();
+ }
+}
diff --git a/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqRegistryHeightRefusalTest.java b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqRegistryHeightRefusalTest.java
new file mode 100755
index 000000000..7efcb1ac3
--- /dev/null
+++ b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqRegistryHeightRefusalTest.java
@@ -0,0 +1,328 @@
+/*
+ * Copyright contributors to Besu / Aere Network.
+ *
+ * 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 org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer;
+import org.hyperledger.besu.datatypes.Address;
+
+import java.lang.reflect.Field;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.OptionalInt;
+
+import org.apache.tuweni.bytes.Bytes;
+import org.apache.tuweni.bytes.Bytes32;
+import org.bouncycastle.crypto.digests.KeccakDigest;
+import org.bouncycastle.pqc.crypto.falcon.FalconPrivateKeyParameters;
+import org.bouncycastle.pqc.crypto.falcon.FalconSigner;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * KEY ROTATION AND REBINDING, the adversarial review of 2026-08-02, at the layer that actually
+ * answers the question.
+ *
+ * <p>WHAT THE REVIEW MEASURED. {@code PqSignerRegistry} had {@code addressForIndex(int)} and {@code
+ * verify(int, Bytes, Bytes)} with no height, and {@code FalconSealSupport} held ONE registry loaded
+ * at start-up. So a header that passed both anchor rules was REJECTED the moment index 0's Falcon
+ * key was rotated - same header, same parent, same validator set.
+ *
+ * <p>WHAT WAS REPAIRED BEFORE THIS FILE, AND WHAT WAS NOT. The height-indexed registry change gave
+ * the validation path {@code addressForIndexAt} / {@code verifyAt} and a height-indexed schedule.
+ * The measurement of 2026-08-05 found the repair INERT, for a reason that is one line long: with no
+ * {@code config.pqRegistryHash} in genesis - and there is none in any genesis this fleet runs -
+ * {@code keyAt} fell back to the registry in force AT THE HEAD, at every height. Height-aware
+ * signatures, head-registry answers. T2 stood exactly as measured.
+ *
+ * <p>WHAT THIS FILE ASSERTS, as a property and not as a scenario: <b>at and above the arming height,
+ * a node that cannot say which key set was in force must REFUSE, not guess.</b> Below the arming
+ * height it must keep answering from the head registry, because nothing there is being judged and
+ * the 11.8 million blocks already on chain 2800 must behave bit for bit as they did.
+ *
+ * <p>THE NEGATIVE CONTROL IS BUILT IN, not promised. {@link
+ * #belowTheArmingHeightTheHeadRegistryStillAnswers()} fails if the refusal is made unconditional;
+ * {@link #whenTheAnchorIsNotArmedNOTHINGCHANGES()} fails if it is made independent of arming; {@link
+ * #withTheScheduleConfiguredTheArmedHeightsAnswerAgain()} fails if the refusal is anything other
+ * than a missing height-to-registry binding. And the measurement itself, {@link
+ * #d2t2AtAndAboveTheArmingHeightWithNoScheduleTheAnswerIsRefusal()}, is GREEN on the unrepaired code
+ * only if the fallback is restored - which is exactly the one-line edit the repair removed.
+ */
+public class PqRegistryHeightRefusalTest {
+
+ /** Anchor activation height H: from here a header's Falcon certificate carries weight. */
+ private static final long H = 1_000L;
+
+ /** Height from which the staged threshold is non-zero, i.e. the fully armed regime. */
+ private static final long K_AT = H + 10L;
+
+ /** A height far above H, standing in for "a year of history above the arming height". */
+ private static final long DEEP = K_AT + 5_000L;
+
+ private static final int N = 7;
+
+ private static final long CHAIN_ID = 220_878L;
+
+ @TempDir private Path tmp;
+
+ private final List<FalconPrivateKeyParameters> privateKeys = new ArrayList<>();
+ private final List<Address> validators = new ArrayList<>();
+ private Path genesisPath;
+
+ /** A fixed 32-byte message, standing in for M(parent) or a committed-seal hash. */
+ private static final Bytes32 MESSAGE =
+ Bytes32.fromHexString("0x" + "5a".repeat(32));
+
+ private Bytes sealByIndexZero;
+
+ @BeforeEach
+ public void setUp() throws Exception {
+ // AERE REGISTRY BINDING (2026-08-06): v2, proof-bound, bound at H. See PqV2Fixture for why the
+ // addresses are derived from real secp256k1 keys and can no longer be spelled 0xA00+i.
+ final KeccakDigest kd = new KeccakDigest(256);
+ final StringBuilder manifest = new StringBuilder();
+ manifest
+ .append("{\"config\":{\"aereFalconRegistry\":{")
+ .append(PqV2Fixture.manifestHeader(N, CHAIN_ID, H));
+ for (int i = 0; i < N; i++) {
+ privateKeys.add(PqV2Fixture.privateKey(i));
+ validators.add(PqV2Fixture.address(i));
+ final byte[] anchoredRow = PqV2Fixture.anchorPreimageRow(i);
+ kd.update(anchoredRow, 0, anchoredRow.length);
+ manifest.append(',').append(PqV2Fixture.manifestEntry(i, N, CHAIN_ID, H));
+ }
+ manifest
+ .append("}},\"alloc\":{\"0000000000000000000000000000000000000fa1\":{\"storage\":{\"0x")
+ .append("0".repeat(64))
+ .append("\":\"0x");
+ final byte[] anchoredHash = new byte[32];
+ kd.doFinal(anchoredHash, 0);
+ manifest.append(Bytes.wrap(anchoredHash).toUnprefixedHexString()).append("\"}}}}");
+
+ genesisPath = tmp.resolve("genesis-d2.json");
+ Files.writeString(genesisPath, manifest.toString());
+ System.setProperty("aere.falcon.genesis", genesisPath.toAbsolutePath().toString());
+
+ // A genuine Falcon-512 signature by index 0 over MESSAGE. Everything below asks one question of
+ // it: at which heights does the node agree that this is index 0's signature.
+ final FalconSigner signer = new FalconSigner();
+ signer.init(true, privateKeys.get(0));
+ sealByIndexZero = Bytes.wrap(signer.generateSignature(MESSAGE.toArray()));
+
+ resetFalconSingleton();
+ // ARMED at H. On the live fleet aere.pq.anchorBlock is unset and this whole file's subject
+ // does not exist; see whenTheAnchorIsNotArmedNOTHINGCHANGES.
+ PqAnchorProducer.useConfigForTesting(
+ new PqAnchorConfig(CHAIN_ID, H, Map.of(H, 0, K_AT, 3), OptionalInt.empty(), false));
+ }
+
+ @AfterEach
+ public void tearDown() throws Exception {
+ System.clearProperty("aere.falcon.genesis");
+ System.clearProperty("aere.pq.genesis");
+ System.clearProperty(FalconSealSupport.PROPERTY_REGISTRY_HISTORY);
+ resetFalconSingleton();
+ PqAnchorProducer.useConfigForTesting(null);
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 0. The fixture itself. Without this, a green run below could mean the registry never loaded.
+ // -------------------------------------------------------------------------------------------
+
+ @Test
+ public void baselineTheFixtureIsGenesisAnchoredAndTheSealIsGENUINE() {
+ final FalconSealSupport pqc = FalconSealSupport.instance();
+ assertThat(pqc.genesisAnchored())
+ .describedAs("the fixture must load a GENESIS-ANCHORED registry, or nothing here means anything")
+ .isTrue();
+ assertThat(pqc.addressBound()).isTrue();
+ assertThat(pqc.verify(0, MESSAGE, sealByIndexZero))
+ .describedAs("the seal must be a REAL Falcon signature under the head registry")
+ .isTrue();
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 1. THE MEASUREMENT. Chain 2800 as it stands: armed, and no pqRegistryHash anywhere.
+ // -------------------------------------------------------------------------------------------
+
+ @Test
+ public void d2t2AtAndAboveTheArmingHeightWithNoScheduleTheAnswerIsRefusal() {
+ final FalconSealSupport pqc = FalconSealSupport.instance();
+
+ // No schedule was ever loaded: verifyRegistryBindingOrAbort has not run, which is the state of
+ // every node on chain 2800 today, because config.pqRegistryHash is in no genesis this fleet
+ // runs (measured 2026-08-05: a search across every deployment and monitoring configuration we
+ // hold returns nothing).
+ assertThat(pqc.verifyAtHistoric(H, 0, MESSAGE, sealByIndexZero))
+ .describedAs(
+ "T2: at the arming height itself, a node with no height-to-registry binding must "
+ + "REFUSE. Before 2026-08-06 it answered from the registry in force at the HEAD, "
+ + "so one key rotation made every block above H unverifiable while the node "
+ + "reported success")
+ .isFalse();
+
+ assertThat(pqc.verifyAtHistoric(DEEP, 0, MESSAGE, sealByIndexZero))
+ .describedAs("and the same, far above the arming height")
+ .isFalse();
+
+ assertThat(pqc.addressForIndexAtHistoric(DEEP, 0))
+ .describedAs(
+ "the address half must refuse identically: PqAnchorSealsRule refuses an index it "
+ + "cannot bind, and a bound-by-guess address is worse than an unbound one")
+ .isNull();
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 2. Negative control: the refusal is HEIGHT-GATED. A rule that always refuses is not a repair.
+ // -------------------------------------------------------------------------------------------
+
+ @Test
+ public void belowTheArmingHeightTheHeadRegistryStillAnswers() {
+ final FalconSealSupport pqc = FalconSealSupport.instance();
+ assertThat(pqc.verifyAtHistoric(H - 1L, 0, MESSAGE, sealByIndexZero))
+ .describedAs(
+ "one block below H nothing is being judged, so the head registry is the right answer "
+ + "and the 11.8 million blocks already on chain must behave exactly as before")
+ .isTrue();
+ assertThat(pqc.verifyAtHistoric(0L, 0, MESSAGE, sealByIndexZero)).isTrue();
+ assertThat(pqc.addressForIndexAtHistoric(H - 1L, 0)).isEqualTo(validators.get(0));
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 3. Negative control: the refusal is ARMING-gated. This is the proof that the live fleet is
+ // untouched, and it is the assertion that fails first if that stops being true.
+ // -------------------------------------------------------------------------------------------
+
+ @Test
+ public void whenTheAnchorIsNotArmedNOTHINGCHANGES() {
+ PqAnchorProducer.useConfigForTesting(PqAnchorConfig.never(CHAIN_ID));
+ final FalconSealSupport pqc = FalconSealSupport.instance();
+ assertThat(pqc.verifyAtHistoric(DEEP, 0, MESSAGE, sealByIndexZero))
+ .describedAs(
+ "chain 2800 today: aere.pq.anchorBlock unset, so there is no arming height, no height "
+ + "is at or above it, and every answer is what it was before this repair")
+ .isTrue();
+ assertThat(pqc.addressForIndexAtHistoric(DEEP, 0)).isEqualTo(validators.get(0));
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 4. Positive control: what the refusal is a refusal ABOUT. Configure the binding and the armed
+ // heights answer again - through the height-resolved path, not the head-registry fallback.
+ // -------------------------------------------------------------------------------------------
+
+ @Test
+ public void withTheScheduleConfiguredTheArmedHeightsAnswerAgain() throws Exception {
+ final FalconSealSupport pqc = FalconSealSupport.instance();
+ final PqRegistryHash.Registry held = PqRegistryHash.loadAuto(genesisPath);
+ final String hash = PqRegistryHash.hashFor(held, CHAIN_ID);
+
+ // The first scheduled entry sits EXACTLY at the arming height, which is the rule the epoch-list
+ // design states: below H requiredHashAt is empty and the fallback is unreachable by anything
+ // that decides a header.
+ // AERE REGISTRY BINDING (2026-08-06): the hash above is hashFor, not hashV1, because this
+ // fixture's registry is now v2 and hashes under a different domain tag. A schedule entry that
+ // names the v1 number names a registry this node does not hold.
+ final PqRegistryHash.Schedule schedule = scheduleFromGenesis(Map.of(H, hash));
+ pqc.verifyRegistryBindingOrAbort(0L, CHAIN_ID, schedule);
+
+ assertThat(pqc.verifyAtHistoric(DEEP, 0, MESSAGE, sealByIndexZero))
+ .describedAs(
+ "with the epoch bound at H and the registry held, the armed heights resolve through "
+ + "the schedule. If this is false the refusal is not about a missing binding and "
+ + "the measurement above proves nothing")
+ .isTrue();
+ assertThat(pqc.addressForIndexAtHistoric(DEEP, 0)).isEqualTo(validators.get(0));
+ assertThat(pqc.verifyAtHistoric(H - 1L, 0, MESSAGE, sealByIndexZero))
+ .describedAs("and below H the fallback is still the answer")
+ .isTrue();
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 5. The case the epoch list exists FOR: an epoch this node does not hold. Refused, and named.
+ // -------------------------------------------------------------------------------------------
+
+ @Test
+ public void anEpochThisNodeDoesNotHoldIsRefusedAndNAMED() throws Exception {
+ final FalconSealSupport pqc = FalconSealSupport.instance();
+ final PqRegistryHash.Registry held = PqRegistryHash.loadAuto(genesisPath);
+ final String hash = PqRegistryHash.hashFor(held, CHAIN_ID);
+ final long rotation = K_AT + 1_000L;
+
+ // Two epochs: the one this node holds, and a rotation to a registry it was never given. This is
+ // the shape of "an operator rotated a compromised key and one node did not get the file".
+ final PqRegistryHash.Schedule schedule =
+ scheduleFromGenesis(
+ new java.util.LinkedHashMap<>(
+ Map.of(H, hash, rotation, "0x" + "cd".repeat(32))));
+ pqc.verifyRegistryBindingOrAbort(0L, CHAIN_ID, schedule);
+
+ assertThat(pqc.verifyAtHistoric(rotation - 1L, 0, MESSAGE, sealByIndexZero))
+ .describedAs("below the rotation this node holds the epoch and answers")
+ .isTrue();
+ assertThat(pqc.verifyAtHistoric(rotation, 0, MESSAGE, sealByIndexZero))
+ .describedAs(
+ "at the rotation the epoch is covered by NOTHING this node holds. It stops here; it "
+ + "does not answer from whatever it happens to have")
+ .isFalse();
+ assertThat(pqc.addressForIndexAtHistoric(rotation, 0)).isNull();
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // Helpers.
+ // -------------------------------------------------------------------------------------------
+
+ /**
+ * Build a schedule the way a node really gets one: written into a genesis file as {@code
+ * config.pqRegistryHash} and parsed back. Constructing the object directly would skip the parser,
+ * which is where the strictly-increasing rule lives.
+ *
+ * @param entries height to 0x-prefixed registry hash, in ascending order of height
+ * @return the parsed schedule
+ * @throws Exception when the temporary genesis cannot be written
+ */
+ private PqRegistryHash.Schedule scheduleFromGenesis(final Map<Long, String> entries)
+ throws Exception {
+ final List<Long> heights = new ArrayList<>(entries.keySet());
+ heights.sort(Long::compare);
+ final StringBuilder sb = new StringBuilder("{\"config\":{\"pqRegistryHash\":[");
+ for (int i = 0; i < heights.size(); i++) {
+ if (i > 0) {
+ sb.append(',');
+ }
+ final long b = heights.get(i);
+ String h = entries.get(b);
+ if (!h.startsWith("0x")) {
+ h = "0x" + h;
+ }
+ sb.append("{\"block\":").append(b).append(",\"hash\":\"").append(h).append("\"}");
+ }
+ sb.append("]}}");
+ final Path p = tmp.resolve("genesis-schedule-" + heights.size() + "-" + heights.get(0) + ".json");
+ Files.writeString(p, sb.toString());
+ return PqRegistryHash.loadScheduleFromGenesis(p);
+ }
+
+ private static void resetFalconSingleton() throws Exception {
+ final Field f = FalconSealSupport.class.getDeclaredField("instance");
+ f.setAccessible(true);
+ f.set(null, null);
+ }
+}
diff --git a/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqRegistryRotationTest.java b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqRegistryRotationTest.java
new file mode 100755
index 000000000..7868c88de
--- /dev/null
+++ b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqRegistryRotationTest.java
@@ -0,0 +1,476 @@
+/*
+ * Copyright contributors to Besu / Aere Network.
+ *
+ * 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 java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * SIGNER-REGISTRY ROTATION AND REVOCATION: the Falcon signer registry has no usable rotation and no
+ * usable revocation.
+ *
+ * <p>WHAT IS MEASURED HERE, and why it is measured against the real classes rather than described.
+ * {@code config.pqRegistryHash} is a SCHEDULE of {block, hash} entries, and the format is documented
+ * as one where "a later entry expresses a key rotation". This file asks whether that sentence
+ * survives contact with the code that enforces it.
+ *
+ * <p>The enforcement side is {@link PqRegistryHash#matchesAt} and, on the block path, {@code
+ * FalconSealSupport.registryBindingSatisfiedAt(height)}, which calls it. Both take exactly ONE
+ * loaded registry, and the node loads exactly one file ({@code aere.falcon.registry}). The entry the
+ * schedule makes active at a height decides which hash is required THERE. So after one rotation at
+ * H2 there are two intervals with two different required hashes, and one file can satisfy at most
+ * one of them.
+ *
+ * <p>The consequence is not cosmetic and it is not confined to the rotation moment. The binding is
+ * enforced while history is being acquired, not only at the head, so the whole range of heights has
+ * to be satisfiable at once and not merely the current interval. That is the constraint the two
+ * measurements below are written against.
+ *
+ * <p>This is the lesson Cosmos ADR-016 writes down explicitly: a rotation scheme has to keep the
+ * MAPPING FROM HEIGHT TO KEY SET, not only the current key set, or blocks signed under the old set
+ * stop being verifiable. Cosmos may bound that history by the unbonding period. We may not: chain
+ * 2800 has no unbonding period and a node syncing from genesis must verify every block that was ever
+ * produced, so every entry ever scheduled has to stay loadable forever.
+ *
+ * <p>{@code rotationDoesNotBrickHistory} and {@code revocationDoesNotBrickHistory} are the
+ * measurement. They FAIL while the defect is present and pass only when a node can be configured to
+ * satisfy the binding at EVERY scheduled height at once. The other tests are controls: they assert
+ * that the schedule really does express rotation and really does refuse a malformed one, so a
+ * failure of the two measurements cannot be blamed on the fixture.
+ */
+public class PqRegistryRotationTest {
+
+ private static final long CHAIN_ID = 2800L;
+
+ /** First binding height: the height the post-quantum registry is first enforced from. */
+ private static final long H1 = 12_000_000L;
+
+ /** Rotation height: from here the chain requires the SECOND registry. */
+ private static final long H2 = 12_100_000L;
+
+ /** Falcon-512 public key length as this registry format stores it (bare h polynomial). */
+ private static final int PK_LENGTH = 896;
+
+ /** The seven validators of chain 2800. */
+ private static final int N = 7;
+
+ /**
+ * One node configuration, expressed as the only question the consensus path ever asks it: does
+ * the registry material this node holds satisfy the binding the chain requires at this height?
+ *
+ * <p>It is an interface and not a Registry so that the measurement can be stated once and asked of
+ * every configuration a node can actually be put into. Today there is exactly one shape of answer,
+ * {@link #single}, because a node loads one file. A repair that lets a node hold the whole
+ * scheduled history adds a second shape here and the assertion below stops failing. Nothing in the
+ * assertion has to change, which is the point: the property is fixed, the capability is what moves.
+ */
+ private interface NodeConfiguration {
+ boolean satisfiesAt(long height);
+
+ String describe();
+ }
+
+ private static NodeConfiguration single(
+ final String name, final PqRegistryHash.Schedule schedule, final PqRegistryHash.Registry r) {
+ return new NodeConfiguration() {
+ @Override
+ public boolean satisfiesAt(final long height) {
+ return PqRegistryHash.matchesAt(schedule, r, height, CHAIN_ID);
+ }
+
+ @Override
+ public String describe() {
+ return "node holding only registry " + name;
+ }
+ };
+ }
+
+ // ---------------------------------------------------------------------------------------
+ // Fixture. Two registries that differ in exactly one row, which is what both a rotation and a
+ // revocation look like on the wire: index 3 stops being the key it was.
+ // ---------------------------------------------------------------------------------------
+
+ private static byte[] deterministicKey(final int index, final int generation) {
+ final byte[] pk = new byte[PK_LENGTH];
+ for (int i = 0; i < pk.length; i++) {
+ pk[i] = (byte) ((i * 31) + (index * 7) + (generation * 101));
+ }
+ return pk;
+ }
+
+ private static byte[] address(final int index) {
+ final byte[] a = new byte[20];
+ for (int i = 0; i < a.length; i++) {
+ a[i] = (byte) ((index * 17) + i);
+ }
+ return a;
+ }
+
+ private static String hex(final byte[] b) {
+ final StringBuilder sb = new StringBuilder(b.length * 2);
+ for (final byte x : b) {
+ sb.append(String.format("%02x", x));
+ }
+ return sb.toString();
+ }
+
+ /**
+ * A seven-row address-bound registry. {@code rotatedIndex} is the row whose key belongs to
+ * generation 2; every other row is generation 1. Passing -1 gives the untouched registry.
+ */
+ private static Path writeRegistry(final Path dir, final String name, final int rotatedIndex)
+ throws IOException {
+ final StringBuilder sb = new StringBuilder();
+ sb.append("count=").append(N).append('\n');
+ for (int i = 0; i < N; i++) {
+ sb.append(i).append('=').append(hex(deterministicKey(i, i == rotatedIndex ? 2 : 1))).append('\n');
+ sb.append(i).append(".addr=").append(hex(address(i))).append('\n');
+ }
+ final Path p = dir.resolve(name);
+ Files.write(p, sb.toString().getBytes(StandardCharsets.UTF_8));
+ return p;
+ }
+
+ private static PqRegistryHash.Schedule scheduleOf(final String hashAtH1, final String hashAtH2) {
+ final String json =
+ "[{\"block\":"
+ + H1
+ + ",\"hash\":\"0x"
+ + hashAtH1
+ + "\"},{\"block\":"
+ + H2
+ + ",\"hash\":\"0x"
+ + hashAtH2
+ + "\"}]";
+ final JsonNode node;
+ try {
+ node = new ObjectMapper().readTree(json);
+ } catch (final IOException e) {
+ throw new IllegalStateException(e);
+ }
+ return PqRegistryHash.parseSchedule(node, "rotation fixture");
+ }
+
+ /** Every height at which the binding is enforced and could differ across the rotation. */
+ private static List<Long> enforcedHeights() {
+ final List<Long> heights = new ArrayList<>();
+ heights.add(H1);
+ heights.add(H1 + 1);
+ heights.add(H2 - 1);
+ heights.add(H2);
+ heights.add(H2 + 1);
+ return heights;
+ }
+
+ /**
+ * The configurations a node can ACTUALLY be put into with the code as it stands. A repair that
+ * gives a node the whole scheduled history appends its configuration here; nothing else changes.
+ */
+ private static List<NodeConfiguration> availableConfigurations(
+ final PqRegistryHash.Schedule schedule,
+ final PqRegistryHash.Registry before,
+ final PqRegistryHash.Registry after) {
+ final List<NodeConfiguration> all = new ArrayList<>();
+ all.add(single("BEFORE", schedule, before));
+ all.add(single("AFTER", schedule, after));
+ all.add(wholeHistory(schedule, before, after));
+ return all;
+ }
+
+ /**
+ * THE REPAIR: the node holds the WHOLE scheduled history and resolves by height. This
+ * configuration did not exist before the repair, which is why the assertion below could not be
+ * satisfied by any node at all.
+ */
+ private static NodeConfiguration wholeHistory(
+ final PqRegistryHash.Schedule schedule,
+ final PqRegistryHash.Registry before,
+ final PqRegistryHash.Registry after) {
+ final List<PqRegistryHash.Registry> held = new ArrayList<>();
+ held.add(before);
+ held.add(after);
+ final PqRegistryHash.RegistrySet set = PqRegistryHash.buildSet(schedule, held, CHAIN_ID);
+ return new NodeConfiguration() {
+ @Override
+ public boolean satisfiesAt(final long height) {
+ return PqRegistryHash.matchesAt(schedule, set, height, CHAIN_ID);
+ }
+
+ @Override
+ public String describe() {
+ return "node holding the whole scheduled history (" + set + ")";
+ }
+ };
+ }
+
+ // ---------------------------------------------------------------------------------------
+ // Controls. If these fail, the fixture is wrong and the measurements below mean nothing.
+ // ---------------------------------------------------------------------------------------
+
+ @Test
+ public void controlTheScheduleReallyDoesExpressARotation(@TempDir final Path dir)
+ throws IOException {
+ final PqRegistryHash.Registry before =
+ PqRegistryHash.loadPropertiesRegistry(writeRegistry(dir, "before.properties", -1));
+ final PqRegistryHash.Registry after =
+ PqRegistryHash.loadPropertiesRegistry(writeRegistry(dir, "after.properties", 3));
+
+ final String hashBefore = PqRegistryHash.hashV1(before, CHAIN_ID);
+ final String hashAfter = PqRegistryHash.hashV1(after, CHAIN_ID);
+ assertThat(hashBefore).isNotEqualTo(hashAfter);
+
+ final PqRegistryHash.Schedule schedule = scheduleOf(hashBefore, hashAfter);
+ assertThat(schedule.enforced()).isTrue();
+ assertThat(schedule.entries()).hasSize(2);
+
+ // Below the first entry nothing is bound: the 11.8 million existing blocks stay untouched.
+ assertThat(PqRegistryHash.requiredHashAt(schedule, H1 - 1)).isEmpty();
+ assertThat(PqRegistryHash.requiredHashAt(schedule, H1).orElseThrow().hash()).isEqualTo(hashBefore);
+ assertThat(PqRegistryHash.requiredHashAt(schedule, H2 - 1).orElseThrow().hash())
+ .isEqualTo(hashBefore);
+ assertThat(PqRegistryHash.requiredHashAt(schedule, H2).orElseThrow().hash()).isEqualTo(hashAfter);
+ }
+
+ @Test
+ public void controlAMalformedScheduleIsRefused(@TempDir final Path dir) throws IOException {
+ final PqRegistryHash.Registry before =
+ PqRegistryHash.loadPropertiesRegistry(writeRegistry(dir, "before.properties", -1));
+ final String h = PqRegistryHash.hashV1(before, CHAIN_ID);
+ final String json =
+ "[{\"block\":" + H2 + ",\"hash\":\"0x" + h + "\"},{\"block\":" + H1 + ",\"hash\":\"0x" + h + "\"}]";
+ final JsonNode node = new ObjectMapper().readTree(json);
+ assertThatThrownBy(() -> PqRegistryHash.parseSchedule(node, "rotation fixture"))
+ .isInstanceOf(PqRegistryHash.RegistryConfigException.class)
+ .hasMessageContaining("STRICTLY INCREASING");
+ }
+
+ // ---------------------------------------------------------------------------------------
+ // THE MEASUREMENT.
+ // ---------------------------------------------------------------------------------------
+
+ @Test
+ public void rotationDoesNotBrickHistory(@TempDir final Path dir) throws IOException {
+ final PqRegistryHash.Registry before =
+ PqRegistryHash.loadPropertiesRegistry(writeRegistry(dir, "before.properties", -1));
+ final PqRegistryHash.Registry after =
+ PqRegistryHash.loadPropertiesRegistry(writeRegistry(dir, "after.properties", 3));
+ final PqRegistryHash.Schedule schedule =
+ scheduleOf(
+ PqRegistryHash.hashV1(before, CHAIN_ID), PqRegistryHash.hashV1(after, CHAIN_ID));
+
+ final List<Long> heights = enforcedHeights();
+ final List<NodeConfiguration> configurations =
+ availableConfigurations(schedule, before, after);
+
+ final List<String> report = new ArrayList<>();
+ NodeConfiguration complete = null;
+ for (final NodeConfiguration c : configurations) {
+ final List<Long> refused = new ArrayList<>();
+ for (final long h : heights) {
+ if (!c.satisfiesAt(h)) {
+ refused.add(h);
+ }
+ }
+ report.add(c.describe() + " is refused at " + refused);
+ if (refused.isEmpty()) {
+ complete = c;
+ }
+ }
+
+ assertThat(complete)
+ .withFailMessage(
+ "ROTATION UNUSABLE: one scheduled rotation at height %d leaves NO node configuration "
+ + "that satisfies the registry binding at every enforced height. %s. A rotation "
+ + "scheme must keep the whole HEIGHT-TO-KEY-SET mapping loadable, not only the "
+ + "current entry.",
+ H2,
+ String.join("; ", report))
+ .isNotNull();
+ }
+
+ @Test
+ public void revocationDoesNotBrickHistory(@TempDir final Path dir) throws IOException {
+ // Revocation is the same wire shape as rotation and is measured separately because it is the
+ // case with a deadline: a compromised Falcon key has to stop counting, and the operator has no
+ // reason to be able to re-sync afterwards only by luck.
+ final PqRegistryHash.Registry withCompromised =
+ PqRegistryHash.loadPropertiesRegistry(writeRegistry(dir, "compromised.properties", -1));
+ final PqRegistryHash.Registry revoked =
+ PqRegistryHash.loadPropertiesRegistry(writeRegistry(dir, "revoked.properties", 5));
+ final PqRegistryHash.Schedule schedule =
+ scheduleOf(
+ PqRegistryHash.hashV1(withCompromised, CHAIN_ID),
+ PqRegistryHash.hashV1(revoked, CHAIN_ID));
+
+ NodeConfiguration complete = null;
+ final List<String> report = new ArrayList<>();
+ for (final NodeConfiguration c : availableConfigurations(schedule, withCompromised, revoked)) {
+ boolean all = true;
+ final List<Long> refused = new ArrayList<>();
+ for (final long h : enforcedHeights()) {
+ if (!c.satisfiesAt(h)) {
+ all = false;
+ refused.add(h);
+ }
+ }
+ report.add(c.describe() + " is refused at " + refused);
+ if (all) {
+ complete = c;
+ }
+ }
+
+ assertThat(complete)
+ .withFailMessage(
+ "REVOCATION UNUSABLE: revoking one compromised signer at height %d leaves NO node "
+ + "configuration that satisfies the binding at every enforced height. %s. The "
+ + "revocation is expressible and is not usable.",
+ H2,
+ String.join("; ", report))
+ .isNotNull();
+ }
+
+ @Test
+ public void theKeySetInForceBelowTheRotationIsTheOldOne(@TempDir final Path dir)
+ throws IOException {
+ // Coverage alone would be satisfied by a set that answered every height with the same registry.
+ // This is the positive proof that the height actually selects: an old block resolves to the OLD
+ // key set, which is the whole reason the history is kept.
+ final PqRegistryHash.Registry before =
+ PqRegistryHash.loadPropertiesRegistry(writeRegistry(dir, "before.properties", -1));
+ final PqRegistryHash.Registry after =
+ PqRegistryHash.loadPropertiesRegistry(writeRegistry(dir, "after.properties", 3));
+ final PqRegistryHash.Schedule schedule =
+ scheduleOf(PqRegistryHash.hashV1(before, CHAIN_ID), PqRegistryHash.hashV1(after, CHAIN_ID));
+ final List<PqRegistryHash.Registry> held = new ArrayList<>();
+ held.add(before);
+ held.add(after);
+ final PqRegistryHash.RegistrySet set = PqRegistryHash.buildSet(schedule, held, CHAIN_ID);
+
+ assertThat(set.coversWholeSchedule()).isTrue();
+ assertThat(PqRegistryHash.registryAt(schedule, set, H1 - 1)).isEmpty();
+ assertThat(PqRegistryHash.registryAt(schedule, set, H2 - 1).orElseThrow()).isSameAs(before);
+ assertThat(PqRegistryHash.registryAt(schedule, set, H2).orElseThrow()).isSameAs(after);
+
+ // And the two really do differ at the rotated index, so "same registry everywhere" could not
+ // have produced the answers above.
+ assertThat(PqRegistryHash.fingerprint(before, 3))
+ .isNotEqualTo(PqRegistryHash.fingerprint(after, 3));
+ assertThat(PqRegistryHash.fingerprint(before, 0)).isEqualTo(PqRegistryHash.fingerprint(after, 0));
+ }
+
+ @Test
+ public void aMissingHistoricalRegistryIsNamedAndFailsClosed(@TempDir final Path dir)
+ throws IOException {
+ // The repair must not turn "I do not hold that registry" into "fine". An uncovered entry is
+ // named by height and refuses at exactly the heights it governs, and nowhere else.
+ final PqRegistryHash.Registry before =
+ PqRegistryHash.loadPropertiesRegistry(writeRegistry(dir, "before.properties", -1));
+ final PqRegistryHash.Registry after =
+ PqRegistryHash.loadPropertiesRegistry(writeRegistry(dir, "after.properties", 3));
+ final PqRegistryHash.Schedule schedule =
+ scheduleOf(PqRegistryHash.hashV1(before, CHAIN_ID), PqRegistryHash.hashV1(after, CHAIN_ID));
+
+ final List<PqRegistryHash.Registry> onlyAfter = new ArrayList<>();
+ onlyAfter.add(after);
+ final PqRegistryHash.RegistrySet partial =
+ PqRegistryHash.buildSet(schedule, onlyAfter, CHAIN_ID);
+
+ assertThat(partial.coversWholeSchedule()).isFalse();
+ assertThat(partial.uncoveredEntryBlocks()).containsExactly(H1);
+ assertThat(PqRegistryHash.matchesAt(schedule, partial, H1, CHAIN_ID)).isFalse();
+ assertThat(PqRegistryHash.matchesAt(schedule, partial, H2 - 1, CHAIN_ID)).isFalse();
+ assertThat(PqRegistryHash.matchesAt(schedule, partial, H2, CHAIN_ID)).isTrue();
+ // Below the schedule nothing is enforced, so an incomplete set still leaves history alone.
+ assertThat(PqRegistryHash.matchesAt(schedule, partial, H1 - 1, CHAIN_ID)).isTrue();
+ // And a null set is refused wherever a binding is active, never passed over.
+ assertThat(PqRegistryHash.matchesAt(schedule, (PqRegistryHash.RegistrySet) null, H1, CHAIN_ID))
+ .isFalse();
+ }
+
+ @Test
+ public void theOperatorConfigurationStringProducesACoveringSet(@TempDir final Path dir)
+ throws IOException {
+ // WHY THIS EXISTS, and it is a gap the other six leave open on purpose-by-omission. Every one of
+ // them reaches the covering configuration by calling PqRegistryHash.buildSet with a list of
+ // Registry objects the test built itself. No operator can do that. What an operator can do is
+ // write a comma-separated list of FILE PATHS into aere.falcon.registry.history, and the node
+ // turns that string into the same set through parseRegistryPaths + loadAuto
+ // (FalconSealSupport.verifyRegistryBindingOrAbort, the registry-binding block). If that route
+ // were broken
+ // the other six would still be green and the capability would still not be usable, which is the
+ // exact shape of "a green result in a reduced environment is true and worthless".
+ //
+ // So this measurement starts from the STRING and ends at the same property the measurement
+ // tests assert: satisfied at every enforced height.
+ final Path beforePath = writeRegistry(dir, "before.properties", -1);
+ final Path afterPath = writeRegistry(dir, "after.properties", 3);
+ final PqRegistryHash.Registry before = PqRegistryHash.loadPropertiesRegistry(beforePath);
+ final PqRegistryHash.Registry after = PqRegistryHash.loadPropertiesRegistry(afterPath);
+ final PqRegistryHash.Schedule schedule =
+ scheduleOf(PqRegistryHash.hashV1(before, CHAIN_ID), PqRegistryHash.hashV1(after, CHAIN_ID));
+
+ // Written the way an operator writes it: one string, comma separated, with the sloppy spacing
+ // a unit file actually carries. The node's own file is the FIRST element of the held list, so
+ // the string names the OTHER one; here both are named, which is also legal and must not
+ // double-count.
+ final String configured = " " + beforePath + " , " + afterPath + " ,";
+ final List<Path> paths = PqRegistryHash.parseRegistryPaths(configured);
+ assertThat(paths).hasSize(2);
+
+ final PqRegistryHash.RegistrySet set = PqRegistryHash.loadSet(schedule, paths, CHAIN_ID);
+ assertThat(set.coversWholeSchedule()).isTrue();
+ assertThat(set.uncoveredEntryBlocks()).isEmpty();
+
+ final List<Long> refused = new ArrayList<>();
+ for (final long h : enforcedHeights()) {
+ if (!PqRegistryHash.matchesAt(schedule, set, h, CHAIN_ID)) {
+ refused.add(h);
+ }
+ }
+ assertThat(refused)
+ .withFailMessage(
+ "ROTATION UNUSABLE on the route an operator can actually take: the history list %s "
+ + "parses and loads, and the resulting set is still refused at %s. The library can "
+ + "express the whole height-to-key-set mapping but the configuration string cannot reach "
+ + "it, so the rotation remains expressible and not usable.",
+ configured, refused)
+ .isEmpty();
+
+ // Positive proof that the string, not luck, did the selecting: below the rotation the OLD file
+ // is in force, at and above it the NEW one.
+ assertThat(PqRegistryHash.registryAt(schedule, set, H2 - 1).orElseThrow())
+ .isNotSameAs(PqRegistryHash.registryAt(schedule, set, H2).orElseThrow());
+
+ // And the failure direction on the same route: a history string that names only one of the two
+ // files leaves the other entry uncovered, named by height, and refusing exactly there.
+ final PqRegistryHash.RegistrySet partial =
+ PqRegistryHash.loadSet(
+ schedule, PqRegistryHash.parseRegistryPaths(afterPath.toString()), CHAIN_ID);
+ assertThat(partial.uncoveredEntryBlocks()).containsExactly(H1);
+ assertThat(PqRegistryHash.matchesAt(schedule, partial, H1, CHAIN_ID)).isFalse();
+ }
+}
diff --git a/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqSealPersistenceTest.java b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqSealPersistenceTest.java
new file mode 100755
index 000000000..8967ff3e7
--- /dev/null
+++ b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqSealPersistenceTest.java
@@ -0,0 +1,621 @@
+/*
+ * Copyright contributors to Besu / Aere Network.
+ *
+ * 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.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import static org.mockito.Mockito.withSettings;
+
+import org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer;
+import org.hyperledger.besu.consensus.common.validator.ValidatorProvider;
+import org.hyperledger.besu.crypto.SecureRandomProvider;
+import org.hyperledger.besu.datatypes.Address;
+import org.hyperledger.besu.ethereum.ProtocolContext;
+import org.hyperledger.besu.ethereum.core.BlockHeader;
+import org.hyperledger.besu.ethereum.core.BlockHeaderTestFixture;
+
+import java.lang.reflect.Field;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.OptionalInt;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.apache.tuweni.bytes.Bytes;
+import org.apache.tuweni.bytes.Bytes32;
+import org.bouncycastle.crypto.digests.KeccakDigest;
+import org.bouncycastle.pqc.crypto.falcon.FalconPrivateKeyParameters;
+import org.bouncycastle.pqc.crypto.falcon.FalconSigner;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.mockito.quality.Strictness;
+
+/**
+ * THE SECOND HALF OF THE FLEET-RESTART CHAIN DEATH: the heard seals themselves.
+ *
+ * <p>MEASURED ON A NETWORK FIRST, NOT ASSUMED. With the anchor armed at K&gt;0, a SIMULTANEOUS
+ * restart of all seven validators kills the chain permanently, measured on an isolated rehearsal
+ * network. The FIRST half of that deadlock was the registry, repaired the same day: it now
+ * activates at start-up from chain-head state, and all seven nodes reported
+ * "anchor activation at STARTUP from head state: SUCCEEDED". The chain died anyway. The refusal
+ * only changed shape, from "registry address-bound=false" to "registry address-bound=TRUE ...
+ * Heard 0 seal(s)", frozen 150 s then 298 s.
+ *
+ * <p>THE SECOND CIRCLE. The Falcon seals over M(head) travel on nothing but the Commit messages of
+ * the head block, and those are never replayed after a restart. They exist nowhere else: the head's
+ * own header carries a certificate over its PARENT, not over itself. So every node came back holding
+ * zero seals, none could reach K, none could propose, and therefore none ever sent another Commit
+ * for another node to hear. Seals come from Commits, Commits come from proposals, proposals need
+ * seals.
+ *
+ * <p>WHAT THIS CLASS MEASURES, one test per link, with the causal chain driven in BOTH directions so
+ * that "refuses" is never satisfied by a producer that always refuses:
+ *
+ * <ol>
+ * <li>{@link #restartWithNoFileIsTheMeasuredDeadlockAndTheFileIsTheWayOut()} - the whole thing
+ * end to end: the same node, the same head, the same K. Without the file the producer throws;
+ * with the file restored it produces a K-seal certificate whose digest matches. This is the
+ * chain death and its exit, in one method.
+ * <li>{@link #aForgedSealInTheFileIsRejectedAtReadAndNeverEntersTheCache()} - THE SECURITY
+ * PROPERTY, and the test the build-time negative control turns RED. Three shapes of forgery in
+ * one file: a signature over the wrong message, random bytes, and a genuine seal re-labelled
+ * under someone else's index. None survives, and the genuine ones alongside them do.
+ * <li>{@link #aCorruptFileDoesNotStopTheNode()} - truncated, random, empty, a directory where the
+ * file should be. Every one of them yields an empty cache and no exception.
+ * <li>{@link #aFileFromAnotherHeightOrAnotherChainIsIgnored()} - the binding checks, before a
+ * single signature is verified.
+ * <li>{@link #theWriteIsAtomicUnderAConcurrentReader()} - a reader hammering the file across 120
+ * writes never observes a partial file.
+ * <li>{@link #thePathComesFromTheDataDirectory()} - the path is derived, never configured.
+ * <li>{@link #whatOneWriteCostsAgainstTheBlockInterval()} - the price of doing this on the
+ * consensus thread, as a number rather than as a hope.
+ * </ol>
+ *
+ * <p>NOT MEASURED here, and named so it is not read as covered: that seven live nodes recover from a
+ * real simultaneous restart with a binary built from this tree. That needs the rehearsal network and
+ * is separate evidence. This class measures every decision that recovery depends on.
+ */
+public class PqSealPersistenceTest {
+
+ /** Anchor activation height H. */
+ private static final long H = 1_000L;
+
+ /** Seal-attachment height, comfortably below H. */
+ private static final long ATTACH = 900L;
+
+ /** Height from which the staged threshold K is in force. */
+ private static final long K_AT = H + 10L;
+
+ /** The threshold with full margin at N=7: K=3, so the margin equals f. */
+ private static final int K = 3;
+
+ private static final int N = 7;
+
+ private static final long CHAIN_ID = 2_800L;
+
+ /** Measured block interval on the live chain, in milliseconds. */
+ private static final long BLOCK_INTERVAL_MS = 523L;
+
+ @TempDir private Path tmp;
+
+ /** Stands in for the node's data directory, which is where the real path comes from. */
+ private Path dataDirectory;
+
+ private final List<Address> validators = new ArrayList<>();
+ private final List<FalconPrivateKeyParameters> privateKeys = new ArrayList<>();
+ private BlockHeader head;
+ private ProtocolContext context;
+ private BftExtraData base;
+
+ @BeforeEach
+ public void setUp() throws Exception {
+ dataDirectory = Files.createDirectories(tmp.resolve("besu-data"));
+
+ // REGISTRY BINDING: v2, proof-bound, bound at H. See PqV2Fixture.
+ final KeccakDigest kd = new KeccakDigest(256);
+ final StringBuilder manifest =
+ new StringBuilder("{\"config\":{\"aereFalconRegistry\":{")
+ .append(PqV2Fixture.manifestHeader(N, CHAIN_ID, H));
+ for (int i = 0; i < N; i++) {
+ privateKeys.add(PqV2Fixture.privateKey(i));
+ validators.add(PqV2Fixture.address(i));
+ final byte[] anchoredRow = PqV2Fixture.anchorPreimageRow(i);
+ kd.update(anchoredRow, 0, anchoredRow.length);
+ manifest.append(',').append(PqV2Fixture.manifestEntry(i, N, CHAIN_ID, H));
+ }
+ final byte[] anchoredHash = new byte[32];
+ kd.doFinal(anchoredHash, 0);
+ manifest
+ .append("}},\"alloc\":{\"0000000000000000000000000000000000000fa1\":{\"storage\":{\"0x")
+ .append("0".repeat(64))
+ .append("\":\"0x")
+ .append(Bytes.wrap(anchoredHash).toUnprefixedHexString())
+ .append("\"}}}}");
+ final Path genesisPath = tmp.resolve("genesis-registry.json");
+ Files.writeString(genesisPath, manifest.toString());
+ System.setProperty("aere.falcon.genesis", genesisPath.toAbsolutePath().toString());
+ System.setProperty("aere.falcon.attachBlock", Long.toString(ATTACH));
+
+ resetFalconSingleton();
+ PqSealCache.instance().disablePersistence();
+ PqSealCache.instance().clear();
+ PqAnchorProducer.useConfigForTesting(
+ new PqAnchorConfig(CHAIN_ID, H, Map.of(H, 0, K_AT, K), OptionalInt.empty(), false));
+
+ head = new BlockHeaderTestFixture().number(K_AT + 20L).buildHeader();
+ context = contextWith(validators);
+ base =
+ new BftExtraData(
+ Bytes32.ZERO,
+ Collections.emptyList(),
+ Optional.empty(),
+ 0,
+ validators,
+ Collections.emptyList());
+ }
+
+ @AfterEach
+ public void tearDown() throws Exception {
+ System.clearProperty("aere.falcon.genesis");
+ System.clearProperty("aere.falcon.attachBlock");
+ PqSealCache.instance().disablePersistence();
+ PqSealCache.instance().clear();
+ PqAnchorProducer.useConfigForTesting(null);
+ resetFalconSingleton();
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 1. The chain death, and its exit, in one method.
+ // -------------------------------------------------------------------------------------------
+
+ @Test
+ public void restartWithNoFileIsTheMeasuredDeadlockAndTheFileIsTheWayOut() {
+ // Fixture control: without a genuinely armed registry every assertion below would be about
+ // nothing at all.
+ assertThat(FalconSealSupport.instance().genesisAnchored()).isTrue();
+ assertThat(FalconSealSupport.instance().addressBound()).isTrue();
+ assertThat(FalconSealSupport.instance().registrySize()).isEqualTo(N);
+
+ // --- BEFORE THE RESTART. The node is running, persistence is on, and it hears K Commits for
+ // its head exactly the way QbftRound.pqCacheHeardSeals feeds them in.
+ PqSealCache.instance().enablePersistence(dataDirectory, CHAIN_ID);
+ final Path file = PqSealStore.fileIn(dataDirectory);
+ for (int i = 0; i < K; i++) {
+ PqSealCache.instance().record(head.getNumber(), head.getHash(), List.of(genuineSeal(i)));
+ }
+ assertThat(file).exists();
+ assertThat(PqSealCache.instance().sealCount(head.getHash())).isEqualTo(K);
+
+ // --- THE RESTART. A fresh process: the map is gone, the file is not. Nothing else changes.
+ PqSealCache.instance().clear();
+ assertThat(PqSealCache.instance().sealCount(head.getHash()))
+ .describedAs("the in-memory map does not survive a restart, which is the whole problem")
+ .isZero();
+
+ // --- THE DEADLOCK, as measured on the seven-node network. Without the file this is terminal:
+ // no proposal means no Commit, and no Commit means no seal, for ever.
+ assertThatThrownBy(() -> PqAnchorProducer.apply(base, head, context))
+ .describedAs(
+ "the measured chain death: a restarted node holds no seals over M(head), so it cannot "
+ + "assemble a certificate and cannot propose")
+ .isInstanceOf(PqAnchorNotReadyException.class)
+ .hasMessageContaining("Heard 0 seal(s)");
+
+ // --- THE REPAIR. One read, every seal re-verified, and the same producer on the same inputs
+ // now produces a certificate. This is the ONLY thing the start-up code adds.
+ final int restored =
+ PqSealCache.instance()
+ .restoreFromDisk(
+ head.getNumber(), head.getHash(), PqSignerRegistry.falconSealSupport());
+ assertThat(restored).isEqualTo(K);
+ assertThat(PqSealCache.instance().sealCount(head.getHash())).isEqualTo(K);
+
+ final BftExtraData produced = PqAnchorProducer.apply(base, head, context);
+ assertThat(produced.getFalconSeals())
+ .describedAs("the restarted node can propose again, carrying a K=%d certificate", K)
+ .hasSize(K);
+ assertThat(PqAnchor.hasStrictlyIncreasingIndices(produced.getFalconSeals())).isTrue();
+ assertThat(produced.getVanityData())
+ .describedAs("and the anchor digest is the one the validator side will recompute")
+ .isEqualTo(
+ PqAnchor.anchorDigest(
+ CHAIN_ID,
+ head.getNumber(),
+ head.getHash().getBytes(),
+ PqAnchor.sortedByIndex(produced.getFalconSeals())));
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 2. THE SECURITY PROPERTY. This is the test the build-time negative control turns RED.
+ // -------------------------------------------------------------------------------------------
+
+ /**
+ * Persisting seals is only defensible because a seal is SELF-AUTHENTICATING: it is re-verified at
+ * read, against the anchored registry, over M rebuilt from the head this process just loaded. If
+ * that were not so, the file would be exactly the unbound-registry defect in another coat -
+ * state believed because it sits in a file a node can be pointed at.
+ *
+ * <p>Three shapes of forgery are in the one file, because "a forged seal" is not one thing:
+ *
+ * <ol>
+ * <li>index 3, a REAL Falcon signature by validator 3, but over another block's M. This is the
+ * replay an attacker with access to any past Commit traffic actually has.
+ * <li>index 4, random bytes of exactly the right length. The cheapest forgery there is.
+ * <li>index 5, validator 0's GENUINE signature over the right M, re-labelled as index 5. This
+ * one is the reason index alone can never be the check: the bytes are valid, the claim is
+ * not.
+ * </ol>
+ */
+ @Test
+ public void aForgedSealInTheFileIsRejectedAtReadAndNeverEntersTheCache() throws Exception {
+ final List<FalconSeal> genuine = List.of(genuineSeal(0), genuineSeal(1), genuineSeal(2));
+
+ final Bytes32 anotherBlocksMessage =
+ PqAnchor.commitMessage(CHAIN_ID, head.getNumber() - 1L, Bytes32.leftPad(Bytes.of(9)));
+ final byte[] randomBytes = new byte[genuine.get(0).getSignature().size()];
+ SecureRandomProvider.createSecureRandom().nextBytes(randomBytes);
+
+ final List<FalconSeal> forged =
+ List.of(
+ new FalconSeal(3, Bytes.wrap(falconSign(privateKeys.get(3), anotherBlocksMessage))),
+ new FalconSeal(4, Bytes.wrap(randomBytes)),
+ new FalconSeal(5, genuine.get(0).getSignature()));
+
+ final List<FalconSeal> all = new ArrayList<>(genuine);
+ all.addAll(forged);
+ final Path file = PqSealStore.fileIn(dataDirectory);
+ PqSealStore.writeAtomically(
+ file,
+ PqSealStore.encode(CHAIN_ID, head.getNumber(), head.getHash().getBytes(), all));
+ assertThat(file).exists();
+
+ PqSealCache.instance().enablePersistence(dataDirectory, CHAIN_ID);
+ final int restored =
+ PqSealCache.instance()
+ .restoreFromDisk(
+ head.getNumber(), head.getHash(), PqSignerRegistry.falconSealSupport());
+
+ assertThat(restored)
+ .describedAs(
+ "THE LOAD-BEARING ASSERTION. Six seals were in the file and only the three genuine ones "
+ + "may come out. Delete the verify() call in PqSealStore and this line goes red, "
+ + "which is exactly what the build-time negative control proves.")
+ .isEqualTo(3);
+
+ final List<FalconSeal> inCache =
+ PqSealCache.instance().sealsFor(head.getNumber(), head.getHash());
+ assertThat(inCache).hasSize(3);
+ assertThat(inCache.stream().map(FalconSeal::getValidatorIndex))
+ .describedAs("no forged index may reach the cache at all")
+ .containsExactly(0, 1, 2);
+ assertThat(inCache).containsExactlyInAnyOrderElementsOf(genuine);
+
+ // And the genuine ones are not merely present, they are usable: the producer, which verifies
+ // again at selection, accepts exactly these three. Without this half the test would be
+ // satisfied by a reader that rejected everything.
+ final BftExtraData produced = PqAnchorProducer.apply(base, head, context);
+ assertThat(produced.getFalconSeals()).hasSize(K).containsExactlyElementsOf(genuine);
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 3. A corrupt file must never be able to stop a node.
+ // -------------------------------------------------------------------------------------------
+
+ @Test
+ public void aCorruptFileDoesNotStopTheNode() throws Exception {
+ final Path file = PqSealStore.fileIn(dataDirectory);
+ final byte[] good =
+ PqSealStore.encode(
+ CHAIN_ID,
+ head.getNumber(),
+ head.getHash().getBytes(),
+ List.of(genuineSeal(0), genuineSeal(1), genuineSeal(2)));
+ PqSealCache.instance().enablePersistence(dataDirectory, CHAIN_ID);
+
+ // (a) no file at all: the ordinary first start.
+ Files.deleteIfExists(file);
+ assertRestoresNothingWithoutThrowing("no file at all");
+
+ // (b) truncated halfway: a write interrupted by a machine that lost power. The atomic rename
+ // is what stops this from happening, and this is what would happen if it did anyway.
+ Files.write(file, Arrays.copyOf(good, good.length / 2));
+ assertRestoresNothingWithoutThrowing("truncated file");
+
+ // (c) random bytes: a wrong file copied over it, or a corrupt sector.
+ final byte[] noise = new byte[good.length];
+ SecureRandomProvider.createSecureRandom().nextBytes(noise);
+ Files.write(file, noise);
+ assertRestoresNothingWithoutThrowing("random bytes");
+
+ // (d) empty file.
+ Files.write(file, new byte[0]);
+ assertRestoresNothingWithoutThrowing("empty file");
+
+ // (e) valid RLP, wrong domain: a file written for something else entirely.
+ Files.write(file, Bytes.fromHexString("0xc50102030405").toArrayUnsafe());
+ assertRestoresNothingWithoutThrowing("valid RLP, wrong shape");
+
+ // (f) a DIRECTORY where the file should be. Not exotic: a mount gone wrong does this.
+ Files.deleteIfExists(file);
+ Files.createDirectory(file);
+ assertRestoresNothingWithoutThrowing("a directory in place of the file");
+ Files.delete(file);
+
+ // And after all of that the node is still a working node: a good file still restores.
+ Files.write(file, good);
+ assertThat(
+ PqSealCache.instance()
+ .restoreFromDisk(
+ head.getNumber(), head.getHash(), PqSignerRegistry.falconSealSupport()))
+ .describedAs(
+ "positive control: without this line every assertion above would be satisfied by a "
+ + "reader that can never read anything")
+ .isEqualTo(3);
+ }
+
+ private void assertRestoresNothingWithoutThrowing(final String what) {
+ PqSealCache.instance().clear();
+ assertThatCode(
+ () ->
+ assertThat(
+ PqSealCache.instance()
+ .restoreFromDisk(
+ head.getNumber(),
+ head.getHash(),
+ PqSignerRegistry.falconSealSupport()))
+ .describedAs("%s must restore nothing", what)
+ .isZero())
+ .describedAs("%s must not throw: a node that cannot read the file is a node with none", what)
+ .doesNotThrowAnyException();
+ assertThat(PqSealCache.instance().sealCount(head.getHash())).isZero();
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 4. The binding checks, made before any signature is verified.
+ // -------------------------------------------------------------------------------------------
+
+ @Test
+ public void aFileFromAnotherHeightOrAnotherChainIsIgnored() throws Exception {
+ final Path file = PqSealStore.fileIn(dataDirectory);
+ final List<FalconSeal> seals = List.of(genuineSeal(0), genuineSeal(1), genuineSeal(2));
+ PqSealCache.instance().enablePersistence(dataDirectory, CHAIN_ID);
+
+ // Same seals, but the file claims another height. They cannot help the proposer of head+1.
+ PqSealStore.writeAtomically(
+ file,
+ PqSealStore.encode(CHAIN_ID, head.getNumber() - 1L, head.getHash().getBytes(), seals));
+ assertRestoresNothingWithoutThrowing("a file from another height");
+
+ // Same seals, another block hash at the right height: a fork of the same number.
+ PqSealStore.writeAtomically(
+ file,
+ PqSealStore.encode(
+ CHAIN_ID, head.getNumber(), Bytes32.leftPad(Bytes.of(7)), seals));
+ assertRestoresNothingWithoutThrowing("a file for another block at the same height");
+
+ // Another chain running the same binaries and possibly the same Falcon keys.
+ PqSealStore.writeAtomically(
+ file,
+ PqSealStore.encode(442_807L, head.getNumber(), head.getHash().getBytes(), seals));
+ assertRestoresNothingWithoutThrowing("a file from another chain");
+
+ // Positive control for this method: the same three seals, correctly bound, do restore.
+ PqSealStore.writeAtomically(
+ file, PqSealStore.encode(CHAIN_ID, head.getNumber(), head.getHash().getBytes(), seals));
+ PqSealCache.instance().clear();
+ assertThat(
+ PqSealCache.instance()
+ .restoreFromDisk(
+ head.getNumber(), head.getHash(), PqSignerRegistry.falconSealSupport()))
+ .isEqualTo(3);
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 5. Atomicity, measured against a reader rather than asserted from the API docs.
+ // -------------------------------------------------------------------------------------------
+
+ @Test
+ public void theWriteIsAtomicUnderAConcurrentReader() throws Exception {
+ final Path file = PqSealStore.fileIn(dataDirectory);
+ final List<FalconSeal> seals = new ArrayList<>();
+ for (int i = 0; i < N; i++) {
+ seals.add(genuineSeal(i));
+ }
+ PqSealStore.writeAtomically(
+ file, PqSealStore.encode(CHAIN_ID, head.getNumber(), head.getHash().getBytes(), seals));
+
+ final AtomicBoolean stop = new AtomicBoolean(false);
+ final AtomicInteger reads = new AtomicInteger();
+ final AtomicInteger partialReads = new AtomicInteger();
+ final Thread reader =
+ new Thread(
+ () -> {
+ while (!stop.get()) {
+ final List<FalconSeal> got =
+ PqSealStore.readVerified(
+ file,
+ CHAIN_ID,
+ head.getNumber(),
+ head.getHash(),
+ PqSignerRegistry.falconSealSupport());
+ reads.incrementAndGet();
+ if (got.isEmpty()) {
+ partialReads.incrementAndGet();
+ }
+ }
+ });
+ reader.setDaemon(true);
+ reader.start();
+
+ for (int round = 0; round < 120; round++) {
+ final List<FalconSeal> subset = seals.subList(0, 1 + (round % N));
+ PqSealStore.writeAtomically(
+ file,
+ PqSealStore.encode(CHAIN_ID, head.getNumber(), head.getHash().getBytes(), subset));
+ }
+ stop.set(true);
+ reader.join(30_000L);
+
+ assertThat(reads)
+ .describedAs("fixture control: the reader must actually have run")
+ .hasValueGreaterThan(0);
+ assertThat(partialReads)
+ .describedAs(
+ "%s reads across 120 writes and not one saw a half-written file. Temp plus rename is "
+ + "the reason; writing in place would have produced partial reads here.",
+ reads.get())
+ .hasValue(0);
+ assertThat(dataDirectory.resolve(PqSealStore.TEMP_FILE_NAME))
+ .describedAs("the temporary file must not be left behind")
+ .doesNotExist();
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 6. The path is DERIVED from the data directory, never separately configured.
+ // -------------------------------------------------------------------------------------------
+
+ @Test
+ public void thePathComesFromTheDataDirectory() {
+ assertThat(PqSealStore.fileIn(dataDirectory))
+ .isEqualTo(dataDirectory.resolve(PqSealStore.FILE_NAME));
+
+ PqSealCache.instance().enablePersistence(dataDirectory, CHAIN_ID);
+ assertThat(PqSealCache.instance().persistenceFile())
+ .isEqualTo(dataDirectory.resolve(PqSealStore.FILE_NAME));
+
+ final Path other = tmp.resolve("another-node");
+ PqSealCache.instance().enablePersistence(other, CHAIN_ID);
+ assertThat(PqSealCache.instance().persistenceFile())
+ .describedAs("two nodes on one machine never share the file")
+ .isEqualTo(other.resolve(PqSealStore.FILE_NAME))
+ .isNotEqualTo(dataDirectory.resolve(PqSealStore.FILE_NAME));
+
+ // A null data directory leaves persistence off rather than inventing a path.
+ PqSealCache.instance().disablePersistence();
+ PqSealCache.instance().enablePersistence(null, CHAIN_ID);
+ assertThat(PqSealCache.instance().persistenceFile()).isNull();
+ PqSealCache.instance().record(head.getNumber(), head.getHash(), List.of(genuineSeal(0)));
+ assertThat(PqSealCache.instance().sealCount(head.getHash()))
+ .describedAs("with persistence off the cache still works exactly as before")
+ .isEqualTo(1);
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 7. The price, as a number.
+ // -------------------------------------------------------------------------------------------
+
+ /**
+ * The write happens on the consensus thread, once per Commit heard, so its cost is a real
+ * property of this change and not a footnote. Seven seals is the whole fleet.
+ */
+ @Test
+ public void whatOneWriteCostsAgainstTheBlockInterval() throws Exception {
+ final Path file = PqSealStore.fileIn(dataDirectory);
+ final List<FalconSeal> seals = new ArrayList<>();
+ for (int i = 0; i < N; i++) {
+ seals.add(genuineSeal(i));
+ }
+ final byte[] payload =
+ PqSealStore.encode(CHAIN_ID, head.getNumber(), head.getHash().getBytes(), seals);
+
+ final int rounds = 100;
+ final long[] micros = new long[rounds];
+ for (int i = 0; i < rounds; i++) {
+ final long t0 = System.nanoTime();
+ PqSealStore.writeAtomically(file, payload);
+ micros[i] = (System.nanoTime() - t0) / 1_000L;
+ }
+ Arrays.sort(micros);
+ final long median = micros[rounds / 2];
+ final long p95 = micros[(int) (rounds * 0.95)];
+ final long worst = micros[rounds - 1];
+
+ // Printed so the number lands in the test XML and can be quoted as a measurement rather than
+ // remembered as an impression.
+ System.out.println(
+ "AERE PERSISTENTA-SIGILII MEASURED: payload="
+ + payload.length
+ + " bytes for "
+ + N
+ + " seals; write median="
+ + median
+ + " us, p95="
+ + p95
+ + " us, worst="
+ + worst
+ + " us over "
+ + rounds
+ + " writes; fsync="
+ + !"false".equalsIgnoreCase(System.getProperty(PqSealStore.PROPERTY_FSYNC))
+ + "; block interval="
+ + BLOCK_INTERVAL_MS
+ + " ms.");
+
+ assertThat(payload.length)
+ .describedAs("seven Falcon-512 seals plus the binding fields")
+ .isLessThan(16 * 1024);
+ assertThat(median)
+ .describedAs(
+ "one write must cost far less than one block interval, or persisting on the consensus "
+ + "thread would be trading a restart deadlock for a liveness cost")
+ .isLessThan(BLOCK_INTERVAL_MS * 1_000L / 10L);
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // Helpers.
+ // -------------------------------------------------------------------------------------------
+
+ private FalconSeal genuineSeal(final int index) {
+ final Bytes32 m = PqAnchor.commitMessage(CHAIN_ID, head.getNumber(), head.getHash().getBytes());
+ return new FalconSeal(index, Bytes.wrap(falconSign(privateKeys.get(index), m)));
+ }
+
+ private static byte[] falconSign(final FalconPrivateKeyParameters key, final Bytes32 m) {
+ final FalconSigner signer = new FalconSigner();
+ signer.init(true, key);
+ return signer.generateSignature(m.toArray());
+ }
+
+ private static ProtocolContext contextWith(final Collection<Address> validatorSet) {
+ final ValidatorProvider validatorProvider =
+ mock(ValidatorProvider.class, withSettings().strictness(Strictness.LENIENT));
+ when(validatorProvider.getValidatorsForBlock(any())).thenReturn(validatorSet);
+ when(validatorProvider.getValidatorsAfterBlock(any())).thenReturn(validatorSet);
+ final BftContext bftContext =
+ mock(BftContext.class, withSettings().strictness(Strictness.LENIENT));
+ when(bftContext.getValidatorProvider()).thenReturn(validatorProvider);
+ when(bftContext.as(any())).thenReturn(bftContext);
+ return new ProtocolContext.Builder().withConsensusContext(bftContext).build();
+ }
+
+ private static void resetFalconSingleton() throws Exception {
+ final Field f = FalconSealSupport.class.getDeclaredField("instance");
+ f.setAccessible(true);
+ f.set(null, null);
+ }
+}
diff --git a/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqSignedHeightTest.java b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqSignedHeightTest.java
new file mode 100755
index 000000000..fc1333f15
--- /dev/null
+++ b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqSignedHeightTest.java
@@ -0,0 +1,403 @@
+/*
+ * 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.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import java.io.IOException;
+import java.lang.reflect.Field;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.OptionalInt;
+
+import org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * AERE SIGNED HEIGHT. THE SILENT DEFERRAL, and it is the worst of the three because nothing shows
+ * it.
+ *
+ * <p>WHAT IS SUPPOSED TO BE TRUE. Every row of a v2 registry carries two signatures - a Falcon
+ * possession proof and an ECDSA claim by that validator's own consensus key - and BOTH sign the bind
+ * height ({@code PqRegistryBinding.bindingPreimage}). A registry put in force from block B is
+ * therefore every one of those validators stating that B is the height they agreed to.
+ *
+ * <p>WHAT WAS ACTUALLY TRUE UNTIL THIS FILE. {@code bindHeight} was never compared with the {@code
+ * block} of the schedule entry that puts the registry in force. Not anywhere. The two numbers had
+ * been in the same lexical scope ever since the schedule became height-indexed, and were never put
+ * on the same expression.
+ *
+ * <p>WHY THE HASH DOES NOT CATCH IT, which is the part that makes this invisible rather than merely
+ * missing. {@code bindHeight} is INSIDE the v2 pre-image, so it is covered by the hash - and that is
+ * exactly why the comparison looks unnecessary. But the enforcement is hash against hash: the
+ * required hash comes from genesis and the computed hash comes from the file, and a file signed for
+ * 40 and scheduled from 70 reproduces its own hash perfectly. Both sides agree, and neither side
+ * ever mentions a height. {@link #theHashMATCHESWhichIsWhyNobodyEverSawThis()} measures precisely
+ * that, and it is the assertion that makes the rest of this file mean something.
+ *
+ * <p>MEASURED ON A NETWORK OF SEVEN, 2026-08-06, scenario C2: the arming height moved from 40 to 70
+ * with the proofs left signed for 40. Seven of seven nodes started, the chain ran to head 95,
+ * agreement was 7 of 7, ZERO errors and ZERO refusals - while the offline tool, on the same files,
+ * said RED. The node and the tool disagreed and nothing put them face to face.
+ *
+ * <p>WHAT IT BUYS SOMEBODY WHO SHOULD NOT HAVE IT: moving the activation day costs 14 fresh
+ * validator signatures if this is checked and ZERO if it is not.
+ *
+ * <p>THE RULE CHOSEN, and why it is the correct one rather than merely a working one:
+ *
+ * <ul>
+ * <li>A proof-bound registry that reproduces the hash of schedule entry E must declare {@code
+ * bindHeight == E.block}. Enforced ({@code AERE-PQC-REG-BIND-08}). Both numbers come from
+ * genesis and the file, and genesis is the one document all seven nodes hold byte-identically,
+ * so this comparison is backed by the same agreement consensus already rests on.
+ * <li>NOT refused: a registry enforced from a FUTURE height. The comparison is against the
+ * ENTRY'S block, never against the chain head, so an entry scheduled a year out is untouched -
+ * {@link #aRegistryScheduledFarInTheFutureIsNOTRefused()}.
+ * <li>NOT refused: a schedule with several epochs. Each entry is compared against the registry
+ * that reproduces ITS hash, one pair at a time - {@link #twoEpochsEachSignedForItsOwnHeightAreFINE()}.
+ * <li>NOT touched: v1 registries. They carry no signatures over any height, {@code bindHeight} is
+ * -1, and the condition is gated on {@code proofBound()} - {@link #aV1RegistryIsNOTJudgedByThisRuleAtAll()}.
+ * <li>An entry no held file reproduces stays UNCOVERED, which is a different condition with a
+ * different message. It is not compared - {@link #anUncoveredEntryIsNotAMisboundOne()}.
+ * </ul>
+ *
+ * <p>AND THE HALF THAT CANNOT BE ENFORCED, written as a limitation and not as a to-do. {@code
+ * aere.pq.anchorBlock} is a system property set per node; it is in no genesis. The seven validators'
+ * agreement on an arming height therefore CANNOT be enforced by code running on one node - only
+ * DETECTED, which is {@code AERE-PQC-REG-BIND-09} and {@link
+ * #armingAtAHeightNobodySignedForIsREFUSEDLocally()}. Enforcement would require the height to move
+ * into genesis, which is a change to the chain and not to this class.
+ *
+ * <p>KEYS. Every key here is generated in memory by {@link PqV2Fixture} and never written outside a
+ * JUnit temporary directory.
+ */
+public class PqSignedHeightTest {
+
+ /** The height the fleet actually signed its registry for. */
+ private static final long SIGNED = 40L;
+
+ /** The height somebody moved the activation to, without asking anybody to sign again. */
+ private static final long MOVED = 70L;
+
+ private static final int N = 7;
+
+ private static final long CHAIN_ID = 220_878L;
+
+ @TempDir private Path tmp;
+
+ private Path signedForForty;
+ private String hashOfSignedForForty;
+
+ @BeforeEach
+ public void setUp() throws Exception {
+ signedForForty = writeRegistry("signed-for-40.properties", SIGNED);
+ hashOfSignedForForty =
+ PqRegistryHash.hashFor(PqRegistryHash.loadAuto(signedForForty), CHAIN_ID);
+ System.setProperty("aere.falcon.registry", signedForForty.toAbsolutePath().toString());
+ resetFalconSingleton();
+ PqAnchorProducer.useConfigForTesting(null);
+ }
+
+ @AfterEach
+ public void tearDown() throws Exception {
+ System.clearProperty("aere.falcon.registry");
+ System.clearProperty(FalconSealSupport.PROPERTY_REGISTRY_HISTORY);
+ resetFalconSingleton();
+ PqAnchorProducer.useConfigForTesting(null);
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 0. WHY NOBODY SAW IT. The hash agrees. If this assertion ever fails, the defect described in
+ // this file did not exist and everything below is theatre.
+ // -------------------------------------------------------------------------------------------
+
+ @Test
+ public void theHashMATCHESWhichIsWhyNobodyEverSawThis() throws Exception {
+ final PqRegistryHash.Registry r = PqRegistryHash.loadAuto(signedForForty);
+ assertThat(r.proofBound()).isTrue();
+ assertThat(r.bindHeight()).isEqualTo(SIGNED);
+
+ // The schedule puts this file in force from 70. The file was signed for 40. The hash the
+ // schedule requires and the hash the file computes are THE SAME NUMBER, because bindHeight sits
+ // inside the pre-image and travels with the file. Hash-against-hash can never see this.
+ final PqRegistryHash.Schedule moved = schedule(Map.of(MOVED, hashOfSignedForForty));
+ assertThat(PqRegistryHash.requiredHashAt(moved, MOVED).orElseThrow().hash())
+ .describedAs(
+ "the required hash and the computed hash agree perfectly while the heights differ by "
+ + "30 blocks. That agreement IS the defect")
+ .isEqualToIgnoringCase(PqRegistryHash.hashFor(r, CHAIN_ID));
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 1. THE MEASUREMENT, repaired: the two numbers are now put side by side and the node refuses.
+ // -------------------------------------------------------------------------------------------
+
+ @Test
+ public void aRegistryScheduledAtAHeightItWasNotSignedForIsREFUSED() throws Exception {
+ final FalconSealSupport pqc = FalconSealSupport.instance();
+
+ assertThatThrownBy(
+ () ->
+ pqc.verifyRegistryBindingOrAbort(
+ MOVED + 25L, CHAIN_ID, schedule(Map.of(MOVED, hashOfSignedForForty))))
+ .isInstanceOf(PqRegistryHash.RegistryConfigException.class)
+ .hasMessageContaining("AERE-PQC-REG-BIND-08")
+ // The message must carry BOTH numbers and name the field, or it is useless at 3am.
+ .hasMessageContaining("bindHeight")
+ .hasMessageContaining("in force FROM BLOCK " + MOVED)
+ .hasMessageContaining("bindHeight=" + SIGNED)
+ .hasMessageContaining("14 fresh validator signatures");
+ }
+
+ @Test
+ public void andItIsRefusedEVENWHENTheMovedHeightIsStillAhead() throws Exception {
+ // The whole hazard of a deferral is that it is invisible until the day arrives. Catching it only
+ // once the chain has reached the height would be catching it at the halt, which is the thing
+ // being repaired. Chain head is far below the moved entry here.
+ final FalconSealSupport pqc = FalconSealSupport.instance();
+ assertThatThrownBy(
+ () ->
+ pqc.verifyRegistryBindingOrAbort(
+ 0L, CHAIN_ID, schedule(Map.of(MOVED, hashOfSignedForForty))))
+ .isInstanceOf(PqRegistryHash.RegistryConfigException.class)
+ .hasMessageContaining("AERE-PQC-REG-BIND-08");
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 2. NEGATIVE CONTROLS. The rule must refuse the moved height and NOTHING ELSE.
+ // -------------------------------------------------------------------------------------------
+
+ @Test
+ public void theSameFileAtTheHeightItWASSignedForStarts() throws Exception {
+ final FalconSealSupport pqc = FalconSealSupport.instance();
+ assertThat(
+ pqc.verifyRegistryBindingOrAbort(
+ SIGNED + 25L, CHAIN_ID, schedule(Map.of(SIGNED, hashOfSignedForForty))))
+ .describedAs(
+ "identical file, identical hash, identical everything except the one number this rule "
+ + "is about. If this refuses too, the rule is a wedge and not a guard")
+ .isEqualTo(PqRegistryHash.GateState.MATCH);
+ }
+
+ @Test
+ public void aRegistryScheduledFarInTheFutureIsNOTRefused() throws Exception {
+ // A registry staged for an activation a year out: signed for that height, scheduled at that
+ // height, chain head nowhere near it. The comparison is entry-block against bindHeight and never
+ // against the chain head, so this is untouched.
+ final long future = 12_000_000L;
+ final Path staged = writeRegistry("staged.properties", future);
+ final String h = PqRegistryHash.hashFor(PqRegistryHash.loadAuto(staged), CHAIN_ID);
+ System.setProperty("aere.falcon.registry", staged.toAbsolutePath().toString());
+ resetFalconSingleton();
+ final FalconSealSupport pqc = FalconSealSupport.instance();
+
+ assertThat(pqc.verifyRegistryBindingOrAbort(1_000L, CHAIN_ID, schedule(Map.of(future, h))))
+ .isEqualTo(PqRegistryHash.GateState.NOT_ENFORCED_BELOW_FIRST_HEIGHT);
+ }
+
+ @Test
+ public void twoEpochsEachSignedForItsOwnHeightAreFINE() throws Exception {
+ final Path second = writeRegistry("epoch-two.properties", MOVED);
+ final String hSecond = PqRegistryHash.hashFor(PqRegistryHash.loadAuto(second), CHAIN_ID);
+ System.setProperty(
+ FalconSealSupport.PROPERTY_REGISTRY_HISTORY, second.toAbsolutePath().toString());
+ final FalconSealSupport pqc = FalconSealSupport.instance();
+
+ final Map<Long, String> both = new LinkedHashMap<>();
+ both.put(SIGNED, hashOfSignedForForty);
+ both.put(MOVED, hSecond);
+
+ assertThat(pqc.verifyRegistryBindingOrAbort(MOVED + 25L, CHAIN_ID, schedule(both)))
+ .describedAs(
+ "a multi-epoch schedule is the normal shape after a rotation. Each entry is judged "
+ + "against the file that reproduces ITS hash, so several epochs are several "
+ + "independent comparisons and not one blanket")
+ .isEqualTo(PqRegistryHash.GateState.MATCH);
+ }
+
+ @Test
+ public void aV1RegistryIsNOTJudgedByThisRuleAtAll() throws Exception {
+ // No proofs, therefore no signature over any height, therefore no height to disagree with.
+ // bindHeight is -1 here and would mismatch every entry there is; the gate on proofBound() is
+ // what keeps a v1 fleet from being refused by a rule about signatures it never made.
+ final Path v1 = writeV1Registry("legacy.properties");
+ final PqRegistryHash.Registry r = PqRegistryHash.loadAuto(v1);
+ assertThat(r.proofBound()).isFalse();
+ assertThat(r.bindHeight()).isNegative();
+
+ final String h = PqRegistryHash.hashFor(r, CHAIN_ID);
+ final PqRegistryHash.RegistrySet set =
+ PqRegistryHash.buildSet(schedule(Map.of(MOVED, h)), List.of(r), CHAIN_ID);
+
+ assertThat(set.misbound())
+ .describedAs("a v1 registry can never be misbound, because it never bound itself to a height")
+ .isEmpty();
+ assertThat(set.forEntryBlock(MOVED)).isSameAs(r);
+ }
+
+ @Test
+ public void anUncoveredEntryIsNotAMisboundOne() throws Exception {
+ // An entry whose hash nothing held reproduces is a MISSING file, which an operator legitimately
+ // has. It must not be reported as a signed-height mismatch: different mistake, different fix.
+ final Map<Long, String> both = new LinkedHashMap<>();
+ both.put(SIGNED, hashOfSignedForForty);
+ both.put(MOVED + 1_000L, "cd".repeat(32));
+ final PqRegistryHash.RegistrySet set =
+ PqRegistryHash.buildSet(
+ schedule(both), List.of(PqRegistryHash.loadAuto(signedForForty)), CHAIN_ID);
+
+ assertThat(set.misbound()).isEmpty();
+ assertThat(set.uncoveredEntryBlocks()).containsExactly(MOVED + 1_000L);
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 3. THE LOCAL HALF. Arming at a height nobody signed for: detected, and honestly labelled.
+ // -------------------------------------------------------------------------------------------
+
+ @Test
+ public void armingAtAHeightNobodySignedForIsREFUSEDLocally() throws Exception {
+ // The schedule is coherent - the file is signed for 40 and scheduled from 40 - and the ANCHOR
+ // has been moved to 70 on this node alone. Nothing in genesis constrains aere.pq.anchorBlock, so
+ // this can only be caught where both values are in one hand, which is here.
+ PqAnchorProducer.useConfigForTesting(
+ new PqAnchorConfig(CHAIN_ID, MOVED, Map.of(MOVED, 3), OptionalInt.empty(), false));
+ resetFalconSingleton();
+ final FalconSealSupport pqc = FalconSealSupport.instance();
+
+ assertThatThrownBy(
+ () ->
+ pqc.verifyRegistryBindingOrAbort(
+ SIGNED + 5L, CHAIN_ID, schedule(Map.of(SIGNED, hashOfSignedForForty))))
+ .isInstanceOf(PqRegistryHash.RegistryConfigException.class)
+ .hasMessageContaining("AERE-PQC-REG-BIND-09")
+ .hasMessageContaining("aere.pq.anchorBlock = " + MOVED)
+ .hasMessageContaining("config.pqRegistryHash heights = [" + SIGNED + "]")
+ // The honest limitation has to be IN the message, or an operator will read this as a
+ // consensus guarantee it is not.
+ .hasMessageContaining("DETECTION on this node only")
+ .hasMessageContaining("all seven nodes and in the same change");
+ }
+
+ @Test
+ public void armingExactlyAtAScheduledHeightSTARTS() throws Exception {
+ PqAnchorProducer.useConfigForTesting(
+ new PqAnchorConfig(CHAIN_ID, SIGNED, Map.of(SIGNED, 3), OptionalInt.empty(), false));
+ resetFalconSingleton();
+ final FalconSealSupport pqc = FalconSealSupport.instance();
+
+ assertThat(
+ pqc.verifyRegistryBindingOrAbort(
+ SIGNED + 5L, CHAIN_ID, schedule(Map.of(SIGNED, hashOfSignedForForty))))
+ .describedAs(
+ "the positive half, and it is the expensive one: a gate that refuses every armed node "
+ + "is not a gate")
+ .isEqualTo(PqRegistryHash.GateState.MATCH);
+ }
+
+ @Test
+ public void anUNARMEDNodeIsNeverAskedTheQuestion() throws Exception {
+ // Chain 2800 as it stands: aere.pq.anchorBlock unset. The arming-height comparison must not
+ // even be reachable, whatever the schedule says.
+ PqAnchorProducer.useConfigForTesting(PqAnchorConfig.never(CHAIN_ID));
+ resetFalconSingleton();
+ final FalconSealSupport pqc = FalconSealSupport.instance();
+
+ assertThatCode(
+ () ->
+ pqc.verifyRegistryBindingOrAbort(
+ SIGNED + 5L, CHAIN_ID, schedule(Map.of(SIGNED, hashOfSignedForForty))))
+ .doesNotThrowAnyException();
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // Helpers.
+ // -------------------------------------------------------------------------------------------
+
+ private Path writeRegistry(final String name, final long bindHeight) throws IOException {
+ final StringBuilder b = new StringBuilder();
+ b.append("formatVersion=").append(PqRegistryBinding.FORMAT_VERSION).append('\n');
+ b.append("chainId=").append(CHAIN_ID).append('\n');
+ b.append("bindHeight=").append(bindHeight).append('\n');
+ b.append("count=").append(N).append('\n');
+ for (int i = 0; i < N; i++) {
+ b.append(i).append('=').append(hex(PqV2Fixture.publicKey(i))).append('\n');
+ b.append(i).append(".addr=").append(PqV2Fixture.address(i).toHexString()).append('\n');
+ b.append(i).append(".pop=").append(PqV2Fixture.popHex(CHAIN_ID, bindHeight, N, i)).append('\n');
+ b.append(i)
+ .append(".claim=")
+ .append(PqV2Fixture.claimHex(CHAIN_ID, bindHeight, N, i))
+ .append('\n');
+ }
+ final Path f = tmp.resolve(name);
+ Files.writeString(f, b.toString(), StandardCharsets.UTF_8);
+ return f;
+ }
+
+ private Path writeV1Registry(final String name) throws IOException {
+ final StringBuilder b = new StringBuilder();
+ b.append("count=").append(N).append('\n');
+ for (int i = 0; i < N; i++) {
+ b.append(i).append('=').append(hex(PqV2Fixture.publicKey(i))).append('\n');
+ b.append(i).append(".addr=").append(PqV2Fixture.address(i).toHexString()).append('\n');
+ }
+ final Path f = tmp.resolve(name);
+ Files.writeString(f, b.toString(), StandardCharsets.UTF_8);
+ return f;
+ }
+
+ private PqRegistryHash.Schedule schedule(final Map<Long, String> entries) throws IOException {
+ final List<Long> heights = new ArrayList<>(entries.keySet());
+ heights.sort(Long::compare);
+ final StringBuilder sb = new StringBuilder("{\"config\":{\"pqRegistryHash\":[");
+ for (int i = 0; i < heights.size(); i++) {
+ if (i > 0) {
+ sb.append(',');
+ }
+ sb.append("{\"block\":")
+ .append(heights.get(i))
+ .append(",\"hash\":\"0x")
+ .append(entries.get(heights.get(i)))
+ .append("\"}");
+ }
+ sb.append("]}}");
+ final Path p = tmp.resolve("genesis-db-" + heights.size() + "-" + heights.get(0) + ".json");
+ Files.writeString(p, sb.toString(), StandardCharsets.UTF_8);
+ return PqRegistryHash.loadScheduleFromGenesis(p);
+ }
+
+ private static String hex(final byte[] b) {
+ final StringBuilder s = new StringBuilder(b.length * 2);
+ for (final byte x : b) {
+ s.append(Character.forDigit((x >> 4) & 0xf, 16)).append(Character.forDigit(x & 0xf, 16));
+ }
+ return s.toString();
+ }
+
+ private static void resetFalconSingleton() throws Exception {
+ final Field f = FalconSealSupport.class.getDeclaredField("instance");
+ f.setAccessible(true);
+ f.set(null, null);
+ }
+}
diff --git a/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqStartupHistoryTest.java b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqStartupHistoryTest.java
new file mode 100755
index 000000000..705838dcf
--- /dev/null
+++ b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqStartupHistoryTest.java
@@ -0,0 +1,325 @@
+/*
+ * 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 java.io.IOException;
+import java.lang.reflect.Field;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * AERE ROTATION HISTORY AT STARTUP. AFTER THE FIRST ROTATION, NO NODE COULD BE RESTARTED WITH ITS OWN CORRECT
+ * CONFIGURATION.
+ *
+ * <p>WHAT WAS MEASURED, and it was measured twice: once during the rotation rehearsal on a network
+ * of seven, and again on 2026-08-06 with the chain head at 2554. A node configured exactly as the
+ * runbook says - the genesis manifest as the registry it signs with, every rotation registry named
+ * in {@code aere.falcon.registry.history} - refused to start with {@code AERE-PQC-REG-MISMATCH-01}.
+ * Nothing was wrong with it.
+ *
+ * <p>THE DEFECT WAS THE ORDER OF TWO BLOCKS OF CODE. {@code
+ * FalconSealSupport.verifyRegistryBindingOrAbort} loaded ONE registry, the primary, and handed it to
+ * the guard. The history list was read FORTY-ONE LINES FURTHER DOWN, to build the height-resolved
+ * set the height-indexed registry change introduced. So the refusal was thrown before the code that
+ * knew the answer had run. The guard was not wrong about what it compared; it was never shown the
+ * other files.
+ *
+ * <p>WHY IT BITES EXACTLY AFTER A ROTATION AND NEVER BEFORE. The primary registry is the genesis
+ * manifest, and genesis does not change. A rotation adds a SECOND entry to {@code
+ * config.pqRegistryHash} and a second file, which can only arrive through the history property. From
+ * the rotation height onwards the entry in force names the second file, the guard compares the
+ * first, and every restart fails. Before the rotation there is one entry and one file and the two
+ * paths are indistinguishable - which is why this survived every test the tree had.
+ *
+ * <p>THE NEGATIVE CONTROLS ARE THE POINT. {@link #withNoHistoryConfiguredTheRefusalIsSTILLTHERE()}
+ * and {@link #aHistoryFileThatCoversNothingDoesNotRescueAnything()} fail if the repair is "stop
+ * refusing" rather than "consult the history". A guard that has stopped refusing is not a repaired
+ * guard, and this pair is what tells the two apart.
+ *
+ * <p>ALSO CLOSED HERE: a finding in its own right. Before this file, {@code
+ * AERE-PQC-REG-MISMATCH-01}, {@code AERE-PQC-REG-MISMATCH-02} and {@code GateState} appeared in NO
+ * test anywhere in the tree (searched over every src/test of every consensus module on
+ * 2026-08-06). The
+ * refusal to start had only ever been exercised by running real nodes.
+ *
+ * <p>KEYS. Every key here is generated in memory by {@link PqV2Fixture} and never written outside a
+ * JUnit temporary directory.
+ */
+public class PqStartupHistoryTest {
+
+ /** Where the chain first requires a registry: epoch one, the genesis manifest. */
+ private static final long H1 = 600L;
+
+ /** The rotation height: from here the chain requires the second registry. */
+ private static final long H2 = 2_000L;
+
+ private static final int N = 7;
+
+ private static final long CHAIN_ID = 220_878L;
+
+ @TempDir private Path tmp;
+
+ private Path epochOne;
+ private Path epochTwo;
+ private String hashOne;
+ private String hashTwo;
+
+ @BeforeEach
+ public void setUp() throws Exception {
+ // Two epochs of the SAME fleet, re-signed at two heights. That is the whole difference between
+ // the files and it is deliberate: it makes them two distinct registries with two distinct
+ // hashes, and it removes every other variable, so a guard that gets this wrong cannot be
+ // excused by a changed key.
+ epochOne = writeRegistry("epoch-one.properties", H1);
+ epochTwo = writeRegistry("epoch-two.properties", H2);
+ hashOne = PqRegistryHash.hashFor(PqRegistryHash.loadAuto(epochOne), CHAIN_ID);
+ hashTwo = PqRegistryHash.hashFor(PqRegistryHash.loadAuto(epochTwo), CHAIN_ID);
+ assertThat(hashOne).isNotEqualTo(hashTwo);
+
+ // A test that asserts what happens WITHOUT an anchor height must establish that itself, not
+ // inherit it from whoever ran before. Measured 2026-08-11: under its previous name this class
+ // sorted before every class that sets aere.pq.anchorBlock, so it passed for the right result and
+ // the wrong reason. The real leak is a remembered configuration, not the property, and it is
+ // fixed where it leaks; this stays as the belt to that pair of braces.
+ System.clearProperty("aere.pq.anchorBlock");
+ System.clearProperty("aere.pq.anchorMinSeals");
+ System.setProperty("aere.falcon.registry", epochOne.toAbsolutePath().toString());
+ resetFalconSingleton();
+ }
+
+ @AfterEach
+ public void tearDown() throws Exception {
+ System.clearProperty("aere.falcon.registry");
+ System.clearProperty(FalconSealSupport.PROPERTY_REGISTRY_HISTORY);
+ resetFalconSingleton();
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 0. The fixture. Without this a green run below could mean the guard was never armed at all.
+ // -------------------------------------------------------------------------------------------
+
+ @Test
+ public void baselineBeforeTheRotationTheOneFileIsENOUGH() throws Exception {
+ final FalconSealSupport pqc = FalconSealSupport.instance();
+ // One entry, one file, head below the rotation: the configuration every node ran before the
+ // rotation existed. This is the state in which the defect is INVISIBLE.
+ assertThat(pqc.verifyRegistryBindingOrAbort(H1 + 10L, CHAIN_ID, schedule(Map.of(H1, hashOne))))
+ .isEqualTo(PqRegistryHash.GateState.MATCH);
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 1. THE MEASUREMENT. The rotated fleet, configured exactly as its runbook says.
+ // -------------------------------------------------------------------------------------------
+
+ @Test
+ public void afterTheRotationTheNODESTARTSBecauseTheGuardREADSTheHistory() throws Exception {
+ System.setProperty(
+ FalconSealSupport.PROPERTY_REGISTRY_HISTORY, epochTwo.toAbsolutePath().toString());
+ final FalconSealSupport pqc = FalconSealSupport.instance();
+
+ final Map<Long, String> both = new LinkedHashMap<>();
+ both.put(H1, hashOne);
+ both.put(H2, hashTwo);
+
+ assertThat(pqc.verifyRegistryBindingOrAbort(H2 + 554L, CHAIN_ID, schedule(both)))
+ .describedAs(
+ "head above the rotation, the required registry held as HISTORY, the primary still the "
+ + "genesis manifest. This is the correct configuration and until 2026-08-06 it "
+ + "refused to start with AERE-PQC-REG-MISMATCH-01, because the guard was handed the "
+ + "primary alone and the history list was read 41 lines later")
+ .isEqualTo(PqRegistryHash.GateState.MATCH);
+ }
+
+ @Test
+ public void andTheWHOLESCHEDULEIsCoveredNotOnlyTheHead() throws Exception {
+ System.setProperty(
+ FalconSealSupport.PROPERTY_REGISTRY_HISTORY, epochTwo.toAbsolutePath().toString());
+ final FalconSealSupport pqc = FalconSealSupport.instance();
+ final Map<Long, String> both = new LinkedHashMap<>();
+ both.put(H1, hashOne);
+ both.put(H2, hashTwo);
+ pqc.verifyRegistryBindingOrAbort(H2 + 554L, CHAIN_ID, schedule(both));
+
+ // Starting is not the whole story: a node that starts and then refuses every header below the
+ // rotation has traded one halt for another. Both intervals must answer.
+ assertThat(pqc.registryBindingSatisfiedAt(H1)).isTrue();
+ assertThat(pqc.registryBindingSatisfiedAt(H2 - 1L)).isTrue();
+ assertThat(pqc.registryBindingSatisfiedAt(H2)).isTrue();
+ assertThat(pqc.registryBindingSatisfiedAt(H2 + 554L)).isTrue();
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 2. NEGATIVE CONTROL. The repair is "read the history", not "stop refusing".
+ // -------------------------------------------------------------------------------------------
+
+ @Test
+ public void withNoHistoryConfiguredTheRefusalIsSTILLTHERE() throws Exception {
+ final FalconSealSupport pqc = FalconSealSupport.instance();
+ final Map<Long, String> both = new LinkedHashMap<>();
+ both.put(H1, hashOne);
+ both.put(H2, hashTwo);
+
+ assertThatThrownBy(() -> pqc.verifyRegistryBindingOrAbort(H2 + 554L, CHAIN_ID, schedule(both)))
+ .describedAs(
+ "a node that genuinely does not hold the rotation registry must still refuse to start. "
+ + "If this passes, the repair removed the guard instead of feeding it")
+ .isInstanceOf(PqRegistryHash.RegistryConfigException.class)
+ .hasMessageContaining("AERE-PQC-REG-MISMATCH-01")
+ .hasMessageContaining("REGISTRY FILES THIS NODE HOLDS: 1")
+ .hasMessageContaining("aere.falcon.registry.history");
+ }
+
+ @Test
+ public void aHistoryFileThatCoversNothingDoesNotRescueAnything() throws Exception {
+ // A third file, signed for a height the schedule never mentions. It is a real, loadable,
+ // proof-bound registry; it simply answers for no interval of this chain.
+ final Path unrelated = writeRegistry("unrelated.properties", H2 + 999L);
+ System.setProperty(
+ FalconSealSupport.PROPERTY_REGISTRY_HISTORY, unrelated.toAbsolutePath().toString());
+ final FalconSealSupport pqc = FalconSealSupport.instance();
+ final Map<Long, String> both = new LinkedHashMap<>();
+ both.put(H1, hashOne);
+ both.put(H2, hashTwo);
+
+ assertThatThrownBy(() -> pqc.verifyRegistryBindingOrAbort(H2 + 554L, CHAIN_ID, schedule(both)))
+ .describedAs(
+ "holding MORE files is not the property that matters; holding the RIGHT one is. A "
+ + "repair that counted files instead of matching hashes would pass the test above "
+ + "and fail here")
+ .isInstanceOf(PqRegistryHash.RegistryConfigException.class)
+ .hasMessageContaining("AERE-PQC-REG-MISMATCH-01")
+ .hasMessageContaining("REGISTRY FILES THIS NODE HOLDS: 2");
+ }
+
+ @Test
+ public void aNodeWithNOREGISTRYATALLIsRefusedAndTheCodeIsTheOtherONE() throws Exception {
+ System.clearProperty("aere.falcon.registry");
+ resetFalconSingleton();
+ final FalconSealSupport pqc = FalconSealSupport.instance();
+
+ assertThatThrownBy(
+ () -> pqc.verifyRegistryBindingOrAbort(H1 + 10L, CHAIN_ID, schedule(Map.of(H1, hashOne))))
+ .describedAs(
+ "no registry at all is a different mistake from the wrong registry, and it has always "
+ + "had its own code. Until this file, neither code was asserted anywhere")
+ .isInstanceOf(PqRegistryHash.RegistryConfigException.class)
+ .hasMessageContaining("AERE-PQC-REG-MISMATCH-02")
+ .hasMessageContaining("NO REGISTRY AT ALL");
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 3. The states that are NOT refusals. A guard that only ever refuses is a wedge in a costume.
+ // -------------------------------------------------------------------------------------------
+
+ @Test
+ public void belowTheFirstScheduledHeightNothingIsEnforcedAndTheNodeIsTOLDItIsStaged()
+ throws Exception {
+ System.setProperty(
+ FalconSealSupport.PROPERTY_REGISTRY_HISTORY, epochTwo.toAbsolutePath().toString());
+ final FalconSealSupport pqc = FalconSealSupport.instance();
+ assertThat(pqc.verifyRegistryBindingOrAbort(H1 - 100L, CHAIN_ID, schedule(Map.of(H1, hashOne))))
+ .isEqualTo(PqRegistryHash.GateState.NOT_ENFORCED_BELOW_FIRST_HEIGHT);
+ }
+
+ @Test
+ public void withNoScheduleAtAllNOTHINGIsEnforcedWhichIsChain2800Today() throws Exception {
+ final FalconSealSupport pqc = FalconSealSupport.instance();
+ assertThat(
+ pqc.verifyRegistryBindingOrAbort(
+ H2 + 554L, CHAIN_ID, PqRegistryHash.emptySchedule()))
+ .describedAs(
+ "config.pqRegistryHash is in no genesis this fleet runs. This is the branch every "
+ + "live node takes, and it must stay first and unconditional")
+ .isEqualTo(PqRegistryHash.GateState.NOT_ENFORCED_NO_SCHEDULE);
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // Helpers.
+ // -------------------------------------------------------------------------------------------
+
+ private Path writeRegistry(final String name, final long bindHeight) throws IOException {
+ final StringBuilder b = new StringBuilder();
+ b.append("formatVersion=").append(PqRegistryBinding.FORMAT_VERSION).append('\n');
+ b.append("chainId=").append(CHAIN_ID).append('\n');
+ b.append("bindHeight=").append(bindHeight).append('\n');
+ b.append("count=").append(N).append('\n');
+ for (int i = 0; i < N; i++) {
+ b.append(i).append('=').append(hex(PqV2Fixture.publicKey(i))).append('\n');
+ b.append(i)
+ .append(".addr=")
+ .append(PqV2Fixture.address(i).toHexString())
+ .append('\n');
+ b.append(i).append(".pop=").append(PqV2Fixture.popHex(CHAIN_ID, bindHeight, N, i)).append('\n');
+ b.append(i)
+ .append(".claim=")
+ .append(PqV2Fixture.claimHex(CHAIN_ID, bindHeight, N, i))
+ .append('\n');
+ }
+ final Path f = tmp.resolve(name);
+ Files.writeString(f, b.toString(), StandardCharsets.UTF_8);
+ return f;
+ }
+
+ /**
+ * Build a schedule the way a node really gets one: written into a genesis file and parsed back.
+ * Constructing the object directly would skip the parser, where the strictly-increasing rule is.
+ */
+ private PqRegistryHash.Schedule schedule(final Map<Long, String> entries) throws IOException {
+ final List<Long> heights = new ArrayList<>(entries.keySet());
+ heights.sort(Long::compare);
+ final StringBuilder sb = new StringBuilder("{\"config\":{\"pqRegistryHash\":[");
+ for (int i = 0; i < heights.size(); i++) {
+ if (i > 0) {
+ sb.append(',');
+ }
+ sb.append("{\"block\":")
+ .append(heights.get(i))
+ .append(",\"hash\":\"0x")
+ .append(entries.get(heights.get(i)))
+ .append("\"}");
+ }
+ sb.append("]}}");
+ final Path p = tmp.resolve("genesis-sched-" + heights.size() + "-" + heights.get(0) + ".json");
+ Files.writeString(p, sb.toString(), StandardCharsets.UTF_8);
+ return PqRegistryHash.loadScheduleFromGenesis(p);
+ }
+
+ private static String hex(final byte[] b) {
+ final StringBuilder s = new StringBuilder(b.length * 2);
+ for (final byte x : b) {
+ s.append(Character.forDigit((x >> 4) & 0xf, 16)).append(Character.forDigit(x & 0xf, 16));
+ }
+ return s.toString();
+ }
+
+ private static void resetFalconSingleton() throws Exception {
+ final Field f = FalconSealSupport.class.getDeclaredField("instance");
+ f.setAccessible(true);
+ f.set(null, null);
+ }
+}
diff --git a/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqV2Fixture.java b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqV2Fixture.java
new file mode 100755
index 000000000..2c7108f4d
--- /dev/null
+++ b/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqV2Fixture.java
@@ -0,0 +1,233 @@
+/*
+ * 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 org.hyperledger.besu.crypto.KeyPair;
+import org.hyperledger.besu.crypto.SECPSignature;
+import org.hyperledger.besu.crypto.SecureRandomProvider;
+import org.hyperledger.besu.crypto.SignatureAlgorithm;
+import org.hyperledger.besu.crypto.SignatureAlgorithmFactory;
+import org.hyperledger.besu.datatypes.Address;
+import org.hyperledger.besu.ethereum.core.Util;
+
+import java.security.SecureRandom;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.apache.tuweni.bytes.Bytes;
+import org.apache.tuweni.bytes.Bytes32;
+import org.bouncycastle.crypto.AsymmetricCipherKeyPair;
+import org.bouncycastle.pqc.crypto.falcon.FalconKeyGenerationParameters;
+import org.bouncycastle.pqc.crypto.falcon.FalconKeyPairGenerator;
+import org.bouncycastle.pqc.crypto.falcon.FalconParameters;
+import org.bouncycastle.pqc.crypto.falcon.FalconPrivateKeyParameters;
+import org.bouncycastle.pqc.crypto.falcon.FalconPublicKeyParameters;
+import org.bouncycastle.pqc.crypto.falcon.FalconSigner;
+
+/**
+ * AERE REGISTRY BINDING (2026-08-06). The shared probe fleet every arming fixture is now built
+ * from, and the reason it had to exist.
+ *
+ * <p>WHAT IT REPLACED, and why the replacement is not cosmetic. Until 2026-08-06 seven separate
+ * fixtures built their registries around addresses spelled {@code String.format("0x%040x", 0xA00 +
+ * i)}. Those addresses are arithmetic, not keys: no secp256k1 private key produces them, so no
+ * validator can ever sign a binding claim for one. The moment {@code AERE-PQC-REG-ARM-02} was wired
+ * into {@code FalconSealSupport}, all seven fixtures described a fleet that CANNOT EXIST - armed,
+ * and provably unable to produce the registry the arming path now requires. Measured on 2026-08-06:
+ * 35 tests across 7 classes, every failure carrying AERE-PQC-REG-ARM-02.
+ *
+ * <p>So each row here starts from a REAL secp256k1 key pair and the address is DERIVED from it,
+ * exactly as a validator's address is derived on the live fleet. That single change is what makes a
+ * v2 row signable at all.
+ *
+ * <p>WHY A SHARED POOL AND NOT A HELPER PER TEST. Falcon-512 key generation dominates the runtime of
+ * these fixtures, and every one of them wants the same thing: n validators, each with a Falcon pair,
+ * an ECDSA pair, and proofs over whatever {@code (chainId, bindHeight, count)} that test declares.
+ * Generating the pool once per JVM turns seven independent keygen loops into one. It also makes the
+ * fixtures COMPARABLE: two tests asking for index 3 get the same validator, so a registry written by
+ * one and read by another is the same fleet.
+ *
+ * <p>WHAT IS DELIBERATELY NOT SHARED: the proofs. {@link #popHex} and {@link #claimHex} take
+ * {@code chainId}, {@code bindHeight} and {@code count} because all three are inside the signed
+ * pre-image. A cached proof would be a proof for another registry, and the loader would - correctly
+ * - refuse it. Caching the KEYS is safe; caching the SIGNATURES would defeat the test.
+ *
+ * <p>KEYS. Every key here is generated in memory at test time and never written outside a JUnit
+ * temporary directory. Nothing in this file reads or produces fleet key material.
+ */
+public final class PqV2Fixture {
+
+ /** Size of the probe pool. Larger than any fixture needs, so one pool serves all of them. */
+ private static final int POOL = 16;
+
+ private static List<Holder> pool;
+ private static SignatureAlgorithm ecdsa;
+
+ private PqV2Fixture() {}
+
+ /** One validator's material: a Falcon pair plus the ECDSA consensus key that must vouch for it. */
+ private record Holder(
+ byte[] falconPublicKey, FalconPrivateKeyParameters falconPrivate, KeyPair ecdsa) {}
+
+ private static synchronized List<Holder> pool() {
+ if (pool == null) {
+ ecdsa = SignatureAlgorithmFactory.getInstance();
+ final SecureRandom rnd = SecureRandomProvider.publicSecureRandom();
+ final FalconKeyPairGenerator gen = new FalconKeyPairGenerator();
+ gen.init(new FalconKeyGenerationParameters(rnd, FalconParameters.falcon_512));
+ final List<Holder> built = new ArrayList<>(POOL);
+ for (int i = 0; i < POOL; i++) {
+ final AsymmetricCipherKeyPair kp = gen.generateKeyPair();
+ built.add(
+ new Holder(
+ ((FalconPublicKeyParameters) kp.getPublic()).getH(),
+ (FalconPrivateKeyParameters) kp.getPrivate(),
+ ecdsa.generateKeyPair()));
+ }
+ pool = built;
+ }
+ return pool;
+ }
+
+ /**
+ * The validator address of row {@code i}, DERIVED from that row's secp256k1 public key.
+ *
+ * @param i the row index
+ * @return the derived validator address
+ */
+ public static Address address(final int i) {
+ return Util.publicKeyToAddress(pool().get(i).ecdsa().getPublicKey());
+ }
+
+ /**
+ * The raw Falcon-512 public key of row {@code i}.
+ *
+ * @param i the row index
+ * @return the raw public key bytes
+ */
+ public static byte[] publicKey(final int i) {
+ return pool().get(i).falconPublicKey();
+ }
+
+ /**
+ * The Falcon private key of row {@code i}, for fixtures that must also SIGN as that validator.
+ *
+ * @param i the row index
+ * @return the Falcon private key parameters
+ */
+ public static FalconPrivateKeyParameters privateKey(final int i) {
+ return pool().get(i).falconPrivate();
+ }
+
+ /**
+ * The Falcon possession proof of row {@code i}, over the pre-image this registry declares.
+ *
+ * @param chainId the chain id the registry declares
+ * @param bindHeight the activation height the registry declares
+ * @param count the row count the registry declares
+ * @param i the row index
+ * @return the proof, 0x-prefixed hex
+ */
+ public static String popHex(
+ final long chainId, final long bindHeight, final int count, final int i) {
+ final Bytes32 digest =
+ PqRegistryBinding.possessionDigest(
+ chainId, bindHeight, count, i, address(i).getBytes().toArray(), publicKey(i));
+ final FalconSigner signer = new FalconSigner();
+ signer.init(true, privateKey(i));
+ return Bytes.wrap(signer.generateSignature(digest.toArray())).toHexString();
+ }
+
+ /**
+ * The ECDSA claim of row {@code i}, signed by that row's OWN consensus key.
+ *
+ * @param chainId the chain id the registry declares
+ * @param bindHeight the activation height the registry declares
+ * @param count the row count the registry declares
+ * @param i the row index
+ * @return the claim signature, 0x-prefixed hex
+ */
+ public static String claimHex(
+ final long chainId, final long bindHeight, final int count, final int i) {
+ final Bytes32 digest =
+ PqRegistryBinding.claimDigest(
+ chainId, bindHeight, count, i, address(i).getBytes().toArray(), publicKey(i));
+ final List<Holder> holders = pool(); // also the point at which 'ecdsa' is initialised
+ final SECPSignature s = ecdsa.sign(digest, holders.get(i).ecdsa());
+ return Bytes.wrap(s.encodedBytes().toArrayUnsafe()).toHexString();
+ }
+
+ /**
+ * The manifest-level header a v2 registry must carry, as JSON object fields with a trailing comma
+ * omitted: {@code "count":n,"chainId":c,"bindHeight":h,"formatVersion":2}.
+ *
+ * @param count the row count
+ * @param chainId the chain id
+ * @param bindHeight the activation height
+ * @return the header fields, ready to sit at the head of a manifest object
+ */
+ public static String manifestHeader(final int count, final long chainId, final long bindHeight) {
+ return "\"count\":"
+ + count
+ + ",\"chainId\":"
+ + chainId
+ + ",\"bindHeight\":"
+ + bindHeight
+ + ",\"formatVersion\":"
+ + PqRegistryBinding.FORMAT_VERSION;
+ }
+
+ /**
+ * One v2 manifest row, as a JSON object field: {@code "i":{"addr":..,"pk":..,"pop":..,"claim":..}}.
+ *
+ * @param i the row index
+ * @param count the row count the registry declares
+ * @param chainId the chain id the registry declares
+ * @param bindHeight the activation height the registry declares
+ * @return the row, ready to append after a comma
+ */
+ public static String manifestEntry(
+ final int i, final int count, final long chainId, final long bindHeight) {
+ return "\""
+ + i
+ + "\":{\"addr\":\""
+ + address(i).toHexString()
+ + "\",\"pk\":\""
+ + Bytes.wrap(publicKey(i)).toHexString()
+ + "\",\"pop\":\""
+ + popHex(chainId, bindHeight, count, i)
+ + "\",\"claim\":\""
+ + claimHex(chainId, bindHeight, count, i)
+ + "\"}";
+ }
+
+ /**
+ * The bytes the genesis anchor slot commits to for row {@code i}: {@code address || publicKey},
+ * which is what {@code hashV0Legacy} accumulates. Unchanged by the v2 binding work - the proofs
+ * are outside the legacy pre-image - and kept here so a fixture cannot drift from the row it just
+ * wrote.
+ *
+ * @param i the row index
+ * @return the anchored pre-image bytes for that row
+ */
+ public static byte[] anchorPreimageRow(final int i) {
+ final byte[] a = address(i).getBytes().toArray();
+ final byte[] k = publicKey(i);
+ final byte[] out = new byte[a.length + k.length];
+ System.arraycopy(a, 0, out, 0, a.length);
+ System.arraycopy(k, 0, out, a.length, k.length);
+ return out;
+ }
+}
diff --git a/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/messagewrappers/Commit.java b/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/messagewrappers/Commit.java
index 142069ec7..b3ba74635 100644
--- a/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/messagewrappers/Commit.java
+++ b/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/messagewrappers/Commit.java
@@ -11,9 +11,16 @@
* specific language governing permissions and limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
+ *
+ * Modifications Copyright contributors to the Aere Network.
+ *
+ * This file was modified by contributors to the Aere Network, as required by section 4(b) of the
+ * Apache License 2.0. The copyright header above is the upstream one and is left exactly as it was
+ * found, as section 4(c) requires. The change itself is in patches/0003-aere-pq-anchor.patch.
*/
package org.hyperledger.besu.consensus.qbft.core.messagewrappers;
+import org.hyperledger.besu.consensus.common.bft.FalconSeal;
import org.hyperledger.besu.consensus.common.bft.messagewrappers.BftMessage;
import org.hyperledger.besu.consensus.common.bft.payload.SignedData;
import org.hyperledger.besu.consensus.qbft.core.payload.CommitPayload;
@@ -22,6 +29,8 @@ import org.hyperledger.besu.datatypes.Hash;
import org.hyperledger.besu.ethereum.rlp.RLP;
import org.hyperledger.besu.ethereum.rlp.RLPInput;
+import java.util.Optional;
+
import org.apache.tuweni.bytes.Bytes;
/** The Commit payload message. */
@@ -45,6 +54,15 @@ public class Commit extends BftMessage<CommitPayload> {
return getPayload().getCommitSeal();
}
+ /**
+ * Gets the optional parallel post-quantum Falcon seal carried on this commit.
+ *
+ * @return the Falcon seal if present, otherwise empty
+ */
+ public Optional<FalconSeal> getFalconSeal() {
+ return getPayload().getFalconSeal();
+ }
+
/**
* Gets digest.
*
diff --git a/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/network/QbftMessageTransmitter.java b/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/network/QbftMessageTransmitter.java
index 30156ed6d..b0ba9ea09 100644
--- a/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/network/QbftMessageTransmitter.java
+++ b/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/network/QbftMessageTransmitter.java
@@ -11,10 +11,17 @@
* specific language governing permissions and limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
+ *
+ * Modifications Copyright contributors to the Aere Network.
+ *
+ * This file was modified by contributors to the Aere Network, as required by section 4(b) of the
+ * Apache License 2.0. The copyright header above is the upstream one and is left exactly as it was
+ * found, as section 4(c) requires. The change itself is in patches/0003-aere-pq-anchor.patch.
*/
package org.hyperledger.besu.consensus.qbft.core.network;
import org.hyperledger.besu.consensus.common.bft.ConsensusRoundIdentifier;
+import org.hyperledger.besu.consensus.common.bft.FalconSeal;
import org.hyperledger.besu.consensus.common.bft.network.ValidatorMulticaster;
import org.hyperledger.besu.consensus.common.bft.payload.SignedData;
import org.hyperledger.besu.consensus.qbft.core.messagedata.CommitMessageData;
@@ -118,8 +125,24 @@ public class QbftMessageTransmitter {
final ConsensusRoundIdentifier roundIdentifier,
final Hash digest,
final SECPSignature commitSeal) {
+ multicastCommit(roundIdentifier, digest, commitSeal, Optional.empty());
+ }
+
+ /**
+ * Multicast commit carrying an optional parallel Falcon-512 seal (AERE hybrid PQC).
+ *
+ * @param roundIdentifier the round identifier
+ * @param digest the digest
+ * @param commitSeal the ECDSA commit seal
+ * @param falconSeal the optional parallel post-quantum Falcon seal
+ */
+ public void multicastCommit(
+ final ConsensusRoundIdentifier roundIdentifier,
+ final Hash digest,
+ final SECPSignature commitSeal,
+ final Optional<FalconSeal> falconSeal) {
try {
- final Commit data = messageFactory.createCommit(roundIdentifier, digest, commitSeal);
+ final Commit data = messageFactory.createCommit(roundIdentifier, digest, commitSeal, falconSeal);
final CommitMessageData message = CommitMessageData.create(data);
diff --git a/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/CommitPayload.java b/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/CommitPayload.java
index db481acd0..e55efb55c 100644
--- a/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/CommitPayload.java
+++ b/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/CommitPayload.java
@@ -11,30 +11,54 @@
* specific language governing permissions and limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
+ *
+ * Modifications Copyright contributors to the Aere Network.
+ *
+ * This file was modified by contributors to the Aere Network, as required by section 4(b) of the
+ * Apache License 2.0. The copyright header above is the upstream one and is left exactly as it was
+ * found, as section 4(c) requires. The change itself is in patches/0003-aere-pq-anchor.patch.
*/
package org.hyperledger.besu.consensus.qbft.core.payload;
import org.hyperledger.besu.consensus.common.bft.ConsensusRoundIdentifier;
+import org.hyperledger.besu.consensus.common.bft.FalconSeal;
import org.hyperledger.besu.consensus.common.bft.payload.Payload;
import org.hyperledger.besu.consensus.qbft.core.messagedata.QbftV1;
import org.hyperledger.besu.crypto.SECPSignature;
import org.hyperledger.besu.crypto.SignatureAlgorithmFactory;
import org.hyperledger.besu.datatypes.Hash;
+import org.hyperledger.besu.ethereum.rlp.RLPException;
import org.hyperledger.besu.ethereum.rlp.RLPInput;
import org.hyperledger.besu.ethereum.rlp.RLPOutput;
import java.util.Objects;
+import java.util.Optional;
import java.util.StringJoiner;
-/** The Commit payload. */
+import org.apache.tuweni.bytes.Bytes;
+
+/**
+ * The Commit payload.
+ *
+ * <p>AERE hybrid PQC extension: a Commit payload MAY carry an OPTIONAL parallel Falcon-512 seal, so
+ * that every validator's post-quantum seal over the commit hash is GOSSIPED alongside the decisive
+ * ECDSA commit seal on the same message. The Falcon seal is appended only when present, so a
+ * Falcon-less Commit encodes byte-for-byte identically to upstream Besu. When present, the whole
+ * payload (including the Falcon seal) is covered by the author's ECDSA signature, binding the
+ * claimed Falcon validator index to the sender's identity.
+ *
+ * <p>AERE FIX-MALEABILITATE: this payload has exactly ONE valid encoding for a given value. See
+ * {@link #readFrom(RLPInput)}.
+ */
public class CommitPayload extends QbftPayload {
private static final int TYPE = QbftV1.COMMIT;
private final ConsensusRoundIdentifier roundIdentifier;
private final Hash digest;
private final SECPSignature commitSeal;
+ private final Optional<FalconSeal> falconSeal;
/**
- * Instantiates a new Commit payload.
+ * Instantiates a new Commit payload (no Falcon seal).
*
* @param roundIdentifier the round identifier
* @param digest the digest
@@ -44,26 +68,107 @@ public class CommitPayload extends QbftPayload {
final ConsensusRoundIdentifier roundIdentifier,
final Hash digest,
final SECPSignature commitSeal) {
+ this(roundIdentifier, digest, commitSeal, Optional.empty());
+ }
+
+ /**
+ * Instantiates a new Commit payload with an optional parallel Falcon-512 seal.
+ *
+ * @param roundIdentifier the round identifier
+ * @param digest the digest
+ * @param commitSeal the ECDSA commit seal (decisive)
+ * @param falconSeal the optional parallel post-quantum Falcon seal over the same commit hash
+ */
+ public CommitPayload(
+ final ConsensusRoundIdentifier roundIdentifier,
+ final Hash digest,
+ final SECPSignature commitSeal,
+ final Optional<FalconSeal> falconSeal) {
this.roundIdentifier = roundIdentifier;
this.digest = digest;
this.commitSeal = commitSeal;
+ this.falconSeal = falconSeal == null ? Optional.empty() : falconSeal;
}
/**
* Read from rlp input and return commit payload.
*
+ * <p>AERE FIX-MALEABILITATE: this decoder is STRICTLY CANONICAL. The payload is taken as an
+ * isolated RLP item, decoded, re-encoded, and the re-encoding must reproduce the received bytes
+ * exactly. Anything else is rejected.
+ *
+ * <p>Why canonical and not forward-tolerant. A Commit is an AUTHENTICATED consensus message:
+ * {@code SignedData.create} recovers the author from {@link QbftPayload#hashForSignature()},
+ * which is computed from the RE-ENCODED payload, not from the bytes that arrived. Any input the
+ * decoder silently ignores therefore does not change the recovered author, so one genuine Commit
+ * can be rewritten into several distinct byte strings that all still authenticate to the original
+ * validator. QBFT itself is not fooled, because it deduplicates Commits by AUTHOR, so no vote can
+ * be forged, stolen or double counted. The gossip layer is, because it deduplicates by message
+ * CONTENT. Tolerating ignorable input is therefore purely a message amplification channel into
+ * consensus, and it buys nothing in return, for the reason in the next paragraph.
+ *
+ * <p>What forward tolerance does NOT buy, stated correctly. An earlier version of this method
+ * ended both lists with {@code leaveListLenient()} and claimed that this generation of nodes
+ * "can never be made to discard a Commit by a newer peer". That claim was FALSE. Consider a
+ * future binary that appends a MEANINGFUL element to this payload: it signs over ITS OWN
+ * encoding, which includes that element. This binary would skip the element, re-encode WITHOUT
+ * it, obtain a different {@code hashForSignature}, ecrecover a different address, and drop the
+ * Commit anyway, as coming from a non-validator. Leniency only moves the discard from RLP
+ * decoding to author validation; it does not prevent it. The only thing that actually lets an old
+ * binary keep accepting a new peer's Commits is for the new peer NOT to change the signed bytes,
+ * which is exactly what the attachment gate in {@code FalconSealSupport} enforces.
+ *
+ * <p>Consequence for future format changes: adding any element to this payload is a CONSENSUS
+ * BREAKING change and must be rolled out behind a height gate, never by relying on old nodes to
+ * ignore it.
+ *
* @param rlpInput the rlp input
* @return the commit payload
+ * @throws RLPException if the received bytes are not the unique canonical encoding of the payload
*/
public static CommitPayload readFrom(final RLPInput rlpInput) {
- rlpInput.enterList();
- final ConsensusRoundIdentifier roundIdentifier = readConsensusRound(rlpInput);
- final Hash digest = Payload.readDigest(rlpInput);
+ // Take the payload as a self-contained RLP item so the EXACT bytes received for it are
+ // available for the canonicality check below. This also advances the enclosing input past the
+ // payload, exactly as the previous enterList/leaveList pair did.
+ final RLPInput payloadRlp = rlpInput.readAsRlp();
+ final Bytes received = payloadRlp.raw();
+
+ payloadRlp.enterList();
+ final ConsensusRoundIdentifier roundIdentifier = readConsensusRound(payloadRlp);
+ final Hash digest = Payload.readDigest(payloadRlp);
final SECPSignature commitSeal =
- rlpInput.readBytes(SignatureAlgorithmFactory.getInstance()::decodeSignature);
- rlpInput.leaveList();
+ payloadRlp.readBytes(SignatureAlgorithmFactory.getInstance()::decodeSignature);
+
+ // AERE hybrid PQC: an OPTIONAL parallel Falcon-512 seal [index, sig] may follow the ECDSA seal.
+ // A Falcon-less commit ends the list here and decodes to Optional.empty().
+ Optional<FalconSeal> falconSeal = Optional.empty();
+ if (!payloadRlp.isEndOfCurrentList()) {
+ payloadRlp.enterList();
+ final int idx = payloadRlp.readIntScalar();
+ final Bytes sig = payloadRlp.readBytes();
+ payloadRlp.leaveList();
+ falconSeal = Optional.of(new FalconSeal(idx, sig));
+ }
+ payloadRlp.leaveList();
+
+ final CommitPayload payload = new CommitPayload(roundIdentifier, digest, commitSeal, falconSeal);
- return new CommitPayload(roundIdentifier, digest, commitSeal);
+ // AERE FIX-MALEABILITATE: exactly one encoding is accepted for a given payload value. This
+ // catches everything the RLP reader itself would tolerate, including any element the decode
+ // path above does not consume, and any encoding difference the reader does not consider an
+ // error. Cheap: one re-encode of a message that is already about to be keccak hashed for
+ // signature recovery.
+ final Bytes reencoded = payload.encoded();
+ if (!reencoded.equals(received)) {
+ throw new RLPException(
+ "Non-canonical Commit payload encoding: received "
+ + received.size()
+ + " bytes, canonical form is "
+ + reencoded.size()
+ + " bytes");
+ }
+
+ return payload;
}
@Override
@@ -72,6 +177,17 @@ public class CommitPayload extends QbftPayload {
writeConsensusRound(rlpOutput);
rlpOutput.writeBytes(digest.getBytes());
rlpOutput.writeBytes(commitSeal.encodedBytes());
+ // AERE hybrid PQC: append the parallel Falcon seal only when present, so a Falcon-less commit is
+ // byte-identical to upstream Besu. The author's ECDSA signature covers this element.
+ // This method defines THE canonical encoding: readFrom rejects anything that does not
+ // reproduce byte for byte what this method writes.
+ if (falconSeal.isPresent()) {
+ final FalconSeal fs = falconSeal.get();
+ rlpOutput.startList();
+ rlpOutput.writeIntScalar(fs.getValidatorIndex());
+ rlpOutput.writeBytes(fs.getSignature());
+ rlpOutput.endList();
+ }
rlpOutput.endList();
}
@@ -98,6 +214,15 @@ public class CommitPayload extends QbftPayload {
return commitSeal;
}
+ /**
+ * Gets the optional parallel post-quantum Falcon seal.
+ *
+ * @return the Falcon seal if this commit carried one, otherwise empty
+ */
+ public Optional<FalconSeal> getFalconSeal() {
+ return falconSeal;
+ }
+
@Override
public ConsensusRoundIdentifier getRoundIdentifier() {
return roundIdentifier;
@@ -114,12 +239,13 @@ public class CommitPayload extends QbftPayload {
final CommitPayload that = (CommitPayload) o;
return Objects.equals(roundIdentifier, that.roundIdentifier)
&& Objects.equals(digest, that.digest)
- && Objects.equals(commitSeal, that.commitSeal);
+ && Objects.equals(commitSeal, that.commitSeal)
+ && Objects.equals(falconSeal, that.falconSeal);
}
@Override
public int hashCode() {
- return Objects.hash(roundIdentifier, digest, commitSeal);
+ return Objects.hash(roundIdentifier, digest, commitSeal, falconSeal);
}
@Override
@@ -128,6 +254,7 @@ public class CommitPayload extends QbftPayload {
.add("roundIdentifier=" + roundIdentifier)
.add("digest=" + digest)
.add("commitSeal=" + commitSeal)
+ .add("falconSeal=" + falconSeal)
.toString();
}
}
diff --git a/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/MessageFactory.java b/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/MessageFactory.java
index 1bcd73ae9..7d0e37134 100644
--- a/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/MessageFactory.java
+++ b/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/MessageFactory.java
@@ -11,10 +11,17 @@
* specific language governing permissions and limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
+ *
+ * Modifications Copyright contributors to the Aere Network.
+ *
+ * This file was modified by contributors to the Aere Network, as required by section 4(b) of the
+ * Apache License 2.0. The copyright header above is the upstream one and is left exactly as it was
+ * found, as section 4(c) requires. The change itself is in patches/0003-aere-pq-anchor.patch.
*/
package org.hyperledger.besu.consensus.qbft.core.payload;
import org.hyperledger.besu.consensus.common.bft.ConsensusRoundIdentifier;
+import org.hyperledger.besu.consensus.common.bft.FalconSeal;
import org.hyperledger.besu.consensus.common.bft.payload.Payload;
import org.hyperledger.besu.consensus.common.bft.payload.SignedData;
import org.hyperledger.besu.consensus.qbft.core.messagewrappers.Commit;
@@ -116,7 +123,26 @@ public class MessageFactory {
final ConsensusRoundIdentifier roundIdentifier,
final Hash digest,
final SECPSignature commitSeal) {
- final CommitPayload payload = new CommitPayload(roundIdentifier, digest, commitSeal);
+ return createCommit(roundIdentifier, digest, commitSeal, Optional.empty());
+ }
+
+ /**
+ * Create commit payload carrying an optional parallel Falcon-512 seal. The whole payload
+ * (including the Falcon seal) is signed by this node's ECDSA key.
+ *
+ * @param roundIdentifier the round identifier
+ * @param digest the digest
+ * @param commitSeal the ECDSA commit seal
+ * @param falconSeal the optional parallel post-quantum Falcon seal over the same commit hash
+ * @return the commit
+ */
+ public Commit createCommit(
+ final ConsensusRoundIdentifier roundIdentifier,
+ final Hash digest,
+ final SECPSignature commitSeal,
+ final Optional<FalconSeal> falconSeal) {
+ final CommitPayload payload =
+ new CommitPayload(roundIdentifier, digest, commitSeal, falconSeal);
return new Commit(createSignedMessage(payload));
}
diff --git a/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/QbftBlockHeightManager.java b/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/QbftBlockHeightManager.java
index 49099c2c1..f8cd07112 100644
--- a/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/QbftBlockHeightManager.java
+++ b/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/QbftBlockHeightManager.java
@@ -11,12 +11,19 @@
* specific language governing permissions and limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
+ *
+ * Modifications Copyright contributors to the Aere Network.
+ *
+ * This file was modified by contributors to the Aere Network, as required by section 4(b) of the
+ * Apache License 2.0. The copyright header above is the upstream one and is left exactly as it was
+ * found, as section 4(c) requires. The change itself is in patches/0003-aere-pq-anchor.patch.
*/
package org.hyperledger.besu.consensus.qbft.core.statemachine;
import org.hyperledger.besu.consensus.common.bft.ConsensusRoundIdentifier;
import org.hyperledger.besu.consensus.common.bft.events.RoundExpiry;
import org.hyperledger.besu.consensus.common.bft.messagewrappers.BftMessage;
+import org.hyperledger.besu.consensus.common.bft.PqAnchorNotReadyException;
import org.hyperledger.besu.consensus.common.bft.payload.Payload;
import org.hyperledger.besu.consensus.qbft.core.messagewrappers.Commit;
import org.hyperledger.besu.consensus.qbft.core.messagewrappers.Prepare;
@@ -200,8 +207,26 @@ public class QbftBlockHeightManager implements BaseQbftBlockHeightManager {
}
final long headerTimeStampSeconds = Math.round(clock.millis() / 1000D);
- final QbftBlockCreator.BlockCreationResult blockCreationResult =
- qbftRound.createBlock(headerTimeStampSeconds);
+ final QbftBlockCreator.BlockCreationResult blockCreationResult;
+ try {
+ blockCreationResult = qbftRound.createBlock(headerTimeStampSeconds);
+ } catch (final RuntimeException thrown) {
+ // MEASURED 2026-08-02: see PqAnchorNotReadyException.findIn. The refusal arrives wrapped in
+ // IllegalStateException from AbstractBlockCreator, so the narrow catch never fired. Anything
+ // that is not a refusal is rethrown untouched.
+ final PqAnchorNotReadyException e = PqAnchorNotReadyException.findIn(thrown);
+ if (e == null) {
+ throw thrown;
+ }
+ // AERE ANCHOR V2: this node is the proposer but cannot back the block with a Falcon quorum
+ // certificate that reaches the staged threshold K, so it does NOT propose. The round times
+ // out and the next proposer takes over. Proposing anyway would emit a header this node has
+ // already computed to be invalid under its own rules, and in QBFT it would cost exactly the
+ // same round timer, so it would buy nothing. The usual cause is a node that has just
+ // restarted and holds no seals until it takes part in one commit: at most one lost turn.
+ LOG.warn("AERE PQ-ANCHOR: not proposing. round={}. {}", roundIdentifier, e.getMessage());
+ return;
+ }
final QbftBlock block = blockCreationResult.block();
final Optional<BlockAccessList> blockAccessList = blockCreationResult.blockAccessList();
if (!block.isEmpty()) {
diff --git a/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/QbftRound.java b/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/QbftRound.java
index 832149e2f..054b9c36c 100644
--- a/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/QbftRound.java
+++ b/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/QbftRound.java
@@ -11,13 +11,25 @@
* specific language governing permissions and limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
+ *
+ * Modifications Copyright contributors to the Aere Network.
+ *
+ * This file was modified by contributors to the Aere Network, as required by section 4(b) of the
+ * Apache License 2.0. The copyright header above is the upstream one and is left exactly as it was
+ * found, as section 4(c) requires. The change itself is in patches/0003-aere-pq-anchor.patch.
*/
package org.hyperledger.besu.consensus.qbft.core.statemachine;
import static java.util.Collections.emptyList;
import org.hyperledger.besu.consensus.common.bft.ConsensusRoundIdentifier;
+import org.hyperledger.besu.consensus.common.bft.FalconSeal;
+import org.hyperledger.besu.consensus.common.bft.FalconSealSupport;
+import org.hyperledger.besu.consensus.common.bft.PqAnchor;
+import org.hyperledger.besu.consensus.common.bft.PqAnchorNotReadyException;
+import org.hyperledger.besu.consensus.common.bft.PqSealCache;
import org.hyperledger.besu.consensus.common.bft.RoundTimer;
+import org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer;
import org.hyperledger.besu.consensus.common.bft.payload.SignedData;
import org.hyperledger.besu.consensus.qbft.core.messagewrappers.Commit;
import org.hyperledger.besu.consensus.qbft.core.messagewrappers.Prepare;
@@ -42,6 +54,7 @@ import org.hyperledger.besu.ethereum.mainnet.block.access.list.BlockAccessList;
import org.hyperledger.besu.plugin.services.securitymodule.SecurityModuleException;
import org.hyperledger.besu.util.Subscribers;
+import java.util.Collection;
import java.util.List;
import java.util.Optional;
@@ -75,6 +88,22 @@ public class QbftRound {
private final QbftBlockHeader parentHeader;
+ // AERE hybrid PQC: guards against re-importing once a block has been successfully imported this
+ // round, while still allowing a retry if an earlier import attempt was rejected (e.g. a post-fork
+ // Falcon quorum certificate that was not yet complete when the first attempt ran).
+ private boolean blockImported = false;
+
+ // AERE ANCHOR V2: the ROUND-INDEPENDENT on-chain hash of the block this round is deciding.
+ //
+ // Why it is not simply block.getHash(): a proposal block is built with the COMMITTED-SEAL header
+ // functions, so its hash includes the round number and differs between rounds for one and the
+ // same block. The certificate carried by the NEXT block is looked up, and its message M is built,
+ // from the parent's on-chain hash, which excludes both the commit seals and the round. Keying the
+ // seal cache on anything round dependent would silently produce empty certificates at every round
+ // change. Replacing the round with 0 in the committed-seal encoding yields byte-for-byte the
+ // on-chain pre-image, so this is the same hash the parent header will carry once imported.
+ private Hash pqOnchainHash;
+
/**
* Instantiates a new Qbft round.
*
@@ -159,8 +188,26 @@ public class QbftRound {
final Optional<BlockAccessList> blockAccessList;
if (bestPreparedCertificate.isEmpty()) {
LOG.debug("Sending proposal with new block. round={}", roundState.getRoundIdentifier());
- final BlockCreationResult blockCreationResult =
- blockCreator.createBlock(headerTimestamp, this.parentHeader);
+ final BlockCreationResult blockCreationResult;
+ try {
+ blockCreationResult = blockCreator.createBlock(headerTimestamp, this.parentHeader);
+ } catch (final RuntimeException thrown) {
+ // MEASURED 2026-08-02: the refusal reaches here WRAPPED. AbstractBlockCreator.createBlock
+ // catches every exception from the extra-data calculator and rethrows it as
+ // IllegalStateException, so catching PqAnchorNotReadyException directly never fired and the
+ // escape aborted handleRoundChangePayload, skipping the multicastRoundChange that
+ // doRoundChange issues after it. Anything that is NOT a refusal is rethrown untouched.
+ final PqAnchorNotReadyException e = PqAnchorNotReadyException.findIn(thrown);
+ if (e == null) {
+ throw thrown;
+ }
+ // AERE ANCHOR V2: this node cannot back the block with a certificate that reaches the
+ // staged threshold, so it does NOT propose. See PqAnchorNotReadyException for why refusing
+ // is strictly better than proposing a header we have already computed to be invalid: in
+ // QBFT both end at the same round timer, so proposing anyway buys no liveness at all.
+ LOG.warn("AERE PQ-ANCHOR: not proposing. round={}. {}", getRoundIdentifier(), e.getMessage());
+ return;
+ }
blockToPublish = blockCreationResult.block();
blockAccessList = blockCreationResult.blockAccessList();
} else {
@@ -282,23 +329,33 @@ public class QbftRound {
private boolean updateStateWithProposedBlock(final Proposal msg) {
final boolean wasPrepared = roundState.isPrepared();
- final boolean wasCommitted = roundState.isCommitted();
final boolean blockAccepted = roundState.setProposedBlock(msg);
if (blockAccepted) {
final QbftBlock block = roundState.getProposedBlock().get();
+
+ // AERE hybrid PQC: compute the commit hash once, then produce BOTH the decisive ECDSA commit
+ // seal and the parallel Falcon-512 seal over the SAME commit hash. The Falcon seal is gossiped
+ // on the commit message so every validator's post-quantum seal reaches the block assembler.
+ final Hash commitHash;
final SECPSignature commitSeal;
try {
- commitSeal = createCommitSeal(block);
+ commitHash = commitHashFor(block);
+ commitSeal = nodeKey.sign(Bytes32.wrap(commitHash.getBytes()));
} catch (final SecurityModuleException e) {
LOG.warn("Failed to construct commit seal; {}", e.getMessage());
return true;
}
+ final Optional<FalconSeal> falconSeal = falconSealFor(block, commitHash);
// There are times handling a proposed block is enough to enter prepared.
if (wasPrepared != roundState.isPrepared()) {
LOG.debug("Sending commit message. round={}", roundState.getRoundIdentifier());
- transmitter.multicastCommit(getRoundIdentifier(), block.getHash(), commitSeal);
+ if (falconSeal.isPresent()) {
+ transmitter.multicastCommit(getRoundIdentifier(), block.getHash(), commitSeal, falconSeal);
+ } else {
+ transmitter.multicastCommit(getRoundIdentifier(), block.getHash(), commitSeal);
+ }
}
// can automatically add _our_ commit message to the roundState
@@ -306,18 +363,26 @@ public class QbftRound {
// prepare
try {
final Commit localCommitMessage =
- messageFactory.createCommit(
- roundState.getRoundIdentifier(), msg.getBlock().getHash(), commitSeal);
+ falconSeal.isPresent()
+ ? messageFactory.createCommit(
+ roundState.getRoundIdentifier(),
+ msg.getBlock().getHash(),
+ commitSeal,
+ falconSeal)
+ : messageFactory.createCommit(
+ roundState.getRoundIdentifier(), msg.getBlock().getHash(), commitSeal);
roundState.addCommitMessage(localCommitMessage);
} catch (final SecurityModuleException e) {
LOG.warn("Failed to create signed Commit message; {}", e.getMessage());
return true;
}
+ // AERE ANCHOR V2: remember every Falcon seal heard for THIS block, so the proposer of the
+ // NEXT block can carry a certificate over it. This is the local node's own seal.
+ pqCacheHeardSeals(block);
+
// It is possible sufficient commit seals are now available and the block should be imported
- if (wasCommitted != roundState.isCommitted()) {
- importBlockToChain();
- }
+ maybeImportBlock();
}
return blockAccepted;
@@ -330,7 +395,14 @@ public class QbftRound {
LOG.debug("Sending commit message. round={}", roundState.getRoundIdentifier());
final QbftBlock block = roundState.getProposedBlock().get();
try {
- transmitter.multicastCommit(getRoundIdentifier(), block.getHash(), createCommitSeal(block));
+ final Hash commitHash = commitHashFor(block);
+ final SECPSignature commitSeal = nodeKey.sign(Bytes32.wrap(commitHash.getBytes()));
+ final Optional<FalconSeal> falconSeal = falconSealFor(block, commitHash);
+ if (falconSeal.isPresent()) {
+ transmitter.multicastCommit(getRoundIdentifier(), block.getHash(), commitSeal, falconSeal);
+ } else {
+ transmitter.multicastCommit(getRoundIdentifier(), block.getHash(), commitSeal);
+ }
// Note: the local-node's commit message was added to RoundState on block acceptance
// and thus does not need to be done again here.
} catch (final SecurityModuleException e) {
@@ -340,20 +412,41 @@ public class QbftRound {
}
private void peerIsCommitted(final Commit msg) {
- final boolean wasCommitted = roundState.isCommitted();
roundState.addCommitMessage(msg);
- if (wasCommitted != roundState.isCommitted()) {
+ // AERE ANCHOR V2: top up the seal cache on EVERY commit, including commits that arrive after
+ // this node has already imported the block. Those late seals are exactly the ones that let a
+ // proposer reach the threshold K without waiting for its own round to be re-run.
+ roundState.getProposedBlock().ifPresent(this::pqCacheHeardSeals);
+ // AERE hybrid PQC: attempt import on every commit while not yet imported. In the common case
+ // this fires exactly once (on the quorum-th commit). Post-fork, if the first attempt lacked a
+ // complete Falcon quorum certificate (e.g. one of the quorum's validators had a faulty Falcon
+ // key), a later commit that carries a valid Falcon seal lets the honest quorum complete.
+ maybeImportBlock();
+ }
+
+ private void maybeImportBlock() {
+ if (roundState.isCommitted() && !blockImported) {
importBlockToChain();
}
}
private void importBlockToChain() {
+ // AERE hybrid PQC: pass the gossiped Falcon seals collected from commit messages so the block
+ // assembler can embed a >= 2f+1 Falcon quorum certificate. When no Falcon seals were gossiped
+ // (Falcon disabled), fall back to the unchanged ECDSA-only sealing path.
+ final Collection<FalconSeal> falconSeals = roundState.getFalconSeals();
final QbftBlock blockToImport =
- blockCreator.createSealedBlock(
- roundState.getProposedBlock().get(),
- roundState.getRoundIdentifier().getRoundNumber(),
- roundState.getCommitSeals());
+ falconSeals.isEmpty()
+ ? blockCreator.createSealedBlock(
+ roundState.getProposedBlock().get(),
+ roundState.getRoundIdentifier().getRoundNumber(),
+ roundState.getCommitSeals())
+ : blockCreator.createSealedBlock(
+ roundState.getProposedBlock().get(),
+ roundState.getRoundIdentifier().getRoundNumber(),
+ roundState.getCommitSeals(),
+ falconSeals);
final long blockNumber = blockToImport.getHeader().getNumber();
if (getRoundIdentifier().getRoundNumber() > 0) {
@@ -373,19 +466,77 @@ public class QbftRound {
final boolean result =
blockImporter.importBlock(blockToImport, roundState.getProposedBlockAccessList());
if (!result) {
+ // Do NOT set blockImported: a post-fork block whose Falcon quorum certificate is not yet
+ // complete can be retried as more commits (with valid Falcon seals) arrive this round.
LOG.error(
"Failed to import proposed block to chain. block={} blockHeader={}",
blockNumber,
blockToImport.getHeader());
} else {
+ blockImported = true;
notifyNewBlockListeners(blockToImport);
}
}
- private SECPSignature createCommitSeal(final QbftBlock block) {
+ private Hash commitHashFor(final QbftBlock block) {
final QbftBlock commitBlock = createCommitBlock(block);
- final Hash commitHash = commitBlock.getHash();
- return nodeKey.sign(Bytes32.wrap(commitHash.getBytes()));
+ return commitBlock.getHash();
+ }
+
+ /**
+ * AERE ANCHOR V2: remember the Falcon seals heard for a block, keyed on its ROUND-INDEPENDENT
+ * on-chain hash, which is the key the producer of the next block looks under. Cheap and
+ * idempotent: the cache keeps the first seal seen per validator index.
+ */
+ private void pqCacheHeardSeals(final QbftBlock block) {
+ final Collection<FalconSeal> seals = roundState.getFalconSeals();
+ if (seals.isEmpty()) {
+ return;
+ }
+ PqSealCache.instance().record(block.getHeader().getNumber(), pqOnchainHashOf(block), seals);
+ }
+
+ /**
+ * AERE ANCHOR V2: the round-independent on-chain hash of the block this round is deciding,
+ * computed once per round. Replacing the round with 0 in the committed-seal encoding produces
+ * byte-for-byte the on-chain pre-image, because that encoding differs from the on-chain one only
+ * by writing the real round instead of a forced zero.
+ */
+ private Hash pqOnchainHashOf(final QbftBlock block) {
+ if (pqOnchainHash == null) {
+ pqOnchainHash = blockInterface.replaceRoundForCommitBlock(block, 0).getHash();
+ }
+ return pqOnchainHash;
+ }
+
+ private Optional<FalconSeal> falconSealFor(final QbftBlock block, final Hash commitHash) {
+ // FalconSealSupport.sign never throws (any fault is swallowed and logged), and returns empty
+ // when this node holds no Falcon signing key, so the ECDSA commit path is never affected.
+ //
+ // AERE FIX-OPRIRE-CONSENS (b): the block NUMBER is now a mandatory argument. Seal ATTACHMENT is
+ // gated on the activation height (and on registry coverage of the whole validator set) inside
+ // FalconSealSupport.sign, so merely holding a Falcon key no longer makes a node emit a
+ // Falcon-carrying Commit. There is deliberately no height-less sign() overload left: a call site
+ // physically cannot bypass the gate.
+ //
+ // AERE ANCHOR V2: WHAT IS SIGNED changes one block BEFORE the anchor activates, because the
+ // certificate carried by block H is made of seals over block H-1. From that height the message
+ // is M = keccak256(RLP["AERE-PQ-COMMIT-1", chainId, number, ON-CHAIN block hash]) instead of the
+ // ECDSA committed-seal hash. The committed-seal hash could not be used: it includes the ROUND,
+ // which is not in the block-hash pre-image, so a validator holding only the parent header could
+ // not rebuild it. Every node flips at the same height, since the height is a pure function of
+ // the same configured H; a node configured with a different H emits seals nobody can use, and
+ // the producer drops them on verification rather than carrying them into a header.
+ final long blockNumber = block.getHeader().getNumber();
+ final Bytes32 message;
+ if (PqAnchorProducer.sealMessageIsAnchorForm(blockNumber)) {
+ message =
+ PqAnchor.commitMessage(
+ PqAnchorProducer.config().chainId(), blockNumber, pqOnchainHashOf(block).getBytes());
+ } else {
+ message = Bytes32.wrap(commitHash.getBytes());
+ }
+ return FalconSealSupport.instance().sign(blockNumber, message);
}
private QbftBlock createCommitBlock(final QbftBlock block) {
diff --git a/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/RoundState.java b/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/RoundState.java
index 1c3078602..e7a7aad94 100644
--- a/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/RoundState.java
+++ b/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/RoundState.java
@@ -11,10 +11,17 @@
* specific language governing permissions and limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
+ *
+ * Modifications Copyright contributors to the Aere Network.
+ *
+ * This file was modified by contributors to the Aere Network, as required by section 4(b) of the
+ * Apache License 2.0. The copyright header above is the upstream one and is left exactly as it was
+ * found, as section 4(c) requires. The change itself is in patches/0003-aere-pq-anchor.patch.
*/
package org.hyperledger.besu.consensus.qbft.core.statemachine;
import org.hyperledger.besu.consensus.common.bft.ConsensusRoundIdentifier;
+import org.hyperledger.besu.consensus.common.bft.FalconSeal;
import org.hyperledger.besu.consensus.qbft.core.messagewrappers.Commit;
import org.hyperledger.besu.consensus.qbft.core.messagewrappers.Prepare;
import org.hyperledger.besu.consensus.qbft.core.messagewrappers.Proposal;
@@ -27,6 +34,7 @@ import org.hyperledger.besu.ethereum.mainnet.block.access.list.BlockAccessList;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.Map;
+import java.util.Objects;
import java.util.Optional;
import java.util.stream.Collectors;
@@ -196,6 +204,23 @@ public class RoundState {
.collect(Collectors.toList());
}
+ /**
+ * Gets the parallel post-quantum Falcon seals gossiped on the collected commit messages (AERE
+ * hybrid PQC). Only commits that carried a Falcon seal contribute; the result is the raw set of
+ * gossiped seals, to be verified and de-duplicated by the block creator / header validation rule
+ * when the quorum certificate is assembled and checked.
+ *
+ * @return the gossiped Falcon seals (possibly empty, never null)
+ */
+ public Collection<FalconSeal> getFalconSeals() {
+ return commitMessages.values().stream()
+ .map(cp -> cp.getSignedPayload().getPayload().getFalconSeal())
+ .filter(Objects::nonNull)
+ .filter(Optional::isPresent)
+ .map(Optional::get)
+ .collect(Collectors.toList());
+ }
+
/**
* Construct prepared certificate.
*
diff --git a/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/types/QbftBlockCreator.java b/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/types/QbftBlockCreator.java
index afe4fb00e..c397cab98 100644
--- a/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/types/QbftBlockCreator.java
+++ b/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/types/QbftBlockCreator.java
@@ -11,13 +11,21 @@
* specific language governing permissions and limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
+ *
+ * Modifications Copyright contributors to the Aere Network.
+ *
+ * This file was modified by contributors to the Aere Network, as required by section 4(b) of the
+ * Apache License 2.0. The copyright header above is the upstream one and is left exactly as it was
+ * found, as section 4(c) requires. The change itself is in patches/0003-aere-pq-anchor.patch.
*/
package org.hyperledger.besu.consensus.qbft.core.types;
+import org.hyperledger.besu.consensus.common.bft.FalconSeal;
import org.hyperledger.besu.crypto.SECPSignature;
import org.hyperledger.besu.ethereum.mainnet.block.access.list.BlockAccessList;
import java.util.Collection;
+import java.util.Collections;
import java.util.Optional;
/** Responsible for creating a block. */
@@ -50,4 +58,33 @@ public interface QbftBlockCreator {
*/
QbftBlock createSealedBlock(
final QbftBlock block, final int roundNumber, final Collection<SECPSignature> commitSeals);
+
+ /**
+ * Create sealed block, additionally embedding a gossiped parallel Falcon-512 quorum certificate
+ * (AERE hybrid PQC). The default implementation ignores the Falcon seals and delegates to the
+ * ECDSA-only sealing, so existing implementations remain valid; the QBFT block creator adaptor
+ * overrides this to aggregate the Falcon seals into a per-block quorum certificate.
+ *
+ * @param block the block
+ * @param roundNumber the round number
+ * @param commitSeals the decisive ECDSA commit seals
+ * @param falconSeals the gossiped parallel Falcon-512 seals collected from commit messages
+ * @return the block
+ */
+ default QbftBlock createSealedBlock(
+ final QbftBlock block,
+ final int roundNumber,
+ final Collection<SECPSignature> commitSeals,
+ final Collection<FalconSeal> falconSeals) {
+ return createSealedBlock(block, roundNumber, commitSeals);
+ }
+
+ /**
+ * Convenience empty Falcon seal collection.
+ *
+ * @return an empty collection of Falcon seals
+ */
+ static Collection<FalconSeal> noFalconSeals() {
+ return Collections.emptyList();
+ }
}
diff --git a/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/QbftBlockHeaderValidationRulesetFactory.java b/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/QbftBlockHeaderValidationRulesetFactory.java
index f54d7bfcd..6c3fba591 100644
--- a/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/QbftBlockHeaderValidationRulesetFactory.java
+++ b/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/QbftBlockHeaderValidationRulesetFactory.java
@@ -11,6 +11,12 @@
* specific language governing permissions and limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
+ *
+ * Modifications Copyright contributors to the Aere Network.
+ *
+ * This file was modified by contributors to the Aere Network, as required by section 4(b) of the
+ * Apache License 2.0. The copyright header above is the upstream one and is left exactly as it was
+ * found, as section 4(c) requires. The change itself is in patches/0003-aere-pq-anchor.patch.
*/
package org.hyperledger.besu.consensus.qbft;
@@ -18,8 +24,16 @@ import static org.hyperledger.besu.ethereum.mainnet.AbstractGasLimitSpecificatio
import static org.hyperledger.besu.ethereum.mainnet.AbstractGasLimitSpecification.DEFAULT_MIN_GAS_LIMIT;
import org.hyperledger.besu.consensus.common.bft.BftHelpers;
+import org.hyperledger.besu.consensus.common.bft.FalconSealSupport;
+import org.hyperledger.besu.consensus.common.bft.PqAnchorConfig;
import org.hyperledger.besu.consensus.common.bft.headervalidationrules.BftCoinbaseValidationRule;
import org.hyperledger.besu.consensus.common.bft.headervalidationrules.BftCommitSealsValidationRule;
+import org.hyperledger.besu.consensus.qbft.headervalidationrules.FalconSealValidationRule;
+import org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorDigestAttachedRule;
+import org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorDigestRule;
+import org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorSealsRule;
+import org.hyperledger.besu.consensus.qbft.headervalidationrules.PqEmergencyShoutRule;
+import org.hyperledger.besu.consensus.qbft.headervalidationrules.PqRegistryBindingRule;
import org.hyperledger.besu.consensus.qbft.headervalidationrules.QbftValidatorsValidationRule;
import org.hyperledger.besu.ethereum.core.BlockHeader;
import org.hyperledger.besu.ethereum.mainnet.BlockHeaderValidator;
@@ -54,8 +68,75 @@ public class QbftBlockHeaderValidationRulesetFactory {
final Duration minimumTimeBetweenBlocks,
final boolean useValidatorContract,
final Optional<BaseFeeMarket> baseFeeMarket) {
+ return blockHeaderValidator(
+ minimumTimeBetweenBlocks,
+ useValidatorContract,
+ baseFeeMarket,
+ PqAnchorConfig.fromSystemConfiguration());
+ }
+
+ /**
+ * Produces a BlockHeaderValidator configured for assessing bft block headers, with an explicit V2
+ * certificate-anchor configuration.
+ *
+ * <p>AERE ANCORA-V2 (2026-08-01). Two rules are added and one is retired, all indexed on the BLOCK
+ * NUMBER:
+ *
+ * <ul>
+ * <li>{@link PqAnchorDigestRule} (R1), DETACHED and part of LIGHT validation: recomputes the
+ * anchor digest D from the header's own parent hash, its own height minus one, the chain id
+ * and the certificate the header itself carries, and requires it to equal vanityData. One
+ * keccak, no state. This is what makes a stripped or swapped certificate detectable, and
+ * detectable on a node that is fast-syncing headers.
+ * <li>{@link PqAnchorSealsRule} (R2), ATTACHED and full validation only: threshold, strictly
+ * increasing indices, eligibility in the PARENT's validator set, and k Falcon verifications
+ * over M(parent).
+ * <li>{@link FalconSealValidationRule} retires at the same height. It is replaced, not promoted:
+ * it is excluded from light validation, it returns true on every path including its
+ * exception path, and it reads historical world state over a window that does not exist on a
+ * syncing node.
+ * </ul>
+ *
+ * <p>Below the activation height H the two new rules return true as their FIRST statement, before
+ * any decode, and the legacy rule behaves exactly as before. A binary built from this code is
+ * therefore bit-for-bit equivalent to today's on the whole existing chain, which is the condition
+ * for warming it on a live node.
+ *
+ * <p>The rule COUNT goes from 11 to 16 (the per-block registry binding adds one, was 13;
+ * SINCRONIZARE adds the attached copy of the digest rule, was 14; OPTIUNI-URGENTA adds the
+ * emergency announcement rule, which can never reject, was 15) (14 unconditional plus the
+ * conditional timestamp rule). The original design note said 12 because it assumed the legacy
+ * Falcon rule would be deleted; retiring it by height instead is what keeps behaviour below H
+ * identical, so it stays in the list.
+ *
+ * @param minimumTimeBetweenBlocks the minimum amount of time that must elapse between blocks.
+ * @param useValidatorContract whether validator selection is using a validator contract
+ * @param baseFeeMarket an {@link Optional} wrapping {@link BaseFeeMarket} class if appropriate.
+ * @param pqAnchorConfig the height-indexed V2 certificate-anchor configuration
+ * @return BlockHeaderValidator configured for assessing bft block headers
+ */
+ public static BlockHeaderValidator.Builder blockHeaderValidator(
+ final Duration minimumTimeBetweenBlocks,
+ final boolean useValidatorContract,
+ final Optional<BaseFeeMarket> baseFeeMarket,
+ final PqAnchorConfig pqAnchorConfig) {
+ // AERE FIX-OPRIRE-CONSENS (b): force EAGER initialisation of FalconSealSupport here, while the
+ // protocol schedule is being built at node startup and before the network is up. The class runs
+ // its fail-closed configuration guards in its constructor; leaving it lazily initialised meant a
+ // node with an unsafe Falcon activation config started normally and only discovered the problem
+ // the first time a QBFT round touched the singleton, i.e. ON the consensus path. Touching it
+ // here moves every refusal back to config time, which is the only safe place for it.
+ FalconSealSupport.instance();
+
BlockHeaderValidator.Builder ruleBuilder =
new BlockHeaderValidator.Builder()
+ // AERE OPTIUNI-URGENTA (2026-08-02): FIRST, and it can never reject. Its whole job is
+ // to make an emergency override impossible to run on without noticing, by writing one
+ // complete ERROR line at every height for as long as one is in force. First in the list
+ // because a rule that always returns true cannot change the verdict of a conjunction,
+ // and because the moment an operator most needs to know a control was overridden is
+ // when a LATER rule is rejecting headers.
+ .addRule(new PqEmergencyShoutRule(pqAnchorConfig))
.addRule(new AncestryValidationRule())
.addRule(new GasUsageValidationRule())
.addRule(
@@ -70,7 +151,34 @@ public class QbftBlockHeaderValidationRulesetFactory {
"Difficulty", BlockHeader::getDifficulty, UInt256.ONE))
.addRule(new QbftValidatorsValidationRule(useValidatorContract))
.addRule(new BftCoinbaseValidationRule())
- .addRule(new BftCommitSealsValidationRule());
+ .addRule(new BftCommitSealsValidationRule())
+ // AERE ANCORA-V2: the legacy log-only Falcon rule now stands down at H. With an
+ // unconfigured anchor this is Long.MAX_VALUE, i.e. it never stands down and behaviour is
+ // unchanged.
+ .addRule(
+ new FalconSealValidationRule(pqAnchorConfig.legacyFalconRuleRetirementBlock()))
+ // AERE ANCORA-V2 R1: cheap, detached, IN LIGHT VALIDATION. Ordered before R2 so the one
+ // keccak that binds the certificate to the block hash runs before k Falcon
+ // verifications are spent on it.
+ .addRule(new PqAnchorDigestRule(pqAnchorConfig))
+ // AERE SINCRONIZARE R1A (2026-08-02): the SAME digest verdict, on the ATTACHED side.
+ // Measured cause it repairs: SKIP_DETACHED, the mode FullImportBlockStep passes to
+ // importBlock for every block acquired by sync, keeps exactly the rules that are NOT
+ // detached, so R1 does not run at import; and DETACHED_ONLY, the mode under which R1
+ // does run, is only applied by DownloadHeaderSequenceTask, i.e. only on ranges with a
+ // closed end. The open-ended range at the tip of a recovery, 199 blocks by default, was
+ // therefore checked by neither. Ordered immediately before R2 so the one keccak still
+ // runs before k Falcon verifications are spent. Delegates to R1, so there is one
+ // definition of the digest and the two paths cannot drift.
+ .addRule(new PqAnchorDigestAttachedRule(pqAnchorConfig))
+ // AERE ANCORA-V2 R2: attached, full validation only.
+ .addRule(new PqAnchorSealsRule(pqAnchorConfig))
+ // AERE REGISTRY BINDING, per-block half: the registry this node runs must be the
+ // registry config.pqRegistryHash requires AT THIS HEIGHT. The startup guard answers
+ // that once, against the head that existed at startup; a rotation entry in
+ // the schedule can pass underneath a running node and never be noticed.
+ // Inert when no schedule is configured, which is chain 2800 today.
+ .addRule(new PqRegistryBindingRule());
// Currently the minimum acceptable time between blocks is 1 second. The timestamp of an
// Ethereum header is stored as seconds since Unix epoch so blocks being produced more
diff --git a/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/QbftExtraDataCodec.java b/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/QbftExtraDataCodec.java
index 39c7aa300..59ef0edd8 100644
--- a/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/QbftExtraDataCodec.java
+++ b/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/QbftExtraDataCodec.java
@@ -11,6 +11,12 @@
* specific language governing permissions and limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
+ *
+ * Modifications Copyright contributors to the Aere Network.
+ *
+ * This file was modified by contributors to the Aere Network, as required by section 4(b) of the
+ * Apache License 2.0. The copyright header above is the upstream one and is left exactly as it was
+ * found, as section 4(c) requires. The change itself is in patches/0003-aere-pq-anchor.patch.
*/
package org.hyperledger.besu.consensus.qbft;
@@ -19,6 +25,7 @@ import static org.hyperledger.besu.consensus.common.bft.Vote.DROP_BYTE_VALUE;
import org.hyperledger.besu.consensus.common.bft.BftExtraData;
import org.hyperledger.besu.consensus.common.bft.BftExtraDataCodec;
+import org.hyperledger.besu.consensus.common.bft.FalconSeal;
import org.hyperledger.besu.consensus.common.bft.Vote;
import org.hyperledger.besu.consensus.common.validator.VoteType;
import org.hyperledger.besu.crypto.SECPSignature;
@@ -41,6 +48,15 @@ import org.apache.tuweni.bytes.Bytes;
/**
* Represents the data structure stored in the extraData field of the BlockHeader used when
* operating under an BFT consensus mechanism.
+ *
+ * <p>AERE hybrid PQC extension: an OPTIONAL, PARALLEL Falcon-512 seal list (a per-block post-quantum
+ * quorum certificate) is appended after the ECDSA committed-seal list, but ONLY in the {@link
+ * EncodingType#ALL} (stored-header) encoding AND only when at least one Falcon seal is present. The
+ * two "exclude" encodings used for the committed-seal hash and the on-chain block hash omit the
+ * Falcon list entirely, and a header with no Falcon seals omits it too, so in every case the bytes
+ * are identical to upstream Besu whenever no post-quantum certificate is carried. This guarantees
+ * the Falcon seals never disturb the ECDSA committed seals or any signed pre-image. Decoding is
+ * backward-tolerant: headers with no Falcon list decode to an empty Falcon-seal collection.
*/
public class QbftExtraDataCodec extends BftExtraDataCodec {
private static final ImmutableBiMap<VoteType, Byte> voteToValue =
@@ -102,9 +118,66 @@ public class QbftExtraDataCodec extends BftExtraDataCodec {
final List<SECPSignature> seals =
rlpInput.readList(
rlp -> SignatureAlgorithmFactory.getInstance().decodeSignature(rlp.readBytes()));
- rlpInput.leaveListLenient();
- return new BftExtraData(vanityData, seals, vote, round, validators);
+ // AERE hybrid PQC: an OPTIONAL parallel Falcon-512 seal list may follow the ECDSA seals.
+ // Backward-tolerant: older/exclude/no-certificate encodings without this element decode to an
+ // empty list.
+ final List<FalconSeal> falconSeals;
+ if (!rlpInput.isEndOfCurrentList()) {
+ falconSeals =
+ rlpInput.readList(
+ rlp -> {
+ rlp.enterList();
+ final int idx = rlp.readIntScalar();
+ final Bytes sig = rlp.readBytes();
+ rlp.leaveList();
+ return new FalconSeal(idx, sig);
+ });
+ } else {
+ falconSeals = Collections.emptyList();
+ }
+
+ // AERE FIX-MALEABILITATE-BLOC (2026-08-01), gate 1 of 2: the leave is STRICT.
+ //
+ // Upstream ends this decode with leaveListLenient(), which silently discards anything still
+ // left in the outer list. That is not survivable here, because the QBFT block hash and the
+ // committed-seal pre-image are BOTH computed from a RE-ENCODE of the decoded BftExtraData
+ // (BftBlockHashing.calculateHashOfBftBlockOnchain line 84-91, calculateDataHashForCommittedSeal
+ // line 53-75). Anything the decoder discards is therefore dropped before hashing, so two
+ // different header byte strings carry the same block hash and the same genuine validator
+ // seals. leaveList() rejects exactly the inputs that have content left over, and is
+ // byte-for-byte identical to the lenient leave on every input that has not.
+ rlpInput.leaveList();
+
+ final BftExtraData decoded =
+ new BftExtraData(vanityData, seals, vote, round, validators, falconSeals);
+
+ // AERE FIX-MALEABILITATE-BLOC (2026-08-01), gate 2 of 2: CANONICAL ROUND TRIP.
+ //
+ // The strict leave alone does not close the hole. The Falcon element is OPTIONAL on the wire
+ // and the encoder omits it when the certificate is empty, so a header carrying an explicit
+ // EMPTY Falcon list decodes to exactly the same value as a header carrying no Falcon element
+ // at all: the trailing 0xc0 is CONSUMED, not left over, so no leave, strict or lenient, can
+ // see it. Requiring the full re-encode to reproduce the received bytes makes the accepted
+ // encoding unique by construction, and closes every present and future shape of this defect
+ // rather than the one instance of it we happened to look for.
+ //
+ // Backward compatibility: the re-encode used here is the same EncodingType.ALL the block
+ // producer uses, so every header any Aere or stock Besu node has ever produced round trips
+ // unchanged. Headers with no Falcon element re-encode with no Falcon element, which is why
+ // the whole pre-Falcon history of the chain still decodes.
+ final Bytes canonical = encode(decoded, EncodingType.ALL);
+ if (!canonical.equals(input)) {
+ throw new RLPException(
+ "Non-canonical BFT extra data: the header's extraData is "
+ + input.size()
+ + " bytes but its canonical re-encoding is "
+ + canonical.size()
+ + " bytes. A QBFT block hash is computed over the re-encoded form, so a non-canonical"
+ + " encoding would give two header byte strings one block hash. Rejected.");
+ }
+
+ return decoded;
}
@Override
@@ -126,6 +199,21 @@ public class QbftExtraDataCodec extends BftExtraDataCodec {
if (encodingType != EncodingType.EXCLUDE_COMMIT_SEALS) {
encoder.writeList(
bftExtraData.getSeals(), (committer, rlp) -> rlp.writeBytes(committer.encodedBytes()));
+ // AERE hybrid PQC: only the full (stored-header) encoding carries the parallel Falcon
+ // quorum certificate, and only when it is non-empty. The exclude encodings omit it so the
+ // committed-seal hash and the on-chain block hash are byte-identical to upstream Besu, and a
+ // header with no certificate is byte-identical to upstream too, so the Falcon seals are
+ // never part of any signed pre-image and never disturb the block hash.
+ if (!bftExtraData.getFalconSeals().isEmpty()) {
+ encoder.writeList(
+ bftExtraData.getFalconSeals(),
+ (falconSeal, rlp) -> {
+ rlp.startList();
+ rlp.writeIntScalar(falconSeal.getValidatorIndex());
+ rlp.writeBytes(falconSeal.getSignature());
+ rlp.endList();
+ });
+ }
} else {
encoder.writeEmptyList();
}
diff --git a/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/adaptor/QbftBlockCreatorAdaptor.java b/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/adaptor/QbftBlockCreatorAdaptor.java
index 594fe83e4..2151f5760 100644
--- a/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/adaptor/QbftBlockCreatorAdaptor.java
+++ b/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/adaptor/QbftBlockCreatorAdaptor.java
@@ -11,26 +11,51 @@
* specific language governing permissions and limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
+ *
+ * Modifications Copyright contributors to the Aere Network.
+ *
+ * This file was modified by contributors to the Aere Network, as required by section 4(b) of the
+ * Apache License 2.0. The copyright header above is the upstream one and is left exactly as it was
+ * found, as section 4(c) requires. The change itself is in patches/0003-aere-pq-anchor.patch.
*/
package org.hyperledger.besu.consensus.qbft.adaptor;
+import org.hyperledger.besu.consensus.common.bft.BftBlockHashing;
import org.hyperledger.besu.consensus.common.bft.BftBlockHeaderFunctions;
import org.hyperledger.besu.consensus.common.bft.BftExtraData;
import org.hyperledger.besu.consensus.common.bft.BftExtraDataCodec;
+import org.hyperledger.besu.consensus.common.bft.FalconSeal;
+import org.hyperledger.besu.consensus.common.bft.FalconSealSupport;
+import org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer;
import org.hyperledger.besu.consensus.qbft.core.types.QbftBlock;
import org.hyperledger.besu.consensus.qbft.core.types.QbftBlockCreator;
import org.hyperledger.besu.consensus.qbft.core.types.QbftBlockHeader;
import org.hyperledger.besu.crypto.SECPSignature;
+import org.hyperledger.besu.datatypes.Address;
+import org.hyperledger.besu.datatypes.Hash;
import org.hyperledger.besu.ethereum.blockcreation.BlockCreator;
import org.hyperledger.besu.ethereum.core.Block;
import org.hyperledger.besu.ethereum.core.BlockHeader;
import org.hyperledger.besu.ethereum.core.BlockHeaderBuilder;
+import java.util.ArrayList;
import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Optional;
+import java.util.OptionalInt;
+import java.util.Set;
+
+import org.apache.tuweni.bytes.Bytes32;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
/** Adaptor class to allow a {@link BlockCreator} to be used as a {@link QbftBlockCreator}. */
public class QbftBlockCreatorAdaptor implements QbftBlockCreator {
+ private static final Logger LOG = LoggerFactory.getLogger(QbftBlockCreatorAdaptor.class);
+
private final BlockCreator besuBlockCreator;
private final BftExtraDataCodec bftExtraDataCodec;
@@ -59,11 +84,57 @@ public class QbftBlockCreatorAdaptor implements QbftBlockCreator {
@Override
public QbftBlock createSealedBlock(
final QbftBlock block, final int roundNumber, final Collection<SECPSignature> commitSeals) {
+ return createSealedBlock(block, roundNumber, commitSeals, Collections.emptyList());
+ }
+
+ @Override
+ public QbftBlock createSealedBlock(
+ final QbftBlock block,
+ final int roundNumber,
+ final Collection<SECPSignature> commitSeals,
+ final Collection<FalconSeal> collectedFalconSeals) {
final Block besuBlock = AdaptorUtil.toBesuBlock(block);
final QbftBlockHeader initialHeader = block.getHeader();
final BftExtraData initialExtraData =
bftExtraDataCodec.decode(AdaptorUtil.toBesuBlockHeader(initialHeader));
+ // AERE ANCHOR V2, THE MEASURED TRAP, CLOSED HERE. Above the activation height element 6 is no
+ // longer "the seals I heard for THIS block": it is the quorum certificate over the PARENT,
+ // CHOSEN BY THE PROPOSER, and vanityData carries its digest D. Every node that seals this block
+ // must therefore carry the proposer's bytes across UNCHANGED. The legacy assembly below would
+ // overwrite them with what this node heard for the current block, which would leave a header
+ // whose vanity D no longer matches its own element 6, i.e. a header this node's own digest rule
+ // rejects. So above the activation height the assembler is skipped outright.
+ //
+ // Note what is NOT done here: no re-verification, no re-selection, no comparison against what
+ // this node heard. That is the whole point of V2. The proposer's certificate has already been
+ // checked by the header rules on the proposal, and re-deriving it locally is exactly the
+ // dependency on "what I heard" that killed the earlier design.
+ if (PqAnchorProducer.config().activeAt(initialHeader.getNumber())) {
+ final BftExtraData anchoredExtraData =
+ new BftExtraData(
+ initialExtraData.getVanityData(),
+ commitSeals,
+ initialExtraData.getVote(),
+ roundNumber,
+ initialExtraData.getValidators(),
+ initialExtraData.getFalconSeals());
+ final BlockHeader anchoredHeader =
+ BlockHeaderBuilder.fromHeader(AdaptorUtil.toBesuBlockHeader(initialHeader))
+ .extraData(bftExtraDataCodec.encode(anchoredExtraData))
+ .blockHeaderFunctions(BftBlockHeaderFunctions.forOnchainBlock(bftExtraDataCodec))
+ .buildBlockHeader();
+ LOG.debug(
+ "AERE PQ-ANCHOR: sealed block {} keeping the proposer's {}-seal parent certificate "
+ + "verbatim ({} seal(s) heard locally for this block were deliberately ignored)",
+ anchoredHeader.getNumber(),
+ initialExtraData.getFalconSeals().size(),
+ collectedFalconSeals == null ? 0 : collectedFalconSeals.size());
+ return new QbftBlockAdaptor(new Block(anchoredHeader, besuBlock.getBody()));
+ }
+
+ // Step 1: build the sealed header exactly as upstream, with the decisive ECDSA committed seals
+ // and (for now) no Falcon seals. The ECDSA path is byte-for-byte unchanged.
final BftExtraData sealedExtraData =
new BftExtraData(
initialExtraData.getVanityData(),
@@ -72,12 +143,153 @@ public class QbftBlockCreatorAdaptor implements QbftBlockCreator {
roundNumber,
initialExtraData.getValidators());
- final BlockHeader sealedHeader =
+ BlockHeader sealedHeader =
BlockHeaderBuilder.fromHeader(AdaptorUtil.toBesuBlockHeader(initialHeader))
.extraData(bftExtraDataCodec.encode(sealedExtraData))
.blockHeaderFunctions(BftBlockHeaderFunctions.forOnchainBlock(bftExtraDataCodec))
.buildBlockHeader();
+
+ // Step 2 (AERE hybrid PQC): assemble a parallel Falcon-512 QUORUM CERTIFICATE from the seals
+ // gossiped on the commit messages. Each seal is re-verified against the registry over the SAME
+ // commit hash the ECDSA committed seal signs, and de-duplicated by validator index, so the
+ // embedded certificate is exactly the set of distinct, valid post-quantum seals. Falcon seals
+ // are excluded from every signed pre-image (committed-seal hash and on-chain block hash), so the
+ // block hash is identical whether or not the certificate is present. Any failure here leaves the
+ // ECDSA-sealed header untouched and valid.
+ try {
+ final FalconSealSupport pqc = FalconSealSupport.instance();
+ final Hash commitHash =
+ new BftBlockHashing(bftExtraDataCodec).calculateDataHashForCommittedSeal(sealedHeader);
+
+ // AERE audit fix, ELIGIBLE-SIGNER BINDING: restrict the embedded certificate to ELIGIBLE
+ // signers (current validators carried in extraData INTERSECT the address-bound registry), so
+ // the assembler never embeds a seal the header rule would later reject as ineligible.
+ final Set<Address> registered = pqc.registeredValidatorAddresses();
+ final Set<Address> eligible = new LinkedHashSet<>();
+ for (final Address v : initialExtraData.getValidators()) {
+ if (registered.contains(v)) {
+ eligible.add(v);
+ }
+ }
+
+ // AERE HEADER GROWTH 2026-08-08: the interval gate. Measured on chain 2800 the same day: with
+ // every validator attaching, this assembler wrote FIVE seals into EVERY header, taking it from
+ // 525 to 3844 bytes. That is roughly SEVEN TIMES the header bytes stored per block, on every
+ // node, for as long as the chain runs, which is what makes the interval a design constraint
+ // and not a tuning knob.
+ //
+ // The anchor producer has had an interval and a cap since 7 August. This assembler, the one
+ // that runs BEFORE the activation height, had neither, so the controls were unreachable
+ // exactly during the window they were needed. Skipping the whole attach block, rather than
+ // assembling and then discarding, also saves the Falcon verification of every heard seal.
+ if (!pqc.attachesCertificateAt(sealedHeader.getNumber())) {
+ LOG.debug(
+ "AERE PQC: block {} is not an attachment height (interval {}), leaving the header "
+ + "ECDSA-only at {} bytes",
+ sealedHeader.getNumber(),
+ pqc.attachInterval().isPresent() ? pqc.attachInterval().getAsInt() : 1,
+ sealedHeader.getExtraData().size());
+ return new QbftBlockAdaptor(new Block(sealedHeader, besuBlock.getBody()));
+ }
+
+ final List<FalconSeal> quorumCert =
+ verifiedDistinctSeals(
+ pqc,
+ sealedHeader.getNumber(),
+ commitHash,
+ collectedFalconSeals,
+ eligible,
+ pqc.attachMaxSeals());
+
+ // Fallback self-seal: if nothing was gossiped (e.g. the pre-gossip 3-arg path) but this node
+ // can sign AND is itself an eligible signer, attach its own seal so a single-signer
+ // certificate is still produced.
+ if (quorumCert.isEmpty() && pqc.signingEnabled()) {
+ // REGISTRY HEIGHT BINDING (2026-08-06): this is the ONE registry question in the stack with
+ // no honest height - "am I, right now, an eligible signer", asked before signing with the
+ // single private key this process holds. It gets its own name rather than a fabricated
+ // height, so that no future reader mistakes it for a verification path. See
+ // FalconSealSupport#localSigningAddress.
+ final Address self = pqc.localSigningAddress();
+ if (self != null && eligible.contains(self)) {
+ // AERE FIX-OPRIRE-CONSENS (b): height-gated like every other attachment point.
+ final Optional<FalconSeal> selfSeal =
+ pqc.sign(sealedHeader.getNumber(), Bytes32.wrap(commitHash.getBytes()));
+ selfSeal.ifPresent(quorumCert::add);
+ }
+ }
+
+ if (!quorumCert.isEmpty()) {
+ final BftExtraData hybridExtraData =
+ new BftExtraData(
+ initialExtraData.getVanityData(),
+ commitSeals,
+ initialExtraData.getVote(),
+ roundNumber,
+ initialExtraData.getValidators(),
+ quorumCert);
+ sealedHeader =
+ BlockHeaderBuilder.fromHeader(AdaptorUtil.toBesuBlockHeader(initialHeader))
+ .extraData(bftExtraDataCodec.encode(hybridExtraData))
+ .blockHeaderFunctions(BftBlockHeaderFunctions.forOnchainBlock(bftExtraDataCodec))
+ .buildBlockHeader();
+ LOG.debug(
+ "AERE PQC: attached Falcon-512 quorum certificate ({} distinct valid seal(s)) to block {}",
+ quorumCert.size(),
+ sealedHeader.getNumber());
+ }
+ } catch (final RuntimeException e) {
+ LOG.warn(
+ "AERE PQC: failed to attach Falcon quorum certificate; keeping ECDSA-only sealed block "
+ + "(block still valid and final): {}",
+ e.toString());
+ }
+
final Block sealedBesuBlock = new Block(sealedHeader, besuBlock.getBody());
return new QbftBlockAdaptor(sealedBesuBlock);
}
+
+ // REGISTRY HEIGHT BINDING (2026-08-06): takes the height of the block being sealed. The seals
+ // gathered here are over THIS block's committed-seal hash, so the height is this block's own and
+ // is known at the call site. It matters at exactly one moment - a rotation height - where
+ // assembling a certificate under the head key set while every validator checks it under the
+ // scheduled one produces a block every other node rejects, with nothing in any log naming the
+ // reason.
+ private static List<FalconSeal> verifiedDistinctSeals(
+ final FalconSealSupport pqc,
+ final long blockNumber,
+ final Hash commitHash,
+ final Collection<FalconSeal> seals,
+ final Set<Address> eligible,
+ final OptionalInt cap) {
+ final List<FalconSeal> out = new ArrayList<>();
+ final Set<Address> seen = new LinkedHashSet<>();
+ final int limit = cap.orElse(Integer.MAX_VALUE);
+ for (final FalconSeal seal : seals) {
+ // The cap is read HERE, at the top of each turn, so it counts only seals that have already
+ // survived eligibility and Falcon verification below. Placed before verification it would
+ // stop at the cap while holding seals that turn out to be bad, and a short certificate made
+ // of unchecked seals is worse than a long one. Same ordering, and same reason, as the anchor
+ // producer's own cap.
+ if (out.size() >= limit) {
+ break;
+ }
+ if (seal == null) {
+ continue;
+ }
+ // Bind each seal to its registered validator address and keep it only if that address is an
+ // eligible signer (a current validator with a registered key), de-duplicated by address.
+ // REGISTRY HEIGHT BINDING, the OWN-HEAD door: this is the block this node is sealing now.
+ final Address signer = pqc.addressForIndexAtOwnHead(blockNumber, seal.getValidatorIndex());
+ if (signer == null || !eligible.contains(signer) || seen.contains(signer)) {
+ continue;
+ }
+ if (pqc.verifyAtOwnHead(
+ blockNumber, seal.getValidatorIndex(), commitHash.getBytes(), seal.getSignature())) {
+ seen.add(signer);
+ out.add(seal);
+ }
+ }
+ return out;
+ }
}
diff --git a/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/blockcreation/QbftBlockCreatorFactory.java b/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/blockcreation/QbftBlockCreatorFactory.java
index 5e6de291b..cabeba99c 100644
--- a/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/blockcreation/QbftBlockCreatorFactory.java
+++ b/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/blockcreation/QbftBlockCreatorFactory.java
@@ -11,6 +11,12 @@
* specific language governing permissions and limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
+ *
+ * Modifications Copyright contributors to the Aere Network.
+ *
+ * This file was modified by contributors to the Aere Network, as required by section 4(b) of the
+ * Apache License 2.0. The copyright header above is the upstream one and is left exactly as it was
+ * found, as section 4(c) requires. The change itself is in patches/0003-aere-pq-anchor.patch.
*/
package org.hyperledger.besu.consensus.qbft.blockcreation;
@@ -20,6 +26,7 @@ import org.hyperledger.besu.consensus.common.ForksSchedule;
import org.hyperledger.besu.consensus.common.bft.BftExtraData;
import org.hyperledger.besu.consensus.common.bft.BftExtraDataCodec;
import org.hyperledger.besu.consensus.common.bft.blockcreation.BftBlockCreatorFactory;
+import org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer;
import org.hyperledger.besu.datatypes.Address;
import org.hyperledger.besu.ethereum.ProtocolContext;
import org.hyperledger.besu.ethereum.core.BlockHeader;
@@ -67,14 +74,34 @@ public class QbftBlockCreatorFactory extends BftBlockCreatorFactory<QbftConfigOp
ethScheduler);
}
+ /**
+ * AERE ANCHOR V2: the QBFT proposer writes the anchor.
+ *
+ * <p>Both paths are covered on purpose. The validator-contract path builds its own extra data and
+ * does NOT go through the shared base method, so a change made only in the base class would leave
+ * that path writing the old vanity and no certificate, which post-fork means every block it
+ * proposes is rejected. This is the second of the two measured traps in the producer chain; the
+ * other is in {@code QbftBlockCreatorAdaptor.createSealedBlock}.
+ *
+ * <p>Below the activation height {@link PqAnchorProducer#apply} returns its input unchanged, so
+ * this method is byte-for-byte the old one until a height is named.
+ *
+ * @param round the round
+ * @param parentHeader the parent header
+ * @return the encoded extra data
+ * @throws org.hyperledger.besu.consensus.common.bft.PqAnchorNotReadyException when this node
+ * cannot back the block with a certificate that reaches the staged threshold; callers must
+ * catch it and simply not propose this round
+ */
@Override
public Bytes createExtraData(final int round, final BlockHeader parentHeader) {
+ final BftExtraData base;
if (forksSchedule
.getFork(parentHeader.getNumber() + 1L, parentHeader.getTimestamp())
.getValue()
.isValidatorContractMode()) {
// vote and validators will come from contract instead of block
- final BftExtraData extraData =
+ base =
new BftExtraData(
ConsensusHelpers.zeroLeftPad(
miningConfiguration.getExtraData(), BftExtraDataCodec.EXTRA_VANITY_LENGTH),
@@ -82,9 +109,11 @@ public class QbftBlockCreatorFactory extends BftBlockCreatorFactory<QbftConfigOp
Optional.empty(),
round,
Collections.emptyList());
- return bftExtraDataCodec.encode(extraData);
+ } else {
+ base = buildExtraData(round, parentHeader);
}
- return super.createExtraData(round, parentHeader);
+ return bftExtraDataCodec.encode(
+ PqAnchorProducer.apply(base, parentHeader, protocolContext));
}
}
diff --git a/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/FalconSealValidationRule.java b/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/FalconSealValidationRule.java
new file mode 100755
index 000000000..b09edac3a
--- /dev/null
+++ b/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/FalconSealValidationRule.java
@@ -0,0 +1,501 @@
+/*
+ * Copyright contributors to Besu / Aere Network.
+ *
+ * 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.headervalidationrules;
+
+import static org.hyperledger.besu.consensus.common.bft.BftHelpers.calculateRequiredValidatorQuorum;
+
+import org.hyperledger.besu.consensus.common.bft.BftBlockHashing;
+import org.hyperledger.besu.consensus.common.bft.BftContext;
+import org.hyperledger.besu.consensus.common.bft.BftExtraData;
+import org.hyperledger.besu.consensus.common.bft.FalconSeal;
+import org.hyperledger.besu.consensus.common.bft.FalconSealSupport;
+import org.hyperledger.besu.consensus.qbft.QbftExtraDataCodec;
+import org.hyperledger.besu.datatypes.Address;
+import org.hyperledger.besu.datatypes.Hash;
+import org.hyperledger.besu.ethereum.ProtocolContext;
+import org.hyperledger.besu.ethereum.core.BlockHeader;
+import org.hyperledger.besu.ethereum.mainnet.AttachedBlockHeaderValidationRule;
+import org.hyperledger.besu.evm.account.Account;
+import org.hyperledger.besu.evm.worldstate.WorldState;
+
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.LinkedHashSet;
+import java.util.Optional;
+import java.util.Set;
+
+import org.apache.tuweni.units.bigints.UInt256;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Verifies the PARALLEL Falcon-512 post-quantum QUORUM CERTIFICATE embedded in a QBFT block header.
+ *
+ * <p>A Falcon quorum certificate is the set of Falcon-512 seals gossiped by validators on their QBFT
+ * commit messages (each a signature over the same commit hash the ECDSA committed seal signs),
+ * aggregated by the block assembler into the header's parallel Falcon-seal list.
+ *
+ * <p><b>AERE audit fix, ELIGIBLE-SIGNER BINDING (2026-07-18).</b> Both the Falcon quorum threshold
+ * AND the counted-seal set are now bound to ONE well-defined set:
+ *
+ * <pre>
+ * eligibleSigners = currentValidators (getValidatorsAfterBlock(parent))
+ * INTERSECT registeredValidatorAddresses (address-bound registry)
+ * quorum = ceil(2 * |eligibleSigners| / 3)
+ * valid = # distinct eligible validator addresses whose Falcon seal verifies
+ * accept (blocking) iff eligibleSigners is well-formed AND valid >= quorum
+ * </pre>
+ *
+ * <p>This closes the previous decoupling, where the quorum tracked the DYNAMIC validator-set size N
+ * (so a routine add-validator vote raised the quorum above the FIXED registry key count and halted
+ * the chain), while seals were counted by REGISTRY membership (so a removed validator's key kept
+ * counting). Binding both sides to {@code currentValidators INTERSECT registry} makes a
+ * validator-set change LIVE-SAFE (an added-but-unkeyed validator neither raises the Falcon quorum
+ * nor contributes to it, so the chain does not halt) and REMOVAL-SAFE (an ex-validator's key is not
+ * in the current validator set, so it is excluded from both eligibility and the count).
+ *
+ * <p><b>ECDSA stays decisive.</b> This rule runs AFTER {@code BftCommitSealsValidationRule}, which
+ * independently requires an ECDSA committed-seal quorum of {@code ceil(2N/3)} over the FULL current
+ * validator set N. A block is valid iff BOTH quorums pass, so the hybrid chain is a strict subset of
+ * the ECDSA-only chain and can never be weaker than ECDSA-only. The Falcon eligible-quorum over a
+ * subset of validators is the decisive post-quantum seal in the regime where ECDSA is forgeable; the
+ * arming invariant below keeps that subset equal to the full validator set at arm time.
+ *
+ * <p>Behaviour is fork-gated by {@code aere.falcon.forkBlock} / {@code AERE_FALCON_FORKBLOCK}:
+ *
+ * <ul>
+ * <li>Before the fork block (or when unconfigured, i.e. fork = Long.MAX_VALUE): the rule is
+ * LOG-ONLY and always returns {@code true}. The decisive seal remains the ECDSA committed-seal
+ * quorum, so a Falcon fault can never halt or split the chain. This preserves the additive,
+ * non-blocking baseline exactly.
+ * <li>At and after the fork block: the rule is BLOCKING. A block is REJECTED unless it carries a
+ * Falcon quorum certificate of at least {@code ceil(2 * |eligibleSigners| / 3)} distinct valid
+ * eligible-signer Falcon seals over the correct commit hash. A registry that is not
+ * address-bound, or an empty eligible set, is a fail-closed REJECT (surfaced at the fork
+ * boundary, i.e. arm time), NEVER an implicit accept.
+ * </ul>
+ *
+ * <p>ARMING INVARIANT (eligible-signer binding): blocking should be armed only when the registry
+ * COVERS the validator set (every current validator has a Falcon key), so that {@code
+ * eligibleSigners == currentValidators} and the Falcon quorum equals the ECDSA quorum with full
+ * fault margin. When coverage is incomplete the rule stays LIVE on the intersection (it does not
+ * halt) but logs a LOUD warning that the margin is reduced and a registry re-anchor is required.
+ * This is the "either the intersection keeps it live, or it fail-closes at arm time, never as a
+ * silent halt" contract.
+ *
+ * <p>STAGE-2 LATE-ANCHOR activation: when the node is configured with a late-anchor manifest
+ * ({@code aere.falcon.manifest} + {@code aere.falcon.anchor.address}) on a chain that launched
+ * WITHOUT a genesis manifest, this rule additionally polls the parent block's world state for the
+ * anchor contract. Once the contract exists and its storage slot 0 carries a hash equal to
+ * keccak256 of the local manifest, the registry activates ({@link
+ * FalconSealSupport#activateLateAnchor}); any mismatch is terminal and fail-closed. This is the
+ * no-re-genesis activation path for an already-live chain.
+ */
+public class FalconSealValidationRule implements AttachedBlockHeaderValidationRule {
+
+ private static final Logger LOG = LoggerFactory.getLogger(FalconSealValidationRule.class);
+
+ /**
+ * AERE 2026-08-07: how often the LOG-ONLY summary repeats when NOTHING has changed.
+ *
+ * <p>WHY THIS EXISTS, measured on a live node. The summary was written at INFO on EVERY imported
+ * block. On chain 2800 at ~523 ms per block that is <b>2295 lines in twenty minutes, about 165.000
+ * a day per node</b>, and every one of them said the same thing: {@code 0 of 0 seals,
+ * |eligible|=0, no-eligible-signers}. A line that cannot change carries no information, and a log
+ * that repeats one at that rate is a log an operator stops reading, which is how a real error
+ * gets missed.
+ *
+ * <p>WHAT IS KEPT. Every CHANGE of outcome still logs at INFO immediately, so an operator sees the
+ * transition into and out of quorum on the block it happens. Unchanged state logs once per
+ * heartbeat, so the line never disappears entirely from a quiet log. Everything else drops to
+ * DEBUG, where it is still available on demand.
+ *
+ * <p>10.000 blocks is about 87 minutes at 523 ms.
+ */
+ static final long LOG_HEARTBEAT_BLOCKS = 10_000L;
+
+ /**
+ * Last outcome logged at INFO, and the height it was logged at. Written from several import
+ * threads, so both are volatile and are only ever used to decide a LOG LEVEL. Nothing here can
+ * change what this rule returns: the throttle is applied strictly after the verdict is computed.
+ */
+ private volatile String lastLoggedOutcome = null;
+
+ private volatile long lastLoggedBlock = Long.MIN_VALUE;
+
+ private final QbftExtraDataCodec extraDataCodec = new QbftExtraDataCodec();
+
+ /**
+ * AERE ANCORA-V2 (2026-08-01): the height at and after which this rule RETIRES.
+ *
+ * <p>The V2 anchor scheme replaces this rule; it does not promote it. The reasons are measured and
+ * are properties of this file: {@code includeInLightValidation()} returns FALSE, so it never runs
+ * on a node that is fast-syncing headers; it returns {@code true} on every path including the
+ * exception path, so it has never been able to reject anything below the fork height; and its
+ * late-anchor lookup reads historical world state over a window of roughly 510 blocks, which does
+ * not exist on a syncing node. Retiring it at exactly the height the two anchor rules take over
+ * means exactly one regime is in force at any height, with no overlap and no gap.
+ *
+ * <p>{@link Long#MAX_VALUE} means "never retire", which is what the no-argument constructor gives,
+ * so every existing call site and test keeps today's behaviour unchanged.
+ */
+ private final long retirementBlock;
+
+ /** Default constructor: the rule never retires, i.e. exactly today's behaviour. */
+ public FalconSealValidationRule() {
+ this(Long.MAX_VALUE);
+ }
+
+ /**
+ * Decides whether the LOG-ONLY summary goes out at INFO, and records it if so.
+ *
+ * <p>Extracted so the throttle can be PROVEN without a log appender: a test that counts real log
+ * lines proves the plumbing, but a test on this method proves the rule, and the rule is what can
+ * be wrong. The end-to-end count is measured separately, on a live node.
+ *
+ * <p>Two reasons to log at INFO, and no others:
+ *
+ * <ul>
+ * <li>the outcome CHANGED since the last INFO, so an operator sees every transition on the block
+ * it happens
+ * <li>{@link #LOG_HEARTBEAT_BLOCKS} have passed, so the line never vanishes from a quiet log
+ * </ul>
+ *
+ * <p>This can only pick a log level. It is called strictly after the verdict is computed and its
+ * result is never read by anything that decides validity.
+ *
+ * @param rezumat a stable key for the outcome; equal keys mean nothing an operator cares about has
+ * changed
+ * @param blockNumber the height being validated
+ * @return true to log at INFO, false to drop to DEBUG
+ */
+ boolean shouldLogAtInfo(final String rezumat, final long blockNumber) {
+ final boolean seSchimba = !rezumat.equals(lastLoggedOutcome);
+ // Long.MIN_VALUE as "never logged" cannot be subtracted from without overflowing, and an
+ // overflow here would silently invert the comparison: the first block would take the DEBUG
+ // branch and the very first line, the one that tells an operator the rule is alive at all,
+ // would never appear.
+ final boolean bataieDeInima =
+ lastLoggedBlock == Long.MIN_VALUE || blockNumber - lastLoggedBlock >= LOG_HEARTBEAT_BLOCKS;
+ if (seSchimba || bataieDeInima) {
+ lastLoggedOutcome = rezumat;
+ lastLoggedBlock = blockNumber;
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Constructor with an explicit retirement height.
+ *
+ * @param retirementBlock the height at and after which this rule stands down in favour of the V2
+ * anchor rules; {@link Long#MAX_VALUE} to never retire
+ */
+ public FalconSealValidationRule(final long retirementBlock) {
+ this.retirementBlock = retirementBlock;
+ }
+
+ @Override
+ public boolean validate(
+ final BlockHeader header, final BlockHeader parent, final ProtocolContext protocolContext) {
+ // AERE ANCORA-V2: RETIREMENT GATE FIRST, before any singleton touch, any world-state lookup and
+ // any decode. From H this rule contributes nothing, and the two anchor rules carry the whole
+ // post-quantum verdict. Below H this branch is not taken and behaviour is bit-for-bit as before.
+ if (header.getNumber() >= retirementBlock) {
+ return true;
+ }
+
+ final FalconSealSupport pqc = FalconSealSupport.instance();
+
+ // STAGE-2: opportunistic late-anchor activation from the parent block's world state. Never
+ // throws; pre-activation this is a cheap account lookup per imported block until the anchor
+ // contract appears on-chain.
+ if (pqc.lateAnchorPending()) {
+ tryActivateLateAnchor(pqc, parent, protocolContext);
+ }
+
+ // AERE FIX-OPRIRE-CONSENS (c): a TERMINALLY FAILED late anchor (an on-chain anchor was observed
+ // and did NOT match the local manifest) is a tamper signal, not a "not yet" signal. The old code
+ // could not tell the two apart: both left genesisAnchored()==false and lateAnchored()==false, so
+ // BLOCKING was simply never armed and the whole PQC layer degraded to LOG-ONLY - it failed OPEN
+ // on exactly the input an attacker controls. FAILED now fails CLOSED at and after the fork
+ // block, while PENDING keeps the deliberate log-only behaviour that lets a legitimate anchor
+ // transaction still land (see the ARMING PRECONDITION note below).
+ final long forkBlock = pqc.forkBlock();
+ if (header.getNumber() >= forkBlock && pqc.lateAnchorFailed()) {
+ LOG.error(
+ "AERE PQC (BLOCKING, FAIL-CLOSED): block {} REJECTED - the late-anchor manifest is "
+ + "TERMINALLY FAILED. keccak256(local manifest) did not match the hash in the "
+ + "on-chain anchor contract {} slot 0, so the Falcon registry is permanently empty. "
+ + "This is a tampered or mismatched anchor, NOT an anchor that has yet to land, and "
+ + "it must not silently degrade PQC enforcement to log-only. Re-anchor with the "
+ + "correct manifest, or restart the node with the manifest that matches the chain.",
+ header.getNumber(),
+ pqc.anchorAddress());
+ return false;
+ }
+ // ARMING PRECONDITION: entering BLOCKING mode requires BOTH the fork height AND an ACTIVE
+ // anchored registry (genesis-anchored, or a late anchor already activated by tryActivateLateAnchor
+ // above). If forkBlock is armed at or before the late-anchor observation height, the registry is
+ // not yet active when the fork block is validated; blocking there rejects every block (empty
+ // registry => no eligible seal) and permanently HALTS the chain before the anchor-deploy
+ // transaction can settle. Staying LOG-ONLY until the registry is anchored lets the chain reach
+ // the anchor-deploy height, activate just-in-time at validate(D+1), and only THEN enforce the
+ // Falcon quorum. This never loses PQC enforcement: with no active registry there are no keys to
+ // verify against, so blocking would provide zero safety, only a halt.
+ final boolean forkReached = header.getNumber() >= forkBlock;
+ final boolean registryActive = pqc.genesisAnchored() || pqc.lateAnchored();
+ final boolean blocking = forkReached && registryActive;
+ // ARMED WITHOUT AN ACTIVE REGISTRY: leave a MARK, not only a line. The log-only answer below is
+ // the right answer for a header rule, and it is also how this condition used to vanish: the node
+ // was configured to enforce a post-quantum quorum, enforced nothing, and said so once per block
+ // into a file. The counter is readable from a test and from a JMX/diagnostic path; the WARN is
+ // emitted only on the first occurrence, because one line per block at a sub-second block period
+ // is itself a hazard.
+ if (forkReached
+ && !registryActive
+ && pqc.noteBlockingArmedWithoutActiveRegistry(header.getNumber())) {
+ LOG.warn(
+ "AERE PQC: block {} is at/after the Falcon fork block ({}) but the anchored registry is "
+ + "NOT yet active (genesisAnchored={}, lateAnchored={}, lateAnchorPending={}). Staying "
+ + "LOG-ONLY so the chain can reach the anchor height and activate; the Falcon quorum "
+ + "becomes BLOCKING only once the registry is anchored. If this persists, the "
+ + "late-anchor contract has not settled - deploy/observe it before relying on PQC "
+ + "enforcement.",
+ header.getNumber(),
+ forkBlock,
+ pqc.genesisAnchored(),
+ pqc.lateAnchored(),
+ pqc.lateAnchorPending());
+ }
+
+ try {
+ final BftExtraData extraData = extraDataCodec.decodeRaw(header.getExtraData());
+ final Collection<FalconSeal> falconSeals = extraData.getFalconSeals();
+
+ final BftContext bftContext = protocolContext.getConsensusContext(BftContext.class);
+ final Collection<Address> validators =
+ bftContext.getValidatorProvider().getValidatorsAfterBlock(parent);
+
+ // AERE FIX-OPRIRE-CONSENS (b): feed the authoritative validator set to the seal-ATTACHMENT
+ // gate. This is the only place in the QBFT stack that already holds a validator set bound to a
+ // height, and it runs on every imported block, i.e. always before the next round's commit.
+ // The gate refuses to attach until the anchored registry covers ALL of these addresses.
+ pqc.observeValidators(header.getNumber(), validators);
+
+ // AERE audit fix, ELIGIBLE-SIGNER BINDING: the eligible-signer set is the intersection of the
+ // CURRENT validator set with the address-bound signer registry. BOTH the quorum and the
+ // counted-seal set are derived from this ONE set, so neither can drift from the other.
+ final Set<Address> registered = pqc.registeredValidatorAddresses();
+ final Set<Address> eligible = new LinkedHashSet<>();
+ for (final Address v : validators) {
+ if (registered.contains(v)) {
+ eligible.add(v);
+ }
+ }
+ final int quorum = calculateRequiredValidatorQuorum(eligible.size());
+
+ final Hash commitHash =
+ new BftBlockHashing(extraDataCodec).calculateDataHashForCommittedSeal(header);
+
+ // Count DISTINCT ELIGIBLE validator addresses whose Falcon seal verifies over the commit hash.
+ // A seal counts only if its registry-bound address is an eligible signer (i.e. a current
+ // validator with a registered key); seals from registered-but-removed validators, or from
+ // unregistered indices, are excluded.
+ // REGISTRY HEIGHT BINDING (2026-08-06): resolve the key set AT THE HEIGHT OF THE HEADER
+ // CARRYING THE SEAL, not at this node's head. These seals are over THIS header's committed-seal
+ // hash, so the height is this header's own - unlike R2, whose certificate commits to the
+ // PARENT. The adversarial review measured this rule asking a height-less registry; below the
+ // arming height the resolver still answers from the head registry, so the 11.8 million blocks
+ // already on chain 2800 are checked exactly as before, but the rule can no longer be the
+ // reason a rotation makes history unverifiable.
+ final Set<Address> counted = new HashSet<>();
+ for (final FalconSeal seal : falconSeals) {
+ // REGISTRY HEIGHT BINDING, the HISTORY door: R1's seals are over THIS header's
+ // committed-seal hash, so the height is the header's own; the header still came from
+ // outside.
+ final Address signer =
+ pqc.addressForIndexAtHistoric(header.getNumber(), seal.getValidatorIndex());
+ if (signer == null || !eligible.contains(signer) || counted.contains(signer)) {
+ continue;
+ }
+ if (pqc.verifyAtHistoric(
+ header.getNumber(),
+ seal.getValidatorIndex(),
+ commitHash.getBytes(),
+ seal.getSignature())) {
+ counted.add(signer);
+ } else if (blocking) {
+ LOG.warn(
+ "AERE PQC (BLOCKING): block {} carried a Falcon seal from validator index {} "
+ + "(address {}) that did NOT verify. It does not count toward the quorum.",
+ header.getNumber(),
+ seal.getValidatorIndex(),
+ signer);
+ }
+ }
+ final int valid = counted.size();
+
+ if (blocking) {
+ // Fail-closed (arm-time surfaced): a registry that is not address-bound, or a validator set
+ // that shares no keyed signer with the registry, cannot carry a genuine eligible Falcon
+ // quorum. Reject rather than let ceil(2*0/3)=0 be trivially met (that would be fail-OPEN).
+ if (!pqc.addressBound() || eligible.isEmpty()) {
+ LOG.error(
+ "AERE PQC (BLOCKING): block {} REJECTED (fail-closed): no eligible Falcon signer set "
+ + "(addressBound={}, |eligible|={}, N={}, registrySize={}). Blocking requires an "
+ + "ADDRESS-BOUND registry that covers current validators; re-anchor before arming.",
+ header.getNumber(),
+ pqc.addressBound(),
+ eligible.size(),
+ validators.size(),
+ pqc.registrySize());
+ return false;
+ }
+
+ // ARMING INVARIANT (loud, non-halting): if the registry does not cover the full validator
+ // set, the chain stays LIVE on the intersection but the two-fault margin is reduced. This is
+ // the signal that a validator was added without re-anchoring; it is never a silent halt.
+ if (!pqc.registryCoversValidators(validators)) {
+ LOG.warn(
+ "AERE PQC (BLOCKING): block {} - registry does NOT cover the validator set "
+ + "(|eligible|={} < N={}). Running on the eligible intersection (LIVE, quorum={}), "
+ + "but two-fault liveness margin is reduced. A validator was added without an "
+ + "atomic registry re-anchor: RE-ANCHOR the Falcon manifest for the full validator "
+ + "set, in the same change that adds the validator, never as a later step.",
+ header.getNumber(),
+ eligible.size(),
+ validators.size(),
+ quorum);
+ }
+
+ if (valid >= quorum) {
+ LOG.info(
+ "AERE PQC (BLOCKING): block {} Falcon quorum certificate OK: {} distinct valid "
+ + "eligible seal(s) >= quorum {} of |eligible|={} (N={}). Post-quantum seal is "
+ + "consensus-enforced alongside ECDSA.",
+ header.getNumber(),
+ valid,
+ quorum,
+ eligible.size(),
+ validators.size());
+ return true;
+ }
+ LOG.warn(
+ "AERE PQC (BLOCKING): block {} REJECTED: only {} distinct valid eligible Falcon seal(s) "
+ + "< quorum {} of |eligible|={} (N={}). A post-fork block MUST carry a Falcon quorum "
+ + "certificate over the eligible-signer set.",
+ header.getNumber(),
+ valid,
+ quorum,
+ eligible.size(),
+ validators.size());
+ return false;
+ }
+
+ // Pre-fork: log-only, never blocks.
+ final String stare =
+ eligible.isEmpty()
+ ? "no-eligible-signers"
+ : (valid >= quorum ? "PQC-QUORUM-MET" : "PQC-quorum-not-yet");
+ // The verdict is already decided above. Everything below only picks a LOG LEVEL.
+ final String rezumat =
+ stare + "|" + valid + "|" + falconSeals.size() + "|" + eligible.size() + "|" + validators.size();
+ final boolean laInfo = shouldLogAtInfo(rezumat, header.getNumber());
+ final String mesaj =
+ "AERE PQC (LOG-ONLY): block {} -> {} of {} Falcon seal(s) verified over |eligible|={} "
+ + "(N={}); 2/3 eligible quorum would be {} [{}]. This check never blocks pre-fork; "
+ + "ECDSA committed seals remain decisive.";
+ if (laInfo) {
+ lastLoggedOutcome = rezumat;
+ lastLoggedBlock = header.getNumber();
+ LOG.info(
+ mesaj,
+ header.getNumber(),
+ valid,
+ falconSeals.size(),
+ eligible.size(),
+ validators.size(),
+ quorum,
+ stare);
+ } else {
+ LOG.debug(
+ mesaj,
+ header.getNumber(),
+ valid,
+ falconSeals.size(),
+ eligible.size(),
+ validators.size(),
+ quorum,
+ stare);
+ }
+ return true;
+ } catch (final Exception e) {
+ if (blocking) {
+ LOG.warn(
+ "AERE PQC (BLOCKING): block {} REJECTED due to a Falcon quorum check error: {}",
+ header.getNumber(),
+ e.toString());
+ return false;
+ }
+ // Pre-fork: NEVER block on a Falcon fault. Log and accept; ECDSA remains decisive.
+ LOG.debug("AERE PQC (LOG-ONLY): skipped Falcon check for a block: {}", e.toString());
+ return true;
+ }
+ }
+
+ /**
+ * STAGE-2: look up the anchor contract in the parent block's world state; if its storage slot 0
+ * is non-zero, attempt registry activation against that hash. Defensive: never throws, never
+ * blocks; until the contract exists this is a single account lookup per imported block.
+ */
+ private void tryActivateLateAnchor(
+ final FalconSealSupport pqc, final BlockHeader parent, final ProtocolContext protocolContext) {
+ try {
+ final Optional<WorldState> ws =
+ protocolContext.getWorldStateArchive().get(parent.getStateRoot(), parent.getHash());
+ if (ws.isEmpty()) {
+ return;
+ }
+ final Account account = ws.get().get(Address.fromHexString(pqc.anchorAddress()));
+ if (account == null) {
+ return;
+ }
+ final UInt256 slot0 = account.getStorageValue(UInt256.ZERO);
+ if (slot0 == null || slot0.isZero()) {
+ return;
+ }
+ pqc.activateLateAnchor(slot0.toBytes().toUnprefixedHexString());
+ } catch (final Exception e) {
+ LOG.debug(
+ "AERE PQC: late-anchor lookup skipped at parent {}: {}",
+ parent.getNumber(),
+ e.toString());
+ }
+ }
+
+ @Override
+ public boolean includeInLightValidation() {
+ return false;
+ }
+
+ @Override
+ public String toString() {
+ return "FalconSealValidation(fork-gated blocking, eligible-signer intersection, late-anchor "
+ + "capable, retiresAt="
+ + (retirementBlock == Long.MAX_VALUE ? "NEVER" : retirementBlock)
+ + ")";
+ }
+}
diff --git a/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorDigestAttachedRule.java b/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorDigestAttachedRule.java
new file mode 100755
index 000000000..b1a3ba265
--- /dev/null
+++ b/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorDigestAttachedRule.java
@@ -0,0 +1,115 @@
+/*
+ * Copyright contributors to Besu / Aere Network.
+ *
+ * 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.headervalidationrules;
+
+import org.hyperledger.besu.consensus.common.bft.PqAnchorConfig;
+import org.hyperledger.besu.ethereum.ProtocolContext;
+import org.hyperledger.besu.ethereum.core.BlockHeader;
+import org.hyperledger.besu.ethereum.mainnet.AttachedBlockHeaderValidationRule;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * R1A: the anchor digest rule again, on the ATTACHED side of the validator.
+ *
+ * <p><b>Why a second copy of a rule that already exists.</b> {@link PqAnchorDigestRule} is DETACHED.
+ * Besu decides which rules run from the {@code HeaderValidationMode}, and the two facts that matter
+ * were measured, not assumed:
+ *
+ * <ul>
+ * <li>{@code HeaderValidationMode.SKIP_DETACHED} keeps exactly the rules for which {@code
+ * isDetachedSupported()} is false. That is the mode {@code FullImportBlockStep} passes to
+ * {@code BlockImporter.importBlock} for every block acquired by full sync. So a detached rule
+ * does NOT run at import.
+ * <li>{@code DETACHED_ONLY}, the mode under which a detached rule does run during download, is
+ * applied by {@code DownloadHeaderSequenceTask} and therefore only on ranges that {@code
+ * SyncTargetRange.hasEnd()} reports as closed. The open-ended range at the tip of a recovery
+ * took the other branch of {@code DownloadHeadersStep} and was validated by nothing.
+ * </ul>
+ *
+ * <p>Together those two facts left a hole of the last {@code downloader-header-request-size} minus
+ * one blocks, 199 by default, of every catch-up: on those the certificate binding was checked by no
+ * rule at all. A node that had been down for under about 103 seconds recovered its entire gap
+ * through that window. This rule closes it from the other side: since it is ATTACHED it survives
+ * {@code SKIP_DETACHED}, so the binding is now enforced on every block that enters the database, by
+ * whichever path it arrived.
+ *
+ * <p><b>It is the same verdict, not a second opinion.</b> The check is delegated to the one {@link
+ * PqAnchorDigestRule} instance rather than reimplemented, so there is exactly one definition of the
+ * anchor digest and no way for the two paths to drift apart. The delegate ignores its {@code parent}
+ * argument entirely - the digest is computed from {@code header.getParentHash()}, {@code
+ * header.getNumber() - 1}, the configured chain id and element 6 of the header's own extraData - so
+ * running it from an attached position is not merely permissible, it is the same pure function of
+ * the same header.
+ *
+ * <p><b>Deliberately NOT in light validation.</b> {@link PqAnchorDigestRule} already covers every
+ * light mode. Returning false here keeps this rule out of proposal-time validation and confines it
+ * to {@code FULL} and {@code SKIP_DETACHED}, which is the import path. That is the smallest change
+ * that closes the hole, and it means the cost added to a path that was already paying for the check
+ * is nil.
+ *
+ * <p><b>Inactive below H.</b> The delegate's height gate is its first statement, before any decode,
+ * so below the activation height this rule is one virtual call and a comparison. A binary carrying
+ * it behaves exactly as today on the whole existing chain.
+ */
+public class PqAnchorDigestAttachedRule implements AttachedBlockHeaderValidationRule {
+
+ private static final Logger LOG = LoggerFactory.getLogger(PqAnchorDigestAttachedRule.class);
+
+ private final PqAnchorConfig config;
+ private final PqAnchorDigestRule delegate;
+
+ /**
+ * Instantiates the attached half of the anchor digest rule.
+ *
+ * @param config the height-indexed activation configuration
+ */
+ public PqAnchorDigestAttachedRule(final PqAnchorConfig config) {
+ this.config = config;
+ this.delegate = new PqAnchorDigestRule(config);
+ }
+
+ @Override
+ public boolean validate(
+ final BlockHeader header, final BlockHeader parent, final ProtocolContext protocolContext) {
+ final boolean ok = delegate.validate(header, parent);
+ if (!ok) {
+ // The delegate has already logged WHY. This line exists so that the path can be told apart in
+ // a log: a rejection carrying R1A was caught while the block was being imported, which is the
+ // window that used to be open, whereas R1 alone means it was caught while headers were being
+ // downloaded.
+ LOG.warn(
+ "AERE PQ ANCHOR (R1A): block {} REJECTED on the ATTACHED path. This is the import-time "
+ + "copy of the digest binding; it is what catches a stripped or swapped certificate "
+ + "on the open-ended range at the tip of a sync, which detached validation never "
+ + "reaches.",
+ header.getNumber());
+ }
+ return ok;
+ }
+
+ @Override
+ public boolean includeInLightValidation() {
+ return false;
+ }
+
+ @Override
+ public String toString() {
+ return "PqAnchorDigestAttached(attached, not-light, "
+ + (config.everActive() ? "H=" + config.anchorBlock() : "INACTIVE")
+ + ")";
+ }
+}
diff --git a/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorDigestRule.java b/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorDigestRule.java
new file mode 100755
index 000000000..c79a884ad
--- /dev/null
+++ b/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorDigestRule.java
@@ -0,0 +1,207 @@
+/*
+ * Copyright contributors to Besu / Aere Network.
+ *
+ * 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.headervalidationrules;
+
+import org.hyperledger.besu.consensus.common.bft.BftExtraData;
+import org.hyperledger.besu.consensus.common.bft.BftExtraDataCodec;
+import org.hyperledger.besu.consensus.common.bft.FalconSeal;
+import org.hyperledger.besu.consensus.common.bft.PqAnchor;
+import org.hyperledger.besu.consensus.common.bft.PqAnchorConfig;
+import org.hyperledger.besu.consensus.qbft.QbftExtraDataCodec;
+import org.hyperledger.besu.ethereum.core.BlockHeader;
+import org.hyperledger.besu.ethereum.mainnet.DetachedBlockHeaderValidationRule;
+
+import java.util.List;
+
+import org.apache.tuweni.bytes.Bytes;
+import org.apache.tuweni.bytes.Bytes32;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * R1: the anchor DIGEST rule. DETACHED, and deliberately part of LIGHT validation.
+ *
+ * <p>This rule is the whole point of the V2 scheme. It is what makes the sentence "the post-quantum
+ * signature protects the block" true rather than decorative: the block's own vanityData, which IS in
+ * the block-hash pre-image, carries the digest of the certificate the block carries for its parent.
+ * Strip the certificate, swap it for a different but equally valid one, or reorder it, and the digest
+ * no longer matches and the header is refused.
+ *
+ * <p><b>Pure function of the header.</b> It reads {@code header.getParentHash()}, {@code
+ * header.getNumber() - 1}, the configured chain id, and element 6 of the header's OWN extraData. No
+ * state, no registry, no loaded parent, no Falcon. One keccak. That is why it is cheap enough to be
+ * declared part of light validation, and it is so declared.
+ *
+ * <p><b>WHERE IT ACTUALLY RUNS. MEASURED 2026-08-02, and this paragraph CORRECTS an earlier claim in
+ * this same file.</b> The earlier text read "a node that fast-syncs headers still catches a stripped
+ * or swapped certificate". That sentence is false in every configuration this binary can reach, and
+ * it was re-measured against the compiled jars of this tree:
+ *
+ * <ul>
+ * <li>{@code SyncMode} in this Besu has exactly TWO values, {@code FULL} and {@code SNAP}. There is
+ * no fast sync here to speak of. [MEASURED: enumerated from the compiled {@code SyncMode}.]
+ * <li>With the anchor armed, {@code PqAnchorSyncModeGuard} REFUSES TO START any node whose mode is
+ * not {@code FULL}. {@code SNAP}, null, blank and unrecognised all abort. Refusing to start is
+ * not catching anything. [MEASURED: 7 of 7 assertions against the compiled guard, and
+ * reproduced on a running node.]
+ * <li>With the anchor NOT armed, or disarmed by the emergency option, every mode starts again and
+ * this rule is inert by its own height gate. [MEASURED: both negative controls of the same
+ * probe, which is what shows the probe is measuring the anchor and not the mode string.]
+ * </ul>
+ *
+ * <p>So do not read the light-validation flag on this rule as a statement about a syncing node. What
+ * makes this rule reach the history a node ACCEPTS is two other things, in two other files: {@code
+ * DownloadHeadersStep} now applying the same validation policy to OPEN-ENDED ranges that it already
+ * applied to closed ones, and {@link PqAnchorDigestAttachedRule}, which carries the identical verdict
+ * on the ATTACHED side so that it survives {@code SKIP_DETACHED} at import. Before those two existed
+ * an honest node with its data directory deleted imported stripped-certificate headers in silence and
+ * reached head 350 with ZERO occurrences of this rule. [MEASURED on a node restarted from an empty
+ * data directory; and the NEGATIVE CONTROL, the same run with the two guards stubbed out,
+ * reproduces exactly that silence, which is what shows the observation is about the guards and not
+ * about the run.]
+ *
+ * <p><b>includeInLightValidation, measured.</b> Both {@code DetachedBlockHeaderValidationRule} and
+ * {@code AttachedBlockHeaderValidationRule} declare {@code includeInLightValidation()} with a DEFAULT
+ * of {@code true}. {@code BlockHeaderValidator.Builder.addRule(DetachedBlockHeaderValidationRule)}
+ * snapshots that value at build time into the wrapping {@code Rule}, and the modes {@code
+ * LIGHT_DETACHED_ONLY}, {@code LIGHT} and {@code DETACHED_ONLY} all admit a detached rule whose flag
+ * is true. This rule overrides the method anyway, explicitly returning true, so the property is
+ * asserted by this file rather than inherited silently from an upstream default that a Besu rebase
+ * could change under us.
+ *
+ * <p><b>Inactive below H.</b> The height gate is the FIRST statement, before any decode. Below H this
+ * rule does not even parse the header, so a binary carrying it behaves exactly as today on the whole
+ * existing chain. That is the condition under which such a binary can be warmed on a live node.
+ *
+ * <p><b>Fail-closed above H.</b> At or above H, anything that prevents the digest from being computed
+ * and matched is a REJECT: a non-canonical or undecodable extraData, a vanity field that is not 32
+ * bytes, a certificate whose indices are not strictly increasing, or a mismatching digest. Never an
+ * implicit accept, and specifically never "true on exception", which is the defect that made the
+ * legacy {@code FalconSealValidationRule} unable to catch anything.
+ *
+ * <p>The residual exposure this does NOT close, stated plainly: the certificate is outside the
+ * block-hash pre-image, so a hostile peer can serve a header with the certificate mangled and that
+ * header becomes unimportable although its block hash is unchanged. That is denial of service at
+ * header service, not forgery, and it is the same exposure the ECDSA committed seals already carry
+ * today.
+ */
+public class PqAnchorDigestRule implements DetachedBlockHeaderValidationRule {
+
+ private static final Logger LOG = LoggerFactory.getLogger(PqAnchorDigestRule.class);
+
+ private final PqAnchorConfig config;
+ private final QbftExtraDataCodec extraDataCodec = new QbftExtraDataCodec();
+
+ /**
+ * Instantiates the anchor digest rule.
+ *
+ * @param config the height-indexed activation configuration
+ */
+ public PqAnchorDigestRule(final PqAnchorConfig config) {
+ this.config = config;
+ }
+
+ @Override
+ public boolean validate(final BlockHeader header, final BlockHeader parent) {
+ final long number = header.getNumber();
+
+ // HEIGHT GATE FIRST. Below H nothing at all happens: no decode, no allocation, no log. This is
+ // the property that lets a binary built from this code run on a live node before activation and
+ // behave bit-for-bit as today. From 2026-08-07 the gate also covers the interval: at a height
+ // that carries no certificate there is nothing to bind, so there is nothing to judge.
+ //
+ // THE SAME METHOD AS R2, and not two questions that merely resemble each other. See the note in
+ // PqAnchorSealsRule.
+ if (!config.anchorAppliesAt(number)) {
+ return true;
+ }
+
+ try {
+ final BftExtraData extraData = extraDataCodec.decodeRaw(header.getExtraData());
+
+ final Bytes vanity = extraData.getVanityData();
+ if (vanity.size() != BftExtraDataCodec.EXTRA_VANITY_LENGTH) {
+ LOG.warn(
+ "AERE PQ ANCHOR (R1): block {} REJECTED - vanityData is {} bytes, expected {}. From H "
+ + "the whole vanity field is the anchor digest D.",
+ number,
+ vanity.size(),
+ BftExtraDataCodec.EXTRA_VANITY_LENGTH);
+ return false;
+ }
+
+ final List<FalconSeal> certificate = List.copyOf(extraData.getFalconSeals());
+ // Height aware since 2026-08-11. Inside the one measured historical window the ordering
+ // requirement relaxes from SORTED to DISTINCT, because 36 canonical headers were written
+ // unsorted during a production interruption and a node syncing from genesis would otherwise
+ // stop at the first of them and never pass it. Distinctness is not relaxed at any height, so a
+ // repeated index stays unrepresentable everywhere. See PqAnchor.hasAcceptableIndices.
+ if (!PqAnchor.hasAcceptableIndices(certificate, number)) {
+ // Checked HERE, not only in the seals rule, so reordering and duplicate indices are caught
+ // in LIGHT validation too, and so that "the order given" and "sorted" are the same list for
+ // every header this rule accepts.
+ LOG.warn(
+ "AERE PQ ANCHOR (R1): block {} REJECTED - the carried certificate's validator indices "
+ + "are not strictly increasing ({} seal(s)). Exactly one order is accepted for a "
+ + "given set, which is also what makes a repeated index unrepresentable.",
+ number,
+ certificate.size());
+ return false;
+ }
+
+ final Bytes32 expected =
+ PqAnchor.anchorDigest(
+ config.chainId(), number - 1L, header.getParentHash().getBytes(), certificate);
+
+ if (!expected.equals(Bytes32.wrap(vanity))) {
+ LOG.warn(
+ "AERE PQ ANCHOR (R1): block {} REJECTED - anchor digest mismatch. vanityData carries "
+ + "{} but the certificate actually carried in this header ({} seal(s), over parent "
+ + "{} at height {}) digests to {}. The certificate was stripped, replaced or "
+ + "reordered after the proposer wrote it.",
+ number,
+ vanity,
+ certificate.size(),
+ header.getParentHash(),
+ number - 1L,
+ expected);
+ return false;
+ }
+
+ return true;
+ } catch (final Exception e) {
+ // FAIL CLOSED. The legacy Falcon rule returned true on every path including this one, which is
+ // why it could never catch anything. A rule that cannot compute its verdict has not proved the
+ // header good.
+ LOG.warn(
+ "AERE PQ ANCHOR (R1): block {} REJECTED - the anchor digest could not be computed: {}",
+ number,
+ e.toString());
+ return false;
+ }
+ }
+
+ @Override
+ public boolean includeInLightValidation() {
+ return true;
+ }
+
+ @Override
+ public String toString() {
+ return "PqAnchorDigest(detached, light, "
+ + (config.everActive() ? "H=" + config.anchorBlock() : "INACTIVE")
+ + ")";
+ }
+}
diff --git a/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorSealsRule.java b/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorSealsRule.java
new file mode 100755
index 000000000..a4350747a
--- /dev/null
+++ b/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorSealsRule.java
@@ -0,0 +1,329 @@
+/*
+ * Copyright contributors to Besu / Aere Network.
+ *
+ * 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.headervalidationrules;
+
+import org.hyperledger.besu.consensus.common.bft.BftContext;
+import org.hyperledger.besu.consensus.common.bft.BftExtraData;
+import org.hyperledger.besu.consensus.common.bft.FalconSeal;
+import org.hyperledger.besu.consensus.common.bft.FalconSealSupport;
+import org.hyperledger.besu.consensus.common.bft.PqAnchor;
+import org.hyperledger.besu.consensus.common.bft.PqAnchorConfig;
+import org.hyperledger.besu.consensus.common.bft.PqSignerRegistry;
+import org.hyperledger.besu.consensus.qbft.QbftExtraDataCodec;
+import org.hyperledger.besu.datatypes.Address;
+import org.hyperledger.besu.ethereum.ProtocolContext;
+import org.hyperledger.besu.ethereum.core.BlockHeader;
+import org.hyperledger.besu.ethereum.mainnet.AttachedBlockHeaderValidationRule;
+
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+import org.apache.tuweni.bytes.Bytes32;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * R2: the anchor SEALS rule. ATTACHED, full validation only.
+ *
+ * <p>Where R1 proves that the header commits to the certificate it carries, R2 proves the
+ * certificate is worth something. It checks exactly four things, and every one of them is a function
+ * of the header, the parent header, and configuration. NONE of them is a comparison against what
+ * this node happened to hear.
+ *
+ * <ol>
+ * <li>k = |C| is at least K(height), the staged threshold;
+ * <li>the validator indices are STRICTLY increasing, so there is one accepted order and a repeated
+ * index is unrepresentable;
+ * <li>every index maps, through the registry, to an address in the PARENT's validator set;
+ * <li>every signature verifies as Falcon-512 over M(parent).
+ * </ol>
+ *
+ * <p><b>Why the "what I heard" comparison is absent, and why that is the entire point.</b> Measured
+ * on 200 consecutive live headers read simultaneously from two of our own nodes, with the block hash
+ * identical 200/200: the seal ORDER differed on 68 and the seal SET differed on 11. Any rule that
+ * required the carried certificate to match the local view would therefore reject roughly one header
+ * in eighteen with no attacker present, and at quorum 5 of 7 that is repeated stalling. Here a node
+ * that heard 7 accepts the proposer's certificate of 5, because Falcon verification is public: the
+ * validator does not need to have heard those commit messages to check them. The same construction
+ * also removes the third divergence axis for free, the one where a node signs the same block twice
+ * (once in {@code updateStateWithProposedBlock}, once in {@code peerIsPrepared}): both signatures are
+ * valid, the proposer writes one, everyone verifies it, and whether Falcon signing is deterministic
+ * stops mattering. That question remains NOT MEASURED and, under this scheme, no longer needs to be.
+ *
+ * <p><b>Not in light validation.</b> This rule needs the consensus context and the registry, so
+ * {@code includeInLightValidation()} returns false. The split is deliberate: the part that binds the
+ * certificate to the block hash is one keccak and runs everywhere, the part that costs k Falcon
+ * verifications runs only where state exists. Measured Falcon verification is 0.068 ms median and
+ * 0.249 ms p95, so five verifications are under 1.3 ms against a measured 517 ms block interval.
+ *
+ * <p><b>Inactive below H</b>, checked as the first statement, before any decode.
+ *
+ * <p><b>Fail-closed above H</b>, on every path including exceptions and including a missing or
+ * mismatched parent.
+ *
+ * <p>LIVENESS COST, stated because it is real: the threshold makes the certificate a liveness
+ * condition. A proposer that cannot gather K seals for the parent cannot propose at all. At N=7 the
+ * quorum is 5 and N-f is 5, i.e. ZERO margin, which is why the validator set must reach N&gt;=9
+ * before this is armed with a quorum threshold. How often that stalls in practice is NOT MEASURED,
+ * because nothing has been run.
+ */
+public class PqAnchorSealsRule implements AttachedBlockHeaderValidationRule {
+
+ private static final Logger LOG = LoggerFactory.getLogger(PqAnchorSealsRule.class);
+
+ private final PqAnchorConfig config;
+ private final PqSignerRegistry registry;
+ private final QbftExtraDataCodec extraDataCodec = new QbftExtraDataCodec();
+
+ /**
+ * Instantiates the anchor seals rule against the production Falcon registry.
+ *
+ * @param config the height-indexed activation configuration
+ */
+ public PqAnchorSealsRule(final PqAnchorConfig config) {
+ this(config, PqSignerRegistry.falconSealSupport());
+ }
+
+ /**
+ * Instantiates the anchor seals rule against an explicit registry.
+ *
+ * @param config the height-indexed activation configuration
+ * @param registry the index-to-signer view used for eligibility and verification
+ */
+ public PqAnchorSealsRule(final PqAnchorConfig config, final PqSignerRegistry registry) {
+ this.config = config;
+ this.registry = registry;
+ }
+
+ @Override
+ public boolean validate(
+ final BlockHeader header, final BlockHeader parent, final ProtocolContext protocolContext) {
+ final long number = header.getNumber();
+
+ // HEIGHT GATE FIRST, same contract as R1: below H this rule does nothing at all, and from
+ // 2026-08-07 it also does nothing at a height that carries no certificate.
+ //
+ // BOTH RULES ASK THE SAME QUESTION, THROUGH THE SAME METHOD, and that is deliberate.
+ // anchorAppliesAt() = activeAt() AND isAnchorHeight(). If R1 and R2 each asked it their own
+ // way, a header accepted by one and refused by the other would be a chain break, appearing at
+ // exactly the first height that is not an anchor height, i.e. the day after activation.
+ if (!config.anchorAppliesAt(number)) {
+ return true;
+ }
+
+ try {
+ if (parent == null) {
+ LOG.warn(
+ "AERE PQ ANCHOR (R2): block {} REJECTED - no parent header available, so the "
+ + "certificate cannot be checked against the parent it claims to attest to.",
+ number);
+ return false;
+ }
+ // The certificate attests to the parent by NUMBER and HASH, and R1 has already bound the
+ // digest to header.getParentHash(). If the parent handed to this rule were a different header,
+ // R1 and R2 would be talking about two different objects. Cheap, and it removes any dependence
+ // on rule ordering with AncestryValidationRule.
+ if (!parent.getHash().equals(header.getParentHash())
+ || parent.getNumber() != number - 1L) {
+ LOG.warn(
+ "AERE PQ ANCHOR (R2): block {} REJECTED - the supplied parent (number {}, hash {}) is "
+ + "not the parent this header names (number {}, hash {}).",
+ number,
+ parent.getNumber(),
+ parent.getHash(),
+ number - 1L,
+ header.getParentHash());
+ return false;
+ }
+
+ // AERE REGISTRY-COVERAGE REPORT (2026-08-02). DIAGNOSTIC ONLY, and deliberately before every
+ // early return below so it runs at every height from H, including the warm-up where K is 0
+ // and the certificate is empty. It feeds FalconSealSupport's coverage report, whose only
+ // previous source was FalconSealValidationRule - a rule that stands down at exactly the
+ // height THIS rule takes over. Above H nothing updated it, so the report was either frozen at
+ // a set from below H or, on a node whose process started above H, never set at all. Nothing
+ // here can change this rule's verdict: the call cannot throw and its result is not read.
+ observeValidatorsForDiagnostics(parent, protocolContext);
+
+ final BftExtraData extraData = extraDataCodec.decodeRaw(header.getExtraData());
+ final List<FalconSeal> certificate = List.copyOf(extraData.getFalconSeals());
+ final int k = certificate.size();
+ final int required = config.minSealsAt(number);
+
+ if (k < required) {
+ LOG.warn(
+ "AERE PQ ANCHOR (R2): block {} REJECTED - the carried certificate has {} seal(s), "
+ + "below the threshold K={} in force at this height.",
+ number,
+ k,
+ required);
+ return false;
+ }
+
+ // Height aware since 2026-08-11, same measured historical window as rule R1. See
+ // PqAnchor.hasAcceptableIndices for why the exception sits at VALIDATION and nowhere else, and
+ // for what it deliberately does NOT relax.
+ if (!PqAnchor.hasAcceptableIndices(certificate, number)) {
+ LOG.warn(
+ "AERE PQ ANCHOR (R2): block {} REJECTED - the certificate's validator indices are not "
+ + "strictly increasing.",
+ number);
+ return false;
+ }
+
+ if (k == 0) {
+ // Legitimate only while K is 0, which is the warm-up stage that starts at H itself, where
+ // the parent predates the scheme and no certificate can exist. R1 has already checked that
+ // the vanity digest is the digest OF THE EMPTY certificate, so an empty certificate is not
+ // an unchecked one.
+ LOG.debug(
+ "AERE PQ ANCHOR (R2): block {} carries an empty certificate and K={} at this height.",
+ number,
+ required);
+ return true;
+ }
+
+ final BftContext bftContext = protocolContext.getConsensusContext(BftContext.class);
+ final Collection<Address> parentValidators =
+ bftContext.getValidatorProvider().getValidatorsForBlock(parent);
+ if (parentValidators == null || parentValidators.isEmpty()) {
+ LOG.warn(
+ "AERE PQ ANCHOR (R2): block {} REJECTED - the validator set of the parent block {} "
+ + "could not be resolved, so seal eligibility cannot be decided.",
+ number,
+ parent.getNumber());
+ return false;
+ }
+ final Set<Address> eligible = new HashSet<>(parentValidators);
+
+ final Bytes32 message =
+ PqAnchor.commitMessage(
+ config.chainId(), parent.getNumber(), parent.getHash().getBytes());
+
+ final Set<Address> counted = new HashSet<>();
+ for (final FalconSeal seal : certificate) {
+ final int index = seal.getValidatorIndex();
+ // REGISTRY ROTATION: the key set is resolved AT THE PARENT'S HEIGHT, because that is the
+ // block these seals commit to. Resolving at this block's height would be wrong by one block,
+ // and at a rotation height "wrong by one block" is a different key set.
+ // REGISTRY HEIGHT BINDING, the HISTORY door: this rule judges a header this node received,
+ // at the PARENT's height, which is the height the certificate commits to. It must refuse a
+ // height it has no registry binding for rather than answer from its own head.
+ final Address signer =
+ registry.addressForIndexAtHistoric(parent.getNumber(), index);
+ if (signer == null) {
+ LOG.warn(
+ "AERE PQ ANCHOR (R2): block {} REJECTED - certificate carries index {}, which the "
+ + "registry does not bind to any validator address. An unbound index cannot be "
+ + "shown to be eligible, so it is refused rather than skipped.",
+ number,
+ index);
+ return false;
+ }
+ if (!eligible.contains(signer)) {
+ LOG.warn(
+ "AERE PQ ANCHOR (R2): block {} REJECTED - certificate carries index {} (address {}), "
+ + "which was NOT a validator of the parent block {}.",
+ number,
+ index,
+ signer,
+ parent.getNumber());
+ return false;
+ }
+ if (!counted.add(signer)) {
+ LOG.warn(
+ "AERE PQ ANCHOR (R2): block {} REJECTED - two distinct registry indices, including "
+ + "{}, resolve to the same validator address {}. That would let one validator be "
+ + "counted twice despite strictly increasing indices.",
+ number,
+ index,
+ signer);
+ return false;
+ }
+ if (!registry.verifyAtHistoric(
+ parent.getNumber(), index, message, seal.getSignature())) {
+ LOG.warn(
+ "AERE PQ ANCHOR (R2): block {} REJECTED - the Falcon signature from index {} "
+ + "(address {}) does not verify over M(parent {}).",
+ number,
+ index,
+ signer,
+ parent.getNumber());
+ return false;
+ }
+ }
+
+ LOG.debug(
+ "AERE PQ ANCHOR (R2): block {} accepted with {} verified Falcon seal(s) over parent {} "
+ + "(threshold K={}, |parent validators|={}).",
+ number,
+ k,
+ parent.getNumber(),
+ required,
+ eligible.size());
+ return true;
+ } catch (final Exception e) {
+ LOG.warn(
+ "AERE PQ ANCHOR (R2): block {} REJECTED - the certificate could not be checked: {}",
+ number,
+ e.toString());
+ return false;
+ }
+ }
+
+ /**
+ * AERE REGISTRY-COVERAGE REPORT: hand the parent's validator set to {@link FalconSealSupport} so
+ * its coverage report is about the height the chain is actually at. Swallows everything: a rule
+ * that rejected a header because a diagnostic threw would be a worse defect than the one this
+ * repairs.
+ *
+ * @param parent the parent header
+ * @param protocolContext the protocol context
+ */
+ private void observeValidatorsForDiagnostics(
+ final BlockHeader parent, final ProtocolContext protocolContext) {
+ try {
+ final Collection<Address> validators =
+ protocolContext
+ .getConsensusContext(BftContext.class)
+ .getValidatorProvider()
+ .getValidatorsForBlock(parent);
+ if (validators != null && !validators.isEmpty()) {
+ FalconSealSupport.instance().observeValidators(parent.getNumber(), validators);
+ }
+ } catch (final Exception e) {
+ LOG.debug(
+ "AERE PQ ANCHOR (R2): validator-set observation skipped at parent {}: {}",
+ parent.getNumber(),
+ e.toString());
+ }
+ }
+
+ @Override
+ public boolean includeInLightValidation() {
+ return false;
+ }
+
+ @Override
+ public String toString() {
+ return "PqAnchorSeals(attached, full, "
+ + (config.everActive() ? "H=" + config.anchorBlock() : "INACTIVE")
+ + ", registry="
+ + registry
+ + ")";
+ }
+}
diff --git a/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqEmergencyShoutRule.java b/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqEmergencyShoutRule.java
new file mode 100755
index 000000000..0f2ac709a
--- /dev/null
+++ b/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqEmergencyShoutRule.java
@@ -0,0 +1,134 @@
+/*
+ * Copyright contributors to Besu / Aere Network.
+ *
+ * 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.headervalidationrules;
+
+import org.hyperledger.besu.consensus.common.bft.PqAnchorConfig;
+import org.hyperledger.besu.ethereum.core.BlockHeader;
+import org.hyperledger.besu.ethereum.mainnet.DetachedBlockHeaderValidationRule;
+
+import java.util.concurrent.atomic.AtomicLong;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * AERE OPTIUNI-URGENTA (2026-08-02): the NOISE half of the emergency controls.
+ *
+ * <p><b>The rule this exists to obey.</b> A quiet way out is worse than no way out. An emergency
+ * control that silently degrades what a node checks will be set once at three in the morning by
+ * somebody who means to remove it in the morning, and will still be set six months later, on a fleet
+ * everybody believes is enforcing something it is not. So each of the three controls costs the
+ * operator a complete ERROR line at EVERY height for as long as it is in force: not a startup
+ * banner that scrolls away, not a metric nobody has a dashboard for, a line in the log of a running
+ * node that never stops.
+ *
+ * <p><b>Why a header rule.</b> The alternative is a timer, and a timer would have to invent its own
+ * idea of the current height and would drift away from the code that actually decides anything. This
+ * rule is asked once per header by the same validator that decides whether the header may enter the
+ * chain, and it reads the SAME {@link PqAnchorConfig} object the anchor rules read, so what it
+ * announces cannot disagree with what is enforced.
+ *
+ * <p><b>It cannot reject anything.</b> {@link #validate} returns true on every path, including its
+ * exception path. That is deliberate and it is what makes it safe to place FIRST in the ruleset: a
+ * rule that always returns true cannot change the verdict of a conjunction, so putting it first
+ * changes nothing except that the announcement is made even on headers a later rule goes on to
+ * reject - which is exactly when an operator most needs to know that a control was overridden.
+ *
+ * <p><b>It says nothing when nothing is overridden.</b> No line is emitted at all when no control is
+ * in force, so "the log contains no AERE-PQC-EMG line" is a measurable statement about a node, and
+ * the silence is the control case that proves the shout is caused by the override and not by the
+ * rule merely existing.
+ *
+ * <p>The registry bypass announces itself from {@code FalconSealSupport}, on the same per-height
+ * basis, because only that class knows whether the bypass is actually carrying weight at a height.
+ */
+public class PqEmergencyShoutRule implements DetachedBlockHeaderValidationRule {
+
+ private static final Logger LOG = LoggerFactory.getLogger(PqEmergencyShoutRule.class);
+
+ private final PqAnchorConfig config;
+
+ /** Highest height already announced, so one line is emitted per height and not per validation. */
+ private final AtomicLong lastShout = new AtomicLong(-1L);
+
+ /**
+ * Instantiates the emergency announcement rule.
+ *
+ * @param config the height-indexed anchor configuration the anchor rules are running on; the
+ * announcement is derived from THIS object, not from a second reading of the configuration
+ */
+ public PqEmergencyShoutRule(final PqAnchorConfig config) {
+ this.config = config;
+ }
+
+ @Override
+ public boolean validate(final BlockHeader header, final BlockHeader parent) {
+ try {
+ final long number = header.getNumber();
+
+ // A configured anchor that has been switched off is a different state from an anchor that was
+ // never configured. Only the first is worth waking somebody for.
+ final boolean disarmed = config.disabled() && config.anchorConfigured();
+ final boolean lowered = config.emergencyCeilingLowersAt(number);
+
+ if (!disarmed && !lowered) {
+ return true;
+ }
+ if (lastShout.getAndSet(number) == number) {
+ return true;
+ }
+
+ if (disarmed) {
+ LOG.error(
+ "AERE PQC EMERGENCY [AERE-PQC-EMG-DISARM-01]: height {} validated WITH THE CERTIFICATE "
+ + "ANCHOR DISARMED. An activation height ({}) is configured on this node and both "
+ + "anchor rules have been switched off by {}, so this node is NOT checking that the "
+ + "post-quantum certificate in a header is the one the proposer wrote, and is NOT "
+ + "enforcing any seal threshold. A stripped or swapped certificate is invisible to "
+ + "this node. WHAT TO DO: this is a RECOVERY setting; once the cause is fixed, "
+ + "restart WITHOUT it, together with the rest of the fleet. THIS LINE REPEATS AT "
+ + "EVERY HEIGHT, BY DESIGN.",
+ number,
+ config.anchorBlock(),
+ PqAnchorConfig.PROPERTY_DISABLE);
+ }
+ if (lowered) {
+ LOG.error(
+ "AERE PQC EMERGENCY [AERE-PQC-EMG-CEILING-01]: height {} validated WITH A LOWERED SEAL "
+ + "THRESHOLD. The schedule in force asks for K={} at this height; the emergency "
+ + "ceiling {} caps it, so the threshold actually enforced is K={}. Fewer "
+ + "post-quantum signatures stand behind every block accepted while this is set. The "
+ + "ceiling can only ever LOWER the threshold, never raise it, so this cannot be the "
+ + "cause of a stall - but it is the reason a stall stopped. WHAT TO DO: raise the "
+ + "value back, or remove the option, as soon as enough validators can seal again. "
+ + "THIS LINE REPEATS AT EVERY HEIGHT, BY DESIGN.",
+ number,
+ config.scheduledMinSealsAt(number),
+ config.minSealsCeiling().isPresent() ? config.minSealsCeiling().getAsInt() : -1,
+ config.minSealsAt(number));
+ }
+ return true;
+ } catch (final RuntimeException e) {
+ // A rule whose only job is to talk must never be the reason a header is refused.
+ LOG.warn("AERE PQC EMERGENCY: the emergency announcement itself failed: {}", e.toString());
+ return true;
+ }
+ }
+
+ @Override
+ public boolean includeInLightValidation() {
+ return true;
+ }
+}
diff --git a/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqRegistryBindingRule.java b/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqRegistryBindingRule.java
new file mode 100755
index 000000000..7792b4616
--- /dev/null
+++ b/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqRegistryBindingRule.java
@@ -0,0 +1,91 @@
+/*
+ * Copyright contributors to Besu / Aere Network.
+ *
+ * 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.headervalidationrules;
+
+import org.hyperledger.besu.consensus.common.bft.FalconSealSupport;
+import org.hyperledger.besu.ethereum.core.BlockHeader;
+import org.hyperledger.besu.ethereum.mainnet.DetachedBlockHeaderValidationRule;
+
+/**
+ * REGISTRY BINDING, the PER-BLOCK half.
+ *
+ * <p><b>The hole this closes, stated as the measurement that found it.</b> The startup guard asks
+ * "does the registry on this node match what genesis requires" exactly once, against the chain head
+ * that existed when the process started. {@code config.pqRegistryHash} is a SCHEDULE, so it can have
+ * a second entry: a key rotation, or a validator-set change that re-anchors the manifest. A node
+ * that was already running when such a height passes underneath it is never asked the question
+ * again. It keeps verifying certificates against a registry the chain has moved off, and it keeps
+ * reporting itself healthy while it does so. That is the same defect the startup guard exists to
+ * remove, arriving through a different door, and the guard's own honest-limits note said so in
+ * writing: "the guard runs at startup; it cannot stop a node that is already running when a rotation
+ * height passes underneath it."
+ *
+ * <p><b>Why a header rule and not a background timer.</b> A timer would have to invent its own
+ * notion of "the current height" and its own reaction, and would be a second, drifting source of
+ * truth about a consensus question. This rule is asked exactly once per header, by the code that
+ * already decides whether a header may enter this node's chain, and it answers with the same
+ * function the startup guard uses, over the same loaded registry object. There is one decision and
+ * one implementation of it.
+ *
+ * <p><b>What it does on mismatch, and why refusing is the fail-closed direction.</b> It returns
+ * false, so the header is not imported, and {@code FalconSealSupport} emits one complete ERROR line
+ * per height naming the hash required, the height it is required from, the hash found, the file in
+ * use and a fingerprint per registry row. Refusing looks harsh, and the alternative is worse: a node
+ * that imports a header whose certificate it cannot correctly verify has asserted a check it did not
+ * perform, which is precisely the behaviour the whole registry-binding repair exists to remove.
+ * Stopping at the first header past the rotation, loudly, is recoverable in one restart; importing
+ * 200,000 blocks under the wrong registry is not.
+ *
+ * <p><b>Inert unless somebody armed it, three times over.</b> The rule returns true when the startup
+ * guard never ran on this node, when genesis carries no {@code config.pqRegistryHash} at all, and at
+ * every height below the schedule's first entry. Chain 2800 as it stands today satisfies all three:
+ * its genesis has no such key, measured on the live genesis file. So a binary carrying this rule
+ * validates the existing 11.8 million blocks exactly as today.
+ *
+ * <p><b>DETACHED. And the light-validation flag does NOT keep it off the sync path - that claim
+ * was here and it was wrong.</b> Detached because the question is about this node's configuration
+ * at a height, not about the parent header. {@code includeInLightValidation()} returns false below,
+ * and the sentence that used to stand here said that this kept the rule away from a node that is
+ * fast-syncing headers. READ IN UPSTREAM BESU, and it does not:
+ * {@code BlockHeaderValidator.validateHeader} filters on {@code Rule::isDetachedSupported} alone
+ * for {@code DETACHED_ONLY}; only the three LIGHT modes consult {@code includeInLightValidation}.
+ * {@code DETACHED_ONLY} is exactly the mode {@code DownloadHeaderSequenceTask} validates
+ * closed-ended header ranges under, so this rule DOES run on the header-download path.
+ *
+ * <p>What that means, stated no further than it was checked: a syncing node whose registry does not
+ * satisfy the entry active at a height inside a downloaded range fails that whole range, and
+ * upstream treats a failed range as a bad peer. Whether that costs the node its peer table, and
+ * what it looks like when every honest peer serves the same headers, is NOT MEASURED.
+ *
+ * <p>The startup guard is what covers a node at the moment it is configured to matter, and since
+ * AERE CONSENS-LENS (2026-08-02) it asks about chainHead + 1, i.e. about the same height this rule
+ * will be asked about first. Before that change the two disagreed by one block, and that one block
+ * was a permanent chain stop at any scheduled rotation.
+ */
+public class PqRegistryBindingRule implements DetachedBlockHeaderValidationRule {
+
+ /** Default constructor. */
+ public PqRegistryBindingRule() {}
+
+ @Override
+ public boolean validate(final BlockHeader header, final BlockHeader parent) {
+ return FalconSealSupport.instance().registryBindingSatisfiedAt(header.getNumber());
+ }
+
+ @Override
+ public boolean includeInLightValidation() {
+ return false;
+ }
+}
diff --git a/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/QbftAnchorRuleWiringTest.java b/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/QbftAnchorRuleWiringTest.java
new file mode 100755
index 000000000..3a92c29ad
--- /dev/null
+++ b/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/QbftAnchorRuleWiringTest.java
@@ -0,0 +1,194 @@
+/*
+ * AERE, the WIRING of the anchor rules into the validation chain.
+ *
+ * WHY THIS EXISTS, and it is the most expensive lesson of method we have paid for so far: "a gate
+ * that has never failed cannot be believed".
+ *
+ * The three rules are registered in QbftBlockHeaderValidationRulesetFactory. The rules themselves
+ * have good and plentiful tests: 18 in PqAnchorDigestRuleTest, 25 in PqAnchorSealsRuleTest. But ALL
+ * of them build the validator themselves, with
+ * `new BlockHeaderValidator.Builder().addRule(rule).build()`, that is, a validator holding a SINGLE
+ * rule, hand-made inside the test. Measured on 2026-08-07: ZERO tests in the whole qbft tree went
+ * through the factory.
+ *
+ * THE CONSEQUENCE, concretely: delete the line that adds PqAnchorSealsRule and all 43 tests stay
+ * GREEN. The rule keeps working perfectly in isolation and is never called on a real header again.
+ * Without this class the wiring is an assertion, not a measurement, and the only thing holding
+ * it up was a run log from a day ago. Yesterday's log does not guard tomorrow's code.
+ *
+ * HOW IT MEASURES. It builds the validator THROUGH THE FACTORY and reads the rules out of the
+ * object that was built, by reflection over its fields, without assuming their names. Reflection
+ * is a technique already used in this module (see PqInertBinaryTest and the reason given there),
+ * and it fits here because the question really is "what ended up inside", not "what the rule does".
+ *
+ * THE NEGATIVE CONTROL, and it lives in this same class: with an UNARMED configuration the same
+ * three rules must still be there. They switch themselves off ON HEIGHT, not by being absent from
+ * the chain. If somebody "optimised" the factory into not adding them when the anchor is not
+ * configured, a node would start without them and would then reach the activation height with
+ * nothing to apply.
+ */
+package org.hyperledger.besu.consensus.qbft;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.lang.reflect.Field;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.OptionalInt;
+import org.hyperledger.besu.consensus.common.bft.PqAnchorConfig;
+import org.junit.jupiter.api.Test;
+
+class QbftAnchorRuleWiringTest {
+
+ private static final long H = 13_793_219L;
+
+ /** The simple names of the classes the factory MUST put into the validator. */
+ private static final List<String> ANCHOR_RULES =
+ List.of("PqAnchorDigestRule", "PqAnchorDigestAttachedRule", "PqAnchorSealsRule");
+
+ private static PqAnchorConfig armed() {
+ return new PqAnchorConfig(2800L, H, Map.of(H, 3), OptionalInt.empty(), false);
+ }
+
+ /**
+ * The simple names of every rule the built validator carries, whatever the fields holding them
+ * happen to be called. The object is read; its shape is not assumed.
+ */
+ private static List<String> rulesInside(final Object validator) throws Exception {
+ final List<String> found = new ArrayList<>();
+ for (Class<?> c = validator.getClass(); c != null && c != Object.class; c = c.getSuperclass()) {
+ for (final Field f : c.getDeclaredFields()) {
+ f.setAccessible(true);
+ final Object value = f.get(validator);
+ if (value instanceof Collection<?> collection) {
+ for (final Object element : collection) {
+ collectNames(element, found);
+ }
+ }
+ }
+ }
+ return found;
+ }
+
+ /**
+ * The name of the element AND of the objects it holds in its own fields, one level down.
+ *
+ * <p>MEASURED 2026-08-07, and the first form of this test failed exactly here: {@code
+ * BlockHeaderValidator} does not hold the rules directly, it WRAPS them, and its collection holds
+ * nothing but objects whose simple name is {@code "Rule"}. A test that had stopped at the first
+ * level would have reported fifteen {@code "Rule"} and nothing else, meaning it would have been
+ * green on anything and red on anything, whichever way it happened to fall. We go down one level,
+ * and only one: what this test looks for is the rule the factory put in, not the whole object
+ * graph.
+ */
+ private static void collectNames(final Object element, final List<String> into) throws Exception {
+ if (element == null) {
+ return;
+ }
+ into.add(element.getClass().getSimpleName());
+ for (final Field f : element.getClass().getDeclaredFields()) {
+ f.setAccessible(true);
+ final Object inner = f.get(element);
+ if (inner == null || f.getType().isPrimitive()) {
+ continue;
+ }
+ final String name = inner.getClass().getSimpleName();
+ into.add(name);
+ // THE THIRD LEVEL, and only for lambdas. Measured 2026-08-07, the second form of this test:
+ // the ATTACHED rules show up directly (PqAnchorSealsRule, PqAnchorDigestAttachedRule), but
+ // the DETACHED ones do not show up at all: the builder wraps them in an adapter lambda, and
+ // all that could be seen was "BlockHeaderValidator$Builder$$Lambda/0x...". So
+ // PqAnchorDigestRule, the anchor's only detached rule, was invisible, and a test happy with
+ // what it could see would have declared missing a rule that was right there.
+ //
+ // We descend ONLY into the lambda, and not into every object, so that the test does not turn
+ // into a walk through the whole graph, where it would find anything and could no longer tell
+ // anything apart.
+ if (name.contains("Lambda")) {
+ for (final Field captured : inner.getClass().getDeclaredFields()) {
+ captured.setAccessible(true);
+ final Object deep = captured.get(inner);
+ if (deep != null) {
+ into.add(deep.getClass().getSimpleName());
+ }
+ }
+ }
+ }
+ }
+
+ private static Object buildViaFactory(final PqAnchorConfig cfg) {
+ return QbftBlockHeaderValidationRulesetFactory.blockHeaderValidator(
+ Duration.ofSeconds(1), false, Optional.empty(), cfg)
+ .build();
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // 1. The three rules are REALLY inside the validator the factory produces.
+ // This is the assertion that turns red if somebody deletes a line from the factory.
+ // ---------------------------------------------------------------------------------------------
+ @Test
+ void theFactoryPutsAllThreeAnchorRulesIntoTheValidatorItBuilds() throws Exception {
+ final List<String> inside = rulesInside(buildViaFactory(armed()));
+
+ assertThat(inside)
+ .describedAs(
+ "the rules found inside the validator the factory built; if one is missing, somebody "
+ + "removed an addRule and no other test in the tree would have noticed")
+ .containsAll(ANCHOR_RULES);
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // 2. THE NEGATIVE CONTROL OF THIS TEST ITSELF. If the reflective reader returned an empty list
+ // for any reason at all, point 1 would have passed on anything at all. So we also demand that
+ // the validator carry upstream rules, which have nothing to do with the anchor.
+ // ---------------------------------------------------------------------------------------------
+ @Test
+ void theReaderActuallySeesSomethingAndNotAnEmptyList() throws Exception {
+ final List<String> inside = rulesInside(buildViaFactory(armed()));
+
+ assertThat(inside)
+ .describedAs("the reflective reader does see rules, otherwise the test above is empty")
+ .isNotEmpty();
+ assertThat(inside.size())
+ .describedAs("a QBFT validator carries far more than the three anchor rules")
+ .isGreaterThan(ANCHOR_RULES.size());
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // 3. UNARMED, the rules are STILL there. They switch off on height, not by being absent from the
+ // chain. A node that started without them would reach the activation height with nothing to
+ // apply, and the difference would not show until that very day.
+ // ---------------------------------------------------------------------------------------------
+ @Test
+ void theRulesArePresentEvenWhenTheAnchorIsNotConfigured() throws Exception {
+ final List<String> inside = rulesInside(buildViaFactory(PqAnchorConfig.never(2800L)));
+
+ assertThat(inside)
+ .describedAs(
+ "the rules switch off on HEIGHT, not by absence; an unarmed node must carry them "
+ + "anyway, so that it has them at the activation height")
+ .containsAll(ANCHOR_RULES);
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // 4. And the three-argument overload, the upstream one production code calls, ends at the same
+ // result. Without this we would have measured only the path the test itself uses.
+ // ---------------------------------------------------------------------------------------------
+ @Test
+ void theUpstreamThreeArgumentOverloadWiresTheAnchorRulesToo() throws Exception {
+ final Object validator =
+ QbftBlockHeaderValidationRulesetFactory.blockHeaderValidator(
+ Duration.ofSeconds(1), false, Optional.empty())
+ .build();
+
+ assertThat(rulesInside(validator))
+ .describedAs(
+ "the 3-argument overload is the one production calls; if it bypasses the anchor, "
+ + "nothing else matters")
+ .containsAll(ANCHOR_RULES);
+ }
+}
diff --git a/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/FalconSealLogThrottleTest.java b/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/FalconSealLogThrottleTest.java
new file mode 100755
index 000000000..f93182488
--- /dev/null
+++ b/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/FalconSealLogThrottleTest.java
@@ -0,0 +1,182 @@
+/*
+ * Copyright contributors to Besu / Aere Network.
+ *
+ * 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.headervalidationrules;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * AERE: the LOG-ONLY summary must not write one INFO line per block.
+ *
+ * <p>MEASURED ON A LIVE NODE, and that is why this test exists. The rule wrote the summary at INFO
+ * on every imported block: <b>2295 lines in twenty minutes</b>, every one of them identical, {@code
+ * 0 of 0 seals, |eligible|=0, no-eligible-signers}. On the previous binary the same twenty minutes
+ * had ZERO such lines, so the spam was entirely introduced by this change.
+ *
+ * <p>An ERROR or INFO line that repeats forever anaesthetises a log: operators learn to scroll past
+ * it, and the one line that matters arrives inside the noise. So a binary that replaces a running
+ * one must add zero new log lines compared with the one it replaces. This test is that requirement,
+ * for the part a unit test can hold.
+ */
+class FalconSealLogThrottleTest {
+
+ private static final String STARE_A = "no-eligible-signers|0|0|0|7";
+ private static final String STARE_B = "PQC-quorum-not-yet|3|5|7|7";
+
+ private static int numaraInfo(
+ final FalconSealValidationRule regula, final String stare, final long dePeLa, final int cate) {
+ int n = 0;
+ for (int i = 0; i < cate; i++) {
+ if (regula.shouldLogAtInfo(stare, dePeLa + i)) {
+ n++;
+ }
+ }
+ return n;
+ }
+
+ @Test
+ void primaOaraSeScrieIntotdeauna() {
+ // Without this, an operator starting the node would NEVER see that the rule is alive.
+ final FalconSealValidationRule r = new FalconSealValidationRule();
+ assertThat(r.shouldLogAtInfo(STARE_A, 12_742_475L)).isTrue();
+ }
+
+ @Test
+ void oMieDeBlocuriCuACEEASIStareScriuOSinguraLinie() {
+ final FalconSealValidationRule r = new FalconSealValidationRule();
+ // 1000 blocks, well below the heartbeat, so only the first one may come out at INFO
+ assertThat(numaraInfo(r, STARE_A, 1_000L, 1_000)).isEqualTo(1);
+ }
+
+ @Test
+ void schimbareaStariiSeVEDEPeBloculInCareSeIntampla() {
+ final FalconSealValidationRule r = new FalconSealValidationRule();
+ numaraInfo(r, STARE_A, 1_000L, 500);
+ // exactly the block on which it changes, not the next one and not a heartbeat later
+ assertThat(r.shouldLogAtInfo(STARE_B, 1_500L)).isTrue();
+ // and after the change it goes quiet again
+ assertThat(numaraInfo(r, STARE_B, 1_501L, 500)).isEqualTo(0);
+ }
+
+ @Test
+ void siInapoiLaStareaVecheSeVEDE() {
+ // A regression, that is falling OUT of quorum, matters at least as much as reaching it.
+ final FalconSealValidationRule r = new FalconSealValidationRule();
+ r.shouldLogAtInfo(STARE_A, 1_000L);
+ r.shouldLogAtInfo(STARE_B, 1_001L);
+ assertThat(r.shouldLogAtInfo(STARE_A, 1_002L)).isTrue();
+ }
+
+ @Test
+ void bataiaDeInimaScrieOLinieLaFiecareFereastra() {
+ final FalconSealValidationRule r = new FalconSealValidationRule();
+ final long H = FalconSealValidationRule.LOG_HEARTBEAT_BLOCKS;
+ // 5 whole windows, one single state. Expected: the first line plus one per window.
+ int info = 0;
+ for (long b = 0; b < 5 * H; b++) {
+ if (r.shouldLogAtInfo(STARE_A, b)) {
+ info++;
+ }
+ }
+ assertThat(info).isEqualTo(5);
+ }
+
+ @Test
+ void reducereaEDeCelPutinODieMieDeOri() {
+ // The number that matters for a node, as a proof rather than a claim in a comment.
+ final FalconSealValidationRule r = new FalconSealValidationRule();
+ final int blocuriPeZi = 165_000; // 86400 / 0.523
+ final int info = numaraInfo(r, STARE_A, 1_000L, blocuriPeZi);
+ assertThat(info).isLessThanOrEqualTo(20);
+ assertThat(blocuriPeZi / Math.max(info, 1)).isGreaterThanOrEqualTo(1_000);
+ }
+
+ @Test
+ void nuSeSufocaCandStareaOscileazaLaFiecareBloc() {
+ // The bad case: if the state really does change on every block, the line MUST come out on every
+ // block. A throttle that smothered that too would hide exactly the moment we care about.
+ final FalconSealValidationRule r = new FalconSealValidationRule();
+ int info = 0;
+ for (int i = 0; i < 100; i++) {
+ if (r.shouldLogAtInfo(i % 2 == 0 ? STARE_A : STARE_B, 1_000L + i)) {
+ info++;
+ }
+ }
+ assertThat(info).isEqualTo(100);
+ }
+
+ @Test
+ void inaltimiCareVinInNEORDINE_nuAprindBataiaDeInima() {
+ // Import runs on several threads, so heights do not always arrive in increasing order. A step
+ // backwards makes the difference negative; that must NOT pass the threshold and write an extra
+ // line.
+ final FalconSealValidationRule r = new FalconSealValidationRule();
+ r.shouldLogAtInfo(STARE_A, 1_000_000L);
+ int info = 0;
+ for (long b = 999_999L; b > 999_899L; b--) {
+ if (r.shouldLogAtInfo(STARE_A, b)) {
+ info++;
+ }
+ }
+ assertThat(info).isZero();
+ }
+
+ @Test
+ void subMaiMulteFireNuIeseUnPotopSiNiciZero() throws Exception {
+ // The rule is called from EthScheduler-Workers-N, so concurrently. I do not ask for perfect
+ // accuracy under races, but I do ask for the two things that matter: no flood, and no SILENCE.
+ final FalconSealValidationRule r = new FalconSealValidationRule();
+ final int fire = 8;
+ final int perFir = 2_000;
+ final AtomicInteger info = new AtomicInteger();
+ final ExecutorService ex = Executors.newFixedThreadPool(fire);
+ final CountDownLatch start = new CountDownLatch(1);
+ final List<Runnable> sarcini = new ArrayList<>();
+ for (int f = 0; f < fire; f++) {
+ final long baza = 1_000L + (long) f * perFir;
+ sarcini.add(
+ () -> {
+ try {
+ start.await();
+ } catch (final InterruptedException e) {
+ Thread.currentThread().interrupt();
+ return;
+ }
+ for (int i = 0; i < perFir; i++) {
+ if (r.shouldLogAtInfo(STARE_A, baza + i)) {
+ info.incrementAndGet();
+ }
+ }
+ });
+ }
+ sarcini.forEach(ex::submit);
+ start.countDown();
+ ex.shutdown();
+ assertThat(ex.awaitTermination(30, TimeUnit.SECONDS)).isTrue();
+ assertThat(info.get()).isGreaterThanOrEqualTo(1);
+ // 16,000 calls; with no throttle that would be 16,000 lines. We require under 1% even under
+ // races.
+ assertThat(info.get()).isLessThan(160);
+ }
+}
diff --git a/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/FalconSealValidationRuleRetirementTest.java b/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/FalconSealValidationRuleRetirementTest.java
new file mode 100755
index 000000000..1351cc2de
--- /dev/null
+++ b/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/FalconSealValidationRuleRetirementTest.java
@@ -0,0 +1,85 @@
+/*
+ * Copyright contributors to Besu / Aere Network.
+ *
+ * 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.headervalidationrules;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.H;
+import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.extraData;
+import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.header;
+import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.parentHeader;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.atLeastOnce;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.withSettings;
+
+import org.hyperledger.besu.ethereum.ProtocolContext;
+import org.hyperledger.besu.ethereum.core.BlockHeader;
+
+import java.util.Collections;
+
+import org.apache.tuweni.bytes.Bytes32;
+import org.junit.jupiter.api.Test;
+import org.mockito.quality.Strictness;
+
+/**
+ * The retirement of the legacy log-only Falcon rule at H.
+ *
+ * <p>The retirement is only worth anything if the gate precedes EVERY state touch, otherwise a node
+ * past H would still be paying for a rule that contributes nothing and still reading historical
+ * world state it may not have. So the test does not merely assert the return value, which is {@code
+ * true} on both sides of the gate and therefore proves nothing on its own: it asserts that above the
+ * retirement height the protocol context is never touched at all, and that below it, it is.
+ */
+public class FalconSealValidationRuleRetirementTest {
+
+ private ProtocolContext context() {
+ return mock(ProtocolContext.class, withSettings().strictness(Strictness.LENIENT));
+ }
+
+ private BlockHeader headerAt(final long number) {
+ return header(
+ number, parentHeader(number - 1L).getHash(), extraData(Bytes32.ZERO, Collections.emptyList()));
+ }
+
+ @Test
+ public void theDefaultConstructorNeverRetires() {
+ assertThat(new FalconSealValidationRule().toString()).contains("retiresAt=NEVER");
+ assertThat(new FalconSealValidationRule(H).toString()).contains("retiresAt=" + H);
+ }
+
+ @Test
+ public void atAndAboveTheRetirementHeightNothingIsTouchedAtAll() {
+ final FalconSealValidationRule rule = new FalconSealValidationRule(H);
+ for (final long number : new long[] {H, H + 1L, H + 5000L}) {
+ final ProtocolContext protocolContext = context();
+ assertThat(rule.validate(headerAt(number), parentHeader(number - 1L), protocolContext))
+ .isTrue();
+ verifyNoInteractions(protocolContext);
+ }
+ }
+
+ @Test
+ public void belowTheRetirementHeightTheRuleStillRunsExactlyAsBefore() {
+ final FalconSealValidationRule rule = new FalconSealValidationRule(H);
+ final ProtocolContext protocolContext = context();
+ // Log-only baseline: it returns true, as it always has, including on its own error paths. That
+ // is precisely why it is being replaced rather than promoted, and precisely why leaving it in
+ // place below H is safe.
+ assertThat(rule.validate(headerAt(H - 1L), parentHeader(H - 2L), protocolContext)).isTrue();
+ verify(protocolContext, atLeastOnce()).getConsensusContext(any());
+ }
+}
diff --git a/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorDigestRuleTest.java b/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorDigestRuleTest.java
new file mode 100755
index 000000000..7f03e3667
--- /dev/null
+++ b/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorDigestRuleTest.java
@@ -0,0 +1,359 @@
+/*
+ * Copyright contributors to Besu / Aere Network.
+ *
+ * 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.headervalidationrules;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.CHAIN_ID;
+import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.H;
+import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.extraData;
+import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.header;
+import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.honestHeader;
+import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.parentHeader;
+import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.seal;
+
+import org.hyperledger.besu.consensus.common.bft.BftExtraData;
+import org.hyperledger.besu.consensus.common.bft.FalconSeal;
+import org.hyperledger.besu.consensus.common.bft.PqAnchor;
+import org.hyperledger.besu.consensus.common.bft.PqAnchorConfig;
+import org.hyperledger.besu.consensus.qbft.QbftExtraDataCodec;
+import org.hyperledger.besu.ethereum.core.BlockHeader;
+import org.hyperledger.besu.ethereum.mainnet.BlockHeaderValidator;
+import org.hyperledger.besu.ethereum.mainnet.HeaderValidationMode;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.OptionalInt;
+
+import org.apache.tuweni.bytes.Bytes;
+import org.apache.tuweni.bytes.Bytes32;
+import org.junit.jupiter.api.Test;
+
+/**
+ * R1, the anchor digest rule.
+ *
+ * <p>Two of these tests are the reason the whole V2 scheme exists, and both are asserted twice: once
+ * through the rule directly, and once through a real {@link BlockHeaderValidator} running in a LIGHT
+ * validation mode, because "caught in full validation" would not be good enough.
+ *
+ * <p>READ THE SCOPE OF THAT SECOND ASSERTION EXACTLY, because an earlier version of this comment did
+ * not, and said instead that a node which fast-syncs headers must catch these too. What the assertion
+ * says is narrower: when a {@link BlockHeaderValidator} is asked to validate this header in mode
+ * LIGHT, this rule runs and refuses. It does NOT say that any Besu sync path asks in that mode, and a
+ * green suite here is not evidence that a syncing node is protected. What protects a syncing node is
+ * measured elsewhere and lives in other files: the open-ended-range fix in {@code
+ * DownloadHeadersStep}, {@code PqAnchorDigestAttachedRule} on the attached side, and {@code
+ * PqAnchorSyncModeGuard} refusing to start outside FULL sync.
+ */
+public class PqAnchorDigestRuleTest {
+
+ private final PqAnchorConfig armed =
+ new PqAnchorConfig(CHAIN_ID, H, Map.of(H, 0, H + 100L, 3), OptionalInt.empty(), false);
+ private final PqAnchorDigestRule rule = new PqAnchorDigestRule(armed);
+ private final QbftExtraDataCodec codec = new QbftExtraDataCodec();
+
+ private static final List<FalconSeal> QUORUM = List.of(seal(0), seal(1), seal(2), seal(3), seal(4));
+
+ // ---------------------------------------------------------------------------------------------
+ // The property that makes this binary safe to warm on a live node.
+ // ---------------------------------------------------------------------------------------------
+
+ @Test
+ public void belowHTheRuleIsInertEvenOnHeadersItWouldOtherwiseReject() {
+ final BlockHeader parent = parentHeader(H - 2L);
+ // Vanity is the client version string this chain actually writes today, and there is no
+ // certificate at all: precisely the shape of all 11.8 million existing blocks.
+ final Bytes legacyVanity =
+ Bytes.fromHexString(
+ "0x000000000000626573752030302e302d646576656c6f702d7878787878787878");
+ final BlockHeader legacyShaped =
+ header(H - 1L, parent.getHash(), extraData(legacyVanity, Collections.emptyList()));
+ assertThat(rule.validate(legacyShaped, parent)).isTrue();
+
+ // And a header just below H whose vanity is pure garbage relative to its certificate is still
+ // accepted, because below H the rule does not even decode.
+ final BlockHeader garbage =
+ header(H - 1L, parent.getHash(), extraData(Bytes32.random(), QUORUM));
+ assertThat(rule.validate(garbage, parent)).isTrue();
+ }
+
+ @Test
+ public void anUnconfiguredAnchorIsInertAtEveryHeight() {
+ final PqAnchorDigestRule inert = new PqAnchorDigestRule(PqAnchorConfig.never(CHAIN_ID));
+ final BlockHeader parent = parentHeader(H + 4L);
+ final BlockHeader tampered =
+ header(H + 5L, parent.getHash(), extraData(Bytes32.random(), QUORUM));
+ assertThat(inert.validate(tampered, parent)).isTrue();
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // Honest headers.
+ // ---------------------------------------------------------------------------------------------
+
+ @Test
+ public void acceptsAnHonestHeaderAtAndAboveH() {
+ final BlockHeader parent = parentHeader(H - 1L);
+ assertThat(rule.validate(honestHeader(H, parent.getHash(), QUORUM), parent)).isTrue();
+
+ final BlockHeader laterParent = parentHeader(H + 500L);
+ assertThat(rule.validate(honestHeader(H + 501L, laterParent.getHash(), QUORUM), laterParent))
+ .isTrue();
+ }
+
+ @Test
+ public void acceptsAnEmptyCertificateAtTheActivationHeightItself() {
+ // Block H's parent predates the scheme, so C is legitimately empty and D is the digest of the
+ // empty list. "Explicitly empty" and "absent" are one object because of the codec's canonical
+ // round-trip gate, so there is no second acceptable byte string here.
+ final BlockHeader parent = parentHeader(H - 1L);
+ final BlockHeader block = honestHeader(H, parent.getHash(), Collections.emptyList());
+ assertThat(rule.validate(block, parent)).isTrue();
+ assertThat(codec.decodeRaw(block.getExtraData()).getFalconSeals()).isEmpty();
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // A1: the certificate is STRIPPED. Required by the mission to be caught in LIGHT validation.
+ // ---------------------------------------------------------------------------------------------
+
+ @Test
+ public void aStrippedCertificateIsRejected() {
+ final BlockHeader parent = parentHeader(H + 9L);
+ final BlockHeader honest = honestHeader(H + 10L, parent.getHash(), QUORUM);
+ final BlockHeader stripped = stripCertificate(honest);
+
+ // The tamper is real: the certificate is gone, and vanityData is untouched.
+ assertThat(codec.decodeRaw(honest.getExtraData()).getFalconSeals()).hasSize(5);
+ assertThat(codec.decodeRaw(stripped.getExtraData()).getFalconSeals()).isEmpty();
+ assertThat(codec.decodeRaw(stripped.getExtraData()).getVanityData())
+ .isEqualTo(codec.decodeRaw(honest.getExtraData()).getVanityData());
+
+ assertThat(rule.validate(honest, parent)).isTrue();
+ assertThat(rule.validate(stripped, parent)).isFalse();
+ }
+
+ @Test
+ public void aStrippedCertificateIsCaughtInLightValidation() {
+ final BlockHeader parent = parentHeader(H + 9L);
+ final BlockHeader stripped = stripCertificate(honestHeader(H + 10L, parent.getHash(), QUORUM));
+ assertRejectedInEveryLightMode(stripped, parent);
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // A2: the certificate is REPLACED by an equally valid one over a different subset.
+ // Required by the mission to be caught in LIGHT validation.
+ // ---------------------------------------------------------------------------------------------
+
+ @Test
+ public void anEquallyValidDifferentSubsetIsRejected() {
+ final BlockHeader parent = parentHeader(H + 9L);
+ final List<FalconSeal> subsetA = List.of(seal(0), seal(1), seal(2), seal(3), seal(4));
+ final List<FalconSeal> subsetB = List.of(seal(0), seal(1), seal(2), seal(3), seal(5));
+ final BlockHeader honest = honestHeader(H + 10L, parent.getHash(), subsetA);
+ final BlockHeader swapped = replaceCertificate(honest, subsetB);
+
+ // Both certificates are well formed, both are the same size, both have strictly increasing
+ // indices, and the block hash pre-image does not contain either of them. Only D separates them.
+ assertThat(codec.decodeRaw(swapped.getExtraData()).getFalconSeals()).hasSize(5);
+ assertThat(PqAnchor.hasStrictlyIncreasingIndices(subsetB)).isTrue();
+
+ assertThat(rule.validate(honest, parent)).isTrue();
+ assertThat(rule.validate(swapped, parent)).isFalse();
+ }
+
+ @Test
+ public void anEquallyValidDifferentSubsetIsCaughtInLightValidation() {
+ final BlockHeader parent = parentHeader(H + 9L);
+ final BlockHeader swapped =
+ replaceCertificate(
+ honestHeader(H + 10L, parent.getHash(), List.of(seal(0), seal(1), seal(2), seal(3), seal(4))),
+ List.of(seal(0), seal(1), seal(2), seal(3), seal(5)));
+ assertRejectedInEveryLightMode(swapped, parent);
+ }
+
+ @Test
+ public void aSubstitutedSignatureUnderTheSameIndexIsRejected() {
+ // The subset is identical; only one signature's bytes differ. A verifier that only counted valid
+ // signatures would accept this; the digest does not.
+ final BlockHeader parent = parentHeader(H + 9L);
+ final List<FalconSeal> original = List.of(seal(0), seal(1), seal(2));
+ final List<FalconSeal> substituted =
+ List.of(seal(0), seal(1), new FalconSeal(2, PqAnchorTestSupport.signature(2, 7)));
+ final BlockHeader swapped =
+ replaceCertificate(honestHeader(H + 10L, parent.getHash(), original), substituted);
+ assertThat(rule.validate(swapped, parent)).isFalse();
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // A3 / A4: order and duplication.
+ // ---------------------------------------------------------------------------------------------
+
+ @Test
+ public void aReorderedCertificateIsRejected() {
+ final BlockHeader parent = parentHeader(H + 9L);
+ final BlockHeader honest = honestHeader(H + 10L, parent.getHash(), QUORUM);
+ final BlockHeader reordered =
+ replaceCertificate(honest, List.of(seal(4), seal(3), seal(2), seal(1), seal(0)));
+ assertThat(rule.validate(reordered, parent)).isFalse();
+ assertRejectedInEveryLightMode(reordered, parent);
+ }
+
+ @Test
+ public void aRepeatedIndexIsRejectedEvenWhenTheDigestIsRecomputedOverIt() {
+ // An attacker who controls the proposer could try to inflate k by repeating a seal AND writing
+ // the matching digest. The strictly increasing requirement refuses it before the digest is even
+ // compared, so inflation is unrepresentable rather than filtered.
+ final BlockHeader parent = parentHeader(H + 9L);
+ final List<FalconSeal> doubled =
+ List.of(seal(0), seal(1), new FalconSeal(1, PqAnchorTestSupport.signature(1, 3)));
+ final Bytes32 digest = PqAnchor.anchorDigest(CHAIN_ID, H + 9L, parent.getHash().getBytes(), doubled);
+ final BlockHeader forged = header(H + 10L, parent.getHash(), extraData(digest, doubled));
+ assertThat(rule.validate(forged, parent)).isFalse();
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // Binding, and fail-closed behaviour.
+ // ---------------------------------------------------------------------------------------------
+
+ @Test
+ public void theDigestIsBoundToTheParentThisHeaderNames() {
+ final BlockHeader parent = parentHeader(H + 9L);
+ final BlockHeader other = parentHeader(H + 9L + 1L);
+ final BlockHeader honest = honestHeader(H + 10L, parent.getHash(), QUORUM);
+ // Same certificate, same height, different parent hash: the digest no longer matches.
+ final BlockHeader rehomed =
+ header(H + 10L, other.getHash(), honest.getExtraData());
+ assertThat(rule.validate(rehomed, other)).isFalse();
+ }
+
+ @Test
+ public void theDigestIsBoundToTheChainId() {
+ // A6: the 442807 proving chain runs the same binaries and can run the same Falcon keys.
+ final BlockHeader parent = parentHeader(H + 9L);
+ final List<FalconSeal> certificate = PqAnchor.sortedByIndex(QUORUM);
+ final Bytes32 foreignDigest =
+ PqAnchor.anchorDigest(442807L, H + 9L, parent.getHash().getBytes(), certificate);
+ final BlockHeader foreign =
+ header(H + 10L, parent.getHash(), extraData(foreignDigest, certificate));
+ assertThat(rule.validate(foreign, parent)).isFalse();
+ }
+
+ @Test
+ public void aVanityFieldThatIsNotThirtyTwoBytesIsRejected() {
+ final BlockHeader parent = parentHeader(H + 9L);
+ final BlockHeader shortVanity =
+ header(H + 10L, parent.getHash(), extraData(Bytes.repeat((byte) 0x11, 31), QUORUM));
+ assertThat(rule.validate(shortVanity, parent)).isFalse();
+ }
+
+ @Test
+ public void undecodableExtraDataIsRejectedRatherThanAccepted() {
+ // BELOW its fork height, and only there, the legacy Falcon rule returned true on every path
+ // including the exception path, so in the only regime this chain has ever run it never caught
+ // anything. At and after the fork block WITH an active registry it is instead BLOCKING and does
+ // return false, on four paths including the exception path (measured by reading
+ // FalconSealValidationRule: blocking = forkReached && registryActive, then four `return false`
+ // sites inside validate). That mode has never been armed on this chain and must not be armed at
+ // the same time as the V2 anchor. A rule that cannot compute its verdict has not proved the
+ // header good.
+ final BlockHeader parent = parentHeader(H + 9L);
+ final BlockHeader broken =
+ header(H + 10L, parent.getHash(), Bytes.fromHexString("0xdeadbeef"));
+ assertThat(rule.validate(broken, parent)).isFalse();
+
+ final BlockHeader empty = header(H + 10L, parent.getHash(), Bytes.EMPTY);
+ assertThat(rule.validate(empty, parent)).isFalse();
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // The light-validation property itself.
+ // ---------------------------------------------------------------------------------------------
+
+ @Test
+ public void includeInLightValidationIsTrue() {
+ assertThat(rule.includeInLightValidation()).isTrue();
+ }
+
+ @Test
+ public void anHonestHeaderStillPassesEveryLightMode() {
+ // The negative control for assertRejectedInEveryLightMode: if the modes below rejected
+ // everything, the rejection tests would prove nothing.
+ final BlockHeader parent = parentHeader(H + 9L);
+ final BlockHeader honest = honestHeader(H + 10L, parent.getHash(), QUORUM);
+ final BlockHeaderValidator validator =
+ new BlockHeaderValidator.Builder().addRule(rule).build();
+ for (final HeaderValidationMode mode :
+ List.of(
+ HeaderValidationMode.LIGHT_DETACHED_ONLY,
+ HeaderValidationMode.LIGHT,
+ HeaderValidationMode.DETACHED_ONLY,
+ HeaderValidationMode.FULL)) {
+ assertThat(validator.validateHeader(honest, parent, null, mode))
+ .withFailMessage("honest header should pass in mode %s", mode)
+ .isTrue();
+ }
+ }
+
+ @Test
+ public void theRuleIsSkippedByTheModesThatSkipDetachedRules() {
+ // Stating the shape honestly: LIGHT_SKIP_DETACHED and SKIP_DETACHED exclude every detached rule,
+ // so R1 does not run there. Those modes are the "attached half" of a split validation whose
+ // other half already ran R1, which is why this is not a hole.
+ final BlockHeader parent = parentHeader(H + 9L);
+ final BlockHeader stripped = stripCertificate(honestHeader(H + 10L, parent.getHash(), QUORUM));
+ final BlockHeaderValidator validator =
+ new BlockHeaderValidator.Builder().addRule(rule).build();
+ assertThat(
+ validator.validateHeader(
+ stripped, parent, null, HeaderValidationMode.LIGHT_SKIP_DETACHED))
+ .isTrue();
+ assertThat(validator.validateHeader(stripped, parent, null, HeaderValidationMode.SKIP_DETACHED))
+ .isTrue();
+ }
+
+ private void assertRejectedInEveryLightMode(final BlockHeader header, final BlockHeader parent) {
+ final BlockHeaderValidator validator =
+ new BlockHeaderValidator.Builder().addRule(rule).build();
+ for (final HeaderValidationMode mode :
+ List.of(
+ HeaderValidationMode.LIGHT_DETACHED_ONLY,
+ HeaderValidationMode.LIGHT,
+ HeaderValidationMode.DETACHED_ONLY,
+ HeaderValidationMode.FULL)) {
+ assertThat(validator.validateHeader(header, parent, null, mode))
+ .withFailMessage("tampered header should be rejected in mode %s", mode)
+ .isFalse();
+ }
+ }
+
+ private BlockHeader stripCertificate(final BlockHeader honest) {
+ return replaceCertificate(honest, Collections.emptyList());
+ }
+
+ private BlockHeader replaceCertificate(
+ final BlockHeader honest, final List<FalconSeal> certificate) {
+ final BftExtraData original = codec.decodeRaw(honest.getExtraData());
+ final Bytes tampered =
+ codec.encode(
+ new BftExtraData(
+ original.getVanityData(),
+ original.getSeals(),
+ Optional.empty(),
+ original.getRound(),
+ original.getValidators(),
+ certificate));
+ return header(honest.getNumber(), honest.getParentHash(), tampered);
+ }
+}
diff --git a/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorSealsRuleTest.java b/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorSealsRuleTest.java
new file mode 100755
index 000000000..3bf64c2a5
--- /dev/null
+++ b/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorSealsRuleTest.java
@@ -0,0 +1,690 @@
+/*
+ * Copyright contributors to Besu / Aere Network.
+ *
+ * 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.headervalidationrules;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.CHAIN_ID;
+import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.H;
+import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.VALIDATORS;
+import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.extraData;
+import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.header;
+import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.parentHeader;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import static org.mockito.Mockito.withSettings;
+
+import org.hyperledger.besu.consensus.common.bft.BftContext;
+import org.hyperledger.besu.consensus.common.bft.FalconSeal;
+import org.hyperledger.besu.consensus.common.bft.PqAnchor;
+import org.hyperledger.besu.consensus.common.bft.PqAnchorConfig;
+import org.hyperledger.besu.consensus.common.bft.PqSignerRegistry;
+import org.hyperledger.besu.consensus.common.validator.ValidatorProvider;
+import org.hyperledger.besu.datatypes.Address;
+import org.hyperledger.besu.ethereum.ProtocolContext;
+import org.hyperledger.besu.ethereum.core.BlockHeader;
+import org.hyperledger.besu.ethereum.mainnet.BlockHeaderValidator;
+import org.hyperledger.besu.ethereum.mainnet.HeaderValidationMode;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.NavigableMap;
+import java.util.OptionalInt;
+import java.util.TreeMap;
+
+import org.apache.tuweni.bytes.Bytes;
+import org.apache.tuweni.bytes.Bytes32;
+import org.junit.jupiter.api.Test;
+import org.mockito.quality.Strictness;
+
+/**
+ * R2, the anchor seals rule.
+ *
+ * <p>The registry is injected rather than taken from the Falcon singleton, so these tests exercise
+ * the RULE: threshold, ordering, eligibility against the parent's validator set, message binding and
+ * per-seal verification. Whether BouncyCastle's Falcon implementation verifies correctly is
+ * FalconSealSupport's own surface, and is deliberately not re-tested here.
+ *
+ * <p>The most important test in this file is {@link
+ * #acceptsAValidCertificateOverASubsetThisNodeNeverHeard()}. It is the one that proves the rule does
+ * not do the thing that killed the previous design.
+ */
+public class PqAnchorSealsRuleTest {
+
+ private static final long K_AT = H + 100L;
+
+ private final PqAnchorConfig armed =
+ new PqAnchorConfig(CHAIN_ID, H, Map.of(H, 0, K_AT, 5), OptionalInt.empty(), false);
+ private final FakeRegistry registry = new FakeRegistry();
+ private final PqAnchorSealsRule rule = new PqAnchorSealsRule(armed, registry);
+
+ // ---------------------------------------------------------------------------------------------
+ // Inert below H.
+ // ---------------------------------------------------------------------------------------------
+
+ @Test
+ public void belowHTheRuleIsInert() {
+ final BlockHeader parent = parentHeader(H - 2L);
+ final BlockHeader nonsense =
+ header(
+ H - 1L,
+ parent.getHash(),
+ extraData(Bytes32.random(), List.of(new FalconSeal(99, Bytes.of(1, 2, 3)))));
+ assertThat(rule.validate(nonsense, parent, contextWith(VALIDATORS))).isTrue();
+ }
+
+ @Test
+ public void anUnconfiguredAnchorIsInertAtEveryHeight() {
+ final PqAnchorSealsRule inert =
+ new PqAnchorSealsRule(PqAnchorConfig.never(CHAIN_ID), registry);
+ final BlockHeader parent = parentHeader(K_AT + 4L);
+ final BlockHeader nonsense =
+ header(
+ K_AT + 5L,
+ parent.getHash(),
+ extraData(Bytes32.random(), List.of(new FalconSeal(99, Bytes.of(1, 2, 3)))));
+ assertThat(inert.validate(nonsense, parent, contextWith(VALIDATORS))).isTrue();
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // Honest certificates.
+ // ---------------------------------------------------------------------------------------------
+
+ @Test
+ public void acceptsAQuorumCertificateOverTheParent() {
+ final BlockHeader parent = parentHeader(K_AT + 9L);
+ final BlockHeader block = blockWithSigners(K_AT + 10L, parent, List.of(0, 1, 2, 3, 4));
+ assertThat(rule.validate(block, parent, contextWith(VALIDATORS))).isTrue();
+ }
+
+ @Test
+ public void acceptsAValidCertificateOverASubsetThisNodeNeverHeard() {
+ // A5, the attack that killed Option V, and the reason V2 exists. Measured on 200 consecutive
+ // live headers read from two of our own nodes with identical block hashes: the seal SET differed
+ // on 11 of them. A rule that compared the carried certificate with the local view would reject
+ // roughly one header in eighteen with no attacker present. This rule never consults a local
+ // view, so every well formed subset of eligible signers is accepted on its own merits.
+ final BlockHeader parent = parentHeader(K_AT + 9L);
+ final ProtocolContext context = contextWith(VALIDATORS);
+ for (final List<Integer> subset :
+ List.of(
+ List.of(0, 1, 2, 3, 4),
+ List.of(0, 1, 2, 3, 5),
+ List.of(2, 3, 4, 5, 6),
+ List.of(0, 2, 4, 5, 6))) {
+ final BlockHeader block = blockWithSigners(K_AT + 10L, parent, subset);
+ assertThat(rule.validate(block, parent, context))
+ .withFailMessage("subset %s should be accepted on its own merits", subset)
+ .isTrue();
+ }
+ }
+
+ @Test
+ public void acceptsMoreThanTheThreshold() {
+ final BlockHeader parent = parentHeader(K_AT + 9L);
+ final BlockHeader block = blockWithSigners(K_AT + 10L, parent, List.of(0, 1, 2, 3, 4, 5, 6));
+ assertThat(rule.validate(block, parent, contextWith(VALIDATORS))).isTrue();
+ }
+
+ @Test
+ public void acceptsAnEmptyCertificateWhileTheThresholdIsZero() {
+ final BlockHeader parent = parentHeader(H - 1L);
+ final BlockHeader block = blockWithSigners(H, parent, List.of());
+ assertThat(rule.validate(block, parent, contextWith(VALIDATORS))).isTrue();
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // The four checks.
+ // ---------------------------------------------------------------------------------------------
+
+ @Test
+ public void rejectsACertificateBelowTheThresholdInForce() {
+ final BlockHeader parent = parentHeader(K_AT + 9L);
+ final BlockHeader block = blockWithSigners(K_AT + 10L, parent, List.of(0, 1, 2, 3));
+ assertThat(rule.validate(block, parent, contextWith(VALIDATORS))).isFalse();
+ }
+
+ @Test
+ public void rejectsAnEmptyCertificateOnceTheThresholdIsAboveZero() {
+ final BlockHeader parent = parentHeader(K_AT + 9L);
+ final BlockHeader block = blockWithSigners(K_AT + 10L, parent, List.of());
+ assertThat(rule.validate(block, parent, contextWith(VALIDATORS))).isFalse();
+ }
+
+ @Test
+ public void rejectsIndicesThatAreNotStrictlyIncreasing() {
+ final BlockHeader parent = parentHeader(K_AT + 9L);
+ final List<FalconSeal> reordered =
+ new ArrayList<>(sealsFor(parent, List.of(0, 1, 2, 3, 4)));
+ Collections.reverse(reordered);
+ final BlockHeader block =
+ header(K_AT + 10L, parent.getHash(), extraData(Bytes32.random(), reordered));
+ assertThat(rule.validate(block, parent, contextWith(VALIDATORS))).isFalse();
+ }
+
+ @Test
+ public void rejectsAnIndexTheRegistryDoesNotBindToAnAddress() {
+ final BlockHeader parent = parentHeader(K_AT + 9L);
+ final List<FalconSeal> seals = new ArrayList<>(sealsFor(parent, List.of(0, 1, 2, 3)));
+ final Bytes32 message = PqAnchor.commitMessage(CHAIN_ID, parent.getNumber(), parent.getHash().getBytes());
+ seals.add(new FalconSeal(42, FakeRegistry.sign(42, message)));
+ final BlockHeader block =
+ header(K_AT + 10L, parent.getHash(), extraData(Bytes32.random(), seals));
+ assertThat(rule.validate(block, parent, contextWith(VALIDATORS))).isFalse();
+ }
+
+ @Test
+ public void rejectsASignerThatWasNotAValidatorOfTheParent() {
+ // Removal safety: a validator dropped before the parent no longer counts, even though its key is
+ // still in the registry.
+ final BlockHeader parent = parentHeader(K_AT + 9L);
+ final BlockHeader block = blockWithSigners(K_AT + 10L, parent, List.of(0, 1, 2, 3, 6));
+ final List<Address> shrunk = VALIDATORS.subList(0, 6);
+ assertThat(rule.validate(block, parent, contextWith(shrunk))).isFalse();
+ // and the same certificate over the full parent set is fine, so the refusal is about
+ // eligibility and nothing else.
+ assertThat(rule.validate(block, parent, contextWith(VALIDATORS))).isTrue();
+ }
+
+ @Test
+ public void rejectsASignatureThatDoesNotVerify() {
+ final BlockHeader parent = parentHeader(K_AT + 9L);
+ final List<FalconSeal> seals = new ArrayList<>(sealsFor(parent, List.of(0, 1, 2, 3)));
+ seals.add(new FalconSeal(4, Bytes.repeat((byte) 0x5a, 655)));
+ final BlockHeader block =
+ header(K_AT + 10L, parent.getHash(), extraData(Bytes32.random(), seals));
+ assertThat(rule.validate(block, parent, contextWith(VALIDATORS))).isFalse();
+ }
+
+ @Test
+ public void rejectsSealsReplayedFromAnotherHeight() {
+ // A6, first half: M binds the chain id, the block number and the block hash, so a certificate
+ // that is genuine over a different parent is not material here.
+ final BlockHeader parent = parentHeader(K_AT + 9L);
+ final BlockHeader otherParent = parentHeader(K_AT + 8L);
+ final List<FalconSeal> foreign = sealsFor(otherParent, List.of(0, 1, 2, 3, 4));
+ final BlockHeader block =
+ header(K_AT + 10L, parent.getHash(), extraData(Bytes32.random(), foreign));
+ assertThat(rule.validate(block, parent, contextWith(VALIDATORS))).isFalse();
+ }
+
+ @Test
+ public void rejectsSealsReplayedFromAnotherChain() {
+ // A6, second half: the 442807 proving chain runs the same binaries and can run the same keys.
+ final BlockHeader parent = parentHeader(K_AT + 9L);
+ final Bytes32 foreignMessage =
+ PqAnchor.commitMessage(442807L, parent.getNumber(), parent.getHash().getBytes());
+ final List<FalconSeal> foreign = new ArrayList<>();
+ for (final int index : List.of(0, 1, 2, 3, 4)) {
+ foreign.add(new FalconSeal(index, FakeRegistry.sign(index, foreignMessage)));
+ }
+ final BlockHeader block =
+ header(K_AT + 10L, parent.getHash(), extraData(Bytes32.random(), foreign));
+ assertThat(rule.validate(block, parent, contextWith(VALIDATORS))).isFalse();
+ }
+
+ @Test
+ public void rejectsTwoIndicesThatResolveToOneValidator() {
+ // Strictly increasing indices stop a repeated INDEX. A malformed registry could still map two
+ // distinct indices onto one address, which would let one validator be counted twice.
+ final BlockHeader parent = parentHeader(K_AT + 9L);
+ registry.bind(7, VALIDATORS.get(0));
+ final BlockHeader block = blockWithSigners(K_AT + 10L, parent, List.of(0, 1, 2, 3, 7));
+ assertThat(rule.validate(block, parent, contextWith(VALIDATORS))).isFalse();
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // KEY ROTATION AND REBINDING: an adversarial review of R2, reproduced against R2 itself.
+ //
+ // T2, the first objection: a header that passes both rules today is REJECTED the moment index 0's
+ // Falcon key is rotated. Nothing else changes - same header, same parent, same validator set.
+ // T3, the second: if the index is rebound to ANOTHER validator who is still in the current set,
+ // the rule sees nothing.
+ //
+ // Neither test could have been written against the earlier interface. PqSignerRegistry carried a
+ // height-less pair plus a default that forwarded the height-aware form to it, so every test
+ // double in the tree - including the review's own probe - silently threw the height away and
+ // answered from ONE key set. A fake with one key set cannot express a rotation, so it cannot fail
+ // on one. The compiler now refuses that fake.
+ // ---------------------------------------------------------------------------------------------
+
+ @Test
+ public void d2t2AHeaderBelowARotationIsStillVerifiedUnderTheKEYSETITWASSEALEDUNDER() {
+ // The T2 measurement, stated as a property rather than a scenario: an honest certificate over
+ // parent P must still verify after the chain rotates index 0's key at a LATER height. The seals
+ // are produced under generation 0, which is what was in force at P.
+ final long rotationHeight = K_AT + 5_000L;
+ final BlockHeader parent = parentHeader(K_AT + 9L);
+ final BlockHeader block = blockWithSigners(K_AT + 10L, parent, List.of(0, 1, 2, 3, 4));
+ final ProtocolContext context = contextWith(VALIDATORS);
+
+ assertThat(rule.validate(block, parent, context))
+ .describedAs("baseline: the certificate is honest before any rotation")
+ .isTrue();
+
+ registry.rotateKeyFrom(rotationHeight, 0, 1);
+
+ assertThat(rule.validate(block, parent, context))
+ .describedAs(
+ "T2: the SAME header, the SAME parent and the SAME validator set, after a key "
+ + "rotation at a height ABOVE it. R2 must resolve keys at the parent's height. If "
+ + "this is false the chain is unjoinable for every node that syncs after a rotation")
+ .isTrue();
+ }
+
+ @Test
+ public void d2t2ASealFromTheOLDKeyIsRefusedABOVETheRotation() {
+ // The other side of the same coin, and the reason the test above is not satisfied by a rule
+ // that ignores rotations entirely. Above the rotation height the old key must NOT verify -
+ // otherwise "rotation" would mean nothing and a revoked key would keep signing forever.
+ final long rotationHeight = K_AT + 5_000L;
+ registry.rotateKeyFrom(rotationHeight, 0, 1);
+
+ final BlockHeader parent = parentHeader(rotationHeight + 9L);
+ final Bytes32 message =
+ PqAnchor.commitMessage(CHAIN_ID, parent.getNumber(), parent.getHash().getBytes());
+ final List<FalconSeal> seals = new ArrayList<>();
+ // index 0 signs with the RETIRED generation; the other four are honest.
+ seals.add(new FalconSeal(0, FakeRegistry.sign(0, 0, message)));
+ for (final int index : List.of(1, 2, 3, 4)) {
+ seals.add(new FalconSeal(index, FakeRegistry.sign(index, message)));
+ }
+ final BlockHeader block =
+ header(rotationHeight + 10L, parent.getHash(), extraData(Bytes32.random(), seals));
+
+ assertThat(rule.validate(block, parent, contextWith(VALIDATORS)))
+ .describedAs("a seal from the key the chain retired must not count above the rotation")
+ .isFalse();
+ }
+
+ @Test
+ public void d2t3ARebindingOfAnIndexToAnotherCURRENTValidatorIsNotSeenByR2() {
+ // T3, and this test is here to keep an OPEN finding open rather than to prove a repair.
+ //
+ // Measured, not argued: PqAnchorSealsRule credits a seal to whatever address the registry hands
+ // back for its index, checks that address against the parent's validator set, and never asks
+ // which KEY signed. So rebinding index 0 to validator 5 - who is in the set - is invisible: the
+ // seal was produced by index 0's key and is credited to validator 5.
+ //
+ // The height-indexed registry NARROWS this: a rebinding changes the registry's canonical hash
+ // (PqRegistryHash.java:274-277 puts the address in the pre-image), so it cannot happen without
+ // a new scheduled epoch that every node must be given. It does not CLOSE it: whoever writes
+ // that epoch can write the rebinding into it and the whole fleet will accept it. Closing it
+ // needs eligibility DERIVED from the Falcon key rather than bound to it by configuration.
+ final long rebindHeight = K_AT + 5_000L;
+ final BlockHeader parent = parentHeader(rebindHeight + 9L);
+ // Validator 5 does not sign; index 0's key does. After the rebinding the seal is credited to 5.
+ registry.rebindFrom(rebindHeight, 0, VALIDATORS.get(5));
+
+ final Bytes32 message =
+ PqAnchor.commitMessage(CHAIN_ID, parent.getNumber(), parent.getHash().getBytes());
+ final List<FalconSeal> seals = new ArrayList<>();
+ for (final int index : List.of(0, 1, 2, 3, 4)) {
+ seals.add(new FalconSeal(index, FakeRegistry.sign(index, message)));
+ }
+ final BlockHeader block =
+ header(rebindHeight + 10L, parent.getHash(), extraData(Bytes32.random(), seals));
+
+ assertThat(rule.validate(block, parent, contextWith(VALIDATORS)))
+ .describedAs(
+ "T3 IS STILL OPEN: R2 accepts a certificate in which index 0's key is credited to "
+ + "validator 5. When this assertion has to be flipped to isFalse(), T3 has been "
+ + "closed and this comment is the record of when it was not")
+ .isTrue();
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // Fail-closed on everything else.
+ // ---------------------------------------------------------------------------------------------
+
+ @Test
+ public void rejectsWhenTheParentIsMissingOrIsNotTheNamedParent() {
+ final BlockHeader parent = parentHeader(K_AT + 9L);
+ final BlockHeader block = blockWithSigners(K_AT + 10L, parent, List.of(0, 1, 2, 3, 4));
+ final ProtocolContext context = contextWith(VALIDATORS);
+ assertThat(rule.validate(block, null, context)).isFalse();
+ assertThat(rule.validate(block, parentHeader(K_AT + 8L), context)).isFalse();
+ }
+
+ @Test
+ public void rejectsWhenTheParentValidatorSetCannotBeResolved() {
+ final BlockHeader parent = parentHeader(K_AT + 9L);
+ final BlockHeader block = blockWithSigners(K_AT + 10L, parent, List.of(0, 1, 2, 3, 4));
+ assertThat(rule.validate(block, parent, contextWith(Collections.emptyList()))).isFalse();
+ assertThat(rule.validate(block, parent, contextWith(null))).isFalse();
+ }
+
+ @Test
+ public void rejectsUndecodableExtraData() {
+ final BlockHeader parent = parentHeader(K_AT + 9L);
+ final BlockHeader broken =
+ header(K_AT + 10L, parent.getHash(), Bytes.fromHexString("0xdeadbeef"));
+ assertThat(rule.validate(broken, parent, contextWith(VALIDATORS))).isFalse();
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // Where it runs.
+ // ---------------------------------------------------------------------------------------------
+
+ @Test
+ public void includeInLightValidationIsFalseAndTheRuleIsSkippedThere() {
+ assertThat(rule.includeInLightValidation()).isFalse();
+ final BlockHeader parent = parentHeader(K_AT + 9L);
+ final BlockHeader tooFew = blockWithSigners(K_AT + 10L, parent, List.of(0, 1));
+ final ProtocolContext context = contextWith(VALIDATORS);
+ final BlockHeaderValidator validator =
+ new BlockHeaderValidator.Builder().addRule(rule).build();
+ // Skipped in every light mode: this is the expensive half, and R1 already carries the binding
+ // into light validation.
+ assertThat(validator.validateHeader(tooFew, parent, context, HeaderValidationMode.LIGHT))
+ .isTrue();
+ assertThat(
+ validator.validateHeader(
+ tooFew, parent, context, HeaderValidationMode.LIGHT_SKIP_DETACHED))
+ .isTrue();
+ // And decisive in full validation.
+ assertThat(validator.validateHeader(tooFew, parent, context, HeaderValidationMode.FULL))
+ .isFalse();
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // Helpers.
+ // ---------------------------------------------------------------------------------------------
+
+ private List<FalconSeal> sealsFor(final BlockHeader parent, final List<Integer> indices) {
+ final Bytes32 message = PqAnchor.commitMessage(CHAIN_ID, parent.getNumber(), parent.getHash().getBytes());
+ final List<FalconSeal> seals = new ArrayList<>();
+ for (final int index : indices) {
+ seals.add(new FalconSeal(index, FakeRegistry.sign(index, message)));
+ }
+ return PqAnchor.sortedByIndex(seals);
+ }
+
+ /**
+ * A header carrying an honest certificate from the given signers. vanityData is set to the correct
+ * anchor digest so that a header built here would also satisfy R1; R2 does not read it.
+ */
+ private BlockHeader blockWithSigners(
+ final long number, final BlockHeader parent, final List<Integer> indices) {
+ final List<FalconSeal> seals = sealsFor(parent, indices);
+ final Bytes32 digest =
+ PqAnchor.anchorDigest(CHAIN_ID, parent.getNumber(), parent.getHash().getBytes(), seals);
+ return header(number, parent.getHash(), extraData(digest, seals));
+ }
+
+ private ProtocolContext contextWith(final Collection<Address> parentValidators) {
+ final ValidatorProvider validatorProvider =
+ mock(ValidatorProvider.class, withSettings().strictness(Strictness.LENIENT));
+ when(validatorProvider.getValidatorsForBlock(any())).thenReturn(parentValidators);
+ final BftContext bftContext =
+ mock(BftContext.class, withSettings().strictness(Strictness.LENIENT));
+ when(bftContext.getValidatorProvider()).thenReturn(validatorProvider);
+ when(bftContext.as(any())).thenReturn(bftContext);
+ return new ProtocolContext.Builder().withConsensusContext(bftContext).build();
+ }
+
+ /**
+ * A registry with the same shape as the Falcon one: index to address, plus a verifier. The fake
+ * signature is a deterministic function of the index AND the message, so replaying a genuine seal
+ * at another height or on another chain fails exactly as a real Falcon verification would.
+ */
+ private static final class FakeRegistry implements PqSignerRegistry {
+
+ /** Epoch start height to the index-to-address binding in force from there. */
+ private final NavigableMap<Long, Map<Integer, Address>> epochs = new TreeMap<>();
+
+ /**
+ * Epoch start height to the KEY GENERATION in force from there. The fake signature is a
+ * function of the generation, so "rotate index 0's key" and "rebind index 0 to another
+ * validator" are two DIFFERENT edits here, exactly as they are on the wire. T2 is the first,
+ * T3 the second.
+ */
+ private final NavigableMap<Long, Integer> generations = new TreeMap<>();
+
+ private FakeRegistry() {
+ final Map<Integer, Address> genesisEpoch = new HashMap<>();
+ for (int i = 0; i < VALIDATORS.size(); i++) {
+ genesisEpoch.put(i, VALIDATORS.get(i));
+ }
+ epochs.put(0L, genesisEpoch);
+ generations.put(0L, 0);
+ }
+
+ void bind(final int index, final Address address) {
+ epochs.get(epochs.firstKey()).put(index, address);
+ }
+
+ /**
+ * T2. From {@code from}, index {@code index} answers to a NEW key generation. Everything else -
+ * the address binding, the validator set, the parent - is untouched, which is the whole point
+ * of the T2 measurement: a header that verified yesterday must still verify today.
+ */
+ void rotateKeyFrom(final long from, final int index, final int generation) {
+ final Map<Integer, Address> carried = new HashMap<>(epochs.floorEntry(from).getValue());
+ epochs.put(from, carried);
+ generations.put(from, generation);
+ rotatedIndex = index;
+ }
+
+ /**
+ * T3. From {@code from}, index {@code index} answers to a DIFFERENT validator address, keys
+ * untouched. The seal is then credited to a validator that did not sign it.
+ */
+ void rebindFrom(final long from, final int index, final Address address) {
+ final Map<Integer, Address> carried = new HashMap<>(epochs.floorEntry(from).getValue());
+ carried.put(index, address);
+ epochs.put(from, carried);
+ generations.put(from, generations.floorEntry(from).getValue());
+ }
+
+ private int rotatedIndex = -1;
+
+ static Bytes sign(final int index, final Bytes message) {
+ return sign(index, 0, message);
+ }
+
+ static Bytes sign(final int index, final int generation, final Bytes message) {
+ final byte[] out = new byte[655];
+ out[0] = (byte) index;
+ for (int i = 0; i < message.size() && i < 32; i++) {
+ out[1 + i] = message.get(i);
+ }
+ for (int i = 33; i < out.length; i++) {
+ out[i] = (byte) ((i * (index + 3) + 7 + generation * 31) & 0xFF);
+ }
+ return Bytes.wrap(out);
+ }
+
+ private int generationAt(final long blockNumber, final int validatorIndex) {
+ final Integer g = generations.floorEntry(blockNumber).getValue();
+ // Only the rotated index changes generation; every other index keeps generation 0.
+ return (rotatedIndex < 0 || validatorIndex == rotatedIndex) ? g : 0;
+ }
+
+ // ROTATION HARDENING (a): the height-less pair is gone from PqSignerRegistry, so this double
+ // can no longer inherit a default that throws the height away. That default is why the original
+ // probe returned the same verdict on repaired and unrepaired code; a fake that cannot express a
+ // rotation cannot prove one is handled.
+ @Override
+ public Address addressForIndexAtOwnHead(final long blockNumber, final int validatorIndex) {
+ return addressForIndexAtHistoric(blockNumber, validatorIndex);
+ }
+
+ @Override
+ public boolean verifyAtOwnHead(
+ final long blockNumber,
+ final int validatorIndex,
+ final Bytes message,
+ final Bytes signature) {
+ return verifyAtHistoric(blockNumber, validatorIndex, message, signature);
+ }
+
+ @Override
+ public Address addressForIndexAtHistoric(final long blockNumber, final int validatorIndex) {
+ return epochs.floorEntry(blockNumber).getValue().get(validatorIndex);
+ }
+
+ @Override
+ public boolean verifyAtHistoric(
+ final long blockNumber,
+ final int validatorIndex,
+ final Bytes message,
+ final Bytes signature) {
+ return signature != null
+ && signature.equals(sign(validatorIndex, generationAt(blockNumber, validatorIndex), message));
+ }
+
+ @Override
+ public String toString() {
+ return "FakeRegistry";
+ }
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // ROTATION HARDENING (b-v2). WHICH DOOR R2 GOES THROUGH, measured by behaviour, not by name.
+ //
+ // The shape chosen here admits its own weakness: the compiler forces you to CHOOSE between
+ // addressForIndexAtHistoric and addressForIndexAtOwnHead, but it does not force you to choose
+ // CORRECTLY. Both have the same type and both are total, so a future patch that moves this call
+ // onto the OWN-HEAD door reopens the rotation defect without anything going red, because the
+ // OWN-HEAD door is precisely the one that always answers.
+ //
+ // The two assertions below are the lock. The registry they run against answers DIFFERENTLY on
+ // the two doors, which no real registry does; that is exactly why it can say which door was
+ // opened. The pair also carries its positive control built in: if R2 called NEITHER door, the
+ // first assertion would pass and the second would fail.
+ // ---------------------------------------------------------------------------------------------
+
+ @Test
+ public void r2GoesThroughTheHISTORYDoorAndThroughNothingElse() {
+ final BlockHeader parent = parentHeader(K_AT + 9L);
+ final BlockHeader block = blockWithSigners(K_AT + 10L, parent, List.of(0, 1, 2, 3, 4));
+ final ProtocolContext context = contextWith(VALIDATORS);
+
+ assertThat(rule.validate(block, parent, context))
+ .describedAs("baseline: the same header is accepted by the ordinary registry")
+ .isTrue();
+
+ final PqAnchorSealsRule peUsaIstorie =
+ new PqAnchorSealsRule(armed, new OneDoorRegistry(registry, false));
+ assertThat(peUsaIstorie.validate(block, parent, context))
+ .describedAs(
+ "the HISTORY door refuses everything and the OWN-HEAD door answers everything. R2 must "
+ + "REJECT. If this is true, R2 is reading the own-head door, which means it would "
+ + "answer a header it received from the registry in force at this node's head - "
+ + "and that is the rotation defect verbatim")
+ .isFalse();
+
+ final PqAnchorSealsRule peUsaCap =
+ new PqAnchorSealsRule(armed, new OneDoorRegistry(registry, true));
+ assertThat(peUsaCap.validate(block, parent, context))
+ .describedAs(
+ "and the mirror image: the HISTORY door answers, the OWN-HEAD door refuses, and R2 "
+ + "ACCEPTS. Without this second half the first could pass on a rule that calls "
+ + "neither door at all")
+ .isTrue();
+ }
+
+ /**
+ * A registry that answers on exactly ONE of the two doors and refuses on the other. No real
+ * registry behaves like this; that is the point - it is an instrument for asking which door a
+ * caller opened, and it is the only kind of double that can catch a call site moved to the wrong
+ * one.
+ */
+ private static final class OneDoorRegistry implements PqSignerRegistry {
+ private final PqSignerRegistry delegate;
+ private final boolean historyAnswers;
+
+ private OneDoorRegistry(final PqSignerRegistry delegate, final boolean historyAnswers) {
+ this.delegate = delegate;
+ this.historyAnswers = historyAnswers;
+ }
+
+ @Override
+ public Address addressForIndexAtHistoric(final long blockNumber, final int validatorIndex) {
+ return historyAnswers ? delegate.addressForIndexAtHistoric(blockNumber, validatorIndex) : null;
+ }
+
+ @Override
+ public boolean verifyAtHistoric(
+ final long blockNumber,
+ final int validatorIndex,
+ final Bytes message,
+ final Bytes signature) {
+ return historyAnswers
+ && delegate.verifyAtHistoric(blockNumber, validatorIndex, message, signature);
+ }
+
+ @Override
+ public Address addressForIndexAtOwnHead(final long blockNumber, final int validatorIndex) {
+ return historyAnswers ? null : delegate.addressForIndexAtOwnHead(blockNumber, validatorIndex);
+ }
+
+ @Override
+ public boolean verifyAtOwnHead(
+ final long blockNumber,
+ final int validatorIndex,
+ final Bytes message,
+ final Bytes signature) {
+ return !historyAnswers
+ && delegate.verifyAtOwnHead(blockNumber, validatorIndex, message, signature);
+ }
+
+ @Override
+ public String toString() {
+ return "OneDoorRegistry{historyAnswers=" + historyAnswers + "}";
+ }
+ }
+
+ @Test
+ public void rejectsAnInvalidSignatureInEVERYPositionOfTheCertificate() {
+ // Added 2026-08-02 after a planted defect proved this was missing.
+ //
+ // The three signature tests above are all satisfied by a rule that skips the verification of
+ // ONE chosen position: rejectsASignatureThatDoesNotVerify puts its bad seal LAST, and the two
+ // replay tests make EVERY seal bad, so any one of them still fails on some other seal. A rule
+ // mutated to skip exactly the first seal's verification left all 187 tests of consensus:qbft
+ // green. An off-by-one in this loop, a stray continue, or a short-circuit on the first element
+ // is exactly that shape of bug, and it would have shipped.
+ //
+ // This test walks the bad signature through every position, so no per-position skip survives.
+ final BlockHeader parent = parentHeader(K_AT + 9L);
+ final List<Integer> signers = List.of(0, 1, 2, 3, 4);
+ for (int position = 0; position < signers.size(); position++) {
+ final List<FalconSeal> seals = new ArrayList<>(sealsFor(parent, signers));
+ final FalconSeal good = seals.get(position);
+ seals.set(
+ position, new FalconSeal(good.getValidatorIndex(), Bytes.repeat((byte) 0x5a, 655)));
+ final BlockHeader block =
+ header(K_AT + 10L, parent.getHash(), extraData(Bytes32.random(), seals));
+ assertThat(rule.validate(block, parent, contextWith(VALIDATORS)))
+ .describedAs("certificate with the only invalid signature at position %d", position)
+ .isFalse();
+ }
+ }
+
+ @Test
+ public void acceptsTheSameCertificateWhenEveryPositionIsHonest() {
+ // The negative control for the test above: without it, a rule that rejected EVERYTHING would
+ // pass the per-position walk and prove nothing at all.
+ final BlockHeader parent = parentHeader(K_AT + 9L);
+ final BlockHeader block = blockWithSigners(K_AT + 10L, parent, List.of(0, 1, 2, 3, 4));
+ assertThat(rule.validate(block, parent, contextWith(VALIDATORS))).isTrue();
+ }
+}
diff --git a/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorTestSupport.java b/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorTestSupport.java
new file mode 100755
index 000000000..4dafdf09b
--- /dev/null
+++ b/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorTestSupport.java
@@ -0,0 +1,144 @@
+/*
+ * Copyright contributors to Besu / Aere Network.
+ *
+ * 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.headervalidationrules;
+
+import org.hyperledger.besu.consensus.common.bft.BftExtraData;
+import org.hyperledger.besu.consensus.common.bft.FalconSeal;
+import org.hyperledger.besu.consensus.common.bft.PqAnchor;
+import org.hyperledger.besu.consensus.qbft.QbftExtraDataCodec;
+import org.hyperledger.besu.datatypes.Address;
+import org.hyperledger.besu.datatypes.Hash;
+import org.hyperledger.besu.ethereum.core.BlockHeader;
+import org.hyperledger.besu.ethereum.core.BlockHeaderTestFixture;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+
+import org.apache.tuweni.bytes.Bytes;
+import org.apache.tuweni.bytes.Bytes32;
+
+/** Header construction shared by the two anchor rule test classes. */
+public final class PqAnchorTestSupport {
+
+ /** The chain the rules under test are configured for. */
+ public static final long CHAIN_ID = 2800L;
+
+ /** The activation height H used throughout these tests. */
+ public static final long H = 1_000L;
+
+ /** The QBFT validator addresses used as the parent's validator set. */
+ public static final List<Address> VALIDATORS =
+ List.of(
+ Address.fromHexString("0x0000000000000000000000000000000000000a01"),
+ Address.fromHexString("0x0000000000000000000000000000000000000a02"),
+ Address.fromHexString("0x0000000000000000000000000000000000000a03"),
+ Address.fromHexString("0x0000000000000000000000000000000000000a04"),
+ Address.fromHexString("0x0000000000000000000000000000000000000a05"),
+ Address.fromHexString("0x0000000000000000000000000000000000000a06"),
+ Address.fromHexString("0x0000000000000000000000000000000000000a07"));
+
+ private static final QbftExtraDataCodec CODEC = new QbftExtraDataCodec();
+
+ private PqAnchorTestSupport() {}
+
+ /**
+ * A deterministic stand-in for a Falcon-512 signature. {@code variant} lets a test produce a
+ * DIFFERENT but equally well formed signature under the same index, which is what an "equally
+ * valid certificate, different bytes" substitution looks like on the wire.
+ *
+ * @param index the validator index the signature belongs to
+ * @param variant a discriminator so two signatures for one index differ
+ * @return 655 deterministic bytes, the measured mean Falcon-512 signature length
+ */
+ public static Bytes signature(final int index, final int variant) {
+ final byte[] bytes = new byte[655];
+ for (int i = 0; i < bytes.length; i++) {
+ bytes[i] = (byte) ((i * 31 + index * 7 + variant * 101) & 0xFF);
+ }
+ return Bytes.wrap(bytes);
+ }
+
+ /**
+ * A seal for a validator index.
+ *
+ * @param index the validator index
+ * @return the seal
+ */
+ public static FalconSeal seal(final int index) {
+ return new FalconSeal(index, signature(index, 0));
+ }
+
+ /**
+ * Build the extraData bytes of a header carrying a given certificate and a given vanity field.
+ *
+ * @param vanity the 32-byte vanity field, which from H is the anchor digest D
+ * @param certificate the carried certificate C, in the order it is to be written
+ * @return canonical extraData bytes
+ */
+ public static Bytes extraData(final Bytes vanity, final Collection<FalconSeal> certificate) {
+ return CODEC.encode(
+ new BftExtraData(
+ vanity, Collections.emptyList(), Optional.empty(), 0, VALIDATORS, certificate));
+ }
+
+ /**
+ * Build a header at a height, over a parent hash, carrying a certificate whose anchor digest is
+ * written into vanityData. This is what an HONEST proposer produces.
+ *
+ * @param number the height of the header
+ * @param parentHash the parent hash the header names
+ * @param certificate the certificate over the parent
+ * @return the header
+ */
+ public static BlockHeader honestHeader(
+ final long number, final Hash parentHash, final List<FalconSeal> certificate) {
+ final List<FalconSeal> sorted = PqAnchor.sortedByIndex(certificate);
+ final Bytes32 digest = PqAnchor.anchorDigest(CHAIN_ID, number - 1L, parentHash.getBytes(), sorted);
+ return header(number, parentHash, extraData(digest, sorted));
+ }
+
+ /**
+ * Build a header with explicit extraData bytes, so a test can tamper with the certificate while
+ * leaving vanityData as the honest proposer wrote it.
+ *
+ * @param number the height of the header
+ * @param parentHash the parent hash the header names
+ * @param extraData the exact extraData bytes
+ * @return the header
+ */
+ public static BlockHeader header(
+ final long number, final Hash parentHash, final Bytes extraData) {
+ return new BlockHeaderTestFixture()
+ .number(number)
+ .parentHash(parentHash)
+ .extraData(extraData)
+ .buildHeader();
+ }
+
+ /**
+ * The parent header a test uses, whose hash is what the child names.
+ *
+ * @param number the parent's height
+ * @return the parent header
+ */
+ public static BlockHeader parentHeader(final long number) {
+ return new BlockHeaderTestFixture()
+ .number(number)
+ .extraData(extraData(Bytes32.ZERO, Collections.emptyList()))
+ .buildHeader();
+ }
+}
diff --git a/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqArmedWithoutRegistryTest.java b/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqArmedWithoutRegistryTest.java
new file mode 100755
index 000000000..419a59766
--- /dev/null
+++ b/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqArmedWithoutRegistryTest.java
@@ -0,0 +1,185 @@
+/*
+ * Copyright contributors to Besu / Aere Network.
+ *
+ * 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.headervalidationrules;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.VALIDATORS;
+import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.parentHeader;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import static org.mockito.Mockito.withSettings;
+
+import org.hyperledger.besu.consensus.common.bft.BftContext;
+import org.hyperledger.besu.consensus.common.bft.FalconSealSupport;
+import org.hyperledger.besu.consensus.common.bft.PqV2Fixture;
+import org.hyperledger.besu.consensus.common.validator.ValidatorProvider;
+import org.hyperledger.besu.datatypes.Address;
+import org.hyperledger.besu.ethereum.ProtocolContext;
+import org.hyperledger.besu.ethereum.core.BlockHeader;
+
+import java.lang.reflect.Field;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Collection;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.mockito.quality.Strictness;
+
+/**
+ * ARMED WITHOUT A REGISTRY, THE RESIDUAL: the accident the configuration guard cannot refuse.
+ *
+ * <p>{@code FalconSealSupport.validateAnchorObservationHeightOrAbort} refuses to start a node whose
+ * blocking height is armed at or before the height at which its registry can become active. That
+ * closes the misconfiguration. It cannot close the ACCIDENT: an operator declares the observation
+ * height correctly, the anchor-deploy transaction does not land, and the blocking height arrives
+ * over an empty registry anyway.
+ *
+ * <p>{@link FalconSealValidationRule} answers that by staying LOG-ONLY, and that answer is right -
+ * blocking over an empty registry buys no safety and costs the chain. It is also exactly how the
+ * condition used to disappear: the node was configured to enforce a post-quantum quorum, enforced
+ * nothing, and said so in a warning that nothing reads and no command can exit on.
+ *
+ * <p>These three tests measure the mark it now leaves, in both directions.
+ */
+public class PqArmedWithoutRegistryTest {
+
+ private static final int N = 9;
+
+ private static final long OBSERVE = 5_000L;
+
+ private static final long ATTACH = 6_000L;
+
+ private static final long FORK = 7_000L;
+
+ private static final String ANCHOR_ADDRESS = "0x0000000000000000000000000000000000000fa1";
+
+ /**
+ * REGISTRY BINDING: the chain this fixture's registry is BOUND to. Inside every proof, so stated.
+ */
+ private static final long CHAIN_ID = 2_800L;
+
+ @TempDir private Path tmp;
+
+ @BeforeEach
+ public void setUp() throws Exception {
+ // REGISTRY BINDING: a v2 manifest, proof-bound, bound at FORK. This manifest used to spell its
+ // addresses 0xC00+i; no secp256k1 key produces those, so once AERE-PQC-REG-ARM-02 was wired
+ // this armed fixture could not start at all. PqV2Fixture lives in consensus:common's test
+ // source set and reaches here through the testArtifacts dependency this module already had.
+ final StringBuilder m = new StringBuilder("{");
+ m.append(PqV2Fixture.manifestHeader(N, CHAIN_ID, FORK));
+ for (int i = 0; i < N; i++) {
+ m.append(',').append(PqV2Fixture.manifestEntry(i, N, CHAIN_ID, FORK));
+ }
+ m.append("}");
+ final Path manifest = tmp.resolve("falcon-late-manifest.json");
+ Files.writeString(manifest, m.toString());
+
+ System.setProperty("aere.falcon.manifest", manifest.toAbsolutePath().toString());
+ System.setProperty("aere.falcon.anchor.address", ANCHOR_ADDRESS);
+ System.setProperty("aere.falcon.anchor.block", Long.toString(OBSERVE));
+ System.setProperty("aere.falcon.attachBlock", Long.toString(ATTACH));
+ System.setProperty("aere.falcon.forkBlock", Long.toString(FORK));
+ System.setProperty("aere.falcon.validatorCount", Integer.toString(N));
+ forgetFalconSingleton();
+ }
+
+ @AfterEach
+ public void tearDown() throws Exception {
+ for (final String p :
+ new String[] {
+ "aere.falcon.manifest",
+ "aere.falcon.anchor.address",
+ "aere.falcon.anchor.block",
+ "aere.falcon.attachBlock",
+ "aere.falcon.forkBlock",
+ "aere.falcon.validatorCount"
+ }) {
+ System.clearProperty(p);
+ }
+ forgetFalconSingleton();
+ }
+
+ @Test
+ public void belowTheBlockingHeightNothingIsRecorded() {
+ // The negative control. A counter that were set unconditionally would pass the test below and
+ // mean nothing at all.
+ final FalconSealValidationRule rule = new FalconSealValidationRule(Long.MAX_VALUE);
+ final BlockHeader parent = parentHeader(FORK - 2L);
+ final BlockHeader block = parentHeader(FORK - 1L);
+
+ assertThat(rule.validate(block, parent, contextWith(VALIDATORS))).isTrue();
+ assertThat(FalconSealSupport.instance().blockingArmedWithoutRegistrySince())
+ .describedAs("below the blocking height there is nothing inert about being log-only")
+ .isEqualTo(-1L);
+ }
+
+ @Test
+ public void atTheBlockingHeightWithNoActiveRegistryTheHeightIsRecorded() {
+ final FalconSealValidationRule rule = new FalconSealValidationRule(Long.MAX_VALUE);
+ final BlockHeader parent = parentHeader(FORK - 1L);
+ final BlockHeader block = parentHeader(FORK);
+
+ assertThat(FalconSealSupport.instance().blockingArmedWithoutRegistrySince()).isEqualTo(-1L);
+ assertThat(rule.validate(block, parent, contextWith(VALIDATORS)))
+ .describedAs(
+ "the rule must still ACCEPT: blocking over an empty registry is a halt, not a safeguard")
+ .isTrue();
+ assertThat(FalconSealSupport.instance().blockingArmedWithoutRegistrySince())
+ .describedAs(
+ "the node is configured to enforce a Falcon quorum at %d and is enforcing nothing. That "
+ + "must be a value something can read, not a line in a file.",
+ FORK)
+ .isEqualTo(FORK);
+ assertThat(FalconSealSupport.instance().anchorObserveBlock())
+ .describedAs("and the declared height it was measured against must be readable too")
+ .isEqualTo(OBSERVE);
+ }
+
+ @Test
+ public void theRecordedHeightIsTheFirstOneAndDoesNotMoveWithTheChain() {
+ final FalconSealValidationRule rule = new FalconSealValidationRule(Long.MAX_VALUE);
+ rule.validate(parentHeader(FORK), parentHeader(FORK - 1L), contextWith(VALIDATORS));
+ rule.validate(parentHeader(FORK + 40L), parentHeader(FORK + 39L), contextWith(VALIDATORS));
+
+ assertThat(FalconSealSupport.instance().blockingArmedWithoutRegistrySince())
+ .describedAs(
+ "the value answers 'since when', so a later block must not overwrite it; if it tracked "
+ + "the head it would report a fresh problem forever and never a duration")
+ .isEqualTo(FORK);
+ }
+
+ private static ProtocolContext contextWith(final Collection<Address> validators) {
+ final ValidatorProvider validatorProvider =
+ mock(ValidatorProvider.class, withSettings().strictness(Strictness.LENIENT));
+ when(validatorProvider.getValidatorsForBlock(any())).thenReturn(validators);
+ when(validatorProvider.getValidatorsAfterBlock(any())).thenReturn(validators);
+ final BftContext bftContext =
+ mock(BftContext.class, withSettings().strictness(Strictness.LENIENT));
+ when(bftContext.getValidatorProvider()).thenReturn(validatorProvider);
+ when(bftContext.as(any())).thenReturn(bftContext);
+ return new ProtocolContext.Builder().withConsensusContext(bftContext).build();
+ }
+
+ private static void forgetFalconSingleton() throws Exception {
+ final Field f = FalconSealSupport.class.getDeclaredField("instance");
+ f.setAccessible(true);
+ f.set(null, null);
+ }
+}
diff --git a/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqEmergencyShoutRuleTest.java b/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqEmergencyShoutRuleTest.java
new file mode 100755
index 000000000..4e5d4251b
--- /dev/null
+++ b/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqEmergencyShoutRuleTest.java
@@ -0,0 +1,78 @@
+/*
+ * Copyright contributors to Besu / Aere Network.
+ *
+ * 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.headervalidationrules;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.CHAIN_ID;
+import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.H;
+import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.parentHeader;
+
+import org.hyperledger.besu.consensus.common.bft.PqAnchorConfig;
+import org.hyperledger.besu.ethereum.core.BlockHeader;
+
+import java.util.Map;
+import java.util.OptionalInt;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * AERE OPTIUNI-URGENTA: the rule whose entire job is to be impossible to ignore.
+ *
+ * <p>The one property that must hold on every path is that it CANNOT REJECT. It is placed first in
+ * the QBFT header ruleset precisely because a rule that always returns true cannot change the
+ * verdict of a conjunction, and that claim has to be asserted rather than assumed, including for a
+ * header it has never seen before and for a null parent.
+ */
+class PqEmergencyShoutRuleTest {
+
+ private PqEmergencyShoutRule rule(final boolean disabled, final OptionalInt ceiling) {
+ return new PqEmergencyShoutRule(
+ new PqAnchorConfig(CHAIN_ID, H, Map.of(H, 0, H + 100L, 5), ceiling, disabled));
+ }
+
+ @Test
+ void acceptsEveryHeaderWhenNothingIsOverridden() {
+ final PqEmergencyShoutRule r = rule(false, OptionalInt.empty());
+ for (final long n : new long[] {0L, 1L, H - 1L, H, H + 100L, H + 1_000_000L}) {
+ final BlockHeader header = parentHeader(n);
+ assertThat(r.validate(header, parentHeader(Math.max(0L, n - 1L)))).isTrue();
+ }
+ }
+
+ @Test
+ void acceptsEveryHeaderWhenTheAnchorIsDisarmed() {
+ final PqEmergencyShoutRule r = rule(true, OptionalInt.empty());
+ assertThat(r.validate(parentHeader(H + 5L), parentHeader(H + 4L))).isTrue();
+ // Twice at the same height: the second call is the bounded one, and it must still accept.
+ assertThat(r.validate(parentHeader(H + 5L), parentHeader(H + 4L))).isTrue();
+ }
+
+ @Test
+ void acceptsEveryHeaderWhenTheThresholdIsCapped() {
+ final PqEmergencyShoutRule r = rule(false, OptionalInt.of(1));
+ assertThat(r.validate(parentHeader(H + 150L), parentHeader(H + 149L))).isTrue();
+ }
+
+ @Test
+ void acceptsEvenWithoutAParent() {
+ final PqEmergencyShoutRule r = rule(true, OptionalInt.of(0));
+ assertThat(r.validate(parentHeader(H + 150L), null)).isTrue();
+ }
+
+ @Test
+ void isPartOfLightValidationSoTheAnnouncementIsHeardOnEveryPath() {
+ assertThat(rule(true, OptionalInt.empty()).includeInLightValidation()).isTrue();
+ }
+}
diff --git a/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqForkGateFeedTest.java b/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqForkGateFeedTest.java
new file mode 100755
index 000000000..d730c7ccc
--- /dev/null
+++ b/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqForkGateFeedTest.java
@@ -0,0 +1,189 @@
+/*
+ * Copyright contributors to Besu / Aere Network.
+ *
+ * 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.headervalidationrules;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.CHAIN_ID;
+import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.H;
+import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.VALIDATORS;
+import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.parentHeader;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import static org.mockito.Mockito.withSettings;
+
+import org.hyperledger.besu.consensus.common.bft.BftContext;
+import org.hyperledger.besu.consensus.common.bft.FalconSealSupport;
+import org.hyperledger.besu.consensus.common.bft.PqAnchorConfig;
+import org.hyperledger.besu.consensus.common.validator.ValidatorProvider;
+import org.hyperledger.besu.datatypes.Address;
+import org.hyperledger.besu.ethereum.ProtocolContext;
+import org.hyperledger.besu.ethereum.core.BlockHeader;
+
+import java.lang.reflect.Field;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.OptionalInt;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.quality.Strictness;
+
+/**
+ * SEAL-ATTACHMENT GATE, THE OTHER HALF: who feeds it above the anchor height.
+ *
+ * <p>The gate's registry-coverage report reads a validator set recorded by {@code
+ * FalconSealSupport.observeValidators}. Until 2026-08-02 the ONLY caller of that method was {@link
+ * FalconSealValidationRule}, and {@link PqAnchorConfig#legacyFalconRuleRetirementBlock()} stands
+ * that rule down at exactly the anchor height H. So from H upward nothing fed it: the recorded set
+ * was either frozen at a height below H, or - on any node whose process started above H - never
+ * recorded at all. The old gate answered "never recorded" by switching seal attachment off, which
+ * is one restart away from a chain that cannot propose.
+ *
+ * <p>This class measures the WIRING, in both directions, and it is the only thing that separates the
+ * repair from a claim about it:
+ *
+ * <ul>
+ * <li>the legacy rule really does stand down at H, so it really cannot be the feed above H;
+ * <li>{@link PqAnchorSealsRule}, which runs at every height from H, really does feed it.
+ * </ul>
+ */
+public class PqForkGateFeedTest {
+
+ private static final PqAnchorConfig ARMED =
+ new PqAnchorConfig(CHAIN_ID, H, Map.of(H, 0), OptionalInt.empty(), false);
+
+ @BeforeEach
+ public void resetSingleton() throws Exception {
+ forgetFalconSingleton();
+ }
+
+ @AfterEach
+ public void resetSingletonAfter() throws Exception {
+ forgetFalconSingleton();
+ }
+
+ @Test
+ public void theLegacyRuleStandsDownAtTheAnchorHeightSoItCannotBeTheFeed() {
+ assertThat(ARMED.legacyFalconRuleRetirementBlock())
+ .describedAs("the legacy Falcon rule retires at exactly the anchor height")
+ .isEqualTo(H);
+
+ final FalconSealValidationRule legacy =
+ new FalconSealValidationRule(ARMED.legacyFalconRuleRetirementBlock());
+ final BlockHeader parent = parentHeader(H - 1L);
+ final BlockHeader atH = parentHeader(H);
+
+ assertThat(legacy.validate(atH, parent, contextWith(VALIDATORS)))
+ .describedAs("retired, so it accepts without doing anything")
+ .isTrue();
+ assertThat(FalconSealSupport.instance().observedValidatorsHeight())
+ .describedAs(
+ "at and above the anchor height the legacy rule records NOTHING. That is correct for the "
+ + "rule and fatal for anything that depended on it as its only source.")
+ .isEqualTo(-1L);
+ }
+
+ @Test
+ public void theAnchorSealsRuleFeedsTheGateAtEveryHeightFromH() {
+ final PqAnchorSealsRule rule = new PqAnchorSealsRule(ARMED, new NoRegistry());
+ final BlockHeader parent = parentHeader(H + 40L);
+ final BlockHeader block = PqAnchorTestSupport.honestHeader(H + 41L, parent.getHash(), List.of());
+
+ assertThat(FalconSealSupport.instance().observedValidatorsHeight()).isEqualTo(-1L);
+ assertThat(rule.validate(block, parent, contextWith(VALIDATORS)))
+ .describedAs("K is 0 at this height, so an empty certificate is legitimate")
+ .isTrue();
+ assertThat(FalconSealSupport.instance().observedValidatorsHeight())
+ .describedAs(
+ "the rule that takes over at H must also take over feeding the coverage report, or the "
+ + "report is about a height the chain left behind")
+ .isEqualTo(parent.getNumber());
+ }
+
+ @Test
+ public void belowTheAnchorHeightTheSealsRuleRecordsNothing() {
+ // The negative control for the test above: a rule that recorded unconditionally would pass it
+ // while breaking the height gate that keeps the whole scheme inert below H.
+ final PqAnchorSealsRule rule = new PqAnchorSealsRule(ARMED, new NoRegistry());
+ final BlockHeader parent = parentHeader(H - 3L);
+ final BlockHeader block = PqAnchorTestSupport.honestHeader(H - 2L, parent.getHash(), List.of());
+
+ assertThat(rule.validate(block, parent, contextWith(VALIDATORS))).isTrue();
+ assertThat(FalconSealSupport.instance().observedValidatorsHeight())
+ .describedAs("below H this rule does nothing at all, recording included")
+ .isEqualTo(-1L);
+ }
+
+ private static ProtocolContext contextWith(final Collection<Address> validators) {
+ final ValidatorProvider validatorProvider =
+ mock(ValidatorProvider.class, withSettings().strictness(Strictness.LENIENT));
+ when(validatorProvider.getValidatorsForBlock(any())).thenReturn(validators);
+ when(validatorProvider.getValidatorsAfterBlock(any())).thenReturn(validators);
+ final BftContext bftContext =
+ mock(BftContext.class, withSettings().strictness(Strictness.LENIENT));
+ when(bftContext.getValidatorProvider()).thenReturn(validatorProvider);
+ when(bftContext.as(any())).thenReturn(bftContext);
+ return new ProtocolContext.Builder().withConsensusContext(bftContext).build();
+ }
+
+ private static void forgetFalconSingleton() throws Exception {
+ final Field f = FalconSealSupport.class.getDeclaredField("instance");
+ f.setAccessible(true);
+ f.set(null, null);
+ }
+
+ /** A registry that binds nothing: this file measures the feed, never the verification. */
+ private static final class NoRegistry
+ implements org.hyperledger.besu.consensus.common.bft.PqSignerRegistry {
+
+ // HEIGHT-INDEXED REGISTRY: the height-less pair was deleted from PqSignerRegistry, so this
+ // double now has to answer "at which height" like everything else. It still binds nothing.
+ @Override
+ public Address addressForIndexAtOwnHead(final long blockNumber, final int validatorIndex) {
+ return addressForIndexAtHistoric(blockNumber, validatorIndex);
+ }
+
+ @Override
+ public boolean verifyAtOwnHead(
+ final long blockNumber,
+ final int validatorIndex,
+ final org.apache.tuweni.bytes.Bytes message,
+ final org.apache.tuweni.bytes.Bytes signature) {
+ return verifyAtHistoric(blockNumber, validatorIndex, message, signature);
+ }
+
+ @Override
+ public Address addressForIndexAtHistoric(final long blockNumber, final int validatorIndex) {
+ return null;
+ }
+
+ @Override
+ public boolean verifyAtHistoric(
+ final long blockNumber,
+ final int validatorIndex,
+ final org.apache.tuweni.bytes.Bytes message,
+ final org.apache.tuweni.bytes.Bytes signature) {
+ return false;
+ }
+
+ @Override
+ public String toString() {
+ return "NoRegistry";
+ }
+ }
+}
diff --git a/ethereum/eth/src/main/java/org/hyperledger/besu/ethereum/eth/sync/DownloadHeadersStep.java b/ethereum/eth/src/main/java/org/hyperledger/besu/ethereum/eth/sync/DownloadHeadersStep.java
index c8eb53639..76c435677 100644
--- a/ethereum/eth/src/main/java/org/hyperledger/besu/ethereum/eth/sync/DownloadHeadersStep.java
+++ b/ethereum/eth/src/main/java/org/hyperledger/besu/ethereum/eth/sync/DownloadHeadersStep.java
@@ -11,6 +11,12 @@
* specific language governing permissions and limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
+ *
+ * Modifications Copyright contributors to the Aere Network.
+ *
+ * This file was modified by contributors to the Aere Network, as required by section 4(b) of the
+ * Apache License 2.0. The copyright header above is the upstream one and is left exactly as it was
+ * found, as section 4(c) requires. The change itself is in patches/0003-aere-pq-anchor.patch.
*/
package org.hyperledger.besu.ethereum.eth.sync;
@@ -26,7 +32,10 @@ import org.hyperledger.besu.ethereum.eth.manager.peertask.task.GetHeadersFromPee
import org.hyperledger.besu.ethereum.eth.sync.range.RangeHeaders;
import org.hyperledger.besu.ethereum.eth.sync.range.SyncTargetRange;
import org.hyperledger.besu.ethereum.eth.sync.tasks.DownloadHeaderSequenceTask;
+import org.hyperledger.besu.ethereum.eth.sync.tasks.exceptions.InvalidBlockException;
+import org.hyperledger.besu.ethereum.mainnet.HeaderValidationMode;
import org.hyperledger.besu.ethereum.mainnet.ProtocolSchedule;
+import org.hyperledger.besu.ethereum.mainnet.ProtocolSpec;
import org.hyperledger.besu.plugin.services.MetricsSystem;
import org.hyperledger.besu.util.FutureUtils;
@@ -112,11 +121,103 @@ public class DownloadHeadersStep
return CompletableFuture.failedFuture(
new RuntimeException("Unable to download headers for range " + range));
}
- return CompletableFuture.completedFuture(taskResult.result().get());
+ final List<BlockHeader> downloaded = taskResult.result().get();
+ // AERE SINCRONIZARE (2026-08-02): validate the OPEN-ENDED range too.
+ try {
+ validateOpenEndedRange(range, downloaded);
+ } catch (final RuntimeException e) {
+ return CompletableFuture.failedFuture(e);
+ }
+ return CompletableFuture.completedFuture(downloaded);
});
}
}
+ /**
+ * AERE SINCRONIZARE (2026-08-02). Apply the SAME validation policy to the open-ended range that
+ * the closed ranges already get.
+ *
+ * <p>The defect, read in this file and then measured on a running network. {@link
+ * #downloadHeaders} has two branches and the choice between them is not "syncing or not", it is
+ * {@link SyncTargetRange#hasEnd()}. A closed range goes to {@code
+ * DownloadHeaderSequenceTask.endingAtHeader}, which validates every header it accepts under
+ * {@code validationPolicy}. The open-ended range - the one at the tip, when the range source
+ * could not obtain a further range boundary from the peer - went to a bare {@code
+ * GetHeadersFromPeerTask} and was handed onward with nothing checked but the hash chaining that
+ * {@code RangeHeadersValidationStep} does at the join. With the default header request size of
+ * 200 that is the last 199 blocks of every catch-up, which is the whole of a short one.
+ *
+ * <p>The consequence for the certificate anchor was measured: an honest node with its data
+ * directory deleted imported headers whose certificate had been tampered with, in silence,
+ * because the only rule that binds the certificate to the block hash is detached and detached
+ * rules run in exactly the branch this range does not take. The attached copy of that rule now
+ * catches such a header at import in any case; this method makes it fail one pipeline stage
+ * earlier, before bodies are fetched for a range that is going to be thrown away, and restores
+ * the property that the tail of a sync is checked exactly like the rest of it.
+ *
+ * <p>Fail closed on every path: a range whose headers do not form a chain from {@code
+ * range.getStart()} is rejected here rather than trimmed or accepted.
+ *
+ * @param range the open-ended range these headers were requested for
+ * @param headers the headers as the peer returned them, ascending from {@code range.getStart()}
+ */
+ private void validateOpenEndedRange(
+ final SyncTargetRange range, final List<BlockHeader> headers) {
+ if (headers.isEmpty()) {
+ return;
+ }
+ final BlockHeader start = range.getStart();
+ BlockHeader parent;
+ int index;
+ if (headers.getFirst().equals(start)) {
+ // The usual shape: the peer echoes the header we asked to start from.
+ parent = start;
+ index = 1;
+ } else if (headers.getFirst().getParentHash().equals(start.getHash())) {
+ parent = start;
+ index = 0;
+ } else {
+ throw InvalidBlockException.fromInvalidBlock(
+ String.format(
+ "AERE-SYNC-TAIL-01: open-ended range starting at #%d (%s) came back beginning at #%d "
+ + "(%s), which neither is that header nor descends from it",
+ start.getNumber(),
+ start.getHash(),
+ headers.getFirst().getNumber(),
+ headers.getFirst().getHash()),
+ headers.getFirst());
+ }
+
+ final HeaderValidationMode mode = validationPolicy.getValidationModeForNextBlock();
+ for (int i = index; i < headers.size(); i++) {
+ final BlockHeader header = headers.get(i);
+ if (!header.getParentHash().equals(parent.getHash())
+ || header.getNumber() != parent.getNumber() + 1L) {
+ throw InvalidBlockException.fromInvalidBlock(
+ String.format(
+ "AERE-SYNC-TAIL-01: open-ended range does not form a chain at #%d (%s); previous "
+ + "header was #%d (%s)",
+ header.getNumber(), header.getHash(), parent.getNumber(), parent.getHash()),
+ header);
+ }
+ final ProtocolSpec protocolSpec = protocolSchedule.getByBlockHeader(header);
+ if (!protocolSpec
+ .getBlockHeaderValidator()
+ .validateHeader(header, parent, protocolContext, mode)) {
+ LOG.warn(
+ "AERE-SYNC-TAIL-01: header #{} ({}) of the OPEN-ENDED range failed {} validation. This "
+ + "is the branch that used to accept the last {} headers of every recovery without "
+ + "running a single header rule.",
+ header.getNumber(),
+ header.getHash(),
+ mode,
+ headerRequestSize - 1);
+ throw InvalidBlockException.fromInvalidBlock(header);
+ }
+ parent = header;
+ }
+ }
+
private RangeHeaders processHeaders(
final SyncTargetRange checkpointRange, final List<BlockHeader> headers) {
if (checkpointRange.hasEnd()) {