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:
parent
7242a2c388
commit
473832438c
@ -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
|
||||||
|
|||||||
@ -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 -> 5 guaranteed K=5 reachable, margin exactly 0
|
|
||||||
* N=9, keyed 7, quorum 6 -> 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -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 -> 5 guaranteed K=5 is met, with EXACTLY zero margin
|
|
||||||
* N=8, keyed 7, quorum 6 -> 5 guaranteed K=5 is still met, still zero margin
|
|
||||||
* N=9, keyed 7, quorum 6 -> 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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>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>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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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>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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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();
|
||||||
|
|||||||
@ -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";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue
Block a user