diff --git a/anchor/BASE.txt b/anchor/BASE.txt new file mode 100644 index 0000000..2707fd6 --- /dev/null +++ b/anchor/BASE.txt @@ -0,0 +1,16 @@ +NAMED BASE: d2032017bb, Besu tag 26.4.0 + +The files in this directory are applied ON TOP of a Besu tree at EXACTLY that commit. Applied to +anything else they overwrite, and silently delete, whatever upstream added in between. Measured +against a newer base, 26.7.1: 97 lines would be lost, among them the registration of the /liveness +and /readiness probes in BesuCommand.java, and nothing would fail. + +IF YOU MOVE THE BASE, do not take whole files from here. Re-apply the same changes as a patch +against your own base: a patch fails loudly when upstream has moved, a whole file does not. + +The finding, in one line: shipping whole files is a silent overwrite, not a merge. That is why the +base commit is named here rather than left implicit, and it is why the same code is delivered to the +node repository as a patch and not as a tree. + +Clean upstream tree at that commit: 4,118 .java files. If your count differs, you are not on the +base this overlay was measured against. diff --git a/anchor/README.md b/anchor/README.md new file mode 100644 index 0000000..d6934c2 --- /dev/null +++ b/anchor/README.md @@ -0,0 +1,142 @@ +# Post-quantum certificate anchor for QBFT + +This is the code that puts a post-quantum validator certificate under the block hash in Hyperledger +Besu's QBFT consensus, as an overlay on a named upstream commit. + +It is published so that the claim can be checked rather than believed. Everything below that is not +demonstrable from these files is marked as not demonstrable from these files. + +--- + +## Scope boundary, stated first and not in a footnote + +**Consensus on Aere Network chain 2800 is classical secp256k1 ECDSA QBFT.** Block proposal, validator +identity and the QBFT vote messages are all classical elliptic-curve cryptography. A cryptographically +relevant quantum computer would break them exactly as it would break any other ECDSA chain. + +What this code adds is narrower and is the whole point: **the block hash commits to a Falcon +certificate signed by the validators.** That is a binding, not a replacement. It does not make +consensus post-quantum and is never described as such. + +The threat it addresses is not "harvest now, decrypt later". A signature is public; there is nothing +to harvest. The threat is **retroactive rewriting**: validator keys recovered later can be used to +re-sign old blocks, and a chain whose history is authenticated only by ECDSA cannot distinguish the +rewrite from the original. Binding a post-quantum certificate into the hash of every anchored header +means a rewrite must also forge the post-quantum signatures. The reasoning follows Azouvi, Danezis, +Nikolaenko, "Winkle" (IACR 2019/1440, AFT 2020). + +--- + +## The problem this solves, which is not obvious + +In QBFT, `extraData` is an RLP list. The block hash is **not** computed over the stored bytes. It is +computed over a re-encoding of the decoded list, with the seal fields removed, so that every node +agrees on a hash before the seals exist. + +That has a consequence that is easy to miss: **anything the decoder does not know about is dropped +before hashing.** Append a certificate as a new element and it survives in storage, travels between +nodes, and is entirely absent from the hash. Two nodes can hold different certificates for the same +block and both consider it valid. The certificate would be decoration. + +Four obvious designs were tried and all four died on that: + +| Attempt | Why it died | +|---|---| +| new RLP element after the seals | dropped by the re-encode, never reaches keccak | +| extend the seal list | changes the seal encoding, so every existing node rejects the header | +| a second `extraData`-like field | not in the header schema; a header with it is not a header | +| commit in the state root | the state root is computed before the certificate exists | + +The design that works uses a field that is **already under keccak**: `vanityData`, element 0 of the +list, 32 bytes, historically arbitrary. At an anchor height it carries the digest of the certificate +instead of the usual vanity string. The certificate itself still rides outside the hash, but it is +now pinned: change one byte of it and the digest no longer matches, and the header is rejected. + +`PqAnchor.java` and `PqAnchorDigestRule.java` are where that lives. Read those two first. + +--- + +## What is in here + +- `consensus/common/.../bft/` — the anchor itself: configuration, the digest, the seal cache and + store, the producer that attaches seals, the Falcon registry that maps a validator to a key. +- `consensus/qbft/.../headervalidationrules/` — the validation rules: the digest must match, the + seals must verify, and the rules must actually be wired into the validator chain. +- `app/.../controller/` — where the rules are built and where the node refuses to start on a + configuration that would produce headers its own fleet rejects. +- tests — including the negative controls. A test that cannot fail is not a test, and several of + these exist specifically to prove the guards can fail. + +`BASE.txt` names the upstream commit. Applying these files to any other tree overwrites whatever +upstream added since, silently. That is stated there in more detail because it is a real hazard. + +**One build file changes, and it is one line.** `consensus/common/build.gradle` gains +`implementation 'org.bouncycastle:bcprov-jdk18on'`. Falcon verification happens inside the consensus +module, so the module needs the library on its own compile path. No version is stated, because +upstream pins it: `platform/build.gradle` declares `bcprov-jdk18on:1.83`, and four upstream modules +already take the dependency in exactly this versionless form. So the line adds a compile-path entry +and no new artifact, and it does not move any version. That is the entire build change, and it is +called out here rather than left to be found in the diff. + +No cryptography is implemented in this overlay. Falcon signature verification calls Bouncy Castle's +implementation; what is ours is the framing, the registry that maps a validator to a key, the digest, +and the validation rules. + +--- + +## What is proven, and by what + +- **The certificate is byte-identical across nodes.** Measured on a test network, six nodes. +- **A stripped certificate is rejected.** 12 attempts, 12 rejections. +- **The guards compile and pass together**, and the negative control was run: with the guards + removed the proofs go red. A guard that has never failed cannot be trusted, so each was made to + fail on purpose. +- **Arming does not halt the chain across a validator-set change.** On a test network of ten + processes, 1,110 blocks were produced across the arming height while four validator-set votes were + driven through it, and the block rate did not change. The negative control for that run was + separate and blunt: three nodes restarted without their Falcon key produced zero blocks in ninety + seconds while every node was alive; with the keys restored, eighty-nine. +- **The wiring is tested.** An earlier version of this code registered a rule that could not be seen + from outside, because Besu's `BlockHeaderValidator.Builder` wraps detached rules in a lambda. The + rule was present and untested for that reason alone. `QbftAnchorRuleWiringTest` exists because of + that, and its negative control is measured: comment out the registration line and it goes red. + +## What is not proven here + +- **This overlay has not been audited by a third party.** No external security review of this code + exists. If you are reading it as an auditor, you are the first. +- **A rehearsal with a deliberately un-upgraded node has not been run.** Every rehearsal so far + upgraded every node. +- **Nothing here demonstrates what is configured on any live network.** These files show what the + code does when armed. They are not evidence about any running fleet, and should not be read as any. + +## One claim we retracted, on purpose + +An earlier version of our public material said that no public chain has a block hash covering a +post-quantum validator certificate. That does not survive a hostile reading. Cellframe's ESBoCS +signs blocks with keys that resolve to Dilithium, Falcon or SPHINCS+, and hashes the block with the +signatures attached. The capability exists in their code today. + +The defensible statement is narrower: no public chain has a post-quantum validator certificate under +the block hash **that is proven and independently verifiable**. This repository is our half of that +sentence. Someone else has to do the verifying, which is why it is here. + +--- + +## Terminology, used precisely + +**A certificate here is signed by f+1 validators, not by a quorum.** With f Byzantine faults +tolerated, f+1 signatures guarantee that at least one honest validator signed. That is a real +property and it is not the same as a quorum, and we do not call it one. Anyone counting will notice, +and they should. + +The seal threshold is a **floor, not a cap**: nodes attach as many verified seals as arrive in time, +which is at least the threshold and often more. A separate cap bounds how many are written, because +each seal costs bytes in every header forever. + +--- + +## Licence + +Apache 2.0, matching upstream Hyperledger Besu. See `../LICENSE` and `../NOTICE`. Files that modify +upstream carry the change notice required by section 4(b); files that are new are ours. diff --git a/anchor/app/src/main/java/org/hyperledger/besu/cli/BesuCommand.java b/anchor/app/src/main/java/org/hyperledger/besu/cli/BesuCommand.java new file mode 100644 index 0000000..8441bb6 --- /dev/null +++ b/anchor/app/src/main/java/org/hyperledger/besu/cli/BesuCommand.java @@ -0,0 +1,3004 @@ +/* + * Copyright ConsenSys AG. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.cli; + +import static com.google.common.base.Preconditions.checkNotNull; +import static com.google.common.base.Preconditions.checkState; +import static java.lang.Long.parseLong; +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.Arrays.asList; +import static org.hyperledger.besu.cli.DefaultCommandValues.getDefaultBesuDataPath; +import static org.hyperledger.besu.cli.util.CommandLineUtils.DEPENDENCY_WARNING_MSG; +import static org.hyperledger.besu.cli.util.CommandLineUtils.isOptionSet; +import static org.hyperledger.besu.config.NetworkDefinition.EPHEMERY; +import static org.hyperledger.besu.config.NetworkDefinition.MAINNET; +import static org.hyperledger.besu.controller.BesuController.DATABASE_PATH; +import static org.hyperledger.besu.ethereum.api.jsonrpc.authentication.EngineAuthService.EPHEMERAL_JWT_FILE; + +import org.hyperledger.besu.Runner; +import org.hyperledger.besu.RunnerBuilder; +import org.hyperledger.besu.chainexport.Era1BlockExporter; +import org.hyperledger.besu.chainexport.RlpBlockExporter; +import org.hyperledger.besu.chainimport.Era1BlockImporter; +import org.hyperledger.besu.chainimport.JsonBlockImporter; +import org.hyperledger.besu.chainimport.RlpBlockImporter; +import org.hyperledger.besu.cli.config.EthNetworkConfig; +import org.hyperledger.besu.cli.config.NativeRequirement; +import org.hyperledger.besu.cli.config.NativeRequirement.NativeRequirementResult; +import org.hyperledger.besu.cli.config.ProfilesCompletionCandidates; +import org.hyperledger.besu.cli.custom.JsonRPCAllowlistHostsProperty; +import org.hyperledger.besu.cli.error.BesuExecutionExceptionHandler; +import org.hyperledger.besu.cli.error.BesuParameterExceptionHandler; +import org.hyperledger.besu.cli.options.AerePqEmergencyOptions; +import org.hyperledger.besu.cli.options.ApiConfigurationOptions; +import org.hyperledger.besu.cli.options.BalConfigurationOptions; +import org.hyperledger.besu.cli.options.ChainPruningOptions; +import org.hyperledger.besu.cli.options.DnsOptions; +import org.hyperledger.besu.cli.options.EngineRPCConfiguration; +import org.hyperledger.besu.cli.options.EngineRPCOptions; +import org.hyperledger.besu.cli.options.EthProtocolOptions; +import org.hyperledger.besu.cli.options.EthstatsOptions; +import org.hyperledger.besu.cli.options.EvmOptions; +import org.hyperledger.besu.cli.options.GraphQlOptions; +import org.hyperledger.besu.cli.options.InProcessRpcOptions; +import org.hyperledger.besu.cli.options.IpcOptions; +import org.hyperledger.besu.cli.options.JsonRpcHttpOptions; +import org.hyperledger.besu.cli.options.LoggingLevelOption; +import org.hyperledger.besu.cli.options.MetricsOptions; +import org.hyperledger.besu.cli.options.MiningOptions; +import org.hyperledger.besu.cli.options.NatOptions; +import org.hyperledger.besu.cli.options.NativeLibraryOptions; +import org.hyperledger.besu.cli.options.NetworkingOptions; +import org.hyperledger.besu.cli.options.NodePrivateKeyFileOption; +import org.hyperledger.besu.cli.options.P2PDiscoveryOptions; +import org.hyperledger.besu.cli.options.PermissionsOptions; +import org.hyperledger.besu.cli.options.PluginsConfigurationOptions; +import org.hyperledger.besu.cli.options.RPCOptions; +import org.hyperledger.besu.cli.options.RpcWebsocketOptions; +import org.hyperledger.besu.cli.options.SynchronizerOptions; +import org.hyperledger.besu.cli.options.TransactionPoolOptions; +import org.hyperledger.besu.cli.options.storage.DataStorageOptions; +import org.hyperledger.besu.cli.options.storage.PathBasedExtraStorageOptions; +import org.hyperledger.besu.cli.options.unstable.QBFTOptions; +import org.hyperledger.besu.cli.presynctasks.PreSynchronizationTaskRunner; +import org.hyperledger.besu.cli.subcommands.PasswordSubCommand; +import org.hyperledger.besu.cli.subcommands.PublicKeySubCommand; +import org.hyperledger.besu.cli.subcommands.TxParseSubCommand; +import org.hyperledger.besu.cli.subcommands.ValidateConfigSubCommand; +import org.hyperledger.besu.cli.subcommands.blocks.BlocksSubCommand; +import org.hyperledger.besu.cli.subcommands.operator.OperatorSubCommand; +import org.hyperledger.besu.cli.subcommands.rlp.RLPSubCommand; +import org.hyperledger.besu.cli.subcommands.storage.StorageSubCommand; +import org.hyperledger.besu.cli.util.BesuCommandCustomFactory; +import org.hyperledger.besu.cli.util.BootnodeResolver; +import org.hyperledger.besu.cli.util.BootnodeResolver.BootnodeResolutionException; +import org.hyperledger.besu.cli.util.CommandLineUtils; +import org.hyperledger.besu.cli.util.ConfigDefaultValueProviderStrategy; +import org.hyperledger.besu.cli.util.VersionProvider; +import org.hyperledger.besu.components.BesuComponent; +import org.hyperledger.besu.config.CheckpointConfigOptions; +import org.hyperledger.besu.config.DiscoveryOptions; +import org.hyperledger.besu.config.GenesisConfig; +import org.hyperledger.besu.config.GenesisConfigOptions; +import org.hyperledger.besu.config.JsonUtil; +import org.hyperledger.besu.config.MergeConfiguration; +import org.hyperledger.besu.config.NetworkDefinition; +import org.hyperledger.besu.consensus.merge.blockcreation.MergeCoordinator; +import org.hyperledger.besu.controller.BesuController; +import org.hyperledger.besu.controller.BesuControllerBuilder; +import org.hyperledger.besu.crypto.Blake2bfMessageDigest; +import org.hyperledger.besu.crypto.KeyPair; +import org.hyperledger.besu.crypto.KeyPairUtil; +import org.hyperledger.besu.crypto.SECP256R1; +import org.hyperledger.besu.crypto.SignatureAlgorithmFactory; +import org.hyperledger.besu.cryptoservices.KeyPairSecurityModule; +import org.hyperledger.besu.cryptoservices.NodeKey; +import org.hyperledger.besu.datatypes.Address; +import org.hyperledger.besu.datatypes.Hash; +import org.hyperledger.besu.datatypes.Wei; +import org.hyperledger.besu.ethereum.api.ApiConfiguration; +import org.hyperledger.besu.ethereum.api.graphql.GraphQLConfiguration; +import org.hyperledger.besu.ethereum.api.jsonrpc.InProcessRpcConfiguration; +import org.hyperledger.besu.ethereum.api.jsonrpc.JsonRpcConfiguration; +import org.hyperledger.besu.ethereum.api.jsonrpc.RpcApis; +import org.hyperledger.besu.ethereum.api.jsonrpc.authentication.JwtAlgorithm; +import org.hyperledger.besu.ethereum.api.jsonrpc.ipc.JsonRpcIpcConfiguration; +import org.hyperledger.besu.ethereum.api.jsonrpc.websocket.WebSocketConfiguration; +import org.hyperledger.besu.ethereum.chain.Blockchain; +import org.hyperledger.besu.ethereum.chain.ChainDataPruner.ChainPruningStrategy; +import org.hyperledger.besu.ethereum.core.MiningConfiguration; +import org.hyperledger.besu.ethereum.core.MiningParametersMetrics; +import org.hyperledger.besu.ethereum.core.VersionMetadata; +import org.hyperledger.besu.ethereum.eth.sync.SyncMode; +import org.hyperledger.besu.ethereum.eth.sync.SynchronizerConfiguration; +import org.hyperledger.besu.ethereum.eth.transactions.ImmutableTransactionPoolConfiguration; +import org.hyperledger.besu.ethereum.eth.transactions.TransactionPoolConfiguration; +import org.hyperledger.besu.ethereum.mainnet.BalConfiguration; +import org.hyperledger.besu.ethereum.p2p.config.DiscoveryConfiguration; +import org.hyperledger.besu.ethereum.p2p.discovery.NodeIdentifier; +import org.hyperledger.besu.ethereum.p2p.discovery.P2PDiscoveryConfiguration; +import org.hyperledger.besu.ethereum.p2p.discovery.dns.EthereumNodeRecord; +import org.hyperledger.besu.ethereum.p2p.peers.EnodeDnsConfiguration; +import org.hyperledger.besu.ethereum.p2p.peers.EnodeURLImpl; +import org.hyperledger.besu.ethereum.p2p.peers.StaticNodesParser; +import org.hyperledger.besu.ethereum.permissioning.LocalPermissioningConfiguration; +import org.hyperledger.besu.ethereum.permissioning.PermissioningConfiguration; +import org.hyperledger.besu.ethereum.storage.StorageProvider; +import org.hyperledger.besu.ethereum.storage.keyvalue.KeyValueSegmentIdentifier; +import org.hyperledger.besu.ethereum.storage.keyvalue.KeyValueStorageProvider; +import org.hyperledger.besu.ethereum.storage.keyvalue.KeyValueStorageProviderBuilder; +import org.hyperledger.besu.ethereum.worldstate.DataStorageConfiguration; +import org.hyperledger.besu.ethereum.worldstate.ImmutableDataStorageConfiguration; +import org.hyperledger.besu.ethereum.worldstate.ImmutablePathBasedExtraStorageConfiguration; +import org.hyperledger.besu.ethereum.worldstate.PathBasedExtraStorageConfiguration; +import org.hyperledger.besu.evm.precompile.AbstractAltBnPrecompiledContract; +import org.hyperledger.besu.evm.precompile.AbstractBLS12PrecompiledContract; +import org.hyperledger.besu.evm.precompile.AbstractPrecompiledContract; +import org.hyperledger.besu.evm.precompile.BigIntegerModularExponentiationPrecompiledContract; +import org.hyperledger.besu.evm.precompile.KZGPointEvalPrecompiledContract; +import org.hyperledger.besu.evm.precompile.P256VerifyPrecompiledContract; +import org.hyperledger.besu.metrics.BesuMetricCategory; +import org.hyperledger.besu.metrics.MetricCategoryRegistryImpl; +import org.hyperledger.besu.metrics.MetricsProtocol; +import org.hyperledger.besu.metrics.ObservableMetricsSystem; +import org.hyperledger.besu.metrics.StandardMetricCategory; +import org.hyperledger.besu.metrics.prometheus.MetricsConfiguration; +import org.hyperledger.besu.metrics.vertx.VertxMetricsAdapterFactory; +import org.hyperledger.besu.nat.NatMethod; +import org.hyperledger.besu.plugin.services.BesuConfiguration; +import org.hyperledger.besu.plugin.services.MetricsSystem; +import org.hyperledger.besu.plugin.services.PicoCLIOptions; +import org.hyperledger.besu.plugin.services.exception.StorageException; +import org.hyperledger.besu.plugin.services.securitymodule.SecurityModule; +import org.hyperledger.besu.plugin.services.storage.DataStorageFormat; +import org.hyperledger.besu.plugin.services.storage.rocksdb.RocksDBPlugin; +import org.hyperledger.besu.services.BesuConfigurationImpl; +import org.hyperledger.besu.services.BesuPluginContextImpl; +import org.hyperledger.besu.services.BesuPluginServiceRegistrar; +import org.hyperledger.besu.services.BlockchainServiceImpl; +import org.hyperledger.besu.services.PermissioningServiceImpl; +import org.hyperledger.besu.services.PicoCLIOptionsImpl; +import org.hyperledger.besu.services.RpcEndpointServiceImpl; +import org.hyperledger.besu.services.SecurityModuleServiceImpl; +import org.hyperledger.besu.services.StorageServiceImpl; +import org.hyperledger.besu.services.TransactionPoolValidatorServiceImpl; +import org.hyperledger.besu.services.TransactionSelectionServiceImpl; +import org.hyperledger.besu.services.TransactionSimulationServiceImpl; +import org.hyperledger.besu.services.TransactionValidatorServiceImpl; +import org.hyperledger.besu.services.kvstore.InMemoryStoragePlugin; +import org.hyperledger.besu.util.BesuVersionUtils; +import org.hyperledger.besu.util.EphemeryGenesisUpdater; +import org.hyperledger.besu.util.InvalidConfigurationException; +import org.hyperledger.besu.util.LogConfigurator; +import org.hyperledger.besu.util.NetworkUtility; +import org.hyperledger.besu.util.PermissioningConfigurationValidator; +import org.hyperledger.besu.util.number.Fraction; +import org.hyperledger.besu.util.number.Percentage; +import org.hyperledger.besu.util.number.PositiveNumber; + +import java.io.BufferedReader; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.math.BigInteger; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.GroupPrincipal; +import java.nio.file.attribute.PosixFileAttributes; +import java.nio.file.attribute.PosixFilePermission; +import java.nio.file.attribute.UserPrincipal; +import java.time.Clock; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.OptionalInt; +import java.util.OptionalLong; +import java.util.Set; +import java.util.TreeMap; +import java.util.function.BiFunction; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +import com.fasterxml.jackson.core.StreamReadConstraints; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Splitter; +import com.google.common.base.Strings; +import com.google.common.base.Suppliers; +import com.google.common.collect.ImmutableMap; +import io.vertx.core.Vertx; +import io.vertx.core.VertxOptions; +import io.vertx.core.json.DecodeException; +import io.vertx.core.json.jackson.DatabindCodec; +import org.apache.logging.log4j.Level; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.core.LoggerContext; +import org.apache.logging.log4j.core.impl.Log4jContextFactory; +import org.apache.tuweni.bytes.Bytes; +import org.apache.tuweni.units.bigints.UInt256; +import org.slf4j.Logger; +import oshi.PlatformEnum; +import oshi.SystemInfo; +import picocli.AutoComplete; +import picocli.CommandLine; +import picocli.CommandLine.Command; +import picocli.CommandLine.ExecutionException; +import picocli.CommandLine.IExecutionStrategy; +import picocli.CommandLine.Model.ITypeInfo; +import picocli.CommandLine.Model.OptionSpec; +import picocli.CommandLine.Option; +import picocli.CommandLine.ParameterException; +import picocli.CommandLine.ParseResult; + +/** Represents the main Besu CLI command that runs the Besu Ethereum client full node. */ +@SuppressWarnings("FieldCanBeLocal") // because Picocli injected fields report false positives +@Command( + description = "This command runs the Besu Ethereum client full node.", + abbreviateSynopsis = true, + name = "besu", + mixinStandardHelpOptions = true, + versionProvider = VersionProvider.class, + header = "@|bold,fg(cyan) Usage:|@", + synopsisHeading = "%n", + descriptionHeading = "%n@|bold,fg(cyan) Description:|@%n%n", + optionListHeading = "%n@|bold,fg(cyan) Options:|@%n", + footerHeading = "%nBesu is licensed under the Apache License 2.0%n", + footer = { + "%n%n@|fg(cyan) To get started quickly, just choose a network to sync and a profile to run with suggested defaults:|@", + "%n@|fg(cyan) for Mainnet|@ --network=mainnet --profile=[minimalist_staker|staker]", + "%nMore info and other profiles at https://besu.hyperledger.org%n" + }) +public class BesuCommand implements DefaultCommandValues, Runnable { + @SuppressWarnings("PrivateStaticFinalLoggers") + // non-static for testing + private final Logger logger; + + private CommandLine commandLine; + + private final Supplier rlpBlockImporter; + private final Function jsonBlockImporterFactory; + private final Supplier era1BlockImporter; + private final Function rlpBlockExporterFactory; + private final BiFunction + era1BlockExporterFactory; + + // Unstable CLI options + final NetworkingOptions unstableNetworkingOptions = NetworkingOptions.create(); + final SynchronizerOptions unstableSynchronizerOptions = SynchronizerOptions.create(); + final EthProtocolOptions unstableEthProtocolOptions = EthProtocolOptions.create(); + private final DnsOptions unstableDnsOptions = DnsOptions.create(); + private final NatOptions unstableNatOptions = NatOptions.create(); + private final NativeLibraryOptions unstableNativeLibraryOptions = NativeLibraryOptions.create(); + private final RPCOptions unstableRPCOptions = RPCOptions.create(); + private final EvmOptions unstableEvmOptions = EvmOptions.create(); + private final IpcOptions unstableIpcOptions = IpcOptions.create(); + private final ChainPruningOptions unstableChainPruningOptions = ChainPruningOptions.create(); + private final QBFTOptions unstableQbftOptions = QBFTOptions.create(); + + /** + * AERE OPTIUNI-URGENTA (2026-08-02): the three post-quantum emergency controls, so that standing + * the anchor down is a RESTART and not a rebuild. See {@link AerePqEmergencyOptions}. + */ + private final AerePqEmergencyOptions unstableAerePqEmergencyOptions = + AerePqEmergencyOptions.create(); + + // stable CLI options + final DataStorageOptions dataStorageOptions = DataStorageOptions.create(); + private final EthstatsOptions ethstatsOptions = EthstatsOptions.create(); + private final NodePrivateKeyFileOption nodePrivateKeyFileOption = + NodePrivateKeyFileOption.create(); + private final LoggingLevelOption loggingLevelOption = LoggingLevelOption.create(); + + @CommandLine.ArgGroup(validate = false, heading = "@|bold Tx Pool Common Options|@%n") + final TransactionPoolOptions transactionPoolOptions = TransactionPoolOptions.create(); + + @CommandLine.ArgGroup(validate = false, heading = "@|bold Block Builder Options|@%n") + final MiningOptions miningOptions = MiningOptions.create(); + + private final RunnerBuilder runnerBuilder; + private final BesuController.Builder controllerBuilder; + private final BesuPluginContextImpl besuPluginContext; + private final StorageServiceImpl storageService; + private final SecurityModuleServiceImpl securityModuleService; + private final PermissioningServiceImpl permissioningService; + private final RpcEndpointServiceImpl rpcEndpointServiceImpl; + + private final Map environment; + private final MetricCategoryRegistryImpl metricCategoryRegistry = + new MetricCategoryRegistryImpl(); + + private final PreSynchronizationTaskRunner preSynchronizationTaskRunner = + new PreSynchronizationTaskRunner(); + + private final Set allocatedPorts = new HashSet<>(); + private Supplier genesisConfigSupplier = + Suppliers.memoize(this::readGenesisConfig); + + /** Memoized supplier for genesis configuration options. Protected to allow test access. */ + protected final Supplier genesisConfigOptionsSupplier = + Suppliers.memoize(this::readGenesisConfigOptions); + + private final Supplier miningParametersSupplier = + Suppliers.memoize(this::getMiningParameters); + private final Supplier apiConfigurationSupplier = + Suppliers.memoize(this::getApiConfiguration); + + private RocksDBPlugin rocksDBPlugin; + + private int maxPeers; + private int maxRemoteInitiatedPeers; + + // CLI options defined by user at runtime. + // Options parsing is done with CLI library Picocli https://picocli.info/ + + // While this variable is never read it is needed for the PicoCLI to create + // the config file option that is read elsewhere. + @SuppressWarnings("UnusedVariable") + @CommandLine.Option( + names = {CONFIG_FILE_OPTION_NAME}, + paramLabel = MANDATORY_FILE_FORMAT_HELP, + description = "TOML config file (default: none)") + private final File configFile = null; + + @CommandLine.Option( + names = {"--data-path"}, + paramLabel = MANDATORY_PATH_FORMAT_HELP, + description = "The path to Besu data directory (default: ${DEFAULT-VALUE})") + Path dataPath = getDefaultBesuDataPath(this); + + // Genesis file path with null default option. + // This default is handled by Runner + // to use mainnet json file from resources as indicated in the + // default network option + // Then we ignore genesis default value here. + @CommandLine.Option( + names = {"--genesis-file"}, + paramLabel = MANDATORY_FILE_FORMAT_HELP, + description = + "Genesis file for your custom network. Setting this option requires --network-id to be set. (Cannot be used with --network)") + private final File genesisFile = null; + + @Option( + names = {"--genesis-state-hash-cache-enabled"}, + description = + "Use genesis state hash from data on startup if specified (default: ${DEFAULT-VALUE})") + private final Boolean genesisStateHashCacheEnabled = false; + + @Option( + names = "--identity", + paramLabel = "", + description = "Identification for this node in the Client ID") + private final Optional identityString = Optional.empty(); + + private Boolean printPathsAndExit = Boolean.FALSE; + private String besuUserName = "besu"; + + @Option( + names = "--print-paths-and-exit", + paramLabel = "", + description = "Print the configured paths and exit without starting the node.", + arity = "0..1") + void setUserName(final String userName) { + PlatformEnum currentPlatform = SystemInfo.getCurrentPlatform(); + // Only allow on Linux and macOS + if (currentPlatform == PlatformEnum.LINUX || currentPlatform == PlatformEnum.MACOS) { + if (userName != null) { + besuUserName = userName; + } + printPathsAndExit = Boolean.TRUE; + } else { + throw new UnsupportedOperationException( + "--print-paths-and-exit is only supported on Linux and macOS."); + } + } + + // P2P Discovery Option Group + @CommandLine.ArgGroup(validate = false, heading = "@|bold P2P Discovery Options|@%n") + P2PDiscoveryOptions p2PDiscoveryOptions = new P2PDiscoveryOptions(); + + P2PDiscoveryConfiguration p2PDiscoveryConfig; + + private final TransactionSelectionServiceImpl transactionSelectionServiceImpl; + private final TransactionPoolValidatorServiceImpl transactionPoolValidatorServiceImpl; + private final TransactionValidatorServiceImpl transactionValidatorServiceImpl; + private final TransactionSimulationServiceImpl transactionSimulationServiceImpl; + private final BlockchainServiceImpl blockchainServiceImpl; + private BesuComponent besuComponent; + + private SyncMode syncMode = null; + + @Option( + names = {"--sync-mode"}, + paramLabel = MANDATORY_MODE_FORMAT_HELP, + description = + "Synchronization mode, possible values are ${COMPLETION-CANDIDATES} (default: SNAP if a --network is supplied. FULL otherwise.)") + void setSyncMode(final String value) { + final String normalized = value.toUpperCase(Locale.ROOT); + if ("CHECKPOINT".equals(normalized)) { + logger.warn( + "CHECKPOINT sync mode is deprecated and has been removed. " + + "Using SNAP sync mode instead. Your checkpoint configuration will be used automatically."); + this.syncMode = SyncMode.SNAP; + } else { + try { + this.syncMode = SyncMode.valueOf(normalized); + } catch (IllegalArgumentException e) { + throw new ParameterException( + this.commandLine, + "Invalid value for option '--sync-mode': '" + + value + + "' is not a valid sync mode. " + + "Valid values are: FULL, SNAP"); + } + } + } + + @Option( + names = "--sync-min-peers", + paramLabel = MANDATORY_INTEGER_FORMAT_HELP, + description = + "Minimum number of peers required before starting sync. Has effect only on non-PoS networks. (default: ${DEFAULT-VALUE})") + private final Integer syncMinPeerCount = SYNC_MIN_PEER_COUNT; + + private NetworkDefinition network = null; + + @Option( + names = {"--network"}, + paramLabel = MANDATORY_NETWORK_FORMAT_HELP, + defaultValue = "MAINNET", + description = + "Synchronize against the indicated network, possible values are ${COMPLETION-CANDIDATES}." + + " (default: ${DEFAULT-VALUE})") + void setNetwork(final String inputNetwork) { + // case-insensitive and (_,-)-insensitive + final var normalizedInputNetwork = inputNetwork.toLowerCase(Locale.ROOT).replace('-', '_'); + this.network = + Arrays.stream(NetworkDefinition.values()) + .filter(nd -> nd.name().toLowerCase(Locale.ROOT).equals(normalizedInputNetwork)) + .findAny() + .orElseThrow( + () -> + new IllegalArgumentException( + "Network %s does not exist".formatted(inputNetwork))); + } + + @Option( + names = {PROFILE_OPTION_NAME}, + paramLabel = PROFILE_FORMAT_HELP, + completionCandidates = ProfilesCompletionCandidates.class, + description = + "Overwrite default settings. Possible values are ${COMPLETION-CANDIDATES}. (default: none)") + private String profile = null; // don't set it as final due to picocli completion candidates + + @Option( + names = {"--nat-method"}, + description = + "Specify the NAT circumvention method to be used, possible values are ${COMPLETION-CANDIDATES}." + + " NONE disables NAT functionality. (default: ${DEFAULT-VALUE})") + private final NatMethod natMethod = DEFAULT_NAT_METHOD; + + @Option( + names = {"--network-id"}, + paramLabel = "", + description = + "P2P network identifier. (default: the selected network chain ID or custom genesis chain ID)") + private final BigInteger networkId = null; + + @Option( + names = {"--kzg-trusted-setup"}, + paramLabel = MANDATORY_FILE_FORMAT_HELP, + description = + "Path to file containing the KZG trusted setup, mandatory for custom networks that support data blobs, " + + "optional for overriding named networks default.") + private final Path kzgTrustedSetupFile = null; + + @Option( + names = {"--version-compatibility-protection"}, + description = + "Perform compatibility checks between the version of Besu being started and the version of Besu that last started with this data directory. (default: ${DEFAULT-VALUE})") + private Boolean versionCompatibilityProtection = null; + + @CommandLine.ArgGroup(validate = false, heading = "@|bold GraphQL Options|@%n") + GraphQlOptions graphQlOptions = new GraphQlOptions(); + + // Engine JSON-PRC Options + @CommandLine.ArgGroup(validate = false, heading = "@|bold Engine JSON-RPC Options|@%n") + EngineRPCOptions engineRPCOptions = new EngineRPCOptions(); + + EngineRPCConfiguration engineRPCConfig = engineRPCOptions.toDomainObject(); + + // JSON-RPC HTTP Options + @CommandLine.ArgGroup(validate = false, heading = "@|bold JSON-RPC HTTP Options|@%n") + JsonRpcHttpOptions jsonRpcHttpOptions = new JsonRpcHttpOptions(); + + // JSON-RPC Websocket Options + @CommandLine.ArgGroup(validate = false, heading = "@|bold JSON-RPC Websocket Options|@%n") + RpcWebsocketOptions rpcWebsocketOptions = new RpcWebsocketOptions(); + + // In-Process RPC Options + @CommandLine.ArgGroup(validate = false, heading = "@|bold In-Process RPC Options|@%n") + InProcessRpcOptions inProcessRpcOptions = InProcessRpcOptions.create(); + + // Metrics Option Group + @CommandLine.ArgGroup(validate = false, heading = "@|bold Metrics Options|@%n") + MetricsOptions metricsOptions = MetricsOptions.create(); + + @Option( + names = {"--host-allowlist"}, + paramLabel = "[,...]... or * or all", + description = + "Comma separated list of hostnames to allow for RPC access, or * to accept any host (default: ${DEFAULT-VALUE})", + defaultValue = "localhost,127.0.0.1") + private final JsonRPCAllowlistHostsProperty hostsAllowlist = new JsonRPCAllowlistHostsProperty(); + + @SuppressWarnings({"FieldCanBeFinal", "FieldMayBeFinal"}) + @Option( + names = {"--color-enabled"}, + description = + "Force color output to be enabled/disabled (default: colorized only if printing to console)") + private static Boolean colorEnabled = null; + + @Option( + names = {"--reorg-logging-threshold"}, + description = + "How deep a chain reorganization must be in order for it to be logged (default: ${DEFAULT-VALUE})") + private final Long reorgLoggingThreshold = 6L; + + // Permission Option Group + @CommandLine.ArgGroup(validate = false, heading = "@|bold Permissions Options|@%n") + PermissionsOptions permissionsOptions = new PermissionsOptions(); + + @Option( + names = {"--revert-reason-enabled"}, + description = + "Enable passing the revert reason back through TransactionReceipts (default: ${DEFAULT-VALUE})") + private final Boolean isRevertReasonEnabled = false; + + @Option( + names = {"--required-blocks", "--required-block"}, + paramLabel = "BLOCK=HASH", + description = "Block number and hash peers are required to have.", + arity = "*", + split = ",") + private final Map requiredBlocks = new HashMap<>(); + + @SuppressWarnings({"FieldCanBeFinal", "FieldMayBeFinal"}) // PicoCLI requires non-final Strings. + @Option( + names = {"--key-value-storage"}, + description = "Identity for the key-value storage to be used.") + private String keyValueStorageName = DEFAULT_KEY_VALUE_STORAGE_NAME; + + @SuppressWarnings({"FieldCanBeFinal", "FieldMayBeFinal"}) + @Option( + names = {"--security-module"}, + paramLabel = "", + description = "Identity for the Security Module to be used.") + private String securityModuleName = DEFAULT_SECURITY_MODULE; + + @Option( + names = {"--auto-log-bloom-caching-enabled"}, + description = "Enable automatic log bloom caching (default: ${DEFAULT-VALUE})", + arity = "1") + private final Boolean autoLogBloomCachingEnabled = true; + + @Option( + names = {"--override-genesis-config"}, + paramLabel = "NAME=VALUE", + description = "Overrides configuration values in the genesis file. Use with care.", + arity = "*", + hidden = true, + split = ",") + private final Map genesisConfigOverrides = + new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + + @CommandLine.Option( + names = {"--pid-path"}, + paramLabel = MANDATORY_PATH_FORMAT_HELP, + description = "Path to PID file (optional)") + private final Path pidPath = null; + + // API Configuration Option Group + @CommandLine.ArgGroup(validate = false, heading = "@|bold API Configuration Options|@%n") + ApiConfigurationOptions apiConfigurationOptions = new ApiConfigurationOptions(); + + @CommandLine.ArgGroup(validate = false, heading = "@|bold Block Access List Options|@%n") + BalConfigurationOptions balConfigurationOptions = new BalConfigurationOptions(); + + @CommandLine.Option( + names = {"--static-nodes-file"}, + paramLabel = MANDATORY_FILE_FORMAT_HELP, + description = + "Specifies the static node file containing the static nodes for this node to connect to") + private final Path staticNodesFile = null; + + @CommandLine.Option( + names = {"--cache-last-blocks"}, + description = "Specifies the number of last blocks to cache (default: ${DEFAULT-VALUE})") + private final Integer numberOfBlocksToCache = 0; + + @CommandLine.Option( + names = {"--cache-last-block-headers"}, + description = + "Specifies the number of last block headers to cache (default: ${DEFAULT-VALUE})") + private final Integer numberOfBlockHeadersToCache = 0; + + @CommandLine.Option( + names = {"--cache-last-block-headers-preload-enabled"}, + description = "Enable preloading of the block header cache (default: ${DEFAULT-VALUE})") + private final Boolean isCacheLastBlockHeadersPreloadEnabled = false; + + @CommandLine.Option( + names = {"--cache-precompiles"}, + description = "Specifies whether to cache precompile results (default: ${DEFAULT-VALUE})") + private final Boolean enablePrecompileCaching = false; + + // Plugins Configuration Option Group + @CommandLine.ArgGroup(validate = false) + PluginsConfigurationOptions pluginsConfigurationOptions = new PluginsConfigurationOptions(); + + private EthNetworkConfig ethNetworkConfig; + private JsonRpcConfiguration jsonRpcConfiguration; + private JsonRpcConfiguration engineJsonRpcConfiguration; + private GraphQLConfiguration graphQLConfiguration; + private WebSocketConfiguration webSocketConfiguration; + private JsonRpcIpcConfiguration jsonRpcIpcConfiguration; + private InProcessRpcConfiguration inProcessRpcConfiguration; + private MetricsConfiguration metricsConfiguration; + private Optional permissioningConfiguration; + private DataStorageConfiguration dataStorageConfiguration; + private Collection staticNodes; + private BesuController besuController; + private BesuConfigurationImpl pluginCommonConfiguration; + + private Vertx vertx; + private Runner runner; + private EnodeDnsConfiguration enodeDnsConfiguration; + private KeyValueStorageProvider keyValueStorageProvider; + private KeyPair keyPair; + private BigInteger ephemeryNextCycleId = BigInteger.ZERO; + + /** + * Besu command constructor. + * + * @param rlpBlockImporter RlpBlockImporter supplier + * @param jsonBlockImporterFactory instance of {@code Function} + * @param era1BlockImporter Era1BlockImporter supplier + * @param rlpBlockExporterFactory instance of {@code Function} + * @param era1BlockExporterFactory instance of {@code Function} + * @param runnerBuilder instance of RunnerBuilder + * @param controllerBuilder instance of BesuController.Builder + * @param besuPluginContext instance of BesuPluginContextImpl + * @param environment Environment variables map + * @param commandLogger instance of Logger for outputting to the CLI + */ + public BesuCommand( + final Supplier rlpBlockImporter, + final Function jsonBlockImporterFactory, + final Supplier era1BlockImporter, + final Function rlpBlockExporterFactory, + final BiFunction era1BlockExporterFactory, + final RunnerBuilder runnerBuilder, + final BesuController.Builder controllerBuilder, + final BesuPluginContextImpl besuPluginContext, + final Map environment, + final Logger commandLogger) { + this( + rlpBlockImporter, + jsonBlockImporterFactory, + era1BlockImporter, + rlpBlockExporterFactory, + era1BlockExporterFactory, + runnerBuilder, + controllerBuilder, + besuPluginContext, + environment, + new StorageServiceImpl(), + new SecurityModuleServiceImpl(), + new PermissioningServiceImpl(), + new RpcEndpointServiceImpl(), + new TransactionSelectionServiceImpl(), + new TransactionPoolValidatorServiceImpl(), + new TransactionSimulationServiceImpl(), + new BlockchainServiceImpl(), + new TransactionValidatorServiceImpl(), + commandLogger); + } + + /** + * Overloaded Besu command constructor visible for testing. + * + * @param rlpBlockImporter RlpBlockImporter supplier + * @param jsonBlockImporterFactory instance of {@code Function} + * @param era1BlockImporter Era1BlockImporter supplier + * @param rlpBlockExporterFactory instance of {@code Function} + * @param era1BlockExporterFactory instance of {@code Function} + * @param runnerBuilder instance of RunnerBuilder + * @param controllerBuilder instance of BesuController.Builder + * @param besuPluginContext instance of BesuPluginContextImpl + * @param environment Environment variables map + * @param storageService instance of StorageServiceImpl + * @param securityModuleService instance of SecurityModuleServiceImpl + * @param permissioningService instance of PermissioningServiceImpl + * @param rpcEndpointServiceImpl instance of RpcEndpointServiceImpl + * @param transactionSelectionServiceImpl instance of TransactionSelectionServiceImpl + * @param transactionPoolValidatorServiceImpl instance of TransactionPoolValidatorServiceImpl + * @param transactionSimulationServiceImpl instance of TransactionSimulationServiceImpl + * @param blockchainServiceImpl instance of BlockchainServiceImpl + * @param transactionValidatorServiceImpl instance of TransactionValidatorServiceImpl + * @param commandLogger instance of Logger for outputting to the CLI + */ + @VisibleForTesting + protected BesuCommand( + final Supplier rlpBlockImporter, + final Function jsonBlockImporterFactory, + final Supplier era1BlockImporter, + final Function rlpBlockExporterFactory, + final BiFunction era1BlockExporterFactory, + final RunnerBuilder runnerBuilder, + final BesuController.Builder controllerBuilder, + final BesuPluginContextImpl besuPluginContext, + final Map environment, + final StorageServiceImpl storageService, + final SecurityModuleServiceImpl securityModuleService, + final PermissioningServiceImpl permissioningService, + final RpcEndpointServiceImpl rpcEndpointServiceImpl, + final TransactionSelectionServiceImpl transactionSelectionServiceImpl, + final TransactionPoolValidatorServiceImpl transactionPoolValidatorServiceImpl, + final TransactionSimulationServiceImpl transactionSimulationServiceImpl, + final BlockchainServiceImpl blockchainServiceImpl, + final TransactionValidatorServiceImpl transactionValidatorServiceImpl, + final Logger commandLogger) { + + this.logger = commandLogger; + this.rlpBlockImporter = rlpBlockImporter; + this.jsonBlockImporterFactory = jsonBlockImporterFactory; + this.era1BlockImporter = era1BlockImporter; + this.rlpBlockExporterFactory = rlpBlockExporterFactory; + this.era1BlockExporterFactory = era1BlockExporterFactory; + this.runnerBuilder = runnerBuilder; + this.controllerBuilder = controllerBuilder; + this.besuPluginContext = besuPluginContext; + this.environment = environment; + this.storageService = storageService; + this.securityModuleService = securityModuleService; + this.permissioningService = permissioningService; + if (besuPluginContext.getService(BesuConfigurationImpl.class).isPresent()) { + this.pluginCommonConfiguration = + besuPluginContext.getService(BesuConfigurationImpl.class).get(); + } else { + this.pluginCommonConfiguration = new BesuConfigurationImpl(); + besuPluginContext.addService(BesuConfiguration.class, this.pluginCommonConfiguration); + } + this.rpcEndpointServiceImpl = rpcEndpointServiceImpl; + this.transactionSelectionServiceImpl = transactionSelectionServiceImpl; + this.transactionPoolValidatorServiceImpl = transactionPoolValidatorServiceImpl; + this.transactionSimulationServiceImpl = transactionSimulationServiceImpl; + this.blockchainServiceImpl = blockchainServiceImpl; + this.transactionValidatorServiceImpl = transactionValidatorServiceImpl; + } + + /** + * Parses command line arguments and configures the application accordingly. + * + * @param resultHandler The strategy to handle the execution result. + * @param parameterExceptionHandler Handler for exceptions related to command line parameters. + * @param executionExceptionHandler Handler for exceptions during command execution. + * @param in The input stream for commands. + * @param besuComponent The Besu component. + * @param args The command line arguments. + * @return The execution result status code. + */ + @VisibleForTesting + public int parse( + final IExecutionStrategy resultHandler, + final BesuParameterExceptionHandler parameterExceptionHandler, + final BesuExecutionExceptionHandler executionExceptionHandler, + final InputStream in, + final BesuComponent besuComponent, + final String... args) { + if (besuComponent == null) { + throw new IllegalArgumentException("BesuComponent must be provided"); + } + this.besuComponent = besuComponent; + initializeCommandLineSettings(in); + + // Create the execution strategy chain. + final IExecutionStrategy executeTask = createExecuteTask(resultHandler); + final IExecutionStrategy pluginRegistrationTask = createPluginRegistrationTask(executeTask); + final IExecutionStrategy setDefaultValueProviderTask = + createDefaultValueProviderTask(pluginRegistrationTask); + + // 1- Config default value provider + // 2- Register plugins + // 3- Execute command + return executeCommandLine( + setDefaultValueProviderTask, parameterExceptionHandler, executionExceptionHandler, args); + } + + private void initializeCommandLineSettings(final InputStream in) { + toCommandLine(); + // Automatically adjust the width of usage messages to the terminal width. + commandLine.getCommandSpec().usageMessage().autoWidth(true); + + handleStableOptions(); + addSubCommands(in); + registerConverters(); + handleUnstableOptions(); + preparePlugins(); + } + + private IExecutionStrategy createExecuteTask(final IExecutionStrategy nextStep) { + return parseResult -> { + commandLine.setExecutionStrategy(nextStep); + // At this point we don't allow unmatched options since plugins were already registered + commandLine.setUnmatchedArgumentsAllowed(false); + return commandLine.execute(parseResult.originalArgs().toArray(new String[0])); + }; + } + + private IExecutionStrategy createPluginRegistrationTask(final IExecutionStrategy nextStep) { + return parseResult -> { + if (parseResult.isUsageHelpRequested() || parseResult.isVersionHelpRequested()) { + // suppressing the info log to avoid that plugin registrations logs are printed + // before the help or the version information + suppressInfoLog(); + } + besuPluginContext.initialize(PluginsConfigurationOptions.fromCommandLine(commandLine)); + besuPluginContext.registerPlugins(); + commandLine.setExecutionStrategy(nextStep); + return commandLine.execute(parseResult.originalArgs().toArray(new String[0])); + }; + } + + @SuppressWarnings("BannedMethod") + private void suppressInfoLog() { + // this is specific for Log4j2, in case we switch to another logging framework, + // this need to be adapted for it + + // silence already created loggers + LoggerContext.getContext(false).getLoggers().forEach(logger -> logger.setLevel(Level.WARN)); + + // silence future loggers by configuration + if (LogManager.getFactory() instanceof Log4jContextFactory log4jContextFactory) { + final var selector = log4jContextFactory.getSelector(); + selector + .getLoggerContexts() + .forEach( + ctx -> + ctx.getConfiguration() + .getLoggers() + .values() + .forEach(loggerConfig -> loggerConfig.setLevel(Level.WARN))); + } + } + + private IExecutionStrategy createDefaultValueProviderTask(final IExecutionStrategy nextStep) { + return new ConfigDefaultValueProviderStrategy(nextStep, environment); + } + + /** + * Executes the command line with the provided execution strategy and exception handlers. + * + * @param executionStrategy The execution strategy to use. + * @param args The command line arguments. + * @return The execution result status code. + */ + private int executeCommandLine( + final IExecutionStrategy executionStrategy, + final BesuParameterExceptionHandler parameterExceptionHandler, + final BesuExecutionExceptionHandler executionExceptionHandler, + final String... args) { + + try { + // Parse and run duplicate-check + // As this happens before the plugins registration and plugins can add options, we must + // allow unmatched options + final ParseResult pr = commandLine.setUnmatchedArgumentsAllowed(true).parseArgs(args); + rejectDuplicateScalarOptions(pr); // your generic validator + } catch (ParameterException e) { + // ← Send it to the standard handler: prints one line & exits status 1 + return parameterExceptionHandler.handleParseException(e, args); + } + return commandLine + .setExecutionStrategy(executionStrategy) + .setParameterExceptionHandler(parameterExceptionHandler) + .setExecutionExceptionHandler(executionExceptionHandler) + // As this happens before the plugins registration and plugins can add options, we must + // allow unmatched options + .setUnmatchedArgumentsAllowed(true) + .execute(args); + } + + /** Used by Dagger to parse all options into a commandline instance. */ + public void toCommandLine() { + commandLine = + new CommandLine(this, new BesuCommandCustomFactory(besuPluginContext)) + .setCaseInsensitiveEnumValuesAllowed(true) + .setToggleBooleanFlags(false); + } + + @Override + public void run() { + if (network != null && network.isDeprecated()) { + logger.warn(NetworkDeprecationMessage.generate(network)); + } + try { + configureLogging(true); + + if (printPathsAndExit) { + // Print configured paths requiring read/write permissions to be adjusted + checkPermissionsAndPrintPaths(besuUserName); + System.exit(0); // Exit before any services are started + } + + logger.info("Starting Besu"); + + // AERE OPTIUNI-URGENTA (2026-08-02): apply the post-quantum emergency controls FIRST, before + // anything that reads them. FalconSealSupport runs its guards in its constructor and + // PqAnchorConfig is resolved while the protocol schedule is assembled, both of which happen + // inside buildController() further down. This is also the earliest point at which logging is + // configured, so the banner lands where an operator will see it. + unstableAerePqEmergencyOptions.applyAndAnnounce(); + + // set merge config on the basis of genesis config + setMergeConfigOptions(); + + instantiateSignatureAlgorithmFactory(); + + // Need to create vertx after cmdline has been parsed, such that metricsSystem is configurable + vertx = createVertx(besuComponent.getMetricsSystem()); + + validateOptions(); + + initialProcess(); + + if (network.equals(EPHEMERY)) { + long lastGenesisTimestamp = parseLong(genesisConfigOverrides.get("timestamp")); + runner.scheduleEphemeryRestart(this, lastGenesisTimestamp); + } + runner.awaitStop(); + + } catch (final Exception e) { + logger.error("Failed to start Besu: {}", e.getMessage()); + logger.debug("Startup failure cause", e); + throw new ParameterException(this.commandLine, e.getMessage(), e); + } + } + + /** + * contains the initial setup and configurations required for Ephemery restart cycles. + * + * @throws Exception if startup fails + */ + public void initialProcess() throws Exception { + if (network.equals(EPHEMERY)) { + genesisConfigSupplier = Suppliers.memoize(this::readGenesisConfig); + if (BigInteger.ZERO.equals(ephemeryNextCycleId)) { + ephemeryNextCycleId = genesisConfigSupplier.get().getConfigOptions().getChainId().get(); + } + dataPath = dataPath.resolve("Ephemery-data-chain-" + ephemeryNextCycleId); + ephemeryNextCycleId = ephemeryNextCycleId.add(BigInteger.ONE); + } + + configure(); + + setIgnorableStorageSegments(); + + // If we're not running against a named network, or if version compat protection has been + // explicitly enabled, perform compatibility check + VersionMetadata.versionCompatibilityChecks(versionCompatibilityProtection, dataDir()); + + configureNativeLibs(Optional.ofNullable(network)); + if (enablePrecompileCaching) { + configurePrecompileCaching(); + } + + besuController = buildController(); + + besuPluginContext.beforeExternalServices(); + + runner = buildRunner(); + runner.startExternalServices(); + + startPlugins(runner); + setReleaseMetrics(); + preSynchronization(); + + runner.startEthereumMainLoop(); + + besuPluginContext.afterExternalServicesMainLoop(); + } + + private void configurePrecompileCaching() { + // enable precompile caching: + AbstractPrecompiledContract.setPrecompileCaching(enablePrecompileCaching); + // separately set KZG precompile caching, it does not extend AbstractPrecompiledContract: + KZGPointEvalPrecompiledContract.setPrecompileCaching(enablePrecompileCaching); + // separately set BLS precompiles caching, they do not extend AbstractPrecompiledContract: + AbstractBLS12PrecompiledContract.setPrecompileCaching(enablePrecompileCaching); + + // set a metric logger + final var precompileCounter = + getMetricsSystem() + .createLabelledCounter( + BesuMetricCategory.BLOCK_PROCESSING, + "precompile_cache", + "precompile cache labeled counter", + "precompile_name", + "event"); + + // set a cache event consumer which logs a metrics event + AbstractPrecompiledContract.setCacheEventConsumer( + cacheEvent -> + precompileCounter + .labels(cacheEvent.precompile(), cacheEvent.cacheMetric().name()) + .inc()); + } + + /** Reject any option that is not multi-valued but appears more than once. */ + private static void rejectDuplicateScalarOptions(final ParseResult pr) { + for (OptionSpec spec : pr.matchedOptions()) { + + // skip help/version flags + if (spec.usageHelp() || spec.versionHelp()) { + continue; + } + + // ── determine if this option can repeat + ITypeInfo type = spec.typeInfo(); + boolean multiValued = + spec.arity().max() > 1 || type.isMultiValue() || type.isCollection() || type.isArray(); + + if (multiValued) { + continue; // lists are allowed to repeat + } + + // ── single-valued: abort if it appears more than once ─────────── + if (pr.matchedOption(spec.longestName()).stringValues().size() > 1) { + throw new ParameterException( + pr.commandSpec().commandLine(), + String.format("Option '%s' should be specified only once", spec.longestName())); + } + } + } + + private void checkPermissionsAndPrintPaths(final String userName) { + // Check permissions for the data path + checkPermissions(dataDir(), userName, false); + + // Check permissions for genesis file + try { + if (genesisFile != null) { + checkPermissions(genesisFile.toPath(), userName, true); + } + } catch (Exception e) { + commandLine + .getOut() + .println("Error: Failed checking genesis file: Reason: " + e.getMessage()); + } + } + + // Helper method to check permissions on a given path + private void checkPermissions(final Path path, final String besuUser, final boolean readOnly) { + try { + // Get the permissions of the file + // check if besu user is the owner - get owner permissions if yes + // else, check if besu user and owner are in the same group - if yes, check the group + // permission + // otherwise check permissions for others + + // Get the owner of the file or directory + UserPrincipal owner = Files.getOwner(path); + boolean hasReadPermission, hasWritePermission; + + // Get file permissions + Set permissions = Files.getPosixFilePermissions(path); + + // Check if besu is the owner + if (owner.getName().equals(besuUser)) { + // Owner permissions + hasReadPermission = permissions.contains(PosixFilePermission.OWNER_READ); + hasWritePermission = permissions.contains(PosixFilePermission.OWNER_WRITE); + } else { + // Get the group of the file + // Get POSIX file attributes and then group + PosixFileAttributes attrs = Files.readAttributes(path, PosixFileAttributes.class); + GroupPrincipal group = attrs.group(); + + // Check if besu user belongs to this group + boolean isMember = isGroupMember(besuUserName, group); + + if (isMember) { + // Group's permissions + hasReadPermission = permissions.contains(PosixFilePermission.GROUP_READ); + hasWritePermission = permissions.contains(PosixFilePermission.GROUP_WRITE); + } else { + // Others' permissions + hasReadPermission = permissions.contains(PosixFilePermission.OTHERS_READ); + hasWritePermission = permissions.contains(PosixFilePermission.OTHERS_WRITE); + } + } + + if (!hasReadPermission || (!readOnly && !hasWritePermission)) { + String accessType = readOnly ? "READ" : "READ_WRITE"; + commandLine.getOut().println("PERMISSION_CHECK_PATH:" + path + ":" + accessType); + } + } catch (Exception e) { + // Do nothing upon catching an error + commandLine + .getOut() + .println( + "Error: Failed to check permissions for path: '" + + path + + "'. Reason: " + + e.getMessage()); + } + } + + private static boolean isGroupMember(final String userName, final GroupPrincipal group) + throws IOException { + // Get the groups of the user by executing 'id -Gn username' + Process process = Runtime.getRuntime().exec(new String[] {"id", "-Gn", userName}); + BufferedReader reader = + new BufferedReader(new InputStreamReader(process.getInputStream(), UTF_8)); + + // Read the output of the command + String line = reader.readLine(); + boolean isMember = false; + if (line != null) { + // Split the groups + Iterable userGroups = Splitter.on(" ").split(line); + // Check if any of the user's groups match the file's group + + for (String grp : userGroups) { + if (grp.equals(group.getName())) { + isMember = true; + break; + } + } + } + return isMember; + } + + @VisibleForTesting + void setBesuConfiguration(final BesuConfigurationImpl pluginCommonConfiguration) { + this.pluginCommonConfiguration = pluginCommonConfiguration; + } + + private void addSubCommands(final InputStream in) { + commandLine.addSubcommand( + BlocksSubCommand.COMMAND_NAME, + new BlocksSubCommand( + rlpBlockImporter, + jsonBlockImporterFactory, + era1BlockImporter, + rlpBlockExporterFactory, + era1BlockExporterFactory, + commandLine.getOut())); + commandLine.addSubcommand( + TxParseSubCommand.COMMAND_NAME, new TxParseSubCommand(commandLine.getOut())); + commandLine.addSubcommand( + PublicKeySubCommand.COMMAND_NAME, new PublicKeySubCommand(commandLine.getOut())); + commandLine.addSubcommand( + PasswordSubCommand.COMMAND_NAME, new PasswordSubCommand(commandLine.getOut())); + commandLine.addSubcommand( + RLPSubCommand.COMMAND_NAME, new RLPSubCommand(commandLine.getOut(), in)); + commandLine.addSubcommand( + OperatorSubCommand.COMMAND_NAME, new OperatorSubCommand(commandLine.getOut())); + commandLine.addSubcommand( + ValidateConfigSubCommand.COMMAND_NAME, + new ValidateConfigSubCommand(commandLine, commandLine.getOut())); + commandLine.addSubcommand( + StorageSubCommand.COMMAND_NAME, new StorageSubCommand(commandLine.getOut())); + final String generateCompletionSubcommandName = "generate-completion"; + commandLine.addSubcommand( + generateCompletionSubcommandName, AutoComplete.GenerateCompletion.class); + final CommandLine generateCompletionSubcommand = + commandLine.getSubcommands().get(generateCompletionSubcommandName); + generateCompletionSubcommand.getCommandSpec().usageMessage().hidden(true); + } + + private void registerConverters() { + commandLine.registerConverter(Address.class, Address::fromHexStringStrict); + commandLine.registerConverter(Bytes.class, Bytes::fromHexString); + commandLine.registerConverter(MetricsProtocol.class, MetricsProtocol::fromString); + commandLine.registerConverter(UInt256.class, (arg) -> UInt256.valueOf(new BigInteger(arg))); + commandLine.registerConverter(Wei.class, (arg) -> Wei.of(Long.parseUnsignedLong(arg))); + commandLine.registerConverter(PositiveNumber.class, PositiveNumber::fromString); + commandLine.registerConverter(Hash.class, Hash::fromHexString); + commandLine.registerConverter(Optional.class, Optional::of); + commandLine.registerConverter(Double.class, Double::parseDouble); + } + + private void handleStableOptions() { + commandLine.addMixin("Ethstats", ethstatsOptions); + commandLine.addMixin("Private key file", nodePrivateKeyFileOption); + commandLine.addMixin("Logging level", loggingLevelOption); + commandLine.addMixin("Data Storage Options", dataStorageOptions); + } + + private void handleUnstableOptions() { + // Add unstable options + final ImmutableMap.Builder unstableOptionsBuild = ImmutableMap.builder(); + final ImmutableMap unstableOptions = + unstableOptionsBuild + .put("Ethereum Wire Protocol", unstableEthProtocolOptions) + .put("P2P Network", unstableNetworkingOptions) + .put("RPC", unstableRPCOptions) + .put("DNS Configuration", unstableDnsOptions) + .put("NAT Configuration", unstableNatOptions) + .put("Synchronizer", unstableSynchronizerOptions) + .put("Native Library", unstableNativeLibraryOptions) + .put("EVM Options", unstableEvmOptions) + .put("IPC Options", unstableIpcOptions) + .put("Chain Data Pruning Options", unstableChainPruningOptions) + .put("QBFT Options", unstableQbftOptions) + // AERE OPTIUNI-URGENTA: registered here so `besu --Xhelp` LISTS them. An emergency + // control nobody can find at three in the morning is not a control. + .put("AERE post-quantum emergency controls", unstableAerePqEmergencyOptions) + .build(); + + UnstableOptionsSubCommand.createUnstableOptions(commandLine, unstableOptions); + } + + private void preparePlugins() { + besuPluginContext.addService(PicoCLIOptions.class, new PicoCLIOptionsImpl(commandLine)); + + metricCategoryRegistry.addCategories(BesuMetricCategory.class); + metricCategoryRegistry.addCategories(StandardMetricCategory.class); + + BesuPluginServiceRegistrar.registerEarlyServices( + besuPluginContext, + securityModuleService, + storageService, + metricCategoryRegistry, + permissioningService, + rpcEndpointServiceImpl, + transactionSelectionServiceImpl, + transactionPoolValidatorServiceImpl, + transactionSimulationServiceImpl, + blockchainServiceImpl, + transactionValidatorServiceImpl); + + // register built-in plugins + rocksDBPlugin = new RocksDBPlugin(); + rocksDBPlugin.register(besuPluginContext); + new InMemoryStoragePlugin().register(besuPluginContext); + + // register default security module + securityModuleService.register( + DEFAULT_SECURITY_MODULE, Suppliers.memoize(this::defaultSecurityModule)); + } + + private SecurityModule defaultSecurityModule() { + keyPair = loadKeyPair(nodePrivateKeyFileOption.getNodePrivateKeyFile()); + return new KeyPairSecurityModule(keyPair); + } + + /** + * Load key pair from private key. Visible to be accessed by subcommands. + * + * @param nodePrivateKeyFile File containing private key + * @return KeyPair loaded from private key file + */ + public KeyPair loadKeyPair(final File nodePrivateKeyFile) { + return KeyPairUtil.loadKeyPair(resolveNodePrivateKeyFile(nodePrivateKeyFile)); + } + + private void preSynchronization() { + preSynchronizationTaskRunner.runTasks(besuController); + } + + Runner buildRunner() { + return synchronize( + besuController, + p2PDiscoveryConfig.p2pEnabled(), + p2PDiscoveryConfig.peerDiscoveryEnabled(), + ethNetworkConfig, + p2PDiscoveryConfig.p2pHost(), + p2PDiscoveryConfig.p2pInterface(), + p2PDiscoveryConfig.p2pPort(), + graphQLConfiguration, + jsonRpcConfiguration, + engineJsonRpcConfiguration, + webSocketConfiguration, + jsonRpcIpcConfiguration, + inProcessRpcConfiguration, + apiConfigurationSupplier.get(), + metricsConfiguration, + permissioningConfiguration, + staticNodes, + pidPath); + } + + private void startPlugins(final Runner runner) { + blockchainServiceImpl.init( + besuController.getProtocolContext().getBlockchain(), besuController.getProtocolSchedule()); + transactionSimulationServiceImpl.init( + besuController.getProtocolContext().getBlockchain(), + besuController.getTransactionSimulator()); + rpcEndpointServiceImpl.init(runner.getInProcessRpcMethods()); + + BesuPluginServiceRegistrar.registerRuntimeServices( + besuPluginContext, + besuController, + runner, + getMetricsSystem(), + miningParametersSupplier.get()); + + besuPluginContext.startPlugins(); + } + + private void setReleaseMetrics() { + besuComponent + .getMetricsSystem() + .createLabelledSuppliedGauge( + StandardMetricCategory.PROCESS, "release", "Release information", "version") + .labels(() -> 1, BesuVersionUtils.version()); + } + + /** + * Configure logging framework for Besu + * + * @param announce sets to true to print the logging level on standard output + */ + public void configureLogging(final boolean announce) { + // To change the configuration if color was enabled/disabled + LogConfigurator.reconfigure(); + // set log level per CLI flags + final String logLevel = loggingLevelOption.getLogLevel(); + if (logLevel != null) { + if (announce) { + System.out.println("Setting logging level to " + logLevel); + } + LogConfigurator.setLevel("", logLevel); + } + } + + /** + * Logging in Color enabled or not. + * + * @return Optional true or false representing logging color is enabled. Empty if not set. + */ + public static Optional getColorEnabled() { + return Optional.ofNullable(colorEnabled); + } + + @VisibleForTesting + void configureNativeLibs(final Optional configuredNetwork) { + if (unstableNativeLibraryOptions.getNativeAltbn128() + && AbstractAltBnPrecompiledContract.maybeEnableNative()) { + logger.info("Using the native implementation of alt bn128"); + } else { + AbstractAltBnPrecompiledContract.disableNative(); + logger.info("Using the Java implementation of alt bn128"); + } + + if (unstableNativeLibraryOptions.getNativeModExp() + && BigIntegerModularExponentiationPrecompiledContract.maybeEnableNative()) { + logger.info("Using the native implementation of modexp"); + } else { + BigIntegerModularExponentiationPrecompiledContract.disableNative(); + logger.info("Using the Java implementation of modexp"); + } + + if (unstableNativeLibraryOptions.getNativeSecp() + && SignatureAlgorithmFactory.getInstance().maybeEnableNative()) { + logger.info("Using the native implementation of the signature algorithm"); + } else { + SignatureAlgorithmFactory.getInstance().disableNative(); + logger.info("Using the Java implementation of the signature algorithm"); + } + + if (unstableNativeLibraryOptions.getNativeBlake2bf() + && Blake2bfMessageDigest.Blake2bfDigest.isNative()) { + logger.info("Using the native implementation of the blake2bf algorithm"); + } else { + Blake2bfMessageDigest.Blake2bfDigest.disableNative(); + logger.info("Using the Java implementation of the blake2bf algorithm"); + } + + if (unstableNativeLibraryOptions.getNativeP256Verify() + && P256VerifyPrecompiledContract.maybeEnableNativeBoringSSL()) { + logger.info("Using the native BoringSSL implementation of p256verify"); + } else { + P256VerifyPrecompiledContract.disableNativeBoringSSL(); + if (SECP256R1.isNativeAvailable()) { + logger.info("Using the native secp256r1 signature algorithm implementation of p256verify"); + } else { + logger.info("Using the Java secp256r1 implementation of p256verify"); + } + } + + if (hasKzgFork(readGenesisConfigOptions())) { + if (kzgTrustedSetupFile != null) { + KZGPointEvalPrecompiledContract.init(kzgTrustedSetupFile); + } else { + KZGPointEvalPrecompiledContract.init(); + } + } else if (kzgTrustedSetupFile != null) { + throw new ParameterException( + this.commandLine, + "--kzg-trusted-setup can only be specified on networks with data blobs enabled"); + } + // assert required native libraries have been loaded + if (genesisFile == null && configuredNetwork.isPresent()) { + checkRequiredNativeLibraries(configuredNetwork.get()); + } + } + + @VisibleForTesting + void checkRequiredNativeLibraries(final NetworkDefinition configuredNetwork) { + if (configuredNetwork == null) { + return; + } + + // assert native library requirements for named networks: + List failedNativeReqs = + NativeRequirement.getNativeRequirements(configuredNetwork).stream() + .filter(r -> !r.present()) + .toList(); + + if (!failedNativeReqs.isEmpty()) { + String failures = + failedNativeReqs.stream() + .map(r -> r.libname() + " " + r.errorMessage()) + .collect(Collectors.joining("\n\t")); + throw new UnsupportedOperationException( + String.format( + "Failed to load required native libraries for network %s. " + + "Verify whether your platform %s and arch %s are supported by besu. " + + "Failures loading: \n%s", + configuredNetwork.name(), + System.getProperty("os.name"), + System.getProperty("os.arch"), + failures)); + } + } + + private void validateOptions() { + issueOptionWarnings(); + validateP2POptions(); + validateMiningParams(); + validateNatParams(); + validateNetStatsParams(); + validateDnsOptionsParams(); + ensureValidPeerBoundParams(); + validateRpcOptionsParams(); + validateRpcWsOptions(); + validateChainDataPruningParams(); + validatePostMergeCheckpointBlockRequirements(); + validateTransactionPoolOptions(); + validateDataStorageOptions(); + validateGraphQlOptions(); + validatePluginOptions(); + validateUnstableNetworkingOptions(); + } + + private void validatePluginOptions() { + pluginsConfigurationOptions.validate(commandLine); + } + + private void validateUnstableNetworkingOptions() { + unstableNetworkingOptions.validate(commandLine); + } + + private void validateApiOptions() { + apiConfigurationOptions.validate(commandLine, logger); + } + + private void validateTransactionPoolOptions() { + transactionPoolOptions.validate(commandLine, genesisConfigOptionsSupplier.get()); + } + + private void validateDataStorageOptions() { + dataStorageOptions.validate(commandLine); + } + + private void validateMiningParams() { + miningOptions.validate( + commandLine, genesisConfigOptionsSupplier.get(), isMergeEnabled(), logger); + } + + private void validateP2POptions() { + p2PDiscoveryOptions.validate(commandLine, getNetworkInterfaceChecker()); + } + + /** + * Returns a network interface checker that can be used to validate P2P options. + * + * @return A {@link P2PDiscoveryOptions.NetworkInterfaceChecker} that checks if a network + * interface is available. + */ + protected P2PDiscoveryOptions.NetworkInterfaceChecker getNetworkInterfaceChecker() { + return NetworkUtility::isNetworkInterfaceAvailable; + } + + private void validateGraphQlOptions() { + graphQlOptions.validate(logger, commandLine); + } + + @SuppressWarnings("ConstantConditions") + private void validateNatParams() { + if (natMethod.equals(NatMethod.AUTO) && !unstableNatOptions.getNatMethodFallbackEnabled()) { + throw new ParameterException( + this.commandLine, + "The `--Xnat-method-fallback-enabled` parameter cannot be used in AUTO mode. Either remove --Xnat-method-fallback-enabled" + + " or select another mode (via --nat--method=XXXX)"); + } + } + + private void validateNetStatsParams() { + if (Strings.isNullOrEmpty(ethstatsOptions.getEthstatsUrl()) + && !ethstatsOptions.getEthstatsContact().isEmpty()) { + throw new ParameterException( + this.commandLine, + "The `--ethstats-contact` requires ethstats server URL to be provided. Either remove --ethstats-contact" + + " or provide a URL (via --ethstats=nodename:secret@host:port)"); + } + } + + private void validateDnsOptionsParams() { + if (!unstableDnsOptions.getDnsEnabled() && unstableDnsOptions.getDnsUpdateEnabled()) { + throw new ParameterException( + this.commandLine, + "The `--Xdns-update-enabled` requires dns to be enabled. Either remove --Xdns-update-enabled" + + " or specify dns is enabled (--Xdns-enabled)"); + } + } + + private void ensureValidPeerBoundParams() { + maxPeers = p2PDiscoveryOptions.maxPeers; + final Boolean isLimitRemoteWireConnectionsEnabled = + p2PDiscoveryOptions.isLimitRemoteWireConnectionsEnabled; + if (isLimitRemoteWireConnectionsEnabled) { + final float fraction = + Fraction.fromPercentage(p2PDiscoveryOptions.maxRemoteConnectionsPercentage).getValue(); + checkState( + fraction >= 0.0 && fraction <= 1.0, + "Fraction of remote connections allowed must be between 0.0 and 1.0 (inclusive)."); + maxRemoteInitiatedPeers = Math.round(fraction * maxPeers); + } else { + maxRemoteInitiatedPeers = maxPeers; + } + } + + private void validateRpcOptionsParams() { + final Predicate configuredApis = + apiName -> + Arrays.stream(RpcApis.values()) + .anyMatch(builtInApi -> apiName.equals(builtInApi.name())) + || rpcEndpointServiceImpl.hasNamespace(apiName); + jsonRpcHttpOptions.validate(logger, commandLine, configuredApis); + } + + private void validateRpcWsOptions() { + final Predicate configuredApis = + apiName -> + Arrays.stream(RpcApis.values()) + .anyMatch(builtInApi -> apiName.equals(builtInApi.name())) + || rpcEndpointServiceImpl.hasNamespace(apiName); + rpcWebsocketOptions.validate(logger, commandLine, configuredApis); + } + + private void validateChainDataPruningParams() { + final ChainPruningStrategy chainPruningStrategy = + unstableChainPruningOptions.getChainPruningStrategy(); + final long blocksRetained = unstableChainPruningOptions.getChainDataPruningBlocksRetained(); + final long balsRetained = unstableChainPruningOptions.getChainDataPruningBalsRetained(); + final long retainedMinimum = unstableChainPruningOptions.getChainDataPruningRetainedMinimum(); + + // Skip validation if pruning is disabled + if (chainPruningStrategy == ChainPruningStrategy.NONE) { + return; + } + + // Basic validation + if (blocksRetained < 0) { + throw new ParameterException( + this.commandLine, "--Xchain-pruning-blocks-retained must be >= 0"); + } + if (balsRetained < 0) { + throw new ParameterException(this.commandLine, "--Xchain-pruning-bals-retained must be >= 0"); + } + if (retainedMinimum < 0) { + throw new ParameterException( + this.commandLine, "--Xchain-pruning-retained-minimum must be >= 0"); + } + + // Validate blocks pruning (when mode is ALL) + if (chainPruningStrategy == ChainPruningStrategy.ALL) { + if (blocksRetained < retainedMinimum) { + throw new ParameterException( + this.commandLine, "--Xchain-pruning-blocks-retained must be >= " + retainedMinimum); + } + + final GenesisConfigOptions genesisConfigOptions = readGenesisConfigOptions(); + if (genesisConfigOptions.isPoa()) { + final var epochLengthOpt = getPoaEpochLength(genesisConfigOptions); + if (epochLengthOpt.isPresent()) { + final long epochLength = epochLengthOpt.getAsLong(); + if (blocksRetained < epochLength) { + throw new ParameterException( + this.commandLine, + String.format( + "--Xchain-pruning-blocks-retained(%d) must be >= epochlength(%d) for %s", + blocksRetained, epochLength, getConsensusMechanism(genesisConfigOptions))); + } + } + } + } + + // Validate BAL pruning (when mode is BAL or ALL) + if (chainPruningStrategy == ChainPruningStrategy.BAL + || chainPruningStrategy == ChainPruningStrategy.ALL) { + if (balsRetained < retainedMinimum) { + throw new ParameterException( + this.commandLine, "--Xchain-pruning-bals-retained must be >= " + retainedMinimum); + } + + // When both are enabled (ALL mode), BAL retention can't exceed block retention + if (chainPruningStrategy == ChainPruningStrategy.ALL && balsRetained > blocksRetained) { + throw new ParameterException( + this.commandLine, + "--Xchain-pruning-bals-retained must be <= --Xchain-pruning-blocks-retained when pruning mode is ALL"); + } + } + } + + private GenesisConfig readGenesisConfig() { + GenesisConfig effectiveGenesisFile; + effectiveGenesisFile = + network.equals(EPHEMERY) + ? EphemeryGenesisUpdater.updateGenesis(genesisConfigOverrides) + : genesisFile != null + ? GenesisConfig.fromConfig(loadAndTransformGenesisFile(genesisFile)) + : GenesisConfig.fromResource( + Optional.ofNullable(network).orElse(MAINNET).getGenesisFile()); + return effectiveGenesisFile.withOverrides(genesisConfigOverrides); + } + + private GenesisConfigOptions readGenesisConfigOptions() { + try { + return genesisConfigSupplier.get().getConfigOptions(); + } catch (final Exception e) { + throw new ParameterException( + this.commandLine, "Unable to load genesis file. " + e.getCause()); + } + } + + /** + * Loads a genesis file from File and applies Geth-to-Besu transformation if needed. + * + * @param genesisFile the genesis file + * @return the loaded and potentially transformed ObjectNode + */ + private ObjectNode loadAndTransformGenesisFile(final File genesisFile) { + try { + final URL url = genesisFile.toURI().toURL(); + final ObjectNode genesisRoot = JsonUtil.objectNodeFromURL(url, false); + + // Check if this is a Geth format genesis file and transform if needed + if (isGethFormat(genesisRoot)) { + transformGethToBesu(genesisRoot); + } + + return genesisRoot; + } catch (final Exception e) { + // Extract the root cause for better error reporting + Throwable rootCause = e; + while (rootCause.getCause() != null && rootCause.getCause() != rootCause) { + rootCause = rootCause.getCause(); + } + throw new RuntimeException("Unable to load genesis file: " + genesisFile, rootCause); + } + } + + /** + * Detects if a genesis file is in Geth format. + * + *

A genesis file is considered Geth format if: + * + *

    + *
  • It has a "config" section + *
  • The config has a "mergeNetsplitBlock" field (Geth-specific) + *
  • The config does NOT have an "ethash" field (Besu-specific) + *
+ * + * @param genesisRoot the root genesis JSON node + * @return true if this is a Geth format genesis file + */ + private boolean isGethFormat(final ObjectNode genesisRoot) { + final Optional configNode = JsonUtil.getObjectNode(genesisRoot, "config"); + if (!configNode.isPresent()) { + return false; + } + + final ObjectNode config = configNode.get(); + final boolean hasMergeNetsplitBlock = config.has("mergeNetsplitBlock"); + final boolean hasEthash = config.has("ethash"); + + // It's Geth format if it has mergeNetsplitBlock but not ethash + return hasMergeNetsplitBlock && !hasEthash; + } + + /** + * Transforms a Geth-format genesis file to Besu format by applying five transformations. + * + *

Transformations applied: + * + *

    + *
  1. Add ethash field: Besu's {@code isEthHash()} method checks for the presence of + * this field in the JSON structure. Since this is a structural check, the overrides + * mechanism doesn't work - we must add it to the JSON. + *
  2. Map mergeNetsplitBlock to preMergeForkBlock: These fields serve identical purposes + * (marking the merge activation block) but use different names in Geth vs Besu. + *
  3. Add baseFeePerGas: When London fork is activated at genesis (block 0), Besu + * expects an explicit base fee. Geth may omit this field, so we add the standard default of + * 1 gwei (0x3B9ACA00). + *
  4. Add withdrawalRequestContractAddress: EIP-7002 withdrawal request contract address + * if missing. + *
  5. Add consolidationRequestContractAddress: EIP-7251 consolidation request contract + * address if missing. + *
+ * + * @param genesisRoot the root genesis JSON node (will be modified in place) + */ + private void transformGethToBesu(final ObjectNode genesisRoot) { + final Optional configNode = JsonUtil.getObjectNode(genesisRoot, "config"); + if (!configNode.isPresent()) { + return; + } + + final ObjectNode config = configNode.get(); + + // Add ethash field if not present + if (!config.has("ethash")) { + config.set("ethash", JsonUtil.createEmptyObjectNode()); + } + + // Map mergeNetsplitBlock to preMergeForkBlock + if (config.has("mergeNetsplitBlock") && !config.has("preMergeForkBlock")) { + final long mergeBlock = config.get("mergeNetsplitBlock").asLong(); + config.put("preMergeForkBlock", mergeBlock); + } + + // Add baseFeePerGas if London is at genesis + if (!genesisRoot.has("baseFeePerGas") && config.has("londonBlock")) { + final long londonBlock = config.get("londonBlock").asLong(Long.MAX_VALUE); + if (londonBlock == 0) { + // Add default 1 gwei base fee + genesisRoot.put("baseFeePerGas", "0x3B9ACA00"); + } + } + + // Add withdrawalRequestContractAddress if missing (EIP-7002) + if (!config.has("withdrawalRequestContractAddress")) { + config.put("withdrawalRequestContractAddress", "0x00000961ef480eb55e80d19ad83579a64c007002"); + } + + // Add consolidationRequestContractAddress if missing (EIP-7251) + if (!config.has("consolidationRequestContractAddress")) { + config.put( + "consolidationRequestContractAddress", "0x0000bbddc7ce488642fb579f8b00f3a590007251"); + } + } + + /** + * Checks if the genesis configuration includes any fork times that require KZG initialization. + * This includes Cancun and all subsequent forks that use KZG commitments for EIP-4844 blob + * transactions. + * + * @param genesisConfigOptions the genesis config options + * @return true if any KZG-requiring fork time is present + */ + private boolean hasKzgFork(final GenesisConfigOptions genesisConfigOptions) { + return genesisConfigOptions.getCancunTime().isPresent() + || genesisConfigOptions.getPragueTime().isPresent() + || genesisConfigOptions.getOsakaTime().isPresent() + || genesisConfigOptions.getBpo1Time().isPresent() + || genesisConfigOptions.getBpo2Time().isPresent() + || genesisConfigOptions.getBpo3Time().isPresent() + || genesisConfigOptions.getBpo4Time().isPresent() + || genesisConfigOptions.getBpo5Time().isPresent() + || genesisConfigOptions.getAmsterdamTime().isPresent() + || genesisConfigOptions.getFutureEipsTime().isPresent(); + } + + /** + * Gets the block period in seconds based on the consensus mechanism. + * + * @param genesisConfigOptions the genesis config options + * @return the block period in seconds, or empty if not applicable + */ + private OptionalInt getBlockPeriodSeconds(final GenesisConfigOptions genesisConfigOptions) { + if (genesisConfigOptions.isIbft2()) { + return OptionalInt.of(genesisConfigOptions.getBftConfigOptions().getBlockPeriodSeconds()); + } + if (genesisConfigOptions.isQbft()) { + return OptionalInt.of(genesisConfigOptions.getQbftConfigOptions().getBlockPeriodSeconds()); + } + return OptionalInt.empty(); + } + + /** + * Gets the epoch length for PoA consensus mechanisms. + * + * @param genesisConfigOptions the genesis config options + * @return epoch length if PoA consensus is configured, empty otherwise + */ + private OptionalLong getPoaEpochLength(final GenesisConfigOptions genesisConfigOptions) { + if (genesisConfigOptions.isIbft2()) { + return OptionalLong.of(genesisConfigOptions.getBftConfigOptions().getEpochLength()); + } else if (genesisConfigOptions.isQbft()) { + return OptionalLong.of(genesisConfigOptions.getQbftConfigOptions().getEpochLength()); + } + return OptionalLong.empty(); + } + + /** + * Gets the name of the consensus mechanism configured in the genesis. + * + * @param genesisConfigOptions the genesis config options + * @return the consensus mechanism name (e.g., "IBFT2", "QBFT", "Clique", "Ethash") + */ + private String getConsensusMechanism(final GenesisConfigOptions genesisConfigOptions) { + if (genesisConfigOptions.isIbft2()) { + return "IBFT2"; + } else if (genesisConfigOptions.isQbft()) { + return "QBFT"; + } else if (genesisConfigOptions.isEthHash()) { + return "Ethash"; + } + return "Unknown"; + } + + private void issueOptionWarnings() { + + // Check that P2P options are able to work + CommandLineUtils.checkOptionDependencies( + logger, + commandLine, + "--p2p-enabled", + !p2PDiscoveryOptions.p2pEnabled, + asList( + "--bootnodes", + "--discovery-enabled", + "--max-peers", + "--banned-node-id", + "--banned-node-ids", + "--p2p-host", + "--p2p-interface", + "--p2p-port", + "--remote-connections-max-percentage")); + + if (getDefaultSyncModeIfNotSet() == SyncMode.FULL + && isOptionSet(commandLine, "--sync-min-peers")) { + logger.warn("--sync-min-peers is ignored in FULL sync-mode"); + } + + CommandLineUtils.failIfOptionDoesntMeetRequirement( + commandLine, + "--Xsnapsync-synchronizer-flat option can only be used when --Xbonsai-full-flat-db-enabled is true", + dataStorageOptions + .toDomainObject() + .getPathBasedExtraStorageConfiguration() + .getUnstable() + .getFullFlatDbEnabled(), + asList( + "--Xsnapsync-synchronizer-flat-account-healed-count-per-request", + "--Xsnapsync-synchronizer-flat-slot-healed-count-per-request")); + + if (!securityModuleName.equals(DEFAULT_SECURITY_MODULE) + && nodePrivateKeyFileOption.getNodePrivateKeyFile() != null) { + logger.warn( + DEPENDENCY_WARNING_MSG, + "--node-private-key-file", + "--security-module=" + DEFAULT_SECURITY_MODULE); + } + } + + private void configure() throws Exception { + p2PDiscoveryConfig = p2PDiscoveryOptions.toDomainObject(); + engineRPCConfig = engineRPCOptions.toDomainObject(); + checkPortClash(); + checkIfRequiredPortsAreAvailable(); + syncMode = getDefaultSyncModeIfNotSet(); + versionCompatibilityProtection = getDefaultVersionCompatibilityProtectionIfNotSet(); + + ethNetworkConfig = updateNetworkConfig(network); + + jsonRpcConfiguration = + jsonRpcHttpOptions.jsonRpcConfiguration( + hostsAllowlist, + p2PDiscoveryConfig.p2pHost(), + unstableRPCOptions.getHttpTimeoutSec(), + unstableRPCOptions.getHttpStreamingTimeoutSec()); + logger.info("RPC HTTP JSON-RPC config: {}", jsonRpcConfiguration); + if (isEngineApiEnabled()) { + engineJsonRpcConfiguration = createEngineJsonRpcConfiguration(); + logger.info("Engine JSON-RPC config: {}", engineJsonRpcConfiguration); + // align JSON decoding limit with HTTP body limit + // character count is close to size in bytes + final long maxRequestContentLength = + Math.max( + jsonRpcConfiguration.getMaxRequestContentLength(), + engineJsonRpcConfiguration.getMaxRequestContentLength()); + configureVertxJsonDecodingMaxLength((int) maxRequestContentLength); + } + + graphQLConfiguration = + graphQlOptions.graphQLConfiguration( + hostsAllowlist, p2PDiscoveryConfig.p2pHost(), unstableRPCOptions.getHttpTimeoutSec()); + + webSocketConfiguration = + rpcWebsocketOptions.webSocketConfiguration( + hostsAllowlist, p2PDiscoveryConfig.p2pHost(), unstableRPCOptions.getWsTimeoutSec()); + jsonRpcIpcConfiguration = + jsonRpcIpcConfiguration( + unstableIpcOptions.isEnabled(), + unstableIpcOptions.getIpcPath(), + unstableIpcOptions.getRpcIpcApis()); + inProcessRpcConfiguration = inProcessRpcOptions.toDomainObject(); + dataStorageConfiguration = getDataStorageConfiguration(); + + permissioningConfiguration = permissioningConfiguration(); + staticNodes = loadStaticNodes(); + + permissioningConfiguration + .flatMap(PermissioningConfiguration::getLocalConfig) + .ifPresent(p -> ensureAllNodesAreInAllowlist(ethNetworkConfig.enodeBootNodes(), p)); + + permissioningConfiguration + .flatMap(PermissioningConfiguration::getLocalConfig) + .ifPresent(p -> ensureAllNodesAreInAllowlist(ethNetworkConfig.enrBootNodes(), p)); + + permissioningConfiguration + .flatMap(PermissioningConfiguration::getLocalConfig) + .ifPresent(p -> ensureAllNodesAreInAllowlist(staticNodes, p)); + metricsConfiguration = metricsConfiguration(); + + instantiateSignatureAlgorithmFactory(); + + logger.info(generateConfigurationOverview()); + logger.info("Security Module: {}", securityModuleName); + } + + private static void configureVertxJsonDecodingMaxLength(final int maxStringLength) { + // Supports large sized engine_newPayload decoding + + // This is a global setting for the Vert.x ObjectMapper + // which is used by both the JsonRpc and EngineJsonRpc services + // Attempting to limit the scope of the mapper config leads to extra serialisation steps + ObjectMapper om = DatabindCodec.mapper(); + StreamReadConstraints src = + StreamReadConstraints.builder().maxStringLength(maxStringLength).build(); + om.getFactory().setStreamReadConstraints(src); + } + + private Optional permissioningConfiguration() throws Exception { + return permissionsOptions.permissioningConfiguration( + jsonRpcHttpOptions, + rpcWebsocketOptions, + getEnodeDnsConfiguration(), + dataDir(), + logger, + commandLine); + } + + private JsonRpcIpcConfiguration jsonRpcIpcConfiguration( + final Boolean enabled, final Path ipcPath, final List rpcIpcApis) { + final Path actualPath; + if (ipcPath == null) { + actualPath = IpcOptions.getDefaultPath(dataDir()); + } else { + actualPath = ipcPath; + } + return new JsonRpcIpcConfiguration( + vertx.isNativeTransportEnabled() && enabled, actualPath, rpcIpcApis); + } + + private void ensureAllNodesAreInAllowlist( + final Collection nodeIdentifiers, + final LocalPermissioningConfiguration permissioningConfiguration) { + try { + PermissioningConfigurationValidator.areAllNodesInAllowlist( + nodeIdentifiers, permissioningConfiguration); + } catch (final Exception e) { + throw new ParameterException(this.commandLine, e.getMessage()); + } + } + + /** + * Builds BesuController + * + * @return instance of BesuController + */ + public BesuController buildController() { + try { + return setupControllerBuilder().build(); + } catch (final Exception e) { + throw new ExecutionException(this.commandLine, e.getMessage(), e); + } + } + + /** + * Builds BesuControllerBuilder which can be used to build BesuController + * + * @return instance of BesuControllerBuilder + */ + public BesuControllerBuilder setupControllerBuilder() { + pluginCommonConfiguration + .init(dataDir(), dataDir().resolve(DATABASE_PATH), getDataStorageConfiguration()) + .withMiningParameters(miningParametersSupplier.get()) + .withJsonRpcHttpOptions(jsonRpcHttpOptions); + final KeyValueStorageProvider storageProvider = keyValueStorageProvider(keyValueStorageName); + final ApiConfiguration apiConfiguration = apiConfigurationSupplier.get(); + final BalConfiguration balConfiguration = balConfigurationOptions.toDomainObject(); + + BesuControllerBuilder besuControllerBuilder = + controllerBuilder + .fromEthNetworkConfig(updateNetworkConfig(network), getDefaultSyncModeIfNotSet()) + .synchronizerConfiguration(buildSyncConfig()) + .ethProtocolConfiguration(unstableEthProtocolOptions.toDomainObject()) + .networkConfiguration(unstableNetworkingOptions.toDomainObject()) + .dataDirectory(dataDir()) + .dataStorageConfiguration(getDataStorageConfiguration()) + .miningParameters(miningParametersSupplier.get()) + .transactionPoolConfiguration(buildTransactionPoolConfiguration()) + .nodeKey(new NodeKey(securityModule())) + .metricsSystem((ObservableMetricsSystem) besuComponent.getMetricsSystem()) + .messagePermissioningProviders(permissioningService.getMessagePermissioningProviders()) + .clock(Clock.systemUTC()) + .isRevertReasonEnabled(isRevertReasonEnabled) + .storageProvider(storageProvider) + .isEarlyRoundChangeEnabled(unstableQbftOptions.isEarlyRoundChangeEnabled()) + .requiredBlocks(requiredBlocks) + .reorgLoggingThreshold(reorgLoggingThreshold) + .evmConfiguration(unstableEvmOptions.toDomainObject()) + .maxPeers(p2PDiscoveryOptions.maxPeers) + .maxRemotelyInitiatedPeers(maxRemoteInitiatedPeers) + .randomPeerPriority(p2PDiscoveryOptions.randomPeerPriority) + .chainPruningConfiguration(unstableChainPruningOptions.toDomainObject()) + .cacheLastBlocks(numberOfBlocksToCache) + .cacheLastBlockHeaders(numberOfBlockHeadersToCache) + .isCacheLastBlockHeadersPreloadEnabled(isCacheLastBlockHeadersPreloadEnabled) + .genesisStateHashCacheEnabled(genesisStateHashCacheEnabled) + .apiConfiguration(apiConfiguration) + .balConfiguration(balConfiguration) + .besuComponent(besuComponent); + if (DataStorageFormat.BONSAI.equals(getDataStorageConfiguration().getDataStorageFormat())) { + final PathBasedExtraStorageConfiguration subStorageConfiguration = + getDataStorageConfiguration().getPathBasedExtraStorageConfiguration(); + besuControllerBuilder.isParallelTxProcessingEnabled( + subStorageConfiguration.getParallelTxProcessingEnabled()); + } + return besuControllerBuilder; + } + + private JsonRpcConfiguration createEngineJsonRpcConfiguration() { + jsonRpcHttpOptions.checkDependencies(logger, commandLine); + final JsonRpcConfiguration engineConfig = + jsonRpcHttpOptions.jsonRpcConfiguration( + engineRPCConfig.engineHostsAllowlist(), + p2PDiscoveryConfig.p2pHost(), + unstableRPCOptions.getHttpTimeoutSec(), + unstableRPCOptions.getHttpStreamingTimeoutSec()); + engineConfig.setPort(engineRPCConfig.engineRpcPort()); + engineConfig.setRpcApis(Arrays.asList("ENGINE", "ETH")); + engineConfig.setEnabled(isEngineApiEnabled()); + if (!engineRPCConfig.isEngineAuthDisabled()) { + engineConfig.setAuthenticationEnabled(true); + engineConfig.setAuthenticationAlgorithm(JwtAlgorithm.HS256); + if (Objects.nonNull(engineRPCConfig.engineJwtKeyFile()) + && java.nio.file.Files.exists(engineRPCConfig.engineJwtKeyFile())) { + engineConfig.setAuthenticationPublicKeyFile(engineRPCConfig.engineJwtKeyFile().toFile()); + } else { + logger.warn( + "Engine API authentication enabled without key file. Expect ephemeral jwt.hex file in datadir"); + } + } + return engineConfig; + } + + /** + * Metrics Configuration for Besu + * + * @return instance of MetricsConfiguration. + */ + public MetricsConfiguration metricsConfiguration() { + if (metricsOptions.getMetricsEnabled() && metricsOptions.getMetricsPushEnabled()) { + throw new ParameterException( + this.commandLine, + "--metrics-enabled option and --metrics-push-enabled option can't be used at the same " + + "time. Please refer to CLI reference for more details about this constraint."); + } + + CommandLineUtils.checkOptionDependencies( + logger, + commandLine, + "--metrics-enabled", + !metricsOptions.getMetricsEnabled(), + asList("--metrics-host", "--metrics-port")); + + CommandLineUtils.checkOptionDependencies( + logger, + commandLine, + "--metrics-push-enabled", + !metricsOptions.getMetricsPushEnabled(), + asList( + "--metrics-push-host", + "--metrics-push-port", + "--metrics-push-interval", + "--metrics-push-prometheus-job")); + + metricsOptions.setMetricCategoryRegistry(metricCategoryRegistry); + + metricsOptions.validate(commandLine); + + final MetricsConfiguration.Builder metricsConfigurationBuilder = + metricsOptions.toDomainObject(); + metricsConfigurationBuilder + .host( + Strings.isNullOrEmpty(metricsOptions.getMetricsHost()) + ? p2PDiscoveryConfig.p2pHost() + : metricsOptions.getMetricsHost()) + .pushHost( + Strings.isNullOrEmpty(metricsOptions.getMetricsPushHost()) + ? p2PDiscoveryOptions.autoDiscoverDefaultIP().getHostAddress() + : metricsOptions.getMetricsPushHost()) + .hostsAllowlist(hostsAllowlist); + final var metricsConfiguration = metricsConfigurationBuilder.build(); + metricCategoryRegistry.setMetricsConfiguration(metricsConfiguration); + return metricsConfiguration; + } + + private KeyValueStorageProvider keyValueStorageProvider(final String name) { + if (this.keyValueStorageProvider == null) { + this.keyValueStorageProvider = + new KeyValueStorageProviderBuilder() + .withStorageFactory( + storageService + .getByName(name) + .orElseThrow( + () -> + new StorageException( + "No KeyValueStorageFactory found for key: " + name))) + .withCommonConfiguration(pluginCommonConfiguration) + .withMetricsSystem(getMetricsSystem()) + .build(); + } + return this.keyValueStorageProvider; + } + + /** + * Get the storage provider + * + * @return the storage provider + */ + public StorageProvider getStorageProvider() { + return keyValueStorageProvider(keyValueStorageName); + } + + private SynchronizerConfiguration buildSyncConfig() { + return unstableSynchronizerOptions + .toDomainObject() + .syncMode(getDefaultSyncModeIfNotSet()) + .syncMinimumPeerCount(syncMinPeerCount) + .build(); + } + + private TransactionPoolConfiguration buildTransactionPoolConfiguration() { + transactionPoolOptions.setPluginTransactionValidatorService( + transactionPoolValidatorServiceImpl); + final var txPoolConf = transactionPoolOptions.toDomainObject(); + final var txPoolConfBuilder = + ImmutableTransactionPoolConfiguration.builder() + .from(txPoolConf) + .saveFile((dataPath.resolve(txPoolConf.getSaveFile().getPath()).toFile())); + + if (genesisConfigOptionsSupplier.get().isZeroBaseFee()) { + logger.warn( + "Forcing price bump for transaction replacement to 0, since we are on a zero basefee network"); + txPoolConfBuilder.priceBump(Percentage.ZERO); + } + + if (miningParametersSupplier.get().getMinTransactionGasPrice().equals(Wei.ZERO) + && !transactionPoolOptions.isPriceBumpSet(commandLine)) { + logger.warn( + "Forcing price bump for transaction replacement to 0, since min-gas-price is set to 0"); + txPoolConfBuilder.priceBump(Percentage.ZERO); + } + + if (miningParametersSupplier + .get() + .getMinTransactionGasPrice() + .lessThan(txPoolConf.getMinGasPrice())) { + if (transactionPoolOptions.isMinGasPriceSet(commandLine)) { + throw new ParameterException( + commandLine, "tx-pool-min-gas-price cannot be greater than the value of min-gas-price"); + + } else { + // for backward compatibility, if tx-pool-min-gas-price is not set, we adjust its value + // to be the same as min-gas-price, so the behavior is as before this change, and we notify + // the user of the change + logger.warn( + "Forcing tx-pool-min-gas-price=" + + miningParametersSupplier.get().getMinTransactionGasPrice().toDecimalString() + + ", since it cannot be greater than the value of min-gas-price"); + txPoolConfBuilder.minGasPrice(miningParametersSupplier.get().getMinTransactionGasPrice()); + } + } + + return txPoolConfBuilder.build(); + } + + private MiningConfiguration getMiningParameters() { + miningOptions.setTransactionSelectionService(transactionSelectionServiceImpl); + final var miningParameters = miningOptions.toDomainObject(); + getBlockPeriodSeconds(readGenesisConfigOptions()) + .ifPresent(miningParameters::setBlockPeriodSeconds); + initMiningParametersMetrics(miningParameters); + + return miningParameters; + } + + private ApiConfiguration getApiConfiguration() { + validateApiOptions(); + return apiConfigurationOptions.apiConfiguration(); + } + + /** + * Get the data storage configuration + * + * @return the data storage configuration + */ + public DataStorageConfiguration getDataStorageConfiguration() { + if (dataStorageConfiguration == null) { + dataStorageConfiguration = dataStorageOptions.toDomainObject(); + } + + if (SyncMode.FULL.equals(getDefaultSyncModeIfNotSet()) + && DataStorageFormat.BONSAI.equals(dataStorageConfiguration.getDataStorageFormat())) { + final PathBasedExtraStorageConfiguration pathBasedExtraStorageConfiguration = + dataStorageConfiguration.getPathBasedExtraStorageConfiguration(); + if (pathBasedExtraStorageConfiguration.getLimitTrieLogsEnabled()) { + if (CommandLineUtils.isOptionSet( + commandLine, PathBasedExtraStorageOptions.LIMIT_TRIE_LOGS_ENABLED)) { + throw new ParameterException( + commandLine, + String.format( + "Cannot enable %s with --sync-mode=%s and --data-storage-format=%s. You must set %s or use a different sync-mode", + PathBasedExtraStorageOptions.LIMIT_TRIE_LOGS_ENABLED, + SyncMode.FULL, + DataStorageFormat.BONSAI, + PathBasedExtraStorageOptions.LIMIT_TRIE_LOGS_ENABLED + "=false")); + } + + dataStorageConfiguration = + ImmutableDataStorageConfiguration.copyOf(dataStorageConfiguration) + .withPathBasedExtraStorageConfiguration( + ImmutablePathBasedExtraStorageConfiguration.copyOf( + dataStorageConfiguration.getPathBasedExtraStorageConfiguration()) + .withLimitTrieLogsEnabled(false)); + logger.warn( + "Forcing {}, since it cannot be enabled with --sync-mode={} and --data-storage-format={}.", + PathBasedExtraStorageOptions.LIMIT_TRIE_LOGS_ENABLED + "=false", + SyncMode.FULL, + DataStorageFormat.BONSAI); + } + } + return dataStorageConfiguration; + } + + /** + * Gets the network for this BesuCommand + * + * @return the network for this BesuCommand + */ + public NetworkDefinition getNetwork() { + return network; + } + + private void initMiningParametersMetrics(final MiningConfiguration miningConfiguration) { + new MiningParametersMetrics(getMetricsSystem(), miningConfiguration); + } + + // Blockchain synchronization from peers. + private Runner synchronize( + final BesuController controller, + final boolean p2pEnabled, + final boolean peerDiscoveryEnabled, + final EthNetworkConfig ethNetworkConfig, + final String p2pAdvertisedHost, + final String p2pListenInterface, + final int p2pListenPort, + final GraphQLConfiguration graphQLConfiguration, + final JsonRpcConfiguration jsonRpcConfiguration, + final JsonRpcConfiguration engineJsonRpcConfiguration, + final WebSocketConfiguration webSocketConfiguration, + final JsonRpcIpcConfiguration jsonRpcIpcConfiguration, + final InProcessRpcConfiguration inProcessRpcConfiguration, + final ApiConfiguration apiConfiguration, + final MetricsConfiguration metricsConfiguration, + final Optional permissioningConfiguration, + final Collection staticNodes, + final Path pidPath) { + + checkNotNull(runnerBuilder); + + final Runner runner = + runnerBuilder + .vertx(vertx) + .besuController(controller) + .p2pEnabled(p2pEnabled) + .natMethod(natMethod) + .natMethodFallbackEnabled(unstableNatOptions.getNatMethodFallbackEnabled()) + .discoveryEnabled(peerDiscoveryEnabled) + .ethNetworkConfig(ethNetworkConfig) + .permissioningConfiguration(permissioningConfiguration) + .p2pAdvertisedHost(p2pAdvertisedHost) + .p2pListenInterface(p2pListenInterface) + .p2pListenPort(p2pListenPort) + .p2pAdvertisedHostIpv6(p2PDiscoveryConfig.p2pHostIpv6()) + .p2pListenInterfaceIpv6(p2PDiscoveryConfig.p2pInterfaceIpv6()) + .p2pListenPortIpv6(p2PDiscoveryConfig.p2pPortIpv6()) + .networkingConfiguration(unstableNetworkingOptions.toDomainObject()) + .graphQLConfiguration(graphQLConfiguration) + .jsonRpcConfiguration(jsonRpcConfiguration) + .engineJsonRpcConfiguration(engineJsonRpcConfiguration) + .webSocketConfiguration(webSocketConfiguration) + .jsonRpcIpcConfiguration(jsonRpcIpcConfiguration) + .inProcessRpcConfiguration(inProcessRpcConfiguration) + .apiConfiguration(apiConfiguration) + .pidPath(pidPath) + .dataDir(dataDir()) + .bannedNodeIds(p2PDiscoveryConfig.bannedNodeIds()) + .metricsSystem((ObservableMetricsSystem) besuComponent.getMetricsSystem()) + .permissioningService(permissioningService) + .metricsConfiguration(metricsConfiguration) + .staticNodes(staticNodes) + .identityString(identityString) + .besuPluginContext(besuPluginContext) + .autoLogBloomCaching(autoLogBloomCachingEnabled) + .ethstatsOptions(ethstatsOptions) + .storageProvider(keyValueStorageProvider(keyValueStorageName)) + .rpcEndpointService(rpcEndpointServiceImpl) + .enodeDnsConfiguration(getEnodeDnsConfiguration()) + .allowedSubnets(p2PDiscoveryConfig.allowedSubnets()) + .poaDiscoveryRetryBootnodes(p2PDiscoveryConfig.poaDiscoveryRetryBootnodes()) + .preferIpv6Outbound(p2PDiscoveryConfig.preferIpv6Outbound()) + .transactionValidatorService(transactionValidatorServiceImpl) + .build(); + + addShutdownHook(runner); + + return runner; + } + + /** + * Builds Vertx instance from MetricsSystem. Visible for testing. + * + * @param metricsSystem Instance of MetricsSystem + * @return Instance of Vertx. + */ + @VisibleForTesting + protected Vertx createVertx(final MetricsSystem metricsSystem) { + return Vertx.builder() + .with(new VertxOptions().setPreferNativeTransport(true)) + .withMetrics(new VertxMetricsAdapterFactory(metricsSystem)) + .build(); + } + + private void addShutdownHook(final Runner runner) { + Runtime.getRuntime() + .addShutdownHook( + new Thread( + () -> { + try { + besuPluginContext.stopPlugins(); + runner.close(); + LogConfigurator.shutdown(); + } catch (final Exception e) { + logger.error("Failed to stop Besu"); + } + }, + "BesuCommand-Shutdown-Hook")); + } + + private EthNetworkConfig updateNetworkConfig(final NetworkDefinition network) { + final EthNetworkConfig.Builder builder = + new EthNetworkConfig.Builder(EthNetworkConfig.getNetworkConfig(network)); + + if (genesisFile != null) { + if (commandLine.getParseResult().hasMatchedOption("network")) { + throw new ParameterException( + this.commandLine, + "--network option and --genesis-file option can't be used at the same time. Please " + + "refer to CLI reference for more details about this constraint."); + } + + if (networkId == null) { + // If no chain id is found in the genesis, use mainnet network id + try { + builder.setNetworkId( + readGenesisConfigOptions() + .getChainId() + .orElse(EthNetworkConfig.getNetworkConfig(MAINNET).networkId())); + } catch (final DecodeException e) { + throw new ParameterException( + this.commandLine, String.format("Unable to parse genesis file %s.", genesisFile), e); + } catch (final ArithmeticException e) { + throw new ParameterException( + this.commandLine, + "No networkId specified and chainId in " + + "genesis file is too large to be used as a networkId"); + } + } + + if (p2PDiscoveryOptions.bootNodes == null) { + builder.setEnodeBootNodes(new ArrayList<>()); + builder.setEnrBootNodes(new ArrayList<>()); + } + builder.setDnsDiscoveryUrl(null); + } + + builder.setGenesisConfig(genesisConfigSupplier.get()); + + if (networkId != null) { + builder.setNetworkId(networkId); + } + // ChainId update is required for Ephemery network + if (network.equals(EPHEMERY)) { + String chainId = genesisConfigOverrides.get("chainId"); + builder.setNetworkId(new BigInteger(chainId)); + } + if (p2PDiscoveryOptions.discoveryDnsUrl != null) { + builder.setDnsDiscoveryUrl(p2PDiscoveryOptions.discoveryDnsUrl); + } else { + final Optional discoveryDnsUrlFromGenesis = + genesisConfigOptionsSupplier.get().getDiscoveryOptions().getDiscoveryDnsUrl(); + discoveryDnsUrlFromGenesis.ifPresent(builder::setDnsDiscoveryUrl); + } + + // Resolve bootnodes: CLI --bootnodes overrides genesis defaults. + // The discovery protocol version determines the expected format: + // V5 → ENR strings ("enr:..."), V4 → enode URLs ("enode://...") + final boolean isV5 = + unstableNetworkingOptions.toDomainObject().discoveryConfiguration().isDiscoveryV5Enabled(); + List rawBootnodes = null; + final boolean cliBootnodesProvided = p2PDiscoveryOptions.bootNodes != null; + if (cliBootnodesProvided) { + try { + rawBootnodes = BootnodeResolver.resolve(p2PDiscoveryOptions.bootNodes); + } catch (final BootnodeResolutionException | IllegalArgumentException e) { + throw new ParameterException(commandLine, e.getMessage(), e); + } + } else { + final DiscoveryOptions discoveryOptions = + genesisConfigOptionsSupplier.get().getDiscoveryOptions(); + rawBootnodes = + isV5 + ? discoveryOptions.getV5BootNodes().orElse(null) + : discoveryOptions.getBootNodes().orElse(null); + } + + if (rawBootnodes != null && !rawBootnodes.isEmpty()) { + if (!p2PDiscoveryOptions.peerDiscoveryEnabled) { + logger.warn("Discovery disabled: bootnodes will be ignored."); + } + try { + if (isV5) { + builder.setEnrBootNodes( + rawBootnodes.stream() + .map( + enr -> { + try { + return EthereumNodeRecord.fromEnr(enr); + } catch (final Exception e) { + throw new ParameterException( + commandLine, + "Invalid ENR bootnode: '" + + enr + + "'. ENR bootnodes must start with 'enr:'. Error: " + + e.getMessage(), + e); + } + }) + .toList()); + } else { + final List enodes = buildEnodes(rawBootnodes, getEnodeDnsConfiguration()); + DiscoveryConfiguration.assertValidBootnodes(enodes); + builder.setEnodeBootNodes(enodes); + } + // CLI --bootnodes is a full override: clear the unused protocol's list + if (cliBootnodesProvided) { + if (isV5) { + builder.setEnodeBootNodes(Collections.emptyList()); + } else { + builder.setEnrBootNodes(Collections.emptyList()); + } + } + } catch (final ParameterException e) { + throw e; // re-throw ParameterException from ENR parsing as-is + } catch (final IllegalArgumentException e) { + throw new ParameterException(commandLine, e.getMessage()); + } catch (final RuntimeException e) { + throw new ParameterException(commandLine, "Invalid bootnode format: " + e.getMessage(), e); + } + } else if (cliBootnodesProvided) { + // Explicitly empty --bootnodes clears all default bootnodes + builder.setEnodeBootNodes(Collections.emptyList()); + builder.setEnrBootNodes(Collections.emptyList()); + } + return builder.build(); + } + + /** + * Returns data directory used by Besu. Visible as it is accessed by other subcommands. + * + * @return Path representing data directory. + */ + public Path dataDir() { + return dataPath.toAbsolutePath(); + } + + private SecurityModule securityModule() { + return securityModuleService + .getByName(securityModuleName) + .orElseThrow(() -> new RuntimeException("Security Module not found: " + securityModuleName)) + .get(); + } + + private File resolveNodePrivateKeyFile(final File nodePrivateKeyFile) { + return Optional.ofNullable(nodePrivateKeyFile) + .orElseGet(() -> KeyPairUtil.getDefaultKeyFile(dataDir())); + } + + /** + * Metrics System used by Besu + * + * @return Instance of MetricsSystem + */ + public MetricsSystem getMetricsSystem() { + return besuComponent.getMetricsSystem(); + } + + private Set loadStaticNodes() throws IOException { + final Path staticNodesPath; + if (staticNodesFile != null) { + staticNodesPath = staticNodesFile.toAbsolutePath(); + if (!staticNodesPath.toFile().exists()) { + throw new ParameterException( + commandLine, String.format("Static nodes file %s does not exist", staticNodesPath)); + } + } else { + final String staticNodesFilename = "static-nodes.json"; + staticNodesPath = dataDir().resolve(staticNodesFilename); + } + logger.debug("Static Nodes file: {}", staticNodesPath); + final Set staticNodes = + StaticNodesParser.fromPath(staticNodesPath, getEnodeDnsConfiguration()); + logger.info("Connecting to {} static nodes.", staticNodes.size()); + logger.debug("Static Nodes = {}", staticNodes); + return staticNodes; + } + + private List buildEnodes( + final List bootNodes, final EnodeDnsConfiguration enodeDnsConfiguration) { + return bootNodes.stream() + .filter(bootNode -> !bootNode.isEmpty()) + .map(bootNode -> EnodeURLImpl.fromString(bootNode, enodeDnsConfiguration)) + .collect(Collectors.toList()); + } + + /** + * Besu CLI Parameters exception handler used by VertX. Visible for testing. + * + * @return instance of BesuParameterExceptionHandler + */ + public BesuParameterExceptionHandler parameterExceptionHandler() { + return new BesuParameterExceptionHandler(this::getLogLevel); + } + + /** + * Returns BesuExecutionExceptionHandler. Visible as it is used in testing. + * + * @return instance of BesuExecutionExceptionHandler used by Vertx. + */ + public BesuExecutionExceptionHandler executionExceptionHandler() { + return new BesuExecutionExceptionHandler(); + } + + /** + * Represents Enode DNS Configuration. Visible for testing. + * + * @return instance of EnodeDnsConfiguration + */ + @VisibleForTesting + public EnodeDnsConfiguration getEnodeDnsConfiguration() { + if (enodeDnsConfiguration == null) { + enodeDnsConfiguration = unstableDnsOptions.toDomainObject(); + } + return enodeDnsConfiguration; + } + + private void checkPortClash() { + getEffectivePorts().stream() + .filter(Objects::nonNull) + .filter(port -> port > 0) + .forEach( + port -> { + if (!allocatedPorts.add(port)) { + throw new ParameterException( + commandLine, + "Port number '" + + port + + "' has been specified multiple times. Please review the supplied configuration."); + } + }); + } + + /** + * Check if required ports are available + * + * @throws InvalidConfigurationException if ports are not available. + */ + protected void checkIfRequiredPortsAreAvailable() { + final List unavailablePorts = new ArrayList<>(); + getEffectivePorts().stream() + .filter(Objects::nonNull) + .filter(port -> port > 0) + .forEach( + port -> { + if (port.equals(p2PDiscoveryConfig.p2pPort()) + && (NetworkUtility.isPortUnavailableForTcp(port) + || NetworkUtility.isPortUnavailableForUdp(port))) { + unavailablePorts.add(port); + } + if (!port.equals(p2PDiscoveryConfig.p2pPort()) + && NetworkUtility.isPortUnavailableForTcp(port)) { + unavailablePorts.add(port); + } + }); + if (!unavailablePorts.isEmpty()) { + throw new InvalidConfigurationException( + "Port(s) '" + + unavailablePorts + + "' already in use. Check for other processes using the port(s)."); + } + } + + /** + * * Gets the list of effective ports (ports that are enabled). + * + * @return The list of effective ports + */ + private List getEffectivePorts() { + final List effectivePorts = new ArrayList<>(); + addPortIfEnabled(effectivePorts, p2PDiscoveryOptions.p2pPort, p2PDiscoveryOptions.p2pEnabled); + addPortIfEnabled( + effectivePorts, graphQlOptions.getGraphQLHttpPort(), graphQlOptions.isGraphQLHttpEnabled()); + addPortIfEnabled( + effectivePorts, jsonRpcHttpOptions.getRpcHttpPort(), jsonRpcHttpOptions.isRpcHttpEnabled()); + addPortIfEnabled( + effectivePorts, rpcWebsocketOptions.getRpcWsPort(), rpcWebsocketOptions.isRpcWsEnabled()); + addPortIfEnabled(effectivePorts, engineRPCConfig.engineRpcPort(), isEngineApiEnabled()); + addPortIfEnabled( + effectivePorts, metricsOptions.getMetricsPort(), metricsOptions.getMetricsEnabled()); + return effectivePorts; + } + + /** + * Adds port to the specified list only if enabled. + * + * @param ports The list of ports + * @param port The port value + * @param enabled true if enabled, false otherwise + */ + private void addPortIfEnabled( + final List ports, final Integer port, final boolean enabled) { + if (enabled) { + ports.add(port); + } + } + + @VisibleForTesting + String getLogLevel() { + return loggingLevelOption.getLogLevel(); + } + + /** + * Returns the flag indicating that version compatibility checks will be made. + * + * @return true if compatibility checks should be made, otherwise false + */ + @VisibleForTesting + public Boolean getVersionCompatibilityProtection() { + return versionCompatibilityProtection; + } + + private void instantiateSignatureAlgorithmFactory() { + getEcCurveFromGenesisFile() + .ifPresent( + ecCurve -> { + try { + SignatureAlgorithmFactory.switchInstance(ecCurve); + } catch (final IllegalArgumentException e) { + throw new CommandLine.InitializationException( + "Invalid genesis file configuration for ecCurve. " + e.getMessage()); + } + }); + } + + private Optional getEcCurveFromGenesisFile() { + if (genesisFile == null) { + return Optional.empty(); + } + return genesisConfigOptionsSupplier.get().getEcCurve(); + } + + /** + * Return the genesis config options + * + * @return the genesis config options + */ + protected GenesisConfigOptions getGenesisConfigOptions() { + return genesisConfigOptionsSupplier.get(); + } + + private void setMergeConfigOptions() { + MergeConfiguration.setMergeEnabled( + genesisConfigOptionsSupplier.get().getTerminalTotalDifficulty().isPresent()); + } + + /** Set ignorable segments in RocksDB Storage Provider plugin. */ + public void setIgnorableStorageSegments() { + if (unstableChainPruningOptions.getChainPruningStrategy().equals(ChainPruningStrategy.NONE) + && !dataStorageConfiguration.getHistoryExpiryPruneEnabled()) { + rocksDBPlugin.addIgnorableSegmentIdentifier(KeyValueSegmentIdentifier.CHAIN_PRUNER_STATE); + } + } + + private void validatePostMergeCheckpointBlockRequirements() { + final GenesisConfigOptions genesisConfigOptions = readGenesisConfigOptions(); + final CheckpointConfigOptions checkpointConfigOptions = + genesisConfigOptions.getCheckpointOptions(); + + // Only validate if checkpoint config is not the default (empty) one + if (checkpointConfigOptions != CheckpointConfigOptions.DEFAULT) { + if (!checkpointConfigOptions.isValid()) { + throw new InvalidConfigurationException( + "The checkpoint block configured in the genesis file is not valid."); + } + } + } + + private boolean isMergeEnabled() { + return MergeConfiguration.isMergeEnabled(); + } + + private boolean isEngineApiEnabled() { + return engineRPCConfig.overrideEngineRpcEnabled() || isMergeEnabled(); + } + + private SyncMode getDefaultSyncModeIfNotSet() { + return Optional.ofNullable(syncMode) + .orElse( + genesisFile == null + && Optional.ofNullable(network) + .map(NetworkDefinition::canSnapSync) + .orElse(false) + ? SyncMode.SNAP + : SyncMode.FULL); + } + + private Boolean getDefaultVersionCompatibilityProtectionIfNotSet() { + // Version compatibility protection is enabled by default for non-named networks + return Optional.ofNullable(versionCompatibilityProtection) + // if we have a specific genesis file or custom network id, we are not using a named network + .orElse(genesisFile != null || networkId != null); + } + + private String generateConfigurationOverview() { + final ConfigurationOverviewBuilder builder = new ConfigurationOverviewBuilder(logger); + + if (environment != null) { + builder.setEnvironment(environment); + } + + if (network != null) { + builder.setNetwork(network.normalize()); + } + + if (profile != null) { + builder.setProfile(profile); + } + + builder.setHasCustomGenesis(genesisFile != null); + if (genesisFile != null) { + builder.setCustomGenesis(genesisFile.getAbsolutePath()); + } + builder.setNetworkId(ethNetworkConfig.networkId()); + + builder + .setDataStorage(dataStorageOptions.normalizeDataStorageFormat()) + .setSyncMode(syncMode.normalize()) + .setSyncMinPeers(syncMinPeerCount); + + builder.setParallelTxProcessingEnabled( + getDataStorageConfiguration() + .getPathBasedExtraStorageConfiguration() + .getParallelTxProcessingEnabled()); + + if (jsonRpcConfiguration != null && jsonRpcConfiguration.isEnabled()) { + builder + .setRpcPort(jsonRpcConfiguration.getPort()) + .setRpcHttpApis(jsonRpcConfiguration.getRpcApis()); + } + + if (engineJsonRpcConfiguration != null && engineJsonRpcConfiguration.isEnabled()) { + builder + .setEnginePort(engineJsonRpcConfiguration.getPort()) + .setEngineApis(engineJsonRpcConfiguration.getRpcApis()); + if (engineJsonRpcConfiguration.isAuthenticationEnabled()) { + if (engineJsonRpcConfiguration.getAuthenticationPublicKeyFile() != null) { + builder.setEngineJwtFile( + engineJsonRpcConfiguration.getAuthenticationPublicKeyFile().getAbsolutePath()); + } else { + // default ephemeral jwt created later + builder.setEngineJwtFile(dataDir().toAbsolutePath() + "/" + EPHEMERAL_JWT_FILE); + } + } + } + + if (rocksDBPlugin.isHighSpecEnabled()) { + builder.setHighSpecEnabled(); + } + + if (DataStorageFormat.BONSAI.equals(getDataStorageConfiguration().getDataStorageFormat())) { + final PathBasedExtraStorageConfiguration subStorageConfiguration = + getDataStorageConfiguration().getPathBasedExtraStorageConfiguration(); + if (subStorageConfiguration.getLimitTrieLogsEnabled()) { + builder + .setLimitTrieLogsEnabled() + .setTrieLogRetentionLimit(subStorageConfiguration.getMaxLayersToLoad()) + .setTrieLogsPruningWindowSize(subStorageConfiguration.getTrieLogPruningWindowSize()); + } + } + + // Add chain pruning configuration + final ChainPruningStrategy pruningStrategy = + unstableChainPruningOptions.getChainPruningStrategy(); + if (!pruningStrategy.equals(ChainPruningStrategy.NONE)) { + builder.setChainPruningEnabled( + pruningStrategy, + unstableChainPruningOptions.getChainDataPruningBlocksRetained(), + unstableChainPruningOptions.getChainDataPruningBalsRetained()); + } + + if (miningParametersSupplier.get().getTargetGasLimit().isPresent()) { + builder.setTargetGasLimit(miningParametersSupplier.get().getTargetGasLimit().getAsLong()); + } else { + MergeCoordinator.getDefaultGasLimitByChainId(genesisConfigOptionsSupplier.get().getChainId()) + .ifPresent(builder::setTargetGasLimit); + } + + miningParametersSupplier + .get() + .getMaxBlobsPerTransaction() + .ifPresent(v -> builder.setMaxBlobsPerTransaction(v)); + + miningParametersSupplier + .get() + .getMaxBlobsPerBlock() + .ifPresent(v -> builder.setMaxBlobsPerBlock(v)); + + builder + .setDiscoveryEnabled(p2PDiscoveryOptions.peerDiscoveryEnabled) + .setSnapServerEnabled(this.unstableSynchronizerOptions.isSnapsyncServerEnabled()) + .setTxPoolImplementation(buildTransactionPoolConfiguration().getTxPoolImplementation()) + .setWorldStateUpdateMode(unstableEvmOptions.toDomainObject().worldUpdaterMode()) + .setEnabledOpcodeOptimizations(unstableEvmOptions.toDomainObject().enableOptimizedOpcodes()) + .setEvmV2(unstableEvmOptions.toDomainObject().enableEvmV2()) + .setPluginContext(this.besuPluginContext) + .setHistoryExpiryPruneEnabled(getDataStorageConfiguration().getHistoryExpiryPruneEnabled()) + .setBlobDBSettings(rocksDBPlugin.getBlobDBSettings()); + + return builder.build(); + } + + /** + * 2 Returns the plugin context. + * + * @return the plugin context. + */ + public BesuPluginContextImpl getBesuPluginContext() { + return besuPluginContext; + } + + /** + * Returns the metrics options + * + * @return the metrics options + */ + public MetricsOptions getMetricsOptions() { + return metricsOptions; + } + + /** + * returns current runner for Ephemery restart. + * + * @return current runner + */ + public Runner getRunner() { + return this.runner; + } + + /** + * returns rocksDBPlugin for Ephemery restart. + * + * @return rocksDBPlugin + */ + public RocksDBPlugin getRocksDBPlugin() { + return this.rocksDBPlugin; + } + + /** + * returns keyPair for Ephemery restart. + * + * @return KeyPair + */ + public KeyPair getKeyPair() { + return keyPair; + } + + /** + * returns commandLine for Ephemery restart. + * + * @return KeyPair + */ + public CommandLine getCommandLine() { + return this.commandLine; + } + + /** Clears all allocated ports for Ephemery restart. */ + public void clearAllocatedPorts() { + allocatedPorts.clear(); + } + + /** Sets data path to parent for Ephemery restart. */ + public void setDataPathToParent() { + this.dataPath = dataPath.getParent(); + } + + @VisibleForTesting + Supplier getGenesisConfigSupplier() { + return genesisConfigSupplier; + } +} diff --git a/anchor/app/src/main/java/org/hyperledger/besu/cli/options/AerePqEmergencyOptions.java b/anchor/app/src/main/java/org/hyperledger/besu/cli/options/AerePqEmergencyOptions.java new file mode 100644 index 0000000..0d68534 --- /dev/null +++ b/anchor/app/src/main/java/org/hyperledger/besu/cli/options/AerePqEmergencyOptions.java @@ -0,0 +1,245 @@ +/* + * Copyright contributors to Besu / Aere Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.cli.options; + +import org.hyperledger.besu.consensus.common.bft.FalconSealSupport; +import org.hyperledger.besu.consensus.common.bft.PqAnchorConfig; + +import java.util.ArrayList; +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import picocli.CommandLine; + +/** + * AERE OPTIUNI-URGENTA (2026-08-02): the three emergency controls for the post-quantum certificate + * anchor, as COMMAND-LINE OPTIONS. + * + *

The requirement, in one sentence: there has to be a way back that does not need a build. + * If the anchor misbehaves on the live chain at three in the morning, the person on the other end of + * the page has to be able to stand it down with a RESTART. Until this class existed the controls + * were real but reachable only as system properties and environment variables, which in practice + * means editing a service unit or a wrapper script on every node of the fleet under time pressure, + * in a file whose syntax nobody remembers, with no {@code --help} to check against. Two of these + * options were already named in the javadoc of {@code PqAnchorConfig} as though they existed. They + * did not. + * + *

The three controls, and why exactly these three. + * + *

    + *
  • {@code --Xaere-pq-anchor-disarm} switches both anchor rules off on this node. This is the + * blunt instrument, and it is the one to reach for when the anchor itself is rejecting + * headers the fleet agrees are good. + *
  • {@code --Xaere-pq-anchor-min-seals-max} caps the seal threshold K. It can only ever LOWER + * it: the effective threshold is {@code min(scheduled, ceiling)}. A control that could raise K + * would be a way to halt a chain, not a way to restart one, so it is not offered. This is the + * one to reach for when the chain has stalled because proposers cannot gather K seals - too + * many validators down, a key rotation half-done - and the rest of the scheme is fine. + *
  • {@code --Xaere-pq-registry-mismatch-allow} lets a node START and KEEP RUNNING with a Falcon + * registry that does not satisfy what genesis requires. This is the way back from the + * REGISTRY BINDING guard, which is a CONFIGURATION guard: one wrong byte in a registry file + * pushed to the fleet stops every node it reached, for a reason that has nothing to do with + * whether the blocks are valid. + *
+ * + *

Every one of them shouts. A quiet way out is worse than no way out, because it will be + * set once in an emergency and found six months later on a fleet everybody believed was enforcing + * something. So each control that is IN FORCE costs one complete ERROR line at EVERY height, for as + * long as the node runs: see {@code PqEmergencyShoutRule} and {@code + * FalconSealSupport.shoutRegistryOverrideUnsafe}. Nothing is emitted when nothing is overridden, so + * a clean log is a measurable statement and not an absence of instrumentation. + * + *

How they take effect, and why through the properties. Each option writes the SAME system + * property the control has always read, before anything reads it. That is deliberate: it leaves + * exactly one place where each decision is made, so the command line cannot mean something subtly + * different from the environment variable, and the code that was measured under the registry + * binding work and under the anchor work is the code still doing the deciding. Precedence is + * command line, then system property, then environment variable; which source won is written into + * the log so an operator never has to guess whether the flag took. + * + *

Deliberately LOCAL, not on-chain. A halted chain cannot deliver a height-scheduled + * configuration change. The only control that works when the chain is ALREADY STOPPED is one that + * lives in the node's own start-up and takes effect on restart, and restarting a quorum of nodes is + * the right price for a change of this weight. + */ +public class AerePqEmergencyOptions { + + private static final Logger LOG = LoggerFactory.getLogger(AerePqEmergencyOptions.class); + + /** The stable log code for the startup banner listing every override in force. */ + public static final String BANNER_CODE = "AERE-PQC-EMG-BANNER-01"; + + /** Default constructor. */ + private AerePqEmergencyOptions() {} + + /** + * Create a new instance. + * + * @return a new instance + */ + public static AerePqEmergencyOptions create() { + return new AerePqEmergencyOptions(); + } + + @CommandLine.Option( + names = {"--Xaere-pq-anchor-disarm"}, + description = + "AERE EMERGENCY. Switch the post-quantum certificate anchor rules OFF on this node. Takes " + + "effect on restart, needs no rebuild, and makes this node log one ERROR line per " + + "block for as long as it is set. Recovery setting only. (default: ${DEFAULT-VALUE})", + arity = "0..1", + fallbackValue = "true", + paramLabel = "", + hidden = true) + private Boolean anchorDisarm = null; + + @CommandLine.Option( + names = {"--Xaere-pq-anchor-min-seals-max"}, + description = + "AERE EMERGENCY. Cap the post-quantum seal threshold K at this value. It can only LOWER " + + "the scheduled threshold, never raise it. Use when the chain has stalled because " + + "proposers cannot gather K seals. Logs one ERROR line per block while it is " + + "actually lowering something. (default: ${DEFAULT-VALUE})", + paramLabel = "", + hidden = true) + private Integer minSealsMax = null; + + @CommandLine.Option( + names = {"--Xaere-pq-registry-mismatch-allow"}, + description = + "AERE EMERGENCY. Start and keep running even though this node's Falcon registry does not " + + "satisfy the binding genesis requires. The node then does NOT verify post-quantum " + + "certificates and says so at every height. Recovery setting only. (default: " + + "${DEFAULT-VALUE})", + arity = "0..1", + fallbackValue = "true", + paramLabel = "", + hidden = true) + private Boolean registryMismatchAllow = null; + + /** + * Apply whatever was given on the command line onto the system properties the controls read, and + * announce the resulting state. + * + *

Call this ONCE, early in startup and before the controller is built, because {@code + * PqAnchorConfig.fromSystemConfiguration()} and the {@code FalconSealSupport} constructor both + * read these properties while the protocol schedule is being assembled. + * + *

An out-of-range ceiling REFUSES TO START rather than being ignored. An emergency control that + * silently declines to do what the operator typed is the worst possible behaviour for a control + * used under pressure: the operator would believe the chain had been given a way out and would go + * looking somewhere else for why it had not moved. + * + * @throws IllegalArgumentException when an option value is out of range + */ + public void applyAndAnnounce() { + final List inForce = new ArrayList<>(); + + if (minSealsMax != null && minSealsMax < 0) { + throw new IllegalArgumentException( + "--Xaere-pq-anchor-min-seals-max must not be negative (got " + + minSealsMax + + "). Use 0 to require no post-quantum seals at all."); + } + + if (anchorDisarm != null) { + System.setProperty(PqAnchorConfig.PROPERTY_DISABLE, anchorDisarm.toString()); + } + if (minSealsMax != null) { + System.setProperty(PqAnchorConfig.PROPERTY_MIN_SEALS_CEILING, minSealsMax.toString()); + } + if (registryMismatchAllow != null) { + System.setProperty( + FalconSealSupport.PROPERTY_REGISTRY_MISMATCH_ALLOW, registryMismatchAllow.toString()); + } + + // Read back from the properties, NOT from the fields. What is announced has to be what the + // controls will actually see, including the case where the operator set the environment + // variable and nothing on the command line. + if (Boolean.parseBoolean( + effective(PqAnchorConfig.PROPERTY_DISABLE, "AERE_PQ_ANCHOR_DISABLE"))) { + inForce.add( + "ANCHOR DISARMED (both certificate-anchor rules off) via " + + origin(anchorDisarm, PqAnchorConfig.PROPERTY_DISABLE, "AERE_PQ_ANCHOR_DISABLE")); + } + final String ceiling = + effective(PqAnchorConfig.PROPERTY_MIN_SEALS_CEILING, "AERE_PQ_ANCHOR_MIN_SEALS_MAX"); + if (ceiling != null) { + inForce.add( + "SEAL THRESHOLD CAPPED at K<=" + + ceiling + + " (lowering only, never raising) via " + + origin( + minSealsMax, + PqAnchorConfig.PROPERTY_MIN_SEALS_CEILING, + "AERE_PQ_ANCHOR_MIN_SEALS_MAX")); + } + if (Boolean.parseBoolean( + effective( + FalconSealSupport.PROPERTY_REGISTRY_MISMATCH_ALLOW, + FalconSealSupport.ENV_REGISTRY_MISMATCH_ALLOW))) { + inForce.add( + "REGISTRY BINDING BYPASSED (this node may run without verifying post-quantum " + + "certificates) via " + + origin( + registryMismatchAllow, + FalconSealSupport.PROPERTY_REGISTRY_MISMATCH_ALLOW, + FalconSealSupport.ENV_REGISTRY_MISMATCH_ALLOW)); + } + + if (inForce.isEmpty()) { + // Say NOTHING. The silence is a measurement: a node with no AERE-PQC-EMG line in its log is + // running with no emergency control in force, and that has to be checkable by grep. + return; + } + + LOG.error( + "AERE PQC EMERGENCY [{}]: this node is starting with {} POST-QUANTUM SAFETY CONTROL(S) " + + "OVERRIDDEN: {}. These are RECOVERY settings. They reduce what this node checks, they " + + "are local to this node and bound to nothing, and a fleet running on them is not " + + "running the scheme it is documented as running. Each one in force writes a line at " + + "every block, on purpose. Remove them and restart as soon as the cause is fixed.", + BANNER_CODE, + inForce.size(), + String.join(" | ", inForce)); + } + + /** The value a control will actually see: system property first, then environment variable. */ + private static String effective(final String property, final String environmentVariable) { + final String fromProperty = System.getProperty(property); + if (fromProperty != null && !fromProperty.isBlank()) { + return fromProperty.trim(); + } + final String fromEnvironment = System.getenv(environmentVariable); + if (fromEnvironment != null && !fromEnvironment.isBlank()) { + return fromEnvironment.trim(); + } + return null; + } + + /** Where the value in force came from, so nobody has to guess whether the flag took. */ + private static String origin( + final Object fromCommandLine, final String property, final String environmentVariable) { + if (fromCommandLine != null) { + return "the COMMAND LINE"; + } + final String fromProperty = System.getProperty(property); + if (fromProperty != null && !fromProperty.isBlank()) { + return "system property " + property; + } + return "environment variable " + environmentVariable; + } +} diff --git a/anchor/app/src/main/java/org/hyperledger/besu/controller/QbftBesuControllerBuilder.java b/anchor/app/src/main/java/org/hyperledger/besu/controller/QbftBesuControllerBuilder.java new file mode 100644 index 0000000..f6dcb1e --- /dev/null +++ b/anchor/app/src/main/java/org/hyperledger/besu/controller/QbftBesuControllerBuilder.java @@ -0,0 +1,732 @@ +/* + * Copyright ConsenSys AG. + * + * 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 com.google.common.base.Preconditions.checkNotNull; + +import org.hyperledger.besu.config.BftConfigOptions; +import org.hyperledger.besu.config.BftFork; +import org.hyperledger.besu.config.JsonGenesisConfigOptions; +import org.hyperledger.besu.config.QbftConfigOptions; +import org.hyperledger.besu.config.QbftFork; +import org.hyperledger.besu.consensus.common.BftValidatorOverrides; +import org.hyperledger.besu.consensus.common.EpochManager; +import org.hyperledger.besu.consensus.common.ForksSchedule; +import org.hyperledger.besu.consensus.common.bft.BftBlockInterface; +import org.hyperledger.besu.consensus.common.bft.BftContext; +import org.hyperledger.besu.consensus.common.bft.BftEventQueue; +import org.hyperledger.besu.consensus.common.bft.BftExecutors; +import org.hyperledger.besu.consensus.common.bft.BftProcessor; +import org.hyperledger.besu.consensus.common.bft.BftProtocolSchedule; +import org.hyperledger.besu.consensus.common.bft.BftRoundExpiryTimeCalculator; +import org.hyperledger.besu.consensus.common.bft.BlockTimer; +import org.hyperledger.besu.consensus.common.bft.EthSynchronizerUpdater; +import org.hyperledger.besu.consensus.common.bft.EventMultiplexer; +import org.hyperledger.besu.consensus.common.bft.FalconSealSupport; +import org.hyperledger.besu.consensus.common.bft.MessageTracker; +import org.hyperledger.besu.consensus.common.bft.PqAnchorConfig; +import org.hyperledger.besu.consensus.common.bft.PqAnchorSyncModeGuard; +import org.hyperledger.besu.consensus.common.bft.PqAnchorThresholdGuard; +import org.hyperledger.besu.consensus.common.bft.PqRegistryHash; +import org.hyperledger.besu.consensus.common.bft.PqSealCache; +import org.hyperledger.besu.consensus.common.bft.PqSignerRegistry; +import org.hyperledger.besu.consensus.common.bft.RoundTimer; +import org.hyperledger.besu.consensus.common.bft.UniqueMessageMulticaster; +import org.hyperledger.besu.consensus.common.bft.blockcreation.BftMiningCoordinator; +import org.hyperledger.besu.consensus.common.bft.blockcreation.BftProposerSelector; +import org.hyperledger.besu.consensus.common.bft.blockcreation.ProposerSelector; +import org.hyperledger.besu.consensus.common.bft.network.ValidatorPeers; +import org.hyperledger.besu.consensus.common.bft.protocol.BftProtocolManager; +import org.hyperledger.besu.consensus.common.bft.statemachine.BftEventHandler; +import org.hyperledger.besu.consensus.common.bft.statemachine.FutureMessageBuffer; +import org.hyperledger.besu.consensus.common.validator.ValidatorProvider; +import org.hyperledger.besu.consensus.common.validator.blockbased.BlockValidatorProvider; +import org.hyperledger.besu.consensus.qbft.FutureMessageSynchronizerHandler; +import org.hyperledger.besu.consensus.qbft.QbftExtraDataCodec; +import org.hyperledger.besu.consensus.qbft.QbftForksSchedulesFactory; +import org.hyperledger.besu.consensus.qbft.QbftProtocolScheduleBuilder; +import org.hyperledger.besu.consensus.qbft.adaptor.AdaptorUtil; +import org.hyperledger.besu.consensus.qbft.adaptor.BftEventHandlerAdaptor; +import org.hyperledger.besu.consensus.qbft.adaptor.QbftBlockCodecAdaptor; +import org.hyperledger.besu.consensus.qbft.adaptor.QbftBlockCreatorFactoryAdaptor; +import org.hyperledger.besu.consensus.qbft.adaptor.QbftBlockInterfaceAdaptor; +import org.hyperledger.besu.consensus.qbft.adaptor.QbftBlockchainAdaptor; +import org.hyperledger.besu.consensus.qbft.adaptor.QbftFinalStateImpl; +import org.hyperledger.besu.consensus.qbft.adaptor.QbftProtocolScheduleAdaptor; +import org.hyperledger.besu.consensus.qbft.adaptor.QbftValidatorModeTransitionLoggerAdaptor; +import org.hyperledger.besu.consensus.qbft.adaptor.QbftValidatorProviderAdaptor; +import org.hyperledger.besu.consensus.qbft.blockcreation.QbftBlockCreatorFactory; +import org.hyperledger.besu.consensus.qbft.core.payload.MessageFactory; +import org.hyperledger.besu.consensus.qbft.core.statemachine.QbftBlockHeightManagerFactory; +import org.hyperledger.besu.consensus.qbft.core.statemachine.QbftController; +import org.hyperledger.besu.consensus.qbft.core.statemachine.QbftRoundFactory; +import org.hyperledger.besu.consensus.qbft.core.types.QbftBlockCodec; +import org.hyperledger.besu.consensus.qbft.core.types.QbftBlockInterface; +import org.hyperledger.besu.consensus.qbft.core.types.QbftEventHandler; +import org.hyperledger.besu.consensus.qbft.core.types.QbftFinalState; +import org.hyperledger.besu.consensus.qbft.core.types.QbftMessage; +import org.hyperledger.besu.consensus.qbft.core.types.QbftMinedBlockObserver; +import org.hyperledger.besu.consensus.qbft.core.types.QbftProtocolSchedule; +import org.hyperledger.besu.consensus.qbft.core.types.QbftValidatorProvider; +import org.hyperledger.besu.consensus.qbft.core.validation.MessageValidatorFactory; +import org.hyperledger.besu.consensus.qbft.jsonrpc.QbftJsonRpcMethods; +import org.hyperledger.besu.consensus.qbft.network.QbftGossiperImpl; +import org.hyperledger.besu.consensus.qbft.protocol.Istanbul100SubProtocol; +import org.hyperledger.besu.consensus.qbft.validator.ForkingValidatorProvider; +import org.hyperledger.besu.consensus.qbft.validator.TransactionValidatorProvider; +import org.hyperledger.besu.consensus.qbft.validator.ValidatorContractController; +import org.hyperledger.besu.consensus.qbft.validator.ValidatorModeTransitionLogger; +import org.hyperledger.besu.datatypes.Address; +import org.hyperledger.besu.ethereum.ProtocolContext; +import org.hyperledger.besu.ethereum.api.jsonrpc.methods.JsonRpcMethods; +import org.hyperledger.besu.ethereum.blockcreation.MiningCoordinator; +import org.hyperledger.besu.ethereum.chain.Blockchain; +import org.hyperledger.besu.ethereum.chain.MinedBlockObserver; +import org.hyperledger.besu.ethereum.chain.MutableBlockchain; +import org.hyperledger.besu.ethereum.core.BlockHeader; +import org.hyperledger.besu.ethereum.core.MiningConfiguration; +import org.hyperledger.besu.ethereum.core.Util; +import org.hyperledger.besu.ethereum.eth.EthProtocol; +import org.hyperledger.besu.ethereum.eth.SnapProtocol; +import org.hyperledger.besu.ethereum.eth.manager.EthProtocolManager; +import org.hyperledger.besu.ethereum.eth.manager.snap.SnapProtocolManager; +import org.hyperledger.besu.ethereum.eth.sync.state.SyncState; +import org.hyperledger.besu.ethereum.eth.transactions.TransactionPool; +import org.hyperledger.besu.ethereum.mainnet.ProtocolSchedule; +import org.hyperledger.besu.ethereum.p2p.config.SubProtocolConfiguration; +import org.hyperledger.besu.ethereum.worldstate.WorldStateArchive; +import org.hyperledger.besu.util.Subscribers; + +import java.time.Duration; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** The Qbft Besu controller builder. */ +public class QbftBesuControllerBuilder extends BesuControllerBuilder { + + private static final Logger LOG = LoggerFactory.getLogger(QbftBesuControllerBuilder.class); + private BftEventQueue bftEventQueue; + private QbftConfigOptions qbftConfig; + private ForksSchedule qbftForksSchedule; + private ValidatorPeers peers; + private TransactionValidatorProvider transactionValidatorProvider; + private BftConfigOptions bftConfigOptions; + private QbftExtraDataCodec qbftExtraDataCodec; + private BftBlockInterface bftBlockInterface; + + /** Default Constructor. */ + public QbftBesuControllerBuilder() {} + + @Override + protected void prepForBuild() { + qbftConfig = genesisConfigOptions.getQbftConfigOptions(); + bftEventQueue = new BftEventQueue(qbftConfig.getMessageQueueLimit()); + qbftForksSchedule = QbftForksSchedulesFactory.create(genesisConfigOptions); + bftConfigOptions = qbftConfig; + qbftExtraDataCodec = new QbftExtraDataCodec(); + bftBlockInterface = new BftBlockInterface(qbftExtraDataCodec); + } + + @Override + protected JsonRpcMethods createAdditionalJsonRpcMethodFactory( + final ProtocolContext protocolContext, + final ProtocolSchedule protocolSchedule, + final MiningConfiguration miningConfiguration) { + + return new QbftJsonRpcMethods( + protocolContext, + protocolSchedule, + miningConfiguration, + createReadOnlyValidatorProvider(protocolContext.getBlockchain()), + bftConfigOptions); + } + + private ValidatorProvider createReadOnlyValidatorProvider(final Blockchain blockchain) { + checkNotNull( + transactionValidatorProvider, "transactionValidatorProvider should have been initialised"); + final long startBlock = + qbftConfig.getStartBlock().isPresent() ? qbftConfig.getStartBlock().getAsLong() : 0; + final EpochManager epochManager = new EpochManager(qbftConfig.getEpochLength(), startBlock); + // Must create our own voteTallyCache as using this would pollute the main voteTallyCache + final BlockValidatorProvider readOnlyBlockValidatorProvider = + BlockValidatorProvider.nonForkingValidatorProvider( + blockchain, epochManager, bftBlockInterface); + return new ForkingValidatorProvider( + blockchain, + qbftForksSchedule, + readOnlyBlockValidatorProvider, + transactionValidatorProvider); + } + + @Override + protected SubProtocolConfiguration createSubProtocolConfiguration( + final EthProtocolManager ethProtocolManager, + final Optional maybeSnapProtocolManager) { + final SubProtocolConfiguration subProtocolConfiguration = + new SubProtocolConfiguration() + .withSubProtocol(EthProtocol.get(), ethProtocolManager) + .withSubProtocol( + Istanbul100SubProtocol.get(), + new BftProtocolManager( + bftEventQueue, + peers, + Istanbul100SubProtocol.ISTANBUL_100, + Istanbul100SubProtocol.get().getName())); + maybeSnapProtocolManager.ifPresent( + snapProtocolManager -> + subProtocolConfiguration.withSubProtocol(SnapProtocol.get(), snapProtocolManager)); + return subProtocolConfiguration; + } + + @Override + protected MiningCoordinator createMiningCoordinator( + final ProtocolSchedule protocolSchedule, + final ProtocolContext protocolContext, + final TransactionPool transactionPool, + final MiningConfiguration miningConfiguration, + final SyncState syncState, + final EthProtocolManager ethProtocolManager) { + final MutableBlockchain blockchain = protocolContext.getBlockchain(); + + // AERE GARDA-ACTIVARE (2026-08-01): the CHAIN-RELATIVE half of the Falcon seal-attachment guard. + // + // FalconSealSupport is constructed eagerly while the protocol schedule is built, which is early + // enough to validate the configuration against ITSELF but too early to see a chain. That left a + // measured hole: attachmentArmed() only ever required blockNumber >= attachBlock, so a validator + // restarted with an activation height the chain had ALREADY PASSED started with no complaint and + // armed on its first commit, emitting Falcon-carrying Commits into a fleet that had not armed. + // + // This is the first point in startup where a chain head exists, and it is still before the + // network and the QBFT state machine are started, so an abort here is a clean refusal to start + // rather than a mid-flight halt. The predicate answers "does the header at this height carry at + // least one Falcon seal", which is the on-chain proof that the fleet legitimately armed and is + // what lets a crashed validator restart into a live activation instead of being locked out. + FalconSealSupport.instance() + .verifyAttachHeightAgainstChainHeadOrAbort( + blockchain.getChainHeadBlockNumber(), + n -> + blockchain + .getBlockHeader(n) + .map(h -> !qbftExtraDataCodec.decode(h).getFalconSeals().isEmpty()) + .orElse(false), + dataDirectory); + + // AERE GARDA-CHEIE-MASINA (2026-08-07): the Falcon key loaded must be THIS machine's own. + // + // Measured on seven test nodes with a single variable changed: node 0 was handed index 1's key. + // It started cleanly, logged index 1 -- the very same line the node that REALLY is index 1 + // writes -- and the chain crossed the K=3 step without a pause, with zero rejected blocks and + // zero divergence, because the seals VERIFY. What was lost in silence: index 0 disappeared from + // the signer set and index 1 was counted twice. + // + // This is the first point in startup where the node knows its own validator address, and it is + // still before the network and the QBFT state machine, so a refusal here is a clean refusal to + // start. The guard is INERT for as long as there is no aere.falcon.key and no address-bound + // registry, so on 2800 as it stands today it cannot fire. + FalconSealSupport.instance() + .verifyLocalKeyBelongsToThisNodeOrAbort(Util.publicKeyToAddress(nodeKey.getPublicKey())); + + // AERE BLOCAJ-REPORNIRE (2026-08-05): activate the late anchor FROM THE CHAIN HEAD STATE, at + // startup, and not only when a block is imported. + // + // WHY, and this is a measured chain death, not a supposition. The full activation rehearsal, on + // a seven-node test network, found a CIRCULAR DEADLOCK nobody had been looking for: + // activateLateAnchor() was called ONLY from FalconSealValidationRule, that is only while + // validating an IMPORTED block. After a simultaneous restart of the whole fleet no block is + // imported, because nobody proposes. So lateActivated stays false, attachmentArmed() answers + // false, no seal is attached, no certificate reaches K, and nobody can propose. Seals come + // from Commits, Commits come from proposals, and proposals need seals. + // The node's exact message, verbatim from the run: "refusing to propose ... holds 0 valid + // eligible Falcon seal(s) ... threshold is 3", over "Attachment stays OFF (fail-safe)". + // With K=0 the chain heals itself. With K>0 it NEVER heals. + // + // A simultaneous restart of the seven is not an exotic scenario: it is a power cut, a scheduled + // kernel update, or any procedure that starts the fleet all at once. + // + // The repair invents nothing and weakens no check: it does here, once, exactly what the import + // path already did, reading the same slot 0 of the same contract, out of the chain head state. + // activateLateAnchor stays idempotent and fail-closed: on a hash mismatch the registry stays + // EMPTY and the path is terminally failed, exactly as before. If the head state cannot be read, + // nothing changes and the import path remains the second chance. + // + // Deliberately placed before the registry guard and before the QBFT machine starts, so that a + // refusal here is still a clean refusal to start. + if (FalconSealSupport.instance().anchorAddress() != null) { + try { + final BlockHeader aereCap = blockchain.getChainHeadHeader(); + protocolContext + .getWorldStateArchive() + .get(aereCap.getStateRoot(), aereCap.getHash()) + .ifPresent( + ws -> { + final var cont = ws.get(Address.fromHexString(FalconSealSupport.instance().anchorAddress())); + if (cont == null) { + return; + } + final var slot0 = cont.getStorageValue(org.apache.tuweni.units.bigints.UInt256.ZERO); + if (slot0 == null || slot0.isZero()) { + return; + } + final boolean armat = + FalconSealSupport.instance() + .activateLateAnchor(slot0.toBytes().toUnprefixedHexString()); + LOG.info( + "AERE PQC: anchor activation at STARTUP from the chain head state {}: {}. " + + "Without it, a simultaneous fleet restart with K>0 deadlocks the chain " + + "for good (measured on a seven-node test network, 2026-08-05).", + aereCap.getNumber(), + armat ? "SUCCEEDED" : "failed, the import path remains the second chance"); + }); + } catch (final Exception e) { + LOG.warn( + "AERE PQC: the anchor activation at startup could not be done ({}); the import path " + + "remains the second chance, but after a fleet restart with K>0 that path never " + + "opens again.", + e.toString()); + } + } + + // AERE REGISTRY-BINDING (2026-08-01): bind the Falcon registry to consensus. + // + // Deliberately placed immediately after the attachment guard and before BftExecutors, for the + // same reason: a chain head exists here, and the network and the QBFT state machine have not + // been started, so a refusal is a clean refusal to start. + // + // Deliberately placed BEFORE the attachment guard would ever let a seal be emitted: a node whose + // registry disagrees with its peers must not reach the point of signing or verifying anything. + // The two guards answer different questions - "is the activation height sane relative to this + // chain" and "is this the registry this chain requires" - and both have to be true. + // + // AERE REGISTRY-BINDING (2026-08-02): the SCHEDULE comes from the genesis configuration BESU + // BOOTED WITH, not from a genesis file re-opened by path from a system property. Re-reading a + // file would have reproduced the defect one level up: the enforced binding would again depend + // on a local file a node can be pointed at wrongly, and a node reading a stale copy would + // enforce a stale schedule, or none, in silence. Read from GenesisConfigOptions there is no + // second file: the value enforced comes out of the same object that produced this node's + // genesis hash, so a node that disagrees about the schedule already disagrees about the chain. + // + // MEASURED, and it is the input that decides the hash: the live chain 2800 genesis carries + // config.chainId = 2800, so getChainId() is PRESENT and the 0L fallback below is not the value + // in play. That had been recorded as unmeasured, and any registry hash pinned into genesis has + // to be computed under the same chain id the guard will use. + final PqRegistryHash.Schedule pqRegistrySchedule = + PqRegistryHash.parseSchedule( + aereGenesisConfigNode("pqRegistryHash"), "booted genesis config.pqRegistryHash"); + FalconSealSupport.instance() + .verifyRegistryBindingOrAbort( + blockchain.getChainHeadBlockNumber(), + genesisConfigOptions + .getChainId() + .map(java.math.BigInteger::longValue) + .orElse(0L), + pqRegistrySchedule); + + // AERE SINCRONIZARE (2026-08-02): the anchor is only a defence if it applies to the history this + // node ACCEPTS, and not only to the blocks it is offered as proposals. Outside FULL sync the + // header import path never instantiates a header validator at all, so both anchor rules would be + // skipped for the entire span between checkpoint and pivot. Refuse to start rather than run a + // node that believes it is protected and is not. + // + // Placed with the other two startup guards and for the same reason: a chain head exists here, the + // network is not up and the QBFT state machine has not been created, so this is a clean refusal + // to start. Inert when aere.pq.anchorBlock is unset, which is chain 2800 as it stands today. + // AERE GARDA-PRAG (2026-08-02): read the anchor configuration ONCE, here, and hand the SAME + // object to every guard that needs it. Two calls to fromSystemConfiguration would print the + // ARMED banner twice and, worse, would give two guards two independent chances to disagree + // about what is actually configured on this node. + final PqAnchorConfig aereAnchorConfig = PqAnchorConfig.fromSystemConfiguration(); + + PqAnchorSyncModeGuard.verifySyncModeOrAbort( + syncConfig == null || syncConfig.getSyncMode() == null + ? null + : syncConfig.getSyncMode().name(), + aereAnchorConfig); + + // AERE PERSISTENTA-SIGILII (2026-08-05): THE SECOND HALF of the chain death on a whole-fleet + // restart. The first half is repaired above (BLOCAJ-REPORNIRE): the registry now arms at startup + // from the chain head state, and all seven nodes reported SUCCEEDED. The chain died anyway, the + // refusal only changing shape: "registry address-bound=TRUE ... Heard 0 seal(s)", frozen 150 s + // on the first restart and 298 s on the second. + // + // WHY. Falcon seals over M(head) travel ONLY on the Commit messages of the head block. Those + // messages are never repeated after a restart, and they exist nowhere else: the head block + // carries in element 6 the certificate over its PARENT, not over itself. So every node started + // with zero seals, nobody reached K, nobody could propose, and so nobody sent another Commit. + // The same circular deadlock, one level down. + // + // WHY THIS IS NOT A REGISTRY-TRUSTED-FROM-A-FILE IN NEW CLOTHES, and this is the whole security + // argument: the seal is SELF-VERIFYING. Every seal read from the file is cryptographically + // verified again against the anchored registry, over an M rebuilt from the head header this very + // process has just loaded, exactly as the producer does at selection time. A forged file cannot + // inject a seal without forging a Falcon-512 signature; all it can obtain is the empty cache an + // absent file already gives. The defect back then was a REGISTRY of keys trusted because it sat + // in a file. + // + // Deliberately here: the registry is already armed by the block above (otherwise no seal could + // resolve and the restore would have gone quiet for nothing), the chain head exists, and the + // network and the QBFT machine have not started. Inert when the anchor is not armed, so chain + // 2800 as it stands today sees nothing. + if (aereAnchorConfig.everActive()) { + PqSealCache.instance().enablePersistence(dataDirectory, aereAnchorConfig.chainId()); + try { + final BlockHeader aereCapSigilii = blockchain.getChainHeadHeader(); + final int aereSigiliiRestaurate = + PqSealCache.instance() + .restoreFromDisk( + aereCapSigilii.getNumber(), + aereCapSigilii.getHash(), + PqSignerRegistry.falconSealSupport()); + LOG.info( + "AERE PERSISTENTA-SIGILII: {} VERIFIED seal(s) restored for chain head {} ({}). The " + + "threshold demanded at the next height is K={}. Zero means this node cannot " + + "propose until it hears a Commit, which after a fleet restart with K>0 never " + + "happens again on its own.", + aereSigiliiRestaurate, + aereCapSigilii.getNumber(), + aereCapSigilii.getHash(), + aereAnchorConfig.minSealsAt(aereCapSigilii.getNumber() + 1L)); + } catch (final Exception e) { + LOG.warn( + "AERE PERSISTENTA-SIGILII: restoring the chain head seals could not be done ({}); the " + + "node starts with an empty cache, exactly as before this repair.", + e.toString()); + } + } + + final BftExecutors bftExecutors = + BftExecutors.create(metricsSystem, BftExecutors.ConsensusType.QBFT); + final QbftBlockCodec blockEncoder = new QbftBlockCodecAdaptor(qbftExtraDataCodec); + + final Address localAddress = Util.publicKeyToAddress(nodeKey.getPublicKey()); + final BftProtocolSchedule bftProtocolSchedule = (BftProtocolSchedule) protocolSchedule; + QbftProtocolSchedule qbftProtocolSchedule = + new QbftProtocolScheduleAdaptor(bftProtocolSchedule, protocolContext); + final QbftBlockCreatorFactory blockCreatorFactory = + new QbftBlockCreatorFactory( + transactionPool, + protocolContext, + bftProtocolSchedule, + qbftForksSchedule, + miningConfiguration, + localAddress, + qbftExtraDataCodec, + ethProtocolManager.ethContext().getScheduler()); + + final ValidatorProvider validatorProvider; + if (qbftConfig.getStartBlock().isPresent()) { + validatorProvider = + protocolContext + .getConsensusContext(BftContext.class, qbftConfig.getStartBlock().getAsLong()) + .getValidatorProvider(); + } else { + validatorProvider = + protocolContext.getConsensusContext(BftContext.class).getValidatorProvider(); + } + + // AERE GARDA-PRAG (2026-08-02): refuse to start on a seal threshold that STOPS the chain. + // + // Measured before this line was written: nothing anywhere refused a threshold value. Searched + // the whole fork tree for any occurrence of minSeals near quorum, throw, refuse, abort or + // Illegal, and there were ZERO. PqAnchorConfig rejects a negative K and a step scheduled below + // the activation height, and nothing else; the only other check, PqAnchorProducer's + // warnIfActivationHeightCanRefuse, writes one LOG.error and returns. So a value at or above the + // QBFT quorum was a configuration a node accepted in silence and then halted on. + // + // Placed here rather than beside the other three startup guards because this is the FIRST point + // at which the validator set is resolved, and the bound is a function of its size. It is still + // before the network exists (ValidatorPeers is built below) and before the QBFT state machine is + // created, so a refusal here is a clean refusal to start rather than a mid-flight halt. + PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort( + aereAnchorConfig, validatorProvider.getValidatorsAtHead().size()); + final QbftValidatorProvider qbftValidatorProvider = + new QbftValidatorProviderAdaptor(validatorProvider); + + final QbftBlockInterface qbftBlockInterface = new QbftBlockInterfaceAdaptor(bftBlockInterface); + + final ProposerSelector proposerSelector = + new BftProposerSelector(blockchain, bftBlockInterface, true, validatorProvider); + + // NOTE: peers should not be used for accessing the network as it does not enforce the + // "only send once" filter applied by the UniqueMessageMulticaster. + peers = new ValidatorPeers(validatorProvider, Istanbul100SubProtocol.NAME); + + final UniqueMessageMulticaster uniqueMessageMulticaster = + new UniqueMessageMulticaster(peers, qbftConfig.getGossipedHistoryLimit()); + + final QbftGossiperImpl gossiper = new QbftGossiperImpl(uniqueMessageMulticaster, blockEncoder); + + final QbftFinalState finalState = + new QbftFinalStateImpl( + validatorProvider, + nodeKey, + Util.publicKeyToAddress(nodeKey.getPublicKey()), + proposerSelector, + uniqueMessageMulticaster, + new RoundTimer( + bftEventQueue, + new BftRoundExpiryTimeCalculator( + Duration.ofSeconds(qbftConfig.getRequestTimeoutSeconds())), + bftExecutors), + new BlockTimer(bftEventQueue, qbftForksSchedule, bftExecutors, clock), + new QbftBlockCreatorFactoryAdaptor(blockCreatorFactory, qbftExtraDataCodec), + clock); + + final MessageValidatorFactory messageValidatorFactory = + new MessageValidatorFactory( + proposerSelector, qbftProtocolSchedule, qbftValidatorProvider, qbftBlockInterface); + + final Subscribers minedBlockObservers = Subscribers.create(); + minedBlockObservers.subscribe( + qbftBlock -> ethProtocolManager.blockMined(AdaptorUtil.toBesuBlock(qbftBlock))); + minedBlockObservers.subscribe( + qbftBlock -> + blockLogger(transactionPool, localAddress) + .blockMined(AdaptorUtil.toBesuBlock(qbftBlock))); + + final EthSynchronizerUpdater synchronizerUpdater = + new EthSynchronizerUpdater(ethProtocolManager.ethContext().getEthPeers()); + final FutureMessageBuffer futureMessageBuffer = + new FutureMessageBuffer<>( + qbftConfig.getFutureMessagesMaxDistance(), + qbftConfig.getFutureMessagesLimit(), + blockchain.getChainHeadBlockNumber(), + new FutureMessageSynchronizerHandler(synchronizerUpdater)); + final MessageTracker duplicateMessageTracker = + new MessageTracker(qbftConfig.getDuplicateMessageLimit()); + + final MessageFactory messageFactory = new MessageFactory(nodeKey, blockEncoder); + + QbftRoundFactory qbftRoundFactory = + new QbftRoundFactory( + finalState, + qbftBlockInterface, + qbftProtocolSchedule, + minedBlockObservers, + messageValidatorFactory, + messageFactory); + QbftBlockHeightManagerFactory qbftBlockHeightManagerFactory = + new QbftBlockHeightManagerFactory( + finalState, + qbftRoundFactory, + messageValidatorFactory, + messageFactory, + qbftValidatorProvider, + new QbftValidatorModeTransitionLoggerAdaptor( + new ValidatorModeTransitionLogger(qbftForksSchedule))); + + qbftBlockHeightManagerFactory.isEarlyRoundChangeEnabled(isEarlyRoundChangeEnabled); + + final QbftEventHandler qbftController = + new QbftController( + new QbftBlockchainAdaptor(blockchain), + finalState, + qbftBlockHeightManagerFactory, + gossiper, + duplicateMessageTracker, + futureMessageBuffer, + blockEncoder); + final BftEventHandler bftEventHandler = new BftEventHandlerAdaptor(qbftController); + + final EventMultiplexer eventMultiplexer = new EventMultiplexer(bftEventHandler); + final BftProcessor bftProcessor = new BftProcessor(bftEventQueue, eventMultiplexer); + + final MiningCoordinator miningCoordinator = + new BftMiningCoordinator( + bftExecutors, + bftEventHandler, + bftProcessor, + blockCreatorFactory, + blockchain, + bftEventQueue, + syncState); + + // Update the next block period in seconds according to the transition schedule + protocolContext + .getBlockchain() + .observeBlockAdded( + o -> { + miningConfiguration.setBlockPeriodSeconds( + qbftForksSchedule + .getFork(o.getHeader().getNumber() + 1, o.getHeader().getTimestamp()) + .getValue() + .getBlockPeriodSeconds()); + miningConfiguration.setEmptyBlockPeriodSeconds( + qbftForksSchedule + .getFork(o.getHeader().getNumber() + 1, o.getHeader().getTimestamp()) + .getValue() + .getEmptyBlockPeriodSeconds()); + }); + + return miningCoordinator; + } + + @Override + protected PluginServiceFactory createAdditionalPluginServices( + final Blockchain blockchain, final ProtocolContext protocolContext) { + final ValidatorProvider validatorProvider = + protocolContext.getConsensusContext(BftContext.class).getValidatorProvider(); + return new BftQueryPluginServiceFactory( + blockchain, qbftExtraDataCodec, validatorProvider, nodeKey, "qbft"); + } + + @Override + protected ProtocolSchedule createProtocolSchedule() { + return QbftProtocolScheduleBuilder.create( + genesisConfigOptions, + qbftForksSchedule, + isRevertReasonEnabled, + qbftExtraDataCodec, + evmConfiguration, + miningConfiguration, + badBlockManager, + isParallelTxProcessingEnabled, + balConfiguration, + metricsSystem); + } + + @Override + protected void validateContext(final ProtocolContext context) { + final BlockHeader genesisBlockHeader = context.getBlockchain().getGenesisBlock().getHeader(); + + if (usingValidatorContractModeButSignersExistIn(genesisBlockHeader)) { + LOG.warn( + "Using validator contract mode but genesis block contains signers - the genesis block signers will not be used."); + } + + if (usingValidatorBlockHeaderModeButNoSignersIn(genesisBlockHeader)) { + LOG.warn("Genesis block contains no signers - chain will not progress."); + } + } + + private boolean usingValidatorContractModeButSignersExistIn( + final BlockHeader genesisBlockHeader) { + return isValidatorContractMode() && signersExistIn(genesisBlockHeader); + } + + private boolean usingValidatorBlockHeaderModeButNoSignersIn( + final BlockHeader genesisBlockHeader) { + return !isValidatorContractMode() && !signersExistIn(genesisBlockHeader); + } + + private boolean isValidatorContractMode() { + return genesisConfigOptions.getQbftConfigOptions().isValidatorContractMode(); + } + + private boolean signersExistIn(final BlockHeader genesisBlockHeader) { + return !bftBlockInterface.validatorsInBlock(genesisBlockHeader).isEmpty(); + } + + @Override + protected BftContext createConsensusContext( + final Blockchain blockchain, + final WorldStateArchive worldStateArchive, + final ProtocolSchedule protocolSchedule) { + final long startBlock = + qbftConfig.getStartBlock().isPresent() ? qbftConfig.getStartBlock().getAsLong() : 0; + final EpochManager epochManager = new EpochManager(qbftConfig.getEpochLength(), startBlock); + + final BftValidatorOverrides validatorOverrides = + convertBftForks(genesisConfigOptions.getTransitions().getQbftForks()); + final BlockValidatorProvider blockValidatorProvider = + BlockValidatorProvider.forkingValidatorProvider( + blockchain, epochManager, bftBlockInterface, validatorOverrides); + + transactionValidatorProvider = + new TransactionValidatorProvider( + blockchain, new ValidatorContractController(transactionSimulator), qbftForksSchedule); + + final ValidatorProvider validatorProvider = + new ForkingValidatorProvider( + blockchain, qbftForksSchedule, blockValidatorProvider, transactionValidatorProvider); + + return new BftContext(validatorProvider, epochManager, bftBlockInterface); + } + + private BftValidatorOverrides convertBftForks(final List bftForks) { + final Map> result = new HashMap<>(); + + for (final BftFork fork : bftForks) { + fork.getValidators() + .ifPresent( + validators -> + result.put( + fork.getForkBlock(), + validators.stream() + .map(Address::fromHexString) + .collect(Collectors.toList()))); + } + + return new BftValidatorOverrides(result); + } + + /** + * AERE REGISTRY-BINDING: read a genesis {@code config.*} value that Besu itself does not model, + * out of the genesis configuration THIS NODE BOOTED WITH. + * + *

Besu's {@code GenesisConfigOptions.asMap()} cannot be used for this: it is an allow-list of + * the keys Besu knows about, so a key of ours is simply absent from it and the guard would read + * "no schedule" on a genesis that plainly carries one. That failure would be silent and would + * disarm the binding on exactly the fleet that had configured it, which is worse than not having + * written it. + * + *

MEASURED, and it is why the lookup is done twice: Besu normalises every genesis config key + * to lower case in {@code GenesisReader} via {@code JsonUtil.normalizeKeys}, so {@code + * pqRegistryHash} as written by an operator arrives here as {@code pqregistryhash}. Looking up + * only the camel-case spelling would find nothing on a real genesis file. Both spellings are + * tried so the guard cannot be switched off by a rebase that changes the normalisation. + * + *

Returns null when the key is absent, which {@code PqRegistryHash.parseSchedule} turns into + * the empty schedule, i.e. nothing is enforced and the existing chain is untouched. + */ + private com.fasterxml.jackson.databind.JsonNode aereGenesisConfigNode(final String key) { + if (!(genesisConfigOptions instanceof JsonGenesisConfigOptions)) { + LOG.warn( + "AERE PQC REGISTRY-BINDING: the genesis configuration is a {}, not the JSON-backed " + + "implementation, so config.{} cannot be read and the Falcon registry binding is " + + "NOT ENFORCED on this node. A binding everybody believes is on and is not is worse " + + "than no binding.", + genesisConfigOptions.getClass().getName(), + key); + return null; + } + final JsonGenesisConfigOptions json = (JsonGenesisConfigOptions) genesisConfigOptions; + final com.fasterxml.jackson.databind.JsonNode exact = json.aereRawConfigValue(key); + return exact != null + ? exact + : json.aereRawConfigValue(key.toLowerCase(java.util.Locale.ROOT)); + } + + private static MinedBlockObserver blockLogger( + final TransactionPool transactionPool, final Address localAddress) { + return block -> + LOG.info( + String.format( + "%s %s #%,d / %d tx / %d pending / %,d (%01.1f%%) gas / (%s)", + block.getHeader().getCoinbase().equals(localAddress) ? "Produced" : "Imported", + block.getBody().getTransactions().isEmpty() ? "empty block" : "block", + block.getHeader().getNumber(), + block.getBody().getTransactions().size(), + transactionPool.count(), + block.getHeader().getGasUsed(), + (block.getHeader().getGasUsed() * 100.0) / block.getHeader().getGasLimit(), + block.getHash().getBytes().toHexString())); + } +} diff --git a/anchor/app/src/test/java/org/hyperledger/besu/cli/options/AerePqEmergencyOptionsTest.java b/anchor/app/src/test/java/org/hyperledger/besu/cli/options/AerePqEmergencyOptionsTest.java new file mode 100644 index 0000000..e74459f --- /dev/null +++ b/anchor/app/src/test/java/org/hyperledger/besu/cli/options/AerePqEmergencyOptionsTest.java @@ -0,0 +1,136 @@ +/* + * Copyright contributors to Besu / Aere Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.cli.options; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.hyperledger.besu.consensus.common.bft.FalconSealSupport; +import org.hyperledger.besu.consensus.common.bft.PqAnchorConfig; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import picocli.CommandLine; + +/** + * AERE OPTIUNI-URGENTA: the command line really reaches the controls. + * + *

These options exist for exactly one reason - that standing the post-quantum anchor down must be + * a RESTART and never a rebuild - so the thing worth asserting is the hand-off: a flag typed on the + * command line ends up in the same system property the control has always read, and it wins over a + * property already set, so an operator who types the flag does not have to know what a wrapper + * script did earlier. + */ +class AerePqEmergencyOptionsTest { + + private static final String[] PROPERTIES = { + PqAnchorConfig.PROPERTY_DISABLE, + PqAnchorConfig.PROPERTY_MIN_SEALS_CEILING, + FalconSealSupport.PROPERTY_REGISTRY_MISMATCH_ALLOW + }; + + @BeforeEach + @AfterEach + void clearProperties() { + for (final String property : PROPERTIES) { + System.clearProperty(property); + } + } + + private AerePqEmergencyOptions parse(final String... args) { + final AerePqEmergencyOptions options = AerePqEmergencyOptions.create(); + new CommandLine(options).parseArgs(args); + return options; + } + + @Test + void nothingOnTheCommandLineTouchesNothing() { + parse().applyAndAnnounce(); + for (final String property : PROPERTIES) { + assertThat(System.getProperty(property)).isNull(); + } + } + + @Test + void bareDisarmFlagArmsTheDisarm() { + parse("--Xaere-pq-anchor-disarm").applyAndAnnounce(); + assertThat(System.getProperty(PqAnchorConfig.PROPERTY_DISABLE)).isEqualTo("true"); + } + + @Test + void disarmCanBeSpelledOutAndCanBeSpelledFalse() { + parse("--Xaere-pq-anchor-disarm=false").applyAndAnnounce(); + assertThat(System.getProperty(PqAnchorConfig.PROPERTY_DISABLE)).isEqualTo("false"); + } + + @Test + void theCommandLineBeatsAPropertySetEarlier() { + // The case this protects: a wrapper script or a unit file left the disarm on, and the operator + // now wants it off. Typing the flag has to be enough. + System.setProperty(PqAnchorConfig.PROPERTY_DISABLE, "true"); + parse("--Xaere-pq-anchor-disarm=false").applyAndAnnounce(); + assertThat(System.getProperty(PqAnchorConfig.PROPERTY_DISABLE)).isEqualTo("false"); + } + + @Test + void ceilingReachesTheProperty() { + parse("--Xaere-pq-anchor-min-seals-max=2").applyAndAnnounce(); + assertThat(System.getProperty(PqAnchorConfig.PROPERTY_MIN_SEALS_CEILING)).isEqualTo("2"); + } + + @Test + void aCeilingOfZeroIsAReadableValueAndNotAnAbsentOne() { + parse("--Xaere-pq-anchor-min-seals-max=0").applyAndAnnounce(); + assertThat(System.getProperty(PqAnchorConfig.PROPERTY_MIN_SEALS_CEILING)).isEqualTo("0"); + } + + @Test + void aNegativeCeilingRefusesToStartRatherThanBeingIgnored() { + assertThatThrownBy(() -> parse("--Xaere-pq-anchor-min-seals-max=-1").applyAndAnnounce()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("must not be negative"); + assertThat(System.getProperty(PqAnchorConfig.PROPERTY_MIN_SEALS_CEILING)).isNull(); + } + + @Test + void registryBypassReachesTheProperty() { + parse("--Xaere-pq-registry-mismatch-allow").applyAndAnnounce(); + assertThat(System.getProperty(FalconSealSupport.PROPERTY_REGISTRY_MISMATCH_ALLOW)) + .isEqualTo("true"); + } + + @Test + void allThreeCanBeGivenTogether() { + parse( + "--Xaere-pq-anchor-disarm", + "--Xaere-pq-anchor-min-seals-max=0", + "--Xaere-pq-registry-mismatch-allow") + .applyAndAnnounce(); + assertThat(System.getProperty(PqAnchorConfig.PROPERTY_DISABLE)).isEqualTo("true"); + assertThat(System.getProperty(PqAnchorConfig.PROPERTY_MIN_SEALS_CEILING)).isEqualTo("0"); + assertThat(System.getProperty(FalconSealSupport.PROPERTY_REGISTRY_MISMATCH_ALLOW)) + .isEqualTo("true"); + } + + @Test + void whatTheCommandLineWritesIsWhatPqAnchorConfigThenReads() { + // The hand-off end to end, through the real reader rather than a re-implementation of it. + parse("--Xaere-pq-anchor-disarm", "--Xaere-pq-anchor-min-seals-max=1").applyAndAnnounce(); + final PqAnchorConfig read = PqAnchorConfig.fromSystemConfiguration(); + assertThat(read.disabled()).isTrue(); + assertThat(read.minSealsCeiling()).hasValue(1); + } +} diff --git a/anchor/config/src/main/java/org/hyperledger/besu/config/JsonGenesisConfigOptions.java b/anchor/config/src/main/java/org/hyperledger/besu/config/JsonGenesisConfigOptions.java new file mode 100644 index 0000000..fb0b9ed --- /dev/null +++ b/anchor/config/src/main/java/org/hyperledger/besu/config/JsonGenesisConfigOptions.java @@ -0,0 +1,659 @@ +/* + * Copyright ConsenSys AG. + * + * 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.config; + +import static java.util.Collections.emptyMap; +import static java.util.Objects.isNull; + +import org.hyperledger.besu.datatypes.Address; +import org.hyperledger.besu.datatypes.Hash; +import org.hyperledger.besu.datatypes.Wei; + +import java.math.BigInteger; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.OptionalInt; +import java.util.OptionalLong; +import java.util.TreeMap; +import java.util.stream.Stream; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.google.common.collect.ImmutableMap; +import org.apache.tuweni.units.bigints.UInt256; + +/** The Json genesis config options. */ +public class JsonGenesisConfigOptions implements GenesisConfigOptions { + + private static final String ETHASH_CONFIG_KEY = "ethash"; + private static final String IBFT_LEGACY_CONFIG_KEY = "ibft"; + private static final String IBFT2_CONFIG_KEY = "ibft2"; + private static final String QBFT_CONFIG_KEY = "qbft"; + private static final String CLIQUE_CONFIG_KEY = "clique"; + private static final String EC_CURVE_CONFIG_KEY = "eccurve"; + private static final String TRANSITIONS_CONFIG_KEY = "transitions"; + private static final String DISCOVERY_CONFIG_KEY = "discovery"; + private static final String CHECKPOINT_CONFIG_KEY = "checkpoint"; + private static final String BLOB_SCHEDULE_CONFIG_KEY = "blobschedule"; + private static final String ZERO_BASE_FEE_KEY = "zerobasefee"; + private static final String FIXED_BASE_FEE_KEY = "fixedbasefee"; + private static final String WITHDRAWAL_REQUEST_CONTRACT_ADDRESS_KEY = + "withdrawalrequestcontractaddress"; + private static final String DEPOSIT_CONTRACT_ADDRESS_KEY = "depositcontractaddress"; + private static final String CONSOLIDATION_REQUEST_CONTRACT_ADDRESS_KEY = + "consolidationrequestcontractaddress"; + + private final ObjectNode configRoot; + private final Map configOverrides = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + private final TransitionsConfigOptions transitions; + + /** + * From json object json genesis config options. + * + * @param configRoot the config root + * @return the json genesis config options + */ + public static JsonGenesisConfigOptions fromJsonObject(final ObjectNode configRoot) { + return fromJsonObjectWithOverrides(configRoot, emptyMap()); + } + + /** + * From json object with overrides json genesis config options. + * + * @param configRoot the config root + * @param configOverrides the config overrides + * @return the json genesis config options + */ + static JsonGenesisConfigOptions fromJsonObjectWithOverrides( + final ObjectNode configRoot, final Map configOverrides) { + final TransitionsConfigOptions transitionsConfigOptions; + transitionsConfigOptions = loadTransitionsFrom(configRoot); + return new JsonGenesisConfigOptions(configRoot, configOverrides, transitionsConfigOptions); + } + + private static TransitionsConfigOptions loadTransitionsFrom(final ObjectNode parentNode) { + final Optional transitionsNode = + JsonUtil.getObjectNode(parentNode, TRANSITIONS_CONFIG_KEY); + if (transitionsNode.isEmpty()) { + return new TransitionsConfigOptions(JsonUtil.createEmptyObjectNode()); + } + + return new TransitionsConfigOptions(transitionsNode.get()); + } + + /** + * Instantiates a new Json genesis config options. + * + * @param maybeConfig the optional config + * @param configOverrides the config overrides map + * @param transitionsConfig the transitions configuration + */ + JsonGenesisConfigOptions( + final ObjectNode maybeConfig, + final Map configOverrides, + final TransitionsConfigOptions transitionsConfig) { + this.configRoot = isNull(maybeConfig) ? JsonUtil.createEmptyObjectNode() : maybeConfig; + if (configOverrides != null) { + this.configOverrides.putAll(configOverrides); + } + this.transitions = transitionsConfig; + } + + @Override + public String getConsensusEngine() { + if (isEthHash()) { + return ETHASH_CONFIG_KEY; + } else if (isIbft2()) { + return IBFT2_CONFIG_KEY; + } else if (isIbftLegacy()) { + return IBFT_LEGACY_CONFIG_KEY; + } else if (isQbft()) { + return QBFT_CONFIG_KEY; + } else if (isClique()) { + return CLIQUE_CONFIG_KEY; + } else { + return "unknown"; + } + } + + @Override + public boolean isEthHash() { + return configRoot.has(ETHASH_CONFIG_KEY); + } + + @Override + public boolean isIbftLegacy() { + return configRoot.has(IBFT_LEGACY_CONFIG_KEY); + } + + @Override + public boolean isClique() { + return configRoot.has(CLIQUE_CONFIG_KEY); + } + + @Override + public boolean isIbft2() { + return configRoot.has(IBFT2_CONFIG_KEY); + } + + @Override + public boolean isQbft() { + return configRoot.has(QBFT_CONFIG_KEY); + } + + @Override + public boolean isPoa() { + return isQbft() || isClique() || isIbft2() || isIbftLegacy(); + } + + @Override + public boolean hasPos() { + return getTerminalTotalDifficulty().isPresent(); + } + + @Override + public IbftLegacyConfigOptions getIbftLegacyConfigOptions() { + return JsonUtil.getObjectNode(configRoot, IBFT_LEGACY_CONFIG_KEY) + .map(IbftLegacyConfigOptions::new) + .orElse(IbftLegacyConfigOptions.DEFAULT); + } + + @Override + public BftConfigOptions getBftConfigOptions() { + final String fieldKey = isIbft2() ? IBFT2_CONFIG_KEY : QBFT_CONFIG_KEY; + return JsonUtil.getObjectNode(configRoot, fieldKey) + .map(JsonBftConfigOptions::new) + .orElse(JsonBftConfigOptions.DEFAULT); + } + + @Override + public QbftConfigOptions getQbftConfigOptions() { + return JsonUtil.getObjectNode(configRoot, QBFT_CONFIG_KEY) + .map(JsonQbftConfigOptions::new) + .orElse(JsonQbftConfigOptions.DEFAULT); + } + + @Override + public DiscoveryOptions getDiscoveryOptions() { + return JsonUtil.getObjectNode(configRoot, DISCOVERY_CONFIG_KEY) + .map(DiscoveryOptions::new) + .orElse(DiscoveryOptions.DEFAULT); + } + + @Override + public CheckpointConfigOptions getCheckpointOptions() { + return JsonUtil.getObjectNode(configRoot, CHECKPOINT_CONFIG_KEY) + .map(CheckpointConfigOptions::new) + .orElse(CheckpointConfigOptions.DEFAULT); + } + + @Override + public JsonCliqueConfigOptions getCliqueConfigOptions() { + return JsonUtil.getObjectNode(configRoot, CLIQUE_CONFIG_KEY) + .map(JsonCliqueConfigOptions::new) + .orElse(JsonCliqueConfigOptions.DEFAULT); + } + + @Override + public EthashConfigOptions getEthashConfigOptions() { + return JsonUtil.getObjectNode(configRoot, ETHASH_CONFIG_KEY) + .map(EthashConfigOptions::new) + .orElse(EthashConfigOptions.DEFAULT); + } + + @Override + public Optional getBlobScheduleOptions() { + return JsonUtil.getObjectNode(configRoot, BLOB_SCHEDULE_CONFIG_KEY) + .map(BlobScheduleOptions::new); + } + + @Override + public TransitionsConfigOptions getTransitions() { + return transitions; + } + + @Override + public OptionalLong getHomesteadBlockNumber() { + return getOptionalLong("homesteadblock"); + } + + @Override + public OptionalLong getDaoForkBlock() { + final OptionalLong block = getOptionalLong("daoforkblock"); + if (block.isPresent() && block.getAsLong() <= 0) { + return OptionalLong.empty(); + } + return block; + } + + @Override + public OptionalLong getTangerineWhistleBlockNumber() { + return getOptionalLong("eip150block"); + } + + @Override + public OptionalLong getSpuriousDragonBlockNumber() { + return getOptionalLong("eip158block"); + } + + @Override + public OptionalLong getByzantiumBlockNumber() { + return getOptionalLong("byzantiumblock"); + } + + @Override + public OptionalLong getConstantinopleBlockNumber() { + return getOptionalLong("constantinopleblock"); + } + + @Override + public OptionalLong getPetersburgBlockNumber() { + final OptionalLong petersburgBlock = getOptionalLong("petersburgblock"); + final OptionalLong constantinopleFixBlock = getOptionalLong("constantinoplefixblock"); + if (constantinopleFixBlock.isPresent()) { + if (petersburgBlock.isPresent()) { + throw new RuntimeException( + "Genesis files cannot specify both petersburgBlock and constantinopleFixBlock."); + } + return constantinopleFixBlock; + } + return petersburgBlock; + } + + @Override + public OptionalLong getIstanbulBlockNumber() { + return getOptionalLong("istanbulblock"); + } + + @Override + public OptionalLong getMuirGlacierBlockNumber() { + return getOptionalLong("muirglacierblock"); + } + + @Override + public OptionalLong getBerlinBlockNumber() { + return getOptionalLong("berlinblock"); + } + + @Override + public OptionalLong getLondonBlockNumber() { + return getOptionalLong("londonblock"); + } + + @Override + public OptionalLong getArrowGlacierBlockNumber() { + return getOptionalLong("arrowglacierblock"); + } + + @Override + public OptionalLong getGrayGlacierBlockNumber() { + return getOptionalLong("grayglacierblock"); + } + + @Override + public OptionalLong getMergeNetSplitBlockNumber() { + return getOptionalLong("mergenetsplitblock"); + } + + @Override + public OptionalLong getShanghaiTime() { + return getOptionalLong("shanghaitime"); + } + + @Override + public OptionalLong getCancunTime() { + return getOptionalLong("cancuntime"); + } + + @Override + public OptionalLong getPragueTime() { + return getOptionalLong("praguetime"); + } + + @Override + public OptionalLong getOsakaTime() { + return getOptionalLong("osakatime"); + } + + @Override + public OptionalLong getBpo1Time() { + return getOptionalLong("bpo1time"); + } + + @Override + public OptionalLong getBpo2Time() { + return getOptionalLong("bpo2time"); + } + + @Override + public OptionalLong getBpo3Time() { + return getOptionalLong("bpo3time"); + } + + @Override + public OptionalLong getBpo4Time() { + return getOptionalLong("bpo4time"); + } + + @Override + public OptionalLong getBpo5Time() { + return getOptionalLong("bpo5time"); + } + + @Override + public OptionalLong getAmsterdamTime() { + return getOptionalLong("amsterdamtime"); + } + + @Override + public OptionalLong getFutureEipsTime() { + return getOptionalLong("futureeipstime"); + } + + @Override + public OptionalLong getExperimentalEipsTime() { + return getOptionalLong("experimentaleipstime"); + } + + @Override + public Optional getBaseFeePerGas() { + return Optional.ofNullable(configOverrides.get("baseFeePerGas")).map(Wei::fromHexString); + } + + @Override + public Optional getTerminalTotalDifficulty() { + return getOptionalBigInteger("terminaltotaldifficulty").map(UInt256::valueOf); + } + + @Override + public OptionalLong getTerminalBlockNumber() { + return getOptionalLong("terminalblocknumber"); + } + + @Override + public Optional getTerminalBlockHash() { + return getOptionalHash("terminalblockhash"); + } + + @Override + public Optional getChainId() { + return getOptionalBigInteger("chainid"); + } + + @Override + public OptionalInt getContractSizeLimit() { + return getOptionalInt("contractsizelimit"); + } + + @Override + public OptionalInt getEvmStackSize() { + return getOptionalInt("evmstacksize"); + } + + @Override + public PowAlgorithm getPowAlgorithm() { + return isEthHash() ? PowAlgorithm.ETHASH : PowAlgorithm.UNSUPPORTED; + } + + @Override + public Optional getEcCurve() { + return JsonUtil.getString(configRoot, EC_CURVE_CONFIG_KEY); + } + + @Override + public boolean isZeroBaseFee() { + return getOptionalBoolean(ZERO_BASE_FEE_KEY).orElse(false); + } + + @Override + public boolean isFixedBaseFee() { + return getOptionalBoolean(FIXED_BASE_FEE_KEY).orElse(false); + } + + @Override + public Optional

getWithdrawalRequestContractAddress() { + Optional inputAddress = + JsonUtil.getString(configRoot, WITHDRAWAL_REQUEST_CONTRACT_ADDRESS_KEY); + return inputAddress.map(Address::fromHexString); + } + + @Override + public Optional
getDepositContractAddress() { + Optional inputAddress = JsonUtil.getString(configRoot, DEPOSIT_CONTRACT_ADDRESS_KEY); + return inputAddress.map(Address::fromHexString); + } + + @Override + public Optional
getConsolidationRequestContractAddress() { + Optional inputAddress = + JsonUtil.getString(configRoot, CONSOLIDATION_REQUEST_CONTRACT_ADDRESS_KEY); + return inputAddress.map(Address::fromHexString); + } + + @Override + public Map asMap() { + final ImmutableMap.Builder builder = ImmutableMap.builder(); + getChainId().ifPresent(chainId -> builder.put("chainId", chainId)); + + // mainnet fork blocks + getHomesteadBlockNumber().ifPresent(l -> builder.put("homesteadBlock", l)); + getDaoForkBlock().ifPresent(l -> builder.put("daoForkBlock", l)); + getTangerineWhistleBlockNumber().ifPresent(l -> builder.put("eip150Block", l)); + getSpuriousDragonBlockNumber().ifPresent(l -> builder.put("eip158Block", l)); + getByzantiumBlockNumber().ifPresent(l -> builder.put("byzantiumBlock", l)); + getConstantinopleBlockNumber().ifPresent(l -> builder.put("constantinopleBlock", l)); + getPetersburgBlockNumber().ifPresent(l -> builder.put("petersburgBlock", l)); + getIstanbulBlockNumber().ifPresent(l -> builder.put("istanbulBlock", l)); + getMuirGlacierBlockNumber().ifPresent(l -> builder.put("muirGlacierBlock", l)); + getBerlinBlockNumber().ifPresent(l -> builder.put("berlinBlock", l)); + getLondonBlockNumber().ifPresent(l -> builder.put("londonBlock", l)); + getArrowGlacierBlockNumber().ifPresent(l -> builder.put("arrowGlacierBlock", l)); + getGrayGlacierBlockNumber().ifPresent(l -> builder.put("grayGlacierBlock", l)); + getMergeNetSplitBlockNumber().ifPresent(l -> builder.put("mergeNetSplitBlock", l)); + getShanghaiTime().ifPresent(l -> builder.put("shanghaiTime", l)); + getCancunTime().ifPresent(l -> builder.put("cancunTime", l)); + getPragueTime().ifPresent(l -> builder.put("pragueTime", l)); + getOsakaTime().ifPresent(l -> builder.put("osakaTime", l)); + getBpo1Time().ifPresent(l -> builder.put("bpo1Time", l)); + getBpo2Time().ifPresent(l -> builder.put("bpo2Time", l)); + getBpo3Time().ifPresent(l -> builder.put("bpo3Time", l)); + getBpo4Time().ifPresent(l -> builder.put("bpo4Time", l)); + getBpo5Time().ifPresent(l -> builder.put("bpo5Time", l)); + getAmsterdamTime().ifPresent(l -> builder.put("amsterdamTime", l)); + getTerminalBlockNumber().ifPresent(l -> builder.put("terminalBlockNumber", l)); + getTerminalBlockHash() + .ifPresent(h -> builder.put("terminalBlockHash", h.getBytes().toHexString())); + getFutureEipsTime().ifPresent(l -> builder.put("futureEipsTime", l)); + getExperimentalEipsTime().ifPresent(l -> builder.put("experimentalEipsTime", l)); + + getContractSizeLimit().ifPresent(l -> builder.put("contractSizeLimit", l)); + getEvmStackSize().ifPresent(l -> builder.put("evmstacksize", l)); + + getWithdrawalRequestContractAddress() + .ifPresent(l -> builder.put("withdrawalRequestContractAddress", l)); + getDepositContractAddress().ifPresent(l -> builder.put("depositContractAddress", l)); + getConsolidationRequestContractAddress() + .ifPresent(l -> builder.put("consolidationRequestContractAddress", l)); + + if (isClique()) { + builder.put("clique", getCliqueConfigOptions().asMap()); + } + if (isEthHash()) { + builder.put("ethash", getEthashConfigOptions().asMap()); + } + if (isIbftLegacy()) { + builder.put("ibft", getIbftLegacyConfigOptions().asMap()); + } + if (isIbft2()) { + builder.put("ibft2", getBftConfigOptions().asMap()); + } + if (isQbft()) { + builder.put("qbft", getQbftConfigOptions().asMap()); + } + + if (isZeroBaseFee()) { + builder.put("zeroBaseFee", true); + } + + if (isFixedBaseFee()) { + builder.put("fixedBaseFee", true); + } + + if (getBlobScheduleOptions().isPresent()) { + builder.put("blobSchedule", getBlobScheduleOptions().get().asMap()); + } + + return builder.build(); + } + + private OptionalLong getOptionalLong(final String key) { + if (configOverrides.containsKey(key)) { + final String value = configOverrides.get(key); + return value == null || value.isEmpty() + ? OptionalLong.empty() + : OptionalLong.of(Long.valueOf(configOverrides.get(key), 10)); + } else { + return JsonUtil.getLong(configRoot, key); + } + } + + private OptionalInt getOptionalInt(final String key) { + if (configOverrides.containsKey(key)) { + final String value = configOverrides.get(key); + return value == null || value.isEmpty() + ? OptionalInt.empty() + : OptionalInt.of(Integer.valueOf(configOverrides.get(key), 10)); + } else { + return JsonUtil.getInt(configRoot, key); + } + } + + private Optional getOptionalBigInteger(final String key) { + if (configOverrides.containsKey(key)) { + final String value = configOverrides.get(key); + return value == null || value.isEmpty() + ? Optional.empty() + : Optional.of(new BigInteger(value)); + } else { + return JsonUtil.getValueAsString(configRoot, key).map(s -> new BigInteger(s, 10)); + } + } + + private Optional getOptionalBoolean(final String key) { + if (configOverrides.containsKey(key)) { + final String value = configOverrides.get(key); + return value == null || value.isEmpty() + ? Optional.empty() + : Optional.of(Boolean.valueOf(configOverrides.get(key))); + } else { + return JsonUtil.getBoolean(configRoot, key); + } + } + + private Optional getOptionalHash(final String key) { + if (configOverrides.containsKey(key)) { + final String overrideHash = configOverrides.get(key); + return Optional.of(Hash.fromHexString(overrideHash)); + } else { + return JsonUtil.getValueAsString(configRoot, key).map(Hash::fromHexString); + } + } + + @Override + public List getForkBlockNumbers() { + Stream forkBlockNumbers = + Stream.of( + getHomesteadBlockNumber(), + getDaoForkBlock(), + getTangerineWhistleBlockNumber(), + getSpuriousDragonBlockNumber(), + getByzantiumBlockNumber(), + getConstantinopleBlockNumber(), + getPetersburgBlockNumber(), + getIstanbulBlockNumber(), + getMuirGlacierBlockNumber(), + getBerlinBlockNumber(), + getLondonBlockNumber(), + getArrowGlacierBlockNumber(), + getGrayGlacierBlockNumber(), + getMergeNetSplitBlockNumber()); + // when adding forks add an entry to ${REPO_ROOT}/config/src/test/resources/all_forks.json + + return forkBlockNumbers + .filter(OptionalLong::isPresent) + .map(OptionalLong::getAsLong) + .distinct() + .sorted() + .toList(); + } + + @Override + public List getForkBlockTimestamps() { + Stream forkBlockTimestamps = + Stream.of( + getShanghaiTime(), + getCancunTime(), + getPragueTime(), + getOsakaTime(), + getBpo1Time(), + getBpo2Time(), + getBpo3Time(), + getBpo4Time(), + getBpo5Time(), + getAmsterdamTime(), + getFutureEipsTime(), + getExperimentalEipsTime()); + // when adding forks add an entry to ${REPO_ROOT}/config/src/test/resources/all_forks.json + + return forkBlockTimestamps + .filter(OptionalLong::isPresent) + .map(OptionalLong::getAsLong) + .distinct() + .sorted() + .toList(); + } + + @Override + public boolean equals(final Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + final JsonGenesisConfigOptions that = (JsonGenesisConfigOptions) o; + return Objects.equals(configRoot, that.configRoot) + && Objects.equals(configOverrides, that.configOverrides); + } + + @Override + public int hashCode() { + return Objects.hash(configRoot, configOverrides); + } + + /** + * AERE REGISTRY-BINDING: the raw genesis {@code config.*} value for a key Besu does not model, or + * null. + * + *

WHY THIS EXISTS. {@link #asMap()} is an allow-list of the keys Besu knows, so a key of ours + * is absent from it, and a guard reading it would conclude "no schedule" on a genesis that + * plainly carries one. Silently disarming a binding on exactly the fleet that configured it is + * worse than never having written the binding. This accessor is additive and read-only: it + * changes no Besu behaviour and returns the node exactly as the genesis reader normalised it. + * + * @param key the config key, as normalised by the genesis reader (lower case) + * @return the value node, or null when absent + */ + public com.fasterxml.jackson.databind.JsonNode aereRawConfigValue(final String key) { + return configRoot.get(key); + } +} diff --git a/anchor/consensus/common/build.gradle b/anchor/consensus/common/build.gradle new file mode 100644 index 0000000..c603ca0 --- /dev/null +++ b/anchor/consensus/common/build.gradle @@ -0,0 +1,81 @@ +/* + * Copyright ConsenSys AG. + * + * 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 + */ + +apply plugin: 'java-library' + +jar { + archiveBaseName = calculateArtifactId(project) + manifest { + attributes( + 'Specification-Title': archiveBaseName, + 'Specification-Version': project.version, + 'Implementation-Title': archiveBaseName, + 'Implementation-Version': calculateVersion(), + 'Commit-Hash': getGitCommitDetails(40).hash + ) + } +} + +dependencies { + api project(':plugin-api') + + implementation project(':config') + implementation project(':crypto:services') + implementation project(':datatypes') + implementation project(':ethereum:api') + implementation project(':ethereum:blockcreation') + implementation project(':ethereum:core') + implementation project(':ethereum:eth') + implementation project(':ethereum:p2p') + implementation project(':ethereum:rlp') + implementation project(':evm') + implementation project(':util') + + compileOnly 'org.jspecify:jspecify' + + implementation 'com.fasterxml.jackson.core:jackson-databind' + implementation 'com.google.guava:guava' + implementation 'org.bouncycastle:bcprov-jdk18on' + implementation 'io.consensys.tuweni:tuweni-bytes' + + testImplementation project(':config') + testImplementation project(':crypto:algorithms') + testImplementation project(':testutil') + testImplementation project( path: ':ethereum:core', configuration: 'testSupportArtifacts') + testImplementation project( path: ':crypto:services', configuration: 'testSupportArtifacts') + testImplementation project(':metrics:core') + + testImplementation 'org.assertj:assertj-core' + testImplementation 'org.awaitility:awaitility' + testImplementation 'org.junit.jupiter:junit-jupiter' + testImplementation 'org.mockito:mockito-core' + testImplementation 'org.mockito:mockito-junit-jupiter' + + testSupportImplementation project( path: ':crypto:services', configuration: 'testSupportArtifacts') + testSupportImplementation project( path: ':ethereum:core', configuration: 'testSupportArtifacts') + testSupportImplementation 'org.mockito:mockito-core' + testSupportImplementation 'org.assertj:assertj-core' +} + +configurations { testArtifacts } +task testJar (type: Jar) { + archiveBaseName = calculateArtifactId(project) + '-test' + from sourceSets.test.output +} + +artifacts { + testArtifacts testJar + testSupportArtifacts testSupportJar +} diff --git a/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/BftBlockInterface.java b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/BftBlockInterface.java new file mode 100644 index 0000000..2e565ec --- /dev/null +++ b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/BftBlockInterface.java @@ -0,0 +1,152 @@ +/* + * Copyright ConsenSys AG. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft; + +import org.hyperledger.besu.consensus.common.BlockInterface; +import org.hyperledger.besu.consensus.common.validator.ValidatorVote; +import org.hyperledger.besu.consensus.common.validator.VoteType; +import org.hyperledger.besu.datatypes.Address; +import org.hyperledger.besu.datatypes.Hash; +import org.hyperledger.besu.ethereum.core.Block; +import org.hyperledger.besu.ethereum.core.BlockHeader; +import org.hyperledger.besu.ethereum.core.BlockHeaderBuilder; +import org.hyperledger.besu.ethereum.core.BlockHeaderFunctions; +import org.hyperledger.besu.ethereum.core.Util; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +/** The Bft block interface. */ +public class BftBlockInterface implements BlockInterface { + + private final BftExtraDataCodec bftExtraDataCodec; + + /** + * Instantiates a new Bft block interface. + * + * @param bftExtraDataCodec the bft extra data codec + */ + public BftBlockInterface(final BftExtraDataCodec bftExtraDataCodec) { + this.bftExtraDataCodec = bftExtraDataCodec; + } + + @Override + public Address getProposerOfBlock(final BlockHeader header) { + return header.getCoinbase(); + } + + @Override + public Address getProposerOfBlock(final org.hyperledger.besu.plugin.data.BlockHeader header) { + return Address.fromHexString(header.getCoinbase().getBytes().toHexString()); + } + + @Override + public Optional extractVoteFromHeader(final BlockHeader header) { + final BftExtraData bftExtraData = bftExtraDataCodec.decode(header); + + if (bftExtraData.getVote().isPresent()) { + final Vote headerVote = bftExtraData.getVote().get(); + final ValidatorVote vote = + new ValidatorVote( + headerVote.isAuth() ? VoteType.ADD : VoteType.DROP, + getProposerOfBlock(header), + headerVote.getRecipient()); + return Optional.of(vote); + } + return Optional.empty(); + } + + @Override + public Collection

validatorsInBlock(final BlockHeader header) { + final BftExtraData bftExtraData = bftExtraDataCodec.decode(header); + return bftExtraData.getValidators(); + } + + /** + * Replace round in block. + * + *

AERE ANCHOR V2, MEASURED DEFECT, FIXED HERE. This method used the five argument {@link + * BftExtraData} constructor, which defaults the Falcon seal collection to EMPTY, so every round + * replacement silently DELETED element 6. That is harmless while element 6 only carries a + * log-only attestation, and fatal under V2: the round change path + * ({@code QbftRound.startRoundWith} to {@code replaceRoundAndProposerForProposalBlock}) re-uses a + * prepared block, and it would have re-proposed it with vanityData still carrying the digest D of + * a certificate that had just been stripped out. The digest rule would then reject every single + * re-proposed block, on a chain where rounds above zero do occur (measured 1 in 307). + * + *

Carrying the seals across is safe for every existing user of this method, because the Falcon + * list is excluded from BOTH hash pre-images: neither the committed-seal hash nor the on-chain + * block hash changes. IBFT, which shares this class, always has an empty list here. + * + * @param block the block + * @param round the round + * @param blockHeaderFunctions the block header functions + * @return the block + */ + public Block replaceRoundInBlock( + final Block block, final int round, final BlockHeaderFunctions blockHeaderFunctions) { + final BftExtraData prevExtraData = bftExtraDataCodec.decode(block.getHeader()); + final BftExtraData substituteExtraData = + new BftExtraData( + prevExtraData.getVanityData(), + prevExtraData.getSeals(), + prevExtraData.getVote(), + round, + prevExtraData.getValidators(), + prevExtraData.getFalconSeals()); + + final BlockHeaderBuilder headerBuilder = BlockHeaderBuilder.fromHeader(block.getHeader()); + headerBuilder + .extraData(bftExtraDataCodec.encode(substituteExtraData)) + .blockHeaderFunctions(blockHeaderFunctions); + + final BlockHeader newHeader = headerBuilder.buildBlockHeader(); + + return new Block(newHeader, block.getBody()); + } + + /** + * Gets extra data. + * + * @param header the header + * @return the extra data + */ + public BftExtraData getExtraData(final BlockHeader header) { + return bftExtraDataCodec.decode(header); + } + + /** + * Gets committers. + * + * @param header the header + * @return the committers + */ + public List

getCommitters(final BlockHeader header) { + final BftExtraData bftExtraData = bftExtraDataCodec.decode(header); + + final Hash committerHash = + Hash.hash( + BftBlockHashing.serializeHeader( + header, + () -> bftExtraDataCodec.encodeWithoutCommitSeals(bftExtraData), + bftExtraDataCodec)); + + return bftExtraData.getSeals().stream() + .map(p -> Util.signatureToAddress(p, committerHash)) + .collect(Collectors.toList()); + } +} diff --git a/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/BftExtraData.java b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/BftExtraData.java new file mode 100644 index 0000000..e911ca4 --- /dev/null +++ b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/BftExtraData.java @@ -0,0 +1,163 @@ +/* + * Copyright ConsenSys AG. + * + * 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 com.google.common.base.Preconditions.checkNotNull; + +import org.hyperledger.besu.crypto.SECPSignature; +import org.hyperledger.besu.datatypes.Address; +import org.hyperledger.besu.ethereum.core.ParsedExtraData; + +import java.util.Collection; +import java.util.Collections; +import java.util.Optional; + +import org.apache.tuweni.bytes.Bytes; + +/** The Bft extra data. */ +public class BftExtraData implements ParsedExtraData { + private final Bytes vanityData; + private final Collection seals; + private final Collection
validators; + private final Optional vote; + private final int round; + + /** + * Parallel, ADDITIVE post-quantum (Falcon-512) seals. These sit alongside the decisive ECDSA + * {@link #seals} and are excluded from every signed pre-image, so they never disturb the ECDSA + * committed seals or the block/header hash. + */ + private final Collection falconSeals; + + /** + * Instantiates a new Bft extra data (no Falcon seals). Retained for callers, notably the IBFT + * codec, that never carry a parallel post-quantum seal. + * + * @param vanityData the vanity data + * @param seals the seals + * @param vote the vote + * @param round the round + * @param validators the validators + */ + public BftExtraData( + final Bytes vanityData, + final Collection seals, + final Optional vote, + final int round, + final Collection
validators) { + this(vanityData, seals, vote, round, validators, Collections.emptyList()); + } + + /** + * Instantiates a new Bft extra data with a parallel Falcon-512 seal list. + * + * @param vanityData the vanity data + * @param seals the ECDSA committed seals (decisive) + * @param vote the vote + * @param round the round + * @param validators the validators + * @param falconSeals the parallel post-quantum Falcon seals (additive, log-only) + */ + public BftExtraData( + final Bytes vanityData, + final Collection seals, + final Optional vote, + final int round, + final Collection
validators, + final Collection falconSeals) { + checkNotNull(vanityData); + checkNotNull(seals); + checkNotNull(validators); + checkNotNull(falconSeals); + this.vanityData = vanityData; + this.seals = seals; + this.validators = validators; + this.vote = vote; + this.round = round; + this.falconSeals = falconSeals; + } + + /** + * Gets vanity data. + * + * @return the vanity data + */ + public Bytes getVanityData() { + return vanityData; + } + + /** + * Gets seals. + * + * @return the seals + */ + public Collection getSeals() { + return seals; + } + + /** + * Gets validators. + * + * @return the validators + */ + public Collection
getValidators() { + return validators; + } + + /** + * Gets vote. + * + * @return the vote + */ + public Optional getVote() { + return vote; + } + + /** + * Gets round. + * + * @return the round + */ + public int getRound() { + return round; + } + + /** + * Gets the parallel post-quantum Falcon seals. + * + * @return the Falcon seals (possibly empty, never null) + */ + public Collection getFalconSeals() { + return falconSeals; + } + + @Override + public String toString() { + return "BftExtraData{" + + "vanityData=" + + vanityData + + ", seals=" + + seals + + ", validators=" + + validators + + ", vote=" + + vote + + ", round=" + + round + + ", falconSeals=" + + falconSeals + + '}'; + } +} diff --git a/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/FalconSeal.java b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/FalconSeal.java new file mode 100644 index 0000000..578b6f5 --- /dev/null +++ b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/FalconSeal.java @@ -0,0 +1,88 @@ +/* + * Copyright contributors to Besu / Aere Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft; + +import java.util.Objects; + +import org.apache.tuweni.bytes.Bytes; + +/** + * A parallel post-quantum (Falcon-512) seal attached to a BFT block header, ALONGSIDE (never + * replacing) the decisive ECDSA committed seals. + * + *

Because Falcon signatures are not public-key-recoverable, each seal carries an explicit {@code + * validatorIndex} so a verifier can look up the signer's Falcon public key in the validator to + * Falcon-pubkey registry. This is an additive, log-only attestation for the hybrid PQC demo; ECDSA + * committed seals remain the liveness-critical seal that decides block acceptance. + */ +public class FalconSeal { + private final int validatorIndex; + private final Bytes signature; + + /** + * Instantiates a new Falcon seal. + * + * @param validatorIndex the index of the signing validator in the Falcon registry + * @param signature the Falcon-512 signature over the block commit hash + */ + public FalconSeal(final int validatorIndex, final Bytes signature) { + this.validatorIndex = validatorIndex; + this.signature = signature; + } + + /** + * Gets the validator index. + * + * @return the validator index used to look up the Falcon public key + */ + public int getValidatorIndex() { + return validatorIndex; + } + + /** + * Gets the Falcon signature bytes. + * + * @return the Falcon-512 signature + */ + public Bytes getSignature() { + return signature; + } + + @Override + public boolean equals(final Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + final FalconSeal that = (FalconSeal) o; + return validatorIndex == that.validatorIndex && Objects.equals(signature, that.signature); + } + + @Override + public int hashCode() { + return Objects.hash(validatorIndex, signature); + } + + @Override + public String toString() { + return "FalconSeal{validatorIndex=" + + validatorIndex + + ", sigLen=" + + (signature == null ? 0 : signature.size()) + + '}'; + } +} diff --git a/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/FalconSealSupport.java b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/FalconSealSupport.java new file mode 100644 index 0000000..b042e15 --- /dev/null +++ b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/FalconSealSupport.java @@ -0,0 +1,3709 @@ +/* + * Copyright contributors to Besu. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft; + +import java.io.FileInputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.Iterator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.OptionalInt; +import java.util.Properties; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.LongPredicate; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.tuweni.bytes.Bytes; +import org.apache.tuweni.bytes.Bytes32; +import org.bouncycastle.crypto.digests.KeccakDigest; +import org.bouncycastle.pqc.crypto.falcon.FalconParameters; +import org.bouncycastle.pqc.crypto.falcon.FalconPrivateKeyParameters; +import org.bouncycastle.pqc.crypto.falcon.FalconPublicKeyParameters; +import org.bouncycastle.pqc.crypto.falcon.FalconSigner; +import org.hyperledger.besu.datatypes.Address; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * BFT-local, PARALLEL Falcon-512 signer + verifier + registry for the Falcon consensus SEAL: a + * post-quantum signature carried ALONGSIDE the classical seals. + * + *

NAMING, and this is not pedantry. This line used to read "for the hybrid post-quantum + * consensus seal". AERE DOES NOT HAVE POST-QUANTUM CONSENSUS and must never be described as having + * it: proposer selection and finality are classical secp256k1 ECDSA QBFT, and the post-quantum + * layer is signature, precompile and account level. Our audit scope tells reviewers to FLAG that + * phrase wherever it appears in code comments. It appeared here. + * + *

This deliberately does NOT ride the shared secp256k1 {@code NodeKey} / {@code SecurityModule} + * singleton (that interface returns ECDSA R,S and is used chain-wide for transactions and devp2p). + * The Falcon key here is a second, consensus-only key. + * + *

AERE audit fix (AUD-CONSENSUS-1 / AUD-CONSENSUS-2, 2026-07-18): the registry now binds each + * Falcon-512 public key to a validator ADDRESS, not merely to an index. This is what makes the + * "eligible signer" set {@code currentValidators INTERSECT registry} well defined, so the blocking + * rule can compute BOTH the Falcon quorum AND the counted-seal set over the SAME set of validator + * addresses (see {@code FalconSealValidationRule}). Without the address binding the registry is only + * a bag of indexed keys with no tie to the live validator set, which is exactly the decoupling that + * let a routine add-validator vote raise the ECDSA-sized quorum above the fixed key count and halt a + * post-fork chain, and let a removed validator's key keep counting. + * + *

Configuration (system property takes precedence over environment variable): + * + *

    + *
  • {@code aere.falcon.key} / {@code AERE_FALCON_KEY}: path to this node's Falcon private key + * .properties file with keys {@code index, f, g, F, pk} (hex, low-level BouncyCastle Falcon + * components). This is a per-node local SECRET and never appears in any registry. + *
  • {@code aere.falcon.genesis} / {@code AERE_FALCON_GENESIS}: path to the chain's genesis file + * carrying a GENESIS-ANCHORED validator-to-Falcon-pubkey manifest. Tamper-evident and + * immutable from genesis. Highest precedence. + *
  • {@code aere.falcon.manifest} / {@code AERE_FALCON_MANIFEST} together with {@code + * aere.falcon.anchor.address} / {@code AERE_FALCON_ANCHOR_ADDRESS}: the STAGE-2 + * LATE-ANCHOR (contract-anchored) registry source for chains that launched WITHOUT a genesis + * manifest (i.e. an already-live chain). The manifest file carries the full + * index-to-{addr,pk} JSON object; the anchor contract's storage slot 0 must hold + * {@code keccak256(canonical manifest)}. The registry stays EMPTY (pending) until consensus + * code observes the anchor contract in the chain's world state and calls {@link + * #activateLateAnchor}; activation is fail-closed on any hash mismatch. This removes the + * re-genesis requirement for activating the hybrid PQC certificate on an existing chain. + *
  • {@code aere.falcon.registry} / {@code AERE_FALCON_REGISTRY}: (LEGACY, mutable-file + * fallback) path to the validator to Falcon-pubkey registry .properties file. Lowest + * precedence; retained for backward compatibility and the upstream unit tests. This legacy + * source carries pubkeys ONLY (no validator address), so a registry loaded from it is NOT + * address-bound and is NOT eligible to arm blocking (see {@link #addressBound()}). + *
  • {@code aere.falcon.forkBlock} / {@code AERE_FALCON_FORKBLOCK}: block number at and after + * which the Falcon quorum certificate is BLOCKING. Unset means never blocking (log-only). + *
+ * + *

Every method is defensive: any load, parse, sign or verify failure is swallowed and logged, so + * a Falcon fault never THROWS on the consensus path. + * + *

THAT IS NO LONGER THE SAME THING AS "IT CANNOT HALT THE CHAIN", and the sentence that used to + * claim so has been removed rather than softened. Not throwing only means the failure is returned + * as a verdict, and two consumers now turn that verdict into a REJECT: {@code + * FalconSealValidationRule} once {@code aere.falcon.forkBlock} is reached with an active registry, + * and {@code PqAnchorSealsRule} (V2 R2) at and above {@code pqAnchorBlock}, which refuses a header + * whenever {@link #addressForIndex} returns null or {@link #verify} returns false. A node whose + * registry file fails to load therefore rejects every header carrying a certificate, i.e. it halts + * itself, and two nodes with DIFFERENT registry files disagree about which headers are valid. That + * is the unbound-registry defect, and it is why the registry has to be bound to genesis by {@code pqRegistryHash} + * before any of this is armed. While both of those gates are unset - which is the state of chain + * 2800 today - the subsystem is log-only and the old sentence holds; it is not a property of this + * file, it is a property of the configuration. + */ +public final class FalconSealSupport { + + private static final Logger LOG = LoggerFactory.getLogger(FalconSealSupport.class); + + private static volatile FalconSealSupport instance; + + /** + * System anchor address whose storage slot 0 holds keccak256 of the canonical Falcon manifest. + * Because genesis {@code alloc} storage is committed into the genesis stateRoot (and hence the + * genesis block hash), the manifest hash is bound to the chain identity itself: two nodes that + * agree on the chain necessarily agree on the manifest hash, and it is independently queryable + * via {@code eth_getStorageAt(0x..0fa1, 0x0)}. 40-hex, lower-case, no 0x prefix. + */ + private static final String ANCHOR_ADDRESS = "0000000000000000000000000000000000000fa1"; + + /** Storage slot (0) at the anchor address holding the manifest hash. 64-hex, no 0x prefix. */ + private static final String ANCHOR_SLOT = + "0000000000000000000000000000000000000000000000000000000000000000"; + + private final boolean signingEnabled; + private final int localIndex; + private final FalconPrivateKeyParameters localPrivateKey; + private final Map registry; + + /** + * Validator ADDRESS bound to each registry index (AUD-CONSENSUS-1 fix). Populated in lockstep + * with {@link #registry} from an address-bound manifest; empty for the legacy pubkey-only source. + * This is the map that makes {@code currentValidators INTERSECT registry} computable. + */ + private final Map indexToAddress; + + private final boolean genesisAnchored; + + /** + * AERE GENESIS BINDING: the registry file this node ACTUALLY used, and which of the three sources it came from. + * Recorded so the consensus-binding guard can re-read exactly that file and hash it, rather than + * hashing something adjacent to it. Null when no registry is configured at all. + */ + private final String registrySourcePath; + + private final PqRegistryHash.SourceKind registrySourceKind; + + /** Outcome of the genesis-binding guard; NOT_CHECKED equivalent is null until it has run. */ + private volatile PqRegistryHash.GateState registryBindingState; + + /** + * AERE GENESIS BINDING (per-block half): the schedule the startup guard actually enforced, the registry object + * it hashed, and the chain id it hashed under. Held so that {@link #registryBindingSatisfiedAt} + * can answer the SAME question on the per-block consensus path without re-reading a file 61 + * million times a year, and without a second, drifting copy of the decision. + * + *

Null until the guard has run. Null means the per-block rule is INERT, which is the only safe + * default: a node whose controller never called the guard (a non-QBFT path, or a unit test) must + * not start rejecting headers because of a binding nobody configured. + */ + private volatile PqRegistryHash.Schedule registryBindingSchedule; + + private volatile PqRegistryHash.Registry registryBindingLoaded; + + /** + * HEIGHT SCHEDULE: every registry this node holds, bound to the schedule entry each one satisfies. + * + *

{@link #registryBindingLoaded} above is the registry for the HEAD, and it is what the node + * signs with. It is kept because every diagnostic message names it. This field is the whole + * scheduled history, and it is what the per-block binding question and height-resolved + * verification are answered from. With no history configured the set holds exactly the one + * registry above, so both answers are bit-for-bit what they were before the height schedule existed. + */ + private volatile PqRegistryHash.RegistrySet registryBindingSet; + + /** HEIGHT SCHEDULE: per (schedule entry, index) Falcon public keys, built on demand from the set. */ + private final Map historicalKeys = new ConcurrentHashMap<>(); + + private volatile long registryBindingChainId; + + /** Highest height at which the per-block binding rule has already shouted, to bound the log. */ + private final java.util.concurrent.atomic.AtomicLong registryBindingLastShout = + new java.util.concurrent.atomic.AtomicLong(-1L); + + /** + * AERE OPTIUNI-URGENTA (2026-08-02): the operator has EXPLICITLY asked this node to run even + * though its Falcon registry does not satisfy the binding genesis requires. + * + *

WHY THIS EXISTS AT ALL. The genesis-binding guard is a CONFIGURATION guard: it refuses to start a node + * whose registry file is not the one the chain names. That refusal is correct and it is + * fail-closed, and it also means a single bad registry file pushed to the fleet takes the fleet + * down and the only documented way back was to rebuild or to hand-edit a service unit at whatever + * hour it happened. This flag is the way back. It is read ONCE, in the constructor, from {@value + * #PROPERTY_REGISTRY_MISMATCH_ALLOW} or its environment form, which is where the command-line + * option {@code --Xaere-pq-registry-mismatch-allow} writes. + * + *

WHY IT IS NOT A QUIET ONE. A silent bypass is worse than no bypass, because a fleet can run + * for months on it and nobody finds out until the property it was supposed to guarantee is needed. + * So: a full-width banner at startup, and one complete ERROR line at EVERY height at which the + * bypass actually carries weight, for as long as the node runs. + */ + private final boolean registryMismatchAllow; + + /** + * True once the bypass has ACTUALLY suppressed a refusal, at startup or on the block path. Distinct + * from {@link #registryMismatchAllow}, which only says the operator armed it: a node whose registry + * is fine must not announce that it is running unverified. + */ + private volatile boolean registryOverrideEngaged; + + /** Highest height at which the unsafe-override announcement has already been made. */ + private final java.util.concurrent.atomic.AtomicLong registryOverrideLastShout = + new java.util.concurrent.atomic.AtomicLong(-1L); + + private volatile String manifestHash; + + // ---- STAGE-2 LATE-ANCHOR (contract-anchored) state ---- + /** Parsed-but-not-yet-activated manifest pubkeys (validator index -> Falcon-512 pubkey). */ + private final Map pendingLate; + /** Parsed-but-not-yet-activated validator addresses (validator index -> address). */ + private final Map pendingLateAddresses; + /** keccak256(canonical pending manifest), 64-hex no 0x; null when no late manifest configured. */ + private final String pendingLateHash; + /** The on-chain anchor CONTRACT address (0x-hex) whose slot 0 must equal pendingLateHash. */ + private final String anchorContractAddress; + + private volatile boolean lateActivated; + private volatile boolean lateFailed; + + // ---- AERE FIX-OPRIRE-CONSENS: SEAL-ATTACHMENT GATE ---- + // + // The halting bug this state exists to prevent: seal ATTACHMENT used to be gated on nothing but + // key presence. The moment an operator dropped a Falcon key onto one validator and restarted it, + // that node began emitting Falcon-carrying Commit messages. Every peer still running the stock + // binary decodes Commit with a STRICT rlpInput.leaveList(), threw MalformedRLPInputException, and + // DISCARDED the entire Commit - including the decisive ECDSA seal it carried. A rolling + // key-by-key restart therefore walks the fleet straight through a state where the stock nodes + // cannot see enough ECDSA commit seals to reach quorum. + // + // The gate makes that order impossible: attachment is OFF until an explicitly configured + // ACTIVATION HEIGHT is reached AND the anchored registry covers every current validator. Both are + // fleet-wide facts, identical on every node, so all nodes flip together at the same block. Key + // presence alone now does nothing at all. + + /** Minimum gap required between the attachment height and the blocking fork height. */ + private static final long DEFAULT_MIN_ATTACH_LEAD = 256L; + + /** + * Minimum validator-set size for which BLOCKING Falcon quorum may be armed. At N=7 the Falcon + * quorum (5 of 7) equals the ECDSA quorum, so the Falcon layer has ZERO extra fault margin: a + * single keyed validator whose Falcon key is faulty or whose process is down becomes a new + * liveness dependency and stops the chain. N>=9 restores a one-fault Falcon margin. + */ + private static final int MIN_BLOCKING_VALIDATORS = 9; + + // ---- AERE ARMING GUARD (2026-08-01): the CHAIN-RELATIVE half of the attachment guard ---- + // + // The constructor-time guards below can only check the configuration against ITSELF. They have no + // chain, so they cannot answer the one question that matters most: is the configured attachment + // height still AHEAD of us? Measured hole (run P1-PAST-ATTACH-RESTART): a fleet configured exactly + // per the documented activation order, with aere.falcon.attachBlock UNSET everywhere, emitted + // nothing for 60 s. One validator was then restarted with aere.falcon.attachBlock=5 while the + // chain head was 19. It started with no complaint and ARMED at block 21, and each of the four + // stock peers immediately began discarding its Commit messages. A procedure is not a guard. + // + // Two things must be true for the fleet to flip together at one block: + // (i) the height has not already gone by; and + // (ii) every node is already PARTICIPATING in consensus before that height arrives, otherwise a + // node that is still starting up arms at the first block it commits, which is a different + // block from the rest of the fleet. Measured on the scratch fleet: 45.2 s elapse between + // the startup config guard and the first commit this node takes part in. + // The margin below is what makes (ii) true, which is why it is a margin and not a mere > head. + + /** + * Default number of blocks by which {@code aere.falcon.attachBlock} must LEAD this node's local + * chain head at startup. Sized from the measured startup-to-first-commit window (45.2 s, i.e. ~46 + * blocks on the 1 s scratch fleet) with a wide safety factor, so that a node which is still + * opening its database, syncing and joining the network cannot have the activation height arrive + * underneath it. Chains with a sub-second block period must RAISE this in proportion: the + * margin has to cover the same wall-clock startup window at the faster block rate. + */ + private static final long DEFAULT_MIN_ATTACH_FUTURE_MARGIN = 1024L; + + /** + * How many blocks back from the chain head the startup guard looks for positive proof that the + * fleet has ALREADY armed at the configured height (a header carrying at least one Falcon seal). + * Once the fleet arms, every subsequent block carries seals, so the evidence is at the head; the + * lookback only absorbs a short tail. + */ + private static final int ACTIVATION_EVIDENCE_LOOKBACK = 128; + + /** + * File written inside the node's data directory when the startup guard ADMITS an attachment height + * that was safely in the future. Its only job is to tell a later restart "this node was already + * admitted with this exact activation height, on this exact registry", so that a crash during an + * activation window is not turned into a permanent refusal to start. + */ + private static final String ADMISSION_RECEIPT_FILE = "aere-falcon-activation.receipt"; + + /** + * HEIGHT SCHEDULE: the registries this node holds for SCHEDULED HEIGHTS IT IS NO LONGER AT, as a + * comma-separated list of files. Env: {@code AERE_FALCON_REGISTRY_HISTORY}. + * + *

WHY A SECOND PROPERTY AND NOT A LIST IN THE FIRST. {@code aere.falcon.registry} names the + * registry this node SIGNS with, and there can only ever be one of those. This names the ones it + * must still be able to VERIFY against, and there is one per rotation the chain has ever done. + * Keeping them apart means a rotation adds a file to this list and changes the first property, and + * neither operation can be confused with the other. + * + *

The list is never pruned. Cosmos ADR-016 may bound its rotation history by the unbonding + * period; chain 2800 has no unbonding period and a node syncing from genesis verifies every block + * ever produced, so every registry ever scheduled has to stay here forever. + */ + public static final String PROPERTY_REGISTRY_HISTORY = "aere.falcon.registry.history"; + + /** + * AERE OPTIUNI-URGENTA: system property behind the command-line option {@code + * --Xaere-pq-registry-mismatch-allow}. Env: {@code AERE_PQ_REGISTRY_MISMATCH_ALLOW}. Setting it + * lets this node start and keep running with a Falcon registry that does NOT satisfy the binding + * genesis requires, at the price of one full ERROR line at every height where that matters. + */ + public static final String PROPERTY_REGISTRY_MISMATCH_ALLOW = "aere.pq.registry.mismatchAllow"; + + /** Environment-variable form of {@link #PROPERTY_REGISTRY_MISMATCH_ALLOW}. */ + public static final String ENV_REGISTRY_MISMATCH_ALLOW = "AERE_PQ_REGISTRY_MISMATCH_ALLOW"; + + /** + * AERE LATE-ANCHOR HEIGHT: the DECLARED height at or after which the on-chain late-anchor registry contract is + * expected to be observable. Mandatory whenever a blocking fork height is armed over a late-anchor + * registry that is still pending; see {@link #validateAnchorObservationHeightOrAbort}. + */ + public static final String PROPERTY_ANCHOR_OBSERVE_BLOCK = "aere.falcon.anchor.block"; + + /** Environment-variable form of {@link #PROPERTY_ANCHOR_OBSERVE_BLOCK}. */ + public static final String ENV_ANCHOR_OBSERVE_BLOCK = "AERE_FALCON_ANCHOR_BLOCK"; + + /** Activation height for seal ATTACHMENT; Long.MAX_VALUE means NEVER attach. */ + private final long attachBlock; + + /** + * AERE LATE-ANCHOR HEIGHT: the BLOCKING height, resolved and validated exactly ONCE, at construction, and + * owned thereafter. Long.MAX_VALUE means never blocking. + * + *

It used to be a property read on every call to {@link #forkBlock()}, with a + * NumberFormatException swallowed into "never blocking, log-only" plus one WARN line. The startup + * guard added for AUD-CONSENSUS-3 validated the property but did not TAKE it, so the one value the + * whole PQC layer is gated on could still turn itself off after the guard had passed, at any call, + * on any input the guard never saw. A value that is checked at the boundary and re-parsed at every + * use is not checked. It is parsed here, once, or the node does not start. + */ + private final long forkBlock; + + /** + * AERE LATE-ANCHOR HEIGHT: the height at and after which the on-chain LATE-ANCHOR registry contract is expected + * to be observable, as DECLARED by the operator. Long.MAX_VALUE when undeclared. + * + *

This value does not activate anything. It exists because without it the ordering that the + * whole late-anchor activation depends on cannot be stated, and therefore cannot be checked: the + * registry becomes usable at a height that only the chain knows, while blocking arms at a height + * only the configuration knows. Declaring it is what lets the two be compared before the node + * joins the network. + */ + private final long anchorObserveBlock; + + /** + * AERE LATE-ANCHOR HEIGHT: first height at which this node validated a header at or after the blocking height + * while the anchored registry was NOT active, or -1 if that has never happened. + * + *

This is the residual the configuration guard cannot close: an operator may declare the + * observation height correctly and the anchor transaction may still fail to land. The header rule + * deliberately stays LOG-ONLY there rather than halt the chain (see AUD-CONSENSUS-4), which is the + * right trade and is also exactly how the condition used to disappear: log-only, one line, nothing + * a command could ever read. Now it leaves a mark. + */ + private final AtomicLong blockingArmedWithoutRegistrySince = new AtomicLong(-1L); + + /** Outcome of the chain-relative startup guard; NOT_CHECKED until it has run. */ + private volatile ChainRelativeGateState chainRelativeState = ChainRelativeGateState.NOT_CHECKED; + + /** Height at which activation evidence was found on chain, or -1. */ + private volatile long armedEvidenceHeight = -1L; + + /** Latest validator set observed from the header-validation path, or null if never observed. */ + private volatile Set

observedValidators; + + /** Height at which {@link #observedValidators} was captured. */ + private volatile long observedValidatorsHeight = -1L; + + /** One-shot latches so the gate diagnostics do not spam one line per block. */ + private final AtomicBoolean loggedAttachmentOff = new AtomicBoolean(false); + + private final AtomicBoolean loggedAttachmentOn = new AtomicBoolean(false); + + private final AtomicBoolean loggedCoverageBlocked = new AtomicBoolean(false); + + /** + * LOOKUP HARDENING (b): the once-only latch for the refusal to resolve keys at an armed height with no + * height-to-registry binding. One line per block at a 523 ms period is a hazard, not a diagnostic. + */ + private final AtomicBoolean loggedUnboundArmedHeight = new AtomicBoolean(false); + + /** + * AERE COVERAGE MARGIN (2026-08-02): the last coverage situation this node reported, as "N/R", so the + * coverage line is emitted exactly when the situation CHANGES and not once per block. Null means + * nothing has been reported yet. + */ + private volatile String lastCoverageSignature; + + private FalconSealSupport() { + int idx = -1; + FalconPrivateKeyParameters priv = null; + final Map reg = new ConcurrentHashMap<>(); + final Map regAddr = new ConcurrentHashMap<>(); + String mHash = null; + boolean anchored = false; + Map late = null; + Map lateAddr = null; + String lateHash = null; + String anchorAddr = null; + + // Registry source resolution order: + // 1) GENESIS-ANCHORED manifest (aere.falcon.genesis) - tamper-evident, immutable, preferred. + // 2) LATE-ANCHOR manifest + contract (aere.falcon.manifest + aere.falcon.anchor.address) - + // Stage-2 activation path for an ALREADY-LIVE chain: pending until the anchor contract is + // observed on-chain and the manifest hash matches (fail-closed). + // 3) LEGACY shared .properties file (aere.falcon.registry) - backward-compatible fallback. + final String genesisPath = resolve("aere.falcon.genesis", "AERE_FALCON_GENESIS"); + final String manifestPath = resolve("aere.falcon.manifest", "AERE_FALCON_MANIFEST"); + final String anchorProp = resolve("aere.falcon.anchor.address", "AERE_FALCON_ANCHOR_ADDRESS"); + final String registryPath = resolve("aere.falcon.registry", "AERE_FALCON_REGISTRY"); + if (genesisPath != null) { + mHash = loadGenesisAnchoredRegistry(genesisPath, reg, regAddr); + anchored = mHash != null; + } else if (manifestPath != null && anchorProp != null) { + final Map pend = new HashMap<>(); + final Map pendAddr = new HashMap<>(); + lateHash = loadPendingLateManifest(manifestPath, pend, pendAddr); + if (lateHash != null) { + late = pend; + lateAddr = pendAddr; + anchorAddr = anchorProp.trim(); + LOG.info( + "AERE PQC: LATE-ANCHOR manifest loaded from {} ({} entries, address-bound={}), " + + "keccak256(manifest)=0x{}; registry PENDING until the on-chain anchor contract " + + "{} (slot 0) is observed to match. Fail-closed on mismatch.", + manifestPath, + pend.size(), + !pendAddr.isEmpty() && pendAddr.size() == pend.size(), + lateHash, + anchorAddr); + } else { + LOG.error( + "AERE PQC: failed to load LATE-ANCHOR manifest from {}; registry EMPTY (fail-closed).", + manifestPath); + } + } else if (registryPath != null) { + // AERE GENESIS BINDING REPAIR (2026-08-02): the properties source the design prescribed was measured to + // HALT EVERY NODE at the activation height. Not by being mutable, and not by disagreeing with + // a hash: by carrying public keys and NO ADDRESSES. addressForIndex() then returns null for + // every index, and the V2 seals rule (R2) refuses every header that carries a certificate. + // Measured on three observers with the data directory wiped, on the SAME honest chain: + // genesis-anchored address-bound registry reached head 374 with R1=0 R2=0, while this source + // stopped at head 19 with R2=4 rejections, indistinguishable from having no registry at all. + // + // The repair is in two halves and both are needed. FIRST, this loader now reads the optional + // ".addr=0x<20 bytes>" row, exactly the shape PqRegistryHash.loadPropertiesRegistry + // already accepts, so the prescribed source CAN be address-bound and CAN carry the fleet past + // activation. SECOND, armingReadinessDiagnostic() below no longer merely logs when an + // activation is configured over a registry that is still pubkey-only: it refuses to start. + // A loud refusal at startup and a silent fleet-wide halt at the activation height are not the + // same event, and only one of them is survivable. + try (InputStream in = new FileInputStream(registryPath)) { + final Properties p = new Properties(); + p.load(in); + final Map propAddr = new java.util.TreeMap<>(); + for (final String name : p.stringPropertyNames()) { + if ("count".equals(name)) { + continue; + } + try { + final String key = name.trim(); + if (key.endsWith(".addr")) { + final int i = Integer.parseInt(key.substring(0, key.length() - ".addr".length())); + final byte[] a = Bytes.fromHexStringLenient(p.getProperty(name).trim()).toArray(); + if (a.length != 20) { + throw new IllegalArgumentException( + "address is " + a.length + " bytes, expected exactly 20"); + } + // Built through the canonical hex of the VALIDATED 20 bytes, not from the raw string + // and not through Address.wrap: the lenient hex parser above accepts shapes the + // strict one does not, so re-rendering the checked bytes is the only spelling that + // cannot disagree with the length check that just passed. + propAddr.put(i, Address.fromHexString(Bytes.wrap(a).toHexString())); + } else { + final int i = Integer.parseInt(key); + final byte[] h = Bytes.fromHexStringLenient(p.getProperty(name).trim()).toArray(); + reg.put(i, new FalconPublicKeyParameters(FalconParameters.falcon_512, h)); + } + } catch (final RuntimeException e) { + LOG.warn("Skipping malformed Falcon registry entry '{}': {}", name, e.toString()); + } + } + // Address binding is ALL-OR-NOTHING. A half-bound registry is worse than an unbound one: + // some indices would resolve and some would not, so the fleet would reject a header that + // one operator can verify and another cannot, which is the same defect wearing a different hat. + final boolean fullyBound = !reg.isEmpty() && propAddr.keySet().equals(reg.keySet()); + if (fullyBound) { + regAddr.putAll(propAddr); + } else if (!propAddr.isEmpty()) { + LOG.error( + "AERE PQC GENESIS-BINDING: LEGACY registry file {} carries {} '.addr' rows for {} keys. A " + + "PARTIALLY address-bound registry is refused as a binding source (all-or-" + + "nothing): some indices would resolve to a validator address and some would " + + "not, so two nodes would disagree about which headers are valid. Treating this " + + "registry as pubkey-only.", + registryPath, + propAddr.size(), + reg.size()); + } + LOG.info( + "AERE PQC: loaded Falcon validator registry ({} entries, address-bound={}) from LEGACY " + + "file {} (mutable; superseded by aere.falcon.genesis / aere.falcon.manifest). " + + "{}", + reg.size(), + fullyBound, + registryPath, + fullyBound + ? "Address-bound, so the eligible-signer intersection is computable and this " + + "registry CAN carry the fleet past an activation height; it is still bound to " + + "consensus only if config.pqRegistryHash names its hash." + : "PUBKEY-ONLY, so addressForIndex() returns null for every index and EVERY header " + + "carrying a certificate would be rejected at the activation height. NOT " + + "eligible to arm blocking."); + } catch (final Exception e) { + LOG.warn( + "AERE PQC: failed to load Falcon registry from {}: {}", registryPath, e.toString()); + } + } else { + LOG.info( + "AERE PQC: no Falcon registry configured (aere.falcon.genesis/aere.falcon.manifest/" + + "aere.falcon.registry); hybrid seal verification will be a no-op."); + } + + // Load this node's Falcon private key (only present on validators that should sign). + final String keyPath = resolve("aere.falcon.key", "AERE_FALCON_KEY"); + if (keyPath != null) { + try (InputStream in = new FileInputStream(keyPath)) { + final Properties p = new Properties(); + p.load(in); + idx = Integer.parseInt(p.getProperty("index").trim()); + final byte[] f = Bytes.fromHexStringLenient(p.getProperty("f").trim()).toArray(); + final byte[] g = Bytes.fromHexStringLenient(p.getProperty("g").trim()).toArray(); + final byte[] bigF = Bytes.fromHexStringLenient(p.getProperty("F").trim()).toArray(); + final byte[] pk = Bytes.fromHexStringLenient(p.getProperty("pk").trim()).toArray(); + priv = new FalconPrivateKeyParameters(FalconParameters.falcon_512, f, g, bigF, pk); + LOG.info( + "AERE PQC: loaded local Falcon signing key for validator index {} from {}", + idx, + keyPath); + } catch (final Exception e) { + LOG.warn("AERE PQC: failed to load Falcon signing key from {}: {}", keyPath, e.toString()); + idx = -1; + priv = null; + } + } + + this.registry = reg; + this.indexToAddress = regAddr; + this.localIndex = idx; + this.localPrivateKey = priv; + this.signingEnabled = priv != null && idx >= 0; + this.manifestHash = mHash; + this.genesisAnchored = anchored; + this.pendingLate = late; + this.pendingLateAddresses = lateAddr; + this.pendingLateHash = lateHash; + this.anchorContractAddress = anchorAddr; + + // AERE GENESIS BINDING: remember WHICH file the registry came from, so the consensus-binding guard hashes + // the file this node is really running on and not a different one that happens to be nearby. + if (genesisPath != null) { + this.registrySourcePath = genesisPath; + this.registrySourceKind = PqRegistryHash.SourceKind.GENESIS_MANIFEST; + } else if (manifestPath != null && anchorProp != null) { + this.registrySourcePath = manifestPath; + this.registrySourceKind = PqRegistryHash.SourceKind.MANIFEST_JSON; + } else if (registryPath != null) { + this.registrySourcePath = registryPath; + this.registrySourceKind = PqRegistryHash.SourceKind.LEGACY_PROPERTIES; + } else { + this.registrySourcePath = null; + this.registrySourceKind = PqRegistryHash.SourceKind.NONE; + } + + // AERE OPTIUNI-URGENTA (2026-08-02): read the registry bypass ONCE, here, so that every later + // decision reads one immutable field rather than re-resolving a property that could have been + // changed underneath it. Announced immediately, whether or not it ends up carrying weight, + // because "somebody armed the bypass on this node" is itself something an operator must be able + // to see in the first screen of the log. + this.registryMismatchAllow = + Boolean.parseBoolean( + resolve(PROPERTY_REGISTRY_MISMATCH_ALLOW, ENV_REGISTRY_MISMATCH_ALLOW)); + if (this.registryMismatchAllow) { + LOG.warn( + "AERE PQC EMERGENCY [AERE-PQC-EMG-REGALLOW-01]: the Falcon registry binding bypass is " + + "ARMED on this node ({}). If the registry this node holds does NOT satisfy what " + + "genesis requires, this node will START ANYWAY and will keep importing headers " + + "whose certificates it cannot correctly verify, and it will say so at every height " + + "where that is true. This is a RECOVERY setting. It is not a configuration to leave " + + "in place: remove it as soon as the correct registry is installed.", + PROPERTY_REGISTRY_MISMATCH_ALLOW); + } + + // AERE audit fix (AUD-CONSENSUS-3): fail-closed on a MALFORMED fork-block config at startup, + // before anything else. A present-but-unparseable aere.falcon.forkBlock must not silently + // degrade to never-blocking (log-only); it aborts node init here (config time, NOT the per-block + // consensus path). + // AERE LATE-ANCHOR HEIGHT: the result is TAKEN, not merely checked. Nothing re-reads the property afterwards. + this.forkBlock = validateForkBlockConfigOrAbort(); + + // AERE FIX-OPRIRE-CONSENS (b): resolve and validate the ATTACHMENT gate. Every inconsistent + // combination aborts here, at config time, before the node joins the network - never as a + // surprise mid-chain. + this.attachBlock = validateAndResolveAttachBlockOrAbort(this.forkBlock); + + // AERE LATE-ANCHOR HEIGHT: the ORDER between the blocking height and the height at which the registry that + // backs it can become active. Aborts here, at config time, for the same reason as everything + // above it: after the node has joined, the same error is a silent degradation to log-only. + this.anchorObserveBlock = + validateAnchorObservationHeightOrAbort(this.forkBlock, this.attachBlock); + + // AERE audit fix (AUD-CONSENSUS-1): fail-closed ARMING diagnostic at config/startup time. + // If a fork block is configured (blocking is intended) but the manifest that WILL back it is + // not address-bound, the eligible-signer intersection cannot be formed and every post-fork + // block will fail closed. Surface that LOUDLY here, at arm/config time, rather than as a silent + // block-production halt later. (Late-anchor: the pending manifest is checked; the live registry + // is still empty until activation, which is expected.) + armingReadinessDiagnostic(); + + // AERE ROW BINDING (2026-08-06): the SECOND arming question, and the one AERE-PQC-REG-ARM-01 cannot + // answer. Address-bound says every index has SOME address next to it. It does not say that the + // validator at that address ever held the Falcon key filed under it. Measured on the real + // verification path on 2026-08-06: a registry with two rows' public keys swapped - no duplicate + // key, no duplicate address, so no uniqueness check would have seen it - produced an ACCEPTED + // header, and one key placed at two indices satisfied a threshold of two on its own. + requireRegistryBindingProofsOrAbort(); + + // AERE COVERAGE MARGIN (2026-08-03): the arm-time comparison the repair above NAMED and did not make. + // armingReadinessDiagnostic() answers "is the manifest address-bound"; it never asks whether the + // threshold the fleet is about to arm is one the fleet can be guaranteed to MEET. + validateThresholdReachabilityOrAbort(); + } + + /** + * AERE audit fix (AUD-CONSENSUS-3): distinguish an UNSET fork block from a MALFORMED configured + * one. {@link #forkBlock()} on the per-block path deliberately never throws (it falls back to + * {@link Long#MAX_VALUE}); on its own that means a typo'd fork block fails OPEN to never-blocking, + * asymmetric with the manifest / anchor loaders that all fail CLOSED. This startup guard closes + * that gap: if the property/env IS present but does not parse as a NON-NEGATIVE block number, we + * ABORT node init (fail-closed). This runs at construction (node init), not during block + * validation, so it never throws on the consensus path. An UNSET value keeps the safe + * never-blocking log-only default. + * + *

AERE LATE-ANCHOR HEIGHT (2026-08-03): it now RETURNS the validated height and the constructor keeps it in + * a final field. Validating a value and then re-reading its source on every use leaves the + * original defect intact one level down, which is exactly where it was found. + * + * @return the validated blocking height, or {@link Long#MAX_VALUE} when unset + */ + private long validateForkBlockConfigOrAbort() { + final String v = resolve("aere.falcon.forkBlock", "AERE_FALCON_FORKBLOCK"); + if (v == null) { + return Long.MAX_VALUE; // unset: safe never-blocking default, nothing to validate + } + final long parsed; + try { + parsed = Long.parseLong(v.trim()); + } catch (final NumberFormatException e) { + throw new ActivationConfigException( + ActivationConfigException.Kind.SYNTAX, + "AERE-PQC-CFG-SYNTAX-03", + "AERE PQC: aere.falcon.forkBlock is set to a MALFORMED value '" + + v + + "' (not an integer block number). Refusing to start (fail-closed): a malformed fork " + + "block must not silently degrade to never-blocking / log-only. Set a valid " + + "non-negative block number, or UNSET the property to run the additive log-only " + + "baseline."); + } + if (parsed < 0) { + throw new ActivationConfigException( + ActivationConfigException.Kind.SYNTAX, + "AERE-PQC-CFG-SYNTAX-04", + "AERE PQC: aere.falcon.forkBlock '" + + v + + "' is negative. Refusing to start (fail-closed): set a valid non-negative block " + + "number, or UNSET the property to run the additive log-only baseline."); + } + return parsed; + } + + /** + * AERE LATE-ANCHOR HEIGHT: the ordering guard between the BLOCKING height and the height at which the registry + * that backs it can first be active. + * + *

THE HOLE THIS CLOSES, in the words of the code that documented it and did nothing about it. + * {@code FalconSealValidationRule} says: "If forkBlock is armed at or before the late-anchor + * observation height, the registry is not yet active when the fork block is validated". Its answer + * is to stay LOG-ONLY, which is the correct answer for a header rule - blocking over an empty + * registry provides zero safety and only a halt. But it means the misconfiguration has no + * consequence a node can be stopped by and no consequence a command can read: the operator asked + * for a post-quantum quorum, the node said yes, and the quorum is never enforced. + * + *

The check cannot be made against the chain, because the height at which an anchor contract + * lands is not knowable at config time. It is therefore made against a DECLARED height, {@code + * aere.falcon.anchor.block}, in the same spirit as EIP-7910's {@code eth_config}: state the fork + * parameters explicitly so they can be compared, instead of inferring them and finding out at the + * boundary. Declaring the height is mandatory whenever blocking is armed over a registry that is + * still pending, because a value nobody stated is a value nobody can check. + * + *

Only two rules are needed, and the second one implies what the finding is named after: + * + *

    + *
  1. a blocking height over a PENDING late anchor with no declared observation height aborts; + *
  2. {@code attachBlock < anchorObserveBlock} aborts. Because {@code forkBlock - attachBlock >= + * minAttachLead} is already enforced above, this makes {@code forkBlock > anchorObserveBlock + * + minAttachLead} a THEOREM rather than a third rule that could drift out of step with the + * other two. + *
+ * + *

Genesis-anchored and legacy-file registries are exempt on purpose: both are active from block + * 0, so there is no observation height to compare against and demanding one would break the + * deployment path that {@link #armingReadinessDiagnostic()} names as the remedy. + * + * @param fork the validated blocking height + * @param attach the validated attachment height + * @return the declared observation height, or {@link Long#MAX_VALUE} when undeclared + */ + private long validateAnchorObservationHeightOrAbort(final long fork, final long attach) { + final String raw = resolve(PROPERTY_ANCHOR_OBSERVE_BLOCK, ENV_ANCHOR_OBSERVE_BLOCK); + final long observe; + if (raw == null) { + observe = Long.MAX_VALUE; + } else { + final long parsed; + try { + parsed = Long.parseLong(raw.trim()); + } catch (final NumberFormatException e) { + throw new ActivationConfigException( + ActivationConfigException.Kind.SYNTAX, + "AERE-PQC-CFG-SYNTAX-09", + "AERE PQC: " + + PROPERTY_ANCHOR_OBSERVE_BLOCK + + " is set to a MALFORMED value '" + + raw + + "' (not an integer block number). Refusing to start (fail-closed): every " + + "aere.falcon.* height fails closed on a syntax error, uniformly, because a " + + "half-fail-closed property set is worse than either extreme - nobody can predict " + + "which half they are in."); + } + if (parsed < 0) { + throw new ActivationConfigException( + ActivationConfigException.Kind.SYNTAX, + "AERE-PQC-CFG-SYNTAX-10", + "AERE PQC: " + + PROPERTY_ANCHOR_OBSERVE_BLOCK + + " '" + + raw + + "' is negative. Refusing to start (fail-closed)."); + } + observe = parsed; + } + + // Nothing blocking is configured, or the registry is already active at block 0. In both cases + // there is no ordering to violate. + if (fork == Long.MAX_VALUE || genesisAnchored || anchorContractAddress == null) { + return observe; + } + + if (raw == null) { + throw new ActivationConfigException( + ActivationConfigException.Kind.UNSAFE, + "AERE-PQC-CFG-UNSAFE-06", + "AERE PQC: aere.falcon.forkBlock is set to " + + fork + + " (Falcon quorum BLOCKING) over a LATE-ANCHOR registry that is still PENDING (anchor " + + "contract " + + anchorContractAddress + + "), and " + + PROPERTY_ANCHOR_OBSERVE_BLOCK + + " is UNDECLARED. Refusing to start (fail-closed). WHY THIS IS REFUSED RATHER THAN " + + "WARNED: if the anchor is observed at or after the blocking height, the registry is " + + "empty when the fork block is validated, and the header rule then stays LOG-ONLY " + + "forever - correctly, because blocking over an empty registry would only halt the " + + "chain. The result is a node that was told to enforce a post-quantum quorum, " + + "reported no error, and enforces nothing. Nothing on the node can detect that, " + + "because the height the registry becomes usable at is known only to the chain and " + + "the height blocking arms at is known only to this configuration. Declare " + + PROPERTY_ANCHOR_OBSERVE_BLOCK + + " as the height at or after which the anchor contract is expected to be observable, " + + "and set aere.falcon.attachBlock at or after it."); + } + + if (attach < observe) { + throw new ActivationConfigException( + ActivationConfigException.Kind.UNSAFE, + "AERE-PQC-CFG-UNSAFE-07", + "AERE PQC: aere.falcon.attachBlock (" + + attach + + ") is BEFORE " + + PROPERTY_ANCHOR_OBSERVE_BLOCK + + " (" + + observe + + "), with aere.falcon.forkBlock=" + + fork + + ". Refusing to start (fail-closed): until the anchor is observed the registry is " + + "empty, so no node can emit a Falcon seal, so the LOG-ONLY soak window between " + + "attachment and blocking observes nothing and proves nothing. Order the three " + + "heights " + + PROPERTY_ANCHOR_OBSERVE_BLOCK + + " <= aere.falcon.attachBlock, and aere.falcon.forkBlock at least " + + minAttachLead() + + " blocks after aere.falcon.attachBlock; that ordering is also the only thing that " + + "keeps the blocking height strictly AFTER the observation height, which is the " + + "configuration this guard exists to refuse."); + } + + LOG.info( + "AERE PQC: activation ORDER accepted: {}={} <= attachBlock={}, forkBlock={} (lead {} >= " + + "{}). Blocking arms {} block(s) after the earliest height at which the anchored " + + "registry can be active.", + PROPERTY_ANCHOR_OBSERVE_BLOCK, + observe, + attach, + fork, + fork - attach, + minAttachLead(), + fork - observe); + return observe; + } + + /** + * AERE FIX-OPRIRE-CONSENS (b): resolve and validate the seal-ATTACHMENT height. Runs at + * construction (node init), never on the consensus path. Every inconsistent combination ABORTS + * here, so a fleet can never be started into a configuration that halts it later. + * + *

Rules enforced: + * + *

    + *
  1. a present but MALFORMED or negative attachBlock aborts (no silent degrade); + *
  2. configuring BLOCKING ({@code aere.falcon.forkBlock}) without an attachBlock aborts: seals + * would never be emitted, so every post-fork block would fail closed and the chain would + * stop; + *
  3. {@code attachBlock > forkBlock} aborts: blocking would arm before any seal exists; + *
  4. {@code forkBlock - attachBlock} below the minimum lead aborts: the fleet needs a + * log-only window in which seals are demonstrably flowing and verifying before the quorum + * becomes decisive; + *
  5. arming BLOCKING with a validator set smaller than {@link #MIN_BLOCKING_VALIDATORS} + * aborts, because at N<9 the Falcon quorum has no fault margin over the ECDSA quorum + * (see the N=7 zero-margin finding). Isolated test networks opt out explicitly with + * {@code aere.falcon.testnetAllowSmallFleet=true}, which logs an ERROR every start. + *
+ * + * @param fork the already-validated blocking height (AERE LATE-ANCHOR HEIGHT: passed in rather than re-parsed + * from the property, so this method and {@link #forkBlock()} cannot disagree about it) + * @return the resolved attachment height, or {@link Long#MAX_VALUE} when unset + */ + private long validateAndResolveAttachBlockOrAbort(final long fork) { + final String rawAttach = resolve("aere.falcon.attachBlock", "AERE_FALCON_ATTACHBLOCK"); + + if (rawAttach == null) { + if (fork == Long.MAX_VALUE && signingEnabled) { + // The rolling-restart case: an operator dropped a Falcon key on this node and restarted it. + // Say plainly, at startup, that this does NOT arm anything. + LOG.warn( + "AERE PQC: this node HOLDS a Falcon signing key (index {}) but seal ATTACHMENT is NOT " + + "configured (aere.falcon.attachBlock unset), so NO Falcon-carrying Commit will be " + + "emitted. This is the safe default: emitting one while any peer still runs a " + + "binary with a strict Commit decoder would make that peer DISCARD the whole " + + "Commit, ECDSA seal included.", + localIndex); + } + if (fork != Long.MAX_VALUE) { + throw new ActivationConfigException( + ActivationConfigException.Kind.UNSAFE, + "AERE-PQC-CFG-UNSAFE-01", + "AERE PQC: aere.falcon.forkBlock is set to " + + fork + + " (Falcon quorum BLOCKING) but aere.falcon.attachBlock is UNSET. Refusing to " + + "start (fail-closed): with attachment off, no node ever emits a Falcon seal, so " + + "every block at or after the fork block would be rejected for want of a quorum " + + "certificate and the chain would STOP. Set aere.falcon.attachBlock to a height at " + + "least " + + minAttachLead() + + " blocks BEFORE the fork block, and only after the whole fleet runs a binary that " + + "can parse a Falcon-carrying Commit."); + } + return Long.MAX_VALUE; + } + + final long attach; + try { + attach = Long.parseLong(rawAttach.trim()); + } catch (final NumberFormatException e) { + throw new ActivationConfigException( + ActivationConfigException.Kind.SYNTAX, + "AERE-PQC-CFG-SYNTAX-01", + "AERE PQC: aere.falcon.attachBlock is set to a MALFORMED value '" + + rawAttach + + "' (not an integer block number). Refusing to start (fail-closed)."); + } + if (attach < 0) { + throw new ActivationConfigException( + ActivationConfigException.Kind.SYNTAX, + "AERE-PQC-CFG-SYNTAX-02", + "AERE PQC: aere.falcon.attachBlock '" + rawAttach + "' is negative. Refusing to start."); + } + + if (fork != Long.MAX_VALUE) { + if (attach > fork) { + throw new ActivationConfigException( + ActivationConfigException.Kind.UNSAFE, + "AERE-PQC-CFG-UNSAFE-02", + "AERE PQC: aere.falcon.attachBlock (" + + attach + + ") is AFTER aere.falcon.forkBlock (" + + fork + + "). Refusing to start (fail-closed): the Falcon quorum would become blocking " + + "before any Falcon seal is ever attached, rejecting every block."); + } + final long lead = fork - attach; + if (lead < minAttachLead()) { + throw new ActivationConfigException( + ActivationConfigException.Kind.UNSAFE, + "AERE-PQC-CFG-UNSAFE-03", + "AERE PQC: only " + + lead + + " block(s) between aere.falcon.attachBlock (" + + attach + + ") and aere.falcon.forkBlock (" + + fork + + "); the minimum lead is " + + minAttachLead() + + ". Refusing to start (fail-closed): the fleet needs a LOG-ONLY window in which " + + "seals are observably flowing and verifying before the quorum becomes decisive."); + } + final int fleet = expectedFleetSize(); + final boolean smallFleetAllowed = + Boolean.parseBoolean( + String.valueOf( + resolve("aere.falcon.testnetAllowSmallFleet", "AERE_FALCON_TESTNET_SMALL_FLEET"))); + if (fleet < MIN_BLOCKING_VALIDATORS && !smallFleetAllowed) { + throw new ActivationConfigException( + ActivationConfigException.Kind.UNSAFE, + "AERE-PQC-CFG-UNSAFE-04", + "AERE PQC: BLOCKING Falcon quorum is configured (forkBlock=" + + fork + + ") for a fleet of " + + fleet + + " validator(s), below the minimum of " + + MIN_BLOCKING_VALIDATORS + + ". Refusing to start (fail-closed): at N<" + + MIN_BLOCKING_VALIDATORS + + " the Falcon quorum equals the ECDSA quorum, so the Falcon layer carries ZERO " + + "extra fault margin and any single keyed validator that is down or holds a faulty " + + "Falcon key becomes a new liveness dependency that stops the chain. Grow the " + + "validator set to at least " + + MIN_BLOCKING_VALIDATORS + + " first. Isolated test networks may set aere.falcon.testnetAllowSmallFleet=true."); + } + if (fleet < MIN_BLOCKING_VALIDATORS) { + LOG.error( + "AERE PQC: aere.falcon.testnetAllowSmallFleet=true - BLOCKING armed on a fleet of {} " + + "(< {}). ZERO Falcon fault margin. This setting MUST NOT be used on mainnet.", + fleet, + MIN_BLOCKING_VALIDATORS); + } + } + + LOG.info( + "AERE PQC: seal-ATTACHMENT gate configured: attachBlock={}, forkBlock={}, minLead={}. " + + "Holding a Falcon key does NOT by itself emit a Falcon-carrying Commit.", + attach, + fork == Long.MAX_VALUE ? "unset" : Long.toString(fork), + minAttachLead()); + return attach; + } + + /** + * Minimum number of blocks between the attachment height and the blocking fork height. + * + *

AERE ARMING GUARD: this used to swallow a malformed value and silently return the default. + * That is the same silent-degrade defect the attachBlock guard exists to prevent, one level down: + * an operator who typed the margin wrong would believe a long soak window was enforced when the + * default was. Every {@code aere.falcon.*} value now fails closed on a syntax error, uniformly. A + * half-fail-closed property set is worse than either extreme, because nobody can predict which + * half they are in. + */ + private long minAttachLead() { + final String v = resolve("aere.falcon.minAttachLead", "AERE_FALCON_MIN_ATTACH_LEAD"); + if (v == null) { + return DEFAULT_MIN_ATTACH_LEAD; + } + final long parsed; + try { + parsed = Long.parseLong(v.trim()); + } catch (final NumberFormatException e) { + throw new ActivationConfigException( + ActivationConfigException.Kind.SYNTAX, + "AERE-PQC-CFG-SYNTAX-07", + "AERE PQC: aere.falcon.minAttachLead is set to a MALFORMED value '" + + v + + "' (not an integer number of blocks). Refusing to start: a soak-window length that " + + "silently reverted to its default would leave the operator believing a longer soak " + + "is enforced than actually is."); + } + if (parsed < 0) { + throw new ActivationConfigException( + ActivationConfigException.Kind.SYNTAX, + "AERE-PQC-CFG-SYNTAX-07", + "AERE PQC: aere.falcon.minAttachLead '" + v + "' is negative. Refusing to start."); + } + return parsed; + } + + /** + * Best available estimate of the validator-set size at config time, used only for the N>=9 + * blocking guard. The registry (or the pending late manifest) is address-bound one entry per + * validator, so its size is the fleet the operator intends to arm. + */ + private int expectedFleetSize() { + final String v = resolve("aere.falcon.validatorCount", "AERE_FALCON_VALIDATOR_COUNT"); + if (v != null) { + try { + return Integer.parseInt(v.trim()); + } catch (final NumberFormatException e) { + throw new ActivationConfigException( + ActivationConfigException.Kind.SYNTAX, + "AERE-PQC-CFG-SYNTAX-08", + "AERE PQC: aere.falcon.validatorCount is set to a MALFORMED value '" + + v + + "' (not an integer). Refusing to start: falling through to the registry-derived " + + "size would silently apply the N>=9 blocking guard to a fleet size the operator " + + "did not state."); + } + } + if (!registry.isEmpty()) { + return registry.size(); + } + return pendingLate == null ? 0 : pendingLate.size(); + } + + // ---- AERE ARMING GUARD (2026-08-01): CHAIN-RELATIVE STARTUP GUARD ---- + + /** Outcome of the chain-relative attachment-height guard. Exposed for tests and diagnostics. */ + public enum ChainRelativeGateState { + /** The guard has not run yet. */ + NOT_CHECKED, + /** No attachment height is configured, so there is nothing to check. */ + NOT_APPLICABLE, + /** The attachment height leads the local chain head by at least the required margin. */ + FUTURE, + /** The height has passed and the chain itself proves the fleet armed at it. */ + RESUMED_ACTIVATED, + /** This node was previously admitted with this exact height while it was still in the future. */ + RESUMED_ADMITTED, + /** An operator named this exact height in an explicit acknowledgement property. */ + OPERATOR_ACKNOWLEDGED + } + + /** + * Thrown to ABORT node startup on a Falcon activation configuration that must not be allowed to + * run. The {@link Kind} separates the two cases an operator has to treat differently. + * + *

A {@link Kind#SYNTAX} failure is a value this code could not parse at all. It can never be + * correct anywhere, it is local to this node, and the fix is to correct the string on this node + * and restart. A {@link Kind#UNSAFE} failure is a value that parsed perfectly and describes a + * configuration that would damage the fleet. It is usually NOT local: the same value is probably + * on the other validators too, and "fixing" it on this node alone can be exactly the wrong move. + * Keeping the two apart is the difference between a one-node typo and a fleet-wide decision. + */ + public static final class ActivationConfigException extends IllegalStateException { + + private static final long serialVersionUID = 1L; + + /** Whether the value could not be parsed, or parsed and is dangerous. */ + public enum Kind { + /** The configured value is not parseable. A typo. Local to this node. */ + SYNTAX, + /** The configured value parses but the resulting activation would be unsafe. */ + UNSAFE + } + + private final Kind kind; + private final String code; + + ActivationConfigException(final Kind kind, final String code, final String message) { + super("[" + code + "] " + message); + this.kind = kind; + this.code = code; + } + + /** + * The failure class. + * + * @return SYNTAX for an unparseable value, UNSAFE for a dangerous one + */ + public Kind kind() { + return kind; + } + + /** + * The stable machine-readable code, e.g. {@code AERE-PQC-CFG-UNSAFE-05}. + * + * @return the code + */ + public String code() { + return code; + } + } + + /** + * CHAIN-RELATIVE half of the seal-attachment guard: refuse to start when {@code + * aere.falcon.attachBlock} is not comfortably AHEAD of this node's chain head, unless there is + * positive evidence that this is a legitimate resumption rather than a misconfiguration. + * + *

Call this exactly once during node startup, from the first point at which a chain head + * exists and BEFORE the network and the consensus state machine are started, so that a refusal is + * a clean refusal to start rather than a mid-flight abort. + * + *

The hole this closes, measured: {@link #attachmentArmed(long)} only ever required {@code + * blockNumber >= attachBlock}. Nothing required {@code attachBlock} to be in the future, so a + * single validator restarted with an already-passed height began emitting Falcon-carrying Commit + * messages on its first commit, into a fleet that was otherwise correctly configured. + * + *

The failure mode this guard introduces, and how it is handled: a node that CRASHES and + * restarts after the attachment height has legitimately passed must still be able to come back. A + * guard that refuses that restart is itself a halt risk, and on the scratch fleet losing one of + * seven validators stalled the chain for 61 s. Three independent pieces of evidence therefore + * distinguish "this height was configured in the past, which is a misconfiguration" from "this + * height passed normally while we were running, which is expected": + * + *

    + *
  1. ON-CHAIN, strongest and fleet-wide: a header at or after the attachment height carries a + * Falcon seal. The fleet demonstrably armed. Nothing local is trusted for this. + *
  2. LOCAL ADMISSION RECEIPT: this node was already admitted, by this same guard, with this + * exact height and this exact registry, at a time when the height was safely in the future. + * This is what rescues a crash in the window BEFORE the height arrives, where no on-chain + * evidence can exist yet. + *
  3. EXPLICIT OPERATOR ACKNOWLEDGEMENT naming the exact height. The escape hatch for a node + * restored from a backup that predates the activation and lost its receipt. + *
+ * + * @param chainHeadNumber this node's local chain head at startup + * @param headerCarriesFalconSeal true iff the header at that height carries >= 1 Falcon seal; may + * be null, in which case on-chain evidence is treated as unavailable (fail-closed) + * @param dataDirectory the node data directory in which the admission receipt lives; may be null + * @throws ActivationConfigException with {@link ActivationConfigException.Kind#UNSAFE} when the + * configured height is not safely ahead and no evidence of a legitimate activation exists + */ + public void verifyAttachHeightAgainstChainHeadOrAbort( + final long chainHeadNumber, + final LongPredicate headerCarriesFalconSeal, + final Path dataDirectory) { + + if (attachBlock == Long.MAX_VALUE) { + chainRelativeState = ChainRelativeGateState.NOT_APPLICABLE; + LOG.info( + "AERE PQC: chain-relative attachment guard NOT APPLICABLE - aere.falcon.attachBlock is " + + "unset, so seal attachment is OFF at every height (safe default)."); + return; + } + + final long margin = minAttachFutureMargin(); + final long head = Math.max(0L, chainHeadNumber); + final long lead = attachBlock - head; + + if (lead >= margin) { + chainRelativeState = ChainRelativeGateState.FUTURE; + LOG.info( + "AERE PQC: attachment-height guard PASSED - aere.falcon.attachBlock={} leads this node's " + + "chain head {} by {} block(s); required margin aere.falcon.minAttachFutureMargin={}." + + " Recording an admission receipt so that a crash-restart inside the activation " + + "window is not refused.", + attachBlock, + head, + lead, + margin); + writeAdmissionReceipt(dataDirectory, head); + return; + } + + // The height is at, behind, or uncomfortably close to the head. Refuse UNLESS something + // positively proves this is a resumption. Absence of evidence is treated as evidence of + // misconfiguration: that direction is the fail-closed one. + + final long evidenceFloor = Math.max(attachBlock, head - ACTIVATION_EVIDENCE_LOOKBACK + 1); + if (fleetAlreadyArmed(head, evidenceFloor, headerCarriesFalconSeal)) { + chainRelativeState = ChainRelativeGateState.RESUMED_ACTIVATED; + LOG.warn( + "AERE PQC: attachment height {} is already PASSED at this node's chain head {} (lead={}, " + + "margin={}), but block {} carries a Falcon seal, so the fleet demonstrably ARMED at " + + "this height and this is a legitimate RESTART, not a misconfiguration. Starting " + + "normally; this node will attach seals immediately, which is what its peers already " + + "do.", + attachBlock, + head, + lead, + margin, + armedEvidenceHeight); + writeAdmissionReceipt(dataDirectory, head); + return; + } + + if (admissionReceiptMatches(dataDirectory)) { + chainRelativeState = ChainRelativeGateState.RESUMED_ADMITTED; + LOG.warn( + "AERE PQC: attachment height {} is not {} block(s) ahead of this node's chain head {} " + + "(lead={}), and no block at or after {} carries a Falcon seal yet, BUT this node " + + "holds an admission receipt for exactly this height and registry, i.e. this same " + + "guard already accepted this configuration while the height was safely in the " + + "future. This is a RESTART inside the activation window, not a misconfiguration. " + + "Starting normally.", + attachBlock, + margin, + head, + lead, + evidenceFloor); + return; + } + + if (operatorAcknowledgedPastAttachBlock()) { + chainRelativeState = ChainRelativeGateState.OPERATOR_ACKNOWLEDGED; + LOG.error( + "AERE PQC: OPERATOR OVERRIDE - aere.falcon.attachBlockAcknowledgePast={} matches " + + "aere.falcon.attachBlock, so the chain-relative guard is being bypassed even though " + + "the height is not {} block(s) ahead of the chain head {} (lead={}) and no on-chain " + + "activation evidence was found. This node WILL attach Falcon seals as soon as it " + + "commits. Use this ONLY to restore a validator into an activation that has genuinely " + + "already happened. REMOVE the property after the node is back.", + attachBlock, + margin, + head, + lead); + writeAdmissionReceipt(dataDirectory, head); + return; + } + + throw new ActivationConfigException( + ActivationConfigException.Kind.UNSAFE, + "AERE-PQC-CFG-UNSAFE-05", + "AERE PQC: aere.falcon.attachBlock=" + + attachBlock + + " is NOT safely ahead of this node's chain head " + + head + + " (lead=" + + lead + + " block(s); required aere.falcon.minAttachFutureMargin=" + + margin + + "). Refusing to start (fail-closed). An attachment height that this chain has already " + + "passed - or that will pass while this node is still starting up and not yet taking " + + "part in consensus - makes THIS node begin emitting Falcon-carrying Commit messages at " + + "a block the rest of the fleet did not arm at. Every peer whose Commit decoder is " + + "strict then discards the whole Commit, decisive ECDSA seal included, and the " + + "committing set silently shrinks. No evidence of a legitimate activation was found: no " + + "header in [" + + evidenceFloor + + "," + + head + + "] carries a Falcon seal, and this node holds no admission receipt for attachBlock=" + + attachBlock + + ". REMEDY: if the fleet HAS legitimately activated at this height and this node lost " + + "its data directory, restart once with " + + "-Daere.falcon.attachBlockAcknowledgePast=" + + attachBlock + + " (the value must equal aere.falcon.attachBlock exactly). Otherwise set " + + "aere.falcon.attachBlock to a height at least " + + margin + + " blocks ahead of the fleet's head, ON EVERY VALIDATOR, and restart the fleet."); + } + + /** + * Look for on-chain proof that the fleet has already armed: a header at or after the attachment + * height carrying at least one Falcon seal. Fails CLOSED - an unreadable or undecodable header is + * not evidence. + */ + private boolean fleetAlreadyArmed( + final long head, final long evidenceFloor, final LongPredicate headerCarriesFalconSeal) { + if (headerCarriesFalconSeal == null || attachBlock > head) { + return false; + } + for (long n = head; n >= evidenceFloor && n >= 0; n--) { + try { + if (headerCarriesFalconSeal.test(n)) { + armedEvidenceHeight = n; + return true; + } + } catch (final RuntimeException e) { + LOG.warn( + "AERE PQC: could not inspect header {} for Falcon seals ({}); treating on-chain " + + "activation evidence as UNAVAILABLE (fail-closed).", + n, + e.toString()); + return false; + } + } + return false; + } + + /** + * Identity of the registry this node is configured against, so an admission receipt written for + * one chain or one manifest can never admit a different one. + */ + private String registryIdentity() { + if (manifestHash != null) { + return manifestHash; + } + if (pendingLateHash != null) { + return pendingLateHash; + } + return "none"; + } + + /** Path of the admission receipt inside the node data directory, or null if unavailable. */ + private Path admissionReceiptPath(final Path dataDirectory) { + return dataDirectory == null ? null : dataDirectory.resolve(ADMISSION_RECEIPT_FILE); + } + + /** + * Record that this guard ADMITTED this attachment height. Deliberately best-effort: a data + * directory that cannot be written must not stop a validator from starting. It is logged at ERROR + * because the consequence is real - without the receipt, a crash-restart before the activation + * height would be refused and would need the acknowledgement property. + */ + private void writeAdmissionReceipt(final Path dataDirectory, final long head) { + final Path p = admissionReceiptPath(dataDirectory); + if (p == null) { + return; + } + final Properties props = new Properties(); + props.setProperty("attachBlock", Long.toString(attachBlock)); + props.setProperty("registryIdentity", registryIdentity()); + props.setProperty("admittedAtChainHead", Long.toString(head)); + props.setProperty("admittedAtEpochMillis", Long.toString(System.currentTimeMillis())); + try { + Files.createDirectories(p.getParent()); + try (OutputStream out = Files.newOutputStream(p)) { + props.store( + out, + "AERE PQC activation admission receipt. Written by the chain-relative attachment guard " + + "when it accepted this attachment height. Presence of this file lets a later " + + "restart tell a legitimate resumption from a past-height misconfiguration. It " + + "contains no key material."); + } + } catch (final Exception e) { + LOG.error( + "AERE PQC: could NOT write the activation admission receipt to {} ({}). Startup " + + "continues, but if this node restarts before block {} it will have no local proof " + + "that this activation height was already accepted, and the chain-relative guard " + + "will refuse to start it until either the fleet has visibly armed on chain or " + + "-Daere.falcon.attachBlockAcknowledgePast={} is set.", + p, + e.toString(), + attachBlock, + attachBlock); + } + } + + /** True iff a receipt exists recording an earlier admission of this exact height and registry. */ + private boolean admissionReceiptMatches(final Path dataDirectory) { + final Path p = admissionReceiptPath(dataDirectory); + if (p == null || !Files.isRegularFile(p)) { + return false; + } + try (InputStream in = Files.newInputStream(p)) { + final Properties props = new Properties(); + props.load(in); + final String a = props.getProperty("attachBlock"); + final String id = props.getProperty("registryIdentity"); + final boolean match = + a != null + && id != null + && Long.parseLong(a.trim()) == attachBlock + && id.trim().equals(registryIdentity()); + if (!match) { + LOG.warn( + "AERE PQC: an activation admission receipt exists at {} but does NOT match the current " + + "configuration (receipt attachBlock={}, registryIdentity={}; configured " + + "attachBlock={}, registryIdentity={}). It is being IGNORED, which is the " + + "fail-closed direction: a receipt for a different activation must never admit " + + "this one.", + p, + a, + id, + attachBlock, + registryIdentity()); + } + return match; + } catch (final Exception e) { + LOG.warn( + "AERE PQC: could not read the activation admission receipt at {} ({}); treating it as " + + "ABSENT (fail-closed).", + p, + e.toString()); + return false; + } + } + + /** + * The explicit operator escape hatch. The acknowledgement must NAME the exact attachment height, + * so it cannot be a blanket "true" that survives across activations and quietly disarms this + * guard forever. An acknowledgement naming a DIFFERENT height is itself an abort: it means the + * operator is acknowledging an activation this node is not configured for. + */ + private boolean operatorAcknowledgedPastAttachBlock() { + final String v = + resolve("aere.falcon.attachBlockAcknowledgePast", "AERE_FALCON_ATTACHBLOCK_ACK_PAST"); + if (v == null) { + return false; + } + final long ack; + try { + ack = Long.parseLong(v.trim()); + } catch (final NumberFormatException e) { + throw new ActivationConfigException( + ActivationConfigException.Kind.SYNTAX, + "AERE-PQC-CFG-SYNTAX-05", + "AERE PQC: aere.falcon.attachBlockAcknowledgePast is set to a MALFORMED value '" + + v + + "' (not an integer block number). Refusing to start. This property is not a " + + "boolean: it must repeat the exact aere.falcon.attachBlock value being acknowledged " + + "(" + + attachBlock + + ")."); + } + if (ack != attachBlock) { + throw new ActivationConfigException( + ActivationConfigException.Kind.UNSAFE, + "AERE-PQC-CFG-UNSAFE-06", + "AERE PQC: aere.falcon.attachBlockAcknowledgePast=" + + ack + + " does not match aere.falcon.attachBlock=" + + attachBlock + + ". Refusing to start (fail-closed): an acknowledgement that names a different " + + "height is a stale acknowledgement left over from an earlier activation, and it " + + "must never silently authorise the current one."); + } + return true; + } + + /** + * Highest legal value for {@code aere.falcon.attachInterval}. + * + *

This is NOT a taste limit, it is {@link #ACTIVATION_EVIDENCE_LOOKBACK}. A validator that + * loses its data directory and restarts past an attachment height proves the fleet is already + * armed by finding a header carrying at least one seal within the last {@value + * #ACTIVATION_EVIDENCE_LOOKBACK} blocks; with a wider interval that window can contain no + * seal-bearing header at all, the evidence search comes up empty, and the node refuses to start + * with AERE-PQC-CFG-UNSAFE-05. The failure would arrive days later, on an unrelated restart, on + * whichever node happened to lose its disk. Refusing the interval at configuration time is the + * only place the operator can still see the connection. + */ + private static final int MAX_ATTACH_INTERVAL = ACTIVATION_EVIDENCE_LOOKBACK; + + /** + * Write the Falcon certificate only on every Nth block, counted from {@code + * aere.falcon.attachBlock}. Configurable via {@code aere.falcon.attachInterval} / {@code + * AERE_FALCON_ATTACHINTERVAL}. + * + *

WHY THIS EXISTS, measured on chain 2800 on 2026-08-08. Attachment was armed on all seven + * validators and the header went from 525 to 3844 bytes, five Falcon seals on EVERY block: more + * than seven times the header bytes, on nodes whose free space could not absorb it. The anchor + * producer + * already has both an interval and a seal cap, but the assembler reached when the anchor is NOT + * armed has neither, and that assembler is the one that runs before the activation height. So the + * cheap-by-design path was unreachable precisely during the window it was needed. + * + *

Unset means EVERY block, which is exactly today's behaviour. A node that writes fewer + * certificates than its neighbours is a behaviour change on a live chain and has to be asked for, + * never delivered as a default. + * + *

There is no consensus coupling here and that is deliberate: below the anchor activation + * height no rule reads element 6 to refuse anything, so two nodes with different intervals still + * agree on exactly which headers are valid. That stops being true at the activation height, where + * PqAnchorDigestRule binds the certificate to the vanity digest, and from there the anchor's own + * interval governs. + * + * @return the interval in blocks, or empty for every block + */ + public OptionalInt attachInterval() { + final String v = resolve("aere.falcon.attachInterval", "AERE_FALCON_ATTACHINTERVAL"); + if (v == null) { + return OptionalInt.empty(); + } + final int parsed; + try { + parsed = Integer.parseInt(v.trim()); + } catch (final NumberFormatException e) { + throw new ActivationConfigException( + ActivationConfigException.Kind.SYNTAX, + "AERE-PQC-CFG-SYNTAX-09", + "AERE PQC: aere.falcon.attachInterval is set to a MALFORMED value '" + + v + + "' (not an integer number of blocks). Refusing to start: an interval that silently " + + "reverted to 'every block' would leave the operator believing a disk control is in " + + "force when it is not, and the disk would fill anyway."); + } + if (parsed < 1 || parsed > MAX_ATTACH_INTERVAL) { + throw new ActivationConfigException( + ActivationConfigException.Kind.UNSAFE, + "AERE-PQC-CFG-UNSAFE-09", + "AERE PQC: aere.falcon.attachInterval " + + parsed + + " is outside the legal range 1.." + + MAX_ATTACH_INTERVAL + + ". Refusing to start. The upper bound is the " + + ACTIVATION_EVIDENCE_LOOKBACK + + "-block window in which a restarting validator looks for proof that the fleet is " + + "already armed; a wider interval can leave that window with no seal-bearing header, " + + "and the node would then refuse to start with AERE-PQC-CFG-UNSAFE-05 days later, " + + "on whichever node happened to lose its data directory."); + } + return OptionalInt.of(parsed); + } + + /** + * Largest number of seals this node writes into a certificate on the pre-activation assembly + * path. Configurable via {@code aere.falcon.attachMaxSeals} / {@code + * AERE_FALCON_ATTACHMAXSEALS}; unset means no cap, which is today's behaviour. + * + *

This is the twin of {@code aere.pq.anchor.maxSeals}, which caps the anchor producer. K is a + * FLOOR, not a ceiling: nodes attach as many seals as reach them in time, not as many as any + * threshold asks for. Measured on 2026-08-08, five seals per block arrived with seven validators + * attaching. + * + *

Unlike the anchor cap there is no minimum tied to a K schedule, because below the activation + * height no rule counts these seals. The only floor is 1: a certificate of zero seals is not a + * cheaper certificate, it is an absent one, and the caller should unset attachment instead. + * + * @return the cap, or empty for no cap + */ + public OptionalInt attachMaxSeals() { + final String v = resolve("aere.falcon.attachMaxSeals", "AERE_FALCON_ATTACHMAXSEALS"); + if (v == null) { + return OptionalInt.empty(); + } + final int parsed; + try { + parsed = Integer.parseInt(v.trim()); + } catch (final NumberFormatException e) { + throw new ActivationConfigException( + ActivationConfigException.Kind.SYNTAX, + "AERE-PQC-CFG-SYNTAX-10", + "AERE PQC: aere.falcon.attachMaxSeals is set to a MALFORMED value '" + + v + + "' (not an integer). Refusing to start rather than silently carrying no cap."); + } + if (parsed < 1) { + throw new ActivationConfigException( + ActivationConfigException.Kind.UNSAFE, + "AERE-PQC-CFG-UNSAFE-10", + "AERE PQC: aere.falcon.attachMaxSeals " + + parsed + + " is below 1. Refusing to start: a cap of zero produces an absent certificate, not " + + "a cheaper one. To stop writing certificates, unset aere.falcon.attachBlock."); + } + return OptionalInt.of(parsed); + } + + /** + * Whether the pre-activation assembler should write a certificate into THIS block's header. + * + *

Counted from {@code attachBlock} so that every node in the fleet picks the same heights + * without any agreement protocol: the two inputs are a configured constant and the block number. + * Nodes with different intervals disagree about which blocks carry a certificate, and that is + * harmless below the activation height for the reason given on {@link #attachInterval()}. + * + * @param blockNumber the height being sealed + * @return true if a certificate should be written at this height + */ + public boolean attachesCertificateAt(final long blockNumber) { + return attachmentArmed(blockNumber) && isAttachHeight(blockNumber, attachBlock, attachInterval()); + } + + /** + * The height arithmetic of {@link #attachesCertificateAt}, with no dependency on instance state. + * + *

Kept separate and package-private so it can be measured on its own. The surrounding gate + * needs a fully constructed node with a loaded registry, a signing key and an armed attachment + * height, which is a great deal of scaffolding around one modulo; a proof that has to build all + * of that in order to ask "is 12,900,100 an attachment height" ends up measuring the scaffolding. + * + * @param blockNumber the height being sealed + * @param from the configured attachment height + * @param every the configured interval, or empty for every block + * @return true if a certificate belongs at this height + */ + static boolean isAttachHeight(final long blockNumber, final long from, final OptionalInt every) { + if (blockNumber < from) { + return false; + } + if (every.isEmpty()) { + return true; + } + return (blockNumber - from) % every.getAsInt() == 0; + } + + /** + * Minimum number of blocks by which the attachment height must lead this node's chain head at + * startup. Configurable via {@code aere.falcon.minAttachFutureMargin} / + * {@code AERE_FALCON_MIN_ATTACH_FUTURE_MARGIN}; a malformed or negative value aborts startup + * rather than silently reverting to the default, so an operator can never believe a margin is in + * force when it is not. + * + * @return the required margin in blocks + */ + public long minAttachFutureMargin() { + final String v = + resolve("aere.falcon.minAttachFutureMargin", "AERE_FALCON_MIN_ATTACH_FUTURE_MARGIN"); + if (v == null) { + return DEFAULT_MIN_ATTACH_FUTURE_MARGIN; + } + final long parsed; + try { + parsed = Long.parseLong(v.trim()); + } catch (final NumberFormatException e) { + throw new ActivationConfigException( + ActivationConfigException.Kind.SYNTAX, + "AERE-PQC-CFG-SYNTAX-06", + "AERE PQC: aere.falcon.minAttachFutureMargin is set to a MALFORMED value '" + + v + + "' (not an integer number of blocks). Refusing to start: a safety margin that " + + "silently reverted to its default would leave the operator believing a margin is in " + + "force when it is not."); + } + if (parsed < 0) { + throw new ActivationConfigException( + ActivationConfigException.Kind.SYNTAX, + "AERE-PQC-CFG-SYNTAX-06", + "AERE PQC: aere.falcon.minAttachFutureMargin '" + + v + + "' is negative. Refusing to start."); + } + return parsed; + } + + /** + * Outcome of the chain-relative startup guard. + * + * @return the gate state, NOT_CHECKED until the guard has run + */ + public ChainRelativeGateState chainRelativeState() { + return chainRelativeState; + } + + // ---- AERE GENESIS BINDING (2026-08-01): BIND THE REGISTRY TO CONSENSUS ---- + + /** + * GENESIS BINDING GUARD: refuse to start when the Falcon registry this node loaded is not the one the chain + * requires at this height. + * + *

THE DEFECT, read out of the constructor above and not guessed at. The registry that answers + * "which Falcon public key is validator index i" can come from {@code aere.falcon.registry}, a + * plain local properties file with no binding to anything. Two nodes handed two different files + * disagree about who index i is, so one and the same certificate verifies on one node and fails on + * the other. That splits the network with no attacker in it. The two anchored sources do check a + * hash, but their fail-closed action is to leave the registry EMPTY and log; the process still + * STARTS, so a node can run for days as a validator that can verify nothing and report itself + * healthy while doing it. + * + *

WHAT THIS GUARD DOES. It re-reads the registry file this node actually used, computes its + * canonical hash, and compares it against {@code config.pqRegistryHash} in genesis, which is a + * height-indexed schedule. Below the first scheduled height nothing is enforced, so the roughly + * 11.8 million blocks already on chain 2800 are untouched. At and above it, a mismatch throws and + * the node does not start; the refusal names the hash expected, the hash found, the file, the + * source kind and a fingerprint per registry row. + * + *

Call this from the same place as {@link #verifyAttachHeightAgainstChainHeadOrAbort}: after a + * chain head exists and before the network and the QBFT state machine are started, so a refusal is + * a clean refusal to start rather than a mid-flight abort. + * + *

The genesis file is located from {@code aere.pq.genesis} / {@code AERE_PQ_GENESIS}, falling + * back to {@code aere.falcon.genesis} / {@code AERE_FALCON_GENESIS}. When neither is set there is + * no schedule to read, nothing is enforced, and that fact is logged at WARN rather than passed + * over: a binding everybody believes is on and is not is worse than no binding at all. + * + * @param chainHeadNumber this node's local chain head at startup + * @param chainId the chain id, which is part of the canonical pre-image + * @return the gate state + * @throws PqRegistryHash.RegistryConfigException when a binding is active and is not satisfied + */ + public PqRegistryHash.GateState verifyRegistryBindingOrAbort( + final long chainHeadNumber, final long chainId) { + + final String genesisForSchedule = + resolve("aere.pq.genesis", "AERE_PQ_GENESIS") != null + ? resolve("aere.pq.genesis", "AERE_PQ_GENESIS") + : resolve("aere.falcon.genesis", "AERE_FALCON_GENESIS"); + + final PqRegistryHash.Schedule schedule; + if (genesisForSchedule == null) { + LOG.warn( + "AERE PQC GENESIS-BINDING: no genesis file is reachable to read config.pqRegistryHash from (neither " + + "aere.pq.genesis nor aere.falcon.genesis is set), so the Falcon registry on this " + + "node is NOT bound to consensus. The registry in use is {} ({}). Two nodes holding " + + "different registry files would disagree about which public key validator index i " + + "has, and the same certificate would verify on one and fail on the other.", + registrySourcePath == null ? "(none)" : registrySourcePath, + registrySourceKind); + schedule = PqRegistryHash.emptySchedule(); + } else { + schedule = PqRegistryHash.loadScheduleFromGenesis(Paths.get(genesisForSchedule)); + } + + return verifyRegistryBindingOrAbort(chainHeadNumber, chainId, schedule); + } + + /** + * AERE GENESIS BINDING, the form the caller should actually use: verify the registry binding against a + * schedule that came from the genesis configuration BESU ITSELF PARSED AND BOOTED WITH. + * + *

WHY THIS OVERLOAD EXISTS, and it is not tidiness. The single-argument form above reads + * {@code config.pqRegistryHash} out of a genesis FILE whose path comes from a system property. + * That is a local file named by local configuration, which is the exact shape of the defect this + * guard was written to close. A node pointed at a stale copy of genesis would enforce a stale + * binding, or none, and would say nothing. Handing the schedule down from {@code + * GenesisConfigOptions} removes the second file entirely: the value enforced is read out of the + * same genesis object that produced this node's genesis hash, so a node that disagrees about the + * schedule already disagrees about the chain and is not on it. + * + * @param chainHeadNumber this node's local chain head at startup + * @param chainId the chain id, which is part of the canonical pre-image + * @param schedule the schedule parsed from the booted genesis configuration + * @return the gate state + * @throws PqRegistryHash.RegistryConfigException when a binding is active and is not satisfied + */ + public PqRegistryHash.GateState verifyRegistryBindingOrAbort( + final long chainHeadNumber, final long chainId, final PqRegistryHash.Schedule schedule) { + + PqRegistryHash.Registry loaded = null; + if (registrySourcePath != null) { + loaded = PqRegistryHash.loadAuto(Paths.get(registrySourcePath)); + } + + // HEIGHT SCHEDULE: build the height-resolved set BEFORE anything is published, from the registry this + // node signs with plus every registry named in the history list. With no history configured the + // set holds exactly the one registry and every answer below is what it was before the height schedule existed. + // + // AERE HELD-SET SCOPE (2026-08-06). THIS BLOCK USED TO SIT 41 LINES LOWER, AND THAT WAS THE DEFECT. The + // startup guard below was handed the single primary registry and threw + // AERE-PQC-REG-MISMATCH-01 before this code ever ran, so a node holding exactly the right files + // - the post-rotation registry as its own, the pre-rotation one as history - refused to start + // with a correct configuration. Re-confirmed 2026-08-06 with head at 2554. Consequence: after + // the first rotation, no node could be restarted without hand intervention. + // + // MOVING IT UP ADDS NO WORK ON AN UNCONFIGURED NODE: resolve() returns null, and + // parseRegistryPaths(null) returns an empty list, so nothing is opened that was not opened + // before. On chain 2800 the schedule is empty, buildSet's loop has zero iterations, and this + // whole block costs one map lookup. + final List held = new ArrayList<>(); + held.add(loaded); + final String history = resolve(PROPERTY_REGISTRY_HISTORY, "AERE_FALCON_REGISTRY_HISTORY"); + for (final java.nio.file.Path p : PqRegistryHash.parseRegistryPaths(history)) { + held.add(PqRegistryHash.loadAuto(p)); + } + final PqRegistryHash.RegistrySet set = PqRegistryHash.buildSet(schedule, held, chainId); + + PqRegistryHash.GateState state; + try { + state = PqRegistryHash.verifyOrAbort(schedule, set, chainHeadNumber, chainId); + requireAnchorHeightIsASignedHeightOrAbort(schedule, set); + } catch (final PqRegistryHash.RegistryConfigException e) { + // AERE OPTIUNI-URGENTA (2026-08-02): THE WAY BACK, and the only one that does not need a + // build. Without it, a registry file that is wrong in one byte takes down every node it was + // pushed to, and the documented recovery was to edit a unit file or rebuild. With it, the + // recovery is a restart with one flag - and the node spends the whole time it runs saying, at + // every height, that it is running unverified. + // + // NOT bypassed here, deliberately: AERE-PQC-REG-ARM-01, the refusal to arm over a registry + // with no validator addresses. That refusal is not a configuration mismatch, it is a + // guarantee that the fleet is about to halt at the activation height, and starting anyway + // would only move the halt a few blocks later. The way out of THAT one is to disarm the + // anchor, which is a different option and is why there are three and not one. + if (!registryMismatchAllow) { + throw e; + } + registryOverrideEngaged = true; + LOG.error( + "AERE PQC EMERGENCY [AERE-PQC-REG-UNSAFE-01]: STARTING ANYWAY WITH AN UNVERIFIED FALCON " + + "REGISTRY. The genesis-binding guard REFUSED this node ({}), and {} is set, so " + + "the refusal has been overridden BY EXPLICIT OPERATOR REQUEST. What that means, " + + "stated plainly: from the first height at which genesis requires a registry hash, " + + "this node cannot correctly decide whether a header's Falcon certificate is valid, " + + "and it will import those headers anyway. It is NOT a validating node for the " + + "post-quantum layer while this is set. WHAT TO DO: install the registry whose hash " + + "genesis names and restart WITHOUT this option. The refusal that was overridden " + + "follows in full.\n{}", + e.code(), + PROPERTY_REGISTRY_MISMATCH_ALLOW, + e.getMessage()); + state = PqRegistryHash.GateState.OVERRIDDEN_UNSAFE; + } + if (schedule.enforced() && !set.coversWholeSchedule()) { + // NOT a refusal here, and the reason is measured, not aesthetic. This method is also the + // path a node takes when it is being started BELOW the first scheduled height, where nothing + // is enforced yet and an operator legitimately holds only one file. The per-block rule + // refuses at exactly the heights that are uncovered, which is the narrowest fail-closed + // action that still names the problem. What must never happen is silence. + LOG.error( + "AERE PQC HEIGHT-SCHEDULE: this node holds {} registry file(s) and the chain's pqRegistryHash " + + "schedule has {} entr(ies), of which the heights {} are covered by NOTHING this " + + "node holds. Every header at or after such a height will be REFUSED, and a node " + + "syncing from genesis will stop there. Name the missing registry file(s) in {} " + + "(comma-separated). The list is never pruned: one file per rotation the chain has " + + "ever performed, kept forever, because a node acquiring history has to verify every " + + "block that was ever produced.", + set.count(), + schedule.entries().size(), + set.uncoveredEntryBlocks(), + PROPERTY_REGISTRY_HISTORY); + } + this.registryBindingState = state; + // Publish the inputs the per-block half needs. Order matters: the schedule is written LAST, and + // registryBindingSatisfiedAt() reads it FIRST and returns true when it is null, so a rule that + // runs while this is being assembled sees "inert" and never sees a half-built gate. + this.registryBindingLoaded = loaded; + this.registryBindingSet = set; + this.historicalKeys.clear(); + this.registryBindingChainId = chainId; + this.registryBindingSchedule = schedule; + return state; + } + + /** + * AERE SIGNED-HEIGHT CHECK (2026-08-06), THE LOCAL HALF. The height this node ARMS at must be a height the fleet + * actually signed a registry for. + * + *

WHY THIS CANNOT BE THE SAME KIND OF GUARD AS THE ONE ABOVE, and the difference is the whole + * honest limitation of that check. {@code aere.pq.anchorBlock} is a SYSTEM PROPERTY, set per node through + * {@code BESU_OPTS}. It is in no genesis. Nothing in the document the seven validators hold + * byte-identically constrains it. So the agreement of seven nodes on an arming height CANNOT BE + * ENFORCED by any code that runs on one node - it can only be DETECTED locally, and that is what + * this does. Enforcement would require the height to move into genesis, which is a change to the + * chain's configuration and not to this class. That is written here as a limitation, not as a + * to-do, because a reader must not mistake this for a consensus rule. + * + *

WHAT IT COMPARES. The arming height A against the {@code block} values of + * {@code config.pqRegistryHash}. A registry row's possession proof and validator claim both sign + * the bind height; the schedule entry that puts a registry in force names the same number (that is + * {@code AERE-PQC-REG-BIND-08}, enforced above). If A is not one of those heights, the moment at + * which certificates begin to carry weight is a number no validator ever put a signature on. + * Measured 2026-08-06 on a network of seven with A moved from 40 to 70: seven of seven started, + * head reached 95, agreement 7 of 7, zero refusals. + * + *

WHY IT CANNOT FIRE ON CHAIN 2800 OR ON ANY NODE RUNNING TODAY. Three independent gates, any + * one of which returns first: the anchor is not armed ({@code aere.pq.anchorBlock} unset, so + * {@code anchorArmedFrom()} is {@link Long#MAX_VALUE}); {@code config.pqRegistryHash} is in no + * genesis this fleet runs, so the schedule is not enforced; and the held registry is v1, carrying + * no signatures over any height at all. + * + * @param schedule the schedule parsed from the booted genesis + * @param set the registries this node holds + * @throws PqRegistryHash.RegistryConfigException with {@code AERE-PQC-REG-BIND-09} + */ + private void requireAnchorHeightIsASignedHeightOrAbort( + final PqRegistryHash.Schedule schedule, final PqRegistryHash.RegistrySet set) { + final long armedFrom = anchorArmedFrom(); + if (armedFrom == Long.MAX_VALUE || !schedule.enforced()) { + return; + } + final PqRegistryHash.Registry primary = set.primary(); + if (primary == null || !primary.proofBound()) { + // Nothing here signed a height. A v1 registry on an armed node is already refused, louder and + // for a better reason, by AERE-PQC-REG-ARM-02 in the constructor; this must not take that + // message over. + return; + } + for (final PqRegistryHash.ScheduleEntry e : schedule.entries()) { + if (e.block() == armedFrom) { + return; + } + } + final StringBuilder heights = new StringBuilder(); + for (final PqRegistryHash.ScheduleEntry e : schedule.entries()) { + if (heights.length() > 0) { + heights.append(", "); + } + heights.append(e.block()); + } + throw new PqRegistryHash.RegistryConfigException( + "AERE-PQC-REG-BIND-09", + "AERE PQC SIGNED-HEIGHT: REFUSING TO START - this node arms at a height no validator signed for.\n" + + " FIELD: aere.pq.anchorBlock (system property, per node, read through " + + "PqAnchorConfig) versus the 'block' values of config.pqRegistryHash in genesis (" + + schedule.source() + + ").\n" + + " READ: aere.pq.anchorBlock = " + + armedFrom + + "\n" + + " config.pqRegistryHash heights = [" + + heights + + "]\n" + + " EXPECTED: the arming height is ONE of those heights. Every row of the registry " + + "carries a Falcon possession proof and an ECDSA claim by that validator's own " + + "consensus key, and both signatures cover a single bind height. From the arming " + + "height a header's Falcon certificate carries consensus weight; if that height is not " + + "one the fleet signed, the day the chain starts enforcing post-quantum seals is a " + + "number one operator typed.\n" + + " WHY NOTHING ELSE CATCHES IT: aere.pq.anchorBlock is not in genesis. It is not in " + + "the document all seven nodes hold identically, so no node can hold another node to " + + "it. This refusal is DETECTION on this node only. Two nodes with different " + + "aere.pq.anchorBlock still do not disagree about a header until the lower of the two " + + "heights, and this guard cannot see the other node's value.\n" + + " WHAT TO DO: EITHER set aere.pq.anchorBlock to one of the heights above, on all " + + "seven nodes and in the same change - a node armed alone validates differently from " + + "the rest. OR, if the activation day genuinely moved, re-run the key ceremony for the " + + "new height, put the re-signed registry's NEW hash in config.pqRegistryHash at that " + + "height, and roll it to the whole fleet. Moving the day is 14 signatures. It is " + + "supposed to be.\n" + + " CHECK THE WHOLE FLEET BEFORE RESTARTING ANYTHING: this property has to read " + + "the same on every node, and a split state is a fault, " + + "not a warning."); + } + + /** + * AERE GENESIS BINDING, the PER-BLOCK half: does the registry this node is running on satisfy the binding the + * chain requires AT THIS HEIGHT? Never throws. + * + *

WHY A STARTUP GUARD IS NOT ENOUGH, measured as a gap and not assumed. The startup guard + * answers the question once, against the chain head that existed at startup. A node that is + * already running when a ROTATION height in the schedule passes underneath it is never asked + * again: it keeps validating with a registry the chain has moved off, and it keeps reporting + * itself healthy while doing it. That is the same failure mode the genesis binding was opened for, arriving by a + * different door. With a schedule of one entry the two guards are equivalent; with two or more, + * only this one covers the interval after the second entry. + * + *

WHAT IT DOES ON MISMATCH. It returns false, and the header rule that calls it refuses the + * header. That is deliberate and it is fail-closed: a node whose registry no longer matches what + * the chain requires cannot verify the certificates in the headers it is being offered, so + * importing them would mean asserting a check it did not perform. It stops instead, loudly, at + * the first header past the rotation. It does NOT touch proposer selection or finality. + * + * @param blockNumber the height of the header being validated + * @return true when no binding is active at that height, or the registry satisfies it + */ + public boolean registryBindingSatisfiedAt(final long blockNumber) { + final PqRegistryHash.Schedule schedule = this.registryBindingSchedule; + if (schedule == null) { + return true; // guard never ran on this node: inert, exactly as before this change + } + final Optional required = + PqRegistryHash.requiredHashAt(schedule, blockNumber); + if (required.isEmpty()) { + return true; // below the first scheduled height: the 11.8 million existing blocks are untouched + } + final PqRegistryHash.Registry loaded = this.registryBindingLoaded; + // HEIGHT SCHEDULE: ask the whole scheduled history, not only the entry in force at the head. Before this + // change a node held one registry, so one rotation left NO configuration that satisfied both + // the pre-rotation interval and the head, and the chain became permanently unjoinable. + final boolean ok = + PqRegistryHash.matchesAt( + schedule, this.registryBindingSet, blockNumber, this.registryBindingChainId); + if (!ok) { + // AERE OPTIUNI-URGENTA: the per-block half of the bypass. It has to consult the ARMED flag + // and not only the ENGAGED one, because the case this rule exists for is precisely the one + // the startup guard never saw: a rotation height passing underneath a node that started + // clean. In that case nothing was overridden at startup and there is nothing engaged yet. + if (registryMismatchAllow) { + registryOverrideEngaged = true; + shoutRegistryOverrideUnsafe(blockNumber, required.get(), loaded); + return true; + } + shoutRegistryBindingMismatch(blockNumber, required.get(), loaded); + } + return ok; + } + + /** + * AERE OPTIUNI-URGENTA: is this node ACTUALLY running on a suppressed registry refusal? + * + * @return true once the bypass has suppressed a real refusal, at startup or on the block path + */ + public boolean registryOverrideEngaged() { + return registryOverrideEngaged; + } + + /** + * AERE OPTIUNI-URGENTA: has an operator armed the registry bypass on this node? + * + * @return true iff the bypass property or environment variable is set + */ + public boolean registryMismatchAllowArmed() { + return registryMismatchAllow; + } + + /** + * One complete ERROR line per height, for as long as the bypass carries weight. Bounded to one per + * height for the same reason the refusal is: at a 0.5 s block period an unbounded line would bury + * the first and most useful occurrence. Bounded per height is NOT bounded once: the mission for + * this option was that the node shouts at EVERY block, and one line per height is what "every + * block" means on a path that is asked about a height more than once. + */ + private void shoutRegistryOverrideUnsafe( + final long blockNumber, + final PqRegistryHash.ScheduleEntry required, + final PqRegistryHash.Registry loaded) { + if (registryOverrideLastShout.getAndSet(blockNumber) == blockNumber) { + return; + } + LOG.error( + "AERE PQC EMERGENCY [AERE-PQC-REG-UNSAFE-02]: height {} ACCEPTED UNVERIFIED. This chain " + + "requires registry hash {} from height {}, the registry this node holds hashes to {}, " + + "and the header was imported anyway because {} is set. This node is NOT checking the " + + "post-quantum certificates in the headers it accepts. Registry in use: {} ({}, {} " + + "entries, address-bound={}). WHAT TO DO: install the registry whose hash is {} and " + + "restart WITHOUT the bypass. THIS LINE REPEATS AT EVERY HEIGHT, BY DESIGN.", + blockNumber, + required.hash(), + required.block(), + // GENESIS BINDING: this used to print hashV1 next to `required.hash()`, which is computed + // with hashFor. Two numbers set side by side to be compared, but computed differently: for + // a registry that carries proofs the two can NEVER match, and the message sends the + // operator hunting for the defect where it is not. Measured on a fleet of seven on + // 2026-08-06. + loaded == null + ? "(no registry loaded)" + : PqRegistryHash.hashFor(loaded, registryBindingChainId), + PROPERTY_REGISTRY_MISMATCH_ALLOW, + loaded == null ? "(none)" : loaded.sourcePath(), + loaded == null ? "(none)" : loaded.kind(), + loaded == null ? 0 : loaded.count(), + loaded != null && loaded.addressBound(), + required.hash()); + } + + /** + * One loud, complete line per height, at most. Bounded because this sits on the header path and a + * mismatched node is offered a header roughly twice a second; an unbounded log would bury the + * first and most useful occurrence. + */ + private void shoutRegistryBindingMismatch( + final long blockNumber, + final PqRegistryHash.ScheduleEntry required, + final PqRegistryHash.Registry loaded) { + if (registryBindingLastShout.getAndSet(blockNumber) == blockNumber) { + return; + } + LOG.error( + "AERE PQC GENESIS-BINDING [AERE-PQC-REG-BLOCK-01]: REFUSING header at height {} (fail-closed). The " + + "chain requires registry hash {} from height {} (schedule source {}), and the " + + "registry this node is running has hash {}. Registry in use: {} ({}), {} entries, " + + "address-bound={}. WHY THIS FIRES NOW AND NOT AT STARTUP: this node started under an " + + "earlier entry of the same schedule and a ROTATION height has passed underneath it. " + + "The startup guard cannot see that; this rule can. WHAT TO DO: install the registry " + + "whose hash is {} and restart. Per-row fingerprints of the registry in use: {}", + blockNumber, + required.hash(), + required.block(), + this.registryBindingSchedule == null ? "(none)" : this.registryBindingSchedule.source(), + // GENESIS BINDING, same reason as above: this is compared against required.hash(), which + // is computed with hashFor. + loaded == null ? "(no registry loaded)" : PqRegistryHash.hashFor(loaded, registryBindingChainId), + loaded == null ? "(none)" : loaded.sourcePath(), + loaded == null ? "(none)" : loaded.kind(), + loaded == null ? 0 : loaded.count(), + loaded != null && loaded.addressBound(), + required.hash(), + rowFingerprints(loaded)); + } + + private String rowFingerprints(final PqRegistryHash.Registry loaded) { + if (loaded == null) { + return "(none)"; + } + final StringBuilder sb = new StringBuilder(); + for (int i = 0; i < loaded.count(); i++) { + if (i > 0) { + sb.append(' '); + } + sb.append('[').append(i).append(']').append(PqRegistryHash.fingerprint(loaded, i)); + } + return sb.toString(); + } + + /** + * Outcome of the genesis-binding guard. + * + * @return the gate state, or null when the guard has not run + */ + public PqRegistryHash.GateState registryBindingState() { + return registryBindingState; + } + + /** + * Parse a Falcon manifest JSON node ({@code count} plus {@code 0..N-1} entries) into {@code + * outPub} / {@code outAddr} and return the keccak256 of the canonical pre-image, or {@code null} + * on any failure. + * + *

Each entry is EITHER an address-bound object {@code {"addr": "0x..", "pk": "hex"}} (the + * audit-fixed form that binds the Falcon key to a validator address) OR a bare pubkey hex string + * (legacy, pubkey-only). A manifest must be uniformly one or the other; a mixed manifest is + * rejected (fail-closed). The canonical pre-image is the concatenation, in ascending + * validator-index order, of {@code addr(20) || pk} when address-bound, or {@code pk} when legacy. + */ + private static String parseManifest( + final JsonNode cfg, final Map outPub, final Map outAddr) { + if (cfg == null || cfg.isMissingNode() || !cfg.has("count")) { + return null; + } + final int count = cfg.get("count").asInt(); + if (count <= 0) { + return null; + } + Boolean addressBoundMode = null; // decided by the first entry, then enforced uniformly + for (int i = 0; i < count; i++) { + final JsonNode e = cfg.get(Integer.toString(i)); + if (e == null) { + return null; + } + final boolean bound = e.isObject(); + if (addressBoundMode == null) { + addressBoundMode = bound; + } else if (addressBoundMode != bound) { + return null; // mixed manifest -> fail-closed + } + if (bound) { + final JsonNode addrNode = e.get("addr"); + final JsonNode pkNode = e.get("pk"); + if (addrNode == null || !addrNode.isTextual() || pkNode == null || !pkNode.isTextual()) { + return null; + } + final Address addr; + try { + addr = Address.fromHexString(addrNode.asText().trim()); + } catch (final RuntimeException ex) { + return null; + } + if (addr == null) { + return null; + } + outAddr.put(i, addr); + outPub.put(i, Bytes.fromHexStringLenient(pkNode.asText().trim()).toArray()); + } else { + if (!e.isTextual()) { + return null; + } + outPub.put(i, Bytes.fromHexStringLenient(e.asText().trim()).toArray()); + } + } + final KeccakDigest kd = new KeccakDigest(256); + for (int i = 0; i < count; i++) { + if (Boolean.TRUE.equals(addressBoundMode)) { + // Base Besu d2032017: Address extends BytesHolder (not a Tuweni Bytes), so obtain the raw + // 20 address bytes via getBytes().toArray(). Byte-identical to the intended pre-image + // (addr20 || pk). Reconciled during PQ-CONSENSUS-BUILDTEST-2026-07-18 (compiles clean). + final byte[] a = outAddr.get(i).getBytes().toArray(); + kd.update(a, 0, a.length); + } + final byte[] h = outPub.get(i); + kd.update(h, 0, h.length); + } + final byte[] digest = new byte[32]; + kd.doFinal(digest, 0); + return Bytes.wrap(digest).toUnprefixedHexString(); + } + + /** + * Load the STAGE-2 late-anchor manifest file (a bare JSON object {@code {"count": N, "0": {...}, + * ...}}) into a PENDING pubkey + address map. The registry is NOT populated here; activation + * happens only via {@link #activateLateAnchor} once the on-chain anchor contract has been + * observed. + * + * @param manifestPath path to the manifest JSON file + * @param pend out-param pending pubkey map + * @param pendAddr out-param pending validator-address map + * @return keccak256(canonical manifest) 64-hex no 0x, or null on any failure + */ + private static String loadPendingLateManifest( + final String manifestPath, + final Map pend, + final Map pendAddr) { + try { + final byte[] raw = Files.readAllBytes(Paths.get(manifestPath)); + final JsonNode root = new ObjectMapper().readTree(raw); + final Map pub = new HashMap<>(); + final Map addr = new HashMap<>(); + final String computed = parseManifest(root, pub, addr); + if (computed == null) { + return null; + } + for (final Map.Entry e : pub.entrySet()) { + pend.put( + e.getKey(), new FalconPublicKeyParameters(FalconParameters.falcon_512, e.getValue())); + } + pendAddr.putAll(addr); + return computed; + } catch (final Exception e) { + LOG.error( + "AERE PQC: failed to parse late-anchor manifest {}: {} (fail-closed).", + manifestPath, + e.toString()); + return null; + } + } + + /** + * Load the validator-to-Falcon-pubkey registry from a GENESIS-ANCHORED manifest and verify it + * against the on-chain-committed manifest hash. + * + *

Fail-CLOSED: if the manifest is absent, malformed, or its keccak256 does not equal the + * on-chain anchor (i.e. a tampered pubkey or address), the registry is left EMPTY and {@code + * null} is returned. With an empty registry every Falcon seal fails to verify, so a post-fork + * BLOCKING chain rejects the block - a manifest tamper is therefore detected and cannot be + * silently accepted. + * + * @param genesisPath path to the genesis file + * @param reg out-param registry map to populate on success + * @param regAddr out-param validator-address map to populate on success + * @return the verified manifest hash (64-hex, no 0x) on success, or {@code null} on any failure + */ + private static String loadGenesisAnchoredRegistry( + final String genesisPath, + final Map reg, + final Map regAddr) { + try { + final byte[] raw = Files.readAllBytes(Paths.get(genesisPath)); + final JsonNode root = new ObjectMapper().readTree(raw); + final JsonNode cfg = root.path("config").path("aereFalconRegistry"); + final Map pub = new HashMap<>(); + final Map addr = new HashMap<>(); + final String computed = parseManifest(cfg, pub, addr); + if (computed == null) { + LOG.error( + "AERE PQC: genesis {} has no valid config.aereFalconRegistry manifest; registry EMPTY " + + "(fail-closed).", + genesisPath); + return null; + } + + final String anchored = readAnchoredHash(root); + if (anchored == null) { + LOG.error( + "AERE PQC: genesis {} has no on-chain anchored manifest hash at alloc 0x{} slot 0; " + + "registry EMPTY (fail-closed).", + genesisPath, + ANCHOR_ADDRESS); + return null; + } + if (!computed.equalsIgnoreCase(anchored)) { + LOG.error( + "AERE PQC: GENESIS MANIFEST TAMPER DETECTED - keccak256(manifest)=0x{} != " + + "genesis-anchored hash 0x{} (alloc 0x{} slot 0). Registry REJECTED / EMPTY " + + "(fail-closed); post-fork blocks will not verify.", + computed, + anchored, + ANCHOR_ADDRESS); + return null; + } + + for (final Map.Entry e : pub.entrySet()) { + reg.put( + e.getKey(), new FalconPublicKeyParameters(FalconParameters.falcon_512, e.getValue())); + } + regAddr.putAll(addr); + LOG.info( + "AERE PQC: loaded GENESIS-ANCHORED Falcon registry ({} entries, address-bound={}) from " + + "{}; manifestHash=0x{} MATCHES on-chain anchor (alloc 0x{} slot 0, committed in the " + + "genesis stateRoot / block hash). Registry is immutable from genesis.", + reg.size(), + !regAddr.isEmpty() && regAddr.size() == reg.size(), + genesisPath, + computed, + ANCHOR_ADDRESS); + return computed; + } catch (final Exception e) { + LOG.error( + "AERE PQC: failed to load genesis-anchored Falcon registry from {}: {} " + + "(registry EMPTY, fail-closed).", + genesisPath, + e.toString()); + return null; + } + } + + /** Read the anchored manifest hash from genesis alloc[ANCHOR].storage[slot0]; null if absent. */ + private static String readAnchoredHash(final JsonNode root) { + final JsonNode alloc = root.path("alloc"); + if (alloc.isMissingNode()) { + return null; + } + final Iterator> it = alloc.fields(); + while (it.hasNext()) { + final Map.Entry en = it.next(); + if (!norm(en.getKey(), 40).equals(ANCHOR_ADDRESS)) { + continue; + } + final JsonNode storage = en.getValue().path("storage"); + if (storage.isMissingNode()) { + return null; + } + final Iterator> sit = storage.fields(); + while (sit.hasNext()) { + final Map.Entry se = sit.next(); + if (norm(se.getKey(), 64).equals(ANCHOR_SLOT)) { + return norm(se.getValue().asText(), 64); + } + } + } + return null; + } + + /** Normalise a hex string: strip 0x, lower-case, left-pad with zeros to {@code width} chars. */ + private static String norm(final String hex, final int width) { + String s = hex == null ? "" : hex.trim(); + if (s.startsWith("0x") || s.startsWith("0X")) { + s = s.substring(2); + } + s = s.toLowerCase(Locale.ROOT); + final StringBuilder b = new StringBuilder(); + for (int i = s.length(); i < width; i++) { + b.append('0'); + } + b.append(s); + return b.toString(); + } + + /** + * Process-wide lazy singleton. + * + * @return the shared instance + */ + public static FalconSealSupport instance() { + if (instance == null) { + synchronized (FalconSealSupport.class) { + if (instance == null) { + instance = new FalconSealSupport(); + } + } + } + return instance; + } + + private static String resolve(final String sysProp, final String envVar) { + final String v = System.getProperty(sysProp); + if (v != null && !v.isBlank()) { + return v; + } + final String e = System.getenv(envVar); + if (e != null && !e.isBlank()) { + return e; + } + return null; + } + + // ---- STAGE-2 LATE-ANCHOR API ---- + + /** + * Whether a late-anchor manifest is configured, parsed, and still awaiting on-chain activation. + * Returns false once activated, terminally failed, or when late-anchor mode is not configured. + * + * @return true iff consensus code should keep looking for the on-chain anchor contract + */ + public boolean lateAnchorPending() { + return anchorContractAddress != null && !lateActivated && !lateFailed; + } + + /** + * The configured on-chain anchor contract address for the late-anchor registry source. + * + * @return the 0x-hex contract address, or null when late-anchor mode is not configured + */ + public String anchorAddress() { + return anchorContractAddress; + } + + /** + * Whether the registry has been activated from the late-anchor (contract-anchored) source. + * + * @return true iff late-anchor activation succeeded + */ + public boolean lateAnchored() { + return lateActivated; + } + + /** + * Whether the late anchor is TERMINALLY FAILED, i.e. an on-chain anchor was observed and its hash + * did NOT match the local manifest (tamper, or a wrong manifest shipped to this node). + * + *

AERE FIX-OPRIRE-CONSENS (c): this is the state the old code had no way to express. {@link + * #lateAnchorPending()} returns false both while a legitimate anchor has simply not landed yet AND + * after a tampered anchor was rejected, and the validation rule keyed BLOCKING off {@code + * genesisAnchored() || lateAnchored()} alone. A tampered anchor therefore silently degraded the + * whole PQC layer to LOG-ONLY: it failed OPEN. PENDING and terminally FAILED are now distinct, and + * the rule fails CLOSED on FAILED. + * + * @return true iff a manifest/anchor mismatch was detected and the registry is permanently empty + */ + public boolean lateAnchorFailed() { + return lateFailed; + } + + /** + * STAGE-2: activate the pending late-anchor registry against the manifest hash observed in the + * anchor contract's storage slot 0 in the chain's world state. + * + *

Idempotent and fail-closed: on a hash match the pending manifest becomes the live registry; + * on a mismatch the registry stays EMPTY and the late-anchor path is terminally failed (so a + * post-fork BLOCKING chain rejects blocks rather than accepting an unverified manifest). + * + * @param onChainHash the 32-byte hash read from the anchor contract's slot 0 (hex, 0x optional) + * @return true iff the registry is (now) activated from the late anchor + */ + public synchronized boolean activateLateAnchor(final String onChainHash) { + if (lateActivated) { + return true; + } + if (lateFailed || anchorContractAddress == null || pendingLateHash == null) { + return false; + } + final String observed = norm(onChainHash, 64); + if (!pendingLateHash.equalsIgnoreCase(observed)) { + LOG.error( + "AERE PQC: LATE-ANCHOR MANIFEST TAMPER DETECTED - keccak256(local manifest)=0x{} != " + + "on-chain anchor 0x{} at contract {} slot 0. Registry REJECTED / EMPTY " + + "(fail-closed); post-fork blocks will not verify.", + pendingLateHash, + observed, + anchorContractAddress); + lateFailed = true; + return false; + } + registry.putAll(pendingLate); + if (pendingLateAddresses != null) { + indexToAddress.putAll(pendingLateAddresses); + } + manifestHash = pendingLateHash; + lateActivated = true; + LOG.info( + "AERE PQC: LATE-ANCHOR ACTIVATED - Falcon registry ({} entries, address-bound={}) is now " + + "live; keccak256(manifest)=0x{} MATCHES the on-chain anchor at contract {} slot 0 " + + "(readable via eth_getStorageAt). Hybrid PQC certificate can now be verified on an " + + "already-live chain with NO re-genesis.", + registry.size(), + addressBound(), + pendingLateHash, + anchorContractAddress); + return true; + } + + /** + * Whether this node holds a Falcon signing key and should attach a parallel seal. + * + * @return true if signing is enabled + */ + public boolean signingEnabled() { + return signingEnabled; + } + + /** + * This node's validator index in the Falcon registry. + * + * @return the local validator index, or -1 if signing is disabled + */ + public int localIndex() { + return localIndex; + } + + /** + * Number of validators in the Falcon registry. + * + * @return the registry size + */ + public int registrySize() { + return registry.size(); + } + + // ---- AERE audit fix (AUD-CONSENSUS-1 / -2): address binding + eligible-signer set ---- + + /** + * Whether the active registry binds every one of its Falcon keys to a validator ADDRESS. Only an + * address-bound registry can produce a well-defined {@code currentValidators INTERSECT registry} + * eligible-signer set, so ONLY an address-bound registry may arm blocking. The legacy pubkey-only + * {@code aere.falcon.registry} file and an empty (pending / unconfigured) registry both return + * false, so a post-fork block over such a registry fails closed. + * + * @return true iff the registry is non-empty and every registry index has a bound validator + * address + */ + public boolean addressBound() { + return !registry.isEmpty() && indexToAddress.size() == registry.size(); + } + + /** + * The validator ADDRESS bound to a registry index, or {@code null} if the index is unregistered + * or the registry is not address-bound. + * + * @param validatorIndex the registry index carried by a Falcon seal + * @return the bound validator address, or null + */ + public Address addressForIndex(final int validatorIndex) { + return indexToAddress.get(validatorIndex); + } + + /** + * AERE 2026-08-07. Refuses to start when the Falcon key this node loaded belongs to a DIFFERENT + * validator, i.e. when the registry binds the key's index to an address that is not this node's. + * + *

WHY THIS EXISTS, and it is a measurement not a worry. Seven test nodes, one variable changed: + * node 0 was handed index 1's key file. It started clean and logged {@code loaded local Falcon + * signing key for validator index 1} - the very same line the node that really is index 1 logs. + * Zero refusals, zero mismatch lines. The chain then crossed the K=3 threshold height without a + * pause, to height 86, with zero rejected blocks on the healthy nodes and zero divergence, because + * the seals VERIFY: it is genuinely index 1's key. What was silently lost is that index 0 + * disappeared from the signer set and index 1 was duplicated. The N=7, K=3, f=2 margin is computed + * over SEVEN DISTINCT signers; you have six and nothing tells you. + * + *

Nothing else catches it. Not startup, not the log, not the block rhythm, not rejections, not + * the divergence detector - every node agrees, because the certificate is valid. Until today the + * only guard was a PROCEDURE step run by hand on each target, and a procedure step can be skipped. + * The written procedure claimed it "shows up as R2 rejections at the anchor height"; measured, it does not. + * + *

WHY IT IS SAFE TO REFUSE HERE. Same argument as the other startup guards: this runs before + * the network and the QBFT state machine start, so it is a clean refusal to start rather than a + * mid-flight halt. It is INERT unless a Falcon key is configured AND the registry is address-bound, + * so wherever no {@code aere.falcon.key} is configured, it cannot fire. + * + *

A null bound address is NOT treated as a failure: that means a pubkey-only registry, which is + * already refused elsewhere for the paths that matter, and turning it into a second refusal here + * would move the operator's attention to the wrong place. + * + * @param thisNodeAddress this node's own validator address, derived from its node key + */ + public void verifyLocalKeyBelongsToThisNodeOrAbort(final Address thisNodeAddress) { + if (localIndex < 0 || localPrivateKey == null) { + return; // no local Falcon key: nothing to bind + } + if (thisNodeAddress == null) { + LOG.warn( + "AERE PQC KEY-ADDR: this node holds Falcon key index {} but its own validator address " + + "could not be derived, so the key-to-machine binding was NOT checked. This is the " + + "one failure mode that stays invisible afterwards.", + localIndex); + return; + } + final Address bound = indexToAddress.get(localIndex); + if (bound == null) { + LOG.warn( + "AERE PQC KEY-ADDR: this node holds Falcon key index {} and the registry binds NO address " + + "to that index (pubkey-only registry), so the key-to-machine binding cannot be " + + "checked. A key placed on the wrong machine would not be detectable here.", + localIndex); + return; + } + if (bound.equals(thisNodeAddress)) { + LOG.info( + "AERE PQC KEY-ADDR: local Falcon key index {} is bound by the registry to {}, which is " + + "this node's own validator address. Key and machine agree.", + localIndex, + bound); + return; + } + throw new ActivationConfigException( + ActivationConfigException.Kind.UNSAFE, + "AERE-PQC-KEY-ADDR-01", + "AERE PQC: REFUSING TO START (fail-closed). The local Falcon key index does not map to " + + "this node.\n" + + " READ: aere.falcon.key carries index " + + localIndex + + ", and the registry binds that index to validator " + + bound + + ".\n" + + " BUT: this node's own validator address is " + + thisNodeAddress + + ".\n" + + " MEANING: this machine was handed ANOTHER validator's Falcon key. If it started, it " + + "would sign as " + + bound + + ", so that index would be produced by two machines while this node's own index would " + + "never appear in any certificate. The quorum would be silently one signer short: the " + + "N/K/f margin is computed over DISTINCT signers.\n" + + " WHY YOU WOULD NOT HAVE NOTICED: measured 2026-08-07 on seven nodes, this exact " + + "mistake produces a chain that crosses the threshold height with no pause, no rejected " + + "blocks, no divergence and no error line anywhere.\n" + + " FIX: put the key whose index the registry binds to " + + thisNodeAddress + + " on this machine, and run the on-target check " + + "(VerificaPotrivireaCheilor) before starting."); + } + + /** + * LOOKUP HARDENING (a). THIS NODE's own signing identity, which is the one place in the stack that + * legitimately has no height. + * + *

WHY IT IS A SEPARATE METHOD AND NOT {@code addressForIndex(localIndex())}. The adversarial + * review of 2026-08-02 found the verification path asking the registry questions with no + * height. Deleting the height-less pair from {@link PqSignerRegistry} left exactly one honest + * caller behind: {@code QbftBlockCreatorAdaptor} asking "am I, right now, an eligible signer", + * before signing with the single private key this process holds. There is no historical question + * in that, and inventing a height for it would be a lie dressed as rigour. Giving it its own name + * means the two uses can no longer be confused by a future reader, which is how the defect arrived in the + * first place. + * + * @return this node's registry-bound address, or null when it holds no key or the registry is not + * address-bound + */ + public Address localSigningAddress() { + return indexToAddress.get(localIndex); + } + + /** + * LOOKUP HARDENING (b). The height at and above which this node is ARMED, i.e. from which a header's + * Falcon certificate carries consensus weight. + * + *

WHY THE HEIGHT-RESOLVED LOOKUPS NEED IT. Before this, {@code keyAt} and {@code + * addressForIndexAt} fell back to the HEAD registry whenever no {@code config.pqRegistryHash} + * entry was in force - and {@code pqRegistryHash} is in no genesis this fleet runs (measured + * 2026-08-05: a search for {@code pqRegistryHash} across the deployment configuration returns + * nothing). So the whole height-schedule machinery was inert and the answer above the arming + * height was still "verify this year-old header against today's keys", which IS the height-less + * lookup defect, unrepaired. Falling back is correct BELOW the arming height, where no certificate is being judged; at and above it, + * the honest answer to "which keys were in force here" is a refusal, not a guess. + * + *

Read through {@link PqAnchorProducer#config()} so the value is the same one the producer and + * the R1/R2 rules use, memoised there. Never throws: a broken anchor configuration already fails + * closed at construction, and this method must not be the thing that turns a diagnostic into an + * exception on the per-block path. + * + *

INERT ON CHAIN 2800 AS IT STANDS. {@code aere.pq.anchorBlock} is unset on the live fleet, so + * {@code everActive()} is false, this returns {@link Long#MAX_VALUE}, no height is at or above it, + * and every answer below is byte-for-byte what it was before. This code cannot stop a validator + * that is running today. + * + * @return the arming height, or {@link Long#MAX_VALUE} when the anchor is not configured + */ + private long anchorArmedFrom() { + try { + final PqAnchorConfig cfg = + org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer.config(); + return cfg.everActive() ? cfg.anchorBlock() : Long.MAX_VALUE; + } catch (final RuntimeException e) { + // An unreadable anchor configuration is already a refusal to start elsewhere. Here, the + // conservative answer is "armed from nowhere", because claiming armed would refuse headers on + // a node whose only fault is a diagnostic that threw. + return Long.MAX_VALUE; + } + } + + /** + * The set of validator addresses that have a Falcon key in the registry. This is the "signer + * registry" side of the eligible-signer intersection {@code currentValidators INTERSECT + * registry}. + * + * @return an immutable snapshot of the registered validator addresses (possibly empty) + */ + public Set

registeredValidatorAddresses() { + return Set.copyOf(indexToAddress.values()); + } + + /** + * Whether the registry covers the given (current) validator set, i.e. every current validator has + * a Falcon key in the registry. This is the ARMING INVARIANT (AUD-CONSENSUS-1): blocking should be + * armed only when coverage is complete, so the eligible-signer set equals the full validator set + * and the Falcon quorum matches the ECDSA quorum. When coverage is incomplete the blocking rule + * stays LIVE on the intersection (it never silently halts) but warns that the margin is reduced + * and a registry re-anchor is required. + * + * @param validators the current QBFT validator set + * @return true iff the registry is address-bound and contains every current validator + */ + public boolean registryCoversValidators(final Collection
validators) { + if (!addressBound()) { + return false; + } + final Set
registered = registeredValidatorAddresses(); + return registered.containsAll(validators); + } + + /** + * Whether the registry was resolved from a verified GENESIS-ANCHORED manifest (as opposed to the + * late-anchor path, the legacy mutable file, or no registry at all). + * + * @return true iff the registry is genesis-anchored and its hash matched the on-chain anchor + */ + public boolean genesisAnchored() { + return genesisAnchored; + } + + /** + * The verified keccak256 of the canonical anchored manifest (genesis-anchored or late-anchored), + * or {@code null} when no anchored registry is active. + * + * @return the manifest hash as 64-hex (no 0x prefix), or null + */ + public String registryManifestHash() { + return manifestHash; + } + + /** + * The block number at and after which the Falcon quorum certificate becomes BLOCKING (a valid + * >= 2f+1 Falcon quorum is required for a block to be accepted). Before this block the Falcon + * seals are additive / log-only. Configured via {@code aere.falcon.forkBlock} / {@code + * AERE_FALCON_FORKBLOCK}; defaults to {@link Long#MAX_VALUE} (never blocking, pure log-only) so + * an unconfigured node behaves exactly like the additive baseline. + * + *

AERE LATE-ANCHOR HEIGHT (2026-08-03): this used to re-read the property on every call and swallow a + * NumberFormatException into {@link Long#MAX_VALUE} with a WARN line. That is the finding, in one + * method: the single value the entire post-quantum enforcement layer is gated on could silently + * become "never blocking", and the only trace was a log line nothing reads. The value is now + * resolved once, in the constructor, by {@link #validateForkBlockConfigOrAbort()}, which ABORTS on + * anything it cannot parse. There is no longer any input to this method and therefore no longer + * any way for it to fail open. + * + * @return the fork block, or {@link Long#MAX_VALUE} if unconfigured + */ + public long forkBlock() { + return forkBlock; + } + + /** + * AERE LATE-ANCHOR HEIGHT: the DECLARED height at or after which the on-chain late-anchor registry contract is + * expected to be observable, or {@link Long#MAX_VALUE} when undeclared. + * + * @return the declared anchor observation height + */ + public long anchorObserveBlock() { + return anchorObserveBlock; + } + + /** + * AERE LATE-ANCHOR HEIGHT: the first height at which this node validated a header at or after the blocking + * height while the anchored registry was NOT active, or -1 if that has never happened. + * + *

The configuration guard refuses the misconfiguration that CAUSES this. It cannot refuse the + * accident: a correctly declared observation height whose anchor transaction never lands. From + * that height on, the node runs log-only while its operator believes the Falcon quorum is + * enforced. This counter is the difference between that situation being visible and it being a + * warning in a file. + * + * @return the first such height, or -1 + */ + public long blockingArmedWithoutRegistrySince() { + return blockingArmedWithoutRegistrySince.get(); + } + + /** + * Record that the blocking height has been reached with no active anchored registry. Never throws; + * called from the header-validation path. + * + * @param blockNumber the height at which the condition was observed + * @return true iff this is the FIRST height at which it was observed, so the caller can log once + * instead of once per block (a per-block line at a sub-second block period is a disk hazard, + * measured on this fleet in a different form on 2026-08-02) + */ + public boolean noteBlockingArmedWithoutActiveRegistry(final long blockNumber) { + final boolean first = blockingArmedWithoutRegistrySince.compareAndSet(-1L, blockNumber); + if (first) { + LOG.error( + "AERE PQC [AERE-PQC-ARM-INERT-01]: block {} is at or after the Falcon BLOCKING height {} " + + "and the anchored registry is NOT active, so the Falcon quorum is NOT being " + + "enforced and will not be until the anchor lands. Declared observation height was " + + "{}. This node is running the additive LOG-ONLY baseline while configured to " + + "enforce a post-quantum quorum. Deploy or re-observe the anchor contract {}.", + blockNumber, + forkBlock, + anchorObserveBlock == Long.MAX_VALUE ? "undeclared" : Long.toString(anchorObserveBlock), + anchorContractAddress == null ? "(none configured)" : anchorContractAddress); + } + return first; + } + + /** + * AERE audit fix (AUD-CONSENSUS-1) fail-closed ARMING diagnostic, emitted once at construction. + * If a fork block is configured (blocking is intended) but the manifest that will back it is not + * address-bound, the {@code currentValidators INTERSECT registry} eligible-signer set cannot be + * formed and post-fork blocks will fail closed. We log this LOUDLY here (arm/config time) rather + * than let it surface as a silent block-production halt later. We deliberately do NOT throw: this + * class must never throw on the consensus path, so the guarantee is loud-diagnostic-plus-fail- + * closed-reject (in the rule), not process abort. + */ + private void armingReadinessDiagnostic() { + final long fork = forkBlock(); + if (fork == Long.MAX_VALUE) { + return; // blocking not configured; nothing to arm + } + final boolean pendingAddressBound = + pendingLate != null + && pendingLateAddresses != null + && !pendingLate.isEmpty() + && pendingLateAddresses.size() == pendingLate.size(); + if (addressBound() || (anchorContractAddress != null && pendingAddressBound)) { + LOG.info( + "AERE PQC: blocking configured (forkBlock={}); backing manifest is ADDRESS-BOUND, so the " + + "eligible-signer set (currentValidators INTERSECT registry) is well defined.", + fork); + return; + } + // AERE GENESIS BINDING REPAIR (2026-08-02). This used to be LOG.error and nothing else, and the comment + // above still says "diagnostic only". That was measured to be the wrong trade. A node that + // starts here does not stay harmless: it joins the fleet, reaches the activation height, and + // from that height rejects every header that carries a certificate. Measured on three + // observers: head 19 with R2=4 rejections, against head 374 with R1=0 R2=0 for the + // address-bound source on the same honest chain. A whole fleet configured this way stops, and + // stops in the shape of a consensus failure rather than a configuration error, which is the + // most expensive shape a configuration error can take. + // + // So it now REFUSES TO START. The reasons this is safe to do HERE, and only here: this is the + // constructor, i.e. config time, on the same path as validateForkBlockConfigOrAbort() and + // validateAndResolveAttachBlockOrAbort(), which already abort; the network is not up; the QBFT + // state machine has not been created; and no header has been offered to anybody. The promise + // that this class never throws is a promise about the PER-BLOCK path, and it is kept: nothing + // below this constructor throws. + // + // The refusal is inert wherever forkBlock is unset, because + // this code cannot stop a live validator that is running now. + throw new ActivationConfigException( + ActivationConfigException.Kind.UNSAFE, + "AERE-PQC-REG-ARM-01", + "AERE PQC GENESIS-BINDING: REFUSING TO START (fail-closed). Falcon blocking is " + + "configured at forkBlock=" + + fork + + " but the active Falcon registry is NOT ADDRESS-BOUND" + + " (registrySize=" + + registry.size() + + ", indexToAddress=" + + indexToAddress.size() + + ", source=" + + (registrySourcePath == null ? "(none)" : registrySourcePath) + + " [" + + registrySourceKind + + "], late-anchor pending=" + + (anchorContractAddress != null) + + "). WHAT THIS MEANS: addressForIndex() returns null for every index, so the " + + "eligible-signer set (currentValidators INTERSECT registry) is empty and EVERY block " + + "at or above the activation height would be rejected by the seals rule. Measured, " + + "this is not a degradation, it is a fleet-wide halt: three observers replaying the " + + "same honest chain reached head 374 with an address-bound registry and stopped at " + + "head 19 with this one. WHAT TO DO: re-anchor the registry in an ADDRESS-BOUND form " + + "- a genesis manifest at config.aereFalconRegistry with {\"addr\",\"pk\"} entries, or " + + "a properties file carrying a '.addr=0x<20 bytes>' row for every index - and set " + + "config.pqRegistryHash to its hash, before configuring aere.falcon.forkBlock. To " + + "start this node unchanged for diagnosis, unset aere.falcon.forkBlock; the node is " + + "then log-only and cannot halt anything."); + } + + /** + * AERE ROW BINDING (2026-08-06). REFUSE TO START when this node is ARMED and the registry it will be + * held to carries no binding proofs. + * + *

WHAT IT ADDS OVER {@code AERE-PQC-REG-ARM-01}. That guard asks whether the registry is + * ADDRESS-BOUND, i.e. whether index {@code i} has an address at all. This one asks the question + * that decides who a seal is CREDITED to: does row {@code i} carry a Falcon possession proof over + * its own {@code (chainId, bindHeight, count, i, address, publicKey)} pre-image, and an ECDSA + * claim over the same bytes signed by that validator's own consensus key. Without both, whoever + * writes the registry file decides attribution, and the threshold itself becomes fiction: two + * indices carrying the same key satisfy K=2 with a single private key. + * + *

WHY UNIQUENESS IS NOT ENOUGH, and this is measured rather than argued. Swapping the public + * keys of two rows leaves four distinct keys and four distinct addresses, so every uniqueness + * check passes and the forged header is ACCEPTED. Uniqueness repairs the THRESHOLD. Only a + * signature by the validator repairs ATTRIBUTION. + * + *

WHEN IT FIRES. Only when this node is armed: {@code aere.falcon.forkBlock} is configured, or + * the certificate anchor is active per {@link #anchorArmedFrom()}. Both are unset as it + * stands, so this refusal is INERT on the live fleet exactly as AERE-PQC-REG-ARM-01 is, and it + * cannot stop a validator that is running today. That inertness is not asserted here, it is + * measured by {@code PqInertBinaryTest}, which drives a node with no {@code aere.pq.*} + * and no {@code aere.falcon.forkBlock} property at all through this constructor. + * + *

WHAT IT DOES NOT JUDGE. A node armed with NO registry file at all returns without a word. + * Row binding is about a row that credits a seal to the wrong validator, and a registry with no rows + * credits nobody; that condition belongs to AERE-PQC-REG-ARM-01 and AERE-PQC-CFG-UNSAFE-08, which + * own it and name the numbers. See the comment at the return itself for what was measured when + * this guard tried to own it too. + * + *

WHY IT IS NOT BEHIND THE EMERGENCY BYPASS. {@code aere.falcon.registry.mismatch.allow} + * overrides the genesis hash gate, which answers "is this the file genesis named" - a question about + * configuration drift, recoverable by installing the right file. This one answers "can this + * registry be trusted to say who signed", and the anchor contract is IMMUTABLE once written: a + * fleet armed over unbound rows carries that defect for the life of the chain. The way out of THIS one + * is the same as for AERE-PQC-REG-ARM-01 - do not arm, or disarm: unset {@code + * aere.falcon.forkBlock}, or set {@code aere.pq.anchor.disable=true}, which makes {@link + * #anchorArmedFrom()} return {@link Long#MAX_VALUE} and leaves {@code PqEmergencyShoutRule} + * shouting at every height. + * + *

WHY IT MAY THROW HERE. Same path, same reasoning and same precedent as + * AERE-PQC-REG-ARM-01 and AERE-PQC-CFG-UNSAFE-04 directly around it: this is the constructor, the + * network is not up, the QBFT state machine does not exist and no header has been offered to + * anybody. The promise that this class never throws is a promise about the PER-BLOCK path, and it + * is kept. + * + *

NOT MEASURED, and written rather than hidden: when the anchor configuration itself cannot be + * read, {@link #anchorArmedFrom()} answers {@link Long#MAX_VALUE} and this guard therefore does + * not fire. That case is owned by the anchor loader, which refuses to start on its own account + * (AERE-PQC-ANCHOR-CONF-01); that the refusal always reaches the operator BEFORE this constructor + * runs has not been measured here. + * + * @throws PqRegistryHash.RegistryConfigException with code {@code AERE-PQC-REG-ARM-02} when this + * node is armed over a registry with no binding proofs + */ + private void requireRegistryBindingProofsOrAbort() { + if (forkBlock() == Long.MAX_VALUE && anchorArmedFrom() == Long.MAX_VALUE) { + return; // nothing armed on this node; inert + } + if (registrySourcePath == null) { + // AN ARMED NODE WITH NO REGISTRY AT ALL IS NOT A ROW-BINDING DEFECT, and this return is the + // difference between a guard and a blanket. Row binding is about mis-ATTRIBUTION: a row that credits a + // seal to a validator who never held the key on it. That requires ROWS. With no registry + // there are no rows, indexToAddress is empty, the eligible-signer set is empty, and nobody + // can be credited with anything - there is nothing for a forged binding to say. + // + // The "armed over nothing" condition is real and it is already owned, twice, by guards that + // say more than this one could: AERE-PQC-REG-ARM-01 immediately above (when + // aere.falcon.forkBlock is set) and AERE-PQC-CFG-UNSAFE-08 immediately below, which refuses + // a positive threshold that the anchored key count cannot guarantee and names the two + // numbers. MEASURED 2026-08-06: refusing here instead took over both of those messages - + // PqForkThresholdReachabilityTest#aPositiveThresholdWithNoAnchoredKeysMustRefuseToStart got + // AERE-PQC-REG-ARM-02 where it must get AERE-PQC-CFG-UNSAFE-08 - and additionally refused + // the K=0 warm-up regime, which is the regime the fleet is meant to arm INTO. A guard that + // breaks the intended activation procedure is not fail-closed, it is just closed. + // + // KNOWN AND NOT CLOSED HERE: aere.falcon.testnetAllowSmallFleet waives + // AERE-PQC-CFG-UNSAFE-08, so on a fleet that sets it, armed + no registry + K>0 still + // starts. That is an explicitly named isolated-network waiver and it is deliberately not + // re-litigated by this guard. + return; + } + // Deliberately re-read from the source file rather than from the parsed maps: the proofs are + // per-ROW and the in-memory index-to-key / index-to-address maps have already thrown them away. + // A guard that judged the maps would be judging what survived the loader, not what was written. + PqRegistryHash.requireBindingsOrThrow(PqRegistryHash.loadAuto(Paths.get(registrySourcePath))); + } + + /** + * AERE COVERAGE MARGIN (2026-08-03). REFUSE TO START when the armed Falcon threshold K is above the number of + * anchored-key holders this fleet is GUARANTEED to have among a block's ECDSA committers. + * + *

WHY THIS EXISTS. The 2026-08-02 repair correctly removed the fleet-wide coverage question from + * the per-commit attachment gate, because answering it with "stop attaching" is what stopped the + * chain. In its place the javadoc of {@link #attachmentArmed} promised that the same protection was + * still there, one level up: "an arm-time decision, and it is made at arm time by + * armingReadinessDiagnostic() and by the operator". Measured on 2026-08-03: {@code + * armingReadinessDiagnostic()} tests ADDRESS-BOUNDNESS and nothing else. It does not read the fleet + * size, the keyed count, or K. The protection named there did not exist. This method is it. + * + *

WHAT IT COMPARES. K is the highest threshold the staged schedule ever reaches, because that is + * the one the chain will eventually be held to; the guarantee is {@link #worstCaseKeyedSigners} + * over the DECLARED fleet size and the anchored key count. Both numbers are known at config time, + * on every node, without a chain and without a vote. The emergency ceiling is deliberately ignored: + * it can only LOWER K later, and a guard must not be satisfied by a lever an operator may not pull. + * + *

WHY IT MAY THROW HERE. Same reasoning, same path and same precedent as AERE-PQC-CFG-UNSAFE-04 + * and the genesis-binding refusal directly above: this is the constructor, the network is not up, the QBFT state + * machine does not exist, and no header has been offered to anyone. The promise that this class + * never throws is a promise about the PER-BLOCK path, and it is kept. + * + *

WHY IT IS NOT A RUNTIME CHECK. A per-block version of this comparison would be exactly the + * defect rebuilt: a fleet-wide fact, false on every node at the same height after an + * ordinary vote, answered with a halting action. The runtime half stays a REPORT, in {@link + * #reportRegistryCoverage}, and decides nothing. + * + *

INERT WHERE {@code aere.pq.anchorBlock} IS UNSET: there is no + * threshold and no comparison. This code cannot stop a validator that is running now. + */ + private void validateThresholdReachabilityOrAbort() { + final PqAnchorConfig anchor; + try { + anchor = PqAnchorConfig.fromSystemConfiguration(); + } catch (final RuntimeException e) { + // The anchor loader already fails closed on its own account and logs why. A guard must not be + // the thing that turns an anchor-config fault into an unexplained startup failure here. + LOG.warn( + "AERE PQC: threshold-reachability guard SKIPPED - the anchor configuration could not be " + + "read ({}). This is NOT a statement that the threshold is reachable.", + e.toString()); + return; + } + if (!anchor.everActive()) { + return; + } + int k = 0; + for (final Integer step : anchor.minSealsSchedule().values()) { + if (step != null && step > k) { + k = step; + } + } + if (k <= 0) { + return; + } + + final int keyed = anchoredKeyCount(); + final int fleet = Math.max(expectedFleetSize(), keyed); + final int guaranteed = worstCaseKeyedSigners(fleet, keyed); + final int deficit = thresholdDeficit(fleet, keyed, k); + if (deficit == 0) { + LOG.info( + "AERE PQC: Falcon threshold K={} is REACHABLE - a fleet of {} with {} anchored key " + + "holder(s) guarantees {} keyed signer(s) among a block's committers.", + k, + fleet, + keyed, + guaranteed); + return; + } + + final boolean waived = + Boolean.parseBoolean( + String.valueOf( + resolve("aere.falcon.testnetAllowSmallFleet", "AERE_FALCON_TESTNET_SMALL_FLEET"))); + if (waived) { + LOG.error( + "AERE PQC: aere.falcon.testnetAllowSmallFleet=true - arming Falcon threshold K={} on a " + + "fleet of {} with only {} anchored key holder(s), which guarantees {}. A proposer " + + "can legitimately fail to assemble a certificate. This setting MUST NOT be used on " + + "mainnet.", + k, + fleet, + keyed, + guaranteed); + return; + } + + throw new ActivationConfigException( + ActivationConfigException.Kind.UNSAFE, + "AERE-PQC-CFG-UNSAFE-08", + "AERE PQC COVERAGE: REFUSING TO START (fail-closed). The Falcon anchor is armed at height " + + anchor.anchorBlock() + + " with a staged threshold that reaches K=" + + k + + ", but this fleet is guaranteed to produce only " + + guaranteed + + " Falcon seal(s) per block: " + + fleet + + " validator(s), of which " + + keyed + + " hold an anchored Falcon key, need ceil(2N/3)=" + + BftHelpers.calculateRequiredValidatorQuorum(fleet) + + " ECDSA committers, and the unluckiest committer set takes every UNKEYED validator " + + "first. Short by " + + deficit + + ". WHAT THIS MEANS: with every node honest and every node up, a proposer can fail to " + + "assemble a certificate, and it fails on every node at once because the validator set " + + "is consensus state - the chain stops in a state where the re-anchoring transaction " + + "that would repair it can no longer be carried by any block. That is an unreachable threshold. WHAT TO " + + "DO: re-anchor the manifest so that every validator holds a key (this is what makes " + + "the standing 'grow to N>=9 before arming' order safe - the manifest has to grow WITH " + + "the set, not after it), or lower the " + + PqAnchorConfig.PROPERTY_MIN_SEALS + + " schedule to at most " + + guaranteed + + ", or declare the true fleet size in aere.falcon.validatorCount if it is wrong here. " + + "Isolated test networks may set aere.falcon.testnetAllowSmallFleet=true."); + } + + /** + * AERE COVERAGE MARGIN: how many validators hold an anchored Falcon key, counting a late anchor that has not + * landed yet the same way {@link #expectedFleetSize()} does, so the two numbers being compared are + * read at the same moment from the same manifest. + * + * @return the anchored key count + */ + private int anchoredKeyCount() { + if (!registry.isEmpty()) { + return registry.size(); + } + return pendingLate == null ? 0 : pendingLate.size(); + } + + // ---- AERE FIX-OPRIRE-CONSENS (b): SEAL-ATTACHMENT GATE ---- + + /** + * The block number at and after which this node may ATTACH a Falcon seal to its Commit messages. + * Configured via {@code aere.falcon.attachBlock} / {@code AERE_FALCON_ATTACHBLOCK}; defaults to + * {@link Long#MAX_VALUE}, i.e. NEVER attach. + * + *

This is deliberately a separate, EARLIER height than {@link #forkBlock()}. The safe ordering + * is: every validator binary upgraded (keyless) -> fleet confirmed on the new binary -> + * attachment height reached, seals start flowing and are verified LOG-ONLY -> only then the fork + * height, where the Falcon quorum becomes blocking. + * + * @return the attachment height, or {@link Long#MAX_VALUE} when attachment is not configured + */ + public long attachBlock() { + return attachBlock; + } + + /** + * Record the validator set seen by the header-validation path. This is the "does the anchored + * registry cover the WHOLE fleet" input to the attachment gate, captured from the one place that + * already has an authoritative validator set for a given height. + * + * @param blockNumber the height the set was read at + * @param validators the validator set at that height + */ + public void observeValidators(final long blockNumber, final Collection

validators) { + if (validators == null || validators.isEmpty() || blockNumber < observedValidatorsHeight) { + return; + } + this.observedValidators = new LinkedHashSet<>(validators); + this.observedValidatorsHeight = blockNumber; + } + + /** + * AERE COVERAGE MARGIN: the height at which a validator set was last observed, or {@code -1} when none ever + * was. Diagnostic, and the only way a test can tell "the gate is being fed" from "the gate happens + * to say yes anyway", which is the difference the coverage repair turns on. + * + * @return the observation height, or -1 if no validator set has been observed + */ + public long observedValidatorsHeight() { + return observedValidators == null ? -1L : observedValidatorsHeight; + } + + /** + * Whether this node may ATTACH a Falcon seal to a Commit for the given block. Never throws. + * + *

All three conditions are fleet-wide facts, so every correct node flips at the same block: + * + *

    + *
  1. an ATTACHMENT HEIGHT is configured and reached; + *
  2. the anchored Falcon registry is ACTIVE (genesis-anchored, or a late anchor already + * observed on-chain) and address-bound; + *
  3. that registry binds THIS node's own Falcon index to a validator address. + *
+ * + *

AERE COVERAGE MARGIN (2026-08-02): condition 3 used to be "that registry COVERS every validator in + * the last observed validator set", and that is the sentence that stopped the chain. Coverage + * is a property of the validator set, which is consensus state, so an ordinary add-validator vote + * falsifies it on every node at the same height; the old answer to that was to switch attachment + * OFF, which is fleet-wide, which starves every proposer of seals, which stops the chain in a + * state where the repairing transaction can no longer be included. Measured in {@code + * PqForkValidatorSetChangeTest}. Condition 3 is now a LOCAL fact that no membership change can + * falsify; coverage is REPORTED by {@link #reportRegistryCoverage} with the margin number it + * costs, and decides nothing. + * + *

WHICH CONDITION PROVIDES WHICH PROTECTION (corrected 2026-08-01; the earlier claim here was + * wrong and is restated so nobody relies on protection that is not there): + * + *

    + *
  • CONDITION 1 is what makes a one-by-one KEY rollout harmless. {@code attachBlock} defaults + * to {@link Long#MAX_VALUE}, so a node holding a Falcon key attaches nothing until an + * operator names an activation height. That default, and nothing else, is why step 3 of the + * activation order (distribute keys, restart one at a time) cannot emit a single seal. + *
  • CONDITION 2 stops attachment when the registry backing it is absent or not address-bound, + * so a seal is never emitted against a registry that cannot be checked. + *
  • CONDITION 3 checks that the anchored registry binds THIS node's own index, so this node + * never emits a seal no verifier can attribute. It stops one node, never the fleet, and + * nothing outside this node's own configuration can change its answer. It does NOT and + * cannot check peer BINARY VERSION, and it is emphatically NOT what makes the halting order + * impossible. Until 2026-08-02 it asked the FLEET question instead - does the registry cover + * every observed validator - and switched attachment off when the answer was no; see the + * coverage note on the method below for why that was a chain stop rather than a safeguard. + *
  • The CHAIN-RELATIVE guard, {@link #verifyAttachHeightAgainstChainHeadOrAbort}, is what + * closes that last gap. It refuses to START a node whose activation height is not safely + * ahead of the chain head, so the height cannot be one that the fleet has already walked + * past without arming. + *
+ * + *

None of these check peer binary version. That remains procedural: see step 2 of the + * activation order and the "what this fix does NOT do" section of FIX-OPRIRE-CONSENS. + * + * @param blockNumber the block whose commit is being sealed + * @return true iff attaching a Falcon seal is safe for the whole fleet + */ + public boolean attachmentArmed(final long blockNumber) { + if (attachBlock == Long.MAX_VALUE) { + if (signingEnabled && loggedAttachmentOff.compareAndSet(false, true)) { + LOG.warn( + "AERE PQC: this node HOLDS a Falcon signing key (index {}) but seal ATTACHMENT is NOT " + + "configured (aere.falcon.attachBlock unset), so NO Falcon-carrying Commit will be " + + "emitted. This is the safe default: emitting one while any peer still runs a " + + "binary with a strict Commit decoder would make that peer DISCARD the whole " + + "Commit, ECDSA seal included. Set aere.falcon.attachBlock only after the ENTIRE " + + "validator fleet runs a binary that can parse a Falcon-carrying Commit.", + localIndex); + } + return false; + } + if (blockNumber < attachBlock) { + return false; + } + if (!(genesisAnchored || lateActivated) || !addressBound()) { + if (loggedCoverageBlocked.compareAndSet(false, true)) { + LOG.error( + "AERE PQC: attachment height {} reached at block {} but the anchored registry is NOT " + + "active/address-bound (genesisAnchored={}, lateAnchored={}, lateAnchorFailed={}). " + + "Attachment stays OFF (fail-safe for liveness).", + attachBlock, + blockNumber, + genesisAnchored, + lateActivated, + lateFailed); + } + return false; + } + // AERE COVERAGE MARGIN (2026-08-02), CONDITION 3, REPLACED. What stands here now is a LOCAL fact: does the + // anchored registry bind MY OWN index to an address, so that every verifier can resolve my seal + // to a signer. It is a property of this node and the anchored manifest, and nothing that happens + // to the validator set can falsify it. + // + // WHAT USED TO STAND HERE AND WHY IT HAD TO GO. The old condition 3 asked a FLEET question - + // "does the anchored registry cover every validator I have observed" - and answered NO by + // switching seal attachment OFF. Both halves are wrong, and the second is the halt: + // + // * an ordinary add-validator vote admits a node that, by construction, is not in a manifest + // anchored before it existed. Coverage therefore goes false on EVERY node at the SAME + // height, because the validator set is consensus state. Every node stops attaching, so no + // proposer can gather K seals, so PqAnchorProducer refuses for every proposer in turn and + // the chain stops - and it stops in a state where the re-anchoring transaction that would + // repair it can no longer be carried by any block. That is the same halt, measured in + // PqForkValidatorSetChangeTest. + // * the input it read is fed by observeValidators, whose only caller stands down at the anchor + // height. Above that height the answer was either FROZEN at a set from below it, or - on any + // node whose process started above it - never set at all, which the old code also answered + // with "stop signing". A node restart is an ordinary operation; it must not be able to + // silence this node's post-quantum seal for good. + // + // "I could not measure the fleet" and "the fleet is unsafe" are different states, and only one of + // them justifies the halting action. Coverage is now REPORTED, with the liveness margin it + // actually costs, and it decides nothing. What coverage genuinely protects - that blocking is + // not ARMED over a partial manifest - is an arm-time decision, not one taken once per commit by + // every node at once. + // + // CORRECTED 2026-08-03. This comment used to name armingReadinessDiagnostic() as the place that + // arm-time decision was made. It was not: that method tests ADDRESS-BOUNDNESS and nothing else, + // and it never reads the fleet size, the keyed count, or K. So for one day the compensating + // control for this repair was a sentence in a comment. It is now validateThresholdReachabilityOrAbort(), + // which refuses to start a node whose armed threshold is above what the fleet is guaranteed to + // produce (AERE-PQC-CFG-UNSAFE-08), and it is measured in PqForkThresholdReachabilityTest. + if (localIndex < 0 || indexToAddress.get(localIndex) == null) { + if (loggedCoverageBlocked.compareAndSet(false, true)) { + LOG.error( + "AERE PQC: attachment height {} reached at block {} but the anchored registry does not " + + "bind THIS node's Falcon index {} to a validator address ({} bound entries). A " + + "seal from an index no verifier can resolve is refused by the seals rule, so " + + "emitting one would only add bytes. Attachment stays OFF for this node alone; the " + + "rest of the fleet is unaffected. Re-anchor a manifest that contains this index.", + attachBlock, + blockNumber, + localIndex, + indexToAddress.size()); + } + return false; + } + reportRegistryCoverage(blockNumber); + if (loggedAttachmentOn.compareAndSet(false, true)) { + LOG.info( + "AERE PQC: seal ATTACHMENT ARMED at block {} (attachBlock={}, anchored address-bound " + + "registry of {} entries binds this node's index {}). This node will now attach a " + + "Falcon seal to its Commit messages. Falcon quorum becomes BLOCKING at forkBlock={}.", + blockNumber, + attachBlock, + indexToAddress.size(), + localIndex, + forkBlock() == Long.MAX_VALUE ? "unset" : Long.toString(forkBlock())); + } + return true; + } + + /** + * AERE COVERAGE MARGIN (2026-08-02). REPORT what the anchored registry covers, and what that costs in + * liveness margin. This decides nothing: it is called after the attachment gate has already said + * yes, and its only effect is a log line. + * + *

It exists because removing coverage from the gate must not also remove the operator's ability + * to see coverage. What it prints is not "incomplete" but a NUMBER, {@link + * #worstCaseKeyedSigners}, because "the registry does not cover the set" is compatible with both a + * chain that is completely fine and a chain that is one proposer turn from stopping, and only the + * number tells them apart. + * + *

Emitted when the situation CHANGES, not once per block: the interesting event is the height + * where the validator set moved, and a line per block would bury it. + * + * @param blockNumber the height being sealed + */ + private void reportRegistryCoverage(final long blockNumber) { + final Set

validators = observedValidators; + if (validators == null) { + if (!"unobserved".equals(lastCoverageSignature)) { + lastCoverageSignature = "unobserved"; + LOG.warn( + "AERE PQC: attaching Falcon seals at block {} without having observed a validator set, " + + "so registry COVERAGE is NOT MEASURED on this node. This is not a safety " + + "statement in either direction: attachment is gated on this node's own registry " + + "binding, which is satisfied. It is normal for a process that started above the " + + "anchor height. Coverage is reported as soon as a validator set is observed.", + blockNumber); + } + return; + } + final int n = validators.size(); + final int keyed = (int) validators.stream().filter(registeredValidatorAddresses()::contains).count(); + final String signature = n + "/" + keyed; + if (signature.equals(lastCoverageSignature)) { + return; + } + lastCoverageSignature = signature; + if (keyed == n) { + LOG.info( + "AERE PQC: anchored registry COVERS the validator set at block {} ({} of {} validators " + + "keyed). Falcon and ECDSA quorums coincide.", + blockNumber, + keyed, + n); + return; + } + LOG.warn( + "AERE PQC: the anchored registry does NOT cover the validator set at block {}: {} of {} " + + "validators hold an anchored Falcon key. Seal attachment CONTINUES - switching it off " + + "here is what stops a chain - but the post-quantum margin has moved: in the " + + "worst case only {} keyed validator(s) are among the {} ECDSA committers of a block. " + + "Compare that with the Falcon threshold K in force: if it is above that number, a " + + "proposer can legitimately fail to assemble a certificate. RE-ANCHOR the manifest for " + + "the full validator set, or lower K, before relying on the post-quantum seal.", + blockNumber, + keyed, + n, + worstCaseKeyedSigners(n, keyed), + BftHelpers.calculateRequiredValidatorQuorum(n)); + } + + /** + * AERE COVERAGE MARGIN. The number of anchored-key holders a block's ECDSA committer set is GUARANTEED to + * contain, in the worst case, when {@code validators} validators are in the set and {@code keyed} + * of them hold an anchored Falcon key. + * + *

A block needs {@code ceil(2N/3)} ECDSA committed seals. An adversarial (or merely unlucky) + * choice of committers takes every unkeyed validator first, so the guaranteed keyed count is + * {@code quorum - (N - keyed)}, floored at zero. A Falcon threshold K above this number is a + * threshold the chain is not guaranteed to be able to meet, which is exactly the shape of that halt: + * at N=7 with all 7 keyed and K=5 the margin is exactly zero, one added unkeyed validator holds it + * at zero, and a second takes it negative. + * + * @param validators the size of the current validator set + * @param keyed how many of them hold an anchored Falcon key + * @return the guaranteed number of keyed signers among a block's committers, never negative + */ + public static int worstCaseKeyedSigners(final int validators, final int keyed) { + if (validators <= 0 || keyed <= 0) { + return 0; + } + final int quorum = BftHelpers.calculateRequiredValidatorQuorum(validators); + return Math.max(0, quorum - (validators - Math.min(keyed, validators))); + } + + /** + * AERE COVERAGE MARGIN (2026-08-03). By how much a Falcon threshold {@code k} exceeds what the fleet is + * GUARANTEED to be able to produce. Zero means reachable; any positive number is a threshold a + * proposer can legitimately fail to meet with every node honest and every node up. + * + *

Zero is REACHABLE, not comfortable: at N=7 fully keyed with K=5 the deficit is 0 and the + * margin is also 0, which is why {@link #MIN_BLOCKING_VALIDATORS} exists as a separate rule. The + * two say different things and neither implies the other. + * + * @param validators the size of the validator set + * @param keyed how many of them hold an anchored Falcon key + * @param k the Falcon threshold in force + * @return {@code k} minus the guaranteed keyed signer count, floored at zero + */ + public static int thresholdDeficit(final int validators, final int keyed, final int k) { + if (k <= 0) { + return 0; + } + return Math.max(0, k - worstCaseKeyedSigners(validators, keyed)); + } + + /** + * Falcon-sign a commit hash with this node's Falcon key, producing a parallel seal. Never throws. + * + *

AERE FIX-OPRIRE-CONSENS (b): the block number is MANDATORY and the attachment gate is + * checked here, at the single point where a seal can be produced. There is deliberately no + * height-less overload, so no call site can attach a seal without passing the gate. + * + * @param blockNumber the number of the block whose commit is being sealed + * @param commitHash the same 32-byte commit hash the ECDSA committed seal signs + * @return the Falcon seal, or empty if signing is disabled, gated off, or failed + */ + public Optional sign(final long blockNumber, final Bytes32 commitHash) { + if (!signingEnabled) { + return Optional.empty(); + } + if (!attachmentArmed(blockNumber)) { + return Optional.empty(); + } + try { + final FalconSigner signer = new FalconSigner(); + signer.init(true, localPrivateKey); + final byte[] sig = signer.generateSignature(commitHash.toArray()); + return Optional.of(new FalconSeal(localIndex, Bytes.wrap(sig))); + } catch (final RuntimeException e) { + LOG.warn("AERE PQC: Falcon signing failed (ECDSA seal unaffected): {}", e.toString()); + return Optional.empty(); + } + } + + /** + * Verify a Falcon seal against the registry public key for its validator index. Never throws. + * + * @param validatorIndex the signer index + * @param commitHash the commit hash that was signed + * @param signature the Falcon signature bytes + * @return true iff a registry public key exists for the index and the signature verifies + */ + public boolean verify(final int validatorIndex, final Bytes commitHash, final Bytes signature) { + return verifyWithKey(registry.get(validatorIndex), validatorIndex, commitHash, signature); + } + + /** + * HEIGHT SCHEDULE. The Falcon public key registered for an index AT A HEIGHT: the one carried by the + * registry the chain's schedule makes active there. + * + *

WHY A HEIGHT IS NEEDED AT ALL. A certificate inside a block at height h was produced under + * the key set the chain required at h. After a rotation the node's own head registry is a + * different key set, so checking an old block against it fails for a reason that has nothing to do + * with the block. Cosmos ADR-016 states the same requirement for the same reason and keeps a + * height-to-key mapping; the only difference here is that ours is never pruned, because this chain + * has no unbonding period and a node syncing from genesis verifies every block ever produced. + * + *

Returns the head registry's key when no binding is active at that height, which is every + * height below the schedule's first entry and every height on a chain with no schedule at all. So + * a node with a single registry gets exactly what it got before the height schedule existed. + */ + private FalconPublicKeyParameters keyAt( + final long blockNumber, final int validatorIndex, final boolean historic) { + final PqRegistryHash.Schedule schedule = this.registryBindingSchedule; + final PqRegistryHash.RegistrySet set = this.registryBindingSet; + if (schedule == null || set == null) { + return headRegistryKeyOrRefuse( + blockNumber, validatorIndex, "no schedule was ever loaded", historic); + } + final Optional required = + PqRegistryHash.requiredHashAt(schedule, blockNumber); + if (required.isEmpty()) { + return headRegistryKeyOrRefuse( + blockNumber, validatorIndex, "the schedule binds no registry at this height", historic); + } + final String cacheKey = required.get().block() + ":" + validatorIndex; + final FalconPublicKeyParameters cached = historicalKeys.get(cacheKey); + if (cached != null) { + return cached; + } + final Optional active = + PqRegistryHash.registryAt(schedule, set, blockNumber); + if (active.isEmpty()) { + // The entry active here is covered by nothing this node holds. Fail closed: no key, so no + // seal verifies, and the per-block binding rule has already refused the header for the same + // reason. Silence here would be the node deciding a question it cannot answer. + return null; + } + for (final PqRegistryHash.Entry e : active.get().entries()) { + if (e.index() == validatorIndex) { + final FalconPublicKeyParameters pub = + new FalconPublicKeyParameters(FalconParameters.falcon_512, e.publicKey()); + historicalKeys.put(cacheKey, pub); + return pub; + } + } + return null; + } + + /** + * HEIGHT SCHEDULE. The validator ADDRESS bound to a registry index at a height. + * + *

LOOKUP HARDENING (b-v2): PRIVATE, with the caller's motive as an argument. The two public + * doors are {@link #addressForIndexAtHistoric} and {@link #addressForIndexAtOwnHead}, each of + * which passes a constant. Nothing outside this file can choose the value, so the one shared + * resolution body cannot drift between the two paths and no caller can pick the wrong flag. + * + * @param blockNumber the height of the header being validated + * @param validatorIndex the registry index carried by a Falcon seal + * @param historic true when the subject is a header this node received, false when it is this + * node's own head or the block it is building + * @return the bound address, or null when the index is unregistered at that height or the + * registry active there is not address-bound + */ + private Address addressAt( + final long blockNumber, final int validatorIndex, final boolean historic) { + final PqRegistryHash.Schedule schedule = this.registryBindingSchedule; + final PqRegistryHash.RegistrySet set = this.registryBindingSet; + if (schedule == null || set == null) { + return headRegistryAddressOrRefuse( + blockNumber, validatorIndex, "no schedule was ever loaded", historic); + } + if (PqRegistryHash.requiredHashAt(schedule, blockNumber).isEmpty()) { + return headRegistryAddressOrRefuse( + blockNumber, validatorIndex, "the schedule binds no registry at this height", historic); + } + final Optional active = + PqRegistryHash.registryAt(schedule, set, blockNumber); + if (active.isEmpty() || !active.get().addressBound()) { + return null; + } + for (final PqRegistryHash.Entry e : active.get().entries()) { + if (e.index() == validatorIndex && e.address() != null) { + return Address.wrap(Bytes.wrap(e.address())); + } + } + return null; + } + + /** + * HEIGHT SCHEDULE / LOOKUP HARDENING (b-v2). THE HISTORY DOOR. Verify a Falcon seal carried by a header this + * node RECEIVED, against the key set the chain required at that height. + * + *

WHY THE MOTIVE IS IN THE NAME AND NOT IN THE HEIGHT, measured on 2026-08-06. The first shape + * of hardening (b) refused whenever no height binding existed at or above the arming height, and + * decided that from the block number alone. That stopped the two paths that work on THIS NODE'S + * OWN head: restoring seals from disk after a restart, and proposing. Six tests went red, five in + * {@code PqSealPersistenceTest} and one in {@code PqForkValidatorSetChangeTest}, and the refusal + * message says the consequence outright - the node refuses to propose, so it stops producing + * blocks. In every one of the six the number presented to the guard was 1030 against an arming + * height of 1000, identical to what a real historical question would present in the same process + * in the same second. So the distinction moved into the question. + * + *

Reaching THIS method means the node is judging somebody else's claim about a height it did + * not build. "Which keys were in force here" then has an answer that is not this node's current + * registry, and answering from the head registry anyway is that defect verbatim: a header produced under + * one key set checked against another, with success reported. + * + * @param blockNumber the height of the header carrying the seal + * @param validatorIndex the signer index + * @param commitHash the message that was signed + * @param signature the Falcon signature bytes + * @return true iff a key was bound for that index AT THAT HEIGHT and the signature verifies + */ + public boolean verifyAtHistoric( + final long blockNumber, + final int validatorIndex, + final Bytes commitHash, + final Bytes signature) { + return verifyWithKey( + keyAt(blockNumber, validatorIndex, true), validatorIndex, commitHash, signature); + } + + /** + * LOOKUP HARDENING (b-v2). THE OWN-HEAD DOOR. Verify a Falcon seal over a block this node holds as its + * own head, or is building right now. + * + *

It does not refuse for a missing height binding, because at this node's own head the head + * registry IS the answer by construction. A wrong local acceptance cannot manufacture history: the + * certificate is re-checked by the other six validators through {@code PqAnchorSealsRule}, which + * goes through {@link #verifyAtHistoric} at the parent's height. The cost of a local mistake is + * one lost proposer round, not a branch. + * + *

What it still refuses: a height the schedule DOES cover with a registry this node does not + * hold. That is not a missing binding, it is a node running a registry the chain has moved off, + * and it fails closed on both doors - see the {@code registryAt} branch of {@link #keyAt}. + * + * @param blockNumber this node's own head, or the block it is sealing + * @param validatorIndex the signer index + * @param commitHash the message that was signed + * @param signature the Falcon signature bytes + * @return true iff a key exists for the index there and the signature verifies + */ + public boolean verifyAtOwnHead( + final long blockNumber, + final int validatorIndex, + final Bytes commitHash, + final Bytes signature) { + return verifyWithKey( + keyAt(blockNumber, validatorIndex, false), validatorIndex, commitHash, signature); + } + + /** + * HEIGHT SCHEDULE / LOOKUP HARDENING (b-v2). THE HISTORY DOOR, address half. See {@link #verifyAtHistoric}. + * + * @param blockNumber the height of the header being validated + * @param validatorIndex the registry index carried by a Falcon seal + * @return the bound address, or null when nothing binds that index at that height + */ + public Address addressForIndexAtHistoric(final long blockNumber, final int validatorIndex) { + return addressAt(blockNumber, validatorIndex, true); + } + + /** + * LOOKUP HARDENING (b-v2). THE OWN-HEAD DOOR, address half. See {@link #verifyAtOwnHead}. + * + * @param blockNumber this node's own head, or the block it is building + * @param validatorIndex the registry index + * @return the bound address, or null + */ + public Address addressForIndexAtOwnHead(final long blockNumber, final int validatorIndex) { + return addressAt(blockNumber, validatorIndex, false); + } + + /** + * LOOKUP HARDENING (b). The one decision the whole hardening turns on, isolated so that removing it is + * a one-line edit and the negative control can prove the hardening test goes red without it. + * + *

THE MEASURED DEFECT. Both height-resolved lookups used to answer a height they had no binding + * for by returning the HEAD registry - the key set in force right now. Below the arming height + * that is correct and costs nothing: no certificate is being judged there. At and above it, it is + * the height-less lookup defect verbatim: "an armed node verifies a year-old header against the + * keys it holds today", so one rotation makes every block between the arming height and the rotation unverifiable, and the + * node reports success while doing it. Refusing is the only answer that does not assert a check + * that was not performed. + * + *

WHAT A REFUSAL COSTS AND WHY IT IS THE CHEAPER FAILURE. A null key makes every seal fail, so + * {@code PqAnchorSealsRule} refuses the header and the node stops at the first block at or above + * the arming height. It stops; it does not fork. A node that cannot say which keys were in force + * cannot forge a competing history either, and the repair is to configure {@code + * config.pqRegistryHash} plus {@code aere.falcon.registry.history} and restart. Compare the + * silence this replaces, where the same node imports the whole chain and calls it verified. + * + *

LOOKUP HARDENING (b-v2), 2026-08-06. The condition gained ONE term, {@code historic}, and + * that term is the whole of the second repair. The first shape refused on height alone; the + * six tests it turned red were all asking about this node's OWN head at height 1030 with an + * arming height of 1000, which is the same pair of numbers a genuinely historical question + * presents. The block number cannot tell them apart. The caller can, and now says so. + * + * @param blockNumber the height being asked about + * @param validatorIndex the registry index + * @param why the reason no height binding was found, for the log + * @param historic true when the subject is a received header, false for this node's own head + * @return the head registry's key when not historic or below the arming height, else null + */ + private FalconPublicKeyParameters headRegistryKeyOrRefuse( + final long blockNumber, + final int validatorIndex, + final String why, + final boolean historic) { + if (!historic || blockNumber < anchorArmedFrom()) { + return registry.get(validatorIndex); + } + shoutUnboundHeight(blockNumber, why); + return null; + } + + /** + * LOOKUP HARDENING (b). The address half of {@link #headRegistryKeyOrRefuse}, with the same rule and + * for the same reason: at and above the arming height an unbound height has no answer, and {@code + * PqAnchorSealsRule} refuses an index it cannot bind to an address rather than skipping it. + * + *

LOOKUP HARDENING (b-v2): same one added term as {@link #headRegistryKeyOrRefuse}. + * + * @param blockNumber the height being asked about + * @param validatorIndex the registry index + * @param why the reason no height binding was found, for the log + * @param historic true when the subject is a received header, false for this node's own head + * @return the head registry's bound address when not historic or below the arming height, else + * null + */ + private Address headRegistryAddressOrRefuse( + final long blockNumber, + final int validatorIndex, + final String why, + final boolean historic) { + if (!historic || blockNumber < anchorArmedFrom()) { + return addressForIndex(validatorIndex); + } + shoutUnboundHeight(blockNumber, why); + return null; + } + + /** + * LOOKUP HARDENING (b). Say it once per configuration change, not once per block: at a 523 ms block + * period a per-block ERROR is itself a hazard on this fleet, and the refusal is already visible as + * a stopped node. + * + * @param blockNumber the height that has no registry binding + * @param why the reason + */ + private void shoutUnboundHeight(final long blockNumber, final String why) { + if (loggedUnboundArmedHeight.compareAndSet(false, true)) { + LOG.error( + "AERE PQC LOOKUP-HARDENING: REFUSING to resolve Falcon keys at height {} - this node is ARMED from {} " + + "and {}. Until 2026-08-06 this fell back to the registry in force at the HEAD, " + + "which is the height-less lookup defect: a header produced under one key set was checked against " + + "another, and one key rotation would have made every block above the arming height " + + "unverifiable while the node reported success. Every header at or above the arming " + + "height will now be REFUSED until the height-to-registry binding exists. WHAT TO DO: " + + "put config.pqRegistryHash in the genesis this node booted (first entry exactly at " + + "the arming height) and name every registry file the schedule covers in {}.", + blockNumber, + anchorArmedFrom(), + why, + PROPERTY_REGISTRY_HISTORY); + } + } + + private boolean verifyWithKey( + final FalconPublicKeyParameters pub, + final int validatorIndex, + final Bytes commitHash, + final Bytes signature) { + if (pub == null || commitHash == null || signature == null) { + return false; + } + try { + final FalconSigner verifier = new FalconSigner(); + verifier.init(false, pub); + return verifier.verifySignature(commitHash.toArray(), signature.toArray()); + } catch (final RuntimeException e) { + LOG.debug("AERE PQC: Falcon verify threw for index {}: {}", validatorIndex, e.toString()); + return false; + } + } +} diff --git a/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchor.java b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchor.java new file mode 100644 index 0000000..a383bcf --- /dev/null +++ b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchor.java @@ -0,0 +1,335 @@ +/* + * Copyright contributors to Besu / Aere Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft; + +import org.hyperledger.besu.crypto.Hash; +import org.hyperledger.besu.ethereum.rlp.BytesValueRLPOutput; +import org.hyperledger.besu.ethereum.rlp.RLPOutput; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.List; + +import org.apache.tuweni.bytes.Bytes; +import org.apache.tuweni.bytes.Bytes32; + +/** + * CERTIFICATE ANCHOR, schema V2: "IT IS CARRIED, NOT REFERENCED". + * + *

Canonical encoding, anchor digest D and signed message M for the AERE post-quantum certificate + * anchor. This class is a PURE function library: no singletons, no configuration, no I/O, no state. + * Everything it computes is a function of its arguments only, which is what makes {@code + * PqAnchorDigestRule} usable in LIGHT validation. + * + *

Why V2 exists. Today the QBFT block hash covers no proof of any post-quantum seal: the + * Falcon seal list sits in {@code EncodingType.ALL} only, and both hash pre-images exclude it. You + * can strip the entire post-quantum attestation from a header and its block hash is unchanged. The + * earlier design (Option V) tried to fix that by putting the digest of the PARENT's certificate, + * read out of the PARENT's header, into the child's vanityData. That died on a measurement: over 200 + * consecutive headers read simultaneously from two of our own RPC nodes, with the block hash + * identical 200/200, the seal ORDER differed on 68 headers and the seal SET differed on 11. The + * cause is structural, not a bug to patch: {@code RoundState} holds commits in a {@code + * LinkedHashMap} keyed on author, {@code values()} yields arrival order, and the assembler does not + * reorder. Every node writes what IT heard. Sorting fixes the order and does NOT fix the set, and no + * deterministic function of a local set can produce the same output from two different sets. + * + *

What V2 does instead. The certificate over block N-1 is not read out of N-1's header. + * It is CARRIED in the extraData of block N, chosen by N's proposer and ratified by N's round, and + * N's vanityData carries its digest over all 32 bytes. The digest therefore depends only on what the + * PROPOSER WROTE, and those bytes reach every node identically inside the same proposal. A validator + * never compares the certificate with what it heard: it checks only the threshold, strictly + * increasing indices, index eligibility in the PARENT's validator set, and k Falcon verifications. A + * node that heard 7 accepts the proposer's certificate of 5. + * + *

The two pre-images. + * + *

+ *   D = keccak256( RLP[ "AERE-PQ-ANCHOR-1", chainId, parentNumber, parentHash, C ] )
+ *   M = keccak256( RLP[ "AERE-PQ-COMMIT-1", chainId, blockNumber,  blockHash        ] )
+ *   C = RLP[ [idx, sig], [idx, sig], ... ]     indices STRICTLY INCREASING
+ * 
+ * + *

D occupies all 32 bytes of vanityData: no magic, no version byte, no padding. The budget is 32 + * bytes and cannot grow, because two LIVE length checks require exactly 32 (AereQbftFinalityVerifier + * .sol line 194 and the second client's decoder). Versioning is by ACTIVATION HEIGHT, not by an + * in-band version byte, because any format change needs a governance-decided height anyway. + * + *

M is deliberately NOT the ECDSA commit hash. The commit hash includes the ROUND, which is not + * in the block-hash pre-image, and rounds above zero do occur on this chain (measured 1 in 307). + * Defining M over the block HASH makes the message reconstructible by any node holding the parent + * header: no state, no round, no local memory. + * + *

The domain labels are what stops a Falcon signature produced for another subsystem (the + * AerePQCTxAccount rail, ML-KEM intents, any application message) from being reinterpreted as a + * consensus seal, and the chainId is what stops a certificate produced on the 442807 proving chain, + * which runs the SAME binaries and may run the same Falcon keys, from being material here. + * + *

CONSENSUS IS STILL CLASSICAL secp256k1 ECDSA. This class does not make consensus post-quantum + * and must never be described as doing so. The post-quantum layer is signature, precompile and + * account level. + */ +public final class PqAnchor { + + /** Domain label for the anchor digest D. Exactly 16 ASCII bytes. */ + public static final String ANCHOR_DOMAIN = "AERE-PQ-ANCHOR-1"; + + /** Domain label for the signed commit message M. Exactly 16 ASCII bytes. */ + public static final String COMMIT_DOMAIN = "AERE-PQ-COMMIT-1"; + + /** The anchor domain label as raw bytes. */ + public static final Bytes ANCHOR_DOMAIN_BYTES = + Bytes.wrap(ANCHOR_DOMAIN.getBytes(StandardCharsets.US_ASCII)); + + /** The commit domain label as raw bytes. */ + public static final Bytes COMMIT_DOMAIN_BYTES = + Bytes.wrap(COMMIT_DOMAIN.getBytes(StandardCharsets.US_ASCII)); + + /** Orders Falcon seals by their registry index, ascending. */ + public static final Comparator BY_INDEX = + Comparator.comparingInt(FalconSeal::getValidatorIndex); + + private PqAnchor() {} + + /** + * Encode the certificate C in its canonical wire form. This is byte-for-byte the shape {@code + * QbftExtraDataCodec} already writes for the Falcon seal element, so the certificate that is + * hashed is the certificate that is carried: one logical object, one accepted byte string. + * + * @param output the RLP sink to write the certificate list into + * @param certificate the seals, in the order they are to be encoded + */ + public static void writeCertificate( + final RLPOutput output, final Collection certificate) { + output.writeList( + certificate, + (seal, rlp) -> { + rlp.startList(); + rlp.writeIntScalar(seal.getValidatorIndex()); + rlp.writeBytes(seal.getSignature()); + rlp.endList(); + }); + } + + /** + * The canonical RLP encoding of the certificate C on its own. Used by tests and diagnostics; the + * digest path writes the certificate directly into the outer list rather than nesting an encoded + * blob. + * + * @param certificate the seals, in the order they are to be encoded + * @return the canonical RLP bytes of C + */ + public static Bytes encodeCertificate(final Collection certificate) { + final BytesValueRLPOutput out = new BytesValueRLPOutput(); + writeCertificate(out, certificate); + return out.encoded(); + } + + /** + * Compute the anchor digest D that block N's vanityData must carry, over the certificate block N + * carries for its PARENT. + * + *

The certificate is hashed IN THE ORDER GIVEN. It is deliberately not sorted here: sorting + * inside the digest would let a header with a reordered certificate still match D, which would + * make the digest rule (cheap, runs in light validation) disagree with the seals rule (expensive, + * runs only where state exists). Instead BOTH rules require {@link + * #hasStrictlyIncreasingIndices(Collection)}, so for every ACCEPTED header "the order given" and + * "sorted" are the same list, and reordering is rejected in light validation too. Producers call + * {@link #sortedByIndex(Collection)} before writing. + * + * @param chainId the chain id, in the pre-image so a certificate from another chain running the + * same binaries and possibly the same Falcon keys is not material here + * @param parentNumber the number of the parent block the certificate attests to + * @param parentHash the hash of the parent block the certificate attests to + * @param certificate the carried certificate C, possibly empty (at the activation height itself) + * @return the 32-byte anchor digest D + */ + public static Bytes32 anchorDigest( + final long chainId, + final long parentNumber, + final Bytes parentHash, + final Collection certificate) { + if (parentNumber < 0) { + throw new IllegalArgumentException( + "AERE PQ ANCHOR: parentNumber must not be negative (got " + parentNumber + ")"); + } + if (parentHash == null || parentHash.size() != 32) { + throw new IllegalArgumentException( + "AERE PQ ANCHOR: parentHash must be 32 bytes (got " + + (parentHash == null ? "null" : parentHash.size() + " bytes") + + ")"); + } + final BytesValueRLPOutput out = new BytesValueRLPOutput(); + out.startList(); + out.writeBytes(ANCHOR_DOMAIN_BYTES); + out.writeLongScalar(chainId); + out.writeLongScalar(parentNumber); + out.writeBytes(parentHash); + writeCertificate(out, certificate); + out.endList(); + return Hash.keccak256(out.encoded()); + } + + /** + * Compute the message M that a validator's Falcon key signs when it seals a block. + * + *

M is a function of the block HASH, never of the commit hash, so it can be recomputed from the + * header alone: no round number, no local state, no memory of the round that produced it. + * + * @param chainId the chain id + * @param blockNumber the number of the block being sealed + * @param blockHash the hash of the block being sealed + * @return the 32-byte message M that Falcon signs + */ + public static Bytes32 commitMessage( + final long chainId, final long blockNumber, final Bytes blockHash) { + if (blockNumber < 0) { + throw new IllegalArgumentException( + "AERE PQ ANCHOR: blockNumber must not be negative (got " + blockNumber + ")"); + } + if (blockHash == null || blockHash.size() != 32) { + throw new IllegalArgumentException( + "AERE PQ ANCHOR: blockHash must be 32 bytes (got " + + (blockHash == null ? "null" : blockHash.size() + " bytes") + + ")"); + } + final BytesValueRLPOutput out = new BytesValueRLPOutput(); + out.startList(); + out.writeBytes(COMMIT_DOMAIN_BYTES); + out.writeLongScalar(chainId); + out.writeLongScalar(blockNumber); + out.writeBytes(blockHash); + out.endList(); + return Hash.keccak256(out.encoded()); + } + + /** + * Whether the certificate's validator indices are STRICTLY increasing. + * + *

Strictly increasing does three jobs at once and all three are structural rather than + * defensive: it fixes ONE accepted order for a given set (closing seal reordering), it makes + * duplicate indices unrepresentable (closing the "inflate k by repeating a seal" attack in the + * grammar of the format rather than with a HashSet applied afterwards), and it rejects negative + * indices when combined with the non-negative check below. + * + * @param certificate the carried certificate + * @return true iff every index is non-negative and each is strictly greater than its predecessor + */ + public static boolean hasStrictlyIncreasingIndices(final Collection certificate) { + long previous = Long.MIN_VALUE; + for (final FalconSeal seal : certificate) { + if (seal == null || seal.getSignature() == null) { + return false; + } + final int index = seal.getValidatorIndex(); + if (index < 0 || index <= previous) { + return false; + } + previous = index; + } + return true; + } + + /** + * First anchor height of the one historical window on chain 2800 whose certificates carry indices + * that are distinct but NOT sorted. Measured 2026-08-11 by decoding every anchor header in + * [13,266,800, 13,269,400] straight off the public endpoint: 82 anchor headers, 46 sorted, 36 not, + * none without a certificate. The 36 are CONTIGUOUS at the 32-block anchor spacing and nothing + * outside the window is affected, so the range below is the measured extent, not a guess. + */ + public static final long UNORDERED_WINDOW_FIRST = 13_267_824L; + + /** Last anchor height of that window. Its neighbours 13,267,792 and 13,268,976 are both sorted. */ + public static final long UNORDERED_WINDOW_LAST = 13_268_944L; + + /** + * Whether the certificate's indices are non-negative and pairwise DISTINCT, in any order. + * + *

This is the part of {@link #hasStrictlyIncreasingIndices(Collection)} that is load bearing + * against an attacker rather than against ambiguity. Sortedness fixes one accepted order for a + * given set; distinctness is what makes "inflate k by repeating one seal" unrepresentable in the + * grammar of the format. A historical exception may relax the first WITHOUT relaxing the second, + * and this method exists so that the exception cannot accidentally relax both. + * + * @param certificate the carried certificate + * @return true iff every index is non-negative and no index appears twice + */ + public static boolean hasDistinctNonNegativeIndices(final Collection certificate) { + final java.util.Set seen = new java.util.HashSet<>(); + for (final FalconSeal seal : certificate) { + if (seal == null || seal.getSignature() == null) { + return false; + } + final int index = seal.getValidatorIndex(); + if (index < 0 || !seen.add(index)) { + return false; + } + } + return true; + } + + /** + * Whether a header at {@code blockNumber} may carry this certificate's index ordering. + * + *

Why this exists. During a production interruption on 2026-08-10 some proposers wrote + * the certificate in the order the commits ARRIVED instead of sorted. {@code RoundState} holds + * commits in a {@code LinkedHashMap} keyed on author and {@code values()} yields arrival order, so + * a producer that skips {@link #sortedByIndex(Collection)} writes an unsorted certificate and + * nothing downstream notices: the anchor digest is taken IN THE ORDER GIVEN, so those headers' + * digests are perfectly consistent with their own bytes. 36 such headers are canonical today. + * + *

Nodes already holding the chain never revalidate them, which is exactly why this was invisible + * for a day. A node syncing from genesis under the plain rule would reject header 13,267,824 and + * stop there forever. That is the same shape as the base-fee floor episode: the history is what it + * is, and the exception belongs at VALIDATION, where a header is accepted as written, not in any + * recomputation. A patch anywhere else moves the error instead of ending it. + * + *

What the exception does NOT relax. Inside the window the certificate must still have + * non-negative and pairwise distinct indices. Measured 2026-08-11 on all 36 headers: distinct in + * every one, no negative index in any. So this accepts exactly the history that exists and nothing + * weaker, and the "repeat one seal to inflate k" attack stays unrepresentable at every height. + * + *

Producers must not call this. {@code PqAnchorProducer} keeps requiring strict + * sortedness, so no new unsorted header can ever be written, at any height. The window is closed by + * construction: it is bounded above by a measured constant, so it cannot grow. + * + * @param certificate the carried certificate + * @param blockNumber the number of the header carrying it + * @return true iff the ordering is acceptable for a header at that height + */ + public static boolean hasAcceptableIndices( + final Collection certificate, final long blockNumber) { + if (hasStrictlyIncreasingIndices(certificate)) { + return true; + } + final boolean inHistoricalWindow = + blockNumber >= UNORDERED_WINDOW_FIRST && blockNumber <= UNORDERED_WINDOW_LAST; + return inHistoricalWindow && hasDistinctNonNegativeIndices(certificate); + } + + /** + * Return the certificate sorted by validator index, ascending. Producers use this before writing + * the certificate into extraData; validators never sort, they require sortedness. + * + * @param certificate the seals in any order + * @return a new list sorted by validator index + */ + public static List sortedByIndex(final Collection certificate) { + final List sorted = new ArrayList<>(certificate); + sorted.sort(BY_INDEX); + return sorted; + } +} diff --git a/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorConfig.java b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorConfig.java new file mode 100644 index 0000000..ce9f081 --- /dev/null +++ b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorConfig.java @@ -0,0 +1,1320 @@ +/* + * Copyright contributors to Besu / Aere Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft; + +import java.util.Collections; +import java.util.Locale; +import java.util.Map; +import java.util.NavigableMap; +import java.util.OptionalInt; +import java.util.TreeMap; + +import com.google.common.base.Splitter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Height-indexed activation configuration for the V2 certificate anchor: {@code pqAnchorBlock} (H) + * and the staged {@code pqAnchorMinSeals} threshold schedule K(height), plus the two LOCAL emergency + * de-arm controls. + * + *

The gate is on the BLOCK NUMBER. Below H no new rule applies at all: {@link + * #activeAt(long)} returns false and both new rules return true as their first statement, before + * they decode anything. The existing chain history is therefore untouched, with the same hashes, + * because neither the hash formula, nor the codec, nor the existing rule set changes for it. That + * property is cheap precisely because the gate is on the number and not on the content, and it is + * the condition under which a binary built from this code can be warmed on a live node without risk. + * + *

Emergency de-arm. Two controls exist and are deliberately LOCAL to the node, not + * on-chain: a halted chain cannot deliver a height-scheduled configuration change, so the only + * control that works when the chain is ALREADY STOPPED is one that lives in the node's own + * configuration and takes effect on restart. + * + *

    + *
  • {@value #PROPERTY_MIN_SEALS_CEILING} lowers K, and can NEVER raise it: the effective + * threshold is {@code min(scheduled, ceiling)}. A control that could raise the threshold would + * be a way to halt the chain, not a way to recover it. + *
  • {@value #PROPERTY_DISABLE} switches both rules off entirely on this node. + *
+ * + *

Both take at least a quorum of nodes to restart before block production resumes, which is + * exactly the right threshold for a change of this weight. + * + *

AERE OPTIUNI-URGENTA (2026-08-02): both are now COMMAND-LINE OPTIONS. Until this date + * they existed only as system properties and environment variables, which meant the documented way + * back from a bad activation was to edit a service unit or a wrapper script, and the javadoc above + * named two command-line options that did not exist. They now do: {@code + * --Xaere-pq-anchor-disarm} and {@code --Xaere-pq-anchor-min-seals-max}, defined in {@code + * org.hyperledger.besu.cli.options.AerePqEmergencyOptions} and applied onto these same property + * names before anything reads them, so there is exactly ONE place that decides what is in force. The + * property and environment forms keep working unchanged; the command line wins over both, and which + * source won is logged. + * + *

AERE CONFIGURATIE-STRICTA (2026-08-06): {@link #fromSystemConfiguration()} is FAIL-CLOSED on + * PRESENCE. Until this date every reading fault in this loader ended in a value rather than in a + * refusal: an unparseable step DISCARDED the whole schedule and left K at 0 with the node ARMED; an + * unparseable ceiling was IGNORED while the command-line banner announced it as being in force; and + * an {@link IllegalArgumentException} from the constructor returned {@link #never(long)}, so the node + * ran with the anchor completely inert. None of the three stops a chain, which is exactly what makes + * them dangerous: the fleet keeps producing blocks, every head matches, every graph is green, and + * the post-quantum property we state in public is simply not being enforced. That is the Holesky + * shape - one wrong configuration field, silently defaulted, nodes that started perfectly and ran + * for months. + * + *

The gate is on PRESENCE, not on value, and that is what keeps the compatibility property + * intact: + * + *

    + *
  • No {@code aere.pq.*} name set at all - not as a system property, not as an + * environment variable - and this method returns {@link #never(long)} at its first statement. + * No decode, no allocation, no log, bit for bit today's behaviour. Absence is not a + * misconfiguration, it is the absence of a configuration, and a binary built from this code + * can still be warmed on a live node that has none of these names set. + *
  • At least one name present, even set to the empty string, and every field is read + * strictly: anything unparseable, an empty value, a discarded schedule, a threshold schedule + * with no step at H, an activation height without an explicit chain id, or a constructor that + * rejects the result, is a {@link FalconSealSupport.ActivationConfigException} carrying {@link + * #REFUSAL_CODE}, i.e. a refusal to start. An operator who typed {@code + * -Daere.pq.anchorBlock=} wanted an anchor and had a shell variable go empty on them; that is + * a mistake, not an absence. + *
+ * + *

The one deliberate exception is {@value #PROPERTY_DISABLE}. It is the brake, it is the only + * control that works when the chain is ALREADY STOPPED, and a brake that can itself be refused is + * not a brake. When it resolves to true the node STARTS: the rest of the configuration is still + * parsed, so that a valid one keeps {@link #anchorConfigured()} true and the per-block disarm + * announcement keeps shouting, and an invalid one is reported at ERROR and the node starts fully + * inert rather than not at all. + * + *

Refusing at STARTUP is not the class of failure that CrowdStrike and Cloudflare are cautionary + * tales about. Those were fail-closed at RUNTIME, delivered to an entire fleet at once. A refusal + * here happens before the node has joined the quorum, is visible in {@code systemctl status} in + * second zero, and is repaired with one line and one restart, at a pace the operator controls. The + * risk that IS real is a bad shared template plus a parallel fleet restart: at quorum 5 of 7 that is + * not degradation, it is a dead chain. The net for it is the operating rule that goes with this + * configuration - restart one at a time, never in parallel - and a preflight that computes its + * verdict from THIS code path rather than from a second reading of the same strings. + * + *

HONEST LIMITATION, stated in code because it is the same defect class as an unbound + * registry. The values here are read from LOCAL system properties or environment variables, + * exactly like {@code aere.falcon.registry} is today. They are NOT yet read from the genesis {@code + * config.qbft} / {@code config.transitions.qbft}, and there is NO consensus binding on them: two + * nodes configured with different H or different K schedules will disagree about which headers are + * valid. Wiring these to genesis, and refusing to start when the Falcon registry does not match the + * genesis {@code pqRegistryHash}, is a PRECONDITION of arming and is tracked as the fork-activation + * and registry work items. Until that lands, a non-default value here is a laboratory setting, not + * a deployment. + */ +public final class PqAnchorConfig { + + private static final Logger LOG = LoggerFactory.getLogger(PqAnchorConfig.class); + + /** System property naming the activation height H. Env: {@code AERE_PQ_ANCHOR_BLOCK}. */ + public static final String PROPERTY_ANCHOR_BLOCK = "aere.pq.anchorBlock"; + + /** + * System property naming the staged threshold schedule, as {@code block:minSeals} pairs separated + * by commas, e.g. {@code 12000000:0,12007200:1,12014400:3,12021600:5}. Env: {@code + * AERE_PQ_ANCHOR_MINSEALS}. + */ + public static final String PROPERTY_MIN_SEALS = "aere.pq.anchorMinSeals"; + + /** System property naming the chain id used in the D and M pre-images. Env: {@code AERE_PQ_CHAINID}. */ + public static final String PROPERTY_CHAIN_ID = "aere.pq.chainId"; + + /** + * EMERGENCY: an upper bound on K. Lowers the effective threshold, never raises it. This is the + * property behind the command-line option {@code --Xaere-pq-anchor-min-seals-max}. Env: {@code + * AERE_PQ_ANCHOR_MIN_SEALS_MAX}. + */ + public static final String PROPERTY_MIN_SEALS_CEILING = "aere.pq.anchor.minSealsCeiling"; + + /** + * EMERGENCY: switch both anchor rules off on this node. This is the property behind the + * command-line option {@code --Xaere-pq-anchor-disarm}. Env: {@code AERE_PQ_ANCHOR_DISABLE}. + */ + public static final String PROPERTY_DISABLE = "aere.pq.anchor.disable"; + + /** + * COST: the largest number of seals a proposer of THIS node writes into a certificate. K is a + * FLOOR, not a cap: a proposer includes every eligible seal it happened to hear in time, so at N=7 + * a K=3 chain carries four, five, six or seven seals in practice, not three. + * + *

MEASURED 2026-08-07, and this is why the property exists. A Falcon-512 seal is 666 bytes. The + * seal counts observed on a live seven-node run with the threshold at 4 were: 42 blocks with 4, 36 + * with 5, 5 with 6. The rehearsal's median header of 3838 bytes is {@code (3838-525)/666 = 4.97} + * seals. Header bytes therefore scale with the seals actually attached: five seals is about 1.7 + * times what the same chain writes capped at K=3, and about seven times a seal-less header. The + * figure used before that day had been computed for a SINGLE seal, so it understated the cost by + * about 4.4x. + * + *

Setting this to K therefore removes ~40% of the anchor's disk cost and takes nothing from the + * quorum margin, because the margin is decided by the THRESHOLD a verifier requires, not by how + * many seals a proposer volunteers above it. + * + *

UNSET MEANS TODAY'S BEHAVIOUR: no cap. That is deliberate. A cap is a live-chain behaviour + * change and must be asked for, never arrive as a default. + * + *

FAIL-CLOSED: a cap below the highest K the schedule ever reaches would make every proposer + * assemble a certificate its own fleet rejects, so the loader REFUSES to start. See {@link + * #withMaxSealsCarried(OptionalInt)}. Env: {@code AERE_PQ_ANCHOR_MAX_SEALS}. + */ + public static final String PROPERTY_MAX_SEALS = "aere.pq.anchor.maxSeals"; + + /** Environment variable twin of {@link #PROPERTY_MAX_SEALS}. */ + public static final String ENV_MAX_SEALS = "AERE_PQ_ANCHOR_MAX_SEALS"; + + /** + * COST, and the big lever: carry a certificate every N-th block instead of every block. + * + *

WHY IT IS ALLOWED TO BE SAFE, and this is the whole argument. Block hashes chain: block N+1 + * commits to block N's hash. So an anchor at height A, whose vanityData binds a Falcon certificate + * over A-1, transitively protects EVERYTHING below A. Rewriting any block under A changes A's + * parent hash, which forces A to be reproduced, which needs a Falcon quorum the adversary does not + * have. The only thing an adversary with every classical validator key can still rewrite is the + * TAIL: the blocks produced since the last anchor. The property is therefore + * + *

fork depth <= anchor interval
+ * + * and it is a knob, not an accident. + * + *

MEASURED 2026-08-07, at K=3 capped, 666 bytes a seal: the certificate cost falls in exact + * proportion to the interval, so every 10th block costs a tenth of the every-block figure, every + * 100th a hundredth, every 256th about a 250th. Against a ~523 ms block, an interval of 100 buys + * that hundredfold saving for a rewritable tail that grows from about half a second to about + * fifty-two seconds. Algorand ships the same shape at 1 in 256. + * + *

UNSET MEANS EVERY BLOCK, which is today's design and the strongest setting. As with the seal + * cap, a weakening never arrives as a default; it has to be asked for. + * + *

ANCHOR HEIGHTS ARE COUNTED FROM H, not from zero: {@code (n - anchorBlock) % interval == 0}. + * H itself is therefore always an anchor height, which matters because H is the one height the + * whole fleet coordinates on. Env: {@code AERE_PQ_ANCHOR_INTERVAL}. + */ + public static final String PROPERTY_ANCHOR_INTERVAL = "aere.pq.anchorInterval"; + + /** Environment variable twin of {@link #PROPERTY_ANCHOR_INTERVAL}. */ + public static final String ENV_ANCHOR_INTERVAL = "AERE_PQ_ANCHOR_INTERVAL"; + + /** Environment variable twin of {@link #PROPERTY_ANCHOR_BLOCK}. */ + public static final String ENV_ANCHOR_BLOCK = "AERE_PQ_ANCHOR_BLOCK"; + + /** Environment variable twin of {@link #PROPERTY_MIN_SEALS}. */ + public static final String ENV_MIN_SEALS = "AERE_PQ_ANCHOR_MINSEALS"; + + /** Environment variable twin of {@link #PROPERTY_CHAIN_ID}. */ + public static final String ENV_CHAIN_ID = "AERE_PQ_CHAINID"; + + /** Environment variable twin of {@link #PROPERTY_MIN_SEALS_CEILING}. */ + public static final String ENV_MIN_SEALS_CEILING = "AERE_PQ_ANCHOR_MIN_SEALS_MAX"; + + /** Environment variable twin of {@link #PROPERTY_DISABLE}. */ + public static final String ENV_DISABLE = "AERE_PQ_ANCHOR_DISABLE"; + + /** + * The stable, greppable code carried by every startup refusal raised while reading this + * configuration, in the shape of the registry-binding refusal {@code AERE-PQC-REG-MISMATCH-01}. + */ + public static final String REFUSAL_CODE = "AERE-PQC-ANCHOR-CONF-01"; + + /** + * MIN-SEALS FLOOR: the greppable name of the guard that refuses an armed anchor whose schedule + * never demands a single signature. Named, and not just a message, so that a check can ask whether + * the guard EXISTS rather than whether some prose happens to be present. + */ + public static final String REFUSAL_MIN_SEALS_FLOOR = REFUSAL_CODE + "/minSealsFloor"; + + /** Sentinel meaning "the anchor never activates". */ + public static final long NEVER = Long.MAX_VALUE; + + /** + * Every name this loader reads, property first and environment twin second. Presence of ANY of + * them is what switches the loader from "this node has no anchor configuration" to "this node has + * an anchor configuration and every field of it must be readable". + */ + private static final String[][] ANCHOR_NAMES = { + {PROPERTY_ANCHOR_BLOCK, ENV_ANCHOR_BLOCK}, + {PROPERTY_MIN_SEALS, ENV_MIN_SEALS}, + {PROPERTY_CHAIN_ID, ENV_CHAIN_ID}, + {PROPERTY_MIN_SEALS_CEILING, ENV_MIN_SEALS_CEILING}, + {PROPERTY_MAX_SEALS, ENV_MAX_SEALS}, + {PROPERTY_ANCHOR_INTERVAL, ENV_ANCHOR_INTERVAL}, + {PROPERTY_DISABLE, ENV_DISABLE} + }; + + private static final String SOURCE_PROPERTY = "system property (BESU_OPTS)"; + + private final long chainId; + private final long anchorBlock; + private final NavigableMap minSealsSchedule; + private final OptionalInt minSealsCeiling; + private final boolean disabled; + private final OptionalInt maxSealsCarried; + private final OptionalInt anchorInterval; + + /** + * Build an explicit configuration. + * + * @param chainId the chain id used in the D and M pre-images + * @param anchorBlock the activation height H, or {@link #NEVER}; must be at least 1 when set, + * because block H needs a parent + * @param minSealsSchedule the staged threshold, keyed on the height each step takes effect at; may + * be empty, in which case K is 0 everywhere + * @param minSealsCeiling an emergency upper bound on K, or empty + * @param disabled true to switch the anchor rules off entirely + */ + public PqAnchorConfig( + final long chainId, + final long anchorBlock, + final Map minSealsSchedule, + final OptionalInt minSealsCeiling, + final boolean disabled) { + this(chainId, anchorBlock, minSealsSchedule, minSealsCeiling, disabled, OptionalInt.empty()); + } + + /** + * The full constructor, with the proposer-side seal cap. + * + * @param chainId the chain id used in the D and M pre-images + * @param anchorBlock the activation height H, or {@link #NEVER} + * @param minSealsSchedule the staged threshold, keyed on the height each step takes effect at + * @param minSealsCeiling an emergency upper bound on K, or empty + * @param disabled true to switch the anchor rules off entirely + * @param maxSealsCarried the largest certificate this node's proposer writes, or empty for no cap + */ + public PqAnchorConfig( + final long chainId, + final long anchorBlock, + final Map minSealsSchedule, + final OptionalInt minSealsCeiling, + final boolean disabled, + final OptionalInt maxSealsCarried) { + this(chainId, anchorBlock, minSealsSchedule, minSealsCeiling, disabled, maxSealsCarried, OptionalInt.empty()); + } + + /** + * The full constructor, with both cost controls. + * + * @param chainId the chain id used in the D and M pre-images + * @param anchorBlock the activation height H, or {@link #NEVER} + * @param minSealsSchedule the staged threshold, keyed on the height each step takes effect at + * @param minSealsCeiling an emergency upper bound on K, or empty + * @param disabled true to switch the anchor rules off entirely + * @param maxSealsCarried the largest certificate this node's proposer writes, or empty for no cap + * @param anchorInterval carry a certificate every N-th block from H, or empty for every block + */ + public PqAnchorConfig( + final long chainId, + final long anchorBlock, + final Map minSealsSchedule, + final OptionalInt minSealsCeiling, + final boolean disabled, + final OptionalInt maxSealsCarried, + final OptionalInt anchorInterval) { + this.anchorInterval = anchorInterval; + if (anchorInterval.isPresent() && anchorInterval.getAsInt() < 1) { + throw new IllegalArgumentException( + "AERE PQ ANCHOR: " + + PROPERTY_ANCHOR_INTERVAL + + " must be at least 1, got " + + anchorInterval.getAsInt() + + "; 1 means every block, which is the strongest setting, and there is nothing below it"); + } + this.maxSealsCarried = maxSealsCarried; + if (maxSealsCarried.isPresent() && maxSealsCarried.getAsInt() < 1) { + throw new IllegalArgumentException( + "AERE PQ ANCHOR: " + + PROPERTY_MAX_SEALS + + " must be at least 1, got " + + maxSealsCarried.getAsInt() + + "; a cap of 0 would make every proposer write an empty certificate"); + } + if (anchorBlock != NEVER && anchorBlock < 1) { + throw new IllegalArgumentException( + "AERE PQ ANCHOR: pqAnchorBlock must be at least 1 (block H needs a parent), got " + + anchorBlock); + } + final NavigableMap schedule = new TreeMap<>(); + for (final Map.Entry step : minSealsSchedule.entrySet()) { + final Long at = step.getKey(); + final Integer k = step.getValue(); + if (at == null || k == null) { + throw new IllegalArgumentException("AERE PQ ANCHOR: null entry in pqAnchorMinSeals"); + } + if (k < 0) { + throw new IllegalArgumentException( + "AERE PQ ANCHOR: pqAnchorMinSeals step at " + at + " has a negative threshold " + k); + } + if (anchorBlock != NEVER && at < anchorBlock) { + throw new IllegalArgumentException( + "AERE PQ ANCHOR: pqAnchorMinSeals step at " + + at + + " is below pqAnchorBlock " + + anchorBlock + + "; a threshold cannot take effect before the rules do"); + } + schedule.put(at, k); + } + if (minSealsCeiling.isPresent() && minSealsCeiling.getAsInt() < 0) { + throw new IllegalArgumentException( + "AERE PQ ANCHOR: emergency min-seals ceiling must not be negative"); + } + this.chainId = chainId; + this.anchorBlock = anchorBlock; + this.minSealsSchedule = Collections.unmodifiableNavigableMap(schedule); + this.minSealsCeiling = minSealsCeiling; + this.disabled = disabled; + } + + /** + * The configuration that behaves exactly like today: the anchor never activates, so both new rules + * are inert at every height. + * + * @param chainId the chain id (unused while inactive, carried for diagnostics) + * @return an inactive configuration + */ + public static PqAnchorConfig never(final long chainId) { + return new PqAnchorConfig(chainId, NEVER, Collections.emptyMap(), OptionalInt.empty(), false); + } + + // ----------------------------------------------------------------------------------------------- + // Reading the names. ONE seam, used by both controls of the proof: a recording reader shows that + // the unconfigured path touches nothing, and a reader whose presence probe always answers "absent" + // is the negative control that makes every refusal below disappear. + // ----------------------------------------------------------------------------------------------- + + /** How this class reaches system properties and environment variables. */ + interface NameReader { + + /** + * The raw system property, or null when the name is not set. + * + * @param name the system property name + * @return the value as set, possibly empty, or null when absent + */ + String property(String name); + + /** + * The raw environment variable, or null when the name is not set. + * + * @param name the environment variable name + * @return the value as set, possibly empty, or null when absent + */ + String environment(String name); + } + + private static final NameReader REAL_NAMES = + new NameReader() { + @Override + public String property(final String name) { + return System.getProperty(name); + } + + @Override + public String environment(final String name) { + return System.getenv(name); + } + }; + + private static volatile NameReader names = REAL_NAMES; + + /** + * Replace the reader, for the positive and negative controls of the configuration proof. + * + * @param replacement the reader to use, or null to restore the real one + */ + static void useNameReaderForTesting(final NameReader replacement) { + names = replacement == null ? REAL_NAMES : replacement; + } + + /** + * The real reader, so a recording test reader can delegate to it instead of re-implementing it. + * + * @return the reader that reads actual system properties and environment variables + */ + static NameReader realNameReader() { + return REAL_NAMES; + } + + /** + * One name as this loader saw it. + * + * @param property the system property name + * @param environment the environment variable name + * @param present whether the name was set at all, INCLUDING being set to the empty string + * @param source human-readable origin, for the refusal message + * @param raw the trimmed value, possibly empty, or null when absent + */ + private record Read( + String property, String environment, boolean present, String source, String raw) { + + boolean hasValue() { + return present && !raw.isEmpty(); + } + } + + private static Read read(final String property, final String environment) { + final NameReader reader = names; + final String fromProperty = reader.property(property); + if (fromProperty != null) { + return new Read(property, environment, true, SOURCE_PROPERTY, fromProperty.trim()); + } + final String fromEnvironment = reader.environment(environment); + if (fromEnvironment != null) { + return new Read( + property, + environment, + true, + "environment variable " + environment, + fromEnvironment.trim()); + } + return new Read(property, environment, false, null, null); + } + + private static boolean anyAnchorNamePresent() { + for (final String[] pair : ANCHOR_NAMES) { + if (read(pair[0], pair[1]).present()) { + return true; + } + } + return false; + } + + // ----------------------------------------------------------------------------------------------- + // The loader. + // ----------------------------------------------------------------------------------------------- + + /** + * Read the configuration from system properties, falling back to environment variables, and apply + * the emergency overrides. + * + *

A node with NONE of the {@code aere.pq.*} names set gets {@link #never(long)} at the first + * statement, i.e. exactly today's behaviour with no decode, no allocation and no log. A node with + * at least one of them set is read strictly and REFUSES TO START on any fault, except through the + * {@value #PROPERTY_DISABLE} brake. See the class javadoc for why the gate is on presence. + * + * @return the configuration this node will run with + * @throws FalconSealSupport.ActivationConfigException when an anchor name is present and the + * configuration cannot be read exactly as written + */ + public static PqAnchorConfig fromSystemConfiguration() { + if (!anyAnchorNamePresent()) { + return never(0L); + } + + final Read block = read(PROPERTY_ANCHOR_BLOCK, ENV_ANCHOR_BLOCK); + final Read seals = read(PROPERTY_MIN_SEALS, ENV_MIN_SEALS); + final Read chain = read(PROPERTY_CHAIN_ID, ENV_CHAIN_ID); + final Read ceiling = read(PROPERTY_MIN_SEALS_CEILING, ENV_MIN_SEALS_CEILING); + final Read disable = read(PROPERTY_DISABLE, ENV_DISABLE); + + final boolean disabled = readDisable(disable); + + if (disabled) { + // THE BRAKE IS ABSOLUTE. The whole reason this control is local to the node is that it has to + // work when the chain is already stopped; a brake that can refuse to engage is not a brake. + // The rest is still parsed, because a configuration that reads cleanly keeps + // anchorConfigured() true and therefore keeps the per-block disarm announcement shouting. + try { + final PqAnchorConfig braked = strict(block, seals, chain, ceiling, true); + if (braked.anchorConfigured()) { + LOG.warn( + "AERE PQ ANCHOR: DISABLED by emergency override {} even though {}={} is configured. " + + "Both anchor rules are inert on this node.", + PROPERTY_DISABLE, + PROPERTY_ANCHOR_BLOCK, + braked.anchorBlock); + } + return braked; + } catch (final RuntimeException e) { + LOG.error( + "AERE PQ ANCHOR: the emergency disarm {} is in force, so this node STARTS, but the rest " + + "of the anchor configuration cannot be read and is therefore NOT carried: {}. The " + + "node runs fully inert and the per-block disarm announcement is LOST, because " + + "there is no readable activation height to announce. Fix the configuration before " + + "removing the disarm.", + PROPERTY_DISABLE, + e.getMessage()); + return never(0L); + } + } + + final PqAnchorConfig bare = strict(block, seals, chain, ceiling, false); + final PqAnchorConfig config = + bare.withMaxSealsCarried(readMaxSeals()).withAnchorInterval(readAnchorInterval()); + requireMinSealsFloorOrRefuse(config); + if (config.anchorInterval().isPresent() && config.everActive()) { + final int iv = config.anchorInterval().getAsInt(); + LOG.warn( + "AERE PQ ANCHOR: certificate carried every {} block(s) from H={}, not every block. The " + + "hash chain makes each anchor protect everything BELOW it, so what stays rewritable " + + "by an adversary holding every classical validator key is the TAIL since the last " + + "anchor: fork depth <= {} blocks. This is a DELIBERATE weakening bought for header " + + "size: the certificate cost falls to roughly 1/{} of the every-block figure.", + iv, + config.anchorBlock, + iv, + iv); + } + if (config.maxSealsCarried().isPresent() && config.everActive()) { + LOG.warn( + "AERE PQ ANCHOR: proposer-side seal CAP in force - this node writes at most {} seal(s) " + + "per certificate, against a highest scheduled threshold of K={}. This lowers the " + + "header cost and takes NOTHING from the quorum margin, which is decided by the " + + "threshold a verifier demands, not by how many seals a proposer volunteers above " + + "it. Measured 2026-08-07: uncapped, a K=3 chain at N=7 carries about five seals, " + + "which is about 1.7 times the header bytes it writes capped at K.", + config.maxSealsCarried().getAsInt(), + config.highestEffectiveMinSeals()); + } + if (config.everActive()) { + LOG.warn( + "AERE PQ ANCHOR: ARMED from LOCAL configuration - pqAnchorBlock={}, chainId={}, " + + "minSeals schedule={}, emergency ceiling={}. These values are NOT bound to genesis " + + "and NOT bound to consensus: two nodes with different values disagree about which " + + "headers are valid. Bind them to genesis before any deployment.", + config.anchorBlock, + config.chainId, + config.minSealsSchedule, + config.minSealsCeiling.isPresent() ? config.minSealsCeiling.getAsInt() : "none"); + } + return config; + } + + /** + * Every field read exactly as written, or a refusal. Nothing here falls back to a default: a + * default that silently replaces a value the operator typed is the defect this method exists to + * remove. + */ + private static PqAnchorConfig strict( + final Read block, + final Read seals, + final Read chain, + final Read ceiling, + final boolean disabled) { + + final OptionalInt ceilingValue = + ceiling.present() + ? OptionalInt.of( + readCeiling(ceiling)) + : OptionalInt.empty(); + + final long chainId = chain.present() ? readChainId(chain) : 0L; + + if (!block.present()) { + if (seals.present()) { + throw refuse( + seals, + FalconSealSupport.ActivationConfigException.Kind.UNSAFE, + "a threshold schedule is configured but " + + PROPERTY_ANCHOR_BLOCK + + " is not set anywhere, so the schedule can never apply and the anchor is dead " + + "while looking configured", + "either set " + + PROPERTY_ANCHOR_BLOCK + + " as well, or remove " + + PROPERTY_MIN_SEALS + + " entirely"); + } + return new PqAnchorConfig( + chainId, NEVER, Collections.emptyMap(), ceilingValue, disabled); + } + + final long anchorBlock = readAnchorBlock(block); + + if (!chain.present()) { + // The Holesky field, by name. There, depositContractAddress was absent on three clients, the + // default zero took its place, every node started, and the chain lost finality for two weeks + // at the height where the value finally mattered. An absent field must not be indistinguishable + // from a zero field, so this one is required and explicit, exactly as go-ethereum made the + // deposit contract address required per network afterwards. + throw refuse( + chain, + FalconSealSupport.ActivationConfigException.Kind.UNSAFE, + PROPERTY_ANCHOR_BLOCK + + "=" + + anchorBlock + + " is set but " + + PROPERTY_CHAIN_ID + + " is not set anywhere; the silent default 0 would enter the D and M pre-images and " + + "would remove domain separation without showing anywhere", + "an explicit chain id, for example " + PROPERTY_CHAIN_ID + "=2800"); + } + if (chainId < 1) { + throw refuse( + chain, + FalconSealSupport.ActivationConfigException.Kind.UNSAFE, + "chain id " + chainId + "; zero and negative values separate no domain at all", + "an explicit chain id >= 1, for example " + PROPERTY_CHAIN_ID + "=2800"); + } + + if (!seals.hasValue()) { + throw refuse( + seals, + FalconSealSupport.ActivationConfigException.Kind.UNSAFE, + PROPERTY_ANCHOR_BLOCK + + "=" + + anchorBlock + + " is set but the threshold schedule is missing or empty, so K would be 0 at every " + + "height and an ARMED node would accept empty certificates", + scheduleExpectation(anchorBlock)); + } + + final NavigableMap schedule = readSchedule(seals, anchorBlock); + if (schedule.floorEntry(anchorBlock) == null) { + throw refuse( + seals, + FalconSealSupport.ActivationConfigException.Kind.UNSAFE, + "the schedule has no step at the activation height " + + anchorBlock + + ", so K stays 0 from activation until the first step, and an ARMED node with K=0 " + + "accepts an empty certificate", + scheduleExpectation(anchorBlock)); + } + + try { + return new PqAnchorConfig(chainId, anchorBlock, schedule, ceilingValue, disabled); + } catch (final IllegalArgumentException e) { + throw refuse( + block, + FalconSealSupport.ActivationConfigException.Kind.UNSAFE, + "the configuration reads but is not valid: " + e.getMessage(), + scheduleExpectation(anchorBlock)); + } + } + + private static String scheduleExpectation(final long anchorBlock) { + return "block:threshold,block:threshold - every block >= " + + PROPERTY_ANCHOR_BLOCK + + "=" + + anchorBlock + + ", whole threshold >= 0, and a step exactly at " + + anchorBlock; + } + + /** + * Read the proposer-side seal cap. Absent means no cap, which is exactly today's behaviour; an + * unreadable value is a refusal, never a silent fallback to "no cap", because a cap that was asked + * for and quietly dropped costs disk nobody is watching. + */ + private static OptionalInt readMaxSeals() { + final Read cap = read(PROPERTY_MAX_SEALS, ENV_MAX_SEALS); + if (!cap.hasValue()) { + return OptionalInt.empty(); + } + try { + return OptionalInt.of(Integer.parseInt(cap.raw())); + } catch (final NumberFormatException e) { + throw new IllegalArgumentException( + REFUSAL_CODE + + ": " + + PROPERTY_MAX_SEALS + + " is set to '" + + cap.raw() + + "' from " + + cap.source() + + ", which is not a whole number. It is NOT ignored: a cap asked for and silently " + + "dropped costs disk nobody is watching."); + } + } + + /** + * THE MIN-SEALS FLOOR. A schedule whose effective K is ZERO at every height leaves the anchor + * ARMED as a structure and completely toothless, for ever. + * + *

WHAT THE CODE ALREADY GUARDED, and why that was not enough. The loader already refuses a + * MISSING schedule, in its own words: {@code "the threshold schedule is missing or empty, so K + * would be 0 at every height and an ARMED node would accept empty certificates"}. The author knew + * exactly what the danger was. But the guard only fired on an ABSENT schedule. A schedule that is + * PRESENT and of the shape {@code :0} reaches the same end state and went through unseen. + * + *

WHY THIS IS NOT THEORETICAL: it is the very shape a staged activation recommends, one that + * STARTS at K=0 as a warm-up window and rises to 3 later. If the second half of the line is lost, + * to a truncated environment variable or a misplaced quote, what is left is {@code :0}. The + * nodes start, every tool comes out green because each of them measures what was ASKED FOR and + * the ask is valid, and the threshold stays zero for ever. Not even a tool that compares the + * effective threshold against the requested one catches this: here both of them are zero. + * + *

The warm-up window stays perfectly legal: this looks at the HIGHEST K in the whole schedule, + * after the emergency ceiling, so {@code H:0,H+165000:3} passes and {@code H:0} on its own does + * not. + * + *

THE LIMIT, written down because it is real: the check sits on the LOADING path, not in the + * constructor. A test that builds an all-zero-schedule object directly is not stopped. The real + * danger is an operator configuration in BESU_OPTS, and that is where the guard is placed. + */ + private static void requireMinSealsFloorOrRefuse(final PqAnchorConfig config) { + if (!config.everActive() || config.disabled) { + return; + } + if (config.highestEffectiveMinSeals() >= 1) { + return; + } + throw new IllegalArgumentException( + REFUSAL_MIN_SEALS_FLOOR + + ": " + + PROPERTY_ANCHOR_BLOCK + + "=" + + config.anchorBlock + + " is armed, but the highest threshold in the WHOLE schedule " + + PROPERTY_MIN_SEALS + + "=" + + config.minSealsSchedule + + (config.minSealsCeiling.isPresent() + ? " (after the emergency ceiling " + config.minSealsCeiling.getAsInt() + ")" + : "") + + " is ZERO. A node started like this has the anchor armed and NO signature requirement" + + " at all, for ever, and every tool comes out green because it measures what was asked" + + " for. A warm-up window with K=0 is legal, but the schedule has to rise somewhere to" + + " at least 1: for example " + + config.anchorBlock + + ":0," + + (config.anchorBlock + 165000L) + + ":3"); + } + + /** + * Read the anchor interval. Absent means every block, the strongest setting; an unreadable value + * is a refusal, never a silent fallback, because a weakening asked for and quietly dropped and a + * weakening quietly APPLIED are both states an operator must never be left guessing between. + */ + private static OptionalInt readAnchorInterval() { + final Read iv = read(PROPERTY_ANCHOR_INTERVAL, ENV_ANCHOR_INTERVAL); + if (!iv.hasValue()) { + return OptionalInt.empty(); + } + try { + return OptionalInt.of(Integer.parseInt(iv.raw())); + } catch (final NumberFormatException e) { + throw new IllegalArgumentException( + REFUSAL_CODE + + ": " + + PROPERTY_ANCHOR_INTERVAL + + " is set to '" + + iv.raw() + + "' from " + + iv.source() + + ", which is not a whole number. It is NOT ignored: this value decides how much of " + + "the chain tip stays rewritable, and a node guessing it differently from its peers " + + "disagrees about which headers carry a certificate at all."); + } + } + + private static boolean readDisable(final Read disable) { + if (!disable.present()) { + return false; + } + final String value = disable.raw().toLowerCase(Locale.ROOT); + switch (value) { + case "true": + case "1": + case "yes": + case "on": + return true; + case "false": + case "0": + case "no": + case "off": + return false; + default: + // Boolean.parseBoolean turns "1", "yes" and "on" into FALSE without a word. That is a brake + // pedal that reports nothing when it does not engage, on the one control an operator reaches + // for while the chain is down. + throw refuse( + disable, + FalconSealSupport.ActivationConfigException.Kind.SYNTAX, + "the value cannot be read as true or false", + "true, false, 1, 0, yes, no, on or off"); + } + } + + private static long readAnchorBlock(final Read block) { + if (!block.hasValue()) { + throw refuse( + block, + FalconSealSupport.ActivationConfigException.Kind.SYNTAX, + "the name is set but the value is empty; that is what an unexpanded shell variable in " + + "BESU_OPTS looks like, and until today this started the node with the anchor fully " + + "inert, without a single line in the log", + "the activation height H as a decimal number, for example 10141734"); + } + final long value = readLong(block); + if (value < 1) { + throw refuse( + block, + FalconSealSupport.ActivationConfigException.Kind.UNSAFE, + "activation height " + value + " leaves no room for a parent", + "an activation height H >= 1, and in practice a height ahead of the chain head"); + } + return value; + } + + private static long readChainId(final Read chain) { + if (!chain.hasValue()) { + throw refuse( + chain, + FalconSealSupport.ActivationConfigException.Kind.SYNTAX, + "the name is set but the value is empty", + "a chain id as a decimal number, for example 2800"); + } + return readLong(chain); + } + + private static int readCeiling(final Read ceiling) { + if (!ceiling.hasValue()) { + throw refuse( + ceiling, + FalconSealSupport.ActivationConfigException.Kind.SYNTAX, + "the name is set but the value is empty", + "a whole ceiling >= 0; 0 disarms the threshold completely"); + } + final int value; + try { + value = Integer.parseInt(ceiling.raw()); + } catch (final NumberFormatException e) { + // Until today this was IGNORED while AerePqEmergencyOptions announced "SEAL THRESHOLD CAPPED" + // anyway, so the operator read the banner as proof that the brake had engaged when it had not. + throw refuse( + ceiling, + FalconSealSupport.ActivationConfigException.Kind.SYNTAX, + "the value cannot be read as a whole number, and an emergency ceiling silently ignored " + + "is worse than no ceiling at all: the startup banner announces it as being in " + + "force anyway", + "a whole ceiling >= 0; 0 disarms the threshold completely"); + } + if (value < 0) { + throw refuse( + ceiling, + FalconSealSupport.ActivationConfigException.Kind.UNSAFE, + "negative ceiling; until today this silently disarmed the WHOLE anchor, while the banner " + + "announced no more than a capped threshold", + "a whole ceiling >= 0; 0 disarms the threshold completely"); + } + return value; + } + + private static long readLong(final Read where) { + try { + return Long.parseLong(where.raw()); + } catch (final NumberFormatException e) { + throw refuse( + where, + FalconSealSupport.ActivationConfigException.Kind.SYNTAX, + "the value cannot be read as a decimal number", + "a decimal number, no separators, no 0x prefix and no suffixes"); + } + } + + private static NavigableMap readSchedule( + final Read seals, final long anchorBlock) { + final NavigableMap schedule = new TreeMap<>(); + for (final String step : Splitter.on(',').split(seals.raw())) { + final String trimmed = step.trim(); + if (trimmed.isEmpty()) { + throw refuse( + seals, + FalconSealSupport.ActivationConfigException.Kind.SYNTAX, + "an empty step, that is one comma too many or two commas side by side", + scheduleExpectation(anchorBlock)); + } + final int colon = trimmed.indexOf(':'); + if (colon < 0) { + throw refuse( + seals, + FalconSealSupport.ActivationConfigException.Kind.SYNTAX, + "step \"" + trimmed + "\" has no ':'", + scheduleExpectation(anchorBlock)); + } + if (colon == 0) { + throw refuse( + seals, + FalconSealSupport.ActivationConfigException.Kind.SYNTAX, + "step \"" + trimmed + "\" has no block before the ':'", + scheduleExpectation(anchorBlock)); + } + if (colon == trimmed.length() - 1) { + throw refuse( + seals, + FalconSealSupport.ActivationConfigException.Kind.SYNTAX, + "step \"" + trimmed + "\" has no threshold after the ':'", + scheduleExpectation(anchorBlock)); + } + final long at; + final int k; + try { + at = Long.parseLong(trimmed.substring(0, colon).trim()); + k = Integer.parseInt(trimmed.substring(colon + 1).trim()); + } catch (final NumberFormatException e) { + throw refuse( + seals, + FalconSealSupport.ActivationConfigException.Kind.SYNTAX, + "step \"" + trimmed + "\" carries a number that cannot be read", + scheduleExpectation(anchorBlock)); + } + if (k < 0) { + throw refuse( + seals, + FalconSealSupport.ActivationConfigException.Kind.UNSAFE, + "step \"" + trimmed + "\" asks for a negative threshold", + scheduleExpectation(anchorBlock)); + } + if (at < anchorBlock) { + throw refuse( + seals, + FalconSealSupport.ActivationConfigException.Kind.UNSAFE, + "step \"" + + trimmed + + "\" is below the activation height " + + anchorBlock + + ", so a threshold would take effect before the rules do", + scheduleExpectation(anchorBlock)); + } + final Integer previous = schedule.put(at, k); + if (previous != null && previous != k) { + throw refuse( + seals, + FalconSealSupport.ActivationConfigException.Kind.UNSAFE, + "height " + + at + + " appears twice with different thresholds, " + + previous + + " and " + + k + + ", so the threshold at that height depends on the order within the string", + scheduleExpectation(anchorBlock)); + } + } + return schedule; + } + + /** + * The one shape a refusal takes: the field, where it was read from, what was actually read, what + * is wrong with it, what was expected, and the two operating instructions that matter more than + * any of it - repair this node, and never restart the fleet in parallel. + */ + private static FalconSealSupport.ActivationConfigException refuse( + final Read where, + final FalconSealSupport.ActivationConfigException.Kind kind, + final String problem, + final String expected) { + final StringBuilder message = new StringBuilder(768); + message + .append("AERE PQ ANCHOR: REFUSING TO START\n") + .append(" FIELD ") + .append(where.property()) + .append('\n') + .append(" SOURCE ") + .append(where.present() ? where.source() : "not set anywhere") + .append('\n') + .append(" READ ") + .append(where.present() ? "\"" + where.raw() + "\"" : "(nothing)") + .append('\n') + .append(" PROBLEM ") + .append(problem) + .append('\n') + .append(" EXPECTED ") + .append(expected) + .append('\n') + .append(" FIX correct BESU_OPTS on THIS node and restart ONLY this node\n") + .append(" WARNING if the same value is on all seven: restart one at a time,\n") + .append(" never in parallel. At quorum 5 of 7 you lose the chain.\n") + .append(" EMERGENCY ") + .append(PROPERTY_DISABLE) + .append("=true starts the node with the anchor off and shouts at every block"); + return new FalconSealSupport.ActivationConfigException(kind, REFUSAL_CODE, message.toString()); + } + + /** + * Whether the anchor rules apply to a block at this height. + * + * @param blockNumber the height being validated + * @return true iff the anchor is configured, not emergency-disabled, and the height is at or above + * H + */ + public boolean activeAt(final long blockNumber) { + return !disabled && anchorBlock != NEVER && blockNumber >= anchorBlock; + } + + /** + * Whether the anchor can ever apply on this node. + * + * @return true iff configured and not emergency-disabled + */ + public boolean everActive() { + return !disabled && anchorBlock != NEVER; + } + + /** + * The minimum number of Falcon seals a certificate must carry at this height, after the emergency + * ceiling is applied. + * + * @param blockNumber the height being validated + * @return the effective threshold K, never negative + */ + public int minSealsAt(final long blockNumber) { + final int scheduled = scheduledMinSealsAt(blockNumber); + return minSealsCeiling.isPresent() ? Math.min(scheduled, minSealsCeiling.getAsInt()) : scheduled; + } + + /** + * The threshold the SCHEDULE asks for at this height, before the emergency ceiling is applied. + * + *

AERE OPTIUNI-URGENTA (2026-08-02). This exists so the per-block emergency announcement can + * state both numbers from the SAME object the rules read, rather than re-deriving one of them from + * configuration a second time. A message that re-computes its own version of what the enforcement + * did can drift away from it, and a shout that says something other than what is actually in force + * is worse than no shout. + * + * @param blockNumber the height being validated + * @return the scheduled threshold K, never negative, ignoring any emergency ceiling + */ + public int scheduledMinSealsAt(final long blockNumber) { + final Map.Entry step = minSealsSchedule.floorEntry(blockNumber); + return step == null ? 0 : step.getValue(); + } + + /** + * Whether the emergency ceiling is ACTUALLY lowering the threshold at this height, as opposed to + * merely being set. + * + *

A ceiling of 5 over a scheduled threshold of 1 changes nothing, and a node announcing "the + * threshold is lowered" in that state would be shouting about a control that is carrying no + * weight. Operators stop reading a log that cries wolf, so the announcement is tied to the ceiling + * having an effect, not to it being present. + * + * @param blockNumber the height being validated + * @return true iff the anchor is active here and the ceiling is strictly below the scheduled + * threshold + */ + public boolean emergencyCeilingLowersAt(final long blockNumber) { + return activeAt(blockNumber) + && minSealsCeiling.isPresent() + && minSealsCeiling.getAsInt() < scheduledMinSealsAt(blockNumber); + } + + /** + * The emergency ceiling on K, if one is configured. + * + * @return the ceiling, or empty + */ + public OptionalInt minSealsCeiling() { + return minSealsCeiling; + } + + /** + * The largest certificate this node's proposer writes, or empty for no cap (today's behaviour). + * + * @return the proposer-side seal cap + */ + public OptionalInt maxSealsCarried() { + return maxSealsCarried; + } + + /** + * The highest EFFECTIVE threshold this configuration ever demands, that is the largest {@code K} + * across every step of the schedule after the emergency ceiling has been applied. This is the one + * number a proposer-side cap must never fall below. + * + * @return the highest effective K, or 0 if the schedule is empty + */ + public int highestEffectiveMinSeals() { + int highest = 0; + for (final Integer k : minSealsSchedule.values()) { + final int effective = minSealsCeiling.isPresent() ? Math.min(k, minSealsCeiling.getAsInt()) : k; + highest = Math.max(highest, effective); + } + return highest; + } + + /** + * Return a copy carrying a proposer-side seal cap, refusing any cap that cannot work. + * + *

THE REFUSAL IS THE POINT. A cap below {@link #highestEffectiveMinSeals()} makes this node's + * proposer assemble certificates its own fleet must reject, at every height from the step that + * raises K past the cap. The chain would not diverge and would not corrupt: it would simply stop + * accepting anything this node proposes, which at N=7 costs a seventh of the proposers and, if the + * same cap is deployed fleet-wide, every one of them. That failure is silent in every existing + * tool, because each of them measures what was ASKED and the ask is internally consistent. So it + * is refused here, at startup, where a refusal is cheap. + * + * @param cap the cap to carry, or empty for none + * @return a copy of this configuration carrying the cap + * @throws IllegalArgumentException if the cap is below the highest effective threshold + */ + public PqAnchorConfig withMaxSealsCarried(final OptionalInt cap) { + if (cap.isPresent()) { + final int highest = highestEffectiveMinSeals(); + if (cap.getAsInt() < highest) { + throw new IllegalArgumentException( + REFUSAL_CODE + + ": " + + PROPERTY_MAX_SEALS + + "=" + + cap.getAsInt() + + " is below the highest threshold this schedule ever demands, K=" + + highest + + ". A proposer capped below K writes certificates its own fleet rejects, and every " + + "existing check stays GREEN while it happens, because each one measures what was " + + "asked. Raise the cap to at least " + + highest + + ", or lower the schedule."); + } + } + // CAREFUL: the interval is carried across too. A copy method that drops a field along the way + // is exactly how a disarm or an interval would vanish silently when the other control is set. + return new PqAnchorConfig( + chainId, anchorBlock, minSealsSchedule, minSealsCeiling, disabled, cap, anchorInterval); + } + + /** + * How often a certificate is carried, counted from H. Empty means every block, which is the + * strongest setting and today's design. + * + * @return the anchor interval + */ + public OptionalInt anchorInterval() { + return anchorInterval; + } + + /** + * Is this a height at which a certificate is carried and demanded? + * + *

Counted FROM H, so H itself is always an anchor height. That matters because H is the single + * height the whole fleet coordinates on, and a scheme whose first anchor lands somewhere else + * would be a scheme nobody can check by hand on activation day. + * + * @param blockNumber the height to test + * @return true if a certificate belongs in this block + */ + public boolean isAnchorHeight(final long blockNumber) { + if (anchorInterval.isEmpty()) { + return true; + } + if (anchorBlock == NEVER || blockNumber < anchorBlock) { + return false; + } + return (blockNumber - anchorBlock) % anchorInterval.getAsInt() == 0; + } + + /** + * The single question both anchor rules ask: do I judge this header at all? + * + *

Two conditions, and they are different in kind. {@link #activeAt(long)} is about WHETHER the + * scheme is switched on for this node at this height; {@link #isAnchorHeight(long)} is about + * whether this particular height is one that carries a certificate. Folding them into one method + * is deliberate: two rules asking the same question two different ways is exactly how R1 and R2 + * would drift apart, and a header accepted by one and refused by the other is a chain split. + * + * @param blockNumber the height to test + * @return true if the anchor rules judge this header + */ + public boolean anchorAppliesAt(final long blockNumber) { + return activeAt(blockNumber) && isAnchorHeight(blockNumber); + } + + /** + * Return a copy carrying an anchor interval. + * + *

THE SECURITY PROPERTY, stated so it can be argued with: block hashes chain, so an anchor at + * height A protects everything below A, because rewriting anything under A changes A's parent hash + * and forces A to be reproduced, which needs a Falcon quorum. What an adversary holding every + * classical validator key can still rewrite is the TAIL since the last anchor. Therefore + * {@code fork depth <= interval}. At ~523 ms a block, an interval of 100 is about 52 seconds of + * rewritable tail, against about half a second at interval 1, at a hundredth of the header cost. + * + * @param interval the interval, or empty for every block + * @return a copy of this configuration carrying the interval + */ + public PqAnchorConfig withAnchorInterval(final OptionalInt interval) { + return new PqAnchorConfig( + chainId, anchorBlock, minSealsSchedule, minSealsCeiling, disabled, maxSealsCarried, interval); + } + + /** + * Whether the anchor is configured at all, INDEPENDENTLY of whether it has been emergency + * disarmed. + * + *

{@link #everActive()} answers "will the rules ever fire", which is false both when nothing is + * configured and when a configured anchor has been disarmed. Telling those two states apart is the + * whole point of the per-block disarm announcement: a node that never had an anchor has nothing to + * shout about, and a node running disarmed over a configured anchor has to shout at every block. + * + * @return true iff an activation height is configured + */ + public boolean anchorConfigured() { + return anchorBlock != NEVER; + } + + /** + * The activation height H. + * + * @return H, or {@link #NEVER} + */ + public long anchorBlock() { + return anchorBlock; + } + + /** + * The height at which the legacy log-only {@code FalconSealValidationRule} retires. It is the same + * height the new rules take over at, so exactly one of the two regimes is in force at any height. + * + * @return H, or {@link #NEVER} when the anchor never activates and the legacy rule never retires + */ + public long legacyFalconRuleRetirementBlock() { + return everActive() ? anchorBlock : NEVER; + } + + /** + * The chain id used in the D and M pre-images. + * + * @return the chain id + */ + public long chainId() { + return chainId; + } + + /** + * The staged threshold schedule. + * + * @return an unmodifiable view keyed on activation height + */ + public NavigableMap minSealsSchedule() { + return minSealsSchedule; + } + + /** + * Whether the emergency disable override is in force. + * + * @return true iff the anchor rules are switched off on this node + */ + public boolean disabled() { + return disabled; + } + + @Override + public String toString() { + return "PqAnchorConfig{chainId=" + + chainId + + ", anchorBlock=" + + (anchorBlock == NEVER ? "NEVER" : anchorBlock) + + ", minSeals=" + + minSealsSchedule + + ", ceiling=" + + (minSealsCeiling.isPresent() ? minSealsCeiling.getAsInt() : "none") + + ", disabled=" + + disabled + + '}'; + } +} diff --git a/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorNotReadyException.java b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorNotReadyException.java new file mode 100644 index 0000000..7725600 --- /dev/null +++ b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorNotReadyException.java @@ -0,0 +1,132 @@ +/* + * Copyright contributors to Besu / Aere Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft; + +/** + * Raised by the block producer when this node cannot back the block it is about to propose with a + * certificate that reaches the staged threshold K. + * + *

THIS IS A DECISION, NOT A FAULT. The alternative, proposing anyway, means emitting a header + * this node has ALREADY COMPUTED to be invalid under its own rules. In QBFT both choices cost the + * same thing, one round timeout, because there is no way for a proposer to yield its turn early: + * a proposal everyone rejects and a proposal nobody sends both end at the round timer. So proposing + * anyway buys no liveness at all and only puts a header that cannot be imported into gossip. The + * refusal is the strictly better of two equal-cost options. + * + *

WHEN IT CAN HAPPEN. Only above the activation height, and only once the operator has raised K + * past zero: the first stage is required to be K=0, so activation itself can never refuse. In + * steady state the usual cause is a node that restarted and has not yet taken part in a commit, at + * most one proposer turn. The other cause, f validators withholding Falcon seals, is a measured + * exposure of its own and is the reason the validator set must grow to N>=9 before K is raised to + * quorum, because at N=7, f=2 the margin is exactly zero. + * + *

Callers on the consensus path must catch this and simply not propose. It carries the numbers a + * human needs to see why, so the refusal is never silent. + */ +public class PqAnchorNotReadyException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + /** + * Find a refusal anywhere in a throwable's cause chain. + * + *

MEASURED REASON THIS EXISTS, 2026-08-02. {@code AbstractBlockCreator.createBlock} wraps + * EVERY exception thrown by the extra-data calculator into {@code IllegalStateException("Block + * creation failed unexpectedly. Will restart on next block added to chain.")} at + * AbstractBlockCreator.java:366 of commit d2032017, and the producer is called from inside that + * same try block, at AbstractBlockCreator.java:333. A plain {@code catch + * (PqAnchorNotReadyException)} on the consensus path is therefore DEAD CODE. Measured on an + * isolated N=4 network with an unreachable threshold (K=4 from height 30, chain halted at 29): + * the refusal WARN was logged 0 times on 4 of 4 nodes, while "Block creation failed unexpectedly" + * and "State machine threw exception while processing event" were each logged once per node, and + * the escape aborted {@code handleRoundChangePayload} rather than skipping one proposal. + * + *

Callers must rethrow when this returns null: a refusal is the ONLY exception the consensus + * path is allowed to swallow, and a broad catch that ate the rest would hide real faults. + * + * @param thrown the exception caught on the consensus path + * @return the refusal carried by that exception, or null when it is not a refusal + */ + public static PqAnchorNotReadyException findIn(final Throwable thrown) { + Throwable cursor = thrown; + // Bounded so a self-referential or cyclic cause chain cannot spin the consensus thread. + for (int hop = 0; cursor != null && hop < 16; hop++) { + if (cursor instanceof PqAnchorNotReadyException) { + return (PqAnchorNotReadyException) cursor; + } + final Throwable next = cursor.getCause(); + if (next == cursor) { + return null; + } + cursor = next; + } + return null; + } + + private final long parentNumber; + private final int seals; + private final int required; + + /** + * Instantiates the refusal. + * + * @param parentNumber the number of the parent block the certificate would attest + * @param seals how many valid, eligible seals this node holds for that parent + * @param required the staged threshold K for the block being produced + * @param detail a human readable explanation of where the seals came from and what was rejected + */ + public PqAnchorNotReadyException( + final long parentNumber, final int seals, final int required, final String detail) { + super( + "AERE-PQ-ANCHOR-NOT-READY: refusing to propose on top of block " + + parentNumber + + " because this node holds " + + seals + + " valid eligible Falcon seal(s) for it and the staged threshold is " + + required + + ". " + + detail); + this.parentNumber = parentNumber; + this.seals = seals; + this.required = required; + } + + /** + * The parent the certificate would have attested. + * + * @return the parent block number + */ + public long parentNumber() { + return parentNumber; + } + + /** + * How many valid eligible seals were available. + * + * @return the seal count + */ + public int seals() { + return seals; + } + + /** + * The staged threshold that was not reached. + * + * @return the threshold K + */ + public int required() { + return required; + } +} diff --git a/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorSyncModeGuard.java b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorSyncModeGuard.java new file mode 100644 index 0000000..73dd049 --- /dev/null +++ b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorSyncModeGuard.java @@ -0,0 +1,130 @@ +/* + * Copyright contributors to Besu / Aere Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft; + +import java.util.Locale; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * AERE SINCRONIZARE (2026-08-02). Refuse to start when the V2 certificate anchor is configured and + * this node is going to acquire its history through a sync mode whose header import path does not + * run the block header validator at all. + * + *

The hole this closes, read in the source and not assumed. On the SNAP path the backward + * header pipeline ends in {@code + * org.hyperledger.besu.ethereum.eth.sync.common.ImportHeadersStep}. That class takes a {@code + * MutableBlockchain}, an anchor header and a pivot header, and nothing else: it holds no {@code + * ProtocolSchedule}, no {@code ProtocolContext} and no {@code BlockHeaderValidator}. Its {@code + * accept(List)} checks one thing, that the first header of the batch hashes to the parent hash the + * previously imported child declared, and then calls {@code storeBlockHeaders} directly. There is no + * {@code HeaderValidationMode} anywhere in the file. So every header between the checkpoint and the + * pivot enters the database having been checked for hash chaining and for nothing else. No QBFT + * rule runs on it: not the validator set rule, not the committed seals rule, and not either half of + * the certificate anchor. Bodies and receipts below the pivot arrive through the same pipeline + * family and are imported without executing the blocks. + * + *

Why refusal and not coverage. Covering the SNAP header path properly means giving that + * step a header validator and a parent for every header it imports. The backward pipeline does not + * have the parent when it imports a header - the parent is downloaded in a later batch - so the + * attached half of the anchor cannot run there at all, and the detached half would run against a + * parent that is not yet known. Refusing to start is the only repair that is complete: it has no + * window, no partial coverage and nothing for an operator to get subtly wrong. What it costs is + * stated plainly rather than hidden: with the anchor armed, a new public node has to acquire the + * chain by full sync. + * + *

Fail closed on the unknown. A null, blank or unrecognised mode name aborts. A guard that + * treats "I could not tell" as "probably fine" is the class of defect this whole programme exists to + * remove. + * + *

Inert when the anchor is not configured. With no {@code aere.pq.anchorBlock} this method + * returns before it looks at the sync mode, so a binary carrying it behaves exactly as it did + * before on any chain where the anchor is not configured on any node. + */ +public final class PqAnchorSyncModeGuard { + + private static final Logger LOG = LoggerFactory.getLogger(PqAnchorSyncModeGuard.class); + + /** The stable machine-readable code emitted when this guard refuses to start a node. */ + public static final String CODE = "AERE-PQC-SYNC-MODE-01"; + + /** The only sync mode whose import path runs the block header validator on every block. */ + public static final String REQUIRED_MODE = "FULL"; + + private PqAnchorSyncModeGuard() {} + + /** + * Refuse to start when the anchor is armed and the sync mode bypasses header validation. + * + *

Call this exactly once during node startup, from a point where a chain head already exists + * and before the network and the consensus state machine are started, so that a refusal is a clean + * refusal to start rather than a mid-flight halt. + * + * @param syncModeName the configured sync mode, as its enum name; null or blank aborts + * @param anchorConfig the height-indexed certificate-anchor configuration; a null or never-active + * configuration makes this method a no-op + * @throws FalconSealSupport.ActivationConfigException when the anchor is armed and the sync mode + * is anything other than {@value #REQUIRED_MODE} + */ + public static void verifySyncModeOrAbort( + final String syncModeName, final PqAnchorConfig anchorConfig) { + + if (anchorConfig == null || !anchorConfig.everActive() || anchorConfig.disabled()) { + LOG.debug( + "AERE PQ ANCHOR: sync-mode guard inert, the certificate anchor is not armed on this node."); + return; + } + + final String mode = + syncModeName == null ? "" : syncModeName.trim().toUpperCase(Locale.ROOT); + + if (REQUIRED_MODE.equals(mode)) { + LOG.info( + "AERE PQ ANCHOR: sync mode is {} and the certificate anchor is armed at H={}. Every header " + + "this node imports goes through the block header validator, so both halves of the " + + "anchor apply to synced history as well as to proposed blocks.", + mode, + anchorConfig.anchorBlock()); + return; + } + + throw new FalconSealSupport.ActivationConfigException( + FalconSealSupport.ActivationConfigException.Kind.UNSAFE, + CODE, + "AERE PQ ANCHOR: REFUSING TO START (fail-closed). The V2 certificate anchor is armed at " + + "H=" + + anchorConfig.anchorBlock() + + " but --sync-mode is " + + (mode.isEmpty() ? "(null or blank)" : mode) + + ", not " + + REQUIRED_MODE + + ". WHAT THIS MEANS: outside " + + REQUIRED_MODE + + " sync, history is acquired through the backward header pipeline, which ends in " + + "ImportHeadersStep. That step holds no ProtocolSchedule and no BlockHeaderValidator; " + + "it checks that each batch of headers hash-chains onto the child already imported and " + + "then writes them straight to storage. No HeaderValidationMode appears in that file. " + + "So the anchor digest rule and the anchor seals rule would both be skipped for every " + + "block between the checkpoint and the pivot, and this node would accept, as its own " + + "history, blocks whose certificates were stripped or swapped in flight. A defence that " + + "does not apply to a node that is syncing is missing exactly when it matters, because " + + "that is the moment a node takes history from strangers. WHAT TO DO: start this node " + + "with --sync-mode=" + + REQUIRED_MODE + + ". To start it unchanged for diagnosis, unset aere.pq.anchorBlock; the anchor rules " + + "are then inert and this node enforces nothing."); + } +} diff --git a/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorThresholdGuard.java b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorThresholdGuard.java new file mode 100644 index 0000000..6551df0 --- /dev/null +++ b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorThresholdGuard.java @@ -0,0 +1,252 @@ +/* + * Copyright contributors to Besu / Aere Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft; + +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * AERE GARDA-PRAG (2026-08-02). Refuse to start when the staged Falcon seal threshold K is set to a + * value that STOPS THE CHAIN, instead of letting the node boot quietly and discover it one block + * later. + * + *

The hole this closes, measured before it was written. Searched across the whole fork + * tree, {@code grep -rn "minSeals" --include=*.java consensus app} filtered for any of {@code + * quorum}, {@code throw}, {@code refus}, {@code abort} or {@code Illegal}: ZERO matches. {@link + * PqAnchorConfig} refuses a NEGATIVE threshold and a step scheduled below the activation height, and + * nothing else. The only guard that looked at the threshold at all was {@code + * PqAnchorProducer.warnIfActivationHeightCanRefuse}, and it covers a different question (K at the + * activation height itself), reaches only {@code LOG.error}, and returns. A halting threshold was + * therefore a configuration a node accepted in silence. + * + *

THE BOUND, AND WHERE THE ARITHMETIC COMES FROM. The refusal is {@code K >= quorum(N)}, + * i.e. the highest value that may be configured is {@code quorum(N) - 1}. + * + *

    + *
  • {@code quorum(N)} is Besu's own {@link BftHelpers#calculateRequiredValidatorQuorum(int)}, + * {@code ceil(2N/3)}. At N=7 that is {@code ceil(14/3) = 5}, so the highest configurable + * threshold at that set size is 4. + *
  • {@code K = quorum} is a GUARANTEED HALT, and that is measured, not argued. A proposer + * assembles its certificate out of the Falcon seals it heard on Commit messages, and the block + * is IMPORTED at the quorum-th Commit; after the import {@code QbftController.consumeMessage} + * discards every message that targets a height not above the chain head, so the Commits that + * arrive afterwards never reach the seal cache. A proposer can therefore gather at most {@code + * quorum} seals, at ANY N. Measured on an isolated N=4 network (quorum 3): {@code k = 3} on + * EVERY header above the activation height, never 4, with all four nodes keyed and healthy. + * Requiring K=quorum thus requires that ALL of the first {@code quorum} Commits carry a valid, + * eligible Falcon seal: one validator without a key among them, or one seal that does not + * verify, and no proposer can ever propose again. + *
  • {@code K > N - f} is the WRONG bound and this guard deliberately does not use it. At + * N=7 the two agree by coincidence, because {@code N - f = 7 - 2 = 5} equals {@code quorum} and + * both reject 5. They part company as soon as the set grows: at N=9, {@code quorum = 6} while + * {@code N - f = 7}, so {@code K > N - f} would happily accept K=7, which is two rungs above + * what a proposer can physically gather. Growing the validator set does not buy quorum margin; + * that was measured too, and it is asserted in the test. + *
+ * + *

What is a refusal and what is only a shout. Above {@code quorum - f} the schedule is + * still reachable but has no margin against f silent or keyless signers, which is a measured + * exposure of its own. That is a deliberate operator choice with a real cost, so it gets a loud + * WARN and the node starts. At or above {@code quorum} the schedule is not reachable at all, so it gets a + * refusal. A guard that refused both would take the emergency ladder away; a guard that shouted for + * both would be the log line this class exists to replace. + * + *

The verdict is computed from the SAME code path enforcement reads. Every step is scored + * with {@link PqAnchorConfig#minSealsAt(long)}, the exact method the producer calls, not with a + * second reading of the raw schedule. That is why the emergency ceiling is honoured here for free: a + * ceiling that lowers a fatal schedule below the bound makes the node start, which is precisely what + * an operator needs at three in the morning, and a ceiling ABOVE the schedule rescues nothing, + * because the ceiling can only ever lower. + * + *

Fail closed on the unknown. A validator-set size of zero or less aborts. "I could not + * count the validators" is not "the threshold is probably fine". + * + *

Inert when the anchor is not armed. With no {@code aere.pq.anchorBlock}, or with the + * anchor emergency-disarmed, this method returns before it computes anything, so a binary carrying + * it behaves exactly as it did before on any chain where the anchor is armed on no node. + */ +public final class PqAnchorThresholdGuard { + + private static final Logger LOG = LoggerFactory.getLogger(PqAnchorThresholdGuard.class); + + /** The stable machine-readable code emitted when this guard refuses to start a node. */ + public static final String CODE = "AERE-PQC-THRESHOLD-01"; + + private PqAnchorThresholdGuard() {} + + /** + * The highest seal threshold that may be configured for a validator set of this size. + * + *

One below the QBFT quorum. At N=7 this is 4. + * + * @param validatorCount the number of validators, at least 1 + * @return the highest configurable threshold K + */ + public static int maxConfigurableThreshold(final int validatorCount) { + return BftHelpers.calculateRequiredValidatorQuorum(validatorCount) - 1; + } + + /** + * The number of faulty validators QBFT tolerates at this set size, {@code floor((N-1)/3)}. + * + * @param validatorCount the number of validators, at least 1 + * @return f + */ + public static int byzantineBudget(final int validatorCount) { + return (validatorCount - 1) / 3; + } + + /** + * Refuse to start when any step of the staged threshold schedule is at or above the QBFT quorum. + * + *

Call this exactly once during node startup, from a point where the validator set is already + * known and before the network and the consensus state machine are started, so that a refusal is a + * clean refusal to start rather than a mid-flight halt. + * + * @param anchorConfig the height-indexed certificate-anchor configuration; a null, never-active or + * disarmed configuration makes this method a no-op + * @param validatorCount the size of the validator set this node is starting into; zero or less + * aborts + * @throws FalconSealSupport.ActivationConfigException when the anchor is armed and any step of the + * effective threshold schedule reaches the quorum, or when the validator set size is unknown + */ + public static void verifyThresholdAgainstQuorumOrAbort( + final PqAnchorConfig anchorConfig, final int validatorCount) { + + if (anchorConfig == null || !anchorConfig.everActive()) { + LOG.debug( + "AERE PQ ANCHOR: threshold guard inert, the certificate anchor is not armed on this node."); + return; + } + + if (validatorCount < 1) { + throw new FalconSealSupport.ActivationConfigException( + FalconSealSupport.ActivationConfigException.Kind.UNSAFE, + CODE, + "AERE PQ ANCHOR: REFUSING TO START (fail-closed). The V2 certificate anchor is armed at H=" + + anchorConfig.anchorBlock() + + " but this node counted " + + validatorCount + + " validators, so the QBFT quorum cannot be computed and the configured seal " + + "threshold cannot be checked against it. A threshold at or above the quorum stops " + + "the chain, and a guard that treated an uncountable validator set as safe would be " + + "exactly the silence this guard exists to remove. WHAT TO DO: start this node " + + "without aere.pq.anchorBlock to diagnose, or fix whatever prevented the validator " + + "set from being read."); + } + + final int quorum = BftHelpers.calculateRequiredValidatorQuorum(validatorCount); + final int maxConfigurable = quorum - 1; + final int f = byzantineBudget(validatorCount); + final int noMarginAbove = quorum - f; + + long fatalHeight = -1L; + int fatalThreshold = -1; + long highestAt = anchorConfig.anchorBlock(); + int highest = anchorConfig.minSealsAt(anchorConfig.anchorBlock()); + + for (final Map.Entry step : anchorConfig.minSealsSchedule().entrySet()) { + final long at = step.getKey(); + // Scored through minSealsAt, the SAME method the producer calls, so the emergency ceiling is + // applied here by exactly the code that will apply it in enforcement. A guard that re-derived + // the effective threshold from the raw schedule could drift away from what is in force. + final int effective = anchorConfig.minSealsAt(at); + if (effective > highest) { + highest = effective; + highestAt = at; + } + if (effective >= quorum && fatalHeight < 0L) { + fatalHeight = at; + fatalThreshold = effective; + } + } + + if (fatalHeight >= 0L) { + throw new FalconSealSupport.ActivationConfigException( + FalconSealSupport.ActivationConfigException.Kind.UNSAFE, + CODE, + "AERE PQ ANCHOR: REFUSING TO START (fail-closed). The staged Falcon seal threshold reaches " + + fatalThreshold + + " at height " + + fatalHeight + + ", and the QBFT quorum for the " + + validatorCount + + " validators this node is starting into is " + + quorum + + " (ceil(2N/3)). The highest threshold that may be configured at this set size is " + + maxConfigurable + + ". WHAT THIS MEANS: a proposer builds its certificate out of the Falcon seals it " + + "heard on Commit messages, and the block is imported at the quorum-th Commit; every " + + "Commit arriving after that import is discarded as targeting a height not above the " + + "chain head, so a proposer can gather at most quorum seals at ANY validator-set " + + "size. Measured on an isolated N=4 network with quorum 3: k=3 on every header above " + + "the activation height, never 4, with all four nodes keyed and healthy. A threshold " + + "of " + + fatalThreshold + + " therefore requires that ALL of the first " + + quorum + + " Commits carry a valid and eligible Falcon seal; one validator without a key among " + + "them, or one seal that does not verify, and no proposer proposes again. That is a " + + "halt, not a degradation, and it starts at height " + + fatalHeight + + ". WHAT TO DO: lower the step to at most " + + maxConfigurable + + " in aere.pq.anchorMinSeals, or, if the chain is already stopped, restart with " + + "--Xaere-pq-anchor-min-seals-max=" + + maxConfigurable + + " which lowers the effective threshold without editing the schedule, or with " + + "--Xaere-pq-anchor-disarm to switch the anchor rules off on this node entirely. " + + "Schedule as read: " + + anchorConfig.minSealsSchedule() + + ", emergency ceiling: " + + (anchorConfig.minSealsCeiling().isPresent() + ? Integer.toString(anchorConfig.minSealsCeiling().getAsInt()) + : "none") + + "."); + } + + if (highest > noMarginAbove) { + LOG.warn( + "AERE PQ ANCHOR: threshold guard PASSED but the schedule has NO MARGIN. K reaches {} at " + + "height {}, the quorum for {} validators is {} and f is {}, so the largest threshold " + + "that survives f silent or keyless signers is {}. Above that line every one of the " + + "first {} committers has to carry a valid Falcon seal minus at most {}. This is " + + "allowed because it is an operator's decision, not because it is safe.", + highest, + highestAt, + validatorCount, + quorum, + f, + noMarginAbove, + quorum, + quorum - highest); + return; + } + + LOG.info( + "AERE PQ ANCHOR: threshold guard passed. K reaches {} at height {}; quorum for {} " + + "validators is {}, highest configurable threshold is {}, margin against silent " + + "signers is {} of a tolerated f={}.", + highest, + highestAt, + validatorCount, + quorum, + maxConfigurable, + noMarginAbove - highest, + f); + } +} diff --git a/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqRegistryBinding.java b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqRegistryBinding.java new file mode 100644 index 0000000..5e3a832 --- /dev/null +++ b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqRegistryBinding.java @@ -0,0 +1,497 @@ +/* + * Copyright contributors to Besu. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft; + +import org.hyperledger.besu.crypto.SECPSignature; +import org.hyperledger.besu.crypto.SignatureAlgorithmFactory; +import org.hyperledger.besu.datatypes.Address; +import org.hyperledger.besu.datatypes.Hash; +import org.hyperledger.besu.ethereum.core.Util; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.List; + +import org.apache.tuweni.bytes.Bytes; +import org.apache.tuweni.bytes.Bytes32; +import org.bouncycastle.crypto.digests.KeccakDigest; +import org.bouncycastle.pqc.crypto.falcon.FalconParameters; +import org.bouncycastle.pqc.crypto.falcon.FalconPublicKeyParameters; +import org.bouncycastle.pqc.crypto.falcon.FalconSigner; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * AERE REGISTRY BINDING: bind each registry row's FALCON PUBLIC KEY to the VALIDATOR ADDRESS it + * sits next to. + * + *

The defect, measured on the real code path on 2026-08-06

+ * + *

{@link PqRegistryHash} makes the registry a chain-committed object: two nodes cannot hold + * different files without one of them refusing to start. It says so itself, in its own class + * javadoc: "It says nothing about whether any validator actually holds the private key matching its + * registered public key." That sentence is the whole of the defect this class closes. + * + *

Concretely, the registry is a table from index i to the pair (ECDSA validator address, + * Falcon public key). Seal verification uses the KEY at index i; signer eligibility is checked + * against the parent block's VALIDATOR SET, i.e. against the ADDRESS at index i. Nothing whatsoever + * connects the two halves of a row, so whoever writes the registry file may place any key under any + * address. Four consequences were measured with probes on the real verification path, all with the + * startup gate reporting MATCH and the header ACCEPTED: + * + *

    + *
  • T3: rebinding one row's address credits a seal made by validator 0's key to validator 1. + *
  • T4: putting ONE public key at TWO indices lets a SINGLE key holder produce two seals that + * are credited to two different addresses, so the threshold K is satisfied by one signer. The + * threshold itself becomes fiction, not merely the attribution. + *
  • T6A: SWAPPING two rows' public keys - no duplicate key, no duplicate address - is accepted. + *
  • T6B: SWAPPING two rows' addresses - again no duplicate of anything - is accepted, and every + * seal is then attributed to the wrong validator. + *
+ * + *

T6A and T6B are why this class exists and why a uniqueness check alone was not enough. Measured + * on 2026-08-06: both permuted registries carry 4 distinct public keys and 4 distinct addresses, so + * every uniqueness test passes, and the forged header is still ACCEPTED. Uniqueness restores the + * THRESHOLD (K verifying seals at K distinct indices are then necessarily K distinct keys); it can + * never restore ATTRIBUTION. Attribution needs a signature. + * + *

What a row must now carry

+ * + *

Two proofs over ONE canonical pre-image, because one signature closes only half the defect: + * + *

    + *
  • POSSESSION, a Falcon signature by the row's own key. Proves the row is not an invented or + * truncated key and that somebody holds the matching secret. It does NOT close T3, T4 or T6: + * at the key ceremony the registry writer holds every Falcon secret, so it can sign a + * possession proof for key 0 sitting under validator 1's address. Anyone who claims a Falcon + * proof-of-possession repairs this binding defect is wrong, and the probe measures it. + *
  • CLAIM, an ECDSA signature by the row's own VALIDATOR key. This is the half that cuts. The + * registry writer cannot forge it without validator i's consensus key, so a key cannot be + * moved under another validator's address, indices cannot be swapped, and a key cannot appear + * under an address whose owner did not ask for it. + *
+ * + *

The canonical pre-image

+ * + *
+ *   "AERE-PQ-POP-1"            13 bytes ASCII, at offset 0
+ *   uint8(formatVersion)        1
+ *   uint64be(chainId)           8
+ *   uint64be(bindHeight)        8   the height in pqRegistryHash that makes THIS registry active
+ *   uint32be(count)             4
+ *   uint32be(index)             4
+ *   address                    20
+ *   uint32be(len(publicKey))    4
+ *   publicKey                 len
+ * 
+ * + *

Every field earns its place against a specific, named attack: + * + *

    + *
  • THE DOMAIN TAG, at offset 0 and inside the hashed pre-image rather than beside it. The same + * Falcon key signs anchor seals over {@code keccak(RLP["AERE-PQ-COMMIT-1", chainId, number, + * hash])} - also a 32-byte digest, also handed to the same {@link FalconSigner}. Without a + * distinct tag a possession proof would be a valid Falcon signature over a 32-byte digest and + * could be replayed as a SEAL, filling the threshold K with a validator that never ran a + * block. Ristenpart and Yilek (EUROCRYPT 2007) is the general form of this: a + * proof-of-possession that can be reused as a working signature is not a defence, it is a + * second door, and the repair is separate hash inputs for the two purposes. + *
  • publicKey IS IN THE SIGNED MESSAGE. Falcon is not Dilithium here: Cremers, Duzlu, Fiedler, + * Fischlin and Janson (IEEE S&P 2021) measured that Falcon does NOT have exclusive + * ownership, because {@code HashToPoint(r || m)} never absorbs the public key. Putting the key + * into the message is the Pornin-Stern PS-3 transform applied at the application layer, with + * no change to the Falcon implementation and no growth in signature size. NOT MEASURED HERE: a + * concrete key-substitution forgery against Falcon-512 at our parameters was not executed; + * what is established is the missing property, from the literature. + *
  • chainId, from Ethereum's own scar. {@code DOMAIN_DEPOSIT} omits the genesis validators root, + * so deposit signatures replay BETWEEN CHAINS; EIP-8205 exists to add the separation. Our + * scratch chain 442807 runs the same binaries and can run the same Falcon keys. + *
  • index and bindHeight, so a row cannot be lifted to a different index, and a row retired at + * one rotation cannot be replayed into a later registry. + *
  • count, so a registry cannot be truncated and have its surviving proofs stay valid. + *
+ * + *

The CLAIM signs {@code keccak256(0x19 || 0x00 || context || preimage)}, the EIP-191 version-0 + * envelope, with {@code context} the low 20 bytes of {@code keccak256("AERE-PQ-CLAIM-1" || + * uint64be(chainId))}. The leading {@code 0x19} is the point: it cannot begin an RLP-encoded legacy + * transaction (those start at {@code 0xc0}) nor a typed one ({@code 0x01}..{@code 0x04}), so a + * validator's claim can never be replayed as a transaction it signed. The context byte string keeps + * this registry's claims disjoint from any other EIP-191 message on the same chain. + * + *

Where this runs, and where it deliberately does not

+ * + *

AT LOAD, ONCE, from {@code PqRegistryHash.assemble}, which is the single point every loader + * funnels through, and fail-closed. The registry is immutable for the whole of an epoch, so the cost + * is N Falcon verifications plus N ecrecover per PROCESS START and exactly ZERO per block. At N=7, + * against the 0.068 ms median quoted in {@code PqAnchorSealsRule}, that is under a millisecond of + * startup. + * + *

NOT per seal. That would add K verifications to every block - about 0.2 ms at K=3 out of a + * 523 ms block - to re-establish a fact that cannot change within an epoch, and every line added to + * that loop is a permanent reconciliation cost against upstream Besu. + * + *

What this does NOT defend against, stated plainly

+ * + *
    + *
  • NOT the holder of the vault. All seven validator ECDSA keys live in one place; whoever has + * them signs a perfectly valid claim for any Falcon key they like. This moves the attack from + * "whoever can edit a file" to "whoever holds the consensus keys". The answer to "how many + * independent people must agree to stop this chain" is unchanged, and is one. + *
  • NOT the rewriting of historical epochs. The immutable anchor contract pins ONE hash, the + * head registry's at activation; earlier scheduled epochs are pinned by nothing on chain. + *
  • NOT key duplication. Possession is not exclusivity: a claim proves validator i asked for + * this key, never that nobody else has a copy. T4 is closed by the uniqueness rule in {@code + * PqRegistryHash.assemble}, not by either signature. + *
  • NOT anything about how a private key is stored or whether it was ever copied. + *
+ */ +public final class PqRegistryBinding { + + private static final Logger LOG = LoggerFactory.getLogger(PqRegistryBinding.class); + + /** Domain tag of the binding pre-image. Distinct from every other Falcon message we sign. */ + public static final String DOMAIN_POSSESSION = "AERE-PQ-POP-1"; + + /** Domain tag mixed into the EIP-191 context of the ECDSA claim. */ + public static final String DOMAIN_CLAIM = "AERE-PQ-CLAIM-1"; + + /** Registry format version that carries binding proofs. Version 1 carries none. */ + public static final int FORMAT_VERSION = 2; + + /** r(32) || s(32) || recId(1), the encoding {@code decodeSignature} expects. */ + private static final int CLAIM_SIGNATURE_LENGTH = 65; + + private PqRegistryBinding() {} + + // =================================================================================== + // The canonical binding pre-image and its two digests + // =================================================================================== + + /** + * The canonical binding pre-image for one registry row. Domain-separated, length-prefixed, + * chain-bound, height-bound and index-bound. See the class javadoc for why each field is there. + * + * @param chainId the chain this registry is bound to + * @param bindHeight the pqRegistryHash schedule height that makes this registry active + * @param count the number of rows in the registry + * @param index the row index + * @param address the 20-byte validator address of the row + * @param publicKey the Falcon public key of the row + * @return the pre-image bytes + */ + public static byte[] bindingPreimage( + final long chainId, + final long bindHeight, + final int count, + final int index, + final byte[] address, + final byte[] publicKey) { + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + writeAll(out, DOMAIN_POSSESSION.getBytes(StandardCharsets.US_ASCII)); + out.write(FORMAT_VERSION); + writeAll(out, uint64be(chainId)); + writeAll(out, uint64be(bindHeight)); + writeAll(out, uint32be(count)); + writeAll(out, uint32be(index)); + writeAll(out, address); + writeAll(out, uint32be(publicKey.length)); + writeAll(out, publicKey); + return out.toByteArray(); + } + + /** + * The 32-byte message the row's FALCON key signs. + * + * @param chainId the chain id + * @param bindHeight the activation height of this registry + * @param count the registry size + * @param index the row index + * @param address the row's validator address + * @param publicKey the row's Falcon public key + * @return the possession digest + */ + public static Bytes32 possessionDigest( + final long chainId, + final long bindHeight, + final int count, + final int index, + final byte[] address, + final byte[] publicKey) { + return Bytes32.wrap( + keccak(bindingPreimage(chainId, bindHeight, count, index, address, publicKey))); + } + + /** + * The 32-byte message the row's VALIDATOR ECDSA key signs: the EIP-191 version-0 envelope around + * the same pre-image. The leading {@code 0x19} makes the claim unrepresentable as a transaction. + * + * @param chainId the chain id + * @param bindHeight the activation height of this registry + * @param count the registry size + * @param index the row index + * @param address the row's validator address + * @param publicKey the row's Falcon public key + * @return the claim digest + */ + public static Bytes32 claimDigest( + final long chainId, + final long bindHeight, + final int count, + final int index, + final byte[] address, + final byte[] publicKey) { + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + out.write(0x19); + out.write(0x00); + writeAll(out, claimContext(chainId)); + writeAll(out, bindingPreimage(chainId, bindHeight, count, index, address, publicKey)); + return Bytes32.wrap(keccak(out.toByteArray())); + } + + /** + * The 20-byte EIP-191 version-0 "intended validator" field for this chain's registry claims. + * + * @param chainId the chain id + * @return 20 context bytes + */ + public static byte[] claimContext(final long chainId) { + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + writeAll(out, DOMAIN_CLAIM.getBytes(StandardCharsets.US_ASCII)); + writeAll(out, uint64be(chainId)); + final byte[] d = keccak(out.toByteArray()); + final byte[] ctx = new byte[20]; + System.arraycopy(d, 12, ctx, 0, 20); + return ctx; + } + + // =================================================================================== + // Verification of one row + // =================================================================================== + + /** + * Verify a Falcon possession proof. Never throws. + * + * @param publicKey the row's raw Falcon-512 public key + * @param digest the possession digest + * @param signature the Falcon signature bytes + * @return true iff the signature verifies under that key + */ + public static boolean verifyPossession( + final byte[] publicKey, final Bytes32 digest, final byte[] signature) { + if (publicKey == null || digest == null || signature == null || signature.length == 0) { + return false; + } + try { + final FalconPublicKeyParameters pub = + new FalconPublicKeyParameters(FalconParameters.falcon_512, publicKey); + final FalconSigner verifier = new FalconSigner(); + verifier.init(false, pub); + return verifier.verifySignature(digest.toArray(), signature); + } catch (final RuntimeException e) { + LOG.debug("AERE PQC REGISTRY-BINDING: Falcon possession verify threw: {}", e.toString()); + return false; + } + } + + /** + * Recover the address that produced an ECDSA claim. Never throws. + * + * @param digest the claim digest + * @param signature 65 bytes, r || s || recId + * @return the recovered address, or null when the signature is malformed or unrecoverable + */ + public static Address recoverClaim(final Bytes32 digest, final byte[] signature) { + if (digest == null || signature == null || signature.length != CLAIM_SIGNATURE_LENGTH) { + return null; + } + try { + final SECPSignature s = + SignatureAlgorithmFactory.getInstance().decodeSignature(Bytes.wrap(signature)); + return Util.signatureToAddress(s, Hash.wrap(digest)); + } catch (final RuntimeException e) { + LOG.debug("AERE PQC REGISTRY-BINDING: claim recovery threw: {}", e.toString()); + return null; + } + } + + // =================================================================================== + // Verification of a whole registry, fail-closed + // =================================================================================== + + /** + * Verify every binding proof in a proof-bound registry, and REFUSE on the first row that does not + * hold. Called from {@code PqRegistryHash.assemble}, so it runs on every load, on every node, at + * every restart, and not merely once at the key ceremony. + * + *

Fail-closed is the whole point and it is not a preference. The neighbouring defect recorded + * on 2026-08-06 is a configuration path that fails OPEN: a misplaced comma drops the schedule, the + * threshold falls to zero, and the node starts DISARMED with nothing visible anywhere. A binding + * check that logged and continued would be the same mistake with a different name. + * + * @param registry the loaded registry + * @throws PqRegistryHash.RegistryConfigException when any row's binding does not hold + */ + public static void verifyOrThrow(final PqRegistryHash.Registry registry) { + if (!registry.proofBound()) { + return; + } + final String source = registry.sourcePath(); + if (!registry.addressBound()) { + throw new PqRegistryHash.RegistryConfigException( + "AERE-PQC-REG-BIND-06", + "AERE PQC REGISTRY-BINDING: registry '" + + source + + "' carries binding proofs but is NOT address-bound. A proof binds a Falcon key to a " + + "validator ADDRESS; with no addresses there is nothing to bind to. Refusing " + + "(fail-closed)."); + } + final long chainId = registry.declaredChainId(); + final long bindHeight = registry.bindHeight(); + final int count = registry.count(); + final List entries = registry.entries(); + + for (final PqRegistryHash.Entry e : entries) { + final byte[] addr = e.address(); + final byte[] pk = e.publicKey(); + final byte[] pop = e.possessionProof(); + final byte[] claim = e.claimProof(); + + if (pop == null || pop.length == 0) { + throw new PqRegistryHash.RegistryConfigException( + "AERE-PQC-REG-BIND-01", + "AERE PQC REGISTRY-BINDING: registry '" + + source + + "' index " + + e.index() + + " has NO possession proof while other rows have one. Binding must be all or " + + "nothing: one unproven row is a row whose key nobody has shown they hold, and it " + + "counts toward the threshold exactly like a proven one. Refusing (fail-closed)."); + } + if (claim == null || claim.length == 0) { + throw new PqRegistryHash.RegistryConfigException( + "AERE-PQC-REG-BIND-02", + "AERE PQC REGISTRY-BINDING: registry '" + + source + + "' index " + + e.index() + + " has NO validator claim. The claim is the half that stops a key being filed " + + "under somebody else's address. Refusing (fail-closed)."); + } + + final Bytes32 popDigest = + possessionDigest(chainId, bindHeight, count, e.index(), addr, pk); + if (!verifyPossession(pk, popDigest, pop)) { + throw new PqRegistryHash.RegistryConfigException( + "AERE-PQC-REG-BIND-03", + "AERE PQC REGISTRY-BINDING: registry '" + + source + + "' index " + + e.index() + + " (address 0x" + + hex(addr) + + ") carries a possession proof that DOES NOT VERIFY under the public key on the " + + "same row, for chainId=" + + chainId + + " bindHeight=" + + bindHeight + + " count=" + + count + + ". Either the key was replaced without re-signing, or the row was lifted from " + + "another index, another height or another chain. Refusing (fail-closed)."); + } + + final Bytes32 clDigest = claimDigest(chainId, bindHeight, count, e.index(), addr, pk); + final Address recovered = recoverClaim(clDigest, claim); + if (recovered == null) { + throw new PqRegistryHash.RegistryConfigException( + "AERE-PQC-REG-BIND-05", + "AERE PQC REGISTRY-BINDING: registry '" + + source + + "' index " + + e.index() + + " carries a claim of " + + claim.length + + " bytes that cannot be decoded as an ECDSA signature (expected exactly " + + CLAIM_SIGNATURE_LENGTH + + ": r||s||recId). Refusing (fail-closed)."); + } + final Address bound = Address.wrap(Bytes.wrap(addr)); + if (!recovered.equals(bound)) { + throw new PqRegistryHash.RegistryConfigException( + "AERE-PQC-REG-BIND-04", + "AERE PQC REGISTRY-BINDING: registry '" + + source + + "' index " + + e.index() + + " is bound to validator " + + bound + + " but its claim was signed by " + + recovered + + ". THIS IS THE DEFECT THIS CHECK EXISTS FOR: the Falcon key on this row was filed " + + "under an address whose owner did not sign for it, so every seal made with that " + + "key would be credited to the wrong validator - and if the same key sits at two " + + "indices, a single key holder alone satisfies the quorum threshold. Refusing " + + "(fail-closed). WHAT TO DO: have validator " + + bound + + " sign the row with its own consensus key, or put the key back under the address " + + "that did sign it."); + } + } + LOG.info( + "AERE PQC REGISTRY-BINDING: registry '{}' - all {} rows carry a verified Falcon possession " + + "proof and a verified validator claim (chainId={}, bindHeight={}).", + source, + count, + chainId, + bindHeight); + } + + // =================================================================================== + // Local helpers. Same shape as PqRegistryHash's, deliberately, so the two files can be read + // against each other without holding two conventions in your head. + // =================================================================================== + + private static void writeAll(final ByteArrayOutputStream out, final byte[] b) { + out.write(b, 0, b.length); + } + + private static byte[] uint32be(final int v) { + return new byte[] {(byte) (v >>> 24), (byte) (v >>> 16), (byte) (v >>> 8), (byte) v}; + } + + private static byte[] uint64be(final long v) { + final byte[] b = new byte[8]; + for (int i = 0; i < 8; i++) { + b[i] = (byte) (v >>> (56 - 8 * i)); + } + return b; + } + + private static byte[] keccak(final byte[] input) { + final KeccakDigest kd = new KeccakDigest(256); + kd.update(input, 0, input.length); + final byte[] d = new byte[32]; + kd.doFinal(d, 0); + return d; + } + + private static String hex(final byte[] b) { + final StringBuilder s = new StringBuilder(b.length * 2); + for (final byte x : b) { + s.append(Character.forDigit((x >> 4) & 0xf, 16)).append(Character.forDigit(x & 0xf, 16)); + } + return s.toString(); + } +} diff --git a/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqRegistryHash.java b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqRegistryHash.java new file mode 100644 index 0000000..6b28572 --- /dev/null +++ b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqRegistryHash.java @@ -0,0 +1,2341 @@ +/* + * Copyright contributors to Besu. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.Properties; +import java.util.TreeMap; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.base.Splitter; +import org.bouncycastle.crypto.digests.KeccakDigest; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * AERE GENESIS BINDING: bind the Falcon validator-index-to-public-key REGISTRY to consensus. + * + *

THE DEFECT THIS EXISTS TO CLOSE. Measured by reading {@code FalconSealSupport}: the registry + * that answers "which Falcon public key is validator index i" can be loaded from a plain local + * properties FILE named by {@code aere.falcon.registry} / {@code AERE_FALCON_REGISTRY}. Nothing ties + * that file to the chain. Two nodes handed two different files disagree about who index i is, so the + * SAME certificate verifies on one node and fails on the other. That is a network split with no + * attacker in it at all: an operator copying the wrong file is enough. The genesis-anchored and + * late-anchor sources do check a hash, but their failure mode is to leave the registry EMPTY and log + * an error, and the process still STARTS - so a node can run for a long time believing it is a + * verifying validator while it can verify nothing. + * + *

THE FIX. A canonical hash of the registry, {@code pqRegistryHash}, is fixed in the genesis + * configuration and bound to HEIGHT. At startup a node computes the canonical hash of whatever + * registry it actually loaded and compares it with the hash the schedule makes active at its chain + * head. On a mismatch the node REFUSES TO START, and the refusal states what it found and what it + * expected, entry by entry, so an operator can find the wrong file in one read instead of diffing + * seven nodes by hand. + * + *

WHY HEIGHT-BOUND. Chain 2800 has roughly 11.8 million blocks that were produced with no + * registry binding at all. A schedule whose first entry is at height H leaves every height below H + * completely unenforced, so nothing existing is invalidated, and a later entry expresses a key + * rotation. This is the same shape as every other fork gate in this codebase: the gate is on the + * block NUMBER, never on content. + * + *

WHAT THIS CLASS DOES NOT DO, stated plainly so nobody relies on absent protection: + * + *

    + *
  • The guard runs at STARTUP. It cannot stop a node that is already running when a scheduled + * rotation height arrives underneath it. {@link #requiredHashAt(Schedule, long)} and {@link + * #matchesAt} are public precisely so a per-block header rule can enforce the same binding on + * the consensus path once such a rule exists. No such rule exists today. + *
  • It does not check that the registry is the RIGHT one in any sense other than "it hashes to + * the value the chain's genesis says it must". The genesis value is a root of trust exactly + * like the genesis hash itself. + *
  • It says nothing about whether any validator actually holds the private key matching its + * registered public key. + *
+ */ +public final class PqRegistryHash { + + private static final Logger LOG = LoggerFactory.getLogger(PqRegistryHash.class); + + /** Domain tag of the canonical v1 pre-image. Any change here is a new format and a new height. */ + public static final String DOMAIN_V1 = "AERE-PQ-REGISTRY-1"; + + /** + * Domain tag of the canonical v2 pre-image: the same registry PLUS the row binding proofs. A + * separate tag, and not a flag inside v1, so that a v1 file and a v2 file can never hash equal and + * a downgrade that strips the proofs cannot satisfy a schedule entry that was written for v2. + */ + public static final String DOMAIN_V2 = "AERE-PQ-REGISTRY-2"; + + /** + * Expected length of the Falcon-512 public key as THIS registry format stores it: the raw {@code + * h} polynomial, 512 coefficients at 14 bits, i.e. 896 bytes. MEASURED: every entry of + * {@code opt/aere-pqc/falcon-registry.properties} is 896 bytes. The 897 figure that appears in + * some notes is the standard encoding, which prepends a one-byte header; that header is NOT in + * these files, and {@code FalconSealSupport} feeds the bytes straight into + * {@code new FalconPublicKeyParameters(falcon_512, h)}, which also wants the bare {@code h}. + * + *

Used only for a diagnostic warning. It never rejects, because the canonical form is + * length-prefixed and is correct for any length. + */ + private static final int FALCON_512_PK_LENGTH = 896; + + /** Max registry entries printed in a refusal message before it is elided. */ + private static final int MAX_ENTRIES_IN_REPORT = 64; + + private PqRegistryHash() {} + + // =================================================================================== + // Registry model + // =================================================================================== + + /** Where a loaded registry came from. Part of every diagnostic, because it decides trust. */ + public enum SourceKind { + /** {@code aere.falcon.registry}: a plain local properties file. No binding of its own. */ + LEGACY_PROPERTIES, + /** {@code aere.falcon.manifest}: the address-bound JSON manifest of the late-anchor path. */ + MANIFEST_JSON, + /** {@code aere.falcon.genesis}: the {@code config.aereFalconRegistry} manifest in genesis. */ + GENESIS_MANIFEST, + /** No registry configured at all. */ + NONE + } + + /** One registry row: a validator index, its Falcon public key, and optionally its address. */ + public static final class Entry { + private final int index; + private final byte[] address; // 20 bytes, or null when the source is not address-bound + private final byte[] publicKey; + private final byte[] possessionProof; // row binding proof, or null in a v1 registry + private final byte[] claimProof; // row binding proof, or null in a v1 registry + + Entry(final int index, final byte[] address, final byte[] publicKey) { + this(index, address, publicKey, null, null); + } + + Entry( + final int index, + final byte[] address, + final byte[] publicKey, + final byte[] possessionProof, + final byte[] claimProof) { + this.index = index; + this.address = address; + this.publicKey = publicKey; + this.possessionProof = possessionProof; + this.claimProof = claimProof; + } + + /** + * The validator index this row binds. + * + * @return the index + */ + public int index() { + return index; + } + + /** + * The bound validator address, or null when the source carries public keys only. + * + * @return 20 address bytes, or null + */ + public byte[] address() { + return address == null ? null : address.clone(); + } + + /** + * The Falcon-512 public key bytes. + * + * @return the public key + */ + public byte[] publicKey() { + return publicKey.clone(); + } + + /** + * ROW BINDING. The Falcon signature by this row's own key over the binding pre-image, proving somebody + * holds the matching secret. + * + * @return the possession proof, or null in a v1 registry + */ + public byte[] possessionProof() { + return possessionProof == null ? null : possessionProof.clone(); + } + + /** + * ROW BINDING. The ECDSA signature by this row's own VALIDATOR key over the binding pre-image. + * This is the half a registry writer cannot forge, and therefore the half that closes both + * rebinding a row to another validator's address and swapping two rows. + * + * @return the claim proof, or null in a v1 registry + */ + public byte[] claimProof() { + return claimProof == null ? null : claimProof.clone(); + } + } + + /** + * A loaded registry in canonical order, with the provenance needed to explain a refusal. + * + *

Loading is STRICT and fail-closed. A registry whose shape is ambiguous has no well defined + * hash, and hashing an ambiguous object would produce a number that looks authoritative and means + * nothing. Every loader therefore rejects rather than guesses: {@code count} must be present, + * indices must be exactly {@code 0..count-1} with no gap and no extra, address binding must be + * all-or-nothing, and hex must be even-length and strictly hexadecimal. + */ + public static final class Registry { + private final SourceKind kind; + private final String sourcePath; + private final List entries; // ascending index, contiguous from 0 + private final boolean addressBound; + private final boolean proofBound; // every row carries both binding proofs + private final long declaredChainId; // the chainId the proofs were signed over + private final long bindHeight; // the activation height the proofs were signed over + + Registry( + final SourceKind kind, + final String sourcePath, + final List entries, + final boolean addressBound) { + this(kind, sourcePath, entries, addressBound, false, -1L, -1L); + } + + Registry( + final SourceKind kind, + final String sourcePath, + final List entries, + final boolean addressBound, + final boolean proofBound, + final long declaredChainId, + final long bindHeight) { + this.kind = kind; + this.sourcePath = sourcePath; + this.entries = entries; + this.addressBound = addressBound; + this.proofBound = proofBound; + this.declaredChainId = declaredChainId; + this.bindHeight = bindHeight; + } + + /** + * Where this registry was loaded from. + * + * @return the source kind + */ + public SourceKind kind() { + return kind; + } + + /** + * The file this registry was read from. + * + * @return the path as configured, or "(none)" + */ + public String sourcePath() { + return sourcePath == null ? "(none)" : sourcePath; + } + + /** + * Number of registry rows. + * + * @return the entry count + */ + public int count() { + return entries.size(); + } + + /** + * The rows, ascending by index. + * + * @return an unmodifiable list + */ + public List entries() { + return List.copyOf(entries); + } + + /** + * Whether every row carries a validator address. + * + * @return true iff address-bound + */ + public boolean addressBound() { + return addressBound; + } + + /** + * ROW BINDING. Whether every row carries a verified Falcon possession proof and a verified validator + * claim. False for every registry written before 2026-08-06. + * + * @return true iff proof-bound + */ + public boolean proofBound() { + return proofBound; + } + + /** + * ROW BINDING. The chain id the binding proofs were signed over, as the FILE declares it. Cross-checked + * against the node's real chain id by {@link #verifyOrAbort}: a registry lifted from the scratch + * chain carries proofs that verify perfectly among themselves and belong to another chain. + * + * @return the declared chain id, or -1 when not proof-bound + */ + public long declaredChainId() { + return declaredChainId; + } + + /** + * ROW BINDING. The pqRegistryHash schedule height the binding proofs were signed over, so a row + * retired at one rotation cannot be replayed into a later registry. + * + * @return the bind height, or -1 when not proof-bound + */ + public long bindHeight() { + return bindHeight; + } + } + + // =================================================================================== + // Canonical encodings + // =================================================================================== + + /** + * The canonical v1 pre-image: domain-separated, length-prefixed, chain-bound. + * + *

+   *   "AERE-PQ-REGISTRY-1"          18 bytes, ASCII
+   *   uint64be(chainId)              8 bytes
+   *   uint32be(count)                4 bytes
+   *   for i in 0..count-1:
+   *       uint32be(i)                4 bytes
+   *       uint8(addressBound?1:0)    1 byte
+   *       address                   20 bytes, only when address-bound
+   *       uint32be(len(publicKey))   4 bytes
+   *       publicKey                 len bytes
+   * 
+ * + *

Three properties this buys, and each one is the answer to a specific way the older + * concatenation form could be argued with: + * + *

    + *
  • LENGTH PREFIXES. The legacy form concatenates raw keys with no separator. It is + * unambiguous only because Falcon-512 public keys happen to be a fixed 897 bytes. "Happens + * to be" is not a proof, and a future parameter set or a truncated key would silently make + * two different registries hash the same. With a length prefix in front of every variable + * field that argument cannot be made at all. + *
  • THE INDEX IS IN THE PRE-IMAGE. Not just the order. A registry that permutes two rows + * hashes differently even under a hash function that ignored order. + *
  • chainId IS IN THE PRE-IMAGE. The scratch chain 442807 runs the same binaries and can run + * the same Falcon keys; without chainId a registry lifted from there would hash equal here. + *
+ * + * @param registry the loaded registry + * @param chainId the chain id this registry is being bound to + * @return the pre-image bytes + */ + public static byte[] canonicalPreimageV1(final Registry registry, final long chainId) { + final java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream(); + writeAll(out, DOMAIN_V1.getBytes(StandardCharsets.US_ASCII)); + writeAll(out, uint64be(chainId)); + writeAll(out, uint32be(registry.count())); + for (final Entry e : registry.entries) { + writeAll(out, uint32be(e.index)); + out.write(registry.addressBound ? 1 : 0); + if (registry.addressBound) { + writeAll(out, e.address); + } + writeAll(out, uint32be(e.publicKey.length)); + writeAll(out, e.publicKey); + } + return out.toByteArray(); + } + + /** + * keccak256 of the canonical v1 pre-image, as 64-hex with no {@code 0x}. + * + * @param registry the loaded registry + * @param chainId the chain id this registry is bound to + * @return the canonical hash, lower-case, unprefixed + */ + public static String hashV1(final Registry registry, final long chainId) { + return keccakHex(canonicalPreimageV1(registry, chainId)); + } + + /** + * ROW BINDING. The canonical v2 pre-image: everything v1 commits to, plus the activation height and the + * two binding proofs of every row. + * + *
+   *   "AERE-PQ-REGISTRY-2"          18 bytes, ASCII
+   *   uint64be(chainId)              8 bytes
+   *   uint64be(bindHeight)           8 bytes
+   *   uint32be(count)                4 bytes
+   *   for i in 0..count-1:
+   *       uint32be(i)                4 bytes
+   *       uint8(1)                   1 byte, address binding is mandatory in v2
+   *       address                   20 bytes
+   *       uint32be(len(publicKey))   4 bytes
+   *       publicKey                 len bytes
+   *       uint32be(len(possession))  4 bytes
+   *       possession                len bytes
+   *       uint32be(len(claim))       4 bytes
+   *       claim                     len bytes
+   * 
+ * + *

WHY THE PROOFS ARE INSIDE THE HASH and not merely checked at load. The hash is the value that + * genesis anchors and, at arming, that the immutable anchor contract pins. If the proofs sat + * outside it, they would be advisory: a node could be handed the same registry with the proof + * fields deleted, it would hash the same, satisfy the schedule, and load without ever verifying + * anything. With them inside, stripping a proof is a different registry with a different hash and + * the existing genesis-binding guard refuses it. That is also why v2 has its OWN domain tag: a v1 file cannot + * collide with a v2 schedule entry, so a format downgrade is refused by machinery that already + * exists rather than by a new rule that could be forgotten. + * + * @param registry the loaded registry, which must be proof-bound + * @param chainId the chain id this registry is being bound to + * @return the pre-image bytes + */ + public static byte[] canonicalPreimageV2(final Registry registry, final long chainId) { + final java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream(); + writeAll(out, DOMAIN_V2.getBytes(StandardCharsets.US_ASCII)); + writeAll(out, uint64be(chainId)); + writeAll(out, uint64be(registry.bindHeight())); + writeAll(out, uint32be(registry.count())); + for (final Entry e : registry.entries) { + writeAll(out, uint32be(e.index)); + out.write(1); + writeAll(out, e.address); + writeAll(out, uint32be(e.publicKey.length)); + writeAll(out, e.publicKey); + writeAll(out, uint32be(e.possessionProof.length)); + writeAll(out, e.possessionProof); + writeAll(out, uint32be(e.claimProof.length)); + writeAll(out, e.claimProof); + } + return out.toByteArray(); + } + + /** + * ROW BINDING. keccak256 of the canonical v2 pre-image, as 64-hex with no {@code 0x}. + * + * @param registry the loaded registry, which must be proof-bound + * @param chainId the chain id this registry is bound to + * @return the canonical v2 hash, lower-case, unprefixed + */ + public static String hashV2(final Registry registry, final long chainId) { + return keccakHex(canonicalPreimageV2(registry, chainId)); + } + + /** + * ROW BINDING. The canonical hash OF THIS REGISTRY: v2 when it carries binding proofs, v1 when it does + * not. Every comparison against a schedule entry goes through here, so a proof-bound registry is + * compared as v2 everywhere and a legacy one keeps exactly the number it had before this change. + * + * @param registry the loaded registry + * @param chainId the chain id this registry is bound to + * @return the canonical hash for this registry's format + */ + public static String hashFor(final Registry registry, final long chainId) { + return registry.proofBound() ? hashV2(registry, chainId) : hashV1(registry, chainId); + } + + /** + * ROW BINDING. The ARMING precondition: refuse to arm the anchor over a registry whose rows are not + * bound to their validator addresses by signatures. + * + *

The shape is deliberately the same as {@code AERE-PQC-REG-ARM-01}, which already refuses to + * arm over a registry with no validator addresses at all, and for the same reason: arming is the + * last moment at which the registry format can still be changed. The anchor contract is immutable + * once written, so a fleet armed over unbound rows carries that defect for the life of the chain. + * + *

WIRED 2026-08-06. It is called from {@code + * FalconSealSupport.requireRegistryBindingProofsOrAbort()}, in the constructor, immediately after + * {@code armingReadinessDiagnostic()} raises AERE-PQC-REG-ARM-01, and only when this node is armed + * (a configured {@code aere.falcon.forkBlock}, or an active certificate anchor). Until 2026-08-06 + * this javadoc read "NOT WIRED YET", and while that was true a v1 registry with no proofs loaded + * on an armed node exactly as before. + * + * @param registry the registry the fleet is about to arm over + * @throws RegistryConfigException when the registry carries no binding proofs + */ + public static void requireBindingsOrThrow(final Registry registry) { + if (registry != null && registry.proofBound()) { + return; + } + throw new RegistryConfigException( + "AERE-PQC-REG-ARM-02", + "AERE PQC ROW-BINDING: REFUSING TO ARM (fail-closed) over the registry " + + (registry == null ? "(none)" : "'" + registry.sourcePath() + "'") + + ", which carries NO binding proofs. Nothing in such a registry connects a Falcon " + + "public key to the validator address on the same row, so whoever writes the file " + + "decides who a seal is credited to, and one key placed at two indices satisfies the " + + "quorum threshold on its own. Measured on the real verification path 2026-08-06: a " + + "registry with two rows' keys swapped, carrying no duplicate key and no duplicate " + + "address, produced an ACCEPTED header. WHAT TO DO: rebuild the registry in the v2 " + + "format, where every row carries a Falcon possession proof and an ECDSA claim signed " + + "by that validator's own consensus key, and set config.pqRegistryHash to its v2 hash."); + } + + /** + * keccak256 of the LEGACY pre-image, byte-for-byte what {@code FalconSealSupport.parseManifest} + * already computes: the concatenation, in ascending index order, of {@code address||publicKey} + * when address-bound and {@code publicKey} alone otherwise. + * + *

Kept so that a {@code pqRegistryHash} can be cross-checked against the manifest hash already + * committed in the genesis {@code alloc} anchor slot, and so an operator can tell which of the two + * numbers they are holding. It is NOT the value {@code pqRegistryHash} is compared against: it has + * no domain tag, no chain binding and no length prefixes. + * + * @param registry the loaded registry + * @return the legacy hash, lower-case, unprefixed + */ + public static String hashV0Legacy(final Registry registry) { + final java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream(); + for (final Entry e : registry.entries) { + if (registry.addressBound) { + writeAll(out, e.address); + } + writeAll(out, e.publicKey); + } + return keccakHex(out.toByteArray()); + } + + /** + * A short, safe fingerprint of one row: the first 8 hex of {@code keccak256(address||publicKey)}. + * + *

This is the field that turns a refusal into a five-second diagnosis. Two operators comparing + * refusal messages from two nodes see immediately WHICH index differs, without moving any key + * material and without either of them having to trust the other's file. + * + * @param registry the loaded registry + * @param index the row index + * @return an 8-hex fingerprint, or "(absent)" when the index is not present + */ + public static String fingerprint(final Registry registry, final int index) { + for (final Entry e : registry.entries) { + if (e.index == index) { + final java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream(); + if (e.address != null) { + writeAll(out, e.address); + } + writeAll(out, e.publicKey); + return keccakHex(out.toByteArray()).substring(0, 8); + } + } + return "(absent)"; + } + + // =================================================================================== + // Loaders. All STRICT, all fail-closed. + // =================================================================================== + + /** + * Load the LEGACY properties registry: {@code count=N} plus rows {@code i=}, and + * optionally {@code i.addr=<20-byte hex>} to make it address-bound. + * + *

Comments, blank lines, line order and surrounding whitespace do not affect the canonical + * hash: the hash is computed over the DECODED registry, not the file bytes. That is deliberate. + * Hashing the file bytes would make a node refuse to start because somebody added a comment, and a + * guard that fires on nothing real is a guard operators learn to switch off. + * + * @param path the properties file + * @return the loaded registry + * @throws RegistryConfigException on any ambiguity + */ + public static Registry loadPropertiesRegistry(final Path path) { + final Properties p = new Properties(); + try (InputStream in = Files.newInputStream(path)) { + p.load(in); + } catch (final IOException e) { + throw new RegistryConfigException( + "AERE-PQC-REG-LOAD-01", + "AERE PQC GENESIS-BINDING: cannot read the Falcon registry file '" + + path + + "': " + + e + + ". Refusing to continue (fail-closed): a node that cannot read its registry cannot " + + "verify a single Falcon seal, and starting it anyway would put an unverifying node " + + "into the validator set while it reports itself healthy."); + } + + final String countRaw = p.getProperty("count"); + if (countRaw == null) { + throw new RegistryConfigException( + "AERE-PQC-REG-LOAD-02", + "AERE PQC GENESIS-BINDING: the Falcon registry '" + + path + + "' has no 'count' property. Refusing to continue (fail-closed): without a declared " + + "count there is no way to tell a complete registry from one that lost its last " + + "lines in transit, and both would hash to something that looks valid. Add " + + "'count='."); + } + final int count = parsePositiveInt(countRaw, "count", path.toString()); + + // AERE ROW BINDING. Header fields of the v2 format. A v1 node reading a v2 file does NOT silently + // ignore these: parseIndexOrThrow refuses an unrecognised key, so an old binary handed a bound + // registry REFUSES rather than loading it with the proofs dropped. That is the correct + // direction of failure and it is why the fields are plain top-level names. + final long declaredChainId = parseOptionalLong(p.getProperty("chainId"), "chainId", path.toString()); + final long bindHeight = parseOptionalLong(p.getProperty("bindHeight"), "bindHeight", path.toString()); + final int formatVersion = + (int) parseOptionalLong(p.getProperty("formatVersion"), "formatVersion", path.toString()); + + final TreeMap pks = new TreeMap<>(); + final TreeMap addrs = new TreeMap<>(); + final TreeMap pops = new TreeMap<>(); + final TreeMap claims = new TreeMap<>(); + for (final String name : p.stringPropertyNames()) { + if ("count".equals(name) + || "chainId".equals(name) + || "bindHeight".equals(name) + || "formatVersion".equals(name)) { + continue; + } + final String key = name.trim(); + if (key.endsWith(".pop")) { + final int i = + parseIndexOrThrow(key.substring(0, key.length() - ".pop".length()), path.toString()); + pops.put(i, strictHex(p.getProperty(name).trim(), "entry " + i + ".pop", path.toString())); + } else if (key.endsWith(".claim")) { + final int i = + parseIndexOrThrow(key.substring(0, key.length() - ".claim".length()), path.toString()); + claims.put( + i, strictHex(p.getProperty(name).trim(), "entry " + i + ".claim", path.toString())); + } else if (key.endsWith(".addr")) { + final int i = + parseIndexOrThrow(key.substring(0, key.length() - ".addr".length()), path.toString()); + final byte[] a = strictHex(p.getProperty(name).trim(), "entry " + i + ".addr", path.toString()); + if (a.length != 20) { + throw new RegistryConfigException( + "AERE-PQC-REG-LOAD-03", + "AERE PQC GENESIS-BINDING: registry '" + + path + + "' entry " + + i + + ".addr is " + + a.length + + " bytes, expected exactly 20. Refusing to continue (fail-closed)."); + } + addrs.put(i, a); + } else { + final int i = parseIndexOrThrow(key, path.toString()); + pks.put(i, strictHex(p.getProperty(name).trim(), "entry " + i, path.toString())); + } + } + return assemble( + SourceKind.LEGACY_PROPERTIES, + path.toString(), + count, + pks, + addrs, + pops, + claims, + declaredChainId, + bindHeight, + formatVersion); + } + + /** + * Load an address-bound JSON manifest: {@code {"count": N, "0": {"addr": "0x..", "pk": ".."}, + * ...}}, or the legacy bare-string form {@code {"count": N, "0": "", ...}}. + * + * @param path the manifest file + * @return the loaded registry + * @throws RegistryConfigException on any ambiguity + */ + public static Registry loadManifestJson(final Path path) { + return fromManifestNode(readJson(path), SourceKind.MANIFEST_JSON, path.toString()); + } + + /** + * Load the manifest embedded in a genesis file at {@code config.aereFalconRegistry}. + * + * @param genesisPath the genesis file + * @return the loaded registry + * @throws RegistryConfigException on any ambiguity + */ + public static Registry loadGenesisManifest(final Path genesisPath) { + final JsonNode cfg = readJson(genesisPath).path("config").path("aereFalconRegistry"); + if (cfg.isMissingNode()) { + throw new RegistryConfigException( + "AERE-PQC-REG-LOAD-04", + "AERE PQC GENESIS-BINDING: genesis '" + + genesisPath + + "' has no config.aereFalconRegistry manifest. Refusing to continue (fail-closed)."); + } + return fromManifestNode(cfg, SourceKind.GENESIS_MANIFEST, genesisPath.toString()); + } + + /** + * Sniff the file and load it with the right loader: a genesis file if it has + * {@code config.aereFalconRegistry}, a bare manifest if it is JSON, otherwise properties. + * + * @param path any registry file + * @return the loaded registry + */ + public static Registry loadAuto(final Path path) { + final byte[] raw; + try { + raw = Files.readAllBytes(path); + } catch (final IOException e) { + throw new RegistryConfigException( + "AERE-PQC-REG-LOAD-01", + "AERE PQC GENESIS-BINDING: cannot read '" + path + "': " + e + ". Refusing to continue (fail-closed)."); + } + int i = 0; + while (i < raw.length && Character.isWhitespace((char) (raw[i] & 0xff))) { + i++; + } + if (i < raw.length && raw[i] == '{') { + final JsonNode root = readJson(path); + if (!root.path("config").path("aereFalconRegistry").isMissingNode()) { + return loadGenesisManifest(path); + } + return fromManifestNode(root, SourceKind.MANIFEST_JSON, path.toString()); + } + return loadPropertiesRegistry(path); + } + + private static Registry fromManifestNode( + final JsonNode cfg, final SourceKind kind, final String source) { + if (cfg == null || cfg.isMissingNode() || !cfg.has("count")) { + throw new RegistryConfigException( + "AERE-PQC-REG-LOAD-05", + "AERE PQC GENESIS-BINDING: manifest '" + source + "' has no 'count'. Refusing (fail-closed)."); + } + final int count = parsePositiveInt(cfg.get("count").asText(), "count", source); + + // Every numeric field present must be inside 0..count-1. + // + // FOUND BY THE PLANTED-FAILURE HARNESS, case L1, in the first run of this code: without this + // check a manifest that declares count=6 while carrying 7 rows loaded happily as a 6-row + // registry, because the loop below only ever asks for indices 0..count-1 and never looks at + // what else is in the file. That is precisely the failure this whole class exists to prevent - + // a registry that is quietly not the file the operator thinks it is - reintroduced one level + // down. Two nodes with the same file but different 'count' would have hashed differently. + final List strays = new ArrayList<>(); + final java.util.Iterator names = cfg.fieldNames(); + while (names.hasNext()) { + final String n = names.next(); + if ("count".equals(n) + || "chainId".equals(n) + || "bindHeight".equals(n) + || "formatVersion".equals(n)) { + continue; + } + final int idx; + try { + idx = Integer.parseInt(n.trim()); + } catch (final NumberFormatException e) { + throw new RegistryConfigException( + "AERE-PQC-REG-LOAD-18", + "AERE PQC GENESIS-BINDING: manifest '" + + source + + "' has the unrecognised field '" + + n + + "'. Every field must be 'count' or a validator index. Refusing (fail-closed)."); + } + if (idx < 0 || idx >= count) { + strays.add(n); + } + } + if (!strays.isEmpty()) { + throw new RegistryConfigException( + "AERE-PQC-REG-LOAD-09", + "AERE PQC GENESIS-BINDING: manifest '" + + source + + "' declares count=" + + count + + " but also carries entr" + + (strays.size() == 1 ? "y " : "ies ") + + strays + + ", outside 0.." + + (count - 1) + + ". Refusing (fail-closed): those rows would have been SILENTLY DROPPED and the " + + "remainder would still have hashed to a perfectly valid-looking value."); + } + + final long declaredChainId = + cfg.has("chainId") ? parseOptionalLong(cfg.get("chainId").asText(), "chainId", source) : -1L; + final long bindHeight = + cfg.has("bindHeight") + ? parseOptionalLong(cfg.get("bindHeight").asText(), "bindHeight", source) + : -1L; + final int formatVersion = + cfg.has("formatVersion") + ? (int) parseOptionalLong(cfg.get("formatVersion").asText(), "formatVersion", source) + : -1; + + final TreeMap pks = new TreeMap<>(); + final TreeMap addrs = new TreeMap<>(); + final TreeMap pops = new TreeMap<>(); + final TreeMap claims = new TreeMap<>(); + for (int i = 0; i < count; i++) { + final JsonNode e = cfg.get(Integer.toString(i)); + if (e == null) { + throw new RegistryConfigException( + "AERE-PQC-REG-LOAD-06", + "AERE PQC GENESIS-BINDING: manifest '" + + source + + "' declares count=" + + count + + " but has no entry '" + + i + + "'. Refusing (fail-closed)."); + } + if (e.isObject()) { + final JsonNode a = e.get("addr"); + final JsonNode k = e.get("pk"); + if (a == null || !a.isTextual() || k == null || !k.isTextual()) { + throw new RegistryConfigException( + "AERE-PQC-REG-LOAD-07", + "AERE PQC GENESIS-BINDING: manifest '" + + source + + "' entry " + + i + + " is an object but lacks textual 'addr' and 'pk'. Refusing (fail-closed)."); + } + final byte[] addr = strictHex(a.asText().trim(), "entry " + i + ".addr", source); + if (addr.length != 20) { + throw new RegistryConfigException( + "AERE-PQC-REG-LOAD-03", + "AERE PQC GENESIS-BINDING: manifest '" + + source + + "' entry " + + i + + " addr is " + + addr.length + + " bytes, expected 20. Refusing (fail-closed)."); + } + addrs.put(i, addr); + pks.put(i, strictHex(k.asText().trim(), "entry " + i + ".pk", source)); + final JsonNode pop = e.get("pop"); + final JsonNode claim = e.get("claim"); + if (pop != null && pop.isTextual()) { + pops.put(i, strictHex(pop.asText().trim(), "entry " + i + ".pop", source)); + } + if (claim != null && claim.isTextual()) { + claims.put(i, strictHex(claim.asText().trim(), "entry " + i + ".claim", source)); + } + } else if (e.isTextual()) { + pks.put(i, strictHex(e.asText().trim(), "entry " + i, source)); + } else { + throw new RegistryConfigException( + "AERE-PQC-REG-LOAD-08", + "AERE PQC GENESIS-BINDING: manifest '" + + source + + "' entry " + + i + + " is neither an object nor a hex string. Refusing (fail-closed)."); + } + } + return assemble( + kind, source, count, pks, addrs, pops, claims, declaredChainId, bindHeight, formatVersion); + } + + /** + * MEASURED 2026-08-02, on the first build of this class INSIDE Besu. These two parameters were + * declared {@code TreeMap}. Besu compiles with Error Prone's {@code NonApiType} check and {@code + * -Werror}, so a concrete collection type in a method signature is a build FAILURE, not a style + * note. This file had been type-checked standalone with {@code javac -Xlint:all} and reported + * clean; that is a weaker statement than it sounds, and the difference is the whole reason the + * wiring had to be compiled in the real tree before this could be called closed. {@code + * NavigableMap} keeps the guarantee the code actually relies on, which is ascending key order. + */ + private static Registry assemble( + final SourceKind kind, + final String source, + final int count, + final java.util.NavigableMap pks, + final java.util.NavigableMap addrs, + final java.util.NavigableMap pops, + final java.util.NavigableMap claims, + final long declaredChainId, + final long bindHeight, + final int declaredFormatVersion) { + + if (pks.size() != count) { + throw new RegistryConfigException( + "AERE-PQC-REG-LOAD-09", + "AERE PQC GENESIS-BINDING: registry '" + + source + + "' declares count=" + + count + + " but carries " + + pks.size() + + " public-key entr" + + (pks.size() == 1 ? "y" : "ies") + + " (indices present: " + + pks.keySet() + + "). Refusing (fail-closed): a registry whose declared size and real size disagree " + + "has no canonical form, so any hash of it would be meaningless."); + } + for (int i = 0; i < count; i++) { + if (!pks.containsKey(i)) { + throw new RegistryConfigException( + "AERE-PQC-REG-LOAD-10", + "AERE PQC GENESIS-BINDING: registry '" + + source + + "' is missing index " + + i + + "; indices must be exactly 0.." + + (count - 1) + + " with no gap. Indices present: " + + pks.keySet() + + ". Refusing (fail-closed)."); + } + if (pks.get(i).length == 0) { + throw new RegistryConfigException( + "AERE-PQC-REG-LOAD-11", + "AERE PQC GENESIS-BINDING: registry '" + source + "' index " + i + " has an EMPTY public key. " + + "Refusing (fail-closed)."); + } + } + final boolean bound; + if (addrs.isEmpty()) { + bound = false; + } else if (addrs.size() == count) { + bound = true; + for (int i = 0; i < count; i++) { + if (!addrs.containsKey(i)) { + throw new RegistryConfigException( + "AERE-PQC-REG-LOAD-12", + "AERE PQC GENESIS-BINDING: registry '" + + source + + "' binds addresses for " + + addrs.keySet() + + " but not for index " + + i + + ". Address binding must be all or nothing. Refusing (fail-closed)."); + } + } + } else { + throw new RegistryConfigException( + "AERE-PQC-REG-LOAD-12", + "AERE PQC GENESIS-BINDING: registry '" + + source + + "' is MIXED: " + + addrs.size() + + " of " + + count + + " entries carry a validator address. Address binding must be all or nothing, " + + "otherwise the eligible-signer set is not well defined. Refusing (fail-closed)."); + } + + // =============================================================================== + // AERE ROW BINDING (2026-08-06). UNIQUENESS. Nothing here needs a signature, and it is the half that + // makes the THRESHOLD real again. + // + // MEASURED on the real verification path: a registry carrying ONE public key at TWO indices was + // loaded without a word, and two seals produced by a SINGLE Falcon private key - distinct + // signatures, because Falcon is randomised - were credited to two different validator addresses + // and satisfied a threshold of K=2. At K=3 one key holder alone would produce a certificate the + // chain calls a quorum. With distinct keys at every index, K verifying seals at K distinct + // indices are necessarily K distinct key holders again. + // + // Duplicate ADDRESSES are refused here too. Today they are already caught one layer up, by the + // `counted` set in PqAnchorSealsRule, but that is an availability trap rather than a defence: + // one address duplicated by mistake stops the chain instead of falsifying it, and it stops it at + // the activation height rather than at the moment the bad file was written. Refusing at load + // turns a fleet-wide halt into a node that will not start and says why. + final java.util.Map seenPk = new java.util.HashMap<>(); + for (int i = 0; i < count; i++) { + final String fp = keccakHex(pks.get(i)); + final Integer first = seenPk.putIfAbsent(fp, i); + if (first != null) { + throw new RegistryConfigException( + "AERE-PQC-REG-LOAD-19", + "AERE PQC ROW-BINDING: registry '" + + source + + "' carries the SAME Falcon public key at index " + + first + + " and index " + + i + + " (key digest " + + fp.substring(0, 16) + + "). Refusing (fail-closed): the anchor threshold counts DISTINCT INDICES, so one " + + "key holder sitting at two indices produces two seals that satisfy a threshold of " + + "two on their own. The threshold would stop meaning 'K validators attested' and " + + "start meaning 'K rows attested', which is not a quorum of anything."); + } + } + if (bound) { + final java.util.Map seenAddr = new java.util.HashMap<>(); + for (int i = 0; i < count; i++) { + final String a = hex(addrs.get(i)); + final Integer first = seenAddr.putIfAbsent(a, i); + if (first != null) { + throw new RegistryConfigException( + "AERE-PQC-REG-LOAD-20", + "AERE PQC ROW-BINDING: registry '" + + source + + "' binds the SAME validator address 0x" + + a + + " at index " + + first + + " and index " + + i + + ". Refusing (fail-closed): the seals rule counts each address once, so the " + + "second index can never contribute and the fleet silently loses a signer - " + + "measured as a REJECTED header at threshold K=2 with two honest signers."); + } + } + } + + // AERE ROW BINDING. FORMAT COHERENCE. Binding proofs are all-or-nothing, exactly like address binding, + // and for the same reason: one unproven row counts toward the threshold like a proven one. + final boolean anyProof = !pops.isEmpty() || !claims.isEmpty(); + final boolean proofBound; + if (anyProof || declaredFormatVersion == PqRegistryBinding.FORMAT_VERSION) { + if (pops.size() != count || claims.size() != count) { + throw new RegistryConfigException( + "AERE-PQC-REG-LOAD-21", + "AERE PQC ROW-BINDING: registry '" + + source + + "' declares count=" + + count + + " but carries " + + pops.size() + + " possession proof(s) and " + + claims.size() + + " validator claim(s). Binding must be ALL OR NOTHING. Refusing (fail-closed): a " + + "registry that is bound for some rows and unbound for others has exactly the " + + "security of its unbound rows, while looking bound."); + } + if (declaredChainId < 0 || bindHeight < 0) { + throw new RegistryConfigException( + "AERE-PQC-REG-LOAD-22", + "AERE PQC ROW-BINDING: registry '" + + source + + "' carries binding proofs but does not declare both 'chainId' and 'bindHeight'. " + + "Both are inside the signed message, so without them the proofs cannot even be " + + "checked, let alone trusted. Refusing (fail-closed)."); + } + proofBound = true; + } else { + proofBound = false; + } + + final List entries = new ArrayList<>(count); + for (int i = 0; i < count; i++) { + entries.add( + new Entry( + i, + bound ? addrs.get(i) : null, + pks.get(i), + proofBound ? pops.get(i) : null, + proofBound ? claims.get(i) : null)); + if (pks.get(i).length != FALCON_512_PK_LENGTH) { + LOG.warn( + "AERE PQC GENESIS-BINDING: registry '{}' index {} carries a {}-byte public key; Falcon-512 public " + + "keys are {} bytes. The registry is NOT rejected for this (the canonical form is " + + "length-prefixed and handles any length), but it is almost certainly the wrong " + + "file or a truncated copy.", + source, + i, + pks.get(i).length, + FALCON_512_PK_LENGTH); + } + } + final Registry assembled = + new Registry(kind, source, entries, bound, proofBound, declaredChainId, bindHeight); + // AERE ROW BINDING. THE VERIFICATION ITSELF, on the single path every loader funnels through, so it + // runs at every restart on every node and not only once at the ceremony. Fail-closed, and O(N) + // per process start with zero cost per block. + PqRegistryBinding.verifyOrThrow(assembled); + if (!proofBound) { + LOG.warn( + "AERE PQC ROW-BINDING: registry '{}' ({} rows) carries NO binding proofs. Nothing in it " + + "connects a Falcon public key to the validator address on the same row, so whoever " + + "wrote this file decided who every seal is credited to. Measured 2026-08-06 on the " + + "real verification path: swapping two rows produces an ACCEPTED header with no " + + "duplicate key and no duplicate address. This is the row-binding defect, and it " + + "is not closed on this node.", + source, + count); + } + return assembled; + } + + // =================================================================================== + // The height-bound schedule + // =================================================================================== + + /** One scheduled binding: from {@code block} onwards the registry must hash to {@code hash}. */ + public static final class ScheduleEntry { + private final long block; + private final String hash; // 64-hex, lower-case, no 0x + + ScheduleEntry(final long block, final String hash) { + this.block = block; + this.hash = hash; + } + + /** + * First height at which this binding applies. + * + * @return the block number + */ + public long block() { + return block; + } + + /** + * The required canonical registry hash. + * + * @return 64-hex, lower-case, no 0x prefix + */ + public String hash() { + return hash; + } + + @Override + public String toString() { + return "{block=" + block + ", hash=0x" + hash + "}"; + } + } + + /** The parsed {@code config.pqRegistryHash} schedule; possibly empty, meaning NOT ENFORCED. */ + public static final class Schedule { + private final List entries; // strictly increasing block + private final String source; + + Schedule(final List entries, final String source) { + this.entries = entries; + this.source = source; + } + + /** + * Whether any binding is configured at all. + * + * @return true iff at least one schedule entry exists + */ + public boolean enforced() { + return !entries.isEmpty(); + } + + /** + * The schedule entries, ascending by block. + * + * @return an unmodifiable list + */ + public List entries() { + return List.copyOf(entries); + } + + /** + * Where the schedule was read from. + * + * @return a human-readable source description + */ + public String source() { + return source; + } + + @Override + public String toString() { + return entries.toString(); + } + } + + /** An empty schedule: nothing is enforced. Preserves today's behaviour exactly. */ + public static Schedule emptySchedule() { + return new Schedule(List.of(), "(none)"); + } + + /** + * Parse {@code config.pqRegistryHash} from a genesis file. + * + *

Two accepted shapes: + * + *

+   *   "pqRegistryHash": "0x<64 hex>"                       -- bound from block 0
+   *   "pqRegistryHash": [ {"block": H,  "hash": "0x.."},
+   *                       {"block": H2, "hash": "0x.."} ]   -- height-bound, with rotation
+   * 
+ * + *

ABSENT means NOT ENFORCED, which is the only choice that leaves the 11.8 million existing + * blocks and every existing test network untouched. Absence is logged loudly, once, because a + * guard everybody believes is on and is not is worse than no guard. + * + * @param genesisPath the genesis file + * @return the parsed schedule, possibly empty + * @throws RegistryConfigException when the value is present but malformed + */ + public static Schedule loadScheduleFromGenesis(final Path genesisPath) { + final JsonNode config = readJson(genesisPath).path("config"); + return parseSchedule(config.get("pqRegistryHash"), genesisPath + " config.pqRegistryHash"); + } + + /** + * Parse a {@code pqRegistryHash} JSON value into a schedule. + * + * @param node the value, or null when absent + * @param source human-readable provenance for diagnostics + * @return the schedule, empty when the node is null or JSON null + * @throws RegistryConfigException when present but malformed + */ + public static Schedule parseSchedule(final JsonNode node, final String source) { + if (node == null || node.isNull() || node.isMissingNode()) { + return new Schedule(List.of(), source + " (ABSENT)"); + } + final List out = new ArrayList<>(); + if (node.isTextual()) { + out.add(new ScheduleEntry(0L, normHash(node.asText().trim(), source))); + } else if (node.isArray()) { + if (node.isEmpty()) { + throw new RegistryConfigException( + "AERE-PQC-REG-SCHED-01", + "AERE PQC GENESIS-BINDING: " + + source + + " is an EMPTY array. Refusing to start (fail-closed): an empty schedule is not " + + "the same statement as an absent one, and guessing which was meant is exactly how " + + "a guard ends up switched off without anybody deciding to switch it off. Either " + + "remove the key entirely, or give it at least one {block, hash} entry."); + } + long previous = Long.MIN_VALUE; + for (final JsonNode e : node) { + if (!e.isObject() || !e.has("block") || !e.has("hash")) { + throw new RegistryConfigException( + "AERE-PQC-REG-SCHED-02", + "AERE PQC GENESIS-BINDING: " + + source + + " entry " + + e + + " is not an object with 'block' and 'hash'. Refusing to start (fail-closed)."); + } + final long block = e.get("block").asLong(-1L); + if (block < 0) { + throw new RegistryConfigException( + "AERE-PQC-REG-SCHED-03", + "AERE PQC GENESIS-BINDING: " + + source + + " has a negative or unparseable block in " + + e + + ". Refusing to start (fail-closed)."); + } + if (block <= previous) { + throw new RegistryConfigException( + "AERE-PQC-REG-SCHED-04", + "AERE PQC GENESIS-BINDING: " + + source + + " blocks are not STRICTLY INCREASING (" + + previous + + " then " + + block + + "). Refusing to start (fail-closed): with a repeated or out-of-order height " + + "two nodes reading the same file could pick different active entries, which is " + + "the very split this binding exists to prevent."); + } + previous = block; + out.add(new ScheduleEntry(block, normHash(e.get("hash").asText(), source))); + } + } else { + throw new RegistryConfigException( + "AERE-PQC-REG-SCHED-05", + "AERE PQC GENESIS-BINDING: " + + source + + " must be a 0x-prefixed 32-byte hash or an array of {block, hash}; found " + + node.getNodeType() + + ". Refusing to start (fail-closed)."); + } + return new Schedule(out, source); + } + + /** + * The registry hash required at a given height, or empty when no binding is active there. + * + *

Public because the startup guard is not the only place this belongs: a per-block header rule + * enforcing the same binding on the consensus path would call exactly this. + * + * @param schedule the parsed schedule + * @param blockNumber the height + * @return the active entry, or empty + */ + public static Optional requiredHashAt( + final Schedule schedule, final long blockNumber) { + ScheduleEntry active = null; + for (final ScheduleEntry e : schedule.entries) { + if (e.block <= blockNumber) { + active = e; + } else { + break; + } + } + return Optional.ofNullable(active); + } + + /** The first scheduled entry strictly after the given height, or empty. */ + private static Optional nextAfter(final Schedule schedule, final long blockNumber) { + for (final ScheduleEntry e : schedule.entries) { + if (e.block > blockNumber) { + return Optional.of(e); + } + } + return Optional.empty(); + } + + /** + * Whether the given registry satisfies the binding active at a height. Never throws. + * + * @param schedule the parsed schedule + * @param registry the loaded registry, may be null + * @param blockNumber the height + * @param chainId the chain id + * @return true iff no binding is active there, or the registry hashes to the required value + */ + public static boolean matchesAt( + final Schedule schedule, + final Registry registry, + final long blockNumber, + final long chainId) { + final Optional req = requiredHashAt(schedule, blockNumber); + if (req.isEmpty()) { + return true; + } + if (registry == null) { + return false; + } + return hashFor(registry, chainId).equalsIgnoreCase(req.get().hash); + } + + // =================================================================================== + // HEIGHT SCHEDULE: the whole scheduled history, not only the entry in force at the head + // =================================================================================== + + /** + * HEIGHT SCHEDULE. The registries a node holds, indexed by the SCHEDULE ENTRY each one satisfies. + * + *

THE DEFECT THIS EXISTS TO CLOSE, measured and not assumed. {@code config.pqRegistryHash} is a + * schedule, and a second entry is how a key rotation or a revocation is expressed. Enforcement, + * however, took exactly ONE loaded registry: {@link #matchesAt(Schedule, Registry, long, long)} + * resolves the entry active at a height and compares it against that single registry. After one + * rotation at H2 there are two intervals with two different required hashes, and one file can + * satisfy at most one of them. Measured on the real classes in {@code PqRegistryRotationTest}: + * a node holding the pre-rotation registry was refused at {12100000, 12100001}, a node holding the + * post-rotation registry was refused at {12000000, 12000001, 12099999}, and there was no third + * choice. + * + *

That is not confined to the rotation moment. {@code PqRegistryBindingRule} is DETACHED, so it + * runs on the header-download path, and {@code PqAnchorSyncModeGuard} refuses to start an armed + * node in anything but FULL sync. Every node acquiring history therefore validates the interval + * before the rotation AND has to reach the head. Using the rotation mechanism once made the chain + * permanently unjoinable, which means the mechanism was expressible and was not usable. + * + *

THE SHAPE OF THE REPAIR, and where it was learned. Cosmos ADR-016 keeps a rotation HISTORY + * mapping height to the consensus key in force there, precisely so that blocks signed under an + * older key stay verifiable, and bounds that history by the unbonding period. We keep the same + * mapping and we do NOT bound it: chain 2800 has no unbonding period and a node syncing from + * genesis must verify every block ever produced, so every entry ever scheduled stays loadable + * forever. Besu's own answer to the same class of question, the genesis {@code transitions} list, + * has the same shape: the value in force at a height is fixed by the height, lives in a document + * every node already holds byte-identically, and is never re-read from mutable state. + * + *

A set that does not cover every scheduled entry is NOT silently degraded. {@link + * #uncoveredEntryBlocks()} names exactly which heights have no registry, so the startup guard can + * refuse with the missing heights in hand instead of the operator discovering them one header at + * a time. + */ + public static final class RegistrySet { + private final List loaded; + private final List entryBlocks; // ascending, one per COVERED schedule entry + private final List entryRegistries; // parallel to entryBlocks + private final List uncovered; // schedule entry blocks with no matching registry + private final List misbound; // signed height did not match the scheduled one + + RegistrySet( + final List loaded, + final List entryBlocks, + final List entryRegistries, + final List uncovered) { + this(loaded, entryBlocks, entryRegistries, uncovered, List.of()); + } + + RegistrySet( + final List loaded, + final List entryBlocks, + final List entryRegistries, + final List uncovered, + final List misbound) { + this.loaded = loaded; + this.entryBlocks = entryBlocks; + this.entryRegistries = entryRegistries; + this.uncovered = uncovered; + this.misbound = misbound; + } + + /** + * Every registry this node was given, in the order it was given them. + * + * @return an unmodifiable list + */ + public List loaded() { + return List.copyOf(loaded); + } + + /** + * The registry this node SIGNS with, i.e. the first one it was given. Every other held file is + * history: loadable so that old blocks stay verifiable, never the one this node writes under. + * + * @return the primary registry, or null when this node holds none + */ + public Registry primary() { + return loaded.isEmpty() ? null : loaded.get(0); + } + + /** + * SIGNED-HEIGHT CHECK. The schedule entries a held registry reproduces BY HASH and yet was not signed for. + * + * @return an unmodifiable list, empty when every covered entry is coherently signed + */ + public List misbound() { + return List.copyOf(misbound); + } + + /** + * The scheduled heights this node CAN answer for. + * + * @return an unmodifiable ascending list + */ + public List coveredEntryBlocks() { + return List.copyOf(entryBlocks); + } + + /** + * How many registries this node holds. + * + * @return the count + */ + public int count() { + return loaded.size(); + } + + /** + * The scheduled heights for which this node holds NO registry at all. + * + * @return an unmodifiable ascending list, empty when the whole schedule is covered + */ + public List uncoveredEntryBlocks() { + return List.copyOf(uncovered); + } + + /** + * Whether this node can satisfy every entry the schedule contains. + * + * @return true iff nothing is uncovered + */ + public boolean coversWholeSchedule() { + return uncovered.isEmpty(); + } + + /** The registry bound to a schedule entry, or null when that entry is uncovered. */ + Registry forEntryBlock(final long entryBlock) { + for (int i = 0; i < entryBlocks.size(); i++) { + if (entryBlocks.get(i).longValue() == entryBlock) { + return entryRegistries.get(i); + } + } + return null; + } + + @Override + public String toString() { + return "RegistrySet{held=" + + loaded.size() + + ", covered=" + + entryBlocks + + ", uncovered=" + + uncovered + + '}'; + } + } + + /** + * HEIGHT SCHEDULE. Bind a set of loaded registries to the schedule by canonical hash. + * + *

The binding is by HASH and never by order or by file name: a registry covers the entry whose + * required hash it reproduces, and nothing else makes it cover anything. An operator therefore + * cannot mis-assign a file, only fail to supply one, and that failure is named. + * + * @param schedule the parsed schedule + * @param registries every registry this node holds; nulls are ignored + * @param chainId the chain id, part of the canonical pre-image + * @return the height-resolved set + */ + public static RegistrySet buildSet( + final Schedule schedule, final List registries, final long chainId) { + final List held = new ArrayList<>(); + for (final Registry r : registries) { + if (r != null) { + held.add(r); + } + } + final List blocks = new ArrayList<>(); + final List mapped = new ArrayList<>(); + final List uncovered = new ArrayList<>(); + final List misbound = new ArrayList<>(); + for (final ScheduleEntry e : schedule.entries) { + Registry match = null; + for (final Registry r : held) { + if (!hashFor(r, chainId).equalsIgnoreCase(e.hash)) { + continue; + } + // AERE SIGNED-HEIGHT CHECK (2026-08-06). THE LINK THAT WAS NEVER DRAWN. Both numbers have been in this + // lexical scope since the height schedule was written and they were never put on the same expression. + // + // bindHeight is the height every row's possession proof and every row's validator claim + // were SIGNED OVER (PqRegistryBinding.bindingPreimage). e.block is the height from which + // genesis puts this registry IN FORCE. They are supposed to be the same number, and the + // hash cannot notice when they are not: bindHeight is INSIDE the v2 pre-image, so a file + // signed for 40 and scheduled from 70 reproduces its own hash perfectly. Measured + // 2026-08-06 on a network of seven: all 7 started, the chain ran to head 95, agreement was + // 7 of 7, zero errors and zero refusals - and the offline tool, on the same files, said RED. + // The node and the tool disagreed and nothing put them face to face. + // + // WHAT THAT BUYS AN OPERATOR WHO IS NOT SUPPOSED TO HAVE IT: moving the activation day + // costs 14 fresh signatures if this is checked, and ZERO if it is not. The seven + // validators' agreement on a height is only an agreement if something refuses the heights + // they did not sign. + if (r.proofBound() && r.bindHeight() != e.block) { + misbound.add(new Misbound(e.block, e.hash, r.sourcePath(), r.bindHeight())); + continue; + } + match = r; + break; + } + if (match == null) { + uncovered.add(e.block); + } else { + blocks.add(e.block); + mapped.add(match); + } + } + return new RegistrySet(held, blocks, mapped, uncovered, misbound); + } + + /** + * SIGNED-HEIGHT CHECK. One schedule entry whose required hash a held registry reproduces, and whose height that + * registry's binding proofs were not signed over. + * + * @param entryBlock the height genesis puts the registry in force from + * @param entryHash the hash genesis requires there + * @param registryPath the file that reproduces that hash + * @param signedHeight the height the file's proofs were actually signed over + */ + public record Misbound( + long entryBlock, String entryHash, String registryPath, long signedHeight) {} + + /** + * HEIGHT SCHEDULE. Load every registry named and bind the result to the schedule. + * + * @param schedule the parsed schedule + * @param paths the registry files this node holds + * @param chainId the chain id + * @return the height-resolved set + * @throws RegistryConfigException when any file is unreadable or ambiguous + */ + public static RegistrySet loadSet( + final Schedule schedule, final List paths, final long chainId) { + final List registries = new ArrayList<>(); + for (final Path p : paths) { + registries.add(loadAuto(p)); + } + return buildSet(schedule, registries, chainId); + } + + /** + * HEIGHT SCHEDULE. Split a comma-separated list of registry paths. Blank elements are dropped; a null or + * blank list gives an empty result rather than a path named "". + * + * @param raw the configured value, may be null + * @return the paths, in the order written + */ + public static List parseRegistryPaths(final String raw) { + final List out = new ArrayList<>(); + if (raw == null || raw.isBlank()) { + return out; + } + for (final String piece : Splitter.on(',').split(raw)) { + final String trimmed = piece.trim(); + if (!trimmed.isEmpty()) { + out.add(Path.of(trimmed)); + } + } + return out; + } + + /** + * HEIGHT SCHEDULE. The registry in force at a height: the one bound to the schedule entry active there. + * + *

This is the function Falcon verification needs. A certificate in a block at height h was + * produced under the key set the chain required at h, so it must be checked against that key set + * and not against whatever the node happens to hold for the head. + * + * @param schedule the parsed schedule + * @param set the registries this node holds, may be null + * @param blockNumber the height + * @return the registry in force there, or empty when no binding is active or none is held + */ + public static Optional registryAt( + final Schedule schedule, final RegistrySet set, final long blockNumber) { + final Optional required = requiredHashAt(schedule, blockNumber); + if (required.isEmpty() || set == null) { + return Optional.empty(); + } + return Optional.ofNullable(set.forEntryBlock(required.get().block)); + } + + /** + * HEIGHT SCHEDULE. Whether the registries this node holds satisfy the binding active at a height. + * + *

The hash is RECOMPUTED here rather than trusted from the binding built earlier, so that this + * answer is a positive proof about the bytes the node is holding right now and not a restatement + * of an earlier decision. + * + * @param schedule the parsed schedule + * @param set the registries this node holds, may be null + * @param blockNumber the height + * @param chainId the chain id + * @return true iff no binding is active there, or a held registry reproduces the required hash + */ + public static boolean matchesAt( + final Schedule schedule, + final RegistrySet set, + final long blockNumber, + final long chainId) { + final Optional required = requiredHashAt(schedule, blockNumber); + if (required.isEmpty()) { + return true; + } + if (set == null) { + return false; + } + final Registry r = set.forEntryBlock(required.get().block); + return r != null && hashFor(r, chainId).equalsIgnoreCase(required.get().hash); + } + + // =================================================================================== + // The startup guard + // =================================================================================== + + /** Outcome of the startup guard. Exposed so tests and diagnostics can assert on it. */ + public enum GateState { + /** No {@code pqRegistryHash} in genesis: nothing is enforced, exactly as before this change. */ + NOT_ENFORCED_NO_SCHEDULE, + /** A schedule exists but its first height is still ahead of this node's chain head. */ + NOT_ENFORCED_BELOW_FIRST_HEIGHT, + /** A binding is active at this height and the loaded registry satisfies it. */ + MATCH, + /** + * AERE OPTIUNI-URGENTA: a binding is active, the registry does NOT satisfy it, and an operator + * explicitly overrode the refusal. The node is running and is NOT verifying certificates. + */ + OVERRIDDEN_UNSAFE + } + + /** + * Refusal to start, or refusal to accept a registry. Carries a stable machine-readable code so + * fleet tooling can match on it without parsing prose. + */ + public static final class RegistryConfigException extends IllegalStateException { + + private static final long serialVersionUID = 1L; + + private final String code; + + RegistryConfigException(final String code, final String message) { + super("[" + code + "] " + message); + this.code = code; + } + + /** + * The stable code, e.g. {@code AERE-PQC-REG-MISMATCH-01}. + * + * @return the code + */ + public String code() { + return code; + } + } + + /** + * THE GUARD. Verify the registry this node actually loaded against the hash the genesis schedule + * makes active at the NEXT height this node will be asked to validate, chainHead + 1, and REFUSE + * TO START on a mismatch. + * + *

AERE CONSENS-LENS (2026-08-02): chainHead + 1, and not chainHead. That one block of + * difference was a permanent chain stop, measured on real nodes; the measurement and the + * reasoning are on the line that computes the height. + * + *

Call once during startup, from a point where a chain head exists and BEFORE the network and + * the consensus state machine are started, so that a refusal is a clean refusal to start rather + * than a mid-flight abort. The same place {@code verifyAttachHeightAgainstChainHeadOrAbort} is + * called from is correct. + * + * @param schedule the parsed {@code config.pqRegistryHash} schedule + * @param registry the registry this node loaded, or null if it loaded none + * @param chainHeadNumber this node's local chain head at startup + * @param chainId the chain id, which is part of the canonical pre-image + * @return the gate state on success + * @throws RegistryConfigException when a binding is active and the registry does not satisfy it + */ + public static GateState verifyOrAbort( + final Schedule schedule, + final Registry registry, + final long chainHeadNumber, + final long chainId) { + return verifyOrAbort( + schedule, + buildSet(schedule, registry == null ? List.of() : List.of(registry), chainId), + chainHeadNumber, + chainId); + } + + /** + * AERE HELD-SET SCOPE (2026-08-06). THE SAME GUARD, ASKED OF EVERY REGISTRY THIS NODE HOLDS. + * + *

WHY THIS OVERLOAD HAD TO EXIST, and it is not tidiness. The height schedule gave a node a HISTORY of + * registry files, one per rotation the chain has ever performed, precisely so that a node can + * validate blocks produced under a retired key set. The startup guard was never told. It compared + * the schedule against ONE registry, the one named by {@code registrySourcePath}, and the history + * was read 41 lines further down - after the refusal had already been thrown. So the order of two + * blocks of code WAS the defect: after the first rotation, a node whose primary file is the + * post-rotation registry and whose history holds the pre-rotation one is correctly configured, and + * was refused to start with {@code AERE-PQC-REG-MISMATCH-01}. Re-confirmed 2026-08-06 with head at + * 2554. The consequence, stated plainly: after the first key rotation, no node in the fleet could + * be restarted with its own correct configuration without hand intervention. + * + *

The question this asks is unchanged - "is the binding the chain requires at the next height I + * will validate satisfied by something I hold" - only the word "something" is now honest. + * + * @param schedule the parsed {@code config.pqRegistryHash} schedule + * @param set every registry this node holds, resolved against the schedule + * @param chainHeadNumber this node's local chain head at startup + * @param chainId the chain id, which is part of the canonical pre-image + * @return the gate state on success + * @throws RegistryConfigException when a binding is active and nothing held satisfies it + */ + public static GateState verifyOrAbort( + final Schedule schedule, + final RegistrySet set, + final long chainHeadNumber, + final long chainId) { + + // AERE ROW BINDING. The proofs are signed over a chainId the FILE declares. A registry lifted from + // the scratch chain 442807 carries proofs that verify perfectly among themselves - they are + // internally consistent, just for another chain - and would otherwise pass. Fail-closed here, + // where the node's real chain id is known and the file's is not yet trusted. + // + // AERE HELD-SET SCOPE (2026-08-06): over EVERY held file, not only the primary. A history file lifted from + // the scratch chain is exactly as dangerous as a primary one - it is the file that answers for + // an interval of history - and before this it was never asked. + for (final Registry r : set.loaded()) { + if (r.proofBound() && r.declaredChainId() != chainId) { + throw new RegistryConfigException( + "AERE-PQC-REG-BIND-07", + "AERE PQC ROW-BINDING: registry '" + + r.sourcePath() + + "' declares chainId=" + + r.declaredChainId() + + " and its binding proofs are signed over that value, but this node is on chainId=" + + chainId + + ". Refusing to start (fail-closed): the proofs in this file are valid, and they " + + "are valid for a different chain."); + } + } + + final Registry registry = set.primary(); + + if (!schedule.enforced()) { + LOG.warn( + "AERE PQC GENESIS-BINDING: pqRegistryHash is NOT CONFIGURED ({}), so the Falcon registry is NOT bound " + + "to consensus on this node. The registry currently loaded is {} ({}, {} entries, " + + "address-bound={}), canonical hash 0x{}. Two nodes holding DIFFERENT registry files " + + "will disagree about which public key validator index i has, so the same " + + "certificate can verify on one and fail on the other. Put that hash into genesis as " + + "config.pqRegistryHash to close this.", + schedule.source(), + registry == null ? "(none)" : registry.sourcePath(), + registry == null ? SourceKind.NONE : registry.kind(), + registry == null ? 0 : registry.count(), + registry != null && registry.addressBound(), + // GENESIS BINDING, measured on a fleet of seven on 2026-08-06: this printed hashV1 next + // to the text "put this in config.pqRegistryHash", while THE GATE compares hashFor, + // which for a registry carrying proofs is hashV2. With the printed value put into + // genesis, all seven nodes start, all seven report the registry loaded, and THE CHAIN + // STOPS AT H-1. The guard shouts NOT CORRECTLY STAGED, so it is not a silent halt, but + // the operator who follows the node's own instruction halts the fleet. A wrong + // instruction is more dangerous than no instruction at all. + registry == null ? "(no registry)" : hashFor(registry, chainId)); + return GateState.NOT_ENFORCED_NO_SCHEDULE; + } + + // AERE SIGNED-HEIGHT CHECK (2026-08-06). THE SILENT DEFERRAL. Placed HERE, below the not-enforced exit above, + // and that position is a rule and not a preference: on chain 2800 config.pqRegistryHash does not + // exist, schedule.enforced() is false, and the return above is the first executable statement + // this guard reaches. Nothing new is ever put above it. + // + // WHY REFUSING IS RIGHT AND NOT MERELY SAFE. A misbound entry is not a missing file, which an + // operator can legitimately have; it is a file the operator DOES hold, which reproduces exactly + // the hash genesis names, and which the fleet signed for a DIFFERENT height. There is no honest + // reading of it. Refusing at startup also catches it before the height arrives, which is the + // whole point: an activation day moved under signed proofs is invisible until the day itself. + if (!set.misbound().isEmpty()) { + final StringBuilder rows = new StringBuilder(); + for (final Misbound m : set.misbound()) { + rows.append(" genesis puts registry '") + .append(m.registryPath()) + .append("' in force FROM BLOCK ") + .append(m.entryBlock()) + .append(", and every binding proof in that file is signed over bindHeight=") + .append(m.signedHeight()) + .append(" (entry hash 0x") + .append(m.entryHash()) + .append(")\n"); + } + throw new RegistryConfigException( + "AERE-PQC-REG-BIND-08", + "AERE PQC SIGNED-HEIGHT: REFUSING TO START - a registry this node holds is scheduled at a height " + + "its validators never signed for.\n" + + " FIELD: 'bindHeight', inside the registry file, versus 'block' of the matching " + + "entry of config.pqRegistryHash in genesis (" + + schedule.source() + + ").\n" + + " READ:\n" + + rows + + " EXPECTED: bindHeight and the schedule entry's block are THE SAME NUMBER. Every " + + "row of a v2 registry carries a Falcon possession proof and an ECDSA claim by that " + + "validator's own consensus key, and both signatures cover the bind height. A " + + "registry in force from block B is every one of those validators stating that B is " + + "the height they agreed to.\n" + + " WHY THE HASH DID NOT CATCH THIS: bindHeight is INSIDE the v2 pre-image, so the " + + "file reproduces its own hash perfectly no matter which height genesis schedules it " + + "at. Hash-against-hash can never see this; only these two numbers side by side can. " + + "Measured 2026-08-06 on a network of seven: all seven started, the chain ran, " + + "agreement was 7 of 7, zero errors, zero refusals.\n" + + " WHAT IT WOULD HAVE COST AN ATTACKER OR A HURRIED OPERATOR: moving the activation " + + "day costs 14 fresh validator signatures when this is checked, and zero when it is " + + "not.\n" + + " REMEDY, and there are exactly two honest ones. EITHER put the schedule entry back " + + "to the height the file was signed for, on every node, because genesis is the " + + "document all seven hold byte-identically. OR re-run the key ceremony and have every " + + "validator re-sign its row for the new height, then set config.pqRegistryHash to the " + + "NEW hash of the re-signed file - it will be a different hash, because the height is " + + "inside it.\n" + + " DO NOT edit bindHeight in the file. The proofs are over it; the file would stop " + + "loading at all (AERE-PQC-REG-BIND-03)."); + } + + // AERE CONSENS-LENS (2026-08-02). THE HEIGHT THIS GUARD ASKS ABOUT IS chainHead + 1, and the + // one-block difference was a PERMANENT CHAIN STOP, measured on real nodes before this line was + // changed. With a schedule of two entries and a rotation at height X: + // - every node runs to X-1 and REFUSES header X (PqRegistryBindingRule, + // AERE-PQC-REG-BLOCK-01). Measured: head stopped at 11 with a rotation at 12. + // - the operator installs the registry the schedule requires FROM X and restarts. This guard, + // asked about the HEAD X-1, demanded the OLD hash and refused to start with + // AERE-PQC-REG-MISMATCH-01, exit 2. Measured. + // - the operator puts the OLD registry back: the node starts and still cannot pass X. + // Measured. + // Old registry: starts, cannot advance. New registry: cannot start. There was no third file, + // and the only exit measured was to set the emergency bypass on every node, i.e. to switch + // the genesis binding off across the whole fleet in order to cross a PLANNED rotation. + // + // chainHead + 1 is the question the node can actually act on: the only header it will be + // offered next is chainHead + 1, and PqRegistryBindingRule judges that header against the entry + // active AT THAT HEIGHT. Asking the same question here means the startup guard and the + // per-block rule can no longer disagree, which is the property that was missing. A node halted + // at X-1 now starts with the registry required from X. A node staged EARLY is refused at + // startup, and that is the honest verdict: it could not validate the blocks between its head + // and the rotation, and refusing at startup is better than stalling on the first header. + final long validationHeight = + chainHeadNumber == Long.MAX_VALUE ? chainHeadNumber : chainHeadNumber + 1; + final Optional active = requiredHashAt(schedule, validationHeight); + + if (active.isEmpty()) { + final ScheduleEntry first = schedule.entries.get(0); + // AERE HELD-SET SCOPE: ask the SET, not only the primary. A node staged for the activation may already + // hold the activation registry as history while still signing under the current one. + final Registry staged = set.forEntryBlock(first.block()); + final String computed = registry == null ? null : hashFor(registry, chainId); + if (staged != null) { + LOG.info( + "AERE PQC GENESIS-BINDING: registry binding is scheduled to start at block {} and this node's chain " + + "head is {}, so nothing is enforced yet. The registry already loaded ({}, {} " + + "entries) ALREADY MATCHES the hash required from block {}: 0x{}. This node is " + + "correctly staged for the activation.", + first.block(), + chainHeadNumber, + staged.sourcePath(), + staged.count(), + first.block(), + hashFor(staged, chainId)); + } else { + LOG.error( + "AERE PQC GENESIS-BINDING: registry binding starts at block {} and this node's chain head is {}, so " + + "nothing is enforced yet AND THIS NODE IS NOT CORRECTLY STAGED. Required from " + + "block {}: 0x{}. Loaded here: {}. This node will run normally and will then " + + "REFUSE the header at block {} (AERE-PQC-REG-BLOCK-01) and stop there. Install " + + "the registry named above AT THAT HALT and restart. Do NOT install it earlier: " + + "from the moment it is installed this node can no longer validate the blocks " + + "between here and the rotation, and it will be refused at startup. NOTE the " + + "honest limitation: this guard runs at STARTUP only, so it will NOT stop this " + + "node while it keeps running.", + first.block(), + chainHeadNumber, + first.block(), + first.hash(), + computed == null ? "NO REGISTRY LOADED AT ALL" : "0x" + computed, + first.block()); + } + return GateState.NOT_ENFORCED_BELOW_FIRST_HEIGHT; + } + + final ScheduleEntry required = active.get(); + + if (set.count() == 0) { + throw new RegistryConfigException( + "AERE-PQC-REG-MISMATCH-02", + "AERE PQC GENESIS-BINDING: REFUSING TO START.\n" + + " EXPECTED: a Falcon validator registry whose canonical hash is\n" + + " 0x" + + required.hash() + + "\n" + + " required from block " + + required.block() + + " by genesis config.pqRegistryHash (" + + schedule.source() + + ").\n" + + " FOUND: NO REGISTRY AT ALL. None of the three registry sources is configured.\n" + + " Searched, in this order:\n" + + " 1) aere.falcon.genesis / AERE_FALCON_GENESIS (genesis manifest)\n" + + " 2) aere.falcon.manifest / AERE_FALCON_MANIFEST (late-anchor)\n" + + " 3) aere.falcon.registry / AERE_FALCON_REGISTRY (legacy properties)\n" + + " CHAIN HEAD: " + + chainHeadNumber + + " NEXT HEIGHT TO VALIDATE: " + + validationHeight + + " (the binding above is the one active AT THAT HEIGHT)\n" + + " CHAIN ID: " + + chainId + + "\n" + + " WHY THIS IS FATAL: from block " + + required.block() + + " this chain states which registry every node must hold. A node with no registry " + + "verifies no Falcon seal at all, so it would sit in the validator set reporting " + + "itself healthy while contributing nothing to the post-quantum layer.\n" + + " REMEDY: point one of the three properties above at the registry file whose hash " + + "is 0x" + + required.hash() + + ", then restart. Compute a file's hash with:\n" + + " java -cp " + + "org.hyperledger.besu.consensus.common.bft.tools.PqRegistryHashTool " + + " --chain-id " + + chainId); + } + + final String computed = hashFor(registry, chainId); + // AERE HELD-SET SCOPE (2026-08-06): the binding is satisfied by ANY file this node holds for this entry, + // not only by the one it signs with. Before this line the answer came from the primary registry + // alone, and after the first rotation the primary is by definition NOT the file that answers for + // the interval below the rotation. + final Registry bound = set.forEntryBlock(required.block()); + if (bound != null) { + LOG.info( + "AERE PQC GENESIS-BINDING: registry binding SATISFIED. Loaded {} ({}, {} entries, address-bound={}); " + + "canonical hash 0x{} equals the hash required from block {} by genesis " + + "config.pqRegistryHash, read from [{}]. Chain head {}, chainId {}. This node holds " + + "{} registry file(s) in total. {}", + bound.sourcePath(), + bound.kind(), + bound.count(), + bound.addressBound(), + hashFor(bound, chainId), + required.block(), + // AERE GENESIS BINDING: naming the PROVENANCE of the schedule is not decoration. The whole defect + // class is "a value that came from somewhere nobody checked", so a line that says the + // binding is satisfied without saying what it was read from asserts more than it knows. + schedule.source(), + chainHeadNumber, + chainId, + set.count(), + nextAfter(schedule, validationHeight) + .map( + n -> + "NEXT ROTATION: from block " + + n.block() + + " this node must hold a registry hashing to 0x" + + n.hash() + + (set.forEntryBlock(n.block()) != null + ? " - already satisfied by a file this node holds." + : " - NOTHING this node holds satisfies it. This node will run" + + " to block " + + (n.block() - 1) + + ", REFUSE the header at " + + n.block() + + " (AERE-PQC-REG-BLOCK-01) and stop there. Install the new file" + + " and name it in " + + "aere.falcon.registry.history, then restart.")) + .orElse("No further rotation is scheduled.")); + return GateState.MATCH; + } + + throw new RegistryConfigException( + "AERE-PQC-REG-MISMATCH-01", + "AERE PQC GENESIS-BINDING: REFUSING TO START - the Falcon validator registry on this node is NOT the one " + + "this chain requires.\n" + + " EXPECTED hash: 0x" + + required.hash() + + "\n" + + " required from block " + + required.block() + + " by genesis config.pqRegistryHash (" + + schedule.source() + + ")\n" + + " FOUND hash: 0x" + + computed + + "\n" + + " CHAIN HEAD: " + + chainHeadNumber + + " NEXT HEIGHT TO VALIDATE: " + + validationHeight + + " (the binding above is the one active AT THAT HEIGHT)\n" + + " CHAIN ID: " + + chainId + + " (chainId is part of the hashed pre-image)\n" + + " FULL SCHEDULE: " + + schedule + + "\n" + + describeHeld(set, chainId) + + describeRegistry(registry, chainId) + + " WHY THIS IS FATAL: this file decides which Falcon public key belongs to validator " + + "index i. A node holding a different file disagrees with its peers about who index i " + + "is, so the SAME certificate verifies on one node and fails on another and the " + + "network splits with no attacker involved. Starting with the wrong file is strictly " + + "worse than not starting.\n" + + " HOW TO FIND THE WRONG ROW: every node prints the per-index fingerprints above. " + + "Compare them with a node that starts cleanly; the first index that differs is the " + + "row that was changed. The fingerprints are keccak digests, so they can be pasted " + + "into a chat without moving key material.\n" + + " REMEDY: this node must HOLD a file whose canonical hash is 0x" + + required.hash() + + ". It does not have to be the file this node signs with: name it in " + + "aere.falcon.registry.history (comma-separated) and every file listed there answers " + + "for the interval the schedule gives it. That list is never pruned - one file per " + + "rotation the chain has ever performed, kept forever. Then restart. To check a " + + "candidate file before restarting:\n" + + " java -cp " + + "org.hyperledger.besu.consensus.common.bft.tools.PqRegistryHashTool " + + " --chain-id " + + chainId + + "\n" + + " DO NOT 'fix' this by editing genesis. The genesis value is what the rest of the " + + "fleet agrees on; changing it on this node alone makes this node the odd one out."); + } + + /** + * AERE HELD-SET SCOPE. Every registry file this node holds and which scheduled interval each one answers for. + * Without this an operator reading {@code MISMATCH-01} cannot tell "I gave this node one file and + * it is the wrong one" from "I gave it four and none covers this height", which are different + * mistakes with different fixes. + */ + private static String describeHeld(final RegistrySet set, final long chainId) { + final StringBuilder b = new StringBuilder(); + b.append(" REGISTRY FILES THIS NODE HOLDS: ").append(set.count()).append('\n'); + for (final Registry r : set.loaded()) { + final String h = hashFor(r, chainId); + Long covers = null; + for (final Long blk : set.coveredEntryBlocks()) { + if (set.forEntryBlock(blk) == r) { + covers = blk; + break; + } + } + b.append(" ") + .append(r.sourcePath()) + .append(" hash=0x") + .append(h) + .append(" bindHeight=") + .append(r.proofBound() ? Long.toString(r.bindHeight()) : "(v1, unsigned)") + .append(" covers=") + .append(covers == null ? "NO SCHEDULED ENTRY" : "from block " + covers) + .append('\n'); + } + if (!set.uncoveredEntryBlocks().isEmpty()) { + b.append(" scheduled heights covered by NOTHING held here: ") + .append(set.uncoveredEntryBlocks()) + .append('\n'); + } + return b.toString(); + } + + /** The "what I found" block of a refusal: source, shape, both hashes, per-index fingerprints. */ + private static String describeRegistry(final Registry registry, final long chainId) { + final StringBuilder b = new StringBuilder(); + b.append(" REGISTRY ACTUALLY LOADED:\n"); + b.append(" source : ").append(registry.sourcePath()).append('\n'); + b.append(" source kind : ").append(registry.kind()).append('\n'); + b.append(" entries : ").append(registry.count()).append('\n'); + b.append(" addressBound: ").append(registry.addressBound()).append('\n'); + b.append(" format: ") + .append(registry.proofBound() ? "v2, binding proofs present" : "v1, NO binding proofs") + .append('\n'); + // GENESIS BINDING: the report printed both v1 and v2 without saying WHICH one goes into + // genesis, and whoever took the last value off the screen took v1 and halted the fleet at + // H-1. The one that matters is now named explicitly, and it is the very one the gate + // compares: hashFor. + b.append(" >>> FOR config.pqRegistryHash: 0x") + .append(hashFor(registry, chainId)) + .append(" <<< this one, and only this one\n"); + if (registry.proofBound()) { + b.append(" bindHeight: ").append(registry.bindHeight()).append('\n'); + b.append(" canonical v2: 0x").append(hashV2(registry, chainId)).append(" (= the one above)\n"); + } + b.append(" canonical v1: 0x").append(hashV1(registry, chainId)) + .append(registry.proofBound() ? " (NOT this one: the registry carries proofs)\n" : " (= the one above)\n"); + b.append(" legacy v0 : 0x") + .append(hashV0Legacy(registry)) + .append(" (the older concat form, for cross-checking the genesis alloc anchor only)\n"); + b.append(" per-index fingerprints, keccak256(addr||pk) first 8 hex:\n"); + final int shown = Math.min(registry.count(), MAX_ENTRIES_IN_REPORT); + for (int i = 0; i < shown; i++) { + final Entry e = registry.entries.get(i); + b.append(" [") + .append(i) + .append("] ") + .append(fingerprint(registry, i)) + .append(" pkLen=") + .append(e.publicKey.length); + if (e.address != null) { + b.append(" addr=0x").append(hex(e.address)); + } + b.append('\n'); + } + if (registry.count() > shown) { + b.append(" ... ").append(registry.count() - shown).append(" more entries\n"); + } + return b.toString(); + } + + // =================================================================================== + // Small helpers. Deliberately local so this class needs nothing from Besu itself and + // can be compiled, hashed and tested standalone. + // =================================================================================== + + private static JsonNode readJson(final Path path) { + try { + return new ObjectMapper().readTree(Files.readAllBytes(path)); + } catch (final IOException e) { + throw new RegistryConfigException( + "AERE-PQC-REG-LOAD-13", + "AERE PQC GENESIS-BINDING: cannot read or parse JSON at '" + + path + + "': " + + e + + ". Refusing (fail-closed)."); + } + } + + private static int parseIndexOrThrow(final String raw, final String source) { + try { + final int i = Integer.parseInt(raw.trim()); + if (i < 0) { + throw new NumberFormatException("negative"); + } + return i; + } catch (final NumberFormatException e) { + throw new RegistryConfigException( + "AERE-PQC-REG-LOAD-14", + "AERE PQC GENESIS-BINDING: registry '" + + source + + "' has the unrecognised key '" + + raw + + "'. Every key must be 'count', a non-negative validator index, or '.addr'. " + + "Refusing (fail-closed): the old loader SKIPPED keys it did not understand and " + + "logged a warning, which means a typo'd index silently produced a smaller registry " + + "that still hashed to something."); + } + } + + /** + * AERE ROW BINDING. Parse an OPTIONAL non-negative header field: absent means -1, present means it must + * be a well formed non-negative number. Absent-or-garbage is never collapsed into a default, + * because a default is how a threshold quietly becomes zero. + */ + private static long parseOptionalLong( + final String raw, final String what, final String source) { + if (raw == null || raw.trim().isEmpty()) { + return -1L; + } + final long v; + try { + v = Long.parseLong(raw.trim()); + } catch (final NumberFormatException e) { + throw new RegistryConfigException( + "AERE-PQC-REG-LOAD-23", + "AERE PQC ROW-BINDING: registry '" + + source + + "' has " + + what + + "='" + + raw + + "', which is not a number. Refusing (fail-closed)."); + } + if (v < 0) { + throw new RegistryConfigException( + "AERE-PQC-REG-LOAD-23", + "AERE PQC ROW-BINDING: registry '" + + source + + "' has a negative " + + what + + " (" + + v + + "). Refusing (fail-closed)."); + } + return v; + } + + private static int parsePositiveInt(final String raw, final String what, final String source) { + try { + final int v = Integer.parseInt(raw.trim()); + if (v <= 0) { + throw new NumberFormatException("not positive"); + } + return v; + } catch (final NumberFormatException e) { + throw new RegistryConfigException( + "AERE-PQC-REG-LOAD-15", + "AERE PQC GENESIS-BINDING: registry '" + + source + + "' has a malformed " + + what + + " '" + + raw + + "'. Refusing (fail-closed)."); + } + } + + /** + * Strict hex: optional {@code 0x}, then an EVEN number of hex digits and nothing else. + * + *

Deliberately NOT the lenient parser the old loader used. Lenient parsing accepts an odd digit + * count and silently left-pads, so a public key that lost one character would load as a DIFFERENT + * key with no complaint, and hash to something that looks perfectly valid. + */ + private static byte[] strictHex(final String raw, final String what, final String source) { + String s = raw == null ? "" : raw.trim(); + if (s.startsWith("0x") || s.startsWith("0X")) { + s = s.substring(2); + } + if (s.isEmpty() || (s.length() & 1) == 1) { + throw new RegistryConfigException( + "AERE-PQC-REG-LOAD-16", + "AERE PQC GENESIS-BINDING: registry '" + + source + + "' " + + what + + " has " + + s.length() + + " hex digits, which is " + + (s.isEmpty() ? "empty" : "ODD") + + ". Refusing (fail-closed): a lenient parser would left-pad this and load a " + + "DIFFERENT key without saying anything."); + } + final byte[] out = new byte[s.length() / 2]; + for (int i = 0; i < out.length; i++) { + final int hi = Character.digit(s.charAt(2 * i), 16); + final int lo = Character.digit(s.charAt(2 * i + 1), 16); + if (hi < 0 || lo < 0) { + throw new RegistryConfigException( + "AERE-PQC-REG-LOAD-17", + "AERE PQC GENESIS-BINDING: registry '" + + source + + "' " + + what + + " contains a non-hex character at position " + + (2 * i) + + ". Refusing (fail-closed)."); + } + out[i] = (byte) ((hi << 4) | lo); + } + return out; + } + + private static String normHash(final String raw, final String source) { + String s = raw == null ? "" : raw.trim(); + if (s.startsWith("0x") || s.startsWith("0X")) { + s = s.substring(2); + } + s = s.toLowerCase(Locale.ROOT); + if (s.length() != 64) { + throw new RegistryConfigException( + "AERE-PQC-REG-SCHED-06", + "AERE PQC GENESIS-BINDING: " + + source + + " carries the hash '" + + raw + + "' which is " + + s.length() + + " hex digits, not 64. Refusing to start (fail-closed)."); + } + for (int i = 0; i < 64; i++) { + if (Character.digit(s.charAt(i), 16) < 0) { + throw new RegistryConfigException( + "AERE-PQC-REG-SCHED-07", + "AERE PQC GENESIS-BINDING: " + source + " hash '" + raw + "' is not hexadecimal. Refusing to start."); + } + } + return s; + } + + private static void writeAll(final java.io.ByteArrayOutputStream out, final byte[] b) { + out.write(b, 0, b.length); + } + + private static byte[] uint32be(final int v) { + return new byte[] {(byte) (v >>> 24), (byte) (v >>> 16), (byte) (v >>> 8), (byte) v}; + } + + private static byte[] uint64be(final long v) { + final byte[] b = new byte[8]; + for (int i = 0; i < 8; i++) { + b[i] = (byte) (v >>> (56 - 8 * i)); + } + return b; + } + + private static String keccakHex(final byte[] input) { + final KeccakDigest kd = new KeccakDigest(256); + kd.update(input, 0, input.length); + final byte[] d = new byte[32]; + kd.doFinal(d, 0); + return hex(d); + } + + private static String hex(final byte[] b) { + final StringBuilder s = new StringBuilder(b.length * 2); + for (final byte x : b) { + s.append(Character.forDigit((x >> 4) & 0xf, 16)).append(Character.forDigit(x & 0xf, 16)); + } + return s.toString(); + } +} diff --git a/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqRegistryHashTool.java b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqRegistryHashTool.java new file mode 100644 index 0000000..e5d2675 --- /dev/null +++ b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqRegistryHashTool.java @@ -0,0 +1,482 @@ +/* + * Copyright contributors to Besu / Aere Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * REGISTRY SCHEDULE TOOL. The tool that writes and checks the two configuration values without + * which the height-indexed registry repair changes nothing. + * + *

WHY IT LIVES IN THE CONSENSUS MODULE AND NOT IN A SCRIPT. The value being computed is a + * keccak digest over a domain-separated, length-prefixed, chain-bound pre-image, and the node + * refuses to start when what it computes differs by one bit from what genesis names. A second + * implementation in another language is a second thing that can drift, and the drift would show up + * as seven validators refusing to start. This tool calls the SAME methods the node calls, from the + * same jar, so "the tool says the hash is X" and "the node computes X" cannot come apart. Run it + * from a node's own distribution: + * + *

+ *   java -cp 'besu/lib/*' org.hyperledger.besu.consensus.common.bft.PqRegistryHashTool \
+ *        verify --chain-id 2800 --genesis <config-dir>/genesis.json \
+ *        --history <config-dir>/falcon/registry-epoca-0.properties
+ * 
+ * + *

THREE VERBS. + * + *

    + *
  • {@code hash} prints the canonical hash of every registry file named. This is the value + * that goes into genesis. + *
  • {@code generate} prints the exact genesis fragment and the exact + * {@code aere.falcon.registry.history} line, given an arming height and one registry file per + * epoch. It writes nothing by itself: arming is a founder decision, and a tool that edits a + * live genesis is a tool that can arm a chain by accident. + *
  • {@code verify} recomputes everything from the files on THIS machine and answers one + * question: is there a scheduled height this node could not resolve. Exit 0 green, 1 red, 2 + * not measurable. + *
+ * + *

THE NEGATIVE CONTROL IS A VERB, not a promise: {@code verify --CONTROL-NEGATIV} changes one + * hex digit of the first required hash in memory and requires the verification to go RED. A check + * that has never failed cannot be trusted, so the tool proves it can fail every time it is run + * that way. + */ +public final class PqRegistryHashTool { + + private PqRegistryHashTool() {} + + /** + * Entry point. + * + * @param args the verb and its options + */ + public static void main(final String[] args) { + System.exit(run(args)); + } + + /** + * The tool, as a function of its arguments, so a test can drive it without exiting the JVM. + * + * @param args the verb and its options + * @return 0 green, 1 red, 2 not measurable + */ + public static int run(final String[] args) { + if (args.length == 0) { + utilizare(); + return 2; + } + final Map o = optiuni(args); + final long chainId = Long.parseLong(o.getOrDefault("chain-id", "2800")); + try { + switch (args[0]) { + case "hash": + return hash(o, chainId); + case "generate": + return generate(o, chainId); + case "verify": + return verify(o, chainId); + default: + utilizare(); + return 2; + } + } catch (final PqRegistryHash.RegistryConfigException e) { + System.out.println("RED: " + e.getMessage()); + return 1; + } catch (final RuntimeException e) { + System.out.println("NOT MEASURED: " + e); + return 2; + } + } + + // --------------------------------------------------------------------------------------------- + + private static int hash(final Map o, final long chainId) { + final List files = files(o, "registry"); + if (files.isEmpty()) { + System.out.println("NOT MEASURED: --registry [,...] is missing"); + return 2; + } + System.out.println("chainId=" + chainId + " (goes INTO the pre-image; 2800 and 442807 give different hashes for the same registry)"); + for (final Path p : files) { + final PqRegistryHash.Registry r = PqRegistryHash.loadAuto(p); + // AERE CANONICAL FINGERPRINT (2026-08-06): hashFor, not hashV1. For a registry that carries + // binding proofs, hashV1 is a number NOTHING in the node ever compares against: the guard + // compares hashFor, that is hashV2. The same mistake, in the `generate` verb below, writes + // into genesis a hash the node will never recognise, and then all seven start and the chain + // stops at H-1. On top of that, hashV1 does NOT tell two rotation epochs of the same fleet + // apart, because the bind height does not enter the v1 pre-image; so it cannot serve even as + // an epoch identifier for diagnostics. + System.out.println( + "0x" + + PqRegistryHash.hashFor(r, chainId) + + " " + + p + + " (entries=" + + r.count() + + ", address-bound=" + + r.addressBound() + + ", format=" + + (r.proofBound() ? "v2, bind-height=" + r.bindHeight() : "v1, no proofs") + + ")"); + } + return 0; + } + + // --------------------------------------------------------------------------------------------- + + private static int generate(final Map o, final long chainId) { + final String armingHeightRaw = o.get("arming-height"); + if (armingHeightRaw == null) { + System.out.println("NOT MEASURED: --arming-height is missing. The first entry of the"); + System.out.println(" schedule must be EXACTLY at aere.pq.anchorBlock: a later"); + System.out.println(" first entry leaves the arming height with no scheduled registry."); + return 2; + } + final long h = Long.parseLong(armingHeightRaw); + final List files = files(o, "registry"); + if (files.isEmpty()) { + System.out.println("NOT MEASURED: --registry is missing"); + return 2; + } + // The epochs: the first at the arming height, then one per --rotation =. + final Map epoci = new LinkedHashMap<>(); + epoci.put(h, files.get(0)); + for (final String rot : list(o.get("rotation"))) { + final int eq = rot.indexOf('='); + if (eq < 0) { + System.out.println("NOT MEASURED: --rotation is written =, got: " + rot); + return 2; + } + epoci.put(Long.parseLong(rot.substring(0, eq).trim()), Path.of(rot.substring(eq + 1).trim())); + } + + final List inaltimi = new ArrayList<>(epoci.keySet()); + inaltimi.sort(Long::compare); + if (inaltimi.get(0) != h) { + System.out.println("RED: the first epoch is at " + inaltimi.get(0) + ", not at the arming height " + h); + return 1; + } + + // NO COMMENT INSIDE THE FRAGMENT, and that is a measurement, not a matter of taste. The first + // form of this method put the file path after every entry, as `// /path/registry.json`. + // The fragment LOOKS fine and CANNOT BE USED: genesis is read with Jackson without + // ALLOW_COMMENTS, so a node handed one of those refuses to start with + // [AERE-PQC-REG-LOAD-13] "Unexpected character ('/')", and it then refuses EVERY header from + // the arming height upwards. Measured 2026-08-06, with two controls that reproduce the refusal. + // Whatever is explanation is printed outside the JSON, on lines beginning with #. + final StringBuilder json = new StringBuilder(); + json.append(" \"pqRegistryHash\": [\n"); + final StringBuilder history = new StringBuilder(); + final StringBuilder harta = new StringBuilder(); + for (int i = 0; i < inaltimi.size(); i++) { + final long b = inaltimi.get(i); + final Path p = epoci.get(b); + final PqRegistryHash.Registry reg = PqRegistryHash.loadAuto(p); + // AERE CANONICAL FINGERPRINT (2026-08-06). THIS IS THE DANGEROUS VERB: what is printed here + // gets pasted into genesis, and genesis is the document all seven nodes hold identical. + // hashV1 next to a registry that carries proofs writes into genesis a number the node's guard + // (hashFor) never produces, so all seven nodes start, all report the registry loaded, and the + // chain stops at H-1. A wrong indication is more dangerous than a missing one. + final String hash = PqRegistryHash.hashFor(reg, chainId); + // AERE SIGNED-HEIGHT CHECK (2026-08-06). The recipe we print has to be the one the NEW code + // accepts. Since 2026-08-06 the node refuses to start (AERE-PQC-REG-BIND-08) on a registry + // forced in at a height its proofs did not sign. If the tool printed that recipe, it would + // manufacture exactly the configuration the node rejects, and it would do so in a file that + // reaches all seven at once. + if (reg.proofBound() && reg.bindHeight() != b) { + System.out.println( + "RED: registry " + + p + + " has bind height " + + reg.bindHeight() + + ", while here it is scheduled from block " + + b + + ". The two numbers must be EQUAL: every row of the registry carries a proof of " + + "possession and a claim, and both sign the bind height. A registry forced in from " + + "another block is an activation day no validator signed, and the node refuses to " + + "start on it with AERE-PQC-REG-BIND-08. It is not repaired by editing the " + + "bindHeight field: the proofs are over it, and the file would no longer load at " + + "all (AERE-PQC-REG-BIND-03). It is repaired either by scheduling the epoch at " + + reg.bindHeight() + + ", or by re-signing the registry for " + + b + + ", which means the key ceremony and a NEW hash."); + return 1; + } + json.append(" {\"block\": ").append(b).append(", \"hash\": \"0x").append(hash).append("\"}"); + if (i < inaltimi.size() - 1) { + json.append(','); + } + json.append('\n'); + harta.append("# block ").append(b).append(" -> ").append(p.toAbsolutePath()).append('\n'); + if (history.length() > 0) { + history.append(','); + } + history.append(p.toAbsolutePath()); + } + json.append(" ]"); + + System.out.println("# 1. In the genesis of chain " + chainId + ", inside the \"config\" object:"); + System.out.println("# The fragment below is VALID JSON and is pasted as it stands. No comments"); + System.out.println("# are added to it: genesis is read WITHOUT ALLOW_COMMENTS and the node refuses to start."); + System.out.println(json); + System.out.println("# Which registry file stands behind each epoch:"); + System.out.print(harta); + System.out.println(); + System.out.println("# 2. On EVERY node, the second client included, in BESU_OPTS:"); + System.out.println("-Daere.falcon.registry.history=" + history); + System.out.println(); + System.out.println("# 3. The order, and it is not negotiable: the registry files are placed on all"); + System.out.println("# seven AND the hash goes into genesis BEFORE the restart. A node armed"); + System.out.println("# without a schedule refuses every header from the arming height upwards."); + System.out.println("# 4. After they are placed, on each node:"); + System.out.println("# PqRegistryHashTool verify --chain-id " + chainId + " --genesis --history "); + return 0; + } + + // --------------------------------------------------------------------------------------------- + + private static int verify(final Map o, final long chainId) { + final String genesisRaw = o.get("genesis"); + if (genesisRaw == null) { + System.out.println("NOT MEASURED: --genesis is missing"); + return 2; + } + final Path genesis = Path.of(genesisRaw); + if (!Files.isReadable(genesis)) { + System.out.println("NOT MEASURED: genesis cannot be read: " + genesis); + return 2; + } + + PqRegistryHash.Schedule schedule = PqRegistryHash.loadScheduleFromGenesis(genesis); + if (!schedule.enforced()) { + System.out.println( + "RED: " + + genesis + + " does not carry config.pqRegistryHash. The height-indexed registry schedule " + + "exists, it is complete, and it is OFF. A node armed without a schedule falls back " + + "on TODAY's registry and says ACCEPTED for a header it has bound to no " + + "height."); + return 1; + } + + boolean controlNegativ = o.containsKey("CONTROL-NEGATIV"); + if (controlNegativ) { + schedule = strica(schedule); + System.out.println( + "NEGATIVE CONTROL: the first required hash was changed by ONE hex character, in memory. " + + "The verification below MUST come out RED."); + } + + final List history = files(o, "history"); + final List tinute = new ArrayList<>(); + for (final Path p : history) { + tinute.add(PqRegistryHash.loadAuto(p)); + } + if (tinute.isEmpty()) { + System.out.println( + "WARNING: --history is empty, so no registry was loaded. On a real node the signing " + + "registry is added automatically; here it is asked for explicitly, so that nothing " + + "is reported green on the strength of what was not measured."); + } + + final PqRegistryHash.RegistrySet set = PqRegistryHash.buildSet(schedule, tinute, chainId); + + System.out.println("chainId = " + chainId); + System.out.println("genesis = " + genesis); + System.out.println("schedule source = " + schedule.source()); + System.out.println("epochs scheduled = " + schedule.entries().size()); + for (final PqRegistryHash.ScheduleEntry e : schedule.entries()) { + final boolean acoperita = !set.uncoveredEntryBlocks().contains(e.block()); + System.out.println( + " block " + e.block() + " 0x" + e.hash() + " " + (acoperita ? "COVERED" : "NOT COVERED")); + } + System.out.println("registries held = " + set.count()); + for (final Path p : history) { + // AERE CANONICAL FINGERPRINT: hashFor. This line sits immediately under the list of scheduled + // epochs printed with their hashes; two numbers laid one under the other so that they get + // compared by eye, and computed with two different functions, are a comparison that can never + // match. + final PqRegistryHash.Registry r = PqRegistryHash.loadAuto(p); + System.out.println( + " " + + PqRegistryHash.hashFor(r, chainId) + + " " + + p + + (r.proofBound() ? " (bind-height=" + r.bindHeight() + ")" : " (v1)")); + } + final String armareRaw = o.get("arming-height"); + int rc = 0; + // AERE SIGNED-HEIGHT CHECK: the registries that reproduce the required hash and did NOT sign + // that height. This is exactly what the node now refuses to start on; until 2026-08-06 the node + // started and the tool said nothing. + for (final PqRegistryHash.Misbound m : set.misbound()) { + System.out.println( + "RED: " + + m.registryPath() + + " reproduces the hash required at block " + + m.entryBlock() + + ", but its proofs sign height " + + m.signedHeight() + + ". The node refuses to start with AERE-PQC-REG-BIND-08."); + rc = 1; + } + if (armareRaw != null) { + final long h = Long.parseLong(armareRaw); + final long prima = schedule.entries().get(0).block(); + if (prima != h) { + System.out.println( + "RED: the first entry of the schedule is at " + + prima + + ", while aere.pq.anchorBlock is " + + h + + ". They must be EQUAL. If the first entry is higher, the heights between H and it " + + "are bound to nothing and the node falls back on the head registry exactly where " + + "the certificate begins to matter."); + rc = 1; + } else { + System.out.println("OK: the first epoch is exactly at the arming height " + h + "."); + } + } else { + System.out.println( + "NOT MEASURED: --arming-height was not given, so it was NOT checked that the first " + + "epoch coincides with aere.pq.anchorBlock."); + } + + final List neacoperite = set.uncoveredEntryBlocks(); + if (!neacoperite.isEmpty()) { + System.out.println( + "RED: heights " + + neacoperite + + " are covered by NOTHING this node holds. Every header from such a height upwards " + + "will be REFUSED, and a node syncing from genesis stops there. Name the missing " + + "file in aere.falcon.registry.history."); + rc = 1; + } else { + System.out.println("OK: every epoch in the schedule is covered by a registry this node holds."); + } + + // Positive probe: the registry in force at the height of every epoch resolves right now. + for (final PqRegistryHash.ScheduleEntry e : schedule.entries()) { + final Optional r = + PqRegistryHash.registryAt(schedule, set, e.block()); + if (r.isEmpty()) { + System.out.println("RED: registryAt(" + e.block() + ") resolves nothing."); + rc = 1; + } else if (!PqRegistryHash.matchesAt(schedule, set, e.block(), chainId)) { + System.out.println("RED: registryAt(" + e.block() + ") resolves a registry with a different hash."); + rc = 1; + } + } + + if (controlNegativ) { + if (rc == 1) { + System.out.println("NEGATIVE CONTROL OK: with a single character changed, the verification is RED."); + return 0; + } + System.out.println( + "NEGATIVE CONTROL FAILED: a hash was changed and the verification stayed GREEN. The " + + "verification cannot be trusted."); + return 1; + } + System.out.println(rc == 0 ? "GREEN" : "RED"); + return rc; + } + + // --------------------------------------------------------------------------------------------- + + /** Change exactly one hex digit of the first required hash, through the real parser. */ + private static PqRegistryHash.Schedule strica(final PqRegistryHash.Schedule s) { + final StringBuilder sb = new StringBuilder("{\"config\":{\"pqRegistryHash\":["); + for (int i = 0; i < s.entries().size(); i++) { + final PqRegistryHash.ScheduleEntry e = s.entries().get(i); + String h = e.hash(); + if (i == 0) { + final char c = h.charAt(h.length() - 1); + h = h.substring(0, h.length() - 1) + (c == '0' ? '1' : '0'); + } + if (i > 0) { + sb.append(','); + } + sb.append("{\"block\":").append(e.block()).append(",\"hash\":\"0x").append(h).append("\"}"); + } + sb.append("]}}"); + try { + final Path t = Files.createTempFile("aere-control-negativ-", ".json"); + t.toFile().deleteOnExit(); + Files.writeString(t, sb.toString()); + return PqRegistryHash.loadScheduleFromGenesis(t); + } catch (final java.io.IOException io) { + throw new IllegalStateException("cannot write the negative-control file: " + io, io); + } + } + + private static List files(final Map o, final String cheie) { + final List out = new ArrayList<>(); + for (final String s : list(o.get(cheie))) { + out.add(Path.of(s)); + } + return out; + } + + private static List list(final String raw) { + final List out = new ArrayList<>(); + if (raw == null || raw.isBlank()) { + return out; + } + for (final String s : com.google.common.base.Splitter.on(',').split(raw)) { + if (!s.trim().isEmpty()) { + out.add(s.trim()); + } + } + return out; + } + + private static Map optiuni(final String[] args) { + final Map o = new LinkedHashMap<>(); + for (int i = 1; i < args.length; i++) { + if (!args[i].startsWith("--")) { + continue; + } + final String k = args[i].substring(2); + if (i + 1 < args.length && !args[i + 1].startsWith("--")) { + final String prev = o.get(k); + o.put(k, prev == null ? args[i + 1] : prev + "," + args[i + 1]); + i++; + } else { + o.put(k, ""); + } + } + return o; + } + + private static void utilizare() { + System.out.println("The height-indexed registry schedule: hash, generate, verify."); + System.out.println(); + System.out.println(" hash --chain-id 2800 --registry [,...]"); + System.out.println(" generate --chain-id 2800 --arming-height --registry "); + System.out.println(" [--rotation

= ...]"); + System.out.println(" verify --chain-id 2800 --genesis --history [,...]"); + System.out.println(" [--arming-height ] [--CONTROL-NEGATIV]"); + System.out.println(); + System.out.println("Exit code: 0 green, 1 red, 2 not measurable."); + } +} diff --git a/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqSealCache.java b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqSealCache.java new file mode 100644 index 0000000..e5e61bd --- /dev/null +++ b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqSealCache.java @@ -0,0 +1,351 @@ +/* + * Copyright contributors to Besu / Aere Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft; + +import org.hyperledger.besu.datatypes.Hash; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * AERE ANCHOR V2: the bounded store of Falcon seals this node HEARD for recent blocks, in memory and + * - for the chain head only - on disk. + * + *

WHAT IT IS FOR. The proposer of block N must carry a certificate over block N-1. Under + * V2 that certificate is chosen by the proposer out of what it heard, so the seals gossiped on + * Commit messages have to survive from the height where they arrive to the height where the next + * block is built. That is all this class does. + * + *

KEYED ON THE ON-CHAIN BLOCK HASH, NEVER ON THE COMMIT DIGEST. The digest carried by a Commit + * message is the committed-seal hash, which INCLUDES the round number, so it differs between rounds + * for one and the same block. The producer looks the certificate up by {@code + * parentHeader.getHash()}, which is the round-independent on-chain hash. Storing under anything + * else would silently produce empty certificates at every round change. + * + *

AERE PERSISTENTA-SIGILII (2026-08-05): PERSISTENT FOR THE CHAIN HEAD, AND THE OLD REASONING FOR + * NOT BEING SO WAS MEASURED WRONG. This class used to say, in this exact place, that losing the map + * on restart costs "at most one proposer turn per restart". That reasoning is correct for ONE node + * restarted inside a LIVE fleet, which is the case it was written for. For a fleet restarted + * ENTIRELY it is false, and the falsehood was measured on a seven-node network: with K>0 every + * node came back holding zero seals, no certificate could reach K, nobody could propose, so no + * Commit was ever sent, so no node ever heard a seal again. The chain was dead, permanently, and no + * amount of waiting healed it. The seals over M(head) exist nowhere else: they travel only on the + * Commit messages of the head block, and those are never replayed. + * + *

AND IT IS NOT THE UNBOUND-REGISTRY DEFECT IN ANOTHER COAT. That one was a registry of public + * keys read from a file and BELIEVED. Every seal read back here is re-verified, cryptographically, + * against the anchored registry over M rebuilt from the header this process just loaded - see + * {@link PqSealStore}. A forged file cannot inject a seal without forging a Falcon-512 signature; + * the worst it achieves is the empty cache an absent file already gives. Persistence is OFF unless + * a caller enables it, and the only caller that does is the QBFT controller builder, only when the + * anchor is actually armed. + * + *

WHAT IT DOES NOT DO. The in-memory path verifies nothing. Whether a seal is valid, whether its + * index maps to an eligible validator, and whether there are enough of them, are decided at + * SELECTION time by the producer, against the message M of the specific parent. A seal that was + * signed under the pre-fork message simply fails that check and is dropped, which is what makes the + * height switch safe without any coordination beyond the shared activation height. + */ +public final class PqSealCache { + + private static final Logger LOG = LoggerFactory.getLogger(PqSealCache.class); + + /** How many distinct block hashes are retained. Several rounds at one height each add one. */ + private static final int MAX_ENTRIES = 1024; + + /** How far below the highest seen height entries are kept, in blocks. */ + private static final long HEIGHT_WINDOW = 256L; + + private static final PqSealCache INSTANCE = new PqSealCache(); + + private final LinkedHashMap byHash = new LinkedHashMap<>(); + private long highestSeen = -1L; + + /** Guards the file, never the map, so a slow disk cannot hold up the consensus thread's map. */ + private final Object writeLock = new Object(); + + private volatile Path persistenceFile; + private volatile long persistenceChainId; + + private long persistedBlock = -1L; + private Hash persistedHash; + private int persistedCount = -1; + + private PqSealCache() {} + + /** + * The process-wide cache. + * + * @return the singleton + */ + public static PqSealCache instance() { + return INSTANCE; + } + + /** + * Turn on writing the chain head's seals to the node's data directory. + * + *

The path is DERIVED from the data directory Besu was started with, never configured + * separately: a second configurable path is a second thing that can be pointed at the wrong node's + * state, and this file is per-node by construction. + * + * @param dataDirectory the node's data directory, or null to leave persistence off + * @param chainId the chain id used in the M pre-image, which must be the SAME value the producer + * uses or every restored seal would fail verification + */ + public void enablePersistence(final Path dataDirectory, final long chainId) { + if (dataDirectory == null) { + LOG.warn( + "AERE PERSISTENTA-SIGILII: no data directory was supplied, so heard seals stay in memory " + + "only. A simultaneous restart of the whole fleet with K>0 stops the chain."); + return; + } + this.persistenceChainId = chainId; + this.persistenceFile = PqSealStore.fileIn(dataDirectory); + LOG.info( + "AERE PERSISTENTA-SIGILII: heard seals for the chain head are kept in {} (chainId={}). " + + "Every seal read back is re-verified against the anchored registry before it is used.", + this.persistenceFile, + chainId); + } + + /** + * Turn persistence off again. For tests, and for an operator who wants the previous behaviour. + */ + public void disablePersistence() { + this.persistenceFile = null; + synchronized (writeLock) { + persistedBlock = -1L; + persistedHash = null; + persistedCount = -1; + } + } + + /** + * The file heard seals are written to, or null when persistence is off. + * + * @return the seal file, or null + */ + public Path persistenceFile() { + return persistenceFile; + } + + /** + * Refill the cache from disk for a specific block, normally this node's chain head at startup. + * + *

Call this AFTER the Falcon registry has been activated, or every seal will be rejected for + * the entirely correct reason that the registry cannot resolve its index yet, and the restore will + * silently do nothing. + * + * @param blockNumber the head block's height + * @param onchainBlockHash the head block's ON-CHAIN hash + * @param registry the anchored registry every restored seal is re-verified against + * @return how many seals survived verification and entered the cache + */ + public int restoreFromDisk( + final long blockNumber, final Hash onchainBlockHash, final PqSignerRegistry registry) { + final Path file = persistenceFile; + if (file == null) { + return 0; + } + final List verified; + try { + verified = + PqSealStore.readVerified( + file, persistenceChainId, blockNumber, onchainBlockHash, registry); + } catch (final RuntimeException e) { + // readVerified is written not to throw; this is the belt on top of the braces, because an + // exception escaping here would turn a lost cache into a node that refuses to start. + LOG.warn( + "AERE PERSISTENTA-SIGILII: restoring seals from {} failed unexpectedly ({}); the cache " + + "starts empty, which is exactly the behaviour before this file existed.", + file, + e.toString()); + return 0; + } + if (verified.isEmpty()) { + return 0; + } + synchronized (this) { + final Entry entry = + byHash.computeIfAbsent(onchainBlockHash, unused -> new Entry(blockNumber)); + for (final FalconSeal seal : verified) { + entry.seals.putIfAbsent(seal.getValidatorIndex(), seal); + } + if (blockNumber > highestSeen) { + highestSeen = blockNumber; + } + prune(); + } + return verified.size(); + } + + /** + * Remember the seals heard for a block. Idempotent per validator index: the first seal seen for + * an index wins, so a later duplicate cannot displace it. + * + * @param blockNumber the height of the block the seals attest + * @param onchainBlockHash the ON-CHAIN hash of that block + * @param seals the seals heard, possibly empty + */ + public void record( + final long blockNumber, final Hash onchainBlockHash, final Collection seals) { + final List toPersist; + synchronized (this) { + if (onchainBlockHash == null || seals == null || seals.isEmpty()) { + return; + } + final Entry entry = + byHash.computeIfAbsent(onchainBlockHash, unused -> new Entry(blockNumber)); + for (final FalconSeal seal : seals) { + if (seal != null && seal.getValidatorIndex() >= 0 && seal.getSignature() != null) { + entry.seals.putIfAbsent(seal.getValidatorIndex(), seal); + } + } + if (blockNumber > highestSeen) { + highestSeen = blockNumber; + } + prune(); + if (persistenceFile == null || blockNumber < highestSeen) { + // Only the HIGHEST block is worth a file: the producer of the next block asks for the head's + // seals and nothing else, and writing older heights would just churn the disk. + return; + } + toPersist = PqAnchor.sortedByIndex(new ArrayList<>(entry.seals.values())); + } + // Deliberately OUTSIDE the map lock: a file write must never be able to stall a caller that only + // wants to read the cache. + persist(blockNumber, onchainBlockHash, toPersist); + } + + private void persist( + final long blockNumber, final Hash onchainBlockHash, final List seals) { + final Path file = persistenceFile; + if (file == null) { + return; + } + synchronized (writeLock) { + if (blockNumber < persistedBlock) { + return; + } + if (blockNumber == persistedBlock + && onchainBlockHash.equals(persistedHash) + && seals.size() <= persistedCount) { + return; + } + try { + PqSealStore.writeAtomically( + file, + PqSealStore.encode( + persistenceChainId, blockNumber, onchainBlockHash.getBytes(), seals)); + persistedBlock = blockNumber; + persistedHash = onchainBlockHash; + persistedCount = seals.size(); + } catch (final Exception e) { + // A disk fault must not be able to stop this node from taking part in consensus. It costs + // the restart protection, which is what the log line says, and nothing else. + LOG.warn( + "AERE PERSISTENTA-SIGILII: could not write the {} heard seal(s) for block {} to {} " + + "({}). This node keeps working; it just loses its seals if it restarts now.", + seals.size(), + blockNumber, + file, + e.toString()); + } + } + } + + /** + * The seals heard for a block, in canonical order. + * + * @param blockNumber the height of the block the seals must attest + * @param onchainBlockHash the ON-CHAIN hash of that block + * @return the seals, sorted by strictly increasing validator index, possibly empty + */ + public synchronized List sealsFor( + final long blockNumber, final Hash onchainBlockHash) { + final Entry entry = byHash.get(onchainBlockHash); + if (entry == null || entry.blockNumber != blockNumber) { + return List.of(); + } + return PqAnchor.sortedByIndex(new ArrayList<>(entry.seals.values())); + } + + /** + * How many block hashes are currently retained. + * + * @return the number of entries + */ + public synchronized int entryCount() { + return byHash.size(); + } + + /** + * How many seals are retained for a block hash. + * + * @param onchainBlockHash the ON-CHAIN hash + * @return the seal count, 0 when nothing is retained + */ + public synchronized int sealCount(final Hash onchainBlockHash) { + final Entry entry = byHash.get(onchainBlockHash); + return entry == null ? 0 : entry.seals.size(); + } + + /** + * Drop everything held in memory. For tests, and for a deliberate operator reset. The file, if + * one exists, is deliberately left alone: it is rewritten by the next commit this node hears. + */ + public synchronized void clear() { + byHash.clear(); + highestSeen = -1L; + synchronized (writeLock) { + persistedBlock = -1L; + persistedHash = null; + persistedCount = -1; + } + } + + private void prune() { + final Iterator> it = byHash.entrySet().iterator(); + while (it.hasNext()) { + final Map.Entry e = it.next(); + if (highestSeen - e.getValue().blockNumber > HEIGHT_WINDOW) { + it.remove(); + } + } + while (byHash.size() > MAX_ENTRIES) { + final Iterator oldest = byHash.keySet().iterator(); + oldest.next(); + oldest.remove(); + } + } + + private static final class Entry { + private final long blockNumber; + private final Map seals = new LinkedHashMap<>(); + + private Entry(final long blockNumber) { + this.blockNumber = blockNumber; + } + } +} diff --git a/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqSealStore.java b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqSealStore.java new file mode 100644 index 0000000..fecb299 --- /dev/null +++ b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqSealStore.java @@ -0,0 +1,384 @@ +/* + * Copyright contributors to Besu / Aere Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft; + +import org.hyperledger.besu.datatypes.Hash; +import org.hyperledger.besu.ethereum.rlp.BytesValueRLPInput; +import org.hyperledger.besu.ethereum.rlp.BytesValueRLPOutput; +import org.hyperledger.besu.ethereum.rlp.RLPInput; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import org.apache.tuweni.bytes.Bytes; +import org.apache.tuweni.bytes.Bytes32; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * AERE PERSISTENTA-SIGILII (2026-08-05): the ON-DISK half of {@link PqSealCache}, holding the Falcon + * seals this node heard for its CHAIN HEAD so that a restart does not start from zero. + * + *

THE SECOND HALF OF A MEASURED CHAIN DEATH. With the anchor armed at K>0, a + * SIMULTANEOUS restart of the whole fleet was measured to kill the chain for good. The first half of + * the deadlock was the registry, which only ever activated while a block was being imported; that is + * repaired (QbftBesuControllerBuilder, marker "AERE BLOCAJ-REPORNIRE") and the seven nodes now report + * "anchor activation at STARTUP from head state: SUCCEEDED". The chain still died, with the refusal + * changing shape only: "registry address-bound=TRUE ... Heard 0 seal(s)". This class is the second + * half. The seals over M(head) travel on nothing but the Commit messages of the head block, which are + * never replayed after a restart, so every node came back holding zero seals, nobody could reach K, + * and nobody could propose. Seals come from Commits, Commits come from proposals, proposals need + * seals - the same circle, one level down. + * + *

WHY THIS IS NOT THE UNBOUND-REGISTRY DEFECT IN ANOTHER COAT, and the distinction is the + * whole safety argument. That one was a REGISTRY read from a local file and BELIEVED: the + * public keys that decide who is a legitimate signer came out of a file a node could be pointed at + * wrongly, so the file was authority. Nothing here is believed. A Falcon seal is SELF-AUTHENTICATING: {@link + * #readVerified(Path, long, long, Hash, PqSignerRegistry)} re-verifies EVERY seal it reads against + * the anchored registry, over the message M rebuilt from the chain-head header this process just + * loaded, exactly as the producer does at selection time. A forged, edited or replayed file cannot + * inject a seal, because forging one means forging a Falcon-512 signature. The worst a hostile file + * can achieve is what an absent file already achieves: an empty cache, which is today's behaviour. + * + *

WHAT THE FILE IS BOUND TO. The domain label, the chain id, the block NUMBER and the block + * HASH are all in the file and all checked before a single signature is verified. A file from another + * chain, another height, or another fork of the same height is discarded whole. That is not a + * security property (the signature check is), it is an honesty property: it makes "these are the + * seals for THIS head" checkable rather than assumed. + * + *

FAILURE IS NEVER FATAL. Missing, truncated, corrupt, oversized, from another height, or + * unreadable for any reason at all: the answer is an empty list and a log line, never an exception + * that reaches the caller. A node that cannot read its seal file must behave exactly like a node that + * has none, because that is precisely the state this whole class exists to make survivable. + */ +public final class PqSealStore { + + private static final Logger LOG = LoggerFactory.getLogger(PqSealStore.class); + + /** The file name, inside the node's data directory. Never an absolute configured path. */ + public static final String FILE_NAME = "aere-pq-seals.rlp"; + + /** The temporary file the atomic write goes through before the rename. */ + public static final String TEMP_FILE_NAME = "aere-pq-seals.rlp.tmp"; + + /** Domain label, so a file written for any other purpose cannot be read as a seal set. */ + public static final String DOMAIN = "AERE-PQ-SEALSTORE-1"; + + private static final Bytes DOMAIN_BYTES = + Bytes.wrap(DOMAIN.getBytes(StandardCharsets.US_ASCII)); + + /** + * Refuse to even parse a file larger than this. A Falcon-512 signature is 666 bytes and a fleet is + * seven, so a real file is about 5 kB; 1 MiB is four orders of magnitude of headroom and still + * bounds what a corrupt or hostile file can make this process allocate. + */ + public static final int MAX_FILE_BYTES = 1024 * 1024; + + /** Refuse to accumulate more seals than this from one file, for the same reason. */ + public static final int MAX_SEALS = 1024; + + /** + * System property forcing an fsync of the temporary file before the rename. Default true: the + * whole point of the file is to survive an unplanned restart, and the measured cost is small + * against the block interval. + */ + public static final String PROPERTY_FSYNC = "aere.pq.sealStore.fsync"; + + private PqSealStore() {} + + /** + * The seal file inside a node's data directory. + * + * @param dataDirectory the node's data directory, as Besu was started with + * @return the path of the seal file + */ + public static Path fileIn(final Path dataDirectory) { + return dataDirectory.resolve(FILE_NAME); + } + + /** + * The canonical bytes of a seal set. + * + *

The certificate itself is written by {@link PqAnchor#writeCertificate}, the SAME encoder that + * produces the bytes carried in element 6 of a header and hashed into the anchor digest. One + * logical object, one byte string, no second encoder to drift. + * + * @param chainId the chain id, also part of the M pre-image + * @param blockNumber the height of the block the seals attest + * @param blockHash the ON-CHAIN hash of that block + * @param seals the seals to store, in the order given + * @return the file payload + */ + public static byte[] encode( + final long chainId, + final long blockNumber, + final Bytes blockHash, + final Collection seals) { + final BytesValueRLPOutput out = new BytesValueRLPOutput(); + out.startList(); + out.writeBytes(DOMAIN_BYTES); + out.writeLongScalar(chainId); + out.writeLongScalar(blockNumber); + out.writeBytes(blockHash); + PqAnchor.writeCertificate(out, seals); + out.endList(); + return out.encoded().toArrayUnsafe(); + } + + /** + * Write the payload so that no reader can ever observe a half-written file. + * + *

Temporary file, optional fsync, then an ATOMIC_MOVE onto the target. A process killed at any + * instant leaves either the previous complete file or the new complete file, never a prefix of + * either. The fallback to a non-atomic move exists because ATOMIC_MOVE is not universally + * supported; a filesystem that cannot do it is still better served by a rename than by writing in + * place. + * + * @param file the target file + * @param payload the bytes to write + * @throws IOException if the write or the rename fails + */ + public static void writeAtomically(final Path file, final byte[] payload) throws IOException { + final Path directory = file.toAbsolutePath().getParent(); + if (directory != null) { + Files.createDirectories(directory); + } + final Path temp = + directory == null ? Path.of(TEMP_FILE_NAME) : directory.resolve(TEMP_FILE_NAME); + try (FileChannel channel = + FileChannel.open( + temp, + StandardOpenOption.CREATE, + StandardOpenOption.WRITE, + StandardOpenOption.TRUNCATE_EXISTING)) { + final ByteBuffer buffer = ByteBuffer.wrap(payload); + while (buffer.hasRemaining()) { + channel.write(buffer); + } + if (!"false".equalsIgnoreCase(System.getProperty(PROPERTY_FSYNC))) { + channel.force(true); + } + } + try { + Files.move( + temp, file, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (final UnsupportedOperationException | IOException atomicUnsupported) { + Files.move(temp, file, StandardCopyOption.REPLACE_EXISTING); + } + } + + /** + * Read the seals stored for a block and return ONLY those that verify RIGHT NOW against the + * anchored registry. + * + *

Every seal is put through the same two questions the producer asks at selection time: does + * the index map to a signer in the registry, and does the Falcon signature verify over M(this + * block). Nothing is accepted because it is in the file. What is deliberately NOT re-checked here + * is validator-set eligibility, and that is not a gap: the producer re-checks it against the + * validator set FOR THE PARENT every time it assembles a certificate, so a seal from a former + * validator is dropped there whether it came from a Commit message or from this file. + * + * @param file the seal file + * @param chainId the chain id this node runs, part of the M pre-image + * @param blockNumber the height the seals must attest, normally this node's chain head + * @param blockHash the ON-CHAIN hash the seals must attest + * @param registry the anchored Falcon registry to verify against + * @return the surviving seals sorted by strictly increasing index, empty on any fault whatsoever + */ + public static List readVerified( + final Path file, + final long chainId, + final long blockNumber, + final Hash blockHash, + final PqSignerRegistry registry) { + + if (file == null || registry == null || blockHash == null) { + return List.of(); + } + + final byte[] raw; + try { + if (!Files.isRegularFile(file)) { + LOG.debug("AERE PERSISTENTA-SIGILII: no seal file at {}; starting with an empty cache.", file); + return List.of(); + } + final long size = Files.size(file); + if (size <= 0 || size > MAX_FILE_BYTES) { + LOG.warn( + "AERE PERSISTENTA-SIGILII: seal file {} has an implausible size ({} bytes); IGNORED, " + + "the cache starts empty exactly as it did before this file existed.", + file, + size); + return List.of(); + } + raw = Files.readAllBytes(file); + } catch (final Exception e) { + LOG.warn( + "AERE PERSISTENTA-SIGILII: seal file {} could not be read ({}); IGNORED, the cache starts " + + "empty. A node that cannot read this file must behave like a node that has none.", + file, + e.toString()); + return List.of(); + } + + final List decoded = new ArrayList<>(); + final long storedNumber; + final Bytes storedHash; + try { + final RLPInput in = new BytesValueRLPInput(Bytes.wrap(raw), false); + in.enterList(); + final Bytes domain = in.readBytes(); + if (!DOMAIN_BYTES.equals(domain)) { + LOG.warn( + "AERE PERSISTENTA-SIGILII: seal file {} carries domain {} instead of {}; IGNORED.", + file, + domain, + DOMAIN); + return List.of(); + } + final long storedChainId = in.readLongScalar(); + if (storedChainId != chainId) { + LOG.warn( + "AERE PERSISTENTA-SIGILII: seal file {} was written for chain {} and this node runs " + + "chain {}; IGNORED.", + file, + storedChainId, + chainId); + return List.of(); + } + storedNumber = in.readLongScalar(); + storedHash = in.readBytes(); + in.enterList(); + while (!in.isEndOfCurrentList()) { + if (decoded.size() >= MAX_SEALS) { + LOG.warn( + "AERE PERSISTENTA-SIGILII: seal file {} holds more than {} seals; IGNORED.", + file, + MAX_SEALS); + return List.of(); + } + in.enterList(); + final int index = in.readIntScalar(); + final Bytes signature = in.readBytes(); + in.leaveList(); + decoded.add(new FalconSeal(index, signature)); + } + in.leaveList(); + in.leaveList(); + } catch (final RuntimeException e) { + LOG.warn( + "AERE PERSISTENTA-SIGILII: seal file {} is CORRUPT and did not decode ({}); IGNORED, the " + + "cache starts empty. This costs at most one proposer turn; refusing to start would " + + "cost the chain.", + file, + e.toString()); + return List.of(); + } + + if (storedNumber != blockNumber || !storedHash.equals(blockHash.getBytes())) { + LOG.info( + "AERE PERSISTENTA-SIGILII: seal file {} holds seals for block {} ({}), this node's head is " + + "{} ({}); IGNORED. Seals over another block are of no use to the proposer of the " + + "next one.", + file, + storedNumber, + storedHash, + blockNumber, + blockHash); + return List.of(); + } + + final Bytes32 message = PqAnchor.commitMessage(chainId, blockNumber, blockHash.getBytes()); + final List verified = new ArrayList<>(); + final Set seen = new LinkedHashSet<>(); + int rejectedUnknownIndex = 0; + int rejectedSignature = 0; + for (final FalconSeal seal : PqAnchor.sortedByIndex(decoded)) { + if (seal.getValidatorIndex() < 0 + || seal.getSignature() == null + || !seen.add(seal.getValidatorIndex()) + // HEIGHT-RESOLVED LOOKUP (2026-08-06). These seals are over block `blockNumber`, which the + // caller has already matched against the stored header, so the height is known exactly. + // On a restart at the head this resolves to the same registry it always did; the point is + // that it can no longer resolve to a DIFFERENT one without saying so. + // OWN-HEAD DOOR (b-v2): the caller has already refused this file unless the + // stored block number and hash equal this node's head, so the subject is this node's + // own head by construction. + || registry.addressForIndexAtOwnHead(blockNumber, seal.getValidatorIndex()) == null) { + rejectedUnknownIndex++; + continue; + } + if (!sealVerifiesAgainstAnchoredRegistry( + registry, blockNumber, message, seal.getValidatorIndex(), seal.getSignature())) { + rejectedSignature++; + continue; + } + verified.add(seal); + } + + LOG.info( + "AERE PERSISTENTA-SIGILII: restored {} of {} stored seal(s) for head {} from {}; {} had no " + + "registry index, {} FAILED Falcon verification over M(head) and were dropped.", + verified.size(), + decoded.size(), + blockNumber, + file, + rejectedUnknownIndex, + rejectedSignature); + return verified; + } + + /** + * THE LOAD-BEARING LINE OF THIS WHOLE FILE, isolated so that removing it is a one-line edit and + * the test suite's negative control can prove that removing it turns the forged-seal test RED. + * + *

This is the same call, on the same registry interface, that {@code PqAnchorProducer.apply} + * makes when it decides which heard seals may enter a certificate. Nothing about a seal is trusted + * because it was on disk. + * + *

HEIGHT-RESOLVED LOOKUP (2026-08-06): it now carries the HEIGHT the seals belong to. The + * adversarial review of + * 2026-08-02 measured that every registry question in this stack was height-less, so a restart + * after a key rotation re-checked seals over an old block against today's keys and dropped them + * all as forged. Here the height is not in doubt: the caller has already refused the file unless + * the stored block number and hash equal this node's head. + * + * @param registry the anchored Falcon registry + * @param blockNumber the height of the block the stored seals attest + * @param message the message M over the block the seals attest + * @param validatorIndex the seal's registry index + * @param signature the Falcon-512 signature bytes + * @return true iff the signature verifies + */ + private static boolean sealVerifiesAgainstAnchoredRegistry( + final PqSignerRegistry registry, + final long blockNumber, + final Bytes32 message, + final int validatorIndex, + final Bytes signature) { + return registry.verifyAtOwnHead(blockNumber, validatorIndex, message, signature); + } +} diff --git a/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqSignerRegistry.java b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqSignerRegistry.java new file mode 100644 index 0000000..3646b9a --- /dev/null +++ b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqSignerRegistry.java @@ -0,0 +1,172 @@ +/* + * Copyright contributors to Besu / Aere Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft; + +import org.hyperledger.besu.datatypes.Address; + +import org.apache.tuweni.bytes.Bytes; + +/** + * The index-to-signer view the anchor seals rule needs: which validator ADDRESS a registry index + * belongs to, and whether a Falcon signature by that index verifies over a message. + * + *

This exists as an interface for one reason that matters and one that is convenience. The one + * that matters: header validation must be able to state its dependency on the registry explicitly, + * so the registry can be bound to consensus (genesis {@code pqRegistryHash}) rather than picked up + * implicitly from a process-wide singleton whose contents are a local file. The convenience: a rule + * with an injectable registry can be unit-tested without standing up the whole Falcon subsystem. + * + *

The production implementation is {@link #falconSealSupport()}, which resolves {@link + * FalconSealSupport#instance()} LAZILY, per call. Lazily matters: constructing a rule must not + * trigger the Falcon subsystem's start-up guards, because rules are constructed while the protocol + * schedule is being built and the ONE place that deliberately forces those guards early is {@code + * QbftBlockHeaderValidationRulesetFactory}, which does it explicitly. + */ +public interface PqSignerRegistry { + + /** + * HEIGHT-INDEXED REGISTRY, LOOKUP HARDENING (a). The validator address bound to a registry index + * AT A HEIGHT. + * + *

WHY THERE IS NO HEIGHT-LESS FORM HERE, and why this is the change and not a nicety. Until + * 2026-08-06 this interface carried BOTH {@code addressForIndex(int)} and a {@code + * addressForIndexAt(long,int)} whose body was {@code default { return addressForIndex(idx); }}. + * That default is precisely the defect an adversarial review of 2026-08-02 measured: a registry + * has no height argument, so a header that verified yesterday is refused the moment index 0's + * Falcon key is rotated. The default made the defect INVISIBLE TO ITS OWN PROOF - that review's + * probe injected a registry that overrides only the height-less pair, inherits the default, and + * therefore returns exactly the same verdict on repaired and unrepaired code. A probe that cannot + * go red is not a probe. + * + *

So the height-less pair is DELETED rather than deprecated, and both survivors are abstract. + * The compiler is now the negative control: any implementation, test double included, that cannot + * answer "at which height" fails to compile. The signing side keeps its height-less identity, but + * on {@link FalconSealSupport} and under a name that cannot be mistaken for a verification path - + * see {@code FalconSealSupport.localSigningAddress()}. + * + *

LOOKUP HARDENING (b-v2), 2026-08-06: this is the HISTORY half of the pair. It is reachable + * only from the two header-validation rules, and it REFUSES an unbound height at or above the + * arming height. The own-head half is {@link #addressForIndexAtOwnHead}, which carries the + * measurement that forced the split. + * + * @param blockNumber the height of the header being validated + * @param validatorIndex the index carried by a Falcon seal + * @return the bound address, or null when the index is unregistered at that height, the registry + * active there is not address-bound, or no registry is covered there at all + */ + Address addressForIndexAtHistoric(long blockNumber, int validatorIndex); + + /** + * LOOKUP HARDENING (b-v2). The address bound to a registry index at a height, asked about THIS + * NODE'S + * OWN HEAD: a block this node is building, or the head it has just restarted onto. + * + *

WHY THIS SECOND NAME EXISTS, and it is a measurement and not a taste. The first shape of + * hardening (b) refused every unbound height at or above the arming height and decided that from + * the block NUMBER alone. On 2026-08-06 that turned six tests red - five in {@code + * PqSealPersistenceTest}, the restart path, and one in {@code PqForkValidatorSetChangeTest}, the + * proposer - and that test's message states the operational consequence in one line: the node + * stops producing blocks. In all six the number handed to the guard was 1030 with an arming + * height of 1000, which is exactly what a genuinely historical question at the same instant would + * hand it. + * There is no arithmetic on the height that separates the two. What separates them is WHO SUPPLIES + * THE SUBJECT, and that is known at every call site and was being thrown away at the boundary. + * + *

THIS pair does not refuse for a missing height binding, because at this node's own head the + * head registry IS the answer by construction, and a wrong local acceptance cannot manufacture + * history: the certificate is re-checked by the other six through {@code PqAnchorSealsRule}, on + * the HISTORIC pair, at the parent's height. The cost of a mistake here is one lost round, not a + * branch. It still returns null when a schedule IS configured and covers this height with a + * registry this node does not hold - that is not a missing binding, it is a node running a + * registry the chain has moved off, and that must fail closed on every path. + * + * @param blockNumber this node's own head, or the block it is building + * @param validatorIndex the registry index + * @return the bound address, or null + */ + Address addressForIndexAtOwnHead(long blockNumber, int validatorIndex); + + /** + * HEIGHT-INDEXED REGISTRY, LOOKUP HARDENING (a). Verify a Falcon signature by a registry index AT + * A HEIGHT. Must never + * throw. Abstract for the reason given on {@link #addressForIndexAtHistoric}. + * + * @param blockNumber the height of the header carrying the seal + * @param validatorIndex the signer's registry index + * @param message the 32-byte message M that was signed + * @param signature the Falcon-512 signature bytes + * @return true iff a public key exists for the index at that height and the signature verifies + */ + boolean verifyAtHistoric(long blockNumber, int validatorIndex, Bytes message, Bytes signature); + + /** + * LOOKUP HARDENING (b-v2). Verify a Falcon signature over a block THIS NODE holds as its own head + * or + * is building right now. Never refuses for a missing height binding; see {@link + * #addressForIndexAtOwnHead} for the measurement that forced the split and for what it still does + * refuse. + * + * @param blockNumber this node's own head, or the block it is building + * @param validatorIndex the signer's registry index + * @param message the 32-byte message M that was signed + * @param signature the Falcon-512 signature bytes + * @return true iff a key exists for the index there and the signature verifies + */ + boolean verifyAtOwnHead(long blockNumber, int validatorIndex, Bytes message, Bytes signature); + + /** + * The production registry, backed by the Falcon subsystem singleton and resolved per call. + * + * @return a registry view over {@link FalconSealSupport#instance()} + */ + static PqSignerRegistry falconSealSupport() { + return new PqSignerRegistry() { + @Override + public Address addressForIndexAtHistoric(final long blockNumber, final int validatorIndex) { + return FalconSealSupport.instance().addressForIndexAtHistoric(blockNumber, validatorIndex); + } + + @Override + public boolean verifyAtHistoric( + final long blockNumber, + final int validatorIndex, + final Bytes message, + final Bytes signature) { + return FalconSealSupport.instance() + .verifyAtHistoric(blockNumber, validatorIndex, message, signature); + } + + @Override + public Address addressForIndexAtOwnHead(final long blockNumber, final int validatorIndex) { + return FalconSealSupport.instance().addressForIndexAtOwnHead(blockNumber, validatorIndex); + } + + @Override + public boolean verifyAtOwnHead( + final long blockNumber, + final int validatorIndex, + final Bytes message, + final Bytes signature) { + return FalconSealSupport.instance() + .verifyAtOwnHead(blockNumber, validatorIndex, message, signature); + } + + @Override + public String toString() { + return "FalconSealSupport"; + } + }; + } +} diff --git a/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/blockcreation/BftBlockCreatorFactory.java b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/blockcreation/BftBlockCreatorFactory.java new file mode 100644 index 0000000..c5a78af --- /dev/null +++ b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/blockcreation/BftBlockCreatorFactory.java @@ -0,0 +1,225 @@ +/* + * Copyright ConsenSys AG. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft.blockcreation; + +import static com.google.common.base.Preconditions.checkState; + +import org.hyperledger.besu.config.BftConfigOptions; +import org.hyperledger.besu.consensus.common.ConsensusHelpers; +import org.hyperledger.besu.consensus.common.ForksSchedule; +import org.hyperledger.besu.consensus.common.bft.BftContext; +import org.hyperledger.besu.consensus.common.bft.BftExtraData; +import org.hyperledger.besu.consensus.common.bft.BftExtraDataCodec; +import org.hyperledger.besu.consensus.common.bft.Vote; +import org.hyperledger.besu.consensus.common.validator.ValidatorProvider; +import org.hyperledger.besu.consensus.common.validator.ValidatorVote; +import org.hyperledger.besu.consensus.common.validator.VoteProvider; +import org.hyperledger.besu.datatypes.Address; +import org.hyperledger.besu.datatypes.Wei; +import org.hyperledger.besu.ethereum.ProtocolContext; +import org.hyperledger.besu.ethereum.blockcreation.BlockCreator; +import org.hyperledger.besu.ethereum.core.BlockHeader; +import org.hyperledger.besu.ethereum.core.MiningConfiguration; +import org.hyperledger.besu.ethereum.eth.manager.EthScheduler; +import org.hyperledger.besu.ethereum.eth.transactions.TransactionPool; +import org.hyperledger.besu.ethereum.mainnet.AbstractGasLimitSpecification; +import org.hyperledger.besu.ethereum.mainnet.ProtocolSchedule; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import org.apache.tuweni.bytes.Bytes; + +/** + * The Bft block creator factory. + * + * @param the type parameter + */ +public class BftBlockCreatorFactory { + /** The Forks schedule. */ + protected final ForksSchedule forksSchedule; + + /** The Mining parameters */ + protected final MiningConfiguration miningConfiguration; + + private final TransactionPool transactionPool; + + /** The Protocol context. */ + protected final ProtocolContext protocolContext; + + /** The Protocol schedule. */ + protected final ProtocolSchedule protocolSchedule; + + /** The Bft extra data codec. */ + protected final BftExtraDataCodec bftExtraDataCodec; + + /** The scheduler for asynchronous block creation tasks */ + protected final EthScheduler ethScheduler; + + private final Address localAddress; + + /** + * Instantiates a new Bft block creator factory. + * + * @param transactionPool the pending transactions + * @param protocolContext the protocol context + * @param protocolSchedule the protocol schedule + * @param forksSchedule the forks schedule + * @param miningParams the mining params + * @param localAddress the local address + * @param bftExtraDataCodec the bft extra data codec + * @param ethScheduler the scheduler for asynchronous block creation tasks + */ + public BftBlockCreatorFactory( + final TransactionPool transactionPool, + final ProtocolContext protocolContext, + final ProtocolSchedule protocolSchedule, + final ForksSchedule forksSchedule, + final MiningConfiguration miningParams, + final Address localAddress, + final BftExtraDataCodec bftExtraDataCodec, + final EthScheduler ethScheduler) { + this.transactionPool = transactionPool; + this.protocolContext = protocolContext; + this.protocolSchedule = protocolSchedule; + this.forksSchedule = forksSchedule; + this.localAddress = localAddress; + this.miningConfiguration = miningParams; + this.bftExtraDataCodec = bftExtraDataCodec; + this.ethScheduler = ethScheduler; + } + + /** + * Create block creator. + * + * @param round the round + * @return the block creator + */ + public BlockCreator create(final int round) { + return new BftBlockCreator( + miningConfiguration, + forksSchedule, + localAddress, + ph -> createExtraData(round, ph), + transactionPool, + protocolContext, + protocolSchedule, + bftExtraDataCodec, + ethScheduler); + } + + /** + * Sets min transaction gas price. + * + * @param minTransactionGasPrice the min transaction gas price + */ + public void setMinTransactionGasPrice(final Wei minTransactionGasPrice) { + miningConfiguration.setMinTransactionGasPrice(minTransactionGasPrice); + } + + /** + * Gets min transaction gas price. + * + * @return the min transaction gas price + */ + public Wei getMinTransactionGasPrice() { + return miningConfiguration.getMinTransactionGasPrice(); + } + + /** + * Gets min priority fee per gas + * + * @return min priority fee per gas + */ + public Wei getMinPriorityFeePerGas() { + return miningConfiguration.getMinPriorityFeePerGas(); + } + + /** + * Create extra data bytes. + * + * @param round the round + * @param parentHeader the parent header + * @return the bytes + */ + public Bytes createExtraData(final int round, final BlockHeader parentHeader) { + return bftExtraDataCodec.encode(buildExtraData(round, parentHeader)); + } + + /** + * Build the extra data VALUE for a block on this parent, before it is encoded. + * + *

AERE ANCHOR V2: split out of {@link #createExtraData(int, BlockHeader)} with NO behaviour + * change, so a subclass can post-process the value (QBFT rewrites vanityData with the anchor + * digest and fills element 6 with the parent's certificate) without decoding and + * re-encoding bytes it just produced. IBFT, which shares this class, keeps the old path exactly: + * it never overrides this and never sees the anchor. + * + * @param round the round + * @param parentHeader the parent header + * @return the extra data value + */ + protected BftExtraData buildExtraData(final int round, final BlockHeader parentHeader) { + final BftContext bftContext = protocolContext.getConsensusContext(BftContext.class); + final ValidatorProvider validatorProvider = bftContext.getValidatorProvider(); + Optional voteProviderAfterBlock = + validatorProvider.getVoteProviderAfterBlock(parentHeader); + checkState(voteProviderAfterBlock.isPresent(), "Bft requires a vote provider"); + final Optional proposal = + voteProviderAfterBlock.get().getVoteAfterBlock(parentHeader, localAddress); + + final List

validators = + new ArrayList<>(validatorProvider.getValidatorsAfterBlock(parentHeader)); + + final BftExtraData extraData = + new BftExtraData( + ConsensusHelpers.zeroLeftPad( + miningConfiguration.getExtraData(), BftExtraDataCodec.EXTRA_VANITY_LENGTH), + Collections.emptyList(), + toVote(proposal), + round, + validators); + + return extraData; + } + + /** + * Change target gas limit. + * + * @param newTargetGasLimit the new target gas limit + */ + public void changeTargetGasLimit(final Long newTargetGasLimit) { + if (AbstractGasLimitSpecification.isValidTargetGasLimit(newTargetGasLimit)) { + miningConfiguration.setTargetGasLimit(newTargetGasLimit); + } else { + throw new UnsupportedOperationException("Specified target gas limit is invalid"); + } + } + + /** + * Gets local address. + * + * @return the local address + */ + public Address getLocalAddress() { + return localAddress; + } + + private static Optional toVote(final Optional input) { + return input.map(v -> new Vote(v.getRecipient(), v.getVotePolarity())); + } +} diff --git a/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/blockcreation/PqAnchorProducer.java b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/blockcreation/PqAnchorProducer.java new file mode 100644 index 0000000..8ff0f9d --- /dev/null +++ b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/blockcreation/PqAnchorProducer.java @@ -0,0 +1,347 @@ +/* + * Copyright contributors to Besu / Aere Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft.blockcreation; + +import org.hyperledger.besu.consensus.common.bft.BftContext; +import org.hyperledger.besu.consensus.common.bft.BftExtraData; +import org.hyperledger.besu.consensus.common.bft.FalconSeal; +import org.hyperledger.besu.consensus.common.bft.FalconSealSupport; +import org.hyperledger.besu.consensus.common.bft.PqAnchor; +import org.hyperledger.besu.consensus.common.bft.PqAnchorConfig; +import org.hyperledger.besu.consensus.common.bft.PqAnchorNotReadyException; +import org.hyperledger.besu.consensus.common.bft.PqSealCache; +import org.hyperledger.besu.consensus.common.bft.PqSignerRegistry; +import org.hyperledger.besu.datatypes.Address; +import org.hyperledger.besu.ethereum.ProtocolContext; +import org.hyperledger.besu.ethereum.core.BlockHeader; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.apache.tuweni.bytes.Bytes32; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * AERE ANCHOR V2, THE PRODUCER SIDE: the proposer of block N chooses the certificate over + * block N-1 out of what IT heard, writes it into element 6 of N's extraData, and writes the anchor + * digest D of that certificate into all 32 bytes of N's vanityData. + * + *

WHY THE OBJECT MOVED. Option V summarised the certificate sitting in the PARENT's header. + * Measured on the live chain, that object is not unique: over 200 consecutive headers read + * simultaneously from two nodes with identical block hashes, seal ORDER differed on 68 and the seal + * SET differed on 11, because RoundState keeps commits in a LinkedHashMap keyed on author and each + * node writes what IT heard. V2 summarises an object ONE node writes, which reaches everybody as + * the same bytes inside the same proposal. This class is that one node's choice. + * + *

THE ONE PLACE THE CERTIFICATE IS CHOSEN. Which heard seals are eligible, which of them + * actually verify, whether there are enough of them, and what D is, are all decided here. Keeping + * it in one method is not tidiness, it is the correctness argument: the decision "can I propose" + * and the bytes that go into the header are computed by the SAME code on the SAME inputs, so a + * proposal can never be emitted that this node's own arithmetic already rejected. A verdict + * computed by a different path than the run is the exact defect class this project keeps having to + * fix. + * + *

WHAT IS SELECTED, in order, and each step is the mirror of a validator-side rule: + * + *

    + *
  1. every seal heard for the parent, sorted by validator index (rule: strictly increasing); + *
  2. dropped unless the index maps, through the address-bound Falcon registry, to an address in + * the validator set FOR THE PARENT (rule: eligible signer); + *
  3. dropped unless the Falcon signature verifies over M(parent) (rule: k verifications); + *
  4. refused entirely if fewer than K(N) survive (rule: k >= K). + *
+ * + *

SO THE PROPOSER NEVER EMITS A SEAL IT HAS NOT ITSELF VERIFIED. The cost is k Falcon + * verifications per proposal, measured median 0.068 ms and p95 0.249 ms each, so five of them are + * about 1.3 ms against a measured 517 ms block interval. + * + *

THE PROPOSER NEVER COMPARES ANYTHING WITH WHAT ITS PEERS HEARD, and nothing downstream does + * either. That asymmetry is the whole reason V2 works where option V could not: a node that heard + * seven seals accepts a five-seal certificate written by a node that heard five, because Falcon + * verification is public. + */ +public final class PqAnchorProducer { + + private static final Logger LOG = LoggerFactory.getLogger(PqAnchorProducer.class); + + private static final AtomicBoolean LOGGED_ACTIVATION_THRESHOLD = new AtomicBoolean(false); + + private static volatile PqAnchorConfig config; + + private PqAnchorProducer() {} + + /** + * The activation configuration this process produces blocks under. + * + *

Read once from system configuration and memoised. It is deliberately a process-wide value + * rather than an injected one: the two producer entry points that need it (the QBFT block creator + * factory and the sealing path in {@code QbftRound}) sit in different modules with no shared + * construction site, and inventing one would mean threading a new argument through the QBFT round + * factory for no gain. The honest cost is that this cannot be varied per instance inside one JVM, + * which only matters for tests; {@link #useConfigForTesting(PqAnchorConfig)} covers that. + * + * @return the anchor configuration + */ + public static PqAnchorConfig config() { + PqAnchorConfig local = config; + if (local == null) { + synchronized (PqAnchorProducer.class) { + local = config; + if (local == null) { + local = PqAnchorConfig.fromSystemConfiguration(); + warnIfActivationHeightCanRefuse(local); + config = local; + } + } + } + return local; + } + + /** + * Override the memoised configuration. For tests, and for a controller builder that wants to feed + * a genesis-derived configuration in before the first block is produced. + * + * @param replacement the configuration to use, or null to force a re-read + */ + public static void useConfigForTesting(final PqAnchorConfig replacement) { + synchronized (PqAnchorProducer.class) { + config = replacement; + LOGGED_ACTIVATION_THRESHOLD.set(false); + } + } + + /** + * Whether a Falcon seal produced FOR this block must be signed over the V2 message M rather than + * over the legacy ECDSA committed-seal hash. + * + *

ONE BLOCK EARLIER than {@link PqAnchorConfig#activeAt(long)}, and that offset is the whole + * bootstrap of the scheme: the certificate carried by block H is made of seals over block H-1, so + * H-1 must already be sealed in the new form. Every node flips at the same height because the + * height is a pure function of the same configured H. + * + * @param blockNumber the number of the block being sealed + * @return true iff the seal must be over M + */ + public static boolean sealMessageIsAnchorForm(final long blockNumber) { + final PqAnchorConfig cfg = config(); + // DELIBERATELY LEFT UNTOUCHED BY THE INTERVAL, 2026-08-07. With an interval in force, + // seals made at heights that feed no anchor reach no certificate at all, so they are WASTE. + // The temptation is to stop signing them. It is not done here, and the reason is that this + // method decides the FORM of the seal: if the form depended on the interval, two nodes + // reading the interval differently would sign different forms and would no longer recognise + // each other. The form stays a function of H alone, which is the one value the fleet + // coordinates anyway. + // + // The waste remains, and it is BANDWIDTH, not DISK: the seals still travel on the Commit + // messages of every block, they simply no longer reach any header. The disk, which was the + // problem, drops by a factor of . Cutting the pointless signing is done separately, + // at the attachment gate, where it cannot change the form. + return cfg.everActive() && blockNumber + 1L >= cfg.anchorBlock(); + } + + /** + * Return the extra data the proposer should encode for a block on top of this parent. + * + *

Below the activation height the input is returned UNCHANGED, so pre-fork behaviour is + * identical byte for byte and the 11.8 million existing blocks stay untouched. The gate is on the + * block NUMBER, never on content. + * + * @param base the extra data the unmodified Besu path would have produced + * @param parentHeader the parent header, whose number and ON-CHAIN hash bind the certificate + * @param protocolContext the protocol context, used only to read the validator set for the parent + * @return either {@code base} itself, or a copy carrying the certificate and the anchor digest + * @throws PqAnchorNotReadyException when the staged threshold cannot be met, meaning this node + * must not propose at all this round + */ + public static BftExtraData apply( + final BftExtraData base, + final BlockHeader parentHeader, + final ProtocolContext protocolContext) { + + final PqAnchorConfig cfg = config(); + final long blockNumber = parentHeader.getNumber() + 1L; + // THE SAME QUESTION BOTH RULES ASK, through the same method: anchorAppliesAt. + // The producer and the validator have to agree on which heights carry a certificate. If the + // producer wrote one where the rule does not ask for it, that would only be waste; if it + // skipped one where the rule DOES ask for it, every block it proposes would be rejected. So + // the same question is asked, not one that merely resembles it. + if (!cfg.anchorAppliesAt(blockNumber)) { + return base; + } + + final long parentNumber = parentHeader.getNumber(); + final Bytes32 message = + PqAnchor.commitMessage(cfg.chainId(), parentNumber, parentHeader.getHash().getBytes()); + + final List heard = + PqSealCache.instance().sealsFor(parentNumber, parentHeader.getHash()); + + final PqSignerRegistry registry = PqSignerRegistry.falconSealSupport(); + final Set

eligible = validatorsForParent(parentHeader, protocolContext); + + final List certificate = new ArrayList<>(); + final Set
seen = new LinkedHashSet<>(); + // Integer.MAX_VALUE means "no cap", i.e. exactly what every node did before 2026-08-07. + final int sealCap = cfg.maxSealsCarried().orElse(Integer.MAX_VALUE); + int rejectedIneligible = 0; + int rejectedInvalid = 0; + // HEIGHT-RESOLVED LOOKUP (2026-08-06): the proposer resolves keys AT THE PARENT'S HEIGHT, the + // same height R2 will use when it re-checks this certificate. Before this the producer asked a + // height-less registry while R2 asked a height-resolved one, so at a rotation height the two + // could disagree about which key set applies - the proposer would assemble a certificate the + // fleet then refuses, and the round would fail for a reason no log named. Building and + // validating now read the same question. + for (final FalconSeal seal : PqAnchor.sortedByIndex(heard)) { + // OWN-HEAD DOOR (b-v2): parentHeader is this node's own head - this method is + // reached only from the proposer, building the block on top of it. Refusing here for a + // missing schedule is what stopped block production in PqForkValidatorSetChangeTest. + final Address signer = + registry.addressForIndexAtOwnHead(parentNumber, seal.getValidatorIndex()); + if (signer == null || !eligible.contains(signer) || !seen.add(signer)) { + rejectedIneligible++; + continue; + } + if (!registry.verifyAtOwnHead( + parentNumber, seal.getValidatorIndex(), message, seal.getSignature())) { + rejectedInvalid++; + continue; + } + certificate.add(seal); + // THE COST CAP. K is a FLOOR, not a ceiling: without this break the proposer writes every + // eligible seal it happened to hear, so a K=3 chain at N=7 carries four, five, six or seven. + // Measured 2026-08-07 on a live seven-node run with the threshold at 4: 42 blocks carried 4 + // seals, 36 carried 5, 5 carried 6. At 666 bytes a seal that is roughly 1.7 times the header + // bytes the same chain would write capped at K, and the extra buys NOTHING: what a verifier + // demands is the threshold, not how many seals a proposer volunteers above it. + // + // The break is safe precisely because it is placed AFTER the eligibility and signature checks: + // every seal counted here has already been verified, so stopping at the cap can never leave + // the certificate short by including a bad one. And the cap can never be set below the highest + // scheduled K, because PqAnchorConfig.withMaxSealsCarried refuses that at startup. + // + // Unset means no cap, which is exactly the behaviour of every node built before today. + if (certificate.size() >= sealCap) { + break; + } + } + + final int required = cfg.minSealsAt(blockNumber); + if (certificate.size() < required) { + throw new PqAnchorNotReadyException( + parentNumber, + certificate.size(), + required, + "Heard " + + heard.size() + + " seal(s) for parent hash " + + parentHeader.getHash() + + "; " + + rejectedIneligible + + " were not eligible signers (registry address-bound=" + + FalconSealSupport.instance().addressBound() + + ", validators for the parent=" + + eligible.size() + + ") and " + + rejectedInvalid + + " did not verify over M(parent). Schedule=" + + cfg.minSealsSchedule() + + ". A node that has just restarted holds no seals until it takes part in one commit," + + " which costs at most one proposer turn."); + } + + // The list is already ascending by index and de-duplicated by signer address; assert the format + // invariant here rather than trusting the loop, because this is the exact property the digest + // rule and the seals rule both depend on. + if (!PqAnchor.hasStrictlyIncreasingIndices(certificate)) { + throw new IllegalStateException( + "AERE PQ-ANCHOR: assembled a certificate whose indices are not strictly increasing; " + + "refusing to write it. This is a bug in the producer, not a configuration fault."); + } + + final Bytes32 digest = + PqAnchor.anchorDigest( + cfg.chainId(), parentNumber, parentHeader.getHash().getBytes(), certificate); + + LOG.debug( + "AERE PQ-ANCHOR: block {} carries a {}-seal certificate over parent {} (K={}), vanity D={}", + blockNumber, + certificate.size(), + parentNumber, + required, + digest); + + return new BftExtraData( + digest, + base.getSeals(), + base.getVote(), + base.getRound(), + base.getValidators(), + certificate); + } + + /** + * The validator set FOR the parent block: the nodes that were entitled to seal it. + * + *

Taken for the PARENT, not for the block being built, because those are the nodes whose seals + * a certificate over the parent may contain. On a validator-set change the two sets differ, and + * using the wrong one would either admit a seal the validator side rejects or drop one it + * accepts. + */ + private static Set

validatorsForParent( + final BlockHeader parentHeader, final ProtocolContext protocolContext) { + final Collection
validators = + protocolContext + .getConsensusContext(BftContext.class) + .getValidatorProvider() + .getValidatorsForBlock(parentHeader); + return new LinkedHashSet<>(validators); + } + + /** + * The first stage must not be able to block anything. K at the activation height itself has to be + * 0, because block H's parent predates the fork and may carry no Falcon seals at all: measured, + * the chain has ZERO today, 0 of 46 and 0 of 40 sampled headers, with the RLP element absent + * entirely. A schedule that puts a positive threshold at H would make the proposer of H refuse, + * and every proposer after it, which is a halt at the activation height. This does not throw, it + * shouts: refusing to start on a threshold an operator may have chosen deliberately is not this + * class's decision to make. + */ + private static void warnIfActivationHeightCanRefuse(final PqAnchorConfig cfg) { + if (!cfg.everActive() || !LOGGED_ACTIVATION_THRESHOLD.compareAndSet(false, true)) { + return; + } + final int atActivation = cfg.minSealsAt(cfg.anchorBlock()); + if (atActivation > 0) { + LOG.error( + "AERE PQ-ANCHOR: the staged threshold at the ACTIVATION height {} is {}, not 0. Block H's " + + "parent predates the fork and may carry no Falcon seals at all, so every proposer " + + "at H would refuse and the chain would stop at exactly the height you armed it. " + + "Open the schedule with {}:0 and raise K only at a later step.", + cfg.anchorBlock(), + atActivation, + cfg.anchorBlock()); + } else { + LOG.info( + "AERE PQ-ANCHOR: producer armed. H={}, K(H)=0 as required, schedule={}.", + cfg.anchorBlock(), + cfg.minSealsSchedule()); + } + } +} diff --git a/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/tools/PqRegistryHashTool.java b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/tools/PqRegistryHashTool.java new file mode 100644 index 0000000..d78385d --- /dev/null +++ b/anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/tools/PqRegistryHashTool.java @@ -0,0 +1,231 @@ +/* + * Copyright contributors to Besu. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft.tools; + +import java.nio.file.Path; +import java.nio.file.Paths; + +import org.hyperledger.besu.consensus.common.bft.PqRegistryHash; + +/** + * AERE GENESIS BINDING: compute the canonical hash of a Falcon validator registry so it can be put + * into genesis as {@code config.pqRegistryHash}. + * + *

This tool calls THE SAME code the startup guard calls. That is the whole point of it existing + * as a class inside {@code consensus:common} rather than as a shell script: a tool that computed the + * hash its own way would sooner or later disagree with the guard, and the operator would be told a + * number that does not let any node start. + * + *

+ *   java -cp <besu lib> org.hyperledger.besu.consensus.common.bft.tools.PqRegistryHashTool \
+ *        <registry-file> [--chain-id N] [--block H] [--quiet]
+ * 
+ * + *

The registry file may be a legacy {@code .properties} registry, a bare JSON manifest, or a + * genesis file carrying {@code config.aereFalconRegistry}; the format is sniffed. + */ +public final class PqRegistryHashTool { + + private PqRegistryHashTool() {} + + /** + * Entry point. + * + * @param args registry file, then optional {@code --chain-id N}, {@code --block H}, {@code + * --quiet} + */ + public static void main(final String[] args) { + String file = null; + long chainId = 2800L; + long block = 0L; + boolean quiet = false; + boolean chainIdGiven = false; + + for (int i = 0; i < args.length; i++) { + switch (args[i]) { + case "--chain-id": + chainId = Long.parseLong(require(args, ++i, "--chain-id")); + chainIdGiven = true; + break; + case "--block": + block = Long.parseLong(require(args, ++i, "--block")); + break; + case "--quiet": + quiet = true; + break; + case "-h": + case "--help": + usage(System.out); + return; + default: + if (args[i].startsWith("--")) { + System.err.println("unknown option: " + args[i]); + usage(System.err); + System.exit(2); + } + file = args[i]; + } + } + if (file == null) { + usage(System.err); + System.exit(2); + return; + } + + final Path path = Paths.get(file); + final PqRegistryHash.Registry reg; + try { + reg = PqRegistryHash.loadAuto(path); + } catch (final PqRegistryHash.RegistryConfigException e) { + System.err.println(e.getMessage()); + System.exit(3); + return; + } + + // AERE CANONICAL FINGERPRINT (2026-08-06). hashFor, not hashV1, and this is the line an + // operator pastes into genesis. For a proof-bound (v2) registry the node's gate compares + // hashV2; printing hashV1 here gives the whole fleet a value NOTHING on a node ever computes. + // Measured on a network of seven on 2026-08-06: all seven start, all seven report the registry + // loaded, and the chain stops at H-1. A wrong instruction is more dangerous than a missing one. + // + // hashV1 is also useless as an epoch identifier, which is the other reason it cannot merely be + // kept alongside: the bind height is not in the v1 pre-image, so two rotation epochs of the same + // fleet produce the SAME v1 number. + final String canonical = PqRegistryHash.hashFor(reg, chainId); + + if (quiet) { + System.out.println("0x" + canonical); + return; + } + + System.out.println("AERE PQC GENESIS-BINDING - canonical Falcon registry hash"); + System.out.println(" file : " + reg.sourcePath()); + System.out.println(" source kind : " + reg.kind()); + System.out.println(" entries : " + reg.count()); + System.out.println(" address-bound : " + reg.addressBound()); + System.out.println(" chainId : " + chainId + (chainIdGiven ? "" : " (default)")); + System.out.println( + " format : " + + (reg.proofBound() + ? "v2, proof-bound, bindHeight=" + reg.bindHeight() + : "v1, no binding proofs")); + System.out.println(); + System.out.println( + " pqRegistryHash (canonical " + + (reg.proofBound() ? "v2" : "v1") + + ", THIS is the value for genesis):"); + System.out.println(" 0x" + canonical); + if (reg.proofBound()) { + System.out.println(); + System.out.println( + " AERE HEIGHT BINDING: schedule this registry at block " + + reg.bindHeight() + + " AND NOWHERE ELSE. Every row's possession proof and validator claim sign that"); + System.out.println( + " height; a node handed this file at any other block refuses to start with"); + System.out.println(" AERE-PQC-REG-BIND-08."); + } + System.out.println(); + System.out.println(" legacy v0 concat hash (only for cross-checking the genesis alloc anchor"); + System.out.println(" slot that FalconSealSupport.parseManifest already computes; NOT the value"); + System.out.println(" pqRegistryHash is compared against):"); + System.out.println(" 0x" + PqRegistryHash.hashV0Legacy(reg)); + System.out.println(); + System.out.println(" per-index fingerprints, keccak256(addr||pk) first 8 hex."); + System.out.println(" Paste these next to another node's to find a differing row; they are"); + System.out.println(" digests, so they carry no key material:"); + for (final PqRegistryHash.Entry e : reg.entries()) { + final byte[] a = e.address(); + System.out.println( + " [" + + e.index() + + "] " + + PqRegistryHash.fingerprint(reg, e.index()) + + " pkLen=" + + e.publicKey().length + + (a == null ? "" : " addr=0x" + hex(a))); + } + System.out.println(); + System.out.println(" Paste into genesis under \"config\":"); + System.out.println(); + // AERE HEIGHT BINDING: the recipe this tool prints has to be the recipe the node accepts. + // Since 2026-08-06 a proof-bound registry scheduled at a block other than its bindHeight is + // refused at startup, so printing one here would be manufacturing the configuration the node + // rejects - in a file that goes to all seven nodes at once. + final long at = block == 0L && reg.proofBound() ? reg.bindHeight() : block; + if (reg.proofBound() && at != reg.bindHeight()) { + System.err.println(); + System.err.println( + "REFUSING TO PRINT A GENESIS FRAGMENT. --block " + + block + + " does not equal this registry's bindHeight " + + reg.bindHeight() + + "."); + System.err.println( + " Every row of this file carries a Falcon possession proof and an ECDSA claim, and both"); + System.err.println( + " sign the bind height. Scheduling it from another block is an activation day no"); + System.err.println( + " validator signed, and a node handed it refuses to start (AERE-PQC-REG-BIND-08)."); + System.err.println( + " Either pass --block " + reg.bindHeight() + ", or re-run the key ceremony for block"); + System.err.println( + " " + block + " - which produces a DIFFERENT hash, because the height is inside it."); + System.exit(4); + return; + } + if (at == 0L) { + System.out.println(" \"pqRegistryHash\": \"0x" + canonical + "\""); + System.out.println(); + System.out.println(" (that binds from block 0. On a live chain use --block instead, so"); + System.out.println(" the existing history stays completely unbound.)"); + } else { + System.out.println(" \"pqRegistryHash\": ["); + System.out.println(" { \"block\": " + at + ", \"hash\": \"0x" + canonical + "\" }"); + System.out.println(" ]"); + System.out.println(); + System.out.println(" Every height below " + at + " stays unbound, so nothing already on"); + System.out.println(" the chain is affected. A later rotation is a further entry with a"); + System.out.println(" STRICTLY greater block."); + } + } + + private static String require(final String[] args, final int i, final String what) { + if (i >= args.length) { + System.err.println(what + " needs a value"); + System.exit(2); + } + return args[i]; + } + + private static String hex(final byte[] b) { + final StringBuilder s = new StringBuilder(b.length * 2); + for (final byte x : b) { + s.append(Character.forDigit((x >> 4) & 0xf, 16)).append(Character.forDigit(x & 0xf, 16)); + } + return s.toString(); + } + + private static void usage(final java.io.PrintStream o) { + o.println( + "usage: PqRegistryHashTool [--chain-id N] [--block H] [--quiet]\n" + + " a legacy .properties registry, a JSON manifest, or a genesis\n" + + " file carrying config.aereFalconRegistry. Format is sniffed.\n" + + " --chain-id N chain id bound into the hashed pre-image (default 2800)\n" + + " --block H emit the genesis snippet as a height-bound schedule at H\n" + + " --quiet print only 0x, for scripts\n" + + "exit: 0 ok, 2 usage, 3 the registry file was rejected"); + } +} diff --git a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/FalconAttachIntervalTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/FalconAttachIntervalTest.java new file mode 100644 index 0000000..2d593d4 --- /dev/null +++ b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/FalconAttachIntervalTest.java @@ -0,0 +1,232 @@ +/* + * AERE, 2026-08-08. The interval and the cap on the path BEFORE the anchor is activated. + * + * WHY THIS EXISTS, and the cost that demanded it. On 8 August Falcon seal attachment was switched + * on across all seven validators of chain 2800. The header went from 525 to 3844 bytes, that is + * FIVE seals on EVERY block: more than seven times the previous header, every ~523 ms. On a chain + * with no transactions the headers are close to everything that gets written to disk, so that + * multiplier is exactly the multiplier of database growth, and it exceeds any reasonable + * provisioning. + * + * AND NOW THE PART THAT IS THE ACTUAL FINDING. The anchor producer already had both an interval and + * a cap, built and proven on 7 August. But the assembler that runs when the anchor is NOT armed had + * neither, and that is precisely the assembler that runs up to the activation height. The cost + * control existed and was out of reach exactly in the window where it was needed. `anchorInterval` + * on its own does not help: without `aere.pq.anchorBlock` it is completely inert, so there is no + * middle road. + * + * WHY IT IS SAFE, and this is the argument that has to hold, not the disk saving. The preimage of a + * Falcon seal contains `parentHash`, which covers the parent header, which covers the grandparent, + * and so on down to genesis. So ONE seal over block H transitively covers ALL the history below H. + * You do not need a signature on every block in order to defend every block. What you give up with + * interval N is exactly and only this: an adversary holding every ECDSA key and no Falcon key can + * rewrite up to N blocks back from the last anchor, instead of 1. The property is + * + * fork depth <= interval + * + * and it is a dial, not an accident. Algorand ships the same shape at 1 in 256 or rarer. + * + * Same property as in `PqAnchorIntervalTest`, a different code path. Two paths need two proofs: + * precisely because we had a proof on one of them only, we paid the full cost on the other. + */ +package org.hyperledger.besu.consensus.common.bft; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.OptionalInt; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class FalconAttachIntervalTest { + + private static final long ATTACH = 12_900_000L; + private static final String P_INTERVAL = "aere.falcon.attachInterval"; + private static final String P_CAP = "aere.falcon.attachMaxSeals"; + + @AfterEach + void curata() { + System.clearProperty(P_INTERVAL); + System.clearProperty(P_CAP); + } + + // --------------------------------------------------------------------------------------------- + // 1. THE COMPATIBILITY BOUNDARY. Unset means EVERY block, that is exactly today's behaviour. A + // cap or an interval shipped as a default would be a behaviour change on the live chain + // smuggled in through a version bump, and such a change is asked for, not shipped. + // --------------------------------------------------------------------------------------------- + @Test + void withNoIntervalEveryHeightCarriesACertificate() { + for (final long n : new long[] {ATTACH, ATTACH + 1, ATTACH + 2, ATTACH + 99, ATTACH + 100_000}) { + assertThat(FalconSealSupport.isAttachHeight(n, ATTACH, OptionalInt.empty())) + .as("height %d", n) + .isTrue(); + } + } + + // --------------------------------------------------------------------------------------------- + // 2. Below the attach height nothing is written, with or without an interval. + // --------------------------------------------------------------------------------------------- + @Test + void belowTheAttachHeightNothingIsWritten() { + for (final OptionalInt every : + new OptionalInt[] {OptionalInt.empty(), OptionalInt.of(1), OptionalInt.of(100)}) { + assertThat(FalconSealSupport.isAttachHeight(ATTACH - 1, ATTACH, every)).isFalse(); + assertThat(FalconSealSupport.isAttachHeight(0, ATTACH, every)).isFalse(); + } + } + + // --------------------------------------------------------------------------------------------- + // 3. An interval of 1 is the same thing written out explicitly, and must behave like "unset". + // --------------------------------------------------------------------------------------------- + @Test + void anIntervalOfOneIsTheSameAsNoInterval() { + for (final long n : new long[] {ATTACH, ATTACH + 1, ATTACH + 7, ATTACH + 1000}) { + assertThat(FalconSealSupport.isAttachHeight(n, ATTACH, OptionalInt.of(1))) + .as("height %d", n) + .isEqualTo(FalconSealSupport.isAttachHeight(n, ATTACH, OptionalInt.empty())); + } + } + + // --------------------------------------------------------------------------------------------- + // 4. THE NEGATIVE CONTROL OF THE PROOF. At interval 100 exactly one height in a hundred carries a + // certificate. We count, we do not sample: a proof that checked three heights I picked myself + // would pass even if the arithmetic were wrong between them. + // --------------------------------------------------------------------------------------------- + @Test + void anIntervalOfOneHundredCarriesExactlyOneHeightInOneHundred() { + int cu = 0; + for (long n = ATTACH; n < ATTACH + 10_000; n++) { + if (FalconSealSupport.isAttachHeight(n, ATTACH, OptionalInt.of(100))) { + cu++; + assertThat((n - ATTACH) % 100).as("height %d is not a multiple", n).isZero(); + } + } + assertThat(cu).isEqualTo(100); + + // and the same span of heights WITH NO interval, so the difference we are buying is visible + int fara = 0; + for (long n = ATTACH; n < ATTACH + 10_000; n++) { + if (FalconSealSupport.isAttachHeight(n, ATTACH, OptionalInt.empty())) { + fara++; + } + } + assertThat(fara).isEqualTo(10_000); + } + + // --------------------------------------------------------------------------------------------- + // 5. The first attach height always carries a certificate, whatever the interval. If it were + // skipped, the arming evidence a restarted node looks for could be missing at the start. + // --------------------------------------------------------------------------------------------- + @Test + void theAttachHeightItselfAlwaysCarriesACertificate() { + for (final int every : new int[] {1, 2, 32, 100, 128}) { + assertThat(FalconSealSupport.isAttachHeight(ATTACH, ATTACH, OptionalInt.of(every))) + .as("interval %d", every) + .isTrue(); + } + } + + // --------------------------------------------------------------------------------------------- + // 6. THE UPPER LIMIT ON THE INTERVAL IS NOT A MATTER OF TASTE. It is the 128 block window in + // which a restarted validator looks for its evidence that the fleet is already armed. With a + // wider interval that window can contain no sealed header at all, and the node then refuses to + // start days later, on some other restart, on the host whose disk has just failed. The only + // place where the operator can still see that connection is right here, at configuration time. + // --------------------------------------------------------------------------------------------- + @Test + void anIntervalWiderThanTheRestartEvidenceWindowIsRefused() { + System.setProperty(P_INTERVAL, "129"); + assertThatThrownBy(() -> FalconSealSupport.instance().attachInterval()) + .isInstanceOf(FalconSealSupport.ActivationConfigException.class) + .hasMessageContaining("AERE-PQC-CFG-UNSAFE-09") + .hasMessageContaining("128"); + + // positive control on the SAME path: exactly 128 has to pass, otherwise the message above could + // be coming from anything else and the proof would not be measuring the boundary + System.setProperty(P_INTERVAL, "128"); + assertThat(FalconSealSupport.instance().attachInterval()).hasValue(128); + } + + @Test + void anIntervalBelowOneIsRefused() { + System.setProperty(P_INTERVAL, "0"); + assertThatThrownBy(() -> FalconSealSupport.instance().attachInterval()) + .isInstanceOf(FalconSealSupport.ActivationConfigException.class) + .hasMessageContaining("AERE-PQC-CFG-UNSAFE-09"); + } + + // --------------------------------------------------------------------------------------------- + // 7. A malformed interval STOPS startup. It does not fall back silently to "every block": that + // would leave the operator sure a disk control is in force while the disk fills at the old + // rate. It is exactly the shape of failure that has cost us the most, a green earned by not + // asking. + // --------------------------------------------------------------------------------------------- + @Test + void aMalformedIntervalRefusesToStartRatherThanSilentlyReverting() { + System.setProperty(P_INTERVAL, "one hundred"); + assertThatThrownBy(() -> FalconSealSupport.instance().attachInterval()) + .isInstanceOf(FalconSealSupport.ActivationConfigException.class) + .hasMessageContaining("AERE-PQC-CFG-SYNTAX-09"); + } + + @Test + void anUnsetIntervalMeansEveryBlock() { + assertThat(System.getProperty(P_INTERVAL)).isNull(); + assertThat(FalconSealSupport.instance().attachInterval()).isEmpty(); + } + + // --------------------------------------------------------------------------------------------- + // 8. THE SEAL CAP. Unset means no cap. Zero is refused, because a certificate of zero seals is + // not a cheaper one, it is an absent one, and the road for that is to not attach at all. + // --------------------------------------------------------------------------------------------- + @Test + void theSealCapDefaultsToNoCapAndRefusesZero() { + assertThat(FalconSealSupport.instance().attachMaxSeals()).isEmpty(); + + System.setProperty(P_CAP, "3"); + assertThat(FalconSealSupport.instance().attachMaxSeals()).hasValue(3); + + System.setProperty(P_CAP, "0"); + assertThatThrownBy(() -> FalconSealSupport.instance().attachMaxSeals()) + .isInstanceOf(FalconSealSupport.ActivationConfigException.class) + .hasMessageContaining("AERE-PQC-CFG-UNSAFE-10"); + + System.setProperty(P_CAP, "three"); + assertThatThrownBy(() -> FalconSealSupport.instance().attachMaxSeals()) + .isInstanceOf(FalconSealSupport.ActivationConfigException.class) + .hasMessageContaining("AERE-PQC-CFG-SYNTAX-10"); + } + + // --------------------------------------------------------------------------------------------- + // 9. THE COST, computed from the very arithmetic above and from the measurement on the live + // chain. This proof exists so that it breaks if someone changes the arithmetic and believes + // they only changed a detail. The figures: 525 bytes for the base header, ~662 per seal + // measured in extraData, 60,300,000 blocks per year. + // --------------------------------------------------------------------------------------------- + @Test + void theMeasuredCostOfEachSettingIsWhatWeToldTheFounder() { + final long blocuriPeAn = 60_300_000L; + final long octetiPeSigiliu = 662L; + + assertThat(gbPeAn(5, 1, octetiPeSigiliu, blocuriPeAn)).isBetween(180L, 210L); // today + assertThat(gbPeAn(3, 1, octetiPeSigiliu, blocuriPeAn)).isBetween(105L, 125L); // cap only + assertThat(gbPeAn(3, 32, octetiPeSigiliu, blocuriPeAn)).isBetween(3L, 5L); // cap + 32 + assertThat(gbPeAn(3, 100, octetiPeSigiliu, blocuriPeAn)).isBetween(1L, 2L); // cap + 100 + + // and the boundary that matters for any provisioning decision: starting from a fixed space + // budget, how many days each setting lasts. The budget below is a parameter of the proof, kept + // deliberately small so that the order of magnitude between the settings is visible. + assertThat(zile(12L, gbPeAn(5, 1, octetiPeSigiliu, blocuriPeAn))).isLessThan(30L); + assertThat(zile(12L, gbPeAn(3, 32, octetiPeSigiliu, blocuriPeAn))).isGreaterThan(700L); + } + + private static long gbPeAn( + final int sigilii, final int interval, final long octetiPeSigiliu, final long blocuriPeAn) { + return (long) sigilii * octetiPeSigiliu * blocuriPeAn / interval / (1024L * 1024L * 1024L); + } + + private static long zile(final long gbLiberi, final long gbPeAn) { + return gbPeAn == 0 ? Long.MAX_VALUE : gbLiberi * 365L / gbPeAn; + } +} diff --git a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorConfigTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorConfigTest.java new file mode 100644 index 0000000..0536d2e --- /dev/null +++ b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorConfigTest.java @@ -0,0 +1,691 @@ +/* + * Copyright contributors to Besu / Aere Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.OptionalInt; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** The height gate, the staged threshold, and the two emergency de-arm controls. */ +public class PqAnchorConfigTest { + + /** The activation height used throughout, so H appears as one number in every message. */ + private static final long H = 10_141_734L; + + /** + * A reader over two maps instead of over the JVM and the process environment. + * + *

Two reasons this exists rather than {@code System.setProperty} everywhere. First, hermetic + * tests: an operator or a CI image that happens to export {@code AERE_PQ_ANCHOR_BLOCK} would + * otherwise change what these tests measure, which is precisely the class of accident under + * examination. Second, it records every access, and the count of accesses is what proves that the + * unconfigured path decodes nothing. + */ + private static final class RecordingReader implements PqAnchorConfig.NameReader { + private final Map properties = new HashMap<>(); + private final Map environment = new HashMap<>(); + private final List accesses = new ArrayList<>(); + + @Override + public String property(final String name) { + accesses.add("property:" + name); + return properties.get(name); + } + + @Override + public String environment(final String name) { + accesses.add("environment:" + name); + return environment.get(name); + } + } + + /** The negative control: a reader that reports every name as absent, whatever is really set. */ + private static final class BlindReader implements PqAnchorConfig.NameReader { + @Override + public String property(final String name) { + return null; + } + + @Override + public String environment(final String name) { + return null; + } + } + + private RecordingReader reader; + + @BeforeEach + public void installReader() { + reader = new RecordingReader(); + PqAnchorConfig.useNameReaderForTesting(reader); + } + + @AfterEach + public void restoreReader() { + PqAnchorConfig.useNameReaderForTesting(null); + } + + private void set(final String property, final String value) { + reader.properties.put(property, value); + } + + private void setEnvironment(final String variable, final String value) { + reader.environment.put(variable, value); + } + + /** A schedule that is correct: a step exactly at H, then two staged raises. */ + private void armCorrectly() { + set(PqAnchorConfig.PROPERTY_CHAIN_ID, "2800"); + set(PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, Long.toString(H)); + set(PqAnchorConfig.PROPERTY_MIN_SEALS, H + ":0," + (H + 7200) + ":1," + (H + 14400) + ":3"); + } + + private static Map schedule() { + final Map steps = new LinkedHashMap<>(); + steps.put(1000L, 0); + steps.put(2000L, 1); + steps.put(3000L, 3); + steps.put(4000L, 5); + return steps; + } + + private static PqAnchorConfig armed() { + return new PqAnchorConfig(2800L, 1000L, schedule(), OptionalInt.empty(), false); + } + + // =============================================================================================== + // 1. The value object. Unchanged behaviour, kept as the floor under everything below. + // =============================================================================================== + + @Test + public void unconfiguredMeansInertAtEveryHeight() { + final PqAnchorConfig config = PqAnchorConfig.never(2800L); + assertThat(config.everActive()).isFalse(); + assertThat(config.activeAt(0L)).isFalse(); + assertThat(config.activeAt(11_800_000L)).isFalse(); + assertThat(config.activeAt(Long.MAX_VALUE)).isFalse(); + // And the legacy Falcon rule never retires, so the whole rule set behaves exactly as today. + assertThat(config.legacyFalconRuleRetirementBlock()).isEqualTo(Long.MAX_VALUE); + } + + @Test + public void theGateIsOnTheBlockNumberAndNothingBelowHIsTouched() { + final PqAnchorConfig config = armed(); + assertThat(config.activeAt(0L)).isFalse(); + assertThat(config.activeAt(999L)).isFalse(); + assertThat(config.activeAt(1000L)).isTrue(); + assertThat(config.activeAt(1001L)).isTrue(); + } + + @Test + public void thresholdFollowsTheStagedScheduleAndIsZeroAtActivation() { + final PqAnchorConfig config = armed(); + assertThat(config.minSealsAt(1000L)).isEqualTo(0); + assertThat(config.minSealsAt(1999L)).isEqualTo(0); + assertThat(config.minSealsAt(2000L)).isEqualTo(1); + assertThat(config.minSealsAt(2999L)).isEqualTo(1); + assertThat(config.minSealsAt(3000L)).isEqualTo(3); + assertThat(config.minSealsAt(4000L)).isEqualTo(5); + assertThat(config.minSealsAt(9_999_999L)).isEqualTo(5); + } + + @Test + public void emergencyCeilingLowersTheThresholdAndCanNeverRaiseIt() { + // THE EMERGENCY CEILING, the only de-arm that works when the chain is ALREADY STOPPED: a halted + // chain cannot deliver a height-scheduled configuration change, so the control has to be local + // to the node. + final PqAnchorConfig lowered = + new PqAnchorConfig(2800L, 1000L, schedule(), OptionalInt.of(1), false); + assertThat(lowered.minSealsAt(4000L)).isEqualTo(1); + assertThat(lowered.minSealsAt(1000L)).isEqualTo(0); + + final PqAnchorConfig raiseAttempt = + new PqAnchorConfig(2800L, 1000L, schedule(), OptionalInt.of(99), false); + assertThat(raiseAttempt.minSealsAt(2000L)).isEqualTo(1); + assertThat(raiseAttempt.minSealsAt(4000L)).isEqualTo(5); + + final PqAnchorConfig floored = + new PqAnchorConfig(2800L, 1000L, schedule(), OptionalInt.of(0), false); + assertThat(floored.minSealsAt(4000L)).isEqualTo(0); + } + + @Test + public void emergencyDisableSwitchesBothRulesOff() { + final PqAnchorConfig disabled = + new PqAnchorConfig(2800L, 1000L, schedule(), OptionalInt.empty(), true); + assertThat(disabled.everActive()).isFalse(); + assertThat(disabled.activeAt(5000L)).isFalse(); + assertThat(disabled.legacyFalconRuleRetirementBlock()).isEqualTo(Long.MAX_VALUE); + } + + @Test + public void invalidConfigurationsAreRefusedAtConstruction() { + // H must leave room for a parent. + assertThatThrownBy( + () -> + new PqAnchorConfig( + 2800L, 0L, Collections.emptyMap(), OptionalInt.empty(), false)) + .isInstanceOf(IllegalArgumentException.class); + // A threshold step cannot take effect before the rules do. + assertThatThrownBy( + () -> + new PqAnchorConfig( + 2800L, 1000L, Map.of(500L, 3), OptionalInt.empty(), false)) + .isInstanceOf(IllegalArgumentException.class); + // A negative threshold is meaningless. + assertThatThrownBy( + () -> + new PqAnchorConfig( + 2800L, 1000L, Map.of(1000L, -1), OptionalInt.empty(), false)) + .isInstanceOf(IllegalArgumentException.class); + } + + // =============================================================================================== + // 2. THE COMPATIBILITY PROPERTY. This is the one the whole design is built around: a binary + // carrying this code, put on a live node that has none of these names set, must behave exactly + // as today. Not approximately. The assertion is on the ACCESS LOG, because "no decode, no + // allocation" is a claim about what the loader touched, not about what it returned. + // =============================================================================================== + + @Test + public void withNoAnchorNameSetAnywhereTheLoaderTouchesNothingAndReturnsTodaysBehaviour() { + final PqAnchorConfig config = PqAnchorConfig.fromSystemConfiguration(); + + assertThat(config.everActive()).isFalse(); + assertThat(config.anchorConfigured()).isFalse(); + assertThat(config.disabled()).isFalse(); + assertThat(config.chainId()).isEqualTo(0L); + assertThat(config.anchorBlock()).isEqualTo(PqAnchorConfig.NEVER); + assertThat(config.minSealsSchedule()).isEmpty(); + assertThat(config.minSealsCeiling()).isEmpty(); + assertThat(config.legacyFalconRuleRetirementBlock()).isEqualTo(PqAnchorConfig.NEVER); + assertThat(config.toString()).isEqualTo(PqAnchorConfig.never(0L).toString()); + for (final long height : new long[] {0L, 1L, H - 1, H, H + 1, Long.MAX_VALUE}) { + assertThat(config.activeAt(height)).isFalse(); + assertThat(config.minSealsAt(height)).isEqualTo(0); + assertThat(config.emergencyCeilingLowersAt(height)).isFalse(); + } + + // THE LOAD-BEARING ASSERTION of this test. SEVEN names, each probed exactly once as a system + // property and once as an environment variable, and then nothing: fourteen accesses, not + // fifteen. A loader that went any further and decoded would read the same names a second time + // and this number would be 28. + // + // IT WAS TEN UNTIL 2026-08-07, and it went up TWICE on the same day, for + // `aere.pq.anchor.maxSeals` and then for `aere.pq.anchorInterval`. Each time the test failed + // and forced somebody to look, which is exactly what it was asked to do. The number is raised + // DELIBERATELY, with the reason written next to it; the assertion is never weakened and never + // deleted. + // + // The consequence of adding a name, and it is intended: a node that has ONLY a cost control + // set, with no anchor height, is no longer "a node with no anchor configuration"; it becomes + // one with an incomplete configuration, and it refuses to start. A cap or an interval with no + // anchor means nothing, and it is better for it to shout than to keep quiet. + assertThat(reader.accesses).hasSize(14); + assertThat(reader.accesses) + .containsExactly( + "property:" + PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, + "environment:" + PqAnchorConfig.ENV_ANCHOR_BLOCK, + "property:" + PqAnchorConfig.PROPERTY_MIN_SEALS, + "environment:" + PqAnchorConfig.ENV_MIN_SEALS, + "property:" + PqAnchorConfig.PROPERTY_CHAIN_ID, + "environment:" + PqAnchorConfig.ENV_CHAIN_ID, + "property:" + PqAnchorConfig.PROPERTY_MIN_SEALS_CEILING, + "environment:" + PqAnchorConfig.ENV_MIN_SEALS_CEILING, + // added 2026-08-07 together with the cost cap; the ORDER matters and it is the one in + // ANCHOR_NAMES, where maxSeals sits between the emergency ceiling and the disarm + "property:" + PqAnchorConfig.PROPERTY_MAX_SEALS, + "environment:" + PqAnchorConfig.ENV_MAX_SEALS, + "property:" + PqAnchorConfig.PROPERTY_ANCHOR_INTERVAL, + "environment:" + PqAnchorConfig.ENV_ANCHOR_INTERVAL, + "property:" + PqAnchorConfig.PROPERTY_DISABLE, + "environment:" + PqAnchorConfig.ENV_DISABLE); + } + + // =============================================================================================== + // 3. THE ARMED PATH. A correct configuration still starts, and the threshold in force is the one + // that was asked for. Without this, "refuses everything" would score as a pass. + // =============================================================================================== + + @Test + public void aCorrectConfigurationStartsAndTheThresholdIsTheOneThatWasAskedFor() { + armCorrectly(); + final PqAnchorConfig config = PqAnchorConfig.fromSystemConfiguration(); + + assertThat(config.everActive()).isTrue(); + assertThat(config.anchorConfigured()).isTrue(); + assertThat(config.chainId()).isEqualTo(2800L); + assertThat(config.anchorBlock()).isEqualTo(H); + assertThat(config.activeAt(H - 1)).isFalse(); + assertThat(config.activeAt(H)).isTrue(); + assertThat(config.minSealsAt(H)).isEqualTo(0); + assertThat(config.minSealsAt(H + 7199)).isEqualTo(0); + assertThat(config.minSealsAt(H + 7200)).isEqualTo(1); + assertThat(config.minSealsAt(H + 14400)).isEqualTo(3); + assertThat(config.minSealsAt(H + 10_000_000)).isEqualTo(3); + assertThat(config.legacyFalconRuleRetirementBlock()).isEqualTo(H); + } + + @Test + public void theEnvironmentFormIsReadTheSameWayAsTheProperty() { + setEnvironment(PqAnchorConfig.ENV_CHAIN_ID, "2800"); + setEnvironment(PqAnchorConfig.ENV_ANCHOR_BLOCK, Long.toString(H)); + setEnvironment(PqAnchorConfig.ENV_MIN_SEALS, H + ":3"); + final PqAnchorConfig config = PqAnchorConfig.fromSystemConfiguration(); + assertThat(config.everActive()).isTrue(); + assertThat(config.minSealsAt(H)).isEqualTo(3); + } + + @Test + public void aCorrectConfigurationReadThroughTheREALSystemPropertiesAlsoStarts() { + // The map reader above is hermetic, which is why it is used everywhere else. This one case goes + // through System.getProperty itself, so that the seam cannot be hiding a difference between the + // reader the tests use and the reader the node uses. + PqAnchorConfig.useNameReaderForTesting(null); + final String[] keys = { + PqAnchorConfig.PROPERTY_CHAIN_ID, + PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, + PqAnchorConfig.PROPERTY_MIN_SEALS, + PqAnchorConfig.PROPERTY_MIN_SEALS_CEILING, + PqAnchorConfig.PROPERTY_DISABLE + }; + final String[] saved = new String[keys.length]; + for (int i = 0; i < keys.length; i++) { + saved[i] = System.getProperty(keys[i]); + } + try { + System.setProperty(PqAnchorConfig.PROPERTY_CHAIN_ID, "2800"); + System.setProperty(PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, "12000000"); + System.setProperty( + PqAnchorConfig.PROPERTY_MIN_SEALS, "12000000:0,12007200:1,12014400:3,12021600:5"); + System.clearProperty(PqAnchorConfig.PROPERTY_MIN_SEALS_CEILING); + System.clearProperty(PqAnchorConfig.PROPERTY_DISABLE); + + final PqAnchorConfig config = PqAnchorConfig.fromSystemConfiguration(); + assertThat(config.chainId()).isEqualTo(2800L); + assertThat(config.anchorBlock()).isEqualTo(12_000_000L); + assertThat(config.activeAt(11_999_999L)).isFalse(); + assertThat(config.activeAt(12_000_000L)).isTrue(); + assertThat(config.minSealsAt(12_000_000L)).isEqualTo(0); + assertThat(config.minSealsAt(12_021_600L)).isEqualTo(5); + + System.setProperty(PqAnchorConfig.PROPERTY_MIN_SEALS_CEILING, "1"); + assertThat(PqAnchorConfig.fromSystemConfiguration().minSealsAt(12_021_600L)).isEqualTo(1); + + System.setProperty(PqAnchorConfig.PROPERTY_DISABLE, "true"); + final PqAnchorConfig off = PqAnchorConfig.fromSystemConfiguration(); + assertThat(off.everActive()).isFalse(); + assertThat(off.activeAt(12_021_600L)).isFalse(); + // The anchor is still CONFIGURED, which is what keeps the per-block disarm shout alive. + assertThat(off.anchorConfigured()).isTrue(); + } finally { + for (int i = 0; i < keys.length; i++) { + if (saved[i] == null) { + System.clearProperty(keys[i]); + } else { + System.setProperty(keys[i], saved[i]); + } + } + } + } + + // =============================================================================================== + // 4. THE NINE WAYS THE ANCHOR USED TO GO QUIETLY DISARMED. One case each, every one of them a + // startup refusal now, and every assertion names the code and the field so that a message that + // stops telling the operator which knob to turn goes red here. + // =============================================================================================== + + private void assertRefusesNaming(final String field) { + assertThatThrownBy(PqAnchorConfig::fromSystemConfiguration) + .isInstanceOf(FalconSealSupport.ActivationConfigException.class) + .hasMessageContaining(PqAnchorConfig.REFUSAL_CODE) + .hasMessageContaining("REFUSING TO START") + .hasMessageContaining("FIELD " + field) + .hasMessageContaining("never in parallel"); + } + + /** P1: the emptied shell variable. The quietest path in the whole file until today. */ + @Test + public void p1_anEmptyAnchorBlockIsAMistakeNotAnAbsence() { + set(PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, ""); + assertRefusesNaming(PqAnchorConfig.PROPERTY_ANCHOR_BLOCK); + } + + /** P2: an unreadable H used to log a number nobody reads as "the anchor is off". */ + @Test + public void p2_anUnparseableAnchorBlockRefusesInsteadOfRunningInert() { + set(PqAnchorConfig.PROPERTY_CHAIN_ID, "2800"); + set(PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, "10_141_734"); + set(PqAnchorConfig.PROPERTY_MIN_SEALS, H + ":3"); + assertRefusesNaming(PqAnchorConfig.PROPERTY_ANCHOR_BLOCK); + } + + /** P3: the discarded schedule. The node armed, and K was 0, and the banner said ARMED. */ + @Test + public void p3_aBrokenStepNoLongerDiscardsTheWholeScheduleAndArmsAtZero() { + set(PqAnchorConfig.PROPERTY_CHAIN_ID, "2800"); + set(PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, Long.toString(H)); + set(PqAnchorConfig.PROPERTY_MIN_SEALS, H + ":3," + (H + 7200) + ":"); + assertRefusesNaming(PqAnchorConfig.PROPERTY_MIN_SEALS); + assertThatThrownBy(PqAnchorConfig::fromSystemConfiguration) + .hasMessageContaining("has no threshold after the ':'"); + } + + /** P3, the other shape: one comma too many. */ + @Test + public void p3_aStrayCommaIsARefusalNotASkippedStep() { + set(PqAnchorConfig.PROPERTY_CHAIN_ID, "2800"); + set(PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, Long.toString(H)); + set(PqAnchorConfig.PROPERTY_MIN_SEALS, H + ":3,," + (H + 7200) + ":4"); + assertRefusesNaming(PqAnchorConfig.PROPERTY_MIN_SEALS); + } + + /** P3, the emptiest shape: armed with no schedule at all means armed at K=0. */ + @Test + public void p3_anArmedAnchorWithAnEmptyScheduleIsRefused() { + set(PqAnchorConfig.PROPERTY_CHAIN_ID, "2800"); + set(PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, Long.toString(H)); + set(PqAnchorConfig.PROPERTY_MIN_SEALS, ""); + assertRefusesNaming(PqAnchorConfig.PROPERTY_MIN_SEALS); + } + + /** + * P4: syntactically perfect, semantically wrong. One digit too many in the first height and K + * stays 0 for ten million blocks, with no log line and no guard anywhere. The rule that kills it + * is a property, not a heuristic: the threshold must be defined AT the activation height. + */ + @Test + public void p4_aScheduleThatDoesNotDefineKAtTheActivationHeightIsRefused() { + set(PqAnchorConfig.PROPERTY_CHAIN_ID, "2800"); + set(PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, Long.toString(H)); + set(PqAnchorConfig.PROPERTY_MIN_SEALS, (H * 10) + ":3"); + assertRefusesNaming(PqAnchorConfig.PROPERTY_MIN_SEALS); + assertThatThrownBy(PqAnchorConfig::fromSystemConfiguration) + .hasMessageContaining("no step at the activation height " + H); + } + + /** + * P5: every value the constructor would have rejected used to be caught, logged once and turned + * into {@code never()}, so the node ran with the anchor completely inert and nothing said so + * again. All four shapes refuse now, and each one names the field that is actually wrong rather + * than the first field the loader happened to be holding. + */ + @Test + public void p5_aConfigurationTheConstructorWouldRejectRefusesInsteadOfReturningNever() { + // H with no room for a parent. + set(PqAnchorConfig.PROPERTY_CHAIN_ID, "2800"); + set(PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, "0"); + set(PqAnchorConfig.PROPERTY_MIN_SEALS, "0:3"); + assertRefusesNaming(PqAnchorConfig.PROPERTY_ANCHOR_BLOCK); + + // A negative threshold. + installReader(); + armCorrectly(); + set(PqAnchorConfig.PROPERTY_MIN_SEALS, H + ":-1"); + assertRefusesNaming(PqAnchorConfig.PROPERTY_MIN_SEALS); + + // A step that would take effect before the rules do. This is the common one: H is pushed back + // and the schedule is left where it was. + installReader(); + armCorrectly(); + set(PqAnchorConfig.PROPERTY_MIN_SEALS, (H - 1000) + ":0," + H + ":3"); + assertRefusesNaming(PqAnchorConfig.PROPERTY_MIN_SEALS); + } + + /** P6: a negative ceiling disarmed the WHOLE anchor while the banner said "threshold capped". */ + @Test + public void p6_aNegativeCeilingRefusesInsteadOfSilentlyKillingTheAnchor() { + armCorrectly(); + set(PqAnchorConfig.PROPERTY_MIN_SEALS_CEILING, "-1"); + assertRefusesNaming(PqAnchorConfig.PROPERTY_MIN_SEALS_CEILING); + } + + /** P7: an unreadable ceiling was IGNORED, and announced as being in force anyway. */ + @Test + public void p7_anUnparseableCeilingRefusesInsteadOfBeingIgnored() { + armCorrectly(); + set(PqAnchorConfig.PROPERTY_MIN_SEALS_CEILING, "three"); + assertRefusesNaming(PqAnchorConfig.PROPERTY_MIN_SEALS_CEILING); + } + + /** + * P8: the Holesky field, by name. An absent chain id took the default zero, the domain separation + * left the D and M pre-images, and nothing anywhere refused. Holesky ran for months on exactly + * this before it lost finality for two weeks. + */ + @Test + public void p8_anArmedAnchorWithoutAnExplicitChainIdIsRefused() { + set(PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, Long.toString(H)); + set(PqAnchorConfig.PROPERTY_MIN_SEALS, H + ":3"); + assertRefusesNaming(PqAnchorConfig.PROPERTY_CHAIN_ID); + } + + /** P8, the explicit zero: written down, still meaningless. */ + @Test + public void p8_anExplicitChainIdOfZeroIsRefused() { + set(PqAnchorConfig.PROPERTY_CHAIN_ID, "0"); + set(PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, Long.toString(H)); + set(PqAnchorConfig.PROPERTY_MIN_SEALS, H + ":3"); + assertRefusesNaming(PqAnchorConfig.PROPERTY_CHAIN_ID); + } + + /** A schedule with no activation height can never do anything, and used to say nothing. */ + @Test + public void aScheduleWithoutAnActivationHeightIsRefused() { + set(PqAnchorConfig.PROPERTY_CHAIN_ID, "2800"); + set(PqAnchorConfig.PROPERTY_MIN_SEALS, H + ":3"); + assertRefusesNaming(PqAnchorConfig.PROPERTY_MIN_SEALS); + } + + /** The same height twice with two different thresholds: whichever wins, it is not a decision. */ + @Test + public void aDuplicateHeightWithTwoThresholdsIsRefused() { + set(PqAnchorConfig.PROPERTY_CHAIN_ID, "2800"); + set(PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, Long.toString(H)); + set(PqAnchorConfig.PROPERTY_MIN_SEALS, H + ":3," + H + ":5"); + assertRefusesNaming(PqAnchorConfig.PROPERTY_MIN_SEALS); + } + + // =============================================================================================== + // 5. THE BRAKE. It is the one control that has to work when the chain is already stopped, so it + // is the one place where "refuse to start" would be the wrong answer. + // =============================================================================================== + + /** P9: Boolean.parseBoolean made "1", "yes" and "on" into a silent FALSE. A pressed brake. */ + @Test + public void p9_theBrakeUnderstandsTheWordsAnOperatorActuallyTypes() { + for (final String yes : new String[] {"true", "TRUE", "1", "yes", "YES", "on", "On"}) { + installReader(); + armCorrectly(); + set(PqAnchorConfig.PROPERTY_DISABLE, yes); + final PqAnchorConfig config = PqAnchorConfig.fromSystemConfiguration(); + assertThat(config.disabled()).as("disarm value %s", yes).isTrue(); + assertThat(config.everActive()).isFalse(); + assertThat(config.activeAt(H + 1)).isFalse(); + // Still CONFIGURED, so PqEmergencyShoutRule keeps shouting at every block. + assertThat(config.anchorConfigured()).isTrue(); + assertThat(config.anchorBlock()).isEqualTo(H); + } + for (final String no : new String[] {"false", "FALSE", "0", "no", "off"}) { + installReader(); + armCorrectly(); + set(PqAnchorConfig.PROPERTY_DISABLE, no); + final PqAnchorConfig config = PqAnchorConfig.fromSystemConfiguration(); + assertThat(config.disabled()).as("disarm value %s", no).isFalse(); + assertThat(config.everActive()).isTrue(); + } + } + + /** A brake value nobody can read is the one thing worse than no brake: refuse. */ + @Test + public void p9_anUnreadableBrakeValueIsRefused() { + armCorrectly(); + set(PqAnchorConfig.PROPERTY_DISABLE, "maybe"); + assertRefusesNaming(PqAnchorConfig.PROPERTY_DISABLE); + } + + /** + * THE BRAKE IS ABSOLUTE. A node whose anchor configuration is broken in every other way must + * still start when the operator has asked for the anchor to be off, because that request is the + * only way back from an activation that stopped the chain. + */ + @Test + public void theBrakeStartsTheNodeEvenOverAConfigurationThatWouldOtherwiseBeRefused() { + set(PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, ""); + set(PqAnchorConfig.PROPERTY_MIN_SEALS, "this,is,not,a,schedule"); + set(PqAnchorConfig.PROPERTY_MIN_SEALS_CEILING, "-9"); + set(PqAnchorConfig.PROPERTY_DISABLE, "true"); + + final PqAnchorConfig config = PqAnchorConfig.fromSystemConfiguration(); + assertThat(config.everActive()).isFalse(); + assertThat(config.activeAt(H)).isFalse(); + // Honest cost, stated here so it is not discovered later: with no readable activation height + // there is nothing for the per-block disarm announcement to name, so it is lost in this corner. + assertThat(config.anchorConfigured()).isFalse(); + } + + // =============================================================================================== + // 6. THE CONTROLS. A gate that has never been shown to open and close is not a gate. + // =============================================================================================== + + /** + * NEGATIVE CONTROL 1. Remove the guard: a reader whose presence probe answers "absent" for every + * name. Every refusal above must disappear and the loader must fall straight through to today's + * behaviour. If any of these still threw, the refusal would be coming from somewhere other than + * the presence gate and this whole file would be measuring the wrong thing. + */ + @Test + public void negativeControl_withThePresenceGateRemovedEveryRefusalDisappears() { + final String[][] brokenConfigurations = { + {PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, ""}, + {PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, "10_141_734"}, + {PqAnchorConfig.PROPERTY_MIN_SEALS, H + ":3," + (H + 7200) + ":"}, + {PqAnchorConfig.PROPERTY_MIN_SEALS, H + ":3,," + (H + 7200) + ":4"}, + {PqAnchorConfig.PROPERTY_MIN_SEALS, (H * 10) + ":3"}, + {PqAnchorConfig.PROPERTY_MIN_SEALS_CEILING, "-1"}, + {PqAnchorConfig.PROPERTY_MIN_SEALS_CEILING, "three"}, + {PqAnchorConfig.PROPERTY_CHAIN_ID, "0"}, + {PqAnchorConfig.PROPERTY_DISABLE, "maybe"} + }; + + // First, with the gate in place, prove each one is genuinely red. A negative control over cases + // that were never red proves nothing at all. + for (final String[] broken : brokenConfigurations) { + installReader(); + set(PqAnchorConfig.PROPERTY_CHAIN_ID, "2800"); + set(PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, Long.toString(H)); + set(PqAnchorConfig.PROPERTY_MIN_SEALS, H + ":3"); + set(broken[0], broken[1]); + assertThatThrownBy(PqAnchorConfig::fromSystemConfiguration) + .as("with the gate in place: %s=%s", broken[0], broken[1]) + .isInstanceOf(FalconSealSupport.ActivationConfigException.class); + } + + // Now pull the guard out and watch every one of them go quiet. + PqAnchorConfig.useNameReaderForTesting(new BlindReader()); + for (final String[] broken : brokenConfigurations) { + assertThatCode(PqAnchorConfig::fromSystemConfiguration) + .as("with the gate removed: %s=%s", broken[0], broken[1]) + .doesNotThrowAnyException(); + assertThat(PqAnchorConfig.fromSystemConfiguration().everActive()) + .as("with the gate removed: %s=%s", broken[0], broken[1]) + .isFalse(); + } + } + + /** + * NEGATIVE CONTROL 2, the sharper one. For every refusal above, the SAME configuration with only + * the one broken field corrected must start cleanly. Without this, a loader that refused + * absolutely everything would score full marks on the nine cases above. + */ + @Test + public void negativeControl_correctingOnlyTheBrokenFieldMakesEachCaseStart() { + // field, the value that must refuse, the value that must start. + final String[][] pairs = { + {PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, "", Long.toString(H)}, + {PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, "10_141_734", Long.toString(H)}, + {PqAnchorConfig.PROPERTY_MIN_SEALS, H + ":3," + (H + 7200) + ":", H + ":3," + (H + 7200) + ":4"}, + {PqAnchorConfig.PROPERTY_MIN_SEALS, H + ":3,," + (H + 7200) + ":4", H + ":3," + (H + 7200) + ":4"}, + {PqAnchorConfig.PROPERTY_MIN_SEALS, (H * 10) + ":3", H + ":3," + (H * 10) + ":4"}, + {PqAnchorConfig.PROPERTY_MIN_SEALS_CEILING, "-1", "2"}, + {PqAnchorConfig.PROPERTY_MIN_SEALS_CEILING, "three", "2"}, + {PqAnchorConfig.PROPERTY_CHAIN_ID, "0", "2800"}, + {PqAnchorConfig.PROPERTY_DISABLE, "maybe", "false"} + }; + for (final String[] pair : pairs) { + installReader(); + armCorrectly(); + set(pair[0], pair[1]); + assertThatThrownBy(PqAnchorConfig::fromSystemConfiguration) + .as("broken: %s=%s", pair[0], pair[1]) + .isInstanceOf(FalconSealSupport.ActivationConfigException.class); + + installReader(); + armCorrectly(); + set(pair[0], pair[2]); + final PqAnchorConfig config = PqAnchorConfig.fromSystemConfiguration(); + assertThat(config.everActive()).as("corrected: %s=%s", pair[0], pair[2]).isTrue(); + assertThat(config.anchorBlock()).isEqualTo(H); + } + } + + /** + * POSITIVE CONTROL for the refusal message itself. The message is the whole product here: an + * operator at three in the morning gets one screen, and it has to name the field, the value read, + * the problem, the repair, and the sequencing rule that keeps a fleet restart from killing the + * chain at quorum 5 of 7. + */ + @Test + public void theRefusalMessageCarriesEverythingAnOperatorNeedsAtThreeInTheMorning() { + set(PqAnchorConfig.PROPERTY_CHAIN_ID, "2800"); + set(PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, Long.toString(H)); + set(PqAnchorConfig.PROPERTY_MIN_SEALS, H + ":3," + (H + 7200) + ":"); + + assertThatThrownBy(PqAnchorConfig::fromSystemConfiguration) + .isInstanceOf(FalconSealSupport.ActivationConfigException.class) + .hasMessageContaining(PqAnchorConfig.REFUSAL_CODE) + .hasMessageContaining("FIELD " + PqAnchorConfig.PROPERTY_MIN_SEALS) + .hasMessageContaining("SOURCE system property (BESU_OPTS)") + .hasMessageContaining("READ \"" + H + ":3," + (H + 7200) + ":\"") + .hasMessageContaining("PROBLEM step \"" + (H + 7200) + ":\" has no threshold after the ':'") + .hasMessageContaining("EXPECTED block:threshold,block:threshold") + .hasMessageContaining("a step exactly at " + H) + .hasMessageContaining("FIX correct BESU_OPTS on THIS node") + .hasMessageContaining("restart one at a time") + .hasMessageContaining("At quorum 5 of 7 you lose the chain") + .hasMessageContaining("EMERGENCY " + PqAnchorConfig.PROPERTY_DISABLE + "=true"); + } + + /** And the origin is named honestly when the value came from the environment, not the command. */ + @Test + public void theRefusalNamesTheEnvironmentWhenThatIsWhereTheValueCameFrom() { + setEnvironment(PqAnchorConfig.ENV_CHAIN_ID, "2800"); + setEnvironment(PqAnchorConfig.ENV_ANCHOR_BLOCK, "not-a-number"); + assertThatThrownBy(PqAnchorConfig::fromSystemConfiguration) + .hasMessageContaining("SOURCE environment variable " + PqAnchorConfig.ENV_ANCHOR_BLOCK); + } +} diff --git a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorEmergencyConfigTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorEmergencyConfigTest.java new file mode 100644 index 0000000..222f45b --- /dev/null +++ b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorEmergencyConfigTest.java @@ -0,0 +1,120 @@ +/* + * Copyright contributors to Besu / Aere Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Map; +import java.util.OptionalInt; + +import org.junit.jupiter.api.Test; + +/** + * AERE OPTIUNI-URGENTA: the arithmetic the two anchor emergency controls stand on. + * + *

The property that matters is not "the ceiling works". It is that the ceiling can only ever go + * ONE WAY. A control an operator reaches for at three in the morning to restart a stalled chain must + * not be capable of stopping one, so a ceiling ABOVE the scheduled threshold has to be inert rather + * than raising it. That is asserted here in both directions, and the announcement predicate is + * asserted alongside it, because a shout that fires when the control is doing nothing trains people + * to ignore the log. + */ +class PqAnchorEmergencyConfigTest { + + private static final long CHAIN_ID = 2800L; + private static final long H = 1_000L; + + private PqAnchorConfig config(final OptionalInt ceiling, final boolean disabled) { + return new PqAnchorConfig( + CHAIN_ID, H, Map.of(H, 0, H + 100L, 3, H + 200L, 5), ceiling, disabled); + } + + @Test + void ceilingLowersTheThreshold() { + final PqAnchorConfig capped = config(OptionalInt.of(1), false); + assertThat(capped.scheduledMinSealsAt(H + 250L)).isEqualTo(5); + assertThat(capped.minSealsAt(H + 250L)).isEqualTo(1); + } + + @Test + void ceilingCanNeverRaiseTheThreshold() { + final PqAnchorConfig capped = config(OptionalInt.of(99), false); + // Scheduled 0, 3 and 5 at the three steps; a ceiling of 99 must change NONE of them. + assertThat(capped.minSealsAt(H)).isEqualTo(0); + assertThat(capped.minSealsAt(H + 100L)).isEqualTo(3); + assertThat(capped.minSealsAt(H + 250L)).isEqualTo(5); + assertThat(capped.minSealsAt(H + 250L)).isEqualTo(capped.scheduledMinSealsAt(H + 250L)); + } + + @Test + void ceilingOfZeroRemovesTheThresholdEntirely() { + final PqAnchorConfig capped = config(OptionalInt.of(0), false); + assertThat(capped.minSealsAt(H + 250L)).isZero(); + } + + @Test + void theAnnouncementFiresOnlyWhereTheCeilingActuallyLowersSomething() { + final PqAnchorConfig capped = config(OptionalInt.of(3), false); + // Below H nothing is active at all, so there is nothing to announce. + assertThat(capped.emergencyCeilingLowersAt(H - 1L)).isFalse(); + // At H the schedule asks for 0; a ceiling of 3 is carrying no weight there. + assertThat(capped.emergencyCeilingLowersAt(H)).isFalse(); + // At H+100 the schedule asks for exactly 3; still no weight. + assertThat(capped.emergencyCeilingLowersAt(H + 100L)).isFalse(); + // At H+200 the schedule asks for 5 and the ceiling holds it at 3. THAT is worth a line. + assertThat(capped.emergencyCeilingLowersAt(H + 250L)).isTrue(); + } + + @Test + void noCeilingMeansNoAnnouncementAnywhere() { + final PqAnchorConfig plain = config(OptionalInt.empty(), false); + assertThat(plain.emergencyCeilingLowersAt(H + 250L)).isFalse(); + assertThat(plain.minSealsCeiling()).isEmpty(); + } + + @Test + void disarmedIsNotTheSameStateAsNeverConfigured() { + final PqAnchorConfig disarmed = config(OptionalInt.empty(), true); + assertThat(disarmed.disabled()).isTrue(); + assertThat(disarmed.anchorConfigured()).isTrue(); + assertThat(disarmed.everActive()).isFalse(); + assertThat(disarmed.activeAt(H + 500L)).isFalse(); + + final PqAnchorConfig never = PqAnchorConfig.never(CHAIN_ID); + assertThat(never.disabled()).isFalse(); + assertThat(never.anchorConfigured()).isFalse(); + assertThat(never.everActive()).isFalse(); + } + + @Test + void aDisarmedNodeStillEnforcesNothingEvenWhereTheScheduleIsHighest() { + final PqAnchorConfig disarmed = config(OptionalInt.empty(), true); + assertThat(disarmed.activeAt(H + 250L)).isFalse(); + // The announcement predicate is gated on activeAt, so a disarmed node announces the DISARM and + // not, additionally, a ceiling that is no longer reachable. + assertThat(disarmed.emergencyCeilingLowersAt(H + 250L)).isFalse(); + } + + @Test + void aNegativeCeilingIsRefusedRatherThanClampedSilently() { + assertThatThrownBy( + () -> + new PqAnchorConfig( + CHAIN_ID, H, Map.of(H, 3), OptionalInt.of(-1), false)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("must not be negative"); + } +} diff --git a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorIntervalTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorIntervalTest.java new file mode 100644 index 0000000..08afdba --- /dev/null +++ b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorIntervalTest.java @@ -0,0 +1,186 @@ +/* + * AERE, 2026-08-07. The anchor interval: a certificate on every Nth block instead of every block. + * + * WHY THIS EXISTS. At ~523 ms per block we produce 165,248 blocks per day, 23 times more than + * Ethereum. A certificate in EVERY block multiplies the header by almost five even with the cap at + * K=3, and on a chain with empty blocks the headers are close to everything that gets written to + * disk. So that multiplier is the multiplier of database growth, and the design does not fit on a + * reasonably provisioned node. The interval divides it by N. + * + * WHY IT IS SAFE, and this is the argument that has to hold, not the saving. Block hashes chain: + * block N+1 commits to the hash of N. So an anchor at height A, whose vanityData binds a Falcon + * certificate over A-1, protects EVERYTHING below A: rewriting any block below A changes the hash + * of A's parent, which requires A to be reproduced, which requires a Falcon quorum the adversary + * does not have. The only thing that stays rewritable is the TAIL since the last anchor. The + * property is therefore + * + * fork depth <= interval + * + * and it is a dial, not an accident. At 100 it means ~52 seconds of tail instead of ~0.5 seconds, + * and it costs one hundredth as much. Algorand ships the same shape at 1 in 256. + */ +package org.hyperledger.besu.consensus.common.bft; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Map; +import java.util.OptionalInt; +import org.junit.jupiter.api.Test; + +class PqAnchorIntervalTest { + + private static final long H = 1000L; + + private static PqAnchorConfig armed() { + return new PqAnchorConfig(2800L, H, Map.of(H, 3), OptionalInt.empty(), false); + } + + // --------------------------------------------------------------------------------------------- + // 1. THE COMPATIBILITY BOUNDARY. Unset means every block, that is the design as it stood until + // today. If this ever breaks, a node built from the new code no longer agrees with an old one. + // --------------------------------------------------------------------------------------------- + @Test + void withNoIntervalEveryHeightIsAnAnchorHeight() { + final PqAnchorConfig cfg = armed(); + assertThat(cfg.anchorInterval()).isEmpty(); + for (final long n : new long[] {H, H + 1, H + 2, H + 99, H + 100, H + 12345}) { + assertThat(cfg.isAnchorHeight(n)).as("height %d", n).isTrue(); + assertThat(cfg.anchorAppliesAt(n)).as("height %d", n).isTrue(); + } + } + + // --------------------------------------------------------------------------------------------- + // 2. An interval of 1 is the same thing written out explicitly, and must behave identically. + // --------------------------------------------------------------------------------------------- + @Test + void anIntervalOfOneIsTheSameAsNoInterval() { + final PqAnchorConfig cfg = armed().withAnchorInterval(OptionalInt.of(1)); + for (final long n : new long[] {H, H + 1, H + 7, H + 1000}) { + assertThat(cfg.isAnchorHeight(n)).as("height %d", n).isTrue(); + } + } + + // --------------------------------------------------------------------------------------------- + // 3. THE HEART. Counting starts at H, not at zero. H is the only height the fleet coordinates on, + // and a scheme whose first anchor falls somewhere else is a scheme nobody checks by hand on + // activation day. + // --------------------------------------------------------------------------------------------- + @Test + void anchorHeightsAreCountedFromHAndHItselfIsAlwaysOne() { + final PqAnchorConfig cfg = armed().withAnchorInterval(OptionalInt.of(100)); + + assertThat(cfg.isAnchorHeight(H)).as("H itself").isTrue(); + assertThat(cfg.isAnchorHeight(H + 100)).isTrue(); + assertThat(cfg.isAnchorHeight(H + 200)).isTrue(); + assertThat(cfg.isAnchorHeight(H + 100000)).isTrue(); + + for (final long off : new long[] {1, 2, 50, 99, 101, 199}) { + assertThat(cfg.isAnchorHeight(H + off)).as("H+%d is not an anchor", off).isFalse(); + assertThat(cfg.anchorAppliesAt(H + off)).as("H+%d is not judged", off).isFalse(); + } + } + + // --------------------------------------------------------------------------------------------- + // 4. Below H there is no anchor, however neatly the arithmetic falls. Without this, a height + // below H that happens to be a multiple would be judged, and a new binary would no longer + // behave like an old one on the history from before activation. + // --------------------------------------------------------------------------------------------- + @Test + void belowHNothingIsAnAnchorHeightHoweverTheArithmeticFalls() { + final PqAnchorConfig cfg = armed().withAnchorInterval(OptionalInt.of(100)); + for (final long n : new long[] {0L, 1L, H - 200, H - 100, H - 1}) { + assertThat(cfg.isAnchorHeight(n)).as("height %d", n).isFalse(); + assertThat(cfg.anchorAppliesAt(n)).as("height %d", n).isFalse(); + } + } + + // --------------------------------------------------------------------------------------------- + // 5. NEGATIVE CONTROL. Zero and negative are refused. An interval of 0 would give a division by + // zero, and a negative one would make the modulo arithmetic answer nonsense. + // --------------------------------------------------------------------------------------------- + @Test + void zeroAndNegativeIntervalsAreRefused() { + for (final int bad : new int[] {0, -1, -100}) { + assertThatThrownBy(() -> armed().withAnchorInterval(OptionalInt.of(bad))) + .as("interval %d", bad) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining(PqAnchorConfig.PROPERTY_ANCHOR_INTERVAL) + .hasMessageContaining("at least 1"); + } + } + + // --------------------------------------------------------------------------------------------- + // 6. anchorAppliesAt is the AND of the two conditions, and the emergency disarm beats all of it. + // A disarm that let the anchor judge at the interval heights would not be a disarm. + // --------------------------------------------------------------------------------------------- + @Test + void theEmergencyDisarmBeatsTheIntervalEverywhere() { + final PqAnchorConfig dezarmat = + new PqAnchorConfig(2800L, H, Map.of(H, 3), OptionalInt.empty(), true) + .withAnchorInterval(OptionalInt.of(100)); + assertThat(dezarmat.isAnchorHeight(H)).isTrue(); // the arithmetic says yes + assertThat(dezarmat.activeAt(H)).isFalse(); // but the scheme is switched off + assertThat(dezarmat.anchorAppliesAt(H)).isFalse(); // so nothing is judged + assertThat(dezarmat.anchorAppliesAt(H + 100)).isFalse(); + } + + // --------------------------------------------------------------------------------------------- + // 7. A node with no anchor at all has no anchor heights either. Without this, a configuration + // with no H but with an interval would answer "yes" to anything and would judge headers on a + // chain that was never activated. + // --------------------------------------------------------------------------------------------- + @Test + void aNodeWithNoAnchorAtAllHasNoAnchorHeights() { + final PqAnchorConfig niciodata = PqAnchorConfig.never(2800L).withAnchorInterval(OptionalInt.of(100)); + for (final long n : new long[] {0L, 1L, H, H + 100, Long.MAX_VALUE - 1}) { + assertThat(niciodata.isAnchorHeight(n)).as("height %d", n).isFalse(); + assertThat(niciodata.anchorAppliesAt(n)).as("height %d", n).isFalse(); + } + } + + // --------------------------------------------------------------------------------------------- + // 8. THE TWO COST CONTROLS DO NOT LOSE EACH OTHER. This is the mistake I made a point of + // catching: a copy method that forgets a field is how the cap or the interval would silently + // disappear when the other one is set, and nobody would notice until the disk bill. + // --------------------------------------------------------------------------------------------- + @Test + void theTwoCostControlsDoNotLoseEachOther() { + final PqAnchorConfig amandoua = + armed().withMaxSealsCarried(OptionalInt.of(3)).withAnchorInterval(OptionalInt.of(100)); + assertThat(amandoua.maxSealsCarried()).hasValue(3); + assertThat(amandoua.anchorInterval()).hasValue(100); + + // and in the reverse order + final PqAnchorConfig invers = + armed().withAnchorInterval(OptionalInt.of(100)).withMaxSealsCarried(OptionalInt.of(3)); + assertThat(invers.maxSealsCarried()).hasValue(3); + assertThat(invers.anchorInterval()).hasValue(100); + + // and the rest of the configuration survives both of them + assertThat(invers.chainId()).isEqualTo(2800L); + assertThat(invers.anchorBlock()).isEqualTo(H); + assertThat(invers.minSealsAt(H)).isEqualTo(3); + } + + // --------------------------------------------------------------------------------------------- + // 9. THE PROPERTY, written as a proof and not merely as a sentence in a comment: between two + // consecutive anchors there are exactly heights, of which exactly one is judged. + // That IS the maximum fork depth, and we count it instead of asserting it. + // --------------------------------------------------------------------------------------------- + @Test + void betweenTwoAnchorsExactlyOneHeightIsJudged() { + final int interval = 100; + final PqAnchorConfig cfg = armed().withAnchorInterval(OptionalInt.of(interval)); + int judecate = 0; + for (long n = H; n < H + interval; n++) { + if (cfg.anchorAppliesAt(n)) { + judecate++; + } + } + assertThat(judecate) + .as("exactly one height judged every %d, so the rewritable tail is %d blocks", + interval, interval) + .isEqualTo(1); + } +} diff --git a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorMinSealsFloorTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorMinSealsFloorTest.java new file mode 100644 index 0000000..53917ee --- /dev/null +++ b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorMinSealsFloorTest.java @@ -0,0 +1,153 @@ +/* + * AERE, 2026-08-07. THE THRESHOLD FLOOR: a schedule whose effective K is zero everywhere leaves the + * anchor armed and completely toothless, forever, and every tool reports GREEN the whole time it is + * happening, because they all measure what was ASKED FOR and the request is valid. + * + * The loader already guarded this consequence in its own words, "K would be 0 at every height + * and an ARMED node would accept empty certificates", but only for a MISSING schedule. A schedule + * that is PRESENT and of the form ":0" reaches the same state, and it used to pass. + * + * And it is not theoretical: the recommended activation schedule has the form ":0,:3", + * that is, it STARTS at zero, precisely in order to leave a warm-up window. If the second half is + * lost to a stray quote or a truncated variable, what remains is exactly the dangerous form, and + * that is why the floor looks at the WHOLE schedule. + */ +package org.hyperledger.besu.consensus.common.bft; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.assertThatCode; + +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class PqAnchorMinSealsFloorTest { + + private static final long H = 13_793_219L; + + private static final class Reader implements PqAnchorConfig.NameReader { + private final Map properties = new HashMap<>(); + + @Override + public String property(final String name) { + return properties.get(name); + } + + @Override + public String environment(final String name) { + return null; + } + } + + private Reader reader; + + @BeforeEach + void installReader() { + reader = new Reader(); + PqAnchorConfig.useNameReaderForTesting(reader); + set(PqAnchorConfig.PROPERTY_CHAIN_ID, "2800"); + set(PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, Long.toString(H)); + } + + @AfterEach + void restoreReader() { + PqAnchorConfig.useNameReaderForTesting(null); + } + + private void set(final String property, final String value) { + reader.properties.put(property, value); + } + + // --------------------------------------------------------------------------------------------- + // 1. THE DEFECT ITSELF. A schedule that never demands anything is refused at startup. + // --------------------------------------------------------------------------------------------- + @Test + void anArmedAnchorWhoseScheduleNeverDemandsASignatureIsRefused() { + set(PqAnchorConfig.PROPERTY_MIN_SEALS, H + ":0"); + + assertThatThrownBy(PqAnchorConfig::fromSystemConfiguration) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining(PqAnchorConfig.REFUSAL_MIN_SEALS_FLOOR) + .hasMessageContaining("ZERO"); + } + + // --------------------------------------------------------------------------------------------- + // 2. Nor do several zero steps add up to anything. Whoever writes two zeros believes they have + // done something. + // --------------------------------------------------------------------------------------------- + @Test + void severalZeroStepsAreStillZero() { + set(PqAnchorConfig.PROPERTY_MIN_SEALS, H + ":0," + (H + 1000) + ":0," + (H + 2000) + ":0"); + + assertThatThrownBy(PqAnchorConfig::fromSystemConfiguration) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining(PqAnchorConfig.REFUSAL_MIN_SEALS_FLOOR); + } + + // --------------------------------------------------------------------------------------------- + // 3. THE CONTROL THAT MATTERS MOST: the warm-up window stays LEGAL. A guard that also refused the + // form our own plan recommends would have been worked around within five minutes, and then we + // would have had neither the guard nor the window. + // --------------------------------------------------------------------------------------------- + @Test + void theWarmUpWindowRecommendedByOurOwnPlanStaysLegal() { + set(PqAnchorConfig.PROPERTY_MIN_SEALS, H + ":0," + (H + 165000) + ":3"); + + assertThatCode(PqAnchorConfig::fromSystemConfiguration).doesNotThrowAnyException(); + final PqAnchorConfig config = PqAnchorConfig.fromSystemConfiguration(); + assertThat(config.minSealsAt(H)).isZero(); + assertThat(config.minSealsAt(H + 165000)).isEqualTo(3); + assertThat(config.highestEffectiveMinSeals()).isEqualTo(3); + } + + @Test + void aScheduleThatDemandsFromTheStartIsFine() { + set(PqAnchorConfig.PROPERTY_MIN_SEALS, H + ":3"); + assertThatCode(PqAnchorConfig::fromSystemConfiguration).doesNotThrowAnyException(); + } + + // --------------------------------------------------------------------------------------------- + // 4. THE SECOND ROAD TO THE SAME STATE, and it is the one that would have fooled me: an + // emergency ceiling of zero drops K to zero at every height, over a schedule that looks + // perfect. The floor looks at the EFFECTIVE K, after the ceiling, precisely to catch this too. + // --------------------------------------------------------------------------------------------- + @Test + void anEmergencyCeilingOfZeroReachesTheSameToothlessStateAndIsAlsoRefused() { + set(PqAnchorConfig.PROPERTY_MIN_SEALS, H + ":3"); + set(PqAnchorConfig.PROPERTY_MIN_SEALS_CEILING, "0"); + + assertThatThrownBy(PqAnchorConfig::fromSystemConfiguration) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining(PqAnchorConfig.REFUSAL_MIN_SEALS_FLOOR) + .hasMessageContaining("emergency ceiling"); + } + + // --------------------------------------------------------------------------------------------- + // 5. THE BRAKE IS ABSOLUTE, and it stays absolute. The emergency disarm has to work when the + // chain is ALREADY stopped; a guard that refused to start a DISARMED node would be a brake + // that refuses to brake, which is exactly what our own comment says it must never be. + // --------------------------------------------------------------------------------------------- + @Test + void theEmergencyDisarmStillStartsWithAToothlessSchedule() { + set(PqAnchorConfig.PROPERTY_MIN_SEALS, H + ":0"); + set(PqAnchorConfig.PROPERTY_DISABLE, "true"); + + assertThatCode(PqAnchorConfig::fromSystemConfiguration).doesNotThrowAnyException(); + assertThat(PqAnchorConfig.fromSystemConfiguration().activeAt(H)).isFalse(); + } + + // --------------------------------------------------------------------------------------------- + // 6. A node with no anchor at all has no floor to violate. Without this, the guard would stop + // every node in the fleet that has not been configured yet, which is most of them, from + // starting on activation day. + // --------------------------------------------------------------------------------------------- + @Test + void aNodeWithNoAnchorConfiguredIsUntouched() { + PqAnchorConfig.useNameReaderForTesting(new Reader()); + assertThatCode(PqAnchorConfig::fromSystemConfiguration).doesNotThrowAnyException(); + assertThat(PqAnchorConfig.fromSystemConfiguration().everActive()).isFalse(); + } +} diff --git a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorProducerCostTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorProducerCostTest.java new file mode 100644 index 0000000..252fd34 --- /dev/null +++ b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorProducerCostTest.java @@ -0,0 +1,251 @@ +/* + * AERE, 2026-08-07. THE TWO COST CONTROLS, measured on the PRODUCER, not on the configuration. + * + * WHY THIS EXISTS. On 7 August `aere.pq.anchor.maxSeals` and `aere.pq.anchorInterval` were built, + * and their configuration guards were proven the same day. But the cut in the producer, the code + * that ACTUALLY stops seals being written past the cap, and that ACTUALLY skips the heights with no + * anchor, stayed an ASSERTION: there was no producer harness in the tree, and we had just seen, at + * the wiring of the rules into the validation chain, what a piece of code that no proof touches + * costs. + * + * This class touches it. It counts the seals written, it does not assume them. + * + * THE NEGATIVE CONTROL IS INSIDE, and it is exactly the pair: the same seven seals heard, the same + * proposer, the same inputs, one single difference, the cap set or not. Without the half with no + * cap, a proof counting 3 would have passed even if the producer had heard only 3. + */ +package org.hyperledger.besu.consensus.common.bft; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.withSettings; + +import java.io.IOException; +import java.lang.reflect.Field; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.OptionalInt; +import org.apache.tuweni.bytes.Bytes; +import org.apache.tuweni.bytes.Bytes32; +import org.bouncycastle.crypto.digests.KeccakDigest; +import org.bouncycastle.pqc.crypto.falcon.FalconPrivateKeyParameters; +import org.bouncycastle.pqc.crypto.falcon.FalconSigner; +import org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer; +import org.hyperledger.besu.consensus.common.validator.ValidatorProvider; +import org.hyperledger.besu.datatypes.Address; +import org.hyperledger.besu.ethereum.ProtocolContext; +import org.hyperledger.besu.ethereum.core.BlockHeader; +import org.hyperledger.besu.ethereum.core.BlockHeaderTestFixture; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.quality.Strictness; + +class PqAnchorProducerCostTest { + + private static final long CHAIN_ID = 220_879L; + private static final long H = 1_000L; + private static final long ATTACH = 900L; + private static final int N = 7; + + @TempDir private Path tmp; + + private final List privateKeys = new ArrayList<>(); + private final List

validators = new ArrayList<>(); + + @BeforeEach + void setUp() throws Exception { + final KeccakDigest kd = new KeccakDigest(256); + final StringBuilder manifest = new StringBuilder(); + manifest + .append("{\"config\":{\"aereFalconRegistry\":{") + .append(PqV2Fixture.manifestHeader(N, CHAIN_ID, H)); + for (int i = 0; i < N; i++) { + privateKeys.add(PqV2Fixture.privateKey(i)); + validators.add(PqV2Fixture.address(i)); + final byte[] row = PqV2Fixture.anchorPreimageRow(i); + kd.update(row, 0, row.length); + manifest.append(',').append(PqV2Fixture.manifestEntry(i, N, CHAIN_ID, H)); + } + manifest.append("}},\"alloc\":{\"0000000000000000000000000000000000000fa1\":{\"storage\":{\"0x") + .append("0".repeat(64)) + .append("\":\"0x"); + final byte[] anchoredHash = new byte[32]; + kd.doFinal(anchoredHash, 0); + manifest.append(Bytes.wrap(anchoredHash).toUnprefixedHexString()).append("\"}}}}"); + + final Path genesis = tmp.resolve("genesis-registry.json"); + Files.writeString(genesis, manifest.toString()); + System.setProperty("aere.falcon.genesis", genesis.toAbsolutePath().toString()); + System.setProperty("aere.falcon.attachBlock", Long.toString(ATTACH)); + + resetFalconSingleton(); + PqSealCache.instance().clear(); + } + + @AfterEach + void tearDown() throws Exception { + System.clearProperty("aere.falcon.genesis"); + System.clearProperty("aere.falcon.attachBlock"); + resetFalconSingleton(); + PqSealCache.instance().clear(); + PqAnchorProducer.useConfigForTesting(null); + } + + // --------------------------------------------------------------------------------------------- + // THE CAP, measured in both directions on the same seven seals heard. + // --------------------------------------------------------------------------------------------- + + @Test + void withNoCapTheProposerWritesEverySealItHeard() { + PqAnchorProducer.useConfigForTesting(armedConfig(OptionalInt.empty(), OptionalInt.empty())); + final BlockHeader parent = parentOf(H + 10L); + hearAllSevenSealsFor(parent); + + final BftExtraData produced = PqAnchorProducer.apply(base(), parent, contextWith(validators)); + + assertThat(produced.getFalconSeals()) + .describedAs( + "K is a FLOOR: with no cap the proposer writes everything it heard, that is seven, not " + + "three. This half is what makes the number in the other half mean anything") + .hasSize(N); + } + + @Test + void withTheCapAtThreeTheProposerWritesExactlyThree() { + PqAnchorProducer.useConfigForTesting(armedConfig(OptionalInt.of(3), OptionalInt.empty())); + final BlockHeader parent = parentOf(H + 10L); + hearAllSevenSealsFor(parent); + + final BftExtraData produced = PqAnchorProducer.apply(base(), parent, contextWith(validators)); + + assertThat(produced.getFalconSeals()) + .describedAs("the same seven seals heard, the only difference is the cap") + .hasSize(3); + } + + /** + * A cap larger than what was heard does not invent seals. Without this, a badly chosen cap could + * look like it "works" merely because the number asked for happens to equal the number heard. + */ + @Test + void aCapAboveWhatWasHeardChangesNothing() { + PqAnchorProducer.useConfigForTesting(armedConfig(OptionalInt.of(20), OptionalInt.empty())); + final BlockHeader parent = parentOf(H + 10L); + hearAllSevenSealsFor(parent); + + assertThat(PqAnchorProducer.apply(base(), parent, contextWith(validators)).getFalconSeals()) + .hasSize(N); + } + + // --------------------------------------------------------------------------------------------- + // THE INTERVAL, also in both directions, and also on the same seals heard. + // --------------------------------------------------------------------------------------------- + + @Test + void atAnAnchorHeightTheProposerWritesACertificate() { + PqAnchorProducer.useConfigForTesting(armedConfig(OptionalInt.of(3), OptionalInt.of(100))); + // the parent is H-1, so the block being built is H itself, and H is always an anchor height + final BlockHeader parent = parentOf(H - 1L); + hearAllSevenSealsFor(parent); + + assertThat(PqAnchorProducer.apply(base(), parent, contextWith(validators)).getFalconSeals()) + .describedAs("H itself is always an anchor height, whatever the interval is") + .hasSize(3); + } + + @Test + void betweenAnchorsTheProposerWritesNothingEvenThoughItHeardEverything() { + PqAnchorProducer.useConfigForTesting(armedConfig(OptionalInt.of(3), OptionalInt.of(100))); + // the parent is H, so the block built is H+1, which is NOT an anchor height at interval 100 + final BlockHeader parent = parentOf(H); + hearAllSevenSealsFor(parent); + + final BftExtraData produced = PqAnchorProducer.apply(base(), parent, contextWith(validators)); + + assertThat(produced.getFalconSeals()) + .describedAs( + "seven seals heard and none written, because this height carries no certificate. " + + "This is where the hundredfold saving comes from") + .isEmpty(); + assertThat(produced.getVanityData()) + .describedAs("and the digest is not placed where there is nothing to bind") + .isEqualTo(Bytes32.ZERO); + } + + /** + * With no interval, the SAME height does carry a certificate. This is the pair of the proof + * above. + */ + @Test + void thatSameHeightDoesCarryACertificateWhenThereIsNoInterval() { + PqAnchorProducer.useConfigForTesting(armedConfig(OptionalInt.of(3), OptionalInt.empty())); + final BlockHeader parent = parentOf(H); + hearAllSevenSealsFor(parent); + + assertThat(PqAnchorProducer.apply(base(), parent, contextWith(validators)).getFalconSeals()) + .describedAs("the only difference from the proof before is the interval") + .hasSize(3); + } + + // --------------------------------------------------------------------------------------------- + // Helpers. + // --------------------------------------------------------------------------------------------- + + private static PqAnchorConfig armedConfig(final OptionalInt cap, final OptionalInt interval) { + return new PqAnchorConfig( + CHAIN_ID, H, Map.of(H, 3), OptionalInt.empty(), false, cap, interval); + } + + private static BlockHeader parentOf(final long number) { + return new BlockHeaderTestFixture().number(number).buildHeader(); + } + + private BftExtraData base() { + return new BftExtraData( + Bytes32.ZERO, Collections.emptyList(), Optional.empty(), 0, validators, Collections.emptyList()); + } + + private void hearAllSevenSealsFor(final BlockHeader parent) { + final Bytes32 m = + PqAnchor.commitMessage(CHAIN_ID, parent.getNumber(), parent.getHash().getBytes()); + final List heard = new ArrayList<>(); + for (int i = 0; i < N; i++) { + heard.add(new FalconSeal(i, Bytes.wrap(falconSign(privateKeys.get(i), m)))); + } + PqSealCache.instance().record(parent.getNumber(), parent.getHash(), heard); + } + + private static byte[] falconSign(final FalconPrivateKeyParameters key, final Bytes32 m) { + final FalconSigner signer = new FalconSigner(); + signer.init(true, key); + return signer.generateSignature(m.toArray()); + } + + private static ProtocolContext contextWith(final Collection
vs) { + final ValidatorProvider validatorProvider = + mock(ValidatorProvider.class, withSettings().strictness(Strictness.LENIENT)); + when(validatorProvider.getValidatorsForBlock(any())).thenReturn(vs); + when(validatorProvider.getValidatorsAfterBlock(any())).thenReturn(vs); + final BftContext bftContext = + mock(BftContext.class, withSettings().strictness(Strictness.LENIENT)); + when(bftContext.getValidatorProvider()).thenReturn(validatorProvider); + when(bftContext.as(any())).thenReturn(bftContext); + return new ProtocolContext.Builder().withConsensusContext(bftContext).build(); + } + + private static void resetFalconSingleton() throws Exception { + final Field f = FalconSealSupport.class.getDeclaredField("instance"); + f.setAccessible(true); + f.set(null, null); + } +} diff --git a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorSealCapTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorSealCapTest.java new file mode 100644 index 0000000..5c61464 --- /dev/null +++ b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorSealCapTest.java @@ -0,0 +1,150 @@ +/* + * AERE, 2026-08-07. The PROPOSER-side seal cap, and its fail-closed guard. + * + * WHY IT EXISTS. K is a FLOOR, not a cap. Measured on a live ten-node run with the threshold at 4: + * 42 blocks carried 4 seals, 36 carried 5, 5 carried 6. The proposer writes every seal it heard and + * that is eligible, not as many as the threshold demands. At 666 bytes a seal, that means about two + * thirds more header written than the threshold asks for, and the surplus buys nothing: what a + * verifier demands is THE THRESHOLD. + * + * WHAT THIS FILE GUARDS, and this is the dangerous part: a cap set BELOW the highest K in the + * schedule makes the proposer write certificates its own fleet rejects, at every height from the + * step that raises K above the cap onwards. The failure is SILENT to every existing tool, because + * each one measures what was ASKED FOR and the request is consistent with itself. That is why the + * node refuses to start. + */ +package org.hyperledger.besu.consensus.common.bft; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Map; +import java.util.OptionalInt; +import org.junit.jupiter.api.Test; + +class PqAnchorSealCapTest { + + private static PqAnchorConfig armed(final Map schedule, final OptionalInt ceiling) { + return new PqAnchorConfig(2800L, 100L, schedule, ceiling, false); + } + + // --------------------------------------------------------------------------------------------- + // 1. No cap means exactly what every node did before today. This is the compatibility boundary, + // and it is the first thing to break if anyone ever gives the property a default value. + // --------------------------------------------------------------------------------------------- + @Test + void withNoCapConfiguredTheProposerIsUncapped() { + final PqAnchorConfig cfg = armed(Map.of(100L, 3), OptionalInt.empty()); + assertThat(cfg.maxSealsCarried()).isEmpty(); + assertThat(cfg.maxSealsCarried().orElse(Integer.MAX_VALUE)).isEqualTo(Integer.MAX_VALUE); + } + + // --------------------------------------------------------------------------------------------- + // 2. THE NEGATIVE CONTROL. A cap below the highest K in the schedule must REFUSE, and the message + // must name both numbers, otherwise whoever reads it at 3 in the morning does not know what to + // change. + // --------------------------------------------------------------------------------------------- + @Test + void aCapBelowTheHighestScheduledThresholdIsRefused() { + final PqAnchorConfig cfg = armed(Map.of(100L, 1, 200L, 3, 300L, 5), OptionalInt.empty()); + assertThat(cfg.highestEffectiveMinSeals()).isEqualTo(5); + + assertThatThrownBy(() -> cfg.withMaxSealsCarried(OptionalInt.of(4))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining(PqAnchorConfig.REFUSAL_CODE) + .hasMessageContaining(PqAnchorConfig.PROPERTY_MAX_SEALS) + .hasMessageContaining("=4") + .hasMessageContaining("K=5"); + } + + // --------------------------------------------------------------------------------------------- + // 3. And the refusal looks at the WHOLE schedule, not just the step at the arming height. A cap + // chosen from the first step is exactly the mistake I expect: our schedule STARTS at a low K. + // --------------------------------------------------------------------------------------------- + @Test + void theRefusalLooksAtTheWholeScheduleNotJustTheFirstStep() { + // exactly the shape the activation plan recommends: starts at 0, climbs to 3 + final PqAnchorConfig cfg = armed(Map.of(100L, 0, 265L, 3), OptionalInt.empty()); + assertThat(cfg.minSealsAt(100L)).isZero(); + assertThat(cfg.highestEffectiveMinSeals()).isEqualTo(3); + + // a cap of 1 "looks" right if you only look at the first step + assertThatThrownBy(() -> cfg.withMaxSealsCarried(OptionalInt.of(1))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("K=3"); + } + + // --------------------------------------------------------------------------------------------- + // 4. A cap exactly at K is the value we want in production, and it has to be accepted. + // --------------------------------------------------------------------------------------------- + @Test + void aCapEqualToTheHighestThresholdIsAccepted() { + final PqAnchorConfig cfg = + armed(Map.of(100L, 0, 265L, 3), OptionalInt.empty()).withMaxSealsCarried(OptionalInt.of(3)); + assertThat(cfg.maxSealsCarried()).hasValue(3); + assertThat(cfg.minSealsAt(265L)).isEqualTo(3); + assertThat(cfg.anchorBlock()).isEqualTo(100L); + assertThat(cfg.chainId()).isEqualTo(2800L); + } + + @Test + void aCapAboveTheHighestThresholdIsAccepted() { + final PqAnchorConfig cfg = + armed(Map.of(100L, 3), OptionalInt.empty()).withMaxSealsCarried(OptionalInt.of(7)); + assertThat(cfg.maxSealsCarried()).hasValue(7); + } + + // --------------------------------------------------------------------------------------------- + // 5. Zero is not a cap, it is a stop. An empty certificate is a state only the SCHEDULE may ask + // for, through K=0, and only inside the warm-up window. + // --------------------------------------------------------------------------------------------- + @Test + void aCapOfZeroIsRefusedOutright() { + assertThatThrownBy( + () -> new PqAnchorConfig(2800L, 100L, Map.of(100L, 0), OptionalInt.empty(), false, OptionalInt.of(0))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining(PqAnchorConfig.PROPERTY_MAX_SEALS) + .hasMessageContaining("at least 1"); + } + + // --------------------------------------------------------------------------------------------- + // 6. The emergency ceiling LOWERS K, so it also lowers the threshold the cost cap has to reach. + // The two cannot end up arguing with each other. + // --------------------------------------------------------------------------------------------- + @Test + void theEmergencyCeilingLowersWhatTheCostCapMustReach() { + final Map schedule = Map.of(100L, 3, 200L, 5); + assertThat(armed(schedule, OptionalInt.empty()).highestEffectiveMinSeals()).isEqualTo(5); + // with the emergency ceiling at 2, the effective K never goes above 2 + final PqAnchorConfig braked = armed(schedule, OptionalInt.of(2)); + assertThat(braked.highestEffectiveMinSeals()).isEqualTo(2); + // so a cost cap of 2 becomes legal, even though the schedule says 5 + assertThat(braked.withMaxSealsCarried(OptionalInt.of(2)).maxSealsCarried()).hasValue(2); + } + + // --------------------------------------------------------------------------------------------- + // 7. An empty schedule demands nothing, so any positive cap is legal. + // --------------------------------------------------------------------------------------------- + @Test + void anEmptyScheduleDemandsNothing() { + final PqAnchorConfig cfg = armed(Map.of(), OptionalInt.empty()); + assertThat(cfg.highestEffectiveMinSeals()).isZero(); + assertThat(cfg.withMaxSealsCarried(OptionalInt.of(1)).maxSealsCarried()).hasValue(1); + } + + // --------------------------------------------------------------------------------------------- + // 8. The rest of the configuration survives the copy. A method that drops a field on the way is + // how an emergency disarm would vanish in silence the moment the cap is set. + // --------------------------------------------------------------------------------------------- + @Test + void everyOtherFieldSurvivesTheCopy() { + final PqAnchorConfig cfg = + new PqAnchorConfig(2800L, 100L, Map.of(100L, 3), OptionalInt.of(3), true) + .withMaxSealsCarried(OptionalInt.of(3)); + assertThat(cfg.chainId()).isEqualTo(2800L); + assertThat(cfg.anchorBlock()).isEqualTo(100L); + assertThat(cfg.minSealsCeiling()).hasValue(3); + assertThat(cfg.activeAt(100L)).isFalse(); // disarmed, and it stayed disarmed + assertThat(cfg.maxSealsCarried()).hasValue(3); + } +} diff --git a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorTest.java new file mode 100644 index 0000000..0e65d7f --- /dev/null +++ b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorTest.java @@ -0,0 +1,301 @@ +/* + * Copyright contributors to Besu / Aere Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.apache.tuweni.bytes.Bytes; +import org.apache.tuweni.bytes.Bytes32; +import org.junit.jupiter.api.Test; + +/** Vectors and invariants for the V2 certificate anchor pre-images. */ +public class PqAnchorTest { + + private static final long CHAIN_ID = 2800L; + private static final Bytes32 PARENT_HASH = + Bytes32.fromHexString("0x1111111111111111111111111111111111111111111111111111111111111111"); + private static final Bytes32 OTHER_HASH = + Bytes32.fromHexString("0x2222222222222222222222222222222222222222222222222222222222222222"); + + /** + * A deterministic stand-in for a Falcon-512 signature. Real ones measure 648 to 660 bytes; the + * anchor pre-image treats them as opaque length-prefixed byte strings, so the exact length only + * has to be representative. + */ + static Bytes signatureFor(final int index, final int variant) { + final byte[] bytes = new byte[655]; + for (int i = 0; i < bytes.length; i++) { + bytes[i] = (byte) ((i * 31 + index * 7 + variant * 101) & 0xFF); + } + return Bytes.wrap(bytes); + } + + static FalconSeal seal(final int index) { + return new FalconSeal(index, signatureFor(index, 0)); + } + + @Test + public void domainLabelsAreExactlySixteenAsciiBytes() { + assertThat(PqAnchor.ANCHOR_DOMAIN.getBytes(StandardCharsets.US_ASCII)).hasSize(16); + assertThat(PqAnchor.COMMIT_DOMAIN.getBytes(StandardCharsets.US_ASCII)).hasSize(16); + assertThat(PqAnchor.ANCHOR_DOMAIN_BYTES.size()).isEqualTo(16); + assertThat(PqAnchor.COMMIT_DOMAIN_BYTES.size()).isEqualTo(16); + assertThat(PqAnchor.ANCHOR_DOMAIN).isNotEqualTo(PqAnchor.COMMIT_DOMAIN); + } + + @Test + public void anchorDigestIsThirtyTwoBytesAndDeterministic() { + final List certificate = Arrays.asList(seal(0), seal(1), seal(2)); + final Bytes32 first = PqAnchor.anchorDigest(CHAIN_ID, 10L, PARENT_HASH, certificate); + final Bytes32 second = PqAnchor.anchorDigest(CHAIN_ID, 10L, PARENT_HASH, certificate); + assertThat(first.size()).isEqualTo(32); + assertThat(first).isEqualTo(second); + } + + @Test + public void anchorDigestChangesWithEveryFieldOfItsPreImage() { + final List certificate = Arrays.asList(seal(0), seal(1), seal(2)); + final Bytes32 base = PqAnchor.anchorDigest(CHAIN_ID, 10L, PARENT_HASH, certificate); + + // chainId: this is what stops a certificate produced on the 442807 proving chain, which runs the + // same binaries and may run the same Falcon keys, from being material here. + assertThat(PqAnchor.anchorDigest(442807L, 10L, PARENT_HASH, certificate)).isNotEqualTo(base); + // parent height and parent hash: replay at another height or on another branch. + assertThat(PqAnchor.anchorDigest(CHAIN_ID, 11L, PARENT_HASH, certificate)).isNotEqualTo(base); + assertThat(PqAnchor.anchorDigest(CHAIN_ID, 10L, OTHER_HASH, certificate)).isNotEqualTo(base); + } + + @Test + public void anchorDigestDetectsAStrippedCertificate() { + // Attack A1: remove element 6 entirely. The canonical round-trip gate cannot see this, because + // truncated bytes re-encode to themselves. The digest can. + final List certificate = Arrays.asList(seal(0), seal(1), seal(2)); + assertThat(PqAnchor.anchorDigest(CHAIN_ID, 10L, PARENT_HASH, Collections.emptyList())) + .isNotEqualTo(PqAnchor.anchorDigest(CHAIN_ID, 10L, PARENT_HASH, certificate)); + } + + @Test + public void anchorDigestDetectsAnEquallyValidDifferentSubset() { + // Attack A2: swap quorum subset {0,1,2,3} for {0,1,2,4}, both authentic over the same parent. + // The block hash does not change, because the certificate is outside its pre-image. D does. + final List subsetA = Arrays.asList(seal(0), seal(1), seal(2), seal(3)); + final List subsetB = Arrays.asList(seal(0), seal(1), seal(2), seal(4)); + assertThat(PqAnchor.anchorDigest(CHAIN_ID, 10L, PARENT_HASH, subsetA)) + .isNotEqualTo(PqAnchor.anchorDigest(CHAIN_ID, 10L, PARENT_HASH, subsetB)); + } + + @Test + public void anchorDigestDetectsAReorderedCertificate() { + // Attack A3, first of the two independent refusals. The second is the strictly increasing index + // requirement, which both rules apply. + final List ordered = Arrays.asList(seal(0), seal(1)); + final List reordered = Arrays.asList(seal(1), seal(0)); + assertThat(PqAnchor.anchorDigest(CHAIN_ID, 10L, PARENT_HASH, ordered)) + .isNotEqualTo(PqAnchor.anchorDigest(CHAIN_ID, 10L, PARENT_HASH, reordered)); + } + + @Test + public void anchorDigestDetectsAChangedSignatureUnderTheSameIndex() { + final List original = List.of(new FalconSeal(3, signatureFor(3, 0))); + final List substituted = List.of(new FalconSeal(3, signatureFor(3, 1))); + assertThat(PqAnchor.anchorDigest(CHAIN_ID, 10L, PARENT_HASH, original)) + .isNotEqualTo(PqAnchor.anchorDigest(CHAIN_ID, 10L, PARENT_HASH, substituted)); + } + + @Test + public void emptyCertificateHasAWellDefinedDigest() { + // This is the block-H case: the parent predates the scheme, K(H) is 0, so C is legitimately + // empty and D is the digest over the empty list. "Explicitly empty" and "absent" are the same + // object because of the codec's canonical round-trip gate. + final Bytes32 digest = PqAnchor.anchorDigest(CHAIN_ID, 0L, PARENT_HASH, Collections.emptyList()); + assertThat(digest.size()).isEqualTo(32); + assertThat(digest) + .isEqualTo(PqAnchor.anchorDigest(CHAIN_ID, 0L, PARENT_HASH, Collections.emptyList())); + assertThat(PqAnchor.encodeCertificate(Collections.emptyList())) + .isEqualTo(Bytes.fromHexString("0xc0")); + } + + @Test + public void commitMessageIsThirtyTwoBytesAndBoundToItsFields() { + final Bytes32 base = PqAnchor.commitMessage(CHAIN_ID, 10L, PARENT_HASH); + assertThat(base.size()).isEqualTo(32); + assertThat(PqAnchor.commitMessage(CHAIN_ID, 10L, PARENT_HASH)).isEqualTo(base); + assertThat(PqAnchor.commitMessage(442807L, 10L, PARENT_HASH)).isNotEqualTo(base); + assertThat(PqAnchor.commitMessage(CHAIN_ID, 11L, PARENT_HASH)).isNotEqualTo(base); + assertThat(PqAnchor.commitMessage(CHAIN_ID, 10L, OTHER_HASH)).isNotEqualTo(base); + } + + @Test + public void anchorAndCommitPreImagesAreDomainSeparated() { + // Attack A7: a Falcon signature made for another subsystem must not be reinterpretable as a + // consensus seal, and an anchor pre-image must not collide with a commit pre-image. + assertThat(PqAnchor.commitMessage(CHAIN_ID, 10L, PARENT_HASH)) + .isNotEqualTo(PqAnchor.anchorDigest(CHAIN_ID, 10L, PARENT_HASH, Collections.emptyList())); + } + + @Test + public void strictlyIncreasingIndicesAreRequired() { + assertThat(PqAnchor.hasStrictlyIncreasingIndices(Collections.emptyList())).isTrue(); + assertThat(PqAnchor.hasStrictlyIncreasingIndices(List.of(seal(0)))).isTrue(); + assertThat(PqAnchor.hasStrictlyIncreasingIndices(Arrays.asList(seal(0), seal(1), seal(4)))) + .isTrue(); + // Attack A3: reordering. + assertThat(PqAnchor.hasStrictlyIncreasingIndices(Arrays.asList(seal(1), seal(0)))).isFalse(); + // Attack A4: the same index twice to inflate k. Unrepresentable, not filtered afterwards. + assertThat(PqAnchor.hasStrictlyIncreasingIndices(Arrays.asList(seal(2), seal(2)))).isFalse(); + assertThat( + PqAnchor.hasStrictlyIncreasingIndices( + Arrays.asList(new FalconSeal(2, signatureFor(2, 0)), new FalconSeal(2, signatureFor(2, 1))))) + .isFalse(); + assertThat(PqAnchor.hasStrictlyIncreasingIndices(List.of(new FalconSeal(-1, Bytes.of(1))))) + .isFalse(); + assertThat(PqAnchor.hasStrictlyIncreasingIndices(List.of(new FalconSeal(1, null)))).isFalse(); + } + + @Test + public void historicalUnorderedWindowIsAcceptedAndOnlyThere() { + // The 36 canonical headers written unsorted during the 2026-08-10 interruption. Two real index + // sequences, decoded off the public endpoint on 2026-08-11: block 13,267,824 lists 2, 6, 1 and + // block 13,268,944 lists 5, 6, 1, 0. + final List real1 = Arrays.asList(seal(2), seal(6), seal(1)); + final List real2 = Arrays.asList(seal(5), seal(6), seal(1), seal(0)); + + // Inside the window: accepted, at both edges and in the middle. + assertThat(PqAnchor.hasAcceptableIndices(real1, PqAnchor.UNORDERED_WINDOW_FIRST)).isTrue(); + assertThat(PqAnchor.hasAcceptableIndices(real2, PqAnchor.UNORDERED_WINDOW_LAST)).isTrue(); + assertThat(PqAnchor.hasAcceptableIndices(real1, PqAnchor.UNORDERED_WINDOW_FIRST + 32L)).isTrue(); + + // NEGATIVE CONTROL, and it is the whole point: one block on either side of the window and the + // very same certificate is rejected. If these two ever pass, the exception has stopped being an + // exception and has become the rule. + assertThat(PqAnchor.hasAcceptableIndices(real1, PqAnchor.UNORDERED_WINDOW_FIRST - 1L)).isFalse(); + assertThat(PqAnchor.hasAcceptableIndices(real1, PqAnchor.UNORDERED_WINDOW_LAST + 1L)).isFalse(); + assertThat(PqAnchor.hasAcceptableIndices(real2, 1L)).isFalse(); + assertThat(PqAnchor.hasAcceptableIndices(real2, Long.MAX_VALUE)).isFalse(); + } + + @Test + public void historicalWindowDoesNotRelaxDistinctness() { + // Attack A4 inside the window: repeating one seal to inflate k. Sortedness is relaxed there, + // distinctness is NOT, at any height. A certificate that repeats an index is still + // unrepresentable, which is the only part of the ordering rule that an attacker cares about. + final List repetat = + Arrays.asList(new FalconSeal(2, signatureFor(2, 0)), new FalconSeal(2, signatureFor(2, 1))); + assertThat(PqAnchor.hasAcceptableIndices(repetat, PqAnchor.UNORDERED_WINDOW_FIRST)).isFalse(); + assertThat(PqAnchor.hasAcceptableIndices(repetat, PqAnchor.UNORDERED_WINDOW_LAST)).isFalse(); + + // Negative indices and null signatures stay rejected inside the window too. + assertThat( + PqAnchor.hasAcceptableIndices( + List.of(new FalconSeal(-1, Bytes.of(1))), PqAnchor.UNORDERED_WINDOW_FIRST)) + .isFalse(); + assertThat( + PqAnchor.hasAcceptableIndices( + List.of(new FalconSeal(1, null)), PqAnchor.UNORDERED_WINDOW_FIRST)) + .isFalse(); + + // And a sorted certificate is accepted everywhere, window or not: the exception only ever adds. + final List sortat = Arrays.asList(seal(0), seal(3), seal(6)); + assertThat(PqAnchor.hasAcceptableIndices(sortat, 1L)).isTrue(); + assertThat(PqAnchor.hasAcceptableIndices(sortat, PqAnchor.UNORDERED_WINDOW_FIRST)).isTrue(); + assertThat(PqAnchor.hasAcceptableIndices(sortat, Long.MAX_VALUE)).isTrue(); + } + + @Test + public void historicalWindowIsClosedAndAnchorAligned() { + // The window cannot grow: it is bounded above by a measured constant. And both bounds must sit on + // the 32-block anchor spacing, because only anchor heights carry certificates. A window whose + // bounds are off the spacing would be evidence that the measurement, not the chain, was wrong. + assertThat(PqAnchor.UNORDERED_WINDOW_LAST).isGreaterThan(PqAnchor.UNORDERED_WINDOW_FIRST); + assertThat((PqAnchor.UNORDERED_WINDOW_LAST - PqAnchor.UNORDERED_WINDOW_FIRST) % 32L).isZero(); + // 36 anchor headers, measured contiguous on 2026-08-11. + assertThat((PqAnchor.UNORDERED_WINDOW_LAST - PqAnchor.UNORDERED_WINDOW_FIRST) / 32L + 1L) + .isEqualTo(36L); + } + + @Test + public void sortedByIndexOrdersAscending() { + final List sorted = PqAnchor.sortedByIndex(Arrays.asList(seal(5), seal(1), seal(3))); + assertThat(sorted.stream().map(FalconSeal::getValidatorIndex)).containsExactly(1, 3, 5); + assertThat(PqAnchor.hasStrictlyIncreasingIndices(sorted)).isTrue(); + } + + @Test + public void malformedPreImageInputsAreRefusedRatherThanHashed() { + assertThatThrownBy( + () -> PqAnchor.anchorDigest(CHAIN_ID, -1L, PARENT_HASH, Collections.emptyList())) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy( + () -> PqAnchor.anchorDigest(CHAIN_ID, 1L, Bytes.of(1, 2, 3), Collections.emptyList())) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> PqAnchor.commitMessage(CHAIN_ID, 1L, Bytes.of(1, 2, 3))) + .isInstanceOf(IllegalArgumentException.class); + } + + // ----------------------------------------------------------------------------------------- + // GOLDEN VECTORS. Added 2026-08-02 after a planted defect proved they were missing. + // + // Every other assertion in this class is RELATIONAL: it says D(x) differs from D(y). A planted + // one-byte corruption applied uniformly to the output of anchorDigest ("plantedD[0] ^= 1") + // therefore left all 378 tests of consensus:common and consensus:qbft GREEN, because every + // relation it was asked about survived the shift. That is precisely the defect class the whole + // scheme exists to remove: two builds that disagree on D do not disagree on any relation, they + // disagree on the VALUE, and until this file pinned the value nothing could see it. It also gives + // the second client a vector to implement against instead of prose. + // + // If one of these three fails, the wire format of the anchor pre-image has changed and that is a + // consensus fork. It is never correct to update the constant to match the code; the code has to + // be brought back to the constant, or a new activation height has to be agreed. + // ----------------------------------------------------------------------------------------- + + private static final Bytes32 GOLDEN_D_EMPTY = + Bytes32.fromHexString("0xbe4e758707704f9a64b2bfd9d67c3072dc5a94ce586006c98301a017cb874935"); + private static final Bytes32 GOLDEN_D_CERT = + Bytes32.fromHexString("0x3d1599ba09480b2938b5d3488f943c04db4ad5a74e3ccdc588aa9ee182205f63"); + private static final Bytes32 GOLDEN_M = + Bytes32.fromHexString("0x1255862357253d98bff5c483601c8cd4a5145027a383b8944be086aa0619a746"); + + private static List goldenCertificate() { + // Three seals, non-contiguous indices, and three DIFFERENT signature lengths inside the + // measured Falcon-512 range 648..660, so the vector also pins the length prefixes. + return List.of( + new FalconSeal(0, Bytes.repeat((byte) 0xa0, 655)), + new FalconSeal(2, Bytes.repeat((byte) 0xa2, 648)), + new FalconSeal(5, Bytes.repeat((byte) 0xa5, 660))); + } + + @Test + public void anchorDigestMatchesItsGoldenVectorOverAnEmptyCertificate() { + assertThat(PqAnchor.anchorDigest(CHAIN_ID, 11801562L, PARENT_HASH, Collections.emptyList())) + .isEqualTo(GOLDEN_D_EMPTY); + } + + @Test + public void anchorDigestMatchesItsGoldenVectorOverACarriedCertificate() { + assertThat(PqAnchor.anchorDigest(CHAIN_ID, 11801562L, PARENT_HASH, goldenCertificate())) + .isEqualTo(GOLDEN_D_CERT); + } + + @Test + public void commitMessageMatchesItsGoldenVector() { + assertThat(PqAnchor.commitMessage(CHAIN_ID, 11801562L, PARENT_HASH)).isEqualTo(GOLDEN_M); + } +} diff --git a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorThresholdGuardTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorThresholdGuardTest.java new file mode 100644 index 0000000..9c7be44 --- /dev/null +++ b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqAnchorThresholdGuardTest.java @@ -0,0 +1,215 @@ +/* + * Copyright contributors to Besu / Aere Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Map; +import java.util.OptionalInt; + +import org.junit.jupiter.api.Test; + +/** + * AERE GARDA-PRAG: the PLANTED FAILURE for the seal-threshold guard. + * + *

Each refusal test builds, by hand, the exact configuration that stops the chain, and asserts + * that the guard sees it. The bound asserted is {@code K <= quorum(N) - 1}, which is 4 at N=7 and 2 + * at N=4, and {@link #growingTheValidatorSetDoesNotBuyQuorumMargin()} is the test that would go green + * under the WRONG bound {@code K > N - f} and red under the right one. + * + *

The negative control for this file does not live in it: it is a second build of the same tree in + * which the guard body is replaced by a stub that accepts everything. Every refusal assertion below + * has to go RED in that build, otherwise these tests are proving nothing. + */ +class PqAnchorThresholdGuardTest { + + private static final long CHAIN_ID = 2800L; + private static final long H = 12_000_000L; + + /** Chain 2800 as it stands: seven validators, quorum ceil(14/3) = 5, f = 2. */ + private static final int N_LIVE = 7; + + private PqAnchorConfig armed(final Map schedule, final OptionalInt ceiling) { + return new PqAnchorConfig(CHAIN_ID, H, schedule, ceiling, false); + } + + @Test + void theArithmeticIsTheOneTheChainActuallyUses() { + // The bound is not a constant typed into this test: it is Besu's own quorum formula, minus one. + assertThat(BftHelpers.calculateRequiredValidatorQuorum(N_LIVE)).isEqualTo(5); + assertThat(PqAnchorThresholdGuard.maxConfigurableThreshold(N_LIVE)).isEqualTo(4); + assertThat(PqAnchorThresholdGuard.byzantineBudget(N_LIVE)).isEqualTo(2); + + assertThat(BftHelpers.calculateRequiredValidatorQuorum(4)).isEqualTo(3); + assertThat(PqAnchorThresholdGuard.maxConfigurableThreshold(4)).isEqualTo(2); + } + + @Test + void plantedFailureAThresholdEqualToTheQuorumIsRefused() { + assertThatThrownBy( + () -> + PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort( + armed(Map.of(H, 0, H + 7_200L, 1, H + 21_600L, 5), OptionalInt.empty()), + N_LIVE)) + .isInstanceOf(FalconSealSupport.ActivationConfigException.class) + .hasMessageContaining(PqAnchorThresholdGuard.CODE) + .hasMessageContaining("REFUSING TO START") + .hasMessageContaining("reaches 5 at height " + (H + 21_600L)) + .hasMessageContaining("may be configured at this set size is 4"); + } + + @Test + void plantedFailureAnImpossibleThresholdAboveTheSetSizeIsRefused() { + assertThatThrownBy( + () -> + PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort( + armed(Map.of(H, 0, H + 100L, 99), OptionalInt.empty()), N_LIVE)) + .isInstanceOf(FalconSealSupport.ActivationConfigException.class) + .hasMessageContaining(PqAnchorThresholdGuard.CODE); + } + + @Test + void plantedFailureTheVeryFirstStepMayAlsoBeFatal() { + // A schedule that opens AT the quorum. The producer's existing log-only warning covers K>0 at H + // for a different reason; this asserts the refusal fires on the same step. + assertThatThrownBy( + () -> + PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort( + armed(Map.of(H, 5), OptionalInt.empty()), N_LIVE)) + .isInstanceOf(FalconSealSupport.ActivationConfigException.class) + .hasMessageContaining("reaches 5 at height " + H); + } + + @Test + void theHighestSafeThresholdStarts() { + assertThatCode( + () -> + PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort( + armed(Map.of(H, 0, H + 7_200L, 1, H + 21_600L, 4), OptionalInt.empty()), + N_LIVE)) + .doesNotThrowAnyException(); + } + + @Test + void aScheduleWithFullMarginStarts() { + // quorum - f = 3 at N=7, so 3 is the last rung that survives f silent signers. + assertThatCode( + () -> + PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort( + armed(Map.of(H, 0, H + 7_200L, 3), OptionalInt.empty()), N_LIVE)) + .doesNotThrowAnyException(); + } + + @Test + void growingTheValidatorSetDoesNotBuyQuorumMargin() { + // THIS is the test that separates the right bound from the wrong one. At N=9 the quorum is 6 + // while N-f is 7, so the rule "refuse when K > N - f" would ACCEPT K=7, which is a rung no + // proposer can ever reach. Both 6 and 7 must be refused. + assertThat(BftHelpers.calculateRequiredValidatorQuorum(9)).isEqualTo(6); + assertThat(9 - PqAnchorThresholdGuard.byzantineBudget(9)).isEqualTo(7); + + assertThatThrownBy( + () -> + PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort( + armed(Map.of(H, 0, H + 100L, 6), OptionalInt.empty()), 9)) + .isInstanceOf(FalconSealSupport.ActivationConfigException.class); + + assertThatThrownBy( + () -> + PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort( + armed(Map.of(H, 0, H + 100L, 7), OptionalInt.empty()), 9)) + .isInstanceOf(FalconSealSupport.ActivationConfigException.class); + + assertThatCode( + () -> + PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort( + armed(Map.of(H, 0, H + 100L, 5), OptionalInt.empty()), 9)) + .doesNotThrowAnyException(); + } + + @Test + void theBoundAtFourValidatorsIsTwo() { + assertThatThrownBy( + () -> + PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort( + armed(Map.of(H, 0, H + 30L, 3), OptionalInt.empty()), 4)) + .isInstanceOf(FalconSealSupport.ActivationConfigException.class) + .hasMessageContaining("quorum for the 4 validators"); + + assertThatCode( + () -> + PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort( + armed(Map.of(H, 0, H + 30L, 2), OptionalInt.empty()), 4)) + .doesNotThrowAnyException(); + } + + @Test + void theEmergencyCeilingRescuesAnOtherwiseFatalSchedule() { + // The way back from a bad activation must not be blocked by the guard that caught it. + assertThatCode( + () -> + PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort( + armed(Map.of(H, 0, H + 21_600L, 5), OptionalInt.of(1)), N_LIVE)) + .doesNotThrowAnyException(); + } + + @Test + void aCeilingAboveTheScheduleRescuesNothing() { + // The ceiling can only ever lower. A ceiling of 9 over a fatal 5 leaves the 5 in force. + assertThatThrownBy( + () -> + PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort( + armed(Map.of(H, 0, H + 21_600L, 5), OptionalInt.of(9)), N_LIVE)) + .isInstanceOf(FalconSealSupport.ActivationConfigException.class); + } + + @Test + void anUncountableValidatorSetIsRefusedRatherThanAssumedSafe() { + assertThatThrownBy( + () -> + PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort( + armed(Map.of(H, 0), OptionalInt.empty()), 0)) + .isInstanceOf(FalconSealSupport.ActivationConfigException.class) + .hasMessageContaining("counted 0 validators"); + } + + @Test + void inertWhenTheAnchorWasNeverConfigured() { + // Chain 2800 as it stands today: a binary carrying this guard must behave exactly as before. + assertThatCode( + () -> + PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort( + PqAnchorConfig.never(CHAIN_ID), 1)) + .doesNotThrowAnyException(); + assertThatCode( + () -> PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort(null, 7)) + .doesNotThrowAnyException(); + } + + @Test + void inertWhenTheAnchorIsEmergencyDisarmed() { + // A disarmed node enforces nothing, so a fatal schedule on it cannot stop anything and must not + // stop the node from starting either. Refusing here would turn the disarm control into a brick. + final PqAnchorConfig disarmed = + new PqAnchorConfig(CHAIN_ID, H, Map.of(H, 0, H + 100L, 99), OptionalInt.empty(), true); + assertThat(disarmed.anchorConfigured()).isTrue(); + assertThat(disarmed.everActive()).isFalse(); + assertThatCode( + () -> PqAnchorThresholdGuard.verifyThresholdAgainstQuorumOrAbort(disarmed, N_LIVE)) + .doesNotThrowAnyException(); + } +} diff --git a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqArmingGateTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqArmingGateTest.java new file mode 100644 index 0000000..41222af --- /dev/null +++ b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqArmingGateTest.java @@ -0,0 +1,388 @@ +/* + * Copyright contributors to Besu / Aere Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.lang.reflect.Field; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Properties; +import java.util.stream.Collectors; + +import org.apache.tuweni.bytes.Bytes; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * REGISTRY BINDING AT ARM TIME, THE LINE THAT WAS MISSING. {@code + * PqRegistryHash.requireBindingsOrThrow} was delivered with its own tests, and nothing called it. + * Its own javadoc said so: "NOT WIRED YET ... the call belongs beside AERE-PQC-REG-ARM-01 in + * FalconSealSupport". This class measures the wire. + * + *

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 PqArmingGateTest { + + /** The height at which this fixture arms Falcon blocking. */ + private static final long FORK = 7_000L; + + /** Attachment must lead the fork block; the same shape PqForkArmingTest uses. */ + private static final long ATTACH = 6_000L; + + /** The chain id the registry is bound to. Not 2800: nothing here may look like the live fleet. */ + private static final long CHAIN_ID = 220_878L; + + /** The height the binding proofs are signed for. */ + private static final long BIND_HEIGHT = FORK; + + private static final int N = 4; + + /** Every property this class is allowed to touch. Cleared before AND after every test. */ + private static final List OWNED_PROPERTIES = + List.of( + "aere.falcon.registry", + "aere.falcon.forkBlock", + "aere.falcon.attachBlock", + "aere.falcon.validatorCount", + "aere.falcon.testnetAllowSmallFleet", + PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, + PqAnchorConfig.PROPERTY_CHAIN_ID, + PqAnchorConfig.PROPERTY_MIN_SEALS); + + @TempDir private Path tmp; + + @BeforeEach + public void setUp() throws Exception { + clearOwnedProperties(); + forgetAnchorConfig(); + resetFalconSingleton(); + } + + @AfterEach + public void tearDown() throws Exception { + clearOwnedProperties(); + forgetAnchorConfig(); + resetFalconSingleton(); + } + + // ------------------------------------------------------------------------------------------- + // 1. THE FINDING, on each of the two arming triggers. + // ------------------------------------------------------------------------------------------- + + /** An armed node over a registry with no binding proofs must refuse to start. */ + @Test + public void armedOverAV1RegistryTheNodeREFUSESToStart() throws Exception { + armWithForkBlock(writeRegistry("registry-v1.properties", false, false)); + + assertThatThrownBy(FalconSealSupport::instance) + .describedAs( + "a v1 registry decides attribution by whoever wrote the file; arming over it is " + + "permanent, because the anchor contract is immutable once written") + .isInstanceOf(PqRegistryHash.RegistryConfigException.class) + .hasMessageContaining("AERE-PQC-REG-ARM-02"); + } + + /** + * The same refusal when the node is armed through the CERTIFICATE ANCHOR and {@code + * aere.falcon.forkBlock} is not set at all. + * + *

This is the case with no predecessor. AERE-PQC-REG-ARM-01 is raised by {@code + * armingReadinessDiagnostic()}, whose first statement is to return when the fork block is unset, + * so an anchor-armed node has never been asked ANY question about its registry's shape at startup. + */ + @Test + public void armedThroughTheANCHORAloneTheNodeAlsoREFUSES() throws Exception { + final Path v1 = writeRegistry("registry-v1.properties", false, false); + System.setProperty("aere.falcon.registry", v1.toAbsolutePath().toString()); + armWithAnchorOnly(); + + assertThat(System.getProperty("aere.falcon.forkBlock")) + .describedAs("this test is only worth something while the fork block is genuinely unset") + .isNull(); + assertThatThrownBy(FalconSealSupport::instance) + .isInstanceOf(PqRegistryHash.RegistryConfigException.class) + .hasMessageContaining("AERE-PQC-REG-ARM-02"); + } + + // ------------------------------------------------------------------------------------------- + // 2. THE POSITIVE CONTROLS. Without these the refusals above could be a load bug. + // ------------------------------------------------------------------------------------------- + + /** + * The same node, the same arming, over a registry whose every row carries a Falcon possession + * proof and an ECDSA claim signed by that row's own validator key, STARTS - and loads. + */ + @Test + public void armedOverAV2RegistryTheNodeSTARTS() throws Exception { + armWithForkBlock(writeRegistry("registry-v2.properties", true, true)); + + assertThatCode(FalconSealSupport::instance) + .describedAs( + "POSITIVE CONTROL: the gate can be green. A refusal that no correct input can pass is " + + "an outage wearing a security message") + .doesNotThrowAnyException(); + assertThat(FalconSealSupport.instance().registrySize()) + .describedAs("and it must really have loaded the file, not merely declined to throw") + .isEqualTo(N); + } + + /** The same, armed through the anchor alone. */ + @Test + public void armedThroughTheANCHORAloneOverAV2RegistryTheNodeSTARTS() throws Exception { + final Path v2 = writeRegistry("registry-v2.properties", true, true); + System.setProperty("aere.falcon.registry", v2.toAbsolutePath().toString()); + armWithAnchorOnly(); + + assertThatCode(FalconSealSupport::instance).doesNotThrowAnyException(); + assertThat(FalconSealSupport.instance().registrySize()).isEqualTo(N); + } + + // ------------------------------------------------------------------------------------------- + // 3. THE BOUNDARY. A node that arms NOTHING must be untouched by any of this. + // ------------------------------------------------------------------------------------------- + + /** + * THE GUARANTEE FOR CHAIN 2800 AS IT STANDS: a node with no {@code aere.pq.*} property and no + * {@code aere.falcon.forkBlock} starts over the very same v1 file that is refused when armed. + * + *

The assertion that carries the weight is not the "starts" - it is the property sweep. A test + * that only asserted "does not throw" would keep passing if a later edit made the guard read some + * other property that happened to be set in this JVM. The sweep states the precondition as a + * measurement: at the moment the constructor runs, NO system property beginning with {@code + * aere.pq.} exists, and neither does the fork block. + */ + @Test + public void withNothingArmedTheGateIsInertOverTheSameV1Registry() throws Exception { + final Path v1 = writeRegistry("registry-v1.properties", false, false); + System.setProperty("aere.falcon.registry", v1.toAbsolutePath().toString()); + + assertThat(systemPropertiesStartingWith("aere.pq.")) + .describedAs("the precondition of this test, measured rather than assumed") + .isEmpty(); + assertThat(System.getProperty("aere.falcon.forkBlock")).isNull(); + + assertThatCode(FalconSealSupport::instance) + .describedAs( + "the same file that is refused when armed is accepted when nothing is armed, so the " + + "trigger is ARMING and not the file") + .doesNotThrowAnyException(); + assertThat(FalconSealSupport.instance().registrySize()) + .describedAs("and an unarmed node's registry is loaded exactly as it was before this guard") + .isEqualTo(N); + } + + /** + * The same boundary with NO registry configured either, which is a node holding nothing at all - + * the shape of a fresh box joining the fleet before any key ceremony. + */ + @Test + public void aNodeWithNoFalconConfigurationAtAllStarts() { + assertThat(systemPropertiesStartingWith("aere.pq.")).isEmpty(); + assertThat(System.getProperty("aere.falcon.registry")).isNull(); + + assertThatCode(FalconSealSupport::instance).doesNotThrowAnyException(); + } + + /** + * An ARMED node with no registry file at all is deliberately NOT this guard's business, and this + * test is what stops that from being a silent decision. + * + *

This guard is about mis-ATTRIBUTION, which needs rows; an empty registry credits nobody. The + * condition + * is owned by AERE-PQC-CFG-UNSAFE-08 when the threshold is positive, and MEASURED here: with a + * threshold of zero, which is the warm-up regime the fleet is meant to arm INTO, the node starts. + * An earlier revision of this guard refused here, and the cost was exactly that - the intended + * activation procedure became unstartable. + */ + @Test + public void armedWithNoRegistryAtAllAndAZeroThresholdTheNodeStarts() { + System.setProperty(PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, Long.toString(FORK)); + System.setProperty(PqAnchorConfig.PROPERTY_CHAIN_ID, Long.toString(CHAIN_ID)); + System.setProperty(PqAnchorConfig.PROPERTY_MIN_SEALS, FORK + ":0"); + System.setProperty("aere.falcon.validatorCount", Integer.toString(N)); + System.setProperty("aere.falcon.testnetAllowSmallFleet", "true"); + + assertThatCode(FalconSealSupport::instance) + .describedAs("K=0 over an empty registry is the warm-up regime, not a mis-attribution defect") + .doesNotThrowAnyException(); + } + + // ------------------------------------------------------------------------------------------- + // 4. HALF A v2 REGISTRY IS NOT A v2 REGISTRY. + // ------------------------------------------------------------------------------------------- + + /** + * A row that carries a Falcon possession proof and no ECDSA claim proves that SOMEBODY holds the + * key, and says nothing about which validator asked for it - which is the whole of this guard. + * + *

MEASURED, and the assertion was CHANGED to match the measurement rather than the other way + * round. The expectation written first was AERE-PQC-REG-ARM-02. What actually happens is a refusal + * one step EARLIER, at load, with AERE-PQC-REG-LOAD-21, because the loader counts proofs against + * claims and refuses a half-bound file before the arming gate ever sees it. That is the stronger + * of the two refusals - it holds whether or not the node is armed - so this is asserted on the + * code that actually fires. + */ + @Test + public void possessionWithoutAClaimIsRefusedEarlierStillAtLoad() throws Exception { + armWithForkBlock(writeRegistry("registry-possession-only.properties", true, false)); + + assertThatThrownBy(FalconSealSupport::instance) + .describedAs( + "the attacker is the key holder, so a genuine possession proof over a lying row is " + + "genuinely produceable; only the validator's own signature closes it") + .isInstanceOf(PqRegistryHash.RegistryConfigException.class) + .hasMessageContaining("AERE-PQC-REG-LOAD-21"); + } + + // ------------------------------------------------------------------------------------------- + // Helpers. + // ------------------------------------------------------------------------------------------- + + /** Arm through {@code aere.falcon.forkBlock}, the trigger AERE-PQC-REG-ARM-01 also watches. */ + private void armWithForkBlock(final Path registry) { + System.setProperty("aere.falcon.registry", registry.toAbsolutePath().toString()); + System.setProperty("aere.falcon.forkBlock", Long.toString(FORK)); + System.setProperty("aere.falcon.attachBlock", Long.toString(ATTACH)); + System.setProperty("aere.falcon.validatorCount", Integer.toString(N)); + // N=4 is below the blocking minimum; this fixture is an isolated network and says so with the + // switch the codebase already uses for exactly that, rather than by pretending to be seven. + System.setProperty("aere.falcon.testnetAllowSmallFleet", "true"); + } + + /** + * Arm through the CERTIFICATE ANCHOR only, leaving {@code aere.falcon.forkBlock} unset. The + * threshold is 2, which {@code worstCaseKeyedSigners(4, 4)} = 3 guarantees, so the + * threshold-reachability guard next door stays silent and cannot be mistaken for this one. + */ + private void armWithAnchorOnly() { + System.setProperty(PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, Long.toString(FORK)); + System.setProperty(PqAnchorConfig.PROPERTY_CHAIN_ID, Long.toString(CHAIN_ID)); + System.setProperty(PqAnchorConfig.PROPERTY_MIN_SEALS, FORK + ":0," + (FORK + 10L) + ":2"); + System.setProperty("aere.falcon.validatorCount", Integer.toString(N)); + System.setProperty("aere.falcon.testnetAllowSmallFleet", "true"); + } + + /** + * Write a registry in the legacy properties form. {@code withPossession} and {@code withClaim} are + * separate so that the half-bound case can be built, which is the one the loader must refuse. + */ + private Path writeRegistry( + final String name, final boolean withPossession, final boolean withClaim) throws Exception { + final StringBuilder b = new StringBuilder(); + if (withPossession || withClaim) { + b.append("formatVersion=").append(PqRegistryBinding.FORMAT_VERSION).append('\n'); + b.append("chainId=").append(CHAIN_ID).append('\n'); + b.append("bindHeight=").append(BIND_HEIGHT).append('\n'); + } + b.append("count=").append(N).append('\n'); + for (int i = 0; i < N; i++) { + b.append(i).append('=').append(unprefixed(PqV2Fixture.publicKey(i))).append('\n'); + b.append(i) + .append(".addr=") + .append(unprefixed(PqV2Fixture.address(i).getBytes().toArray())) + .append('\n'); + if (withPossession) { + b.append(i) + .append(".pop=") + .append(strip(PqV2Fixture.popHex(CHAIN_ID, BIND_HEIGHT, N, i))) + .append('\n'); + } + if (withClaim) { + b.append(i) + .append(".claim=") + .append(strip(PqV2Fixture.claimHex(CHAIN_ID, BIND_HEIGHT, N, i))) + .append('\n'); + } + } + final Path f = tmp.resolve(name); + Files.writeString(f, b.toString(), StandardCharsets.UTF_8); + return f; + } + + private static String unprefixed(final byte[] b) { + return Bytes.wrap(b).toUnprefixedHexString(); + } + + private static String strip(final String hex) { + return hex.startsWith("0x") ? hex.substring(2) : hex; + } + + /** Every system property name with the given prefix, so a precondition can be MEASURED. */ + private static List 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/PqCallerIntentTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqCallerIntentTest.java new file mode 100644 index 0000000..0af0116 --- /dev/null +++ b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqCallerIntentTest.java @@ -0,0 +1,386 @@ +/* + * Copyright contributors to Besu / Aere Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer; +import org.hyperledger.besu.datatypes.Address; + +import java.lang.reflect.Field; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.OptionalInt; + +import org.apache.tuweni.bytes.Bytes; +import org.apache.tuweni.bytes.Bytes32; +import org.bouncycastle.crypto.digests.KeccakDigest; +import org.bouncycastle.pqc.crypto.falcon.FalconPrivateKeyParameters; +import org.bouncycastle.pqc.crypto.falcon.FalconSigner; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * ROTATION HARDENING (b-v2). The repair of the repair: the caller's MOTIVE decides, not the height. + * + *

WHAT THE FIRST SHAPE DID, MEASURED AND NOT ARGUED. On 2026-08-06 hardening (b) refused every + * unbound height at or above the arming height, deciding from the block NUMBER alone. Run against + * the suite that gave 588 tests and 0 failures on a clean tree, it gave 597 tests and 6 failures: + * five in {@code PqSealPersistenceTest} and one in {@code PqForkValidatorSetChangeTest}. Both + * classes work on THIS NODE'S OWN head - restarting and re-reading its own seal file, and proposing + * on top of its own head - and in all six the number handed to the guard was 1030 with an arming + * height of 1000. A genuinely historical question, in the same process in the same second, hands + * the guard exactly those numbers too. No arithmetic on the height separates them. + * + *

THE OPERATIONAL CONSEQUENCE, in the words of that failure itself: {@code refusing to + * propose on top of block 1030 because this node holds 0 valid eligible Falcon seal(s)}. The first + * shape turned a defect that is invisible on a running fleet and fatal only to a node syncing later + * into one that stops block production on all seven, in the minute the anchor is armed. + * + *

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 each was measured before this class was allowed to count as evidence. + */ +public class PqCallerIntentTest { + + /** Anchor activation height H, matching the fixture the six failures ran under. */ + private static final long H = 1_000L; + + /** Height from which the staged threshold is non-zero, i.e. the fully armed regime. */ + private static final long K_AT = H + 10L; + + /** + * The height the six failures actually presented to the guard: this node's own head, above the + * arming height. Named for what it is, because the whole point is that the NUMBER is innocent. + */ + private static final long OWN_HEAD = 1_030L; + + /** A height far above H, standing in for "a year of history above the arming height". */ + private static final long DEEP = K_AT + 5_000L; + + private static final int N = 7; + + private static final long CHAIN_ID = 220_878L; + + @TempDir private Path tmp; + + private final List 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 REGISTRY BINDING (2026-08-06): a v2, PROOF-BOUND registry. It used to be v1, with + // addresses spelled 0xA00+i, which no secp256k1 key can sign for, so this fixture described a + // fleet that could never satisfy AERE-PQC-REG-ARM-02 once that guard was wired. The registry is + // bound at H, the height this fixture arms the anchor from. + final KeccakDigest kd = new KeccakDigest(256); + final StringBuilder manifest = new StringBuilder(); + manifest + .append("{\"config\":{\"aereFalconRegistry\":{") + .append(PqV2Fixture.manifestHeader(N, CHAIN_ID, H)); + for (int i = 0; i < N; i++) { + privateKeys.add(PqV2Fixture.privateKey(i)); + validators.add(PqV2Fixture.address(i)); + final byte[] anchoredRow = PqV2Fixture.anchorPreimageRow(i); + kd.update(anchoredRow, 0, anchoredRow.length); + manifest.append(',').append(PqV2Fixture.manifestEntry(i, N, CHAIN_ID, H)); + } + manifest + .append("}},\"alloc\":{\"0000000000000000000000000000000000000fa1\":{\"storage\":{\"0x") + .append("0".repeat(64)) + .append("\":\"0x"); + final byte[] anchoredHash = new byte[32]; + kd.doFinal(anchoredHash, 0); + manifest.append(Bytes.wrap(anchoredHash).toUnprefixedHexString()).append("\"}}}}"); + + genesisPath = tmp.resolve("genesis-d2v2.json"); + Files.writeString(genesisPath, manifest.toString()); + System.setProperty("aere.falcon.genesis", genesisPath.toAbsolutePath().toString()); + + final FalconSigner signer = new FalconSigner(); + signer.init(true, privateKeys.get(0)); + sealByIndexZero = Bytes.wrap(signer.generateSignature(MESSAGE.toArray())); + + resetFalconSingleton(); + // Exactly the montage the six failures ran under: armed at 1000, K staged at 1010, own head + // 1030, and NO config.pqRegistryHash anywhere - the state of every node on chain 2800 today. + PqAnchorProducer.useConfigForTesting( + new PqAnchorConfig(CHAIN_ID, H, Map.of(H, 0, K_AT, 3), OptionalInt.empty(), false)); + } + + @AfterEach + public void tearDown() throws Exception { + System.clearProperty("aere.falcon.genesis"); + System.clearProperty("aere.pq.genesis"); + System.clearProperty(FalconSealSupport.PROPERTY_REGISTRY_HISTORY); + resetFalconSingleton(); + PqAnchorProducer.useConfigForTesting(null); + } + + // ------------------------------------------------------------------------------------------- + // 0. The fixture. Without this a green run below could mean the registry never loaded at all. + // ------------------------------------------------------------------------------------------- + + @Test + public void baselineTheFixtureIsGenesisAnchoredAndTheSealIsGENUINE() { + final FalconSealSupport pqc = FalconSealSupport.instance(); + assertThat(pqc.genesisAnchored()) + .describedAs("the fixture must load a GENESIS-ANCHORED registry, or nothing here means anything") + .isTrue(); + assertThat(pqc.addressBound()).isTrue(); + assertThat(pqc.verify(0, MESSAGE, sealByIndexZero)) + .describedAs("the seal must be a REAL Falcon signature under the head registry") + .isTrue(); + } + + // ------------------------------------------------------------------------------------------- + // 1. THE WHOLE REPAIR, IN ONE ASSERTION. Same height, same index, same signature, same instant. + // Two answers, because two different questions were asked. + // ------------------------------------------------------------------------------------------- + + @Test + public void theSameHeightGivesTwoAnswersBecauseTheQUESTIONSDIFFER() { + final FalconSealSupport pqc = FalconSealSupport.instance(); + + assertThat(pqc.verifyAtHistoric(OWN_HEAD, 0, MESSAGE, sealByIndexZero)) + .describedAs( + "HISTORY door at 1030, armed from 1000, no schedule: a node judging somebody else's " + + "header cannot say which keys were in force there, so it REFUSES. Answering from " + + "the head registry here is the rotation defect verbatim") + .isFalse(); + + assertThat(pqc.verifyAtOwnHead(OWN_HEAD, 0, MESSAGE, sealByIndexZero)) + .describedAs( + "OWN-HEAD door, SAME height, SAME seal, same instant: this node's own head, where the " + + "head registry IS the answer by construction. Refusing here is what stopped the " + + "proposer and the restart path on 2026-08-06, and it bought no security: the " + + "certificate is re-checked by the other six through the history door") + .isTrue(); + + assertThat(pqc.addressForIndexAtHistoric(OWN_HEAD, 0)) + .describedAs("the address halves must split the same way, or R2 and the producer disagree") + .isNull(); + assertThat(pqc.addressForIndexAtOwnHead(OWN_HEAD, 0)).isEqualTo(validators.get(0)); + } + + // ------------------------------------------------------------------------------------------- + // 2. The restart path, which is five of the six failures. PqSealStore has already forced the + // stored block number and hash to equal this node's head before it asks. + // ------------------------------------------------------------------------------------------- + + @Test + public void theRESTARTPathAnswersAtAnArmedHeightWithNoSchedule() { + final FalconSealSupport pqc = FalconSealSupport.instance(); + assertThat(pqc.verifyAtOwnHead(OWN_HEAD, 0, MESSAGE, sealByIndexZero)) + .describedAs( + "PqSealPersistenceTest restored 0 of 3 genuine seals under the first shape. A node " + + "that cannot re-read its own seal file after a restart is a node that cannot " + + "propose, and the file is the documented way out of that restart deadlock") + .isTrue(); + assertThat(pqc.addressForIndexAtOwnHead(OWN_HEAD, 0)) + .describedAs("and the index must bind, or every stored seal is dropped as unknown") + .isEqualTo(validators.get(0)); + } + + // ------------------------------------------------------------------------------------------- + // 3. The proposer path, the sixth failure. PqAnchorProducer resolves at the PARENT's height, + // and the parent is this node's own head. + // ------------------------------------------------------------------------------------------- + + @Test + public void thePROPOSERPathAnswersAtAnArmedHeightWithNoSchedule() { + final FalconSealSupport pqc = FalconSealSupport.instance(); + for (int i = 0; i < 5; i++) { + final FalconSigner s = new FalconSigner(); + s.init(true, privateKeys.get(i)); + final Bytes sealI = Bytes.wrap(s.generateSignature(MESSAGE.toArray())); + assertThat(pqc.verifyAtOwnHead(OWN_HEAD, i, MESSAGE, sealI)) + .describedAs( + "all K=5 genuine seals must resolve, or PqAnchorProducer throws " + + "PqAnchorNotReadyException with '5 were not eligible signers' and the node " + + "stops producing blocks - which is exactly what was measured") + .isTrue(); + assertThat(pqc.addressForIndexAtOwnHead(OWN_HEAD, i)).isEqualTo(validators.get(i)); + } + } + + // ------------------------------------------------------------------------------------------- + // 4. THE OWN-HEAD DOOR IS NOT A LOOPHOLE. This is the assertion that has to hold for the form to + // be worth anything: a configured epoch this node does NOT hold fails closed on BOTH doors. + // ------------------------------------------------------------------------------------------- + + @Test + public void theOwnHeadDoorIsNOTALoopholeAnUnheldEpochIsRefusedOnBOTHDOORS() throws Exception { + final FalconSealSupport pqc = FalconSealSupport.instance(); + final PqRegistryHash.Registry held = PqRegistryHash.loadAuto(genesisPath); + final String hash = PqRegistryHash.hashFor(held, CHAIN_ID); + final long rotation = K_AT + 1_000L; + + final Map 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 REGISTRY BINDING (2026-08-06): hashFor, not hashV1. A schedule entry has to carry the + // canonical hash OF THE REGISTRY IT NAMES, and this fixture's registry is now v2, which hashes + // under a different domain tag. MEASURED: leaving hashV1 here made the entry name a registry + // nobody holds, and the height-resolved lookups fell through to a refusal - a green test + // turning red for a reason that had nothing to do with what it measures. This is the same + // breakage a real genesis takes: any config.pqRegistryHash computed before the registry was + // rebuilt as v2 stops matching the moment it is rebuilt. + entries.put(H, PqRegistryHash.hashFor(held, CHAIN_ID)); + pqc.verifyRegistryBindingOrAbort(0L, CHAIN_ID, scheduleFromGenesis(entries)); + + for (final long h : new long[] {H - 1L, H, OWN_HEAD, DEEP}) { + assertThat(pqc.verifyAtHistoric(h, 0, MESSAGE, sealByIndexZero)) + .describedAs( + "at height " + + h + + " with the epoch bound at the arming height, the history door resolves through " + + "the SCHEDULE, not through any fallback") + .isTrue(); + assertThat(pqc.verifyAtOwnHead(h, 0, MESSAGE, sealByIndexZero)) + .describedAs( + "and the own-head door gives the SAME answer at height " + + h + + ". The two doors differ only in what they do when NOTHING binds the height. If " + + "they differed with a binding in force, the split would have introduced a " + + "second key set rather than a second failure mode") + .isTrue(); + assertThat(pqc.addressForIndexAtHistoric(h, 0)).isEqualTo(pqc.addressForIndexAtOwnHead(h, 0)); + } + } + + // ------------------------------------------------------------------------------------------- + // 6. The live fleet is untouched. aere.pq.anchorBlock is unset on all seven today, so there is + // no arming height, and BOTH doors answer exactly as they did before either hardening. + // ------------------------------------------------------------------------------------------- + + @Test + public void whenTheAnchorIsNotArmedBOTHDOORSAnswerAndNOTHINGCHANGES() { + PqAnchorProducer.useConfigForTesting(PqAnchorConfig.never(CHAIN_ID)); + final FalconSealSupport pqc = FalconSealSupport.instance(); + assertThat(pqc.verifyAtHistoric(DEEP, 0, MESSAGE, sealByIndexZero)).isTrue(); + assertThat(pqc.verifyAtOwnHead(DEEP, 0, MESSAGE, sealByIndexZero)).isTrue(); + assertThat(pqc.addressForIndexAtHistoric(DEEP, 0)).isEqualTo(validators.get(0)); + assertThat(pqc.addressForIndexAtOwnHead(DEEP, 0)).isEqualTo(validators.get(0)); + } + + // ------------------------------------------------------------------------------------------- + // 7. Below the arming height nothing is being judged, so both doors answer. This is the + // assertion that goes red first if anybody makes the history door refuse unconditionally. + // ------------------------------------------------------------------------------------------- + + @Test + public void belowTheArmingHeightBOTHDOORSAnswer() { + final FalconSealSupport pqc = FalconSealSupport.instance(); + assertThat(pqc.verifyAtHistoric(H - 1L, 0, MESSAGE, sealByIndexZero)).isTrue(); + assertThat(pqc.verifyAtOwnHead(H - 1L, 0, MESSAGE, sealByIndexZero)).isTrue(); + assertThat(pqc.verifyAtHistoric(0L, 0, MESSAGE, sealByIndexZero)).isTrue(); + assertThat(pqc.addressForIndexAtHistoric(H - 1L, 0)).isEqualTo(validators.get(0)); + assertThat(pqc.addressForIndexAtOwnHead(H - 1L, 0)).isEqualTo(validators.get(0)); + } + + // ------------------------------------------------------------------------------------------- + // Helpers. + // ------------------------------------------------------------------------------------------- + + /** + * Build a schedule the way a node really gets one: written into a genesis file as {@code + * config.pqRegistryHash} and parsed back, so the strictly-increasing rule in the parser is on the + * path rather than bypassed. + * + * @param entries height to 0x-prefixed registry hash, in ascending order of height + * @return the parsed schedule + * @throws Exception when the temporary genesis cannot be written + */ + private PqRegistryHash.Schedule scheduleFromGenesis(final Map 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/PqFleetRestartArmingTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqFleetRestartArmingTest.java new file mode 100644 index 0000000..b784d90 --- /dev/null +++ b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqFleetRestartArmingTest.java @@ -0,0 +1,390 @@ +/* + * Copyright contributors to Besu / Aere Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.hyperledger.besu.crypto.SecureRandomProvider; +import org.hyperledger.besu.datatypes.Address; + +import java.lang.reflect.Field; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.SecureRandom; + +import org.apache.tuweni.bytes.Bytes; +import org.bouncycastle.crypto.AsymmetricCipherKeyPair; +import org.bouncycastle.crypto.digests.KeccakDigest; +import org.bouncycastle.pqc.crypto.falcon.FalconKeyGenerationParameters; +import org.bouncycastle.pqc.crypto.falcon.FalconKeyPairGenerator; +import org.bouncycastle.pqc.crypto.falcon.FalconParameters; +import org.bouncycastle.pqc.crypto.falcon.FalconPrivateKeyParameters; +import org.bouncycastle.pqc.crypto.falcon.FalconPublicKeyParameters; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * THE FLEET-RESTART DEADLOCK, AND THE STATE MACHINE THE REPAIR MOVES. + * + *

MEASURED FIRST, ON A NETWORK, NOT ASSUMED. A full activation rehearsal on a seven-node test + * network found that with the anchor armed at K>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 the seal-attachment repair, 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 PqFleetRestartArmingTest { + + /** Fleet size: seven, the validator count this deployment runs. */ + private static final int N = 7; + + /** Height at which the anchor contract is expected to be observable. */ + private static final long OBSERVE = 1_000L; + + /** Seal-attachment height, at or after OBSERVE. */ + private static final long ATTACH = 1_200L; + + /** A chain head well past the attachment height: this is what a restart comes back to. */ + private static final long HEAD = 5_000L; + + private static final String ANCHOR_ADDRESS = "0x0000000000000000000000000000000000000fa1"; + + @TempDir private Path tmp; + + /** keccak256 over (addr20 || pk) for every index in order: what the anchor contract holds. */ + private String onChainHash; + + /** The same registry, spelled as a GENESIS-anchored manifest (the rehearsal's own shape). */ + private Path genesisPath; + + @BeforeEach + public void setUp() throws Exception { + resetFalconSingleton(); + + final SecureRandom rnd = SecureRandomProvider.createSecureRandom(); + final KeccakDigest kd = new KeccakDigest(256); + final StringBuilder manifest = new StringBuilder("{\"count\":").append(N); + final StringBuilder genesis = + new StringBuilder("{\"config\":{\"aereFalconRegistry\":{\"count\":").append(N); + for (int i = 0; i < N; i++) { + final FalconKeyPairGenerator gen = new FalconKeyPairGenerator(); + gen.init(new FalconKeyGenerationParameters(rnd, FalconParameters.falcon_512)); + final AsymmetricCipherKeyPair kp = gen.generateKeyPair(); + final FalconPublicKeyParameters pub = (FalconPublicKeyParameters) kp.getPublic(); + final FalconPrivateKeyParameters priv = (FalconPrivateKeyParameters) kp.getPrivate(); + final Address addr = Address.fromHexString(String.format("0x%040x", 0xA00 + i)); + + // The pre-image is accumulated in lockstep with the manifest text, exactly the way a real + // anchoring transaction is built, so the hash below is not copied out of the code under test. + final byte[] addrBytes = addr.getBytes().toArray(); + kd.update(addrBytes, 0, addrBytes.length); + kd.update(pub.getH(), 0, pub.getH().length); + + final String entry = + ",\"" + + i + + "\":{\"addr\":\"" + + addr.toHexString() + + "\",\"pk\":\"" + + Bytes.wrap(pub.getH()).toHexString() + + "\"}"; + manifest.append(entry); + genesis.append(entry); + + if (i == 0) { + // This node is validator 0 and HOLDS a signing key, otherwise attachment is off for a + // reason that has nothing to do with the deadlock and the measurement would be vacuous. + final Path key0 = tmp.resolve("falcon-key-0.properties"); + Files.writeString( + key0, + "index=0\n" + + "f=" + + Bytes.wrap(priv.getSpolyf()).toHexString() + + "\n" + + "g=" + + Bytes.wrap(priv.getG()).toHexString() + + "\n" + + "F=" + + Bytes.wrap(priv.getSpolyF()).toHexString() + + "\n" + + "pk=" + + Bytes.wrap(pub.getH()).toHexString() + + "\n"); + System.setProperty("aere.falcon.key", key0.toAbsolutePath().toString()); + } + } + manifest.append("}"); + + final byte[] digest = new byte[32]; + kd.doFinal(digest, 0); + onChainHash = Bytes.wrap(digest).toUnprefixedHexString(); + + final Path manifestPath = tmp.resolve("falcon-late-manifest.json"); + Files.writeString(manifestPath, manifest.toString()); + + // Same seven entries, anchored the way the rehearsal network anchored them: in genesis, with + // the hash committed in the anchor contract's slot 0 through alloc storage. + genesis + .append("}},\"alloc\":{\"0000000000000000000000000000000000000fa1\":{\"storage\":{\"0x") + .append("0".repeat(64)) + .append("\":\"0x") + .append(onChainHash) + .append("\"}}}}"); + genesisPath = tmp.resolve("genesis-registry.json"); + Files.writeString(genesisPath, genesis.toString()); + + System.setProperty("aere.falcon.manifest", manifestPath.toAbsolutePath().toString()); + System.setProperty("aere.falcon.anchor.address", ANCHOR_ADDRESS); + System.setProperty("aere.falcon.anchor.block", Long.toString(OBSERVE)); + System.setProperty("aere.falcon.attachBlock", Long.toString(ATTACH)); + System.setProperty("aere.falcon.validatorCount", Integer.toString(N)); + } + + @AfterEach + public void tearDown() throws Exception { + for (final String p : + new String[] { + "aere.falcon.manifest", + "aere.falcon.genesis", + "aere.falcon.key", + "aere.falcon.anchor.address", + "aere.falcon.anchor.block", + "aere.falcon.attachBlock", + "aere.falcon.forkBlock", + "aere.falcon.validatorCount" + }) { + System.clearProperty(p); + } + resetFalconSingleton(); + } + + // ------------------------------------------------------------------------------------------- + // 1. The deadlock state, stated as a property. + // ------------------------------------------------------------------------------------------- + + @Test + public void restartedFleetIsNotArmedAndDoesNotHealWithTime() { + final FalconSealSupport pqc = FalconSealSupport.instance(); + + assertThat(pqc.lateAnchorPending()) + .describedAs( + "fixture control: the late-anchor manifest must LOAD and stay PENDING, or every " + + "assertion below is about a registry that was never configured") + .isTrue(); + assertThat(pqc.lateAnchored()).isFalse(); + assertThat(pqc.lateAnchorFailed()).isFalse(); + assertThat(pqc.signingEnabled()) + .describedAs("fixture control: this node holds a Falcon key, so attachment is not off for " + + "the trivial reason") + .isTrue(); + assertThat(pqc.attachBlock()).isEqualTo(ATTACH); + + // This IS the post-restart state: the process has just started, the chain head is far past the + // attachment height, and no block has been imported because nobody has proposed one. + assertThat(pqc.attachmentArmed(HEAD)) + .describedAs( + "the measured deadlock: attachment height long since passed, registry still pending, " + + "so no seal is attached and no certificate can ever reach K") + .isFalse(); + + // And it does not heal. Time, and blocks that are never imported, change nothing. + for (long n = HEAD; n <= HEAD + 10_000L; n += 1_000L) { + assertThat(pqc.attachmentArmed(n)) + .describedAs("still not armed at height %s; nothing in the process flips it", n) + .isFalse(); + } + assertThat(pqc.registrySize()) + .describedAs("the registry is EMPTY while pending, which is why a seal cannot verify either") + .isZero(); + } + + // ------------------------------------------------------------------------------------------- + // 2. The repair's mechanism: the SECOND caller, the one startup adds. + // ------------------------------------------------------------------------------------------- + + @Test + public void activatingFromTheChainHeadArmsAttachment() { + final FalconSealSupport pqc = FalconSealSupport.instance(); + assertThat(pqc.attachmentArmed(HEAD)).isFalse(); + + // Exactly what the startup repair does: hand over the 32-byte value read from the anchor + // contract's slot 0 in the CHAIN-HEAD world state. No block is imported anywhere here. + final boolean activated = pqc.activateLateAnchor(onChainHash); + + assertThat(activated).isTrue(); + assertThat(pqc.lateAnchored()).isTrue(); + assertThat(pqc.lateAnchorPending()).isFalse(); + assertThat(pqc.registrySize()).isEqualTo(N); + assertThat(pqc.addressBound()) + .describedAs("the activated registry must bind every index to an address, or a seal cannot " + + "be resolved to a signer") + .isTrue(); + assertThat(pqc.attachmentArmed(HEAD)) + .describedAs( + "THE REPAIR: one activation from chain-head state arms attachment, so a restarted " + + "validator emits Falcon-carrying Commits again, certificates reach K, and a " + + "proposer can propose. This is the edge the deadlock needed and did not have.") + .isTrue(); + } + + // ------------------------------------------------------------------------------------------- + // 3. THE NEGATIVE CONTROL. The repair must not have bought liveness by weakening the check. + // ------------------------------------------------------------------------------------------- + + @Test + public void aWrongOnChainHashLeavesAttachmentOffAndIsTerminal() { + final FalconSealSupport pqc = FalconSealSupport.instance(); + + // One flipped nibble: a tampered anchor, or a wrong manifest shipped to this node. + final char first = onChainHash.charAt(0); + final String wrong = (first == '0' ? '1' : '0') + onChainHash.substring(1); + assertThat(wrong).isNotEqualTo(onChainHash).hasSize(64); + + assertThat(pqc.activateLateAnchor(wrong)) + .describedAs("a mismatching anchor must NOT activate the registry") + .isFalse(); + assertThat(pqc.lateAnchored()).isFalse(); + assertThat(pqc.lateAnchorFailed()) + .describedAs("and the mismatch must be TERMINAL, not merely 'not yet'") + .isTrue(); + assertThat(pqc.registrySize()) + .describedAs("the registry stays EMPTY: fail-closed, not fail-open") + .isZero(); + assertThat(pqc.attachmentArmed(HEAD)) + .describedAs( + "attachment stays OFF after a failed activation. If this were true, the startup repair " + + "would have turned a tamper detection into an arming path.") + .isFalse(); + + // And the correct hash afterwards does not resurrect it: a node that has seen a tampered anchor + // stays refused, which is the same fail-closed rule the import path already had. + assertThat(pqc.activateLateAnchor(onChainHash)).isFalse(); + assertThat(pqc.attachmentArmed(HEAD)).isFalse(); + } + + // ------------------------------------------------------------------------------------------- + // 4. Scope control: two callers now exist in one process. + // ------------------------------------------------------------------------------------------- + + @Test + public void activationIsIdempotentAcrossRepeatedStartupCalls() { + final FalconSealSupport pqc = FalconSealSupport.instance(); + + assertThat(pqc.activateLateAnchor(onChainHash)).isTrue(); + final int afterFirst = pqc.registrySize(); + + // The startup call has fired; the import path fires too, on the first block that arrives. + assertThat(pqc.activateLateAnchor(onChainHash)).isTrue(); + assertThat(pqc.registrySize()).isEqualTo(afterFirst).isEqualTo(N); + assertThat(pqc.attachmentArmed(HEAD)).isTrue(); + + // Even a garbage hash after activation cannot un-arm it: activation is a one-way latch, so a + // second reader with a stale view cannot disarm a fleet that is already sealing. + assertThat(pqc.activateLateAnchor("00".repeat(32))).isTrue(); + assertThat(pqc.lateAnchorFailed()).isFalse(); + assertThat(pqc.attachmentArmed(HEAD)).isTrue(); + } + + // ------------------------------------------------------------------------------------------- + // 5. The rehearsal's OWN stimulus, replayed against THIS tree. Read the note before trusting it. + // ------------------------------------------------------------------------------------------- + + /** + * The seven-node rehearsal ran a GENESIS-anchored registry, and the line it logged after the + * simultaneous restart was the COVERAGE one: "no validator set has been observed yet, so registry + * COVERAGE cannot be proven. Attachment stays OFF (fail-safe)". That condition does not exist in + * this tree: {@code grep} for it returns nothing, because the seal-attachment repair took the + * fleet-wide question out of the per-commit gate. The rehearsal binary was built before that + * repair landed, and still had it. + * + *

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/PqForkArmingTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqForkArmingTest.java new file mode 100644 index 0000000..2f76543 --- /dev/null +++ b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqForkArmingTest.java @@ -0,0 +1,368 @@ +/* + * Copyright contributors to Besu / Aere Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + + +import java.lang.reflect.Field; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.apache.tuweni.bytes.Bytes; +import org.bouncycastle.crypto.digests.KeccakDigest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * FORK-HEIGHT ARMING. THE MEASUREMENT THAT DID NOT EXIST. + * + *

The concern, as it was written down: a malformed forkBlock falls OPEN, with only a log line, + * and arming it at or before the anchor observation height passes undetected. It stood as an + * assertion with no command behind it for months. This file is the command that can fail. + * + *

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 PqForkArmingTest { + + /** Fleet size; nine, because the blocking guard refuses to arm below nine. */ + private static final int N = 9; + + /** Height at which the on-chain late-anchor registry contract is expected to be observed. */ + private static final long OBSERVE = 5_000L; + + /** Seal-attachment height: at or after OBSERVE, so a seal can actually be emitted. */ + private static final long ATTACH = 6_000L; + + /** Blocking height: at least minAttachLead (256) after ATTACH. */ + private static final long FORK = 7_000L; + + private static final String ANCHOR_ADDRESS = "0x0000000000000000000000000000000000000fa1"; + + /** + * REGISTRY BINDING: the chain this fixture's registries are BOUND to. Every proof commits to it, + * so it has to be stated rather than defaulted. + */ + private static final long CHAIN_ID = 2_800L; + + @TempDir private Path tmp; + + private Path manifestPath; + private Path genesisPath; + + @BeforeEach + public void setUp() throws Exception { + // REGISTRY BINDING: both registries below are v2 and PROOF-BOUND, bound at FORK, the + // height this fixture arms from. They used to carry addresses spelled 0xB00+i, which no + // secp256k1 key can sign for, so this whole fixture became unstartable the moment + // AERE-PQC-REG-ARM-02 was wired into the constructor. + + // LATE-ANCHOR manifest: the registry is PENDING until the anchor contract is observed on chain. + final StringBuilder late = new StringBuilder("{"); + late.append(PqV2Fixture.manifestHeader(N, CHAIN_ID, FORK)); + for (int i = 0; i < N; i++) { + late.append(',').append(PqV2Fixture.manifestEntry(i, N, CHAIN_ID, FORK)); + } + late.append("}"); + manifestPath = tmp.resolve("falcon-late-manifest.json"); + Files.writeString(manifestPath, late.toString()); + + // GENESIS-ANCHORED manifest: the registry is ACTIVE from block 0, so no observation height can + // exist and none may be demanded. Built exactly the way a real genesis is, hash included. + final KeccakDigest kd = new KeccakDigest(256); + final StringBuilder gen = new StringBuilder("{\"config\":{\"aereFalconRegistry\":{"); + gen.append(PqV2Fixture.manifestHeader(N, CHAIN_ID, FORK)); + for (int i = 0; i < N; i++) { + final byte[] anchoredRow = PqV2Fixture.anchorPreimageRow(i); + kd.update(anchoredRow, 0, anchoredRow.length); + gen.append(',').append(PqV2Fixture.manifestEntry(i, N, CHAIN_ID, FORK)); + } + final byte[] anchoredHash = new byte[32]; + kd.doFinal(anchoredHash, 0); + gen.append("}},\"alloc\":{\"0000000000000000000000000000000000000fa1\":{\"storage\":{\"0x") + .append("0".repeat(64)) + .append("\":\"0x") + .append(Bytes.wrap(anchoredHash).toUnprefixedHexString()) + .append("\"}}}}"); + genesisPath = tmp.resolve("genesis-registry.json"); + Files.writeString(genesisPath, gen.toString()); + + System.setProperty("aere.falcon.validatorCount", Integer.toString(N)); + resetFalconSingleton(); + } + + @AfterEach + public void tearDown() throws Exception { + for (final String p : + new String[] { + "aere.falcon.manifest", + "aere.falcon.genesis", + "aere.falcon.anchor.address", + "aere.falcon.anchor.block", + "aere.falcon.attachBlock", + "aere.falcon.forkBlock", + "aere.falcon.validatorCount" + }) { + System.clearProperty(p); + } + resetFalconSingleton(); + } + + // ------------------------------------------------------------------------------------------- + // 1. The fixture's own control. + // ------------------------------------------------------------------------------------------- + + @Test + public void controlAWellFormedLateAnchorConfigurationStarts() { + lateAnchor(); + System.setProperty("aere.falcon.anchor.block", Long.toString(OBSERVE)); + System.setProperty("aere.falcon.attachBlock", Long.toString(ATTACH)); + System.setProperty("aere.falcon.forkBlock", Long.toString(FORK)); + + final FalconSealSupport pqc = FalconSealSupport.instance(); + assertThat(pqc.lateAnchorPending()) + .describedAs( + "the late-anchor manifest must load and stay PENDING, or every refusal below is a " + + "refusal about a registry that was never there") + .isTrue(); + assertThat(pqc.forkBlock()).isEqualTo(FORK); + assertThat(pqc.attachBlock()).isEqualTo(ATTACH); + assertThat(pqc.forkBlock()) + .describedAs( + "the ordering the guard exists to enforce, stated as a property: blocking arms strictly " + + "AFTER the height at which the registry it depends on can become active") + .isGreaterThan(OBSERVE); + } + + // ------------------------------------------------------------------------------------------- + // 2-3. Half one at config time. + // ------------------------------------------------------------------------------------------- + + @Test + public void aMalformedForkBlockRefusesToStart() { + lateAnchor(); + System.setProperty("aere.falcon.anchor.block", Long.toString(OBSERVE)); + System.setProperty("aere.falcon.attachBlock", Long.toString(ATTACH)); + // The exact typo shape a human makes when copying a height out of a document. + System.setProperty("aere.falcon.forkBlock", "9_189_161"); + + assertThatThrownBy(FalconSealSupport::instance) + .describedAs( + "a malformed blocking height must ABORT, never degrade to never-blocking with a log line") + .isInstanceOf(FalconSealSupport.ActivationConfigException.class) + .hasMessageContaining("MALFORMED"); + } + + @Test + public void aNegativeForkBlockRefusesToStart() { + lateAnchor(); + System.setProperty("aere.falcon.anchor.block", Long.toString(OBSERVE)); + System.setProperty("aere.falcon.attachBlock", Long.toString(ATTACH)); + System.setProperty("aere.falcon.forkBlock", "-1"); + + assertThatThrownBy(FalconSealSupport::instance) + .isInstanceOf(FalconSealSupport.ActivationConfigException.class) + .hasMessageContaining("negative"); + } + + // ------------------------------------------------------------------------------------------- + // 4. Half one where it actually survived: the value was validated but never OWNED. + // ------------------------------------------------------------------------------------------- + + @Test + public void theForkBlockIsResolvedOnceAndCannotBeReopenedAfterStartup() { + lateAnchor(); + System.setProperty("aere.falcon.anchor.block", Long.toString(OBSERVE)); + System.setProperty("aere.falcon.attachBlock", Long.toString(ATTACH)); + System.setProperty("aere.falcon.forkBlock", Long.toString(FORK)); + + final FalconSealSupport pqc = FalconSealSupport.instance(); + assertThat(pqc.forkBlock()).isEqualTo(FORK); + + // The startup guard has already run and passed. Nothing will run it again. If the accessor + // re-reads the property, then the ONE decision the whole PQC layer is gated on is a value that + // can still turn into "never blocking" at any moment, for any reason that leaves the property + // unparseable, and the only trace is one WARN line per call. + System.setProperty("aere.falcon.forkBlock", "not-a-number"); + assertThat(pqc.forkBlock()) + .describedAs( + "the blocking height must be resolved ONCE, at the boundary, and owned thereafter. A " + + "value that is validated at startup and re-parsed on every use is not validated.") + .isEqualTo(FORK); + } + + // ------------------------------------------------------------------------------------------- + // 5-7. Half two: the ORDER between the blocking height and the anchor observation height. + // ------------------------------------------------------------------------------------------- + + @Test + public void blockingOverAPendingAnchorWithNoDeclaredObservationHeightRefuses() { + lateAnchor(); + System.setProperty("aere.falcon.attachBlock", Long.toString(ATTACH)); + System.setProperty("aere.falcon.forkBlock", Long.toString(FORK)); + // aere.falcon.anchor.block deliberately NOT set. + + assertThatThrownBy(FalconSealSupport::instance) + .describedAs( + "with the registry still PENDING and no stated activation height, nothing in this " + + "process can compare the blocking height against the height at which the registry " + + "becomes usable, so the ordering error the finding names cannot be detected at all") + .isInstanceOf(FalconSealSupport.ActivationConfigException.class) + .hasMessageContaining("AERE-PQC-CFG-UNSAFE-06"); + } + + @Test + public void anAttachHeightBeforeTheObservationHeightRefuses() { + lateAnchor(); + System.setProperty("aere.falcon.anchor.block", Long.toString(ATTACH + 1L)); + System.setProperty("aere.falcon.attachBlock", Long.toString(ATTACH)); + System.setProperty("aere.falcon.forkBlock", Long.toString(FORK)); + + assertThatThrownBy(FalconSealSupport::instance) + .describedAs( + "attachment before the registry can be active emits nothing, so the log-only soak " + + "window measures nothing and the blocking height arrives over a registry no node " + + "has ever produced a seal against") + .isInstanceOf(FalconSealSupport.ActivationConfigException.class) + .hasMessageContaining("AERE-PQC-CFG-UNSAFE-07"); + } + + @Test + public void aForkHeightAtTheObservationHeightRefuses() { + lateAnchor(); + System.setProperty("aere.falcon.anchor.block", Long.toString(FORK)); + System.setProperty("aere.falcon.attachBlock", Long.toString(ATTACH)); + System.setProperty("aere.falcon.forkBlock", Long.toString(FORK)); + + assertThatThrownBy(FalconSealSupport::instance) + .describedAs("the literal stimulus in the finding: armed AT the anchor observation height") + .isInstanceOf(FalconSealSupport.ActivationConfigException.class) + .hasMessageContaining("AERE-PQC-CFG-UNSAFE-07"); + } + + // ------------------------------------------------------------------------------------------- + // 8-9. The new value must fail closed like every other one. + // ------------------------------------------------------------------------------------------- + + @Test + public void aMalformedObservationHeightRefuses() { + lateAnchor(); + System.setProperty("aere.falcon.anchor.block", "1e3"); + System.setProperty("aere.falcon.attachBlock", Long.toString(ATTACH)); + System.setProperty("aere.falcon.forkBlock", Long.toString(FORK)); + + assertThatThrownBy(FalconSealSupport::instance) + .isInstanceOf(FalconSealSupport.ActivationConfigException.class) + .hasMessageContaining("AERE-PQC-CFG-SYNTAX-09"); + } + + @Test + public void aNegativeObservationHeightRefuses() { + lateAnchor(); + System.setProperty("aere.falcon.anchor.block", "-5"); + System.setProperty("aere.falcon.attachBlock", Long.toString(ATTACH)); + System.setProperty("aere.falcon.forkBlock", Long.toString(FORK)); + + assertThatThrownBy(FalconSealSupport::instance) + .isInstanceOf(FalconSealSupport.ActivationConfigException.class) + .hasMessageContaining("AERE-PQC-CFG-SYNTAX-10"); + } + + // ------------------------------------------------------------------------------------------- + // 10-11. Scope controls. A guard that refuses everything is not a guard. + // ------------------------------------------------------------------------------------------- + + @Test + public void aGenesisAnchoredRegistryNeedsNoObservationHeight() { + System.setProperty("aere.falcon.genesis", genesisPath.toAbsolutePath().toString()); + System.setProperty("aere.falcon.attachBlock", Long.toString(ATTACH)); + System.setProperty("aere.falcon.forkBlock", Long.toString(FORK)); + // aere.falcon.anchor.block deliberately NOT set: a genesis-anchored registry is active from + // block 0, so there IS no observation height and demanding one would break the whole + // genesis-anchored deployment path. + + final FalconSealSupport pqc = FalconSealSupport.instance(); + assertThat(pqc.genesisAnchored()).isTrue(); + assertThat(pqc.addressBound()).isTrue(); + assertThat(pqc.forkBlock()).isEqualTo(FORK); + } + + @Test + public void anObservationHeightWithoutBlockingIsHarmless() { + lateAnchor(); + System.setProperty("aere.falcon.anchor.block", Long.toString(OBSERVE)); + // No forkBlock, no attachBlock: the log-only baseline every node on chain 2800 runs today. + + final FalconSealSupport pqc = FalconSealSupport.instance(); + assertThat(pqc.forkBlock()).isEqualTo(Long.MAX_VALUE); + assertThat(pqc.attachBlock()).isEqualTo(Long.MAX_VALUE); + assertThat(pqc.lateAnchorPending()).isTrue(); + } + + // ------------------------------------------------------------------------------------------- + // Helpers. + // ------------------------------------------------------------------------------------------- + + private void lateAnchor() { + System.setProperty("aere.falcon.manifest", manifestPath.toAbsolutePath().toString()); + System.setProperty("aere.falcon.anchor.address", ANCHOR_ADDRESS); + } + + private static void resetFalconSingleton() throws Exception { + final Field f = FalconSealSupport.class.getDeclaredField("instance"); + f.setAccessible(true); + f.set(null, null); + } +} diff --git a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqForkThresholdReachabilityTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqForkThresholdReachabilityTest.java new file mode 100644 index 0000000..f9e4f36 --- /dev/null +++ b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqForkThresholdReachabilityTest.java @@ -0,0 +1,355 @@ +/* + * Copyright contributors to Besu / Aere Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + + +import java.lang.reflect.Field; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.apache.tuweni.bytes.Bytes; +import org.bouncycastle.crypto.digests.KeccakDigest; +import org.bouncycastle.pqc.crypto.falcon.FalconPrivateKeyParameters; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer; +import org.junit.jupiter.api.io.TempDir; + +/** + * THRESHOLD REACHABILITY, THE HALF THAT WAS STILL OPEN: is the threshold K one the fleet can be + * GUARANTEED to meet? + * + *

The 2026-08-02 repair closed the mechanism that stopped the chain on one add-validator vote: it + * took the fleet-wide coverage question out of the per-commit attachment gate and made coverage a + * REPORT. That repair is correct and it is measured next door in {@code PqForkValidatorSetChangeTest}. + * But it left behind an explicit promise, written in the javadoc of {@code attachmentArmed}: + * + *

+ * + * "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 address-binding guard 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 the fork-arming + * configuration guard. + * + *

NOT MEASURED here, and named so it is not read as covered: what a LIVE fleet does in the rounds + * between the vote landing and the first proposer failing. That needs a network. This class measures + * the decision, which is the thing a node can be stopped from taking. + */ +public class PqForkThresholdReachabilityTest { + + /** Anchor activation height H. */ + private static final long H = 1_000L; + + /** The height from which the staged threshold is K. */ + private static final long K_AT = H + 10L; + + /** The threshold this project intends to arm. */ + private static final int K = 5; + + /** + * REGISTRY BINDING: the chain the registries this fixture writes are BOUND to. It is the same + * value {@link #armAnchor} states in {@code aere.pq.chainId}: a registry bound to one chain and an + * anchor armed on another is a configuration this fixture must never accidentally describe. + */ + private static final long CHAIN_ID = 2_800L; + + @TempDir private Path tmp; + + @BeforeEach + public void setUp() throws Exception { + resetFalconSingleton(); + } + + @AfterEach + public void tearDown() throws Exception { + for (final String p : + new String[] { + "aere.falcon.genesis", + "aere.falcon.key", + "aere.falcon.attachBlock", + "aere.falcon.validatorCount", + "aere.falcon.testnetAllowSmallFleet", + PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, + PqAnchorConfig.PROPERTY_MIN_SEALS, + PqAnchorConfig.PROPERTY_CHAIN_ID + }) { + System.clearProperty(p); + } + // Clearing the properties is NOT enough. PqAnchorProducer holds the configuration in a + // remembered field, and that field outlives the properties. Measured 2026-08-11: without the + // line below, this class leaves the anchor ARMED for whichever class runs next, and four tests + // in PqStartupHistoryTest fail on a guard that is firing CORRECTLY. The suite was green because + // the ordering of the day hid the leak, not because the leak was absent. + PqAnchorProducer.useConfigForTesting(null); + resetFalconSingleton(); + } + + // ------------------------------------------------------------------------------------------- + // 1. THE FINDING. A threshold the fleet is not guaranteed to meet must not start. + // ------------------------------------------------------------------------------------------- + + @Test + public void armingAThresholdTheFleetCannotGuaranteeMustRefuseToStart() throws Exception { + // The exact state the standing plan walks through: the set has grown to nine, the anchored + // manifest still names the original seven, and the threshold is the one the schedule arms. + writeAnchoredRegistry(7); + System.setProperty("aere.falcon.validatorCount", "9"); + armAnchor(K); + + assertThatThrownBy(FalconSealSupport::instance) + .describedAs( + "N=9 with 7 keyed guarantees only %d Falcon seal(s) among a block's committers, and the " + + "armed threshold is K=%d. A node must refuse to start rather than arm a threshold " + + "no proposer is guaranteed to be able to meet.", + FalconSealSupport.worstCaseKeyedSigners(9, 7), K) + .isInstanceOf(FalconSealSupport.ActivationConfigException.class) + .hasMessageContaining("AERE-PQC-CFG-UNSAFE-08") + // The message has to carry BOTH numbers. "Unsafe" without them sends an operator to read + // code; the two numbers are the whole diagnosis and the whole remedy. + .hasMessageContaining("K=" + K) + .hasMessageContaining("guaranteed"); + } + + // ------------------------------------------------------------------------------------------- + // 2. NEGATIVE CONTROL. A guard that refuses everything is not a guard. + // ------------------------------------------------------------------------------------------- + + @Test + public void aReachableThresholdMustStillStart() throws Exception { + // N=7 fully keyed: quorum 5, guaranteed 5, K=5. Margin is exactly zero, which is a different + // statement from "unreachable", and the guard must not confuse the two. This is also the + // configuration the fleet runs today, so a guard that refused it would be a self-inflicted halt. + writeAnchoredRegistry(7); + System.setProperty("aere.falcon.validatorCount", "7"); + armAnchor(K); + + assertThatCode(FalconSealSupport::instance) + .describedAs("N=7 fully keyed guarantees exactly K=%d; zero margin is not unreachable", K) + .doesNotThrowAnyException(); + assertThat(FalconSealSupport.instance().registrySize()).isEqualTo(7); + } + + @Test + public void growingTheManifestWithTheSetIsWhatMakesNineSafe() throws Exception { + // The remedy the refusal names, measured rather than asserted: re-anchor the manifest for the + // whole set and the same N=9, same K=5 starts. + writeAnchoredRegistry(9); + System.setProperty("aere.falcon.validatorCount", "9"); + armAnchor(K); + + assertThatCode(FalconSealSupport::instance).doesNotThrowAnyException(); + assertThat(FalconSealSupport.worstCaseKeyedSigners(9, 9)) + .describedAs("nine keyed of nine guarantees the full ECDSA quorum") + .isEqualTo(6); + } + + // ------------------------------------------------------------------------------------------- + // 3. INERT WHERE IT MUST BE INERT. Chain 2800 as it stands today. + // ------------------------------------------------------------------------------------------- + + @Test + public void withNoAnchorConfiguredTheGuardIsInert() throws Exception { + // With aere.pq.anchorBlock unset there is no anchor, so K does not exist and there is nothing + // to compare. A guard that could stop a node in that state would be a new way to lose the fleet, + // which is a strictly worse defect than the one it repairs. + writeAnchoredRegistry(7); + System.setProperty("aere.falcon.validatorCount", "9"); + + assertThatCode(FalconSealSupport::instance) + .describedAs("no anchor configured: no threshold, no comparison, no refusal") + .doesNotThrowAnyException(); + } + + @Test + public void aScheduleThatNeverRaisesTheThresholdAboveZeroIsInert() throws Exception { + writeAnchoredRegistry(7); + System.setProperty("aere.falcon.validatorCount", "9"); + armAnchor(0); + + assertThatCode(FalconSealSupport::instance) + .describedAs("K=0 everywhere is the warm-up regime; nothing can fail to be met") + .doesNotThrowAnyException(); + } + + // ------------------------------------------------------------------------------------------- + // 4. THE CASE WITH NO KEYS AT ALL, which is the same arithmetic at its floor. + // ------------------------------------------------------------------------------------------- + + @Test + public void aPositiveThresholdWithNoAnchoredKeysMustRefuseToStart() throws Exception { + // No manifest anywhere and K=5: guaranteed is 0, so every block at or above H would be rejected + // for want of a certificate nobody can produce. Distinct from the genesis-binding refusal, + // which only fires when aere.falcon.forkBlock is set; the anchor path has its own arming + // height. + System.setProperty("aere.falcon.validatorCount", "7"); + armAnchor(K); + + assertThatThrownBy(FalconSealSupport::instance) + .isInstanceOf(FalconSealSupport.ActivationConfigException.class) + .hasMessageContaining("AERE-PQC-CFG-UNSAFE-08"); + } + + // ------------------------------------------------------------------------------------------- + // 5. THE WAIVER IS EXPLICIT, NAMED, AND ONLY FOR ISOLATED NETWORKS. + // ------------------------------------------------------------------------------------------- + + @Test + public void anIsolatedTestNetworkCanWaiveTheGuardExplicitly() throws Exception { + writeAnchoredRegistry(7); + System.setProperty("aere.falcon.validatorCount", "9"); + System.setProperty("aere.falcon.testnetAllowSmallFleet", "true"); + armAnchor(K); + + assertThatCode(FalconSealSupport::instance) + .describedAs( + "the same switch that waives the N>=9 rule waives this one, because both say the same " + + "thing: this fleet has no Falcon fault margin and must not be a mainnet") + .doesNotThrowAnyException(); + } + + // ------------------------------------------------------------------------------------------- + // 6. THE ARITHMETIC ITSELF, at the boundary, as a pure function. + // ------------------------------------------------------------------------------------------- + + @Test + public void theDeficitIsTheDistanceBetweenTheThresholdAndTheGuarantee() { + assertThat(FalconSealSupport.thresholdDeficit(7, 7, 5)) + .describedAs("N=7 fully keyed meets K=5 exactly") + .isZero(); + assertThat(FalconSealSupport.thresholdDeficit(8, 7, 5)) + .describedAs("one unkeyed validator added: still met") + .isZero(); + assertThat(FalconSealSupport.thresholdDeficit(9, 7, 5)) + .describedAs("two added without re-anchoring: short by one, which is the halt") + .isEqualTo(1); + assertThat(FalconSealSupport.thresholdDeficit(9, 9, 5)).isZero(); + assertThat(FalconSealSupport.thresholdDeficit(7, 0, 1)) + .describedAs("no keys at all: a positive threshold is short by all of it") + .isEqualTo(1); + assertThat(FalconSealSupport.thresholdDeficit(7, 7, 0)) + .describedAs("K=0 can never be in deficit") + .isZero(); + } + + // ------------------------------------------------------------------------------------------- + // Helpers. + // ------------------------------------------------------------------------------------------- + + /** Arm the V2 anchor from system configuration with a staged threshold that reaches {@code k}. */ + private static void armAnchor(final int k) { + System.setProperty(PqAnchorConfig.PROPERTY_ANCHOR_BLOCK, Long.toString(H)); + // AERE CONFIGURATIE-STRICTA (2026-08-06): an activation height without an explicit + // chain id is now a startup refusal, because a silently defaulted 0 in the D and M + // pre-images is the Holesky shape. The fixture states what the fleet states. + System.setProperty(PqAnchorConfig.PROPERTY_CHAIN_ID, Long.toString(CHAIN_ID)); + System.setProperty( + PqAnchorConfig.PROPERTY_MIN_SEALS, H + ":0," + K_AT + ":" + k); + } + + /** + * Write a genesis-anchored, address-bound Falcon manifest for {@code count} validators and point + * this node at index 0's key, exactly as {@code PqForkValidatorSetChangeTest} does. The anchored hash + * is accumulated in lockstep with the manifest text, so the fixture is anchored the way a real + * genesis is rather than by a flag. + */ + private void writeAnchoredRegistry(final int count) throws Exception { + // REGISTRY BINDING: v2, proof-bound, bound at H, the height armAnchor() arms from. The + // rows come from PqV2Fixture because a v2 claim must be signed by the validator whose address + // is on the row, and the 0xA00+i addresses this used to spell have no key behind them. + final KeccakDigest kd = new KeccakDigest(256); + final StringBuilder manifest = new StringBuilder(); + manifest + .append("{\"config\":{\"aereFalconRegistry\":{") + .append(PqV2Fixture.manifestHeader(count, CHAIN_ID, H)); + for (int i = 0; i < count; i++) { + final FalconPrivateKeyParameters priv = PqV2Fixture.privateKey(i); + final byte[] anchoredRow = PqV2Fixture.anchorPreimageRow(i); + kd.update(anchoredRow, 0, anchoredRow.length); + manifest.append(',').append(PqV2Fixture.manifestEntry(i, count, CHAIN_ID, H)); + if (i == 0) { + final Path key0 = tmp.resolve("falcon-key-0.properties"); + Files.writeString( + key0, + "index=0\n" + + "f=" + + Bytes.wrap(priv.getSpolyf()).toHexString() + + "\n" + + "g=" + + Bytes.wrap(priv.getG()).toHexString() + + "\n" + + "F=" + + Bytes.wrap(priv.getSpolyF()).toHexString() + + "\n" + + "pk=" + + Bytes.wrap(PqV2Fixture.publicKey(i)).toHexString() + + "\n"); + System.setProperty("aere.falcon.key", key0.toAbsolutePath().toString()); + } + } + manifest.append("}},\"alloc\":{\"0000000000000000000000000000000000000fa1\":{\"storage\":{\"0x") + .append("0".repeat(64)) + .append("\":\"0x"); + final byte[] anchoredHash = new byte[32]; + kd.doFinal(anchoredHash, 0); + manifest.append(Bytes.wrap(anchoredHash).toUnprefixedHexString()).append("\"}}}}"); + + final Path genesis = tmp.resolve("genesis-registry.json"); + Files.writeString(genesis, manifest.toString()); + System.setProperty("aere.falcon.genesis", genesis.toAbsolutePath().toString()); + } + + private static void resetFalconSingleton() throws Exception { + final Field f = FalconSealSupport.class.getDeclaredField("instance"); + f.setAccessible(true); + f.set(null, null); + } +} diff --git a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqForkValidatorSetChangeTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqForkValidatorSetChangeTest.java new file mode 100644 index 0000000..113c557 --- /dev/null +++ b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqForkValidatorSetChangeTest.java @@ -0,0 +1,427 @@ +/* + * Copyright contributors to Besu / Aere Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.withSettings; + +import org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer; +import org.hyperledger.besu.consensus.common.validator.ValidatorProvider; +import org.hyperledger.besu.datatypes.Address; +import org.hyperledger.besu.ethereum.ProtocolContext; +import org.hyperledger.besu.ethereum.core.BlockHeader; +import org.hyperledger.besu.ethereum.core.BlockHeaderTestFixture; + +import java.lang.reflect.Field; +import java.nio.file.Files; +import java.nio.file.Path; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.OptionalInt; + +import org.apache.tuweni.bytes.Bytes; +import org.apache.tuweni.bytes.Bytes32; +import org.bouncycastle.crypto.digests.KeccakDigest; +import org.bouncycastle.pqc.crypto.falcon.FalconPrivateKeyParameters; +import org.bouncycastle.pqc.crypto.falcon.FalconSigner; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.quality.Strictness; + +/** + * VALIDATOR-SET CHANGE UNDER AN ARMED ANCHOR. THE MEASUREMENT THAT DID NOT EXIST. + * + *

The concern, as it was written down, was this: if PQC were armed, an ordinary add-validator + * vote would stop the chain, because the Falcon blocking quorum follows the dynamic set and cannot + * be reached inside the vote window. It was carried as UNMEASURED, on the argument that the direct + * measurement would require ARMING PQC on a chain, which is exactly the thing that stops the chain. + * + *

That is true of a whole chain. It is NOT true of the decision that stops it. Every step from + * "the validator set changed" to "no block can be proposed" is taken by three objects in this + * module, each of which is a pure function of its inputs: {@link FalconSealSupport#attachmentArmed} + * decides whether this node emits a Falcon seal at all, {@link PqSealCache} holds what was heard, + * and {@link PqAnchorProducer#apply} decides whether this node may propose. This class drives those + * three with a REAL address-bound genesis-anchored registry and REAL Falcon-512 keys, and asks the + * question that was held to be unaskable. + * + *

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 the question + * under test. This one fails if the fixture is not genuinely armed. + *
  2. {@link #addingOneValidatorMustNotTurnSealAttachmentOff()} - the concern 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 PqForkValidatorSetChangeTest { + + /** Anchor activation height H used throughout. */ + private static final long H = 1_000L; + + /** Seal-attachment height, comfortably below H. */ + private static final long ATTACH = 900L; + + /** Height from which the staged threshold K is 5, i.e. the armed regime. */ + private static final long K_AT = H + 10L; + + private static final int K = 5; + + private static final int N = 7; + + private static final long CHAIN_ID = 220_878L; + + @TempDir private Path tmp; + + private final List

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. + // + // REGISTRY BINDING: v2, proof-bound, bound at H. The addresses come from PqV2Fixture and + // are DERIVED from real secp256k1 keys, because a claim has to be signed by the validator whose + // address is on the row and no key produces the 0xA00+i addresses this used to spell. + final KeccakDigest kd = new KeccakDigest(256); + final StringBuilder manifest = new StringBuilder(); + manifest + .append("{\"config\":{\"aereFalconRegistry\":{") + .append(PqV2Fixture.manifestHeader(N, CHAIN_ID, H)); + for (int i = 0; i < N; i++) { + final FalconPrivateKeyParameters priv = PqV2Fixture.privateKey(i); + privateKeys.add(priv); + keyedValidators.add(PqV2Fixture.address(i)); + final byte[] anchoredRow = PqV2Fixture.anchorPreimageRow(i); + kd.update(anchoredRow, 0, anchoredRow.length); + manifest.append(',').append(PqV2Fixture.manifestEntry(i, N, CHAIN_ID, H)); + if (i == 0) { + key0Path = tmp.resolve("falcon-key-0.properties"); + Files.writeString( + key0Path, + "index=0\n" + + "f=" + + Bytes.wrap(priv.getSpolyf()).toHexString() + + "\n" + + "g=" + + Bytes.wrap(priv.getG()).toHexString() + + "\n" + + "F=" + + Bytes.wrap(priv.getSpolyF()).toHexString() + + "\n" + + "pk=" + + Bytes.wrap(PqV2Fixture.publicKey(i)).toHexString() + + "\n"); + System.setProperty("aere.falcon.key", key0Path.toAbsolutePath().toString()); + } + } + manifest.append("}},\"alloc\":{\"0000000000000000000000000000000000000fa1\":{\"storage\":{\"0x") + .append("0".repeat(64)) + .append("\":\"0x"); + final byte[] anchoredHash = new byte[32]; + kd.doFinal(anchoredHash, 0); + manifest.append(Bytes.wrap(anchoredHash).toUnprefixedHexString()).append("\"}}}}"); + // The eighth validator: a perfectly ordinary node that an ordinary vote admits, and that has no + // Falcon key because the manifest that is anchored on chain was written for seven. It is row N + // of the same probe pool, so it is a REAL address with a REAL key behind it that simply was not + // filed in the registry - which is the situation this test is about. + newcomer = PqV2Fixture.address(N); + + genesisPath = tmp.resolve("genesis-registry.json"); + Files.writeString(genesisPath, manifest.toString()); + System.setProperty("aere.falcon.genesis", genesisPath.toAbsolutePath().toString()); + System.setProperty("aere.falcon.attachBlock", Long.toString(ATTACH)); + + resetFalconSingleton(); + PqSealCache.instance().clear(); + PqAnchorProducer.useConfigForTesting( + new PqAnchorConfig(CHAIN_ID, H, Map.of(H, 0, K_AT, K), OptionalInt.empty(), false)); + } + + @AfterEach + public void tearDown() throws Exception { + System.clearProperty("aere.falcon.genesis"); + System.clearProperty("aere.falcon.key"); + System.clearProperty("aere.falcon.attachBlock"); + resetFalconSingleton(); + PqSealCache.instance().clear(); + PqAnchorProducer.useConfigForTesting(null); + } + + // ----------------------------------------------------------------------------------------- + // 1. Negative control for the fixture itself. + // ----------------------------------------------------------------------------------------- + + @Test + public void baselineTheGateIsArmedWhileTheRegistryCoversTheSet() { + final FalconSealSupport pqc = FalconSealSupport.instance(); + assertThat(pqc.genesisAnchored()) + .describedAs("the fixture must load a GENESIS-ANCHORED registry, or nothing below means anything") + .isTrue(); + assertThat(pqc.addressBound()).isTrue(); + assertThat(pqc.registrySize()).isEqualTo(N); + assertThat(pqc.signingEnabled()).isTrue(); + + pqc.observeValidators(H + 1L, keyedValidators); + assertThat(pqc.attachmentArmed(H + 2L)) + .describedAs("with the registry covering all %d validators the gate must be ARMED", N) + .isTrue(); + final Optional seal = pqc.sign(H + 2L, message(H + 1L)); + assertThat(seal).isPresent(); + assertThat(pqc.verify(0, message(H + 1L), seal.get().getSignature())).isTrue(); + } + + // ----------------------------------------------------------------------------------------- + // 2. The concern on its own stimulus: one validator added. + // ----------------------------------------------------------------------------------------- + + @Test + public void addingOneValidatorMustNotTurnSealAttachmentOff() { + final FalconSealSupport pqc = FalconSealSupport.instance(); + + pqc.observeValidators(H + 1L, keyedValidators); + assertThat(pqc.attachmentArmed(H + 2L)) + .describedAs("armed before the set changes") + .isTrue(); + + final List
afterVote = new ArrayList<>(keyedValidators); + afterVote.add(newcomer); + pqc.observeValidators(H + 2L, afterVote); + + assertThat(pqc.attachmentArmed(H + 3L)) + .describedAs( + "one ordinary add-validator vote must not switch Falcon seal ATTACHMENT off. It is a " + + "fleet-wide fact, so it turns off on EVERY node at the same height, and with no " + + "node attaching no proposer can gather the K=%d seals an anchored header needs.", + K) + .isTrue(); + assertThat(pqc.sign(H + 3L, message(H + 2L))) + .describedAs("and the seal must actually be produced, not merely permitted") + .isPresent(); + } + + // ----------------------------------------------------------------------------------------- + // 3. The same halt through a restart, which needs no vote at all. + // ----------------------------------------------------------------------------------------- + + @Test + public void aNodeStartedAboveTheAnchorHeightMustStillAttach() { + // No observeValidators call at all. Above H the only caller of it, FalconSealValidationRule, + // returns at its retirement gate before observing, so this is exactly the state of a node whose + // chain head is already above H when the process starts. + final FalconSealSupport pqc = FalconSealSupport.instance(); + assertThat(pqc.attachmentArmed(H + 50L)) + .describedAs( + "a node that starts above the anchor height has observed no validator set, and " + + "\"I could not measure the set\" must not be answered with \"stop signing\": that " + + "answer is the halt. Restarting a node is an ordinary operation.") + .isTrue(); + assertThat(pqc.sign(H + 50L, message(H + 49L))).isPresent(); + } + + // ----------------------------------------------------------------------------------------- + // 4. The causal link, measured in BOTH directions. + // ----------------------------------------------------------------------------------------- + + @Test + public void theProposerRefusesWhenNothingWasAttachedAndProposesWhenSomethingWas() { + final BlockHeader parent = new BlockHeaderTestFixture().number(K_AT + 20L).buildHeader(); + final ProtocolContext context = contextWith(keyedValidators); + final BftExtraData base = + new BftExtraData( + Bytes32.ZERO, + Collections.emptyList(), + Optional.empty(), + 0, + keyedValidators, + Collections.emptyList()); + + // (a) nothing heard, because nothing was attached: the proposer cannot propose. + assertThatThrownBy(() -> PqAnchorProducer.apply(base, parent, context)) + .isInstanceOf(PqAnchorNotReadyException.class); + + // (b) five real Falcon seals heard: the same proposer, same inputs, produces a certificate. + // Without this half, (a) would be satisfied by a producer that always refuses. + final Bytes32 m = + PqAnchor.commitMessage(CHAIN_ID, parent.getNumber(), parent.getHash().getBytes()); + final List 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 attachment repair deliberately did NOT touch. + * + * @throws Exception if the fixture cannot be rebuilt + */ + @Test + public void theGateStillRefusesEverythingItMustStillRefuse() throws Exception { + // (1) below the configured attachment height. + assertThat(FalconSealSupport.instance().attachmentArmed(ATTACH - 1L)) + .describedAs("below the attachment height nothing may be attached") + .isFalse(); + + // (2) no attachment height configured at all, which is the default and the state of chain 2800. + System.clearProperty("aere.falcon.attachBlock"); + resetFalconSingleton(); + assertThat(FalconSealSupport.instance().attachmentArmed(H + 5L)) + .describedAs("with aere.falcon.attachBlock unset a node holding a key attaches nothing") + .isFalse(); + + // (3) attachment height reached, but no anchored registry to be checked against. + System.setProperty("aere.falcon.attachBlock", Long.toString(ATTACH)); + System.clearProperty("aere.falcon.genesis"); + resetFalconSingleton(); + assertThat(FalconSealSupport.instance().attachmentArmed(H + 5L)) + .describedAs("a seal is never emitted against a registry that cannot be checked") + .isFalse(); + + // (4) anchored, address-bound registry, but it does not bind THIS node's index. The seal would + // be unattributable, so the seals rule would refuse the whole header carrying it. + System.setProperty("aere.falcon.genesis", genesisPath.toAbsolutePath().toString()); + final Path strayKey = tmp.resolve("falcon-key-stray.properties"); + Files.writeString(strayKey, Files.readString(key0Path).replace("index=0", "index=42")); + System.setProperty("aere.falcon.key", strayKey.toAbsolutePath().toString()); + resetFalconSingleton(); + final FalconSealSupport stray = FalconSealSupport.instance(); + assertThat(stray.genesisAnchored()) + .describedAs("the registry must still load, or (4) would pass for the wrong reason") + .isTrue(); + assertThat(stray.attachmentArmed(H + 5L)) + .describedAs("an index the anchored registry does not bind must not attach") + .isFalse(); + assertThat(stray.sign(H + 5L, message(H + 4L))).isEmpty(); + } + + // ----------------------------------------------------------------------------------------- + // Helpers. + // ----------------------------------------------------------------------------------------- + + private static Bytes32 message(final long blockNumber) { + return PqAnchor.commitMessage(CHAIN_ID, blockNumber, Bytes32.leftPad(Bytes.of(1))); + } + + private static byte[] falconSign(final FalconPrivateKeyParameters key, final Bytes32 m) { + final FalconSigner signer = new FalconSigner(); + signer.init(true, key); + return signer.generateSignature(m.toArray()); + } + + private static ProtocolContext contextWith(final Collection

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/PqInertBinaryTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqInertBinaryTest.java new file mode 100644 index 0000000..f8a26fe --- /dev/null +++ b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqInertBinaryTest.java @@ -0,0 +1,475 @@ +/* + * Copyright contributors to Besu / Aere Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.withSettings; + +import org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer; +import org.hyperledger.besu.consensus.common.validator.ValidatorProvider; +import org.hyperledger.besu.datatypes.Address; +import org.hyperledger.besu.ethereum.ProtocolContext; +import org.hyperledger.besu.ethereum.core.BlockHeader; +import org.hyperledger.besu.ethereum.core.BlockHeaderTestFixture; + +import java.lang.reflect.Field; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.Properties; +import java.util.stream.Collectors; + +import org.apache.logging.log4j.Level; +import org.apache.tuweni.bytes.Bytes32; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.quality.Strictness; + +/** + * THE COMPATIBILITY PROPERTY, which is the one that decides whether any of this can be shipped. + * + *

The three anchor patches plus the registry-binding arming gate are meant to travel onto the + * seven live boxes BEFORE the activation height, so that the fleet is already running the binary + * when the height arrives and activation is a restart-free event. That plan is only sound if a node + * holding this binary and NO {@code aere.pq.*} configuration is indistinguishable from one holding + * the binary it replaces: it must start, it must produce blocks, and it must not say a word about + * an anchor that is not armed. If that property is lost, the whole package is unusable regardless + * of how correct the anchor logic is, because it could not be staged. + * + *

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 PqInertBinaryTest { + + /** + * 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)); + // THIS WAS `H + ":0"` UNTIL 2026-08-07, and the threshold floor added the same day refused it. + // Refused correctly, and it is worth reading twice: this positive control only needed "an + // armed anchor", so it had used the shortest form that got past the loader, and that form + // was exactly the TOOTHLESS one, a schedule whose threshold is zero for ever. In other + // words our own test property had picked, without meaning to, precisely the dangerous + // configuration, and would have passed it on as "this is how the anchor is armed" to anyone + // who copied from here. + // + // The warm-up window is kept, because that is the form the activation plan recommends, and + // the step that gives the schedule teeth is added. The positive control measures the same + // thing: the captor hears the producer's arming line. + System.setProperty(PqAnchorConfig.PROPERTY_MIN_SEALS, H + ":0," + (H + 1000L) + ":3"); + + final LogCaptor captor = LogCaptor.attach(); + try { + PqAnchorProducer.config(); + } finally { + captor.detach(); + } + + assertThat(captor.anchorLines()) + .describedAs( + "the captor must be able to hear the anchor, or the silence next door is worthless") + .isNotEmpty(); + assertThat(String.join("\n", captor.anchorLines())).contains("producer armed"); + } + + // --------------------------------------------------------------------------------------------- + // Helpers. + // --------------------------------------------------------------------------------------------- + + /** Extra data with no anchor digest, i.e. exactly what a pre-fork proposer builds. */ + private static BftExtraData plainExtraData() { + return new BftExtraData( + Bytes32.ZERO, + Collections.emptyList(), + Optional.empty(), + 0, + Collections.emptyList(), + Collections.emptyList()); + } + + private static ProtocolContext contextWith(final Collection

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( + PqInertBinaryTest.class.getClassLoader(), new Class[] {appenderCls}, handler); + + final Level prior = (Level) loggerConfigCls.getMethod("getLevel").invoke(rootLoggerConfig); + loggerConfigCls + .getMethod("addAppender", appenderCls, Level.class, filterCls) + .invoke(rootLoggerConfig, appender, Level.ALL, null); + loggerConfigCls.getMethod("setLevel", Level.class).invoke(rootLoggerConfig, Level.ALL); + ctxCls.getMethod("updateLoggers").invoke(ctx); + + final LogCaptor captor = new LogCaptor(rootLoggerConfig, ctx, prior); + captor.bind(sink); + return captor; + } + + /** The proxy writes into its own list; this keeps a single reading surface. */ + private List 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/PqRegistryBindingTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqRegistryBindingTest.java new file mode 100644 index 0000000..d902552 --- /dev/null +++ b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqRegistryBindingTest.java @@ -0,0 +1,595 @@ +/* + * Copyright contributors to Besu. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.hyperledger.besu.crypto.KeyPair; +import org.hyperledger.besu.crypto.SECPSignature; +import org.hyperledger.besu.crypto.SecureRandomProvider; +import org.hyperledger.besu.crypto.SignatureAlgorithm; +import org.hyperledger.besu.crypto.SignatureAlgorithmFactory; +import org.hyperledger.besu.datatypes.Address; +import org.hyperledger.besu.ethereum.core.Util; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.List; + +import org.apache.tuweni.bytes.Bytes; +import org.apache.tuweni.bytes.Bytes32; +import org.bouncycastle.crypto.AsymmetricCipherKeyPair; +import org.bouncycastle.pqc.crypto.falcon.FalconKeyGenerationParameters; +import org.bouncycastle.pqc.crypto.falcon.FalconKeyPairGenerator; +import org.bouncycastle.pqc.crypto.falcon.FalconParameters; +import org.bouncycastle.pqc.crypto.falcon.FalconPrivateKeyParameters; +import org.bouncycastle.pqc.crypto.falcon.FalconPublicKeyParameters; +import org.bouncycastle.pqc.crypto.falcon.FalconSigner; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * AERE REGISTRY BINDING. The registry says validator i has Falcon key k. Nothing said validator i + * ever agreed to that, or that anybody holds k's secret. + * + *

WHAT WAS MEASURED BEFORE THIS TEST EXISTED, on the real verification path, with the startup + * gate reporting MATCH and the header ACCEPTED every time: + * + *

    + *
  • T3, one row's address repointed: a seal made by validator 0's key credited to validator 1. + *
  • T4, one key at two indices: two seals from a SINGLE private key satisfy a threshold of 2. + *
  • T6A, two rows' keys swapped: accepted, and the registry has NO duplicate key and NO + * duplicate address, so no uniqueness rule can ever see it. + *
  • T6B, two rows' addresses swapped: accepted, again with nothing duplicated, and every seal + * attributed to the wrong validator. + *
+ * + *

T6A and T6B are the reason this test class needs signatures and not just a set. Uniqueness + * makes the THRESHOLD honest again; only a signature by the validator's own consensus key makes the + * ATTRIBUTION honest. + * + *

KEYS. Every Falcon and ECDSA key here is generated in memory, used inside one test method, and + * never written anywhere but a JUnit temporary directory. Nothing in this file touches the key + * ceremony or the controls that stand in front of real key generation. + */ +class PqRegistryBindingTest { + + private static final long CHAIN_ID = 2800L; + private static final long OTHER_CHAIN_ID = 442807L; + private static final long BIND_HEIGHT = 12_000_000L; + private static final int N = 4; + + /** One validator's material: a Falcon pair and the ECDSA consensus key that must vouch for it. */ + private record Holder( + byte[] falconPublicKey, FalconPrivateKeyParameters falconPrivate, KeyPair ecdsa) { + Address address() { + return Util.publicKeyToAddress(ecdsa.getPublicKey()); + } + } + + private static List holders; + private static SignatureAlgorithm ecdsa; + + @BeforeAll + static void generateProbeKeys() { + ecdsa = SignatureAlgorithmFactory.getInstance(); + final SecureRandom rnd = SecureRandomProvider.publicSecureRandom(); + final FalconKeyPairGenerator gen = new FalconKeyPairGenerator(); + gen.init(new FalconKeyGenerationParameters(rnd, FalconParameters.falcon_512)); + holders = new ArrayList<>(); + for (int i = 0; i < N; i++) { + final AsymmetricCipherKeyPair kp = gen.generateKeyPair(); + holders.add( + new Holder( + ((FalconPublicKeyParameters) kp.getPublic()).getH(), + (FalconPrivateKeyParameters) kp.getPrivate(), + ecdsa.generateKeyPair())); + } + } + + // =================================================================================== + // The positive control. Without this, every refusal below proves only that the loader + // refuses everything. + // =================================================================================== + + @Test + void anHonestlySignedRegistryLoadsAndIsProofBound(@TempDir final Path dir) throws IOException { + final Path f = writeRegistry(dir, "honest.properties", rows(), CHAIN_ID, BIND_HEIGHT); + + final PqRegistryHash.Registry r = PqRegistryHash.loadAuto(f); + + assertThat(r.count()).isEqualTo(N); + assertThat(r.addressBound()).isTrue(); + assertThat(r.proofBound()).isTrue(); + assertThat(r.declaredChainId()).isEqualTo(CHAIN_ID); + assertThat(r.bindHeight()).isEqualTo(BIND_HEIGHT); + for (final PqRegistryHash.Entry e : r.entries()) { + assertThat(e.possessionProof()).isNotNull().isNotEmpty(); + assertThat(e.claimProof()).hasSize(65); + } + } + + // =================================================================================== + // T3 and T6: attribution. These are the cases NO uniqueness rule can catch. + // =================================================================================== + + @Test + void t6aSwappingTwoRowsPublicKeysIsRefused(@TempDir final Path dir) throws IOException { + final List rows = rows(); + final byte[] pk0 = rows.get(0).publicKey; + rows.get(0).publicKey = rows.get(2).publicKey; + rows.get(2).publicKey = pk0; + final Path f = writeRegistry(dir, "t6a.properties", rows, CHAIN_ID, BIND_HEIGHT); + + // The registry still has four distinct keys and four distinct addresses. Measured on the real + // path on 2026-08-06, exactly this file produced an ACCEPTED header. + assertThat(distinct(rows, true)).isEqualTo(N); + assertThat(distinct(rows, false)).isEqualTo(N); + + assertThatThrownBy(() -> PqRegistryHash.loadAuto(f)) + .isInstanceOf(PqRegistryHash.RegistryConfigException.class) + .hasMessageContaining("AERE-PQC-REG-BIND-03"); + } + + @Test + void t6bSwappingTwoRowsAddressesIsRefused(@TempDir final Path dir) throws IOException { + final List rows = rows(); + final Address a0 = rows.get(0).address; + rows.get(0).address = rows.get(2).address; + rows.get(2).address = a0; + final Path f = writeRegistry(dir, "t6b.properties", rows, CHAIN_ID, BIND_HEIGHT); + + assertThat(distinct(rows, true)).isEqualTo(N); + assertThat(distinct(rows, false)).isEqualTo(N); + + assertThatThrownBy(() -> PqRegistryHash.loadAuto(f)) + .isInstanceOf(PqRegistryHash.RegistryConfigException.class) + .hasMessageContaining("AERE-PQC-REG-BIND-03"); + } + + @Test + void t3AKeyFiledUnderAnotherValidatorsAddressIsRefused(@TempDir final Path dir) + throws IOException { + // The exact T3 shape, with the proofs re-signed by the party that WOULD hold them at a key + // ceremony: the registry writer, who has every FALCON secret. It signs a perfectly valid + // possession proof for key 0 sitting under validator 1's address. Only the ECDSA claim, which + // needs validator 1's consensus key, stops it - and that is the whole argument for why a Falcon + // proof-of-possession alone does not repair the attribution gap. + final List rows = rows(); + rows.get(0).address = holders.get(1).address(); + rows.get(1).address = holders.get(3).address(); // keep addresses distinct + rows.get(3).address = holders.get(0).address(); + resignAsRegistryWriter(rows); + final Path f = writeRegistry(dir, "t3.properties", rows, CHAIN_ID, BIND_HEIGHT); + + assertThat(distinct(rows, false)).isEqualTo(N); + + assertThatThrownBy(() -> PqRegistryHash.loadAuto(f)) + .isInstanceOf(PqRegistryHash.RegistryConfigException.class) + .hasMessageContaining("AERE-PQC-REG-BIND-04"); + } + + @Test + void aFalconPossessionProofAloneDoesNotBindTheAddress(@TempDir final Path dir) throws IOException { + // Stated as a test so nobody can quietly ship "we added proof of possession" as the repair. + // The possession proof below is genuine and verifies; the row is still a lie. + final List rows = rows(); + rows.get(0).address = holders.get(1).address(); + rows.get(1).address = holders.get(0).address(); + resignAsRegistryWriter(rows); + final Path f = writeRegistry(dir, "pop-only.properties", rows, CHAIN_ID, BIND_HEIGHT); + + final Row r0 = rows.get(0); + final Bytes32 popDigest = + PqRegistryBinding.possessionDigest( + CHAIN_ID, BIND_HEIGHT, N, 0, r0.address.getBytes().toArray(), r0.publicKey); + assertThat(PqRegistryBinding.verifyPossession(r0.publicKey, popDigest, r0.possession)) + .as("the Falcon possession proof is genuine and verifies") + .isTrue(); + + assertThatThrownBy(() -> PqRegistryHash.loadAuto(f)) + .as("and the registry is still refused, by the ECDSA half") + .isInstanceOf(PqRegistryHash.RegistryConfigException.class) + .hasMessageContaining("AERE-PQC-REG-BIND-04"); + } + + // =================================================================================== + // T4 and T5: the threshold. Uniqueness, no signature involved. + // =================================================================================== + + @Test + void t4TheSameKeyAtTwoIndicesIsRefused(@TempDir final Path dir) throws IOException { + final List rows = rows(); + rows.get(2).publicKey = rows.get(0).publicKey; + final Path f = writeRegistry(dir, "t4.properties", rows, CHAIN_ID, BIND_HEIGHT); + + assertThatThrownBy(() -> PqRegistryHash.loadAuto(f)) + .isInstanceOf(PqRegistryHash.RegistryConfigException.class) + .hasMessageContaining("AERE-PQC-REG-LOAD-19"); + } + + @Test + void t5TheSameAddressAtTwoIndicesIsRefused(@TempDir final Path dir) throws IOException { + final List rows = rows(); + rows.get(2).address = rows.get(0).address; + final Path f = writeRegistry(dir, "t5.properties", rows, CHAIN_ID, BIND_HEIGHT); + + assertThatThrownBy(() -> PqRegistryHash.loadAuto(f)) + .isInstanceOf(PqRegistryHash.RegistryConfigException.class) + .hasMessageContaining("AERE-PQC-REG-LOAD-20"); + } + + @Test + void t4AndT5AreRefusedInALEGACYRegistryToo(@TempDir final Path dir) throws IOException { + // The uniqueness half must not be reachable only through the new format, or a v1 file remains + // the way to ship a duplicated key. This is the ONE case where the before/after can be measured + // on the SAME bytes by the unpatched binary, and it is: the probe loads this file on the old + // tree and refuses it on the new one. + final StringBuilder b = new StringBuilder("count=" + N + "\n"); + for (int i = 0; i < N; i++) { + b.append(i).append('=').append(hex(holders.get(i == 2 ? 0 : i).falconPublicKey())).append('\n'); + b.append(i).append(".addr=").append(hex(holders.get(i).address().getBytes().toArray())).append('\n'); + } + final Path f = dir.resolve("legacy-dup.properties"); + Files.writeString(f, b.toString(), StandardCharsets.UTF_8); + + assertThatThrownBy(() -> PqRegistryHash.loadAuto(f)) + .isInstanceOf(PqRegistryHash.RegistryConfigException.class) + .hasMessageContaining("AERE-PQC-REG-LOAD-19"); + } + + // =================================================================================== + // Replay: the fields that are inside the signed message earn their place here. + // =================================================================================== + + @Test + void aProofLiftedToAnotherIndexIsRefused(@TempDir final Path dir) throws IOException { + final List rows = rows(); + final byte[] pop0 = rows.get(0).possession; + rows.get(0).possession = rows.get(1).possession; + rows.get(1).possession = pop0; + final Path f = writeRegistry(dir, "lift.properties", rows, CHAIN_ID, BIND_HEIGHT); + + assertThatThrownBy(() -> PqRegistryHash.loadAuto(f)) + .isInstanceOf(PqRegistryHash.RegistryConfigException.class) + .hasMessageContaining("AERE-PQC-REG-BIND-03"); + } + + @Test + void aRegistrySignedForAnotherHeightIsRefused(@TempDir final Path dir) throws IOException { + final Path f = writeRegistry(dir, "height.properties", rows(), CHAIN_ID, BIND_HEIGHT + 1); + + assertThatThrownBy(() -> PqRegistryHash.loadAuto(f)) + .isInstanceOf(PqRegistryHash.RegistryConfigException.class) + .hasMessageContaining("AERE-PQC-REG-BIND-03"); + } + + @Test + void aRegistryLiftedFromTheScratchChainIsRefusedAtTheGate(@TempDir final Path dir) + throws IOException { + // Signed for 442807, declared as 442807, internally perfect. It loads - the proofs really do + // verify - and the GATE refuses it, because the node is on 2800. + final Path f = + writeRegistry(dir, "scratch.properties", rows(OTHER_CHAIN_ID), OTHER_CHAIN_ID, BIND_HEIGHT); + final PqRegistryHash.Registry r = PqRegistryHash.loadAuto(f); + assertThat(r.proofBound()).isTrue(); + + assertThatThrownBy( + () -> + PqRegistryHash.verifyOrAbort( + PqRegistryHash.emptySchedule(), r, BIND_HEIGHT, CHAIN_ID)) + .isInstanceOf(PqRegistryHash.RegistryConfigException.class) + .hasMessageContaining("AERE-PQC-REG-BIND-07"); + } + + @Test + void aTruncatedProofIsRefused(@TempDir final Path dir) throws IOException { + final List rows = rows(); + final byte[] cut = new byte[rows.get(1).possession.length - 8]; + System.arraycopy(rows.get(1).possession, 0, cut, 0, cut.length); + rows.get(1).possession = cut; + final Path f = writeRegistry(dir, "cut.properties", rows, CHAIN_ID, BIND_HEIGHT); + + assertThatThrownBy(() -> PqRegistryHash.loadAuto(f)) + .isInstanceOf(PqRegistryHash.RegistryConfigException.class) + .hasMessageContaining("AERE-PQC-REG-BIND-03"); + } + + @Test + void aClaimSignedByTheWrongValidatorIsRefused(@TempDir final Path dir) throws IOException { + final List rows = rows(); + rows.get(1).claim = + signClaim(holders.get(2).ecdsa(), CHAIN_ID, BIND_HEIGHT, 1, rows.get(1)); + final Path f = writeRegistry(dir, "wrongsigner.properties", rows, CHAIN_ID, BIND_HEIGHT); + + assertThatThrownBy(() -> PqRegistryHash.loadAuto(f)) + .isInstanceOf(PqRegistryHash.RegistryConfigException.class) + .hasMessageContaining("AERE-PQC-REG-BIND-04"); + } + + @Test + void aPartiallyBoundRegistryIsRefused(@TempDir final Path dir) throws IOException { + final List rows = rows(); + rows.get(3).claim = null; + final Path f = writeRegistry(dir, "partial.properties", rows, CHAIN_ID, BIND_HEIGHT); + + assertThatThrownBy(() -> PqRegistryHash.loadAuto(f)) + .isInstanceOf(PqRegistryHash.RegistryConfigException.class) + .hasMessageContaining("AERE-PQC-REG-LOAD-21"); + } + + @Test + void proofsWithoutAChainIdOrBindHeightAreRefused(@TempDir final Path dir) throws IOException { + final Path f = writeRegistry(dir, "noheader.properties", rows(), -1L, -1L); + + assertThatThrownBy(() -> PqRegistryHash.loadAuto(f)) + .isInstanceOf(PqRegistryHash.RegistryConfigException.class) + .hasMessageContaining("AERE-PQC-REG-LOAD-22"); + } + + // =================================================================================== + // The hash must COVER the proofs, or they are advisory and can be stripped. + // =================================================================================== + + @Test + void theCanonicalHashCoversBothProofsOfEveryRow(@TempDir final Path dir) throws IOException { + final List rows = rows(); + final PqRegistryHash.Registry r = + PqRegistryHash.loadAuto(writeRegistry(dir, "cover.properties", rows, CHAIN_ID, BIND_HEIGHT)); + + final Bytes pre = Bytes.wrap(PqRegistryHash.canonicalPreimageV2(r, CHAIN_ID)); + for (final Row row : rows) { + assertThat(indexOf(pre, Bytes.wrap(row.possession))) + .as("possession proof is inside the hashed pre-image") + .isNotNegative(); + assertThat(indexOf(pre, Bytes.wrap(row.claim))) + .as("claim is inside the hashed pre-image") + .isNotNegative(); + } + assertThat(PqRegistryHash.hashFor(r, CHAIN_ID)).isEqualTo(PqRegistryHash.hashV2(r, CHAIN_ID)); + } + + @Test + void strippingTheProofsChangesTheHashSoADowngradeCannotSatisfyASchedule(@TempDir final Path dir) + throws IOException { + final List rows = rows(); + final PqRegistryHash.Registry bound = + PqRegistryHash.loadAuto(writeRegistry(dir, "bound.properties", rows, CHAIN_ID, BIND_HEIGHT)); + + // The same rows with the proof lines deleted: what an attacker writes to make the check + // disappear. It must not hash to the value genesis anchored. + final StringBuilder b = new StringBuilder("count=" + N + "\n"); + for (int i = 0; i < N; i++) { + b.append(i).append('=').append(hex(rows.get(i).publicKey)).append('\n'); + b.append(i).append(".addr=").append(hex(rows.get(i).address.getBytes().toArray())).append('\n'); + } + final Path stripped = dir.resolve("stripped.properties"); + Files.writeString(stripped, b.toString(), StandardCharsets.UTF_8); + final PqRegistryHash.Registry v1 = PqRegistryHash.loadAuto(stripped); + + assertThat(v1.proofBound()).isFalse(); + assertThat(PqRegistryHash.hashFor(v1, CHAIN_ID)) + .isNotEqualTo(PqRegistryHash.hashFor(bound, CHAIN_ID)); + + final PqRegistryHash.Schedule schedule = + PqRegistryHash.parseSchedule( + scheduleNode(PqRegistryHash.hashFor(bound, CHAIN_ID)), "test"); + assertThat(PqRegistryHash.matchesAt(schedule, bound, BIND_HEIGHT, CHAIN_ID)).isTrue(); + assertThat(PqRegistryHash.matchesAt(schedule, v1, BIND_HEIGHT, CHAIN_ID)).isFalse(); + } + + // =================================================================================== + // Domain separation: a possession proof must never be usable as an anchor seal. + // =================================================================================== + + @Test + void aPossessionProofIsNotASealForTheSameHeight(@TempDir final Path dir) throws IOException { + final List rows = rows(); + final Row r0 = rows.get(0); + final Bytes32 pop = + PqRegistryBinding.possessionDigest( + CHAIN_ID, BIND_HEIGHT, N, 0, r0.address.getBytes().toArray(), r0.publicKey); + final Bytes32 commit = + PqAnchor.commitMessage(CHAIN_ID, BIND_HEIGHT, Bytes32.repeat((byte) 0x7e)); + + assertThat(pop).isNotEqualTo(commit); + // The tag is at offset 0 of the pre-image, so the two message spaces cannot overlap by + // construction rather than by luck. + final byte[] pre = + PqRegistryBinding.bindingPreimage( + CHAIN_ID, BIND_HEIGHT, N, 0, r0.address.getBytes().toArray(), r0.publicKey); + assertThat(new String(pre, 0, PqRegistryBinding.DOMAIN_POSSESSION.length(), StandardCharsets.US_ASCII)) + .isEqualTo(PqRegistryBinding.DOMAIN_POSSESSION); + // A seal signed over the COMMIT message does not verify as a possession proof. + final byte[] sealOverCommit = falconSign(holders.get(0).falconPrivate(), commit); + assertThat(PqRegistryBinding.verifyPossession(r0.publicKey, pop, sealOverCommit)).isFalse(); + // ... and the possession proof does not verify as a seal. + assertThat(PqRegistryBinding.verifyPossession(r0.publicKey, commit, r0.possession)).isFalse(); + assertThat(dir).exists(); + } + + // =================================================================================== + // The arming precondition. + // =================================================================================== + + @Test + void armingOverAnUnboundRegistryIsRefused(@TempDir final Path dir) throws IOException { + final StringBuilder b = new StringBuilder("count=" + N + "\n"); + for (int i = 0; i < N; i++) { + b.append(i).append('=').append(hex(holders.get(i).falconPublicKey())).append('\n'); + b.append(i).append(".addr=").append(hex(holders.get(i).address().getBytes().toArray())).append('\n'); + } + final Path f = dir.resolve("unbound.properties"); + Files.writeString(f, b.toString(), StandardCharsets.UTF_8); + final PqRegistryHash.Registry v1 = PqRegistryHash.loadAuto(f); + + assertThatThrownBy(() -> PqRegistryHash.requireBindingsOrThrow(v1)) + .isInstanceOf(PqRegistryHash.RegistryConfigException.class) + .hasMessageContaining("AERE-PQC-REG-ARM-02"); + + final PqRegistryHash.Registry v2 = + PqRegistryHash.loadAuto(writeRegistry(dir, "armed.properties", rows(), CHAIN_ID, BIND_HEIGHT)); + PqRegistryHash.requireBindingsOrThrow(v2); // must not throw + } + + // =================================================================================== + // Fixtures + // =================================================================================== + + private static final class Row { + private byte[] publicKey; + private Address address; + private byte[] possession; + private byte[] claim; + } + + /** Honest rows for CHAIN_ID at BIND_HEIGHT, every proof signed by the party that should sign it. */ + private static List rows() { + return rows(CHAIN_ID); + } + + private static List rows(final long chainId) { + final List out = new ArrayList<>(); + for (int i = 0; i < N; i++) { + final Row r = new Row(); + r.publicKey = holders.get(i).falconPublicKey(); + r.address = holders.get(i).address(); + out.add(r); + } + for (int i = 0; i < N; i++) { + final Row r = out.get(i); + r.possession = + falconSign( + holders.get(i).falconPrivate(), + PqRegistryBinding.possessionDigest( + chainId, BIND_HEIGHT, N, i, r.address.getBytes().toArray(), r.publicKey)); + r.claim = signClaim(holders.get(i).ecdsa(), chainId, BIND_HEIGHT, i, r); + } + return out; + } + + /** + * Re-sign every row the way THE REGISTRY WRITER would at a key ceremony: it holds every FALCON + * secret, so it can always produce a valid possession proof for whatever row it just wrote. The + * ECDSA claim it can produce is the one belonging to the holder of the key on that row, never the + * one belonging to the address it filed the key under. That gap is the whole of the attribution + * problem this class exists for, and it is why the Falcon half alone repairs nothing. + */ + private static void resignAsRegistryWriter(final List rows) { + for (int i = 0; i < rows.size(); i++) { + final Row r = rows.get(i); + final Holder owner = ownerOfKey(r.publicKey); + r.possession = + falconSign( + owner.falconPrivate(), + PqRegistryBinding.possessionDigest( + CHAIN_ID, BIND_HEIGHT, N, i, r.address.getBytes().toArray(), r.publicKey)); + r.claim = signClaim(owner.ecdsa(), CHAIN_ID, BIND_HEIGHT, i, r); + } + } + + private static Holder ownerOfKey(final byte[] publicKey) { + for (final Holder h : holders) { + if (java.util.Arrays.equals(h.falconPublicKey(), publicKey)) { + return h; + } + } + throw new IllegalStateException("no probe holder owns that key"); + } + + private static byte[] signClaim( + final KeyPair kp, final long chainId, final long height, final int index, final Row r) { + final Bytes32 d = + PqRegistryBinding.claimDigest( + chainId, height, N, index, r.address.getBytes().toArray(), r.publicKey); + final SECPSignature s = ecdsa.sign(d, kp); + return s.encodedBytes().toArrayUnsafe(); + } + + private static byte[] falconSign(final FalconPrivateKeyParameters priv, final Bytes32 message) { + final FalconSigner signer = new FalconSigner(); + signer.init(true, priv); + return signer.generateSignature(message.toArray()); + } + + private static Path writeRegistry( + final Path dir, + final String name, + final List rows, + final long chainId, + final long bindHeight) + throws IOException { + final StringBuilder b = new StringBuilder(); + b.append("formatVersion=").append(PqRegistryBinding.FORMAT_VERSION).append('\n'); + if (chainId >= 0) { + b.append("chainId=").append(chainId).append('\n'); + } + if (bindHeight >= 0) { + b.append("bindHeight=").append(bindHeight).append('\n'); + } + b.append("count=").append(rows.size()).append('\n'); + for (int i = 0; i < rows.size(); i++) { + final Row r = rows.get(i); + b.append(i).append('=').append(hex(r.publicKey)).append('\n'); + b.append(i).append(".addr=").append(hex(r.address.getBytes().toArray())).append('\n'); + if (r.possession != null) { + b.append(i).append(".pop=").append(hex(r.possession)).append('\n'); + } + if (r.claim != null) { + b.append(i).append(".claim=").append(hex(r.claim)).append('\n'); + } + } + final Path f = dir.resolve(name); + Files.writeString(f, b.toString(), StandardCharsets.UTF_8); + return f; + } + + private static long distinct(final List rows, final boolean keys) { + return rows.stream() + .map(r -> keys ? hex(r.publicKey) : hex(r.address.getBytes().toArray())) + .distinct() + .count(); + } + + private static int indexOf(final Bytes haystack, final Bytes needle) { + for (int i = 0; i + needle.size() <= haystack.size(); i++) { + if (haystack.slice(i, needle.size()).equals(needle)) { + return i; + } + } + return -1; + } + + private static com.fasterxml.jackson.databind.JsonNode scheduleNode(final String hash) { + final com.fasterxml.jackson.databind.node.ArrayNode a = + new com.fasterxml.jackson.databind.ObjectMapper().createArrayNode(); + a.addObject().put("block", BIND_HEIGHT).put("hash", "0x" + hash); + return a; + } + + private static String hex(final byte[] b) { + final StringBuilder s = new StringBuilder(b.length * 2); + for (final byte x : b) { + s.append(Character.forDigit((x >> 4) & 0xf, 16)).append(Character.forDigit(x & 0xf, 16)); + } + return s.toString(); + } +} diff --git a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqRegistryHeightRefusalTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqRegistryHeightRefusalTest.java new file mode 100644 index 0000000..7efcb1a --- /dev/null +++ b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqRegistryHeightRefusalTest.java @@ -0,0 +1,328 @@ +/* + * Copyright contributors to Besu / Aere Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer; +import org.hyperledger.besu.datatypes.Address; + +import java.lang.reflect.Field; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.OptionalInt; + +import org.apache.tuweni.bytes.Bytes; +import org.apache.tuweni.bytes.Bytes32; +import org.bouncycastle.crypto.digests.KeccakDigest; +import org.bouncycastle.pqc.crypto.falcon.FalconPrivateKeyParameters; +import org.bouncycastle.pqc.crypto.falcon.FalconSigner; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * KEY ROTATION AND REBINDING, the adversarial review of 2026-08-02, at the layer that actually + * answers the question. + * + *

WHAT THE REVIEW MEASURED. {@code PqSignerRegistry} had {@code addressForIndex(int)} and {@code + * verify(int, Bytes, Bytes)} with no height, and {@code FalconSealSupport} held ONE registry loaded + * at start-up. So a header that passed both anchor rules was REJECTED the moment index 0's Falcon + * key was rotated - same header, same parent, same validator set. + * + *

WHAT WAS REPAIRED BEFORE THIS FILE, AND WHAT WAS NOT. The height-indexed registry change gave + * the validation path {@code addressForIndexAt} / {@code verifyAt} and a height-indexed schedule. + * The measurement of 2026-08-05 found the repair INERT, for a reason that is one line long: with no + * {@code config.pqRegistryHash} in genesis - and there is none in any genesis this fleet runs - + * {@code keyAt} fell back to the registry in force AT THE HEAD, at every height. Height-aware + * signatures, head-registry answers. T2 stood exactly as measured. + * + *

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 PqRegistryHeightRefusalTest { + + /** Anchor activation height H: from here a header's Falcon certificate carries weight. */ + private static final long H = 1_000L; + + /** Height from which the staged threshold is non-zero, i.e. the fully armed regime. */ + private static final long K_AT = H + 10L; + + /** A height far above H, standing in for "a year of history above the arming height". */ + private static final long DEEP = K_AT + 5_000L; + + private static final int N = 7; + + private static final long CHAIN_ID = 220_878L; + + @TempDir private Path tmp; + + private final List 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 REGISTRY BINDING (2026-08-06): v2, proof-bound, bound at H. See PqV2Fixture for why the + // addresses are derived from real secp256k1 keys and can no longer be spelled 0xA00+i. + final KeccakDigest kd = new KeccakDigest(256); + final StringBuilder manifest = new StringBuilder(); + manifest + .append("{\"config\":{\"aereFalconRegistry\":{") + .append(PqV2Fixture.manifestHeader(N, CHAIN_ID, H)); + for (int i = 0; i < N; i++) { + privateKeys.add(PqV2Fixture.privateKey(i)); + validators.add(PqV2Fixture.address(i)); + final byte[] anchoredRow = PqV2Fixture.anchorPreimageRow(i); + kd.update(anchoredRow, 0, anchoredRow.length); + manifest.append(',').append(PqV2Fixture.manifestEntry(i, N, CHAIN_ID, H)); + } + manifest + .append("}},\"alloc\":{\"0000000000000000000000000000000000000fa1\":{\"storage\":{\"0x") + .append("0".repeat(64)) + .append("\":\"0x"); + final byte[] anchoredHash = new byte[32]; + kd.doFinal(anchoredHash, 0); + manifest.append(Bytes.wrap(anchoredHash).toUnprefixedHexString()).append("\"}}}}"); + + genesisPath = tmp.resolve("genesis-d2.json"); + Files.writeString(genesisPath, manifest.toString()); + System.setProperty("aere.falcon.genesis", genesisPath.toAbsolutePath().toString()); + + // A genuine Falcon-512 signature by index 0 over MESSAGE. Everything below asks one question of + // it: at which heights does the node agree that this is index 0's signature. + final FalconSigner signer = new FalconSigner(); + signer.init(true, privateKeys.get(0)); + sealByIndexZero = Bytes.wrap(signer.generateSignature(MESSAGE.toArray())); + + resetFalconSingleton(); + // ARMED at H. On the live fleet aere.pq.anchorBlock is unset and this whole file's subject + // does not exist; see whenTheAnchorIsNotArmedNOTHINGCHANGES. + PqAnchorProducer.useConfigForTesting( + new PqAnchorConfig(CHAIN_ID, H, Map.of(H, 0, K_AT, 3), OptionalInt.empty(), false)); + } + + @AfterEach + public void tearDown() throws Exception { + System.clearProperty("aere.falcon.genesis"); + System.clearProperty("aere.pq.genesis"); + System.clearProperty(FalconSealSupport.PROPERTY_REGISTRY_HISTORY); + resetFalconSingleton(); + PqAnchorProducer.useConfigForTesting(null); + } + + // ------------------------------------------------------------------------------------------- + // 0. The fixture itself. Without this, a green run below could mean the registry never loaded. + // ------------------------------------------------------------------------------------------- + + @Test + public void baselineTheFixtureIsGenesisAnchoredAndTheSealIsGENUINE() { + final FalconSealSupport pqc = FalconSealSupport.instance(); + assertThat(pqc.genesisAnchored()) + .describedAs("the fixture must load a GENESIS-ANCHORED registry, or nothing here means anything") + .isTrue(); + assertThat(pqc.addressBound()).isTrue(); + assertThat(pqc.verify(0, MESSAGE, sealByIndexZero)) + .describedAs("the seal must be a REAL Falcon signature under the head registry") + .isTrue(); + } + + // ------------------------------------------------------------------------------------------- + // 1. THE MEASUREMENT. Chain 2800 as it stands: armed, and no pqRegistryHash anywhere. + // ------------------------------------------------------------------------------------------- + + @Test + public void d2t2AtAndAboveTheArmingHeightWithNoScheduleTheAnswerIsRefusal() { + final FalconSealSupport pqc = FalconSealSupport.instance(); + + // No schedule was ever loaded: verifyRegistryBindingOrAbort has not run, which is the state of + // every node on chain 2800 today, because config.pqRegistryHash is in no genesis this fleet + // runs (measured 2026-08-05: a search across every deployment and monitoring configuration we + // hold returns nothing). + assertThat(pqc.verifyAtHistoric(H, 0, MESSAGE, sealByIndexZero)) + .describedAs( + "T2: at the arming height itself, a node with no height-to-registry binding must " + + "REFUSE. Before 2026-08-06 it answered from the registry in force at the HEAD, " + + "so one key rotation made every block above H unverifiable while the node " + + "reported success") + .isFalse(); + + assertThat(pqc.verifyAtHistoric(DEEP, 0, MESSAGE, sealByIndexZero)) + .describedAs("and the same, far above the arming height") + .isFalse(); + + assertThat(pqc.addressForIndexAtHistoric(DEEP, 0)) + .describedAs( + "the address half must refuse identically: PqAnchorSealsRule refuses an index it " + + "cannot bind, and a bound-by-guess address is worse than an unbound one") + .isNull(); + } + + // ------------------------------------------------------------------------------------------- + // 2. Negative control: the refusal is HEIGHT-GATED. A rule that always refuses is not a repair. + // ------------------------------------------------------------------------------------------- + + @Test + public void belowTheArmingHeightTheHeadRegistryStillAnswers() { + final FalconSealSupport pqc = FalconSealSupport.instance(); + assertThat(pqc.verifyAtHistoric(H - 1L, 0, MESSAGE, sealByIndexZero)) + .describedAs( + "one block below H nothing is being judged, so the head registry is the right answer " + + "and the 11.8 million blocks already on chain must behave exactly as before") + .isTrue(); + assertThat(pqc.verifyAtHistoric(0L, 0, MESSAGE, sealByIndexZero)).isTrue(); + assertThat(pqc.addressForIndexAtHistoric(H - 1L, 0)).isEqualTo(validators.get(0)); + } + + // ------------------------------------------------------------------------------------------- + // 3. Negative control: the refusal is ARMING-gated. This is the proof that the live fleet is + // untouched, and it is the assertion that fails first if that stops being true. + // ------------------------------------------------------------------------------------------- + + @Test + public void whenTheAnchorIsNotArmedNOTHINGCHANGES() { + PqAnchorProducer.useConfigForTesting(PqAnchorConfig.never(CHAIN_ID)); + final FalconSealSupport pqc = FalconSealSupport.instance(); + assertThat(pqc.verifyAtHistoric(DEEP, 0, MESSAGE, sealByIndexZero)) + .describedAs( + "chain 2800 today: aere.pq.anchorBlock unset, so there is no arming height, no height " + + "is at or above it, and every answer is what it was before this repair") + .isTrue(); + assertThat(pqc.addressForIndexAtHistoric(DEEP, 0)).isEqualTo(validators.get(0)); + } + + // ------------------------------------------------------------------------------------------- + // 4. Positive control: what the refusal is a refusal ABOUT. Configure the binding and the armed + // heights answer again - through the height-resolved path, not the head-registry fallback. + // ------------------------------------------------------------------------------------------- + + @Test + public void withTheScheduleConfiguredTheArmedHeightsAnswerAgain() throws Exception { + final FalconSealSupport pqc = FalconSealSupport.instance(); + final PqRegistryHash.Registry held = PqRegistryHash.loadAuto(genesisPath); + final String hash = PqRegistryHash.hashFor(held, CHAIN_ID); + + // The first scheduled entry sits EXACTLY at the arming height, which is the rule the epoch-list + // design states: below H requiredHashAt is empty and the fallback is unreachable by anything + // that decides a header. + // AERE REGISTRY BINDING (2026-08-06): the hash above is hashFor, not hashV1, because this + // fixture's registry is now v2 and hashes under a different domain tag. A schedule entry that + // names the v1 number names a registry this node does not hold. + final PqRegistryHash.Schedule schedule = scheduleFromGenesis(Map.of(H, hash)); + pqc.verifyRegistryBindingOrAbort(0L, CHAIN_ID, schedule); + + assertThat(pqc.verifyAtHistoric(DEEP, 0, MESSAGE, sealByIndexZero)) + .describedAs( + "with the epoch bound at H and the registry held, the armed heights resolve through " + + "the schedule. If this is false the refusal is not about a missing binding and " + + "the measurement above proves nothing") + .isTrue(); + assertThat(pqc.addressForIndexAtHistoric(DEEP, 0)).isEqualTo(validators.get(0)); + assertThat(pqc.verifyAtHistoric(H - 1L, 0, MESSAGE, sealByIndexZero)) + .describedAs("and below H the fallback is still the answer") + .isTrue(); + } + + // ------------------------------------------------------------------------------------------- + // 5. The case the epoch list exists FOR: an epoch this node does not hold. Refused, and named. + // ------------------------------------------------------------------------------------------- + + @Test + public void anEpochThisNodeDoesNotHoldIsRefusedAndNAMED() throws Exception { + final FalconSealSupport pqc = FalconSealSupport.instance(); + final PqRegistryHash.Registry held = PqRegistryHash.loadAuto(genesisPath); + final String hash = PqRegistryHash.hashFor(held, CHAIN_ID); + final long rotation = K_AT + 1_000L; + + // Two epochs: the one this node holds, and a rotation to a registry it was never given. This is + // the shape of "an operator rotated a compromised key and one node did not get the file". + final PqRegistryHash.Schedule schedule = + scheduleFromGenesis( + new java.util.LinkedHashMap<>( + Map.of(H, hash, rotation, "0x" + "cd".repeat(32)))); + pqc.verifyRegistryBindingOrAbort(0L, CHAIN_ID, schedule); + + assertThat(pqc.verifyAtHistoric(rotation - 1L, 0, MESSAGE, sealByIndexZero)) + .describedAs("below the rotation this node holds the epoch and answers") + .isTrue(); + assertThat(pqc.verifyAtHistoric(rotation, 0, MESSAGE, sealByIndexZero)) + .describedAs( + "at the rotation the epoch is covered by NOTHING this node holds. It stops here; it " + + "does not answer from whatever it happens to have") + .isFalse(); + assertThat(pqc.addressForIndexAtHistoric(rotation, 0)).isNull(); + } + + // ------------------------------------------------------------------------------------------- + // Helpers. + // ------------------------------------------------------------------------------------------- + + /** + * Build a schedule the way a node really gets one: written into a genesis file as {@code + * config.pqRegistryHash} and parsed back. Constructing the object directly would skip the parser, + * which is where the strictly-increasing rule lives. + * + * @param entries height to 0x-prefixed registry hash, in ascending order of height + * @return the parsed schedule + * @throws Exception when the temporary genesis cannot be written + */ + private PqRegistryHash.Schedule scheduleFromGenesis(final Map 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/PqRegistryRotationTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqRegistryRotationTest.java new file mode 100644 index 0000000..7868c88 --- /dev/null +++ b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqRegistryRotationTest.java @@ -0,0 +1,476 @@ +/* + * Copyright contributors to Besu / Aere Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * SIGNER-REGISTRY ROTATION AND REVOCATION: the Falcon signer registry has no usable rotation and no + * usable revocation. + * + *

WHAT IS MEASURED HERE, and why it is measured against the real classes rather than described. + * {@code config.pqRegistryHash} is a SCHEDULE of {block, hash} entries, and the format is documented + * as one where "a later entry expresses a key rotation". This file asks whether that sentence + * survives contact with the code that enforces it. + * + *

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. The binding is + * enforced while history is being acquired, not only at the head, so the whole range of heights has + * to be satisfiable at once and not merely the current interval. That is the constraint the two + * measurements below are written against. + * + *

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 PqRegistryRotationTest { + + private static final long CHAIN_ID = 2800L; + + /** First binding height: the height the post-quantum registry is first enforced from. */ + private static final long H1 = 12_000_000L; + + /** Rotation height: from here the chain requires the SECOND registry. */ + private static final long H2 = 12_100_000L; + + /** Falcon-512 public key length as this registry format stores it (bare h polynomial). */ + private static final int PK_LENGTH = 896; + + /** The seven validators of chain 2800. */ + private static final int N = 7; + + /** + * One node configuration, expressed as the only question the consensus path ever asks it: does + * the registry material this node holds satisfy the binding the chain requires at this height? + * + *

It is an interface and not a Registry so that the measurement can be stated once and asked of + * every configuration a node can actually be put into. Today there is exactly one shape of answer, + * {@link #single}, because a node loads one file. A repair that lets a node hold the whole + * scheduled history adds a second shape here and the assertion below stops failing. Nothing in the + * assertion has to change, which is the point: the property is fixed, the capability is what moves. + */ + private interface NodeConfiguration { + boolean satisfiesAt(long height); + + String describe(); + } + + private static NodeConfiguration single( + final String name, final PqRegistryHash.Schedule schedule, final PqRegistryHash.Registry r) { + return new NodeConfiguration() { + @Override + public boolean satisfiesAt(final long height) { + return PqRegistryHash.matchesAt(schedule, r, height, CHAIN_ID); + } + + @Override + public String describe() { + return "node holding only registry " + name; + } + }; + } + + // --------------------------------------------------------------------------------------- + // Fixture. Two registries that differ in exactly one row, which is what both a rotation and a + // revocation look like on the wire: index 3 stops being the key it was. + // --------------------------------------------------------------------------------------- + + private static byte[] deterministicKey(final int index, final int generation) { + final byte[] pk = new byte[PK_LENGTH]; + for (int i = 0; i < pk.length; i++) { + pk[i] = (byte) ((i * 31) + (index * 7) + (generation * 101)); + } + return pk; + } + + private static byte[] address(final int index) { + final byte[] a = new byte[20]; + for (int i = 0; i < a.length; i++) { + a[i] = (byte) ((index * 17) + i); + } + return a; + } + + private static String hex(final byte[] b) { + final StringBuilder sb = new StringBuilder(b.length * 2); + for (final byte x : b) { + sb.append(String.format("%02x", x)); + } + return sb.toString(); + } + + /** + * A seven-row address-bound registry. {@code rotatedIndex} is the row whose key belongs to + * generation 2; every other row is generation 1. Passing -1 gives the untouched registry. + */ + private static Path writeRegistry(final Path dir, final String name, final int rotatedIndex) + throws IOException { + final StringBuilder sb = new StringBuilder(); + sb.append("count=").append(N).append('\n'); + for (int i = 0; i < N; i++) { + sb.append(i).append('=').append(hex(deterministicKey(i, i == rotatedIndex ? 2 : 1))).append('\n'); + sb.append(i).append(".addr=").append(hex(address(i))).append('\n'); + } + final Path p = dir.resolve(name); + Files.write(p, sb.toString().getBytes(StandardCharsets.UTF_8)); + return p; + } + + private static PqRegistryHash.Schedule scheduleOf(final String hashAtH1, final String hashAtH2) { + final String json = + "[{\"block\":" + + H1 + + ",\"hash\":\"0x" + + hashAtH1 + + "\"},{\"block\":" + + H2 + + ",\"hash\":\"0x" + + hashAtH2 + + "\"}]"; + final JsonNode node; + try { + node = new ObjectMapper().readTree(json); + } catch (final IOException e) { + throw new IllegalStateException(e); + } + return PqRegistryHash.parseSchedule(node, "rotation fixture"); + } + + /** Every height at which the binding is enforced and could differ across the rotation. */ + private static List 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; + } + + /** + * THE REPAIR: the node holds the WHOLE scheduled history and resolves by height. This + * configuration did not exist before the repair, which is why the assertion below could not be + * satisfied by any node at all. + */ + private static NodeConfiguration wholeHistory( + final PqRegistryHash.Schedule schedule, + final PqRegistryHash.Registry before, + final PqRegistryHash.Registry after) { + final List held = new ArrayList<>(); + held.add(before); + held.add(after); + final PqRegistryHash.RegistrySet set = PqRegistryHash.buildSet(schedule, held, CHAIN_ID); + return new NodeConfiguration() { + @Override + public boolean satisfiesAt(final long height) { + return PqRegistryHash.matchesAt(schedule, set, height, CHAIN_ID); + } + + @Override + public String describe() { + return "node holding the whole scheduled history (" + set + ")"; + } + }; + } + + // --------------------------------------------------------------------------------------- + // Controls. If these fail, the fixture is wrong and the measurements below mean nothing. + // --------------------------------------------------------------------------------------- + + @Test + public void controlTheScheduleReallyDoesExpressARotation(@TempDir final Path dir) + throws IOException { + final PqRegistryHash.Registry before = + PqRegistryHash.loadPropertiesRegistry(writeRegistry(dir, "before.properties", -1)); + final PqRegistryHash.Registry after = + PqRegistryHash.loadPropertiesRegistry(writeRegistry(dir, "after.properties", 3)); + + final String hashBefore = PqRegistryHash.hashV1(before, CHAIN_ID); + final String hashAfter = PqRegistryHash.hashV1(after, CHAIN_ID); + assertThat(hashBefore).isNotEqualTo(hashAfter); + + final PqRegistryHash.Schedule schedule = scheduleOf(hashBefore, hashAfter); + assertThat(schedule.enforced()).isTrue(); + assertThat(schedule.entries()).hasSize(2); + + // Below the first entry nothing is bound: the 11.8 million existing blocks stay untouched. + assertThat(PqRegistryHash.requiredHashAt(schedule, H1 - 1)).isEmpty(); + assertThat(PqRegistryHash.requiredHashAt(schedule, H1).orElseThrow().hash()).isEqualTo(hashBefore); + assertThat(PqRegistryHash.requiredHashAt(schedule, H2 - 1).orElseThrow().hash()) + .isEqualTo(hashBefore); + assertThat(PqRegistryHash.requiredHashAt(schedule, H2).orElseThrow().hash()).isEqualTo(hashAfter); + } + + @Test + public void controlAMalformedScheduleIsRefused(@TempDir final Path dir) throws IOException { + final PqRegistryHash.Registry before = + PqRegistryHash.loadPropertiesRegistry(writeRegistry(dir, "before.properties", -1)); + final String h = PqRegistryHash.hashV1(before, CHAIN_ID); + final String json = + "[{\"block\":" + H2 + ",\"hash\":\"0x" + h + "\"},{\"block\":" + H1 + ",\"hash\":\"0x" + h + "\"}]"; + final JsonNode node = new ObjectMapper().readTree(json); + assertThatThrownBy(() -> PqRegistryHash.parseSchedule(node, "rotation fixture")) + .isInstanceOf(PqRegistryHash.RegistryConfigException.class) + .hasMessageContaining("STRICTLY INCREASING"); + } + + // --------------------------------------------------------------------------------------- + // THE MEASUREMENT. + // --------------------------------------------------------------------------------------- + + @Test + public void rotationDoesNotBrickHistory(@TempDir final Path dir) throws IOException { + final PqRegistryHash.Registry before = + PqRegistryHash.loadPropertiesRegistry(writeRegistry(dir, "before.properties", -1)); + final PqRegistryHash.Registry after = + PqRegistryHash.loadPropertiesRegistry(writeRegistry(dir, "after.properties", 3)); + final PqRegistryHash.Schedule schedule = + scheduleOf( + PqRegistryHash.hashV1(before, CHAIN_ID), PqRegistryHash.hashV1(after, CHAIN_ID)); + + final List 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 UNUSABLE: one scheduled rotation at height %d leaves NO node configuration " + + "that satisfies the registry binding at every enforced height. %s. A rotation " + + "scheme must keep the whole HEIGHT-TO-KEY-SET mapping loadable, not only the " + + "current entry.", + H2, + String.join("; ", report)) + .isNotNull(); + } + + @Test + public void revocationDoesNotBrickHistory(@TempDir final Path dir) throws IOException { + // Revocation is the same wire shape as rotation and is measured separately because it is the + // case with a deadline: a compromised Falcon key has to stop counting, and the operator has no + // reason to be able to re-sync afterwards only by luck. + final PqRegistryHash.Registry withCompromised = + PqRegistryHash.loadPropertiesRegistry(writeRegistry(dir, "compromised.properties", -1)); + final PqRegistryHash.Registry revoked = + PqRegistryHash.loadPropertiesRegistry(writeRegistry(dir, "revoked.properties", 5)); + final PqRegistryHash.Schedule schedule = + scheduleOf( + PqRegistryHash.hashV1(withCompromised, CHAIN_ID), + PqRegistryHash.hashV1(revoked, CHAIN_ID)); + + NodeConfiguration complete = null; + final List 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 UNUSABLE: revoking one compromised signer at height %d leaves NO node " + + "configuration that satisfies the binding at every enforced height. %s. The " + + "revocation is expressible and is not usable.", + H2, + String.join("; ", report)) + .isNotNull(); + } + + @Test + public void theKeySetInForceBelowTheRotationIsTheOldOne(@TempDir final Path dir) + throws IOException { + // Coverage alone would be satisfied by a set that answered every height with the same registry. + // This is the positive proof that the height actually selects: an old block resolves to the OLD + // key set, which is the whole reason the history is kept. + final PqRegistryHash.Registry before = + PqRegistryHash.loadPropertiesRegistry(writeRegistry(dir, "before.properties", -1)); + final PqRegistryHash.Registry after = + PqRegistryHash.loadPropertiesRegistry(writeRegistry(dir, "after.properties", 3)); + final PqRegistryHash.Schedule schedule = + scheduleOf(PqRegistryHash.hashV1(before, CHAIN_ID), PqRegistryHash.hashV1(after, CHAIN_ID)); + final List 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 registry-binding block). If that route + // were broken + // the other six would still be green and the capability would still not be usable, which is the + // exact shape of "a green result in a reduced environment is true and worthless". + // + // So this measurement starts from the STRING and ends at the same property the measurement + // tests assert: satisfied at every enforced height. + final Path beforePath = writeRegistry(dir, "before.properties", -1); + final Path afterPath = writeRegistry(dir, "after.properties", 3); + final PqRegistryHash.Registry before = PqRegistryHash.loadPropertiesRegistry(beforePath); + final PqRegistryHash.Registry after = PqRegistryHash.loadPropertiesRegistry(afterPath); + final PqRegistryHash.Schedule schedule = + scheduleOf(PqRegistryHash.hashV1(before, CHAIN_ID), PqRegistryHash.hashV1(after, CHAIN_ID)); + + // Written the way an operator writes it: one string, comma separated, with the sloppy spacing + // a unit file actually carries. The node's own file is the FIRST element of the held list, so + // the string names the OTHER one; here both are named, which is also legal and must not + // double-count. + final String configured = " " + beforePath + " , " + afterPath + " ,"; + final List 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 UNUSABLE on the route an operator can actually take: the history list %s " + + "parses and loads, and the resulting set is still refused at %s. The library can " + + "express the whole height-to-key-set mapping but the configuration string cannot reach " + + "it, so the rotation remains expressible and not usable.", + configured, refused) + .isEmpty(); + + // Positive proof that the string, not luck, did the selecting: below the rotation the OLD file + // is in force, at and above it the NEW one. + assertThat(PqRegistryHash.registryAt(schedule, set, H2 - 1).orElseThrow()) + .isNotSameAs(PqRegistryHash.registryAt(schedule, set, H2).orElseThrow()); + + // And the failure direction on the same route: a history string that names only one of the two + // files leaves the other entry uncovered, named by height, and refusing exactly there. + final PqRegistryHash.RegistrySet partial = + PqRegistryHash.loadSet( + schedule, PqRegistryHash.parseRegistryPaths(afterPath.toString()), CHAIN_ID); + assertThat(partial.uncoveredEntryBlocks()).containsExactly(H1); + assertThat(PqRegistryHash.matchesAt(schedule, partial, H1, CHAIN_ID)).isFalse(); + } +} diff --git a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqSealPersistenceTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqSealPersistenceTest.java new file mode 100644 index 0000000..8967ff3 --- /dev/null +++ b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqSealPersistenceTest.java @@ -0,0 +1,621 @@ +/* + * Copyright contributors to Besu / Aere Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.withSettings; + +import org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer; +import org.hyperledger.besu.consensus.common.validator.ValidatorProvider; +import org.hyperledger.besu.crypto.SecureRandomProvider; +import org.hyperledger.besu.datatypes.Address; +import org.hyperledger.besu.ethereum.ProtocolContext; +import org.hyperledger.besu.ethereum.core.BlockHeader; +import org.hyperledger.besu.ethereum.core.BlockHeaderTestFixture; + +import java.lang.reflect.Field; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.OptionalInt; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +import org.apache.tuweni.bytes.Bytes; +import org.apache.tuweni.bytes.Bytes32; +import org.bouncycastle.crypto.digests.KeccakDigest; +import org.bouncycastle.pqc.crypto.falcon.FalconPrivateKeyParameters; +import org.bouncycastle.pqc.crypto.falcon.FalconSigner; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.quality.Strictness; + +/** + * THE SECOND HALF OF THE FLEET-RESTART CHAIN DEATH: the heard seals themselves. + * + *

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, measured on an isolated rehearsal + * network. The FIRST half of that deadlock was the registry, repaired the same day: it now + * activates at start-up from chain-head state, and all seven nodes reported + * "anchor activation at STARTUP from head state: SUCCEEDED". The chain died anyway. The refusal + * only changed shape, from "registry address-bound=false" to "registry address-bound=TRUE ... + * Heard 0 seal(s)", frozen 150 s then 298 s. + * + *

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 PqSealPersistenceTest { + + /** Anchor activation height H. */ + private static final long H = 1_000L; + + /** Seal-attachment height, comfortably below H. */ + private static final long ATTACH = 900L; + + /** Height from which the staged threshold K is in force. */ + private static final long K_AT = H + 10L; + + /** The threshold with full margin at N=7: K=3, so the margin equals f. */ + private static final int K = 3; + + private static final int N = 7; + + private static final long CHAIN_ID = 2_800L; + + /** Measured block interval on the live chain, in milliseconds. */ + private static final long BLOCK_INTERVAL_MS = 523L; + + @TempDir private Path tmp; + + /** Stands in for the node's data directory, which is where the real path comes from. */ + private Path dataDirectory; + + private final List

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")); + + // REGISTRY BINDING: v2, proof-bound, bound at H. See PqV2Fixture. + final KeccakDigest kd = new KeccakDigest(256); + final StringBuilder manifest = + new StringBuilder("{\"config\":{\"aereFalconRegistry\":{") + .append(PqV2Fixture.manifestHeader(N, CHAIN_ID, H)); + for (int i = 0; i < N; i++) { + privateKeys.add(PqV2Fixture.privateKey(i)); + validators.add(PqV2Fixture.address(i)); + final byte[] anchoredRow = PqV2Fixture.anchorPreimageRow(i); + kd.update(anchoredRow, 0, anchoredRow.length); + manifest.append(',').append(PqV2Fixture.manifestEntry(i, N, CHAIN_ID, H)); + } + final byte[] anchoredHash = new byte[32]; + kd.doFinal(anchoredHash, 0); + manifest + .append("}},\"alloc\":{\"0000000000000000000000000000000000000fa1\":{\"storage\":{\"0x") + .append("0".repeat(64)) + .append("\":\"0x") + .append(Bytes.wrap(anchoredHash).toUnprefixedHexString()) + .append("\"}}}}"); + final Path genesisPath = tmp.resolve("genesis-registry.json"); + Files.writeString(genesisPath, manifest.toString()); + System.setProperty("aere.falcon.genesis", genesisPath.toAbsolutePath().toString()); + System.setProperty("aere.falcon.attachBlock", Long.toString(ATTACH)); + + resetFalconSingleton(); + PqSealCache.instance().disablePersistence(); + PqSealCache.instance().clear(); + PqAnchorProducer.useConfigForTesting( + new PqAnchorConfig(CHAIN_ID, H, Map.of(H, 0, K_AT, K), OptionalInt.empty(), false)); + + head = new BlockHeaderTestFixture().number(K_AT + 20L).buildHeader(); + context = contextWith(validators); + base = + new BftExtraData( + Bytes32.ZERO, + Collections.emptyList(), + Optional.empty(), + 0, + validators, + Collections.emptyList()); + } + + @AfterEach + public void tearDown() throws Exception { + System.clearProperty("aere.falcon.genesis"); + System.clearProperty("aere.falcon.attachBlock"); + PqSealCache.instance().disablePersistence(); + PqSealCache.instance().clear(); + PqAnchorProducer.useConfigForTesting(null); + resetFalconSingleton(); + } + + // ------------------------------------------------------------------------------------------- + // 1. The chain death, and its exit, in one method. + // ------------------------------------------------------------------------------------------- + + @Test + public void restartWithNoFileIsTheMeasuredDeadlockAndTheFileIsTheWayOut() { + // Fixture control: without a genuinely armed registry every assertion below would be about + // nothing at all. + assertThat(FalconSealSupport.instance().genesisAnchored()).isTrue(); + assertThat(FalconSealSupport.instance().addressBound()).isTrue(); + assertThat(FalconSealSupport.instance().registrySize()).isEqualTo(N); + + // --- BEFORE THE RESTART. The node is running, persistence is on, and it hears K Commits for + // its head exactly the way QbftRound.pqCacheHeardSeals feeds them in. + PqSealCache.instance().enablePersistence(dataDirectory, CHAIN_ID); + final Path file = PqSealStore.fileIn(dataDirectory); + for (int i = 0; i < K; i++) { + PqSealCache.instance().record(head.getNumber(), head.getHash(), List.of(genuineSeal(i))); + } + assertThat(file).exists(); + assertThat(PqSealCache.instance().sealCount(head.getHash())).isEqualTo(K); + + // --- THE RESTART. A fresh process: the map is gone, the file is not. Nothing else changes. + PqSealCache.instance().clear(); + assertThat(PqSealCache.instance().sealCount(head.getHash())) + .describedAs("the in-memory map does not survive a restart, which is the whole problem") + .isZero(); + + // --- THE DEADLOCK, as measured on the seven-node network. Without the file this is terminal: + // no proposal means no Commit, and no Commit means no seal, for ever. + assertThatThrownBy(() -> PqAnchorProducer.apply(base, head, context)) + .describedAs( + "the measured chain death: a restarted node holds no seals over M(head), so it cannot " + + "assemble a certificate and cannot propose") + .isInstanceOf(PqAnchorNotReadyException.class) + .hasMessageContaining("Heard 0 seal(s)"); + + // --- THE REPAIR. One read, every seal re-verified, and the same producer on the same inputs + // now produces a certificate. This is the ONLY thing the start-up code adds. + final int restored = + PqSealCache.instance() + .restoreFromDisk( + head.getNumber(), head.getHash(), PqSignerRegistry.falconSealSupport()); + assertThat(restored).isEqualTo(K); + assertThat(PqSealCache.instance().sealCount(head.getHash())).isEqualTo(K); + + final BftExtraData produced = PqAnchorProducer.apply(base, head, context); + assertThat(produced.getFalconSeals()) + .describedAs("the restarted node can propose again, carrying a K=%d certificate", K) + .hasSize(K); + assertThat(PqAnchor.hasStrictlyIncreasingIndices(produced.getFalconSeals())).isTrue(); + assertThat(produced.getVanityData()) + .describedAs("and the anchor digest is the one the validator side will recompute") + .isEqualTo( + PqAnchor.anchorDigest( + CHAIN_ID, + head.getNumber(), + head.getHash().getBytes(), + PqAnchor.sortedByIndex(produced.getFalconSeals()))); + } + + // ------------------------------------------------------------------------------------------- + // 2. THE SECURITY PROPERTY. This is the test the build-time negative control turns RED. + // ------------------------------------------------------------------------------------------- + + /** + * Persisting seals is only defensible because a seal is SELF-AUTHENTICATING: it is re-verified at + * read, against the anchored registry, over M rebuilt from the head this process just loaded. If + * that were not so, the file would be exactly the unbound-registry defect in another coat - + * state believed because it sits in a file a node can be pointed at. + * + *

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/PqSignedHeightTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqSignedHeightTest.java new file mode 100644 index 0000000..fc1333f --- /dev/null +++ b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqSignedHeightTest.java @@ -0,0 +1,403 @@ +/* + * Copyright contributors to Besu. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.IOException; +import java.lang.reflect.Field; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.OptionalInt; + +import org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * AERE SIGNED HEIGHT. THE SILENT DEFERRAL, and it is the worst of the three because nothing shows + * it. + * + *

WHAT IS SUPPOSED TO BE TRUE. Every row of a v2 registry carries two signatures - a Falcon + * possession proof and an ECDSA claim by that validator's own consensus key - and BOTH sign the bind + * height ({@code PqRegistryBinding.bindingPreimage}). A registry put in force from block B is + * therefore every one of those validators stating that B is the height they agreed to. + * + *

WHAT WAS ACTUALLY TRUE UNTIL THIS FILE. {@code bindHeight} was never compared with the {@code + * block} of the schedule entry that puts the registry in force. Not anywhere. The two numbers had + * been in the same lexical scope ever since the schedule became height-indexed, and were never put + * on the same expression. + * + *

WHY THE HASH DOES NOT CATCH IT, which is the part that makes this invisible rather than merely + * missing. {@code bindHeight} is INSIDE the v2 pre-image, so it is covered by the hash - and that is + * exactly why the comparison looks unnecessary. But the enforcement is hash against hash: the + * required hash comes from genesis and the computed hash comes from the file, and a file signed for + * 40 and scheduled from 70 reproduces its own hash perfectly. Both sides agree, and neither side + * ever mentions a height. {@link #theHashMATCHESWhichIsWhyNobodyEverSawThis()} measures precisely + * that, and it is the assertion that makes the rest of this file mean something. + * + *

MEASURED ON A NETWORK OF SEVEN, 2026-08-06, scenario C2: the arming height moved from 40 to 70 + * with the proofs left signed for 40. Seven of seven nodes started, the chain ran to head 95, + * agreement was 7 of 7, ZERO errors and ZERO refusals - while the offline tool, on the same files, + * said RED. The node and the tool disagreed and nothing put them face to face. + * + *

WHAT IT BUYS SOMEBODY WHO SHOULD NOT HAVE IT: moving the activation day costs 14 fresh + * validator signatures if this is checked and ZERO if it is not. + * + *

THE RULE CHOSEN, and why it is the correct one rather than merely a working one: + * + *

    + *
  • A proof-bound registry that reproduces the hash of schedule entry E must declare {@code + * bindHeight == E.block}. Enforced ({@code AERE-PQC-REG-BIND-08}). Both numbers come from + * genesis and the file, and genesis is the one document all seven nodes hold byte-identically, + * so this comparison is backed by the same agreement consensus already rests on. + *
  • NOT refused: a registry enforced from a FUTURE height. The comparison is against the + * ENTRY'S block, never against the chain head, so an entry scheduled a year out is untouched - + * {@link #aRegistryScheduledFarInTheFutureIsNOTRefused()}. + *
  • NOT refused: a schedule with several epochs. Each entry is compared against the registry + * that reproduces ITS hash, one pair at a time - {@link #twoEpochsEachSignedForItsOwnHeightAreFINE()}. + *
  • NOT touched: v1 registries. They carry no signatures over any height, {@code bindHeight} is + * -1, and the condition is gated on {@code proofBound()} - {@link #aV1RegistryIsNOTJudgedByThisRuleAtAll()}. + *
  • An entry no held file reproduces stays UNCOVERED, which is a different condition with a + * different message. It is not compared - {@link #anUncoveredEntryIsNotAMisboundOne()}. + *
+ * + *

AND THE HALF THAT CANNOT BE ENFORCED, written as a limitation and not as a to-do. {@code + * aere.pq.anchorBlock} is a system property set per node; it is in no genesis. The seven validators' + * agreement on an arming height therefore CANNOT be enforced by code running on one node - only + * DETECTED, which is {@code AERE-PQC-REG-BIND-09} and {@link + * #armingAtAHeightNobodySignedForIsREFUSEDLocally()}. Enforcement would require the height to move + * into genesis, which is a change to the chain and not to this class. + * + *

KEYS. Every key here is generated in memory by {@link PqV2Fixture} and never written outside a + * JUnit temporary directory. + */ +public class PqSignedHeightTest { + + /** The height the fleet actually signed its registry for. */ + private static final long SIGNED = 40L; + + /** The height somebody moved the activation to, without asking anybody to sign again. */ + private static final long MOVED = 70L; + + private static final int N = 7; + + private static final long CHAIN_ID = 220_878L; + + @TempDir private Path tmp; + + private Path signedForForty; + private String hashOfSignedForForty; + + @BeforeEach + public void setUp() throws Exception { + signedForForty = writeRegistry("signed-for-40.properties", SIGNED); + hashOfSignedForForty = + PqRegistryHash.hashFor(PqRegistryHash.loadAuto(signedForForty), CHAIN_ID); + System.setProperty("aere.falcon.registry", signedForForty.toAbsolutePath().toString()); + resetFalconSingleton(); + PqAnchorProducer.useConfigForTesting(null); + } + + @AfterEach + public void tearDown() throws Exception { + System.clearProperty("aere.falcon.registry"); + System.clearProperty(FalconSealSupport.PROPERTY_REGISTRY_HISTORY); + resetFalconSingleton(); + PqAnchorProducer.useConfigForTesting(null); + } + + // ------------------------------------------------------------------------------------------- + // 0. WHY NOBODY SAW IT. The hash agrees. If this assertion ever fails, the defect described in + // this file did not exist and everything below is theatre. + // ------------------------------------------------------------------------------------------- + + @Test + public void theHashMATCHESWhichIsWhyNobodyEverSawThis() throws Exception { + final PqRegistryHash.Registry r = PqRegistryHash.loadAuto(signedForForty); + assertThat(r.proofBound()).isTrue(); + assertThat(r.bindHeight()).isEqualTo(SIGNED); + + // The schedule puts this file in force from 70. The file was signed for 40. The hash the + // schedule requires and the hash the file computes are THE SAME NUMBER, because bindHeight sits + // inside the pre-image and travels with the file. Hash-against-hash can never see this. + final PqRegistryHash.Schedule moved = schedule(Map.of(MOVED, hashOfSignedForForty)); + assertThat(PqRegistryHash.requiredHashAt(moved, MOVED).orElseThrow().hash()) + .describedAs( + "the required hash and the computed hash agree perfectly while the heights differ by " + + "30 blocks. That agreement IS the defect") + .isEqualToIgnoringCase(PqRegistryHash.hashFor(r, CHAIN_ID)); + } + + // ------------------------------------------------------------------------------------------- + // 1. THE MEASUREMENT, repaired: the two numbers are now put side by side and the node refuses. + // ------------------------------------------------------------------------------------------- + + @Test + public void aRegistryScheduledAtAHeightItWasNotSignedForIsREFUSED() throws Exception { + final FalconSealSupport pqc = FalconSealSupport.instance(); + + assertThatThrownBy( + () -> + pqc.verifyRegistryBindingOrAbort( + MOVED + 25L, CHAIN_ID, schedule(Map.of(MOVED, hashOfSignedForForty)))) + .isInstanceOf(PqRegistryHash.RegistryConfigException.class) + .hasMessageContaining("AERE-PQC-REG-BIND-08") + // The message must carry BOTH numbers and name the field, or it is useless at 3am. + .hasMessageContaining("bindHeight") + .hasMessageContaining("in force FROM BLOCK " + MOVED) + .hasMessageContaining("bindHeight=" + SIGNED) + .hasMessageContaining("14 fresh validator signatures"); + } + + @Test + public void andItIsRefusedEVENWHENTheMovedHeightIsStillAhead() throws Exception { + // The whole hazard of a deferral is that it is invisible until the day arrives. Catching it only + // once the chain has reached the height would be catching it at the halt, which is the thing + // being repaired. Chain head is far below the moved entry here. + final FalconSealSupport pqc = FalconSealSupport.instance(); + assertThatThrownBy( + () -> + pqc.verifyRegistryBindingOrAbort( + 0L, CHAIN_ID, schedule(Map.of(MOVED, hashOfSignedForForty)))) + .isInstanceOf(PqRegistryHash.RegistryConfigException.class) + .hasMessageContaining("AERE-PQC-REG-BIND-08"); + } + + // ------------------------------------------------------------------------------------------- + // 2. NEGATIVE CONTROLS. The rule must refuse the moved height and NOTHING ELSE. + // ------------------------------------------------------------------------------------------- + + @Test + public void theSameFileAtTheHeightItWASSignedForStarts() throws Exception { + final FalconSealSupport pqc = FalconSealSupport.instance(); + assertThat( + pqc.verifyRegistryBindingOrAbort( + SIGNED + 25L, CHAIN_ID, schedule(Map.of(SIGNED, hashOfSignedForForty)))) + .describedAs( + "identical file, identical hash, identical everything except the one number this rule " + + "is about. If this refuses too, the rule is a wedge and not a guard") + .isEqualTo(PqRegistryHash.GateState.MATCH); + } + + @Test + public void aRegistryScheduledFarInTheFutureIsNOTRefused() throws Exception { + // A registry staged for an activation a year out: signed for that height, scheduled at that + // height, chain head nowhere near it. The comparison is entry-block against bindHeight and never + // against the chain head, so this is untouched. + final long future = 12_000_000L; + final Path staged = writeRegistry("staged.properties", future); + final String h = PqRegistryHash.hashFor(PqRegistryHash.loadAuto(staged), CHAIN_ID); + System.setProperty("aere.falcon.registry", staged.toAbsolutePath().toString()); + resetFalconSingleton(); + final FalconSealSupport pqc = FalconSealSupport.instance(); + + assertThat(pqc.verifyRegistryBindingOrAbort(1_000L, CHAIN_ID, schedule(Map.of(future, h)))) + .isEqualTo(PqRegistryHash.GateState.NOT_ENFORCED_BELOW_FIRST_HEIGHT); + } + + @Test + public void twoEpochsEachSignedForItsOwnHeightAreFINE() throws Exception { + final Path second = writeRegistry("epoch-two.properties", MOVED); + final String hSecond = PqRegistryHash.hashFor(PqRegistryHash.loadAuto(second), CHAIN_ID); + System.setProperty( + FalconSealSupport.PROPERTY_REGISTRY_HISTORY, second.toAbsolutePath().toString()); + final FalconSealSupport pqc = FalconSealSupport.instance(); + + final Map both = new LinkedHashMap<>(); + both.put(SIGNED, hashOfSignedForForty); + both.put(MOVED, hSecond); + + assertThat(pqc.verifyRegistryBindingOrAbort(MOVED + 25L, CHAIN_ID, schedule(both))) + .describedAs( + "a multi-epoch schedule is the normal shape after a rotation. Each entry is judged " + + "against the file that reproduces ITS hash, so several epochs are several " + + "independent comparisons and not one blanket") + .isEqualTo(PqRegistryHash.GateState.MATCH); + } + + @Test + public void aV1RegistryIsNOTJudgedByThisRuleAtAll() throws Exception { + // No proofs, therefore no signature over any height, therefore no height to disagree with. + // bindHeight is -1 here and would mismatch every entry there is; the gate on proofBound() is + // what keeps a v1 fleet from being refused by a rule about signatures it never made. + final Path v1 = writeV1Registry("legacy.properties"); + final PqRegistryHash.Registry r = PqRegistryHash.loadAuto(v1); + assertThat(r.proofBound()).isFalse(); + assertThat(r.bindHeight()).isNegative(); + + final String h = PqRegistryHash.hashFor(r, CHAIN_ID); + final PqRegistryHash.RegistrySet set = + PqRegistryHash.buildSet(schedule(Map.of(MOVED, h)), List.of(r), CHAIN_ID); + + assertThat(set.misbound()) + .describedAs("a v1 registry can never be misbound, because it never bound itself to a height") + .isEmpty(); + assertThat(set.forEntryBlock(MOVED)).isSameAs(r); + } + + @Test + public void anUncoveredEntryIsNotAMisboundOne() throws Exception { + // An entry whose hash nothing held reproduces is a MISSING file, which an operator legitimately + // has. It must not be reported as a signed-height mismatch: different mistake, different fix. + final Map both = new LinkedHashMap<>(); + both.put(SIGNED, hashOfSignedForForty); + both.put(MOVED + 1_000L, "cd".repeat(32)); + final PqRegistryHash.RegistrySet set = + PqRegistryHash.buildSet( + schedule(both), List.of(PqRegistryHash.loadAuto(signedForForty)), CHAIN_ID); + + assertThat(set.misbound()).isEmpty(); + assertThat(set.uncoveredEntryBlocks()).containsExactly(MOVED + 1_000L); + } + + // ------------------------------------------------------------------------------------------- + // 3. THE LOCAL HALF. Arming at a height nobody signed for: detected, and honestly labelled. + // ------------------------------------------------------------------------------------------- + + @Test + public void armingAtAHeightNobodySignedForIsREFUSEDLocally() throws Exception { + // The schedule is coherent - the file is signed for 40 and scheduled from 40 - and the ANCHOR + // has been moved to 70 on this node alone. Nothing in genesis constrains aere.pq.anchorBlock, so + // this can only be caught where both values are in one hand, which is here. + PqAnchorProducer.useConfigForTesting( + new PqAnchorConfig(CHAIN_ID, MOVED, Map.of(MOVED, 3), OptionalInt.empty(), false)); + resetFalconSingleton(); + final FalconSealSupport pqc = FalconSealSupport.instance(); + + assertThatThrownBy( + () -> + pqc.verifyRegistryBindingOrAbort( + SIGNED + 5L, CHAIN_ID, schedule(Map.of(SIGNED, hashOfSignedForForty)))) + .isInstanceOf(PqRegistryHash.RegistryConfigException.class) + .hasMessageContaining("AERE-PQC-REG-BIND-09") + .hasMessageContaining("aere.pq.anchorBlock = " + MOVED) + .hasMessageContaining("config.pqRegistryHash heights = [" + SIGNED + "]") + // The honest limitation has to be IN the message, or an operator will read this as a + // consensus guarantee it is not. + .hasMessageContaining("DETECTION on this node only") + .hasMessageContaining("all seven nodes and in the same change"); + } + + @Test + public void armingExactlyAtAScheduledHeightSTARTS() throws Exception { + PqAnchorProducer.useConfigForTesting( + new PqAnchorConfig(CHAIN_ID, SIGNED, Map.of(SIGNED, 3), OptionalInt.empty(), false)); + resetFalconSingleton(); + final FalconSealSupport pqc = FalconSealSupport.instance(); + + assertThat( + pqc.verifyRegistryBindingOrAbort( + SIGNED + 5L, CHAIN_ID, schedule(Map.of(SIGNED, hashOfSignedForForty)))) + .describedAs( + "the positive half, and it is the expensive one: a gate that refuses every armed node " + + "is not a gate") + .isEqualTo(PqRegistryHash.GateState.MATCH); + } + + @Test + public void anUNARMEDNodeIsNeverAskedTheQuestion() throws Exception { + // Chain 2800 as it stands: aere.pq.anchorBlock unset. The arming-height comparison must not + // even be reachable, whatever the schedule says. + PqAnchorProducer.useConfigForTesting(PqAnchorConfig.never(CHAIN_ID)); + resetFalconSingleton(); + final FalconSealSupport pqc = FalconSealSupport.instance(); + + assertThatCode( + () -> + pqc.verifyRegistryBindingOrAbort( + SIGNED + 5L, CHAIN_ID, schedule(Map.of(SIGNED, hashOfSignedForForty)))) + .doesNotThrowAnyException(); + } + + // ------------------------------------------------------------------------------------------- + // Helpers. + // ------------------------------------------------------------------------------------------- + + private Path writeRegistry(final String name, final long bindHeight) throws IOException { + final StringBuilder b = new StringBuilder(); + b.append("formatVersion=").append(PqRegistryBinding.FORMAT_VERSION).append('\n'); + b.append("chainId=").append(CHAIN_ID).append('\n'); + b.append("bindHeight=").append(bindHeight).append('\n'); + b.append("count=").append(N).append('\n'); + for (int i = 0; i < N; i++) { + b.append(i).append('=').append(hex(PqV2Fixture.publicKey(i))).append('\n'); + b.append(i).append(".addr=").append(PqV2Fixture.address(i).toHexString()).append('\n'); + b.append(i).append(".pop=").append(PqV2Fixture.popHex(CHAIN_ID, bindHeight, N, i)).append('\n'); + b.append(i) + .append(".claim=") + .append(PqV2Fixture.claimHex(CHAIN_ID, bindHeight, N, i)) + .append('\n'); + } + final Path f = tmp.resolve(name); + Files.writeString(f, b.toString(), StandardCharsets.UTF_8); + return f; + } + + private Path writeV1Registry(final String name) throws IOException { + final StringBuilder b = new StringBuilder(); + b.append("count=").append(N).append('\n'); + for (int i = 0; i < N; i++) { + b.append(i).append('=').append(hex(PqV2Fixture.publicKey(i))).append('\n'); + b.append(i).append(".addr=").append(PqV2Fixture.address(i).toHexString()).append('\n'); + } + final Path f = tmp.resolve(name); + Files.writeString(f, b.toString(), StandardCharsets.UTF_8); + return f; + } + + private PqRegistryHash.Schedule schedule(final Map entries) throws IOException { + 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(','); + } + sb.append("{\"block\":") + .append(heights.get(i)) + .append(",\"hash\":\"0x") + .append(entries.get(heights.get(i))) + .append("\"}"); + } + sb.append("]}}"); + final Path p = tmp.resolve("genesis-db-" + heights.size() + "-" + heights.get(0) + ".json"); + Files.writeString(p, sb.toString(), StandardCharsets.UTF_8); + return PqRegistryHash.loadScheduleFromGenesis(p); + } + + private static String hex(final byte[] b) { + final StringBuilder s = new StringBuilder(b.length * 2); + for (final byte x : b) { + s.append(Character.forDigit((x >> 4) & 0xf, 16)).append(Character.forDigit(x & 0xf, 16)); + } + return s.toString(); + } + + private static void resetFalconSingleton() throws Exception { + final Field f = FalconSealSupport.class.getDeclaredField("instance"); + f.setAccessible(true); + f.set(null, null); + } +} diff --git a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqStartupHistoryTest.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqStartupHistoryTest.java new file mode 100644 index 0000000..705838d --- /dev/null +++ b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqStartupHistoryTest.java @@ -0,0 +1,325 @@ +/* + * Copyright contributors to Besu. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.IOException; +import java.lang.reflect.Field; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * AERE ROTATION HISTORY AT STARTUP. AFTER THE FIRST ROTATION, NO NODE COULD BE RESTARTED WITH ITS OWN CORRECT + * CONFIGURATION. + * + *

WHAT WAS MEASURED, and it was measured twice: once during the rotation rehearsal on a network + * of seven, and again on 2026-08-06 with the chain head at 2554. A node configured exactly as the + * runbook says - the genesis manifest as the registry it signs with, every rotation registry named + * in {@code aere.falcon.registry.history} - refused to start with {@code AERE-PQC-REG-MISMATCH-01}. + * Nothing was wrong with it. + * + *

THE DEFECT WAS THE ORDER OF TWO BLOCKS OF CODE. {@code + * FalconSealSupport.verifyRegistryBindingOrAbort} loaded ONE registry, the primary, and handed it to + * the guard. The history list was read FORTY-ONE LINES FURTHER DOWN, to build the height-resolved + * set the height-indexed registry change introduced. So the refusal was thrown before the code that + * knew the answer had run. The guard was not wrong about what it compared; it was never shown the + * other files. + * + *

WHY IT BITES EXACTLY AFTER A ROTATION AND NEVER BEFORE. The primary registry is the genesis + * manifest, and genesis does not change. A rotation adds a SECOND entry to {@code + * config.pqRegistryHash} and a second file, which can only arrive through the history property. From + * the rotation height onwards the entry in force names the second file, the guard compares the + * first, and every restart fails. Before the rotation there is one entry and one file and the two + * paths are indistinguishable - which is why this survived every test the tree had. + * + *

THE NEGATIVE CONTROLS ARE THE POINT. {@link #withNoHistoryConfiguredTheRefusalIsSTILLTHERE()} + * and {@link #aHistoryFileThatCoversNothingDoesNotRescueAnything()} fail if the repair is "stop + * refusing" rather than "consult the history". A guard that has stopped refusing is not a repaired + * guard, and this pair is what tells the two apart. + * + *

ALSO CLOSED HERE: a finding in its own right. Before this file, {@code + * AERE-PQC-REG-MISMATCH-01}, {@code AERE-PQC-REG-MISMATCH-02} and {@code GateState} appeared in NO + * test anywhere in the tree (searched over every src/test of every consensus module on + * 2026-08-06). The + * refusal to start had only ever been exercised by running real nodes. + * + *

KEYS. Every key here is generated in memory by {@link PqV2Fixture} and never written outside a + * JUnit temporary directory. + */ +public class PqStartupHistoryTest { + + /** Where the chain first requires a registry: epoch one, the genesis manifest. */ + private static final long H1 = 600L; + + /** The rotation height: from here the chain requires the second registry. */ + private static final long H2 = 2_000L; + + private static final int N = 7; + + private static final long CHAIN_ID = 220_878L; + + @TempDir private Path tmp; + + private Path epochOne; + private Path epochTwo; + private String hashOne; + private String hashTwo; + + @BeforeEach + public void setUp() throws Exception { + // Two epochs of the SAME fleet, re-signed at two heights. That is the whole difference between + // the files and it is deliberate: it makes them two distinct registries with two distinct + // hashes, and it removes every other variable, so a guard that gets this wrong cannot be + // excused by a changed key. + epochOne = writeRegistry("epoch-one.properties", H1); + epochTwo = writeRegistry("epoch-two.properties", H2); + hashOne = PqRegistryHash.hashFor(PqRegistryHash.loadAuto(epochOne), CHAIN_ID); + hashTwo = PqRegistryHash.hashFor(PqRegistryHash.loadAuto(epochTwo), CHAIN_ID); + assertThat(hashOne).isNotEqualTo(hashTwo); + + // A test that asserts what happens WITHOUT an anchor height must establish that itself, not + // inherit it from whoever ran before. Measured 2026-08-11: under its previous name this class + // sorted before every class that sets aere.pq.anchorBlock, so it passed for the right result and + // the wrong reason. The real leak is a remembered configuration, not the property, and it is + // fixed where it leaks; this stays as the belt to that pair of braces. + System.clearProperty("aere.pq.anchorBlock"); + System.clearProperty("aere.pq.anchorMinSeals"); + System.setProperty("aere.falcon.registry", epochOne.toAbsolutePath().toString()); + resetFalconSingleton(); + } + + @AfterEach + public void tearDown() throws Exception { + System.clearProperty("aere.falcon.registry"); + System.clearProperty(FalconSealSupport.PROPERTY_REGISTRY_HISTORY); + resetFalconSingleton(); + } + + // ------------------------------------------------------------------------------------------- + // 0. The fixture. Without this a green run below could mean the guard was never armed at all. + // ------------------------------------------------------------------------------------------- + + @Test + public void baselineBeforeTheRotationTheOneFileIsENOUGH() throws Exception { + final FalconSealSupport pqc = FalconSealSupport.instance(); + // One entry, one file, head below the rotation: the configuration every node ran before the + // rotation existed. This is the state in which the defect is INVISIBLE. + assertThat(pqc.verifyRegistryBindingOrAbort(H1 + 10L, CHAIN_ID, schedule(Map.of(H1, hashOne)))) + .isEqualTo(PqRegistryHash.GateState.MATCH); + } + + // ------------------------------------------------------------------------------------------- + // 1. THE MEASUREMENT. The rotated fleet, configured exactly as its runbook says. + // ------------------------------------------------------------------------------------------- + + @Test + public void afterTheRotationTheNODESTARTSBecauseTheGuardREADSTheHistory() throws Exception { + System.setProperty( + FalconSealSupport.PROPERTY_REGISTRY_HISTORY, epochTwo.toAbsolutePath().toString()); + final FalconSealSupport pqc = FalconSealSupport.instance(); + + final Map both = new LinkedHashMap<>(); + both.put(H1, hashOne); + both.put(H2, hashTwo); + + assertThat(pqc.verifyRegistryBindingOrAbort(H2 + 554L, CHAIN_ID, schedule(both))) + .describedAs( + "head above the rotation, the required registry held as HISTORY, the primary still the " + + "genesis manifest. This is the correct configuration and until 2026-08-06 it " + + "refused to start with AERE-PQC-REG-MISMATCH-01, because the guard was handed the " + + "primary alone and the history list was read 41 lines later") + .isEqualTo(PqRegistryHash.GateState.MATCH); + } + + @Test + public void andTheWHOLESCHEDULEIsCoveredNotOnlyTheHead() throws Exception { + System.setProperty( + FalconSealSupport.PROPERTY_REGISTRY_HISTORY, epochTwo.toAbsolutePath().toString()); + final FalconSealSupport pqc = FalconSealSupport.instance(); + final Map both = new LinkedHashMap<>(); + both.put(H1, hashOne); + both.put(H2, hashTwo); + pqc.verifyRegistryBindingOrAbort(H2 + 554L, CHAIN_ID, schedule(both)); + + // Starting is not the whole story: a node that starts and then refuses every header below the + // rotation has traded one halt for another. Both intervals must answer. + assertThat(pqc.registryBindingSatisfiedAt(H1)).isTrue(); + assertThat(pqc.registryBindingSatisfiedAt(H2 - 1L)).isTrue(); + assertThat(pqc.registryBindingSatisfiedAt(H2)).isTrue(); + assertThat(pqc.registryBindingSatisfiedAt(H2 + 554L)).isTrue(); + } + + // ------------------------------------------------------------------------------------------- + // 2. NEGATIVE CONTROL. The repair is "read the history", not "stop refusing". + // ------------------------------------------------------------------------------------------- + + @Test + public void withNoHistoryConfiguredTheRefusalIsSTILLTHERE() throws Exception { + final FalconSealSupport pqc = FalconSealSupport.instance(); + final Map both = new LinkedHashMap<>(); + both.put(H1, hashOne); + both.put(H2, hashTwo); + + assertThatThrownBy(() -> pqc.verifyRegistryBindingOrAbort(H2 + 554L, CHAIN_ID, schedule(both))) + .describedAs( + "a node that genuinely does not hold the rotation registry must still refuse to start. " + + "If this passes, the repair removed the guard instead of feeding it") + .isInstanceOf(PqRegistryHash.RegistryConfigException.class) + .hasMessageContaining("AERE-PQC-REG-MISMATCH-01") + .hasMessageContaining("REGISTRY FILES THIS NODE HOLDS: 1") + .hasMessageContaining("aere.falcon.registry.history"); + } + + @Test + public void aHistoryFileThatCoversNothingDoesNotRescueAnything() throws Exception { + // A third file, signed for a height the schedule never mentions. It is a real, loadable, + // proof-bound registry; it simply answers for no interval of this chain. + final Path unrelated = writeRegistry("unrelated.properties", H2 + 999L); + System.setProperty( + FalconSealSupport.PROPERTY_REGISTRY_HISTORY, unrelated.toAbsolutePath().toString()); + final FalconSealSupport pqc = FalconSealSupport.instance(); + final Map both = new LinkedHashMap<>(); + both.put(H1, hashOne); + both.put(H2, hashTwo); + + assertThatThrownBy(() -> pqc.verifyRegistryBindingOrAbort(H2 + 554L, CHAIN_ID, schedule(both))) + .describedAs( + "holding MORE files is not the property that matters; holding the RIGHT one is. A " + + "repair that counted files instead of matching hashes would pass the test above " + + "and fail here") + .isInstanceOf(PqRegistryHash.RegistryConfigException.class) + .hasMessageContaining("AERE-PQC-REG-MISMATCH-01") + .hasMessageContaining("REGISTRY FILES THIS NODE HOLDS: 2"); + } + + @Test + public void aNodeWithNOREGISTRYATALLIsRefusedAndTheCodeIsTheOtherONE() throws Exception { + System.clearProperty("aere.falcon.registry"); + resetFalconSingleton(); + final FalconSealSupport pqc = FalconSealSupport.instance(); + + assertThatThrownBy( + () -> pqc.verifyRegistryBindingOrAbort(H1 + 10L, CHAIN_ID, schedule(Map.of(H1, hashOne)))) + .describedAs( + "no registry at all is a different mistake from the wrong registry, and it has always " + + "had its own code. Until this file, neither code was asserted anywhere") + .isInstanceOf(PqRegistryHash.RegistryConfigException.class) + .hasMessageContaining("AERE-PQC-REG-MISMATCH-02") + .hasMessageContaining("NO REGISTRY AT ALL"); + } + + // ------------------------------------------------------------------------------------------- + // 3. The states that are NOT refusals. A guard that only ever refuses is a wedge in a costume. + // ------------------------------------------------------------------------------------------- + + @Test + public void belowTheFirstScheduledHeightNothingIsEnforcedAndTheNodeIsTOLDItIsStaged() + throws Exception { + System.setProperty( + FalconSealSupport.PROPERTY_REGISTRY_HISTORY, epochTwo.toAbsolutePath().toString()); + final FalconSealSupport pqc = FalconSealSupport.instance(); + assertThat(pqc.verifyRegistryBindingOrAbort(H1 - 100L, CHAIN_ID, schedule(Map.of(H1, hashOne)))) + .isEqualTo(PqRegistryHash.GateState.NOT_ENFORCED_BELOW_FIRST_HEIGHT); + } + + @Test + public void withNoScheduleAtAllNOTHINGIsEnforcedWhichIsChain2800Today() throws Exception { + final FalconSealSupport pqc = FalconSealSupport.instance(); + assertThat( + pqc.verifyRegistryBindingOrAbort( + H2 + 554L, CHAIN_ID, PqRegistryHash.emptySchedule())) + .describedAs( + "config.pqRegistryHash is in no genesis this fleet runs. This is the branch every " + + "live node takes, and it must stay first and unconditional") + .isEqualTo(PqRegistryHash.GateState.NOT_ENFORCED_NO_SCHEDULE); + } + + // ------------------------------------------------------------------------------------------- + // Helpers. + // ------------------------------------------------------------------------------------------- + + private Path writeRegistry(final String name, final long bindHeight) throws IOException { + final StringBuilder b = new StringBuilder(); + b.append("formatVersion=").append(PqRegistryBinding.FORMAT_VERSION).append('\n'); + b.append("chainId=").append(CHAIN_ID).append('\n'); + b.append("bindHeight=").append(bindHeight).append('\n'); + b.append("count=").append(N).append('\n'); + for (int i = 0; i < N; i++) { + b.append(i).append('=').append(hex(PqV2Fixture.publicKey(i))).append('\n'); + b.append(i) + .append(".addr=") + .append(PqV2Fixture.address(i).toHexString()) + .append('\n'); + b.append(i).append(".pop=").append(PqV2Fixture.popHex(CHAIN_ID, bindHeight, N, i)).append('\n'); + b.append(i) + .append(".claim=") + .append(PqV2Fixture.claimHex(CHAIN_ID, bindHeight, N, i)) + .append('\n'); + } + final Path f = tmp.resolve(name); + Files.writeString(f, b.toString(), StandardCharsets.UTF_8); + return f; + } + + /** + * Build a schedule the way a node really gets one: written into a genesis file and parsed back. + * Constructing the object directly would skip the parser, where the strictly-increasing rule is. + */ + private PqRegistryHash.Schedule schedule(final Map entries) throws IOException { + 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(','); + } + sb.append("{\"block\":") + .append(heights.get(i)) + .append(",\"hash\":\"0x") + .append(entries.get(heights.get(i))) + .append("\"}"); + } + sb.append("]}}"); + final Path p = tmp.resolve("genesis-sched-" + heights.size() + "-" + heights.get(0) + ".json"); + Files.writeString(p, sb.toString(), StandardCharsets.UTF_8); + return PqRegistryHash.loadScheduleFromGenesis(p); + } + + private static String hex(final byte[] b) { + final StringBuilder s = new StringBuilder(b.length * 2); + for (final byte x : b) { + s.append(Character.forDigit((x >> 4) & 0xf, 16)).append(Character.forDigit(x & 0xf, 16)); + } + return s.toString(); + } + + private static void resetFalconSingleton() throws Exception { + final Field f = FalconSealSupport.class.getDeclaredField("instance"); + f.setAccessible(true); + f.set(null, null); + } +} diff --git a/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqV2Fixture.java b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqV2Fixture.java new file mode 100644 index 0000000..2c7108f --- /dev/null +++ b/anchor/consensus/common/src/test/java/org/hyperledger/besu/consensus/common/bft/PqV2Fixture.java @@ -0,0 +1,233 @@ +/* + * Copyright contributors to Besu. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.common.bft; + +import org.hyperledger.besu.crypto.KeyPair; +import org.hyperledger.besu.crypto.SECPSignature; +import org.hyperledger.besu.crypto.SecureRandomProvider; +import org.hyperledger.besu.crypto.SignatureAlgorithm; +import org.hyperledger.besu.crypto.SignatureAlgorithmFactory; +import org.hyperledger.besu.datatypes.Address; +import org.hyperledger.besu.ethereum.core.Util; + +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.List; + +import org.apache.tuweni.bytes.Bytes; +import org.apache.tuweni.bytes.Bytes32; +import org.bouncycastle.crypto.AsymmetricCipherKeyPair; +import org.bouncycastle.pqc.crypto.falcon.FalconKeyGenerationParameters; +import org.bouncycastle.pqc.crypto.falcon.FalconKeyPairGenerator; +import org.bouncycastle.pqc.crypto.falcon.FalconParameters; +import org.bouncycastle.pqc.crypto.falcon.FalconPrivateKeyParameters; +import org.bouncycastle.pqc.crypto.falcon.FalconPublicKeyParameters; +import org.bouncycastle.pqc.crypto.falcon.FalconSigner; + +/** + * AERE REGISTRY BINDING (2026-08-06). The shared probe fleet every arming fixture is now built + * from, and the reason it had to exist. + * + *

WHAT IT REPLACED, and why the replacement is not cosmetic. Until 2026-08-06 seven separate + * fixtures built their registries around addresses spelled {@code String.format("0x%040x", 0xA00 + + * i)}. Those addresses are arithmetic, not keys: no secp256k1 private key produces them, so no + * validator can ever sign a binding claim for one. The moment {@code AERE-PQC-REG-ARM-02} was wired + * into {@code FalconSealSupport}, all seven fixtures described a fleet that CANNOT EXIST - armed, + * and provably unable to produce the registry the arming path now requires. Measured on 2026-08-06: + * 35 tests across 7 classes, every failure carrying AERE-PQC-REG-ARM-02. + * + *

So each row here starts from a REAL secp256k1 key pair and the address is DERIVED from it, + * exactly as a validator's address is derived on the live fleet. That single change is what makes a + * v2 row signable at all. + * + *

WHY A SHARED POOL AND NOT A HELPER PER TEST. Falcon-512 key generation dominates the runtime of + * these fixtures, and every one of them wants the same thing: n validators, each with a Falcon pair, + * an ECDSA pair, and proofs over whatever {@code (chainId, bindHeight, count)} that test declares. + * Generating the pool once per JVM turns seven independent keygen loops into one. It also makes the + * fixtures COMPARABLE: two tests asking for index 3 get the same validator, so a registry written by + * one and read by another is the same fleet. + * + *

WHAT IS DELIBERATELY NOT SHARED: the proofs. {@link #popHex} and {@link #claimHex} take + * {@code chainId}, {@code bindHeight} and {@code count} because all three are inside the signed + * pre-image. A cached proof would be a proof for another registry, and the loader would - correctly + * - refuse it. Caching the KEYS is safe; caching the SIGNATURES would defeat the test. + * + *

KEYS. Every key here is generated in memory at test time and never written outside a JUnit + * temporary directory. Nothing in this file reads or produces fleet key material. + */ +public final class PqV2Fixture { + + /** Size of the probe pool. Larger than any fixture needs, so one pool serves all of them. */ + private static final int POOL = 16; + + private static List pool; + private static SignatureAlgorithm ecdsa; + + private PqV2Fixture() {} + + /** One validator's material: a Falcon pair plus the ECDSA consensus key that must vouch for it. */ + private record Holder( + byte[] falconPublicKey, FalconPrivateKeyParameters falconPrivate, KeyPair ecdsa) {} + + private static synchronized List pool() { + if (pool == null) { + ecdsa = SignatureAlgorithmFactory.getInstance(); + final SecureRandom rnd = SecureRandomProvider.publicSecureRandom(); + final FalconKeyPairGenerator gen = new FalconKeyPairGenerator(); + gen.init(new FalconKeyGenerationParameters(rnd, FalconParameters.falcon_512)); + final List built = new ArrayList<>(POOL); + for (int i = 0; i < POOL; i++) { + final AsymmetricCipherKeyPair kp = gen.generateKeyPair(); + built.add( + new Holder( + ((FalconPublicKeyParameters) kp.getPublic()).getH(), + (FalconPrivateKeyParameters) kp.getPrivate(), + ecdsa.generateKeyPair())); + } + pool = built; + } + return pool; + } + + /** + * The validator address of row {@code i}, DERIVED from that row's secp256k1 public key. + * + * @param i the row index + * @return the derived validator address + */ + public static Address address(final int i) { + return Util.publicKeyToAddress(pool().get(i).ecdsa().getPublicKey()); + } + + /** + * The raw Falcon-512 public key of row {@code i}. + * + * @param i the row index + * @return the raw public key bytes + */ + public static byte[] publicKey(final int i) { + return pool().get(i).falconPublicKey(); + } + + /** + * The Falcon private key of row {@code i}, for fixtures that must also SIGN as that validator. + * + * @param i the row index + * @return the Falcon private key parameters + */ + public static FalconPrivateKeyParameters privateKey(final int i) { + return pool().get(i).falconPrivate(); + } + + /** + * The Falcon possession proof of row {@code i}, over the pre-image this registry declares. + * + * @param chainId the chain id the registry declares + * @param bindHeight the activation height the registry declares + * @param count the row count the registry declares + * @param i the row index + * @return the proof, 0x-prefixed hex + */ + public static String popHex( + final long chainId, final long bindHeight, final int count, final int i) { + final Bytes32 digest = + PqRegistryBinding.possessionDigest( + chainId, bindHeight, count, i, address(i).getBytes().toArray(), publicKey(i)); + final FalconSigner signer = new FalconSigner(); + signer.init(true, privateKey(i)); + return Bytes.wrap(signer.generateSignature(digest.toArray())).toHexString(); + } + + /** + * The ECDSA claim of row {@code i}, signed by that row's OWN consensus key. + * + * @param chainId the chain id the registry declares + * @param bindHeight the activation height the registry declares + * @param count the row count the registry declares + * @param i the row index + * @return the claim signature, 0x-prefixed hex + */ + public static String claimHex( + final long chainId, final long bindHeight, final int count, final int i) { + final Bytes32 digest = + PqRegistryBinding.claimDigest( + chainId, bindHeight, count, i, address(i).getBytes().toArray(), publicKey(i)); + final List holders = pool(); // also the point at which 'ecdsa' is initialised + final SECPSignature s = ecdsa.sign(digest, holders.get(i).ecdsa()); + return Bytes.wrap(s.encodedBytes().toArrayUnsafe()).toHexString(); + } + + /** + * The manifest-level header a v2 registry must carry, as JSON object fields with a trailing comma + * omitted: {@code "count":n,"chainId":c,"bindHeight":h,"formatVersion":2}. + * + * @param count the row count + * @param chainId the chain id + * @param bindHeight the activation height + * @return the header fields, ready to sit at the head of a manifest object + */ + public static String manifestHeader(final int count, final long chainId, final long bindHeight) { + return "\"count\":" + + count + + ",\"chainId\":" + + chainId + + ",\"bindHeight\":" + + bindHeight + + ",\"formatVersion\":" + + PqRegistryBinding.FORMAT_VERSION; + } + + /** + * One v2 manifest row, as a JSON object field: {@code "i":{"addr":..,"pk":..,"pop":..,"claim":..}}. + * + * @param i the row index + * @param count the row count the registry declares + * @param chainId the chain id the registry declares + * @param bindHeight the activation height the registry declares + * @return the row, ready to append after a comma + */ + public static String manifestEntry( + final int i, final int count, final long chainId, final long bindHeight) { + return "\"" + + i + + "\":{\"addr\":\"" + + address(i).toHexString() + + "\",\"pk\":\"" + + Bytes.wrap(publicKey(i)).toHexString() + + "\",\"pop\":\"" + + popHex(chainId, bindHeight, count, i) + + "\",\"claim\":\"" + + claimHex(chainId, bindHeight, count, i) + + "\"}"; + } + + /** + * The bytes the genesis anchor slot commits to for row {@code i}: {@code address || publicKey}, + * which is what {@code hashV0Legacy} accumulates. Unchanged by the v2 binding work - the proofs + * are outside the legacy pre-image - and kept here so a fixture cannot drift from the row it just + * wrote. + * + * @param i the row index + * @return the anchored pre-image bytes for that row + */ + public static byte[] anchorPreimageRow(final int i) { + final byte[] a = address(i).getBytes().toArray(); + final byte[] k = publicKey(i); + final byte[] out = new byte[a.length + k.length]; + System.arraycopy(a, 0, out, 0, a.length); + System.arraycopy(k, 0, out, a.length, k.length); + return out; + } +} diff --git a/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/messagewrappers/Commit.java b/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/messagewrappers/Commit.java new file mode 100644 index 0000000..7e89e5b --- /dev/null +++ b/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/messagewrappers/Commit.java @@ -0,0 +1,80 @@ +/* + * Copyright ConsenSys AG. + * + * 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.core.messagewrappers; + +import org.hyperledger.besu.consensus.common.bft.FalconSeal; +import org.hyperledger.besu.consensus.common.bft.messagewrappers.BftMessage; +import org.hyperledger.besu.consensus.common.bft.payload.SignedData; +import org.hyperledger.besu.consensus.qbft.core.payload.CommitPayload; +import org.hyperledger.besu.crypto.SECPSignature; +import org.hyperledger.besu.datatypes.Hash; +import org.hyperledger.besu.ethereum.rlp.RLP; +import org.hyperledger.besu.ethereum.rlp.RLPInput; + +import java.util.Optional; + +import org.apache.tuweni.bytes.Bytes; + +/** The Commit payload message. */ +public class Commit extends BftMessage { + + /** + * Instantiates a new Commit. + * + * @param payload the payload + */ + public Commit(final SignedData payload) { + super(payload); + } + + /** + * Gets commit seal. + * + * @return the commit seal + */ + public SECPSignature getCommitSeal() { + return getPayload().getCommitSeal(); + } + + /** + * Gets the optional parallel post-quantum Falcon seal carried on this commit. + * + * @return the Falcon seal if present, otherwise empty + */ + public Optional getFalconSeal() { + return getPayload().getFalconSeal(); + } + + /** + * Gets digest. + * + * @return the digest + */ + public Hash getDigest() { + return getPayload().getDigest(); + } + + /** + * Decode. + * + * @param data the data + * @return the commit + */ + public static Commit decode(final Bytes data) { + final RLPInput rlpIn = RLP.input(data); + + return new Commit(readPayload(rlpIn, CommitPayload::readFrom)); + } +} diff --git a/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/network/QbftMessageTransmitter.java b/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/network/QbftMessageTransmitter.java new file mode 100644 index 0000000..ebd0771 --- /dev/null +++ b/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/network/QbftMessageTransmitter.java @@ -0,0 +1,169 @@ +/* + * Copyright ConsenSys AG. + * + * 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.core.network; + +import org.hyperledger.besu.consensus.common.bft.ConsensusRoundIdentifier; +import org.hyperledger.besu.consensus.common.bft.FalconSeal; +import org.hyperledger.besu.consensus.common.bft.network.ValidatorMulticaster; +import org.hyperledger.besu.consensus.common.bft.payload.SignedData; +import org.hyperledger.besu.consensus.qbft.core.messagedata.CommitMessageData; +import org.hyperledger.besu.consensus.qbft.core.messagedata.PrepareMessageData; +import org.hyperledger.besu.consensus.qbft.core.messagedata.ProposalMessageData; +import org.hyperledger.besu.consensus.qbft.core.messagedata.RoundChangeMessageData; +import org.hyperledger.besu.consensus.qbft.core.messagewrappers.Commit; +import org.hyperledger.besu.consensus.qbft.core.messagewrappers.Prepare; +import org.hyperledger.besu.consensus.qbft.core.messagewrappers.Proposal; +import org.hyperledger.besu.consensus.qbft.core.messagewrappers.RoundChange; +import org.hyperledger.besu.consensus.qbft.core.payload.MessageFactory; +import org.hyperledger.besu.consensus.qbft.core.payload.PreparePayload; +import org.hyperledger.besu.consensus.qbft.core.payload.RoundChangePayload; +import org.hyperledger.besu.consensus.qbft.core.statemachine.PreparedCertificate; +import org.hyperledger.besu.consensus.qbft.core.types.QbftBlock; +import org.hyperledger.besu.crypto.SECPSignature; +import org.hyperledger.besu.datatypes.Hash; +import org.hyperledger.besu.ethereum.mainnet.block.access.list.BlockAccessList; +import org.hyperledger.besu.plugin.services.securitymodule.SecurityModuleException; + +import java.util.List; +import java.util.Optional; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** The Qbft message transmitter. */ +public class QbftMessageTransmitter { + + private static final Logger LOG = LoggerFactory.getLogger(QbftMessageTransmitter.class); + + private final MessageFactory messageFactory; + private final ValidatorMulticaster multicaster; + + /** + * Instantiates a new Qbft message transmitter. + * + * @param messageFactory the message factory + * @param multicaster the multicaster + */ + public QbftMessageTransmitter( + final MessageFactory messageFactory, final ValidatorMulticaster multicaster) { + this.messageFactory = messageFactory; + this.multicaster = multicaster; + } + + /** + * Multicast proposal. + * + * @param roundIdentifier the round identifier + * @param block the block + * @param blockAccessList the block access list + * @param roundChanges the round changes + * @param prepares the prepares + */ + public void multicastProposal( + final ConsensusRoundIdentifier roundIdentifier, + final QbftBlock block, + final Optional blockAccessList, + final List> roundChanges, + final List> prepares) { + try { + final Proposal data = + messageFactory.createProposal( + roundIdentifier, block, blockAccessList, roundChanges, prepares); + + final ProposalMessageData message = ProposalMessageData.create(data); + + multicaster.send(message); + } catch (final SecurityModuleException e) { + LOG.warn("Failed to generate signature for Proposal (not sent): {} ", e.getMessage()); + } + } + + /** + * Multicast prepare. + * + * @param roundIdentifier the round identifier + * @param digest the digest + */ + public void multicastPrepare(final ConsensusRoundIdentifier roundIdentifier, final Hash digest) { + try { + final Prepare data = messageFactory.createPrepare(roundIdentifier, digest); + + final PrepareMessageData message = PrepareMessageData.create(data); + + multicaster.send(message); + } catch (final SecurityModuleException e) { + LOG.warn("Failed to generate signature for Prepare (not sent): {} ", e.getMessage()); + } + } + + /** + * Multicast commit. + * + * @param roundIdentifier the round identifier + * @param digest the digest + * @param commitSeal the commit seal + */ + public void multicastCommit( + final ConsensusRoundIdentifier roundIdentifier, + final Hash digest, + final SECPSignature commitSeal) { + multicastCommit(roundIdentifier, digest, commitSeal, Optional.empty()); + } + + /** + * Multicast commit carrying an optional parallel Falcon-512 seal (AERE hybrid PQC). + * + * @param roundIdentifier the round identifier + * @param digest the digest + * @param commitSeal the ECDSA commit seal + * @param falconSeal the optional parallel post-quantum Falcon seal + */ + public void multicastCommit( + final ConsensusRoundIdentifier roundIdentifier, + final Hash digest, + final SECPSignature commitSeal, + final Optional falconSeal) { + try { + final Commit data = messageFactory.createCommit(roundIdentifier, digest, commitSeal, falconSeal); + + final CommitMessageData message = CommitMessageData.create(data); + + multicaster.send(message); + } catch (final SecurityModuleException e) { + LOG.warn("Failed to generate signature for Commit (not sent): {} ", e.getMessage()); + } + } + + /** + * Multicast round change. + * + * @param roundIdentifier the round identifier + * @param preparedRoundCertificate the prepared round certificate + */ + public void multicastRoundChange( + final ConsensusRoundIdentifier roundIdentifier, + final Optional preparedRoundCertificate) { + try { + final RoundChange data = + messageFactory.createRoundChange(roundIdentifier, preparedRoundCertificate); + + final RoundChangeMessageData message = RoundChangeMessageData.create(data); + + multicaster.send(message); + } catch (final SecurityModuleException e) { + LOG.warn("Failed to generate signature for RoundChange (not sent): {} ", e.getMessage()); + } + } +} diff --git a/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/CommitPayload.java b/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/CommitPayload.java new file mode 100644 index 0000000..4aefac6 --- /dev/null +++ b/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/CommitPayload.java @@ -0,0 +1,254 @@ +/* + * Copyright ConsenSys AG. + * + * 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.core.payload; + +import org.hyperledger.besu.consensus.common.bft.ConsensusRoundIdentifier; +import org.hyperledger.besu.consensus.common.bft.FalconSeal; +import org.hyperledger.besu.consensus.common.bft.payload.Payload; +import org.hyperledger.besu.consensus.qbft.core.messagedata.QbftV1; +import org.hyperledger.besu.crypto.SECPSignature; +import org.hyperledger.besu.crypto.SignatureAlgorithmFactory; +import org.hyperledger.besu.datatypes.Hash; +import org.hyperledger.besu.ethereum.rlp.RLPException; +import org.hyperledger.besu.ethereum.rlp.RLPInput; +import org.hyperledger.besu.ethereum.rlp.RLPOutput; + +import java.util.Objects; +import java.util.Optional; +import java.util.StringJoiner; + +import org.apache.tuweni.bytes.Bytes; + +/** + * The Commit payload. + * + *

AERE hybrid PQC extension: a Commit payload MAY carry an OPTIONAL parallel Falcon-512 seal, so + * that every validator's post-quantum seal over the commit hash is GOSSIPED alongside the decisive + * ECDSA commit seal on the same message. The Falcon seal is appended only when present, so a + * Falcon-less Commit encodes byte-for-byte identically to upstream Besu. When present, the whole + * payload (including the Falcon seal) is covered by the author's ECDSA signature, binding the + * claimed Falcon validator index to the sender's identity. + * + *

AERE FIX-MALEABILITATE: this payload has exactly ONE valid encoding for a given value. See + * {@link #readFrom(RLPInput)}. + */ +public class CommitPayload extends QbftPayload { + private static final int TYPE = QbftV1.COMMIT; + private final ConsensusRoundIdentifier roundIdentifier; + private final Hash digest; + private final SECPSignature commitSeal; + private final Optional falconSeal; + + /** + * Instantiates a new Commit payload (no Falcon seal). + * + * @param roundIdentifier the round identifier + * @param digest the digest + * @param commitSeal the commit seal + */ + public CommitPayload( + final ConsensusRoundIdentifier roundIdentifier, + final Hash digest, + final SECPSignature commitSeal) { + this(roundIdentifier, digest, commitSeal, Optional.empty()); + } + + /** + * Instantiates a new Commit payload with an optional parallel Falcon-512 seal. + * + * @param roundIdentifier the round identifier + * @param digest the digest + * @param commitSeal the ECDSA commit seal (decisive) + * @param falconSeal the optional parallel post-quantum Falcon seal over the same commit hash + */ + public CommitPayload( + final ConsensusRoundIdentifier roundIdentifier, + final Hash digest, + final SECPSignature commitSeal, + final Optional falconSeal) { + this.roundIdentifier = roundIdentifier; + this.digest = digest; + this.commitSeal = commitSeal; + this.falconSeal = falconSeal == null ? Optional.empty() : falconSeal; + } + + /** + * Read from rlp input and return commit payload. + * + *

AERE FIX-MALEABILITATE: this decoder is STRICTLY CANONICAL. The payload is taken as an + * isolated RLP item, decoded, re-encoded, and the re-encoding must reproduce the received bytes + * exactly. Anything else is rejected. + * + *

Why canonical and not forward-tolerant. A Commit is an AUTHENTICATED consensus message: + * {@code SignedData.create} recovers the author from {@link QbftPayload#hashForSignature()}, + * which is computed from the RE-ENCODED payload, not from the bytes that arrived. Any input the + * decoder silently ignores therefore does not change the recovered author, so one genuine Commit + * can be rewritten into several distinct byte strings that all still authenticate to the original + * validator. QBFT itself is not fooled, because it deduplicates Commits by AUTHOR, so no vote can + * be forged, stolen or double counted. The gossip layer is, because it deduplicates by message + * CONTENT. Tolerating ignorable input is therefore purely a message amplification channel into + * consensus, and it buys nothing in return, for the reason in the next paragraph. + * + *

What forward tolerance does NOT buy, stated correctly. An earlier version of this method + * ended both lists with {@code leaveListLenient()} and claimed that this generation of nodes + * "can never be made to discard a Commit by a newer peer". That claim was FALSE. Consider a + * future binary that appends a MEANINGFUL element to this payload: it signs over ITS OWN + * encoding, which includes that element. This binary would skip the element, re-encode WITHOUT + * it, obtain a different {@code hashForSignature}, ecrecover a different address, and drop the + * Commit anyway, as coming from a non-validator. Leniency only moves the discard from RLP + * decoding to author validation; it does not prevent it. The only thing that actually lets an old + * binary keep accepting a new peer's Commits is for the new peer NOT to change the signed bytes, + * which is exactly what the attachment gate in {@code FalconSealSupport} enforces. + * + *

Consequence for future format changes: adding any element to this payload is a CONSENSUS + * BREAKING change and must be rolled out behind a height gate, never by relying on old nodes to + * ignore it. + * + * @param rlpInput the rlp input + * @return the commit payload + * @throws RLPException if the received bytes are not the unique canonical encoding of the payload + */ + public static CommitPayload readFrom(final RLPInput rlpInput) { + // Take the payload as a self-contained RLP item so the EXACT bytes received for it are + // available for the canonicality check below. This also advances the enclosing input past the + // payload, exactly as the previous enterList/leaveList pair did. + final RLPInput payloadRlp = rlpInput.readAsRlp(); + final Bytes received = payloadRlp.raw(); + + payloadRlp.enterList(); + final ConsensusRoundIdentifier roundIdentifier = readConsensusRound(payloadRlp); + final Hash digest = Payload.readDigest(payloadRlp); + final SECPSignature commitSeal = + payloadRlp.readBytes(SignatureAlgorithmFactory.getInstance()::decodeSignature); + + // AERE hybrid PQC: an OPTIONAL parallel Falcon-512 seal [index, sig] may follow the ECDSA seal. + // A Falcon-less commit ends the list here and decodes to Optional.empty(). + Optional falconSeal = Optional.empty(); + if (!payloadRlp.isEndOfCurrentList()) { + payloadRlp.enterList(); + final int idx = payloadRlp.readIntScalar(); + final Bytes sig = payloadRlp.readBytes(); + payloadRlp.leaveList(); + falconSeal = Optional.of(new FalconSeal(idx, sig)); + } + payloadRlp.leaveList(); + + final CommitPayload payload = new CommitPayload(roundIdentifier, digest, commitSeal, falconSeal); + + // AERE FIX-MALEABILITATE: exactly one encoding is accepted for a given payload value. This + // catches everything the RLP reader itself would tolerate, including any element the decode + // path above does not consume, and any encoding difference the reader does not consider an + // error. Cheap: one re-encode of a message that is already about to be keccak hashed for + // signature recovery. + final Bytes reencoded = payload.encoded(); + if (!reencoded.equals(received)) { + throw new RLPException( + "Non-canonical Commit payload encoding: received " + + received.size() + + " bytes, canonical form is " + + reencoded.size() + + " bytes"); + } + + return payload; + } + + @Override + public void writeTo(final RLPOutput rlpOutput) { + rlpOutput.startList(); + writeConsensusRound(rlpOutput); + rlpOutput.writeBytes(digest.getBytes()); + rlpOutput.writeBytes(commitSeal.encodedBytes()); + // AERE hybrid PQC: append the parallel Falcon seal only when present, so a Falcon-less commit is + // byte-identical to upstream Besu. The author's ECDSA signature covers this element. + // This method defines THE canonical encoding: readFrom rejects anything that does not + // reproduce byte for byte what this method writes. + if (falconSeal.isPresent()) { + final FalconSeal fs = falconSeal.get(); + rlpOutput.startList(); + rlpOutput.writeIntScalar(fs.getValidatorIndex()); + rlpOutput.writeBytes(fs.getSignature()); + rlpOutput.endList(); + } + rlpOutput.endList(); + } + + @Override + public int getMessageType() { + return TYPE; + } + + /** + * Gets digest. + * + * @return the digest + */ + public Hash getDigest() { + return digest; + } + + /** + * Gets commit seal. + * + * @return the commit seal + */ + public SECPSignature getCommitSeal() { + return commitSeal; + } + + /** + * Gets the optional parallel post-quantum Falcon seal. + * + * @return the Falcon seal if this commit carried one, otherwise empty + */ + public Optional getFalconSeal() { + return falconSeal; + } + + @Override + public ConsensusRoundIdentifier getRoundIdentifier() { + return roundIdentifier; + } + + @Override + public boolean equals(final Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + final CommitPayload that = (CommitPayload) o; + return Objects.equals(roundIdentifier, that.roundIdentifier) + && Objects.equals(digest, that.digest) + && Objects.equals(commitSeal, that.commitSeal) + && Objects.equals(falconSeal, that.falconSeal); + } + + @Override + public int hashCode() { + return Objects.hash(roundIdentifier, digest, commitSeal, falconSeal); + } + + @Override + public String toString() { + return new StringJoiner(", ", CommitPayload.class.getSimpleName() + "[", "]") + .add("roundIdentifier=" + roundIdentifier) + .add("digest=" + digest) + .add("commitSeal=" + commitSeal) + .add("falconSeal=" + falconSeal) + .toString(); + } +} diff --git a/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/MessageFactory.java b/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/MessageFactory.java new file mode 100644 index 0000000..da18fd3 --- /dev/null +++ b/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/payload/MessageFactory.java @@ -0,0 +1,188 @@ +/* + * Copyright ConsenSys AG. + * + * 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.core.payload; + +import org.hyperledger.besu.consensus.common.bft.ConsensusRoundIdentifier; +import org.hyperledger.besu.consensus.common.bft.FalconSeal; +import org.hyperledger.besu.consensus.common.bft.payload.Payload; +import org.hyperledger.besu.consensus.common.bft.payload.SignedData; +import org.hyperledger.besu.consensus.qbft.core.messagewrappers.Commit; +import org.hyperledger.besu.consensus.qbft.core.messagewrappers.Prepare; +import org.hyperledger.besu.consensus.qbft.core.messagewrappers.Proposal; +import org.hyperledger.besu.consensus.qbft.core.messagewrappers.RoundChange; +import org.hyperledger.besu.consensus.qbft.core.statemachine.PreparedCertificate; +import org.hyperledger.besu.consensus.qbft.core.types.QbftBlock; +import org.hyperledger.besu.consensus.qbft.core.types.QbftBlockCodec; +import org.hyperledger.besu.crypto.SECPSignature; +import org.hyperledger.besu.cryptoservices.NodeKey; +import org.hyperledger.besu.datatypes.Hash; +import org.hyperledger.besu.ethereum.mainnet.block.access.list.BlockAccessList; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import org.apache.tuweni.bytes.Bytes32; + +/** The Message factory. */ +public class MessageFactory { + + private final NodeKey nodeKey; + private final QbftBlockCodec blockEncoder; + + /** + * Instantiates a new Message factory. + * + * @param nodeKey the node key + * @param blockEncoder the block encoder + */ + public MessageFactory(final NodeKey nodeKey, final QbftBlockCodec blockEncoder) { + this.nodeKey = nodeKey; + this.blockEncoder = blockEncoder; + } + + /** + * Create proposal. + * + * @param roundIdentifier the round identifier + * @param block the block + * @param blockAccessList the block access list + * @param roundChanges the round changes + * @param prepares the prepares + * @return the proposal + */ + public Proposal createProposal( + final ConsensusRoundIdentifier roundIdentifier, + final QbftBlock block, + final Optional blockAccessList, + final List> roundChanges, + final List> prepares) { + + final ProposalPayload payload = + new ProposalPayload(roundIdentifier, block, blockEncoder, blockAccessList); + + return new Proposal(createSignedMessage(payload), roundChanges, prepares); + } + + /** + * Create proposal. + * + * @param roundIdentifier the round identifier + * @param block the block + * @param roundChanges the round changes + * @param prepares the prepares + * @return the proposal + */ + public Proposal createProposal( + final ConsensusRoundIdentifier roundIdentifier, + final QbftBlock block, + final List> roundChanges, + final List> prepares) { + return createProposal(roundIdentifier, block, Optional.empty(), roundChanges, prepares); + } + + /** + * Create Prepare payload. + * + * @param roundIdentifier the round identifier + * @param digest the digest + * @return the prepare + */ + public Prepare createPrepare(final ConsensusRoundIdentifier roundIdentifier, final Hash digest) { + final PreparePayload payload = new PreparePayload(roundIdentifier, digest); + return new Prepare(createSignedMessage(payload)); + } + + /** + * Create commit payload. + * + * @param roundIdentifier the round identifier + * @param digest the digest + * @param commitSeal the commit seal + * @return the commit + */ + public Commit createCommit( + final ConsensusRoundIdentifier roundIdentifier, + final Hash digest, + final SECPSignature commitSeal) { + return createCommit(roundIdentifier, digest, commitSeal, Optional.empty()); + } + + /** + * Create commit payload carrying an optional parallel Falcon-512 seal. The whole payload + * (including the Falcon seal) is signed by this node's ECDSA key. + * + * @param roundIdentifier the round identifier + * @param digest the digest + * @param commitSeal the ECDSA commit seal + * @param falconSeal the optional parallel post-quantum Falcon seal over the same commit hash + * @return the commit + */ + public Commit createCommit( + final ConsensusRoundIdentifier roundIdentifier, + final Hash digest, + final SECPSignature commitSeal, + final Optional falconSeal) { + final CommitPayload payload = + new CommitPayload(roundIdentifier, digest, commitSeal, falconSeal); + return new Commit(createSignedMessage(payload)); + } + + /** + * Create round change payload. + * + * @param roundIdentifier the round identifier + * @param preparedRoundData the prepared round data + * @return the round change + */ + public RoundChange createRoundChange( + final ConsensusRoundIdentifier roundIdentifier, + final Optional preparedRoundData) { + + final RoundChangePayload payload; + if (preparedRoundData.isPresent()) { + + final QbftBlock preparedBlock = preparedRoundData.get().getBlock(); + payload = + new RoundChangePayload( + roundIdentifier, + Optional.of( + new PreparedRoundMetadata( + preparedBlock.getHash(), preparedRoundData.get().getRound()))); + + return new RoundChange( + createSignedMessage(payload), + Optional.of(preparedBlock), + preparedRoundData.get().getBlockAccessList(), + blockEncoder, + preparedRoundData.get().getPrepares()); + + } else { + payload = new RoundChangePayload(roundIdentifier, Optional.empty()); + return new RoundChange( + createSignedMessage(payload), + Optional.empty(), + Optional.empty(), + blockEncoder, + Collections.emptyList()); + } + } + + private SignedData createSignedMessage(final M payload) { + final SECPSignature signature = + nodeKey.sign(Bytes32.wrap(payload.hashForSignature().getBytes())); + return SignedData.create(payload, signature); + } +} diff --git a/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/QbftBlockHeightManager.java b/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/QbftBlockHeightManager.java new file mode 100644 index 0000000..4a20d6b --- /dev/null +++ b/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/QbftBlockHeightManager.java @@ -0,0 +1,527 @@ +/* + * Copyright ConsenSys AG. + * + * 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.core.statemachine; + +import org.hyperledger.besu.consensus.common.bft.ConsensusRoundIdentifier; +import org.hyperledger.besu.consensus.common.bft.events.RoundExpiry; +import org.hyperledger.besu.consensus.common.bft.messagewrappers.BftMessage; +import org.hyperledger.besu.consensus.common.bft.PqAnchorNotReadyException; +import org.hyperledger.besu.consensus.common.bft.payload.Payload; +import org.hyperledger.besu.consensus.qbft.core.messagewrappers.Commit; +import org.hyperledger.besu.consensus.qbft.core.messagewrappers.Prepare; +import org.hyperledger.besu.consensus.qbft.core.messagewrappers.Proposal; +import org.hyperledger.besu.consensus.qbft.core.messagewrappers.RoundChange; +import org.hyperledger.besu.consensus.qbft.core.network.QbftMessageTransmitter; +import org.hyperledger.besu.consensus.qbft.core.payload.MessageFactory; +import org.hyperledger.besu.consensus.qbft.core.types.QbftBlock; +import org.hyperledger.besu.consensus.qbft.core.types.QbftBlockCreator; +import org.hyperledger.besu.consensus.qbft.core.types.QbftBlockHeader; +import org.hyperledger.besu.consensus.qbft.core.types.QbftFinalState; +import org.hyperledger.besu.consensus.qbft.core.types.QbftValidatorProvider; +import org.hyperledger.besu.consensus.qbft.core.validation.FutureRoundProposalMessageValidator; +import org.hyperledger.besu.consensus.qbft.core.validation.MessageValidatorFactory; +import org.hyperledger.besu.datatypes.Address; +import org.hyperledger.besu.ethereum.mainnet.block.access.list.BlockAccessList; +import org.hyperledger.besu.plugin.services.securitymodule.SecurityModuleException; + +import java.time.Clock; +import java.util.Collection; +import java.util.Collections; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.TimeUnit; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.function.Function; + +import com.google.common.collect.Maps; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Responsible for starting/clearing Consensus rounds at a given block height. One of these is + * created when a new block is imported to the chain. It immediately then creates a Round-0 object, + * and sends a Proposal message. If the round times out prior to importing a block, this class is + * responsible for creating a RoundChange message and transmitting it. + */ +public class QbftBlockHeightManager implements BaseQbftBlockHeightManager { + + private static final Logger LOG = LoggerFactory.getLogger(QbftBlockHeightManager.class); + + private final QbftRoundFactory roundFactory; + private final QbftValidatorProvider validatorProvider; + private final RoundChangeManager roundChangeManager; + private final QbftBlockHeader parentHeader; + private final QbftMessageTransmitter transmitter; + private final MessageFactory messageFactory; + private final Map futureRoundStateBuffer = Maps.newHashMap(); + private final FutureRoundProposalMessageValidator futureRoundProposalMessageValidator; + private final Clock clock; + private final Function roundStateCreator; + private final QbftFinalState finalState; + + private Optional latestPreparedCertificate = Optional.empty(); + private Optional currentRound = Optional.empty(); + private boolean isEarlyRoundChangeEnabled = false; + + /** + * Instantiates a new Qbft block height manager. + * + * @param parentHeader the parent header + * @param finalState the final state + * @param roundChangeManager the round change manager + * @param qbftRoundFactory the qbft round factory + * @param clock the clock + * @param messageValidatorFactory the message validator factory + * @param messageFactory the message factory + * @param validatorProvider the validator provider + */ + public QbftBlockHeightManager( + final QbftBlockHeader parentHeader, + final QbftFinalState finalState, + final RoundChangeManager roundChangeManager, + final QbftRoundFactory qbftRoundFactory, + final Clock clock, + final MessageValidatorFactory messageValidatorFactory, + final MessageFactory messageFactory, + final QbftValidatorProvider validatorProvider) { + this.parentHeader = parentHeader; + this.roundFactory = qbftRoundFactory; + this.validatorProvider = validatorProvider; + this.transmitter = + new QbftMessageTransmitter(messageFactory, finalState.getValidatorMulticaster()); + this.messageFactory = messageFactory; + this.clock = clock; + this.roundChangeManager = roundChangeManager; + this.finalState = finalState; + + futureRoundProposalMessageValidator = + messageValidatorFactory.createFutureRoundProposalMessageValidator( + getChainHeight(), parentHeader); + + roundStateCreator = + (roundIdentifier) -> + new RoundState( + roundIdentifier, + finalState.getQuorum(), + messageValidatorFactory.createMessageValidator(roundIdentifier, parentHeader)); + + final long nextBlockHeight = parentHeader.getNumber() + 1; + final ConsensusRoundIdentifier roundIdentifier = + new ConsensusRoundIdentifier(nextBlockHeight, 0); + + finalState.getBlockTimer().startTimer(roundIdentifier, parentHeader::getTimestamp); + } + + /** + * Instantiates a new Qbft block height manager. Secondary constructor with early round change + * option. + * + * @param parentHeader the parent header + * @param finalState the final state + * @param roundChangeManager the round change manager + * @param qbftRoundFactory the qbft round factory + * @param clock the clock + * @param messageValidatorFactory the message validator factory + * @param messageFactory the message factory + * @param validatorProvider the validator provider + * @param isEarlyRoundChangeEnabled enable round change when f+1 RC messages are received + */ + public QbftBlockHeightManager( + final QbftBlockHeader parentHeader, + final QbftFinalState finalState, + final RoundChangeManager roundChangeManager, + final QbftRoundFactory qbftRoundFactory, + final Clock clock, + final MessageValidatorFactory messageValidatorFactory, + final MessageFactory messageFactory, + final QbftValidatorProvider validatorProvider, + final boolean isEarlyRoundChangeEnabled) { + this( + parentHeader, + finalState, + roundChangeManager, + qbftRoundFactory, + clock, + messageValidatorFactory, + messageFactory, + validatorProvider); + this.isEarlyRoundChangeEnabled = isEarlyRoundChangeEnabled; + } + + @Override + public void handleBlockTimerExpiry(final ConsensusRoundIdentifier roundIdentifier) { + if (currentRound.isPresent()) { + // It is possible for the block timer to take longer than it should due to the precision of + // the timer in Java and the OS. This means occasionally the proposal can arrive before the + // block timer expiry and hence the round has already been set. There is no negative impact + // on the protocol in this case. + return; + } + + startNewRound(0); + + final QbftRound qbftRound = currentRound.get(); + + logValidatorChanges(qbftRound); + + if (roundIdentifier.equals(qbftRound.getRoundIdentifier())) { + buildBlockAndMaybePropose(roundIdentifier, qbftRound); + } else { + LOG.trace( + "Block timer expired for a round ({}) other than current ({})", + roundIdentifier, + qbftRound.getRoundIdentifier()); + } + } + + private void buildBlockAndMaybePropose( + final ConsensusRoundIdentifier roundIdentifier, final QbftRound qbftRound) { + + // mining will be checked against round 0 as the current round is initialised to 0 above + final boolean isProposer = + finalState.isLocalNodeProposerForRound(qbftRound.getRoundIdentifier()); + + if (!isProposer) { + // nothing to do here... + LOG.trace("This node is not a proposer so it will not send a proposal: " + roundIdentifier); + return; + } + + final long headerTimeStampSeconds = Math.round(clock.millis() / 1000D); + final QbftBlockCreator.BlockCreationResult blockCreationResult; + try { + blockCreationResult = qbftRound.createBlock(headerTimeStampSeconds); + } catch (final RuntimeException thrown) { + // MEASURED 2026-08-02: see PqAnchorNotReadyException.findIn. The refusal arrives wrapped in + // IllegalStateException from AbstractBlockCreator, so the narrow catch never fired. Anything + // that is not a refusal is rethrown untouched. + final PqAnchorNotReadyException e = PqAnchorNotReadyException.findIn(thrown); + if (e == null) { + throw thrown; + } + // AERE ANCHOR V2: this node is the proposer but cannot back the block with a Falcon quorum + // certificate that reaches the staged threshold K, so it does NOT propose. The round times + // out and the next proposer takes over. Proposing anyway would emit a header this node has + // already computed to be invalid under its own rules, and in QBFT it would cost exactly the + // same round timer, so it would buy nothing. The usual cause is a node that has just + // restarted and holds no seals until it takes part in one commit: at most one lost turn. + LOG.warn("AERE PQ-ANCHOR: not proposing. round={}. {}", roundIdentifier, e.getMessage()); + return; + } + final QbftBlock block = blockCreationResult.block(); + final Optional blockAccessList = blockCreationResult.blockAccessList(); + if (!block.isEmpty()) { + LOG.trace( + "Block is not empty and this node is a proposer so it will send a proposal: " + + roundIdentifier); + qbftRound.updateStateWithProposalAndTransmit( + block, blockAccessList, Collections.emptyList(), Collections.emptyList()); + } else { + // handle the block times period + final long currentTimeInMillis = finalState.getClock().millis(); + boolean emptyBlockExpired = + finalState + .getBlockTimer() + .checkEmptyBlockExpired(parentHeader::getTimestamp, currentTimeInMillis); + if (emptyBlockExpired) { + LOG.trace( + "Block has no transactions and this node is a proposer so it will send a proposal: " + + roundIdentifier); + qbftRound.updateStateWithProposalAndTransmit( + block, blockAccessList, Collections.emptyList(), Collections.emptyList()); + } else { + LOG.trace( + "Block has no transactions but emptyBlockPeriodSeconds did not expired yet: " + + roundIdentifier); + finalState + .getBlockTimer() + .resetTimerForEmptyBlock( + roundIdentifier, parentHeader::getTimestamp, currentTimeInMillis); + finalState.getRoundTimer().cancelTimer(); + currentRound = Optional.empty(); + } + } + } + + /** + * If the list of validators for the next block to be proposed/imported has changed from the + * previous block, log the change. Only log for round 0 (i.e. once per block). + * + * @param qbftRound The current round + */ + private void logValidatorChanges(final QbftRound qbftRound) { + if (qbftRound.getRoundIdentifier().getRoundNumber() == 0) { + final Collection

previousValidators = + validatorProvider.getValidatorsForBlock(parentHeader); + final Collection
validatorsForHeight = + validatorProvider.getValidatorsAfterBlock(parentHeader); + if (!(validatorsForHeight.containsAll(previousValidators)) + || !(previousValidators.containsAll(validatorsForHeight))) { + LOG.info( + "QBFT Validator list change. Previous chain height {}: {}. Current chain height {}: {}.", + parentHeader.getNumber(), + previousValidators, + parentHeader.getNumber() + 1, + validatorsForHeight); + } + } + } + + @Override + public void roundExpired(final RoundExpiry expire) { + if (currentRound.isEmpty()) { + LOG.error( + "Received Round timer expiry before round is created timerRound={}", expire.getView()); + return; + } + + QbftRound qbftRound = currentRound.get(); + if (!expire.getView().equals(qbftRound.getRoundIdentifier())) { + LOG.trace( + "Ignoring Round timer expired which does not match current round. round={}, timerRound={}", + qbftRound.getRoundIdentifier(), + expire.getView()); + return; + } + + doRoundChange(qbftRound.getRoundIdentifier().getRoundNumber() + 1); + } + + private synchronized void doRoundChange(final int newRoundNumber) { + + if (currentRound.isPresent() + && currentRound.get().getRoundIdentifier().getRoundNumber() >= newRoundNumber) { + return; + } + LOG.debug( + "Round has expired or changing based on RC quorum, creating PreparedCertificate and notifying peers. round={}", + currentRound.get().getRoundIdentifier()); + final Optional preparedCertificate = + currentRound.get().constructPreparedCertificate(); + + if (preparedCertificate.isPresent()) { + latestPreparedCertificate = preparedCertificate; + } + + startNewRound(newRoundNumber); + if (currentRound.isEmpty()) { + LOG.info("Failed to start round "); + return; + } + QbftRound qbftRoundNew = currentRound.get(); + + try { + final RoundChange localRoundChange = + messageFactory.createRoundChange( + qbftRoundNew.getRoundIdentifier(), latestPreparedCertificate); + + // Its possible the locally created RoundChange triggers the transmission of a NewRound + // message - so it must be handled accordingly. + handleRoundChangePayload(localRoundChange); + } catch (final SecurityModuleException e) { + LOG.warn("Failed to create signed RoundChange message.", e); + } + + transmitter.multicastRoundChange(qbftRoundNew.getRoundIdentifier(), latestPreparedCertificate); + } + + @Override + public void handleProposalPayload(final Proposal proposal) { + LOG.trace("Received a Proposal Payload."); + final MessageAge messageAge = + determineAgeOfPayload(proposal.getRoundIdentifier().getRoundNumber()); + + if (messageAge == MessageAge.PRIOR_ROUND) { + LOG.trace("Received Proposal Payload for a prior round={}", proposal.getRoundIdentifier()); + } else { + if (messageAge == MessageAge.FUTURE_ROUND) { + if (!futureRoundProposalMessageValidator.validateProposalMessage(proposal)) { + LOG.info("Received future Proposal which is illegal, no round change triggered."); + return; + } + startNewRound(proposal.getRoundIdentifier().getRoundNumber()); + } + currentRound.ifPresent(r -> r.handleProposalMessage(proposal)); + } + } + + @Override + public void handlePreparePayload(final Prepare prepare) { + LOG.trace("Received a Prepare Payload."); + actionOrBufferMessage( + prepare, + currentRound.isPresent() ? currentRound.get()::handlePrepareMessage : (ignore) -> {}, + RoundState::addPrepareMessage); + } + + @Override + public void handleCommitPayload(final Commit commit) { + LOG.trace("Received a Commit Payload."); + actionOrBufferMessage( + commit, + currentRound.isPresent() ? currentRound.get()::handleCommitMessage : (ignore) -> {}, + RoundState::addCommitMessage); + } + + private

> void actionOrBufferMessage( + final M qbftMessage, + final Consumer inRoundHandler, + final BiConsumer buffer) { + final MessageAge messageAge = + determineAgeOfPayload(qbftMessage.getRoundIdentifier().getRoundNumber()); + if (messageAge == MessageAge.CURRENT_ROUND) { + inRoundHandler.accept(qbftMessage); + } else if (messageAge == MessageAge.FUTURE_ROUND) { + final ConsensusRoundIdentifier msgRoundId = qbftMessage.getRoundIdentifier(); + final RoundState roundstate = + futureRoundStateBuffer.computeIfAbsent( + msgRoundId.getRoundNumber(), k -> roundStateCreator.apply(msgRoundId)); + buffer.accept(roundstate, qbftMessage); + } + } + + @Override + public void handleRoundChangePayload(final RoundChange message) { + final ConsensusRoundIdentifier targetRound = message.getRoundIdentifier(); + + LOG.debug( + "Round change from {}: block {}, round {}", + message.getAuthor(), + message.getRoundIdentifier().getSequenceNumber(), + message.getRoundIdentifier().getRoundNumber()); + + // Diagnostic logging (only logs anything if the chain has stalled) + roundChangeManager.storeAndLogRoundChangeSummary(message); + + final MessageAge messageAge = + determineAgeOfPayload(message.getRoundIdentifier().getRoundNumber()); + if (messageAge == MessageAge.PRIOR_ROUND) { + LOG.debug("Received RoundChange Payload for a prior round. targetRound={}", targetRound); + return; + } + + final Optional> result = + roundChangeManager.appendRoundChangeMessage(message); + + if (!isEarlyRoundChangeEnabled) { + if (result.isPresent()) { + LOG.debug( + "Received sufficient RoundChange messages to change round to targetRound={}", + targetRound); + if (messageAge == MessageAge.FUTURE_ROUND) { + startNewRound(targetRound.getRoundNumber()); + } + + final RoundChangeArtifacts roundChangeMetadata = RoundChangeArtifacts.create(result.get()); + + if (finalState.isLocalNodeProposerForRound(targetRound)) { + if (currentRound.isEmpty()) { + startNewRound(0); + } + currentRound + .get() + .startRoundWith(roundChangeMetadata, TimeUnit.MILLISECONDS.toSeconds(clock.millis())); + } + } + } else { + + if (currentRound.isEmpty()) { + startNewRound(0); + } + int currentRoundNumber = currentRound.get().getRoundIdentifier().getRoundNumber(); + // If this node is proposer for the current round, check if quorum is achieved for RC messages + // aiming this round + if (targetRound.getRoundNumber() == currentRoundNumber + && finalState.isLocalNodeProposerForRound(targetRound) + && result.isPresent()) { + + final RoundChangeArtifacts roundChangeMetadata = RoundChangeArtifacts.create(result.get()); + + currentRound + .get() + .startRoundWith(roundChangeMetadata, TimeUnit.MILLISECONDS.toSeconds(clock.millis())); + } + + // check if f+1 RC messages for future rounds are received + QbftRound qbftRound = currentRound.get(); + Optional nextHigherRound = + roundChangeManager.futureRCQuorumReceived(qbftRound.getRoundIdentifier()); + if (nextHigherRound.isPresent()) { + LOG.info( + "Received sufficient RoundChange messages to change round to targetRound={}", + nextHigherRound.get()); + doRoundChange(nextHigherRound.get()); + } + } + } + + private void startNewRound(final int roundNumber) { + LOG.debug("Starting new round {}", roundNumber); + // validate the current round + if (futureRoundStateBuffer.containsKey(roundNumber)) { + currentRound = + Optional.of( + roundFactory.createNewRoundWithState( + parentHeader, futureRoundStateBuffer.get(roundNumber))); + futureRoundStateBuffer.keySet().removeIf(k -> k <= roundNumber); + } else { + currentRound = Optional.of(roundFactory.createNewRound(parentHeader, roundNumber)); + } + // discard roundChange messages from the current and previous rounds + roundChangeManager.discardRoundsPriorTo(currentRound.get().getRoundIdentifier()); + } + + @Override + public long getChainHeight() { + return parentHeader.getNumber() + 1; + } + + @Override + public QbftBlockHeader getParentBlockHeader() { + return parentHeader; + } + + private MessageAge determineAgeOfPayload(final int messageRoundNumber) { + final int currentRoundNumber = + currentRound.map(r -> r.getRoundIdentifier().getRoundNumber()).orElse(-1); + if (messageRoundNumber > currentRoundNumber) { + return MessageAge.FUTURE_ROUND; + } else if (messageRoundNumber == currentRoundNumber) { + return MessageAge.CURRENT_ROUND; + } + return MessageAge.PRIOR_ROUND; + } + + @Override + public Optional getCurrentRound() { + return currentRound; + } + + @Override + public Optional getRoundChangeManager() { + return Optional.of(roundChangeManager); + } + + /** The enum Message age. */ + public enum MessageAge { + /** Prior round message age. */ + PRIOR_ROUND, + /** Current round message age. */ + CURRENT_ROUND, + /** Future round message age. */ + FUTURE_ROUND + } +} diff --git a/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/QbftRound.java b/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/QbftRound.java new file mode 100644 index 0000000..2b2dd48 --- /dev/null +++ b/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/QbftRound.java @@ -0,0 +1,543 @@ +/* + * Copyright ConsenSys AG. + * + * 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.core.statemachine; + +import static java.util.Collections.emptyList; + +import org.hyperledger.besu.consensus.common.bft.ConsensusRoundIdentifier; +import org.hyperledger.besu.consensus.common.bft.FalconSeal; +import org.hyperledger.besu.consensus.common.bft.FalconSealSupport; +import org.hyperledger.besu.consensus.common.bft.PqAnchor; +import org.hyperledger.besu.consensus.common.bft.PqAnchorNotReadyException; +import org.hyperledger.besu.consensus.common.bft.PqSealCache; +import org.hyperledger.besu.consensus.common.bft.RoundTimer; +import org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer; +import org.hyperledger.besu.consensus.common.bft.payload.SignedData; +import org.hyperledger.besu.consensus.qbft.core.messagewrappers.Commit; +import org.hyperledger.besu.consensus.qbft.core.messagewrappers.Prepare; +import org.hyperledger.besu.consensus.qbft.core.messagewrappers.Proposal; +import org.hyperledger.besu.consensus.qbft.core.network.QbftMessageTransmitter; +import org.hyperledger.besu.consensus.qbft.core.payload.MessageFactory; +import org.hyperledger.besu.consensus.qbft.core.payload.PreparePayload; +import org.hyperledger.besu.consensus.qbft.core.payload.RoundChangePayload; +import org.hyperledger.besu.consensus.qbft.core.types.QbftBlock; +import org.hyperledger.besu.consensus.qbft.core.types.QbftBlockCreator; +import org.hyperledger.besu.consensus.qbft.core.types.QbftBlockCreator.BlockCreationResult; +import org.hyperledger.besu.consensus.qbft.core.types.QbftBlockHeader; +import org.hyperledger.besu.consensus.qbft.core.types.QbftBlockImporter; +import org.hyperledger.besu.consensus.qbft.core.types.QbftBlockInterface; +import org.hyperledger.besu.consensus.qbft.core.types.QbftMinedBlockObserver; +import org.hyperledger.besu.consensus.qbft.core.types.QbftProtocolSchedule; +import org.hyperledger.besu.crypto.SECPSignature; +import org.hyperledger.besu.cryptoservices.NodeKey; +import org.hyperledger.besu.datatypes.Address; +import org.hyperledger.besu.datatypes.Hash; +import org.hyperledger.besu.ethereum.mainnet.block.access.list.BlockAccessList; +import org.hyperledger.besu.plugin.services.securitymodule.SecurityModuleException; +import org.hyperledger.besu.util.Subscribers; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +import org.apache.tuweni.bytes.Bytes32; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** The Qbft round. */ +public class QbftRound { + + private static final Logger LOG = LoggerFactory.getLogger(QbftRound.class); + + private final Subscribers observers; + + /** The Round state. */ + protected final RoundState roundState; + + /** The Block creator. */ + protected final QbftBlockCreator blockCreator; + + /** The Protocol context. */ + protected final QbftBlockInterface blockInterface; + + /** The Protocol schedule. */ + protected final QbftProtocolSchedule protocolSchedule; + + private final NodeKey nodeKey; + private final Address localAddress; + private final MessageFactory messageFactory; // used only to create stored local msgs + private final QbftMessageTransmitter transmitter; + + private final QbftBlockHeader parentHeader; + + // AERE hybrid PQC: guards against re-importing once a block has been successfully imported this + // round, while still allowing a retry if an earlier import attempt was rejected (e.g. a post-fork + // Falcon quorum certificate that was not yet complete when the first attempt ran). + private boolean blockImported = false; + + // AERE ANCHOR V2: the ROUND-INDEPENDENT on-chain hash of the block this round is deciding. + // + // Why it is not simply block.getHash(): a proposal block is built with the COMMITTED-SEAL header + // functions, so its hash includes the round number and differs between rounds for one and the + // same block. The certificate carried by the NEXT block is looked up, and its message M is built, + // from the parent's on-chain hash, which excludes both the commit seals and the round. Keying the + // seal cache on anything round dependent would silently produce empty certificates at every round + // change. Replacing the round with 0 in the committed-seal encoding yields byte-for-byte the + // on-chain pre-image, so this is the same hash the parent header will carry once imported. + private Hash pqOnchainHash; + + /** + * Instantiates a new Qbft round. + * + * @param roundState the round state + * @param blockCreator the block creator + * @param blockInterface the block interface + * @param protocolSchedule the protocol schedule + * @param observers the observers + * @param nodeKey the node key + * @param localAddress the local address + * @param messageFactory the message factory + * @param transmitter the transmitter + * @param roundTimer the round timer + * @param parentHeader the parent header + */ + public QbftRound( + final RoundState roundState, + final QbftBlockCreator blockCreator, + final QbftBlockInterface blockInterface, + final QbftProtocolSchedule protocolSchedule, + final Subscribers observers, + final NodeKey nodeKey, + final Address localAddress, + final MessageFactory messageFactory, + final QbftMessageTransmitter transmitter, + final RoundTimer roundTimer, + final QbftBlockHeader parentHeader) { + this.roundState = roundState; + this.blockCreator = blockCreator; + this.blockInterface = blockInterface; + this.protocolSchedule = protocolSchedule; + this.observers = observers; + this.nodeKey = nodeKey; + this.localAddress = localAddress; + this.messageFactory = messageFactory; + this.transmitter = transmitter; + this.parentHeader = parentHeader; + roundTimer.startTimer(getRoundIdentifier()); + } + + /** + * Gets round identifier. + * + * @return the round identifier + */ + public ConsensusRoundIdentifier getRoundIdentifier() { + return roundState.getRoundIdentifier(); + } + + /** + * Gets round state. + * + * @return the round state + */ + public RoundState getRoundState() { + return roundState; + } + + /** + * Create a block + * + * @param headerTimeStampSeconds of the block + * @return a block creation result + */ + public BlockCreationResult createBlock(final long headerTimeStampSeconds) { + LOG.debug("Creating proposed block. round={}", roundState.getRoundIdentifier()); + return blockCreator.createBlock(headerTimeStampSeconds, this.parentHeader); + } + + /** + * Start round with. + * + * @param roundChangeArtifacts the round change artifacts + * @param headerTimestamp the header timestamp + */ + public void startRoundWith( + final RoundChangeArtifacts roundChangeArtifacts, final long headerTimestamp) { + final Optional bestPreparedCertificate = + roundChangeArtifacts.getBestPreparedPeer(); + + final QbftBlock blockToPublish; + final Optional blockAccessList; + if (bestPreparedCertificate.isEmpty()) { + LOG.debug("Sending proposal with new block. round={}", roundState.getRoundIdentifier()); + final BlockCreationResult blockCreationResult; + try { + blockCreationResult = blockCreator.createBlock(headerTimestamp, this.parentHeader); + } catch (final RuntimeException thrown) { + // MEASURED 2026-08-02: the refusal reaches here WRAPPED. AbstractBlockCreator.createBlock + // catches every exception from the extra-data calculator and rethrows it as + // IllegalStateException, so catching PqAnchorNotReadyException directly never fired and the + // escape aborted handleRoundChangePayload, skipping the multicastRoundChange that + // doRoundChange issues after it. Anything that is NOT a refusal is rethrown untouched. + final PqAnchorNotReadyException e = PqAnchorNotReadyException.findIn(thrown); + if (e == null) { + throw thrown; + } + // AERE ANCHOR V2: this node cannot back the block with a certificate that reaches the + // staged threshold, so it does NOT propose. See PqAnchorNotReadyException for why refusing + // is strictly better than proposing a header we have already computed to be invalid: in + // QBFT both end at the same round timer, so proposing anyway buys no liveness at all. + LOG.warn("AERE PQ-ANCHOR: not proposing. round={}. {}", getRoundIdentifier(), e.getMessage()); + return; + } + blockToPublish = blockCreationResult.block(); + blockAccessList = blockCreationResult.blockAccessList(); + } else { + LOG.debug( + "Sending proposal from PreparedCertificate. round={}", roundState.getRoundIdentifier()); + QbftBlock preparedBlock = bestPreparedCertificate.get().getBlock(); + blockToPublish = + blockInterface.replaceRoundAndProposerForProposalBlock( + preparedBlock, roundState.getRoundIdentifier().getRoundNumber(), localAddress); + blockAccessList = bestPreparedCertificate.get().getBlockAccessList(); + } + + LOG.debug(" proposal - new/prepared block hash : {}", blockToPublish.getHash()); + + updateStateWithProposalAndTransmit( + blockToPublish, + blockAccessList, + roundChangeArtifacts.getRoundChanges(), + bestPreparedCertificate.map(PreparedCertificate::getPrepares).orElse(emptyList())); + } + + /** + * Update state with proposal and transmit. + * + * @param block the block + * @param blockAccessList optional block access list + * @param roundChanges the round changes + * @param prepares the prepares + */ + protected void updateStateWithProposalAndTransmit( + final QbftBlock block, + final Optional blockAccessList, + final List> roundChanges, + final List> prepares) { + final Proposal proposal; + try { + proposal = + messageFactory.createProposal( + getRoundIdentifier(), block, blockAccessList, roundChanges, prepares); + } catch (final SecurityModuleException e) { + LOG.warn("Failed to create a signed Proposal, waiting for next round.", e); + return; + } + + transmitter.multicastProposal( + proposal.getRoundIdentifier(), + proposal.getSignedPayload().getPayload().getProposedBlock(), + proposal.getBlockAccessList(), + roundChanges, + prepares); + if (updateStateWithProposedBlock(proposal)) { + sendPrepare(block); + } + } + + /** + * Handle proposal message. + * + * @param msg the msg + */ + public void handleProposalMessage(final Proposal msg) { + LOG.debug( + "Received a proposal message. round={}. author={}", + roundState.getRoundIdentifier(), + msg.getAuthor()); + final QbftBlock block = msg.getSignedPayload().getPayload().getProposedBlock(); + if (updateStateWithProposedBlock(msg)) { + sendPrepare(block); + } + } + + private void sendPrepare(final QbftBlock block) { + LOG.debug("Sending prepare message. round={}", roundState.getRoundIdentifier()); + try { + final Prepare localPrepareMessage = + messageFactory.createPrepare(getRoundIdentifier(), block.getHash()); + peerIsPrepared(localPrepareMessage); + transmitter.multicastPrepare( + localPrepareMessage.getRoundIdentifier(), localPrepareMessage.getDigest()); + } catch (final SecurityModuleException e) { + LOG.warn("Failed to create a signed Prepare; {}", e.getMessage()); + } + } + + /** + * Handle prepare message. + * + * @param msg the msg + */ + public void handlePrepareMessage(final Prepare msg) { + LOG.debug( + "Received a prepare message. round={}. author={}", + roundState.getRoundIdentifier(), + msg.getAuthor()); + peerIsPrepared(msg); + } + + /** + * Handle commit message. + * + * @param msg the msg + */ + public void handleCommitMessage(final Commit msg) { + LOG.debug( + "Received a commit message. round={}. author={}", + roundState.getRoundIdentifier(), + msg.getAuthor()); + peerIsCommitted(msg); + } + + /** + * Construct prepared certificate. + * + * @return the optional PreparedCertificate + */ + public Optional constructPreparedCertificate() { + return roundState.constructPreparedCertificate(); + } + + private boolean updateStateWithProposedBlock(final Proposal msg) { + final boolean wasPrepared = roundState.isPrepared(); + final boolean blockAccepted = roundState.setProposedBlock(msg); + + if (blockAccepted) { + final QbftBlock block = roundState.getProposedBlock().get(); + + // AERE hybrid PQC: compute the commit hash once, then produce BOTH the decisive ECDSA commit + // seal and the parallel Falcon-512 seal over the SAME commit hash. The Falcon seal is gossiped + // on the commit message so every validator's post-quantum seal reaches the block assembler. + final Hash commitHash; + final SECPSignature commitSeal; + try { + commitHash = commitHashFor(block); + commitSeal = nodeKey.sign(Bytes32.wrap(commitHash.getBytes())); + } catch (final SecurityModuleException e) { + LOG.warn("Failed to construct commit seal; {}", e.getMessage()); + return true; + } + final Optional falconSeal = falconSealFor(block, commitHash); + + // There are times handling a proposed block is enough to enter prepared. + if (wasPrepared != roundState.isPrepared()) { + LOG.debug("Sending commit message. round={}", roundState.getRoundIdentifier()); + if (falconSeal.isPresent()) { + transmitter.multicastCommit(getRoundIdentifier(), block.getHash(), commitSeal, falconSeal); + } else { + transmitter.multicastCommit(getRoundIdentifier(), block.getHash(), commitSeal); + } + } + + // can automatically add _our_ commit message to the roundState + // cannot create a prepare message here, as it may be _our_ proposal, and thus we cannot also + // prepare + try { + final Commit localCommitMessage = + falconSeal.isPresent() + ? messageFactory.createCommit( + roundState.getRoundIdentifier(), + msg.getBlock().getHash(), + commitSeal, + falconSeal) + : messageFactory.createCommit( + roundState.getRoundIdentifier(), msg.getBlock().getHash(), commitSeal); + roundState.addCommitMessage(localCommitMessage); + } catch (final SecurityModuleException e) { + LOG.warn("Failed to create signed Commit message; {}", e.getMessage()); + return true; + } + + // AERE ANCHOR V2: remember every Falcon seal heard for THIS block, so the proposer of the + // NEXT block can carry a certificate over it. This is the local node's own seal. + pqCacheHeardSeals(block); + + // It is possible sufficient commit seals are now available and the block should be imported + maybeImportBlock(); + } + + return blockAccepted; + } + + private void peerIsPrepared(final Prepare msg) { + final boolean wasPrepared = roundState.isPrepared(); + roundState.addPrepareMessage(msg); + if (wasPrepared != roundState.isPrepared()) { + LOG.debug("Sending commit message. round={}", roundState.getRoundIdentifier()); + final QbftBlock block = roundState.getProposedBlock().get(); + try { + final Hash commitHash = commitHashFor(block); + final SECPSignature commitSeal = nodeKey.sign(Bytes32.wrap(commitHash.getBytes())); + final Optional falconSeal = falconSealFor(block, commitHash); + if (falconSeal.isPresent()) { + transmitter.multicastCommit(getRoundIdentifier(), block.getHash(), commitSeal, falconSeal); + } else { + transmitter.multicastCommit(getRoundIdentifier(), block.getHash(), commitSeal); + } + // Note: the local-node's commit message was added to RoundState on block acceptance + // and thus does not need to be done again here. + } catch (final SecurityModuleException e) { + LOG.warn("Failed to construct a commit seal: {}", e.getMessage()); + } + } + } + + private void peerIsCommitted(final Commit msg) { + roundState.addCommitMessage(msg); + // AERE ANCHOR V2: top up the seal cache on EVERY commit, including commits that arrive after + // this node has already imported the block. Those late seals are exactly the ones that let a + // proposer reach the threshold K without waiting for its own round to be re-run. + roundState.getProposedBlock().ifPresent(this::pqCacheHeardSeals); + // AERE hybrid PQC: attempt import on every commit while not yet imported. In the common case + // this fires exactly once (on the quorum-th commit). Post-fork, if the first attempt lacked a + // complete Falcon quorum certificate (e.g. one of the quorum's validators had a faulty Falcon + // key), a later commit that carries a valid Falcon seal lets the honest quorum complete. + maybeImportBlock(); + } + + private void maybeImportBlock() { + if (roundState.isCommitted() && !blockImported) { + importBlockToChain(); + } + } + + private void importBlockToChain() { + + // AERE hybrid PQC: pass the gossiped Falcon seals collected from commit messages so the block + // assembler can embed a >= 2f+1 Falcon quorum certificate. When no Falcon seals were gossiped + // (Falcon disabled), fall back to the unchanged ECDSA-only sealing path. + final Collection falconSeals = roundState.getFalconSeals(); + final QbftBlock blockToImport = + falconSeals.isEmpty() + ? blockCreator.createSealedBlock( + roundState.getProposedBlock().get(), + roundState.getRoundIdentifier().getRoundNumber(), + roundState.getCommitSeals()) + : blockCreator.createSealedBlock( + roundState.getProposedBlock().get(), + roundState.getRoundIdentifier().getRoundNumber(), + roundState.getCommitSeals(), + falconSeals); + + final long blockNumber = blockToImport.getHeader().getNumber(); + if (getRoundIdentifier().getRoundNumber() > 0) { + LOG.info( + "Importing proposed block to chain. round={}, hash={}", + getRoundIdentifier(), + blockToImport.getHash()); + } else { + LOG.debug( + "Importing proposed block to chain. round={}, hash={}", + getRoundIdentifier(), + blockToImport.getHash()); + } + + final QbftBlockImporter blockImporter = + protocolSchedule.getBlockImporter(blockToImport.getHeader()); + final boolean result = + blockImporter.importBlock(blockToImport, roundState.getProposedBlockAccessList()); + if (!result) { + // Do NOT set blockImported: a post-fork block whose Falcon quorum certificate is not yet + // complete can be retried as more commits (with valid Falcon seals) arrive this round. + LOG.error( + "Failed to import proposed block to chain. block={} blockHeader={}", + blockNumber, + blockToImport.getHeader()); + } else { + blockImported = true; + notifyNewBlockListeners(blockToImport); + } + } + + private Hash commitHashFor(final QbftBlock block) { + final QbftBlock commitBlock = createCommitBlock(block); + return commitBlock.getHash(); + } + + /** + * AERE ANCHOR V2: remember the Falcon seals heard for a block, keyed on its ROUND-INDEPENDENT + * on-chain hash, which is the key the producer of the next block looks under. Cheap and + * idempotent: the cache keeps the first seal seen per validator index. + */ + private void pqCacheHeardSeals(final QbftBlock block) { + final Collection seals = roundState.getFalconSeals(); + if (seals.isEmpty()) { + return; + } + PqSealCache.instance().record(block.getHeader().getNumber(), pqOnchainHashOf(block), seals); + } + + /** + * AERE ANCHOR V2: the round-independent on-chain hash of the block this round is deciding, + * computed once per round. Replacing the round with 0 in the committed-seal encoding produces + * byte-for-byte the on-chain pre-image, because that encoding differs from the on-chain one only + * by writing the real round instead of a forced zero. + */ + private Hash pqOnchainHashOf(final QbftBlock block) { + if (pqOnchainHash == null) { + pqOnchainHash = blockInterface.replaceRoundForCommitBlock(block, 0).getHash(); + } + return pqOnchainHash; + } + + private Optional falconSealFor(final QbftBlock block, final Hash commitHash) { + // FalconSealSupport.sign never throws (any fault is swallowed and logged), and returns empty + // when this node holds no Falcon signing key, so the ECDSA commit path is never affected. + // + // AERE FIX-OPRIRE-CONSENS (b): the block NUMBER is now a mandatory argument. Seal ATTACHMENT is + // gated on the activation height (and on registry coverage of the whole validator set) inside + // FalconSealSupport.sign, so merely holding a Falcon key no longer makes a node emit a + // Falcon-carrying Commit. There is deliberately no height-less sign() overload left: a call site + // physically cannot bypass the gate. + // + // AERE ANCHOR V2: WHAT IS SIGNED changes one block BEFORE the anchor activates, because the + // certificate carried by block H is made of seals over block H-1. From that height the message + // is M = keccak256(RLP["AERE-PQ-COMMIT-1", chainId, number, ON-CHAIN block hash]) instead of the + // ECDSA committed-seal hash. The committed-seal hash could not be used: it includes the ROUND, + // which is not in the block-hash pre-image, so a validator holding only the parent header could + // not rebuild it. Every node flips at the same height, since the height is a pure function of + // the same configured H; a node configured with a different H emits seals nobody can use, and + // the producer drops them on verification rather than carrying them into a header. + final long blockNumber = block.getHeader().getNumber(); + final Bytes32 message; + if (PqAnchorProducer.sealMessageIsAnchorForm(blockNumber)) { + message = + PqAnchor.commitMessage( + PqAnchorProducer.config().chainId(), blockNumber, pqOnchainHashOf(block).getBytes()); + } else { + message = Bytes32.wrap(commitHash.getBytes()); + } + return FalconSealSupport.instance().sign(blockNumber, message); + } + + private QbftBlock createCommitBlock(final QbftBlock block) { + return blockInterface.replaceRoundForCommitBlock(block, getRoundIdentifier().getRoundNumber()); + } + + private void notifyNewBlockListeners(final QbftBlock block) { + observers.forEach(obs -> obs.blockMined(block)); + } +} diff --git a/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/RoundState.java b/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/RoundState.java new file mode 100644 index 0000000..9a647fb --- /dev/null +++ b/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/statemachine/RoundState.java @@ -0,0 +1,236 @@ +/* + * Copyright ConsenSys AG. + * + * 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.core.statemachine; + +import org.hyperledger.besu.consensus.common.bft.ConsensusRoundIdentifier; +import org.hyperledger.besu.consensus.common.bft.FalconSeal; +import org.hyperledger.besu.consensus.qbft.core.messagewrappers.Commit; +import org.hyperledger.besu.consensus.qbft.core.messagewrappers.Prepare; +import org.hyperledger.besu.consensus.qbft.core.messagewrappers.Proposal; +import org.hyperledger.besu.consensus.qbft.core.types.QbftBlock; +import org.hyperledger.besu.consensus.qbft.core.validation.MessageValidator; +import org.hyperledger.besu.crypto.SECPSignature; +import org.hyperledger.besu.datatypes.Address; +import org.hyperledger.besu.ethereum.mainnet.block.access.list.BlockAccessList; + +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Collectors; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The Round state holds all the messages for a given round and tracks whether quorum has been + * reached for the round to be prepared or committed. + */ +public class RoundState { + private static final Logger LOG = LoggerFactory.getLogger(RoundState.class); + + private final ConsensusRoundIdentifier roundIdentifier; + private final MessageValidator validator; + private final long quorum; + + private Optional proposalMessage = Optional.empty(); + + // Must track the actual Prepare message, not just the sender, as these may need to be reused + // to send out in a PrepareCertificate. + private final Map prepareMessages = new LinkedHashMap<>(); + private final Map commitMessages = new LinkedHashMap<>(); + + private boolean prepared = false; + private boolean committed = false; + + /** + * Instantiates a new Round state. + * + * @param roundIdentifier the round identifier + * @param quorum the quorum + * @param validator the validator + */ + public RoundState( + final ConsensusRoundIdentifier roundIdentifier, + final int quorum, + final MessageValidator validator) { + this.roundIdentifier = roundIdentifier; + this.quorum = quorum; + this.validator = validator; + } + + /** + * Gets round identifier. + * + * @return the round identifier + */ + public ConsensusRoundIdentifier getRoundIdentifier() { + return roundIdentifier; + } + + /** + * Gets the message validator. + * + * @return the message validator + */ + public MessageValidator getValidator() { + return validator; + } + + /** + * Sets proposed block. + * + * @param msg the Proposal payload msg + * @return the proposed block + */ + public boolean setProposedBlock(final Proposal msg) { + + if (proposalMessage.isEmpty()) { + if (validator.validateProposal(msg)) { + proposalMessage = Optional.of(msg); + prepareMessages.entrySet().removeIf(e -> !validator.validatePrepare(e.getValue())); + commitMessages.entrySet().removeIf(e -> !validator.validateCommit(e.getValue())); + updateState(); + return true; + } + } + + return false; + } + + /** + * Add prepare message. + * + * @param msg the msg + */ + public void addPrepareMessage(final Prepare msg) { + if (proposalMessage.isEmpty() || validator.validatePrepare(msg)) { + prepareMessages.putIfAbsent(msg.getAuthor(), msg); + LOG.trace("Round state added prepare message prepare={}", msg); + } + updateState(); + } + + /** + * Add commit message. + * + * @param msg the msg + */ + public void addCommitMessage(final Commit msg) { + if (proposalMessage.isEmpty() || validator.validateCommit(msg)) { + commitMessages.putIfAbsent(msg.getAuthor(), msg); + LOG.trace("Round state added commit message commit={}", msg); + } + + updateState(); + } + + private void updateState() { + prepared = (prepareMessages.size() >= quorum) && proposalMessage.isPresent(); + committed = (commitMessages.size() >= quorum) && proposalMessage.isPresent(); + LOG.trace( + "Round state updated prepared={} committed={} preparedQuorum={}/{} committedQuorum={}/{}", + prepared, + committed, + prepareMessages.size(), + quorum, + commitMessages.size(), + quorum); + } + + /** + * Gets proposed block. + * + * @return the proposed block + */ + public Optional getProposedBlock() { + return proposalMessage.map(p -> p.getSignedPayload().getPayload().getProposedBlock()); + } + + /** + * Gets proposed block access list. + * + * @return the block access list + */ + public Optional getProposedBlockAccessList() { + return proposalMessage.flatMap(Proposal::getBlockAccessList); + } + + /** + * Is prepared. + * + * @return the boolean + */ + public boolean isPrepared() { + return prepared; + } + + /** + * Is committed. + * + * @return the boolean + */ + public boolean isCommitted() { + return committed; + } + + /** + * Gets commit seals. + * + * @return the commit seals + */ + public Collection getCommitSeals() { + return commitMessages.values().stream() + .map(cp -> cp.getSignedPayload().getPayload().getCommitSeal()) + .collect(Collectors.toList()); + } + + /** + * Gets the parallel post-quantum Falcon seals gossiped on the collected commit messages (AERE + * hybrid PQC). Only commits that carried a Falcon seal contribute; the result is the raw set of + * gossiped seals, to be verified and de-duplicated by the block creator / header validation rule + * when the quorum certificate is assembled and checked. + * + * @return the gossiped Falcon seals (possibly empty, never null) + */ + public Collection getFalconSeals() { + return commitMessages.values().stream() + .map(cp -> cp.getSignedPayload().getPayload().getFalconSeal()) + .filter(Objects::nonNull) + .filter(Optional::isPresent) + .map(Optional::get) + .collect(Collectors.toList()); + } + + /** + * Construct prepared certificate. + * + * @return the optional prepared certificate + */ + public Optional constructPreparedCertificate() { + if (isPrepared()) { + return Optional.of( + new PreparedCertificate( + proposalMessage.get().getSignedPayload().getPayload().getProposedBlock(), + prepareMessages.values().stream() + .map(Prepare::getSignedPayload) + .collect(Collectors.toList()), + roundIdentifier.getRoundNumber(), + proposalMessage.get().getBlockAccessList())); + } + return Optional.empty(); + } +} diff --git a/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/types/QbftBlockCreator.java b/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/types/QbftBlockCreator.java new file mode 100644 index 0000000..acc3cc8 --- /dev/null +++ b/anchor/consensus/qbft-core/src/main/java/org/hyperledger/besu/consensus/qbft/core/types/QbftBlockCreator.java @@ -0,0 +1,84 @@ +/* + * Copyright contributors to Besu. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.qbft.core.types; + +import org.hyperledger.besu.consensus.common.bft.FalconSeal; +import org.hyperledger.besu.crypto.SECPSignature; +import org.hyperledger.besu.ethereum.mainnet.block.access.list.BlockAccessList; + +import java.util.Collection; +import java.util.Collections; +import java.util.Optional; + +/** Responsible for creating a block. */ +public interface QbftBlockCreator { + + /** + * Block creation result. + * + * @param block the block + * @param blockAccessList optional block access list + */ + public record BlockCreationResult(QbftBlock block, Optional blockAccessList) {} + + /** + * Create a block. + * + * @param headerTimeStampSeconds the header timestamp + * @param parentHeader the parent header + * @return the block + */ + BlockCreationResult createBlock(long headerTimeStampSeconds, QbftBlockHeader parentHeader); + + /** + * Create sealed block. + * + * @param block the block + * @param roundNumber the round number + * @param commitSeals the commit seals + * @return the block + */ + QbftBlock createSealedBlock( + final QbftBlock block, final int roundNumber, final Collection commitSeals); + + /** + * Create sealed block, additionally embedding a gossiped parallel Falcon-512 quorum certificate + * (AERE hybrid PQC). The default implementation ignores the Falcon seals and delegates to the + * ECDSA-only sealing, so existing implementations remain valid; the QBFT block creator adaptor + * overrides this to aggregate the Falcon seals into a per-block quorum certificate. + * + * @param block the block + * @param roundNumber the round number + * @param commitSeals the decisive ECDSA commit seals + * @param falconSeals the gossiped parallel Falcon-512 seals collected from commit messages + * @return the block + */ + default QbftBlock createSealedBlock( + final QbftBlock block, + final int roundNumber, + final Collection commitSeals, + final Collection falconSeals) { + return createSealedBlock(block, roundNumber, commitSeals); + } + + /** + * Convenience empty Falcon seal collection. + * + * @return an empty collection of Falcon seals + */ + static Collection noFalconSeals() { + return Collections.emptyList(); + } +} diff --git a/anchor/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/QbftBlockHeaderValidationRulesetFactory.java b/anchor/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/QbftBlockHeaderValidationRulesetFactory.java new file mode 100644 index 0000000..d355ed2 --- /dev/null +++ b/anchor/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/QbftBlockHeaderValidationRulesetFactory.java @@ -0,0 +1,188 @@ +/* + * Copyright ConsenSys AG. + * + * 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; + +import static org.hyperledger.besu.ethereum.mainnet.AbstractGasLimitSpecification.DEFAULT_MAX_GAS_LIMIT; +import static org.hyperledger.besu.ethereum.mainnet.AbstractGasLimitSpecification.DEFAULT_MIN_GAS_LIMIT; + +import org.hyperledger.besu.consensus.common.bft.BftHelpers; +import org.hyperledger.besu.consensus.common.bft.FalconSealSupport; +import org.hyperledger.besu.consensus.common.bft.PqAnchorConfig; +import org.hyperledger.besu.consensus.common.bft.headervalidationrules.BftCoinbaseValidationRule; +import org.hyperledger.besu.consensus.common.bft.headervalidationrules.BftCommitSealsValidationRule; +import org.hyperledger.besu.consensus.qbft.headervalidationrules.FalconSealValidationRule; +import org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorDigestAttachedRule; +import org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorDigestRule; +import org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorSealsRule; +import org.hyperledger.besu.consensus.qbft.headervalidationrules.PqEmergencyShoutRule; +import org.hyperledger.besu.consensus.qbft.headervalidationrules.PqRegistryBindingRule; +import org.hyperledger.besu.consensus.qbft.headervalidationrules.QbftValidatorsValidationRule; +import org.hyperledger.besu.ethereum.core.BlockHeader; +import org.hyperledger.besu.ethereum.mainnet.BlockHeaderValidator; +import org.hyperledger.besu.ethereum.mainnet.feemarket.BaseFeeMarket; +import org.hyperledger.besu.ethereum.mainnet.headervalidationrules.AncestryValidationRule; +import org.hyperledger.besu.ethereum.mainnet.headervalidationrules.ConstantFieldValidationRule; +import org.hyperledger.besu.ethereum.mainnet.headervalidationrules.GasLimitRangeAndDeltaValidationRule; +import org.hyperledger.besu.ethereum.mainnet.headervalidationrules.GasUsageValidationRule; +import org.hyperledger.besu.ethereum.mainnet.headervalidationrules.TimestampBoundedByFutureParameter; +import org.hyperledger.besu.ethereum.mainnet.headervalidationrules.TimestampMoreRecentThanParent; + +import java.time.Duration; +import java.util.Optional; + +import org.apache.tuweni.units.bigints.UInt256; + +/** The Qbft block header validation ruleset factory. */ +public class QbftBlockHeaderValidationRulesetFactory { + /** Default constructor */ + private QbftBlockHeaderValidationRulesetFactory() {} + + /** + * Produces a BlockHeaderValidator configured for assessing bft block headers which are to form + * part of the BlockChain (i.e. not proposed blocks, which do not contain commit seals) + * + * @param minimumTimeBetweenBlocks the minimum amount of time that must elapse between blocks. + * @param useValidatorContract whether validator selection is using a validator contract + * @param baseFeeMarket an {@link Optional} wrapping {@link BaseFeeMarket} class if appropriate. + * @return BlockHeaderValidator configured for assessing bft block headers + */ + public static BlockHeaderValidator.Builder blockHeaderValidator( + final Duration minimumTimeBetweenBlocks, + final boolean useValidatorContract, + final Optional baseFeeMarket) { + return blockHeaderValidator( + minimumTimeBetweenBlocks, + useValidatorContract, + baseFeeMarket, + PqAnchorConfig.fromSystemConfiguration()); + } + + /** + * Produces a BlockHeaderValidator configured for assessing bft block headers, with an explicit V2 + * certificate-anchor configuration. + * + *

AERE ANCORA-V2 (2026-08-01). Two rules are added and one is retired, all indexed on the BLOCK + * NUMBER: + * + *

    + *
  • {@link PqAnchorDigestRule} (R1), DETACHED and part of LIGHT validation: recomputes the + * anchor digest D from the header's own parent hash, its own height minus one, the chain id + * and the certificate the header itself carries, and requires it to equal vanityData. One + * keccak, no state. This is what makes a stripped or swapped certificate detectable, and + * detectable on a node that is fast-syncing headers. + *
  • {@link PqAnchorSealsRule} (R2), ATTACHED and full validation only: threshold, strictly + * increasing indices, eligibility in the PARENT's validator set, and k Falcon verifications + * over M(parent). + *
  • {@link FalconSealValidationRule} retires at the same height. It is replaced, not promoted: + * it is excluded from light validation, it returns true on every path including its + * exception path, and it reads historical world state over a window that does not exist on a + * syncing node. + *
+ * + *

Below the activation height H the two new rules return true as their FIRST statement, before + * any decode, and the legacy rule behaves exactly as before. A binary built from this code is + * therefore bit-for-bit equivalent to today's on the whole existing chain, which is the condition + * for warming it on a live node. + * + *

The rule COUNT goes from 11 to 16 (the per-block registry binding adds one, was 13; + * SINCRONIZARE adds the attached copy of the digest rule, was 14; OPTIUNI-URGENTA adds the + * emergency announcement rule, which can never reject, was 15) (14 unconditional plus the + * conditional timestamp rule). The original design note said 12 because it assumed the legacy + * Falcon rule would be deleted; retiring it by height instead is what keeps behaviour below H + * identical, so it stays in the list. + * + * @param minimumTimeBetweenBlocks the minimum amount of time that must elapse between blocks. + * @param useValidatorContract whether validator selection is using a validator contract + * @param baseFeeMarket an {@link Optional} wrapping {@link BaseFeeMarket} class if appropriate. + * @param pqAnchorConfig the height-indexed V2 certificate-anchor configuration + * @return BlockHeaderValidator configured for assessing bft block headers + */ + public static BlockHeaderValidator.Builder blockHeaderValidator( + final Duration minimumTimeBetweenBlocks, + final boolean useValidatorContract, + final Optional baseFeeMarket, + final PqAnchorConfig pqAnchorConfig) { + // AERE FIX-OPRIRE-CONSENS (b): force EAGER initialisation of FalconSealSupport here, while the + // protocol schedule is being built at node startup and before the network is up. The class runs + // its fail-closed configuration guards in its constructor; leaving it lazily initialised meant a + // node with an unsafe Falcon activation config started normally and only discovered the problem + // the first time a QBFT round touched the singleton, i.e. ON the consensus path. Touching it + // here moves every refusal back to config time, which is the only safe place for it. + FalconSealSupport.instance(); + + BlockHeaderValidator.Builder ruleBuilder = + new BlockHeaderValidator.Builder() + // AERE OPTIUNI-URGENTA (2026-08-02): FIRST, and it can never reject. Its whole job is + // to make an emergency override impossible to run on without noticing, by writing one + // complete ERROR line at every height for as long as one is in force. First in the list + // because a rule that always returns true cannot change the verdict of a conjunction, + // and because the moment an operator most needs to know a control was overridden is + // when a LATER rule is rejecting headers. + .addRule(new PqEmergencyShoutRule(pqAnchorConfig)) + .addRule(new AncestryValidationRule()) + .addRule(new GasUsageValidationRule()) + .addRule( + new GasLimitRangeAndDeltaValidationRule( + DEFAULT_MIN_GAS_LIMIT, DEFAULT_MAX_GAS_LIMIT, baseFeeMarket)) + .addRule(new TimestampBoundedByFutureParameter(1)) + .addRule( + new ConstantFieldValidationRule<>( + "MixHash", BlockHeader::getMixHash, BftHelpers.EXPECTED_MIX_HASH)) + .addRule( + new ConstantFieldValidationRule<>( + "Difficulty", BlockHeader::getDifficulty, UInt256.ONE)) + .addRule(new QbftValidatorsValidationRule(useValidatorContract)) + .addRule(new BftCoinbaseValidationRule()) + .addRule(new BftCommitSealsValidationRule()) + // AERE ANCORA-V2: the legacy log-only Falcon rule now stands down at H. With an + // unconfigured anchor this is Long.MAX_VALUE, i.e. it never stands down and behaviour is + // unchanged. + .addRule( + new FalconSealValidationRule(pqAnchorConfig.legacyFalconRuleRetirementBlock())) + // AERE ANCORA-V2 R1: cheap, detached, IN LIGHT VALIDATION. Ordered before R2 so the one + // keccak that binds the certificate to the block hash runs before k Falcon + // verifications are spent on it. + .addRule(new PqAnchorDigestRule(pqAnchorConfig)) + // AERE SINCRONIZARE R1A (2026-08-02): the SAME digest verdict, on the ATTACHED side. + // Measured cause it repairs: SKIP_DETACHED, the mode FullImportBlockStep passes to + // importBlock for every block acquired by sync, keeps exactly the rules that are NOT + // detached, so R1 does not run at import; and DETACHED_ONLY, the mode under which R1 + // does run, is only applied by DownloadHeaderSequenceTask, i.e. only on ranges with a + // closed end. The open-ended range at the tip of a recovery, 199 blocks by default, was + // therefore checked by neither. Ordered immediately before R2 so the one keccak still + // runs before k Falcon verifications are spent. Delegates to R1, so there is one + // definition of the digest and the two paths cannot drift. + .addRule(new PqAnchorDigestAttachedRule(pqAnchorConfig)) + // AERE ANCORA-V2 R2: attached, full validation only. + .addRule(new PqAnchorSealsRule(pqAnchorConfig)) + // AERE REGISTRY BINDING, per-block half: the registry this node runs must be the + // registry config.pqRegistryHash requires AT THIS HEIGHT. The startup guard answers + // that once, against the head that existed at startup; a rotation entry in + // the schedule can pass underneath a running node and never be noticed. + // Inert when no schedule is configured, which is chain 2800 today. + .addRule(new PqRegistryBindingRule()); + + // Currently the minimum acceptable time between blocks is 1 second. The timestamp of an + // Ethereum header is stored as seconds since Unix epoch so blocks being produced more + // frequently than once a second cannot pass this validator. For non-production scenarios + // (e.g. for testing block production much more frequently than once a second) Besu has + // an experimental 'xblockperiodmilliseconds' option for BFT chains. If this is enabled + // we cannot apply the TimestampMoreRecentThanParent validation rule so we do not add it + if (minimumTimeBetweenBlocks.compareTo(Duration.ofSeconds(1)) >= 0) { + ruleBuilder.addRule(new TimestampMoreRecentThanParent(minimumTimeBetweenBlocks.toSeconds())); + } + return ruleBuilder; + } +} diff --git a/anchor/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/QbftExtraDataCodec.java b/anchor/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/QbftExtraDataCodec.java new file mode 100644 index 0000000..5563091 --- /dev/null +++ b/anchor/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/QbftExtraDataCodec.java @@ -0,0 +1,266 @@ +/* + * Copyright ConsenSys AG. + * + * 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; + +import static org.hyperledger.besu.consensus.common.bft.Vote.ADD_BYTE_VALUE; +import static org.hyperledger.besu.consensus.common.bft.Vote.DROP_BYTE_VALUE; + +import org.hyperledger.besu.consensus.common.bft.BftExtraData; +import org.hyperledger.besu.consensus.common.bft.BftExtraDataCodec; +import org.hyperledger.besu.consensus.common.bft.FalconSeal; +import org.hyperledger.besu.consensus.common.bft.Vote; +import org.hyperledger.besu.consensus.common.validator.VoteType; +import org.hyperledger.besu.crypto.SECPSignature; +import org.hyperledger.besu.crypto.SignatureAlgorithmFactory; +import org.hyperledger.besu.datatypes.Address; +import org.hyperledger.besu.ethereum.rlp.BytesValueRLPInput; +import org.hyperledger.besu.ethereum.rlp.BytesValueRLPOutput; +import org.hyperledger.besu.ethereum.rlp.RLPException; +import org.hyperledger.besu.ethereum.rlp.RLPInput; +import org.hyperledger.besu.ethereum.rlp.RLPOutput; + +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import com.google.common.collect.ImmutableBiMap; +import org.apache.tuweni.bytes.Bytes; + +/** + * Represents the data structure stored in the extraData field of the BlockHeader used when + * operating under an BFT consensus mechanism. + * + *

AERE hybrid PQC extension: an OPTIONAL, PARALLEL Falcon-512 seal list (a per-block post-quantum + * quorum certificate) is appended after the ECDSA committed-seal list, but ONLY in the {@link + * EncodingType#ALL} (stored-header) encoding AND only when at least one Falcon seal is present. The + * two "exclude" encodings used for the committed-seal hash and the on-chain block hash omit the + * Falcon list entirely, and a header with no Falcon seals omits it too, so in every case the bytes + * are identical to upstream Besu whenever no post-quantum certificate is carried. This guarantees + * the Falcon seals never disturb the ECDSA committed seals or any signed pre-image. Decoding is + * backward-tolerant: headers with no Falcon list decode to an empty Falcon-seal collection. + */ +public class QbftExtraDataCodec extends BftExtraDataCodec { + private static final ImmutableBiMap voteToValue = + ImmutableBiMap.of( + VoteType.ADD, ADD_BYTE_VALUE, + VoteType.DROP, DROP_BYTE_VALUE); + + /** Default constructor */ + public QbftExtraDataCodec() {} + + /** + * Encode from addresses. + * + * @param addresses the addresses + * @return the bytes + */ + public static Bytes encodeFromAddresses(final Collection

addresses) { + return new QbftExtraDataCodec() + .encode( + new BftExtraData( + Bytes.wrap(new byte[EXTRA_VANITY_LENGTH]), + Collections.emptyList(), + Optional.empty(), + 0, + addresses)); + } + + /** + * Create genesis extra data string. + * + * @param validators the validators + * @return the string + */ + public static String createGenesisExtraDataString(final List
validators) { + return encodeFromAddresses(validators).toString(); + } + + @Override + public BftExtraData decodeRaw(final Bytes input) { + if (input.isEmpty()) { + throw new IllegalArgumentException("Invalid Bytes supplied - Bft Extra Data required."); + } + + final RLPInput rlpInput = new BytesValueRLPInput(input, false); + + rlpInput.enterList(); // This accounts for the "root node" which contains BFT data items. + final Bytes vanityData = rlpInput.readBytes(); + final List
validators = rlpInput.readList(Address::readFrom); + + final Optional vote; + if (rlpInput.nextIsList() && rlpInput.nextSize() == 0) { + vote = Optional.empty(); + rlpInput.skipNext(); + } else { + vote = Optional.of(decodeVote(rlpInput)); + } + + final int round = rlpInput.readIntScalar(); + final List seals = + rlpInput.readList( + rlp -> SignatureAlgorithmFactory.getInstance().decodeSignature(rlp.readBytes())); + + // AERE hybrid PQC: an OPTIONAL parallel Falcon-512 seal list may follow the ECDSA seals. + // Backward-tolerant: older/exclude/no-certificate encodings without this element decode to an + // empty list. + final List falconSeals; + if (!rlpInput.isEndOfCurrentList()) { + falconSeals = + rlpInput.readList( + rlp -> { + rlp.enterList(); + final int idx = rlp.readIntScalar(); + final Bytes sig = rlp.readBytes(); + rlp.leaveList(); + return new FalconSeal(idx, sig); + }); + } else { + falconSeals = Collections.emptyList(); + } + + // AERE FIX-MALEABILITATE-BLOC (2026-08-01), gate 1 of 2: the leave is STRICT. + // + // Upstream ends this decode with leaveListLenient(), which silently discards anything still + // left in the outer list. That is not survivable here, because the QBFT block hash and the + // committed-seal pre-image are BOTH computed from a RE-ENCODE of the decoded BftExtraData + // (BftBlockHashing.calculateHashOfBftBlockOnchain line 84-91, calculateDataHashForCommittedSeal + // line 53-75). Anything the decoder discards is therefore dropped before hashing, so two + // different header byte strings carry the same block hash and the same genuine validator + // seals. leaveList() rejects exactly the inputs that have content left over, and is + // byte-for-byte identical to the lenient leave on every input that has not. + rlpInput.leaveList(); + + final BftExtraData decoded = + new BftExtraData(vanityData, seals, vote, round, validators, falconSeals); + + // AERE FIX-MALEABILITATE-BLOC (2026-08-01), gate 2 of 2: CANONICAL ROUND TRIP. + // + // The strict leave alone does not close the hole. The Falcon element is OPTIONAL on the wire + // and the encoder omits it when the certificate is empty, so a header carrying an explicit + // EMPTY Falcon list decodes to exactly the same value as a header carrying no Falcon element + // at all: the trailing 0xc0 is CONSUMED, not left over, so no leave, strict or lenient, can + // see it. Requiring the full re-encode to reproduce the received bytes makes the accepted + // encoding unique by construction, and closes every present and future shape of this defect + // rather than the one instance of it we happened to look for. + // + // Backward compatibility: the re-encode used here is the same EncodingType.ALL the block + // producer uses, so every header any Aere or stock Besu node has ever produced round trips + // unchanged. Headers with no Falcon element re-encode with no Falcon element, which is why + // the whole pre-Falcon history of the chain still decodes. + final Bytes canonical = encode(decoded, EncodingType.ALL); + if (!canonical.equals(input)) { + throw new RLPException( + "Non-canonical BFT extra data: the header's extraData is " + + input.size() + + " bytes but its canonical re-encoding is " + + canonical.size() + + " bytes. A QBFT block hash is computed over the re-encoded form, so a non-canonical" + + " encoding would give two header byte strings one block hash. Rejected."); + } + + return decoded; + } + + @Override + protected Bytes encode(final BftExtraData bftExtraData, final EncodingType encodingType) { + final BytesValueRLPOutput encoder = new BytesValueRLPOutput(); + encoder.startList(); + encoder.writeBytes(bftExtraData.getVanityData()); + encoder.writeList( + bftExtraData.getValidators(), (validator, rlp) -> rlp.writeBytes(validator.getBytes())); + + if (bftExtraData.getVote().isPresent()) { + encodeVote(encoder, bftExtraData.getVote().get()); + } else { + encoder.writeList(Collections.emptyList(), (o, rlpOutput) -> {}); + } + + if (encodingType != EncodingType.EXCLUDE_COMMIT_SEALS_AND_ROUND_NUMBER) { + encoder.writeIntScalar(bftExtraData.getRound()); + if (encodingType != EncodingType.EXCLUDE_COMMIT_SEALS) { + encoder.writeList( + bftExtraData.getSeals(), (committer, rlp) -> rlp.writeBytes(committer.encodedBytes())); + // AERE hybrid PQC: only the full (stored-header) encoding carries the parallel Falcon + // quorum certificate, and only when it is non-empty. The exclude encodings omit it so the + // committed-seal hash and the on-chain block hash are byte-identical to upstream Besu, and a + // header with no certificate is byte-identical to upstream too, so the Falcon seals are + // never part of any signed pre-image and never disturb the block hash. + if (!bftExtraData.getFalconSeals().isEmpty()) { + encoder.writeList( + bftExtraData.getFalconSeals(), + (falconSeal, rlp) -> { + rlp.startList(); + rlp.writeIntScalar(falconSeal.getValidatorIndex()); + rlp.writeBytes(falconSeal.getSignature()); + rlp.endList(); + }); + } + } else { + encoder.writeEmptyList(); + } + } else { + encoder.writeIntScalar(0); + encoder.writeEmptyList(); + } + encoder.endList(); + + return encoder.encoded(); + } + + /** + * Encode vote. + * + * @param rlpOutput the rlp output + * @param vote the vote + */ + protected void encodeVote(final RLPOutput rlpOutput, final Vote vote) { + final VoteType voteType = vote.isAuth() ? VoteType.ADD : VoteType.DROP; + rlpOutput.startList(); + rlpOutput.writeBytes(vote.getRecipient().getBytes()); + if (voteType == VoteType.ADD) { + rlpOutput.writeByte(ADD_BYTE_VALUE); + } else { + rlpOutput.writeNull(); + } + rlpOutput.endList(); + } + + /** + * Decode vote. + * + * @param rlpInput the rlp input + * @return the vote + */ + protected Vote decodeVote(final RLPInput rlpInput) { + rlpInput.enterList(); + final Address recipient = Address.readFrom(rlpInput); + + final VoteType vote; + if (rlpInput.nextSize() == 0) { + rlpInput.skipNext(); + vote = VoteType.DROP; + } else { + vote = voteToValue.inverse().get(rlpInput.readByte()); + } + + if (vote == null) { + throw new RLPException("Vote field was of an incorrect binary value."); + } + rlpInput.leaveList(); + + return new Vote(recipient, vote); + } +} diff --git a/anchor/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/adaptor/QbftBlockCreatorAdaptor.java b/anchor/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/adaptor/QbftBlockCreatorAdaptor.java new file mode 100644 index 0000000..267d79e --- /dev/null +++ b/anchor/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/adaptor/QbftBlockCreatorAdaptor.java @@ -0,0 +1,289 @@ +/* + * Copyright contributors to Besu. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.qbft.adaptor; + +import org.hyperledger.besu.consensus.common.bft.BftBlockHashing; +import org.hyperledger.besu.consensus.common.bft.BftBlockHeaderFunctions; +import org.hyperledger.besu.consensus.common.bft.BftExtraData; +import org.hyperledger.besu.consensus.common.bft.BftExtraDataCodec; +import org.hyperledger.besu.consensus.common.bft.FalconSeal; +import org.hyperledger.besu.consensus.common.bft.FalconSealSupport; +import org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer; +import org.hyperledger.besu.consensus.qbft.core.types.QbftBlock; +import org.hyperledger.besu.consensus.qbft.core.types.QbftBlockCreator; +import org.hyperledger.besu.consensus.qbft.core.types.QbftBlockHeader; +import org.hyperledger.besu.crypto.SECPSignature; +import org.hyperledger.besu.datatypes.Address; +import org.hyperledger.besu.datatypes.Hash; +import org.hyperledger.besu.ethereum.blockcreation.BlockCreator; +import org.hyperledger.besu.ethereum.core.Block; +import org.hyperledger.besu.ethereum.core.BlockHeader; +import org.hyperledger.besu.ethereum.core.BlockHeaderBuilder; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Optional; +import java.util.OptionalInt; +import java.util.Set; + +import org.apache.tuweni.bytes.Bytes32; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** Adaptor class to allow a {@link BlockCreator} to be used as a {@link QbftBlockCreator}. */ +public class QbftBlockCreatorAdaptor implements QbftBlockCreator { + + private static final Logger LOG = LoggerFactory.getLogger(QbftBlockCreatorAdaptor.class); + + private final BlockCreator besuBlockCreator; + private final BftExtraDataCodec bftExtraDataCodec; + + /** + * Constructs a new QbftBlockCreator + * + * @param besuBftBlockCreator the Besu BFT block creator + * @param bftExtraDataCodec the bftExtraDataCodec used to encode extra data for the new header + */ + public QbftBlockCreatorAdaptor( + final BlockCreator besuBftBlockCreator, final BftExtraDataCodec bftExtraDataCodec) { + this.besuBlockCreator = besuBftBlockCreator; + this.bftExtraDataCodec = bftExtraDataCodec; + } + + @Override + public BlockCreationResult createBlock( + final long headerTimeStampSeconds, final QbftBlockHeader parentHeader) { + var blockResult = + besuBlockCreator.createBlock( + headerTimeStampSeconds, AdaptorUtil.toBesuBlockHeader(parentHeader)); + return new BlockCreationResult( + new QbftBlockAdaptor(blockResult.getBlock()), blockResult.getBlockAccessList()); + } + + @Override + public QbftBlock createSealedBlock( + final QbftBlock block, final int roundNumber, final Collection commitSeals) { + return createSealedBlock(block, roundNumber, commitSeals, Collections.emptyList()); + } + + @Override + public QbftBlock createSealedBlock( + final QbftBlock block, + final int roundNumber, + final Collection commitSeals, + final Collection collectedFalconSeals) { + final Block besuBlock = AdaptorUtil.toBesuBlock(block); + final QbftBlockHeader initialHeader = block.getHeader(); + final BftExtraData initialExtraData = + bftExtraDataCodec.decode(AdaptorUtil.toBesuBlockHeader(initialHeader)); + + // AERE ANCHOR V2, THE MEASURED TRAP, CLOSED HERE. Above the activation height element 6 is no + // longer "the seals I heard for THIS block": it is the quorum certificate over the PARENT, + // CHOSEN BY THE PROPOSER, and vanityData carries its digest D. Every node that seals this block + // must therefore carry the proposer's bytes across UNCHANGED. The legacy assembly below would + // overwrite them with what this node heard for the current block, which would leave a header + // whose vanity D no longer matches its own element 6, i.e. a header this node's own digest rule + // rejects. So above the activation height the assembler is skipped outright. + // + // Note what is NOT done here: no re-verification, no re-selection, no comparison against what + // this node heard. That is the whole point of V2. The proposer's certificate has already been + // checked by the header rules on the proposal, and re-deriving it locally is exactly the + // dependency on "what I heard" that killed the earlier design. + if (PqAnchorProducer.config().activeAt(initialHeader.getNumber())) { + final BftExtraData anchoredExtraData = + new BftExtraData( + initialExtraData.getVanityData(), + commitSeals, + initialExtraData.getVote(), + roundNumber, + initialExtraData.getValidators(), + initialExtraData.getFalconSeals()); + final BlockHeader anchoredHeader = + BlockHeaderBuilder.fromHeader(AdaptorUtil.toBesuBlockHeader(initialHeader)) + .extraData(bftExtraDataCodec.encode(anchoredExtraData)) + .blockHeaderFunctions(BftBlockHeaderFunctions.forOnchainBlock(bftExtraDataCodec)) + .buildBlockHeader(); + LOG.debug( + "AERE PQ-ANCHOR: sealed block {} keeping the proposer's {}-seal parent certificate " + + "verbatim ({} seal(s) heard locally for this block were deliberately ignored)", + anchoredHeader.getNumber(), + initialExtraData.getFalconSeals().size(), + collectedFalconSeals == null ? 0 : collectedFalconSeals.size()); + return new QbftBlockAdaptor(new Block(anchoredHeader, besuBlock.getBody())); + } + + // Step 1: build the sealed header exactly as upstream, with the decisive ECDSA committed seals + // and (for now) no Falcon seals. The ECDSA path is byte-for-byte unchanged. + final BftExtraData sealedExtraData = + new BftExtraData( + initialExtraData.getVanityData(), + commitSeals, + initialExtraData.getVote(), + roundNumber, + initialExtraData.getValidators()); + + BlockHeader sealedHeader = + BlockHeaderBuilder.fromHeader(AdaptorUtil.toBesuBlockHeader(initialHeader)) + .extraData(bftExtraDataCodec.encode(sealedExtraData)) + .blockHeaderFunctions(BftBlockHeaderFunctions.forOnchainBlock(bftExtraDataCodec)) + .buildBlockHeader(); + + // Step 2 (AERE hybrid PQC): assemble a parallel Falcon-512 QUORUM CERTIFICATE from the seals + // gossiped on the commit messages. Each seal is re-verified against the registry over the SAME + // commit hash the ECDSA committed seal signs, and de-duplicated by validator index, so the + // embedded certificate is exactly the set of distinct, valid post-quantum seals. Falcon seals + // are excluded from every signed pre-image (committed-seal hash and on-chain block hash), so the + // block hash is identical whether or not the certificate is present. Any failure here leaves the + // ECDSA-sealed header untouched and valid. + try { + final FalconSealSupport pqc = FalconSealSupport.instance(); + final Hash commitHash = + new BftBlockHashing(bftExtraDataCodec).calculateDataHashForCommittedSeal(sealedHeader); + + // AERE audit fix, ELIGIBLE-SIGNER BINDING: restrict the embedded certificate to ELIGIBLE + // signers (current validators carried in extraData INTERSECT the address-bound registry), so + // the assembler never embeds a seal the header rule would later reject as ineligible. + final Set
registered = pqc.registeredValidatorAddresses(); + final Set
eligible = new LinkedHashSet<>(); + for (final Address v : initialExtraData.getValidators()) { + if (registered.contains(v)) { + eligible.add(v); + } + } + + // AERE HEADER GROWTH 2026-08-08: the interval gate. Measured on chain 2800 the same day: with + // every validator attaching, this assembler wrote FIVE seals into EVERY header, taking it from + // 525 to 3844 bytes. That is roughly SEVEN TIMES the header bytes stored per block, on every + // node, for as long as the chain runs, which is what makes the interval a design constraint + // and not a tuning knob. + // + // The anchor producer has had an interval and a cap since 7 August. This assembler, the one + // that runs BEFORE the activation height, had neither, so the controls were unreachable + // exactly during the window they were needed. Skipping the whole attach block, rather than + // assembling and then discarding, also saves the Falcon verification of every heard seal. + if (!pqc.attachesCertificateAt(sealedHeader.getNumber())) { + LOG.debug( + "AERE PQC: block {} is not an attachment height (interval {}), leaving the header " + + "ECDSA-only at {} bytes", + sealedHeader.getNumber(), + pqc.attachInterval().isPresent() ? pqc.attachInterval().getAsInt() : 1, + sealedHeader.getExtraData().size()); + return new QbftBlockAdaptor(new Block(sealedHeader, besuBlock.getBody())); + } + + final List quorumCert = + verifiedDistinctSeals( + pqc, + sealedHeader.getNumber(), + commitHash, + collectedFalconSeals, + eligible, + pqc.attachMaxSeals()); + + // Fallback self-seal: if nothing was gossiped (e.g. the pre-gossip 3-arg path) but this node + // can sign AND is itself an eligible signer, attach its own seal so a single-signer + // certificate is still produced. + if (quorumCert.isEmpty() && pqc.signingEnabled()) { + // REGISTRY HEIGHT BINDING (2026-08-06): this is the ONE registry question in the stack with + // no honest height - "am I, right now, an eligible signer", asked before signing with the + // single private key this process holds. It gets its own name rather than a fabricated + // height, so that no future reader mistakes it for a verification path. See + // FalconSealSupport#localSigningAddress. + final Address self = pqc.localSigningAddress(); + if (self != null && eligible.contains(self)) { + // AERE FIX-OPRIRE-CONSENS (b): height-gated like every other attachment point. + final Optional selfSeal = + pqc.sign(sealedHeader.getNumber(), Bytes32.wrap(commitHash.getBytes())); + selfSeal.ifPresent(quorumCert::add); + } + } + + if (!quorumCert.isEmpty()) { + final BftExtraData hybridExtraData = + new BftExtraData( + initialExtraData.getVanityData(), + commitSeals, + initialExtraData.getVote(), + roundNumber, + initialExtraData.getValidators(), + quorumCert); + sealedHeader = + BlockHeaderBuilder.fromHeader(AdaptorUtil.toBesuBlockHeader(initialHeader)) + .extraData(bftExtraDataCodec.encode(hybridExtraData)) + .blockHeaderFunctions(BftBlockHeaderFunctions.forOnchainBlock(bftExtraDataCodec)) + .buildBlockHeader(); + LOG.debug( + "AERE PQC: attached Falcon-512 quorum certificate ({} distinct valid seal(s)) to block {}", + quorumCert.size(), + sealedHeader.getNumber()); + } + } catch (final RuntimeException e) { + LOG.warn( + "AERE PQC: failed to attach Falcon quorum certificate; keeping ECDSA-only sealed block " + + "(block still valid and final): {}", + e.toString()); + } + + final Block sealedBesuBlock = new Block(sealedHeader, besuBlock.getBody()); + return new QbftBlockAdaptor(sealedBesuBlock); + } + + // REGISTRY HEIGHT BINDING (2026-08-06): takes the height of the block being sealed. The seals + // gathered here are over THIS block's committed-seal hash, so the height is this block's own and + // is known at the call site. It matters at exactly one moment - a rotation height - where + // assembling a certificate under the head key set while every validator checks it under the + // scheduled one produces a block every other node rejects, with nothing in any log naming the + // reason. + private static List verifiedDistinctSeals( + final FalconSealSupport pqc, + final long blockNumber, + final Hash commitHash, + final Collection seals, + final Set
eligible, + final OptionalInt cap) { + final List out = new ArrayList<>(); + final Set
seen = new LinkedHashSet<>(); + final int limit = cap.orElse(Integer.MAX_VALUE); + for (final FalconSeal seal : seals) { + // The cap is read HERE, at the top of each turn, so it counts only seals that have already + // survived eligibility and Falcon verification below. Placed before verification it would + // stop at the cap while holding seals that turn out to be bad, and a short certificate made + // of unchecked seals is worse than a long one. Same ordering, and same reason, as the anchor + // producer's own cap. + if (out.size() >= limit) { + break; + } + if (seal == null) { + continue; + } + // Bind each seal to its registered validator address and keep it only if that address is an + // eligible signer (a current validator with a registered key), de-duplicated by address. + // REGISTRY HEIGHT BINDING, the OWN-HEAD door: this is the block this node is sealing now. + final Address signer = pqc.addressForIndexAtOwnHead(blockNumber, seal.getValidatorIndex()); + if (signer == null || !eligible.contains(signer) || seen.contains(signer)) { + continue; + } + if (pqc.verifyAtOwnHead( + blockNumber, seal.getValidatorIndex(), commitHash.getBytes(), seal.getSignature())) { + seen.add(signer); + out.add(seal); + } + } + return out; + } +} diff --git a/anchor/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/blockcreation/QbftBlockCreatorFactory.java b/anchor/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/blockcreation/QbftBlockCreatorFactory.java new file mode 100644 index 0000000..242efbe --- /dev/null +++ b/anchor/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/blockcreation/QbftBlockCreatorFactory.java @@ -0,0 +1,113 @@ +/* + * Copyright ConsenSys AG. + * + * 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.blockcreation; + +import org.hyperledger.besu.config.QbftConfigOptions; +import org.hyperledger.besu.consensus.common.ConsensusHelpers; +import org.hyperledger.besu.consensus.common.ForksSchedule; +import org.hyperledger.besu.consensus.common.bft.BftExtraData; +import org.hyperledger.besu.consensus.common.bft.BftExtraDataCodec; +import org.hyperledger.besu.consensus.common.bft.blockcreation.BftBlockCreatorFactory; +import org.hyperledger.besu.consensus.common.bft.blockcreation.PqAnchorProducer; +import org.hyperledger.besu.datatypes.Address; +import org.hyperledger.besu.ethereum.ProtocolContext; +import org.hyperledger.besu.ethereum.core.BlockHeader; +import org.hyperledger.besu.ethereum.core.MiningConfiguration; +import org.hyperledger.besu.ethereum.eth.manager.EthScheduler; +import org.hyperledger.besu.ethereum.eth.transactions.TransactionPool; +import org.hyperledger.besu.ethereum.mainnet.ProtocolSchedule; + +import java.util.Collections; +import java.util.Optional; + +import org.apache.tuweni.bytes.Bytes; + +/** Supports contract based voters and validators in extra data */ +public class QbftBlockCreatorFactory extends BftBlockCreatorFactory { + /** + * Instantiates a new Qbft block creator factory. + * + * @param transactionPool the pending transactions + * @param protocolContext the protocol context + * @param protocolSchedule the protocol schedule + * @param forksSchedule the forks schedule + * @param miningParams the mining params + * @param localAddress the local address + * @param bftExtraDataCodec the bft extra data codec + * @param ethScheduler the scheduler for asynchronous block creation tasks + */ + public QbftBlockCreatorFactory( + final TransactionPool transactionPool, + final ProtocolContext protocolContext, + final ProtocolSchedule protocolSchedule, + final ForksSchedule forksSchedule, + final MiningConfiguration miningParams, + final Address localAddress, + final BftExtraDataCodec bftExtraDataCodec, + final EthScheduler ethScheduler) { + super( + transactionPool, + protocolContext, + protocolSchedule, + forksSchedule, + miningParams, + localAddress, + bftExtraDataCodec, + ethScheduler); + } + + /** + * AERE ANCHOR V2: the QBFT proposer writes the anchor. + * + *

Both paths are covered on purpose. The validator-contract path builds its own extra data and + * does NOT go through the shared base method, so a change made only in the base class would leave + * that path writing the old vanity and no certificate, which post-fork means every block it + * proposes is rejected. This is the second of the two measured traps in the producer chain; the + * other is in {@code QbftBlockCreatorAdaptor.createSealedBlock}. + * + *

Below the activation height {@link PqAnchorProducer#apply} returns its input unchanged, so + * this method is byte-for-byte the old one until a height is named. + * + * @param round the round + * @param parentHeader the parent header + * @return the encoded extra data + * @throws org.hyperledger.besu.consensus.common.bft.PqAnchorNotReadyException when this node + * cannot back the block with a certificate that reaches the staged threshold; callers must + * catch it and simply not propose this round + */ + @Override + public Bytes createExtraData(final int round, final BlockHeader parentHeader) { + final BftExtraData base; + if (forksSchedule + .getFork(parentHeader.getNumber() + 1L, parentHeader.getTimestamp()) + .getValue() + .isValidatorContractMode()) { + // vote and validators will come from contract instead of block + base = + new BftExtraData( + ConsensusHelpers.zeroLeftPad( + miningConfiguration.getExtraData(), BftExtraDataCodec.EXTRA_VANITY_LENGTH), + Collections.emptyList(), + Optional.empty(), + round, + Collections.emptyList()); + } else { + base = buildExtraData(round, parentHeader); + } + + return bftExtraDataCodec.encode( + PqAnchorProducer.apply(base, parentHeader, protocolContext)); + } +} diff --git a/anchor/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/FalconSealValidationRule.java b/anchor/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/FalconSealValidationRule.java new file mode 100644 index 0000000..b09edac --- /dev/null +++ b/anchor/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/FalconSealValidationRule.java @@ -0,0 +1,501 @@ +/* + * Copyright contributors to Besu / Aere Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.qbft.headervalidationrules; + +import static org.hyperledger.besu.consensus.common.bft.BftHelpers.calculateRequiredValidatorQuorum; + +import org.hyperledger.besu.consensus.common.bft.BftBlockHashing; +import org.hyperledger.besu.consensus.common.bft.BftContext; +import org.hyperledger.besu.consensus.common.bft.BftExtraData; +import org.hyperledger.besu.consensus.common.bft.FalconSeal; +import org.hyperledger.besu.consensus.common.bft.FalconSealSupport; +import org.hyperledger.besu.consensus.qbft.QbftExtraDataCodec; +import org.hyperledger.besu.datatypes.Address; +import org.hyperledger.besu.datatypes.Hash; +import org.hyperledger.besu.ethereum.ProtocolContext; +import org.hyperledger.besu.ethereum.core.BlockHeader; +import org.hyperledger.besu.ethereum.mainnet.AttachedBlockHeaderValidationRule; +import org.hyperledger.besu.evm.account.Account; +import org.hyperledger.besu.evm.worldstate.WorldState; + +import java.util.Collection; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.Optional; +import java.util.Set; + +import org.apache.tuweni.units.bigints.UInt256; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Verifies the PARALLEL Falcon-512 post-quantum QUORUM CERTIFICATE embedded in a QBFT block header. + * + *

A Falcon quorum certificate is the set of Falcon-512 seals gossiped by validators on their QBFT + * commit messages (each a signature over the same commit hash the ECDSA committed seal signs), + * aggregated by the block assembler into the header's parallel Falcon-seal list. + * + *

AERE audit fix, ELIGIBLE-SIGNER BINDING (2026-07-18). Both the Falcon quorum threshold + * AND the counted-seal set are now bound to ONE well-defined set: + * + *

+ *   eligibleSigners = currentValidators (getValidatorsAfterBlock(parent))
+ *                     INTERSECT registeredValidatorAddresses (address-bound registry)
+ *   quorum          = ceil(2 * |eligibleSigners| / 3)
+ *   valid           = # distinct eligible validator addresses whose Falcon seal verifies
+ *   accept (blocking) iff eligibleSigners is well-formed AND valid >= quorum
+ * 
+ * + *

This closes the previous decoupling, where the quorum tracked the DYNAMIC validator-set size N + * (so a routine add-validator vote raised the quorum above the FIXED registry key count and halted + * the chain), while seals were counted by REGISTRY membership (so a removed validator's key kept + * counting). Binding both sides to {@code currentValidators INTERSECT registry} makes a + * validator-set change LIVE-SAFE (an added-but-unkeyed validator neither raises the Falcon quorum + * nor contributes to it, so the chain does not halt) and REMOVAL-SAFE (an ex-validator's key is not + * in the current validator set, so it is excluded from both eligibility and the count). + * + *

ECDSA stays decisive. This rule runs AFTER {@code BftCommitSealsValidationRule}, which + * independently requires an ECDSA committed-seal quorum of {@code ceil(2N/3)} over the FULL current + * validator set N. A block is valid iff BOTH quorums pass, so the hybrid chain is a strict subset of + * the ECDSA-only chain and can never be weaker than ECDSA-only. The Falcon eligible-quorum over a + * subset of validators is the decisive post-quantum seal in the regime where ECDSA is forgeable; the + * arming invariant below keeps that subset equal to the full validator set at arm time. + * + *

Behaviour is fork-gated by {@code aere.falcon.forkBlock} / {@code AERE_FALCON_FORKBLOCK}: + * + *

    + *
  • Before the fork block (or when unconfigured, i.e. fork = Long.MAX_VALUE): the rule is + * LOG-ONLY and always returns {@code true}. The decisive seal remains the ECDSA committed-seal + * quorum, so a Falcon fault can never halt or split the chain. This preserves the additive, + * non-blocking baseline exactly. + *
  • At and after the fork block: the rule is BLOCKING. A block is REJECTED unless it carries a + * Falcon quorum certificate of at least {@code ceil(2 * |eligibleSigners| / 3)} distinct valid + * eligible-signer Falcon seals over the correct commit hash. A registry that is not + * address-bound, or an empty eligible set, is a fail-closed REJECT (surfaced at the fork + * boundary, i.e. arm time), NEVER an implicit accept. + *
+ * + *

ARMING INVARIANT (eligible-signer binding): blocking should be armed only when the registry + * COVERS the validator set (every current validator has a Falcon key), so that {@code + * eligibleSigners == currentValidators} and the Falcon quorum equals the ECDSA quorum with full + * fault margin. When coverage is incomplete the rule stays LIVE on the intersection (it does not + * halt) but logs a LOUD warning that the margin is reduced and a registry re-anchor is required. + * This is the "either the intersection keeps it live, or it fail-closes at arm time, never as a + * silent halt" contract. + * + *

STAGE-2 LATE-ANCHOR activation: when the node is configured with a late-anchor manifest + * ({@code aere.falcon.manifest} + {@code aere.falcon.anchor.address}) on a chain that launched + * WITHOUT a genesis manifest, this rule additionally polls the parent block's world state for the + * anchor contract. Once the contract exists and its storage slot 0 carries a hash equal to + * keccak256 of the local manifest, the registry activates ({@link + * FalconSealSupport#activateLateAnchor}); any mismatch is terminal and fail-closed. This is the + * no-re-genesis activation path for an already-live chain. + */ +public class FalconSealValidationRule implements AttachedBlockHeaderValidationRule { + + private static final Logger LOG = LoggerFactory.getLogger(FalconSealValidationRule.class); + + /** + * AERE 2026-08-07: how often the LOG-ONLY summary repeats when NOTHING has changed. + * + *

WHY THIS EXISTS, measured on a live node. The summary was written at INFO on EVERY imported + * block. On chain 2800 at ~523 ms per block that is 2295 lines in twenty minutes, about 165.000 + * a day per node, and every one of them said the same thing: {@code 0 of 0 seals, + * |eligible|=0, no-eligible-signers}. A line that cannot change carries no information, and a log + * that repeats one at that rate is a log an operator stops reading, which is how a real error + * gets missed. + * + *

WHAT IS KEPT. Every CHANGE of outcome still logs at INFO immediately, so an operator sees the + * transition into and out of quorum on the block it happens. Unchanged state logs once per + * heartbeat, so the line never disappears entirely from a quiet log. Everything else drops to + * DEBUG, where it is still available on demand. + * + *

10.000 blocks is about 87 minutes at 523 ms. + */ + static final long LOG_HEARTBEAT_BLOCKS = 10_000L; + + /** + * Last outcome logged at INFO, and the height it was logged at. Written from several import + * threads, so both are volatile and are only ever used to decide a LOG LEVEL. Nothing here can + * change what this rule returns: the throttle is applied strictly after the verdict is computed. + */ + private volatile String lastLoggedOutcome = null; + + private volatile long lastLoggedBlock = Long.MIN_VALUE; + + private final QbftExtraDataCodec extraDataCodec = new QbftExtraDataCodec(); + + /** + * AERE ANCORA-V2 (2026-08-01): the height at and after which this rule RETIRES. + * + *

The V2 anchor scheme replaces this rule; it does not promote it. The reasons are measured and + * are properties of this file: {@code includeInLightValidation()} returns FALSE, so it never runs + * on a node that is fast-syncing headers; it returns {@code true} on every path including the + * exception path, so it has never been able to reject anything below the fork height; and its + * late-anchor lookup reads historical world state over a window of roughly 510 blocks, which does + * not exist on a syncing node. Retiring it at exactly the height the two anchor rules take over + * means exactly one regime is in force at any height, with no overlap and no gap. + * + *

{@link Long#MAX_VALUE} means "never retire", which is what the no-argument constructor gives, + * so every existing call site and test keeps today's behaviour unchanged. + */ + private final long retirementBlock; + + /** Default constructor: the rule never retires, i.e. exactly today's behaviour. */ + public FalconSealValidationRule() { + this(Long.MAX_VALUE); + } + + /** + * Decides whether the LOG-ONLY summary goes out at INFO, and records it if so. + * + *

Extracted so the throttle can be PROVEN without a log appender: a test that counts real log + * lines proves the plumbing, but a test on this method proves the rule, and the rule is what can + * be wrong. The end-to-end count is measured separately, on a live node. + * + *

Two reasons to log at INFO, and no others: + * + *

    + *
  • the outcome CHANGED since the last INFO, so an operator sees every transition on the block + * it happens + *
  • {@link #LOG_HEARTBEAT_BLOCKS} have passed, so the line never vanishes from a quiet log + *
+ * + *

This can only pick a log level. It is called strictly after the verdict is computed and its + * result is never read by anything that decides validity. + * + * @param rezumat a stable key for the outcome; equal keys mean nothing an operator cares about has + * changed + * @param blockNumber the height being validated + * @return true to log at INFO, false to drop to DEBUG + */ + boolean shouldLogAtInfo(final String rezumat, final long blockNumber) { + final boolean seSchimba = !rezumat.equals(lastLoggedOutcome); + // Long.MIN_VALUE as "never logged" cannot be subtracted from without overflowing, and an + // overflow here would silently invert the comparison: the first block would take the DEBUG + // branch and the very first line, the one that tells an operator the rule is alive at all, + // would never appear. + final boolean bataieDeInima = + lastLoggedBlock == Long.MIN_VALUE || blockNumber - lastLoggedBlock >= LOG_HEARTBEAT_BLOCKS; + if (seSchimba || bataieDeInima) { + lastLoggedOutcome = rezumat; + lastLoggedBlock = blockNumber; + return true; + } + return false; + } + + /** + * Constructor with an explicit retirement height. + * + * @param retirementBlock the height at and after which this rule stands down in favour of the V2 + * anchor rules; {@link Long#MAX_VALUE} to never retire + */ + public FalconSealValidationRule(final long retirementBlock) { + this.retirementBlock = retirementBlock; + } + + @Override + public boolean validate( + final BlockHeader header, final BlockHeader parent, final ProtocolContext protocolContext) { + // AERE ANCORA-V2: RETIREMENT GATE FIRST, before any singleton touch, any world-state lookup and + // any decode. From H this rule contributes nothing, and the two anchor rules carry the whole + // post-quantum verdict. Below H this branch is not taken and behaviour is bit-for-bit as before. + if (header.getNumber() >= retirementBlock) { + return true; + } + + final FalconSealSupport pqc = FalconSealSupport.instance(); + + // STAGE-2: opportunistic late-anchor activation from the parent block's world state. Never + // throws; pre-activation this is a cheap account lookup per imported block until the anchor + // contract appears on-chain. + if (pqc.lateAnchorPending()) { + tryActivateLateAnchor(pqc, parent, protocolContext); + } + + // AERE FIX-OPRIRE-CONSENS (c): a TERMINALLY FAILED late anchor (an on-chain anchor was observed + // and did NOT match the local manifest) is a tamper signal, not a "not yet" signal. The old code + // could not tell the two apart: both left genesisAnchored()==false and lateAnchored()==false, so + // BLOCKING was simply never armed and the whole PQC layer degraded to LOG-ONLY - it failed OPEN + // on exactly the input an attacker controls. FAILED now fails CLOSED at and after the fork + // block, while PENDING keeps the deliberate log-only behaviour that lets a legitimate anchor + // transaction still land (see the ARMING PRECONDITION note below). + final long forkBlock = pqc.forkBlock(); + if (header.getNumber() >= forkBlock && pqc.lateAnchorFailed()) { + LOG.error( + "AERE PQC (BLOCKING, FAIL-CLOSED): block {} REJECTED - the late-anchor manifest is " + + "TERMINALLY FAILED. keccak256(local manifest) did not match the hash in the " + + "on-chain anchor contract {} slot 0, so the Falcon registry is permanently empty. " + + "This is a tampered or mismatched anchor, NOT an anchor that has yet to land, and " + + "it must not silently degrade PQC enforcement to log-only. Re-anchor with the " + + "correct manifest, or restart the node with the manifest that matches the chain.", + header.getNumber(), + pqc.anchorAddress()); + return false; + } + // ARMING PRECONDITION: entering BLOCKING mode requires BOTH the fork height AND an ACTIVE + // anchored registry (genesis-anchored, or a late anchor already activated by tryActivateLateAnchor + // above). If forkBlock is armed at or before the late-anchor observation height, the registry is + // not yet active when the fork block is validated; blocking there rejects every block (empty + // registry => no eligible seal) and permanently HALTS the chain before the anchor-deploy + // transaction can settle. Staying LOG-ONLY until the registry is anchored lets the chain reach + // the anchor-deploy height, activate just-in-time at validate(D+1), and only THEN enforce the + // Falcon quorum. This never loses PQC enforcement: with no active registry there are no keys to + // verify against, so blocking would provide zero safety, only a halt. + final boolean forkReached = header.getNumber() >= forkBlock; + final boolean registryActive = pqc.genesisAnchored() || pqc.lateAnchored(); + final boolean blocking = forkReached && registryActive; + // ARMED WITHOUT AN ACTIVE REGISTRY: leave a MARK, not only a line. The log-only answer below is + // the right answer for a header rule, and it is also how this condition used to vanish: the node + // was configured to enforce a post-quantum quorum, enforced nothing, and said so once per block + // into a file. The counter is readable from a test and from a JMX/diagnostic path; the WARN is + // emitted only on the first occurrence, because one line per block at a sub-second block period + // is itself a hazard. + if (forkReached + && !registryActive + && pqc.noteBlockingArmedWithoutActiveRegistry(header.getNumber())) { + LOG.warn( + "AERE PQC: block {} is at/after the Falcon fork block ({}) but the anchored registry is " + + "NOT yet active (genesisAnchored={}, lateAnchored={}, lateAnchorPending={}). Staying " + + "LOG-ONLY so the chain can reach the anchor height and activate; the Falcon quorum " + + "becomes BLOCKING only once the registry is anchored. If this persists, the " + + "late-anchor contract has not settled - deploy/observe it before relying on PQC " + + "enforcement.", + header.getNumber(), + forkBlock, + pqc.genesisAnchored(), + pqc.lateAnchored(), + pqc.lateAnchorPending()); + } + + try { + final BftExtraData extraData = extraDataCodec.decodeRaw(header.getExtraData()); + final Collection falconSeals = extraData.getFalconSeals(); + + final BftContext bftContext = protocolContext.getConsensusContext(BftContext.class); + final Collection

validators = + bftContext.getValidatorProvider().getValidatorsAfterBlock(parent); + + // AERE FIX-OPRIRE-CONSENS (b): feed the authoritative validator set to the seal-ATTACHMENT + // gate. This is the only place in the QBFT stack that already holds a validator set bound to a + // height, and it runs on every imported block, i.e. always before the next round's commit. + // The gate refuses to attach until the anchored registry covers ALL of these addresses. + pqc.observeValidators(header.getNumber(), validators); + + // AERE audit fix, ELIGIBLE-SIGNER BINDING: the eligible-signer set is the intersection of the + // CURRENT validator set with the address-bound signer registry. BOTH the quorum and the + // counted-seal set are derived from this ONE set, so neither can drift from the other. + final Set
registered = pqc.registeredValidatorAddresses(); + final Set
eligible = new LinkedHashSet<>(); + for (final Address v : validators) { + if (registered.contains(v)) { + eligible.add(v); + } + } + final int quorum = calculateRequiredValidatorQuorum(eligible.size()); + + final Hash commitHash = + new BftBlockHashing(extraDataCodec).calculateDataHashForCommittedSeal(header); + + // Count DISTINCT ELIGIBLE validator addresses whose Falcon seal verifies over the commit hash. + // A seal counts only if its registry-bound address is an eligible signer (i.e. a current + // validator with a registered key); seals from registered-but-removed validators, or from + // unregistered indices, are excluded. + // REGISTRY HEIGHT BINDING (2026-08-06): resolve the key set AT THE HEIGHT OF THE HEADER + // CARRYING THE SEAL, not at this node's head. These seals are over THIS header's committed-seal + // hash, so the height is this header's own - unlike R2, whose certificate commits to the + // PARENT. The adversarial review measured this rule asking a height-less registry; below the + // arming height the resolver still answers from the head registry, so the 11.8 million blocks + // already on chain 2800 are checked exactly as before, but the rule can no longer be the + // reason a rotation makes history unverifiable. + final Set
counted = new HashSet<>(); + for (final FalconSeal seal : falconSeals) { + // REGISTRY HEIGHT BINDING, the HISTORY door: R1's seals are over THIS header's + // committed-seal hash, so the height is the header's own; the header still came from + // outside. + final Address signer = + pqc.addressForIndexAtHistoric(header.getNumber(), seal.getValidatorIndex()); + if (signer == null || !eligible.contains(signer) || counted.contains(signer)) { + continue; + } + if (pqc.verifyAtHistoric( + header.getNumber(), + seal.getValidatorIndex(), + commitHash.getBytes(), + seal.getSignature())) { + counted.add(signer); + } else if (blocking) { + LOG.warn( + "AERE PQC (BLOCKING): block {} carried a Falcon seal from validator index {} " + + "(address {}) that did NOT verify. It does not count toward the quorum.", + header.getNumber(), + seal.getValidatorIndex(), + signer); + } + } + final int valid = counted.size(); + + if (blocking) { + // Fail-closed (arm-time surfaced): a registry that is not address-bound, or a validator set + // that shares no keyed signer with the registry, cannot carry a genuine eligible Falcon + // quorum. Reject rather than let ceil(2*0/3)=0 be trivially met (that would be fail-OPEN). + if (!pqc.addressBound() || eligible.isEmpty()) { + LOG.error( + "AERE PQC (BLOCKING): block {} REJECTED (fail-closed): no eligible Falcon signer set " + + "(addressBound={}, |eligible|={}, N={}, registrySize={}). Blocking requires an " + + "ADDRESS-BOUND registry that covers current validators; re-anchor before arming.", + header.getNumber(), + pqc.addressBound(), + eligible.size(), + validators.size(), + pqc.registrySize()); + return false; + } + + // ARMING INVARIANT (loud, non-halting): if the registry does not cover the full validator + // set, the chain stays LIVE on the intersection but the two-fault margin is reduced. This is + // the signal that a validator was added without re-anchoring; it is never a silent halt. + if (!pqc.registryCoversValidators(validators)) { + LOG.warn( + "AERE PQC (BLOCKING): block {} - registry does NOT cover the validator set " + + "(|eligible|={} < N={}). Running on the eligible intersection (LIVE, quorum={}), " + + "but two-fault liveness margin is reduced. A validator was added without an " + + "atomic registry re-anchor: RE-ANCHOR the Falcon manifest for the full validator " + + "set, in the same change that adds the validator, never as a later step.", + header.getNumber(), + eligible.size(), + validators.size(), + quorum); + } + + if (valid >= quorum) { + LOG.info( + "AERE PQC (BLOCKING): block {} Falcon quorum certificate OK: {} distinct valid " + + "eligible seal(s) >= quorum {} of |eligible|={} (N={}). Post-quantum seal is " + + "consensus-enforced alongside ECDSA.", + header.getNumber(), + valid, + quorum, + eligible.size(), + validators.size()); + return true; + } + LOG.warn( + "AERE PQC (BLOCKING): block {} REJECTED: only {} distinct valid eligible Falcon seal(s) " + + "< quorum {} of |eligible|={} (N={}). A post-fork block MUST carry a Falcon quorum " + + "certificate over the eligible-signer set.", + header.getNumber(), + valid, + quorum, + eligible.size(), + validators.size()); + return false; + } + + // Pre-fork: log-only, never blocks. + final String stare = + eligible.isEmpty() + ? "no-eligible-signers" + : (valid >= quorum ? "PQC-QUORUM-MET" : "PQC-quorum-not-yet"); + // The verdict is already decided above. Everything below only picks a LOG LEVEL. + final String rezumat = + stare + "|" + valid + "|" + falconSeals.size() + "|" + eligible.size() + "|" + validators.size(); + final boolean laInfo = shouldLogAtInfo(rezumat, header.getNumber()); + final String mesaj = + "AERE PQC (LOG-ONLY): block {} -> {} of {} Falcon seal(s) verified over |eligible|={} " + + "(N={}); 2/3 eligible quorum would be {} [{}]. This check never blocks pre-fork; " + + "ECDSA committed seals remain decisive."; + if (laInfo) { + lastLoggedOutcome = rezumat; + lastLoggedBlock = header.getNumber(); + LOG.info( + mesaj, + header.getNumber(), + valid, + falconSeals.size(), + eligible.size(), + validators.size(), + quorum, + stare); + } else { + LOG.debug( + mesaj, + header.getNumber(), + valid, + falconSeals.size(), + eligible.size(), + validators.size(), + quorum, + stare); + } + return true; + } catch (final Exception e) { + if (blocking) { + LOG.warn( + "AERE PQC (BLOCKING): block {} REJECTED due to a Falcon quorum check error: {}", + header.getNumber(), + e.toString()); + return false; + } + // Pre-fork: NEVER block on a Falcon fault. Log and accept; ECDSA remains decisive. + LOG.debug("AERE PQC (LOG-ONLY): skipped Falcon check for a block: {}", e.toString()); + return true; + } + } + + /** + * STAGE-2: look up the anchor contract in the parent block's world state; if its storage slot 0 + * is non-zero, attempt registry activation against that hash. Defensive: never throws, never + * blocks; until the contract exists this is a single account lookup per imported block. + */ + private void tryActivateLateAnchor( + final FalconSealSupport pqc, final BlockHeader parent, final ProtocolContext protocolContext) { + try { + final Optional ws = + protocolContext.getWorldStateArchive().get(parent.getStateRoot(), parent.getHash()); + if (ws.isEmpty()) { + return; + } + final Account account = ws.get().get(Address.fromHexString(pqc.anchorAddress())); + if (account == null) { + return; + } + final UInt256 slot0 = account.getStorageValue(UInt256.ZERO); + if (slot0 == null || slot0.isZero()) { + return; + } + pqc.activateLateAnchor(slot0.toBytes().toUnprefixedHexString()); + } catch (final Exception e) { + LOG.debug( + "AERE PQC: late-anchor lookup skipped at parent {}: {}", + parent.getNumber(), + e.toString()); + } + } + + @Override + public boolean includeInLightValidation() { + return false; + } + + @Override + public String toString() { + return "FalconSealValidation(fork-gated blocking, eligible-signer intersection, late-anchor " + + "capable, retiresAt=" + + (retirementBlock == Long.MAX_VALUE ? "NEVER" : retirementBlock) + + ")"; + } +} diff --git a/anchor/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorDigestAttachedRule.java b/anchor/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorDigestAttachedRule.java new file mode 100644 index 0000000..b1a3ba2 --- /dev/null +++ b/anchor/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorDigestAttachedRule.java @@ -0,0 +1,115 @@ +/* + * Copyright contributors to Besu / Aere Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.qbft.headervalidationrules; + +import org.hyperledger.besu.consensus.common.bft.PqAnchorConfig; +import org.hyperledger.besu.ethereum.ProtocolContext; +import org.hyperledger.besu.ethereum.core.BlockHeader; +import org.hyperledger.besu.ethereum.mainnet.AttachedBlockHeaderValidationRule; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * R1A: the anchor digest rule again, on the ATTACHED side of the validator. + * + *

Why a second copy of a rule that already exists. {@link PqAnchorDigestRule} is DETACHED. + * Besu decides which rules run from the {@code HeaderValidationMode}, and the two facts that matter + * were measured, not assumed: + * + *

    + *
  • {@code HeaderValidationMode.SKIP_DETACHED} keeps exactly the rules for which {@code + * isDetachedSupported()} is false. That is the mode {@code FullImportBlockStep} passes to + * {@code BlockImporter.importBlock} for every block acquired by full sync. So a detached rule + * does NOT run at import. + *
  • {@code DETACHED_ONLY}, the mode under which a detached rule does run during download, is + * applied by {@code DownloadHeaderSequenceTask} and therefore only on ranges that {@code + * SyncTargetRange.hasEnd()} reports as closed. The open-ended range at the tip of a recovery + * took the other branch of {@code DownloadHeadersStep} and was validated by nothing. + *
+ * + *

Together those two facts left a hole of the last {@code downloader-header-request-size} minus + * one blocks, 199 by default, of every catch-up: on those the certificate binding was checked by no + * rule at all. A node that had been down for under about 103 seconds recovered its entire gap + * through that window. This rule closes it from the other side: since it is ATTACHED it survives + * {@code SKIP_DETACHED}, so the binding is now enforced on every block that enters the database, by + * whichever path it arrived. + * + *

It is the same verdict, not a second opinion. The check is delegated to the one {@link + * PqAnchorDigestRule} instance rather than reimplemented, so there is exactly one definition of the + * anchor digest and no way for the two paths to drift apart. The delegate ignores its {@code parent} + * argument entirely - the digest is computed from {@code header.getParentHash()}, {@code + * header.getNumber() - 1}, the configured chain id and element 6 of the header's own extraData - so + * running it from an attached position is not merely permissible, it is the same pure function of + * the same header. + * + *

Deliberately NOT in light validation. {@link PqAnchorDigestRule} already covers every + * light mode. Returning false here keeps this rule out of proposal-time validation and confines it + * to {@code FULL} and {@code SKIP_DETACHED}, which is the import path. That is the smallest change + * that closes the hole, and it means the cost added to a path that was already paying for the check + * is nil. + * + *

Inactive below H. The delegate's height gate is its first statement, before any decode, + * so below the activation height this rule is one virtual call and a comparison. A binary carrying + * it behaves exactly as today on the whole existing chain. + */ +public class PqAnchorDigestAttachedRule implements AttachedBlockHeaderValidationRule { + + private static final Logger LOG = LoggerFactory.getLogger(PqAnchorDigestAttachedRule.class); + + private final PqAnchorConfig config; + private final PqAnchorDigestRule delegate; + + /** + * Instantiates the attached half of the anchor digest rule. + * + * @param config the height-indexed activation configuration + */ + public PqAnchorDigestAttachedRule(final PqAnchorConfig config) { + this.config = config; + this.delegate = new PqAnchorDigestRule(config); + } + + @Override + public boolean validate( + final BlockHeader header, final BlockHeader parent, final ProtocolContext protocolContext) { + final boolean ok = delegate.validate(header, parent); + if (!ok) { + // The delegate has already logged WHY. This line exists so that the path can be told apart in + // a log: a rejection carrying R1A was caught while the block was being imported, which is the + // window that used to be open, whereas R1 alone means it was caught while headers were being + // downloaded. + LOG.warn( + "AERE PQ ANCHOR (R1A): block {} REJECTED on the ATTACHED path. This is the import-time " + + "copy of the digest binding; it is what catches a stripped or swapped certificate " + + "on the open-ended range at the tip of a sync, which detached validation never " + + "reaches.", + header.getNumber()); + } + return ok; + } + + @Override + public boolean includeInLightValidation() { + return false; + } + + @Override + public String toString() { + return "PqAnchorDigestAttached(attached, not-light, " + + (config.everActive() ? "H=" + config.anchorBlock() : "INACTIVE") + + ")"; + } +} diff --git a/anchor/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorDigestRule.java b/anchor/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorDigestRule.java new file mode 100644 index 0000000..c79a884 --- /dev/null +++ b/anchor/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorDigestRule.java @@ -0,0 +1,207 @@ +/* + * Copyright contributors to Besu / Aere Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.qbft.headervalidationrules; + +import org.hyperledger.besu.consensus.common.bft.BftExtraData; +import org.hyperledger.besu.consensus.common.bft.BftExtraDataCodec; +import org.hyperledger.besu.consensus.common.bft.FalconSeal; +import org.hyperledger.besu.consensus.common.bft.PqAnchor; +import org.hyperledger.besu.consensus.common.bft.PqAnchorConfig; +import org.hyperledger.besu.consensus.qbft.QbftExtraDataCodec; +import org.hyperledger.besu.ethereum.core.BlockHeader; +import org.hyperledger.besu.ethereum.mainnet.DetachedBlockHeaderValidationRule; + +import java.util.List; + +import org.apache.tuweni.bytes.Bytes; +import org.apache.tuweni.bytes.Bytes32; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * R1: the anchor DIGEST rule. DETACHED, and deliberately part of LIGHT validation. + * + *

This rule is the whole point of the V2 scheme. It is what makes the sentence "the post-quantum + * signature protects the block" true rather than decorative: the block's own vanityData, which IS in + * the block-hash pre-image, carries the digest of the certificate the block carries for its parent. + * Strip the certificate, swap it for a different but equally valid one, or reorder it, and the digest + * no longer matches and the header is refused. + * + *

Pure function of the header. It reads {@code header.getParentHash()}, {@code + * header.getNumber() - 1}, the configured chain id, and element 6 of the header's OWN extraData. No + * state, no registry, no loaded parent, no Falcon. One keccak. That is why it is cheap enough to be + * declared part of light validation, and it is so declared. + * + *

WHERE IT ACTUALLY RUNS. MEASURED 2026-08-02, and this paragraph CORRECTS an earlier claim in + * this same file. The earlier text read "a node that fast-syncs headers still catches a stripped + * or swapped certificate". That sentence is false in every configuration this binary can reach, and + * it was re-measured against the compiled jars of this tree: + * + *

    + *
  • {@code SyncMode} in this Besu has exactly TWO values, {@code FULL} and {@code SNAP}. There is + * no fast sync here to speak of. [MEASURED: enumerated from the compiled {@code SyncMode}.] + *
  • With the anchor armed, {@code PqAnchorSyncModeGuard} REFUSES TO START any node whose mode is + * not {@code FULL}. {@code SNAP}, null, blank and unrecognised all abort. Refusing to start is + * not catching anything. [MEASURED: 7 of 7 assertions against the compiled guard, and + * reproduced on a running node.] + *
  • With the anchor NOT armed, or disarmed by the emergency option, every mode starts again and + * this rule is inert by its own height gate. [MEASURED: both negative controls of the same + * probe, which is what shows the probe is measuring the anchor and not the mode string.] + *
+ * + *

So do not read the light-validation flag on this rule as a statement about a syncing node. What + * makes this rule reach the history a node ACCEPTS is two other things, in two other files: {@code + * DownloadHeadersStep} now applying the same validation policy to OPEN-ENDED ranges that it already + * applied to closed ones, and {@link PqAnchorDigestAttachedRule}, which carries the identical verdict + * on the ATTACHED side so that it survives {@code SKIP_DETACHED} at import. Before those two existed + * an honest node with its data directory deleted imported stripped-certificate headers in silence and + * reached head 350 with ZERO occurrences of this rule. [MEASURED on a node restarted from an empty + * data directory; and the NEGATIVE CONTROL, the same run with the two guards stubbed out, + * reproduces exactly that silence, which is what shows the observation is about the guards and not + * about the run.] + * + *

includeInLightValidation, measured. Both {@code DetachedBlockHeaderValidationRule} and + * {@code AttachedBlockHeaderValidationRule} declare {@code includeInLightValidation()} with a DEFAULT + * of {@code true}. {@code BlockHeaderValidator.Builder.addRule(DetachedBlockHeaderValidationRule)} + * snapshots that value at build time into the wrapping {@code Rule}, and the modes {@code + * LIGHT_DETACHED_ONLY}, {@code LIGHT} and {@code DETACHED_ONLY} all admit a detached rule whose flag + * is true. This rule overrides the method anyway, explicitly returning true, so the property is + * asserted by this file rather than inherited silently from an upstream default that a Besu rebase + * could change under us. + * + *

Inactive below H. The height gate is the FIRST statement, before any decode. Below H this + * rule does not even parse the header, so a binary carrying it behaves exactly as today on the whole + * existing chain. That is the condition under which such a binary can be warmed on a live node. + * + *

Fail-closed above H. At or above H, anything that prevents the digest from being computed + * and matched is a REJECT: a non-canonical or undecodable extraData, a vanity field that is not 32 + * bytes, a certificate whose indices are not strictly increasing, or a mismatching digest. Never an + * implicit accept, and specifically never "true on exception", which is the defect that made the + * legacy {@code FalconSealValidationRule} unable to catch anything. + * + *

The residual exposure this does NOT close, stated plainly: the certificate is outside the + * block-hash pre-image, so a hostile peer can serve a header with the certificate mangled and that + * header becomes unimportable although its block hash is unchanged. That is denial of service at + * header service, not forgery, and it is the same exposure the ECDSA committed seals already carry + * today. + */ +public class PqAnchorDigestRule implements DetachedBlockHeaderValidationRule { + + private static final Logger LOG = LoggerFactory.getLogger(PqAnchorDigestRule.class); + + private final PqAnchorConfig config; + private final QbftExtraDataCodec extraDataCodec = new QbftExtraDataCodec(); + + /** + * Instantiates the anchor digest rule. + * + * @param config the height-indexed activation configuration + */ + public PqAnchorDigestRule(final PqAnchorConfig config) { + this.config = config; + } + + @Override + public boolean validate(final BlockHeader header, final BlockHeader parent) { + final long number = header.getNumber(); + + // HEIGHT GATE FIRST. Below H nothing at all happens: no decode, no allocation, no log. This is + // the property that lets a binary built from this code run on a live node before activation and + // behave bit-for-bit as today. From 2026-08-07 the gate also covers the interval: at a height + // that carries no certificate there is nothing to bind, so there is nothing to judge. + // + // THE SAME METHOD AS R2, and not two questions that merely resemble each other. See the note in + // PqAnchorSealsRule. + if (!config.anchorAppliesAt(number)) { + return true; + } + + try { + final BftExtraData extraData = extraDataCodec.decodeRaw(header.getExtraData()); + + final Bytes vanity = extraData.getVanityData(); + if (vanity.size() != BftExtraDataCodec.EXTRA_VANITY_LENGTH) { + LOG.warn( + "AERE PQ ANCHOR (R1): block {} REJECTED - vanityData is {} bytes, expected {}. From H " + + "the whole vanity field is the anchor digest D.", + number, + vanity.size(), + BftExtraDataCodec.EXTRA_VANITY_LENGTH); + return false; + } + + final List certificate = List.copyOf(extraData.getFalconSeals()); + // Height aware since 2026-08-11. Inside the one measured historical window the ordering + // requirement relaxes from SORTED to DISTINCT, because 36 canonical headers were written + // unsorted during a production interruption and a node syncing from genesis would otherwise + // stop at the first of them and never pass it. Distinctness is not relaxed at any height, so a + // repeated index stays unrepresentable everywhere. See PqAnchor.hasAcceptableIndices. + if (!PqAnchor.hasAcceptableIndices(certificate, number)) { + // Checked HERE, not only in the seals rule, so reordering and duplicate indices are caught + // in LIGHT validation too, and so that "the order given" and "sorted" are the same list for + // every header this rule accepts. + LOG.warn( + "AERE PQ ANCHOR (R1): block {} REJECTED - the carried certificate's validator indices " + + "are not strictly increasing ({} seal(s)). Exactly one order is accepted for a " + + "given set, which is also what makes a repeated index unrepresentable.", + number, + certificate.size()); + return false; + } + + final Bytes32 expected = + PqAnchor.anchorDigest( + config.chainId(), number - 1L, header.getParentHash().getBytes(), certificate); + + if (!expected.equals(Bytes32.wrap(vanity))) { + LOG.warn( + "AERE PQ ANCHOR (R1): block {} REJECTED - anchor digest mismatch. vanityData carries " + + "{} but the certificate actually carried in this header ({} seal(s), over parent " + + "{} at height {}) digests to {}. The certificate was stripped, replaced or " + + "reordered after the proposer wrote it.", + number, + vanity, + certificate.size(), + header.getParentHash(), + number - 1L, + expected); + return false; + } + + return true; + } catch (final Exception e) { + // FAIL CLOSED. The legacy Falcon rule returned true on every path including this one, which is + // why it could never catch anything. A rule that cannot compute its verdict has not proved the + // header good. + LOG.warn( + "AERE PQ ANCHOR (R1): block {} REJECTED - the anchor digest could not be computed: {}", + number, + e.toString()); + return false; + } + } + + @Override + public boolean includeInLightValidation() { + return true; + } + + @Override + public String toString() { + return "PqAnchorDigest(detached, light, " + + (config.everActive() ? "H=" + config.anchorBlock() : "INACTIVE") + + ")"; + } +} diff --git a/anchor/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorSealsRule.java b/anchor/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorSealsRule.java new file mode 100644 index 0000000..a435074 --- /dev/null +++ b/anchor/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorSealsRule.java @@ -0,0 +1,329 @@ +/* + * Copyright contributors to Besu / Aere Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.qbft.headervalidationrules; + +import org.hyperledger.besu.consensus.common.bft.BftContext; +import org.hyperledger.besu.consensus.common.bft.BftExtraData; +import org.hyperledger.besu.consensus.common.bft.FalconSeal; +import org.hyperledger.besu.consensus.common.bft.FalconSealSupport; +import org.hyperledger.besu.consensus.common.bft.PqAnchor; +import org.hyperledger.besu.consensus.common.bft.PqAnchorConfig; +import org.hyperledger.besu.consensus.common.bft.PqSignerRegistry; +import org.hyperledger.besu.consensus.qbft.QbftExtraDataCodec; +import org.hyperledger.besu.datatypes.Address; +import org.hyperledger.besu.ethereum.ProtocolContext; +import org.hyperledger.besu.ethereum.core.BlockHeader; +import org.hyperledger.besu.ethereum.mainnet.AttachedBlockHeaderValidationRule; + +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import org.apache.tuweni.bytes.Bytes32; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * R2: the anchor SEALS rule. ATTACHED, full validation only. + * + *

Where R1 proves that the header commits to the certificate it carries, R2 proves the + * certificate is worth something. It checks exactly four things, and every one of them is a function + * of the header, the parent header, and configuration. NONE of them is a comparison against what + * this node happened to hear. + * + *

    + *
  1. k = |C| is at least K(height), the staged threshold; + *
  2. the validator indices are STRICTLY increasing, so there is one accepted order and a repeated + * index is unrepresentable; + *
  3. every index maps, through the registry, to an address in the PARENT's validator set; + *
  4. every signature verifies as Falcon-512 over M(parent). + *
+ * + *

Why the "what I heard" comparison is absent, and why that is the entire point. Measured + * on 200 consecutive live headers read simultaneously from two of our own nodes, with the block hash + * identical 200/200: the seal ORDER differed on 68 and the seal SET differed on 11. Any rule that + * required the carried certificate to match the local view would therefore reject roughly one header + * in eighteen with no attacker present, and at quorum 5 of 7 that is repeated stalling. Here a node + * that heard 7 accepts the proposer's certificate of 5, because Falcon verification is public: the + * validator does not need to have heard those commit messages to check them. The same construction + * also removes the third divergence axis for free, the one where a node signs the same block twice + * (once in {@code updateStateWithProposedBlock}, once in {@code peerIsPrepared}): both signatures are + * valid, the proposer writes one, everyone verifies it, and whether Falcon signing is deterministic + * stops mattering. That question remains NOT MEASURED and, under this scheme, no longer needs to be. + * + *

Not in light validation. This rule needs the consensus context and the registry, so + * {@code includeInLightValidation()} returns false. The split is deliberate: the part that binds the + * certificate to the block hash is one keccak and runs everywhere, the part that costs k Falcon + * verifications runs only where state exists. Measured Falcon verification is 0.068 ms median and + * 0.249 ms p95, so five verifications are under 1.3 ms against a measured 517 ms block interval. + * + *

Inactive below H, checked as the first statement, before any decode. + * + *

Fail-closed above H, on every path including exceptions and including a missing or + * mismatched parent. + * + *

LIVENESS COST, stated because it is real: the threshold makes the certificate a liveness + * condition. A proposer that cannot gather K seals for the parent cannot propose at all. At N=7 the + * quorum is 5 and N-f is 5, i.e. ZERO margin, which is why the validator set must reach N>=9 + * before this is armed with a quorum threshold. How often that stalls in practice is NOT MEASURED, + * because nothing has been run. + */ +public class PqAnchorSealsRule implements AttachedBlockHeaderValidationRule { + + private static final Logger LOG = LoggerFactory.getLogger(PqAnchorSealsRule.class); + + private final PqAnchorConfig config; + private final PqSignerRegistry registry; + private final QbftExtraDataCodec extraDataCodec = new QbftExtraDataCodec(); + + /** + * Instantiates the anchor seals rule against the production Falcon registry. + * + * @param config the height-indexed activation configuration + */ + public PqAnchorSealsRule(final PqAnchorConfig config) { + this(config, PqSignerRegistry.falconSealSupport()); + } + + /** + * Instantiates the anchor seals rule against an explicit registry. + * + * @param config the height-indexed activation configuration + * @param registry the index-to-signer view used for eligibility and verification + */ + public PqAnchorSealsRule(final PqAnchorConfig config, final PqSignerRegistry registry) { + this.config = config; + this.registry = registry; + } + + @Override + public boolean validate( + final BlockHeader header, final BlockHeader parent, final ProtocolContext protocolContext) { + final long number = header.getNumber(); + + // HEIGHT GATE FIRST, same contract as R1: below H this rule does nothing at all, and from + // 2026-08-07 it also does nothing at a height that carries no certificate. + // + // BOTH RULES ASK THE SAME QUESTION, THROUGH THE SAME METHOD, and that is deliberate. + // anchorAppliesAt() = activeAt() AND isAnchorHeight(). If R1 and R2 each asked it their own + // way, a header accepted by one and refused by the other would be a chain break, appearing at + // exactly the first height that is not an anchor height, i.e. the day after activation. + if (!config.anchorAppliesAt(number)) { + return true; + } + + try { + if (parent == null) { + LOG.warn( + "AERE PQ ANCHOR (R2): block {} REJECTED - no parent header available, so the " + + "certificate cannot be checked against the parent it claims to attest to.", + number); + return false; + } + // The certificate attests to the parent by NUMBER and HASH, and R1 has already bound the + // digest to header.getParentHash(). If the parent handed to this rule were a different header, + // R1 and R2 would be talking about two different objects. Cheap, and it removes any dependence + // on rule ordering with AncestryValidationRule. + if (!parent.getHash().equals(header.getParentHash()) + || parent.getNumber() != number - 1L) { + LOG.warn( + "AERE PQ ANCHOR (R2): block {} REJECTED - the supplied parent (number {}, hash {}) is " + + "not the parent this header names (number {}, hash {}).", + number, + parent.getNumber(), + parent.getHash(), + number - 1L, + header.getParentHash()); + return false; + } + + // AERE REGISTRY-COVERAGE REPORT (2026-08-02). DIAGNOSTIC ONLY, and deliberately before every + // early return below so it runs at every height from H, including the warm-up where K is 0 + // and the certificate is empty. It feeds FalconSealSupport's coverage report, whose only + // previous source was FalconSealValidationRule - a rule that stands down at exactly the + // height THIS rule takes over. Above H nothing updated it, so the report was either frozen at + // a set from below H or, on a node whose process started above H, never set at all. Nothing + // here can change this rule's verdict: the call cannot throw and its result is not read. + observeValidatorsForDiagnostics(parent, protocolContext); + + final BftExtraData extraData = extraDataCodec.decodeRaw(header.getExtraData()); + final List certificate = List.copyOf(extraData.getFalconSeals()); + final int k = certificate.size(); + final int required = config.minSealsAt(number); + + if (k < required) { + LOG.warn( + "AERE PQ ANCHOR (R2): block {} REJECTED - the carried certificate has {} seal(s), " + + "below the threshold K={} in force at this height.", + number, + k, + required); + return false; + } + + // Height aware since 2026-08-11, same measured historical window as rule R1. See + // PqAnchor.hasAcceptableIndices for why the exception sits at VALIDATION and nowhere else, and + // for what it deliberately does NOT relax. + if (!PqAnchor.hasAcceptableIndices(certificate, number)) { + LOG.warn( + "AERE PQ ANCHOR (R2): block {} REJECTED - the certificate's validator indices are not " + + "strictly increasing.", + number); + return false; + } + + if (k == 0) { + // Legitimate only while K is 0, which is the warm-up stage that starts at H itself, where + // the parent predates the scheme and no certificate can exist. R1 has already checked that + // the vanity digest is the digest OF THE EMPTY certificate, so an empty certificate is not + // an unchecked one. + LOG.debug( + "AERE PQ ANCHOR (R2): block {} carries an empty certificate and K={} at this height.", + number, + required); + return true; + } + + final BftContext bftContext = protocolContext.getConsensusContext(BftContext.class); + final Collection

parentValidators = + bftContext.getValidatorProvider().getValidatorsForBlock(parent); + if (parentValidators == null || parentValidators.isEmpty()) { + LOG.warn( + "AERE PQ ANCHOR (R2): block {} REJECTED - the validator set of the parent block {} " + + "could not be resolved, so seal eligibility cannot be decided.", + number, + parent.getNumber()); + return false; + } + final Set
eligible = new HashSet<>(parentValidators); + + final Bytes32 message = + PqAnchor.commitMessage( + config.chainId(), parent.getNumber(), parent.getHash().getBytes()); + + final Set
counted = new HashSet<>(); + for (final FalconSeal seal : certificate) { + final int index = seal.getValidatorIndex(); + // REGISTRY ROTATION: the key set is resolved AT THE PARENT'S HEIGHT, because that is the + // block these seals commit to. Resolving at this block's height would be wrong by one block, + // and at a rotation height "wrong by one block" is a different key set. + // REGISTRY HEIGHT BINDING, the HISTORY door: this rule judges a header this node received, + // at the PARENT's height, which is the height the certificate commits to. It must refuse a + // height it has no registry binding for rather than answer from its own head. + final Address signer = + registry.addressForIndexAtHistoric(parent.getNumber(), index); + if (signer == null) { + LOG.warn( + "AERE PQ ANCHOR (R2): block {} REJECTED - certificate carries index {}, which the " + + "registry does not bind to any validator address. An unbound index cannot be " + + "shown to be eligible, so it is refused rather than skipped.", + number, + index); + return false; + } + if (!eligible.contains(signer)) { + LOG.warn( + "AERE PQ ANCHOR (R2): block {} REJECTED - certificate carries index {} (address {}), " + + "which was NOT a validator of the parent block {}.", + number, + index, + signer, + parent.getNumber()); + return false; + } + if (!counted.add(signer)) { + LOG.warn( + "AERE PQ ANCHOR (R2): block {} REJECTED - two distinct registry indices, including " + + "{}, resolve to the same validator address {}. That would let one validator be " + + "counted twice despite strictly increasing indices.", + number, + index, + signer); + return false; + } + if (!registry.verifyAtHistoric( + parent.getNumber(), index, message, seal.getSignature())) { + LOG.warn( + "AERE PQ ANCHOR (R2): block {} REJECTED - the Falcon signature from index {} " + + "(address {}) does not verify over M(parent {}).", + number, + index, + signer, + parent.getNumber()); + return false; + } + } + + LOG.debug( + "AERE PQ ANCHOR (R2): block {} accepted with {} verified Falcon seal(s) over parent {} " + + "(threshold K={}, |parent validators|={}).", + number, + k, + parent.getNumber(), + required, + eligible.size()); + return true; + } catch (final Exception e) { + LOG.warn( + "AERE PQ ANCHOR (R2): block {} REJECTED - the certificate could not be checked: {}", + number, + e.toString()); + return false; + } + } + + /** + * AERE REGISTRY-COVERAGE REPORT: hand the parent's validator set to {@link FalconSealSupport} so + * its coverage report is about the height the chain is actually at. Swallows everything: a rule + * that rejected a header because a diagnostic threw would be a worse defect than the one this + * repairs. + * + * @param parent the parent header + * @param protocolContext the protocol context + */ + private void observeValidatorsForDiagnostics( + final BlockHeader parent, final ProtocolContext protocolContext) { + try { + final Collection
validators = + protocolContext + .getConsensusContext(BftContext.class) + .getValidatorProvider() + .getValidatorsForBlock(parent); + if (validators != null && !validators.isEmpty()) { + FalconSealSupport.instance().observeValidators(parent.getNumber(), validators); + } + } catch (final Exception e) { + LOG.debug( + "AERE PQ ANCHOR (R2): validator-set observation skipped at parent {}: {}", + parent.getNumber(), + e.toString()); + } + } + + @Override + public boolean includeInLightValidation() { + return false; + } + + @Override + public String toString() { + return "PqAnchorSeals(attached, full, " + + (config.everActive() ? "H=" + config.anchorBlock() : "INACTIVE") + + ", registry=" + + registry + + ")"; + } +} diff --git a/anchor/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqEmergencyShoutRule.java b/anchor/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqEmergencyShoutRule.java new file mode 100644 index 0000000..0f2ac70 --- /dev/null +++ b/anchor/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqEmergencyShoutRule.java @@ -0,0 +1,134 @@ +/* + * Copyright contributors to Besu / Aere Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.qbft.headervalidationrules; + +import org.hyperledger.besu.consensus.common.bft.PqAnchorConfig; +import org.hyperledger.besu.ethereum.core.BlockHeader; +import org.hyperledger.besu.ethereum.mainnet.DetachedBlockHeaderValidationRule; + +import java.util.concurrent.atomic.AtomicLong; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * AERE OPTIUNI-URGENTA (2026-08-02): the NOISE half of the emergency controls. + * + *

The rule this exists to obey. A quiet way out is worse than no way out. An emergency + * control that silently degrades what a node checks will be set once at three in the morning by + * somebody who means to remove it in the morning, and will still be set six months later, on a fleet + * everybody believes is enforcing something it is not. So each of the three controls costs the + * operator a complete ERROR line at EVERY height for as long as it is in force: not a startup + * banner that scrolls away, not a metric nobody has a dashboard for, a line in the log of a running + * node that never stops. + * + *

Why a header rule. The alternative is a timer, and a timer would have to invent its own + * idea of the current height and would drift away from the code that actually decides anything. This + * rule is asked once per header by the same validator that decides whether the header may enter the + * chain, and it reads the SAME {@link PqAnchorConfig} object the anchor rules read, so what it + * announces cannot disagree with what is enforced. + * + *

It cannot reject anything. {@link #validate} returns true on every path, including its + * exception path. That is deliberate and it is what makes it safe to place FIRST in the ruleset: a + * rule that always returns true cannot change the verdict of a conjunction, so putting it first + * changes nothing except that the announcement is made even on headers a later rule goes on to + * reject - which is exactly when an operator most needs to know that a control was overridden. + * + *

It says nothing when nothing is overridden. No line is emitted at all when no control is + * in force, so "the log contains no AERE-PQC-EMG line" is a measurable statement about a node, and + * the silence is the control case that proves the shout is caused by the override and not by the + * rule merely existing. + * + *

The registry bypass announces itself from {@code FalconSealSupport}, on the same per-height + * basis, because only that class knows whether the bypass is actually carrying weight at a height. + */ +public class PqEmergencyShoutRule implements DetachedBlockHeaderValidationRule { + + private static final Logger LOG = LoggerFactory.getLogger(PqEmergencyShoutRule.class); + + private final PqAnchorConfig config; + + /** Highest height already announced, so one line is emitted per height and not per validation. */ + private final AtomicLong lastShout = new AtomicLong(-1L); + + /** + * Instantiates the emergency announcement rule. + * + * @param config the height-indexed anchor configuration the anchor rules are running on; the + * announcement is derived from THIS object, not from a second reading of the configuration + */ + public PqEmergencyShoutRule(final PqAnchorConfig config) { + this.config = config; + } + + @Override + public boolean validate(final BlockHeader header, final BlockHeader parent) { + try { + final long number = header.getNumber(); + + // A configured anchor that has been switched off is a different state from an anchor that was + // never configured. Only the first is worth waking somebody for. + final boolean disarmed = config.disabled() && config.anchorConfigured(); + final boolean lowered = config.emergencyCeilingLowersAt(number); + + if (!disarmed && !lowered) { + return true; + } + if (lastShout.getAndSet(number) == number) { + return true; + } + + if (disarmed) { + LOG.error( + "AERE PQC EMERGENCY [AERE-PQC-EMG-DISARM-01]: height {} validated WITH THE CERTIFICATE " + + "ANCHOR DISARMED. An activation height ({}) is configured on this node and both " + + "anchor rules have been switched off by {}, so this node is NOT checking that the " + + "post-quantum certificate in a header is the one the proposer wrote, and is NOT " + + "enforcing any seal threshold. A stripped or swapped certificate is invisible to " + + "this node. WHAT TO DO: this is a RECOVERY setting; once the cause is fixed, " + + "restart WITHOUT it, together with the rest of the fleet. THIS LINE REPEATS AT " + + "EVERY HEIGHT, BY DESIGN.", + number, + config.anchorBlock(), + PqAnchorConfig.PROPERTY_DISABLE); + } + if (lowered) { + LOG.error( + "AERE PQC EMERGENCY [AERE-PQC-EMG-CEILING-01]: height {} validated WITH A LOWERED SEAL " + + "THRESHOLD. The schedule in force asks for K={} at this height; the emergency " + + "ceiling {} caps it, so the threshold actually enforced is K={}. Fewer " + + "post-quantum signatures stand behind every block accepted while this is set. The " + + "ceiling can only ever LOWER the threshold, never raise it, so this cannot be the " + + "cause of a stall - but it is the reason a stall stopped. WHAT TO DO: raise the " + + "value back, or remove the option, as soon as enough validators can seal again. " + + "THIS LINE REPEATS AT EVERY HEIGHT, BY DESIGN.", + number, + config.scheduledMinSealsAt(number), + config.minSealsCeiling().isPresent() ? config.minSealsCeiling().getAsInt() : -1, + config.minSealsAt(number)); + } + return true; + } catch (final RuntimeException e) { + // A rule whose only job is to talk must never be the reason a header is refused. + LOG.warn("AERE PQC EMERGENCY: the emergency announcement itself failed: {}", e.toString()); + return true; + } + } + + @Override + public boolean includeInLightValidation() { + return true; + } +} diff --git a/anchor/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqRegistryBindingRule.java b/anchor/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqRegistryBindingRule.java new file mode 100644 index 0000000..7792b46 --- /dev/null +++ b/anchor/consensus/qbft/src/main/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqRegistryBindingRule.java @@ -0,0 +1,91 @@ +/* + * Copyright contributors to Besu / Aere Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.qbft.headervalidationrules; + +import org.hyperledger.besu.consensus.common.bft.FalconSealSupport; +import org.hyperledger.besu.ethereum.core.BlockHeader; +import org.hyperledger.besu.ethereum.mainnet.DetachedBlockHeaderValidationRule; + +/** + * REGISTRY BINDING, the PER-BLOCK half. + * + *

The hole this closes, stated as the measurement that found it. The startup guard asks + * "does the registry on this node match what genesis requires" exactly once, against the chain head + * that existed when the process started. {@code config.pqRegistryHash} is a SCHEDULE, so it can have + * a second entry: a key rotation, or a validator-set change that re-anchors the manifest. A node + * that was already running when such a height passes underneath it is never asked the question + * again. It keeps verifying certificates against a registry the chain has moved off, and it keeps + * reporting itself healthy while it does so. That is the same defect the startup guard exists to + * remove, arriving through a different door, and the guard's own honest-limits note said so in + * writing: "the guard runs at startup; it cannot stop a node that is already running when a rotation + * height passes underneath it." + * + *

Why a header rule and not a background timer. A timer would have to invent its own + * notion of "the current height" and its own reaction, and would be a second, drifting source of + * truth about a consensus question. This rule is asked exactly once per header, by the code that + * already decides whether a header may enter this node's chain, and it answers with the same + * function the startup guard uses, over the same loaded registry object. There is one decision and + * one implementation of it. + * + *

What it does on mismatch, and why refusing is the fail-closed direction. It returns + * false, so the header is not imported, and {@code FalconSealSupport} emits one complete ERROR line + * per height naming the hash required, the height it is required from, the hash found, the file in + * use and a fingerprint per registry row. Refusing looks harsh, and the alternative is worse: a node + * that imports a header whose certificate it cannot correctly verify has asserted a check it did not + * perform, which is precisely the behaviour the whole registry-binding repair exists to remove. + * Stopping at the first header past the rotation, loudly, is recoverable in one restart; importing + * 200,000 blocks under the wrong registry is not. + * + *

Inert unless somebody armed it, three times over. The rule returns true when the startup + * guard never ran on this node, when genesis carries no {@code config.pqRegistryHash} at all, and at + * every height below the schedule's first entry. Chain 2800 as it stands today satisfies all three: + * its genesis has no such key, measured on the live genesis file. So a binary carrying this rule + * validates the existing 11.8 million blocks exactly as today. + * + *

DETACHED. And the light-validation flag does NOT keep it off the sync path - that claim + * was here and it was wrong. Detached because the question is about this node's configuration + * at a height, not about the parent header. {@code includeInLightValidation()} returns false below, + * and the sentence that used to stand here said that this kept the rule away from a node that is + * fast-syncing headers. READ IN UPSTREAM BESU, and it does not: + * {@code BlockHeaderValidator.validateHeader} filters on {@code Rule::isDetachedSupported} alone + * for {@code DETACHED_ONLY}; only the three LIGHT modes consult {@code includeInLightValidation}. + * {@code DETACHED_ONLY} is exactly the mode {@code DownloadHeaderSequenceTask} validates + * closed-ended header ranges under, so this rule DOES run on the header-download path. + * + *

What that means, stated no further than it was checked: a syncing node whose registry does not + * satisfy the entry active at a height inside a downloaded range fails that whole range, and + * upstream treats a failed range as a bad peer. Whether that costs the node its peer table, and + * what it looks like when every honest peer serves the same headers, is NOT MEASURED. + * + *

The startup guard is what covers a node at the moment it is configured to matter, and since + * AERE CONSENS-LENS (2026-08-02) it asks about chainHead + 1, i.e. about the same height this rule + * will be asked about first. Before that change the two disagreed by one block, and that one block + * was a permanent chain stop at any scheduled rotation. + */ +public class PqRegistryBindingRule implements DetachedBlockHeaderValidationRule { + + /** Default constructor. */ + public PqRegistryBindingRule() {} + + @Override + public boolean validate(final BlockHeader header, final BlockHeader parent) { + return FalconSealSupport.instance().registryBindingSatisfiedAt(header.getNumber()); + } + + @Override + public boolean includeInLightValidation() { + return false; + } +} diff --git a/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/QbftAnchorRuleWiringTest.java b/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/QbftAnchorRuleWiringTest.java new file mode 100644 index 0000000..3a92c29 --- /dev/null +++ b/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/QbftAnchorRuleWiringTest.java @@ -0,0 +1,194 @@ +/* + * AERE, the WIRING of the anchor rules into the validation chain. + * + * WHY THIS EXISTS, and it is the most expensive lesson of method we have paid for so far: "a gate + * that has never failed cannot be believed". + * + * The three rules are registered in QbftBlockHeaderValidationRulesetFactory. The rules themselves + * have good and plentiful tests: 18 in PqAnchorDigestRuleTest, 25 in PqAnchorSealsRuleTest. But ALL + * of them build the validator themselves, with + * `new BlockHeaderValidator.Builder().addRule(rule).build()`, that is, a validator holding a SINGLE + * rule, hand-made inside the test. Measured on 2026-08-07: ZERO tests in the whole qbft tree went + * through the factory. + * + * THE CONSEQUENCE, concretely: delete the line that adds PqAnchorSealsRule and all 43 tests stay + * GREEN. The rule keeps working perfectly in isolation and is never called on a real header again. + * Without this class the wiring is an assertion, not a measurement, and the only thing holding + * it up was a run log from a day ago. Yesterday's log does not guard tomorrow's code. + * + * HOW IT MEASURES. It builds the validator THROUGH THE FACTORY and reads the rules out of the + * object that was built, by reflection over its fields, without assuming their names. Reflection + * is a technique already used in this module (see PqInertBinaryTest and the reason given there), + * and it fits here because the question really is "what ended up inside", not "what the rule does". + * + * THE NEGATIVE CONTROL, and it lives in this same class: with an UNARMED configuration the same + * three rules must still be there. They switch themselves off ON HEIGHT, not by being absent from + * the chain. If somebody "optimised" the factory into not adding them when the anchor is not + * configured, a node would start without them and would then reach the activation height with + * nothing to apply. + */ +package org.hyperledger.besu.consensus.qbft; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.lang.reflect.Field; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.OptionalInt; +import org.hyperledger.besu.consensus.common.bft.PqAnchorConfig; +import org.junit.jupiter.api.Test; + +class QbftAnchorRuleWiringTest { + + private static final long H = 13_793_219L; + + /** The simple names of the classes the factory MUST put into the validator. */ + private static final List ANCHOR_RULES = + List.of("PqAnchorDigestRule", "PqAnchorDigestAttachedRule", "PqAnchorSealsRule"); + + private static PqAnchorConfig armed() { + return new PqAnchorConfig(2800L, H, Map.of(H, 3), OptionalInt.empty(), false); + } + + /** + * The simple names of every rule the built validator carries, whatever the fields holding them + * happen to be called. The object is read; its shape is not assumed. + */ + private static List rulesInside(final Object validator) throws Exception { + final List found = new ArrayList<>(); + for (Class c = validator.getClass(); c != null && c != Object.class; c = c.getSuperclass()) { + for (final Field f : c.getDeclaredFields()) { + f.setAccessible(true); + final Object value = f.get(validator); + if (value instanceof Collection collection) { + for (final Object element : collection) { + collectNames(element, found); + } + } + } + } + return found; + } + + /** + * The name of the element AND of the objects it holds in its own fields, one level down. + * + *

MEASURED 2026-08-07, and the first form of this test failed exactly here: {@code + * BlockHeaderValidator} does not hold the rules directly, it WRAPS them, and its collection holds + * nothing but objects whose simple name is {@code "Rule"}. A test that had stopped at the first + * level would have reported fifteen {@code "Rule"} and nothing else, meaning it would have been + * green on anything and red on anything, whichever way it happened to fall. We go down one level, + * and only one: what this test looks for is the rule the factory put in, not the whole object + * graph. + */ + private static void collectNames(final Object element, final List into) throws Exception { + if (element == null) { + return; + } + into.add(element.getClass().getSimpleName()); + for (final Field f : element.getClass().getDeclaredFields()) { + f.setAccessible(true); + final Object inner = f.get(element); + if (inner == null || f.getType().isPrimitive()) { + continue; + } + final String name = inner.getClass().getSimpleName(); + into.add(name); + // THE THIRD LEVEL, and only for lambdas. Measured 2026-08-07, the second form of this test: + // the ATTACHED rules show up directly (PqAnchorSealsRule, PqAnchorDigestAttachedRule), but + // the DETACHED ones do not show up at all: the builder wraps them in an adapter lambda, and + // all that could be seen was "BlockHeaderValidator$Builder$$Lambda/0x...". So + // PqAnchorDigestRule, the anchor's only detached rule, was invisible, and a test happy with + // what it could see would have declared missing a rule that was right there. + // + // We descend ONLY into the lambda, and not into every object, so that the test does not turn + // into a walk through the whole graph, where it would find anything and could no longer tell + // anything apart. + if (name.contains("Lambda")) { + for (final Field captured : inner.getClass().getDeclaredFields()) { + captured.setAccessible(true); + final Object deep = captured.get(inner); + if (deep != null) { + into.add(deep.getClass().getSimpleName()); + } + } + } + } + } + + private static Object buildViaFactory(final PqAnchorConfig cfg) { + return QbftBlockHeaderValidationRulesetFactory.blockHeaderValidator( + Duration.ofSeconds(1), false, Optional.empty(), cfg) + .build(); + } + + // --------------------------------------------------------------------------------------------- + // 1. The three rules are REALLY inside the validator the factory produces. + // This is the assertion that turns red if somebody deletes a line from the factory. + // --------------------------------------------------------------------------------------------- + @Test + void theFactoryPutsAllThreeAnchorRulesIntoTheValidatorItBuilds() throws Exception { + final List inside = rulesInside(buildViaFactory(armed())); + + assertThat(inside) + .describedAs( + "the rules found inside the validator the factory built; if one is missing, somebody " + + "removed an addRule and no other test in the tree would have noticed") + .containsAll(ANCHOR_RULES); + } + + // --------------------------------------------------------------------------------------------- + // 2. THE NEGATIVE CONTROL OF THIS TEST ITSELF. If the reflective reader returned an empty list + // for any reason at all, point 1 would have passed on anything at all. So we also demand that + // the validator carry upstream rules, which have nothing to do with the anchor. + // --------------------------------------------------------------------------------------------- + @Test + void theReaderActuallySeesSomethingAndNotAnEmptyList() throws Exception { + final List inside = rulesInside(buildViaFactory(armed())); + + assertThat(inside) + .describedAs("the reflective reader does see rules, otherwise the test above is empty") + .isNotEmpty(); + assertThat(inside.size()) + .describedAs("a QBFT validator carries far more than the three anchor rules") + .isGreaterThan(ANCHOR_RULES.size()); + } + + // --------------------------------------------------------------------------------------------- + // 3. UNARMED, the rules are STILL there. They switch off on height, not by being absent from the + // chain. A node that started without them would reach the activation height with nothing to + // apply, and the difference would not show until that very day. + // --------------------------------------------------------------------------------------------- + @Test + void theRulesArePresentEvenWhenTheAnchorIsNotConfigured() throws Exception { + final List inside = rulesInside(buildViaFactory(PqAnchorConfig.never(2800L))); + + assertThat(inside) + .describedAs( + "the rules switch off on HEIGHT, not by absence; an unarmed node must carry them " + + "anyway, so that it has them at the activation height") + .containsAll(ANCHOR_RULES); + } + + // --------------------------------------------------------------------------------------------- + // 4. And the three-argument overload, the upstream one production code calls, ends at the same + // result. Without this we would have measured only the path the test itself uses. + // --------------------------------------------------------------------------------------------- + @Test + void theUpstreamThreeArgumentOverloadWiresTheAnchorRulesToo() throws Exception { + final Object validator = + QbftBlockHeaderValidationRulesetFactory.blockHeaderValidator( + Duration.ofSeconds(1), false, Optional.empty()) + .build(); + + assertThat(rulesInside(validator)) + .describedAs( + "the 3-argument overload is the one production calls; if it bypasses the anchor, " + + "nothing else matters") + .containsAll(ANCHOR_RULES); + } +} diff --git a/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/FalconSealLogThrottleTest.java b/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/FalconSealLogThrottleTest.java new file mode 100644 index 0000000..f931824 --- /dev/null +++ b/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/FalconSealLogThrottleTest.java @@ -0,0 +1,182 @@ +/* + * Copyright contributors to Besu / Aere Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.qbft.headervalidationrules; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.Test; + +/** + * AERE: the LOG-ONLY summary must not write one INFO line per block. + * + *

MEASURED ON A LIVE NODE, and that is why this test exists. The rule wrote the summary at INFO + * on every imported block: 2295 lines in twenty minutes, every one of them identical, {@code + * 0 of 0 seals, |eligible|=0, no-eligible-signers}. On the previous binary the same twenty minutes + * had ZERO such lines, so the spam was entirely introduced by this change. + * + *

An ERROR or INFO line that repeats forever anaesthetises a log: operators learn to scroll past + * it, and the one line that matters arrives inside the noise. So a binary that replaces a running + * one must add zero new log lines compared with the one it replaces. This test is that requirement, + * for the part a unit test can hold. + */ +class FalconSealLogThrottleTest { + + private static final String STARE_A = "no-eligible-signers|0|0|0|7"; + private static final String STARE_B = "PQC-quorum-not-yet|3|5|7|7"; + + private static int numaraInfo( + final FalconSealValidationRule regula, final String stare, final long dePeLa, final int cate) { + int n = 0; + for (int i = 0; i < cate; i++) { + if (regula.shouldLogAtInfo(stare, dePeLa + i)) { + n++; + } + } + return n; + } + + @Test + void primaOaraSeScrieIntotdeauna() { + // Without this, an operator starting the node would NEVER see that the rule is alive. + final FalconSealValidationRule r = new FalconSealValidationRule(); + assertThat(r.shouldLogAtInfo(STARE_A, 12_742_475L)).isTrue(); + } + + @Test + void oMieDeBlocuriCuACEEASIStareScriuOSinguraLinie() { + final FalconSealValidationRule r = new FalconSealValidationRule(); + // 1000 blocks, well below the heartbeat, so only the first one may come out at INFO + assertThat(numaraInfo(r, STARE_A, 1_000L, 1_000)).isEqualTo(1); + } + + @Test + void schimbareaStariiSeVEDEPeBloculInCareSeIntampla() { + final FalconSealValidationRule r = new FalconSealValidationRule(); + numaraInfo(r, STARE_A, 1_000L, 500); + // exactly the block on which it changes, not the next one and not a heartbeat later + assertThat(r.shouldLogAtInfo(STARE_B, 1_500L)).isTrue(); + // and after the change it goes quiet again + assertThat(numaraInfo(r, STARE_B, 1_501L, 500)).isEqualTo(0); + } + + @Test + void siInapoiLaStareaVecheSeVEDE() { + // A regression, that is falling OUT of quorum, matters at least as much as reaching it. + final FalconSealValidationRule r = new FalconSealValidationRule(); + r.shouldLogAtInfo(STARE_A, 1_000L); + r.shouldLogAtInfo(STARE_B, 1_001L); + assertThat(r.shouldLogAtInfo(STARE_A, 1_002L)).isTrue(); + } + + @Test + void bataiaDeInimaScrieOLinieLaFiecareFereastra() { + final FalconSealValidationRule r = new FalconSealValidationRule(); + final long H = FalconSealValidationRule.LOG_HEARTBEAT_BLOCKS; + // 5 whole windows, one single state. Expected: the first line plus one per window. + int info = 0; + for (long b = 0; b < 5 * H; b++) { + if (r.shouldLogAtInfo(STARE_A, b)) { + info++; + } + } + assertThat(info).isEqualTo(5); + } + + @Test + void reducereaEDeCelPutinODieMieDeOri() { + // The number that matters for a node, as a proof rather than a claim in a comment. + final FalconSealValidationRule r = new FalconSealValidationRule(); + final int blocuriPeZi = 165_000; // 86400 / 0.523 + final int info = numaraInfo(r, STARE_A, 1_000L, blocuriPeZi); + assertThat(info).isLessThanOrEqualTo(20); + assertThat(blocuriPeZi / Math.max(info, 1)).isGreaterThanOrEqualTo(1_000); + } + + @Test + void nuSeSufocaCandStareaOscileazaLaFiecareBloc() { + // The bad case: if the state really does change on every block, the line MUST come out on every + // block. A throttle that smothered that too would hide exactly the moment we care about. + final FalconSealValidationRule r = new FalconSealValidationRule(); + int info = 0; + for (int i = 0; i < 100; i++) { + if (r.shouldLogAtInfo(i % 2 == 0 ? STARE_A : STARE_B, 1_000L + i)) { + info++; + } + } + assertThat(info).isEqualTo(100); + } + + @Test + void inaltimiCareVinInNEORDINE_nuAprindBataiaDeInima() { + // Import runs on several threads, so heights do not always arrive in increasing order. A step + // backwards makes the difference negative; that must NOT pass the threshold and write an extra + // line. + final FalconSealValidationRule r = new FalconSealValidationRule(); + r.shouldLogAtInfo(STARE_A, 1_000_000L); + int info = 0; + for (long b = 999_999L; b > 999_899L; b--) { + if (r.shouldLogAtInfo(STARE_A, b)) { + info++; + } + } + assertThat(info).isZero(); + } + + @Test + void subMaiMulteFireNuIeseUnPotopSiNiciZero() throws Exception { + // The rule is called from EthScheduler-Workers-N, so concurrently. I do not ask for perfect + // accuracy under races, but I do ask for the two things that matter: no flood, and no SILENCE. + final FalconSealValidationRule r = new FalconSealValidationRule(); + final int fire = 8; + final int perFir = 2_000; + final AtomicInteger info = new AtomicInteger(); + final ExecutorService ex = Executors.newFixedThreadPool(fire); + final CountDownLatch start = new CountDownLatch(1); + final List sarcini = new ArrayList<>(); + for (int f = 0; f < fire; f++) { + final long baza = 1_000L + (long) f * perFir; + sarcini.add( + () -> { + try { + start.await(); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + for (int i = 0; i < perFir; i++) { + if (r.shouldLogAtInfo(STARE_A, baza + i)) { + info.incrementAndGet(); + } + } + }); + } + sarcini.forEach(ex::submit); + start.countDown(); + ex.shutdown(); + assertThat(ex.awaitTermination(30, TimeUnit.SECONDS)).isTrue(); + assertThat(info.get()).isGreaterThanOrEqualTo(1); + // 16,000 calls; with no throttle that would be 16,000 lines. We require under 1% even under + // races. + assertThat(info.get()).isLessThan(160); + } +} diff --git a/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/FalconSealValidationRuleRetirementTest.java b/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/FalconSealValidationRuleRetirementTest.java new file mode 100644 index 0000000..1351cc2 --- /dev/null +++ b/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/FalconSealValidationRuleRetirementTest.java @@ -0,0 +1,85 @@ +/* + * Copyright contributors to Besu / Aere Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.qbft.headervalidationrules; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.H; +import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.extraData; +import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.header; +import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.parentHeader; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.withSettings; + +import org.hyperledger.besu.ethereum.ProtocolContext; +import org.hyperledger.besu.ethereum.core.BlockHeader; + +import java.util.Collections; + +import org.apache.tuweni.bytes.Bytes32; +import org.junit.jupiter.api.Test; +import org.mockito.quality.Strictness; + +/** + * The retirement of the legacy log-only Falcon rule at H. + * + *

The retirement is only worth anything if the gate precedes EVERY state touch, otherwise a node + * past H would still be paying for a rule that contributes nothing and still reading historical + * world state it may not have. So the test does not merely assert the return value, which is {@code + * true} on both sides of the gate and therefore proves nothing on its own: it asserts that above the + * retirement height the protocol context is never touched at all, and that below it, it is. + */ +public class FalconSealValidationRuleRetirementTest { + + private ProtocolContext context() { + return mock(ProtocolContext.class, withSettings().strictness(Strictness.LENIENT)); + } + + private BlockHeader headerAt(final long number) { + return header( + number, parentHeader(number - 1L).getHash(), extraData(Bytes32.ZERO, Collections.emptyList())); + } + + @Test + public void theDefaultConstructorNeverRetires() { + assertThat(new FalconSealValidationRule().toString()).contains("retiresAt=NEVER"); + assertThat(new FalconSealValidationRule(H).toString()).contains("retiresAt=" + H); + } + + @Test + public void atAndAboveTheRetirementHeightNothingIsTouchedAtAll() { + final FalconSealValidationRule rule = new FalconSealValidationRule(H); + for (final long number : new long[] {H, H + 1L, H + 5000L}) { + final ProtocolContext protocolContext = context(); + assertThat(rule.validate(headerAt(number), parentHeader(number - 1L), protocolContext)) + .isTrue(); + verifyNoInteractions(protocolContext); + } + } + + @Test + public void belowTheRetirementHeightTheRuleStillRunsExactlyAsBefore() { + final FalconSealValidationRule rule = new FalconSealValidationRule(H); + final ProtocolContext protocolContext = context(); + // Log-only baseline: it returns true, as it always has, including on its own error paths. That + // is precisely why it is being replaced rather than promoted, and precisely why leaving it in + // place below H is safe. + assertThat(rule.validate(headerAt(H - 1L), parentHeader(H - 2L), protocolContext)).isTrue(); + verify(protocolContext, atLeastOnce()).getConsensusContext(any()); + } +} diff --git a/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorDigestRuleTest.java b/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorDigestRuleTest.java new file mode 100644 index 0000000..7f03e36 --- /dev/null +++ b/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorDigestRuleTest.java @@ -0,0 +1,359 @@ +/* + * Copyright contributors to Besu / Aere Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.qbft.headervalidationrules; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.CHAIN_ID; +import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.H; +import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.extraData; +import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.header; +import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.honestHeader; +import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.parentHeader; +import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.seal; + +import org.hyperledger.besu.consensus.common.bft.BftExtraData; +import org.hyperledger.besu.consensus.common.bft.FalconSeal; +import org.hyperledger.besu.consensus.common.bft.PqAnchor; +import org.hyperledger.besu.consensus.common.bft.PqAnchorConfig; +import org.hyperledger.besu.consensus.qbft.QbftExtraDataCodec; +import org.hyperledger.besu.ethereum.core.BlockHeader; +import org.hyperledger.besu.ethereum.mainnet.BlockHeaderValidator; +import org.hyperledger.besu.ethereum.mainnet.HeaderValidationMode; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.OptionalInt; + +import org.apache.tuweni.bytes.Bytes; +import org.apache.tuweni.bytes.Bytes32; +import org.junit.jupiter.api.Test; + +/** + * R1, the anchor digest rule. + * + *

Two of these tests are the reason the whole V2 scheme exists, and both are asserted twice: once + * through the rule directly, and once through a real {@link BlockHeaderValidator} running in a LIGHT + * validation mode, because "caught in full validation" would not be good enough. + * + *

READ THE SCOPE OF THAT SECOND ASSERTION EXACTLY, because an earlier version of this comment did + * not, and said instead that a node which fast-syncs headers must catch these too. What the assertion + * says is narrower: when a {@link BlockHeaderValidator} is asked to validate this header in mode + * LIGHT, this rule runs and refuses. It does NOT say that any Besu sync path asks in that mode, and a + * green suite here is not evidence that a syncing node is protected. What protects a syncing node is + * measured elsewhere and lives in other files: the open-ended-range fix in {@code + * DownloadHeadersStep}, {@code PqAnchorDigestAttachedRule} on the attached side, and {@code + * PqAnchorSyncModeGuard} refusing to start outside FULL sync. + */ +public class PqAnchorDigestRuleTest { + + private final PqAnchorConfig armed = + new PqAnchorConfig(CHAIN_ID, H, Map.of(H, 0, H + 100L, 3), OptionalInt.empty(), false); + private final PqAnchorDigestRule rule = new PqAnchorDigestRule(armed); + private final QbftExtraDataCodec codec = new QbftExtraDataCodec(); + + private static final List QUORUM = List.of(seal(0), seal(1), seal(2), seal(3), seal(4)); + + // --------------------------------------------------------------------------------------------- + // The property that makes this binary safe to warm on a live node. + // --------------------------------------------------------------------------------------------- + + @Test + public void belowHTheRuleIsInertEvenOnHeadersItWouldOtherwiseReject() { + final BlockHeader parent = parentHeader(H - 2L); + // Vanity is the client version string this chain actually writes today, and there is no + // certificate at all: precisely the shape of all 11.8 million existing blocks. + final Bytes legacyVanity = + Bytes.fromHexString( + "0x000000000000626573752030302e302d646576656c6f702d7878787878787878"); + final BlockHeader legacyShaped = + header(H - 1L, parent.getHash(), extraData(legacyVanity, Collections.emptyList())); + assertThat(rule.validate(legacyShaped, parent)).isTrue(); + + // And a header just below H whose vanity is pure garbage relative to its certificate is still + // accepted, because below H the rule does not even decode. + final BlockHeader garbage = + header(H - 1L, parent.getHash(), extraData(Bytes32.random(), QUORUM)); + assertThat(rule.validate(garbage, parent)).isTrue(); + } + + @Test + public void anUnconfiguredAnchorIsInertAtEveryHeight() { + final PqAnchorDigestRule inert = new PqAnchorDigestRule(PqAnchorConfig.never(CHAIN_ID)); + final BlockHeader parent = parentHeader(H + 4L); + final BlockHeader tampered = + header(H + 5L, parent.getHash(), extraData(Bytes32.random(), QUORUM)); + assertThat(inert.validate(tampered, parent)).isTrue(); + } + + // --------------------------------------------------------------------------------------------- + // Honest headers. + // --------------------------------------------------------------------------------------------- + + @Test + public void acceptsAnHonestHeaderAtAndAboveH() { + final BlockHeader parent = parentHeader(H - 1L); + assertThat(rule.validate(honestHeader(H, parent.getHash(), QUORUM), parent)).isTrue(); + + final BlockHeader laterParent = parentHeader(H + 500L); + assertThat(rule.validate(honestHeader(H + 501L, laterParent.getHash(), QUORUM), laterParent)) + .isTrue(); + } + + @Test + public void acceptsAnEmptyCertificateAtTheActivationHeightItself() { + // Block H's parent predates the scheme, so C is legitimately empty and D is the digest of the + // empty list. "Explicitly empty" and "absent" are one object because of the codec's canonical + // round-trip gate, so there is no second acceptable byte string here. + final BlockHeader parent = parentHeader(H - 1L); + final BlockHeader block = honestHeader(H, parent.getHash(), Collections.emptyList()); + assertThat(rule.validate(block, parent)).isTrue(); + assertThat(codec.decodeRaw(block.getExtraData()).getFalconSeals()).isEmpty(); + } + + // --------------------------------------------------------------------------------------------- + // A1: the certificate is STRIPPED. Required by the mission to be caught in LIGHT validation. + // --------------------------------------------------------------------------------------------- + + @Test + public void aStrippedCertificateIsRejected() { + final BlockHeader parent = parentHeader(H + 9L); + final BlockHeader honest = honestHeader(H + 10L, parent.getHash(), QUORUM); + final BlockHeader stripped = stripCertificate(honest); + + // The tamper is real: the certificate is gone, and vanityData is untouched. + assertThat(codec.decodeRaw(honest.getExtraData()).getFalconSeals()).hasSize(5); + assertThat(codec.decodeRaw(stripped.getExtraData()).getFalconSeals()).isEmpty(); + assertThat(codec.decodeRaw(stripped.getExtraData()).getVanityData()) + .isEqualTo(codec.decodeRaw(honest.getExtraData()).getVanityData()); + + assertThat(rule.validate(honest, parent)).isTrue(); + assertThat(rule.validate(stripped, parent)).isFalse(); + } + + @Test + public void aStrippedCertificateIsCaughtInLightValidation() { + final BlockHeader parent = parentHeader(H + 9L); + final BlockHeader stripped = stripCertificate(honestHeader(H + 10L, parent.getHash(), QUORUM)); + assertRejectedInEveryLightMode(stripped, parent); + } + + // --------------------------------------------------------------------------------------------- + // A2: the certificate is REPLACED by an equally valid one over a different subset. + // Required by the mission to be caught in LIGHT validation. + // --------------------------------------------------------------------------------------------- + + @Test + public void anEquallyValidDifferentSubsetIsRejected() { + final BlockHeader parent = parentHeader(H + 9L); + final List subsetA = List.of(seal(0), seal(1), seal(2), seal(3), seal(4)); + final List subsetB = List.of(seal(0), seal(1), seal(2), seal(3), seal(5)); + final BlockHeader honest = honestHeader(H + 10L, parent.getHash(), subsetA); + final BlockHeader swapped = replaceCertificate(honest, subsetB); + + // Both certificates are well formed, both are the same size, both have strictly increasing + // indices, and the block hash pre-image does not contain either of them. Only D separates them. + assertThat(codec.decodeRaw(swapped.getExtraData()).getFalconSeals()).hasSize(5); + assertThat(PqAnchor.hasStrictlyIncreasingIndices(subsetB)).isTrue(); + + assertThat(rule.validate(honest, parent)).isTrue(); + assertThat(rule.validate(swapped, parent)).isFalse(); + } + + @Test + public void anEquallyValidDifferentSubsetIsCaughtInLightValidation() { + final BlockHeader parent = parentHeader(H + 9L); + final BlockHeader swapped = + replaceCertificate( + honestHeader(H + 10L, parent.getHash(), List.of(seal(0), seal(1), seal(2), seal(3), seal(4))), + List.of(seal(0), seal(1), seal(2), seal(3), seal(5))); + assertRejectedInEveryLightMode(swapped, parent); + } + + @Test + public void aSubstitutedSignatureUnderTheSameIndexIsRejected() { + // The subset is identical; only one signature's bytes differ. A verifier that only counted valid + // signatures would accept this; the digest does not. + final BlockHeader parent = parentHeader(H + 9L); + final List original = List.of(seal(0), seal(1), seal(2)); + final List substituted = + List.of(seal(0), seal(1), new FalconSeal(2, PqAnchorTestSupport.signature(2, 7))); + final BlockHeader swapped = + replaceCertificate(honestHeader(H + 10L, parent.getHash(), original), substituted); + assertThat(rule.validate(swapped, parent)).isFalse(); + } + + // --------------------------------------------------------------------------------------------- + // A3 / A4: order and duplication. + // --------------------------------------------------------------------------------------------- + + @Test + public void aReorderedCertificateIsRejected() { + final BlockHeader parent = parentHeader(H + 9L); + final BlockHeader honest = honestHeader(H + 10L, parent.getHash(), QUORUM); + final BlockHeader reordered = + replaceCertificate(honest, List.of(seal(4), seal(3), seal(2), seal(1), seal(0))); + assertThat(rule.validate(reordered, parent)).isFalse(); + assertRejectedInEveryLightMode(reordered, parent); + } + + @Test + public void aRepeatedIndexIsRejectedEvenWhenTheDigestIsRecomputedOverIt() { + // An attacker who controls the proposer could try to inflate k by repeating a seal AND writing + // the matching digest. The strictly increasing requirement refuses it before the digest is even + // compared, so inflation is unrepresentable rather than filtered. + final BlockHeader parent = parentHeader(H + 9L); + final List doubled = + List.of(seal(0), seal(1), new FalconSeal(1, PqAnchorTestSupport.signature(1, 3))); + final Bytes32 digest = PqAnchor.anchorDigest(CHAIN_ID, H + 9L, parent.getHash().getBytes(), doubled); + final BlockHeader forged = header(H + 10L, parent.getHash(), extraData(digest, doubled)); + assertThat(rule.validate(forged, parent)).isFalse(); + } + + // --------------------------------------------------------------------------------------------- + // Binding, and fail-closed behaviour. + // --------------------------------------------------------------------------------------------- + + @Test + public void theDigestIsBoundToTheParentThisHeaderNames() { + final BlockHeader parent = parentHeader(H + 9L); + final BlockHeader other = parentHeader(H + 9L + 1L); + final BlockHeader honest = honestHeader(H + 10L, parent.getHash(), QUORUM); + // Same certificate, same height, different parent hash: the digest no longer matches. + final BlockHeader rehomed = + header(H + 10L, other.getHash(), honest.getExtraData()); + assertThat(rule.validate(rehomed, other)).isFalse(); + } + + @Test + public void theDigestIsBoundToTheChainId() { + // A6: the 442807 proving chain runs the same binaries and can run the same Falcon keys. + final BlockHeader parent = parentHeader(H + 9L); + final List certificate = PqAnchor.sortedByIndex(QUORUM); + final Bytes32 foreignDigest = + PqAnchor.anchorDigest(442807L, H + 9L, parent.getHash().getBytes(), certificate); + final BlockHeader foreign = + header(H + 10L, parent.getHash(), extraData(foreignDigest, certificate)); + assertThat(rule.validate(foreign, parent)).isFalse(); + } + + @Test + public void aVanityFieldThatIsNotThirtyTwoBytesIsRejected() { + final BlockHeader parent = parentHeader(H + 9L); + final BlockHeader shortVanity = + header(H + 10L, parent.getHash(), extraData(Bytes.repeat((byte) 0x11, 31), QUORUM)); + assertThat(rule.validate(shortVanity, parent)).isFalse(); + } + + @Test + public void undecodableExtraDataIsRejectedRatherThanAccepted() { + // BELOW its fork height, and only there, the legacy Falcon rule returned true on every path + // including the exception path, so in the only regime this chain has ever run it never caught + // anything. At and after the fork block WITH an active registry it is instead BLOCKING and does + // return false, on four paths including the exception path (measured by reading + // FalconSealValidationRule: blocking = forkReached && registryActive, then four `return false` + // sites inside validate). That mode has never been armed on this chain and must not be armed at + // the same time as the V2 anchor. A rule that cannot compute its verdict has not proved the + // header good. + final BlockHeader parent = parentHeader(H + 9L); + final BlockHeader broken = + header(H + 10L, parent.getHash(), Bytes.fromHexString("0xdeadbeef")); + assertThat(rule.validate(broken, parent)).isFalse(); + + final BlockHeader empty = header(H + 10L, parent.getHash(), Bytes.EMPTY); + assertThat(rule.validate(empty, parent)).isFalse(); + } + + // --------------------------------------------------------------------------------------------- + // The light-validation property itself. + // --------------------------------------------------------------------------------------------- + + @Test + public void includeInLightValidationIsTrue() { + assertThat(rule.includeInLightValidation()).isTrue(); + } + + @Test + public void anHonestHeaderStillPassesEveryLightMode() { + // The negative control for assertRejectedInEveryLightMode: if the modes below rejected + // everything, the rejection tests would prove nothing. + final BlockHeader parent = parentHeader(H + 9L); + final BlockHeader honest = honestHeader(H + 10L, parent.getHash(), QUORUM); + final BlockHeaderValidator validator = + new BlockHeaderValidator.Builder().addRule(rule).build(); + for (final HeaderValidationMode mode : + List.of( + HeaderValidationMode.LIGHT_DETACHED_ONLY, + HeaderValidationMode.LIGHT, + HeaderValidationMode.DETACHED_ONLY, + HeaderValidationMode.FULL)) { + assertThat(validator.validateHeader(honest, parent, null, mode)) + .withFailMessage("honest header should pass in mode %s", mode) + .isTrue(); + } + } + + @Test + public void theRuleIsSkippedByTheModesThatSkipDetachedRules() { + // Stating the shape honestly: LIGHT_SKIP_DETACHED and SKIP_DETACHED exclude every detached rule, + // so R1 does not run there. Those modes are the "attached half" of a split validation whose + // other half already ran R1, which is why this is not a hole. + final BlockHeader parent = parentHeader(H + 9L); + final BlockHeader stripped = stripCertificate(honestHeader(H + 10L, parent.getHash(), QUORUM)); + final BlockHeaderValidator validator = + new BlockHeaderValidator.Builder().addRule(rule).build(); + assertThat( + validator.validateHeader( + stripped, parent, null, HeaderValidationMode.LIGHT_SKIP_DETACHED)) + .isTrue(); + assertThat(validator.validateHeader(stripped, parent, null, HeaderValidationMode.SKIP_DETACHED)) + .isTrue(); + } + + private void assertRejectedInEveryLightMode(final BlockHeader header, final BlockHeader parent) { + final BlockHeaderValidator validator = + new BlockHeaderValidator.Builder().addRule(rule).build(); + for (final HeaderValidationMode mode : + List.of( + HeaderValidationMode.LIGHT_DETACHED_ONLY, + HeaderValidationMode.LIGHT, + HeaderValidationMode.DETACHED_ONLY, + HeaderValidationMode.FULL)) { + assertThat(validator.validateHeader(header, parent, null, mode)) + .withFailMessage("tampered header should be rejected in mode %s", mode) + .isFalse(); + } + } + + private BlockHeader stripCertificate(final BlockHeader honest) { + return replaceCertificate(honest, Collections.emptyList()); + } + + private BlockHeader replaceCertificate( + final BlockHeader honest, final List certificate) { + final BftExtraData original = codec.decodeRaw(honest.getExtraData()); + final Bytes tampered = + codec.encode( + new BftExtraData( + original.getVanityData(), + original.getSeals(), + Optional.empty(), + original.getRound(), + original.getValidators(), + certificate)); + return header(honest.getNumber(), honest.getParentHash(), tampered); + } +} diff --git a/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorSealsRuleTest.java b/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorSealsRuleTest.java new file mode 100644 index 0000000..3bf64c2 --- /dev/null +++ b/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorSealsRuleTest.java @@ -0,0 +1,690 @@ +/* + * Copyright contributors to Besu / Aere Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.qbft.headervalidationrules; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.CHAIN_ID; +import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.H; +import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.VALIDATORS; +import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.extraData; +import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.header; +import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.parentHeader; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.withSettings; + +import org.hyperledger.besu.consensus.common.bft.BftContext; +import org.hyperledger.besu.consensus.common.bft.FalconSeal; +import org.hyperledger.besu.consensus.common.bft.PqAnchor; +import org.hyperledger.besu.consensus.common.bft.PqAnchorConfig; +import org.hyperledger.besu.consensus.common.bft.PqSignerRegistry; +import org.hyperledger.besu.consensus.common.validator.ValidatorProvider; +import org.hyperledger.besu.datatypes.Address; +import org.hyperledger.besu.ethereum.ProtocolContext; +import org.hyperledger.besu.ethereum.core.BlockHeader; +import org.hyperledger.besu.ethereum.mainnet.BlockHeaderValidator; +import org.hyperledger.besu.ethereum.mainnet.HeaderValidationMode; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.NavigableMap; +import java.util.OptionalInt; +import java.util.TreeMap; + +import org.apache.tuweni.bytes.Bytes; +import org.apache.tuweni.bytes.Bytes32; +import org.junit.jupiter.api.Test; +import org.mockito.quality.Strictness; + +/** + * R2, the anchor seals rule. + * + *

The registry is injected rather than taken from the Falcon singleton, so these tests exercise + * the RULE: threshold, ordering, eligibility against the parent's validator set, message binding and + * per-seal verification. Whether BouncyCastle's Falcon implementation verifies correctly is + * FalconSealSupport's own surface, and is deliberately not re-tested here. + * + *

The most important test in this file is {@link + * #acceptsAValidCertificateOverASubsetThisNodeNeverHeard()}. It is the one that proves the rule does + * not do the thing that killed the previous design. + */ +public class PqAnchorSealsRuleTest { + + private static final long K_AT = H + 100L; + + private final PqAnchorConfig armed = + new PqAnchorConfig(CHAIN_ID, H, Map.of(H, 0, K_AT, 5), OptionalInt.empty(), false); + private final FakeRegistry registry = new FakeRegistry(); + private final PqAnchorSealsRule rule = new PqAnchorSealsRule(armed, registry); + + // --------------------------------------------------------------------------------------------- + // Inert below H. + // --------------------------------------------------------------------------------------------- + + @Test + public void belowHTheRuleIsInert() { + final BlockHeader parent = parentHeader(H - 2L); + final BlockHeader nonsense = + header( + H - 1L, + parent.getHash(), + extraData(Bytes32.random(), List.of(new FalconSeal(99, Bytes.of(1, 2, 3))))); + assertThat(rule.validate(nonsense, parent, contextWith(VALIDATORS))).isTrue(); + } + + @Test + public void anUnconfiguredAnchorIsInertAtEveryHeight() { + final PqAnchorSealsRule inert = + new PqAnchorSealsRule(PqAnchorConfig.never(CHAIN_ID), registry); + final BlockHeader parent = parentHeader(K_AT + 4L); + final BlockHeader nonsense = + header( + K_AT + 5L, + parent.getHash(), + extraData(Bytes32.random(), List.of(new FalconSeal(99, Bytes.of(1, 2, 3))))); + assertThat(inert.validate(nonsense, parent, contextWith(VALIDATORS))).isTrue(); + } + + // --------------------------------------------------------------------------------------------- + // Honest certificates. + // --------------------------------------------------------------------------------------------- + + @Test + public void acceptsAQuorumCertificateOverTheParent() { + final BlockHeader parent = parentHeader(K_AT + 9L); + final BlockHeader block = blockWithSigners(K_AT + 10L, parent, List.of(0, 1, 2, 3, 4)); + assertThat(rule.validate(block, parent, contextWith(VALIDATORS))).isTrue(); + } + + @Test + public void acceptsAValidCertificateOverASubsetThisNodeNeverHeard() { + // A5, the attack that killed Option V, and the reason V2 exists. Measured on 200 consecutive + // live headers read from two of our own nodes with identical block hashes: the seal SET differed + // on 11 of them. A rule that compared the carried certificate with the local view would reject + // roughly one header in eighteen with no attacker present. This rule never consults a local + // view, so every well formed subset of eligible signers is accepted on its own merits. + final BlockHeader parent = parentHeader(K_AT + 9L); + final ProtocolContext context = contextWith(VALIDATORS); + for (final List subset : + List.of( + List.of(0, 1, 2, 3, 4), + List.of(0, 1, 2, 3, 5), + List.of(2, 3, 4, 5, 6), + List.of(0, 2, 4, 5, 6))) { + final BlockHeader block = blockWithSigners(K_AT + 10L, parent, subset); + assertThat(rule.validate(block, parent, context)) + .withFailMessage("subset %s should be accepted on its own merits", subset) + .isTrue(); + } + } + + @Test + public void acceptsMoreThanTheThreshold() { + final BlockHeader parent = parentHeader(K_AT + 9L); + final BlockHeader block = blockWithSigners(K_AT + 10L, parent, List.of(0, 1, 2, 3, 4, 5, 6)); + assertThat(rule.validate(block, parent, contextWith(VALIDATORS))).isTrue(); + } + + @Test + public void acceptsAnEmptyCertificateWhileTheThresholdIsZero() { + final BlockHeader parent = parentHeader(H - 1L); + final BlockHeader block = blockWithSigners(H, parent, List.of()); + assertThat(rule.validate(block, parent, contextWith(VALIDATORS))).isTrue(); + } + + // --------------------------------------------------------------------------------------------- + // The four checks. + // --------------------------------------------------------------------------------------------- + + @Test + public void rejectsACertificateBelowTheThresholdInForce() { + final BlockHeader parent = parentHeader(K_AT + 9L); + final BlockHeader block = blockWithSigners(K_AT + 10L, parent, List.of(0, 1, 2, 3)); + assertThat(rule.validate(block, parent, contextWith(VALIDATORS))).isFalse(); + } + + @Test + public void rejectsAnEmptyCertificateOnceTheThresholdIsAboveZero() { + final BlockHeader parent = parentHeader(K_AT + 9L); + final BlockHeader block = blockWithSigners(K_AT + 10L, parent, List.of()); + assertThat(rule.validate(block, parent, contextWith(VALIDATORS))).isFalse(); + } + + @Test + public void rejectsIndicesThatAreNotStrictlyIncreasing() { + final BlockHeader parent = parentHeader(K_AT + 9L); + final List reordered = + new ArrayList<>(sealsFor(parent, List.of(0, 1, 2, 3, 4))); + Collections.reverse(reordered); + final BlockHeader block = + header(K_AT + 10L, parent.getHash(), extraData(Bytes32.random(), reordered)); + assertThat(rule.validate(block, parent, contextWith(VALIDATORS))).isFalse(); + } + + @Test + public void rejectsAnIndexTheRegistryDoesNotBindToAnAddress() { + final BlockHeader parent = parentHeader(K_AT + 9L); + final List seals = new ArrayList<>(sealsFor(parent, List.of(0, 1, 2, 3))); + final Bytes32 message = PqAnchor.commitMessage(CHAIN_ID, parent.getNumber(), parent.getHash().getBytes()); + seals.add(new FalconSeal(42, FakeRegistry.sign(42, message))); + final BlockHeader block = + header(K_AT + 10L, parent.getHash(), extraData(Bytes32.random(), seals)); + assertThat(rule.validate(block, parent, contextWith(VALIDATORS))).isFalse(); + } + + @Test + public void rejectsASignerThatWasNotAValidatorOfTheParent() { + // Removal safety: a validator dropped before the parent no longer counts, even though its key is + // still in the registry. + final BlockHeader parent = parentHeader(K_AT + 9L); + final BlockHeader block = blockWithSigners(K_AT + 10L, parent, List.of(0, 1, 2, 3, 6)); + final List

shrunk = VALIDATORS.subList(0, 6); + assertThat(rule.validate(block, parent, contextWith(shrunk))).isFalse(); + // and the same certificate over the full parent set is fine, so the refusal is about + // eligibility and nothing else. + assertThat(rule.validate(block, parent, contextWith(VALIDATORS))).isTrue(); + } + + @Test + public void rejectsASignatureThatDoesNotVerify() { + final BlockHeader parent = parentHeader(K_AT + 9L); + final List seals = new ArrayList<>(sealsFor(parent, List.of(0, 1, 2, 3))); + seals.add(new FalconSeal(4, Bytes.repeat((byte) 0x5a, 655))); + final BlockHeader block = + header(K_AT + 10L, parent.getHash(), extraData(Bytes32.random(), seals)); + assertThat(rule.validate(block, parent, contextWith(VALIDATORS))).isFalse(); + } + + @Test + public void rejectsSealsReplayedFromAnotherHeight() { + // A6, first half: M binds the chain id, the block number and the block hash, so a certificate + // that is genuine over a different parent is not material here. + final BlockHeader parent = parentHeader(K_AT + 9L); + final BlockHeader otherParent = parentHeader(K_AT + 8L); + final List foreign = sealsFor(otherParent, List.of(0, 1, 2, 3, 4)); + final BlockHeader block = + header(K_AT + 10L, parent.getHash(), extraData(Bytes32.random(), foreign)); + assertThat(rule.validate(block, parent, contextWith(VALIDATORS))).isFalse(); + } + + @Test + public void rejectsSealsReplayedFromAnotherChain() { + // A6, second half: the 442807 proving chain runs the same binaries and can run the same keys. + final BlockHeader parent = parentHeader(K_AT + 9L); + final Bytes32 foreignMessage = + PqAnchor.commitMessage(442807L, parent.getNumber(), parent.getHash().getBytes()); + final List foreign = new ArrayList<>(); + for (final int index : List.of(0, 1, 2, 3, 4)) { + foreign.add(new FalconSeal(index, FakeRegistry.sign(index, foreignMessage))); + } + final BlockHeader block = + header(K_AT + 10L, parent.getHash(), extraData(Bytes32.random(), foreign)); + assertThat(rule.validate(block, parent, contextWith(VALIDATORS))).isFalse(); + } + + @Test + public void rejectsTwoIndicesThatResolveToOneValidator() { + // Strictly increasing indices stop a repeated INDEX. A malformed registry could still map two + // distinct indices onto one address, which would let one validator be counted twice. + final BlockHeader parent = parentHeader(K_AT + 9L); + registry.bind(7, VALIDATORS.get(0)); + final BlockHeader block = blockWithSigners(K_AT + 10L, parent, List.of(0, 1, 2, 3, 7)); + assertThat(rule.validate(block, parent, contextWith(VALIDATORS))).isFalse(); + } + + // --------------------------------------------------------------------------------------------- + // KEY ROTATION AND REBINDING: an adversarial review of R2, reproduced against R2 itself. + // + // T2, the first objection: a header that passes both rules today is REJECTED the moment index 0's + // Falcon key is rotated. Nothing else changes - same header, same parent, same validator set. + // T3, the second: if the index is rebound to ANOTHER validator who is still in the current set, + // the rule sees nothing. + // + // Neither test could have been written against the earlier interface. PqSignerRegistry carried a + // height-less pair plus a default that forwarded the height-aware form to it, so every test + // double in the tree - including the review's own probe - silently threw the height away and + // answered from ONE key set. A fake with one key set cannot express a rotation, so it cannot fail + // on one. The compiler now refuses that fake. + // --------------------------------------------------------------------------------------------- + + @Test + public void d2t2AHeaderBelowARotationIsStillVerifiedUnderTheKEYSETITWASSEALEDUNDER() { + // The T2 measurement, stated as a property rather than a scenario: an honest certificate over + // parent P must still verify after the chain rotates index 0's key at a LATER height. The seals + // are produced under generation 0, which is what was in force at P. + final long rotationHeight = K_AT + 5_000L; + final BlockHeader parent = parentHeader(K_AT + 9L); + final BlockHeader block = blockWithSigners(K_AT + 10L, parent, List.of(0, 1, 2, 3, 4)); + final ProtocolContext context = contextWith(VALIDATORS); + + assertThat(rule.validate(block, parent, context)) + .describedAs("baseline: the certificate is honest before any rotation") + .isTrue(); + + registry.rotateKeyFrom(rotationHeight, 0, 1); + + assertThat(rule.validate(block, parent, context)) + .describedAs( + "T2: the SAME header, the SAME parent and the SAME validator set, after a key " + + "rotation at a height ABOVE it. R2 must resolve keys at the parent's height. If " + + "this is false the chain is unjoinable for every node that syncs after a rotation") + .isTrue(); + } + + @Test + public void d2t2ASealFromTheOLDKeyIsRefusedABOVETheRotation() { + // The other side of the same coin, and the reason the test above is not satisfied by a rule + // that ignores rotations entirely. Above the rotation height the old key must NOT verify - + // otherwise "rotation" would mean nothing and a revoked key would keep signing forever. + final long rotationHeight = K_AT + 5_000L; + registry.rotateKeyFrom(rotationHeight, 0, 1); + + final BlockHeader parent = parentHeader(rotationHeight + 9L); + final Bytes32 message = + PqAnchor.commitMessage(CHAIN_ID, parent.getNumber(), parent.getHash().getBytes()); + final List seals = new ArrayList<>(); + // index 0 signs with the RETIRED generation; the other four are honest. + seals.add(new FalconSeal(0, FakeRegistry.sign(0, 0, message))); + for (final int index : List.of(1, 2, 3, 4)) { + seals.add(new FalconSeal(index, FakeRegistry.sign(index, message))); + } + final BlockHeader block = + header(rotationHeight + 10L, parent.getHash(), extraData(Bytes32.random(), seals)); + + assertThat(rule.validate(block, parent, contextWith(VALIDATORS))) + .describedAs("a seal from the key the chain retired must not count above the rotation") + .isFalse(); + } + + @Test + public void d2t3ARebindingOfAnIndexToAnotherCURRENTValidatorIsNotSeenByR2() { + // T3, and this test is here to keep an OPEN finding open rather than to prove a repair. + // + // Measured, not argued: PqAnchorSealsRule credits a seal to whatever address the registry hands + // back for its index, checks that address against the parent's validator set, and never asks + // which KEY signed. So rebinding index 0 to validator 5 - who is in the set - is invisible: the + // seal was produced by index 0's key and is credited to validator 5. + // + // The height-indexed registry NARROWS this: a rebinding changes the registry's canonical hash + // (PqRegistryHash.java:274-277 puts the address in the pre-image), so it cannot happen without + // a new scheduled epoch that every node must be given. It does not CLOSE it: whoever writes + // that epoch can write the rebinding into it and the whole fleet will accept it. Closing it + // needs eligibility DERIVED from the Falcon key rather than bound to it by configuration. + final long rebindHeight = K_AT + 5_000L; + final BlockHeader parent = parentHeader(rebindHeight + 9L); + // Validator 5 does not sign; index 0's key does. After the rebinding the seal is credited to 5. + registry.rebindFrom(rebindHeight, 0, VALIDATORS.get(5)); + + final Bytes32 message = + PqAnchor.commitMessage(CHAIN_ID, parent.getNumber(), parent.getHash().getBytes()); + final List seals = new ArrayList<>(); + for (final int index : List.of(0, 1, 2, 3, 4)) { + seals.add(new FalconSeal(index, FakeRegistry.sign(index, message))); + } + final BlockHeader block = + header(rebindHeight + 10L, parent.getHash(), extraData(Bytes32.random(), seals)); + + assertThat(rule.validate(block, parent, contextWith(VALIDATORS))) + .describedAs( + "T3 IS STILL OPEN: R2 accepts a certificate in which index 0's key is credited to " + + "validator 5. When this assertion has to be flipped to isFalse(), T3 has been " + + "closed and this comment is the record of when it was not") + .isTrue(); + } + + // --------------------------------------------------------------------------------------------- + // Fail-closed on everything else. + // --------------------------------------------------------------------------------------------- + + @Test + public void rejectsWhenTheParentIsMissingOrIsNotTheNamedParent() { + final BlockHeader parent = parentHeader(K_AT + 9L); + final BlockHeader block = blockWithSigners(K_AT + 10L, parent, List.of(0, 1, 2, 3, 4)); + final ProtocolContext context = contextWith(VALIDATORS); + assertThat(rule.validate(block, null, context)).isFalse(); + assertThat(rule.validate(block, parentHeader(K_AT + 8L), context)).isFalse(); + } + + @Test + public void rejectsWhenTheParentValidatorSetCannotBeResolved() { + final BlockHeader parent = parentHeader(K_AT + 9L); + final BlockHeader block = blockWithSigners(K_AT + 10L, parent, List.of(0, 1, 2, 3, 4)); + assertThat(rule.validate(block, parent, contextWith(Collections.emptyList()))).isFalse(); + assertThat(rule.validate(block, parent, contextWith(null))).isFalse(); + } + + @Test + public void rejectsUndecodableExtraData() { + final BlockHeader parent = parentHeader(K_AT + 9L); + final BlockHeader broken = + header(K_AT + 10L, parent.getHash(), Bytes.fromHexString("0xdeadbeef")); + assertThat(rule.validate(broken, parent, contextWith(VALIDATORS))).isFalse(); + } + + // --------------------------------------------------------------------------------------------- + // Where it runs. + // --------------------------------------------------------------------------------------------- + + @Test + public void includeInLightValidationIsFalseAndTheRuleIsSkippedThere() { + assertThat(rule.includeInLightValidation()).isFalse(); + final BlockHeader parent = parentHeader(K_AT + 9L); + final BlockHeader tooFew = blockWithSigners(K_AT + 10L, parent, List.of(0, 1)); + final ProtocolContext context = contextWith(VALIDATORS); + final BlockHeaderValidator validator = + new BlockHeaderValidator.Builder().addRule(rule).build(); + // Skipped in every light mode: this is the expensive half, and R1 already carries the binding + // into light validation. + assertThat(validator.validateHeader(tooFew, parent, context, HeaderValidationMode.LIGHT)) + .isTrue(); + assertThat( + validator.validateHeader( + tooFew, parent, context, HeaderValidationMode.LIGHT_SKIP_DETACHED)) + .isTrue(); + // And decisive in full validation. + assertThat(validator.validateHeader(tooFew, parent, context, HeaderValidationMode.FULL)) + .isFalse(); + } + + // --------------------------------------------------------------------------------------------- + // Helpers. + // --------------------------------------------------------------------------------------------- + + private List sealsFor(final BlockHeader parent, final List indices) { + final Bytes32 message = PqAnchor.commitMessage(CHAIN_ID, parent.getNumber(), parent.getHash().getBytes()); + final List seals = new ArrayList<>(); + for (final int index : indices) { + seals.add(new FalconSeal(index, FakeRegistry.sign(index, message))); + } + return PqAnchor.sortedByIndex(seals); + } + + /** + * A header carrying an honest certificate from the given signers. vanityData is set to the correct + * anchor digest so that a header built here would also satisfy R1; R2 does not read it. + */ + private BlockHeader blockWithSigners( + final long number, final BlockHeader parent, final List indices) { + final List seals = sealsFor(parent, indices); + final Bytes32 digest = + PqAnchor.anchorDigest(CHAIN_ID, parent.getNumber(), parent.getHash().getBytes(), seals); + return header(number, parent.getHash(), extraData(digest, seals)); + } + + private ProtocolContext contextWith(final Collection
parentValidators) { + final ValidatorProvider validatorProvider = + mock(ValidatorProvider.class, withSettings().strictness(Strictness.LENIENT)); + when(validatorProvider.getValidatorsForBlock(any())).thenReturn(parentValidators); + final BftContext bftContext = + mock(BftContext.class, withSettings().strictness(Strictness.LENIENT)); + when(bftContext.getValidatorProvider()).thenReturn(validatorProvider); + when(bftContext.as(any())).thenReturn(bftContext); + return new ProtocolContext.Builder().withConsensusContext(bftContext).build(); + } + + /** + * A registry with the same shape as the Falcon one: index to address, plus a verifier. The fake + * signature is a deterministic function of the index AND the message, so replaying a genuine seal + * at another height or on another chain fails exactly as a real Falcon verification would. + */ + private static final class FakeRegistry implements PqSignerRegistry { + + /** Epoch start height to the index-to-address binding in force from there. */ + private final NavigableMap> epochs = new TreeMap<>(); + + /** + * Epoch start height to the KEY GENERATION in force from there. The fake signature is a + * function of the generation, so "rotate index 0's key" and "rebind index 0 to another + * validator" are two DIFFERENT edits here, exactly as they are on the wire. T2 is the first, + * T3 the second. + */ + private final NavigableMap generations = new TreeMap<>(); + + private FakeRegistry() { + final Map genesisEpoch = new HashMap<>(); + for (int i = 0; i < VALIDATORS.size(); i++) { + genesisEpoch.put(i, VALIDATORS.get(i)); + } + epochs.put(0L, genesisEpoch); + generations.put(0L, 0); + } + + void bind(final int index, final Address address) { + epochs.get(epochs.firstKey()).put(index, address); + } + + /** + * T2. From {@code from}, index {@code index} answers to a NEW key generation. Everything else - + * the address binding, the validator set, the parent - is untouched, which is the whole point + * of the T2 measurement: a header that verified yesterday must still verify today. + */ + void rotateKeyFrom(final long from, final int index, final int generation) { + final Map carried = new HashMap<>(epochs.floorEntry(from).getValue()); + epochs.put(from, carried); + generations.put(from, generation); + rotatedIndex = index; + } + + /** + * T3. From {@code from}, index {@code index} answers to a DIFFERENT validator address, keys + * untouched. The seal is then credited to a validator that did not sign it. + */ + void rebindFrom(final long from, final int index, final Address address) { + final Map carried = new HashMap<>(epochs.floorEntry(from).getValue()); + carried.put(index, address); + epochs.put(from, carried); + generations.put(from, generations.floorEntry(from).getValue()); + } + + private int rotatedIndex = -1; + + static Bytes sign(final int index, final Bytes message) { + return sign(index, 0, message); + } + + static Bytes sign(final int index, final int generation, final Bytes message) { + final byte[] out = new byte[655]; + out[0] = (byte) index; + for (int i = 0; i < message.size() && i < 32; i++) { + out[1 + i] = message.get(i); + } + for (int i = 33; i < out.length; i++) { + out[i] = (byte) ((i * (index + 3) + 7 + generation * 31) & 0xFF); + } + return Bytes.wrap(out); + } + + private int generationAt(final long blockNumber, final int validatorIndex) { + final Integer g = generations.floorEntry(blockNumber).getValue(); + // Only the rotated index changes generation; every other index keeps generation 0. + return (rotatedIndex < 0 || validatorIndex == rotatedIndex) ? g : 0; + } + + // ROTATION HARDENING (a): the height-less pair is gone from PqSignerRegistry, so this double + // can no longer inherit a default that throws the height away. That default is why the original + // probe returned the same verdict on repaired and unrepaired code; a fake that cannot express a + // rotation cannot prove one is handled. + @Override + public Address addressForIndexAtOwnHead(final long blockNumber, final int validatorIndex) { + return addressForIndexAtHistoric(blockNumber, validatorIndex); + } + + @Override + public boolean verifyAtOwnHead( + final long blockNumber, + final int validatorIndex, + final Bytes message, + final Bytes signature) { + return verifyAtHistoric(blockNumber, validatorIndex, message, signature); + } + + @Override + public Address addressForIndexAtHistoric(final long blockNumber, final int validatorIndex) { + return epochs.floorEntry(blockNumber).getValue().get(validatorIndex); + } + + @Override + public boolean verifyAtHistoric( + final long blockNumber, + final int validatorIndex, + final Bytes message, + final Bytes signature) { + return signature != null + && signature.equals(sign(validatorIndex, generationAt(blockNumber, validatorIndex), message)); + } + + @Override + public String toString() { + return "FakeRegistry"; + } + } + + // --------------------------------------------------------------------------------------------- + // ROTATION HARDENING (b-v2). WHICH DOOR R2 GOES THROUGH, measured by behaviour, not by name. + // + // The shape chosen here admits its own weakness: the compiler forces you to CHOOSE between + // addressForIndexAtHistoric and addressForIndexAtOwnHead, but it does not force you to choose + // CORRECTLY. Both have the same type and both are total, so a future patch that moves this call + // onto the OWN-HEAD door reopens the rotation defect without anything going red, because the + // OWN-HEAD door is precisely the one that always answers. + // + // The two assertions below are the lock. The registry they run against answers DIFFERENTLY on + // the two doors, which no real registry does; that is exactly why it can say which door was + // opened. The pair also carries its positive control built in: if R2 called NEITHER door, the + // first assertion would pass and the second would fail. + // --------------------------------------------------------------------------------------------- + + @Test + public void r2GoesThroughTheHISTORYDoorAndThroughNothingElse() { + final BlockHeader parent = parentHeader(K_AT + 9L); + final BlockHeader block = blockWithSigners(K_AT + 10L, parent, List.of(0, 1, 2, 3, 4)); + final ProtocolContext context = contextWith(VALIDATORS); + + assertThat(rule.validate(block, parent, context)) + .describedAs("baseline: the same header is accepted by the ordinary registry") + .isTrue(); + + final PqAnchorSealsRule peUsaIstorie = + new PqAnchorSealsRule(armed, new OneDoorRegistry(registry, false)); + assertThat(peUsaIstorie.validate(block, parent, context)) + .describedAs( + "the HISTORY door refuses everything and the OWN-HEAD door answers everything. R2 must " + + "REJECT. If this is true, R2 is reading the own-head door, which means it would " + + "answer a header it received from the registry in force at this node's head - " + + "and that is the rotation defect verbatim") + .isFalse(); + + final PqAnchorSealsRule peUsaCap = + new PqAnchorSealsRule(armed, new OneDoorRegistry(registry, true)); + assertThat(peUsaCap.validate(block, parent, context)) + .describedAs( + "and the mirror image: the HISTORY door answers, the OWN-HEAD door refuses, and R2 " + + "ACCEPTS. Without this second half the first could pass on a rule that calls " + + "neither door at all") + .isTrue(); + } + + /** + * A registry that answers on exactly ONE of the two doors and refuses on the other. No real + * registry behaves like this; that is the point - it is an instrument for asking which door a + * caller opened, and it is the only kind of double that can catch a call site moved to the wrong + * one. + */ + private static final class OneDoorRegistry implements PqSignerRegistry { + private final PqSignerRegistry delegate; + private final boolean historyAnswers; + + private OneDoorRegistry(final PqSignerRegistry delegate, final boolean historyAnswers) { + this.delegate = delegate; + this.historyAnswers = historyAnswers; + } + + @Override + public Address addressForIndexAtHistoric(final long blockNumber, final int validatorIndex) { + return historyAnswers ? delegate.addressForIndexAtHistoric(blockNumber, validatorIndex) : null; + } + + @Override + public boolean verifyAtHistoric( + final long blockNumber, + final int validatorIndex, + final Bytes message, + final Bytes signature) { + return historyAnswers + && delegate.verifyAtHistoric(blockNumber, validatorIndex, message, signature); + } + + @Override + public Address addressForIndexAtOwnHead(final long blockNumber, final int validatorIndex) { + return historyAnswers ? null : delegate.addressForIndexAtOwnHead(blockNumber, validatorIndex); + } + + @Override + public boolean verifyAtOwnHead( + final long blockNumber, + final int validatorIndex, + final Bytes message, + final Bytes signature) { + return !historyAnswers + && delegate.verifyAtOwnHead(blockNumber, validatorIndex, message, signature); + } + + @Override + public String toString() { + return "OneDoorRegistry{historyAnswers=" + historyAnswers + "}"; + } + } + + @Test + public void rejectsAnInvalidSignatureInEVERYPositionOfTheCertificate() { + // Added 2026-08-02 after a planted defect proved this was missing. + // + // The three signature tests above are all satisfied by a rule that skips the verification of + // ONE chosen position: rejectsASignatureThatDoesNotVerify puts its bad seal LAST, and the two + // replay tests make EVERY seal bad, so any one of them still fails on some other seal. A rule + // mutated to skip exactly the first seal's verification left all 187 tests of consensus:qbft + // green. An off-by-one in this loop, a stray continue, or a short-circuit on the first element + // is exactly that shape of bug, and it would have shipped. + // + // This test walks the bad signature through every position, so no per-position skip survives. + final BlockHeader parent = parentHeader(K_AT + 9L); + final List signers = List.of(0, 1, 2, 3, 4); + for (int position = 0; position < signers.size(); position++) { + final List seals = new ArrayList<>(sealsFor(parent, signers)); + final FalconSeal good = seals.get(position); + seals.set( + position, new FalconSeal(good.getValidatorIndex(), Bytes.repeat((byte) 0x5a, 655))); + final BlockHeader block = + header(K_AT + 10L, parent.getHash(), extraData(Bytes32.random(), seals)); + assertThat(rule.validate(block, parent, contextWith(VALIDATORS))) + .describedAs("certificate with the only invalid signature at position %d", position) + .isFalse(); + } + } + + @Test + public void acceptsTheSameCertificateWhenEveryPositionIsHonest() { + // The negative control for the test above: without it, a rule that rejected EVERYTHING would + // pass the per-position walk and prove nothing at all. + final BlockHeader parent = parentHeader(K_AT + 9L); + final BlockHeader block = blockWithSigners(K_AT + 10L, parent, List.of(0, 1, 2, 3, 4)); + assertThat(rule.validate(block, parent, contextWith(VALIDATORS))).isTrue(); + } +} diff --git a/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorTestSupport.java b/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorTestSupport.java new file mode 100644 index 0000000..4dafdf0 --- /dev/null +++ b/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqAnchorTestSupport.java @@ -0,0 +1,144 @@ +/* + * Copyright contributors to Besu / Aere Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.qbft.headervalidationrules; + +import org.hyperledger.besu.consensus.common.bft.BftExtraData; +import org.hyperledger.besu.consensus.common.bft.FalconSeal; +import org.hyperledger.besu.consensus.common.bft.PqAnchor; +import org.hyperledger.besu.consensus.qbft.QbftExtraDataCodec; +import org.hyperledger.besu.datatypes.Address; +import org.hyperledger.besu.datatypes.Hash; +import org.hyperledger.besu.ethereum.core.BlockHeader; +import org.hyperledger.besu.ethereum.core.BlockHeaderTestFixture; + +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import org.apache.tuweni.bytes.Bytes; +import org.apache.tuweni.bytes.Bytes32; + +/** Header construction shared by the two anchor rule test classes. */ +public final class PqAnchorTestSupport { + + /** The chain the rules under test are configured for. */ + public static final long CHAIN_ID = 2800L; + + /** The activation height H used throughout these tests. */ + public static final long H = 1_000L; + + /** The QBFT validator addresses used as the parent's validator set. */ + public static final List
VALIDATORS = + List.of( + Address.fromHexString("0x0000000000000000000000000000000000000a01"), + Address.fromHexString("0x0000000000000000000000000000000000000a02"), + Address.fromHexString("0x0000000000000000000000000000000000000a03"), + Address.fromHexString("0x0000000000000000000000000000000000000a04"), + Address.fromHexString("0x0000000000000000000000000000000000000a05"), + Address.fromHexString("0x0000000000000000000000000000000000000a06"), + Address.fromHexString("0x0000000000000000000000000000000000000a07")); + + private static final QbftExtraDataCodec CODEC = new QbftExtraDataCodec(); + + private PqAnchorTestSupport() {} + + /** + * A deterministic stand-in for a Falcon-512 signature. {@code variant} lets a test produce a + * DIFFERENT but equally well formed signature under the same index, which is what an "equally + * valid certificate, different bytes" substitution looks like on the wire. + * + * @param index the validator index the signature belongs to + * @param variant a discriminator so two signatures for one index differ + * @return 655 deterministic bytes, the measured mean Falcon-512 signature length + */ + public static Bytes signature(final int index, final int variant) { + final byte[] bytes = new byte[655]; + for (int i = 0; i < bytes.length; i++) { + bytes[i] = (byte) ((i * 31 + index * 7 + variant * 101) & 0xFF); + } + return Bytes.wrap(bytes); + } + + /** + * A seal for a validator index. + * + * @param index the validator index + * @return the seal + */ + public static FalconSeal seal(final int index) { + return new FalconSeal(index, signature(index, 0)); + } + + /** + * Build the extraData bytes of a header carrying a given certificate and a given vanity field. + * + * @param vanity the 32-byte vanity field, which from H is the anchor digest D + * @param certificate the carried certificate C, in the order it is to be written + * @return canonical extraData bytes + */ + public static Bytes extraData(final Bytes vanity, final Collection certificate) { + return CODEC.encode( + new BftExtraData( + vanity, Collections.emptyList(), Optional.empty(), 0, VALIDATORS, certificate)); + } + + /** + * Build a header at a height, over a parent hash, carrying a certificate whose anchor digest is + * written into vanityData. This is what an HONEST proposer produces. + * + * @param number the height of the header + * @param parentHash the parent hash the header names + * @param certificate the certificate over the parent + * @return the header + */ + public static BlockHeader honestHeader( + final long number, final Hash parentHash, final List certificate) { + final List sorted = PqAnchor.sortedByIndex(certificate); + final Bytes32 digest = PqAnchor.anchorDigest(CHAIN_ID, number - 1L, parentHash.getBytes(), sorted); + return header(number, parentHash, extraData(digest, sorted)); + } + + /** + * Build a header with explicit extraData bytes, so a test can tamper with the certificate while + * leaving vanityData as the honest proposer wrote it. + * + * @param number the height of the header + * @param parentHash the parent hash the header names + * @param extraData the exact extraData bytes + * @return the header + */ + public static BlockHeader header( + final long number, final Hash parentHash, final Bytes extraData) { + return new BlockHeaderTestFixture() + .number(number) + .parentHash(parentHash) + .extraData(extraData) + .buildHeader(); + } + + /** + * The parent header a test uses, whose hash is what the child names. + * + * @param number the parent's height + * @return the parent header + */ + public static BlockHeader parentHeader(final long number) { + return new BlockHeaderTestFixture() + .number(number) + .extraData(extraData(Bytes32.ZERO, Collections.emptyList())) + .buildHeader(); + } +} diff --git a/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqArmedWithoutRegistryTest.java b/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqArmedWithoutRegistryTest.java new file mode 100644 index 0000000..419a597 --- /dev/null +++ b/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqArmedWithoutRegistryTest.java @@ -0,0 +1,185 @@ +/* + * Copyright contributors to Besu / Aere Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.qbft.headervalidationrules; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.VALIDATORS; +import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.parentHeader; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.withSettings; + +import org.hyperledger.besu.consensus.common.bft.BftContext; +import org.hyperledger.besu.consensus.common.bft.FalconSealSupport; +import org.hyperledger.besu.consensus.common.bft.PqV2Fixture; +import org.hyperledger.besu.consensus.common.validator.ValidatorProvider; +import org.hyperledger.besu.datatypes.Address; +import org.hyperledger.besu.ethereum.ProtocolContext; +import org.hyperledger.besu.ethereum.core.BlockHeader; + +import java.lang.reflect.Field; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collection; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.quality.Strictness; + +/** + * ARMED WITHOUT A REGISTRY, THE RESIDUAL: the accident the configuration guard cannot refuse. + * + *

{@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 PqArmedWithoutRegistryTest { + + private static final int N = 9; + + private static final long OBSERVE = 5_000L; + + private static final long ATTACH = 6_000L; + + private static final long FORK = 7_000L; + + private static final String ANCHOR_ADDRESS = "0x0000000000000000000000000000000000000fa1"; + + /** + * REGISTRY BINDING: the chain this fixture's registry is BOUND to. Inside every proof, so stated. + */ + private static final long CHAIN_ID = 2_800L; + + @TempDir private Path tmp; + + @BeforeEach + public void setUp() throws Exception { + // REGISTRY BINDING: a v2 manifest, proof-bound, bound at FORK. This manifest used to spell its + // addresses 0xC00+i; no secp256k1 key produces those, so once AERE-PQC-REG-ARM-02 was wired + // this armed fixture could not start at all. PqV2Fixture lives in consensus:common's test + // source set and reaches here through the testArtifacts dependency this module already had. + final StringBuilder m = new StringBuilder("{"); + m.append(PqV2Fixture.manifestHeader(N, CHAIN_ID, FORK)); + for (int i = 0; i < N; i++) { + m.append(',').append(PqV2Fixture.manifestEntry(i, N, CHAIN_ID, FORK)); + } + m.append("}"); + final Path manifest = tmp.resolve("falcon-late-manifest.json"); + Files.writeString(manifest, m.toString()); + + System.setProperty("aere.falcon.manifest", manifest.toAbsolutePath().toString()); + System.setProperty("aere.falcon.anchor.address", ANCHOR_ADDRESS); + System.setProperty("aere.falcon.anchor.block", Long.toString(OBSERVE)); + System.setProperty("aere.falcon.attachBlock", Long.toString(ATTACH)); + System.setProperty("aere.falcon.forkBlock", Long.toString(FORK)); + System.setProperty("aere.falcon.validatorCount", Integer.toString(N)); + forgetFalconSingleton(); + } + + @AfterEach + public void tearDown() throws Exception { + for (final String p : + new String[] { + "aere.falcon.manifest", + "aere.falcon.anchor.address", + "aere.falcon.anchor.block", + "aere.falcon.attachBlock", + "aere.falcon.forkBlock", + "aere.falcon.validatorCount" + }) { + System.clearProperty(p); + } + forgetFalconSingleton(); + } + + @Test + public void belowTheBlockingHeightNothingIsRecorded() { + // The negative control. A counter that were set unconditionally would pass the test below and + // mean nothing at all. + final FalconSealValidationRule rule = new FalconSealValidationRule(Long.MAX_VALUE); + final BlockHeader parent = parentHeader(FORK - 2L); + final BlockHeader block = parentHeader(FORK - 1L); + + assertThat(rule.validate(block, parent, contextWith(VALIDATORS))).isTrue(); + assertThat(FalconSealSupport.instance().blockingArmedWithoutRegistrySince()) + .describedAs("below the blocking height there is nothing inert about being log-only") + .isEqualTo(-1L); + } + + @Test + public void atTheBlockingHeightWithNoActiveRegistryTheHeightIsRecorded() { + final FalconSealValidationRule rule = new FalconSealValidationRule(Long.MAX_VALUE); + final BlockHeader parent = parentHeader(FORK - 1L); + final BlockHeader block = parentHeader(FORK); + + assertThat(FalconSealSupport.instance().blockingArmedWithoutRegistrySince()).isEqualTo(-1L); + assertThat(rule.validate(block, parent, contextWith(VALIDATORS))) + .describedAs( + "the rule must still ACCEPT: blocking over an empty registry is a halt, not a safeguard") + .isTrue(); + assertThat(FalconSealSupport.instance().blockingArmedWithoutRegistrySince()) + .describedAs( + "the node is configured to enforce a Falcon quorum at %d and is enforcing nothing. That " + + "must be a value something can read, not a line in a file.", + FORK) + .isEqualTo(FORK); + assertThat(FalconSealSupport.instance().anchorObserveBlock()) + .describedAs("and the declared height it was measured against must be readable too") + .isEqualTo(OBSERVE); + } + + @Test + public void theRecordedHeightIsTheFirstOneAndDoesNotMoveWithTheChain() { + final FalconSealValidationRule rule = new FalconSealValidationRule(Long.MAX_VALUE); + rule.validate(parentHeader(FORK), parentHeader(FORK - 1L), contextWith(VALIDATORS)); + rule.validate(parentHeader(FORK + 40L), parentHeader(FORK + 39L), contextWith(VALIDATORS)); + + assertThat(FalconSealSupport.instance().blockingArmedWithoutRegistrySince()) + .describedAs( + "the value answers 'since when', so a later block must not overwrite it; if it tracked " + + "the head it would report a fresh problem forever and never a duration") + .isEqualTo(FORK); + } + + private static ProtocolContext contextWith(final Collection

validators) { + final ValidatorProvider validatorProvider = + mock(ValidatorProvider.class, withSettings().strictness(Strictness.LENIENT)); + when(validatorProvider.getValidatorsForBlock(any())).thenReturn(validators); + when(validatorProvider.getValidatorsAfterBlock(any())).thenReturn(validators); + final BftContext bftContext = + mock(BftContext.class, withSettings().strictness(Strictness.LENIENT)); + when(bftContext.getValidatorProvider()).thenReturn(validatorProvider); + when(bftContext.as(any())).thenReturn(bftContext); + return new ProtocolContext.Builder().withConsensusContext(bftContext).build(); + } + + private static void forgetFalconSingleton() throws Exception { + final Field f = FalconSealSupport.class.getDeclaredField("instance"); + f.setAccessible(true); + f.set(null, null); + } +} diff --git a/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqEmergencyShoutRuleTest.java b/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqEmergencyShoutRuleTest.java new file mode 100644 index 0000000..4e5d425 --- /dev/null +++ b/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqEmergencyShoutRuleTest.java @@ -0,0 +1,78 @@ +/* + * Copyright contributors to Besu / Aere Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.qbft.headervalidationrules; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.CHAIN_ID; +import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.H; +import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.parentHeader; + +import org.hyperledger.besu.consensus.common.bft.PqAnchorConfig; +import org.hyperledger.besu.ethereum.core.BlockHeader; + +import java.util.Map; +import java.util.OptionalInt; + +import org.junit.jupiter.api.Test; + +/** + * AERE OPTIUNI-URGENTA: the rule whose entire job is to be impossible to ignore. + * + *

The one property that must hold on every path is that it CANNOT REJECT. It is placed first in + * the QBFT header ruleset precisely because a rule that always returns true cannot change the + * verdict of a conjunction, and that claim has to be asserted rather than assumed, including for a + * header it has never seen before and for a null parent. + */ +class PqEmergencyShoutRuleTest { + + private PqEmergencyShoutRule rule(final boolean disabled, final OptionalInt ceiling) { + return new PqEmergencyShoutRule( + new PqAnchorConfig(CHAIN_ID, H, Map.of(H, 0, H + 100L, 5), ceiling, disabled)); + } + + @Test + void acceptsEveryHeaderWhenNothingIsOverridden() { + final PqEmergencyShoutRule r = rule(false, OptionalInt.empty()); + for (final long n : new long[] {0L, 1L, H - 1L, H, H + 100L, H + 1_000_000L}) { + final BlockHeader header = parentHeader(n); + assertThat(r.validate(header, parentHeader(Math.max(0L, n - 1L)))).isTrue(); + } + } + + @Test + void acceptsEveryHeaderWhenTheAnchorIsDisarmed() { + final PqEmergencyShoutRule r = rule(true, OptionalInt.empty()); + assertThat(r.validate(parentHeader(H + 5L), parentHeader(H + 4L))).isTrue(); + // Twice at the same height: the second call is the bounded one, and it must still accept. + assertThat(r.validate(parentHeader(H + 5L), parentHeader(H + 4L))).isTrue(); + } + + @Test + void acceptsEveryHeaderWhenTheThresholdIsCapped() { + final PqEmergencyShoutRule r = rule(false, OptionalInt.of(1)); + assertThat(r.validate(parentHeader(H + 150L), parentHeader(H + 149L))).isTrue(); + } + + @Test + void acceptsEvenWithoutAParent() { + final PqEmergencyShoutRule r = rule(true, OptionalInt.of(0)); + assertThat(r.validate(parentHeader(H + 150L), null)).isTrue(); + } + + @Test + void isPartOfLightValidationSoTheAnnouncementIsHeardOnEveryPath() { + assertThat(rule(true, OptionalInt.empty()).includeInLightValidation()).isTrue(); + } +} diff --git a/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqForkGateFeedTest.java b/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqForkGateFeedTest.java new file mode 100644 index 0000000..d730c7c --- /dev/null +++ b/anchor/consensus/qbft/src/test/java/org/hyperledger/besu/consensus/qbft/headervalidationrules/PqForkGateFeedTest.java @@ -0,0 +1,189 @@ +/* + * Copyright contributors to Besu / Aere Network. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.consensus.qbft.headervalidationrules; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.CHAIN_ID; +import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.H; +import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.VALIDATORS; +import static org.hyperledger.besu.consensus.qbft.headervalidationrules.PqAnchorTestSupport.parentHeader; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.withSettings; + +import org.hyperledger.besu.consensus.common.bft.BftContext; +import org.hyperledger.besu.consensus.common.bft.FalconSealSupport; +import org.hyperledger.besu.consensus.common.bft.PqAnchorConfig; +import org.hyperledger.besu.consensus.common.validator.ValidatorProvider; +import org.hyperledger.besu.datatypes.Address; +import org.hyperledger.besu.ethereum.ProtocolContext; +import org.hyperledger.besu.ethereum.core.BlockHeader; + +import java.lang.reflect.Field; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.OptionalInt; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.quality.Strictness; + +/** + * SEAL-ATTACHMENT GATE, THE OTHER HALF: who feeds it above the anchor height. + * + *

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 PqForkGateFeedTest { + + private static final PqAnchorConfig ARMED = + new PqAnchorConfig(CHAIN_ID, H, Map.of(H, 0), OptionalInt.empty(), false); + + @BeforeEach + public void resetSingleton() throws Exception { + forgetFalconSingleton(); + } + + @AfterEach + public void resetSingletonAfter() throws Exception { + forgetFalconSingleton(); + } + + @Test + public void theLegacyRuleStandsDownAtTheAnchorHeightSoItCannotBeTheFeed() { + assertThat(ARMED.legacyFalconRuleRetirementBlock()) + .describedAs("the legacy Falcon rule retires at exactly the anchor height") + .isEqualTo(H); + + final FalconSealValidationRule legacy = + new FalconSealValidationRule(ARMED.legacyFalconRuleRetirementBlock()); + final BlockHeader parent = parentHeader(H - 1L); + final BlockHeader atH = parentHeader(H); + + assertThat(legacy.validate(atH, parent, contextWith(VALIDATORS))) + .describedAs("retired, so it accepts without doing anything") + .isTrue(); + assertThat(FalconSealSupport.instance().observedValidatorsHeight()) + .describedAs( + "at and above the anchor height the legacy rule records NOTHING. That is correct for the " + + "rule and fatal for anything that depended on it as its only source.") + .isEqualTo(-1L); + } + + @Test + public void theAnchorSealsRuleFeedsTheGateAtEveryHeightFromH() { + final PqAnchorSealsRule rule = new PqAnchorSealsRule(ARMED, new NoRegistry()); + final BlockHeader parent = parentHeader(H + 40L); + final BlockHeader block = PqAnchorTestSupport.honestHeader(H + 41L, parent.getHash(), List.of()); + + assertThat(FalconSealSupport.instance().observedValidatorsHeight()).isEqualTo(-1L); + assertThat(rule.validate(block, parent, contextWith(VALIDATORS))) + .describedAs("K is 0 at this height, so an empty certificate is legitimate") + .isTrue(); + assertThat(FalconSealSupport.instance().observedValidatorsHeight()) + .describedAs( + "the rule that takes over at H must also take over feeding the coverage report, or the " + + "report is about a height the chain left behind") + .isEqualTo(parent.getNumber()); + } + + @Test + public void belowTheAnchorHeightTheSealsRuleRecordsNothing() { + // The negative control for the test above: a rule that recorded unconditionally would pass it + // while breaking the height gate that keeps the whole scheme inert below H. + final PqAnchorSealsRule rule = new PqAnchorSealsRule(ARMED, new NoRegistry()); + final BlockHeader parent = parentHeader(H - 3L); + final BlockHeader block = PqAnchorTestSupport.honestHeader(H - 2L, parent.getHash(), List.of()); + + assertThat(rule.validate(block, parent, contextWith(VALIDATORS))).isTrue(); + assertThat(FalconSealSupport.instance().observedValidatorsHeight()) + .describedAs("below H this rule does nothing at all, recording included") + .isEqualTo(-1L); + } + + private static ProtocolContext contextWith(final Collection
validators) { + final ValidatorProvider validatorProvider = + mock(ValidatorProvider.class, withSettings().strictness(Strictness.LENIENT)); + when(validatorProvider.getValidatorsForBlock(any())).thenReturn(validators); + when(validatorProvider.getValidatorsAfterBlock(any())).thenReturn(validators); + final BftContext bftContext = + mock(BftContext.class, withSettings().strictness(Strictness.LENIENT)); + when(bftContext.getValidatorProvider()).thenReturn(validatorProvider); + when(bftContext.as(any())).thenReturn(bftContext); + return new ProtocolContext.Builder().withConsensusContext(bftContext).build(); + } + + private static void forgetFalconSingleton() throws Exception { + final Field f = FalconSealSupport.class.getDeclaredField("instance"); + f.setAccessible(true); + f.set(null, null); + } + + /** A registry that binds nothing: this file measures the feed, never the verification. */ + private static final class NoRegistry + implements org.hyperledger.besu.consensus.common.bft.PqSignerRegistry { + + // HEIGHT-INDEXED REGISTRY: the height-less pair was deleted from PqSignerRegistry, so this + // double now has to answer "at which height" like everything else. It still binds nothing. + @Override + public Address addressForIndexAtOwnHead(final long blockNumber, final int validatorIndex) { + return addressForIndexAtHistoric(blockNumber, validatorIndex); + } + + @Override + public boolean verifyAtOwnHead( + final long blockNumber, + final int validatorIndex, + final org.apache.tuweni.bytes.Bytes message, + final org.apache.tuweni.bytes.Bytes signature) { + return verifyAtHistoric(blockNumber, validatorIndex, message, signature); + } + + @Override + public Address addressForIndexAtHistoric(final long blockNumber, final int validatorIndex) { + return null; + } + + @Override + public boolean verifyAtHistoric( + final long blockNumber, + final int validatorIndex, + final org.apache.tuweni.bytes.Bytes message, + final org.apache.tuweni.bytes.Bytes signature) { + return false; + } + + @Override + public String toString() { + return "NoRegistry"; + } + } +} diff --git a/anchor/ethereum/eth/src/main/java/org/hyperledger/besu/ethereum/eth/sync/DownloadHeadersStep.java b/anchor/ethereum/eth/src/main/java/org/hyperledger/besu/ethereum/eth/sync/DownloadHeadersStep.java new file mode 100644 index 0000000..f8a4bed --- /dev/null +++ b/anchor/ethereum/eth/src/main/java/org/hyperledger/besu/ethereum/eth/sync/DownloadHeadersStep.java @@ -0,0 +1,229 @@ +/* + * Copyright ConsenSys AG. + * + * 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.ethereum.eth.sync; + +import static java.util.Collections.emptyList; +import static java.util.concurrent.CompletableFuture.completedFuture; + +import org.hyperledger.besu.ethereum.ProtocolContext; +import org.hyperledger.besu.ethereum.core.BlockHeader; +import org.hyperledger.besu.ethereum.eth.manager.EthContext; +import org.hyperledger.besu.ethereum.eth.manager.peertask.PeerTaskExecutorResponseCode; +import org.hyperledger.besu.ethereum.eth.manager.peertask.PeerTaskExecutorResult; +import org.hyperledger.besu.ethereum.eth.manager.peertask.task.GetHeadersFromPeerTask; +import org.hyperledger.besu.ethereum.eth.sync.range.RangeHeaders; +import org.hyperledger.besu.ethereum.eth.sync.range.SyncTargetRange; +import org.hyperledger.besu.ethereum.eth.sync.tasks.DownloadHeaderSequenceTask; +import org.hyperledger.besu.ethereum.eth.sync.tasks.exceptions.InvalidBlockException; +import org.hyperledger.besu.ethereum.mainnet.HeaderValidationMode; +import org.hyperledger.besu.ethereum.mainnet.ProtocolSchedule; +import org.hyperledger.besu.ethereum.mainnet.ProtocolSpec; +import org.hyperledger.besu.plugin.services.MetricsSystem; +import org.hyperledger.besu.util.FutureUtils; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class DownloadHeadersStep + implements Function> { + private static final Logger LOG = LoggerFactory.getLogger(DownloadHeadersStep.class); + private final ProtocolSchedule protocolSchedule; + private final ProtocolContext protocolContext; + private final EthContext ethContext; + private final ValidationPolicy validationPolicy; + private final int headerRequestSize; + private final MetricsSystem metricsSystem; + + public DownloadHeadersStep( + final ProtocolSchedule protocolSchedule, + final ProtocolContext protocolContext, + final EthContext ethContext, + final ValidationPolicy validationPolicy, + final int headerRequestSize, + final MetricsSystem metricsSystem) { + this.protocolSchedule = protocolSchedule; + this.protocolContext = protocolContext; + this.ethContext = ethContext; + this.validationPolicy = validationPolicy; + this.headerRequestSize = headerRequestSize; + this.metricsSystem = metricsSystem; + } + + @Override + public CompletableFuture apply(final SyncTargetRange checkpointRange) { + final CompletableFuture> taskFuture = downloadHeaders(checkpointRange); + final CompletableFuture processedFuture = + taskFuture.thenApply(headers -> processHeaders(checkpointRange, headers)); + FutureUtils.propagateCancellation(processedFuture, taskFuture); + return processedFuture; + } + + private CompletableFuture> downloadHeaders(final SyncTargetRange range) { + if (range.hasEnd()) { + LOG.debug( + "Downloading headers for range {} to {}", + range.getStart().getNumber(), + range.getEnd().getNumber()); + if (range.getSegmentLengthExclusive() == 0) { + // There are no extra headers to download. + return completedFuture(emptyList()); + } + return DownloadHeaderSequenceTask.endingAtHeader( + protocolSchedule, + protocolContext, + ethContext, + range.getEnd(), + range.getSegmentLengthExclusive(), + validationPolicy, + metricsSystem) + .run(); + } else { + LOG.debug("Downloading headers starting from {}", range.getStart().getNumber()); + return ethContext + .getScheduler() + .scheduleServiceTask( + () -> { + GetHeadersFromPeerTask task = + new GetHeadersFromPeerTask( + range.getStart().getHash(), + range.getStart().getNumber(), + headerRequestSize, + 0, + GetHeadersFromPeerTask.Direction.FORWARD, + protocolSchedule); + PeerTaskExecutorResult> taskResult = + ethContext.getPeerTaskExecutor().execute(task); + if (taskResult.responseCode() != PeerTaskExecutorResponseCode.SUCCESS + || taskResult.result().isEmpty()) { + return CompletableFuture.failedFuture( + new RuntimeException("Unable to download headers for range " + range)); + } + final List downloaded = taskResult.result().get(); + // AERE SINCRONIZARE (2026-08-02): validate the OPEN-ENDED range too. + try { + validateOpenEndedRange(range, downloaded); + } catch (final RuntimeException e) { + return CompletableFuture.failedFuture(e); + } + return CompletableFuture.completedFuture(downloaded); + }); + } + } + + /** + * AERE SINCRONIZARE (2026-08-02). Apply the SAME validation policy to the open-ended range that + * the closed ranges already get. + * + *

The defect, read in this file and then measured on a running network. {@link + * #downloadHeaders} has two branches and the choice between them is not "syncing or not", it is + * {@link SyncTargetRange#hasEnd()}. A closed range goes to {@code + * DownloadHeaderSequenceTask.endingAtHeader}, which validates every header it accepts under + * {@code validationPolicy}. The open-ended range - the one at the tip, when the range source + * could not obtain a further range boundary from the peer - went to a bare {@code + * GetHeadersFromPeerTask} and was handed onward with nothing checked but the hash chaining that + * {@code RangeHeadersValidationStep} does at the join. With the default header request size of + * 200 that is the last 199 blocks of every catch-up, which is the whole of a short one. + * + *

The consequence for the certificate anchor was measured: an honest node with its data + * directory deleted imported headers whose certificate had been tampered with, in silence, + * because the only rule that binds the certificate to the block hash is detached and detached + * rules run in exactly the branch this range does not take. The attached copy of that rule now + * catches such a header at import in any case; this method makes it fail one pipeline stage + * earlier, before bodies are fetched for a range that is going to be thrown away, and restores + * the property that the tail of a sync is checked exactly like the rest of it. + * + *

Fail closed on every path: a range whose headers do not form a chain from {@code + * range.getStart()} is rejected here rather than trimmed or accepted. + * + * @param range the open-ended range these headers were requested for + * @param headers the headers as the peer returned them, ascending from {@code range.getStart()} + */ + private void validateOpenEndedRange( + final SyncTargetRange range, final List headers) { + if (headers.isEmpty()) { + return; + } + final BlockHeader start = range.getStart(); + BlockHeader parent; + int index; + if (headers.getFirst().equals(start)) { + // The usual shape: the peer echoes the header we asked to start from. + parent = start; + index = 1; + } else if (headers.getFirst().getParentHash().equals(start.getHash())) { + parent = start; + index = 0; + } else { + throw InvalidBlockException.fromInvalidBlock( + String.format( + "AERE-SYNC-TAIL-01: open-ended range starting at #%d (%s) came back beginning at #%d " + + "(%s), which neither is that header nor descends from it", + start.getNumber(), + start.getHash(), + headers.getFirst().getNumber(), + headers.getFirst().getHash()), + headers.getFirst()); + } + + final HeaderValidationMode mode = validationPolicy.getValidationModeForNextBlock(); + for (int i = index; i < headers.size(); i++) { + final BlockHeader header = headers.get(i); + if (!header.getParentHash().equals(parent.getHash()) + || header.getNumber() != parent.getNumber() + 1L) { + throw InvalidBlockException.fromInvalidBlock( + String.format( + "AERE-SYNC-TAIL-01: open-ended range does not form a chain at #%d (%s); previous " + + "header was #%d (%s)", + header.getNumber(), header.getHash(), parent.getNumber(), parent.getHash()), + header); + } + final ProtocolSpec protocolSpec = protocolSchedule.getByBlockHeader(header); + if (!protocolSpec + .getBlockHeaderValidator() + .validateHeader(header, parent, protocolContext, mode)) { + LOG.warn( + "AERE-SYNC-TAIL-01: header #{} ({}) of the OPEN-ENDED range failed {} validation. This " + + "is the branch that used to accept the last {} headers of every recovery without " + + "running a single header rule.", + header.getNumber(), + header.getHash(), + mode, + headerRequestSize - 1); + throw InvalidBlockException.fromInvalidBlock(header); + } + parent = header; + } + } + + private RangeHeaders processHeaders( + final SyncTargetRange checkpointRange, final List headers) { + if (checkpointRange.hasEnd()) { + final List headersToImport = new ArrayList<>(headers); + headersToImport.add(checkpointRange.getEnd()); + return new RangeHeaders(checkpointRange, headersToImport); + } else { + List headersToImport = headers; + if (!headers.isEmpty() && headers.getFirst().equals(checkpointRange.getStart())) { + headersToImport = headers.subList(1, headers.size()); + } + return new RangeHeaders(checkpointRange, headersToImport); + } + } +}