Consolidate twin test classes: one copy of each proof

Twelve test classes existed twice, under a neutral name and under an internal
inventory name, ~4,600 duplicated lines. Two copies of the same proof diverge
silently: one receives a repair and the other does not - which is exactly what
happened once, and what the cache-hygiene test below documents. The neutral
names survive; per pair the test counts are identical, and the only code
difference (a warm-up window constant) exercises the same path and the same
assertion. The cache-hygiene reproduction now runs the surviving class's
lifecycle, unchanged in what it asserts.
This commit is contained in:
Aere Network 2026-09-01 00:13:42 +03:00
parent 7242a2c388
commit 473832438c
14 changed files with 9 additions and 4619 deletions

View File

@ -38,17 +38,7 @@ addba40c0d931a3ecfa3b2f0179311dacaa604884e3ebb1d79f958bd913f72e2 consensus/comm
b6c9ecbf3cd2ee73111984cd04a89c32ee56a1cc699a197b65ef7c10001b8cbc consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/blockcreation/BftBlockCreatorFactory.java b6c9ecbf3cd2ee73111984cd04a89c32ee56a1cc699a197b65ef7c10001b8cbc consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/blockcreation/BftBlockCreatorFactory.java
5c8861eba1ea697d8deb88139d92c6ea6636b29d76c881b13e7f6c841ba2324f consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/blockcreation/PqAnchorProducer.java 5c8861eba1ea697d8deb88139d92c6ea6636b29d76c881b13e7f6c841ba2324f consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/blockcreation/PqAnchorProducer.java
6ace00e18914a1558563e689b7427654b5e6226a31447bef1d0416d993f78840 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/tools/PqRegistryHashTool.java 6ace00e18914a1558563e689b7427654b5e6226a31447bef1d0416d993f78840 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/tools/PqRegistryHashTool.java
2620bb98485e5d5649b58d331d1a35031bef56cab8bd18d1e7f9050e61bc09de consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D078ThresholdReachabilityTest.java
5534cfab3bd59968823265655351491abef8ea73f594677a62bf47c8e88deb54 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D078ValidatorSetChangeTest.java
da249c59f356e06928f73543d5529ae1911e4613a8125477f475b1dd54daed62 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D079ForkArmingTest.java
9a794d7a4010ff5c561008229cb2ae97d5f79367641190c97cde8802b83daa72 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D081RegistryRotationTest.java
9ab9918f4e5bef6485fef1251a07504e32b8b2d42393d3fcbb35d7e1241a7deb consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D140FleetRestartArmingTest.java
2e81e02a419cdb2053b67e2bc9e699af1366955fa18d351178637b64a48a27be consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D141SealPersistenceTest.java
42dd6396583aa19475e23c9781f568958d9789f0dba084dc63ac9833203f3f1f consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D146ArmingGateTest.java
75fca200a2ac9e7226c70d96916a4ae33201a8acb466d0fdc591576e09edb3fc consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D147InertBinaryTest.java
acd5c1e7f49dbbf444c19c91386bbd03b9298d952d2d13721793670103e71ee9 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D177NeutralNamesTest.java acd5c1e7f49dbbf444c19c91386bbd03b9298d952d2d13721793670103e71ee9 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D177NeutralNamesTest.java
bc6b58570f835276324a5bd2704e7de8cac4e0693bd0138199d149c1ec7d4174 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D2CallerIntentTest.java
4aeab501c83f0d7fbe7a45c99e2035fd034b055b2a6cd5925026e29e4e427849 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D2RegistryHeightRefusalTest.java
c574ec22e642ac464b1728d27e4bb26202448a45c044f08935cc0a0efa0297d9 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/FalconAttachIntervalTest.java c574ec22e642ac464b1728d27e4bb26202448a45c044f08935cc0a0efa0297d9 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/FalconAttachIntervalTest.java
0eb20506f851c1510bcaf659db8c5b0d384cdc60ed06192a03a665f0ee2b3912 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/HybridSealProducerTest.java 0eb20506f851c1510bcaf659db8c5b0d384cdc60ed06192a03a665f0ee2b3912 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/HybridSealProducerTest.java
186c4c92199400783ee3424231818dd440b0da41aeb3d8bd643cd13759998250 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/HybridSealSupportTest.java 186c4c92199400783ee3424231818dd440b0da41aeb3d8bd643cd13759998250 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/HybridSealSupportTest.java
@ -58,7 +48,7 @@ c574ec22e642ac464b1728d27e4bb26202448a45c044f08935cc0a0efa0297d9 consensus/comm
7470d72271dd4f9de96094b3d1c6dc4b5b5ea62e46a058d4e154e3742d3ccef1 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorIntervalTest.java 7470d72271dd4f9de96094b3d1c6dc4b5b5ea62e46a058d4e154e3742d3ccef1 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorIntervalTest.java
e2487ff508ffb51bb61ca19531f4c4e68c5981611fd9fec5c92a2965f0bb8f47 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorLapseTest.java e2487ff508ffb51bb61ca19531f4c4e68c5981611fd9fec5c92a2965f0bb8f47 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorLapseTest.java
dabb059478da5259c309e6637e987ab35ef7b69cc286e41f936e4f3e83d3f082 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorMinSealsFloorTest.java dabb059478da5259c309e6637e987ab35ef7b69cc286e41f936e4f3e83d3f082 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorMinSealsFloorTest.java
c20743ce1b54af2b7b9d42366bef14678fd94aa8d1dc98eabf21ff0f9ae15302 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorProducerCacheHygieneTest.java fe84c239e3504f55e8d950e53b844641cc1dd7a7c864dd8f58db3d2f50c12b9d consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorProducerCacheHygieneTest.java
0bf1c8a9cdd91d7c34053d6e5b4ff929787f34eb7aa7d6eed26fc5c94fa1c42b consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorProducerCostTest.java 0bf1c8a9cdd91d7c34053d6e5b4ff929787f34eb7aa7d6eed26fc5c94fa1c42b consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorProducerCostTest.java
23daeb4888c8de8337518a27ef60e42573ebafaf340075e8ce9236f0d8d580f6 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorSealCapTest.java 23daeb4888c8de8337518a27ef60e42573ebafaf340075e8ce9236f0d8d580f6 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorSealCapTest.java
09c41c77408acc4711e4c46175485bf41babff0d0a8ec7481670fddfd28967b0 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorTest.java 09c41c77408acc4711e4c46175485bf41babff0d0a8ec7481670fddfd28967b0 consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorTest.java
@ -136,8 +126,6 @@ ab0fcd8722dcb76560f0ef8fda8af9c2b8b6ec9b468326aaf616c853526f7f81 consensus/qbft
8ac99e94c89f61f0281199cf369e282fc4f9fb1a3414a1650766e1d7548e76f5 consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqRegistryBindingRule.java 8ac99e94c89f61f0281199cf369e282fc4f9fb1a3414a1650766e1d7548e76f5 consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqRegistryBindingRule.java
52b56f157500ae3527b7e55c51786915bb5d6980065668f1e7b8e297bf7125ef consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/QbftAnchorRuleWiringTest.java 52b56f157500ae3527b7e55c51786915bb5d6980065668f1e7b8e297bf7125ef consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/QbftAnchorRuleWiringTest.java
5a18c7fee308654d9557fc507ca7a64704bdac5d13835e7df47a3dfb41519902 consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/AereBaseFeeImportRuleTest.java 5a18c7fee308654d9557fc507ca7a64704bdac5d13835e7df47a3dfb41519902 consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/AereBaseFeeImportRuleTest.java
8f27193a286d1e6bb4c84f98e5af9821ee9a22873a26288fad67256c710a51cd consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/D078GateFeedTest.java
ebc5811c3a765b1175023d2c767eb8c71f4f2bdbb63ec0b354294a3f0ac15dfa consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/D079ArmedWithoutRegistryTest.java
ad018cba0a3fe7f018b11c6c6a2d45e3f5547342ec45b620a4df2595801ae71a consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/FalconSealLogThrottleTest.java ad018cba0a3fe7f018b11c6c6a2d45e3f5547342ec45b620a4df2595801ae71a consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/FalconSealLogThrottleTest.java
3895d10bcf5ffbdaf0506503a0d9e3d72c600288def268fd5e9f68c4a042e162 consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/FalconSealValidationRuleRetirementTest.java 3895d10bcf5ffbdaf0506503a0d9e3d72c600288def268fd5e9f68c4a042e162 consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/FalconSealValidationRuleRetirementTest.java
e2df575ee4d6ab5bd961b0886ece3d3c392a50193f1d3256a438a72da9e20d10 consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorDigestRuleTest.java e2df575ee4d6ab5bd961b0886ece3d3c392a50193f1d3256a438a72da9e20d10 consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorDigestRuleTest.java

View File

@ -1,356 +0,0 @@
/*
* 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 org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer;
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.junit.jupiter.api.io.TempDir;
/**
* D-078, 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 D078ValidatorSetChangeTest}.
* 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 A8 repair 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 D-079.
*
* <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 D078ThresholdReachabilityTest {
/** 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;
/**
* AERE D-146: 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);
}
// DATED 2026-08-20, the SECOND time this exact leak was paid for. armAnchor() plus
// FalconSealSupport.instance() builds the anchor config through PqAnchorProducer.config(),
// whose once-per-JVM cache outlives every property cleared above. Measured today on the
// production tree: the armed config this class caches turned all five PqFleetRestartArmingTest
// fixtures into AERE-PQC-REG-ARM-02 refusals, green alone, red in the suite, identical sources.
// The twin (PqForkThresholdReachabilityTest) has carried this line since 2026-08-11 with the
// same story; this class was forked before that fix and never received it.
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 {
// aere.pq.anchorBlock is UNSET on the live chain, 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 A8 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 D078ValidatorSetChangeTest} 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 {
// AERE D-146 (2026-08-06): 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);
}
}

View File

@ -1,428 +0,0 @@
/*
* 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;
/**
* D-078. THE MEASUREMENT THAT DID NOT EXIST.
*
* <p>The registry entry reads: "if PQC were armed, an ordinary add-validator vote would stop the
* chain: the Falcon blocking quorum follows the dynamic set and cannot be reached inside the vote
* window", and it carried {@code verifica: NICIUNA} because "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 the registry says cannot be asked.
*
* <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 D-078. This one
* fails if the fixture is not genuinely armed.
* <li>{@link #addingOneValidatorMustNotTurnSealAttachmentOff()} - D-078 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 D078ValidatorSetChangeTest {
/** 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.
//
// AERE D-146 (2026-08-06): 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. D-078 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(
"D-078: 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; with "
+ "no node attaching, no proposer can gather K=%d seals and the chain stops with no "
+ "way to carry the re-anchoring transaction that would repair it.",
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 D-078 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);
}
}

View File

@ -1,368 +0,0 @@
/*
* 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;
/**
* D-079. THE MEASUREMENT THAT DID NOT EXIST.
*
* <p>The registry entry reads: "a malformed forkBlock falls OPEN, with only a log line, and arming
* it at or before the anchor observation height passes undetected", and it carried {@code verifica:
* NICIUNA} since 18 July. 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 D079ForkArmingTest {
/** 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";
/**
* AERE D-146: 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 {
// AERE D-146 (2026-08-06): 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);
}
}

View File

@ -1,484 +0,0 @@
/*
* 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;
/**
* D-081: 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 A8 dossier states
* that "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. {@code
* PqRegistryBindingRule} is a DETACHED rule, so it runs on the header-download path, and {@code
* PqAnchorSyncModeGuard} refuses to start an armed node in anything but FULL sync. A node acquiring
* history therefore validates every height, including the interval before the rotation. Holding the
* post-rotation registry it is refused there; holding the pre-rotation registry it is refused at the
* head. There is no third choice. ONE rotation makes the chain permanently unjoinable.
*
* <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 D081RegistryRotationTest {
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, "D-081 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;
}
/**
* D-081 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, "D-081 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 IS NOT USABLE: one scheduled rotation at height %d leaves NO node configuration "
+ "that "
+ "satisfies the registry binding at every enforced height. %s. A node that cannot "
+ "satisfy the binding at a height cannot import a header at that height "
+ "(PqRegistryBindingRule is DETACHED, so it runs on the header-download path), and "
+ "PqAnchorSyncModeGuard forces FULL sync when the anchor is armed, so every node "
+ "acquiring history must pass through the pre-rotation interval AND reach the head. "
+ "Using the rotation mechanism once therefore makes the chain permanently "
+ "unjoinable. 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 IS NOT USABLE: revoking one 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: performing it costs the ability to "
+ "acquire the chain.",
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 D-081 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 IS NOT USABLE 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();
}
}

View File

@ -1,409 +0,0 @@
/*
* 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;
/**
* D-140. THE FLEET-RESTART DEADLOCK, AND THE STATE MACHINE THE REPAIR MOVES.
*
* <p>MEASURED FIRST, ON A NETWORK, NOT ASSUMED. The full activation rehearsal on a seven-node test
* network (repetitie-activare-2026-08-05) 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 D-078, 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 D140FleetRestartArmingTest {
/**
* Fleet size for THIS fixture. Not a statement about any live network: the 2026-08-05 decision
* to stay at seven was reversed, and the set has been nine since 2026-08-12. Seven is kept here
* because it is the size at which the margin arithmetic this class exercises is tightest.
*/
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 {
// ADDED 2026-08-29 (finding D-293), and it was MISSING here while its twin had carried it since
// 2026-08-20. That is the whole danger of keeping two copies of a test: a repair lands in one
// and not the other, and nothing fails to say so. This class never arms the certificate anchor,
// but FalconSealSupport's constructor consults it (anchorArmedFrom() -> PqAnchorProducer.config(),
// a per-JVM cache): a neighbouring class that leaves an ARMED config cached turns every
// proof-less fixture below into an AERE-PQC-REG-ARM-02 refusal. Measured on the production tree:
// alone 5/5 green, inside the full suite the same 5 red, identical sources. Clearing the
// properties does not clear the caches.
for (final String p : System.getProperties().stringPropertyNames()) {
if (p.startsWith("aere.pq.")) {
System.clearProperty(p);
}
}
org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer.useConfigForTesting(
null);
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 D-078 (2026-08-02) removed the fleet
* question from the per-commit gate. The rehearsal binary was built from the 2026-08-01 tree,
* which 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);
}
}

View File

@ -1,621 +0,0 @@
/*
* 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;
/**
* D-141. 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 (rehearsal
* repetitie-activare-2026-08-05, isolated chain 330858). 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 "activare a ancorei la PORNIRE din starea capului: REUSITA". 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 D141SealPersistenceTest {
/** 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 founder's decision of 2026-08-05: N=7 stays, and K=3 is the value with full margin. */
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"));
// AERE D-146 (2026-08-06): 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 defect A8 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);
}
}

View File

@ -1,387 +0,0 @@
/*
* 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;
/**
* D-146, THE LINE THAT WAS MISSING. {@code PqRegistryHash.requireBindingsOrThrow} was delivered on
* 2026-08-06 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, which is being edited by another
* stream". 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 D146ArmingGateTest {
/** 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 D079ForkArmingTest 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("registru-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("registru-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("registru-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("registru-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("registru-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 D-146")
.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>D-146 is 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 D-146 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 D-146.
*
* <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("registru-doar-posesie.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 D-078 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);
}
}

View File

@ -1,466 +0,0 @@
/*
* 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 D-146 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 D147InertBinaryTest {
/**
* 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));
// A whole-zero schedule is refused since the D-147 floor (armed anchor, no signature
// requirement, for ever). The warm-up step at H stays 0; the rise satisfies the floor.
System.setProperty(PqAnchorConfig.PROPERTY_MIN_SEALS, H + ":0," + (H + 21_600L) + ":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(
D147InertBinaryTest.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);
}
}
}

View File

@ -1,386 +0,0 @@
/*
* 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;
/**
* D2 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 D141SealPersistenceTest} and one in {@code D078ValidatorSetChangeTest}. 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 the D078 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 both are recorded in the evidence directory.
*/
public class D2CallerIntentTest {
/** 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 D-146 (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 D2/T2 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(
"D141SealPersistenceTest 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 the D-141 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 D-146 (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);
}
}

View File

@ -1,326 +0,0 @@
/*
* 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;
/**
* D2, the adversarial review of 2026-08-02, at the layer that actually answers the question.
*
* <p>WHAT THE DOSSIER 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. Commit f3ebe90c (D-081) 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 D2RegistryHeightRefusalTest {
/** 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 D-146 (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, grep over deploy/ and monitoring/ returns nothing).
assertThat(pqc.verifyAtHistoric(H, 0, MESSAGE, sealByIndexZero))
.describedAs(
"D2/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 D-146 (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);
}
}

View File

@ -46,6 +46,11 @@ import org.junit.jupiter.api.io.TempDir;
* asserts the JVM is clean. If the cleanup line is ever removed from that teardown again, this * asserts the JVM is clean. If the cleanup line is ever removed from that teardown again, this
* test goes red deterministically -- that removal is exactly the planted failure it was proven * test goes red deterministically -- that removal is exactly the planted failure it was proven
* against on the day it was written. * against on the day it was written.
*
* <p>DATED 2026-08-31: the fork pair was consolidated -- the twin classes were one copy too many,
* and the divergence above is precisely what duplication costs. The reproduction now runs the
* lifecycle of the surviving class, {@code PqForkThresholdReachabilityTest}, which has carried the
* cleanup line since 2026-08-11; the assertion is unchanged.
*/ */
public class PqAnchorProducerCacheHygieneTest { public class PqAnchorProducerCacheHygieneTest {
@ -75,8 +80,8 @@ public class PqAnchorProducerCacheHygieneTest {
@Test @Test
public void theReachabilitySequenceLeavesNoArmedAnchorBehind() throws Exception { public void theReachabilitySequenceLeavesNoArmedAnchorBehind() throws Exception {
final D078ThresholdReachabilityTest vinovat = new D078ThresholdReachabilityTest(); final PqForkThresholdReachabilityTest vinovat = new PqForkThresholdReachabilityTest();
final Field tmpField = D078ThresholdReachabilityTest.class.getDeclaredField("tmp"); final Field tmpField = PqForkThresholdReachabilityTest.class.getDeclaredField("tmp");
tmpField.setAccessible(true); tmpField.setAccessible(true);
tmpField.set(vinovat, tmp); tmpField.set(vinovat, tmp);
@ -91,7 +96,7 @@ public class PqAnchorProducerCacheHygieneTest {
assertThat(PqAnchorProducer.config().everActive()) assertThat(PqAnchorProducer.config().everActive())
.describedAs( .describedAs(
"after D078ThresholdReachabilityTest's own teardown, a config built in this JVM must " "after PqForkThresholdReachabilityTest's own teardown, a config built in this JVM must "
+ "not claim an armed anchor; if it does, the per-JVM cache survived the cleanup " + "not claim an armed anchor; if it does, the per-JVM cache survived the cleanup "
+ "and every proof-less fixture in the next class dies with AERE-PQC-REG-ARM-02") + "and every proof-less fixture in the next class dies with AERE-PQC-REG-ARM-02")
.isFalse(); .isFalse();

View File

@ -1,189 +0,0 @@
/*
* 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;
/**
* D-078, THE OTHER HALF: who feeds the seal-attachment gate 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 D078GateFeedTest {
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 {
// D2 (2026-08-06): 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";
}
}
}

View File

@ -1,183 +0,0 @@
/*
* 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;
/**
* D-079, 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 D079ArmedWithoutRegistryTest {
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";
/** AERE D-146: 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 {
// AERE D-146 (2026-08-06): v2, 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);
}
}