diff --git a/anchor/MANIFEST-sha256.txt b/anchor/MANIFEST-sha256.txt index 9057423..6f5d727 100644 --- a/anchor/MANIFEST-sha256.txt +++ b/anchor/MANIFEST-sha256.txt @@ -38,17 +38,7 @@ addba40c0d931a3ecfa3b2f0179311dacaa604884e3ebb1d79f958bd913f72e2 consensus/comm 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 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 -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 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 @@ -58,7 +48,7 @@ c574ec22e642ac464b1728d27e4bb26202448a45c044f08935cc0a0efa0297d9 consensus/comm 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 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 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 @@ -136,8 +126,6 @@ ab0fcd8722dcb76560f0ef8fda8af9c2b8b6ec9b468326aaf616c853526f7f81 consensus/qbft 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 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 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 diff --git a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D078ThresholdReachabilityTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D078ThresholdReachabilityTest.java deleted file mode 100644 index c64af47..0000000 --- a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D078ThresholdReachabilityTest.java +++ /dev/null @@ -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? - * - *

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}: - * - *

- * - * "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". - * - *
- * - *

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. - * - *

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: - * - *

- *   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
- * 
- * - *

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. - * - *

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. - * - *

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); - } -} - diff --git a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D078ValidatorSetChangeTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D078ValidatorSetChangeTest.java deleted file mode 100644 index 836c179..0000000 --- a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D078ValidatorSetChangeTest.java +++ /dev/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. - * - *

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". - * - *

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. - * - *

WHAT EACH TEST MEASURES, and why each of them can fail: - * - *

    - *
  1. {@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. - *
  2. {@link #addingOneValidatorMustNotTurnSealAttachmentOff()} - D-078 itself, on the exact - * stimulus in the title: one more validator in the set, with no Falcon key. - *
  3. {@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. - *
  4. {@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. - *
- * - *

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

keyedValidators = new ArrayList<>(); - private final List 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 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
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 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. - * - *

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: - * - *

-   *   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
-   * 
- * - *

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

validators) { - final ValidatorProvider validatorProvider = - mock(ValidatorProvider.class, withSettings().strictness(Strictness.LENIENT)); - when(validatorProvider.getValidatorsForBlock(any())).thenReturn(validators); - when(validatorProvider.getValidatorsAfterBlock(any())).thenReturn(validators); - final BftContext bftContext = - mock(BftContext.class, withSettings().strictness(Strictness.LENIENT)); - when(bftContext.getValidatorProvider()).thenReturn(validatorProvider); - when(bftContext.as(any())).thenReturn(bftContext); - return new ProtocolContext.Builder().withConsensusContext(bftContext).build(); - } - - private static void resetFalconSingleton() throws Exception { - final Field f = FalconSealSupport.class.getDeclaredField("instance"); - f.setAccessible(true); - f.set(null, null); - } -} diff --git a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D079ForkArmingTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D079ForkArmingTest.java deleted file mode 100644 index c1067e6..0000000 --- a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D079ForkArmingTest.java +++ /dev/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. - * - *

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. - * - *

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. - * - *

WHAT EACH TEST MEASURES, and how each can fail: - * - *

    - *
  1. {@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. - *
  2. {@link #aMalformedForkBlockRefusesToStart()} and {@link #aNegativeForkBlockRefusesToStart()} - * - the config-time half of the finding, at construction. - *
  3. {@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. - *
  4. {@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. - *
  5. {@link #anAttachHeightBeforeTheObservationHeightRefuses()} and {@link - * #aForkHeightAtTheObservationHeightRefuses()} - half two on its own stimulus: the ordering - * is wrong and the node starts anyway. - *
  6. {@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. - *
  7. {@link #aGenesisAnchoredRegistryNeedsNoObservationHeight()} and {@link - * #anObservationHeightWithoutBlockingIsHarmless()} - the scope controls. A guard that refused - * every blocking configuration would pass every test above and be useless. - *
- * - *

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); - } -} diff --git a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D081RegistryRotationTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D081RegistryRotationTest.java deleted file mode 100644 index 12c091e..0000000 --- a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D081RegistryRotationTest.java +++ /dev/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. - * - *

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. - * - *

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. - * - *

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. - * - *

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. - * - *

{@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? - * - *

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 enforcedHeights() { - final List 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 availableConfigurations( - final PqRegistryHash.Schedule schedule, - final PqRegistryHash.Registry before, - final PqRegistryHash.Registry after) { - final List 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 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 heights = enforcedHeights(); - final List configurations = - availableConfigurations(schedule, before, after); - - final List report = new ArrayList<>(); - NodeConfiguration complete = null; - for (final NodeConfiguration c : configurations) { - final List 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 report = new ArrayList<>(); - for (final NodeConfiguration c : availableConfigurations(schedule, withCompromised, revoked)) { - boolean all = true; - final List 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 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 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 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 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(); - } -} diff --git a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D140FleetRestartArmingTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D140FleetRestartArmingTest.java deleted file mode 100644 index 0399191..0000000 --- a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D140FleetRestartArmingTest.java +++ /dev/null @@ -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. - * - *

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)". - * - *

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. - * - *

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. - * - *

    - *
  1. {@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. - *
  2. {@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. - *
  3. {@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. - *
  4. {@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. - *
  5. {@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. - *
- * - *

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. - * - *

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. - * - *

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. - * - *

NOT MEASURED: that seven live nodes on a genesis-anchored network recover from a - * simultaneous restart with a binary built from this tree. - */ - @Test - public void aGenesisAnchoredNodeIsArmedImmediatelyAfterRestart() throws Exception { - System.clearProperty("aere.falcon.manifest"); - System.clearProperty("aere.falcon.anchor.address"); - System.clearProperty("aere.falcon.anchor.block"); - System.setProperty("aere.falcon.genesis", genesisPath.toAbsolutePath().toString()); - resetFalconSingleton(); - - final FalconSealSupport pqc = FalconSealSupport.instance(); - - assertThat(pqc.genesisAnchored()) - .describedAs("fixture control: the genesis manifest must verify against the anchored hash") - .isTrue(); - assertThat(pqc.registrySize()).isEqualTo(N); - assertThat(pqc.addressBound()).isTrue(); - assertThat(pqc.attachmentArmed(HEAD)) - .describedAs( - "a genesis-anchored node arms with NO validator set observed and NO block imported. " - + "The rehearsal's coverage condition is gone from this tree.") - .isTrue(); - } - - private static void resetFalconSingleton() throws Exception { - final Field f = FalconSealSupport.class.getDeclaredField("instance"); - f.setAccessible(true); - f.set(null, null); - } -} diff --git a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D141SealPersistenceTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D141SealPersistenceTest.java deleted file mode 100644 index d66a209..0000000 --- a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D141SealPersistenceTest.java +++ /dev/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. - * - *

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. - * - *

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. - * - *

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: - * - *

    - *
  1. {@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. - *
  2. {@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. - *
  3. {@link #aCorruptFileDoesNotStopTheNode()} - truncated, random, empty, a directory where the - * file should be. Every one of them yields an empty cache and no exception. - *
  4. {@link #aFileFromAnotherHeightOrAnotherChainIsIgnored()} - the binding checks, before a - * single signature is verified. - *
  5. {@link #theWriteIsAtomicUnderAConcurrentReader()} - a reader hammering the file across 120 - * writes never observes a partial file. - *
  6. {@link #thePathComesFromTheDataDirectory()} - the path is derived, never configured. - *
  7. {@link #whatOneWriteCostsAgainstTheBlockInterval()} - the price of doing this on the - * consensus thread, as a number rather than as a hope. - *
- * - *

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

validators = new ArrayList<>(); - private final List 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. - * - *

Three shapes of forgery are in the one file, because "a forged seal" is not one thing: - * - *

    - *
  1. 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. - *
  2. index 4, random bytes of exactly the right length. The cheapest forgery there is. - *
  3. 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. - *
- */ - @Test - public void aForgedSealInTheFileIsRejectedAtReadAndNeverEntersTheCache() throws Exception { - final List 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 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 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 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 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 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 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 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 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
validatorSet) { - final ValidatorProvider validatorProvider = - mock(ValidatorProvider.class, withSettings().strictness(Strictness.LENIENT)); - when(validatorProvider.getValidatorsForBlock(any())).thenReturn(validatorSet); - when(validatorProvider.getValidatorsAfterBlock(any())).thenReturn(validatorSet); - final BftContext bftContext = - mock(BftContext.class, withSettings().strictness(Strictness.LENIENT)); - when(bftContext.getValidatorProvider()).thenReturn(validatorProvider); - when(bftContext.as(any())).thenReturn(bftContext); - return new ProtocolContext.Builder().withConsensusContext(bftContext).build(); - } - - private static void resetFalconSingleton() throws Exception { - final Field f = FalconSealSupport.class.getDeclaredField("instance"); - f.setAccessible(true); - f.set(null, null); - } -} diff --git a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D146ArmingGateTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D146ArmingGateTest.java deleted file mode 100644 index 079183b..0000000 --- a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D146ArmingGateTest.java +++ /dev/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. - * - *

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. - * - *

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. - * - *

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. - * - *

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 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. - * - *

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. - * - *

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. - * - *

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. - * - *

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 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. - * - *

MEASURED 2026-08-06, and it is the reason this method exists rather than being assumed - * unnecessary. {@code PqAnchorProducer.config()} memoises the first configuration it ever builds, - * for the life of the JVM. That is CORRECT in production - a node is one JVM with one set of - * properties, and a configuration that could change underneath the consensus path would be worse - * than one that cannot. In a test JVM shared by every class in this module it means an anchor - * armed by an earlier test is still armed here, and {@link - * #withNothingArmedTheGateIsInertOverTheSameV1Registry} failed exactly that way before this call - * was added: the property sweep found no {@code aere.pq.*} and the node still refused, because - * the memo held another class's anchor. - */ - private static void forgetAnchorConfig() { - org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer.useConfigForTesting( - null); - } - - private static void resetFalconSingleton() throws Exception { - final Field f = FalconSealSupport.class.getDeclaredField("instance"); - f.setAccessible(true); - f.set(null, null); - } -} diff --git a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D147InertBinaryTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D147InertBinaryTest.java deleted file mode 100644 index 68737f3..0000000 --- a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D147InertBinaryTest.java +++ /dev/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. - * - *

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. - * - *

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. - * - *

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. - * - *

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. - * - *

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. - * - *

{@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 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 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. - * - *

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. - * - *

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 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. - * - *

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. - * - *

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

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 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}. - * - *

{@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 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 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 bound; - - private void bind(final List 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 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 aereLines() { - return lines.stream() - .filter(l -> loggerOf(l).contains(".bft") || l.contains("AERE")) - .map(l -> l.substring(l.indexOf(" | ") + 3)) - .collect(Collectors.toList()); - } - - private static String loggerOf(final String line) { - final int i = line.indexOf(" | "); - return i < 0 ? "" : line.substring(0, i); - } - } -} diff --git a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D2CallerIntentTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D2CallerIntentTest.java deleted file mode 100644 index cc1ce06..0000000 --- a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D2CallerIntentTest.java +++ /dev/null @@ -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. - * - *

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. - * - *

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. - * - *

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. - * - *

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 privateKeys = new ArrayList<>(); - private final List

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 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 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 entries) - throws Exception { - final List heights = new ArrayList<>(entries.keySet()); - heights.sort(Long::compare); - final StringBuilder sb = new StringBuilder("{\"config\":{\"pqRegistryHash\":["); - for (int i = 0; i < heights.size(); i++) { - if (i > 0) { - sb.append(','); - } - final long b = heights.get(i); - String h = entries.get(b); - if (!h.startsWith("0x")) { - h = "0x" + h; - } - sb.append("{\"block\":").append(b).append(",\"hash\":\"").append(h).append("\"}"); - } - sb.append("]}}"); - final Path p = - tmp.resolve("genesis-schedule-v2-" + heights.size() + "-" + heights.get(0) + ".json"); - Files.writeString(p, sb.toString()); - return PqRegistryHash.loadScheduleFromGenesis(p); - } - - private static void resetFalconSingleton() throws Exception { - final Field f = FalconSealSupport.class.getDeclaredField("instance"); - f.setAccessible(true); - f.set(null, null); - } -} diff --git a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D2RegistryHeightRefusalTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D2RegistryHeightRefusalTest.java deleted file mode 100644 index 1ebb6b8..0000000 --- a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/D2RegistryHeightRefusalTest.java +++ /dev/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. - * - *

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. - * - *

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. - * - *

WHAT THIS FILE ASSERTS, as a property and not as a scenario: at and above the arming height, - * a node that cannot say which key set was in force must REFUSE, not guess. 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. - * - *

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 privateKeys = new ArrayList<>(); - private final List

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 entries) - throws Exception { - final List heights = new ArrayList<>(entries.keySet()); - heights.sort(Long::compare); - final StringBuilder sb = new StringBuilder("{\"config\":{\"pqRegistryHash\":["); - for (int i = 0; i < heights.size(); i++) { - if (i > 0) { - sb.append(','); - } - final long b = heights.get(i); - String h = entries.get(b); - if (!h.startsWith("0x")) { - h = "0x" + h; - } - sb.append("{\"block\":").append(b).append(",\"hash\":\"").append(h).append("\"}"); - } - sb.append("]}}"); - final Path p = tmp.resolve("genesis-schedule-" + heights.size() + "-" + heights.get(0) + ".json"); - Files.writeString(p, sb.toString()); - return PqRegistryHash.loadScheduleFromGenesis(p); - } - - private static void resetFalconSingleton() throws Exception { - final Field f = FalconSealSupport.class.getDeclaredField("instance"); - f.setAccessible(true); - f.set(null, null); - } -} diff --git a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorProducerCacheHygieneTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorProducerCacheHygieneTest.java index 10cff59..7230f1e 100644 --- a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorProducerCacheHygieneTest.java +++ b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorProducerCacheHygieneTest.java @@ -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 * test goes red deterministically -- that removal is exactly the planted failure it was proven * against on the day it was written. + * + *

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 { @@ -75,8 +80,8 @@ public class PqAnchorProducerCacheHygieneTest { @Test public void theReachabilitySequenceLeavesNoArmedAnchorBehind() throws Exception { - final D078ThresholdReachabilityTest vinovat = new D078ThresholdReachabilityTest(); - final Field tmpField = D078ThresholdReachabilityTest.class.getDeclaredField("tmp"); + final PqForkThresholdReachabilityTest vinovat = new PqForkThresholdReachabilityTest(); + final Field tmpField = PqForkThresholdReachabilityTest.class.getDeclaredField("tmp"); tmpField.setAccessible(true); tmpField.set(vinovat, tmp); @@ -91,7 +96,7 @@ public class PqAnchorProducerCacheHygieneTest { assertThat(PqAnchorProducer.config().everActive()) .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 " + "and every proof-less fixture in the next class dies with AERE-PQC-REG-ARM-02") .isFalse(); diff --git a/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/D078GateFeedTest.java b/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/D078GateFeedTest.java deleted file mode 100644 index f016124..0000000 --- a/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/D078GateFeedTest.java +++ /dev/null @@ -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. - * - *

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. - * - *

This class measures the WIRING, in both directions, and it is the only thing that separates the - * repair from a claim about it: - * - *

    - *
  • the legacy rule really does stand down at H, so it really cannot be the feed above H; - *
  • {@link PqAnchorSealsRule}, which runs at every height from H, really does feed it. - *
- */ -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
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"; - } - } -} diff --git a/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/D079ArmedWithoutRegistryTest.java b/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/D079ArmedWithoutRegistryTest.java deleted file mode 100644 index f2b8dcf..0000000 --- a/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/D079ArmedWithoutRegistryTest.java +++ /dev/null @@ -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. - * - *

{@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. - * - *

{@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. - * - *

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

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); - } -}