From 93f9d5839b418664665f75d7c640f752fa432d2b Mon Sep 17 00:00:00 2001 From: Aere Network Date: Sun, 27 Sep 2026 20:35:53 +0300 Subject: [PATCH] anchor: the propagation adoption gate (rule C, D-412) is OBSERVE by default - it computes the same verdict and adopts, logging 'would NOT adopt'; ENFORCE only with aere.d412.adoptionGate=enforce. On the public testnet the enforcing form froze a validator that had missed one height's commit messages (D-419). Adds PqTipAdoptionGateModeTest. LICENSING.md regenerated over the 209 files the package carries. --- LICENSING.md | 13 +- anchor/MANIFEST-sha256.txt | 5 +- .../besu/controller/PqTipAdoptionGate.java | 109 ++++++++++- .../controller/QbftBesuControllerBuilder.java | 13 +- .../controller/PqTipAdoptionGateModeTest.java | 180 ++++++++++++++++++ 5 files changed, 302 insertions(+), 18 deletions(-) create mode 100644 anchor/app/src/test/java/org/hyperledger/besu/controller/PqTipAdoptionGateModeTest.java diff --git a/LICENSING.md b/LICENSING.md index 2f7be4e..f86368b 100644 --- a/LICENSING.md +++ b/LICENSING.md @@ -123,7 +123,7 @@ the section 4(b) notice of changed files), and each keeps its original notice pe -## anchor/: every file that carries someone else's copyright (generated 2026-09-24) +## anchor/: every file that carries someone else's copyright (generated 2026-09-27) This list is **generated from the files this package actually ships**, not written by hand: the previous hand-written table fell behind the tree on 2026-09-12 and seven upstream files went @@ -132,8 +132,8 @@ repository, and the publisher refuses to ship a package whose list disagrees wit You do not need that tool to check the list: every row below names a file in this package, and `grep -i copyright` on it shows the same line. -Measured 2026-09-24 over `anchor/`: **202 files**. **90** carry someone -else's copyright alone, **57** carry a shared notice naming both upstream and us, and **11** name only +Measured 2026-09-27 over `anchor/`: **209 files**. **90** carry someone +else's copyright alone, **64** carry a shared notice naming both upstream and us, and **11** name only `contributors to the AERE Network`; 44 carry no copyright line at all. The first two groups are the table below. A shared notice is listed too: those are precisely the upstream files we changed, so section 4(b) has something to say about each of them. @@ -145,11 +145,14 @@ notice (4(c)), and where we changed it the words **modified by Aere Network** ar |---|---|---| | `anchor/app/src/main/java/org/hyperledger/besu/cli/BesuCommand.java` | `Copyright ConsenSys AG.` | upstream | | `anchor/app/src/main/java/org/hyperledger/besu/cli/options/AerePqEmergencyOptions.java` | `Copyright contributors to Besu / AERE Network.` | upstream, **modified by Aere Network** | +| `anchor/app/src/main/java/org/hyperledger/besu/controller/PqTipAdoptionGate.java` | `Copyright contributors to Besu / AERE Network.` | upstream, **modified by Aere Network** | | `anchor/app/src/main/java/org/hyperledger/besu/controller/QbftBesuControllerBuilder.java` | `Copyright ConsenSys AG.` | upstream | | `anchor/app/src/test/java/org/hyperledger/besu/cli/options/AerePqEmergencyOptionsTest.java` | `Copyright contributors to Besu / AERE Network.` | upstream, **modified by Aere Network** | +| `anchor/app/src/test/java/org/hyperledger/besu/controller/PqTipAdoptionGateModeTest.java` | `Copyright contributors to Besu / AERE Network.` | upstream, **modified by Aere Network** | | `anchor/config/src/main/java/org/hyperledger/besu/config/JsonGenesisConfigOptions.java` | `Copyright ConsenSys AG.` | upstream | | `anchor/consensus/common/build.gradle` | `Copyright ConsenSys AG.` | upstream | | `anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/AerePqFinality.java` | `Copyright contributors to Besu / AERE Network.` | upstream, **modified by Aere Network** | +| `anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/AerePqFinalityTags.java` | `Copyright contributors to Besu / AERE Network.` | upstream, **modified by Aere Network** | | `anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/AerePqHeaderSeal.java` | `Copyright contributors to Besu / AERE Network.` | upstream, **modified by Aere Network** | | `anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/BftBlockInterface.java` | `Copyright ConsenSys AG.` | upstream | | `anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/BftExtraData.java` | `Copyright ConsenSys AG.` | upstream | @@ -179,6 +182,7 @@ notice (4(c)), and where we changed it the words **modified by Aere Network** ar | `anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/LICENSE-BouncyCastle.txt` | `Copyright (c) 2000-2025 The Legion of the Bouncy Castle Inc. (https://www.bouncycastle.org)` | upstream | | `anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/NodeEntry.java` | `Copyright (c) 2000-2025 The Legion of the Bouncy Castle Inc., see LICENTA-BOUNCYCASTLE.txt),` | upstream | | `anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/PK.java` | `Copyright (c) 2000-2025 The Legion of the Bouncy Castle Inc., see LICENTA-BOUNCYCASTLE.txt),` | upstream | +| `anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/ParallelSigning.java` | `Copyright contributors to Besu / AERE Network.` | upstream, **modified by Aere Network** | | `anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/SIG.java` | `Copyright (c) 2000-2025 The Legion of the Bouncy Castle Inc., see LICENTA-BOUNCYCASTLE.txt),` | upstream | | `anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/SIG_FORS.java` | `Copyright (c) 2000-2025 The Legion of the Bouncy Castle Inc., see LICENTA-BOUNCYCASTLE.txt),` | upstream | | `anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/SIG_XMSS.java` | `Copyright (c) 2000-2025 The Legion of the Bouncy Castle Inc., see LICENTA-BOUNCYCASTLE.txt),` | upstream | @@ -195,6 +199,7 @@ notice (4(c)), and where we changed it the words **modified by Aere Network** ar | `anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/WotsPlus.java` | `Copyright (c) 2000-2025 The Legion of the Bouncy Castle Inc., see LICENTA-BOUNCYCASTLE.txt),` | upstream | | `anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/tools/PqRegistryHashTool.java` | `Copyright contributors to Besu.` | upstream | | `anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/AerePqFinalityAuthorTest.java` | `Copyright contributors to Besu / AERE Network.` | upstream, **modified by Aere Network** | +| `anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/AerePqFinalityTagsTest.java` | `Copyright contributors to Besu / AERE Network.` | upstream, **modified by Aere Network** | | `anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorConfigTest.java` | `Copyright contributors to Besu / AERE Network.` | upstream, **modified by Aere Network** | | `anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorEmergencyConfigTest.java` | `Copyright contributors to Besu / AERE Network.` | upstream, **modified by Aere Network** | | `anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorLapseTest.java` | `Copyright contributors to Besu / Aere Network.` | upstream, **modified by Aere Network** | @@ -218,6 +223,7 @@ notice (4(c)), and where we changed it the words **modified by Aere Network** ar | `anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PreparePqAttachGateTest.java` | `Copyright contributors to Besu.` | upstream | | `anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/ProposalPqAttachGateTest.java` | `Copyright contributors to Besu.` | upstream | | `anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/RoundChangePqAttachGateTest.java` | `Copyright contributors to Besu.` | upstream | +| `anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/slhdsa/SlhDsaParallelSigningTest.java` | `Copyright contributors to Besu / AERE Network.` | upstream, **modified by Aere Network** | | `anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/messagewrappers/Commit.java` | `Copyright ConsenSys AG.` | upstream | | `anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/network/QbftMessageTransmitter.java` | `Copyright ConsenSys AG.` | upstream | | `anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/CommitPayload.java` | `Copyright ConsenSys AG.` | upstream | @@ -290,3 +296,4 @@ notice (4(c)), and where we changed it the words **modified by Aere Network** ar | `anchor/ethereum/api/src/test/java/org/hyperledger/besu/ethereum/api/jsonrpc/internal/methods/AereStateUnavailableIsAnErrorTest.java` | `Copyright contributors to Hyperledger Besu.` | upstream | | `anchor/ethereum/api/src/test/java/org/hyperledger/besu/ethereum/api/query/AereHeadStateRetryTest.java` | `Copyright contributors to Hyperledger Besu.` | upstream | | `anchor/ethereum/eth/src/main/java/org/hyperledger/besu/ethereum/eth/sync/DownloadHeadersStep.java` | `Copyright ConsenSys AG.` | upstream | +| `anchor/ethereum/eth/src/main/java/org/hyperledger/besu/ethereum/eth/sync/PropagatedBlockAdoptionGate.java` | `Copyright contributors to Besu / AERE Network.` | upstream, **modified by Aere Network** | diff --git a/anchor/MANIFEST-sha256.txt b/anchor/MANIFEST-sha256.txt index 8d01298..fafdc9a 100644 --- a/anchor/MANIFEST-sha256.txt +++ b/anchor/MANIFEST-sha256.txt @@ -5,9 +5,10 @@ computed them, and the same 276 files once produced two different aggregates her 329e4e2f93143cc8af092f0a951fa23619c12c4ec1793b053654f8e26e088da0 app/src/main/java/org/hyperledger/besu/cli/BesuCommand.java 3f289d74bb1878280a99742320d7a27c19146a2f19cdf6a0857f2aad4361ad5e app/src/main/java/org/hyperledger/besu/cli/options/AerePqEmergencyOptions.java -0ce3fcfdcfa7ab17f4fd1251e9066e3e1c03a59047a01fb548f490391fe4cdb4 app/src/main/java/org/hyperledger/besu/controller/PqTipAdoptionGate.java -505066636bdd2a0e6908d490ad0b3341788d270320aed81dc2923572d61216a7 app/src/main/java/org/hyperledger/besu/controller/QbftBesuControllerBuilder.java +3676cf2ef881b81437ddec15bf6b8f8c393a5a6459f77179898703c289c9f8bc app/src/main/java/org/hyperledger/besu/controller/PqTipAdoptionGate.java +feaaa439fc80af14d766683231d46e5b2aa12aa6e651ed83f7ad8e9507fa1413 app/src/main/java/org/hyperledger/besu/controller/QbftBesuControllerBuilder.java 129493150c0b13e8020bdf50c0c1ae07268d4f73a69a821b303bf4894451fa5e app/src/test/java/org/hyperledger/besu/cli/options/AerePqEmergencyOptionsTest.java +2de4b531aa8e2ea377e31908ada2d54e5e7b2df949f2456f2c0913a988f8dd00 app/src/test/java/org/hyperledger/besu/controller/PqTipAdoptionGateModeTest.java 728c29c299edf6011f2c2798cbe930a043112e0e06b924e7edb54284ca44a758 config/src/main/java/org/hyperledger/besu/config/JsonGenesisConfigOptions.java a3b93f2602e9755d91358c9b3f473235f4d40ae23d912f797cdb03ed7e1348f9 consensus/common/build.gradle 68d635954ef33448a661256f50642eeeff2f7bb5874a54ffc2e64193c83fe391 consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/AereAnchorProposalDelay.java diff --git a/anchor/app/src/main/java/org/hyperledger/besu/controller/PqTipAdoptionGate.java b/anchor/app/src/main/java/org/hyperledger/besu/controller/PqTipAdoptionGate.java index 19f3692..5daabf7 100644 --- a/anchor/app/src/main/java/org/hyperledger/besu/controller/PqTipAdoptionGate.java +++ b/anchor/app/src/main/java/org/hyperledger/besu/controller/PqTipAdoptionGate.java @@ -61,11 +61,56 @@ import org.slf4j.LoggerFactory; * cache cannot forge the proof. To clear the gate an adversary would need the commit quorum's distinct * Falcon signatures over the block's commit message - which is exactly the agreement D-412 shows a single * key cannot produce. + * + *

OBSERVE by default, since D-419 (2026-09-27). Measured on testnet 28001: validator + * aere-testnet@1 spent five seconds importing its own 3,366-transaction block, missed the commit messages of + * the next height, and this gate then refused the block the other four had decided - 226 times over 1 h 45 + * min - while its own consensus climbed to round 10 and could never decide that height again, and the sync + * downloader never engaged ({@code eth_syncing=false} 4,000 blocks behind). The third bullet above was the + * premise, and it did not hold. Meanwhile the one scenario the gate was written for had been measured on + * 2026-09-26 WITHOUT the gate: honest validators holding the honest block at the tip did not adopt the forged + * one (0 of 3), because the honest block wins the race; the real D-412 exposure is readers and catch-up, + * which Rule B1 (finality tags) and the anchor bound (PqAnchorSealsRule) cover. So the gate now defaults to + * OBSERVE: it computes exactly the same verdict and logs every block it WOULD refuse ({@value + * #OBSERVE_MARKER}), and adopts it. ENFORCE ({@value #PROPERTY_MODE}=enforce or {@value #ENV_MODE}=enforce) + * keeps the old behaviour and must be asked for; it has no release condition yet, so it can wedge a validator + * that missed a height. A safe release needs evidence one key cannot forge (a certified anchor above the + * refused block), not a timeout: a proposer that stays silent and waits for the timeout is the D-412 attack. */ public class PqTipAdoptionGate implements PropagatedBlockAdoptionGate.Gate { private static final Logger LOG = LoggerFactory.getLogger(PqTipAdoptionGate.class); + /** System property selecting the gate mode: {@code enforce} or {@code observe} (the default). */ + public static final String PROPERTY_MODE = "aere.d412.adoptionGate"; + + /** Environment twin of {@link #PROPERTY_MODE}. */ + public static final String ENV_MODE = "AERE_D412_ADOPTION_GATE"; + + /** Logged, in observe mode, for every propagated block the enforce mode would have refused. */ + public static final String OBSERVE_MARKER = "AERE D-412 OBSERVE: would NOT adopt"; + + /** + * Whether the configuration asks for ENFORCE. Anything other than {@code enforce} (after trimming, any + * case), including nothing at all, is OBSERVE: enforcing must be an explicit request. + * + * @param property the system property value, possibly null + * @param env the environment value, possibly null + * @return true only for an explicit enforce + */ + static boolean enforceRequested(final String property, final String env) { + final String v = property != null && !property.isBlank() ? property : env; + if (v == null || v.isBlank()) { + return false; + } + final String m = v.trim().toLowerCase(java.util.Locale.ROOT); + if (!m.equals("enforce") && !m.equals("observe")) { + LOG.warn( + "AERE D-412: unknown adoption gate mode '{}' ({} / {}); using OBSERVE", m, PROPERTY_MODE, ENV_MODE); + } + return m.equals("enforce"); + } + /** Verifies one Falcon seal over the commit message of a block; injectable for tests. */ @FunctionalInterface public interface SealVerifier { @@ -86,9 +131,14 @@ public class PqTipAdoptionGate implements PropagatedBlockAdoptionGate.Gate { private final long chainId; private final PqSealCache cache; private final SealVerifier verifier; + private final boolean enforce; + private final java.util.function.LongPredicate stage5Active; + private final java.util.concurrent.atomic.AtomicLong wouldRefuse = + new java.util.concurrent.atomic.AtomicLong(); /** - * Production wiring. + * Production wiring; the mode comes from {@link #PROPERTY_MODE} / {@link #ENV_MODE} (OBSERVE unless + * enforce is asked for). * * @param validatorProvider the validator provider (the set at a height; ids from H_id, addresses below) * @param localEcdsaAddress this node's ECDSA address (from its node key) @@ -101,7 +151,9 @@ public class PqTipAdoptionGate implements PropagatedBlockAdoptionGate.Gate { localEcdsaAddress, chainId, PqSealCache.instance(), - (n, i, m, s) -> PqSignerRegistry.falconSealSupport().verifyAtHistoric(n, i, m, s)); + (n, i, m, s) -> PqSignerRegistry.falconSealSupport().verifyAtHistoric(n, i, m, s), + enforceRequested(System.getProperty(PROPERTY_MODE), System.getenv(ENV_MODE)), + AerePqHeaderSeal::active); } /** @@ -112,18 +164,43 @@ public class PqTipAdoptionGate implements PropagatedBlockAdoptionGate.Gate { * @param chainId the chain id * @param cache the seal cache to read * @param verifier the seal verifier + * @param enforce true to refuse (ENFORCE), false to log and adopt (OBSERVE) + * @param stage5Active whether a height is at or above H_hdr (production: {@link AerePqHeaderSeal#active}) */ PqTipAdoptionGate( final ValidatorProvider validatorProvider, final Address localEcdsaAddress, final long chainId, final PqSealCache cache, - final SealVerifier verifier) { + final SealVerifier verifier, + final boolean enforce, + final java.util.function.LongPredicate stage5Active) { this.validatorProvider = validatorProvider; this.localEcdsaAddress = localEcdsaAddress; this.chainId = chainId; this.cache = cache; this.verifier = verifier; + this.enforce = enforce; + this.stage5Active = stage5Active; + } + + /** + * Whether this gate refuses (ENFORCE) or only logs (OBSERVE). + * + * @return true in ENFORCE mode + */ + public boolean isEnforcing() { + return enforce; + } + + /** + * How many propagated blocks this gate judged without a post-quantum commit quorum (refused in ENFORCE, + * logged and adopted in OBSERVE). + * + * @return the count since start + */ + public long wouldRefuseCount() { + return wouldRefuse.get(); } @Override @@ -131,7 +208,7 @@ public class PqTipAdoptionGate implements PropagatedBlockAdoptionGate.Gate { final long h = header.getNumber(); // Below H_hdr the header still carries a quorum of ECDSA committed seals, whose upstream rule // already refuses a single-key block; nothing to add here. - if (!AerePqHeaderSeal.active(h)) { + if (!stage5Active.test(h)) { return true; } final Collection

validators = validatorProvider.getValidatorsForBlock(parent); @@ -172,16 +249,30 @@ public class PqTipAdoptionGate implements PropagatedBlockAdoptionGate.Gate { } } final boolean ok = verified.size() >= quorum; - if (!ok) { + if (ok) { + return true; + } + wouldRefuse.incrementAndGet(); + if (!enforce) { + // D-419: OBSERVE. Same verdict, recorded, and the block is adopted as before the gate existed. LOG.info( - "AERE D-412: propagated block {} at tip height {} holds {} verified post-quantum commit " - + "seal(s), quorum is {}; NOT adopting it via propagation - this node's own consensus " - + "decides this height.", + "{} propagated block {} at tip height {}: it holds {} verified post-quantum commit seal(s), " + + "quorum is {}; adopting it (observe mode, D-419).", + OBSERVE_MARKER, header.getHash(), h, verified.size(), quorum); + return true; } - return ok; + LOG.info( + "AERE D-412: propagated block {} at tip height {} holds {} verified post-quantum commit " + + "seal(s), quorum is {}; NOT adopting it via propagation - this node's own consensus " + + "decides this height.", + header.getHash(), + h, + verified.size(), + quorum); + return false; } } diff --git a/anchor/app/src/main/java/org/hyperledger/besu/controller/QbftBesuControllerBuilder.java b/anchor/app/src/main/java/org/hyperledger/besu/controller/QbftBesuControllerBuilder.java index d7c826c..afca7ac 100644 --- a/anchor/app/src/main/java/org/hyperledger/besu/controller/QbftBesuControllerBuilder.java +++ b/anchor/app/src/main/java/org/hyperledger/besu/controller/QbftBesuControllerBuilder.java @@ -619,12 +619,17 @@ public class QbftBesuControllerBuilder extends BesuControllerBuilder { // ADOPTION-at-the-tip is gated, only for a validator, only from H_hdr. Inert here on any chain whose // H_hdr is unset, which is chain 2800 today. Placed after the validator set is resolved (localAddress // and validatorProvider both exist) and before the network and the QBFT machine start. + // D-419 (2026-09-27): the gate defaults to OBSERVE (logs every block it would refuse, adopts it); ENFORCE + // wedged a validator that missed one height's commits on testnet 28001 and must be asked for explicitly. if (aereAnchorConfig.everActive()) { - PropagatedBlockAdoptionGate.install( - new PqTipAdoptionGate(validatorProvider, localAddress, aereAnchorConfig.chainId())); + final PqTipAdoptionGate tipGate = + new PqTipAdoptionGate(validatorProvider, localAddress, aereAnchorConfig.chainId()); + PropagatedBlockAdoptionGate.install(tipGate); LOG.info( - "AERE D-412: propagation adoption gate armed; a propagated block at the tip is adopted only " - + "with a post-quantum commit quorum from H_hdr={} (unset means never on this node).", + "AERE D-412: propagation adoption gate armed in {} mode; a propagated block at the tip {} " + + "without a post-quantum commit quorum from H_hdr={} (unset means never on this node).", + tipGate.isEnforcing() ? "ENFORCE" : "OBSERVE", + tipGate.isEnforcing() ? "is NOT adopted" : "is adopted and logged", AerePqHeaderSeal.headerBlock()); } diff --git a/anchor/app/src/test/java/org/hyperledger/besu/controller/PqTipAdoptionGateModeTest.java b/anchor/app/src/test/java/org/hyperledger/besu/controller/PqTipAdoptionGateModeTest.java new file mode 100644 index 0000000..1e87f3b --- /dev/null +++ b/anchor/app/src/test/java/org/hyperledger/besu/controller/PqTipAdoptionGateModeTest.java @@ -0,0 +1,180 @@ +/* + * 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.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import org.hyperledger.besu.consensus.common.bft.AerePqIdentity; +import org.hyperledger.besu.consensus.common.bft.FalconSeal; +import org.hyperledger.besu.consensus.common.bft.PqSealCache; +import org.hyperledger.besu.consensus.common.validator.ValidatorProvider; +import org.hyperledger.besu.datatypes.Address; +import org.hyperledger.besu.ethereum.core.BlockHeader; +import org.hyperledger.besu.ethereum.core.BlockHeaderTestFixture; + +import java.util.List; +import java.util.concurrent.atomic.AtomicLong; + +import org.apache.tuweni.bytes.Bytes; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * D-419 (2026-09-27): the Rule C tip adoption gate in its two modes. OBSERVE (the default) computes the same + * verdict as ENFORCE, counts the blocks it would refuse, and adopts them; ENFORCE refuses. Every case has its + * opposite: the same block without a quorum is refused under ENFORCE and adopted under OBSERVE, and a quorum of + * seals that do NOT verify is refused under ENFORCE, so the gate verifies seals and does not merely count them. + */ +class PqTipAdoptionGateModeTest { + + private static final long H_HDR = 100L; + private static final long CHAIN_ID = 28098L; + private static final Address LOCAL = Address.fromHexString("0x00000000000000000000000000000000000000a1"); + private static final List
SET = + List.of( + LOCAL, + Address.fromHexString("0x00000000000000000000000000000000000000a2"), + Address.fromHexString("0x00000000000000000000000000000000000000a3"), + Address.fromHexString("0x00000000000000000000000000000000000000a4")); + // a distinct timestamp per header, so every test reads its own seal-cache entry from the shared singleton + private static final AtomicLong TS = new AtomicLong(1_900_000_000L); + + private ValidatorProvider provider; + + @BeforeEach + void setUp() { + // no v3 identity installed: isLocal falls back to address equality, which is what this set uses + AerePqIdentity.uninstall(); + provider = mock(ValidatorProvider.class); + when(provider.getValidatorsForBlock(any())).thenReturn(SET); + } + + @AfterEach + void tearDown() { + AerePqIdentity.uninstall(); + } + + private static BlockHeader header(final long number) { + return new BlockHeaderTestFixture().number(number).timestamp(TS.incrementAndGet()).buildHeader(); + } + + private PqTipAdoptionGate gate(final boolean enforce, final boolean sealsVerify) { + return new PqTipAdoptionGate( + provider, + LOCAL, + CHAIN_ID, + PqSealCache.instance(), + (n, i, m, s) -> sealsVerify, + enforce, + h -> h >= H_HDR); + } + + private static void recordSeals(final BlockHeader h, final int count) { + final List seals = + java.util.stream.IntStream.range(0, count) + .mapToObj(i -> new FalconSeal(i, Bytes.of(i + 1))) + .toList(); + PqSealCache.instance().record(h.getNumber(), h.getHash(), seals); + } + + @Test + void enforceRefusesATipBlockWithoutAQuorum() { + final BlockHeader h = header(150); + final PqTipAdoptionGate g = gate(true, true); + assertThat(g.mayAdopt(h, header(149))).isFalse(); + assertThat(g.wouldRefuseCount()).isEqualTo(1); + } + + @Test + void observeAdoptsTheSameBlockAndCountsIt() { + final BlockHeader h = header(150); + final PqTipAdoptionGate g = gate(false, true); + assertThat(g.mayAdopt(h, header(149))).isTrue(); + assertThat(g.wouldRefuseCount()).isEqualTo(1); + } + + @Test + void enforceAdoptsWithAVerifiedQuorum() { + final BlockHeader h = header(151); + recordSeals(h, 3); // quorum of 4 validators is 3 + final PqTipAdoptionGate g = gate(true, true); + assertThat(g.mayAdopt(h, header(150))).isTrue(); + assertThat(g.wouldRefuseCount()).isZero(); + } + + @Test + void enforceRefusesAQuorumOfSealsThatDoNotVerify() { + final BlockHeader h = header(152); + recordSeals(h, 4); + final PqTipAdoptionGate g = gate(true, false); + assertThat(g.mayAdopt(h, header(151))).isFalse(); + assertThat(g.wouldRefuseCount()).isEqualTo(1); + } + + @Test + void belowHdrNeitherModeGatesOrCounts() { + final BlockHeader h = header(H_HDR - 1); + final PqTipAdoptionGate e = gate(true, true); + final PqTipAdoptionGate o = gate(false, true); + assertThat(e.mayAdopt(h, header(H_HDR - 2))).isTrue(); + assertThat(o.mayAdopt(h, header(H_HDR - 2))).isTrue(); + assertThat(e.wouldRefuseCount()).isZero(); + assertThat(o.wouldRefuseCount()).isZero(); + } + + @Test + void aNodeOutsideTheSetIsNotGatedEvenInEnforce() { + when(provider.getValidatorsForBlock(any())).thenReturn(SET.subList(1, 4)); + final PqTipAdoptionGate g = gate(true, true); + assertThat(g.mayAdopt(header(153), header(152))).isTrue(); + assertThat(g.wouldRefuseCount()).isZero(); + } + + @Test + void enforceMustBeAskedForExplicitly() { + assertThat(PqTipAdoptionGate.enforceRequested(null, null)).isFalse(); + assertThat(PqTipAdoptionGate.enforceRequested("", " ")).isFalse(); + assertThat(PqTipAdoptionGate.enforceRequested("observe", null)).isFalse(); + assertThat(PqTipAdoptionGate.enforceRequested("bogus", null)).isFalse(); + assertThat(PqTipAdoptionGate.enforceRequested("enforce", null)).isTrue(); + assertThat(PqTipAdoptionGate.enforceRequested(" ENFORCE ", null)).isTrue(); + assertThat(PqTipAdoptionGate.enforceRequested(null, "enforce")).isTrue(); + // the system property wins over the environment when both are set + assertThat(PqTipAdoptionGate.enforceRequested("observe", "enforce")).isFalse(); + } + + @Test + void theProductionConstructorDefaultsToObserve() { + final String before = System.getProperty(PqTipAdoptionGate.PROPERTY_MODE); + try { + System.clearProperty(PqTipAdoptionGate.PROPERTY_MODE); + if (System.getenv(PqTipAdoptionGate.ENV_MODE) == null) { + assertThat(new PqTipAdoptionGate(provider, LOCAL, CHAIN_ID).isEnforcing()).isFalse(); + } + System.setProperty(PqTipAdoptionGate.PROPERTY_MODE, "enforce"); + assertThat(new PqTipAdoptionGate(provider, LOCAL, CHAIN_ID).isEnforcing()).isTrue(); + } finally { + if (before == null) { + System.clearProperty(PqTipAdoptionGate.PROPERTY_MODE); + } else { + System.setProperty(PqTipAdoptionGate.PROPERTY_MODE, before); + } + } + } +}